chore: 整理项目结构,清理归档文件,更新部署配置
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -280,16 +280,15 @@ export async function sendMessage(data: SendMessageRequest): Promise<SendMessage
|
||||
const response: any = await apiClient.post('/h5/conversations/current/messages', data, {
|
||||
timeout: 30000,
|
||||
})
|
||||
// response = {code:0, data: {user_message:..., ai_reply:...}, message:"success"}
|
||||
// response.data = 业务数据 {user_message:..., ai_reply:..., ...}
|
||||
const raw = response.data
|
||||
// 注意:apiClient 拦截器返回的是 {code: 0, data: {...}, message: "success"} 包装对象,
|
||||
// 需要通过 response.data 获取实际业务数据
|
||||
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
||||
return {
|
||||
user_message: mapMessage(raw.user_message),
|
||||
ai_reply: raw.ai_reply ? mapMessage(raw.ai_reply) : raw.ai_reply,
|
||||
is_guidance: raw.is_guidance,
|
||||
ai_reply_count: raw.ai_reply_count,
|
||||
can_call_agent: raw.can_call_agent,
|
||||
user_message: mapMessage(response.data.user_message),
|
||||
ai_reply: response.data.ai_reply ? mapMessage(response.data.ai_reply) : response.data.ai_reply,
|
||||
is_guidance: response.data.is_guidance,
|
||||
ai_reply_count: response.data.ai_reply_count,
|
||||
can_call_agent: response.data.can_call_agent,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,8 +115,9 @@ export async function uploadImage(file: File): Promise<{
|
||||
'/messages/image',
|
||||
formData,
|
||||
{
|
||||
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Content-Type': undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -141,8 +142,9 @@ export async function uploadMessageFile(file: File): Promise<{
|
||||
'/messages/file',
|
||||
formData,
|
||||
{
|
||||
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Content-Type': undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -47,19 +47,46 @@ export async function uploadFile(file: File | Blob, blobNamePrefix: string = 'pa
|
||||
formData.append('file', file as File)
|
||||
}
|
||||
|
||||
// 发送上传请求(60 秒超时,大文件上传可能较慢)
|
||||
// 注意:必须显式删除 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
// 原因:apiClient 实例默认设置了 'Content-Type': 'application/json'
|
||||
// 如果不覆盖,Axios 会保留 application/json,后端无法解析 FormData 中的 file 字段
|
||||
const response: any = await apiClient.post('/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': undefined,
|
||||
},
|
||||
timeout: 60000,
|
||||
})
|
||||
// ISS-B3 修复:上传带自动重试(3次,指数退避 1s→2s→4s)
|
||||
return await uploadWithRetry(formData)
|
||||
}
|
||||
|
||||
// 响应拦截器已确保 code === 0
|
||||
// response = {code:0, data: {url:"...",...}, message:"success"}(拦截器返回值)
|
||||
// response.data = 业务数据 {url:"...", filename:"...", ...}
|
||||
return response.data as UploadResponse
|
||||
/**
|
||||
* 上传重试包装函数
|
||||
*
|
||||
* 做什么:对文件上传请求进行自动重试,最多 3 次
|
||||
* 为什么:网络波动时避免用户手动重试,提升上传成功率
|
||||
* 重试策略:指数退避(1秒 → 2秒 → 4秒),最多 3 次重试(共 4 次尝试)
|
||||
*
|
||||
* @param formData - 已构建好的 FormData 对象
|
||||
* @param maxRetries - 最大重试次数(默认 3)
|
||||
* @returns 上传响应数据
|
||||
*/
|
||||
async function uploadWithRetry(formData: FormData, maxRetries: number = 3): Promise<UploadResponse> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// 发送上传请求(60 秒超时,大文件上传可能较慢)
|
||||
// 注意:必须显式删除 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
// 原因:apiClient 实例默认设置了 'Content-Type': 'application/json'
|
||||
// 如果不覆盖,Axios 会保留 application/json,后端无法解析 FormData 中的 file 字段
|
||||
const response: any = await apiClient.post('/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': undefined,
|
||||
},
|
||||
timeout: 60000,
|
||||
})
|
||||
// 响应拦截器已确保 code === 0
|
||||
// response = {code:0, data: {url:"...",...}, message:"success"}(拦截器返回值)
|
||||
// response.data = 业务数据 {url:"...", filename:"...", ...}
|
||||
return response.data as UploadResponse
|
||||
} catch (err) {
|
||||
if (attempt === maxRetries) throw err
|
||||
// 指数退避:1s, 2s, 4s
|
||||
const delay = 1000 * Math.pow(2, attempt)
|
||||
console.warn(`[Upload H5] 上传失败,${delay / 1000}秒后重试(第 ${attempt + 1}/${maxRetries} 次)`)
|
||||
await new Promise(r => setTimeout(r, delay))
|
||||
}
|
||||
}
|
||||
// 不应到达此处,但 TypeScript 需要返回值
|
||||
throw new Error('上传失败:已达最大重试次数')
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
:trigger-text="store.approvalCardTriggerText"
|
||||
@select="handleApprovalSelect"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 企微IT智能服务台 — H5用户端输入栏组件
|
||||
// =============================================================================
|
||||
// 说明:底部输入栏,固定在消息列表下方,包含:
|
||||
// [工具栏:表情/图片/文件/拍照] [文本输入框] [发送按钮]
|
||||
// [工具栏:表情/文件] [文本输入框] [发送按钮]
|
||||
// - 输入框默认3行可见,高度随内容动态适应
|
||||
// - 输入框顶部拖拽手柄可手动调节高度
|
||||
// - Enter 发送,Shift+Enter 换行
|
||||
@@ -23,23 +23,14 @@
|
||||
|
||||
<!-- 输入区域:工具栏 + 输入框 + 发送按钮 -->
|
||||
<div class="input-bar__row">
|
||||
<!-- 工具栏:表情/图片/文件/拍照/截图 -->
|
||||
<!-- 工具栏:表情/文件 (已移除图片/拍照/截图) -->
|
||||
<div class="input-bar__toolbar">
|
||||
<button class="input-bar__tool-btn" title="表情" @click="handleEmoji">
|
||||
<span>😊</span>
|
||||
</button>
|
||||
<button class="input-bar__tool-btn" title="图片" @click="handleImage">
|
||||
<span>🖼️</span>
|
||||
</button>
|
||||
<button class="input-bar__tool-btn" title="文件" @click="handleFile">
|
||||
<span>📎</span>
|
||||
</button>
|
||||
<button class="input-bar__tool-btn" title="拍照" @click="handleCamera">
|
||||
<span>📷</span>
|
||||
</button>
|
||||
<button class="input-bar__tool-btn" title="截图" @click="handleScreenshot">
|
||||
<span>✂️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 表情选择面板(简易版:常用 Emoji 网格) -->
|
||||
@@ -95,31 +86,13 @@
|
||||
请描述你遇到的问题,AI 助手会帮你分析 💡
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入框(图片/文件上传用,由工具栏按钮触发) -->
|
||||
<!-- 隐藏的文件输入框(文件上传用,由工具栏按钮触发) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 隐藏的拍照输入框(移动端直接调用摄像头) -->
|
||||
<input
|
||||
ref="cameraInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style="display: none"
|
||||
@change="handleCameraCapture"
|
||||
/>
|
||||
|
||||
<!-- 截图区域选择编辑器(对标微信/企微截图体验) -->
|
||||
<ScreenshotEditor
|
||||
v-if="showScreenshotEditor"
|
||||
:screenshot-canvas="screenshotCanvas"
|
||||
@confirm="onScreenshotConfirm"
|
||||
@cancel="onScreenshotCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -129,15 +102,13 @@
|
||||
* 布局:[工具栏] [输入框] [发送按钮]
|
||||
* 输入框固定底部,默认3行可见,高度动态适应
|
||||
* 顶部拖拽手柄可手动调节输入栏整体高度
|
||||
* 工具栏:表情/图片/文件/拍照
|
||||
* 支持粘贴图片上传(Ctrl+V)和文件选择上传
|
||||
* 工具栏:表情/文件 (已移除图片/拍照/截图)
|
||||
* 支持文件选择上传(图片上传已禁用)
|
||||
*/
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数:安全提取错误详情(防止 [object Object])
|
||||
@@ -176,9 +147,6 @@ const inputFieldRef = ref<any>(null)
|
||||
/** 隐藏文件输入框 DOM 引用(用于触发系统文件选择器) */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 隐藏拍照输入框 DOM 引用(用于调用移动端摄像头) */
|
||||
const cameraInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 是否可以发送消息(输入框有内容且未在加载中) */
|
||||
const canSend = computed(() => {
|
||||
return inputText.value.trim().length > 0 && !store.loading && store.isLoggedIn
|
||||
@@ -190,12 +158,6 @@ const isInputResizing = ref<boolean>(false)
|
||||
/** 表情面板是否可见 */
|
||||
const showEmojiPanel = ref<boolean>(false)
|
||||
|
||||
/** 截图编辑器是否可见 */
|
||||
const showScreenshotEditor = ref<boolean>(false)
|
||||
|
||||
/** html2canvas 生成的完整页面截图 Canvas 对象(传给 ScreenshotEditor) */
|
||||
let screenshotCanvas: HTMLCanvasElement | null = null
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期:Document 级别粘贴监听(修复 van-field @paste 不触发文件粘贴的问题)
|
||||
// ============================================================================
|
||||
@@ -450,18 +412,6 @@ function handleEmoji(): void {
|
||||
showEmojiPanel.value = !showEmojiPanel.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片按钮点击
|
||||
* 触发文件选择器(限定图片类型)
|
||||
*/
|
||||
function handleImage(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件按钮点击
|
||||
* 触发文件选择器(不限类型)
|
||||
@@ -474,147 +424,6 @@ function handleFile(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拍照按钮点击
|
||||
* 触发移动端摄像头拍照(capture="environment" 调用后置摄像头)
|
||||
* 桌面端降级为普通图片选择器
|
||||
*/
|
||||
function handleCamera(): void {
|
||||
if (cameraInputRef.value) {
|
||||
// 重置 input,允许重复拍照
|
||||
cameraInputRef.value.value = ''
|
||||
cameraInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拍照/选择图片后的回调
|
||||
* 将拍摄的图片上传并发送图片消息
|
||||
*/
|
||||
async function handleCameraCapture(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
}
|
||||
|
||||
// 重置 input,允许重复拍照
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理截图按钮点击(对标微信/企微)
|
||||
*
|
||||
* 做什么:截取当前页面,然后进入区域选择模式
|
||||
* 为什么:用户反馈截图不好用,需要对标微信/企微的截图体验
|
||||
*
|
||||
* 交互流程:
|
||||
* 1. 用 html2canvas 截取整个页面
|
||||
* 2. 显示 ScreenshotEditor 组件(全屏遮罩+区域选择)
|
||||
* 3. 用户在编辑器中选择区域并确认
|
||||
* 4. 接收裁剪后的图片 Blob,上传并发送
|
||||
* 5. 如果 html2canvas 失败(企微内置浏览器兼容问题),fallback 到手动选择图片
|
||||
*/
|
||||
async function handleScreenshot(): Promise<void> {
|
||||
try {
|
||||
showToast('正在截取页面...')
|
||||
|
||||
// 1. 截取整个页面(增加兼容性配置:useCORS + allowTaint 提升企微浏览器兼容性)
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
// 企微内置浏览器兼容性优化
|
||||
foreignObjectRendering: false,
|
||||
removeContainer: true,
|
||||
})
|
||||
|
||||
// 2. 保存 canvas 并显示截图编辑器
|
||||
screenshotCanvas = canvas
|
||||
showScreenshotEditor.value = true
|
||||
} catch (error) {
|
||||
console.error('截图失败,尝试 fallback 方案:', error)
|
||||
// Fallback:html2canvas 在企微内置浏览器中可能失败
|
||||
// 降级为手动选择图片(从相册选取或重新拍照)
|
||||
try {
|
||||
if (cameraInputRef.value) {
|
||||
showToast('截图功能不可用,请选择图片替代')
|
||||
cameraInputRef.value.value = ''
|
||||
// 不使用 capture 属性,允许从相册选择
|
||||
cameraInputRef.value.removeAttribute('capture')
|
||||
cameraInputRef.value.click()
|
||||
// 恢复 capture 属性
|
||||
nextTick(() => {
|
||||
if (cameraInputRef.value) {
|
||||
cameraInputRef.value.setAttribute('capture', 'environment')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
showToast('截图失败,请重试')
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error('截图 fallback 也失败:', fallbackError)
|
||||
showToast('截图失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图编辑器确认回调(对标微信/企微)
|
||||
* 接收裁剪后的图片 Blob,上传并发送
|
||||
*
|
||||
* 注意:H5 端当前后端 sendMessage API 只支持文本消息,
|
||||
* 所以截图上传后以文本形式发送截图链接,后续后端支持图片消息后可升级
|
||||
*/
|
||||
async function onScreenshotConfirm(blob: Blob): Promise<void> {
|
||||
try {
|
||||
console.log('[InputBar] 截图确认,开始上传,blob size:', blob.size)
|
||||
showToast('截图上传中...')
|
||||
|
||||
const result = await uploadFile(blob, 'screenshot')
|
||||
console.log('[InputBar] 上传成功,result:', result)
|
||||
|
||||
// 以图片消息类型发送截图(携带 media_url,MessageBubble 会渲染缩略图)
|
||||
console.log('[InputBar] 开始调用 store.sendNewMessage,media_url:', result.url)
|
||||
await store.sendNewMessage('[截图]', {
|
||||
msg_type: 'image',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
console.log('[InputBar] store.sendNewMessage 完成,当前消息数:', store.messages.length)
|
||||
|
||||
showToast('截图发送成功')
|
||||
console.log('[InputBar] 截图发送成功 toast 已显示')
|
||||
} catch (error: any) {
|
||||
console.error('[InputBar] 截图发送失败:', error)
|
||||
showToast(
|
||||
`截图发送失败:${
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'未知错误'
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
console.log('[InputBar] 截图流程 finally 块执行完成')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图编辑器取消回调
|
||||
*/
|
||||
function onScreenshotCancel(): void {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 拖拽调节输入栏高度
|
||||
// ============================================================================
|
||||
@@ -710,7 +519,7 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 工具栏:表情/图片/文件/截图 */
|
||||
/* 工具栏:表情/文件 (已移除图片/拍照/截图) */
|
||||
.input-bar__toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -105,10 +105,12 @@ export function useH5WebSocket() {
|
||||
const isDev = import.meta.env.DEV
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsHost = isDev ? 'localhost:8000' : window.location.host
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/h5/${employeeId}?token=${token}`
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/h5/${employeeId}`
|
||||
|
||||
console.log(`[H5 WS] 正在连接: ${wsUrl.replace(/token=[^&]+/, 'token=***')}`)
|
||||
ws = new WebSocket(wsUrl)
|
||||
console.log(`[H5 WS] 正在连接: ${wsUrl}`)
|
||||
// ISS-B2 修复: 使用 WebSocket subprotocol 传递 token(与坐席端一致)
|
||||
// 浏览器原生 WebSocket API 第2参数是 protocols,服务端从 sec-websocket-protocol 头读取 bearer.{token}
|
||||
ws = new WebSocket(wsUrl, [`bearer.${token}`])
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 连接成功
|
||||
|
||||
@@ -17,7 +17,6 @@ import ChatView from '@/views/ChatView.vue'
|
||||
// v0.5.4 BC/DR 应急页(身份检测 + H5 右栏)
|
||||
import EmergencyDispatcher from '@/views/EmergencyDispatcher.vue'
|
||||
import H5PreviewView from '@/views/H5PreviewView.vue'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 企微环境检测工具函数
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -129,20 +128,29 @@ router.beforeEach(async (to, _from, next) => {
|
||||
if (urlToken) {
|
||||
// token 已存入 localStorage,重新初始化 store 中的 token 状态
|
||||
employeeStore.$patch({ token: urlToken })
|
||||
// #90 修复:Portal传递token后,需要调用后端API获取用户信息
|
||||
// 否则 isAuthenticated 只检查 token 存在,不检查用户信息有效性
|
||||
try {
|
||||
await employeeStore.fetchEmployeeInfo()
|
||||
} catch (e) {
|
||||
console.warn('[Router] Portal token 验证失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 第一道防线:企微环境检测(非企微环境 → 拦截)
|
||||
// 第一道防线:企微环境检测(非企微环境 → 降级登录)
|
||||
// ========================================================================
|
||||
// 生产环境强制企微内访问;开发环境(localhost)跳过检测
|
||||
// 非企微环境:允许有 token 的用户直接进入,没有 token 则跳转降级登录页
|
||||
// 开发环境(localhost)跳过检测
|
||||
const isLocalhost = /^localhost(:\d+)?$/.test(window.location.hostname) || window.location.hostname === '127.0.0.1'
|
||||
if (!isLocalhost && !isWeworkEnv()) {
|
||||
// 如果有 token(从 Portal 传入),允许直接进入
|
||||
// 如果有 token(从 Portal 传入或本地存储),允许直接进入
|
||||
if (employeeStore.isAuthenticated) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
next({ name: 'WeworkOnly' })
|
||||
// 非企微环境:跳转到降级登录页,而非企微专属拦截页
|
||||
next({ name: 'Login' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,79 @@ import {
|
||||
} from '@/api/conversation'
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 本地缓存配置
|
||||
// --------------------------------------------------------------------------
|
||||
const MESSAGES_CACHE_KEY = 'h5_messages_cache' // 消息缓存 key 前缀
|
||||
const MESSAGES_CACHE_EXPIRE_DAYS = 7 // 缓存过期天数
|
||||
const MESSAGES_CACHE_MAX = 100 // 单会话最多缓存消息数
|
||||
|
||||
/** 获取消息缓存 key */
|
||||
function getCacheKey(conversationId: string): string {
|
||||
return `${MESSAGES_CACHE_KEY}_${conversationId}`
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取消息缓存 */
|
||||
function loadMessagesFromCache(conversationId: string): Message[] | null {
|
||||
try {
|
||||
const key = getCacheKey(conversationId)
|
||||
const cached = localStorage.getItem(key)
|
||||
if (!cached) return null
|
||||
|
||||
const data = JSON.parse(cached)
|
||||
// 检查是否过期
|
||||
const cacheTime = data.timestamp || 0
|
||||
const now = Date.now()
|
||||
const expireMs = MESSAGES_CACHE_EXPIRE_DAYS * 24 * 60 * 60 * 1000
|
||||
if (now - cacheTime > expireMs) {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
return data.messages || null
|
||||
} catch (e) {
|
||||
console.warn('[Store] 读取消息缓存失败:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存消息到 localStorage */
|
||||
function saveMessagesToCache(conversationId: string, messages: Message[]): void {
|
||||
try {
|
||||
const key = getCacheKey(conversationId)
|
||||
// 只保留最近 N 条消息
|
||||
const trimmed = messages.slice(-MESSAGES_CACHE_MAX)
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
messages: trimmed,
|
||||
timestamp: Date.now(),
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('[Store] 保存消息缓存失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除指定会话的缓存 */
|
||||
function clearMessagesCache(conversationId: string): void {
|
||||
try {
|
||||
const key = getCacheKey(conversationId)
|
||||
localStorage.removeItem(key)
|
||||
} catch (e) {
|
||||
console.warn('[Store] 清除消息缓存失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 合并缓存和新消息(去重) */
|
||||
function mergeMessages(cached: Message[], fresh: Message[]): Message[] {
|
||||
const map = new Map<string, Message>()
|
||||
// 先添加缓存
|
||||
cached.forEach(m => map.set(m.message_id, m))
|
||||
// 再添加新消息(覆盖缓存)
|
||||
fresh.forEach(m => map.set(m.message_id, m))
|
||||
// 按时间排序
|
||||
return Array.from(map.values()).sort((a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Store 定义
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -432,6 +505,12 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
status: 'sent',
|
||||
}
|
||||
console.log('[Store] 乐观更新成功:临时消息已替换为真实消息')
|
||||
|
||||
// 更新本地缓存
|
||||
const convId = currentConversation.value?.conversation_id
|
||||
if (convId) {
|
||||
saveMessagesToCache(convId, messages.value)
|
||||
}
|
||||
} else {
|
||||
// 防御性:找不到临时消息时直接添加
|
||||
messages.value.push({ ...resp.user_message, status: 'sent' })
|
||||
@@ -520,6 +599,12 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
// 更新最后消息 ID
|
||||
lastMessageId.value = uniqueNewMessages[uniqueNewMessages.length - 1].message_id
|
||||
console.log('[Store] 轮询到新消息:', uniqueNewMessages.length, '条')
|
||||
|
||||
// 更新本地缓存
|
||||
const convId = currentConversation.value?.conversation_id
|
||||
if (convId) {
|
||||
saveMessagesToCache(convId, messages.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -819,19 +904,64 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
try {
|
||||
console.log('[Store] 开始初始化应用...')
|
||||
// 获取用户信息
|
||||
await fetchUserInfo()
|
||||
// 获取当前会话
|
||||
await fetchCurrentConversation()
|
||||
// 加载右侧面板数据
|
||||
|
||||
// ===== 步骤1:并行加载用户信息和会话(不等待消息) =====
|
||||
await Promise.all([
|
||||
fetchApprovalLinks(),
|
||||
fetchSoftwareDownloads(),
|
||||
fetchUserInfo(),
|
||||
fetchCurrentConversation(),
|
||||
])
|
||||
// 启动消息轮询
|
||||
|
||||
// ===== 步骤2:加载本地缓存(如果有) =====
|
||||
const convId = currentConversation.value?.conversation_id
|
||||
if (convId) {
|
||||
const cached = loadMessagesFromCache(convId)
|
||||
if (cached && cached.length > 0) {
|
||||
console.log(`[Store] 加载缓存消息 ${cached.length} 条`)
|
||||
messages.value = cached
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 步骤3:后台加载后端消息(不阻塞UI) =====
|
||||
// 使用 Promise.resolve().then() 让它在下一次事件循环执行,不阻塞主线程
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
const freshMessages = await pollMessages()
|
||||
if (freshMessages.length > 0) {
|
||||
// 合并缓存和最新消息
|
||||
if (convId) {
|
||||
messages.value = mergeMessages(messages.value, freshMessages)
|
||||
// 保存到缓存
|
||||
saveMessagesToCache(convId, messages.value)
|
||||
} else {
|
||||
messages.value = freshMessages
|
||||
}
|
||||
// 更新最后消息ID
|
||||
const lastMsg = freshMessages[freshMessages.length - 1]
|
||||
if (lastMsg) {
|
||||
lastMessageId.value = lastMsg.message_id
|
||||
}
|
||||
console.log(`[Store] 后端消息已合并,共 ${messages.value.length} 条`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Store] 加载后端消息失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 步骤4:延迟加载右侧面板数据(不阻塞首屏) =====
|
||||
// 使用 setTimeout 让它不阻塞消息加载
|
||||
setTimeout(() => {
|
||||
Promise.all([
|
||||
fetchApprovalLinks(),
|
||||
fetchSoftwareDownloads(),
|
||||
]).catch(e => console.warn('[Store] 加载面板数据失败:', e))
|
||||
}, 500)
|
||||
|
||||
// ===== 步骤5:启动轮询 =====
|
||||
startPolling()
|
||||
|
||||
// ===== 步骤6:标记初始化完成(消息已显示) =====
|
||||
initialized.value = true
|
||||
console.log('[Store] 应用初始化完成')
|
||||
console.log('[Store] 应用初始化完成(缓存优先显示)')
|
||||
} catch (error) {
|
||||
console.error('[Store] 应用初始化失败:', error)
|
||||
}
|
||||
@@ -842,6 +972,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
* 在组件卸载时调用,停止轮询,清理定时器
|
||||
*/
|
||||
function cleanup(): void {
|
||||
// 清除当前会话的缓存
|
||||
if (currentConversation.value?.conversation_id) {
|
||||
clearMessagesCache(currentConversation.value.conversation_id)
|
||||
}
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ export const useEmployeeStore = defineStore('employee', () => {
|
||||
/** 访问令牌(Bearer Token)— 优先从 h5_token 读取,降级读取 portal_token */
|
||||
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || localStorage.getItem(PORTAL_TOKEN_KEY) || '')
|
||||
|
||||
// 页面刷新时:如果 token 存在且有效,重置重定向计数,避免残留计数导致误报"登录状态异常"
|
||||
if (token.value && !isTokenExpired(token.value)) {
|
||||
localStorage.removeItem(OAUTH_REDIRECT_COUNT_KEY)
|
||||
}
|
||||
|
||||
/** 当前员工信息 */
|
||||
const employeeInfo = ref<EmployeeInfo | null>(null)
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showLoadingToast, showFailToast } from 'vant'
|
||||
import { showFailToast } from 'vant'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -36,8 +36,6 @@ const loading = ref(true)
|
||||
const errorMsg = ref('')
|
||||
const statusText = ref('正在检测身份...')
|
||||
|
||||
const isMobile = computed(() => window.innerWidth < 500)
|
||||
|
||||
/**
|
||||
* 加载企微 JS-SDK
|
||||
* 注意:企微 JS-SDK 文件名是 jweixin-1.2.0.js(历史遗留,虽然叫 jweixin)
|
||||
@@ -108,7 +106,7 @@ function wxAgentConfig(config: any): Promise<{ userId: string }> {
|
||||
nonceStr: config.nonce_str,
|
||||
signature: config.signature,
|
||||
jsApiList: ['selectExternalContact'],
|
||||
success: (res: any) => {
|
||||
success: (_res: any) => {
|
||||
// 拿当前 userid(实际场景可能要从 selectExternalContact 等接口拿)
|
||||
// 这里我们直接通过 URL 参数或后端回查
|
||||
// 简化版:从后端 cookie / 之前登录态拿
|
||||
|
||||
@@ -368,8 +368,6 @@ function handleSelect(option: 'yes' | 'no'): void {
|
||||
// 标记当前节点为已完成
|
||||
currentNode.value.status = 'done'
|
||||
|
||||
const currentId = currentNode.value.id
|
||||
|
||||
// 获取下一个节点
|
||||
const nextNode: FlowchartNode | undefined =
|
||||
option === 'yes'
|
||||
|
||||
Reference in New Issue
Block a user