chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — H5用户端 Docker 镜像构建文件
|
||||
# =============================================================================
|
||||
# 说明:基于 node:20 构建前端并输出到 nginx 目录
|
||||
# 用法:docker build -t wecom-it-desk-h5 .
|
||||
# =============================================================================
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 第一阶段:构建阶段(编译 Vue 项目)
|
||||
# --------------------------------------------------------------------------
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖声明文件(利用 Docker 层缓存)
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制项目源码
|
||||
COPY . .
|
||||
|
||||
# 构建生产版本
|
||||
RUN npm run build
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 第二阶段:输出阶段(只保留构建产物)
|
||||
# --------------------------------------------------------------------------
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# 从构建阶段复制构建产物到 nginx 目录
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
# 启动 nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AiHelperPanel: typeof import('./src/components/assistant/AiHelperPanel.vue')['default']
|
||||
ApprovalLinks: typeof import('./src/components/assistant/ApprovalLinks.vue')['default']
|
||||
CallAgentModal: typeof import('./src/components/chat/CallAgentModal.vue')['default']
|
||||
ChatPanel: typeof import('./src/components/chat/ChatPanel.vue')['default']
|
||||
ComingSoon: typeof import('./src/components/assistant/ComingSoon.vue')['default']
|
||||
InputBar: typeof import('./src/components/chat/InputBar.vue')['default']
|
||||
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
|
||||
ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default']
|
||||
RightPanel: typeof import('./src/components/assistant/RightPanel.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ScreenshotEditor: typeof import('./src/components/chat/ScreenshotEditor.vue')['default']
|
||||
ShakeButton: typeof import('./src/components/chat/ShakeButton.vue')['default']
|
||||
SoftwareDownloads: typeof import('./src/components/assistant/SoftwareDownloads.vue')['default']
|
||||
TroubleshootFlow: typeof import('./src/components/chat/TroubleshootFlow.vue')['default']
|
||||
TroubleshootProgress: typeof import('./src/components/chat/TroubleshootProgress.vue')['default']
|
||||
VanButton: typeof import('vant/es')['Button']
|
||||
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
|
||||
VanEmpty: typeof import('vant/es')['Empty']
|
||||
VanField: typeof import('vant/es')['Field']
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端环境类型声明
|
||||
// =============================================================================
|
||||
// 说明:声明 .vue 文件的模块类型,让 TypeScript 能识别 .vue 文件
|
||||
// =============================================================================
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// 声明 .vue 文件模块类型
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 移动端视口设置(适配企微 WebView) -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<!-- 页面标题 -->
|
||||
<title>IT智能服务台</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app"></div>
|
||||
<!-- 入口脚本 -->
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "wecom-it-desk-h5",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "企微IT智能服务台 - H5用户端前端",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"axios": "^1.7.0",
|
||||
"html2canvas-pro": "^2.0.4",
|
||||
"pinia": "^2.1.0",
|
||||
"vant": "^4.8.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vant/auto-import-resolver": "^1.2.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
"vite": "^5.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端根组件
|
||||
// =============================================================================
|
||||
// 说明:Vue3 应用的根组件,所有页面都渲染在这个组件内
|
||||
// 使用 Vant4 ConfigProvider 支持暗黑模式
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- Vant4 主题配置:根据 themeStore 切换浅色/深色 -->
|
||||
<van-config-provider :theme="themeStore.currentTheme">
|
||||
<router-view />
|
||||
</van-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
/** 主题 Store */
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// 应用挂载时初始化主题(从 localStorage 读取偏好并应用)
|
||||
onMounted(() => {
|
||||
themeStore.initTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 根组件样式已在 global.css 中定义 */
|
||||
</style>
|
||||
@@ -0,0 +1,400 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端 API 调用层
|
||||
// =============================================================================
|
||||
// 说明:封装会话、消息、审批、下载等与后端 /api/h5/ 交互的 API 方法
|
||||
// 注意:OAuth2 相关 API 已迁移至 @/api/employee.ts
|
||||
// 1. 会话相关(当前会话、发送消息、轮询消息、敲桌子招手)
|
||||
// 2. 审批流程链接
|
||||
// 3. 软件下载列表
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from '@/api'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 用户信息(兼容旧接口 /h5/user) */
|
||||
export interface UserInfo {
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名 */
|
||||
employee_name: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
/** 岗位 */
|
||||
position: string
|
||||
/** 职级 */
|
||||
level: string
|
||||
/** 是否 VIP 员工 */
|
||||
is_vip: boolean
|
||||
/** 头像 URL */
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
/** 会话信息 */
|
||||
export interface ConversationInfo {
|
||||
/** 会话 ID */
|
||||
conversation_id: string
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名(会话发起人) */
|
||||
employee_name: string
|
||||
/** 会话状态:waiting(排队中) / serving(服务中) / closed(已结单) */
|
||||
status: 'waiting' | 'serving' | 'closed'
|
||||
/** 坐席 ID(未接入时为空) */
|
||||
agent_id: string
|
||||
/** 坐席名称(未接入时为空) */
|
||||
agent_name: string
|
||||
/** 创建时间 */
|
||||
created_at: string
|
||||
/** 更新时间 */
|
||||
updated_at: string
|
||||
/** AI 实质性回复计数(满3次可呼叫坐席) */
|
||||
ai_substantive_reply_count?: number
|
||||
/** 是否可以呼叫人工坐席(AI 回复 >= 3 次) */
|
||||
can_call_agent?: boolean
|
||||
/** 被邀请参与会话的人员列表(邀请功能 P0-09~P0-11) */
|
||||
participants?: ParticipantItem[]
|
||||
}
|
||||
|
||||
/** 消息类型 */
|
||||
export type MessageType = 'employee' | 'agent' | 'ai' | 'system'
|
||||
|
||||
/** 消息内容类型(text/image/file/voice/video/location 等) */
|
||||
export type MsgContentType = 'text' | 'image' | 'file' | 'voice' | 'video' | 'location'
|
||||
|
||||
/** 单条消息 */
|
||||
export interface Message {
|
||||
/** 消息 ID */
|
||||
message_id: string
|
||||
/** 会话 ID */
|
||||
conversation_id: string
|
||||
/** 消息类型:employee(员工) / agent(坐席) / ai(AI) / system(系统) */
|
||||
message_type: MessageType
|
||||
/** 消息内容类型:text/image/file 等 */
|
||||
msg_type?: MsgContentType
|
||||
/** 消息内容 */
|
||||
content: string
|
||||
/** 发送者名称 */
|
||||
sender_name: string
|
||||
/** 创建时间 */
|
||||
created_at: string
|
||||
/** 图片/文件 URL */
|
||||
media_url?: string
|
||||
/** 文件名 */
|
||||
file_name?: string
|
||||
/** 文件大小(字节) */
|
||||
file_size?: number
|
||||
/** 扩展数据(额外字段,如 pic_url 等) */
|
||||
extra_data?: Record<string, any>
|
||||
/** 引用回复:被回复的消息 ID */
|
||||
reply_to_id?: string
|
||||
/** 发送状态:sending(发送中) / sent(已发送) / failed(发送失败) - 用于乐观更新UI */
|
||||
status?: 'sending' | 'sent' | 'failed'
|
||||
}
|
||||
|
||||
/** 发送消息请求参数 */
|
||||
export interface SendMessageRequest {
|
||||
/** 消息内容 */
|
||||
content: string
|
||||
/** 消息内容类型:text/image/file(默认 text) */
|
||||
msg_type?: MsgContentType
|
||||
/** 图片/文件 URL(非文本消息必填) */
|
||||
media_url?: string
|
||||
/** 文件名 */
|
||||
file_name?: string
|
||||
/** 文件大小(字节) */
|
||||
file_size?: number
|
||||
}
|
||||
|
||||
/** 轮询消息请求参数 */
|
||||
export interface PollMessagesParams {
|
||||
/** 获取此 ID 之后的消息(增量轮询) */
|
||||
after_message_id?: string
|
||||
}
|
||||
|
||||
/** 招手请求参数 */
|
||||
export interface ShakeRequest {
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名 */
|
||||
employee_name: string
|
||||
}
|
||||
|
||||
/** 招手响应数据(与后端 h5.py shake 端点一致) */
|
||||
export interface ShakeResponse {
|
||||
/** 趣味话术内容 */
|
||||
funny_phrase: string
|
||||
/** 会话信息(用于判断是否已接入坐席) */
|
||||
conversation: {
|
||||
/** 会话状态:queued(排队中) / serving(服务中) / closed(已结单) */
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批流程链接 */
|
||||
export interface ApprovalLink {
|
||||
/** 链接 ID */
|
||||
id: string
|
||||
/** 链接标题 */
|
||||
title: string
|
||||
/** 链接地址 */
|
||||
url: string
|
||||
/** 分类名称 */
|
||||
category: string
|
||||
/** 图标(可选) */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** 软件下载项 */
|
||||
export interface SoftwareDownload {
|
||||
/** 软件 ID */
|
||||
id: string
|
||||
/** 软件名称 */
|
||||
name: string
|
||||
/** 版本号 */
|
||||
version: string
|
||||
/** 下载地址 */
|
||||
download_url: string
|
||||
/** 分类名称 */
|
||||
category: string
|
||||
/** 支持平台标签(如 "Windows", "macOS") */
|
||||
platforms: string[]
|
||||
/** 图标(可选) */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** 发送消息响应(含 AI 自动回复) */
|
||||
export interface SendMessageResponse {
|
||||
/** 用户发送的消息 */
|
||||
user_message: Message
|
||||
/** AI 自动回复消息 */
|
||||
ai_reply: Message
|
||||
/** 是否为引导类回复(打招呼/呼叫人工),不计入实质回复 */
|
||||
is_guidance: boolean
|
||||
/** 当前 AI 实质性回复计数 */
|
||||
ai_reply_count: number
|
||||
/** 是否可以呼叫人工坐席 */
|
||||
can_call_agent: boolean
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 邀请功能类型(P0-09~P0-11)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 参与者信息(与后端 ParticipantInfo 对应) */
|
||||
export interface ParticipantItem {
|
||||
/** 企微员工UserID 或部门ID */
|
||||
id: string
|
||||
/** 姓名 或 部门名称 */
|
||||
name: string
|
||||
/** 部门(仅员工类型) */
|
||||
department?: string
|
||||
/** 类型 — employee(个人)或 department(部门) */
|
||||
type?: 'employee' | 'department'
|
||||
/** 头像URL(从企微通讯录获取,无头像时为空字符串) */
|
||||
avatar?: string
|
||||
/** 是否已加入(邀请后、点击加入前为 false) */
|
||||
joined?: boolean
|
||||
/** 加入时间(ISO 格式) */
|
||||
joined_at?: string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 后端字段 → H5前端字段映射
|
||||
// -------------------------------------------------------------------------
|
||||
// 根因:后端 MessageResponse 使用 id / sender_type,
|
||||
// 但 H5 前端 Message 接口使用 message_id / message_type。
|
||||
// 字段名不一致导致 MessageBubble 无法识别消息类型(全 undefined),
|
||||
// Vue :key 也失效(key 全为 undefined),消息无法正常渲染。
|
||||
// 修复:在 API 层统一映射,保持 H5 组件代码不变。
|
||||
|
||||
/**
|
||||
* 将后端 MessageResponse 映射为 H5 前端 Message 格式
|
||||
* - id → message_id
|
||||
* - sender_type → message_type
|
||||
* - 其余字段直接透传
|
||||
*/
|
||||
function mapMessage(raw: any): Message {
|
||||
return {
|
||||
message_id: raw.id || raw.message_id || '',
|
||||
conversation_id: raw.conversation_id || '',
|
||||
message_type: (raw.sender_type || raw.message_type || 'text') as MessageType,
|
||||
msg_type: raw.msg_type,
|
||||
content: raw.content || '',
|
||||
sender_name: raw.sender_name || '',
|
||||
created_at: raw.created_at || '',
|
||||
media_url: raw.media_url,
|
||||
file_name: raw.file_name,
|
||||
file_size: raw.file_size,
|
||||
extra_data: raw.extra_data,
|
||||
reply_to_id: raw.reply_to_id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量映射后端消息列表为 H5 前端 Message 格式
|
||||
*/
|
||||
function mapMessages(rawList: any[]): Message[] {
|
||||
return (rawList || []).map(mapMessage)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API 方法
|
||||
// -------------------------------------------------------------------------
|
||||
// 注意:响应拦截器返回 response.data(即 {code, data, message} 包装对象)
|
||||
// API 函数通过 await + response.data 取出业务数据(与原始工作代码一致)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取当前用户信息(兼容旧接口)
|
||||
* 返回当前登录员工的详细信息(姓名、部门、岗位、VIP 状态等)
|
||||
* 注意:推荐使用 @/api/employee.ts 中的 getEmployeeInfo() 替代
|
||||
* @returns 用户信息对象
|
||||
*/
|
||||
export async function getUser(): Promise<UserInfo> {
|
||||
const response: any = await apiClient.get('/h5/user')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话
|
||||
* 返回当前员工正在进行的会话,如果无活跃会话则返回 null
|
||||
* @returns 会话信息或 null
|
||||
*/
|
||||
export async function getCurrentConversation(): Promise<ConversationInfo | null> {
|
||||
const response: any = await apiClient.get('/h5/conversations/current')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(含 AI 自动回复)
|
||||
* 在当前会话中发送一条消息,后端自动生成 AI 回复
|
||||
* @param data 消息内容
|
||||
* @returns 包含用户消息和 AI 回复的响应
|
||||
*/
|
||||
export async function sendMessage(data: SendMessageRequest): Promise<SendMessageResponse> {
|
||||
// 图片/文件消息后端处理可能较慢(AI + Dify),增加超时到30秒
|
||||
// 修复截图发送超时Bug:apiClient默认10s不够
|
||||
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
|
||||
// 修复字段映射:后端返回 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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询消息
|
||||
* 获取当前会话中指定消息 ID 之后的新消息(增量轮询)
|
||||
* @param params 轮询参数(after_message_id 用于增量获取)
|
||||
* @returns 新消息列表
|
||||
*/
|
||||
export async function pollMessages(params?: PollMessagesParams): Promise<Message[]> {
|
||||
const response: any = await apiClient.get('/h5/conversations/current/messages/poll', { params })
|
||||
// response.data = { items: [...], has_more: bool }
|
||||
const data = response.data
|
||||
const rawItems = data?.items || data || []
|
||||
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
||||
return mapMessages(rawItems)
|
||||
}
|
||||
|
||||
/**
|
||||
* 摇人 — 一键呼叫 IT 坐席
|
||||
* 触发转人工流程,返回趣味话术和会话状态
|
||||
* @param data 摇人请求参数(employee_id 必填,employee_name 可选)
|
||||
* @returns 摇人响应(包含趣味话术和会话信息)
|
||||
*/
|
||||
export async function shake(data: ShakeRequest): Promise<ShakeResponse> {
|
||||
const response: any = await apiClient.post('/h5/conversations/current/shake', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批流程链接列表
|
||||
* 返回所有可用的审批流程链接,按分类分组
|
||||
* @returns 审批流程链接数组
|
||||
*/
|
||||
export async function getApprovalLinks(): Promise<ApprovalLink[]> {
|
||||
const response: any = await apiClient.get('/h5/approval-links')
|
||||
// response.data = { items: [...] } 或 [...]
|
||||
const data = response.data
|
||||
return (data?.items || data || []) as ApprovalLink[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取软件下载列表
|
||||
* 返回所有可下载的软件列表,按分类分组
|
||||
* @returns 软件下载数组
|
||||
*/
|
||||
export async function getSoftwareDownloads(): Promise<SoftwareDownload[]> {
|
||||
const response: any = await apiClient.get('/h5/software-downloads')
|
||||
// response.data = { items: [...] } 或 [...]
|
||||
const data = response.data
|
||||
return (data?.items || data || []) as SoftwareDownload[]
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 邀请功能 API(P0-09~P0-11)
|
||||
// -------------------------------------------------------------------------
|
||||
// 注意:H5 专用端点使用 /h5/ 前缀,认证通过 Bearer Token 自动获取 employee_id
|
||||
// 后端 _get_current_employee 依赖会从 Token 中提取 employee_id,无需前端传递
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 被邀请人加入会话
|
||||
* 通过企微卡片链接点击后调用,后端从 Token 认证获取 employee_id
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 更新后的会话信息
|
||||
*/
|
||||
export async function joinConversation(
|
||||
conversationId: string
|
||||
): Promise<any> {
|
||||
const response: any = await apiClient.post(
|
||||
`/h5/conversations/${conversationId}/join`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 参与者主动退出会话
|
||||
* 后端从 Token 认证获取 employee_id,无需前端传递
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 更新后的会话信息
|
||||
*/
|
||||
export async function leaveAsParticipant(
|
||||
conversationId: string
|
||||
): Promise<any> {
|
||||
const response: any = await apiClient.post(
|
||||
`/h5/conversations/${conversationId}/leave-participant`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话参与者列表
|
||||
* 返回指定会话的所有被邀请参与者信息
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 参与者列表
|
||||
*/
|
||||
export async function getParticipants(
|
||||
conversationId: string
|
||||
): Promise<ParticipantItem[]> {
|
||||
const response: any = await apiClient.get(
|
||||
`/h5/conversations/${conversationId}/participants`
|
||||
)
|
||||
const data = response.data
|
||||
return data?.participants || []
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端员工API
|
||||
// =============================================================================
|
||||
// 说明:封装员工认证和身份信息相关的 API 方法
|
||||
// 1. OAuth2 授权回调(code 换取 token + 用户信息)
|
||||
// 2. 获取当前员工详细信息
|
||||
// 3. 获取 OAuth2 授权 URL
|
||||
// 4. Mock 登录(测试阶段,跳过 OAuth2)
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from '@/api'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** OAuth2 回调请求参数 */
|
||||
export interface OAuthCallbackRequest {
|
||||
/** 企微 OAuth2 授权码 */
|
||||
code: string
|
||||
/** 企微 OAuth2 state 参数(可选) */
|
||||
state?: string
|
||||
}
|
||||
|
||||
/** OAuth2 回调返回数据 */
|
||||
export interface OAuthCallbackResponse {
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名 */
|
||||
employee_name: string
|
||||
/** 访问令牌 */
|
||||
token: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
/** 岗位 */
|
||||
position: string
|
||||
/** 头像 URL */
|
||||
avatar: string
|
||||
}
|
||||
|
||||
/** 员工详细信息 */
|
||||
export interface EmployeeInfo {
|
||||
/** 员工 ID */
|
||||
employee_id: string
|
||||
/** 员工姓名 */
|
||||
employee_name: string
|
||||
/** 部门名称 */
|
||||
department: string
|
||||
/** 岗位 */
|
||||
position: string
|
||||
/** 手机号 */
|
||||
mobile: string
|
||||
/** 邮箱 */
|
||||
email: string
|
||||
/** 头像 URL */
|
||||
avatar: string
|
||||
/** 是否 VIP 员工 */
|
||||
is_vip: boolean
|
||||
}
|
||||
|
||||
/** OAuth2 授权URL响应 */
|
||||
export interface OAuthAuthorizeResponse {
|
||||
/** 企微OAuth2授权URL */
|
||||
authorize_url: string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API 方法
|
||||
// -------------------------------------------------------------------------
|
||||
// 注意:响应拦截器返回 response.data(即 {code, data, message} 包装对象)
|
||||
// API 函数通过 await + response.data 取出业务数据(与原始工作代码一致)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* OAuth2 授权回调
|
||||
* 将企微 OAuth2 授权码传给后端,换取员工身份和访问令牌
|
||||
* 成功后保存 token 和基本信息到 localStorage
|
||||
*
|
||||
* @param data 包含 code 和可选 state 的请求参数
|
||||
* @returns 员工身份信息(employee_id, employee_name, token 等)
|
||||
* @throws 授权失败时抛出异常
|
||||
*/
|
||||
export async function oauthCallback(data: OAuthCallbackRequest): Promise<OAuthCallbackResponse> {
|
||||
const response: any = await apiClient.post('/h5/oauth/callback', data)
|
||||
// response = {code:0, data: {token:"...", ...}, message:"success"}(拦截器返回值)
|
||||
// response.data = 业务数据 {token:"...", employee_id:"...", ...}
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 登录(测试阶段,跳过 OAuth2)
|
||||
* 直接通过员工 ID 获取真实的 Bearer Token
|
||||
* 仅当后端 MOCK_LOGIN_ENABLED=true 时可用
|
||||
*
|
||||
* @param data 包含 employee_id 和 employee_name 的请求参数
|
||||
* @returns 员工身份信息(employee_id, employee_name, token 等)
|
||||
* @throws 登录失败时抛出异常
|
||||
*/
|
||||
export async function mockLogin(data: { employee_id: string; employee_name?: string }): Promise<OAuthCallbackResponse> {
|
||||
const response: any = await apiClient.post('/h5/mock-login', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前员工详细信息
|
||||
* 返回当前登录员工的详细信息(姓名、部门、岗位、手机号、邮箱等)
|
||||
* 需要携带有效的 Bearer Token
|
||||
* @returns 员工详细信息对象
|
||||
*/
|
||||
export async function getEmployeeInfo(): Promise<EmployeeInfo> {
|
||||
const response: any = await apiClient.get('/h5/me')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企微 OAuth2 授权 URL
|
||||
* 返回完整的授权链接,前端跳转到该链接进行静默授权
|
||||
* @returns 包含 authorize_url 的响应对象
|
||||
*/
|
||||
export async function getOAuthAuthorizeUrl(): Promise<OAuthAuthorizeResponse> {
|
||||
// 传入 redirect_uri 确保后端构造正确的回调地址(而非默认的 /h5/)
|
||||
const redirectUri = window.location.origin + '/itdesk/'
|
||||
const response: any = await apiClient.get('/h5/oauth/authorize', {
|
||||
params: { redirect_uri: redirectUri },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端 Axios 实例与拦截器
|
||||
// =============================================================================
|
||||
// 说明:创建 Axios 实例,配置:
|
||||
// 1. 请求基础 URL
|
||||
// 2. 请求拦截器(添加 Bearer Token 认证头)
|
||||
// 3. 响应拦截器(统一错误处理 + 401 自动重新授权)
|
||||
// =============================================================================
|
||||
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
// Vant 轻提示
|
||||
import { showToast } from 'vant'
|
||||
// Bug #2 修复:从独立回调模块导入,替代 dynamic import('@/stores/employee'),
|
||||
// 打破 api/index.ts → stores/employee.ts 的循环依赖
|
||||
import { triggerAuthExpired } from '@/utils/authCallback'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 创建 Axios 实例
|
||||
// --------------------------------------------------------------------------
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
// 基础 URL:所有请求会自动加上这个前缀
|
||||
baseURL: '/api',
|
||||
// 请求超时时间(20秒,原10秒)
|
||||
// 原因:图片/文件上传、AI消息处理等场景后端处理需要更多时间
|
||||
// 修复截图发送超时Bug
|
||||
timeout: 20000,
|
||||
// 默认请求头
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 请求拦截器
|
||||
// --------------------------------------------------------------------------
|
||||
// 在每个请求发送前执行,用于添加 Bearer Token 认证头
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 从 localStorage 获取 token,添加到 Authorization 头
|
||||
// 替换旧的 X-Employee-Id 明文头,使用 Bearer Token 进行安全认证
|
||||
const token = localStorage.getItem('h5_token')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 兼容过渡:如果同时存在 employee_id 且无 token,则仍然发送 X-Employee-Id
|
||||
// 这确保了在 token 过期但 localStorage 中仍有旧数据的降级场景
|
||||
if (!token) {
|
||||
const employeeId = localStorage.getItem('employee_id')
|
||||
if (employeeId) {
|
||||
config.headers['X-Employee-Id'] = employeeId
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
// 请求配置错误时直接返回
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 响应拦截器
|
||||
// --------------------------------------------------------------------------
|
||||
// 在每个响应返回后执行,用于统一处理错误
|
||||
// 特殊处理 401:自动清除 token 并重新走 OAuth2 授权流程
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 从响应中提取业务数据
|
||||
const res = response.data
|
||||
|
||||
// 统一响应格式:{code: 0, data: {}, message: "success"}
|
||||
if (res.code !== 0) {
|
||||
// 特殊处理:业务码 1002 = 未授权(token 过期/无效)
|
||||
// 后端 _get_current_employee 在 Redis 查不到 token 时返回此码
|
||||
if (res.code === 1002) {
|
||||
handleAuthExpired('biz1002')
|
||||
return Promise.reject(new Error(res.message || '未授权'))
|
||||
}
|
||||
|
||||
// 普通业务错误:显示轻提示
|
||||
showToast(res.message || '请求失败')
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
|
||||
// 业务成功:返回 response.data(即 {code, data, message} 包装对象)
|
||||
// API 函数通过 response.data 取出业务数据(与原始工作代码一致)
|
||||
return response.data
|
||||
},
|
||||
async (error) => {
|
||||
// 网络错误或服务器错误
|
||||
let message = '网络异常,请稍后重试'
|
||||
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// HTTP 401:Token 过期或无效(FastAPI 直接返回的 HTTP 状态码)
|
||||
await handleAuthExpired('http401')
|
||||
break
|
||||
case 403:
|
||||
message = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
message = '请求的资源不存在'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
message = `请求失败 (${error.response.status})`
|
||||
}
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
message = '请求超时,请稍后重试'
|
||||
}
|
||||
|
||||
// 显示轻提示(401 时不显示通用提示,因为会自动跳转授权)
|
||||
if (!error.response || error.response.status !== 401) {
|
||||
showToast(message)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 辅助:处理认证过期/未授权(复用逻辑)
|
||||
// --------------------------------------------------------------------------
|
||||
// 场景1: HTTP 401(Axios error 拦截器)
|
||||
// 场景2: 业务码 1002 "未授权"(success 拦截器,后端 Redis token 过期时返回)
|
||||
// 防循环机制:通过 localStorage 计数器限制 OAuth2 重定向次数
|
||||
|
||||
/** OAuth2 重定向计数器 key(与 employee store 保持一致) */
|
||||
const OAUTH_REDIRECT_COUNT_KEY = 'oauth_redirect_count'
|
||||
/** 最大允许重定向次数 */
|
||||
const OAUTH_MAX_REDIRECT_COUNT = 3
|
||||
|
||||
// Bug #3 修复:401 去重锁,防止并发请求同时触发多次 OAuth2 重定向
|
||||
// 当第一个 401 处理完成后,后续并发的 401 等待同一个 Promise 即可
|
||||
let _authExpiredPromise: Promise<void> | null = null
|
||||
|
||||
async function handleAuthExpired(source: 'http401' | 'biz1002'): Promise<void> {
|
||||
const label = source === 'http401' ? 'HTTP 401' : '业务码 1002'
|
||||
|
||||
// Bug #3 修复:如果已有 401 正在处理中,复用同一个 Promise,避免多次重定向
|
||||
if (_authExpiredPromise) {
|
||||
console.warn(`[API] ${label} 未授权 — 已有处理进行中,等待完成`)
|
||||
return _authExpiredPromise
|
||||
}
|
||||
|
||||
console.warn(`[API] ${label} 未授权,清除凭证并跳转登录`)
|
||||
|
||||
// 创建处理 Promise 并缓存(去重用)
|
||||
_authExpiredPromise = (async () => {
|
||||
try {
|
||||
// 清除本地 token
|
||||
localStorage.removeItem('h5_token')
|
||||
localStorage.removeItem('employee_id')
|
||||
localStorage.removeItem('employee_name')
|
||||
|
||||
// 判断是 mock 模式还是生产模式
|
||||
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
|
||||
if (!corpId) {
|
||||
// Mock 模式:跳转登录页
|
||||
showToast('登录已过期,请重新登录')
|
||||
// 避免重复跳转(当前已经在登录页时不再跳转)
|
||||
if (window.location.pathname !== '/itdesk/login') {
|
||||
window.location.href = '/itdesk/login'
|
||||
}
|
||||
} else {
|
||||
// 防循环检测:超过最大重定向次数时停止跳转
|
||||
const currentCount = parseInt(localStorage.getItem(OAUTH_REDIRECT_COUNT_KEY) || '0', 10)
|
||||
if (currentCount >= OAUTH_MAX_REDIRECT_COUNT) {
|
||||
console.error('[API] OAuth2 重定向次数超限,疑似无限循环,停止重定向')
|
||||
showToast('登录状态异常,请刷新页面重试')
|
||||
return
|
||||
}
|
||||
|
||||
console.warn(`[API] OAuth2 重定向计数: ${currentCount}/${OAUTH_MAX_REDIRECT_COUNT}`)
|
||||
|
||||
// Bug #2 修复:通过回调注册中心触发 store 的 handleUnauthorized,
|
||||
// 替代原来的 dynamic import('@/stores/employee'),消除循环依赖风险
|
||||
const triggered = await triggerAuthExpired()
|
||||
if (!triggered) {
|
||||
console.warn('[API] 认证过期处理器未注册,降级为刷新页面')
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[API] 401 处理失败,刷新页面:', e)
|
||||
window.location.reload()
|
||||
} finally {
|
||||
// Bug #3 修复:处理完成后清除锁,允许未来的 401 重新触发
|
||||
_authExpiredPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return _authExpiredPromise
|
||||
}
|
||||
|
||||
// 导出 Axios 实例
|
||||
export default apiClient
|
||||
@@ -0,0 +1,150 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端消息 API
|
||||
// =============================================================================
|
||||
// 说明:封装消息相关的 API 调用
|
||||
// 包括:消息撤回、删除、标记已读、图片上传、文件上传
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import type { Message } from './conversation'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 消息列表响应 */
|
||||
export interface MessageListData {
|
||||
items: Message[]
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 撤回消息(2分钟内)
|
||||
*
|
||||
* @param messageId - 消息ID
|
||||
* @returns 撤回结果
|
||||
*/
|
||||
export async function recallMessage(messageId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/messages/${messageId}/recall`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*
|
||||
* @param messageId - 消息ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export async function deleteMessage(messageId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.delete(
|
||||
`/messages/${messageId}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记会话已读
|
||||
*
|
||||
* @param conversationId - 会话ID
|
||||
* @returns 标记结果
|
||||
*/
|
||||
export async function markConversationRead(conversationId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/conversations/${conversationId}/mark-read`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询新消息(H5用户端)
|
||||
*
|
||||
* @param afterMessageId - 上次轮询的最后一消息ID
|
||||
* @returns 新消息列表
|
||||
*/
|
||||
export async function pollMessages(afterMessageId?: string): Promise<Message[]> {
|
||||
const params: Record<string, string> = {}
|
||||
if (afterMessageId) {
|
||||
params.after_message_id = afterMessageId
|
||||
}
|
||||
const response: AxiosResponse = await apiClient.get(
|
||||
'/h5/conversations/current/messages/poll',
|
||||
{ params }
|
||||
)
|
||||
const data = response.data.data
|
||||
const items = data?.items || []
|
||||
// 映射后端字段到前端字段
|
||||
return items.map((item: any) => ({
|
||||
message_id: item.id || item.message_id || '',
|
||||
conversation_id: item.conversation_id || '',
|
||||
message_type: item.sender_type || 'text',
|
||||
msg_type: item.msg_type,
|
||||
content: item.content || '',
|
||||
sender_name: item.sender_name || '',
|
||||
created_at: item.created_at || '',
|
||||
media_url: item.media_url,
|
||||
file_name: item.file_name,
|
||||
file_size: item.file_size,
|
||||
extra_data: item.extra_data,
|
||||
reply_to_id: item.reply_to_id,
|
||||
status: item.status,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片
|
||||
*
|
||||
* @param file - 图片文件
|
||||
* @returns 上传结果(包含 url, filename, file_size)
|
||||
*/
|
||||
export async function uploadImage(file: File): Promise<{
|
||||
url: string
|
||||
filename: string
|
||||
file_size: number
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
'/messages/image',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*
|
||||
* @param file - 文件
|
||||
* @returns 上传结果(包含 url, filename, file_size)
|
||||
*/
|
||||
export async function uploadMessageFile(file: File): Promise<{
|
||||
url: string
|
||||
filename: string
|
||||
file_size: number
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
'/messages/file',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data.data
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端排查模板类型定义
|
||||
// =============================================================================
|
||||
// 说明:与坐席端 troubleshooting.ts 共享的类型定义
|
||||
// H5 端暂不直接调用排查模板 API(数据通过 WebSocket 从坐席端推送),
|
||||
// 但需要 FlowchartNode 等类型来渲染交互式排查流程
|
||||
// =============================================================================
|
||||
|
||||
/** 排查步骤路径节点 */
|
||||
export interface PathStep {
|
||||
/** 步骤标题 */
|
||||
label: string
|
||||
/** 步骤状态: done / current / pending */
|
||||
status: 'done' | 'current' | 'pending'
|
||||
}
|
||||
|
||||
/** 决策树递归节点 */
|
||||
export interface FlowchartNode {
|
||||
/** 节点唯一标识 */
|
||||
id: string
|
||||
/** 节点类型: step(步骤/操作)/ decision(判断/问答) */
|
||||
type: 'step' | 'decision'
|
||||
/** 节点标签文字 */
|
||||
label: string
|
||||
/** 节点状态: done / current / pending */
|
||||
status?: 'done' | 'current' | 'pending'
|
||||
/** 子节点列表(step 类型) */
|
||||
children?: FlowchartNode[]
|
||||
/** "是" 分支(decision 类型) */
|
||||
yes_branch?: FlowchartNode
|
||||
/** "否" 分支(decision 类型) */
|
||||
no_branch?: FlowchartNode
|
||||
}
|
||||
|
||||
/** 排查模板摘要(WebSocket 推送时使用) */
|
||||
export interface TroubleshootingTemplateSummary {
|
||||
/** 模板唯一标识 */
|
||||
id: string
|
||||
/** 模板名称 */
|
||||
name: string
|
||||
/** 分类 */
|
||||
category: string
|
||||
/** 排障步骤路径 */
|
||||
path_steps: PathStep[]
|
||||
/** 流程图定义 */
|
||||
flowchart: FlowchartNode
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端文件上传 API
|
||||
// =============================================================================
|
||||
// 说明:封装文件/图片上传接口
|
||||
// 1. 上传文件到后端 /api/upload(multipart/form-data)
|
||||
// 2. 返回文件 URL、文件名、文件大小等信息
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from '@/api'
|
||||
|
||||
/** 上传响应数据 */
|
||||
export interface UploadResponse {
|
||||
/** 文件访问 URL(相对路径,如 /media/2026/06/10/xxx.png) */
|
||||
url: string
|
||||
/** 服务器存储的文件名 */
|
||||
filename: string
|
||||
/** 文件大小(字节) */
|
||||
file_size: number
|
||||
/** 消息类型(image 或 file,根据扩展名判断) */
|
||||
msg_type: 'image' | 'file'
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到服务器
|
||||
*
|
||||
* 做什么:将文件/图片上传到后端 /api/upload 端点
|
||||
* 流程:
|
||||
* 1. 创建 FormData,将文件添加到 file 字段
|
||||
* 2. 如果传入的是 Blob(如粘贴的图片),自动生成文件名
|
||||
* 3. 发送 multipart/form-data 请求
|
||||
* 4. 返回上传结果(URL + 文件信息)
|
||||
*
|
||||
* @param file - 要上传的文件(File 或 Blob 对象)
|
||||
* @param blobNamePrefix - Blob 文件名前缀(默认 'paste',截图场景传 'screenshot')
|
||||
* @returns 上传响应(含文件 URL、文件名等)
|
||||
*/
|
||||
export async function uploadFile(file: File | Blob, blobNamePrefix: string = 'paste'): Promise<UploadResponse> {
|
||||
// 构建 FormData
|
||||
const formData = new FormData()
|
||||
|
||||
// 如果是 Blob 而非 File,需要生成文件名(File 自带 name 属性)
|
||||
if (file instanceof Blob && !(file instanceof File)) {
|
||||
// 粘贴的图片默认为 PNG 格式,使用传入的前缀区分来源
|
||||
const fileName = `${blobNamePrefix}_${Date.now()}.png`
|
||||
formData.append('file', file, fileName)
|
||||
} else {
|
||||
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,
|
||||
})
|
||||
|
||||
// 响应拦截器已确保 code === 0
|
||||
// response = {code:0, data: {url:"...",...}, message:"success"}(拦截器返回值)
|
||||
// response.data = 业务数据 {url:"...", filename:"...", ...}
|
||||
return response.data as UploadResponse
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端 AI 助手面板容器
|
||||
// =============================================================================
|
||||
// 说明:AI 助手面板的容器组件,使用 Vant4 Tab 组件
|
||||
// 4个Tab:相似问题 | 审批流程 | 软件下载 | 搜索
|
||||
// 右上角展开/收起按钮(移动端)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="ai-helper-panel">
|
||||
<!-- 面板头部:标题 + 收起按钮 -->
|
||||
<div class="ai-helper-panel__header">
|
||||
<span class="ai-helper-panel__title">AI 助手</span>
|
||||
<!-- 移动端收起按钮 -->
|
||||
<van-icon
|
||||
name="cross"
|
||||
size="18"
|
||||
color="var(--text-tertiary)"
|
||||
class="ai-helper-panel__close"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换区域 -->
|
||||
<van-tabs v-model:active="activeTab" sticky animated swipeable>
|
||||
<!-- Tab1: 相似问题(暂未实现,显示占位符) -->
|
||||
<van-tab title="相似问题">
|
||||
<ComingSoon title="相似问题与做法" />
|
||||
</van-tab>
|
||||
|
||||
<!-- Tab2: 审批流程 -->
|
||||
<van-tab title="审批流程">
|
||||
<ApprovalLinks />
|
||||
</van-tab>
|
||||
|
||||
<!-- Tab3: 软件下载 -->
|
||||
<van-tab title="软件下载">
|
||||
<SoftwareDownloads />
|
||||
</van-tab>
|
||||
|
||||
<!-- Tab4: 搜索(暂未实现,显示占位符) -->
|
||||
<van-tab title="搜索">
|
||||
<ComingSoon title="知识库搜索" />
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AiHelperPanel AI 助手面板容器
|
||||
* 4个Tab模块:
|
||||
* - 相似问题:占位符"即将上线"
|
||||
* - 审批流程:真实功能(从 API 获取数据)
|
||||
* - 软件下载:真实功能(从 API 获取数据)
|
||||
* - 搜索:占位符"即将上线"
|
||||
* 移动端可通过右上角按钮收起面板
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import ComingSoon from './ComingSoon.vue'
|
||||
import ApprovalLinks from './ApprovalLinks.vue'
|
||||
import SoftwareDownloads from './SoftwareDownloads.vue'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 当前激活的 Tab 索引(0=相似问题, 1=审批流程, 2=软件下载, 3=搜索) */
|
||||
const activeTab = ref<number>(1) // 默认显示审批流程
|
||||
|
||||
/**
|
||||
* 收起面板(移动端使用)
|
||||
* 通知 store 切换面板可见性
|
||||
*/
|
||||
function handleClose(): void {
|
||||
store.toggleAssistantPanel()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* AI 助手面板容器 */
|
||||
.ai-helper-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 面板头部 */
|
||||
.ai-helper-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 面板标题 */
|
||||
.ai-helper-panel__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 收起按钮 */
|
||||
.ai-helper-panel__close {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Tab 区域占满剩余空间 */
|
||||
.ai-helper-panel :deep(.van-tabs) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-helper-panel :deep(.van-tabs__content) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-helper-panel :deep(.van-tab__panel) {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端审批流程链接组件
|
||||
// =============================================================================
|
||||
// 说明:展示所有审批流程链接,按分类分组
|
||||
// 使用 Vant4 CellGroup + Cell 组件
|
||||
// 点击链接在企微内置浏览器中打开
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="approval-links">
|
||||
<!-- 加载中提示 -->
|
||||
<div v-if="loading" class="approval-links__loading">
|
||||
<van-loading size="24px" vertical>加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-else-if="Object.keys(groupedLinks).length === 0" class="approval-links__empty">
|
||||
<van-empty description="暂无审批流程" image="search" />
|
||||
</div>
|
||||
|
||||
<!-- 按分类展示审批链接 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(links, category) in groupedLinks"
|
||||
:key="category"
|
||||
class="approval-links__group"
|
||||
>
|
||||
<!-- 分类标题 -->
|
||||
<div class="approval-links__category">{{ category }}</div>
|
||||
<!-- 该分类下的链接列表 -->
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
:title="link.title"
|
||||
:icon="link.icon || 'link-o'"
|
||||
is-link
|
||||
@click="openLink(link.url)"
|
||||
>
|
||||
<!-- 右侧箭头 -->
|
||||
<template #right-icon>
|
||||
<van-icon name="arrow" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ApprovalLinks 审批流程链接组件
|
||||
* 从 API 获取审批流程数据,按分类分组展示
|
||||
* 点击链接在企微内置浏览器中打开
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 加载状态:当审批链接列表为空且未初始化时视为加载中 */
|
||||
const loading = computed(() => {
|
||||
return store.approvalLinks.length === 0 && !store.initialized
|
||||
})
|
||||
|
||||
/** 审批链接按分类分组(从 store 计算属性获取) */
|
||||
const groupedLinks = computed(() => store.approvalLinksByCategory)
|
||||
|
||||
/**
|
||||
* 打开审批流程链接
|
||||
* 在企微内置浏览器中打开目标链接
|
||||
* @param url 审批流程链接地址
|
||||
*/
|
||||
function openLink(url: string): void {
|
||||
// 在企微 WebView 中直接使用 window.open 即可在内置浏览器中打开
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 审批链接容器 */
|
||||
.approval-links {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 加载中状态 */
|
||||
.approval-links__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 空数据状态 */
|
||||
.approval-links__empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 分类分组容器 */
|
||||
.approval-links__group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 分类标题 */
|
||||
.approval-links__category {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 16px 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端"即将上线"占位组件
|
||||
// =============================================================================
|
||||
// 说明:通用占位组件,用于尚未实现的功能模块
|
||||
// 接收 title prop,显示灰色图标 + "{title} · 即将上线"
|
||||
// 用于"相似问题与做法"和"知识库搜索"两个模块
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="coming-soon">
|
||||
<!-- 灰色占位图标 -->
|
||||
<div class="coming-soon__icon">
|
||||
<van-icon name="clock-o" size="48" color="var(--text-placeholder)" />
|
||||
</div>
|
||||
<!-- 模块名称 + 即将上线提示 -->
|
||||
<p class="coming-soon__text">{{ title }} · 即将上线</p>
|
||||
<p class="coming-soon__subtext">功能开发中,敬请期待</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ComingSoon 占位组件
|
||||
* @prop title - 模块名称(如"相似问题与做法"、"知识库搜索")
|
||||
*/
|
||||
|
||||
defineProps<{
|
||||
/** 模块名称,显示在"即将上线"前 */
|
||||
title: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 占位容器:居中布局 */
|
||||
.coming-soon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 灰色图标 */
|
||||
.coming-soon__icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 主提示文字:模块名 + 即将上线 */
|
||||
.coming-soon__text {
|
||||
font-size: 15px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 副提示文字 */
|
||||
.coming-soon__subtext {
|
||||
font-size: 12px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,629 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端右侧面板
|
||||
// =============================================================================
|
||||
// 说明:桌面端右侧面板,三段式布局:
|
||||
// 1. 上方:AI推送区(根据排查步骤和会话内容动态推送)
|
||||
// 2. 中部:固定常用资源标签页(资源申请流程入口、常用必装软件)
|
||||
// 3. 下方:趣味问答(答对可提高用户积分和等级)
|
||||
// 注意:此面板仅在桌面端(≥500px)显示,手机端隐藏
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="right-panel">
|
||||
<!-- ====== 上方:AI推送区 ====== -->
|
||||
<div class="right-panel__section right-panel__ai-push">
|
||||
<div class="right-panel__section-header">
|
||||
<span class="right-panel__section-icon">🤖</span>
|
||||
<span class="right-panel__section-title">AI 推荐</span>
|
||||
</div>
|
||||
<div class="right-panel__section-body">
|
||||
<!-- 推荐卡片列表(根据排查步骤和会话内容动态推送) -->
|
||||
<div
|
||||
v-for="item in aiPushItems"
|
||||
:key="item.id"
|
||||
class="ai-push-card"
|
||||
:class="`ai-push-card--${item.type}`"
|
||||
@click="handlePushClick(item)"
|
||||
>
|
||||
<div class="ai-push-card__header">
|
||||
<span class="ai-push-card__icon">{{ item.icon }}</span>
|
||||
<span class="ai-push-card__type-label">{{ item.typeLabel }}</span>
|
||||
</div>
|
||||
<div class="ai-push-card__title">{{ item.title }}</div>
|
||||
<div v-if="item.subtitle" class="ai-push-card__subtitle">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
<!-- 暂无推荐 -->
|
||||
<div v-if="aiPushItems.length === 0" class="right-panel__empty">
|
||||
<span>💡 对话过程中会自动推送相关资源</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 中部:常用资源标签页 ====== -->
|
||||
<div class="right-panel__section right-panel__resources">
|
||||
<div class="right-panel__section-header">
|
||||
<span class="right-panel__section-icon">📚</span>
|
||||
<span class="right-panel__section-title">常用资源</span>
|
||||
</div>
|
||||
<!-- 标签切换 -->
|
||||
<div class="right-panel__tabs">
|
||||
<button
|
||||
class="right-panel__tab"
|
||||
:class="{ 'right-panel__tab--active': activeResourceTab === 'process' }"
|
||||
@click="activeResourceTab = 'process'"
|
||||
>申请流程</button>
|
||||
<button
|
||||
class="right-panel__tab"
|
||||
:class="{ 'right-panel__tab--active': activeResourceTab === 'software' }"
|
||||
@click="activeResourceTab = 'software'"
|
||||
>必装软件</button>
|
||||
</div>
|
||||
<!-- 申请流程标签页内容 -->
|
||||
<div v-if="activeResourceTab === 'process'" class="right-panel__tab-content">
|
||||
<div
|
||||
v-for="item in processItems"
|
||||
:key="item.id"
|
||||
class="resource-item"
|
||||
@click="handleProcessClick(item)"
|
||||
>
|
||||
<span class="resource-item__icon">{{ item.icon }}</span>
|
||||
<div class="resource-item__info">
|
||||
<span class="resource-item__title">{{ item.title }}</span>
|
||||
<span v-if="item.desc" class="resource-item__desc">{{ item.desc }}</span>
|
||||
</div>
|
||||
<span class="resource-item__arrow">→</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 必装软件标签页内容 -->
|
||||
<div v-if="activeResourceTab === 'software'" class="right-panel__tab-content">
|
||||
<div
|
||||
v-for="item in softwareItems"
|
||||
:key="item.id"
|
||||
class="resource-item"
|
||||
@click="handleSoftwareClick(item)"
|
||||
>
|
||||
<span class="resource-item__icon">{{ item.icon }}</span>
|
||||
<div class="resource-item__info">
|
||||
<span class="resource-item__title">{{ item.title }}</span>
|
||||
<span v-if="item.desc" class="resource-item__desc">{{ item.desc }}</span>
|
||||
</div>
|
||||
<span class="resource-item__arrow">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 下方:趣味问答 ====== -->
|
||||
<div class="right-panel__section right-panel__quiz">
|
||||
<div class="right-panel__section-header">
|
||||
<span class="right-panel__section-icon">🎯</span>
|
||||
<span class="right-panel__section-title">趣味问答</span>
|
||||
<span class="right-panel__quiz-score">🏆 {{ userScore }}分</span>
|
||||
</div>
|
||||
<div class="right-panel__section-body">
|
||||
<template v-if="currentQuiz">
|
||||
<div class="quiz-question">{{ currentQuiz.question }}</div>
|
||||
<div class="quiz-options">
|
||||
<button
|
||||
v-for="(option, idx) in currentQuiz.options"
|
||||
:key="idx"
|
||||
class="quiz-option"
|
||||
:class="{
|
||||
'quiz-option--correct': quizAnswered && idx === currentQuiz.correctIndex,
|
||||
'quiz-option--wrong': quizAnswered && quizSelectedIndex === idx && idx !== currentQuiz.correctIndex
|
||||
}"
|
||||
:disabled="quizAnswered"
|
||||
@click="handleQuizAnswer(idx)"
|
||||
>
|
||||
<span class="quiz-option__label">{{ optionLabels[idx] }}</span>
|
||||
<span class="quiz-option__text">{{ option }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 答题结果 -->
|
||||
<div v-if="quizAnswered" class="quiz-result">
|
||||
<span v-if="quizSelectedIndex === currentQuiz.correctIndex" class="quiz-result--correct">
|
||||
✅ 答对啦!+10积分
|
||||
</span>
|
||||
<span v-else class="quiz-result--wrong">
|
||||
❌ 答错了!正确答案是 {{ optionLabels[currentQuiz.correctIndex] }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="right-panel__empty">
|
||||
<span>暂无问答题目</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* RightPanel 右侧面板组件
|
||||
* 三段式布局:AI推送区 / 常用资源标签页 / 趣味问答
|
||||
* 仅在桌面端(≥500px)显示
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
// 阶段二接入 Dify 动态推送时启用:
|
||||
// import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
// const store = useConversationStore() // 阶段二接入 Dify 动态推送时启用
|
||||
|
||||
// ── AI推送区 ──
|
||||
|
||||
/** AI推送条目类型 */
|
||||
interface AiPushItem {
|
||||
id: string
|
||||
type: 'guide' | 'process' | 'download' // 处理指南/申请流程/软件下载
|
||||
icon: string
|
||||
typeLabel: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
/** AI推送数据(阶段一使用静态数据,阶段二接入Dify动态推送) */
|
||||
const aiPushItems = computed<AiPushItem[]>(() => {
|
||||
// TODO: 阶段二根据排查步骤和会话内容动态生成推送
|
||||
// 当前使用示例数据
|
||||
return [
|
||||
{
|
||||
id: 'push-1',
|
||||
type: 'guide',
|
||||
icon: '📖',
|
||||
typeLabel: '处理指南',
|
||||
title: 'WiFi连接问题处理指南',
|
||||
subtitle: '已解决28次类似问题',
|
||||
},
|
||||
{
|
||||
id: 'push-2',
|
||||
type: 'process',
|
||||
icon: '📋',
|
||||
typeLabel: '申请流程',
|
||||
title: '网络连接申请流程',
|
||||
subtitle: '在线申请,1-3个工作日',
|
||||
},
|
||||
{
|
||||
id: 'push-3',
|
||||
type: 'download',
|
||||
icon: '💾',
|
||||
typeLabel: '软件下载',
|
||||
title: '无线网卡驱动下载',
|
||||
subtitle: '适用于 Windows 10/11',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 点击AI推送卡片
|
||||
*/
|
||||
function handlePushClick(item: AiPushItem): void {
|
||||
// TODO: 阶段二实现推送跳转
|
||||
console.log('[RightPanel] AI推送点击:', item.title)
|
||||
}
|
||||
|
||||
// ── 常用资源标签页 ──
|
||||
|
||||
/** 当前激活的资源标签页 */
|
||||
const activeResourceTab = ref<'process' | 'software'>('process')
|
||||
|
||||
/** 申请流程列表 */
|
||||
const processItems = ref([
|
||||
{ id: 'p-1', icon: '💻', title: 'IT设备申请', desc: '电脑/显示器/外设' },
|
||||
{ id: 'p-2', icon: '🔐', title: '权限申请', desc: '系统/文件夹/VPN' },
|
||||
{ id: 'p-3', icon: '🌐', title: 'VPN申请', desc: '远程办公网络' },
|
||||
{ id: 'p-4', icon: '📧', title: '邮箱别名申请', desc: '别名/分发组' },
|
||||
])
|
||||
|
||||
/** 必装软件列表 */
|
||||
const softwareItems = ref([
|
||||
{ id: 's-1', icon: '📝', title: 'Office 365', desc: 'Word/Excel/PPT' },
|
||||
{ id: 's-2', icon: '📄', title: 'Adobe Acrobat', desc: 'PDF阅读/编辑' },
|
||||
{ id: 's-3', icon: '💬', title: '企业微信', desc: '即时通讯/协作' },
|
||||
{ id: 's-4', icon: '🛡️', title: '火绒安全', desc: '杀毒/终端防护' },
|
||||
])
|
||||
|
||||
/**
|
||||
* 点击申请流程项
|
||||
*/
|
||||
function handleProcessClick(item: { id: string; title: string }): void {
|
||||
// TODO: 阶段二实现流程跳转
|
||||
console.log('[RightPanel] 申请流程点击:', item.title)
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击软件下载项
|
||||
*/
|
||||
function handleSoftwareClick(item: { id: string; title: string }): void {
|
||||
// TODO: 阶段二实现软件下载
|
||||
console.log('[RightPanel] 软件下载点击:', item.title)
|
||||
}
|
||||
|
||||
// ── 趣味问答 ──
|
||||
|
||||
/** 问答题目类型 */
|
||||
interface QuizQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[]
|
||||
correctIndex: number // 正确答案的索引
|
||||
}
|
||||
|
||||
/** 选项标签 */
|
||||
const optionLabels = ['A', 'B', 'C', 'D']
|
||||
|
||||
/** 用户积分 */
|
||||
const userScore = ref(0)
|
||||
|
||||
/** 是否已答题 */
|
||||
const quizAnswered = ref(false)
|
||||
|
||||
/** 用户选择的答案索引 */
|
||||
const quizSelectedIndex = ref(-1)
|
||||
|
||||
/** 问答题目列表(阶段一使用静态数据) */
|
||||
const quizQuestions = ref<QuizQuestion[]>([
|
||||
{
|
||||
id: 'q-1',
|
||||
question: 'IT服务台电话分机号是?',
|
||||
options: ['8001', '8002', '8003', '8004'],
|
||||
correctIndex: 0,
|
||||
},
|
||||
{
|
||||
id: 'q-2',
|
||||
question: '电脑无法连接WiFi时,首先应该检查什么?',
|
||||
options: ['重启路由器', 'WiFi适配器是否禁用', '联系网络管理员', '重新安装系统'],
|
||||
correctIndex: 1,
|
||||
},
|
||||
{
|
||||
id: 'q-3',
|
||||
question: 'VPN申请一般需要几个工作日?',
|
||||
options: ['1个工作日', '1-3个工作日', '3-5个工作日', '5个工作日以上'],
|
||||
correctIndex: 1,
|
||||
},
|
||||
])
|
||||
|
||||
/** 当前问答题目 */
|
||||
const currentQuiz = computed<QuizQuestion | null>(() => {
|
||||
return quizQuestions.value[0] || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 用户回答问答
|
||||
* @param index - 用户选择的选项索引
|
||||
*/
|
||||
function handleQuizAnswer(index: number): void {
|
||||
if (quizAnswered.value) return
|
||||
quizAnswered.value = true
|
||||
quizSelectedIndex.value = index
|
||||
|
||||
// 答对加10分
|
||||
if (currentQuiz.value && index === currentQuiz.value.correctIndex) {
|
||||
userScore.value += 10
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ====== 右侧面板容器 ====== */
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ====== 面板区域通用样式 ====== */
|
||||
.right-panel__section {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.right-panel__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.right-panel__section-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.right-panel__section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.right-panel__section-body {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.right-panel__empty {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
/* ====== AI推送区 ====== */
|
||||
.right-panel__ai-push {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-push-card {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ai-push-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-push-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.ai-push-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.ai-push-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-push-card__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ai-push-card__type-label {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 处理指南类型 */
|
||||
.ai-push-card--guide .ai-push-card__type-label {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* 申请流程类型 */
|
||||
.ai-push-card--process .ai-push-card__type-label {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 软件下载类型 */
|
||||
.ai-push-card--download .ai-push-card__type-label {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.ai-push-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ai-push-card__subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ====== 常用资源标签页 ====== */
|
||||
.right-panel__resources {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.right-panel__tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.right-panel__tab {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.right-panel__tab--active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.right-panel__tab:hover:not(.right-panel__tab--active) {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.right-panel__tab-content {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.resource-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.resource-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.resource-item:active {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.resource-item__icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.resource-item__desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.resource-item__arrow {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ====== 趣味问答 ====== */
|
||||
.right-panel__quiz {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.right-panel__quiz-score {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiz-question {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.quiz-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quiz-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quiz-option:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.quiz-option:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.quiz-option:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 正确选项 */
|
||||
.quiz-option--correct {
|
||||
border-color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
/* 错误选项 */
|
||||
.quiz-option--wrong {
|
||||
border-color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.quiz-option__label {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quiz-option--correct .quiz-option__label {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quiz-option--wrong .quiz-option__label {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quiz-option__text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quiz-result {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quiz-result--correct {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.quiz-result--wrong {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端软件下载入口组件
|
||||
// =============================================================================
|
||||
// 说明:展示所有可下载的软件,按分类分组
|
||||
// 显示名称 + 版本号 + 平台标签
|
||||
// 点击下载链接直接下载
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="software-downloads">
|
||||
<!-- 加载中提示 -->
|
||||
<div v-if="loading" class="software-downloads__loading">
|
||||
<van-loading size="24px" vertical>加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-else-if="Object.keys(groupedDownloads).length === 0" class="software-downloads__empty">
|
||||
<van-empty description="暂无软件下载" image="search" />
|
||||
</div>
|
||||
|
||||
<!-- 按分类展示软件下载列表 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(items, category) in groupedDownloads"
|
||||
:key="category"
|
||||
class="software-downloads__group"
|
||||
>
|
||||
<!-- 分类标题 -->
|
||||
<div class="software-downloads__category">{{ category }}</div>
|
||||
<!-- 该分类下的软件列表 -->
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:label="versionLabel(item)"
|
||||
:icon="item.icon || 'down'"
|
||||
is-link
|
||||
@click="handleDownload(item)"
|
||||
>
|
||||
<!-- 右侧平台标签 -->
|
||||
<template #right-icon>
|
||||
<div class="software-downloads__platforms">
|
||||
<span
|
||||
v-for="platform in item.platforms"
|
||||
:key="platform"
|
||||
class="software-downloads__tag"
|
||||
>
|
||||
{{ platform }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SoftwareDownloads 软件下载入口组件
|
||||
* 从 API 获取软件下载数据,按分类分组展示
|
||||
* 显示名称、版本号、平台标签,点击下载
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import type { SoftwareDownload } from '@/api/conversation'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 加载状态:当软件下载列表为空且未初始化时视为加载中 */
|
||||
const loading = computed(() => {
|
||||
return store.softwareDownloads.length === 0 && !store.initialized
|
||||
})
|
||||
|
||||
/** 软件下载按分类分组(从 store 计算属性获取) */
|
||||
const groupedDownloads = computed(() => store.softwareDownloadsByCategory)
|
||||
|
||||
/**
|
||||
* 生成版本号标签文字
|
||||
* @param item 软件下载项
|
||||
* @returns 版本号文字(如 "v2.1.0")
|
||||
*/
|
||||
function versionLabel(item: SoftwareDownload): string {
|
||||
return item.version ? `版本: ${item.version}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理下载点击
|
||||
* 在企微内置浏览器中打开下载链接
|
||||
* @param item 软件下载项
|
||||
*/
|
||||
function handleDownload(item: SoftwareDownload): void {
|
||||
window.open(item.download_url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 软件下载容器 */
|
||||
.software-downloads {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 加载中状态 */
|
||||
.software-downloads__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 空数据状态 */
|
||||
.software-downloads__empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 分类分组容器 */
|
||||
.software-downloads__group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 分类标题 */
|
||||
.software-downloads__category {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 16px 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 平台标签容器 */
|
||||
.software-downloads__platforms {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 单个平台标签 */
|
||||
.software-downloads__tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,732 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — H5用户端「呼叫坐席」动画弹窗
|
||||
=============================================================================
|
||||
流程(简化版):
|
||||
1. 按钮出现后点击 → 弹出此弹窗
|
||||
2. 立即随机播放趣味动画 + 话术,同时发 shake 请求
|
||||
3. 发送成功 → 显示成功提示,3秒后自动关闭
|
||||
|
||||
注意:问题描述已在聊天中完成(AI 实质性回复 >= 3 轮),弹窗不需要再录入。
|
||||
|
||||
七种动画场景(随机选一种):
|
||||
1. 🙋 招手 — "看这里!看我这里...我有个问题!"
|
||||
2. 🪑 拍桌子 — "快快快!我等不及了!"
|
||||
3. 💀 劈稻草人 — "这个问题不解决我就要原地爆炸了💥"
|
||||
4. 🍉 砍西瓜 — "IT!救我!这个问题卡住了🍉"
|
||||
5. 🔔 摇铃铛 — "叮叮叮!有人吗!IT 在线吗!"
|
||||
6. 💣 大炮发射 — "开炮!这个问题必须解决了!"
|
||||
7. 🚀 导弹发射 — "发射!紧急呼叫 IT 特种部队!"
|
||||
============================================================================= -->
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="visible" class="call-modal__overlay" @click.self="handleClose">
|
||||
<Transition name="modal-zoom" appear>
|
||||
<div class="call-modal call-modal--compact" v-if="visible">
|
||||
|
||||
<!-- ========== 动画场景 + 话术 ========== -->
|
||||
<div class="call-modal__step">
|
||||
<div class="call-modal__header">
|
||||
<span class="call-modal__icon">🔔</span>
|
||||
<h3>摇传菜铃呼叫人工坐席...</h3>
|
||||
</div>
|
||||
<div class="call-modal__body call-modal__body--center">
|
||||
|
||||
<!-- 场景SVG动画区域(根据 selectedScene 渲染对应场景) -->
|
||||
|
||||
<!-- 场景1:招手 🙋 -->
|
||||
<div v-if="selectedScene === 1" class="scene scene--hand">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#E8EAF6"/>
|
||||
<rect x="45" y="75" width="110" height="65" rx="10" fill="#ECEFF1"/>
|
||||
<rect x="50" y="80" width="100" height="55" rx="8" fill="#CFD8DC"/>
|
||||
<rect x="55" y="85" width="40" height="25" rx="3" fill="#90CAF9"/>
|
||||
<rect x="100" y="85" width="40" height="25" rx="3" fill="#90CAF9"/>
|
||||
<circle cx="135" cy="70" r="16" fill="#F5C6A0"/>
|
||||
<circle cx="130" cy="67" r="2" fill="#333"/>
|
||||
<circle cx="140" cy="67" r="2" fill="#333"/>
|
||||
<path d="M130 75 Q135 79 140 75" stroke="#333" stroke-width="1.5" fill="none"/>
|
||||
<rect x="12" y="72" width="30" height="50" rx="8" fill="#F5C6A0"/>
|
||||
<g class="hand-arm">
|
||||
<line x1="42" y1="80" x2="55" y2="55" stroke="#F5C6A0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="55" y="36" width="100" height="24" rx="12" fill="#FF9800"/>
|
||||
<text x="105" y="52" text-anchor="middle" fill="white" font-size="11" font-weight="bold">看这里!🙋</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景2:拍桌子 🪑 -->
|
||||
<div v-else-if="selectedScene === 2" class="scene scene--pound">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#D7CCC8"/>
|
||||
<rect x="30" y="105" width="140" height="35" rx="6" fill="#8D6E63"/>
|
||||
<rect x="35" y="110" width="130" height="25" rx="4" fill="#A1887F"/>
|
||||
<rect x="20" y="75" width="35" height="50" rx="8" fill="#795548"/>
|
||||
<circle cx="37" cy="60" r="16" fill="#F5C6A0"/>
|
||||
<g class="pound-fists">
|
||||
<circle cx="75" cy="98" r="9" fill="#F5C6A0"/>
|
||||
<circle cx="105" cy="98" r="9" fill="#F5C6A0"/>
|
||||
</g>
|
||||
<g class="splash-lines">
|
||||
<line x1="68" y1="90" x2="62" y2="82" stroke="#FF5722" stroke-width="2"/>
|
||||
<line x1="80" y1="88" x2="80" y2="78" stroke="#FF5722" stroke-width="2"/>
|
||||
<line x1="90" y1="88" x2="95" y2="78" stroke="#FF5722" stroke-width="2"/>
|
||||
<line x1="110" y1="90" x2="110" y2="80" stroke="#FF5722" stroke-width="2"/>
|
||||
<line x1="120" y1="90" x2="126" y2="82" stroke="#FF5722" stroke-width="2"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="90" y="36" width="100" height="24" rx="12" fill="#FF5722"/>
|
||||
<text x="140" y="52" text-anchor="middle" fill="white" font-size="11" font-weight="bold">快快快!🪑</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景3-7略(同原版,保留全部7个场景的SVG) -->
|
||||
<!-- 场景3:劈稻草人 💀 -->
|
||||
<div v-else-if="selectedScene === 3" class="scene scene--scarecrow">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#C8E6C9"/>
|
||||
<rect x="145" y="100" width="6" height="40" fill="#795548"/>
|
||||
<rect x="133" y="95" width="30" height="5" rx="2" fill="#795548"/>
|
||||
<circle cx="148" cy="85" r="14" fill="#FFE082"/>
|
||||
<line x1="148" y1="74" x2="148" y2="70" stroke="#333" stroke-width="1.5"/>
|
||||
<rect x="25" y="72" width="30" height="50" rx="8" fill="#1565C0"/>
|
||||
<circle cx="40" cy="57" r="16" fill="#F5C6A0"/>
|
||||
<g class="slash-blade">
|
||||
<rect x="55" y="40" width="6" height="50" rx="2" fill="#B0BEC5"/>
|
||||
<rect x="52" y="38" width="12" height="8" rx="2" fill="#90A4AE"/>
|
||||
</g>
|
||||
<g class="explosion-effect">
|
||||
<circle cx="148" cy="85" r="10" fill="#FF5722" opacity="0"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="5" y="5" width="105" height="24" rx="12" fill="#D32F2F"/>
|
||||
<text x="57" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold">我要爆炸了💥</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景4:砍西瓜 🍉 -->
|
||||
<div v-else-if="selectedScene === 4" class="scene scene--watermelon">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#C8E6C9"/>
|
||||
<g class="melon">
|
||||
<ellipse cx="140" cy="125" rx="25" ry="18" fill="#4CAF50"/>
|
||||
<ellipse cx="140" cy="122" rx="18" ry="10" fill="#F44336"/>
|
||||
</g>
|
||||
<rect x="25" y="75" width="30" height="47" rx="8" fill="#1565C0"/>
|
||||
<circle cx="40" cy="60" r="16" fill="#F5C6A0"/>
|
||||
<g class="knife">
|
||||
<rect x="55" y="42" width="6" height="45" rx="2" fill="#B0BEC5"/>
|
||||
<rect x="52" y="40" width="12" height="8" rx="2" fill="#90A4AE"/>
|
||||
</g>
|
||||
<g class="juice-splash">
|
||||
<circle cx="130" cy="110" r="2" fill="#F44336" opacity="0"/>
|
||||
<circle cx="145" cy="105" r="2.5" fill="#F44336" opacity="0"/>
|
||||
<circle cx="155" cy="112" r="1.5" fill="#F44336" opacity="0"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="5" y="5" width="105" height="24" rx="12" fill="#43A047"/>
|
||||
<text x="57" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold">IT救我!🍉</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景5:摇传菜铃 🔔 -->
|
||||
<div v-else-if="selectedScene === 5" class="scene scene--bell">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#FFF3E0"/>
|
||||
<rect x="25" y="70" width="30" height="52" rx="8" fill="#FF9800"/>
|
||||
<circle cx="40" cy="55" r="16" fill="#F5C6A0"/>
|
||||
<g class="bell-left">
|
||||
<path d="M52 72 Q52 58 60 50 Q68 42 72 50 Q76 58 74 72" fill="#FFD54F" stroke="#FFA000" stroke-width="1.5"/>
|
||||
<ellipse cx="63" cy="72" rx="9" ry="3" fill="#FFA000"/>
|
||||
</g>
|
||||
<g class="bell-right">
|
||||
<path d="M92 72 Q92 58 100 50 Q108 42 112 50 Q116 58 114 72" fill="#FFD54F" stroke="#FFA000" stroke-width="1.5"/>
|
||||
<ellipse cx="103" cy="72" rx="9" ry="3" fill="#FFA000"/>
|
||||
</g>
|
||||
<g class="sound-waves">
|
||||
<circle cx="160" cy="55" r="10" fill="none" stroke="#FF9800" stroke-width="1.5" opacity="0"/>
|
||||
<circle cx="160" cy="55" r="16" fill="none" stroke="#FF9800" stroke-width="1.5" opacity="0"/>
|
||||
<circle cx="160" cy="55" r="22" fill="none" stroke="#FF9800" stroke-width="1" opacity="0"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="5" y="5" width="105" height="24" rx="12" fill="#FF9800"/>
|
||||
<text x="57" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold">叮叮叮!有人吗</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景6:大炮发射 💣 -->
|
||||
<div v-else-if="selectedScene === 6" class="scene scene--cannon">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="145" width="200" height="35" fill="#D7CCC8"/>
|
||||
<rect x="55" y="132" width="50" height="16" rx="3" fill="#5D4037"/>
|
||||
<circle cx="80" cy="138" r="9" fill="#4E342E"/>
|
||||
<g class="cannon-barrel">
|
||||
<rect x="58" y="110" width="55" height="14" rx="4" fill="#616161"/>
|
||||
<rect x="108" y="108" width="12" height="18" rx="3" fill="#757575"/>
|
||||
</g>
|
||||
<g class="fuse">
|
||||
<path d="M58 117 Q52 115 48 118 Q44 121 40 117" stroke="#FF9800" stroke-width="2" fill="none"/>
|
||||
<circle cx="38" cy="116" r="3" fill="#FF5722" class="fuse-spark"/>
|
||||
</g>
|
||||
<g class="cannonball">
|
||||
<circle cx="120" cy="105" r="7" fill="#37474F"/>
|
||||
</g>
|
||||
<g class="explosion">
|
||||
<circle cx="170" cy="60" r="18" fill="#FF5722" opacity="0" class="blast-main"/>
|
||||
<circle cx="170" cy="60" r="12" fill="#FFC107" opacity="0" class="blast-inner"/>
|
||||
<line x1="170" y1="40" x2="170" y2="30" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-1"/>
|
||||
<line x1="185" y1="48" x2="192" y2="42" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-2"/>
|
||||
<line x1="188" y1="60" x2="196" y2="60" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-3"/>
|
||||
<line x1="185" y1="72" x2="192" y2="78" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-4"/>
|
||||
<line x1="170" y1="80" x2="170" y2="90" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-5"/>
|
||||
<line x1="155" y1="72" x2="148" y2="78" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-6"/>
|
||||
<line x1="152" y1="60" x2="144" y2="60" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-7"/>
|
||||
<line x1="155" y1="48" x2="148" y2="42" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-8"/>
|
||||
</g>
|
||||
<g class="target">
|
||||
<circle cx="170" cy="55" r="14" fill="none" stroke="#F44336" stroke-width="2"/>
|
||||
<circle cx="170" cy="55" r="8" fill="none" stroke="#F44336" stroke-width="1.5"/>
|
||||
<circle cx="170" cy="55" r="2" fill="#F44336"/>
|
||||
<text x="170" y="38" text-anchor="middle" fill="#F44336" font-size="9" font-weight="bold">BUG</text>
|
||||
</g>
|
||||
<rect x="5" y="75" width="30" height="57" rx="7" fill="#795548"/>
|
||||
<circle cx="20" cy="60" r="16" fill="#F5C6A0"/>
|
||||
<circle cx="15" cy="57" r="2" fill="#333"/>
|
||||
<circle cx="25" cy="57" r="2" fill="#333"/>
|
||||
<path d="M14 66 Q20 70 26 66" stroke="#333" stroke-width="1.5" fill="none"/>
|
||||
<line x1="35" y1="85" x2="48" y2="100" stroke="#F5C6A0" stroke-width="7" stroke-linecap="round" class="gunner-arm"/>
|
||||
<g class="bubble-float">
|
||||
<rect x="5" y="5" width="95" height="24" rx="12" fill="#FF5722"/>
|
||||
<text x="52" y="21" text-anchor="middle" fill="white" font-size="11" font-weight="bold">开炮!💣</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景7:导弹发射 🚀 -->
|
||||
<div v-else-if="selectedScene === 7" class="scene scene--missile">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="0" width="200" height="140" fill="#E3F2FD"/>
|
||||
<rect x="0" y="140" width="200" height="40" fill="#BDBDBD"/>
|
||||
<rect x="85" y="130" width="30" height="12" rx="2" fill="#616161"/>
|
||||
<rect x="88" y="142" width="8" height="10" fill="#757575"/>
|
||||
<rect x="104" y="142" width="8" height="10" fill="#757575"/>
|
||||
<g class="missile-body">
|
||||
<rect x="94" y="50" width="12" height="65" rx="4" fill="#E0E0E0"/>
|
||||
<path d="M94 50 L100 35 L106 50 Z" fill="#F44336"/>
|
||||
<polygon points="94,108 86,115 94,112" fill="#F44336"/>
|
||||
<polygon points="106,108 114,115 106,112" fill="#F44336"/>
|
||||
</g>
|
||||
<g class="rocket-flame">
|
||||
<ellipse cx="100" cy="118" rx="4" ry="10" fill="#FF9800" class="flame-outer"/>
|
||||
<ellipse cx="100" cy="116" rx="2" ry="6" fill="#FFEB3B" class="flame-inner"/>
|
||||
</g>
|
||||
<g class="smoke">
|
||||
<circle cx="88" cy="145" r="8" fill="#CFD8DC" opacity="0" class="smoke-puff puff-1"/>
|
||||
<circle cx="112" cy="145" r="8" fill="#CFD8DC" opacity="0" class="smoke-puff puff-2"/>
|
||||
<circle cx="100" cy="155" r="10" fill="#CFD8DC" opacity="0" class="smoke-puff puff-3"/>
|
||||
</g>
|
||||
<g class="trail">
|
||||
<path d="M100 130 L100 145" stroke="#FFC107" stroke-width="2" opacity="0" class="trail-line"/>
|
||||
</g>
|
||||
<rect x="15" y="80" width="28" height="52" rx="7" fill="#1565C0"/>
|
||||
<circle cx="29" cy="65" r="16" fill="#F5C6A0"/>
|
||||
<rect x="18" y="58" width="22" height="8" rx="3" fill="#333" opacity="0.7"/>
|
||||
<circle cx="24" cy="62" r="1.5" fill="#FFF"/>
|
||||
<circle cx="34" cy="62" r="1.5" fill="#FFF"/>
|
||||
<ellipse cx="29" cy="72" rx="4" ry="3" fill="#333"/>
|
||||
<line x1="43" y1="90" x2="60" y2="100" stroke="#F5C6A0" stroke-width="7" stroke-linecap="round"/>
|
||||
<rect x="55" y="105" width="15" height="20" rx="3" fill="#424242"/>
|
||||
<circle cx="62.5" cy="112" r="3" fill="#F44336" class="launch-button"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 话术文字 -->
|
||||
<div class="call-modal__speech-text">
|
||||
<span class="call-modal__speech-emoji">{{ sceneEmoji }}</span>
|
||||
<span class="call-modal__speech-content">{{ sceneText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 发送状态 -->
|
||||
<div v-if="sending" class="call-modal__sending">
|
||||
<span class="call-modal__dot-flashing"></span>
|
||||
<span>正在通知 IT 坐席...</span>
|
||||
</div>
|
||||
<div v-if="sendSuccess" class="call-modal__success">
|
||||
✅ 呼叫成功!坐席马上就来~
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:关闭按钮 -->
|
||||
<div class="call-modal__footer call-modal__footer--center">
|
||||
<button
|
||||
v-if="sendSuccess"
|
||||
class="call-modal__btn call-modal__btn--primary call-modal__btn--large"
|
||||
@click="handleClose"
|
||||
>好的,返回聊天</button>
|
||||
<button
|
||||
v-else
|
||||
class="call-modal__btn call-modal__btn--cancel"
|
||||
@click="handleClose"
|
||||
>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* CallAgentModal — 「呼叫坐席」动画弹窗(简化版)
|
||||
*
|
||||
* 流程:打开 → 随机播放趣味动画 + 发 shake 请求 → 关闭
|
||||
* 前提:用户已在聊天中描述问题,AI 已回复 >= 3 轮
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'call-success'): void
|
||||
}>()
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
// ── 状态 ──
|
||||
const selectedScene = ref<number>(1)
|
||||
const sending = ref<boolean>(false)
|
||||
const sendSuccess = ref<boolean>(false)
|
||||
|
||||
// ── 场景配置 ──
|
||||
const scenes = [
|
||||
{ id: 1, emoji: '🙋', text: '看这里!看我这里...我有个问题!', weight: 3 },
|
||||
{ id: 2, emoji: '🪑', text: '快快快!我等不及了!', weight: 3 },
|
||||
{ id: 3, emoji: '💀', text: '这个问题不解决我就要原地爆炸了💥', weight: 1.5 },
|
||||
{ id: 4, emoji: '🍉', text: 'IT!救我!这个问题卡住我了🍉', weight: 1.5 },
|
||||
{ id: 5, emoji: '🔔', text: '叮叮叮!有人吗!IT 在线吗!', weight: 1 },
|
||||
{ id: 6, emoji: '💣', text: '开炮!💣 这个问题必须解决了!', weight: 1.5 },
|
||||
{ id: 7, emoji: '🚀', text: '发射!🚀 紧急呼叫 IT 特种部队!', weight: 1.5 },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 固定使用场景5:摇铃铛 🔔
|
||||
* 不再随机选择,回归统一的摇铃呼叫体验
|
||||
*/
|
||||
function pickScene(): number {
|
||||
return 5
|
||||
}
|
||||
|
||||
const sceneText = computed(() => scenes.find(s => s.id === selectedScene.value)?.text ?? '')
|
||||
const sceneEmoji = computed(() => scenes.find(s => s.id === selectedScene.value)?.emoji ?? '🙋')
|
||||
|
||||
// ── 弹窗打开时自动发起呼叫 ──
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
startCall()
|
||||
}
|
||||
})
|
||||
|
||||
async function startCall(): Promise<void> {
|
||||
selectedScene.value = pickScene()
|
||||
sending.value = true
|
||||
sendSuccess.value = false
|
||||
|
||||
try {
|
||||
await store.shakeAgent()
|
||||
sendSuccess.value = true
|
||||
emit('call-success')
|
||||
|
||||
// 3秒后自动关闭
|
||||
setTimeout(() => {
|
||||
if (sendSuccess.value) handleClose()
|
||||
}, 4000)
|
||||
} catch (err) {
|
||||
// 发送失败,关闭弹窗
|
||||
handleClose()
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose(): void {
|
||||
emit('update:visible', false)
|
||||
// 重置状态
|
||||
setTimeout(() => {
|
||||
sending.value = false
|
||||
sendSuccess.value = false
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
弹窗容器
|
||||
========================================================================== */
|
||||
.call-modal__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.call-modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
width: 88vw;
|
||||
max-width: 360px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
.call-modal--compact {
|
||||
max-width: 340px;
|
||||
}
|
||||
.call-modal__header {
|
||||
text-align: center;
|
||||
padding: 20px 16px 8px;
|
||||
}
|
||||
.call-modal__icon { font-size: 32px; }
|
||||
.call-modal__header h3 {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.call-modal__body { padding: 12px 16px; }
|
||||
.call-modal__body--center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.call-modal__footer {
|
||||
padding: 12px 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.call-modal__footer--center { justify-content: center; }
|
||||
.call-modal__btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.call-modal__btn--cancel { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.call-modal__btn--primary { background: var(--color-warning); color: var(--bg-secondary); }
|
||||
.call-modal__btn--large { padding: 12px 48px; font-size: 15px; }
|
||||
.call-modal__btn:active { transform: scale(0.96); }
|
||||
|
||||
/* ==========================================================================
|
||||
话术文字
|
||||
========================================================================== */
|
||||
.call-modal__speech-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.call-modal__speech-emoji { font-size: 22px; }
|
||||
|
||||
/* ==========================================================================
|
||||
发送状态
|
||||
========================================================================== */
|
||||
.call-modal__sending {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.call-modal__dot-flashing {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning);
|
||||
animation: dot-flash 0.6s infinite alternate;
|
||||
}
|
||||
@keyframes dot-flash {
|
||||
0% { opacity: 0.2; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.call-modal__success {
|
||||
margin-top: 12px;
|
||||
font-size: 15px;
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
SVG 场景容器
|
||||
========================================================================== */
|
||||
.scene { margin-bottom: 4px; }
|
||||
.scene__svg { width: 180px; height: 150px; display: block; margin: 0 auto; }
|
||||
|
||||
/* ==========================================================================
|
||||
场景1:招手动画
|
||||
========================================================================== */
|
||||
.hand-arm {
|
||||
animation: hand-wave 0.6s ease-in-out infinite;
|
||||
transform-origin: 42px 80px;
|
||||
}
|
||||
@keyframes hand-wave {
|
||||
0%, 100% { transform: rotate(-5deg); }
|
||||
50% { transform: rotate(-30deg); }
|
||||
}
|
||||
.bubble-float { animation: bubble-updown 2s ease-in-out infinite; }
|
||||
@keyframes bubble-updown {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景2:拍桌子动画
|
||||
========================================================================== */
|
||||
.pound-fists {
|
||||
animation: desk-pound 0.4s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes desk-pound {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(-8px); }
|
||||
}
|
||||
.scene--pound {
|
||||
animation: desk-shake 0.4s ease-in-out infinite alternate;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
@keyframes desk-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-3px); }
|
||||
75% { transform: translateX(3px); }
|
||||
}
|
||||
.splash-lines line:nth-child(1) { animation: splash 0.6s ease-out infinite; }
|
||||
.splash-lines line:nth-child(2) { animation: splash 0.6s ease-out 0.1s infinite; }
|
||||
.splash-lines line:nth-child(3) { animation: splash 0.6s ease-out 0.2s infinite; }
|
||||
.splash-lines line:nth-child(4) { animation: splash 0.6s ease-out 0.15s infinite; }
|
||||
.splash-lines line:nth-child(5) { animation: splash 0.6s ease-out 0.25s infinite; }
|
||||
@keyframes splash {
|
||||
0% { opacity: 1; transform: translate(0,0); }
|
||||
100% { opacity: 0; transform: translate(var(--sx, 0), -12px); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景3:劈稻草人
|
||||
========================================================================== */
|
||||
.slash-blade {
|
||||
animation: blade-slash 1.0s ease-in-out infinite;
|
||||
transform-origin: 58px 38px;
|
||||
}
|
||||
@keyframes blade-slash {
|
||||
0%, 15% { transform: rotate(-60deg); }
|
||||
30%, 55% { transform: rotate(20deg); }
|
||||
70%, 100% { transform: rotate(-60deg); }
|
||||
}
|
||||
.explosion-effect circle {
|
||||
animation: boom-flash 1.0s ease-in-out infinite;
|
||||
}
|
||||
@keyframes boom-flash {
|
||||
0%, 50% { opacity: 0; transform: scale(0.5); }
|
||||
55% { opacity: 0.9; transform: scale(2); }
|
||||
65% { opacity: 0; transform: scale(3); }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景4:砍西瓜
|
||||
========================================================================== */
|
||||
.knife {
|
||||
animation: knife-chop 0.8s ease-in-out infinite;
|
||||
transform-origin: 58px 40px;
|
||||
}
|
||||
@keyframes knife-chop {
|
||||
0%, 20% { transform: rotate(-50deg); }
|
||||
40%, 60% { transform: rotate(15deg); }
|
||||
80%, 100% { transform: rotate(-50deg); }
|
||||
}
|
||||
.juice-splash circle:nth-child(1) { animation: juice-splash 0.8s ease-out 0.4s infinite; }
|
||||
.juice-splash circle:nth-child(2) { animation: juice-splash 0.8s ease-out 0.45s infinite; }
|
||||
.juice-splash circle:nth-child(3) { animation: juice-splash 0.8s ease-out 0.5s infinite; }
|
||||
@keyframes juice-splash {
|
||||
0% { opacity: 0; transform: translate(0,0) scale(0); }
|
||||
50% { opacity: 1; transform: translate(-5px, -10px) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-10px, -20px) scale(0.5); }
|
||||
}
|
||||
.melon {
|
||||
animation: melon-shake 0.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes melon-shake {
|
||||
0%, 37% { transform: translate(0,0); }
|
||||
42%, 58% { transform: translate(-3px, 2px); }
|
||||
62% { transform: translate(0,0); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景5:摇传菜铃
|
||||
========================================================================== */
|
||||
.bell-left {
|
||||
animation: bell-ring-left 0.5s ease-in-out infinite;
|
||||
transform-origin: 63px 58px;
|
||||
}
|
||||
.bell-right {
|
||||
animation: bell-ring-right 0.5s ease-in-out 0.25s infinite;
|
||||
transform-origin: 103px 58px;
|
||||
}
|
||||
@keyframes bell-ring-left {
|
||||
0%, 100% { transform: rotate(0); }
|
||||
50% { transform: rotate(-12deg); }
|
||||
}
|
||||
@keyframes bell-ring-right {
|
||||
0%, 100% { transform: rotate(0); }
|
||||
50% { transform: rotate(12deg); }
|
||||
}
|
||||
.sound-waves circle:nth-child(1) { animation: wave-expand 1s ease-out infinite; }
|
||||
.sound-waves circle:nth-child(2) { animation: wave-expand 1s ease-out 0.3s infinite; }
|
||||
.sound-waves circle:nth-child(3) { animation: wave-expand 1s ease-out 0.6s infinite; }
|
||||
@keyframes wave-expand {
|
||||
0% { opacity: 1; stroke-width: 2; }
|
||||
100% { opacity: 0; stroke-width: 0.5; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景6:大炮发射
|
||||
========================================================================== */
|
||||
.cannon-barrel {
|
||||
animation: cannon-recoil 1.2s ease-in-out infinite;
|
||||
transform-origin: 80px 117px;
|
||||
}
|
||||
@keyframes cannon-recoil {
|
||||
0%, 15% { transform: translateX(0); }
|
||||
20%, 25% { transform: translateX(-8px); }
|
||||
30%, 100% { transform: translateX(0); }
|
||||
}
|
||||
.fuse-spark {
|
||||
animation: spark-flicker 0.15s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes spark-flicker {
|
||||
0% { r: 2; fill: #FF5722; opacity: 0.6; }
|
||||
100% { r: 3.5; fill: #FFEB3B; opacity: 1; }
|
||||
}
|
||||
.cannonball {
|
||||
animation: cannonball-fly 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes cannonball-fly {
|
||||
0%, 25% { transform: translate(0, 0); opacity: 0; }
|
||||
28% { transform: translate(15px, -20px); opacity: 1; }
|
||||
50% { transform: translate(50px, -48px); opacity: 1; }
|
||||
100% { transform: translate(50px, -48px); opacity: 1; }
|
||||
}
|
||||
.blast-main { animation: blast-appear 1.2s ease-in-out infinite; }
|
||||
@keyframes blast-appear {
|
||||
0%, 55% { opacity: 0; transform: scale(0); }
|
||||
58% { opacity: 1; transform: scale(1.5); }
|
||||
65% { opacity: 0.9; transform: scale(1.2); }
|
||||
75% { opacity: 0; transform: scale(1.8); }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.blast-inner { animation: blast-inner 1.2s ease-in-out infinite; }
|
||||
@keyframes blast-inner {
|
||||
0%, 58% { opacity: 0; transform: scale(0); }
|
||||
62% { opacity: 1; transform: scale(1); }
|
||||
68% { opacity: 0.7; transform: scale(1.3); }
|
||||
75% { opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.blast-ray { animation: blast-ray 1.2s ease-in-out infinite; }
|
||||
.ray-1, .ray-5 { animation-delay: 0.55s; }
|
||||
.ray-2, .ray-6 { animation-delay: 0.56s; }
|
||||
.ray-3, .ray-7 { animation-delay: 0.57s; }
|
||||
.ray-4, .ray-8 { animation-delay: 0.58s; }
|
||||
@keyframes blast-ray {
|
||||
0%, 60% { opacity: 0; }
|
||||
62% { opacity: 1; }
|
||||
70% { opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.target { animation: target-shake 1.2s ease-in-out infinite; }
|
||||
@keyframes target-shake {
|
||||
0%, 56% { transform: translate(0,0) rotate(0); }
|
||||
60% { transform: translate(3px,-3px) rotate(5deg); }
|
||||
64% { transform: translate(-3px,2px) rotate(-5deg); }
|
||||
68% { transform: translate(0,0) rotate(0); }
|
||||
100% { transform: translate(0,0) rotate(0); }
|
||||
}
|
||||
.gunner-arm {
|
||||
animation: gunner-arm 1.2s ease-in-out infinite;
|
||||
transform-origin: 35px 85px;
|
||||
}
|
||||
@keyframes gunner-arm {
|
||||
0%, 25% { transform: rotate(0deg); }
|
||||
28% { transform: rotate(15deg); }
|
||||
35% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景7:导弹发射
|
||||
========================================================================== */
|
||||
.missile-body { animation: missile-launch 1.5s ease-in-out infinite; }
|
||||
@keyframes missile-launch {
|
||||
0%, 10% { transform: translateY(0); }
|
||||
15%, 70% { transform: translateY(-80px); }
|
||||
80%, 100% { transform: translateY(-80px); }
|
||||
}
|
||||
.rocket-flame { animation: missile-launch 1.5s ease-in-out infinite; }
|
||||
.flame-outer { animation: flame-pulse 0.2s ease-in-out infinite alternate; }
|
||||
.flame-inner { animation: flame-pulse 0.15s ease-in-out infinite alternate; }
|
||||
@keyframes flame-pulse {
|
||||
0% { transform: scaleY(1); opacity: 0.8; }
|
||||
100% { transform: scaleY(1.3); opacity: 1; }
|
||||
}
|
||||
.smoke-puff { animation: smoke-burst 1.5s ease-out infinite; }
|
||||
.puff-1 { animation-delay: 0.2s; }
|
||||
.puff-2 { animation-delay: 0.3s; }
|
||||
.puff-3 { animation-delay: 0.4s; }
|
||||
@keyframes smoke-burst {
|
||||
0% { opacity: 0; transform: scale(0.5) translateY(0); }
|
||||
15% { opacity: 0.7; transform: scale(1) translateY(0); }
|
||||
100% { opacity: 0; transform: scale(2.5) translateY(-20px); }
|
||||
}
|
||||
.trail-line { animation: trail-glow 1.5s ease-out infinite; }
|
||||
@keyframes trail-glow {
|
||||
0%, 15% { opacity: 0; }
|
||||
20% { opacity: 0.8; }
|
||||
70% { opacity: 0.6; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.launch-button { animation: button-blink 0.5s ease-in-out infinite alternate; }
|
||||
@keyframes button-blink {
|
||||
0% { fill: #F44336; }
|
||||
100% { fill: #FFEB3B; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
过渡动画
|
||||
========================================================================== */
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active { transition: opacity 0.25s ease; }
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to { opacity: 0; }
|
||||
.modal-zoom-enter-active { transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
.modal-zoom-leave-active { transition: all 0.2s ease-in; }
|
||||
.modal-zoom-enter-from { opacity: 0; transform: scale(0.8); }
|
||||
.modal-zoom-leave-to { opacity: 0; transform: scale(0.9); }
|
||||
</style>
|
||||
@@ -0,0 +1,438 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端对话区面板
|
||||
// =============================================================================
|
||||
// 说明:对话区主面板,包含:
|
||||
// 1. 标题栏(IT智能服务台 + 坐席状态 + 🔔呼叫 + 主题切换)
|
||||
// 2. 排查步骤(固定在消息区顶部,不随滚动消失)
|
||||
// 3. 消息列表(自动滚动到底部)
|
||||
// 4. 消息类型渲染:员工(右蓝) / 坐席(左白) / AI(左绿+AI标签) / 系统(居中灰)
|
||||
// 5. 底部输入栏(工具栏+输入+发送+引导条)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="chat-panel">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="chat-panel__header">
|
||||
<div class="chat-panel__header-left">
|
||||
<span class="chat-panel__title">IT智能服务台</span>
|
||||
<!-- 坐席在线状态 -->
|
||||
<span v-if="store.agentOnline" class="chat-panel__status chat-panel__status--online">
|
||||
<span class="chat-panel__status-dot"></span>
|
||||
坐席在线
|
||||
</span>
|
||||
<span v-else class="chat-panel__status chat-panel__status--offline">
|
||||
<span class="chat-panel__status-dot"></span>
|
||||
坐席离线
|
||||
</span>
|
||||
</div>
|
||||
<div class="chat-panel__header-actions">
|
||||
<!-- 🔔 呼叫坐席按钮 -->
|
||||
<button
|
||||
v-if="store.canCallAgent"
|
||||
class="chat-panel__bell-btn"
|
||||
:disabled="!store.isLoggedIn || store.shaking"
|
||||
title="摇铃呼叫人工坐席"
|
||||
@click="showCallModal = true"
|
||||
>
|
||||
<span class="chat-panel__bell-icon">🔔</span>
|
||||
<span class="chat-panel__bell-text">呼叫</span>
|
||||
</button>
|
||||
<!-- 主题切换开关(☀️ 滑轨 🌙) -->
|
||||
<div
|
||||
class="theme-switch"
|
||||
:title="themeStore.currentTheme === 'light' ? '切换到深色模式' : '切换到浅色模式'"
|
||||
@click="themeStore.toggleTheme()"
|
||||
>
|
||||
<span class="switch-icon">☀️</span>
|
||||
<div class="switch-track">
|
||||
<div class="switch-thumb"></div>
|
||||
</div>
|
||||
<span class="switch-icon">🌙</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 排查步骤:固定在消息区顶部(标题栏下方、所有消息之上,不随滚动消失) -->
|
||||
<div v-if="store.troubleshootingSteps?.length" class="chat-panel__troubleshoot-fixed">
|
||||
<TroubleshootFlow />
|
||||
</div>
|
||||
|
||||
<!-- 参与者横幅:当有被邀请参与者时显示(邀请功能 P0-09~P0-11) -->
|
||||
<div
|
||||
v-if="store.participants.length > 0"
|
||||
class="chat-panel__participant-banner"
|
||||
@click="store.toggleParticipantPanel()"
|
||||
>
|
||||
<span class="chat-panel__participant-icon">👥</span>
|
||||
<span class="chat-panel__participant-text">
|
||||
{{ store.joinedParticipantCount }}人已加入,共{{ store.participants.length }}人被邀请
|
||||
</span>
|
||||
<span
|
||||
class="chat-panel__participant-arrow"
|
||||
:class="{ 'chat-panel__participant-arrow--open': store.participantPanelVisible }"
|
||||
>▾</span>
|
||||
</div>
|
||||
|
||||
<!-- 参与者面板(展开时显示) -->
|
||||
<div v-if="store.participantPanelVisible" class="chat-panel__participant-panel">
|
||||
<ParticipantList />
|
||||
</div>
|
||||
|
||||
<!-- 消息列表区域(可滚动) -->
|
||||
<div ref="messageListRef" class="chat-panel__messages" @scroll="handleScroll">
|
||||
<!-- 未登录提示 -->
|
||||
<div v-if="!store.isLoggedIn" class="chat-panel__not-logged">
|
||||
<van-empty description="正在获取身份信息..." image="search" />
|
||||
</div>
|
||||
|
||||
<!-- 无消息提示 -->
|
||||
<div v-else-if="store.messages.length === 0" class="chat-panel__empty">
|
||||
<div class="chat-panel__empty-icon">💬</div>
|
||||
<p class="chat-panel__empty-text">暂无消息</p>
|
||||
<p class="chat-panel__empty-hint">输入问题咨询,或 🔔 摇铃呼叫坐席</p>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<template v-else>
|
||||
<MessageBubble
|
||||
v-for="msg in store.messages"
|
||||
:key="msg.message_id"
|
||||
:msg="msg"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 底部输入栏 -->
|
||||
<InputBar />
|
||||
|
||||
<!-- 呼叫坐席弹窗(描述问题 → AI确认 → 动画) -->
|
||||
<CallAgentModal
|
||||
:visible="showCallModal"
|
||||
@update:visible="showCallModal = $event"
|
||||
@call-success="handleCallSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ChatPanel 对话区面板
|
||||
* 包含标题栏、排查步骤(固定顶部)、消息列表和底部输入栏
|
||||
* 消息列表自动滚动到底部
|
||||
* 排查步骤固定在消息区顶部,不随消息滚动消失
|
||||
*/
|
||||
|
||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import InputBar from './InputBar.vue'
|
||||
import CallAgentModal from './CallAgentModal.vue'
|
||||
import TroubleshootFlow from './TroubleshootFlow.vue'
|
||||
import ParticipantList from './ParticipantList.vue'
|
||||
|
||||
const store = useConversationStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
/** 消息列表容器的 DOM 引用 */
|
||||
const messageListRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 是否显示「呼叫坐席」弹窗 */
|
||||
const showCallModal = ref<boolean>(false)
|
||||
|
||||
/** 是否应该自动滚动到底部(用户手动上滚时暂停自动滚动) */
|
||||
const shouldAutoScroll = ref<boolean>(true)
|
||||
|
||||
/**
|
||||
* 滚动到消息列表底部
|
||||
* 使用 nextTick 确保 DOM 更新后再滚动
|
||||
*/
|
||||
async function scrollToBottom(): Promise<void> {
|
||||
await nextTick()
|
||||
if (messageListRef.value && shouldAutoScroll.value) {
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理滚动事件
|
||||
* 如果用户滚动到接近底部,恢复自动滚动
|
||||
* 如果用户向上滚动离开底部,暂停自动滚动
|
||||
*/
|
||||
function handleScroll(): void {
|
||||
if (!messageListRef.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = messageListRef.value
|
||||
// 距离底部 100px 以内时恢复自动滚动
|
||||
shouldAutoScroll.value = scrollHeight - scrollTop - clientHeight < 100
|
||||
}
|
||||
|
||||
/** 呼叫成功回调:刷新会话状态 */
|
||||
function handleCallSuccess(): void {
|
||||
store.fetchCurrentConversation()
|
||||
}
|
||||
|
||||
// 监听消息列表变化,自动滚动到底部
|
||||
watch(
|
||||
() => store.messages.length,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
|
||||
// 组件挂载后滚动到底部
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 对话区面板容器 */
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* 兼容不支持 dvh 的浏览器 */
|
||||
@supports not (height: 100dvh) {
|
||||
.chat-panel {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.chat-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 标题栏左侧 */
|
||||
.chat-panel__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 标题文字 */
|
||||
.chat-panel__title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 坐席在线状态 */
|
||||
.chat-panel__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.chat-panel__status--online {
|
||||
background-color: var(--color-success-soft, rgba(34, 197, 94, 0.1));
|
||||
color: var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.chat-panel__status--offline {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.chat-panel__status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chat-panel__status--online .chat-panel__status-dot {
|
||||
background-color: var(--color-success, #22c55e);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-panel__status--offline .chat-panel__status-dot {
|
||||
background-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* 标题栏右侧操作区 */
|
||||
.chat-panel__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 🔔 呼叫坐席按钮(标题栏) */
|
||||
.chat-panel__bell-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-warning, #FF9800);
|
||||
background: linear-gradient(135deg, #FFF8E1, #FFE082);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #E65100;
|
||||
animation: bell-idle 2s ease-in-out infinite;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-panel__bell-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
||||
animation: bell-ring 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.chat-panel__bell-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.chat-panel__bell-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.chat-panel__bell-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-panel__bell-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 静止时轻微摇摆 */
|
||||
@keyframes bell-idle {
|
||||
0%, 100% { transform: rotate(0); }
|
||||
25% { transform: rotate(-3deg); }
|
||||
75% { transform: rotate(3deg); }
|
||||
}
|
||||
|
||||
/* 悬停时快速摇铃 */
|
||||
@keyframes bell-ring {
|
||||
0%, 100% { transform: rotate(0) scale(1.05); }
|
||||
15% { transform: rotate(-10deg) scale(1.05); }
|
||||
30% { transform: rotate(8deg) scale(1.05); }
|
||||
45% { transform: rotate(-6deg) scale(1.05); }
|
||||
60% { transform: rotate(4deg) scale(1.05); }
|
||||
75% { transform: rotate(-2deg) scale(1.05); }
|
||||
90% { transform: rotate(1deg) scale(1.05); }
|
||||
}
|
||||
|
||||
/* 排查步骤:固定在消息区顶部 */
|
||||
.chat-panel__troubleshoot-fixed {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 参与者横幅:点击可展开/收起参与者面板 */
|
||||
.chat-panel__participant-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
background-color: var(--accent-soft, rgba(59, 130, 246, 0.06));
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.chat-panel__participant-banner:hover {
|
||||
background-color: var(--accent-soft, rgba(59, 130, 246, 0.12));
|
||||
}
|
||||
|
||||
.chat-panel__participant-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-panel__participant-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-panel__participant-arrow {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chat-panel__participant-arrow--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 参与者面板(展开时显示 ParticipantList) */
|
||||
.chat-panel__participant-panel {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 消息列表区域 */
|
||||
.chat-panel__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 未登录提示 */
|
||||
.chat-panel__not-logged {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 无消息空状态 */
|
||||
.chat-panel__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
/* 空状态图标 */
|
||||
.chat-panel__empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 空状态提示文字 */
|
||||
.chat-panel__empty-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 空状态辅助提示 */
|
||||
.chat-panel__empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-placeholder);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,872 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端输入栏组件
|
||||
// =============================================================================
|
||||
// 说明:底部输入栏,固定在消息列表下方,包含:
|
||||
// [工具栏:表情/图片/文件/拍照] [文本输入框] [发送按钮]
|
||||
// - 输入框默认3行可见,高度随内容动态适应
|
||||
// - 输入框顶部拖拽手柄可手动调节高度
|
||||
// - Enter 发送,Shift+Enter 换行
|
||||
// - 支持粘贴图片上传(Ctrl+V 粘贴截图)
|
||||
// - 支持图片/文件选择上传
|
||||
// - 底部引导条提示摇铃功能
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="input-bar">
|
||||
<!-- 拖拽手柄:可手动调节输入栏高度 -->
|
||||
<div
|
||||
class="input-bar__resize-handle"
|
||||
@mousedown="handleInputResizeStart"
|
||||
>
|
||||
<div class="input-bar__resize-track"></div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域:工具栏 + 输入框 + 发送按钮 -->
|
||||
<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 网格) -->
|
||||
<div v-if="showEmojiPanel" class="emoji-panel">
|
||||
<div class="emoji-panel__grid">
|
||||
<button
|
||||
v-for="emoji in commonEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-panel__item"
|
||||
@click="onEmojiClick(emoji)"
|
||||
>{{ emoji }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入行:输入框 + 发送按钮 -->
|
||||
<div class="input-bar__input-row">
|
||||
<!-- 文本输入框 — 默认3行,自适应内容高度 -->
|
||||
<van-field
|
||||
ref="inputFieldRef"
|
||||
v-model="inputText"
|
||||
class="input-bar__field"
|
||||
placeholder="请输入消息..."
|
||||
type="textarea"
|
||||
:autosize="{ minHeight: 54, maxHeight: 200 }"
|
||||
rows="3"
|
||||
:disabled="!store.isLoggedIn"
|
||||
@keydown.enter="handleEnterKey"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<van-button
|
||||
class="input-bar__send-btn"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canSend"
|
||||
:loading="store.loading"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部引导条:根据是否可呼叫坐席切换文案 -->
|
||||
<div v-if="store.canCallAgent" class="input-bar__guide input-bar__guide--active">
|
||||
🔔 摇传菜铃通道已开启,点击标题栏传菜铃呼叫 IT 坐席
|
||||
</div>
|
||||
|
||||
<!-- 表情面板打开时的半透明遮罩(点击关闭表情面板) -->
|
||||
<div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></div>
|
||||
<div v-else class="input-bar__guide">
|
||||
请描述你遇到的问题,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>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* InputBar 输入栏组件
|
||||
* 布局:[工具栏] [输入框] [发送按钮]
|
||||
* 输入框固定底部,默认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])
|
||||
// ============================================================================
|
||||
/**
|
||||
* 将 error.response.data.detail 安全转换为字符串
|
||||
* FastAPI 422 时 detail 是数组,直接拼接会变成 "[object Object]"
|
||||
*/
|
||||
function formatErrorDetail(detail: any): string {
|
||||
if (!detail) return ''
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
// FastAPI 验证错误:取每个元素的 msg 和 loc
|
||||
return detail.map((d: any) => {
|
||||
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
|
||||
if (d.msg) return d.msg
|
||||
return JSON.stringify(d)
|
||||
}).join('; ')
|
||||
}
|
||||
if (typeof detail === 'object') return JSON.stringify(detail)
|
||||
return String(detail)
|
||||
}
|
||||
|
||||
const store = useConversationStore()
|
||||
// 保留 emit 声明供阶段二使用(呼叫坐席事件将改为由标题栏传菜铃触发)
|
||||
defineEmits<{
|
||||
(e: 'call-agent'): void
|
||||
}>()
|
||||
|
||||
/** 输入框文本 */
|
||||
const inputText = ref<string>('')
|
||||
|
||||
/** 输入框组件引用(用于获取 DOM 元素) */
|
||||
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
|
||||
})
|
||||
|
||||
/** 是否正在拖拽调节输入栏高度 */
|
||||
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 不触发文件粘贴的问题)
|
||||
// ============================================================================
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handleDocPaste)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handleDocPaste)
|
||||
})
|
||||
|
||||
/**
|
||||
* Document 级别粘贴处理(备用方案)
|
||||
* 做什么:捕获 van-field @paste 未处理的文件粘贴
|
||||
*/
|
||||
async function handleDocPaste(event: ClipboardEvent): Promise<void> {
|
||||
// 如果事件目标是输入框,说明 @paste 已经处理,跳过
|
||||
const target = event.target as HTMLElement
|
||||
if (target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用表情列表(8行x8列 = 64 个常用表情)
|
||||
* 按使用频率排序,覆盖日常沟通场景
|
||||
*/
|
||||
const commonEmojis = [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂',
|
||||
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
|
||||
'😚','😙','😋','😛','😜','🤪','😝','🤑',
|
||||
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
|
||||
'😶','😏','😒','🙄','😬','😮','🤯','😲',
|
||||
'😳','🥺','😢','😭','😤','😠','😡','🤬',
|
||||
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
|
||||
'❤️','🧡','💛','💚','💙','💜','💯','✅',
|
||||
]
|
||||
|
||||
/**
|
||||
* 选择表情后插入到输入框
|
||||
*
|
||||
* 做什么:将表情字符追加到输入框末尾
|
||||
*
|
||||
* 为什么简单追加而非操作 DOM 光标位置:
|
||||
* Vant van-field 的内部 textarea 有自己的状态管理,
|
||||
* 直接操作 DOM(selectionStart/selectionEnd)可能与 Vant 内部状态冲突。
|
||||
* 简单追加到 inputText.value 是最可靠的方式——v-model 会自动同步到 textarea。
|
||||
*/
|
||||
function onEmojiClick(emoji: string): void {
|
||||
inputText.value += emoji
|
||||
showEmojiPanel.value = false
|
||||
nextTick(() => {
|
||||
// 让输入框获得焦点,用户可以继续输入
|
||||
const el = inputFieldRef.value?.$el?.querySelector('textarea') as HTMLTextAreaElement | null
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息发送
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理 Enter 键
|
||||
* 桌面端 Enter 发送,Shift+Enter 换行
|
||||
* 移动端不拦截 Enter(让系统默认行为处理)
|
||||
*/
|
||||
function handleEnterKey(event: KeyboardEvent): void {
|
||||
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
if (isMobile) return
|
||||
|
||||
// 桌面端:Shift+Enter 换行,Enter 发送
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* 调用 store 发送消息方法,发送后清空输入框
|
||||
*/
|
||||
async function handleSend(): Promise<void> {
|
||||
const content = inputText.value.trim()
|
||||
if (!content || store.loading) return
|
||||
|
||||
inputText.value = ''
|
||||
|
||||
await store.sendNewMessage(content)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 文件/图片上传
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
* 做什么:检测剪贴板中的图片或文件,自动上传并发送
|
||||
* 为什么:用户需要从剪贴板直接粘贴图片/文件
|
||||
*
|
||||
* 支持:
|
||||
* 1. 粘贴图片(截图工具 Ctrl+V / 复制图片)
|
||||
* 2. 粘贴文件(从文件管理器复制的文件)
|
||||
* 3. 纯文本粘贴(默认行为,不拦截)
|
||||
*/
|
||||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
// 处理所有文件类型(图片 + 普通文件)
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault() // 阻止默认粘贴(文件不插入文本)
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
// 根据文件类型选择上传方式
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return // 每次粘贴只处理第一个文件
|
||||
}
|
||||
}
|
||||
// 纯文本:不拦截,浏览器默认行为(插入文本到输入框)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片并发送图片消息
|
||||
* 做什么:粘贴或选择图片后,上传到服务器并发送消息
|
||||
* 为什么:用户粘贴图片时期望直接发送,而不是只上传不发送
|
||||
*
|
||||
* 注意:H5 端当前后端 sendMessage API 只支持文本消息,
|
||||
* 所以图片上传后以文本形式发送截图链接,后续后端支持图片消息后可升级
|
||||
*
|
||||
* @param file - 图片文件对象(File 或 Blob)
|
||||
*/
|
||||
async function handleImageUpload(file: File | Blob): Promise<void> {
|
||||
try {
|
||||
showToast('图片上传中...')
|
||||
|
||||
// 1. 上传图片到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
// 2. 以图片消息类型发送(携带 media_url,MessageBubble 会渲染缩略图)
|
||||
const fileName = file instanceof File ? file.name : '截图'
|
||||
await store.sendNewMessage(`[图片] ${fileName}`, {
|
||||
msg_type: 'image',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
|
||||
showToast('图片发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('图片上传失败:', error)
|
||||
showToast(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'图片上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件并发送文件消息
|
||||
* 做什么:粘贴或选择文件后,上传到服务器并发送消息
|
||||
* 为什么:用户粘贴文件时期望直接发送
|
||||
*
|
||||
* 注意:H5 端当前后端 sendMessage API 只支持文本消息,
|
||||
* 所以文件上传后以文本形式发送文件链接
|
||||
*
|
||||
* @param file - 文件对象(File 或 Blob)
|
||||
*/
|
||||
async function handleFileUpload(file: File | Blob): Promise<void> {
|
||||
try {
|
||||
const fileName = file instanceof File ? file.name : '文件'
|
||||
showToast(`文件上传中: ${fileName}`)
|
||||
|
||||
// 1. 上传文件到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
// 2. 以文件消息类型发送(携带 media_url,MessageBubble 会渲染文件卡片)
|
||||
await store.sendNewMessage(`[文件] ${result.filename}`, {
|
||||
msg_type: 'file',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
|
||||
showToast('文件发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
showToast(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'文件上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件选择上传
|
||||
* 通过隐藏的 <input type="file"> 触发
|
||||
*/
|
||||
async function handleFileSelect(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
showToast(`正在上传: ${file.name}`)
|
||||
|
||||
// 1. 上传文件到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
showToast(`${file.name} 上传成功`)
|
||||
console.log('[InputBar] 文件上传成功:', result.url)
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
showToast(`${file.name} 上传失败`)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置 input,允许重复选择同一文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表情按钮点击
|
||||
* 弹出简易表情选择器(常用 Emoji 面板)
|
||||
*/
|
||||
function handleEmoji(): void {
|
||||
showEmojiPanel.value = !showEmojiPanel.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片按钮点击
|
||||
* 触发文件选择器(限定图片类型)
|
||||
*/
|
||||
function handleImage(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件按钮点击
|
||||
* 触发文件选择器(不限类型)
|
||||
*/
|
||||
function handleFile(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = ''
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拍照按钮点击
|
||||
* 触发移动端摄像头拍照(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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 拖拽调节输入栏高度
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 拖拽开始:记录初始 Y 坐标
|
||||
* 向上拖拽增大输入栏高度,向下拖拽减小
|
||||
*/
|
||||
function handleInputResizeStart(event: MouseEvent): void {
|
||||
isInputResizing.value = true
|
||||
|
||||
const startY = event.clientY
|
||||
const inputBar = (event.target as HTMLElement).closest('.input-bar') as HTMLElement
|
||||
const startHeight = inputBar ? inputBar.offsetHeight : 100
|
||||
|
||||
/**
|
||||
* 拖拽中:根据鼠标移动距离更新输入栏高度
|
||||
*/
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
if (!isInputResizing.value) return
|
||||
|
||||
const deltaY = startY - e.clientY // 向上拖拽正值,向下负值
|
||||
let newHeight = startHeight + deltaY
|
||||
|
||||
// 限制最小/最大高度:100px ~ 400px
|
||||
newHeight = Math.max(100, Math.min(400, newHeight))
|
||||
|
||||
if (inputBar) {
|
||||
inputBar.style.height = `${newHeight}px`
|
||||
inputBar.style.flexShrink = '0'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽结束:移除事件监听
|
||||
*/
|
||||
function handleMouseUp(): void {
|
||||
isInputResizing.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
输入栏容器 — 固定在消息列表底部
|
||||
========================================================================== */
|
||||
.input-bar {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0 12px 8px;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── 拖拽手柄:顶部横条,可拖拽调节输入栏高度 ── */
|
||||
.input-bar__resize-handle {
|
||||
height: 8px;
|
||||
cursor: row-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 -12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.input-bar__resize-track {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--border-color);
|
||||
transition: background-color 0.2s, width 0.2s;
|
||||
}
|
||||
|
||||
.input-bar__resize-handle:hover .input-bar__resize-track {
|
||||
background-color: var(--accent);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
/* 输入行:工具栏 + 输入框 + 发送按钮 */
|
||||
.input-bar__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 工具栏:表情/图片/文件/截图 */
|
||||
.input-bar__toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 0 4px 0;
|
||||
}
|
||||
|
||||
/* ── 表情选择面板 ── */
|
||||
.emoji-panel {
|
||||
position: relative;
|
||||
z-index: 200;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.emoji-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 36px);
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.emoji-panel__item {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emoji-panel__item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.emoji-panel__item:active {
|
||||
background: var(--accent-soft, rgba(59,130,246,0.15));
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* 表情面板遮罩(点击关闭) */
|
||||
.emoji-panel__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.input-bar__tool-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar__tool-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-bar__tool-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* 输入行:输入框 + 发送按钮 */
|
||||
.input-bar__input-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
文本输入框
|
||||
========================================================================== */
|
||||
.input-bar__field {
|
||||
flex: 1;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-bar__field:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 覆盖 Vant Field 默认样式 */
|
||||
.input-bar__field :deep(.van-field__control) {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.input-bar__field :deep(.van-field__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
发送按钮
|
||||
========================================================================== */
|
||||
.input-bar__send-btn {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
底部引导条
|
||||
========================================================================== */
|
||||
.input-bar__guide {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 6px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* 呼叫坐席通道已开启(更醒目) */
|
||||
.input-bar__guide--active {
|
||||
color: var(--color-warning);
|
||||
font-weight: 500;
|
||||
animation: guide-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes guide-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,656 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端输入框组件
|
||||
// =============================================================================
|
||||
// 说明:底部输入框组件,包含:
|
||||
// - 输入框默认3行高度,自动扩展(max-height: 150px)
|
||||
// - 底部显示字数统计(当前/最大,如:120/500)
|
||||
// - 右下角发送按钮(icon)
|
||||
// - Enter键发送,Shift+Enter换行
|
||||
// - 空内容时禁用发送按钮
|
||||
// - 支持粘贴图片上传
|
||||
// - 支持图片/文件选择上传
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="input-box">
|
||||
<!-- 工具栏:表情/图片/文件/拍照/截图 -->
|
||||
<div class="input-box__toolbar">
|
||||
<button class="input-box__tool-btn" title="表情" @click="handleEmoji">
|
||||
<span>😊</span>
|
||||
</button>
|
||||
<button class="input-box__tool-btn" title="图片" @click="handleImage">
|
||||
<span>🖼️</span>
|
||||
</button>
|
||||
<button class="input-box__tool-btn" title="文件" @click="handleFile">
|
||||
<span>📎</span>
|
||||
</button>
|
||||
<button class="input-box__tool-btn" title="拍照" @click="handleCamera">
|
||||
<span>📷</span>
|
||||
</button>
|
||||
<button class="input-box__tool-btn" title="截图" @click="handleScreenshot">
|
||||
<span>✂️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 表情选择面板(简易版:常用 Emoji 网格) -->
|
||||
<div v-if="showEmojiPanel" class="emoji-panel">
|
||||
<div class="emoji-panel__grid">
|
||||
<button
|
||||
v-for="emoji in commonEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-panel__item"
|
||||
@click="onEmojiClick(emoji)"
|
||||
>{{ emoji }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-box__area">
|
||||
<!-- 文本输入框 — 默认3行,自适应内容高度 -->
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
v-model="inputText"
|
||||
class="input-box__textarea"
|
||||
placeholder="请输入消息..."
|
||||
:rows="3"
|
||||
:style="{ height: textareaHeight + 'px' }"
|
||||
:disabled="!store.isLoggedIn"
|
||||
@keydown="handleEnterKey"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
></textarea>
|
||||
|
||||
<!-- 发送按钮 — 右下角,icon样式 -->
|
||||
<button
|
||||
class="input-box__send-btn"
|
||||
:class="{ 'input-box__send-btn--active': canSend }"
|
||||
:disabled="!canSend"
|
||||
:loading="store.loading"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg v-if="!store.loading" class="send-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15-2-15-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 字数统计 -->
|
||||
<div class="input-box__counter">
|
||||
{{ charCount }}/{{ maxChars }}
|
||||
</div>
|
||||
|
||||
<!-- 底部引导条 -->
|
||||
<div v-if="store.canCallAgent" class="input-box__guide input-box__guide--active">
|
||||
🔔 摇铃通道已开启,点击标题栏传菜铃呼叫 IT 坐席
|
||||
</div>
|
||||
<div v-else class="input-box__guide">
|
||||
请描述你遇到的问题,AI 助手会帮你分析 💡
|
||||
</div>
|
||||
|
||||
<!-- 表情面板打开时的半透明遮罩(点击关闭表情面板) -->
|
||||
<div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></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>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* InputBox 输入框组件
|
||||
* 输入框默认3行高度,自动扩展(max-height: 150px)
|
||||
* 底部显示字数统计,右下角发送按钮
|
||||
* Enter发送,Shift+Enter换行
|
||||
*/
|
||||
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'
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数:安全提取错误详情
|
||||
// ============================================================================
|
||||
function formatErrorDetail(detail: any): string {
|
||||
if (!detail) return ''
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((d: any) => {
|
||||
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
|
||||
if (d.msg) return d.msg
|
||||
return JSON.stringify(d)
|
||||
}).join('; ')
|
||||
}
|
||||
if (typeof detail === 'object') return JSON.stringify(detail)
|
||||
return String(detail)
|
||||
}
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 输入框文本 */
|
||||
const inputText = ref<string>('')
|
||||
|
||||
/** 输入框 DOM 引用 */
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
/** 隐藏文件输入框 DOM 引用 */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 隐藏拍照输入框 DOM 引用 */
|
||||
const cameraInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 最大字符数 */
|
||||
const maxChars = 500
|
||||
|
||||
/** textarea 高度 */
|
||||
const textareaHeight = ref(60)
|
||||
|
||||
/** 是否显示表情面板 */
|
||||
const showEmojiPanel = ref(false)
|
||||
|
||||
/** 截图编辑器是否可见 */
|
||||
const showScreenshotEditor = ref(false)
|
||||
|
||||
/** html2canvas 生成的截图 Canvas */
|
||||
let screenshotCanvas: HTMLCanvasElement | null = null
|
||||
|
||||
/** 当前字符数 */
|
||||
const charCount = computed(() => inputText.value.length))
|
||||
|
||||
/** 是否可以发送消息 */
|
||||
const canSend = computed(() => {
|
||||
return inputText.value.trim().length > 0 && !store.loading && store.isLoggedIn && charCount.value <= maxChars
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 常用表情列表
|
||||
// ============================================================================
|
||||
const commonEmojis = [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂',
|
||||
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
|
||||
'😚','😙','😋','😛','😜','🤪','😝','🤑',
|
||||
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
|
||||
'😶','😏','😒','🙄','😬','😮','🤯','😲',
|
||||
'😳','🥺','😢','😭','😤','😠','😡','🤬',
|
||||
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
|
||||
'❤️','🧡','💛','💚','💙','💜','💯','✅',
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handleDocPaste)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handleDocPaste)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 输入框高度自适应
|
||||
// ============================================================================
|
||||
function handleInput(): void {
|
||||
// 计算内容高度
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
const scrollHeight = inputRef.value.scrollHeight
|
||||
const newHeight = Math.min(Math.max(scrollHeight, 60), 150)
|
||||
textareaHeight.value = newHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document 级别粘贴监听
|
||||
// ============================================================================
|
||||
async function handleDocPaste(event: ClipboardEvent): Promise<void> {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 表情处理
|
||||
// ============================================================================
|
||||
function onEmojiClick(emoji: string): void {
|
||||
inputText.value += emoji
|
||||
showEmojiPanel.value = false
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleEmoji(): void {
|
||||
showEmojiPanel.value = !showEmojiPanel.value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 键盘事件
|
||||
// ============================================================================
|
||||
function handleEnterKey(event: KeyboardEvent): void {
|
||||
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
if (isMobile) return
|
||||
|
||||
// 桌面端:Shift+Enter 换行,Enter 发送
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 发送消息
|
||||
// ============================================================================
|
||||
async function handleSend(): Promise<void> {
|
||||
const content = inputText.value.trim()
|
||||
if (!content || store.loading) return
|
||||
|
||||
inputText.value = ''
|
||||
textareaHeight.value = 60
|
||||
|
||||
await store.sendNewMessage(content)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 文件上传
|
||||
// ============================================================================
|
||||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageUpload(file: File | Blob): Promise<void> {
|
||||
try {
|
||||
showToast('图片上传中...')
|
||||
const result = await uploadFile(file)
|
||||
const fileName = file instanceof File ? file.name : '截图'
|
||||
await store.sendNewMessage(`[图片] ${fileName}`, {
|
||||
msg_type: 'image',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
showToast('图片发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('图片上传失败:', error)
|
||||
showToast(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'图片上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(file: File | Blob): Promise<void> {
|
||||
try {
|
||||
const fileName = file instanceof File ? file.name : '文件'
|
||||
showToast(`文件上传中: ${fileName}`)
|
||||
const result = await uploadFile(file)
|
||||
await store.sendNewMessage(`[文件] ${result.filename}`, {
|
||||
msg_type: 'file',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
showToast('文件发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
showToast(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'文件上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
showToast(`正在上传: ${file.name}`)
|
||||
const result = await uploadFile(file)
|
||||
showToast(`${file.name} 上传成功`)
|
||||
console.log('[InputBox] 文件上传成功:', result.url)
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
showToast(`${file.name} 上传失败`)
|
||||
}
|
||||
}
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function handleImage(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function handleFile(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = ''
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCamera(): void {
|
||||
if (cameraInputRef.value) {
|
||||
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.value = ''
|
||||
}
|
||||
|
||||
async function handleScreenshot(): Promise<void> {
|
||||
try {
|
||||
showToast('正在截取页面...')
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
foreignObjectRendering: false,
|
||||
removeContainer: true,
|
||||
})
|
||||
screenshotCanvas = canvas
|
||||
showScreenshotEditor.value = true
|
||||
} catch (error) {
|
||||
console.error('截图失败:', error)
|
||||
showToast('截图失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function onScreenshotConfirm(blob: Blob): Promise<void> {
|
||||
try {
|
||||
showToast('截图上传中...')
|
||||
const result = await uploadFile(blob, 'screenshot')
|
||||
await store.sendNewMessage('[截图]', {
|
||||
msg_type: 'image',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
showToast('截图发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('[InputBox] 截图发送失败:', error)
|
||||
showToast(
|
||||
`截图发送失败:${
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'未知错误'
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
}
|
||||
|
||||
function onScreenshotCancel(): void {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
输入框容器
|
||||
========================================================================== */
|
||||
.input-box {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.input-box__toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-box__tool-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-box__tool-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 输入区域 */
|
||||
.input-box__area {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-box__textarea {
|
||||
flex: 1;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
resize: none;
|
||||
line-height: 1.5;
|
||||
min-height: 60px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-box__textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-box__textarea:disabled {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 发送按钮 */
|
||||
.input-box__send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.input-box__send-btn--active {
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-box__send-btn--active:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.input-box__send-btn--active .send-icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 字数统计 */
|
||||
.input-box__counter {
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 底部引导条 */
|
||||
.input-box__guide {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.input-box__guide--active {
|
||||
color: var(--color-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表情选择面板 */
|
||||
.emoji-panel {
|
||||
position: relative;
|
||||
z-index: 200;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.emoji-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 36px);
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.emoji-panel__item {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emoji-panel__item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.emoji-panel__item:active {
|
||||
background: var(--accent-soft, rgba(59,130,246,0.15));
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.emoji-panel__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,675 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端消息气泡组件
|
||||
// =============================================================================
|
||||
// 说明:根据消息类型渲染不同样式的消息气泡
|
||||
// - 员工消息:靠右蓝底白字 (var(--accent))
|
||||
// - 坐席消息:靠左白底+边框,显示坐席名称
|
||||
// - AI消息:靠左绿底 (#07C160) + "[AI回复]" 标签
|
||||
// - 系统消息:居中灰字
|
||||
// 同步坐席端功能:
|
||||
// - 图片消息:缩略图展示(点击查看大图)
|
||||
// - 文件消息:文件卡片展示(点击下载)
|
||||
// - 引用回复:蓝色竖线 + 发送者 + 摘要
|
||||
// - 消息复制:长按/点击复制按钮
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 系统消息:居中灰色文字 -->
|
||||
<div v-if="msg.message_type === 'system'" class="message-bubble message-bubble--system">
|
||||
<span class="message-bubble__system-text">{{ msg.content }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 员工/坐席/AI 消息:气泡样式 -->
|
||||
<div
|
||||
v-else
|
||||
class="message-bubble"
|
||||
:class="bubbleClass"
|
||||
@mouseenter="showActions = true"
|
||||
@mouseleave="showActions = false"
|
||||
>
|
||||
<!-- 发送者名称(坐席和 AI 消息显示在左侧) -->
|
||||
<div v-if="msg.message_type !== 'employee'" class="message-bubble__sender">
|
||||
<!-- AI 消息显示 [AI回复] 标签 -->
|
||||
<span v-if="msg.message_type === 'ai'" class="message-bubble__ai-tag">[AI回复]</span>
|
||||
<span class="message-bubble__sender-name">{{ senderName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容气泡 -->
|
||||
<div class="message-bubble__content" :class="contentClass">
|
||||
<!-- 引用回复摘要(当此消息回复了某条消息时显示) -->
|
||||
<div
|
||||
v-if="msg.reply_to_id && replyToContent"
|
||||
class="reply-quote"
|
||||
@click.stop="$emit('scrollToMessage', msg.reply_to_id)"
|
||||
>
|
||||
<div class="reply-quote__bar"></div>
|
||||
<div class="reply-quote__text">
|
||||
<span class="reply-quote__sender">{{ replyToSender }}</span>
|
||||
{{ replyToContent }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<template v-if="!msg.msg_type || msg.msg_type === 'text'">
|
||||
<p class="message-bubble__text" style="white-space: pre-wrap;">{{ msg.content }}</p>
|
||||
</template>
|
||||
|
||||
<!-- 图片消息:显示缩略图(可点击查看大图) -->
|
||||
<template v-else-if="msg.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<img
|
||||
v-if="msg.media_url || msg.extra_data?.pic_url"
|
||||
:src="msg.media_url || msg.extra_data?.pic_url"
|
||||
:alt="msg.file_name || '图片'"
|
||||
class="image-message__thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- 无 URL 时显示占位卡片 -->
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">🖼️</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">图片消息</span>
|
||||
<span v-if="msg.file_name" class="media-card__name">{{ msg.file_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件消息:显示文件卡片 -->
|
||||
<template v-else-if="msg.msg_type === 'file'">
|
||||
<a
|
||||
v-if="msg.media_url"
|
||||
:href="msg.media_url"
|
||||
target="_blank"
|
||||
class="media-card media-card--link"
|
||||
@click.stop
|
||||
>
|
||||
<div class="media-card__icon">📎</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ msg.file_name || '文件消息' }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<!-- 无 URL 时显示纯卡片 -->
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">📎</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ msg.file_name || '文件消息' }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他非文本消息(语音/视频等):显示通用媒体卡片 -->
|
||||
<template v-else>
|
||||
<div class="media-card">
|
||||
<div class="media-card__icon">{{ mediaIcon }}</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ mediaTypeLabel }}</span>
|
||||
<span v-if="msg.file_name" class="media-card__name">{{ msg.file_name }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮组(hover/长按 显示):复制 -->
|
||||
<div v-if="showActions" class="message-bubble__actions">
|
||||
<button
|
||||
v-if="!msg.msg_type || msg.msg_type === 'text'"
|
||||
class="action-btn"
|
||||
title="复制消息"
|
||||
@click.stop="copyMessage"
|
||||
>
|
||||
{{ copySuccess ? '✓' : '📋' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息时间 -->
|
||||
<div class="message-bubble__time" :class="timeClass">
|
||||
{{ formatTime(msg.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MessageBubble 消息气泡组件
|
||||
* 根据消息类型(employee/agent/ai/system)和内容类型(text/image/file)渲染不同样式
|
||||
* 同步坐席端功能:图片消息、文件消息、引用回复、消息复制
|
||||
* @prop msg - 消息对象
|
||||
*/
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { showToast } from 'vant'
|
||||
import type { Message } from '@/api/conversation'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 消息对象 */
|
||||
msg: Message
|
||||
}>()
|
||||
|
||||
// 引用回复点击事件(模板中使用 $emit 触发)
|
||||
defineEmits<{
|
||||
/** 点击引用回复摘要,滚动到被回复的消息 */
|
||||
(e: 'scrollToMessage', messageId: string): void
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 剪贴板相关(消息复制功能)
|
||||
// ============================================================================
|
||||
|
||||
/** useClipboard:VueUse 提供的剪贴板操作组合函数 */
|
||||
const { copy } = useClipboard()
|
||||
|
||||
/** 是否显示操作按钮(鼠标悬停时显示) */
|
||||
const showActions = ref(false)
|
||||
|
||||
/** 复制成功反馈标识(1.5秒后自动消失) */
|
||||
const copySuccess = ref(false)
|
||||
|
||||
/**
|
||||
* 复制消息内容到剪贴板。
|
||||
* 复制成功后显示 ✓ 图标 1.5 秒,并弹出 Vant Toast 提示。
|
||||
*/
|
||||
async function copyMessage(): Promise<void> {
|
||||
try {
|
||||
await copy(props.msg.content)
|
||||
copySuccess.value = true
|
||||
showToast('已复制')
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 气泡容器的 CSS 类名 */
|
||||
const bubbleClass = computed(() => {
|
||||
const classes = [`message-bubble--${props.msg.message_type}`]
|
||||
// 乐观更新:发送中/失败状态添加额外样式
|
||||
if (props.msg.status === 'sending') {
|
||||
classes.push('message-bubble--sending')
|
||||
} else if (props.msg.status === 'failed') {
|
||||
classes.push('message-bubble--failed')
|
||||
}
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
/** 消息内容的 CSS 类名 */
|
||||
const contentClass = computed(() => {
|
||||
return `message-bubble__content--${props.msg.message_type}`
|
||||
})
|
||||
|
||||
/** 时间的 CSS 类名(员工消息时间靠右,其他靠左) */
|
||||
const timeClass = computed(() => {
|
||||
return props.msg.message_type === 'employee'
|
||||
? 'message-bubble__time--right'
|
||||
: 'message-bubble__time--left'
|
||||
})
|
||||
|
||||
/** 发送者名称 */
|
||||
const senderName = computed(() => {
|
||||
if (props.msg.message_type === 'agent') {
|
||||
return props.msg.sender_name || 'IT坐席'
|
||||
}
|
||||
if (props.msg.message_type === 'ai') {
|
||||
return 'AI助手'
|
||||
}
|
||||
return props.msg.sender_name
|
||||
})
|
||||
|
||||
/** 消息类型对应的 Emoji 图标 */
|
||||
const mediaIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
image: '🖼️',
|
||||
voice: '🎤',
|
||||
video: '🎬',
|
||||
file: '📎',
|
||||
location: '📍',
|
||||
}
|
||||
return icons[props.msg.msg_type || ''] || '📄'
|
||||
})
|
||||
|
||||
/** 消息类型对应的中文标签 */
|
||||
const mediaTypeLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
image: '图片消息',
|
||||
voice: '语音消息',
|
||||
video: '视频消息',
|
||||
file: '文件消息',
|
||||
location: '位置消息',
|
||||
}
|
||||
return labels[props.msg.msg_type || ''] || '媒体消息'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 引用回复相关计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 被回复的消息内容摘要(截取前50字) */
|
||||
const replyToContent = computed(() => {
|
||||
if (!props.msg.reply_to_id) return ''
|
||||
// H5 端暂从 store 的消息列表查找(消息列表可能不包含被引用消息)
|
||||
// 找不到时显示省略号
|
||||
return '...'
|
||||
})
|
||||
|
||||
/** 被回复的消息发送者 */
|
||||
const replyToSender = computed(() => {
|
||||
if (!props.msg.reply_to_id) return ''
|
||||
return ''
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 工具方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 格式化时间显示
|
||||
* 将 ISO 时间字符串格式化为 HH:mm 格式
|
||||
* @param isoTime ISO 格式的时间字符串
|
||||
* @returns 格式化后的时间(如 "14:30")
|
||||
*/
|
||||
function formatTime(isoTime: string): string {
|
||||
if (!isoTime) return ''
|
||||
const date = new Date(isoTime)
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小为人类可读字符串
|
||||
* @param bytes - 文件大小(字节)
|
||||
* @returns 格式化后的字符串,如 "1.5 MB"
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览:点击图片缩略图时,在新标签页打开大图
|
||||
*/
|
||||
function previewImage(): void {
|
||||
const url = props.msg.media_url || props.msg.extra_data?.pic_url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 消息气泡容器(通用)
|
||||
// ============================================================================ */
|
||||
.message-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ====== 员工消息:靠右 ====== */
|
||||
.message-bubble--employee {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* ====== 坐席消息:靠左 ====== */
|
||||
.message-bubble--agent {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ====== AI 消息:靠左 ====== */
|
||||
.message-bubble--ai {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ====== 系统消息:居中 ====== */
|
||||
.message-bubble--system {
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 系统消息
|
||||
// ============================================================================ */
|
||||
.message-bubble__system-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-system-text);
|
||||
background-color: var(--color-system-bg);
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 发送者信息
|
||||
// ============================================================================ */
|
||||
|
||||
/* 发送者名称行 */
|
||||
.message-bubble__sender {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* AI 标签 */
|
||||
.message-bubble__ai-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
color: var(--color-ai-tag-text);
|
||||
background-color: var(--color-ai-tag-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 发送者名称 */
|
||||
.message-bubble__sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息内容气泡
|
||||
// ============================================================================ */
|
||||
|
||||
/* 通用内容气泡 */
|
||||
.message-bubble__content {
|
||||
max-width: 75%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 员工消息内容:蓝底白字 */
|
||||
.message-bubble__content--employee {
|
||||
background-color: var(--color-employee-bg);
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* 坐席消息内容:白底+边框(与坐席端一致的边框样式) */
|
||||
.message-bubble__content--agent {
|
||||
background-color: var(--color-agent-bg);
|
||||
border-top-left-radius: 4px;
|
||||
border: 1px solid var(--color-agent-border);
|
||||
}
|
||||
|
||||
/* AI 消息内容:绿底 */
|
||||
.message-bubble__content--ai {
|
||||
background-color: var(--color-ai-bg);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息文字颜色
|
||||
// ============================================================================ */
|
||||
.message-bubble__text {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 员工消息文字:白色 */
|
||||
.message-bubble__content--employee .message-bubble__text {
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 坐席消息文字 */
|
||||
.message-bubble__content--agent .message-bubble__text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* AI 消息文字 */
|
||||
.message-bubble__content--ai .message-bubble__text {
|
||||
color: var(--color-ai-text);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息时间
|
||||
// ============================================================================ */
|
||||
.message-bubble__time {
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* 时间靠右(员工消息) */
|
||||
.message-bubble__time--right {
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 时间靠左(坐席/AI 消息) */
|
||||
.message-bubble__time--left {
|
||||
text-align: left;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 引用回复样式(同步坐席端 ReplyQuote)
|
||||
// ============================================================================ */
|
||||
.reply-quote {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.reply-quote:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reply-quote__bar {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-quote__text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.reply-quote__sender {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 图片消息样式
|
||||
// ============================================================================ */
|
||||
.image-message {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.image-message__thumbnail {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.image-message__thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 文件消息媒体卡片样式
|
||||
// ============================================================================ */
|
||||
.media-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.media-card__icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-card__label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-card__name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-card__size {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 文件卡片链接样式 */
|
||||
.media-card--link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.media-card--link:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 操作按钮组 — 悬停在消息气泡上时显示
|
||||
// ============================================================================ */
|
||||
.message-bubble__actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 乐观更新 UI - 发送状态样式
|
||||
// ============================================================================ */
|
||||
|
||||
/* 发送中状态:半透明 + spinner */
|
||||
.message-bubble--sending .message-bubble__content {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.message-bubble--sending .message-bubble__content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--text-placeholder);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 发送失败状态:红色边框 + 重试按钮 */
|
||||
.message-bubble--failed .message-bubble__content {
|
||||
border: 2px solid #ee0a24;
|
||||
}
|
||||
|
||||
.message-bubble--failed .message-bubble__content::after {
|
||||
content: '重试';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 12px;
|
||||
color: #ee0a24;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #ee0a24;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-bubble--failed .message-bubble__content::after:hover {
|
||||
background: #ee0a24;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,648 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端消息气泡组件
|
||||
// =============================================================================
|
||||
// 说明:单条消息的气泡展示
|
||||
// 功能:
|
||||
// - 长按/右键弹出操作菜单:复制、撤回(2分钟内)、删除
|
||||
// - 消息状态显示:发送中、已发送、已送达、已读
|
||||
// - 时间戳显示规则:同日期只显示时间,不同日期显示月日时间
|
||||
// - 消息类型:文本、图片、文件、语音、系统消息
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 系统消息:居中灰色文字 -->
|
||||
<div v-if="msg.message_type === 'system'" class="message-item message-item--system">
|
||||
<span class="message-item__system-text">{{ msg.content }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 非系统消息 -->
|
||||
<div
|
||||
v-else
|
||||
class="message-item"
|
||||
:class="bubbleClass"
|
||||
@contextmenu.prevent="showContextMenu"
|
||||
@longpress="showContextMenu"
|
||||
>
|
||||
<!-- 发送者名称(坐席和 AI 消息显示在左侧) -->
|
||||
<div v-if="msg.message_type !== 'employee'" class="message-item__sender">
|
||||
<span v-if="msg.message_type === 'ai'" class="message-item__ai-tag">[AI回复]</span>
|
||||
<span class="message-item__sender-name">{{ senderName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容气泡 -->
|
||||
<div class="message-item__content" :class="contentClass">
|
||||
<!-- 文本消息 -->
|
||||
<template v-if="!msg.msg_type || msg.msg_type === 'text'">
|
||||
<p class="message-item__text" style="white-space: pre-wrap;">{{ msg.content }}</p>
|
||||
</template>
|
||||
|
||||
<!-- 图片消息:显示缩略图(可点击查看大图) -->
|
||||
<template v-else-if="msg.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<img
|
||||
v-if="msg.media_url || msg.extra_data?.pic_url"
|
||||
:src="msg.media_url || msg.extra_data?.pic_url"
|
||||
:alt="msg.file_name || '图片'"
|
||||
class="image-message__thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">🖼️</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">图片消息</span>
|
||||
<span v-if="msg.file_name" class="media-card__name">{{ msg.file_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件消息:显示文件卡片 -->
|
||||
<template v-else-if="msg.msg_type === 'file'">
|
||||
<a
|
||||
v-if="msg.media_url"
|
||||
:href="msg.media_url"
|
||||
target="_blank"
|
||||
class="media-card media-card--link"
|
||||
>
|
||||
<div class="media-card__icon">📎</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ msg.file_name || '文件消息' }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">📎</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ msg.file_name || '文件消息' }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他非文本消息 -->
|
||||
<template v-else>
|
||||
<div class="media-card">
|
||||
<div class="media-card__icon">{{ mediaIcon }}</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ mediaTypeLabel }}</span>
|
||||
<span v-if="msg.file_name" class="media-card__name">{{ msg.file_name }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 消息状态图标 -->
|
||||
<div v-if="showStatusIcon" class="message-item__status">
|
||||
{{ statusIcon }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<div class="message-item__time" :class="timeClass">
|
||||
{{ formatTime(msg.created_at) }}
|
||||
</div>
|
||||
|
||||
<!-- 操作菜单(长按/右键显示) -->
|
||||
<div v-if="contextMenuVisible" class="context-menu" :style="contextMenuStyle">
|
||||
<button class="context-menu__item" @click.stop="copyMessage">
|
||||
📋 复制
|
||||
</button>
|
||||
<button
|
||||
v-if="canRecall"
|
||||
class="context-menu__item"
|
||||
@click.stop="recallMessage"
|
||||
>
|
||||
↩️ 撤回
|
||||
</button>
|
||||
<button
|
||||
v-if="msg.message_type === 'employee'"
|
||||
class="context-menu__item context-menu__item--danger"
|
||||
@click.stop="deleteMessage"
|
||||
>
|
||||
🗑️ 删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 遮罩层(点击关闭菜单) -->
|
||||
<div v-if="contextMenuVisible" class="context-menu__overlay" @click="closeContextMenu"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MessageItem 消息气泡组件
|
||||
* 长按/右键弹出操作菜单:复制、撤回(2分钟内)、删除
|
||||
* 消息状态显示:发送中、已发送、已送达、已读
|
||||
* 时间戳显示规则:同日期只显示时间,不同日期显示月日时间
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { showToast } from 'vant'
|
||||
import type { Message } from '@/api/conversation'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 消息对象 */
|
||||
msg: Message
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 撤回消息 */
|
||||
(e: 'recall', messageId: string): void
|
||||
/** 删除消息 */
|
||||
(e: 'delete', messageId: string): void
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 剪贴板相关
|
||||
// ============================================================================
|
||||
const { copy } = useClipboard()
|
||||
|
||||
/** 是否显示操作菜单 */
|
||||
const contextMenuVisible = ref(false)
|
||||
|
||||
/** 操作菜单位置 */
|
||||
const contextMenuStyle = ref<Record<string, string>>({})
|
||||
|
||||
/** 复制成功反馈 */
|
||||
const copySuccess = ref(false)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 气泡容器的 CSS 类名 */
|
||||
const bubbleClass = computed(() => {
|
||||
const classes = [`message-item--${props.msg.message_type}`]
|
||||
if (props.msg.status === 'sending') {
|
||||
classes.push('message-item--sending')
|
||||
} else if (props.msg.status === 'failed') {
|
||||
classes.push('message-item--failed')
|
||||
}
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
/** 消息内容的 CSS 类名 */
|
||||
const contentClass = computed(() => {
|
||||
return `message-item__content--${props.msg.message_type}`
|
||||
})
|
||||
|
||||
/** 时间的 CSS 类名 */
|
||||
const timeClass = computed(() => {
|
||||
return props.msg.message_type === 'employee'
|
||||
? 'message-item__time--right'
|
||||
: 'message-item__time--left'
|
||||
})
|
||||
|
||||
/** 发送者名称 */
|
||||
const senderName = computed(() => {
|
||||
if (props.msg.message_type === 'agent') {
|
||||
return props.msg.sender_name || 'IT坐席'
|
||||
}
|
||||
if (props.msg.message_type === 'ai') {
|
||||
return 'AI助手'
|
||||
}
|
||||
return props.msg.sender_name
|
||||
})
|
||||
|
||||
/** 是否显示状态图标 */
|
||||
const showStatusIcon = computed(() => {
|
||||
return props.msg.message_type === 'employee' && props.msg.status
|
||||
})
|
||||
|
||||
/** 状态图标 */
|
||||
const statusIcon = computed(() => {
|
||||
const statusMap: Record<string, string> = {
|
||||
sending: '⏳',
|
||||
sent: '✓',
|
||||
delivered: '✓✓',
|
||||
read: '✓✓',
|
||||
}
|
||||
return statusMap[props.msg.status || ''] || ''
|
||||
})
|
||||
|
||||
/** 是否可以撤回(2分钟内) */
|
||||
const canRecall = computed(() => {
|
||||
if (props.msg.message_type !== 'employee') return false
|
||||
if (!props.msg.created_at) return false
|
||||
const createdAt = new Date(props.msg.created_at)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - createdAt.getTime()
|
||||
const diffMinutes = diffMs / (1000 * 60)
|
||||
return diffMinutes <= 2
|
||||
})
|
||||
|
||||
/** 消息类型对应的 Emoji 图标 */
|
||||
const mediaIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
image: '🖼️',
|
||||
voice: '🎤',
|
||||
video: '🎬',
|
||||
file: '📎',
|
||||
location: '📍',
|
||||
}
|
||||
return icons[props.msg.msg_type || ''] || '📄'
|
||||
})
|
||||
|
||||
/** 消息类型对应的中文标签 */
|
||||
const mediaTypeLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
image: '图片消息',
|
||||
voice: '语音消息',
|
||||
video: '视频消息',
|
||||
file: '文件消息',
|
||||
location: '位置消息',
|
||||
}
|
||||
return labels[props.msg.msg_type || ''] || '媒体消息'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 操作菜单
|
||||
// ============================================================================
|
||||
function showContextMenu(event: MouseEvent | TouchEvent): void {
|
||||
// 计算菜单位置
|
||||
let clientX = 0
|
||||
let clientY = 0
|
||||
|
||||
if ('clientX' in event) {
|
||||
clientX = event.clientX
|
||||
clientY = event.clientY
|
||||
} else {
|
||||
clientX = event.touches[0].clientX
|
||||
clientY = event.touches[0].clientY
|
||||
}
|
||||
|
||||
contextMenuStyle.value = {
|
||||
left: `${clientX}px`,
|
||||
top: `${clientY}px`,
|
||||
}
|
||||
contextMenuVisible.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu(): void {
|
||||
contextMenuVisible.value = false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息操作
|
||||
// ============================================================================
|
||||
async function copyMessage(): Promise<void> {
|
||||
try {
|
||||
await copy(props.msg.content)
|
||||
copySuccess.value = true
|
||||
showToast('已复制')
|
||||
closeContextMenu()
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function recallMessage(): void {
|
||||
emit('recall', props.msg.message_id)
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function deleteMessage(): void {
|
||||
emit('delete', props.msg.message_id)
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 格式化时间显示
|
||||
* 同日期只显示时间(HH:mm),不同日期显示月日时间(MM-DD HH:mm)
|
||||
*/
|
||||
function formatTime(isoTime: string): string {
|
||||
if (!isoTime) return ''
|
||||
const date = new Date(isoTime)
|
||||
const now = new Date()
|
||||
const isSameDay = date.toDateString() === now.toDateString()
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
|
||||
if (isSameDay) {
|
||||
return `${hours}:${minutes}`
|
||||
} else {
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览
|
||||
*/
|
||||
function previewImage(): void {
|
||||
const url = props.msg.media_url || props.msg.extra_data?.pic_url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 消息气泡容器
|
||||
// ============================================================================ */
|
||||
.message-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 16px;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 员工消息:靠右 */
|
||||
.message-item--employee {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 坐席消息:靠左 */
|
||||
.message-item--agent {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* AI 消息:靠左 */
|
||||
.message-item--ai {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 系统消息:居中 */
|
||||
.message-item--system {
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 系统消息
|
||||
// ============================================================================ */
|
||||
.message-item__system-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-system-text);
|
||||
background-color: var(--color-system-bg);
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 发送者信息
|
||||
// ============================================================================ */
|
||||
.message-item__sender {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.message-item__ai-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
color: var(--color-ai-tag-text);
|
||||
background-color: var(--color-ai-tag-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-item__sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息内容气泡
|
||||
// ============================================================================ */
|
||||
.message-item__content {
|
||||
max-width: 75%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 员工消息内容:蓝底白字 */
|
||||
.message-item__content--employee {
|
||||
background-color: var(--color-employee-bg);
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* 坐席消息内容:白底+边框 */
|
||||
.message-item__content--agent {
|
||||
background-color: var(--color-agent-bg);
|
||||
border-top-left-radius: 4px;
|
||||
border: 1px solid var(--color-agent-border);
|
||||
}
|
||||
|
||||
/* AI 消息内容:绿底 */
|
||||
.message-item__content--ai {
|
||||
background-color: var(--color-ai-bg);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息文字颜色
|
||||
// ============================================================================ */
|
||||
.message-item__text {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-item__content--employee .message-item__text {
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.message-item__content--agent .message-item__text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-item__content--ai .message-item__text {
|
||||
color: var(--color-ai-text);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息状态
|
||||
// ============================================================================ */
|
||||
.message-item__status {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息时间
|
||||
// ============================================================================ */
|
||||
.message-item__time {
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.message-item__time--right {
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.message-item__time--left {
|
||||
text-align: left;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 图片消息样式
|
||||
// ============================================================================ */
|
||||
.image-message {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.image-message__thumbnail {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.image-message__thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 文件消息媒体卡片样式
|
||||
// ============================================================================ */
|
||||
.media-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.media-card__icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-card__label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-card__name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-card__size {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.media-card--link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.media-card--link:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 操作菜单
|
||||
// ============================================================================ */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.context-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.context-menu__item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.context-menu__item--danger {
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
.context-menu__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 发送状态样式
|
||||
// ============================================================================ */
|
||||
.message-item--sending .message-item__content {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.message-item--failed .message-item__content {
|
||||
border: 2px solid #ee0a24;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端消息列表组件
|
||||
// =============================================================================
|
||||
// 说明:消息列表容器,包含:
|
||||
// - 进入会话自动标记已读
|
||||
// - 会话列表显示最后更新时间
|
||||
// - 未读消息角标
|
||||
// - 消息轮询
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="listRef"
|
||||
class="message-list"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<!-- 消息列表 -->
|
||||
<MessageItem
|
||||
v-for="msg in messages"
|
||||
:key="msg.message_id"
|
||||
:msg="msg"
|
||||
@recall="handleRecall"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- 加载更多指示器 -->
|
||||
<div v-if="loading" class="message-list__loading">
|
||||
<van-loading size="20px" />
|
||||
</div>
|
||||
|
||||
<!-- 无消息提示 -->
|
||||
<div v-if="!loading && messages.length === 0" class="message-list__empty">
|
||||
<div class="message-list__empty-icon">💬</div>
|
||||
<p>暂无消息</p>
|
||||
<p class="message-list__empty-hint">输入问题咨询,或 🔔 摇铃呼叫坐席</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MessageList 消息列表组件
|
||||
* 进入会话自动标记已读
|
||||
* 消息轮询
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { pollMessages, markConversationRead, recallMessage, deleteMessage } from '@/api/message'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 消息列表 DOM 引用 */
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 轮询定时器 */
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 消息列表 */
|
||||
const messages = ref(store.messages)
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
onMounted(() => {
|
||||
// 进入会话自动标记已读
|
||||
markAsRead()
|
||||
|
||||
// 启动轮询
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
watch(
|
||||
() => store.messages,
|
||||
(newMessages) => {
|
||||
messages.value = newMessages
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 标记已读 */
|
||||
async function markAsRead(): Promise<void> {
|
||||
const convId = store.currentConversation?.conversation_id
|
||||
if (!convId) return
|
||||
|
||||
try {
|
||||
await markConversationRead(convId)
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动轮询 */
|
||||
function startPolling(): void {
|
||||
pollTimer = setInterval(async () => {
|
||||
await fetchNewMessages()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/** 停止轮询 */
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取新消息 */
|
||||
async function fetchNewMessages(): Promise<void> {
|
||||
if (store.loading) return
|
||||
|
||||
const convId = store.currentConversation?.conversation_id
|
||||
if (!convId) return
|
||||
|
||||
try {
|
||||
const lastMsg = messages.value[messages.value.length - 1]
|
||||
const afterMessageId = lastMsg?.message_id
|
||||
|
||||
const newMessages = await pollMessages(convId, afterMessageId)
|
||||
|
||||
if (newMessages && newMessages.length > 0) {
|
||||
// 添加新消息到列表
|
||||
for (const msg of newMessages) {
|
||||
store.messages.push(msg)
|
||||
}
|
||||
|
||||
// 自动滚动到底部
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取新消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动到底部 */
|
||||
function scrollToBottom(): void {
|
||||
nextTick(() => {
|
||||
if (listRef.value) {
|
||||
listRef.value.scrollTop = listRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 处理滚动 */
|
||||
function handleScroll(): void {
|
||||
// 可以在这里实现加载更多历史消息
|
||||
}
|
||||
|
||||
/** 处理撤回 */
|
||||
async function handleRecall(messageId: string): Promise<void> {
|
||||
try {
|
||||
await recallMessage(messageId)
|
||||
showToast('消息已撤回')
|
||||
|
||||
// 从列表中移除消息
|
||||
const index = messages.value.findIndex((m: any) => m.message_id === messageId)
|
||||
if (index !== -1) {
|
||||
messages.value.splice(index, 1)
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || '撤回失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理删除 */
|
||||
async function handleDelete(messageId: string): Promise<void> {
|
||||
try {
|
||||
await deleteMessage(messageId)
|
||||
showToast('消息已删除')
|
||||
|
||||
// 从列表中移除消息
|
||||
const index = messages.value.findIndex((m: any) => m.message_id === messageId)
|
||||
if (index !== -1) {
|
||||
messages.value.splice(index, 1)
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:nextTick
|
||||
function nextTick(fn: () => void): void {
|
||||
setTimeout(fn, 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 消息列表容器
|
||||
// ============================================================================ */
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.message-list__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.message-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-list__empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-list__empty p {
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-list__empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端参与者列表组件
|
||||
// =============================================================================
|
||||
// 说明:展示会话中所有参与者信息
|
||||
// - 被邀请人列表(真实头像 + 姓名 + 部门 + 加入状态)
|
||||
// - 当前用户高亮标识
|
||||
// - 退出会话按钮(仅被邀请人可见)
|
||||
// - 头像降级策略:有avatar URL用<img>,无则显示姓名首字
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="participant-list">
|
||||
<!-- 参与者列表 -->
|
||||
<div class="participant-list__items">
|
||||
<!-- 原始员工(会话发起人) -->
|
||||
<div v-if="ownerInfo" class="participant-item participant-item--owner">
|
||||
<div class="participant-item__avatar">
|
||||
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
||||
<img
|
||||
v-if="ownerInfo.avatar"
|
||||
:src="ownerInfo.avatar"
|
||||
:alt="ownerInfo.name"
|
||||
class="participant-item__avatar-img"
|
||||
@error="onAvatarError($event)"
|
||||
/>
|
||||
<span v-else class="participant-item__avatar-letter">
|
||||
{{ avatarLetter(ownerInfo.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="participant-item__info">
|
||||
<span class="participant-item__name">{{ ownerInfo.name }}</span>
|
||||
<span v-if="ownerInfo.department" class="participant-item__dept">{{ ownerInfo.department }}</span>
|
||||
</div>
|
||||
<span class="participant-item__badge participant-item__badge--owner">发起人</span>
|
||||
</div>
|
||||
|
||||
<!-- 主责坐席 -->
|
||||
<div v-if="agentInfo" class="participant-item participant-item--agent">
|
||||
<div class="participant-item__avatar participant-item__avatar--agent">
|
||||
<span class="participant-item__avatar-letter">
|
||||
{{ avatarLetter(agentInfo.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="participant-item__info">
|
||||
<span class="participant-item__name">{{ agentInfo.name }}</span>
|
||||
<span class="participant-item__dept">IT坐席</span>
|
||||
</div>
|
||||
<span class="participant-item__badge participant-item__badge--agent">坐席</span>
|
||||
</div>
|
||||
|
||||
<!-- 被邀请参与者 -->
|
||||
<div
|
||||
v-for="p in invitedParticipants"
|
||||
:key="p.id"
|
||||
class="participant-item"
|
||||
:class="{ 'participant-item--self': p.id === currentUserId }"
|
||||
>
|
||||
<div class="participant-item__avatar">
|
||||
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
||||
<img
|
||||
v-if="p.avatar"
|
||||
:src="p.avatar"
|
||||
:alt="p.name"
|
||||
class="participant-item__avatar-img"
|
||||
@error="onAvatarError($event)"
|
||||
/>
|
||||
<span v-else class="participant-item__avatar-letter">
|
||||
{{ avatarLetter(p.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="participant-item__info">
|
||||
<span class="participant-item__name">
|
||||
{{ p.name }}
|
||||
<span v-if="p.id === currentUserId" class="participant-item__self-tag">(我)</span>
|
||||
</span>
|
||||
<span v-if="p.department" class="participant-item__dept">{{ p.department }}</span>
|
||||
</div>
|
||||
<!-- 加入状态 -->
|
||||
<span
|
||||
v-if="p.joined"
|
||||
class="participant-item__badge participant-item__badge--joined"
|
||||
>已加入</span>
|
||||
<span
|
||||
v-else
|
||||
class="participant-item__badge participant-item__badge--pending"
|
||||
>待加入</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 退出按钮(仅被邀请参与者可见) -->
|
||||
<div v-if="store.isParticipant" class="participant-list__footer">
|
||||
<button
|
||||
class="participant-list__leave-btn"
|
||||
:disabled="leaving"
|
||||
@click="handleLeave"
|
||||
>
|
||||
{{ leaving ? '退出中...' : '退出会话' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ParticipantList 参与者列表组件
|
||||
* 展示会话中所有参与者信息(含真实头像),被邀请人可退出
|
||||
*
|
||||
* 头像策略:
|
||||
* 1. 被邀请参与者:后端 invite_participants 时从 employees 表/企微API获取 avatar,存入 participants JSON
|
||||
* 2. 原始员工(发起人):从 employeeStore.employeeInfo.avatar 获取当前用户头像
|
||||
* 3. 坐席:暂无头像接口,使用首字母降级
|
||||
* 4. 所有 <img> 加载失败时,自动降级显示姓名首字
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { showToast, showConfirmDialog } from 'vant'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
import type { ParticipantItem } from '@/api/conversation'
|
||||
|
||||
const store = useConversationStore()
|
||||
const employeeStore = useEmployeeStore()
|
||||
|
||||
/** 退出操作进行中 */
|
||||
const leaving = ref(false)
|
||||
|
||||
/** 当前登录用户 ID */
|
||||
const currentUserId = computed(() => store.userInfo?.employee_id || '')
|
||||
|
||||
/** 原始员工信息(会话发起人),含头像 */
|
||||
const ownerInfo = computed(() => {
|
||||
const conv = store.currentConversation
|
||||
if (!conv) return null
|
||||
|
||||
// 判断发起人是否是当前用户
|
||||
const isSelf = conv.employee_id === currentUserId.value
|
||||
return {
|
||||
// 当前用户显示"我",其他用户显示真实姓名(从会话 employee_name 获取)
|
||||
name: isSelf ? '我' : (conv.employee_name || '员工'),
|
||||
department: '',
|
||||
// 当前用户的头像从 employeeStore 获取,其他用户暂无头像接口
|
||||
avatar: isSelf ? (employeeStore.employeeInfo?.avatar || '') : '',
|
||||
}
|
||||
})
|
||||
|
||||
/** 坐席信息(坐席头像暂无接口,使用首字母降级) */
|
||||
const agentInfo = computed(() => {
|
||||
const conv = store.currentConversation
|
||||
if (!conv?.agent_name) return null
|
||||
return { name: conv.agent_name }
|
||||
})
|
||||
|
||||
/** 被邀请的参与者(排除原始员工,avatar 由后端填充) */
|
||||
const invitedParticipants = computed<ParticipantItem[]>(() => {
|
||||
return store.participants || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取姓名首字作为降级头像
|
||||
* @param name - 姓名
|
||||
*/
|
||||
function avatarLetter(name: string): string {
|
||||
return (name || '?').charAt(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像加载失败时的降级处理
|
||||
* 做什么:隐藏 <img>,显示父容器的首字降级
|
||||
* 为什么:企微头像 URL 可能过期或网络异常
|
||||
*/
|
||||
function onAvatarError(event: Event): void {
|
||||
const img = event.target as HTMLImageElement
|
||||
// 隐藏图片元素,让 CSS 显示首字降级
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出会话
|
||||
* 做什么:被邀请人主动退出当前会话
|
||||
* 为什么:参与者不再需要参与时,可自行退出
|
||||
*/
|
||||
async function handleLeave(): Promise<void> {
|
||||
try {
|
||||
// 二次确认
|
||||
await showConfirmDialog({
|
||||
title: '退出会话',
|
||||
message: '确定要退出该会话吗?退出后将无法查看会话消息。',
|
||||
confirmButtonText: '确定退出',
|
||||
cancelButtonText: '再想想',
|
||||
confirmButtonColor: '#ee0a24',
|
||||
})
|
||||
|
||||
leaving.value = true
|
||||
await store.leaveAsParticipant()
|
||||
showToast('已退出会话')
|
||||
// 退出成功后收起面板
|
||||
store.participantPanelVisible = false
|
||||
} catch (err: any) {
|
||||
// 用户取消确认时 err 为 'cancel',不提示错误
|
||||
if (err !== 'cancel') {
|
||||
console.error('[ParticipantList] 退出失败:', err)
|
||||
showToast('退出失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
leaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 参与者列表容器 */
|
||||
.participant-list {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 参与者项 */
|
||||
.participant-list__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 单个参与者 */
|
||||
.participant-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.participant-item:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* 当前用户高亮 */
|
||||
.participant-item--self {
|
||||
background-color: var(--accent-soft, rgba(59, 130, 246, 0.08));
|
||||
}
|
||||
|
||||
/* 头像容器(圆形,默认渐变背景 + 首字) */
|
||||
.participant-item__avatar {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.participant-item__avatar--agent {
|
||||
background: linear-gradient(135deg, #07C160, #00b347);
|
||||
}
|
||||
|
||||
/* 真实头像图片(覆盖在容器上) */
|
||||
.participant-item__avatar-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 首字降级(无头像时显示) */
|
||||
.participant-item__avatar-letter {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 信息区 */
|
||||
.participant-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.participant-item__name {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.participant-item__self-tag {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.participant-item__dept {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 状态徽标 */
|
||||
.participant-item__badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.participant-item__badge--owner {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.participant-item__badge--agent {
|
||||
background-color: rgba(7, 193, 96, 0.1);
|
||||
color: #07C160;
|
||||
}
|
||||
|
||||
.participant-item__badge--joined {
|
||||
background-color: rgba(7, 193, 96, 0.1);
|
||||
color: #07C160;
|
||||
}
|
||||
|
||||
.participant-item__badge--pending {
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
/* 底部退出按钮区域 */
|
||||
.participant-list__footer {
|
||||
padding: 12px 12px 4px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 退出按钮 */
|
||||
.participant-list__leave-btn {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
border: 1px solid #ee0a24;
|
||||
background: transparent;
|
||||
color: #ee0a24;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.participant-list__leave-btn:hover:not(:disabled) {
|
||||
background-color: rgba(238, 10, 36, 0.06);
|
||||
}
|
||||
|
||||
.participant-list__leave-btn:active:not(:disabled) {
|
||||
background-color: rgba(238, 10, 36, 0.12);
|
||||
}
|
||||
|
||||
.participant-list__leave-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,555 @@
|
||||
<template>
|
||||
<!--
|
||||
ScreenshotEditor H5 版 - 区域选择截图组件
|
||||
对标微信/企微截图体验,适配触摸操作
|
||||
1. 全屏遮罩,背景显示页面截图(变暗)
|
||||
2. 触摸拖拽框选区域
|
||||
3. 释放后显示选区和工具栏(确认/取消/重新选择)
|
||||
4. 确认后裁剪选中区域并 emit 回去
|
||||
-->
|
||||
<div class="screenshot-editor-overlay">
|
||||
<!-- 背景:页面截图(变暗) -->
|
||||
<canvas
|
||||
ref="canvasBgRef"
|
||||
class="screenshot-editor-bg"
|
||||
></canvas>
|
||||
|
||||
<!-- 选区绘制层(跟随触摸拖拽) -->
|
||||
<!--
|
||||
注意:触摸事件不使用 Vue 的 @touchstart/@touchmove 绑定,
|
||||
因为 Vue 在移动端默认用 passive 模式绑定,无法调用 preventDefault()。
|
||||
改为在 onMounted 中用 addEventListener 手动绑定非 passive 监听器。
|
||||
-->
|
||||
<div
|
||||
ref="selectionLayerRef"
|
||||
class="screenshot-selection-layer"
|
||||
>
|
||||
<!-- 暗色遮罩 -->
|
||||
<div class="screenshot-dark-overlay" :style="darkOverlayStyle"></div>
|
||||
|
||||
<!-- 选区边框 -->
|
||||
<div
|
||||
v-if="selecting || selectionComplete"
|
||||
class="screenshot-selection-box"
|
||||
:style="selectionBoxStyle"
|
||||
>
|
||||
<!-- 选区尺寸提示 -->
|
||||
<div class="screenshot-size-tip" v-if="selectionComplete">
|
||||
{{ selectionWidth }} × {{ selectionHeight }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏(选区完成后显示) -->
|
||||
<div v-if="selectionComplete" class="screenshot-toolbar">
|
||||
<button class="screenshot-toolbar-btn" @click="handleCancel" title="取消截图">
|
||||
取消
|
||||
</button>
|
||||
<button class="screenshot-toolbar-btn" @click="handleReselect" title="重新选择">
|
||||
重选
|
||||
</button>
|
||||
<button class="screenshot-toolbar-btn screenshot-toolbar-btn--primary" @click="handleConfirm" title="确认截图">
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 提示文字(未选区时显示) -->
|
||||
<div v-if="!selecting && !selectionComplete" class="screenshot-tip">
|
||||
按住屏幕拖拽选择截图区域,返回键 取消
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ScreenshotEditor H5 版
|
||||
* 做什么:实现触摸友好的区域截图功能
|
||||
* 为什么:对标微信/企微截图体验,适配移动端
|
||||
*
|
||||
* 交互流程:
|
||||
* 1. 传入页面截图的 canvas/image
|
||||
* 2. 用户触摸拖拽选择区域
|
||||
* 3. 确认后裁剪选中区域,通过 emit 返回 Blob
|
||||
* 4. 取消则关闭编辑器
|
||||
*
|
||||
* Props:
|
||||
* - visible: 是否显示编辑器
|
||||
* - screenshotCanvas: html2canvas 生成的完整页面截图(Canvas)
|
||||
*
|
||||
* Emits:
|
||||
* - confirm: 确认截图,参数是裁剪后图片的 Blob
|
||||
* - cancel: 取消截图
|
||||
*/
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
// ========== Props & Emits ==========
|
||||
interface Props {
|
||||
/** html2canvas 生成的完整页面截图 Canvas 对象 */
|
||||
screenshotCanvas: HTMLCanvasElement | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
screenshotCanvas: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', blob: Blob): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
// ========== Refs ==========
|
||||
const canvasBgRef = ref<HTMLCanvasElement | null>(null)
|
||||
const selectionLayerRef = ref<HTMLDivElement | null>(null) // 选区绘制层 DOM 引用
|
||||
|
||||
// 选区状态
|
||||
const selecting = ref(false) // 是否正在拖拽选区
|
||||
const selectionComplete = ref(false) // 选区是否完成
|
||||
const startX = ref(0) // 拖拽起点 X
|
||||
const startY = ref(0) // 拖拽起点 Y
|
||||
const endX = ref(0) // 拖拽终点 X
|
||||
const endY = ref(0) // 拖拽终点 Y
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
/** 选区左坐标(取 start/end 最小值) */
|
||||
const selectionLeft = computed(() => Math.min(startX.value, endX.value))
|
||||
/** 选区上坐标 */
|
||||
const selectionTop = computed(() => Math.min(startY.value, endY.value))
|
||||
/** 选区宽度 */
|
||||
const selectionWidth = computed(() => Math.abs(endX.value - startX.value))
|
||||
/** 选区高度 */
|
||||
const selectionHeight = computed(() => Math.abs(endY.value - startY.value))
|
||||
|
||||
/** 选区盒模型样式 */
|
||||
const selectionBoxStyle = computed(() => ({
|
||||
left: `${selectionLeft.value}px`,
|
||||
top: `${selectionTop.value}px`,
|
||||
width: `${selectionWidth.value}px`,
|
||||
height: `${selectionHeight.value}px`,
|
||||
}))
|
||||
|
||||
/** 暗色遮罩样式 */
|
||||
const darkOverlayStyle = computed(() => {
|
||||
if (!selectionComplete.value && !selecting.value) {
|
||||
return {} // 未选区时全暗
|
||||
}
|
||||
// 使用 clip-path 挖空选区
|
||||
const left = selectionLeft.value
|
||||
const top = selectionTop.value
|
||||
const width = selectionWidth.value
|
||||
const height = selectionHeight.value
|
||||
return {
|
||||
clipPath: selecting.value
|
||||
? 'none'
|
||||
: `polygon(0% 0%, 0% 100%, ${left}px ${top}px, ${left}px ${top + height}px, ${left + width}px ${top + height}px, ${left + width}px ${top}px, 0% 100%, 100% 100%, 100% 0%)`,
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(() => {
|
||||
// 将截图绘制到背景 canvas
|
||||
nextTick(() => {
|
||||
drawBackground()
|
||||
})
|
||||
|
||||
const layer = selectionLayerRef.value
|
||||
if (!layer) {
|
||||
console.error('[ScreenshotEditor] selectionLayerRef 为 null,无法绑定事件')
|
||||
return
|
||||
}
|
||||
|
||||
// ========= 触摸事件(移动端)=========
|
||||
// 手动绑定(非 passive 模式,允许 preventDefault 阻止页面滚动)
|
||||
// Vue 的 @touchstart/@touchmove 在移动端默认 passive,无法调用 preventDefault()
|
||||
layer.addEventListener('touchstart', onTouchStart, { passive: false })
|
||||
layer.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
layer.addEventListener('touchend', onTouchEnd)
|
||||
|
||||
// ========= 鼠标事件(桌面端 H5,企微桌面端占 70%)=========
|
||||
// 鼠标拖拽拖选区域(对标微信/企微桌面端截图体验)
|
||||
layer.addEventListener('mousedown', onMouseDown)
|
||||
// mousemove/mouseup 绑定在 document 上,防止拖拽出选区层后丢失事件
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
|
||||
console.log('[ScreenshotEditor] 事件绑定完成(触摸 + 鼠标)')
|
||||
|
||||
// 监听 ESC 键取消
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const layer = selectionLayerRef.value
|
||||
|
||||
// 清除触摸事件
|
||||
if (layer) {
|
||||
layer.removeEventListener('touchstart', onTouchStart)
|
||||
layer.removeEventListener('touchmove', onTouchMove)
|
||||
layer.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
// 清除鼠标事件(绑定在 document 上的也要清)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
console.log('[ScreenshotEditor] 事件已清除')
|
||||
})
|
||||
|
||||
// 监听 screenshotCanvas 变化,重新绘制背景
|
||||
watch(
|
||||
() => props.screenshotCanvas,
|
||||
() => {
|
||||
nextTick(() => drawBackground())
|
||||
}
|
||||
)
|
||||
|
||||
// ========== 方法 ==========
|
||||
|
||||
/** 将截图绘制到背景 canvas */
|
||||
function drawBackground(): void {
|
||||
const canvas = canvasBgRef.value
|
||||
if (!canvas || !props.screenshotCanvas) return
|
||||
|
||||
// 设置 canvas 尺寸为窗口大小
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// 将截图绘制到 canvas(覆盖整个窗口)
|
||||
ctx.drawImage(props.screenshotCanvas, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
/** 键盘事件:ESC 取消 */
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
|
||||
/** 触摸开始:开始选区 */
|
||||
function onTouchStart(e: TouchEvent): void {
|
||||
// 阻止浏览器默认行为(防止页面滚动/缩放干扰拖拽选区)
|
||||
e.preventDefault()
|
||||
|
||||
// 如果选区已完成,判断是否点击在选区外
|
||||
if (selectionComplete.value) {
|
||||
const touch = e.touches[0]
|
||||
const rect = getSelectionRect()
|
||||
if (
|
||||
touch.clientX < rect.left ||
|
||||
touch.clientX > rect.right ||
|
||||
touch.clientY < rect.top ||
|
||||
touch.clientY > rect.bottom
|
||||
) {
|
||||
// 点击在选区外,重新开始选区
|
||||
resetSelection()
|
||||
} else {
|
||||
// 点击在选区内,不处理
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
selecting.value = true
|
||||
const touch = e.touches[0]
|
||||
startX.value = touch.clientX
|
||||
startY.value = touch.clientY
|
||||
endX.value = touch.clientX
|
||||
endY.value = touch.clientY
|
||||
}
|
||||
|
||||
/** 触摸移动:更新选区 */
|
||||
function onTouchMove(e: TouchEvent): void {
|
||||
if (selecting.value) {
|
||||
// 阻止默认行为(防止页面滚动干扰拖拽)
|
||||
e.preventDefault()
|
||||
const touch = e.touches[0]
|
||||
endX.value = touch.clientX
|
||||
endY.value = touch.clientY
|
||||
}
|
||||
}
|
||||
|
||||
/** 触摸结束:完成选区 */
|
||||
function onTouchEnd(): void {
|
||||
if (selecting.value) {
|
||||
selecting.value = false
|
||||
|
||||
// 判断选区是否有效(最小 10x10)
|
||||
if (selectionWidth.value < 10 || selectionHeight.value < 10) {
|
||||
// 选区太小,忽略
|
||||
resetSelection()
|
||||
return
|
||||
}
|
||||
|
||||
selectionComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 鼠标事件处理(桌面端 H5 用)=========
|
||||
|
||||
/** 鼠标按下:开始选区 */
|
||||
function onMouseDown(e: MouseEvent): void {
|
||||
console.log('[ScreenshotEditor] mousedown', e.clientX, e.clientY)
|
||||
|
||||
// 如果选区已完成,判断是否点击在选区外
|
||||
if (selectionComplete.value) {
|
||||
const rect = getSelectionRect()
|
||||
if (
|
||||
e.clientX < rect.left ||
|
||||
e.clientX > rect.right ||
|
||||
e.clientY < rect.top ||
|
||||
e.clientY > rect.bottom
|
||||
) {
|
||||
// 点击在选区外,重新开始选区
|
||||
resetSelection()
|
||||
} else {
|
||||
// 点击在选区内,不处理(允许拖拽移动选区,后续可扩展)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
selecting.value = true
|
||||
startX.value = e.clientX
|
||||
startY.value = e.clientY
|
||||
endX.value = e.clientX
|
||||
endY.value = e.clientY
|
||||
}
|
||||
|
||||
/** 鼠标移动:更新选区 */
|
||||
function onMouseMove(e: MouseEvent): void {
|
||||
if (selecting.value) {
|
||||
endX.value = e.clientX
|
||||
endY.value = e.clientY
|
||||
}
|
||||
}
|
||||
|
||||
/** 鼠标抬起:完成选区 */
|
||||
function onMouseUp(): void {
|
||||
if (selecting.value) {
|
||||
selecting.value = false
|
||||
|
||||
// 判断选区是否有效(最小 10x10)
|
||||
if (selectionWidth.value < 10 || selectionHeight.value < 10) {
|
||||
resetSelection()
|
||||
return
|
||||
}
|
||||
|
||||
selectionComplete.value = true
|
||||
console.log('[ScreenshotEditor] 选区完成', getSelectionRect())
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置选区 */
|
||||
function resetSelection(): void {
|
||||
selecting.value = false
|
||||
selectionComplete.value = false
|
||||
startX.value = 0
|
||||
startY.value = 0
|
||||
endX.value = 0
|
||||
endY.value = 0
|
||||
}
|
||||
|
||||
/** 获取选区矩形(绝对坐标) */
|
||||
function getSelectionRect() {
|
||||
return {
|
||||
left: selectionLeft.value,
|
||||
top: selectionTop.value,
|
||||
right: selectionLeft.value + selectionWidth.value,
|
||||
bottom: selectionTop.value + selectionHeight.value,
|
||||
width: selectionWidth.value,
|
||||
height: selectionHeight.value,
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消截图 */
|
||||
function handleCancel(): void {
|
||||
resetSelection()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
/** 重新选择 */
|
||||
function handleReselect(): void {
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
/** 确认截图:裁剪选中区域并 emit Blob */
|
||||
async function handleConfirm(): Promise<void> {
|
||||
if (!props.screenshotCanvas) {
|
||||
showToast('截图数据丢失,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 计算缩放因子(screenshotCanvas 可能包含 devicePixelRatio)
|
||||
const scaleX = props.screenshotCanvas.width / window.innerWidth
|
||||
const scaleY = props.screenshotCanvas.height / window.innerHeight
|
||||
|
||||
// 用 canvas 裁剪选中区域
|
||||
const cropCanvas = document.createElement('canvas')
|
||||
cropCanvas.width = selectionWidth.value
|
||||
cropCanvas.height = selectionHeight.value
|
||||
|
||||
const ctx = cropCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
showToast('截图裁剪失败(无法创建画布)')
|
||||
return
|
||||
}
|
||||
|
||||
// 从完整截图中裁剪选中区域(使用缩放后的坐标)
|
||||
ctx.drawImage(
|
||||
props.screenshotCanvas,
|
||||
selectionLeft.value * scaleX,
|
||||
selectionTop.value * scaleY,
|
||||
selectionWidth.value * scaleX,
|
||||
selectionHeight.value * scaleY,
|
||||
0,
|
||||
0,
|
||||
selectionWidth.value,
|
||||
selectionHeight.value
|
||||
)
|
||||
|
||||
// 转为 Blob
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
cropCanvas.toBlob((b) => resolve(b), 'image/png')
|
||||
})
|
||||
|
||||
if (!blob || blob.size === 0) {
|
||||
showToast('截图生成失败(裁剪结果为空),请重试')
|
||||
return
|
||||
}
|
||||
|
||||
emit('confirm', blob)
|
||||
resetSelection()
|
||||
} catch (err) {
|
||||
console.error('[ScreenshotEditor] 确认截图失败:', err)
|
||||
showToast('截图确认失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 全屏遮罩 */
|
||||
.screenshot-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 背景 canvas(显示页面截图) */
|
||||
.screenshot-editor-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 选区绘制层 */
|
||||
.screenshot-selection-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
touch-action: none; /* 阻止浏览器默认触摸手势(滚动/缩放),确保触摸事件到达 js */
|
||||
-webkit-touch-callout: none; /* 禁止长按弹出菜单 */
|
||||
}
|
||||
|
||||
/* 暗色遮罩(未选区时全暗,选区后挖空)
|
||||
pointer-events: none — 让触摸事件穿透遮罩,到达选区层 */
|
||||
.screenshot-dark-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 选区边框 */
|
||||
.screenshot-selection-box {
|
||||
position: absolute;
|
||||
border: 2px solid #1989fa;
|
||||
background: rgba(25, 137, 250, 0.05);
|
||||
z-index: 20;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 选区尺寸提示 */
|
||||
.screenshot-size-tip {
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.screenshot-toolbar {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.screenshot-toolbar-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.screenshot-toolbar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.screenshot-toolbar-btn--primary {
|
||||
background: #1989fa;
|
||||
border-color: #1989fa;
|
||||
}
|
||||
.screenshot-toolbar-btn--primary:hover {
|
||||
background: #0570db;
|
||||
}
|
||||
|
||||
/* 提示文字 */
|
||||
.screenshot-tip {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — H5用户端「呼叫坐席」触发按钮
|
||||
=============================================================================
|
||||
说明:只负责触发弹窗,不直接发 shake 请求。
|
||||
点击 → 播放敲桌子动画 → emit('trigger')
|
||||
动画:静态呼吸浮动 → 悬浮放大 → 点击时双手交替敲桌子 0.8s
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="pound-btn"
|
||||
:class="{
|
||||
'pound-btn--pounding': isPounding,
|
||||
'pound-btn--idle': !isPounding && !disabled && !shaking,
|
||||
}"
|
||||
:disabled="disabled || shaking || isPounding"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- 左拳 -->
|
||||
<span class="pound-btn__fist pound-btn__fist--left"
|
||||
:class="{ 'pound-btn__fist--active': isPounding }">👊</span>
|
||||
<!-- 右拳 -->
|
||||
<span class="pound-btn__fist pound-btn__fist--right"
|
||||
:class="{ 'pound-btn__fist--active': isPounding }">👊</span>
|
||||
|
||||
<!-- 右上角红点(提示可点击) -->
|
||||
<span v-if="!disabled && !shaking" class="pound-btn__dot"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ShakeButton — 「呼叫坐席」触发按钮
|
||||
* 点击只触发弹窗,不直接发请求
|
||||
* 动画:双手交替敲桌子效果
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'trigger'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 是否禁用按钮 */
|
||||
disabled?: boolean
|
||||
/** 是否正在呼叫中(防止重复) */
|
||||
shaking?: boolean
|
||||
}>()
|
||||
|
||||
/** 是否正在播放敲击动画 */
|
||||
const isPounding = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* 处理点击:播放动画 → 触发弹窗
|
||||
*/
|
||||
function handleClick(): void {
|
||||
if (props.disabled || props.shaking || isPounding.value) return
|
||||
|
||||
isPounding.value = true
|
||||
setTimeout(() => {
|
||||
isPounding.value = false
|
||||
}, 800)
|
||||
|
||||
emit('trigger')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
按钮主体:圆形,橙色渐变,48px
|
||||
========================================================================== */
|
||||
.pound-btn {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, var(--color-warning), var(--color-warning));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
box-shadow: 0 2px 10px rgba(255, 87, 34, 0.4);
|
||||
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 悬浮:放大 + 发光 */
|
||||
.pound-btn--idle:hover {
|
||||
transform: scale(1.12);
|
||||
box-shadow: 0 4px 18px rgba(255, 87, 34, 0.55);
|
||||
}
|
||||
|
||||
/* 敲击中:整体震动 */
|
||||
.pound-btn--pounding {
|
||||
animation: desk-shake 0.8s ease-in-out;
|
||||
box-shadow: 0 6px 24px rgba(255, 87, 34, 0.7);
|
||||
}
|
||||
|
||||
/* 禁用 */
|
||||
.pound-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: 0 2px 10px rgba(255, 87, 34, 0.4) !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
双拳
|
||||
========================================================================== */
|
||||
.pound-btn__fists {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pound-btn__fist {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
transition: transform 0.08s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 静止时轻微呼吸浮动 */
|
||||
.pound-btn--idle .pound-btn__fist--left {
|
||||
animation: fist-idle-left 2s ease-in-out infinite;
|
||||
}
|
||||
.pound-btn--idle .pound-btn__fist--right {
|
||||
animation: fist-idle-right 2s ease-in-out 0.3s infinite;
|
||||
}
|
||||
|
||||
/* 敲击时 */
|
||||
.pound-btn__fist--left.pound-btn__fist--active {
|
||||
animation: fist-pound-left 0.8s ease-in-out;
|
||||
}
|
||||
.pound-btn__fist--right.pound-btn__fist--active {
|
||||
animation: fist-pound-right 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
右上角红点
|
||||
========================================================================== */
|
||||
.pound-btn__dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-danger);
|
||||
border: 1.5px solid var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
CSS 动画关键帧
|
||||
========================================================================== */
|
||||
|
||||
/* 按钮整体水平震动 — 模拟桌子被敲击 */
|
||||
@keyframes desk-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10% { transform: translateX(-4px); }
|
||||
20% { transform: translateX(4px); }
|
||||
30% { transform: translateX(-3px); }
|
||||
40% { transform: translateX(3px); }
|
||||
50% { transform: translateX(-2px); }
|
||||
60% { transform: translateX(2px); }
|
||||
70% { transform: translateX(-1px); }
|
||||
80% { transform: translateX(1px); }
|
||||
90% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* 左手拳头敲击 */
|
||||
@keyframes fist-pound-left {
|
||||
0% { transform: translateY(0) scale(1); }
|
||||
5% { transform: translateY(6px) scale(1.15); }
|
||||
10% { transform: translateY(-2px) scale(0.95); }
|
||||
15% { transform: translateY(0) scale(1); }
|
||||
25% { transform: translateY(0) scale(1); }
|
||||
30% { transform: translateY(6px) scale(1.15); }
|
||||
35% { transform: translateY(-2px) scale(0.95); }
|
||||
40% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(0) scale(1); }
|
||||
55% { transform: translateY(6px) scale(1.15); }
|
||||
60% { transform: translateY(-2px) scale(0.95); }
|
||||
65% { transform: translateY(0) scale(1); }
|
||||
100% { transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* 右手拳头敲击(与左手交替) */
|
||||
@keyframes fist-pound-right {
|
||||
0% { transform: translateY(0) scale(1); }
|
||||
12% { transform: translateY(6px) scale(1.15); }
|
||||
18% { transform: translateY(-2px) scale(0.95); }
|
||||
22% { transform: translateY(0) scale(1); }
|
||||
32% { transform: translateY(0) scale(1); }
|
||||
37% { transform: translateY(6px) scale(1.15); }
|
||||
42% { transform: translateY(-2px) scale(0.95); }
|
||||
47% { transform: translateY(0) scale(1); }
|
||||
57% { transform: translateY(0) scale(1); }
|
||||
62% { transform: translateY(6px) scale(1.15); }
|
||||
67% { transform: translateY(-2px) scale(0.95); }
|
||||
72% { transform: translateY(0) scale(1); }
|
||||
100% { transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* 静止时左手呼吸 */
|
||||
@keyframes fist-idle-left {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
/* 静止时右手呼吸(错开半周期) */
|
||||
@keyframes fist-idle-right {
|
||||
0%, 100% { transform: translateY(-2px); }
|
||||
50% { transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,594 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端排查步骤交互组件
|
||||
// =============================================================================
|
||||
// 说明:与坐席端同构的排查步骤功能,用户可交互
|
||||
// 核心功能:
|
||||
// 1. 横向路径进度条(和坐席端一致的 done/current/pending 三态)
|
||||
// 2. 步骤节点:展示当前操作说明(图文指引)
|
||||
// 3. 决策节点:展示问答卡片,用户点击选项聚焦问题范围
|
||||
// 4. 坐席和用户同步看到当前路径和节点
|
||||
// 数据流:
|
||||
// 坐席推进步骤 → WebSocket 推送 → store 更新 → 本组件响应式渲染
|
||||
// 用户选择选项 → WebSocket 推送 → 坐席端同步看到用户的选择
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 仅在有排查步骤时显示 -->
|
||||
<div v-if="steps.length > 0" class="ts-flow">
|
||||
<!-- ====== 路径进度条 ====== -->
|
||||
<div class="ts-flow__path">
|
||||
<div class="ts-flow__path-bar">
|
||||
<template v-for="(step, index) in steps" :key="index">
|
||||
<div
|
||||
class="ts-flow__step"
|
||||
:class="`ts-flow__step--${step.status}`"
|
||||
>
|
||||
<span
|
||||
class="ts-flow__dot"
|
||||
:class="`ts-flow__dot--${step.status}`"
|
||||
>
|
||||
<template v-if="step.status === 'done'">✓</template>
|
||||
<template v-else>{{ index + 1 }}</template>
|
||||
</span>
|
||||
<span class="ts-flow__label">{{ step.label }}</span>
|
||||
</div>
|
||||
<!-- 步骤间连接线 -->
|
||||
<span
|
||||
v-if="index < steps.length - 1"
|
||||
class="ts-flow__arrow"
|
||||
:class="{ 'ts-flow__arrow--active': step.status === 'done' }"
|
||||
>→</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 交互卡片区域 ====== -->
|
||||
<div class="ts-flow__card">
|
||||
|
||||
<!-- ===== 决策节点:问答交互卡片 ===== -->
|
||||
<template v-if="currentNode && currentNode.type === 'decision'">
|
||||
<div class="ts-flow__question">
|
||||
<div class="ts-flow__question-header">
|
||||
<span class="ts-flow__question-icon">❓</span>
|
||||
<span class="ts-flow__question-text">{{ currentNode.label }}</span>
|
||||
</div>
|
||||
<!-- 选项按钮 -->
|
||||
<div class="ts-flow__options">
|
||||
<button
|
||||
class="ts-flow__option ts-flow__option--yes"
|
||||
@click="handleOptionSelect('yes')"
|
||||
:disabled="optionSubmitting"
|
||||
>
|
||||
<span class="ts-flow__option-icon">✅</span>
|
||||
<span>{{ currentNode.yes_branch?.label || '是' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="ts-flow__option ts-flow__option--no"
|
||||
@click="handleOptionSelect('no')"
|
||||
:disabled="optionSubmitting"
|
||||
>
|
||||
<span class="ts-flow__option-icon">❌</span>
|
||||
<span>{{ currentNode.no_branch?.label || '否' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== 步骤节点:操作说明卡片 ===== -->
|
||||
<template v-else-if="currentNode && currentNode.type === 'step'">
|
||||
<div class="ts-flow__instruction">
|
||||
<div class="ts-flow__instruction-header">
|
||||
<span class="ts-flow__instruction-icon">📋</span>
|
||||
<span class="ts-flow__instruction-title">正在执行:{{ currentNode.label }}</span>
|
||||
</div>
|
||||
<!-- 步骤说明(如果有子节点,展示子步骤概要) -->
|
||||
<div v-if="currentNode.children && currentNode.children.length > 0" class="ts-flow__instruction-steps">
|
||||
<div
|
||||
v-for="(child, idx) in currentNode.children"
|
||||
:key="child.id"
|
||||
class="ts-flow__sub-step"
|
||||
:class="`ts-flow__sub-step--${child.status || 'pending'}`"
|
||||
>
|
||||
<span class="ts-flow__sub-dot">{{ idx + 1 }}</span>
|
||||
<span class="ts-flow__sub-label">{{ child.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 提示文字 -->
|
||||
<div class="ts-flow__instruction-hint">
|
||||
<span>💡 请按坐席指引操作,完成后坐席会推进到下一步</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== 无当前节点:等待坐席启动 ===== -->
|
||||
<template v-else>
|
||||
<div class="ts-flow__waiting">
|
||||
<span class="ts-flow__waiting-icon">⏳</span>
|
||||
<span>坐席正在选择排查流程,请稍候...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== 底部同步状态 ===== -->
|
||||
<div class="ts-flow__sync-status">
|
||||
<span class="ts-flow__sync-dot"></span>
|
||||
<span>排查进度实时同步 · {{ templateName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TroubleshootFlow 排查步骤交互组件
|
||||
*
|
||||
* 与坐席端 TroubleshootBar 同构但面向用户:
|
||||
* - 坐席端的决策树 → 用户端的问答卡片(用户点击选项)
|
||||
* - 坐席端的步骤路径 → 用户端的横向进度条
|
||||
* - 坐席端的流程图节点 → 用户端的操作说明卡片
|
||||
*
|
||||
* 交互流程:
|
||||
* 1. 坐席选择排查模板 → WebSocket 推送模板数据到 H5
|
||||
* 2. H5 展示第一个节点(通常是步骤节点)
|
||||
* 3. 遇到决策节点 → 展示问答卡片,用户点击选项
|
||||
* 4. 用户选择 → WebSocket 发送选择 → 后端更新流程状态
|
||||
* 5. 坐席和用户同步看到新的当前节点
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import type { FlowchartNode } from '@/api/troubleshooting'
|
||||
|
||||
// TODO: 阶段二实现 WebSocket 时,从共享模块导入类型
|
||||
// 目前使用坐席端已有的 FlowchartNode 类型
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 用户选择选项时防重复提交 */
|
||||
const optionSubmitting = ref(false)
|
||||
|
||||
/** 排查步骤列表(路径进度) — 从 store 获取 */
|
||||
const steps = computed(() => {
|
||||
return store.troubleshootingSteps || []
|
||||
})
|
||||
|
||||
/** 排查模板名称 — 从 store 获取 */
|
||||
const templateName = computed(() => {
|
||||
return store.troubleshootingTemplateName || '排查中'
|
||||
})
|
||||
|
||||
/** 当前活跃的流程图节点 — 从 store 获取 */
|
||||
const currentNode = computed<FlowchartNode | null>(() => {
|
||||
return store.troubleshootingCurrentNode || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 用户选择决策节点的选项
|
||||
*
|
||||
* @param option - 'yes' 或 'no',对应 yes_branch / no_branch
|
||||
*
|
||||
* 流程:
|
||||
* 1. 本地更新状态(即时反馈)
|
||||
* 2. 通过 WebSocket 推送用户选择给坐席端
|
||||
* 3. 坐席端确认后,后端更新流程状态
|
||||
* 4. 双端同步新的当前节点
|
||||
*/
|
||||
function handleOptionSelect(option: 'yes' | 'no'): void {
|
||||
if (optionSubmitting.value) return
|
||||
optionSubmitting.value = true
|
||||
|
||||
try {
|
||||
// 本地即时反馈:标记当前决策节点为 done
|
||||
if (currentNode.value) {
|
||||
currentNode.value.status = 'done'
|
||||
}
|
||||
|
||||
// 确定下一个节点
|
||||
const nextNode = option === 'yes'
|
||||
? currentNode.value?.yes_branch
|
||||
: currentNode.value?.no_branch
|
||||
|
||||
if (nextNode) {
|
||||
// 更新 store 中的当前节点
|
||||
store.troubleshootingCurrentNode = nextNode
|
||||
nextNode.status = 'current'
|
||||
|
||||
// 如果下一个节点是步骤节点,更新路径进度
|
||||
if (nextNode.type === 'step') {
|
||||
updatePathSteps(nextNode)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 阶段二 — 通过 WebSocket 发送用户选择
|
||||
// websocket.send({
|
||||
// type: 'troubleshooting_choice',
|
||||
// conversation_id: store.currentConversation?.conversation_id,
|
||||
// node_id: currentNode.value?.id,
|
||||
// choice: option,
|
||||
// })
|
||||
console.log('[TroubleshootFlow] 用户选择:', option, '下一节点:', nextNode?.label)
|
||||
} finally {
|
||||
// 延迟重置,防止快速重复点击
|
||||
setTimeout(() => {
|
||||
optionSubmitting.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新路径步骤状态
|
||||
* 根据当前节点找到对应的路径步骤,更新为 current
|
||||
*/
|
||||
function updatePathSteps(activeNode: FlowchartNode): void {
|
||||
const currentSteps = store.troubleshootingSteps
|
||||
if (!currentSteps || currentSteps.length === 0) return
|
||||
|
||||
// 在路径步骤中查找匹配当前节点的标签
|
||||
const matchedIndex = currentSteps.findIndex(s => s.label === activeNode.label)
|
||||
if (matchedIndex >= 0) {
|
||||
// 更新所有步骤状态
|
||||
const updated = currentSteps.map((step, i) => ({
|
||||
...step,
|
||||
status: i < matchedIndex ? 'done' as const
|
||||
: i === matchedIndex ? 'current' as const
|
||||
: 'pending' as const,
|
||||
}))
|
||||
store.troubleshootingSteps = updated
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== 主容器 ===== */
|
||||
.ts-flow {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* ===== 路径进度条 ===== */
|
||||
.ts-flow__path {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.ts-flow__path-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ts-flow__path-bar::-webkit-scrollbar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.ts-flow__path-bar::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 单个步骤 */
|
||||
.ts-flow__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 步骤圆点 */
|
||||
.ts-flow__dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 已完成步骤 */
|
||||
.ts-flow__dot--done {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ts-flow__step--done .ts-flow__label {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 当前步骤 — 脉冲动画 */
|
||||
.ts-flow__dot--current {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: ts-dot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ts-flow__step--current .ts-flow__label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 待处理步骤 */
|
||||
.ts-flow__dot--pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ts-flow__step--pending .ts-flow__label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 步骤标签 */
|
||||
.ts-flow__label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 步骤间箭头 */
|
||||
.ts-flow__arrow {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.ts-flow__arrow--active {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* ===== 交互卡片区域 ===== */
|
||||
.ts-flow__card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* ===== 决策节点:问答交互 ===== */
|
||||
.ts-flow__question {
|
||||
/* 问答卡片 */
|
||||
}
|
||||
|
||||
.ts-flow__question-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ts-flow__question-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.ts-flow__question-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 选项按钮容器 */
|
||||
.ts-flow__options {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 选项按钮 */
|
||||
.ts-flow__option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ts-flow__option:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.ts-flow__option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* "是"选项 — 绿色主题 */
|
||||
.ts-flow__option--yes {
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
}
|
||||
|
||||
.ts-flow__option--yes:active:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
/* "否"选项 — 红色主题 */
|
||||
.ts-flow__option--no {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
.ts-flow__option--no:active:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.ts-flow__option-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ===== 步骤节点:操作说明 ===== */
|
||||
.ts-flow__instruction {
|
||||
/* 操作说明卡片 */
|
||||
}
|
||||
|
||||
.ts-flow__instruction-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ts-flow__instruction-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ts-flow__instruction-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 子步骤列表 */
|
||||
.ts-flow__instruction-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.ts-flow__sub-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ts-flow__sub-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ts-flow__sub-step--done .ts-flow__sub-dot {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.ts-flow__sub-step--done .ts-flow__sub-label {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.ts-flow__sub-step--current .ts-flow__sub-dot {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ts-flow__sub-step--current .ts-flow__sub-label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ts-flow__sub-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 操作提示 */
|
||||
.ts-flow__instruction-hint {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--accent-soft);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 等待状态 ===== */
|
||||
.ts-flow__waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.ts-flow__waiting-icon {
|
||||
font-size: 16px;
|
||||
animation: ts-waiting 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== 底部同步状态 ===== */
|
||||
.ts-flow__sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.ts-flow__sync-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: ts-sync-blink 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== 动画 ===== */
|
||||
@keyframes ts-dot-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px var(--accent-soft);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ts-sync-blink {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ts-waiting {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端排查步骤进度组件
|
||||
// =============================================================================
|
||||
// 说明:展示坐席端的排查步骤进度,通过 WebSocket 实时同步
|
||||
// 功能:
|
||||
// 1. 横向步骤进度条(完成/当前/待处理 三态)
|
||||
// 2. 当前步骤脉冲动画提示
|
||||
// 3. 坐席更新步骤时实时推送到用户端
|
||||
// 数据源:conversation store 中的 troubleshootingSteps
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div v-if="steps.length > 0" class="h5-ts-progress">
|
||||
<!-- 标题栏 -->
|
||||
<div class="h5-ts-progress__header">
|
||||
<span class="h5-ts-progress__title">🔧 排查进度 · {{ templateName }}</span>
|
||||
<span class="h5-ts-progress__time">{{ currentTime }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 横向步骤进度条 -->
|
||||
<div class="h5-ts-progress__bar">
|
||||
<template v-for="(step, index) in steps" :key="index">
|
||||
<div
|
||||
class="h5-ts-step"
|
||||
:class="`h5-ts-step--${step.status}`"
|
||||
>
|
||||
<!-- 步骤圆点 -->
|
||||
<span
|
||||
class="h5-ts-dot"
|
||||
:class="`h5-ts-dot--${step.status}`"
|
||||
>
|
||||
<template v-if="step.status === 'done'">✓</template>
|
||||
<template v-else>{{ index + 1 }}</template>
|
||||
</span>
|
||||
<!-- 步骤标签 -->
|
||||
<span class="h5-ts-label">{{ step.label }}</span>
|
||||
</div>
|
||||
<!-- 步骤间箭头 -->
|
||||
<span v-if="index < steps.length - 1" class="h5-ts-arrow">→</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 当前步骤说明 -->
|
||||
<div v-if="currentStepLabel" class="h5-ts-progress__current-hint">
|
||||
<span class="h5-ts-progress__current-icon">▶</span>
|
||||
正在进行:<strong>{{ currentStepLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TroubleshootProgress 排查步骤进度组件
|
||||
*
|
||||
* 数据结构:
|
||||
* - steps: PathStep[] — 排查步骤列表
|
||||
* - label: 步骤名称
|
||||
* - status: 'done' | 'current' | 'pending'
|
||||
* - templateName: 排查模板名称(如"VPN连接故障")
|
||||
*
|
||||
* 数据来源:conversation store 中的 troubleshootingSteps
|
||||
* 坐席端更新步骤时 → WebSocket 推送 → store 更新 → 本组件自动响应
|
||||
*/
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 当前时间(用于显示) */
|
||||
const currentTime = ref('')
|
||||
|
||||
/** 更新时间的定时器 */
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 排查步骤列表 — 从 store 获取 */
|
||||
const steps = computed(() => {
|
||||
return store.troubleshootingSteps || []
|
||||
})
|
||||
|
||||
/** 排查模板名称 — 从 store 获取 */
|
||||
const templateName = computed(() => {
|
||||
return store.troubleshootingTemplateName || '排查中'
|
||||
})
|
||||
|
||||
/** 当前步骤的标签 */
|
||||
const currentStepLabel = computed(() => {
|
||||
const current = steps.value.find(s => s.status === 'current')
|
||||
return current?.label || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新当前时间显示
|
||||
*/
|
||||
function updateTime(): void {
|
||||
const now = new Date()
|
||||
const h = now.getHours().toString().padStart(2, '0')
|
||||
const m = now.getMinutes().toString().padStart(2, '0')
|
||||
currentTime.value = `${h}:${m}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 60000) // 每分钟更新
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 进度卡片容器 */
|
||||
.h5-ts-progress {
|
||||
margin: 8px 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.h5-ts-progress__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.h5-ts-progress__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.h5-ts-progress__time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 横向步骤进度条 */
|
||||
.h5-ts-progress__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.h5-ts-progress__bar::-webkit-scrollbar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.h5-ts-progress__bar::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 单个步骤 */
|
||||
.h5-ts-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 步骤圆点 */
|
||||
.h5-ts-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 已完成步骤 */
|
||||
.h5-ts-dot--done {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.h5-ts-step--done .h5-ts-label {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 当前步骤 — 脉冲动画 */
|
||||
.h5-ts-dot--current {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: dot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.h5-ts-step--current .h5-ts-label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 待处理步骤 */
|
||||
.h5-ts-dot--pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.h5-ts-step--pending .h5-ts-label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 步骤标签 */
|
||||
.h5-ts-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 步骤间箭头 */
|
||||
.h5-ts-arrow {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* 当前步骤说明 */
|
||||
.h5-ts-progress__current-hint {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.h5-ts-progress__current-icon {
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 脉冲动画 */
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px var(--accent-soft);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端 WebSocket 组合式函数
|
||||
// =============================================================================
|
||||
// 说明:封装 H5 员工端的 WebSocket 连接管理,提供:
|
||||
// 1. 自动连接 + 断线重连(指数退避,最大 30 秒)
|
||||
// 2. 心跳保活(每 30 秒发送 ping)
|
||||
// 3. 事件分发:收到消息后根据 type 调用对应 store 方法
|
||||
// 4. 降级策略:WS 断连时自动启动轮询 fallback,WS 重连后自动停止轮询
|
||||
//
|
||||
// 与坐席端 useWebSocket 的区别:
|
||||
// - 连接端点不同:/ws/h5/{employee_id}(坐席端用 /ws/{agent_id})
|
||||
// - 认证方式不同:使用 employee token(坐席端用 agent token)
|
||||
// - 事件处理不同:重点关注参与者变更事件(坐席端关注所有事件)
|
||||
// - 无 typing 发送能力(H5员工不需要发送 typing 指示器)
|
||||
//
|
||||
// 使用方式:
|
||||
// const { connect, disconnect } = useH5WebSocket()
|
||||
// onMounted(() => connect())
|
||||
// onUnmounted(() => disconnect())
|
||||
// =============================================================================
|
||||
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 常量配置
|
||||
// --------------------------------------------------------------------------
|
||||
/** 心跳间隔(毫秒):每 30 秒发送一次 ping,保持连接存活 */
|
||||
const HEARTBEAT_INTERVAL = 30000
|
||||
|
||||
/** 最大重连延迟(毫秒):指数退避上限 30 秒 */
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
|
||||
/** 重连延迟基数(毫秒):首次重连等待 1 秒 */
|
||||
const RECONNECT_BASE_DELAY = 1000
|
||||
|
||||
/**
|
||||
* H5员工端 WebSocket 组合式函数
|
||||
*
|
||||
* 核心职责:
|
||||
* - 管理 WebSocket 连接的生命周期(建立、维持、断开、重连)
|
||||
* - 处理服务端推送的实时事件,分发到对应的 store
|
||||
* - 实现 WS → 轮询的自动降级和恢复
|
||||
*
|
||||
* 为什么用组合式函数(composable):
|
||||
* - 遵循 Vue3 的组合式 API 模式,与组件生命周期绑定
|
||||
* - 与坐席端 useWebSocket 保持一致的架构风格
|
||||
*/
|
||||
export function useH5WebSocket() {
|
||||
// ==========================================================================
|
||||
// 内部状态
|
||||
// ==========================================================================
|
||||
|
||||
/** WebSocket 实例 */
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
/** 心跳定时器 ID */
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 重连定时器 ID */
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 重连尝试次数(用于指数退避计算) */
|
||||
let reconnectAttempts = 0
|
||||
|
||||
/** 是否主动断开(用户登出时设为 true,避免自动重连) */
|
||||
let intentionalDisconnect = false
|
||||
|
||||
// ==========================================================================
|
||||
// 连接管理
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 建立 H5 员工 WebSocket 连接
|
||||
*
|
||||
* 做什么:根据当前员工ID和token构建 WS URL,建立连接,注册事件处理函数
|
||||
* 为什么:H5员工需要实时接收参与者变更事件(新参与者加入、有人退出等)
|
||||
*
|
||||
* 连接 URL 格式:
|
||||
* - 开发环境:ws://localhost:8000/ws/h5/{employeeId}?token=xxx
|
||||
* - 生产环境:wss://domain.com/ws/h5/{employeeId}?token=xxx
|
||||
*/
|
||||
function connect(): void {
|
||||
const employeeStore = useEmployeeStore()
|
||||
const employeeId = employeeStore.employeeId
|
||||
const token = employeeStore.token
|
||||
|
||||
// 如果没有员工ID或token,说明未登录,不建立连接
|
||||
if (!employeeId || !token) {
|
||||
console.warn('[H5 WS] 未登录或缺少token,跳过连接')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已有连接,先断开
|
||||
if (ws) {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
// 重置主动断开标记
|
||||
intentionalDisconnect = false
|
||||
|
||||
// 构建 WebSocket URL
|
||||
// 开发环境:直接连后端 8000 端口(与坐席端一致)
|
||||
// 生产环境:通过同源 wss:// 连接(nginx 统一代理)
|
||||
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}`
|
||||
|
||||
console.log(`[H5 WS] 正在连接: ${wsUrl.replace(/token=[^&]+/, 'token=***')}`)
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 连接成功
|
||||
// ----------------------------------------------------------------------
|
||||
ws.onopen = () => {
|
||||
console.log('[H5 WS] 连接成功')
|
||||
// 重置重连计数
|
||||
reconnectAttempts = 0
|
||||
// 启动心跳
|
||||
startHeartbeat()
|
||||
// WS 已连接,停止轮询 fallback
|
||||
// 为什么:WS 连接正常时不需要轮询,减少不必要的 HTTP 请求
|
||||
const store = useConversationStore()
|
||||
store.stopPolling()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 收到消息
|
||||
// ----------------------------------------------------------------------
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
handleMessage(msg)
|
||||
} catch (error) {
|
||||
console.error('[H5 WS] 消息解析失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 连接关闭
|
||||
// ----------------------------------------------------------------------
|
||||
ws.onclose = () => {
|
||||
console.log('[H5 WS] 连接关闭')
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
// 清空 ws 引用
|
||||
ws = null
|
||||
|
||||
// 如果不是主动断开,启动降级和重连
|
||||
if (!intentionalDisconnect) {
|
||||
// WS 断连,启动轮询 fallback
|
||||
// 为什么:WS 不可用时,仍需通过轮询获取最新数据
|
||||
const store = useConversationStore()
|
||||
store.startPolling()
|
||||
// 尝试重连
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 连接错误
|
||||
// ----------------------------------------------------------------------
|
||||
ws.onerror = (error: Event) => {
|
||||
console.error('[H5 WS] 连接错误:', error)
|
||||
// onclose 会自动触发,这里不需要额外处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动断开 WebSocket 连接
|
||||
*
|
||||
* 做什么:关闭 WS 连接,清理定时器,标记为主动断开
|
||||
* 为什么:员工登出时需要主动断开,避免后台重连
|
||||
*/
|
||||
function disconnect(): void {
|
||||
// 标记为主动断开,阻止自动重连
|
||||
intentionalDisconnect = true
|
||||
|
||||
// 清理重连定时器
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
// 清理心跳定时器
|
||||
stopHeartbeat()
|
||||
|
||||
// 关闭 WebSocket 连接
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
// 重置重连计数
|
||||
reconnectAttempts = 0
|
||||
|
||||
console.log('[H5 WS] 已主动断开连接')
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 心跳保活
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 启动心跳定时器
|
||||
*
|
||||
* 做什么:每 HEARTBEAT_INTERVAL 毫秒发送一次 ping 消息
|
||||
* 为什么:防止中间代理(Nginx、CDN 等)因空闲超时断开 WebSocket 连接
|
||||
*/
|
||||
function startHeartbeat(): void {
|
||||
// 先清理旧定时器(避免重复)
|
||||
stopHeartbeat()
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳定时器
|
||||
*/
|
||||
function stopHeartbeat(): void {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 断线重连(指数退避)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 安排重连
|
||||
*
|
||||
* 做什么:根据指数退避算法计算延迟,安排下一次重连
|
||||
* 为什么:避免 WS 断连后所有客户端同时重连导致服务器压力过大
|
||||
*
|
||||
* 指数退避公式:delay = min(base * 2^attempts, maxDelay)
|
||||
* 第1次重连:1秒后
|
||||
* 第2次重连:2秒后
|
||||
* 第3次重连:4秒后
|
||||
* 第4次及以后:8秒、16秒、30秒(达到上限)
|
||||
*/
|
||||
function scheduleReconnect(): void {
|
||||
// 如果已主动断开,不重连
|
||||
if (intentionalDisconnect) return
|
||||
|
||||
// 计算延迟(指数退避)
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts),
|
||||
MAX_RECONNECT_DELAY
|
||||
)
|
||||
reconnectAttempts++
|
||||
|
||||
console.log(
|
||||
`[H5 WS] 将在 ${delay / 1000} 秒后重连(第 ${reconnectAttempts} 次)`
|
||||
)
|
||||
|
||||
// 清理旧的重连定时器
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
}
|
||||
|
||||
// 安排重连
|
||||
reconnectTimer = setTimeout(() => {
|
||||
console.log('[H5 WS] 正在重连...')
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 消息处理(事件分发)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 处理从 WebSocket 收到的消息
|
||||
*
|
||||
* 做什么:根据消息的 type 字段,调用对应的 store 方法处理
|
||||
* 为什么:不同类型的事件需要不同的处理逻辑
|
||||
*
|
||||
* H5员工端关注的事件:
|
||||
* - participant_invited: 新参与者被邀请 — 刷新参与者列表
|
||||
* - participant_joined: 参与者加入 — 刷新参与者列表
|
||||
* - participant_removed: 参与者被移除 — 刷新参与者列表
|
||||
* - participant_left: 参与者退出 — 刷新参与者列表
|
||||
* - new_message: 新消息 — 追加到消息列表
|
||||
* - pong: 心跳响应,忽略
|
||||
*
|
||||
* @param msg - WebSocket 消息对象,包含 type 和 data 字段
|
||||
*/
|
||||
function handleMessage(msg: { type: string; data?: any }): void {
|
||||
const store = useConversationStore()
|
||||
|
||||
switch (msg.type) {
|
||||
// ==================================================================
|
||||
// 参与者变更事件(邀请功能核心)
|
||||
// ==================================================================
|
||||
case 'participant_invited':
|
||||
// 参与者被邀请:实时刷新参与者列表
|
||||
// 做什么:用 WS 推送的 participants 数据直接更新 store
|
||||
// 为什么:比等3秒轮询更实时,被邀请人能看到最新的参与者状态
|
||||
if (msg.data?.participants) {
|
||||
store.updateParticipants(msg.data.participants)
|
||||
}
|
||||
break
|
||||
|
||||
case 'participant_joined':
|
||||
// 参与者加入:实时刷新参与者列表
|
||||
if (msg.data?.participants) {
|
||||
store.updateParticipants(msg.data.participants)
|
||||
}
|
||||
break
|
||||
|
||||
case 'participant_removed':
|
||||
// 参与者被移除:实时刷新参与者列表
|
||||
// 特殊:如果被移除的是当前用户,需要退出会话视图
|
||||
if (msg.data?.participants) {
|
||||
store.updateParticipants(msg.data.participants)
|
||||
}
|
||||
// 检查是否当前用户被移除
|
||||
if (msg.data?.changed) {
|
||||
const employeeStore = useEmployeeStore()
|
||||
const removedIds = msg.data.changed.map((p: any) => p.id)
|
||||
if (removedIds.includes(employeeStore.employeeId)) {
|
||||
console.log('[H5 WS] 当前用户被移除会话')
|
||||
store.handleRemovedFromConversation()
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'participant_left':
|
||||
// 参与者主动退出:实时刷新参与者列表
|
||||
if (msg.data?.participants) {
|
||||
store.updateParticipants(msg.data.participants)
|
||||
}
|
||||
break
|
||||
|
||||
// ==================================================================
|
||||
// 新消息事件
|
||||
// ==================================================================
|
||||
case 'new_message':
|
||||
// 新消息事件:追加到消息列表
|
||||
// 做什么:检查消息是否属于当前会话,是则追加到消息列表
|
||||
// 为什么:比3秒轮询更实时,坐席/其他参与者的回复立即可见
|
||||
if (msg.data) {
|
||||
store.handleNewMessage(msg.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// 心跳响应,不需要处理
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`[H5 WS] 未知消息类型: ${msg.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 返回
|
||||
// ==========================================================================
|
||||
return {
|
||||
/** 建立 WebSocket 连接 */
|
||||
connect,
|
||||
/** 主动断开 WebSocket 连接(登出时调用) */
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端主题切换 composable
|
||||
// =============================================================================
|
||||
// 说明:提供浅色/深色主题切换功能
|
||||
// 核心功能:
|
||||
// 1. applyTheme(theme) — 设置 document.documentElement data-theme + localStorage
|
||||
// 2. getInitialTheme() — 从 localStorage 读取,支持系统偏好检测
|
||||
// 3. 初始化时自动调用 applyTheme
|
||||
// =============================================================================
|
||||
|
||||
/** 主题类型 */
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
|
||||
/** localStorage 存储键 */
|
||||
const THEME_STORAGE_KEY = 'it_desk_h5_theme'
|
||||
|
||||
/**
|
||||
* 应用主题到 DOM
|
||||
* 设置 document.documentElement 的 data-theme 属性,并持久化到 localStorage
|
||||
*
|
||||
* @param theme - 目标主题 ('light' | 'dark')
|
||||
*/
|
||||
export function applyTheme(theme: ThemeMode): void {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取初始主题
|
||||
* 从 localStorage 读取已保存的主题偏好,默认返回 'light'
|
||||
*
|
||||
* @returns 当前主题模式
|
||||
*/
|
||||
export function getInitialTheme(): ThemeMode {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (saved === 'dark' || saved === 'light') {
|
||||
return saved
|
||||
}
|
||||
// 检测系统偏好
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题切换 composable
|
||||
* 封装主题切换逻辑,初始化时自动应用已保存的主题
|
||||
*
|
||||
* @returns { applyTheme, getInitialTheme }
|
||||
*/
|
||||
export function useTheme() {
|
||||
// 初始化时立即应用已保存的主题
|
||||
const initialTheme = getInitialTheme()
|
||||
applyTheme(initialTheme)
|
||||
|
||||
return {
|
||||
applyTheme,
|
||||
getInitialTheme,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端应用入口
|
||||
// =============================================================================
|
||||
// 说明:Vue3 应用入口文件,负责:
|
||||
// 1. 创建 Vue 应用实例
|
||||
// 2. 注册 Vant4 移动端组件库
|
||||
// 3. 注册 Pinia 状态管理
|
||||
// 4. 注册 Vue Router 路由
|
||||
// 5. 挂载到 DOM
|
||||
// =============================================================================
|
||||
|
||||
import { createApp } from 'vue'
|
||||
// 根组件
|
||||
import App from './App.vue'
|
||||
// 路由配置
|
||||
import router from './router'
|
||||
// Pinia 状态管理
|
||||
import { createPinia } from 'pinia'
|
||||
// 全局样式
|
||||
import './styles/global.css'
|
||||
|
||||
// 创建 Vue 应用实例
|
||||
const app = createApp(App)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 注册插件
|
||||
// --------------------------------------------------------------------------
|
||||
// Pinia: 状态管理(存储会话信息、摇人状态等)
|
||||
app.use(createPinia())
|
||||
// Vue Router: 路由管理(页面跳转)
|
||||
app.use(router)
|
||||
|
||||
// 注意:Vant 组件通过 vite 插件 unplugin-vue-components 按需自动导入,
|
||||
// 不需要在这里手动注册,减小打包体积
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 挂载应用到 DOM
|
||||
// --------------------------------------------------------------------------
|
||||
app.mount('#app')
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 企微 OAuth2 授权检查(已迁移至路由守卫 router/index.ts)
|
||||
// --------------------------------------------------------------------------
|
||||
// OAuth2 授权逻辑现在在路由守卫中统一处理:
|
||||
// 1. 检查 URL 中的 code 参数
|
||||
// 2. 有 code → 调用后端 OAuth2 回调
|
||||
// 3. 无 code → 跳转企微授权页面
|
||||
// 参见:frontend-h5/src/router/index.ts 中的 router.beforeEach
|
||||
@@ -0,0 +1,169 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端路由配置
|
||||
// =============================================================================
|
||||
// 说明:定义页面路由映射,包含:
|
||||
// 1. 首页路由 → ChatView 聊天页面
|
||||
// 2. 登录路由 → Login 登降级登录页(本地开发用)
|
||||
// 3. 企微拦截路由 → WeworkOnly 非企微环境展示
|
||||
// 4. OAuth2 路由守卫(企微UA检测 → OAuth2认证)
|
||||
// =============================================================================
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 企微环境检测工具函数
|
||||
// --------------------------------------------------------------------------
|
||||
/**
|
||||
* 检测当前浏览器是否在企业微信 WebView 中。
|
||||
*
|
||||
* 企微浏览器的 User-Agent 包含 "wxwork" 标识(移动端和桌面端均包含)。
|
||||
* 例如:
|
||||
* - 企微桌面端:Mozilla/5.0 ... wxwork/4.1.22 ...
|
||||
* - 企微移动端:Mozilla/5.0 (iPhone ... MicroMessenger/7.x ... wxwork/3.x ...
|
||||
*
|
||||
* @returns true 表示在企微环境内
|
||||
*/
|
||||
function isWeworkEnv(): boolean {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
return /wxwork/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'ChatView',
|
||||
// 懒加载:首次访问时才加载组件,减小首屏体积
|
||||
component: () => import('@/views/ChatView.vue'),
|
||||
meta: { title: 'IT智能服务台', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { title: '登录', requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/wework-only',
|
||||
name: 'WeworkOnly',
|
||||
component: () => import('@/views/WeworkOnly.vue'),
|
||||
meta: { title: '请在企业微信中打开', requiresAuth: false },
|
||||
},
|
||||
// 404 兜底:未匹配的路径重定向到首页
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
},
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
// createWebHistory: 使用 HTML5 History 模式,基础路径 /itdesk/(与IT数据平台共享域名)
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/itdesk/'),
|
||||
routes,
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 路由守卫 — 企微环境检测 + 认证检查
|
||||
// --------------------------------------------------------------------------
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// WeworkOnly 页面和 Login 页面不需要企微检测
|
||||
if (to.name === 'WeworkOnly' || to.name === 'Login') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Portal Token 传递:从 URL 参数 ?token=xxx 读取并保存到 localStorage
|
||||
// Bug #4 修复:使用 window.location.pathname 构造完整路径(含 base),
|
||||
// 确保 history.replaceState 生成的 URL 与实际路由一致(避免 /itdesk/ 缺失)。
|
||||
// 同时立即清除 URL 中的 token 参数,减少 token 泄露窗口期。
|
||||
// ========================================================================
|
||||
const urlSearchParams = new URLSearchParams(window.location.search)
|
||||
const urlToken = (to.query.token as string) || urlSearchParams.get('token')
|
||||
if (urlToken) {
|
||||
// 保存 token 到 H5 端 localStorage key
|
||||
localStorage.setItem('h5_token', urlToken)
|
||||
|
||||
// Bug #4 修复:从 URLSearchParams 中移除 token,立即用 history.replaceState 清除
|
||||
urlSearchParams.delete('token')
|
||||
const remainingSearch = urlSearchParams.toString()
|
||||
const cleanUrl = remainingSearch
|
||||
? `${window.location.pathname}?${remainingSearch}`
|
||||
: window.location.pathname
|
||||
window.history.replaceState({}, '', cleanUrl)
|
||||
}
|
||||
|
||||
// 动态导入 employee store(在 token 处理之后导入,确保 token 已存入 localStorage)
|
||||
const { useEmployeeStore } = await import('@/stores/employee')
|
||||
const employeeStore = useEmployeeStore()
|
||||
|
||||
// 如果从 Portal 传入了 token,让 store 同步读取
|
||||
if (urlToken) {
|
||||
// token 已存入 localStorage,重新初始化 store 中的 token 状态
|
||||
employeeStore.$patch({ token: urlToken })
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 第一道防线:企微环境检测(非企微环境 → 拦截)
|
||||
// ========================================================================
|
||||
// 生产环境强制企微内访问;开发环境(localhost)跳过检测
|
||||
const isLocalhost = /^localhost(:\d+)?$/.test(window.location.hostname) || window.location.hostname === '127.0.0.1'
|
||||
if (!isLocalhost && !isWeworkEnv()) {
|
||||
// 如果有 token(从 Portal 传入),允许直接进入
|
||||
if (employeeStore.isAuthenticated) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
next({ name: 'WeworkOnly' })
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 URL 中的 code 参数(企微 OAuth2 回调)
|
||||
// 优先使用 Vue Router 的 route.query(更可靠,不受 nginx 重写影响)
|
||||
// 降级使用 window.location.search(兜底,防止某些场景下 route.query 未解析)
|
||||
const code = (to.query.code as string) || new URLSearchParams(window.location.search).get('code')
|
||||
|
||||
// 情况一:URL 中有 code 参数(企微 OAuth2 回调)
|
||||
if (code) {
|
||||
try {
|
||||
await employeeStore.handleOAuthCallback(code)
|
||||
// 清除 URL 中的 code 和 state 参数(OAuth2 回调参数),保留其他必要参数
|
||||
// Bug #4 修复:同 Portal Token,使用 URLSearchParams + window.location.pathname 构造 clean URL
|
||||
const codeSearchParams = new URLSearchParams(window.location.search)
|
||||
codeSearchParams.delete('code')
|
||||
codeSearchParams.delete('state')
|
||||
const remainingCodeSearch = codeSearchParams.toString()
|
||||
const cleanCodeUrl = remainingCodeSearch
|
||||
? `${window.location.pathname}?${remainingCodeSearch}`
|
||||
: window.location.pathname
|
||||
window.history.replaceState({}, '', cleanCodeUrl)
|
||||
next()
|
||||
} catch (error) {
|
||||
console.error('[Router] OAuth2 授权失败:', error)
|
||||
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
|
||||
if (corpId) {
|
||||
employeeStore.redirectToOAuth()
|
||||
} else {
|
||||
next({ name: 'Login' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 情况二:检查是否已认证(必须有有效 token)
|
||||
if (employeeStore.isAuthenticated) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 情况三:未登录 → 跳转登录页或 OAuth2
|
||||
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
|
||||
if (corpId) {
|
||||
employeeStore.redirectToOAuth()
|
||||
} else {
|
||||
next({ name: 'Login' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,861 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端会话状态管理(Pinia Store)
|
||||
// =============================================================================
|
||||
// 说明:管理用户端的核心状态,包括:
|
||||
// 1. 用户信息(从 employee store 获取)
|
||||
// 2. 当前会话信息
|
||||
// 3. 消息列表(含轮询逻辑)
|
||||
// 4. 招手/敲桌子状态
|
||||
// 5. AI 助手面板展开/收起状态
|
||||
// 注意:OAuth2 认证逻辑已迁移至 @/stores/employee.ts
|
||||
// =============================================================================
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
getUser,
|
||||
getCurrentConversation,
|
||||
sendMessage,
|
||||
pollMessages,
|
||||
shake,
|
||||
getApprovalLinks,
|
||||
getSoftwareDownloads,
|
||||
leaveAsParticipant as leaveAsParticipantApi,
|
||||
type UserInfo,
|
||||
type ConversationInfo,
|
||||
type Message,
|
||||
type MessageType,
|
||||
type SendMessageRequest,
|
||||
type SendMessageResponse,
|
||||
type MsgContentType,
|
||||
type ApprovalLink,
|
||||
type SoftwareDownload,
|
||||
type ParticipantItem,
|
||||
} from '@/api/conversation'
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Store 定义
|
||||
// --------------------------------------------------------------------------
|
||||
export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
// ==========================================================================
|
||||
// 响应式状态
|
||||
// ==========================================================================
|
||||
|
||||
/** 当前用户信息(通过 /h5/user 接口获取,兼容旧逻辑) */
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
/** 当前会话信息 */
|
||||
const currentConversation = ref<ConversationInfo | null>(null)
|
||||
|
||||
/** 消息列表 */
|
||||
const messages = ref<Message[]>([])
|
||||
|
||||
/** 是否正在加载(发送消息、摇人等操作时) */
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
/** 招手是否正在进行中(防止重复点击) */
|
||||
const shaking = ref<boolean>(false)
|
||||
|
||||
/** 是否可以呼叫人工坐席(AI 实质性回复 >= 3 次时显示按钮) */
|
||||
const canCallAgent = ref<boolean>(false)
|
||||
|
||||
/** 坐席是否在线(通过 WebSocket 或轮询获取) */
|
||||
const agentOnline = ref<boolean>(true) // 默认在线,阶段一简化处理
|
||||
|
||||
/** 轮询定时器 ID */
|
||||
const pollTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
/** AI 助手面板是否展开(移动端使用) */
|
||||
const assistantPanelVisible = ref<boolean>(false)
|
||||
|
||||
/** 审批流程链接列表 */
|
||||
const approvalLinks = ref<ApprovalLink[]>([])
|
||||
|
||||
/** 软件下载列表 */
|
||||
const softwareDownloads = ref<SoftwareDownload[]>([])
|
||||
|
||||
/** 最后一条消息的 ID(用于增量轮询) */
|
||||
const lastMessageId = ref<string>('')
|
||||
|
||||
/** 是否已初始化(完成数据加载) */
|
||||
const initialized = ref<boolean>(false)
|
||||
|
||||
/** 排查步骤列表(从坐席端同步,通过 WebSocket 推送) */
|
||||
const troubleshootingSteps = ref<Array<{ label: string; status: 'done' | 'current' | 'pending' }>>([])
|
||||
|
||||
/** 排查步骤模板名称 */
|
||||
const troubleshootingTemplateName = ref<string>('')
|
||||
|
||||
/** 排查流程图当前活跃节点(交互式 — 用户可点击选项) */
|
||||
const troubleshootingCurrentNode = ref<{
|
||||
id: string
|
||||
type: 'step' | 'decision'
|
||||
label: string
|
||||
status?: 'done' | 'current' | 'pending'
|
||||
children?: any[]
|
||||
yes_branch?: any
|
||||
no_branch?: any
|
||||
} | null>(null)
|
||||
|
||||
/** 参与者列表(邀请功能 P0-09~P0-11,从会话信息同步) */
|
||||
const participants = ref<ParticipantItem[]>([])
|
||||
|
||||
/** 参与者面板是否展开 */
|
||||
const participantPanelVisible = ref<boolean>(false)
|
||||
|
||||
// ==========================================================================
|
||||
// 消息去重相关状态(与 agent 端一致,WS-06 修复)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 已处理消息ID集合(用于消息去重)
|
||||
* 做什么:记录最近处理过的 message_id,防止网络抖动或将来接入 WS 后
|
||||
* 收到重复消息导致前端重复显示
|
||||
* 为什么:轮询和将来的 WS 推送可能同时到达,需要幂等去重
|
||||
* 最多保留 500 条,超过时删除最旧的一条(FIFO)
|
||||
* ES2015+ 规范:Set 迭代顺序 = 插入顺序,delete 后重新 add 会移到最后
|
||||
*/
|
||||
const processedMessageIds = ref<Set<string>>(new Set())
|
||||
|
||||
// ==========================================================================
|
||||
// 计算属性
|
||||
// ==========================================================================
|
||||
|
||||
/** 是否已登录(委托给 employee store) */
|
||||
const isLoggedIn = computed(() => {
|
||||
const employeeStore = useEmployeeStore()
|
||||
return employeeStore.isAuthenticated
|
||||
})
|
||||
|
||||
/** 是否有活跃会话(会话未结单) */
|
||||
const hasActiveConversation = computed(() => {
|
||||
if (!currentConversation.value) return false
|
||||
return currentConversation.value.status !== 'closed'
|
||||
})
|
||||
|
||||
/** 当前用户是否为被邀请的参与者(非原始员工) */
|
||||
const isParticipant = computed(() => {
|
||||
if (!currentConversation.value || !userInfo.value) return false
|
||||
// 如果当前用户 ID 等于会话的 employee_id,说明是原始员工,不是被邀请人
|
||||
if (currentConversation.value.employee_id === userInfo.value.employee_id) return false
|
||||
// 检查是否在 participants 列表中
|
||||
return participants.value.some(p => p.id === userInfo.value?.employee_id)
|
||||
})
|
||||
|
||||
/** 已加入的参与者数量(用于横幅展示 "👥 N人参与") */
|
||||
const joinedParticipantCount = computed(() => {
|
||||
// 原始员工 + 坐席 + 已加入的参与者
|
||||
const joinedParticipants = participants.value.filter(p => p.joined)
|
||||
return joinedParticipants.length
|
||||
})
|
||||
|
||||
/** 审批链接按分类分组 */
|
||||
const approvalLinksByCategory = computed(() => {
|
||||
const grouped: Record<string, ApprovalLink[]> = {}
|
||||
for (const link of approvalLinks.value) {
|
||||
if (!grouped[link.category]) {
|
||||
grouped[link.category] = []
|
||||
}
|
||||
grouped[link.category].push(link)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
/** 软件下载按分类分组 */
|
||||
const softwareDownloadsByCategory = computed(() => {
|
||||
const grouped: Record<string, SoftwareDownload[]> = {}
|
||||
for (const item of softwareDownloads.value) {
|
||||
if (!grouped[item.category]) {
|
||||
grouped[item.category] = []
|
||||
}
|
||||
grouped[item.category].push(item)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 消息去重辅助函数(WS-06 修复,与 agent 端一致)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 记录已处理的消息ID(用于去重)
|
||||
*
|
||||
* 做什么:将 message_id 加入 processedMessageIds,
|
||||
* 如果已存在则先删除再重新添加(移到"最新"位置),
|
||||
* 超过 500 条时删除最旧的一条(FIFO)。
|
||||
* 为什么:防止网络抖动或将来 WS 重连导致重复处理同一条消息,
|
||||
* 使用 Set + 插入顺序(ES2015+ 规范)实现 FIFO 淘汰。
|
||||
*
|
||||
* @param messageId - 消息ID
|
||||
*/
|
||||
function trackProcessedMessageId(messageId: string): void {
|
||||
const set = processedMessageIds.value
|
||||
// 如果已存在,先删除(ES2015+:重新 add 会移到插入顺序末尾)
|
||||
set.delete(messageId)
|
||||
set.add(messageId)
|
||||
// 超过 500 条时,删除最旧的一条(Set 迭代第一个 = 最早插入)
|
||||
if (set.size > 500) {
|
||||
const first = set.values().next().value as string
|
||||
if (first) set.delete(first)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 推送的新消息事件(将来接入 WS 时使用,与 agent 端对齐)
|
||||
*
|
||||
* 做什么:
|
||||
* 1. 【WS-06去重】先检查 message_id 是否已处理过,已处理则跳过
|
||||
* 2. 将新消息追加到消息列表
|
||||
* 3. 更新最后消息ID
|
||||
*
|
||||
* 为什么需要:
|
||||
* - 将来接入 WebSocket 后,WS 推送比轮询更实时
|
||||
* - 需要消息去重,防止 WS 重连后后端重发导致重复显示
|
||||
*
|
||||
* @param data - WebSocket 推送的消息数据
|
||||
*/
|
||||
function handleNewMessage(data: {
|
||||
conversation_id: string
|
||||
message_id: string
|
||||
sender_type: string
|
||||
sender_id: string
|
||||
sender_name?: string
|
||||
content: string
|
||||
msg_type?: string
|
||||
}): void {
|
||||
// WS-06 消息去重:检查 message_id 是否已处理过
|
||||
if (processedMessageIds.value.has(data.message_id)) {
|
||||
console.log(`[H5 WS去重] 跳过重复消息: ${data.message_id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录此消息ID为"已处理"
|
||||
trackProcessedMessageId(data.message_id)
|
||||
|
||||
// 追加消息到本地列表
|
||||
// 修复:message_type 应使用 sender_type(employee/agent/ai/system),
|
||||
// 而非 msg_type(text/image/file),否则 MessageBubble 无法识别消息类型
|
||||
messages.value.push({
|
||||
message_id: data.message_id,
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: (data.sender_type || 'system') as MessageType,
|
||||
msg_type: (data.msg_type || 'text') as MsgContentType,
|
||||
content: data.content,
|
||||
sender_name: data.sender_name || '',
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// 更新最后消息ID
|
||||
lastMessageId.value = data.message_id
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 操作方法
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 处理 OAuth2 授权回调
|
||||
* 委托给 employee store 处理
|
||||
* @param code 企微 OAuth2 授权码
|
||||
* @param state 企微 OAuth2 state 参数(可选)
|
||||
* @deprecated 请使用 employeeStore.handleOAuthCallback() 替代
|
||||
*/
|
||||
async function handleOAuthCallback(code: string, state?: string): Promise<void> {
|
||||
const employeeStore = useEmployeeStore()
|
||||
await employeeStore.handleOAuthCallback(code, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前用户信息
|
||||
* 从后端获取当前登录员工的详细信息
|
||||
*/
|
||||
async function fetchUserInfo(): Promise<void> {
|
||||
try {
|
||||
// 优先使用 employee store 的信息
|
||||
const employeeStore = useEmployeeStore()
|
||||
if (employeeStore.employeeInfo) {
|
||||
userInfo.value = {
|
||||
employee_id: employeeStore.employeeInfo.employee_id,
|
||||
employee_name: employeeStore.employeeInfo.employee_name,
|
||||
department: employeeStore.employeeInfo.department,
|
||||
position: employeeStore.employeeInfo.position,
|
||||
level: '',
|
||||
is_vip: employeeStore.employeeInfo.is_vip,
|
||||
avatar_url: employeeStore.employeeInfo.avatar,
|
||||
}
|
||||
}
|
||||
|
||||
// 同时从 /h5/user 获取最新信息(包含 is_vip 等)
|
||||
const data = await getUser()
|
||||
userInfo.value = data
|
||||
console.log('[Store] 获取用户信息成功:', data.employee_name)
|
||||
} catch (error) {
|
||||
console.error('[Store] 获取用户信息失败:', error)
|
||||
// 开发模式:API 失败时使用 mock 数据,不阻塞初始化
|
||||
if (!import.meta.env.VITE_WECOM_CORP_ID) {
|
||||
console.warn('[Store] 开发模式:使用 mock 用户信息')
|
||||
const employeeStore = useEmployeeStore()
|
||||
userInfo.value = {
|
||||
employee_id: employeeStore.employeeId || 'dev_test_employee',
|
||||
employee_name: employeeStore.employeeName || '开发测试用户',
|
||||
department: 'IT部',
|
||||
position: '开发工程师',
|
||||
level: '',
|
||||
is_vip: false,
|
||||
avatar_url: '',
|
||||
}
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前会话
|
||||
* 获取当前员工正在进行的会话
|
||||
* 同步 participants 到 store(邀请功能 P0-09~P0-11)
|
||||
*/
|
||||
async function fetchCurrentConversation(): Promise<void> {
|
||||
try {
|
||||
const data = await getCurrentConversation()
|
||||
currentConversation.value = data
|
||||
// 同步 can_call_agent 状态
|
||||
canCallAgent.value = data?.can_call_agent ?? false
|
||||
// 同步 participants(邀请功能)
|
||||
participants.value = data?.participants || []
|
||||
console.log('[Store] 获取当前会话:', data ? data.conversation_id : '无活跃会话', '参与者:', participants.value.length)
|
||||
} catch (error) {
|
||||
console.error('[Store] 获取当前会话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(含 AI 自动回复)- 乐观更新 UI
|
||||
* 在当前会话中发送一条文本消息。
|
||||
* 后端会自动生成 AI 回复,返回用户消息 + AI 回复。
|
||||
*
|
||||
* 乐观更新流程:
|
||||
* 1. 发送前生成临时消息,立即添加到列表显示"发送中..."
|
||||
* 2. API 返回成功后用真实消息替换,状态改为"已发送"
|
||||
* 3. API 失败时状态改为"发送失败",用户可点击重试
|
||||
*
|
||||
* @param content 消息内容
|
||||
* @param tempMessageId 临时消息ID(用于重试时定位)
|
||||
*/
|
||||
async function sendNewMessage(
|
||||
content: string,
|
||||
options?: {
|
||||
msg_type?: MsgContentType
|
||||
media_url?: string
|
||||
file_name?: string
|
||||
file_size?: number
|
||||
}
|
||||
): Promise<void> {
|
||||
console.log('[Store] sendNewMessage 开始执行, content:', content, 'options:', options)
|
||||
if (!content.trim()) {
|
||||
console.warn('[Store] content 为空,直接返回')
|
||||
return
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 步骤1:乐观更新 - 立即添加临时消息到列表
|
||||
// ========================================================================
|
||||
const employeeStore = useEmployeeStore()
|
||||
const tempMessageId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const tempMessage: Message = {
|
||||
message_id: tempMessageId,
|
||||
conversation_id: currentConversation.value?.conversation_id || '',
|
||||
message_type: 'employee',
|
||||
msg_type: options?.msg_type || 'text',
|
||||
content: content.trim(),
|
||||
sender_name: employeeStore.employeeName || '我',
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'sending', // 乐观更新:发送中状态
|
||||
}
|
||||
messages.value.push(tempMessage)
|
||||
console.log('[Store] 乐观更新:临时消息已添加到列表, tempMessageId:', tempMessageId)
|
||||
|
||||
// ========================================================================
|
||||
// 步骤2:调用后端 API
|
||||
// ========================================================================
|
||||
loading.value = true
|
||||
try {
|
||||
// 构建请求参数:文本消息只传 content,非文本消息额外传 msg_type/media_url 等
|
||||
const reqData: SendMessageRequest = {
|
||||
content: content.trim(),
|
||||
}
|
||||
if (options?.msg_type) {
|
||||
reqData.msg_type = options.msg_type
|
||||
reqData.media_url = options.media_url
|
||||
reqData.file_name = options.file_name
|
||||
reqData.file_size = options.file_size
|
||||
}
|
||||
console.log('[Store] 请求参数:', JSON.stringify(reqData))
|
||||
|
||||
const resp: SendMessageResponse = await sendMessage(reqData)
|
||||
console.log('[Store] API 响应:', resp)
|
||||
|
||||
// 防御性检查:确保 resp 和必要字段存在
|
||||
if (!resp) {
|
||||
console.error('[Store] API 响应为空')
|
||||
throw new Error('API 响应为空')
|
||||
}
|
||||
if (!resp.user_message) {
|
||||
console.error('[Store] API 响应缺少 user_message')
|
||||
throw new Error('API 响应格式错误:缺少 user_message')
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 步骤3:乐观更新成功 - 用真实消息替换临时消息
|
||||
// ========================================================================
|
||||
// 找到临时消息并替换为真实消息
|
||||
const tempIndex = messages.value.findIndex(m => m.message_id === tempMessageId)
|
||||
if (tempIndex !== -1) {
|
||||
// 用真实消息替换,状态改为"已发送"
|
||||
messages.value[tempIndex] = {
|
||||
...resp.user_message,
|
||||
status: 'sent',
|
||||
}
|
||||
console.log('[Store] 乐观更新成功:临时消息已替换为真实消息')
|
||||
} else {
|
||||
// 防御性:找不到临时消息时直接添加
|
||||
messages.value.push({ ...resp.user_message, status: 'sent' })
|
||||
}
|
||||
|
||||
// 将 AI 回复追加到本地列表(如果存在)
|
||||
if (resp.ai_reply) {
|
||||
messages.value.push(resp.ai_reply)
|
||||
lastMessageId.value = resp.ai_reply.message_id
|
||||
}
|
||||
|
||||
// 更新「是否可呼叫坐席」标志
|
||||
canCallAgent.value = resp.can_call_agent ?? false
|
||||
|
||||
// 如果会话信息中也有更新,保持同步
|
||||
if (currentConversation.value) {
|
||||
currentConversation.value.can_call_agent = resp.can_call_agent ?? false
|
||||
currentConversation.value.ai_substantive_reply_count = resp.ai_reply_count ?? 0
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[Store] 消息发送成功, AI回复计数:',
|
||||
resp.ai_reply_count,
|
||||
'可呼叫坐席:',
|
||||
resp.can_call_agent,
|
||||
'是否引导:',
|
||||
resp.is_guidance
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error('[Store] 消息发送失败:', error)
|
||||
console.error('[Store] 错误详情:', error?.message, error?.response?.data, error?.stack)
|
||||
|
||||
// ========================================================================
|
||||
// 步骤4:乐观更新失败 - 将临时消息状态改为"发送失败"
|
||||
// ========================================================================
|
||||
const tempIndex = messages.value.findIndex(m => m.message_id === tempMessageId)
|
||||
if (tempIndex !== -1) {
|
||||
// 状态改为"发送失败",用户可点击重试
|
||||
messages.value[tempIndex].status = 'failed'
|
||||
console.log('[Store] 乐观更新失败:临时消息状态已改为 failed')
|
||||
}
|
||||
|
||||
// 向上抛出错误,让调用方(InputBar)能感知并提示用户
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
console.log('[Store] sendNewMessage finally 执行完成')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询新消息
|
||||
* 增量获取当前会话中 lastMessageId 之后的新消息
|
||||
* 获取到新消息后追加到本地列表,并更新 lastMessageId
|
||||
*/
|
||||
async function pollNewMessages(): Promise<void> {
|
||||
// 未登录或无活跃会话时不轮询
|
||||
if (!isLoggedIn.value || !hasActiveConversation.value) return
|
||||
|
||||
try {
|
||||
const params = lastMessageId.value
|
||||
? { after_message_id: lastMessageId.value }
|
||||
: undefined
|
||||
const newMessages = await pollMessages(params)
|
||||
|
||||
if (newMessages && newMessages.length > 0) {
|
||||
// ======================================================================
|
||||
// WS-06 消息去重:过滤掉已处理过的消息
|
||||
// ======================================================================
|
||||
// 为什么:轮询和将来的 WS 推送可能同时到达同一条消息,
|
||||
// 需要先做 message_id 幂等检查,避免重复显示
|
||||
const uniqueNewMessages = newMessages.filter(msg => {
|
||||
// 检查是否已处理过
|
||||
if (processedMessageIds.value.has(msg.message_id)) {
|
||||
console.log(`[H5轮询去重] 跳过重复消息: ${msg.message_id}`)
|
||||
return false
|
||||
}
|
||||
// 记录此消息ID为"已处理"(更新到 Set 的最新位置)
|
||||
trackProcessedMessageId(msg.message_id)
|
||||
return true
|
||||
})
|
||||
|
||||
if (uniqueNewMessages.length > 0) {
|
||||
// 追加新消息到本地列表
|
||||
messages.value.push(...uniqueNewMessages)
|
||||
// 更新最后消息 ID
|
||||
lastMessageId.value = uniqueNewMessages[uniqueNewMessages.length - 1].message_id
|
||||
console.log('[Store] 轮询到新消息:', uniqueNewMessages.length, '条')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 轮询失败不弹提示,静默处理避免干扰用户
|
||||
console.error('[Store] 轮询消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动消息轮询
|
||||
* 使用 setInterval 每 3 秒轮询一次新消息
|
||||
* 在组件挂载时调用,组件卸载时务必调用 stopPolling 停止
|
||||
*/
|
||||
function startPolling(): void {
|
||||
// 防止重复启动
|
||||
if (pollTimer.value) return
|
||||
|
||||
console.log('[Store] 启动消息轮询(3秒间隔)')
|
||||
pollTimer.value = setInterval(() => {
|
||||
pollNewMessages()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止消息轮询
|
||||
* 在组件卸载或会话结束时调用
|
||||
*/
|
||||
function stopPolling(): void {
|
||||
if (pollTimer.value) {
|
||||
console.log('[Store] 停止消息轮询')
|
||||
clearInterval(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 招手/敲桌子 — 呼叫 IT 坐席
|
||||
* 调用后端招手接口,返回趣味话术
|
||||
* 将话术以系统消息形式插入对话列表
|
||||
*/
|
||||
async function shakeAgent(): Promise<void> {
|
||||
// 防止重复点击
|
||||
if (shaking.value) return
|
||||
|
||||
shaking.value = true
|
||||
try {
|
||||
// 从 employee store 获取当前员工信息
|
||||
const employeeStore = useEmployeeStore()
|
||||
const data = await shake({
|
||||
employee_id: employeeStore.employeeId,
|
||||
employee_name: employeeStore.employeeName,
|
||||
})
|
||||
console.log('[Store] 招手成功:', data)
|
||||
|
||||
// 将趣味话术以系统消息形式插入对话列表
|
||||
const systemMsg: Message = {
|
||||
message_id: `sys_shake_${Date.now()}`,
|
||||
conversation_id: currentConversation.value?.conversation_id || '',
|
||||
message_type: 'system',
|
||||
content: data.funny_phrase,
|
||||
sender_name: '系统',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(systemMsg)
|
||||
|
||||
// 如果招手后坐席已接入(status === 'serving'),刷新会话信息
|
||||
if (data.conversation?.status === 'serving') {
|
||||
await fetchCurrentConversation()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Store] 招手失败:', error)
|
||||
} finally {
|
||||
shaking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载审批流程链接
|
||||
* 从后端获取所有可用的审批流程链接
|
||||
*/
|
||||
async function fetchApprovalLinks(): Promise<void> {
|
||||
try {
|
||||
const data = await getApprovalLinks()
|
||||
approvalLinks.value = data
|
||||
console.log('[Store] 获取审批链接成功:', data.length, '条')
|
||||
} catch (error) {
|
||||
console.error('[Store] 获取审批链接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载软件下载列表
|
||||
* 从后端获取所有可下载的软件列表
|
||||
*/
|
||||
async function fetchSoftwareDownloads(): Promise<void> {
|
||||
try {
|
||||
const data = await getSoftwareDownloads()
|
||||
softwareDownloads.value = data
|
||||
console.log('[Store] 获取软件下载列表成功:', data.length, '条')
|
||||
} catch (error) {
|
||||
console.error('[Store] 获取软件下载列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 AI 助手面板展开/收起(移动端使用)
|
||||
*/
|
||||
function toggleAssistantPanel(): void {
|
||||
assistantPanelVisible.value = !assistantPanelVisible.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到指定会话(邀请链接加入后使用)
|
||||
* 做什么:加入邀请会话后,将当前视图切换到该会话
|
||||
* 为什么:被邀请人可能已有自己的会话,需要切换到邀请的会话
|
||||
*
|
||||
* 修复(2026-06-12):原实现仅调用 fetchCurrentConversation() 重新获取
|
||||
* "当前会话",未使用 conversationId 参数。如果后端不会自动切换
|
||||
* current conversation,则拿到的仍是用户原来的会话。
|
||||
* 现改为:先刷新当前会话(加入后后端会更新 current conversation),
|
||||
* 再验证会话ID是否匹配,确保切换成功。
|
||||
*
|
||||
* @param conversationId - 目标会话ID
|
||||
*/
|
||||
async function switchToConversation(conversationId: string): Promise<void> {
|
||||
try {
|
||||
// 重新获取当前会话(加入后后端会更新 current conversation)
|
||||
await fetchCurrentConversation()
|
||||
|
||||
// 验证:当前会话是否已切换到目标会话
|
||||
const conv = currentConversation.value
|
||||
if (conv && conv.conversation_id !== conversationId) {
|
||||
console.warn(
|
||||
'[Store] 当前会话ID不匹配, 期望:', conversationId,
|
||||
'实际:', conv.conversation_id
|
||||
)
|
||||
// 后端未自动切换时,仍按当前获取到的会话展示
|
||||
// (后端 /h5/conversations/current 理论上应返回刚加入的会话)
|
||||
}
|
||||
|
||||
// 清空消息列表,重新加载
|
||||
messages.value = []
|
||||
lastMessageId.value = ''
|
||||
// 立即拉取一次消息,避免等3秒轮询
|
||||
await pollNewMessages()
|
||||
console.log('[Store] 已切换到邀请会话:', conversationId)
|
||||
} catch (error) {
|
||||
console.error('[Store] 切换会话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 参与者主动退出会话(邀请功能 P0-11)
|
||||
* 做什么:被邀请人退出当前会话
|
||||
* 为什么:参与者不再需要参与时,可自行退出
|
||||
* 副作用:退出后清空当前会话状态
|
||||
*/
|
||||
async function leaveAsParticipant(): Promise<void> {
|
||||
if (!currentConversation.value) return
|
||||
|
||||
const convId = currentConversation.value.conversation_id
|
||||
|
||||
try {
|
||||
// H5 专用端点通过 Token 认证获取 employee_id,无需前端传递
|
||||
await leaveAsParticipantApi(convId)
|
||||
console.log('[Store] 已退出会话:', convId)
|
||||
// 退出后清空当前会话状态
|
||||
currentConversation.value = null
|
||||
participants.value = []
|
||||
messages.value = []
|
||||
lastMessageId.value = ''
|
||||
// 停止轮询
|
||||
stopPolling()
|
||||
} catch (error) {
|
||||
console.error('[Store] 退出会话失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换参与者面板展开/收起
|
||||
*/
|
||||
function toggleParticipantPanel(): void {
|
||||
participantPanelVisible.value = !participantPanelVisible.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试发送失败的消息
|
||||
* 做什么:当用户点击"发送失败"消息的重试按钮时,删除失败消息并重新发送
|
||||
* 为什么:乐观更新失败时允许用户手动重试发送
|
||||
*
|
||||
* @param messageId 失败消息的 ID
|
||||
*/
|
||||
async function retryMessage(messageId: string): Promise<void> {
|
||||
// 找到失败消息
|
||||
const msgIndex = messages.value.findIndex(m => m.message_id === messageId)
|
||||
if (msgIndex === -1) {
|
||||
console.warn('[Store] 重试消息找不到:', messageId)
|
||||
return
|
||||
}
|
||||
|
||||
const failedMessage = messages.value[msgIndex]
|
||||
if (failedMessage.status !== 'failed') {
|
||||
console.warn('[Store] 消息状态不是 failed,无法重试:', messageId)
|
||||
return
|
||||
}
|
||||
|
||||
const content = failedMessage.content
|
||||
// 删除失败消息
|
||||
messages.value.splice(msgIndex, 1)
|
||||
console.log('[Store] 删除失败消息:', messageId)
|
||||
|
||||
// 重新发送
|
||||
await sendNewMessage(content, {
|
||||
msg_type: failedMessage.msg_type,
|
||||
media_url: failedMessage.media_url,
|
||||
file_name: failedMessage.file_name,
|
||||
file_size: failedMessage.file_size,
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// WebSocket 事件处理方法(H5 员工端)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 通过 WS 推送直接更新参与者列表
|
||||
* 做什么:用后端 WS 推送的 participants 数据直接替换 store 中的列表
|
||||
* 为什么:比等3秒轮询更实时,参与者变更(加入/退出/被移除)立即可见
|
||||
*
|
||||
* @param newParticipants - 后端推送的最新参与者列表
|
||||
*/
|
||||
function updateParticipants(newParticipants: ParticipantItem[]): void {
|
||||
participants.value = newParticipants
|
||||
// 同步到 currentConversation(保持一致性)
|
||||
if (currentConversation.value) {
|
||||
currentConversation.value.participants = newParticipants
|
||||
}
|
||||
console.log('[Store] 参与者列表已通过WS更新:', newParticipants.length, '人')
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户被主责坐席从会话中移除
|
||||
* 做什么:清空当前会话状态,回到无会话状态
|
||||
* 为什么:被移除后不应再查看会话消息
|
||||
* 触发条件:WS 收到 participant_removed 事件,且 changed 中包含当前用户
|
||||
*/
|
||||
function handleRemovedFromConversation(): void {
|
||||
console.log('[Store] 当前用户被移除会话,清空状态')
|
||||
currentConversation.value = null
|
||||
participants.value = []
|
||||
messages.value = []
|
||||
lastMessageId.value = ''
|
||||
participantPanelVisible.value = false
|
||||
// 停止轮询(WS 会继续监听,下次有新会话时会自动更新)
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用
|
||||
* 1. 获取用户信息
|
||||
* 2. 获取当前会话
|
||||
* 3. 加载审批链接和软件下载
|
||||
* 4. 启动消息轮询
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
if (initialized.value) return
|
||||
|
||||
try {
|
||||
console.log('[Store] 开始初始化应用...')
|
||||
// 获取用户信息
|
||||
await fetchUserInfo()
|
||||
// 获取当前会话
|
||||
await fetchCurrentConversation()
|
||||
// 加载右侧面板数据
|
||||
await Promise.all([
|
||||
fetchApprovalLinks(),
|
||||
fetchSoftwareDownloads(),
|
||||
])
|
||||
// 启动消息轮询
|
||||
startPolling()
|
||||
initialized.value = true
|
||||
console.log('[Store] 应用初始化完成')
|
||||
} catch (error) {
|
||||
console.error('[Store] 应用初始化失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理状态
|
||||
* 在组件卸载时调用,停止轮询,清理定时器
|
||||
*/
|
||||
function cleanup(): void {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 返回所有状态和方法
|
||||
// ==========================================================================
|
||||
return {
|
||||
// 状态
|
||||
userInfo,
|
||||
currentConversation,
|
||||
messages,
|
||||
loading,
|
||||
shaking,
|
||||
canCallAgent,
|
||||
agentOnline,
|
||||
assistantPanelVisible,
|
||||
approvalLinks,
|
||||
softwareDownloads,
|
||||
lastMessageId,
|
||||
initialized,
|
||||
troubleshootingSteps,
|
||||
troubleshootingTemplateName,
|
||||
troubleshootingCurrentNode,
|
||||
participants,
|
||||
participantPanelVisible,
|
||||
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
hasActiveConversation,
|
||||
isParticipant,
|
||||
joinedParticipantCount,
|
||||
approvalLinksByCategory,
|
||||
softwareDownloadsByCategory,
|
||||
|
||||
// 方法
|
||||
handleOAuthCallback,
|
||||
fetchUserInfo,
|
||||
fetchCurrentConversation,
|
||||
sendNewMessage,
|
||||
pollNewMessages,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
shakeAgent,
|
||||
fetchApprovalLinks,
|
||||
fetchSoftwareDownloads,
|
||||
toggleAssistantPanel,
|
||||
switchToConversation,
|
||||
leaveAsParticipant,
|
||||
toggleParticipantPanel,
|
||||
retryMessage,
|
||||
updateParticipants,
|
||||
handleRemovedFromConversation,
|
||||
initialize,
|
||||
cleanup,
|
||||
|
||||
// WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用)
|
||||
handleNewMessage,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,414 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端员工状态管理(Pinia Store)
|
||||
// =============================================================================
|
||||
// 说明:管理员工认证状态,包括:
|
||||
// 1. Bearer Token 管理(保存、读取、清除)
|
||||
// 2. 用户信息管理(姓名、部门、岗位等)
|
||||
// 3. OAuth2 授权流程(回调处理、重新授权)
|
||||
// 4. Mock 登录(测试阶段,跳过 OAuth2)
|
||||
// =============================================================================
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
oauthCallback,
|
||||
mockLogin as mockLoginApi,
|
||||
getEmployeeInfo,
|
||||
getOAuthAuthorizeUrl,
|
||||
type OAuthCallbackResponse,
|
||||
type EmployeeInfo,
|
||||
} from '@/api/employee'
|
||||
// Bug #2 修复:从独立模块导入回调注册函数,避免循环依赖
|
||||
import { registerAuthExpiredHandler } from '@/utils/authCallback'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// localStorage Key 常量
|
||||
// --------------------------------------------------------------------------
|
||||
const TOKEN_KEY = 'h5_token'
|
||||
const PORTAL_TOKEN_KEY = 'portal_token'
|
||||
const EMPLOYEE_ID_KEY = 'employee_id'
|
||||
const EMPLOYEE_NAME_KEY = 'employee_name'
|
||||
/** OAuth2 重定向计数器 key(防止无限重定向循环) */
|
||||
const OAUTH_REDIRECT_COUNT_KEY = 'oauth_redirect_count'
|
||||
/** 最大允许重定向次数(超过此值停止重定向,避免无限循环) */
|
||||
const OAUTH_MAX_REDIRECT_COUNT = 3
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Store 定义
|
||||
// --------------------------------------------------------------------------
|
||||
export const useEmployeeStore = defineStore('employee', () => {
|
||||
|
||||
// ==========================================================================
|
||||
// 响应式状态
|
||||
// ==========================================================================
|
||||
|
||||
/** 访问令牌(Bearer Token)— 优先从 h5_token 读取,降级读取 portal_token */
|
||||
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || localStorage.getItem(PORTAL_TOKEN_KEY) || '')
|
||||
|
||||
/** 当前员工信息 */
|
||||
const employeeInfo = ref<EmployeeInfo | null>(null)
|
||||
|
||||
/** 是否正在认证中(OAuth2回调处理中) */
|
||||
const authenticating = ref<boolean>(false)
|
||||
|
||||
// ==========================================================================
|
||||
// 计算属性
|
||||
// ==========================================================================
|
||||
|
||||
/** 是否已认证(必须有有效的 Bearer Token) */
|
||||
const isAuthenticated = computed(() => {
|
||||
// 只检查 token,不检查 employee_id
|
||||
// employee_id 单独存在 localStorage 不代表已认证(可能是残留数据)
|
||||
if (!token.value) return false
|
||||
|
||||
// Bug #1 修复:检查 JWT token 是否过期
|
||||
// 如果 token 是 JWT 格式(含 exp 字段),验证是否已过期
|
||||
// 如果不是 JWT 格式(无法解析),放行让后端校验
|
||||
return !isTokenExpired(token.value)
|
||||
})
|
||||
|
||||
/** 当前员工ID */
|
||||
const employeeId = computed(() => employeeInfo.value?.employee_id || localStorage.getItem(EMPLOYEE_ID_KEY) || '')
|
||||
|
||||
/** 当前员工姓名 */
|
||||
const employeeName = computed(() => employeeInfo.value?.employee_name || localStorage.getItem(EMPLOYEE_NAME_KEY) || '')
|
||||
|
||||
// ==========================================================================
|
||||
// 内部方法
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 检查 JWT token 是否已过期
|
||||
* 解析 JWT payload 中的 exp 字段,与当前时间比较。
|
||||
* 包含 60 秒安全余量,防止客户端与服务端时钟偏差导致的问题。
|
||||
*
|
||||
* @param jwtToken JWT 格式的 token 字符串
|
||||
* @returns true 表示已过期或格式异常,false 表示仍有效
|
||||
*/
|
||||
function isTokenExpired(jwtToken: string): boolean {
|
||||
try {
|
||||
const parts = jwtToken.split('.')
|
||||
if (parts.length !== 3) {
|
||||
// 非标准 JWT 格式(可能是自定义 token),无法判断过期,放行让后端校验
|
||||
return false
|
||||
}
|
||||
const payload = JSON.parse(atob(parts[1]))
|
||||
if (!payload.exp) {
|
||||
// JWT 中无 exp 字段(永不过期或非标准),放行
|
||||
return false
|
||||
}
|
||||
// exp 是秒级 Unix 时间戳;提前 60 秒视为过期(安全余量)
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
return nowSeconds >= payload.exp - 60
|
||||
} catch {
|
||||
// 解析失败(base64 解码或 JSON 解析出错),不阻塞用户,放行让后端校验
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 token 到状态和 localStorage
|
||||
* @param newToken 新的访问令牌
|
||||
*/
|
||||
function _saveToken(newToken: string): void {
|
||||
token.value = newToken
|
||||
localStorage.setItem(TOKEN_KEY, newToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除认证状态
|
||||
* 移除 token 和员工信息
|
||||
*/
|
||||
function _clearAuth(): void {
|
||||
token.value = ''
|
||||
employeeInfo.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(EMPLOYEE_ID_KEY)
|
||||
localStorage.removeItem(EMPLOYEE_NAME_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 OAuth2 重定向计数器
|
||||
* 在登录成功后调用,清除之前的重定向计数
|
||||
*/
|
||||
function _resetRedirectCount(): void {
|
||||
localStorage.removeItem(OAUTH_REDIRECT_COUNT_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 递增 OAuth2 重定向计数器,并返回当前计数
|
||||
* @returns 当前重定向次数
|
||||
*/
|
||||
function _incrementRedirectCount(): number {
|
||||
const current = parseInt(localStorage.getItem(OAUTH_REDIRECT_COUNT_KEY) || '0', 10)
|
||||
const next = current + 1
|
||||
localStorage.setItem(OAUTH_REDIRECT_COUNT_KEY, String(next))
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已超过 OAuth2 最大重定向次数
|
||||
* @returns true 表示已超过阈值,应停止重定向
|
||||
*/
|
||||
function _isRedirectLoop(): boolean {
|
||||
const current = parseInt(localStorage.getItem(OAUTH_REDIRECT_COUNT_KEY) || '0', 10)
|
||||
return current >= OAUTH_MAX_REDIRECT_COUNT
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 公共方法
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 处理 OAuth2 授权回调
|
||||
* 将企微 OAuth2 授权码传给后端,换取 token 和员工身份
|
||||
* 成功后保存 token 和基本信息到 localStorage
|
||||
*
|
||||
* @param code 企微 OAuth2 授权码
|
||||
* @param state 企微 OAuth2 state 参数(可选)
|
||||
* @returns OAuth2 回调返回的数据
|
||||
* @throws 授权失败时抛出异常
|
||||
*/
|
||||
async function handleOAuthCallback(code: string, state?: string): Promise<OAuthCallbackResponse> {
|
||||
authenticating.value = true
|
||||
try {
|
||||
const data = await oauthCallback({ code, state })
|
||||
|
||||
// 保存 token
|
||||
_saveToken(data.token)
|
||||
|
||||
// 保存基本信息到 localStorage(供降级和快速读取使用)
|
||||
localStorage.setItem(EMPLOYEE_ID_KEY, data.employee_id)
|
||||
localStorage.setItem(EMPLOYEE_NAME_KEY, data.employee_name)
|
||||
|
||||
// 填充员工信息
|
||||
employeeInfo.value = {
|
||||
employee_id: data.employee_id,
|
||||
employee_name: data.employee_name,
|
||||
department: data.department,
|
||||
position: data.position,
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: data.avatar,
|
||||
is_vip: false,
|
||||
}
|
||||
|
||||
console.log('[EmployeeStore] OAuth2 登录成功:', data.employee_name)
|
||||
// 登录成功后重置重定向计数器,清除之前的循环记录
|
||||
_resetRedirectCount()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('[EmployeeStore] OAuth2 登录失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
authenticating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 登录(测试阶段,跳过 OAuth2)
|
||||
* 调用后端 /api/h5/mock-login 获取真实 Bearer Token
|
||||
* 仅当后端 MOCK_LOGIN_ENABLED=true 时可用
|
||||
*
|
||||
* @param empId 员工 ID
|
||||
* @param empName 员工姓名(可选)
|
||||
* @returns 登录返回的数据
|
||||
* @throws 登录失败时抛出异常
|
||||
*/
|
||||
async function mockLogin(empId: string, empName?: string): Promise<OAuthCallbackResponse> {
|
||||
authenticating.value = true
|
||||
try {
|
||||
const data = await mockLoginApi({
|
||||
employee_id: empId,
|
||||
employee_name: empName || '测试用户',
|
||||
})
|
||||
|
||||
// 保存 token
|
||||
_saveToken(data.token)
|
||||
|
||||
// 保存基本信息到 localStorage
|
||||
localStorage.setItem(EMPLOYEE_ID_KEY, data.employee_id)
|
||||
localStorage.setItem(EMPLOYEE_NAME_KEY, data.employee_name)
|
||||
|
||||
// 填充员工信息
|
||||
employeeInfo.value = {
|
||||
employee_id: data.employee_id,
|
||||
employee_name: data.employee_name,
|
||||
department: data.department,
|
||||
position: data.position,
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: data.avatar,
|
||||
is_vip: false,
|
||||
}
|
||||
|
||||
console.log('[EmployeeStore] Mock 登录成功:', data.employee_name)
|
||||
_resetRedirectCount()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('[EmployeeStore] Mock 登录失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
authenticating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前员工详细信息
|
||||
* 从后端 /api/h5/me 获取最新的员工信息
|
||||
*/
|
||||
async function fetchEmployeeInfo(): Promise<void> {
|
||||
if (!token.value) return
|
||||
|
||||
try {
|
||||
const data = await getEmployeeInfo()
|
||||
employeeInfo.value = data
|
||||
// 同步更新 localStorage
|
||||
localStorage.setItem(EMPLOYEE_ID_KEY, data.employee_id)
|
||||
localStorage.setItem(EMPLOYEE_NAME_KEY, data.employee_name)
|
||||
console.log('[EmployeeStore] 获取员工信息成功:', data.employee_name)
|
||||
} catch (error) {
|
||||
console.error('[EmployeeStore] 获取员工信息失败:', error)
|
||||
// 开发模式:API 失败时使用 mock 数据,不阻塞初始化
|
||||
if (!import.meta.env.VITE_WECOM_CORP_ID) {
|
||||
console.warn('[EmployeeStore] 开发模式:使用 mock 员工信息')
|
||||
employeeInfo.value = {
|
||||
employee_id: localStorage.getItem(EMPLOYEE_ID_KEY) || 'dev_test_employee',
|
||||
employee_name: '开发测试用户',
|
||||
department: 'IT部',
|
||||
position: '开发工程师',
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
is_vip: false,
|
||||
}
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转企微 OAuth2 授权页面
|
||||
* 优先从后端获取授权URL(后端知道正确的 CORP_ID),传入当前页面地址作为回调
|
||||
* 失败则本地构造
|
||||
*/
|
||||
async function redirectToOAuth(): Promise<void> {
|
||||
// 当前页面的完整回调地址
|
||||
const currentRedirectUri = window.location.origin + '/itdesk/'
|
||||
|
||||
try {
|
||||
// 优先从后端获取授权URL(后端知道正确的 CORP_ID)
|
||||
const data = await getOAuthAuthorizeUrl()
|
||||
if (data.authorize_url) {
|
||||
window.location.href = data.authorize_url
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[EmployeeStore] 从后端获取授权URL失败,尝试本地构造:', error)
|
||||
}
|
||||
|
||||
// 降级:本地构造授权URL
|
||||
const corpId = import.meta.env.VITE_WECOM_CORP_ID || ''
|
||||
if (!corpId) {
|
||||
console.warn('[EmployeeStore] 未配置 VITE_WECOM_CORP_ID,无法跳转授权')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = encodeURIComponent(currentRedirectUri)
|
||||
const oauthUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${corpId}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect`
|
||||
window.location.href = oauthUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 401 未授权错误
|
||||
* 清除认证状态,重新跳转 OAuth2 授权
|
||||
* 加入防循环机制:当短时间内多次重定向时,停止跳转并提示用户
|
||||
*/
|
||||
async function handleUnauthorized(): Promise<void> {
|
||||
console.warn('[EmployeeStore] Token 已过期或无效,重新授权')
|
||||
|
||||
// 防循环检测:超过最大重定向次数时停止跳转
|
||||
if (_isRedirectLoop()) {
|
||||
console.error('[EmployeeStore] OAuth2 重定向次数超限,疑似无限循环,停止重定向')
|
||||
_clearAuth()
|
||||
_resetRedirectCount()
|
||||
// 显示错误提示,引导用户手动操作
|
||||
const { showToast } = await import('vant')
|
||||
showToast('登录状态异常,请刷新页面重试')
|
||||
return
|
||||
}
|
||||
|
||||
_clearAuth()
|
||||
// 递增重定向计数
|
||||
const count = _incrementRedirectCount()
|
||||
console.warn(`[EmployeeStore] OAuth2 重定向计数: ${count}/${OAUTH_MAX_REDIRECT_COUNT}`)
|
||||
await redirectToOAuth()
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地开发降级登录
|
||||
* 在非企微环境下手动输入 employee_id 进行登录
|
||||
* 注意:此方式不设置 Bearer token(后端无法验证),
|
||||
* 而是保留 employee_id 在 localStorage,让 Axios 拦截器
|
||||
* 使用 X-Employee-Id 降级头发送身份信息
|
||||
*
|
||||
* @param empId 员工 ID
|
||||
*/
|
||||
function devLogin(empId: string): void {
|
||||
localStorage.setItem(EMPLOYEE_ID_KEY, empId)
|
||||
// 注意:不设置 h5_token,让 API 拦截器使用 X-Employee-Id 降级头
|
||||
token.value = ''
|
||||
employeeInfo.value = {
|
||||
employee_id: empId,
|
||||
employee_name: '开发测试用户',
|
||||
department: 'IT部',
|
||||
position: '开发工程师',
|
||||
mobile: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
is_vip: false,
|
||||
}
|
||||
console.log('[EmployeeStore] 开发模式登录:', empId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
* 清除认证状态
|
||||
*/
|
||||
function logout(): void {
|
||||
_clearAuth()
|
||||
console.log('[EmployeeStore] 已登出')
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Store 初始化:注册认证过期回调
|
||||
// ==========================================================================
|
||||
// Bug #2 修复:让 api/index.ts 通过 triggerAuthExpired() 触发 handleUnauthorized,
|
||||
// 避免 api/index.ts → stores/employee.ts 的循环 dynamic import。
|
||||
registerAuthExpiredHandler(handleUnauthorized)
|
||||
|
||||
// ==========================================================================
|
||||
// 返回所有状态和方法
|
||||
// ==========================================================================
|
||||
return {
|
||||
// 状态
|
||||
token,
|
||||
employeeInfo,
|
||||
authenticating,
|
||||
|
||||
// 计算属性
|
||||
isAuthenticated,
|
||||
employeeId,
|
||||
employeeName,
|
||||
|
||||
// 方法
|
||||
handleOAuthCallback,
|
||||
mockLogin,
|
||||
fetchEmployeeInfo,
|
||||
redirectToOAuth,
|
||||
handleUnauthorized,
|
||||
devLogin,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端主题 Pinia Store
|
||||
// =============================================================================
|
||||
// 说明:管理全局主题状态(浅色/深色),提供 toggleTheme 和 initTheme 方法
|
||||
// =============================================================================
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { applyTheme, getInitialTheme, type ThemeMode } from '@/composables/useTheme'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Store 定义
|
||||
// --------------------------------------------------------------------------
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// ==========================================================================
|
||||
// 响应式状态
|
||||
// ==========================================================================
|
||||
|
||||
/** 当前主题模式 */
|
||||
const currentTheme = ref<ThemeMode>('light')
|
||||
|
||||
// ==========================================================================
|
||||
// 方法
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 切换主题
|
||||
* 在浅色和深色之间切换,并持久化到 localStorage
|
||||
*/
|
||||
function toggleTheme(): void {
|
||||
const next: ThemeMode = currentTheme.value === 'light' ? 'dark' : 'light'
|
||||
currentTheme.value = next
|
||||
applyTheme(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化主题
|
||||
* 从 localStorage 读取已保存的主题偏好并应用
|
||||
*/
|
||||
function initTheme(): void {
|
||||
const theme = getInitialTheme()
|
||||
currentTheme.value = theme
|
||||
applyTheme(theme)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 返回
|
||||
// ==========================================================================
|
||||
return {
|
||||
currentTheme,
|
||||
toggleTheme,
|
||||
initTheme,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,351 @@
|
||||
/* =============================================================================
|
||||
* 企微IT智能服务台 — H5用户端全局样式
|
||||
* =============================================================================
|
||||
* 说明:全局基础样式,包括:
|
||||
* 1. CSS 变量(浅色/深色双主题)
|
||||
* 2. 全局重置样式
|
||||
* 3. 企微 WebView 适配
|
||||
* 4. 摇人按钮动画
|
||||
* 5. 通用工具类
|
||||
* ============================================================================= */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* CSS 变量 — 统一管理主题色和间距(同步原型 v5.3)
|
||||
* 色值体系来源:Tailwind / shadcn-ui 色板,原型 v5.3 定义
|
||||
* -------------------------------------------------------------------------- */
|
||||
:root {
|
||||
/* ---- 浅色主题背景色 — 企微风格(更柔和的灰) ---- */
|
||||
--bg-primary: #f7f7f7;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #ededed;
|
||||
--bg-hover: #e8ecf1;
|
||||
--bg-active: #dce3ec;
|
||||
--bg-accent-soft: rgba(7, 193, 96, 0.1);
|
||||
|
||||
/* ---- 浅色主题文字色 — 企微风格 ---- */
|
||||
--text-primary: #191919;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--text-muted: #999999; /* 同 text-tertiary,原型命名 */
|
||||
--text-placeholder: #c0c4cc;
|
||||
|
||||
/* ---- 浅色主题边框色 — 企微风格(更淡) ---- */
|
||||
--border: #e5e5e5; /* 原型命名 */
|
||||
--border-color: #e5e5e5; /* 兼容旧引用 */
|
||||
--border-light: #f0f0f0;
|
||||
|
||||
/* ---- 主色调 — 企微绿 ---- */
|
||||
--accent: #07C160;
|
||||
--accent-hover: #06ae56;
|
||||
--accent-soft: rgba(7, 193, 96, 0.1);
|
||||
|
||||
/* ---- 语义色 — 企微风格 ---- */
|
||||
--color-primary: #07C160;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
--color-info: #60a5fa;
|
||||
|
||||
/* 语义色 soft 版 */
|
||||
--success-soft: #dcfce7;
|
||||
--warning-soft: #fef3c7;
|
||||
--danger-soft: #fee2e2;
|
||||
--accent-soft: rgba(7, 193, 96, 0.1);
|
||||
--color-success-soft: #dcfce7;
|
||||
--color-warning-soft: #fef3c7;
|
||||
--color-danger-soft: #fee2e2;
|
||||
|
||||
/* 扩展色 */
|
||||
--purple: #8b5cf6;
|
||||
--purple-soft: #ede9fe;
|
||||
--orange: #f97316;
|
||||
--orange-soft: #fff7ed;
|
||||
|
||||
/* 员工消息背景(企微绿底白字) */
|
||||
--color-employee-bg: #07C160;
|
||||
/* 坐席消息背景(白底黑字) */
|
||||
--color-agent-bg: #ffffff;
|
||||
/* 坐席消息边框 */
|
||||
--color-agent-border: #e2e8f0;
|
||||
/* AI 消息背景 */
|
||||
--color-ai-bg: #dcfce7;
|
||||
/* AI 消息文字 */
|
||||
--color-ai-text: #166534;
|
||||
/* AI 标签背景 */
|
||||
--color-ai-tag-bg: #dcfce7;
|
||||
/* AI 标签文字 */
|
||||
--color-ai-tag-text: #22c55e;
|
||||
/* 系统消息文字 */
|
||||
--color-system-text: #94a3b8;
|
||||
/* 系统消息背景 */
|
||||
--color-system-bg: #f0f2f5;
|
||||
/* 摇人按钮绿色渐变 — 企微风格 */
|
||||
--color-shake-start: #07C160;
|
||||
--color-shake-end: #06ae56;
|
||||
/* 呼叫引导条文字 */
|
||||
--color-guide-active: #f97316;
|
||||
|
||||
/* ---- 圆角 — 企微风格偏圆润 ---- */
|
||||
--border-radius-sm: 4px;
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 12px;
|
||||
--border-radius-round: 50%;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* ---- 间距 ---- */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 24px;
|
||||
|
||||
/* ---- 字体大小 ---- */
|
||||
--font-size-sm: 12px;
|
||||
--font-size-md: 14px;
|
||||
--font-size-lg: 16px;
|
||||
|
||||
/* ---- 阴影 ---- */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* ---- 过渡 ---- */
|
||||
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* 深色主题覆盖(同步原型 v5.3)
|
||||
* -------------------------------------------------------------------------- */
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #0f1923;
|
||||
--bg-secondary: #151f2b;
|
||||
--bg-tertiary: #1a2736;
|
||||
--bg-hover: #1e3044;
|
||||
--bg-active: #243b52;
|
||||
--bg-accent-soft: #2b5f8a;
|
||||
|
||||
--text-primary: #e8edf2;
|
||||
--text-secondary: #8ba1b7;
|
||||
--text-tertiary: #5c7185;
|
||||
--text-muted: #5c7185;
|
||||
--text-placeholder: #3d5568;
|
||||
|
||||
--border: #1e3044;
|
||||
--border-color: #1e3044;
|
||||
--border-light: #2a3f56;
|
||||
|
||||
--accent: #4da6ff;
|
||||
--accent-hover: #73b9ff;
|
||||
--accent-soft: #2b5f8a;
|
||||
|
||||
--color-primary: #4da6ff;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-danger: #f87171;
|
||||
--color-info: #60a5fa;
|
||||
|
||||
--success-soft: #1a3a2a;
|
||||
--warning-soft: #3a2f10;
|
||||
--danger-soft: #3a1a1a;
|
||||
--accent-soft: #2b5f8a;
|
||||
--color-success-soft: #1a3a2a;
|
||||
--color-warning-soft: #3a2f10;
|
||||
--color-danger-soft: #3a1a1a;
|
||||
|
||||
--purple: #a78bfa;
|
||||
--purple-soft: #2d2060;
|
||||
--orange: #fb923c;
|
||||
--orange-soft: #3d1f08;
|
||||
|
||||
--color-employee-bg: #4da6ff;
|
||||
--color-agent-bg: #1a2736;
|
||||
--color-agent-border: #1e3044;
|
||||
--color-ai-bg: #1a3a2a;
|
||||
--color-ai-text: #34d399;
|
||||
--color-ai-tag-bg: rgba(52, 211, 153, 0.15);
|
||||
--color-ai-tag-text: #34d399;
|
||||
--color-system-text: #5c7185;
|
||||
--color-system-bg: #1a2736;
|
||||
--color-shake-start: #FF6B35;
|
||||
--color-shake-end: #FF8F5E;
|
||||
--color-guide-active: #fb923c;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.25);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* 全局重置
|
||||
* -------------------------------------------------------------------------- */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans SC', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* 防止企微 WebView 中的文字大小被系统设置影响 */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* 企微 WebView 适配
|
||||
* -------------------------------------------------------------------------- */
|
||||
/* 禁止长按弹出菜单(企微 WebView 中常见问题) */
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 输入框允许选择文字 */
|
||||
input,
|
||||
textarea {
|
||||
-webkit-user-select: auto;
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
/* 禁止点击高亮 */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* 摇人按钮摇晃动画
|
||||
* -------------------------------------------------------------------------- */
|
||||
@keyframes shake {
|
||||
0% { transform: rotate(0deg); }
|
||||
10% { transform: rotate(-15deg); }
|
||||
20% { transform: rotate(15deg); }
|
||||
30% { transform: rotate(-10deg); }
|
||||
40% { transform: rotate(10deg); }
|
||||
50% { transform: rotate(-5deg); }
|
||||
60% { transform: rotate(5deg); }
|
||||
70% { transform: rotate(-2deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
90% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* 摇人按钮动画类 */
|
||||
.shake-animation {
|
||||
animation: shake 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* 通用工具类
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/* 文本省略 */
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 双栏布局容器 */
|
||||
.dual-column-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 左栏:对话区(60%) */
|
||||
.dual-column-left {
|
||||
flex: 0 0 60%;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 右栏:AI助手面板(40%) */
|
||||
.dual-column-right {
|
||||
flex: 0 0 40%;
|
||||
max-width: 40%;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* 主题切换滑轨样式(匹配原型v5.3)
|
||||
* -------------------------------------------------------------------------- */
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.theme-switch .switch-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.theme-switch .switch-track {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--border-light);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-switch .switch-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-switch .switch-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.3s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-switch .switch-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* 滚动条美化
|
||||
* -------------------------------------------------------------------------- */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--text-placeholder);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// =============================================================================
|
||||
// 认证过期回调注册中心
|
||||
// =============================================================================
|
||||
// 解决 api/index.ts ↔ stores/employee.ts 循环依赖问题。
|
||||
//
|
||||
// 依赖链:stores/employee.ts → api/employee.ts → api/index.ts
|
||||
// 如果 api/index.ts 再 import stores/employee.ts,就形成循环依赖。
|
||||
//
|
||||
// 方案:将回调注册逻辑提取为独立模块(无任何依赖),
|
||||
// - stores/employee.ts 启动时调用 registerAuthExpiredHandler() 注册处理器
|
||||
// - api/index.ts 触发 401 时调用 triggerAuthExpired() 调用已注册的处理器
|
||||
// =============================================================================
|
||||
|
||||
/** 回调函数类型 */
|
||||
type AuthExpiredHandler = () => Promise<void>
|
||||
|
||||
/** 已注册的处理器(初始为 null,store 初始化后赋值) */
|
||||
let _handler: AuthExpiredHandler | null = null
|
||||
|
||||
/**
|
||||
* 注册认证过期处理器
|
||||
* 由 stores/employee.ts 在 store 初始化时调用,将 handleUnauthorized 注册为回调。
|
||||
*
|
||||
* @param handler 认证过期时执行的处理函数
|
||||
*/
|
||||
export function registerAuthExpiredHandler(handler: AuthExpiredHandler): void {
|
||||
_handler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发认证过期处理
|
||||
* 由 api/index.ts 在收到 401/1002 时调用,委托给已注册的处理器。
|
||||
* 如果尚未注册(模块加载顺序异常),返回 false 让调用方做降级处理。
|
||||
*
|
||||
* @returns true 表示已成功触发处理,false 表示处理器未注册
|
||||
*/
|
||||
export async function triggerAuthExpired(): Promise<boolean> {
|
||||
if (_handler) {
|
||||
await _handler()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端聊天主页面
|
||||
// =============================================================================
|
||||
// 说明:响应式双栏/单栏布局
|
||||
// - 桌面端(≥500px):左栏对话区 + 拖拽手柄 + 右栏三段式面板
|
||||
// - 手机端(<500px):全宽单栏对话区,无右侧面板
|
||||
// - 拖拽手柄:左右栏宽度可手动调节(桌面端)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<!-- 左栏:对话区(始终显示) -->
|
||||
<div
|
||||
class="chat-view__left"
|
||||
:class="{ 'chat-view__left--full': !showRightPanel }"
|
||||
:style="!isMobile && showRightPanel ? { flex: `0 0 ${leftWidth}%`, maxWidth: `${leftWidth}%` } : {}"
|
||||
>
|
||||
<ChatPanel />
|
||||
</div>
|
||||
|
||||
<!-- 拖拽手柄:左右栏分隔条(仅桌面端显示) -->
|
||||
<div
|
||||
v-if="showRightPanel && !isMobile"
|
||||
class="chat-view__resize-handle"
|
||||
@mousedown="handleResizeStart"
|
||||
>
|
||||
<div class="chat-view__resize-grip"></div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:三段式面板(仅桌面端显示) -->
|
||||
<div
|
||||
v-if="showRightPanel"
|
||||
class="chat-view__right"
|
||||
>
|
||||
<RightPanel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ChatView 聊天主页面
|
||||
* 响应式布局:
|
||||
* - 桌面端(≥500px):双栏 — 左栏对话区 + 右栏三段式面板
|
||||
* - 手机端(<500px):单栏 — 全宽对话区,无右侧面板
|
||||
* 桌面端支持拖拽手柄手动调节左右栏宽度
|
||||
* 右侧面板三段式:AI推送区 / 常用资源标签页 / 趣味问答
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router' // 新增:useRoute 用于读取 URL 参数
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useEmployeeStore } from '@/stores/employee' // 新增:检查登录状态
|
||||
import { useH5WebSocket } from '@/composables/useH5WebSocket' // H5 WebSocket
|
||||
import ChatPanel from '@/components/chat/ChatPanel.vue'
|
||||
import RightPanel from '@/components/assistant/RightPanel.vue'
|
||||
import { joinConversation as joinConversationApi } from '@/api/conversation' // 邀请功能
|
||||
|
||||
// 新增:路由实例(用于跳转登录页)
|
||||
const router = useRouter()
|
||||
const route = useRoute() // 用于读取 URL 参数(邀请链接等)
|
||||
|
||||
// 新增:员工状态管理(用于检查登录状态)
|
||||
const employeeStore = useEmployeeStore()
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
// H5 WebSocket 连接(实时推送参与者变更、新消息等事件)
|
||||
const h5ws = useH5WebSocket()
|
||||
|
||||
/** 当前窗口宽度 */
|
||||
const windowWidth = ref<number>(window.innerWidth)
|
||||
|
||||
/** 左栏宽度百分比(默认60%) */
|
||||
const leftWidth = ref<number>(60)
|
||||
|
||||
/** 是否正在拖拽调节宽度 */
|
||||
const isResizing = ref<boolean>(false)
|
||||
|
||||
/** 是否为移动端窄屏(宽度 < 500px,与原型图对齐) */
|
||||
const isMobile = computed(() => windowWidth.value < 500)
|
||||
|
||||
/** 是否显示右栏面板(桌面端始终显示,手机端隐藏) */
|
||||
const showRightPanel = computed(() => {
|
||||
// 桌面端:始终显示右栏
|
||||
if (!isMobile.value) return true
|
||||
// 手机端:不显示右栏
|
||||
return false
|
||||
})
|
||||
|
||||
/**
|
||||
* ── 拖拽调节左右栏宽度 ──
|
||||
* 方案:只固定左侧宽度,右侧 flex:1 自动填满,不留空白
|
||||
* 左栏最小宽度 40%,右栏最小宽度 220px
|
||||
*/
|
||||
|
||||
/** 拖拽开始:记录初始 X 坐标和初始左栏宽度 */
|
||||
function handleResizeStart(event: MouseEvent): void {
|
||||
isResizing.value = true
|
||||
|
||||
const startX = event.clientX
|
||||
const startLeftWidth = leftWidth.value
|
||||
const containerWidth = windowWidth.value
|
||||
|
||||
/**
|
||||
* 拖拽中:根据鼠标移动距离换算为百分比,更新左栏宽度
|
||||
*/
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
if (!isResizing.value) return
|
||||
|
||||
const deltaX = e.clientX - startX
|
||||
const deltaPercent = (deltaX / containerWidth) * 100
|
||||
|
||||
let newLeftWidth = startLeftWidth + deltaPercent
|
||||
|
||||
// 限制最小/最大宽度:左栏 40%~80%
|
||||
newLeftWidth = Math.max(40, Math.min(80, newLeftWidth))
|
||||
|
||||
leftWidth.value = newLeftWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽结束:移除事件监听
|
||||
*/
|
||||
function handleMouseUp(): void {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听窗口大小变化
|
||||
* 更新 windowWidth 以响应式调整布局
|
||||
*/
|
||||
function handleResize(): void {
|
||||
windowWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
// 生命周期:挂载时添加 resize 监听 + 检查登录状态 + 初始化应用
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// 🔒 登录状态检查:未登录则跳转登录页
|
||||
if (!employeeStore.isAuthenticated) {
|
||||
console.warn('[ChatView] 未登录,跳转登录页')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化应用(获取用户信息、会话、审批链接等)
|
||||
store.initialize()
|
||||
|
||||
// 🔌 建立 WebSocket 连接(实时推送参与者变更、新消息等)
|
||||
// 做什么:登录后建立 WS 连接,替代纯轮询
|
||||
// 为什么:WS 推送比3秒轮询更实时,参与者变更可立即可见
|
||||
h5ws.connect()
|
||||
})
|
||||
|
||||
// 📋 邀请链接处理:URL 中的 invite 和 eid 参数
|
||||
// 做什么:被邀请人点击企微卡片后,自动加入会话并切换视图
|
||||
// 为什么:实现邀请-加入闭环(P0-10)
|
||||
// 修复(2026-06-12):原实现使用 setTimeout(1000) 等待 store 初始化,
|
||||
// 存在竞态风险(初始化超过1秒时会失败)。现改为 watch 监听 initialized 变化,
|
||||
// 确保 store 完全初始化后再执行邀请加入逻辑。
|
||||
const inviteId = route.query.invite as string
|
||||
const eid = route.query.eid as string
|
||||
|
||||
if (inviteId && eid) {
|
||||
console.log(`[ChatView] 检测到邀请链接: conv=${inviteId}, eid=${eid}`)
|
||||
|
||||
// 监听 store 初始化完成,然后执行邀请加入
|
||||
const stopWatch = watch(
|
||||
() => store.initialized,
|
||||
async (isInitialized) => {
|
||||
if (!isInitialized) return
|
||||
|
||||
// 停止监听(只执行一次)
|
||||
stopWatch()
|
||||
|
||||
try {
|
||||
// H5 专用端点通过 Token 认证获取 employee_id,无需传递 eid
|
||||
await joinConversationApi(inviteId)
|
||||
console.log('[ChatView] 已成功加入邀请的会话')
|
||||
// 切换到邀请的会话(刷新会话信息 + 加载消息)
|
||||
await store.switchToConversation(inviteId)
|
||||
} catch (err: any) {
|
||||
console.error('[ChatView] 加入会话失败:', err)
|
||||
// 如果是拒绝性错误(未被邀请/会话已结束),不切换
|
||||
const code = err?.response?.data?.code
|
||||
if (code !== 3034 && code !== 3033) {
|
||||
// 其他错误(如重复加入),仍尝试切换到该会话
|
||||
try {
|
||||
await store.switchToConversation(inviteId)
|
||||
} catch (switchErr) {
|
||||
console.error('[ChatView] 切换会话也失败:', switchErr)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 清理 URL 参数(避免刷新后重复加入)
|
||||
router.replace({ path: route.path, query: {} })
|
||||
}
|
||||
},
|
||||
{ immediate: true } // 如果已经初始化完成,立即执行
|
||||
)
|
||||
}
|
||||
|
||||
// 生命周期:卸载时移除 resize 监听 + 停止轮询 + 断开WS + 清理
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
h5ws.disconnect()
|
||||
store.cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 聊天主页面容器:双栏 Flex 布局 */
|
||||
.chat-view {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100dvh; /* 修复:用 dvh 单位,移动端友好 */
|
||||
background-color: var(--bg-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 左栏:对话区 */
|
||||
.chat-view__left {
|
||||
flex: 0 0 60%;
|
||||
max-width: 60%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左栏全宽(移动端右栏收起时) */
|
||||
.chat-view__left--full {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 拖拽手柄:左右栏分隔条 */
|
||||
.chat-view__resize-handle {
|
||||
flex: 0 0 6px;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background-color: var(--border-color);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 拖拽手柄悬停和拖拽中高亮 */
|
||||
.chat-view__resize-handle:hover,
|
||||
.chat-view__resize-handle:active {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 拖拽手柄上的抓握指示点 */
|
||||
.chat-view__resize-grip {
|
||||
width: 2px;
|
||||
height: 32px;
|
||||
border-radius: 1px;
|
||||
background-color: var(--text-placeholder);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.chat-view__resize-handle:hover .chat-view__resize-grip {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 右栏:三段式面板 */
|
||||
.chat-view__right {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 移动端适配(<500px) */
|
||||
@media (max-width: 499px) {
|
||||
/* 移动端左栏全宽 */
|
||||
.chat-view__left {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端降级登录页
|
||||
// =============================================================================
|
||||
// 说明:测试阶段的降级登录页面
|
||||
// - 未认证企微无法进行 OAuth2 授权(可信域名备案限制)
|
||||
// - 通过后端 Mock 登录接口获取真实 Bearer Token
|
||||
// - 仅当后端 MOCK_LOGIN_ENABLED=true 时可用
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<!-- 标题区 -->
|
||||
<div class="login-header">
|
||||
<div class="login-icon">🛠️</div>
|
||||
<h1 class="login-title">IT智能服务台</h1>
|
||||
<p class="login-subtitle">测试模式登录</p>
|
||||
</div>
|
||||
|
||||
<!-- 表单区 -->
|
||||
<div class="login-form">
|
||||
<van-field
|
||||
v-model="employeeId"
|
||||
label="员工ID"
|
||||
placeholder="请输入企微员工 UserID"
|
||||
clearable
|
||||
:rules="[{ required: true, message: '请输入员工ID' }]"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
|
||||
<van-field
|
||||
v-model="employeeName"
|
||||
label="姓名"
|
||||
placeholder="请输入姓名(可选)"
|
||||
clearable
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
class="login-btn"
|
||||
:loading="loading"
|
||||
:disabled="!employeeId.trim()"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登 录
|
||||
</van-button>
|
||||
|
||||
<p class="login-hint">
|
||||
此页面仅用于测试阶段。<br />
|
||||
通过后端 Mock 登录接口获取真实 Token。<br />
|
||||
正式上线后将使用企微 OAuth2 静默授权。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Login 测试登录页
|
||||
* 测试阶段绕过企微 OAuth2,通过后端 mock-login 获取真实 Bearer Token
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
const router = useRouter()
|
||||
const employeeStore = useEmployeeStore()
|
||||
|
||||
/** 员工ID输入值 */
|
||||
const employeeId = ref<string>('')
|
||||
|
||||
/** 员工姓名输入值 */
|
||||
const employeeName = ref<string>('')
|
||||
|
||||
/** 是否正在登录 */
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* 处理登录
|
||||
* 调用后端 mock-login 接口获取真实 Bearer Token
|
||||
*/
|
||||
async function handleLogin(): Promise<void> {
|
||||
const id = employeeId.value.trim()
|
||||
if (!id) {
|
||||
showToast('请输入员工ID')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await employeeStore.mockLogin(id, employeeName.value.trim() || undefined)
|
||||
showToast('登录成功')
|
||||
router.push({ name: 'ChatView' })
|
||||
} catch (error: any) {
|
||||
console.error('[Login] 登录失败:', error)
|
||||
const msg = error?.response?.data?.message || error?.message || '登录失败,请重试'
|
||||
showToast(msg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 登录页面容器:全屏居中 */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 登录卡片 */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 40px 32px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 标题区 */
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* 图标 */
|
||||
.login-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
/* 副标题 */
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 表单区 */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-btn {
|
||||
margin-top: 8px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 提示文字 */
|
||||
.login-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-placeholder);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<!-- =============================================================================
|
||||
# 企微专属拦截页 — 非企微环境展示,引导用户在企微中打开
|
||||
#============================================================================= -->
|
||||
<template>
|
||||
<div class="wework-only">
|
||||
<div class="wework-only__card">
|
||||
<!-- 企微图标 -->
|
||||
<div class="wework-only__icon">
|
||||
<svg viewBox="0 0 1024 1024" width="80" height="80">
|
||||
<path
|
||||
d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 664c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm64-160c-17.7 0-32-14.3-32-32V416c0-17.7 14.3-32 32-32s32 14.3 32 32v120c0 17.7-14.3 32-32 32z"
|
||||
fill="#07C160"
|
||||
/>
|
||||
<path
|
||||
d="M685.6 354.4c-9.6-9.6-25.2-9.6-34.8 0L512 493.2 373.2 354.4c-9.6-9.6-25.2-9.6-34.8 0s-9.6 25.2 0 34.8L486.8 537.6c4.8 4.8 11.1 7.2 17.6 7.2s12.8-2.4 17.6-7.2l138.8-148.4c9.5-9.6 9.5-25.2-.2-34.8z"
|
||||
fill="#07C160"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 提示文案 -->
|
||||
<h2 class="wework-only__title">请在企业微信中打开</h2>
|
||||
<p class="wework-only__desc">
|
||||
IT智能服务台仅支持在企业微信内使用,<br />
|
||||
请通过企业微信工作台进入。
|
||||
</p>
|
||||
|
||||
<!-- 操作指引 -->
|
||||
<div class="wework-only__steps">
|
||||
<div class="wework-only__step">
|
||||
<span class="wework-only__step-num">1</span>
|
||||
<span>打开企业微信</span>
|
||||
</div>
|
||||
<div class="wework-only__step">
|
||||
<span class="wework-only__step-num">2</span>
|
||||
<span>进入「工作台」</span>
|
||||
</div>
|
||||
<div class="wework-only__step">
|
||||
<span class="wework-only__step-num">3</span>
|
||||
<span>找到「IT支持服务」</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wework-only {
|
||||
/* 全屏居中,适配移动端 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
/* 企微设计语言:浅色背景 */
|
||||
background: #f5f6f7;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wework-only__card {
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 48px 32px;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
/* 轻微阴影 */
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.wework-only__icon {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.wework-only__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.wework-only__desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.wework-only__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.wework-only__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.wework-only__step-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #07C160;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端 Vite 配置
|
||||
// =============================================================================
|
||||
// 说明:Vite 构建工具配置,定义开发服务器、Vant 按需引入等
|
||||
// =============================================================================
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
// Vant 按需引入组件解析器
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { VantResolver } from '@vant/auto-import-resolver'
|
||||
|
||||
// Vite 配置
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
// 生产环境基础路径(部署在 /itdesk/ 子路径下,与IT数据平台共享域名)
|
||||
base: '/itdesk/',
|
||||
|
||||
plugins: [
|
||||
// Vue3 插件
|
||||
vue(),
|
||||
// Vant 组件按需引入
|
||||
// 自动导入 Vant 组件,无需手动 import,减小打包体积
|
||||
Components({
|
||||
resolvers: [VantResolver()],
|
||||
}),
|
||||
],
|
||||
|
||||
// 开发服务器配置
|
||||
server: {
|
||||
// 开发服务器端口(避免和坐席前端冲突)
|
||||
port: 5174,
|
||||
// 自动打开浏览器
|
||||
open: true,
|
||||
// API 代理:将 /api 请求转发到后端
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
// 本地开发剥离 /api 前缀,因为后端路由不包含 /api(生产 nginx 负责剥离)
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 构建配置
|
||||
build: {
|
||||
// 输出目录
|
||||
outDir: 'dist',
|
||||
// 静态资源内联阈值
|
||||
assetsInlineLimit: 4096,
|
||||
},
|
||||
|
||||
// 路径别名
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user