chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+40
View File
@@ -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;"]
+31
View File
@@ -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']
}
}
+14
View File
@@ -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
}
+16
View File
@@ -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>
+2458
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
+30
View File
@@ -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>
+400
View File
@@ -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秒
// 修复截图发送超时BugapiClient默认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_typeH5前端期望 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_typeH5前端期望 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[]
}
// -------------------------------------------------------------------------
// 邀请功能 APIP0-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 || []
}
+127
View File
@@ -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
}
+203
View File
@@ -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 401Token 过期或无效(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 401Axios 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
+150
View File
@@ -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
}
+47
View File
@@ -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
}
+65
View File
@@ -0,0 +1,65 @@
// =============================================================================
// 企微IT智能服务台 — H5用户端文件上传 API
// =============================================================================
// 说明:封装文件/图片上传接口
// 1. 上传文件到后端 /api/uploadmultipart/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 有自己的状态管理,
* 直接操作 DOMselectionStart/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_urlMessageBubble 会渲染缩略图)
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_urlMessageBubble 会渲染文件卡片)
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)
// Fallbackhtml2canvas 在企微内置浏览器中可能失败
// 降级为手动选择图片(从相册选取或重新拍照)
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_urlMessageBubble 会渲染缩略图)
console.log('[InputBar] 开始调用 store.sendNewMessagemedia_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
}>()
// ============================================================================
// 剪贴板相关(消息复制功能)
// ============================================================================
/** useClipboardVueUse 提供的剪贴板操作组合函数 */
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,
}
}
+61
View File
@@ -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,
}
}
+48
View File
@@ -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
+169
View File
@@ -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
+861
View File
@@ -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_typeemployee/agent/ai/system),
// 而非 msg_typetext/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,
}
})
+414
View File
@@ -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,
}
})
+54
View File
@@ -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,
}
})
+351
View File
@@ -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);
}
+43
View File
@@ -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
}
+295
View File
@@ -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>
+182
View File
@@ -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>
+119
View File
@@ -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>
+31
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+60
View File
@@ -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',
},
},
})