Files
ks-app-employment-service/packageA/pages/chat/components/ai-paging.vue
2026-04-09 21:14:25 +08:00

2315 lines
82 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">
<!-- 选择模式操作栏 -->
<view v-if="isSelectMode" class="select-mode-header">
<view class="select-header-left">
<view class="select-count">{{ selectedMessages.length }} 条已选择</view>
<view class="select-all" @click="selectAllMessages">全选</view>
</view>
<view class="select-header-right">
<view class="cancel-btn" @click="exitSelectMode">取消</view>
<view class="delete-btn" @click="deleteSelectedMessages" :disabled="selectedMessages.length === 0">删除</view>
</view>
</view>
<!-- Tab切换 -->
<view v-else class="tab-container">
<view
class="tab-item"
:class="{ active: activeTab === 'policy' }"
@click="switchTab('policy')"
>
政策查询
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'job' }"
@click="switchTab('job')"
>
岗位推荐
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<view class="chat-background" v-if="!messages.length">
<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>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="chat-background" v-if="!messages.length">
<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>
<!-- #endif -->
<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"
v-show="shouldShowMessage(msg)"
:key="index"
:id="'msg-' + index"
class="chat-item"
:class="{ self: msg.self }"
@longpress="handleLongPress(msg, index)"
@click="isSelectMode ? toggleSelectMessage(index) : null"
>
<!-- 选择框 -->
<view v-if="isSelectMode" class="message-checkbox">
<view class="checkbox" :class="{ checked: selectedMessages.includes(index) }">
<uni-icons v-if="selectedMessages.includes(index)" type="success" size="24" color="#FFFFFF"></uni-icons>
</view>
</view>
<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)"
:class="{ 'msg-image-file': isImage(file.type, file.name) }"
>
<image
v-if="isImage(file.type, file.name)"
class="msg-image-thumbnail"
:src="file.url"
mode="aspectFill"
></image>
<image
v-else
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) && !isSelectMode">
<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 && !isSelectMode"
>
<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>{{isRecognizing}}</view> -->
<view class="chat-item self" v-if="isRecognizing">
<view class="message msg-loading">
<view class="loading-content">
<view class="ai-loading">
<view></view>
<view></view>
<view></view>
</view>
<text class="loading-text">正在识别语音...</text>
</view>
</view>
</view>
<view v-if="isTyping" class="self">
<view class="message msg-loading">
<view class="loading-content">
<view class="ai-loading">
<view></view>
<view></view>
<view></view>
</view>
<text class="loading-text">AI正在思考中...</text>
</view>
</view>
</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="true"
placeholder="请输入您想找的岗位信息或就政策信息【比如:设计师、10000-12000、广州】【比如:今年喀什地区高校毕业生有什么就业政策】"
v-show="!isVoice"
/>
<view
class="input_vio"
@touchstart.prevent="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
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>
<view class="ai-tips">本服务为AI生成内容结果仅供参考</view>
<!-- 文件上传区域 - 使用绝对定位向上展开避免被tabbar遮挡 -->
<view class="file-upload-popup" :class="{ 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>
</view>
<!-- 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, file.name) }"
v-for="(file, index) in filesList"
:key="index"
>
<view class="file-thumbnail" @click="jumpUrl(file)">
<image
class="file-iconImg"
v-if="isImage(file.type, file.name) && !file.error"
:src="file.tempPath || file.url"
mode="aspectFill"
@load="onImageLoad(file)"
@error="onImageError(file)"
></image>
<view class="file-doc" v-else-if="!isImage(file.type, file.name)">
<FileIcon class="doc-icon" :type="file.type"></FileIcon>
</view>
<view class="image-loading" v-if="isImage(file.type, file.name) && file.loading">
<uni-icons type="spinner-cycle" size="30" color="#999"></uni-icons>
<text class="loading-text">加载中...</text>
</view>
<view class="image-error" v-if="isImage(file.type, file.name) && file.error" @click="retryImageLoad(file)">
<uni-icons type="info" size="30" color="#ff4444"></uni-icons>
<text class="error-text">加载失败</text>
<text class="retry-text">点击重试</text>
</view>
<!-- 图片数量标签 -->
<view class="image-count-badge" v-if="isImage(file.type, file.name) && filesList.filter(f => isImage(f.type, f.name)).length > 1">
{{ filesList.filter(f => isImage(f.type, f.name)).indexOf(file) + 1 }}/{{ filesList.filter(f => isImage(f.type, f.name)).length }}
</view>
</view>
<view class="file-info">
<view class="filename-text">{{ file.name }}</view>
<view class="filerow" v-if="!isImage(file.type, file.name)">
<FileText :type="file.type"></FileText>
<view class="row-x"></view>
<view class="filename-size">{{ file.size }}</view>
</view>
<view class="filerow" v-else>
<text class="file-type-tag">图片</text>
<view class="row-x"></view>
<view class="filename-size">{{ file.size }}</view>
</view>
</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,
onMounted,
onUnmounted,
toRaw,
reactive,
computed,
watch,
getCurrentInstance,
} from 'vue';
import { storeToRefs } from 'pinia';
// 移除重复导入使用从globalFunction注入的config
import useChatGroupDBStore from '@/stores/userChatGroupStore';
import MdRender from '@/components/md-render/md-render.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';
import successIcon from '@/static/icon/success.png';
// 全局
const { $api, navTo, throttle, config } = inject('globalFunction');
const emit = defineEmits(['onConfirm']);
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
// hook
const {
isRecording,
startRecording,
stopRecording,
cancelRecording,
audioDataForDisplay,
volumeLevel,
recognizedText,
lastFinalText,
recordingDuration,
isRecognizing,
reset
} = useAudioRecorder();
// 监听语音识别结果变化,自动发送消息
watch(
() => recognizedText.value,
(newVal) => {
if (newVal && newVal.trim()) {
console.log('监听到语音识别结果变化,自动发送消息:', newVal);
sendMessage(newVal);
}
}
);
// 监听isRecognizing状态显示提示
watch(
() => isRecognizing.value,
(newVal) => {
if (newVal) {
$api.msg('正在识别语音...');
}
}
);
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis);
// 获取组件实例(用于小程序 SelectorQuery
const instance = getCurrentInstance();
// state
const activeTab = ref('policy'); // 'policy' or 'job'
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);
// 删除功能相关状态
const isSelectMode = ref(false);
const selectedMessages = ref([]);
// 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();
reset(); // 重置语音识别状态
});
const requestMicPermission = async () => {
// #ifdef H5
try {
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('✅ 麦克风权限已授权');
// 立刻停止所有音轨,释放麦克风
stream.getTracks().forEach((track) => track.stop());
return true;
} else {
console.warn('❌ 当前环境不支持麦克风');
return false;
}
} catch (err) {
console.warn('❌ 用户拒绝麦克风权限或不支持:', err);
return false;
}
// #endif
// #ifdef MP-WEIXIN
try {
// 微信小程序使用 uni.authorize 请求权限
const res = await uni.authorize({
scope: 'scope.record'
});
console.log('✅ 麦克风权限已授权');
return true;
} catch (err) {
console.warn('❌ 用户拒绝麦克风权限:', err);
// 用户拒绝授权,但不影响其他功能
return false;
}
// #endif
// #ifndef H5 || MP-WEIXIN
// 其他平台暂不支持
console.warn('❌ 当前平台不支持麦克风权限检测');
return false;
// #endif
};
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, { immediate: false });
// 一旦开始朗读就设置speechIndex避免重复调用
speechIndex.value = index;
} else {
console.log('⏳ Waiting for more content before TTS, current length:', message.displayText.length);
}
} else {
// 已经开始朗读这条消息,不再重复调用
console.log('⏭️ Already speaking this message, skipping duplicate TTS call');
}
}
},
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, { immediate: true });
}
}
},
})
.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 {
let query;
// #ifdef MP-WEIXIN
query = uni.createSelectorQuery().in(instance);
// #endif
// #ifndef MP-WEIXIN
query = uni.createSelectorQuery();
// #endif
query.select('.scrollView').boundingClientRect();
query.select('.list-content').boundingClientRect();
query.exec((res) => {
if (!res || !res[0] || !res[1]) {
console.warn('scrollToBottom: 元素未找到或尚未渲染');
return;
}
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) => {
$api.chatRequest('/guest', undefined, 'POST').then((res) => {
console.log('getGuess ---- res:', res);
guessList.value = res.data;
showGuess.value = true;
nextTick(() => {
scrollToBottom();
});
});
}
// 从文件路径提取文件名
function getFileNameFromPath(filePath) {
if (!filePath) return '';
// 处理反斜杠和正斜杠
const path = filePath.replace(/\\/g, '/');
const lastSlashIndex = path.lastIndexOf('/');
if (lastSlashIndex !== -1) {
return path.substring(lastSlashIndex + 1);
}
return path;
}
// 从文件名推断文件类型
function getFileTypeFromName(fileName) {
if (!fileName) return '';
const lowerName = fileName.toLowerCase();
if (lowerName.endsWith('.jpg') || lowerName.endsWith('.jpeg')) return 'image/jpeg';
if (lowerName.endsWith('.png')) return 'image/png';
if (lowerName.endsWith('.gif')) return 'image/gif';
if (lowerName.endsWith('.bmp')) return 'image/bmp';
if (lowerName.endsWith('.webp')) return 'image/webp';
if (lowerName.endsWith('.svg')) return 'image/svg+xml';
if (lowerName.endsWith('.pdf')) return 'application/pdf';
if (lowerName.endsWith('.doc')) return 'application/msword';
if (lowerName.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (lowerName.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
if (lowerName.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
if (lowerName.endsWith('.xls')) return 'application/vnd.ms-excel';
if (lowerName.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
if (lowerName.endsWith('.txt')) return 'text/plain';
if (lowerName.endsWith('.md')) return 'text/markdown';
if (lowerName.endsWith('.html') || lowerName.endsWith('.htm')) return 'text/html';
if (lowerName.endsWith('.csv')) return 'text/csv';
return '';
}
function isImage(type, name) {
if (!type && !name) return false;
const debug = false;
if (debug) console.log('isImage检查:', { type, name });
if (type) {
const lowerType = type.toLowerCase();
if (lowerType.includes('image/')) {
if (debug) console.log('通过MIME类型识别为图片:', lowerType);
return true;
}
if (lowerType.includes('image')) {
if (debug) console.log('通过"image"关键词识别为图片:', lowerType);
return true;
}
const imageExtensionsInType = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'heic', 'heif', 'tiff', 'tif'];
for (const ext of imageExtensionsInType) {
if (lowerType.includes(ext)) {
if (debug) console.log('通过类型中的扩展名识别为图片:', ext);
return true;
}
}
}
if (name) {
const lowerName = name.toLowerCase();
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.heic', '.heif', '.tiff', '.tif', '.jfif', '.pjpeg', '.pjp'];
for (const ext of imageExtensions) {
if (lowerName.endsWith(ext)) {
if (debug) console.log('通过文件扩展名识别为图片:', ext);
return true;
}
}
const imageExtensionsWithoutDot = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'heic', 'heif', 'tiff', 'tif', 'jfif', 'pjpeg', 'pjp'];
for (const ext of imageExtensionsWithoutDot) {
if (lowerName.endsWith('.' + ext)) {
if (debug) console.log('通过带点的扩展名识别为图片:', ext);
return true;
}
}
if (debug) console.log('未识别为图片:', lowerName);
}
if (debug) console.log('不是图片文件');
return false;
}
function isFile(type) {
const allowedTypes = config.allowedFileTypes || [];
if (!allowedTypes.includes(type)) {
return false;
}
return true;
}
function jumpUrl(file) {
if (!file.url && !file.tempPath) {
$api.msg('文件地址丢失');
return;
}
if (isImage(file.type, file.name)) {
const imageUrl = file.tempPath || file.url;
uni.previewImage({ urls: [imageUrl], current: 0 });
} else {
window.open(file.url);
}
}
function VerifyNumberFiles(additionalCount = 1) {
const currentCount = filesList.value.length;
const maxCount = config.allowedFileNumber || 9;
if (currentCount + additionalCount > maxCount) {
$api.msg(`最多只能上传${maxCount}个文件,当前已有${currentCount}`);
return true;
} else {
return false;
}
}
function onImageLoad(file) {
const index = filesList.value.findIndex(f => f.url === file.url);
if (index !== -1) {
filesList.value[index].loading = false;
filesList.value[index].error = false;
}
}
function onImageError(file) {
const index = filesList.value.findIndex(f => f.url === file.url);
if (index !== -1) {
filesList.value[index].loading = false;
filesList.value[index].error = true;
}
}
function retryImageLoad(file) {
const index = filesList.value.findIndex(f => f.url === file.url);
if (index !== -1) {
filesList.value[index].loading = true;
filesList.value[index].error = false;
// 触发重新加载
setTimeout(() => {
if (filesList.value[index]) {
filesList.value[index].loading = false;
}
}, 100);
}
}
function uploadCamera(type = 'camera') {
// 在微信小程序中检查权限
// #ifdef MP-WEIXIN
const scope = type === 'camera' ? 'scope.camera' : 'scope.writePhotosAlbum';
uni.getSetting({
success: (res) => {
if (!res.authSetting[scope]) {
// 未授权,发起授权请求
uni.authorize({
scope: scope,
success: () => {
// 授权成功,执行选择图片
chooseImageAfterAuth();
},
fail: (err) => {
console.error('授权失败:', err);
if (err.errMsg && err.errMsg.includes('auth deny')) {
uni.showModal({
title: '权限提示',
content: `需要${type === 'camera' ? '相机' : '相册'}权限才能上传图片,请在设置中开启权限`,
showCancel: false,
confirmText: '确定'
});
} else {
$api.msg('授权失败,请重试');
}
}
});
} else {
// 已授权,直接执行选择图片
chooseImageAfterAuth();
}
},
fail: (err) => {
console.error('获取设置失败:', err);
chooseImageAfterAuth(); // 失败时也尝试选择图片
}
});
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序环境直接选择图片
chooseImageAfterAuth();
// #endif
function chooseImageAfterAuth() {
uni.chooseImage({
count: 9, //支持多选最多9张
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
// sourceType: [type], // 移除 sourceType 参数,解决微信 "unknown scene" 错误
success: function (res) {
const tempFilePaths = res.tempFilePaths;
const files = res.tempFiles;
// 检查文件数量限制
if (VerifyNumberFiles(files.length)) return;
// 显示上传提示
$api.msg(`开始上传${files.length}张图片...`);
// 上传所有图片
const uploadPromises = tempFilePaths.map((filePath, index) => {
return $api.uploadFile(filePath, true).then((resData) => {
resData = JSON.parse(resData);
if (resData.code === 200 && resData.filePath) {
// 从文件对象获取文件信息
const fileInfo = files[index] || {};
const fileName = getFileNameFromPath(fileInfo.name || filePath);
const fileType = fileInfo.type || getFileTypeFromName(fileName);
const fileSize = fileInfo.size || 0;
// 使用统一的文件路径处理函数
const processedFile = processFilePath(
resData.filePath, // 原始服务器路径
fileName,
fileType,
fileSize,
tempFilePaths[index] // 临时路径用于预览
);
// 添加图片加载状态字段
processedFile.loading = false;
processedFile.error = false;
return processedFile;
} else {
throw new Error(resData.msg || '上传失败');
}
});
});
Promise.all(uploadPromises).then((uploadedFiles) => {
// 将所有上传成功的文件添加到filesList并设置图片加载状态
uploadedFiles.forEach(file => {
if (isImage(file.type, file.name)) {
file.loading = true; // 图片开始加载
}
filesList.value.push(file);
});
textInput.value = state.uploadFileTips;
showfile.value = false; // 关闭上传面板
$api.msg(`成功上传${uploadedFiles.length}张图片`);
}).catch((error) => {
$api.msg(error.message || '图片上传失败');
});
},
fail: function (err) {
console.error('选择图片失败:', err);
// 专门处理微信 "unknown scene" 错误
if (err.errMsg && err.errMsg.includes('unknown scene')) {
$api.msg('选择图片失败,请尝试重新选择或检查权限设置');
} else if (err.errMsg && err.errMsg.includes('auth deny')) {
$api.msg('没有相机或相册权限,请在设置中开启');
} else {
$api.msg('选择图片失败: ' + (err.errMsg || '未知错误'));
}
}
});
}
}
function getUploadFile(type = 'camera') {
// 检测是否是微信小程序环境
const isWeixinMiniProgram = typeof wx !== 'undefined' && wx.chooseMessageFile;
console.log('当前环境检测:', {
isWeixinMiniProgram,
wxExists: typeof wx !== 'undefined',
hasChooseMessageFile: typeof wx !== 'undefined' && wx.chooseMessageFile
});
if (isWeixinMiniProgram) {
console.log('微信小程序环境使用uni.chooseMessageFile');
// 微信小程序环境使用uni.chooseMessageFile
uni.chooseMessageFile({
count: 9,
type: 'file',
success: (res) => {
console.log('uni.chooseMessageFile返回的res:', res);
// uni.chooseMessageFile返回的数据结构不同
// tempFiles数组中每个对象有path、name、size等属性
const tempFiles = res.tempFiles || [];
const tempFilePaths = tempFiles.map(file => file.path);
const files = tempFiles;
console.log('tempFilePaths:', tempFilePaths);
console.log('tempFiles:', tempFiles);
if (files && files.length > 0) {
console.log('第一个文件详情:', files[0]);
console.log('第一个文件的所有属性:', Object.keys(files[0]));
// 详细记录每个文件的所有属性
files.forEach((file, index) => {
console.log(`文件 ${index} 完整对象:`, JSON.stringify(file));
console.log(`文件 ${index} 关键属性:`, {
name: file.name,
type: file.type,
path: file.path,
size: file.size,
hasType: 'type' in file,
typeIsString: typeof file.type === 'string',
typeLength: file.type ? file.type.length : 0
});
});
}
// 调用公共的文件处理函数
handleSelectedFiles(tempFilePaths, files);
},
fail: (err) => {
console.error('uni.chooseMessageFile失败:', err);
// 微信小程序特定的错误处理
if (err.errMsg && err.errMsg.includes('cancel')) {
// 用户取消选择,不提示错误
console.log('用户取消选择文件');
} else {
$api.msg('选择文件失败,请重试');
}
}
});
} else {
console.log('非微信小程序环境使用uni.chooseFile');
// 其他平台使用uni.chooseFile
uni.chooseFile({
count: 9,
success: (res) => {
console.log('uni.chooseFile返回的res:', res);
const tempFilePaths = res.tempFilePaths;
const files = res.tempFiles;
console.log('tempFilePaths:', tempFilePaths);
console.log('tempFiles:', files);
if (files && files.length > 0) {
console.log('第一个文件详情:', files[0]);
console.log('第一个文件的所有属性:', Object.keys(files[0]));
// 详细记录每个文件的所有属性
files.forEach((file, index) => {
console.log(`文件 ${index} 完整对象:`, JSON.stringify(file));
console.log(`文件 ${index} 关键属性:`, {
name: file.name,
type: file.type,
path: file.path,
size: file.size,
hasType: 'type' in file,
typeIsString: typeof file.type === 'string',
typeLength: file.type ? file.type.length : 0
});
});
}
// 调用公共的文件处理函数
handleSelectedFiles(tempFilePaths, files);
},
fail: (err) => {
console.error('uni.chooseFile失败:', err);
if (err.errMsg && !err.errMsg.includes('cancel')) {
$api.msg('选择文件失败,请重试');
}
}
});
}
}
// 处理服务器返回的文件路径生成正确的显示URL和服务器路径
function processFilePath(originalFilePath, fileName, fileType, fileSize, tempPath) {
let displayUrl = originalFilePath; // 用于缩略图显示的URL外网域名
const serverPath = originalFilePath; // 原始服务器路径(内网地址,用于对话接口)
console.log('processFilePath原始fileUrl:', originalFilePath);
// 如果filePath包含IP地址替换为配置的域名用于显示
if (displayUrl && displayUrl.includes('://')) {
// 检查是否是本地临时路径如http://tmp/),如果是则保持原样
if (displayUrl.startsWith('http://tmp/')) {
// 本地临时路径,保持原样
console.log('本地临时路径,保持原样:', displayUrl);
} else {
// 方法1直接替换域名部分保持路径结构- 使用兼容的方法
// 例如http://10.98.80.146/file/xxx.jpg -> https://www.xjksly.cn/file/xxx.jpg
const originalUrl = displayUrl;
// 提取协议、域名和路径部分
let protocol = '';
let domain = '';
let path = '';
// 分离协议和剩余部分
const protocolIndex = originalUrl.indexOf('://');
if (protocolIndex !== -1) {
protocol = originalUrl.substring(0, protocolIndex + 3);
const rest = originalUrl.substring(protocolIndex + 3);
const slashIndex = rest.indexOf('/');
if (slashIndex !== -1) {
domain = rest.substring(0, slashIndex);
path = rest.substring(slashIndex);
} else {
domain = rest;
path = '/';
}
}
console.log('解析结果:', { protocol, domain, path });
// 使用config.baseUrl或config.imgBaseUrl作为新域名
let baseUrl = config.baseUrl || config.imgBaseUrl;
// 如果baseUrl以/api/ks结尾去掉这个部分
if (baseUrl.endsWith('/api/ks')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 7);
}
// 构建新URL使用新域名 + 原路径
// 确保baseUrl不以/结尾path以/开头
let cleanBaseUrl = baseUrl;
if (cleanBaseUrl.endsWith('/')) {
cleanBaseUrl = cleanBaseUrl.substring(0, cleanBaseUrl.length - 1);
}
if (!path.startsWith('/')) {
path = '/' + path;
}
displayUrl = cleanBaseUrl + path;
console.log('替换域名后的displayUrl用于显示:', displayUrl);
}
} else if (displayUrl && !displayUrl.startsWith('http')) {
// 如果filePath不是完整的URL直接添加图片基础URL前缀
displayUrl = config.imgBaseUrl + displayUrl;
console.log('非完整URL添加imgBaseUrl前缀:', displayUrl);
}
return {
url: displayUrl, // 使用处理后的完整URL用于缩略图显示
serverPath: serverPath, // 原始服务器路径(内网地址,用于对话接口)
type: fileType,
name: fileName,
size: fileSize > 0 ? $api.formatFileSize(fileSize) : '',
tempPath: tempPath // 保存临时路径用于预览
};
}
// 处理选择的文件(公共逻辑)
function handleSelectedFiles(tempFilePaths, files) {
// 检查文件数量限制
if (VerifyNumberFiles(files.length)) return;
const allowedTypes = config.allowedFileTypes || [];
// 调试:在文件类型检查之前记录详细信息
console.log('=== handleSelectedFiles 文件类型检查开始 ===');
console.log('allowedTypes配置:', allowedTypes);
console.log('files数组长度:', files.length);
files.forEach((file, index) => {
console.log(`文件 ${index} 检查前详情:`, {
name: file.name,
type: file.type,
hasType: 'type' in file,
typeValue: file.type,
typeIsString: typeof file.type === 'string',
typeLength: file.type ? file.type.length : 0,
size: file.size,
path: file.path
});
// 尝试从文件名推断类型
const inferredType = getFileTypeFromName(file.name);
console.log(`文件 ${index} 从文件名推断的类型:`, inferredType);
// 检查是否在allowedTypes中
const isAllowedByType = allowedTypes.includes(file.type);
const isAllowedByInferred = allowedTypes.includes(inferredType);
const isImageFile = isImage(file.type, file.name);
console.log(`文件 ${index} 检查结果:`, {
isAllowedByType,
isAllowedByInferred,
isImageFile,
finalAllowed: isAllowedByType || isAllowedByInferred || isImageFile
});
});
console.log('=== handleSelectedFiles 文件类型检查结束 ===');
// 检查所有文件类型 - 增强版优先使用file.type回退到文件名推断
const invalidFiles = files.filter(file => {
// 如果文件类型存在且在允许的类型列表中,则通过
if (file.type && allowedTypes.includes(file.type)) {
return false;
}
// 尝试从文件名推断类型
const inferredType = getFileTypeFromName(file.name);
if (inferredType && allowedTypes.includes(inferredType)) {
return false;
}
// 如果是图片,也通过
if (isImage(file.type, file.name)) {
return false;
}
// 否则,文件无效
return true;
});
if (invalidFiles.length > 0) {
// 调试:查看不支持的文件类型
console.log('不支持的文件类型:', invalidFiles.map(f => ({name: f.name, type: f.type})));
// 更详细地显示为什么这些文件不被支持
invalidFiles.forEach((file, index) => {
const inferredType = getFileTypeFromName(file.name);
console.log(`无效文件 ${index} 分析:`, {
name: file.name,
type: file.type,
inferredType,
isImage: isImage(file.type, file.name),
isInAllowedTypes: allowedTypes.includes(file.type),
isInferredInAllowedTypes: allowedTypes.includes(inferredType)
});
});
return $api.msg('仅支持 txt md html word doc docx pdf ppt pptx csv excel xlsx 和图片格式');
}
// 显示上传提示
const imageCount = files.filter(file => isImage(file.type, file.name)).length;
const otherFileCount = files.length - imageCount;
let tip = '开始上传';
if (imageCount > 0) tip += `${imageCount}张图片`;
if (otherFileCount > 0) {
if (imageCount > 0) tip += '和';
tip += `${otherFileCount}个文件`;
}
$api.msg(tip + '...');
// 上传所有文件
const uploadPromises = tempFilePaths.map((filePath, index) => {
return $api.uploadFile(filePath, true).then((resData) => {
resData = JSON.parse(resData);
console.log('服务器返回的resData:', resData);
if (resData.code === 200 && resData.filePath) {
// 从文件对象或文件路径获取文件信息
const fileInfo = files[index] || {};
const fileName = fileInfo.name || getFileNameFromPath(tempFilePaths[index]);
const fileType = fileInfo.type || getFileTypeFromName(fileName);
const fileSize = fileInfo.size || 0;
// 使用统一的文件路径处理函数
const processedFile = processFilePath(
resData.filePath, // 原始服务器路径
fileName,
fileType,
fileSize,
tempFilePaths[index] // 临时路径用于预览
);
// 调试:记录文件信息
console.log('上传文件信息:', {
fileInfo: fileInfo,
type: fileType,
name: fileName,
size: fileSize,
isImage: isImage(fileType, fileName),
originalFilePath: resData.filePath,
displayUrl: processedFile.url,
serverPath: processedFile.serverPath,
imgBaseUrl: config.imgBaseUrl
});
return processedFile;
} else {
throw new Error(resData.msg || '上传失败');
}
});
});
Promise.all(uploadPromises).then((uploadedFiles) => {
// 将所有上传成功的文件添加到filesList
uploadedFiles.forEach(file => {
if (isImage(file.type, file.name)) {
file.loading = true; // 图片开始加载
file.error = false;
}
filesList.value.push(file);
});
textInput.value = state.uploadFileTips;
showfile.value = false; // 关闭上传面板
$api.msg(`成功上传${uploadedFiles.length}个文件`);
}).catch((error) => {
$api.msg(error.message || '文件上传失败');
});
}
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();
status.value = 'idle';
} else {
stopRecording();
if (isAudioPermission.value) {
// 主要根据录音时长判断,而不是完全依赖识别结果
// 由于setInterval是异步的这里需要考虑计时延迟
const actualDuration = recordingDuration.value > 0 ? recordingDuration.value : (isRecording.value ? 0.5 : 0);
if (actualDuration < 1) {
$api.msg('说话时长太短');
status.value = 'idle';
} else {
// 状态管理由useAudioRecorder hook内部处理
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 closeFile() {
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;
// 保存上一次调用的文本内容避免重复调用TTS
let lastSpeechText = '';
function readMarkdown(value, index, options = {}) {
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);
}
// 总是先停止当前播放,无论是不是同一消息
console.log('🛑 Always stopping current speech before starting new one');
speechIndex.value = index;
// 立即调用speak不使用防抖延迟
const speakNow = () => {
// 检查文本内容是否发生变化避免重复调用TTS
if (value !== lastSpeechText) {
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');
// 更新上一次调用的文本内容
lastSpeechText = value;
} catch (error) {
console.error('❌ Error calling speak function:', error);
}
} else {
console.log('🔄 Same text as last speech, skipping duplicate TTS call');
}
};
// 改进防抖逻辑,确保在短时间内只调用一次
if (options.immediate) {
// 如果是onComplete回调立即播放
speakNow();
} else {
// 对于流式数据,总是使用防抖,避免频繁调用
ttsDebounceTimer = setTimeout(speakNow, 500); // 延长防抖时间到500ms
}
}
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 条
}
// 切换tab
function switchTab(tab) {
activeTab.value = tab;
}
// 检查消息是否应该显示在当前tab
function shouldShowMessage(msg) {
if (msg.self) {
return true; // 用户自己的消息总是显示
}
if (activeTab.value === 'policy') {
// 政策查询tab显示除了岗位卡片以外的所有内容
// 岗位卡片通常包含特定的标记,如```job-json或岗位推荐等关键词
const isJobCard = msg.displayText && (
msg.displayText.includes('```job-json') ||
msg.displayText.includes('岗位推荐') ||
msg.displayText.includes('推荐岗位') ||
msg.displayText.includes('岗位信息') ||
(msg.displayText.includes('```') && msg.displayText.includes('公司') && msg.displayText.includes('薪资'))
);
return !isJobCard;
} else {
// 岗位推荐tab只显示岗位卡片内容
const isJobCard = msg.displayText && (
msg.displayText.includes('```job-json') ||
msg.displayText.includes('岗位推荐') ||
msg.displayText.includes('推荐岗位') ||
msg.displayText.includes('岗位信息') ||
(msg.displayText.includes('```') && msg.displayText.includes('公司') && msg.displayText.includes('薪资'))
);
return isJobCard;
}
}
// 长按事件处理函数
function handleLongPress(msg, index) {
isSelectMode.value = true;
selectedMessages.value = [index];
// 如果长按的是用户消息同时选中对应的AI回复
if (msg.self) {
const nextIndex = index + 1;
if (nextIndex < messages.value.length && !messages.value[nextIndex].self) {
selectedMessages.value.push(nextIndex);
}
}
}
// 选择/取消选择消息
function toggleSelectMessage(index) {
const msg = messages.value[index];
const idx = selectedMessages.value.indexOf(index);
if (idx > -1) {
// 取消选择
selectedMessages.value.splice(idx, 1);
// 如果取消选择的是用户消息同时取消选择对应的AI回复
if (msg.self) {
const nextIndex = index + 1;
if (nextIndex < messages.value.length && !messages.value[nextIndex].self) {
const aiIdx = selectedMessages.value.indexOf(nextIndex);
if (aiIdx > -1) {
selectedMessages.value.splice(aiIdx, 1);
}
}
}
if (selectedMessages.value.length === 0) {
isSelectMode.value = false;
}
} else {
// 选择消息
selectedMessages.value.push(index);
// 如果选择的是用户消息同时选择对应的AI回复
if (msg.self) {
const nextIndex = index + 1;
if (nextIndex < messages.value.length && !messages.value[nextIndex].self) {
if (!selectedMessages.value.includes(nextIndex)) {
selectedMessages.value.push(nextIndex);
}
}
}
}
}
// 退出选择模式
function exitSelectMode() {
isSelectMode.value = false;
selectedMessages.value = [];
}
// 全选消息
function selectAllMessages() {
selectedMessages.value = messages.value.map((_, index) => index);
}
// 删除选中消息
function deleteSelectedMessages() {
if (selectedMessages.value.length === 0) return;
uni.showModal({
content: `确定删除${selectedMessages.value.length}条消息吗?`,
success(res) {
if (res.confirm) {
// 调用store中的删除方法
useChatGroupDBStore().deleteMessages(selectedMessages.value);
// 退出选择模式
exitSelectMode();
$api.msg('消息删除成功');
}
}
});
}
defineExpose({ scrollToBottom, closeGuess, closeFile, changeQueries, handleTouchCancel, switchTab });
</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
// 消息中的图片文件样式
.msg-image-file
height: 120rpx
width: 120rpx
padding: 4rpx
flex-direction: column
justify-content: center
.msg-image-thumbnail
width: 100%
height: 80rpx
border-radius: 8rpx
margin-bottom: 8rpx
.msg-file-text
font-size: 20rpx
text-align: center
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.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 10rpx 44rpx;
}
.chat-item {
display: flex;
align-items: flex-start;
margin-bottom: 10rpx;
width: 100%;
}
.chat-item.self {
justify-content: flex-end;
}
.message
margin-top: 0rpx
// 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: #F6F6F6;
border-radius: 20rpx 0 20rpx 20rpx;
padding: 20rpx;
width: fit-content;
display: flex;
align-items: center;
justify-content: center;
.loading-content{
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.loading-text{
font-size: 28rpx;
color: #666666;
font-weight: 500;
}
}
.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;
/* #ifdef H5 */
padding-bottom: calc(24rpx + env(safe-area-inset-bottom) + 70rpx);
/* #endif */
/* #ifdef MP-WEIXIN */
/* 小程序不支持CSS中的本地图片使用image标签替代 */
padding-bottom: calc(24rpx + env(safe-area-inset-bottom) + 40rpx - 50rpx);
/* #endif */
position: relative;
background: #FFFFFF;
box-shadow: 0rpx -4rpx 10rpx 0rpx rgba(11,44,112,0.06);
transition: height 2s ease-in-out;
z-index: 1001;
}
.ai-tips{
font-size: 24rpx;
color: #8c8c8c;
line-height: 33rpx;
margin-top: 18rpx;
text-align: center;
}
.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; /* 禁用默认滚动 */
position: fixed;
left: 0;
right: 0;
bottom: 160rpx; /* 为底部导航栏留出空间 */
z-index: 9999; /* 确保高于其他元素 */
.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
}
/* 文件上传弹窗 - 向上展开避免被tabbar遮挡 */
.file-upload-popup {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: #FFFFFF;
padding: 0 28rpx 24rpx 28rpx;
box-shadow: 0rpx -4rpx 10rpx 0rpx rgba(11,44,112,0.06);
transform: translateY(20rpx);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: 1002;
}
.file-upload-popup.show {
transform: translateY(0);
opacity: 1;
visibility: visible;
}
.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
bottom: 100%
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);
z-index: 1003
.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: 200rpx;
max-width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
border: 2rpx solid #E2E2E2;
overflow: hidden
display: flex
flex-direction: column
&.file-border
border: 2rpx solid #4A90E2;
background: #F5F9FF;
.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-thumbnail
flex: 1
display: flex
align-items: center
justify-content: center
overflow: hidden
border-radius: 10rpx 10rpx 0 0
background: #F8F9FA
.file-iconImg
height: 100%
width: 100%
object-fit: cover
.file-info
padding: 12rpx 16rpx
background: white
border-top: 1rpx solid #F0F0F0
.file-type-tag
font-size: 20rpx
color: #4A90E2
background: #F0F7FF
padding: 4rpx 8rpx
border-radius: 4rpx
.image-loading, .image-error
position: absolute
top: 0
left: 0
right: 0
bottom: 0
display: flex
flex-direction: column
align-items: center
justify-content: center
background: rgba(255, 255, 255, 0.9)
.loading-text, .error-text
font-size: 24rpx
margin-top: 10rpx
.loading-text
color: #999
.error-text
color: #ff4444
.retry-text
font-size: 22rpx
color: #4A90E2
margin-top: 5rpx
text-decoration: underline
.image-count-badge
position: absolute
top: 10rpx
right: 10rpx
background: rgba(0, 0, 0, 0.6)
color: white
font-size: 20rpx
padding: 4rpx 8rpx
border-radius: 12rpx
z-index: 2
.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;
/* 更美观的loading动画 - 兼容H5和小程序 */
@keyframes ai-loading-dots {
0%, 20%, 80%, 100% {
transform: scale(1);
opacity: 0.6;
}
40% {
transform: scale(1.2);
opacity: 1;
}
}
/* 重置默认样式 */
.ai-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
width: auto;
height: auto;
background: transparent;
border: none;
border-radius: 0;
padding: 0;
margin: 0;
}
/* 三个点的样式 - 使用标准CSS语法不使用嵌套 */
.ai-loading view {
display: inline-block;
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: #256BFA;
animation: ai-loading-dots 1.4s ease-in-out infinite both;
margin: 0;
padding: 0;
}
/* 为每个点设置不同的动画延迟 */
.ai-loading view:nth-child(1) {
animation-delay: -0.32s;
}
.ai-loading view:nth-child(2) {
animation-delay: -0.16s;
}
.ai-loading view:nth-child(3) {
animation-delay: 0s;
}
/* Tab切换样式 */
.tab-container {
display: flex;
background: #FFFFFF;
border-bottom: 2rpx solid #F4F4F4;
padding: 0 44rpx;
height: 88rpx;
align-items: center;
z-index: 10;
}
.tab-item {
flex: 1;
text-align: center;
font-size: 28rpx;
font-weight: 500;
color: #666666;
line-height: 88rpx;
height: 88rpx;
position: relative;
transition: all 0.3s ease;
}
.tab-item.active {
color: #256BFA;
font-weight: bold;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 4rpx;
background: #256BFA;
border-radius: 2rpx;
}
.tab-item:active {
background-color: #F5F5F5;
}
/* 选择模式样式 */
.select-mode-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 44rpx;
height: 88rpx;
background: #FFFFFF;
border-bottom: 2rpx solid #F4F4F4;
z-index: 10;
}
.select-header-left {
display: flex;
align-items: center;
gap: 24rpx;
}
.select-count {
font-size: 28rpx;
color: #333333;
font-weight: 500;
}
.select-all {
font-size: 28rpx;
color: #256BFA;
font-weight: 500;
}
.select-header-right {
display: flex;
align-items: center;
gap: 32rpx;
}
.cancel-btn {
font-size: 28rpx;
color: #666666;
font-weight: 500;
}
.delete-btn {
font-size: 28rpx;
color: #FF4444;
font-weight: 500;
}
.delete-btn:disabled {
color: #CCCCCC;
}
/* 消息选择框样式 */
.message-checkbox {
margin-right: 12rpx;
display: flex;
align-items: flex-start;
padding-top: 8rpx;
}
.checkbox {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #CCCCCC;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.checkbox.checked {
background-color: #256BFA;
border-color: #256BFA;
}
/* 调整消息项布局,为选择框留出空间 */
.chat-item {
position: relative;
}
.chat-item .message {
flex: 1;
}
</style>