Files
ks-app-employment-service/pages/chat/components/ai-paging.vue
2025-10-13 16:01:49 +08:00

1200 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="chat-container">
<!-- #ifdef MP-WEIXIN -->
<view class="chat-background">
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="chat-background" v-fade:600="!messages.length">
<!-- #endif -->
<image class="backlogo" src="/static/icon/backAI.png"></image>
<view class="back-rowTitle">欢迎使用{{ config.appInfo.areaName }}AI智能求职</view>
<view class="back-rowText">
我可以根据您的简历和求职需求帮你精准匹配{{ config.appInfo.areaName }}互联网招聘信息对比招聘信息的优缺点提供面试指导等请把你的任务交给我吧~
</view>
<view class="back-rowh3">猜你所想</view>
<view
class="back-rowmsg button-click"
v-for="(item, index) in queries"
:key="index"
@click="sendMessageGuess(item)"
>
{{ item }}
</view>
<view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
</view>
</view>
<scroll-view class="chat-list scrollView" :scroll-top="scrollTop" :scroll-y="true" scroll-with-animation>
<!-- #ifdef MP-WEIXIN -->
<view class="chat-list list-content">
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="chat-list list-content" v-fade:600="messages.length >= 1">
<!-- #endif -->
<view
v-for="(msg, index) in messages"
:key="index"
:id="'msg-' + index"
class="chat-item"
:class="{ self: msg.self }"
>
<view class="message" v-if="msg.self">
<view class="msg-filecontent" v-if="msg.files.length">
<view
class="msg-files btn-light"
v-for="(file, vInex) in msg.files"
:key="vInex"
@click="jumpUrl(file)"
>
<image class="msg-file-icon" src="/static/icon/Vector2.png"></image>
<text class="msg-file-text">{{ file.name || '附件' }}</text>
</view>
</view>
{{ msg.displayText }}
</view>
<view class="message" :class="{ messageNull: !msg.displayText }" v-else>
<!-- {{ msg.displayText }} -->
<view class="message-markdown">
<md-render
:content="msg.displayText"
:typing="isTyping && messages.length - 1 === index"
></md-render>
<view class="message-controll" v-show="showControll(index)">
<view class="controll-left">
<image
class="controll-icon btn-light"
src="/static/icon/copy.png"
@click="copyMarkdown(msg.displayText)"
></image>
<image
class="controll-icon mar_le10 btn-light"
src="/static/icon/feedback.png"
v-if="!msg.userBadFeedback"
@click="userGoodFeedback(msg)"
></image>
<image
v-if="isSpeaking && !isPaused && speechIndex === index"
class="controll-icon mar_le10 btn-light"
src="/static/icon/stop.png"
@click="stopMarkdown(msg.displayText, index)"
></image>
<image
class="controll-icon mar_le10 btn-light"
src="/static/icon/broadcast.png"
@click="readMarkdown(msg.displayText, index)"
v-else
></image>
</view>
<view class="controll-right">
<image
class="controll-icon mar_ri10 btn-light"
src="/static/icon/refresh.png"
@click="refreshMarkdown(index)"
></image>
</view>
</view>
</view>
<!-- guess -->
<view
class="guess"
v-if="showGuess && !msg.self && messages.length - 1 === index && msg.displayText"
>
<view class="gulist">
<view
class="guess-list"
@click="sendMessageGuess(item)"
v-for="(item, index) in guessList"
:key="index"
>
{{ item }}
</view>
</view>
</view>
</view>
</view>
<view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
</view>
<view v-if="isTyping" class="self">
<text class="message msg-loading">
<span class="ai-loading"></span>
</text>
</view>
</view>
</scroll-view>
<!-- 使用 v-show 保证不销毁事件 -->
<view class="vio_container" :class="status" v-show="status !== 'idle'">
<view class="record-tip">{{ statusText }}</view>
<WaveDisplay
:background="audiowaveStyle"
:isActive="isRecording"
:audioData="audioDataForDisplay"
:showInfo="isRecording"
/>
</view>
<view class="input-area" v-show="status === 'idle'">
<view class="areatext">
<input
v-model="textInput"
placeholder-class="inputplaceholder"
class="input"
@confirm="sendMessage"
:disabled="isTyping"
:adjust-position="false"
placeholder="请输入您的职位名称、薪资要求、岗位地址"
v-show="!isVoice"
/>
<view
class="input_vio"
@touchstart.prevent="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
:catchtouchstart="true"
:catchtouchmove="true"
:catchtouchend="true"
v-show="isVoice"
type="default"
>
按住说话
</view>
<!-- upload -->
<view class="btn-box" @click="changeShowFile">
<image
class="send-btn"
:class="{ 'add-file-btn': showfile }"
src="/static/icon/addGroup.png"
></image>
</view>
<!-- sendmessgae Button-->
<view class="btn-box purple" v-if="textInput && !isTyping" @click="sendMessage">
<image class="send-btn" src="/static/icon/send3.png"></image>
</view>
<view class="btn-box" v-else-if="!isTyping && !isVoice" @click="changeVoice">
<image class="send-btn" src="/static/icon/send2x.png"></image>
</view>
<view class="btn-box" v-else-if="!isTyping" @click="changeVoice">
<image class="send-btn" src="/static/icon/send4.png"></image>
</view>
<view class="btn-box purple" v-else>
<view class="btn-box-round"></view>
</view>
</view>
<!-- btn -->
<CollapseTransition :show="showfile">
<view class="area-tips">
<uni-icons type="info-filled" color="#ADADAD" size="15"></uni-icons>
上传后自动解析简历内容
</view>
<view class="area-file">
<view class="file-card" @click="uploadCamera">
<image class="card-img" src="/static/icon/file1.png"></image>
<text>拍照上传</text>
</view>
<view class="file-card" @click="uploadCamera('album')">
<image class="card-img" src="/static/icon/file2.png"></image>
<text>相册上传</text>
</view>
<view class="file-card" @click="getUploadFile">
<image class="card-img" src="/static/icon/file3.png"></image>
<text>文件上传</text>
</view>
</view>
</CollapseTransition>
<!-- filelist -->
<view class="area-uploadfiles" v-if="filesList.length">
<scroll-view class="uploadfiles-scroll" scroll-x="true">
<view class="uploadfiles-list">
<view
class="file-uploadsend"
:class="{ 'file-border': isImage(file.type) }"
v-for="(file, index) in filesList"
:key="index"
>
<image
class="file-iconImg"
@click="jumpUrl(file)"
v-if="isImage(file.type)"
:src="file.url"
></image>
<view class="file-doc" @click="jumpUrl(file)" v-else>
<FileIcon class="doc-icon" :type="file.type"></FileIcon>
<view class="doc-con">
<view class="filename-text">{{ file.name }}</view>
<view class="filerow">
<FileText :type="file.type"></FileText>
<view class="row-x"></view>
<view class="filename-size">{{ file.size }}</view>
</view>
</view>
<!-- <image class="file-icon" @click="jumpUrl(file)" :src=""></image> -->
</view>
<view class="file-del" catchtouchmove="true" @click="delfile(file)">
<uni-icons type="closeempty" color="#FFFFFF" size="10"></uni-icons>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<PopupFeeBack ref="feeback" @onSend="confirmFeeBack"></PopupFeeBack>
<MsgTips ref="feeBackTips" content="已收到反馈,感谢您的关注" title="反馈成功" :icon="successIcon"></MsgTips>
</view>
</template>
<script setup>
import {
ref,
inject,
nextTick,
defineProps,
defineEmits,
onMounted,
onUnmounted,
toRaw,
reactive,
computed,
watch,
} from 'vue';
import { storeToRefs } from 'pinia';
// import config from '@/config.js';
import useChatGroupDBStore from '@/stores/userChatGroupStore';
import MdRender from '@/components/md-render/md-render.vue';
import CollapseTransition from '@/components/CollapseTransition/CollapseTransition.vue';
import PopupFeeBack from './popupbadFeeback.vue';
import AudioWave from './AudioWave.vue';
import WaveDisplay from './WaveDisplay.vue';
import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue';
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
// 全局
const { $api, navTo, throttle, config } = inject('globalFunction');
const emit = defineEmits(['onConfirm']);
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
import successIcon from '@/static/icon/success.png';
// hook
const {
isRecording,
startRecording,
stopRecording,
cancelRecording,
audioDataForDisplay,
volumeLevel,
recognizedText,
lastFinalText,
} = useAudioRecorder();
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis);
// state
const queries = ref([]);
const guessList = ref([]);
const scrollTop = ref(0);
const showGuess = ref(false);
const showfile = ref(false);
const filesList = ref([]);
const bgText = ref(false);
const isVoice = ref(false);
const status = ref('idle'); // idle | recording | cancel
const startY = ref(0);
const cancelThreshold = 100;
const speechIndex = ref(0);
const isAudioPermission = ref(false);
const feebackData = ref(null);
// ref for DOM element
const voiceBtn = ref(null);
const feeback = ref(null);
const feeBackTips = ref(null);
const state = reactive({
uploadFileTips: '请根据以上附件,帮我推荐岗位。',
});
const statusText = computed(() => {
switch (status.value) {
case 'recording':
return '松手发送,上划取消';
case 'cancel':
return '松手取消';
default:
return '按住说话';
}
});
const audiowaveStyle = computed(() => {
return status.value === 'cancel'
? '#f54545'
: status.value === 'recording'
? 'linear-gradient(to right, #377dff, #9a60ff)'
: '#f1f1f1';
});
onMounted(async () => {
changeQueries();
scrollToBottom();
isAudioPermission.value = await requestMicPermission();
});
const requestMicPermission = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('✅ 麦克风权限已授权');
// 立刻停止所有音轨,释放麦克风
stream.getTracks().forEach((track) => track.stop());
return true;
} catch (err) {
console.warn('❌ 用户拒绝麦克风权限或不支持:', err);
return false;
}
};
function showControll(index) {
if (index === messages.value.length - 1 && isTyping.value) {
return false;
}
return true;
}
const sendMessage = (text) => {
const values = textInput.value || text;
showfile.value = false;
showGuess.value = false;
if (values.trim()) {
// 判断是否有对话ID // 没有则创建对话列表
const callback = () => {
const normalArr = toRaw(filesList.value); // 转换为普通数组
filesList.value = [];
useChatGroupDBStore()
.getStearm(values, normalArr, scrollToBottom, {
onDataReceived: (data, message, index) => {
// 流式朗读:只在内容足够长且包含完整信息时才开始朗读
if (!message.self && message.displayText && message.displayText.trim()) {
// 检查是否已经开始朗读这条消息
if (speechIndex.value !== index) {
// 延迟TTS等待内容更完整
// 只有在内容长度超过50个字符或者包含岗位信息时才开始朗读
const hasJobInfo = message.displayText.includes('```job-json') ||
message.displayText.includes('岗位') ||
message.displayText.includes('公司') ||
message.displayText.includes('薪资');
if (message.displayText.length > 50 || hasJobInfo) {
console.log('🎵 Starting streaming TTS for message index:', index);
console.log('📝 Current text length:', message.displayText.length);
console.log('📝 Has job info:', hasJobInfo);
// 开始朗读当前消息
speechIndex.value = index;
readMarkdown(message.displayText, index);
} else {
console.log('⏳ Waiting for more content before TTS, current length:', message.displayText.length);
}
}
}
},
onComplete: () => {
console.log('🎯 onComplete callback triggered');
console.log('📊 Messages array length:', messages.value.length);
// 确保最后一条AI消息的朗读完成
const lastMessageIndex = messages.value.length - 1;
if (lastMessageIndex >= 0) {
const lastMessage = messages.value[lastMessageIndex];
if (!lastMessage.self && lastMessage.displayText && lastMessage.displayText.trim()) {
console.log('🎵 Final TTS for complete message');
console.log('📝 Final text length:', lastMessage.displayText.length);
console.log('📝 Final text preview:', lastMessage.displayText.substring(0, 100) + '...');
// 停止当前的朗读(如果有的话)
if (isSpeaking.value) {
console.log('🛑 Stopping current TTS to start final complete TTS');
cancelAudio();
}
// 开始朗读完整的内容
speechIndex.value = lastMessageIndex;
readMarkdown(lastMessage.displayText, lastMessageIndex);
}
}
},
})
.then(() => {
getGuess();
scrollToBottom();
});
emit('onConfirm', values);
textInput.value = '';
scrollToBottom();
};
// 没有对话列表则创建
if (!chatSessionID.value) {
useChatGroupDBStore()
.addTabel(values)
.then((res) => {
callback();
});
} else {
callback();
}
} else {
if (filesList.value.length) {
$api.msg('上传文件请输入想问的问题描述');
} else {
$api.msg('请输入职位信息或描述');
}
}
};
const sendMessageGuess = (item) => {
showGuess.value = false;
textInput.value = item;
sendMessage(item);
};
const delfile = (file) => {
uni.showModal({
content: '确认删除文件?',
success(res) {
if (res.confirm) {
filesList.value = filesList.value.filter((item) => item.url !== file.url);
if (!filesList.value.length) {
if (textInput.value === state.uploadFileTips) {
textInput.value = '';
}
}
$api.msg('附件删除成功');
}
},
});
};
const scrollToBottom = throttle(function () {
nextTick(() => {
try {
const query = uni.createSelectorQuery();
query.select('.scrollView').boundingClientRect();
query.select('.list-content').boundingClientRect();
query.exec((res) => {
const scrollViewHeight = res[0].height;
const scrollContentHeight = res[1].height;
if (scrollContentHeight > scrollViewHeight) {
const scrolldistance = scrollContentHeight - scrollViewHeight;
scrollTop.value = scrolldistance;
}
});
} catch (err) {
console.warn(err);
}
});
}, 500);
function getGuess() {
$api.chatRequest('/guest', { sessionId: chatSessionID.value }, 'POST').then((res) => {
guessList.value = res.data;
showGuess.value = true;
nextTick(() => {
scrollToBottom();
});
});
}
function isImage(type) {
return new RegExp('image').test(type);
}
function isFile(type) {
const allowedTypes = config.allowedFileTypes || [];
if (!allowedTypes.includes(type)) {
return false;
}
return true;
}
function jumpUrl(file) {
if (file.url) {
window.open(file.url);
} else {
$api.msg('文件地址丢失');
}
}
function VerifyNumberFiles(num) {
if (filesList.value.length >= config.allowedFileNumber) {
$api.msg(`最大上传文件数量 ${config.allowedFileNumber}`);
return true;
} else {
return false;
}
}
function uploadCamera(type = 'camera') {
if (VerifyNumberFiles()) return;
uni.chooseImage({
count: 1, //默认9
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
sourceType: [type], //从相册选择
success: function (res) {
const tempFilePaths = res.tempFilePaths;
const file = res.tempFiles[0];
// 继续上传
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
resData = JSON.parse(resData);
if (isImage(file.type)) {
filesList.value.push({
url: resData.msg,
type: file.type,
name: file.name,
});
textInput.value = state.uploadFileTips;
}
});
},
});
}
function getUploadFile(type = 'camera') {
if (VerifyNumberFiles()) return;
uni.chooseFile({
count: 1,
success: (res) => {
const tempFilePaths = res.tempFilePaths;
const file = res.tempFiles[0];
const allowedTypes = config.allowedFileTypes || [];
const size = $api.formatFileSize(file.size);
if (!allowedTypes.includes(file.type)) {
return $api.msg('仅支持 txt md html word pdf ppt csv excel 格式类型');
}
// 继续上传
$api.uploadFile(tempFilePaths[0], true).then((resData) => {
resData = JSON.parse(resData);
filesList.value.push({
url: resData.msg,
type: file.type,
name: file.name,
size: size,
});
textInput.value = state.uploadFileTips;
});
},
});
}
const tipsPermisson = () => {
uni.showToast({
title: '需要授权麦克风权限才能使用语音功能',
icon: 'none',
});
};
const handleTouchStart = async (e) => {
if (!isAudioPermission.value) {
return tipsPermisson();
}
cancelAudio();
startY.value = e.touches[0].clientY;
status.value = 'recording';
showfile.value = false;
startRecording();
$api.sleep(1000).then(() => {
scrollToBottom();
});
};
const handleTouchMove = (e) => {
const moveY = e.touches[0].clientY;
if (startY.value - moveY > cancelThreshold) {
status.value = 'cancel';
} else {
status.value = 'recording';
}
};
const handleTouchEnd = () => {
if (status.value === 'cancel') {
console.log('取消发送');
cancelRecording();
} else {
stopRecording();
if (isAudioPermission.value) {
if (recognizedText.value) {
sendMessage(recognizedText.value);
} else {
$api.msg('说话时长太短');
}
}
}
status.value = 'idle';
};
const handleTouchCancel = () => {
stopRecording();
status.value = 'idle';
};
function closeGuess() {
showGuess.value = false;
}
function changeVoice() {
isVoice.value = !isVoice.value;
}
function changeShowFile() {
showfile.value = !showfile.value;
}
function colseFile() {
showfile.value = false;
}
function copyMarkdown(value) {
$api.copyText(value);
}
function userGoodFeedback(msg) {
// $api.msg('该功能正在开发中,敬请期待后续更新!');
feeback.value?.open();
feebackData.value = msg;
}
function confirmFeeBack(value) {
useChatGroupDBStore()
.badFeedback(feebackData.value, value)
.then(() => {
feeback.value?.close();
feeBackTips.value?.open();
});
}
// 防抖定时器
let ttsDebounceTimer = null;
function readMarkdown(value, index) {
console.log('🎤 readMarkdown called');
console.log('📝 Text to speak:', value ? value.substring(0, 100) + '...' : 'No text');
console.log('🔢 Index:', index);
console.log('🔢 Current speechIndex:', speechIndex.value);
console.log('⏸️ Is paused:', isPaused.value);
console.log('🔊 Is speaking:', isSpeaking.value);
// 清除之前的防抖定时器
if (ttsDebounceTimer) {
clearTimeout(ttsDebounceTimer);
}
// 如果当前正在播放其他消息,先停止
if (speechIndex.value !== index && speechIndex.value !== 0) {
console.log('🛑 Stopping current speech and starting new one');
speechIndex.value = index;
speak(value);
return;
}
speechIndex.value = index;
// 如果当前正在播放且暂停了,直接恢复
if (isPaused.value && isSpeaking.value) {
console.log('▶️ Resuming paused speech');
resume();
return;
}
// 如果当前正在播放且没有暂停,不需要重新开始
if (isSpeaking.value && !isPaused.value) {
console.log('🔊 Already speaking, no need to restart');
return;
}
// 使用防抖避免频繁调用TTS
ttsDebounceTimer = setTimeout(() => {
console.log('🎵 Starting new speech');
console.log('🎵 Calling speak function with text length:', value ? value.length : 0);
try {
speak(value);
console.log('✅ Speak function called successfully');
} catch (error) {
console.error('❌ Error calling speak function:', error);
}
}, 300); // 300ms防抖延迟
}
function stopMarkdown(value, index) {
console.log('⏸️ stopMarkdown called for index:', index);
console.log('🔢 Current speechIndex:', speechIndex.value);
console.log('🔊 Is speaking:', isSpeaking.value);
console.log('⏸️ Is paused:', isPaused.value);
// 清除防抖定时器
if (ttsDebounceTimer) {
clearTimeout(ttsDebounceTimer);
ttsDebounceTimer = null;
}
speechIndex.value = index;
pause();
}
function refreshMarkdown(index) {
if (isTyping.value) {
$api.msg('正在生成');
} else {
const text = messages.value[index - 1].text;
sendMessageGuess(text);
}
}
const jobSearchQueries = [
'青岛有哪些薪资 12K 以上的岗位适合我?',
'青岛 3 年工作经验能找到哪些 12K 以上的工作?',
'青岛哪些公司在招聘,薪资范围在 12K 以上?',
'青岛有哪些企业提供 15K 以上的岗位?',
'青岛哪些公司在招 3-5 年经验的岗位?',
'我有三年的工作经验,能否推荐一些适合我的青岛的国企 岗位?',
'青岛国企目前在招聘哪些岗位?',
'青岛有哪些适合 3 年经验的国企岗位?',
'青岛国企招聘的岗位待遇如何?',
'青岛国企岗位的薪资水平是多少?',
'青岛哪些国企支持双休 & 五险一金完善?',
'青岛有哪些公司支持远程办公?',
'青岛有哪些外企的岗位,薪资 12K 以上的多吗?',
'青岛哪些企业在招聘 Web3.0 相关岗位?',
'青岛哪些岗位支持海外远程?薪资如何?',
'青岛招聘 AI/大数据相关岗位的公司有哪些?',
];
function changeQueries(value) {
queries.value = getRandomJobQueries(jobSearchQueries);
}
function getRandomJobQueries(queries, count = 2) {
const shuffled = queries.sort(() => 0.5 - Math.random()); // 随机打乱数组
return shuffled.slice(0, count); // 取前 count 条
}
defineExpose({ scrollToBottom, closeGuess, colseFile, changeQueries, handleTouchCancel });
</script>
<style lang="stylus" scoped>
/* 过渡样式 */
.collapse-enter-active,
.collapse-leave-active {
transition: max-height 0.3s ease, opacity 0.3s ease;
}
.collapse-enter-from,
.collapse-leave-to {
max-height: 0;
opacity: 0;
}
.collapse-enter-to,
.collapse-leave-from {
max-height: 400rpx; /* 根据你内容最大高度设定 */
opacity: 1;
}
.msg-filecontent
display: flex
flex-wrap: wrap
.msg-files
overflow: hidden
margin-right: 10rpx
height: 30rpx
max-width: 201rpx
background: #FFFFFF
border-radius: 10rpx
display: flex
align-items: center
justify-content: flex-start
padding: 10rpx
color: #000000
margin-bottom: 10rpx
.msg-file-icon
width: 29rpx
height: 26rpx
padding-right: 10rpx
.msg-file-text
flex: 1
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
color: rgba(96, 96, 96, 1)
font-size: 24rpx
.msg-files:active
background: #e9e9e9
.guess
padding: 5rpx 0 10rpx 0
.guess-list
padding: 16rpx 24rpx
margin-top: 28rpx
font-size: 24rpx
color: #8c8c8c
width: 100%;
border-radius: 20rpx 20rpx 20rpx 20rpx;
border: 2rpx solid #E5E5E5;
font-size: 28rpx;
color: #333333;
line-height: 33rpx;
.gulist
display: flex
flex-wrap: wrap
position: relative
image-margin-top = 40rpx
.chat-container
display: flex;
flex-direction: column;
height: 100%;
position: relative
z-index: 1
background: #FFFFFF
.chat-background
position: absolute
padding: 44rpx;
display: flex
flex-direction: column
justify-content: flex-start
align-items: center
width: calc(100% - 88rpx)
position: relative
z-index: 1
.backlogo
width: 313rpx;
height: 190rpx;
.back-rowTitle
height: 56rpx;
font-weight: bold;
font-size: 40rpx;
color: #333333;
line-height: 47rpx;
margin-top: 40rpx
.back-rowText
margin-top: 28rpx
width: 100%;
height: 148rpx;
font-weight: 400;
font-size: 28rpx;
color: #333333;
line-height: 40rpx
// border-bottom: 2rpx dashed rgba(0, 0, 0, 0.2);
border-bottom: 2rpx solid transparent;
background-image: linear-gradient(to right, rgba(0, 0, 0, 0.2) 50%, transparent 50%);
background-size: 10rpx 2rpx; /* 调整虚线宽度和间距 */
background-repeat: repeat-x;
background-position: 0 148rpx
.back-rowh3
width: 100%;
font-weight: 500;
font-size: 28rpx;
color: #000000;
margin-top: 28rpx
.back-rowmsg
width: calc(100% - 32rpx)
margin-top: 24rpx
font-weight: 500;
color: rgba(51, 51, 51, 1);
line-height: 28rpx;
font-size: 24rpx
background: rgba(246, 246, 246, 1);
border-radius: 8rpx 8rpx 8rpx 8rpx;
padding: 32rpx 18rpx;
.chat-list {
flex: 1;
overflow-y: auto;
white-space: pre-wrap;
}
.list-content {
padding: 0 44rpx 44rpx 44rpx;
}
.chat-item {
display: flex;
align-items: flex-start;
margin-bottom: 20rpx;
width: 100%;
}
.chat-item.self {
justify-content: flex-end;
}
.message
margin-top: 40rpx
// max-width: 80%;
width: 100%;
word-break: break-word;
color: #333333;
user-select: text;
-webkit-user-select: text;
.message-markdown
border-radius: 0 20rpx 20rpx 20rpx;
padding: 20rpx 20rpx 20rpx 20rpx;
background: #F6F6F6;
.message-controll
display: flex
justify-content: space-between
align-items: center
border-top: 2rpx solid #EAEAEA
padding: 24rpx 0 4rpx 0
margin-top: 10rpx
.controll-left
.controll-right
.controll-icon
width: 60rpx;
height: 60rpx;
.messageNull
display: none
.msg-loading{
background: transparent;
font-size: 24rpx;
color: #8f8d8e;
display: flex;
align-items: flex-end;
justify-content: flex-start;
}
.loaded{
padding-left: 20rpx
}
.chat-item.self .message {
background: linear-gradient( 225deg, #DAE2FE 0%, #E9E3FF 100%);
border-radius: 20rpx 0 20rpx 20rpx;
padding: 20rpx;
width: fit-content;
}
.input-area {
padding: 32rpx 28rpx 24rpx 28rpx;
position: relative;
background: #FFFFFF;
box-shadow: 0rpx -4rpx 10rpx 0rpx rgba(11,44,112,0.06);
transition: height 2s ease-in-out;
}
.input-area::after
position: absolute
content: ''
top: 0
left: 0
width: 100%
z-index: 1
box-shadow: 0rpx -4rpx 10rpx 0rpx rgba(11,44,112,0.06);
.areatext{
display: flex;
}
.input {
flex: 1;
border-radius: 5rpx;
min-height: 63rpx;
line-height: 63rpx;
padding: 4rpx 24rpx;
position: relative
background: #F5F5F5;
border-radius: 60rpx 60rpx 60rpx 60rpx;
}
.input_vio
flex: 1;
border-radius: 5rpx;
min-height: 63rpx;
line-height: 63rpx;
padding: 4rpx 24rpx;
// position: relative
border-radius: 60rpx 60rpx 60rpx 60rpx;
font-size: 28rpx
color: #333333
background: #F5F5F5;
text-align: center
font-size: 28rpx
font-weight: 500
user-select:none;
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
touch-action: none; /* 禁用默认滚动 */
.input_vio:active
background: #e8e8e8
.vio_container
background: transparent
padding: 28rpx
text-align: center
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
touch-action: none; /* 禁用默认滚动 */
.record-tip
font-weight: 400;
color: #909090;
text-align: center;
padding-bottom: 16rpx
.inputplaceholder {
font-weight: 500;
font-size: 24rpx;
color: #000000;
line-height: 28rpx;
opacity: 0.4
}
.btn-box
margin-left: 12rpx;
width: 70rpx;
height: 70rpx;
border-radius: 50%
background: #F5F5F5;
display: flex
align-items: center
justify-content: center
.send-btn,
.receive-btn
transition: transform 0.5s ease;
width: 38rpx;
height: 38rpx;
.btn-box-round
width: 22rpx
height: 22rpx
border-radius: 4rpx
background: #FFFFFF
.purple
background: linear-gradient( 225deg, #9E74FD 0%, #256BFA 100%);
.add-file-btn{
transform: rotate(45deg)
transition: transform 0.5s ease;
}
.area-tips{
font-weight: 400;
font-size: 26rpx;
color: #434343;
margin-top: 18rpx
}
.area-file
display: grid
width: 100%
grid-template-columns: repeat(3, 1fr)
grid-gap: 20rpx
padding: 20rpx 0 0 0;
.file-card
display: flex
flex-direction: column
align-items: center
padding: 24rpx 0
background: #F5F5F5;
border-radius: 20rpx 20rpx 20rpx 20rpx;
text
font-size: 24rpx
font-weight: 500
color: rgba(0,0,0,.5)
padding-top: 8rpx
.card-img
height: 56rpx
width: 56rpx
.file-card:active
background: #e8e8e8
.area-uploadfiles
position: absolute
top: -180rpx
width: calc(100% - 30rpx)
background: #FFFFFF
left: 0
padding: 10rpx 0 10rpx 30rpx
box-shadow: 0rpx -4rpx 10rpx 0rpx rgba(11,44,112,0.06);
.uploadfiles-scroll
height: 100%
.uploadfiles-list
height: 100%
display: flex
margin-right: 28rpx
flex-wrap: nowrap
.file-doc
display: flex
align-items: center
justify-content: flex-start
padding: 16rpx 20rpx 18rpx 20rpx
height: calc(100% - 40rpx)
.doc-icon
width: 60rpx
height: 76rpx
margin-right: 20rpx
.doc-con
flex: 1
width: 0
.file-uploadsend
margin: 10rpx 18rpx 0 0;
height: 100%
font-size: 24rpx
position: relative
min-width: 460rpx;
height: 160rpx;
border-radius: 12rpx 12rpx 12rpx 12rpx;
border: 2rpx solid #E2E2E2;
overflow: hidden
.file-del
position: absolute
right: 25rpx
top: 18rpx
z-index: 9
border-radius: 50%
display: flex
align-items: center
justify-content: center
transform: translate(50%, -10rpx)
height: 40rpx
width: 37rpx;
height: 37rpx;
background: #4B4B4B;
border: 2rpx solid #FFFFFF;
.file-del:active
background: #e8e8e8
.filename-text
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
color: #333333
font-size: 24rpx
font-weight: 500
max-width: 100%
.filename-size
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
color: #7B7B7B;
max-width: 100%
.file-iconImg
height: 100%
width: 100%
.filerow
display: flex
align-items: center
margin-top: 7rpx
.row-x
margin: 0 18rpx
height: 20rpx
width: 2rpx
background: rgba(226, 226, 226, .9)
.file-border
width: 160rpx !important;
@keyframes ai-circle {
0% {
-webkit-transform: rotate(0);
transform: rotate(0);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.ai-loading
display: inline-flex;
vertical-align: middle;
width: 28rpx;
height: 28rpx;
background: 0 0;
border-radius: 50%;
border: 4rpx solid;
border-color: #e5e5e5 #e5e5e5 #e5e5e5 #8f8d8e;
-webkit-animation: ai-circle 1s linear infinite;
animation: ai-circle 1s linear infinite;
</style>