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智能服务台 — 坐席前端 Docker 镜像构建文件
# =============================================================================
# 说明:基于 node:20 构建前端并输出到 nginx 目录
# 用法:docker build -t wecom-it-desk-agent .
# =============================================================================
# --------------------------------------------------------------------------
# 第一阶段:构建阶段(编译 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;"]
+36
View File
@@ -0,0 +1,36 @@
vue-tsc exit: 0
vite v5.4.21 building for production...
transforming...
鉁?[39m 1750 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html  0.60 kB 鈹?gzip: 0.46 kB
dist/assets/Login-EWccUmDe.css  0.11 kB 鈹?gzip: 0.12 kB
dist/assets/Workspace-6EBHZjSf.css  50.26 kB 鈹?gzip: 8.14 kB
dist/assets/index-BapoCc2i.css  370.01 kB 鈹?gzip: 50.60 kB
dist/assets/Login-BJWQskaL.js  2.33 kB 鈹?gzip: 1.33 kB
dist/assets/_plugin-vue_export-helper-D49RZYFh.js  48.01 kB 鈹?gzip: 18.74 kB
dist/assets/Workspace-C_ym5-3o.js  375.97 kB 鈹?gzip: 120.61 kB
dist/assets/index-c8TJy9jG.js 1,199.56 kB 鈹?gzip: 387.84 kB
鉁?built in 4.52s
The CJS build of Vite's Node API is deprecated. See https://vite.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.
../../../../../D:/璧勬枡/03-椤圭洰寮€鍙?wecom_it_smart_desk/frontend-agent/node_modules/@vueuse/core/dist/index.js (3362:0): A comment
"/* #__PURE__ */"
in "../../../../../D:/璧勬枡/03-椤圭洰寮€鍙?wecom_it_smart_desk/frontend-agent/node_modules/@vueuse/core/dist/index.js" contains an annotation that Rollup cannot interpret due to the position of the comment. The comment will be removed to avoid issues.
../../../../../D:/璧勬枡/03-椤圭洰寮€鍙?wecom_it_smart_desk/frontend-agent/node_modules/@vueuse/core/dist/index.js (5780:22): A comment
"/* #__PURE__ */"
in "../../../../../D:/璧勬枡/03-椤圭洰寮€鍙?wecom_it_smart_desk/frontend-agent/node_modules/@vueuse/core/dist/index.js" contains an annotation that Rollup cannot interpret due to the position of the comment. The comment will be removed to avoid issues.

(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
vite build exit: 0
+22
View File
@@ -0,0 +1,22 @@
// =============================================================================
// 企微IT智能服务台 — 环境类型声明
// =============================================================================
// 说明:声明 .vue 文件的模块类型,让 TypeScript 能识别 .vue 文件
// =============================================================================
/// <reference types="vite/client" />
// 声明 .vue 文件模块类型
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
// 声明 element-plus 语言包模块类型
declare module 'element-plus/dist/locale/zh-cn.mjs'
// 扩展 Window 接口,添加 navigator(用于 clipboard 操作)
interface Window {
navigator: Navigator
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- 移动端视口设置 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 页面标题 -->
<title>IT智能服务台 - 坐席工作台</title>
<!-- ElementPlus 图标 -->
<link rel="icon" type="image/svg+xml" href="/itagent/vite.svg" />
</head>
<body>
<!-- Vue 应用挂载点 -->
<div id="app"></div>
<!-- 入口脚本 -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2045
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "wecom-it-desk-agent",
"version": "1.0.0",
"private": true,
"description": "企微IT智能服务台 - 坐席工作台前端",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0",
"element-plus": "^2.7.0",
"html2canvas-pro": "^2.0.4",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vue3-emoji-picker": "^1.1.8"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.5.0",
"vite": "^5.3.0",
"vue-tsc": "^2.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
@echo off
cd /d "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent"
"C:\Program Files\nodejs\node.exe" "node_modules\vue-tsc\bin\vue-tsc.js"
if errorlevel 1 exit /b errorlevel
"C:\Program Files\nodejs\node.exe" "node_modules\vite\bin\vite.js" build
exit /b %errorlevel%
+44
View File
@@ -0,0 +1,44 @@
$ErrorActionPreference = "Stop"
# Run vue-tsc
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "C:\Program Files\nodejs\node.exe"
$psi.Arguments = "node_modules\vue-tsc\bin\vue-tsc.js"
$psi.WorkingDirectory = "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent"
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.EnvironmentVariables["NODE_PATH"] = "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\node_modules"
$proc = [System.Diagnostics.Process]::Start($psi)
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
$exitCode = $proc.ExitCode
$stdout | Out-File -FilePath "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\build-output.txt" -Encoding UTF8
$stderr | Out-File -FilePath "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\build-output.txt" -Append -Encoding UTF8
"vue-tsc exit: $exitCode" | Out-File -FilePath "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\build-output.txt" -Append -Encoding UTF8
if ($exitCode -ne 0) {
Write-Host "vue-tsc failed"
exit $exitCode
}
# Run vite build
$psi2 = New-Object System.Diagnostics.ProcessStartInfo
$psi2.FileName = "C:\Program Files\nodejs\node.exe"
$psi2.Arguments = "node_modules\vite\bin\vite.js build"
$psi2.WorkingDirectory = "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent"
$psi2.UseShellExecute = $false
$psi2.RedirectStandardOutput = $true
$psi2.RedirectStandardError = $true
$psi2.EnvironmentVariables["NODE_PATH"] = "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\node_modules"
$proc2 = [System.Diagnostics.Process]::Start($psi2)
$stdout2 = $proc2.StandardOutput.ReadToEnd()
$stderr2 = $proc2.StandardError.ReadToEnd()
$proc2.WaitForExit()
$exitCode2 = $proc2.ExitCode
$stdout2 | Out-File -FilePath "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\build-output.txt" -Append -Encoding UTF8
$stderr2 | Out-File -FilePath "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\build-output.txt" -Append -Encoding UTF8
"vite build exit: $exitCode2" | Out-File -FilePath "d:\资料\03-项目开发\wecom_it_smart_desk\frontend-agent\build-output.txt" -Append -Encoding UTF8
+19
View File
@@ -0,0 +1,19 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席工作台根组件
// =============================================================================
// 说明:Vue3 应用的根组件,所有页面都渲染在这个组件内
// 使用 router-view 渲染当前路由对应的页面
// ============================================================================= -->
<template>
<!-- 路由视图显示当前路由对应的页面组件 -->
<router-view />
</template>
<script setup lang="ts">
// 根组件无需额外逻辑,只负责渲染路由页面
</script>
<style>
/* 根组件样式已在 global.css 中定义 */
</style>
+170
View File
@@ -0,0 +1,170 @@
// =============================================================================
// 企微IT智能服务台 — 坐席 API 调用模块
// =============================================================================
// 说明:封装与坐席相关的所有 HTTP 请求
// 对应后端 API/api/agents
// 包括:登录、获取当前坐席、更新状态、获取坐席列表
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Schema 保持一致
// --------------------------------------------------------------------------
/** 坐席对象(对应后端 AgentResponse */
export interface Agent {
/** 坐席ID */
id: string
/** 企微用户ID */
user_id: string
/** 坐席姓名 */
name: string
/** 坐席状态: online/offline/busy */
status: string
/** 当前服务会话数 */
current_load: number
/** 最大同时服务数 */
max_load: number
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/** 登录响应数据(包含坐席信息和 token) */
export interface LoginData extends Agent {
/** 认证 token(存入 localStorage,后续请求自动携带) */
token: string
}
/** 坐席列表响应 */
export interface AgentListData {
/** 坐席列表 */
items: Agent[]
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 坐席登录
* 第一步使用简单的用户名登录(无密码验证)
* admin 角色需要 OTP 二次验证
* 登录成功后 token 存入 localStorage,后续请求自动携带
*
* @param userId - 企微用户ID
* @param name - 坐席姓名
* @param otpCode - OTP 动态码(admin 角色必填)
* @returns 坐席信息和 token
*/
export async function login(userId: string, name: string, otpCode?: string): Promise<LoginData> {
const response: AxiosResponse = await apiClient.post('/agents/login', {
user_id: userId,
name: name,
otp_code: otpCode || undefined,
})
return response.data.data
}
/**
* 获取当前坐席信息
* 需要在请求头中携带有效的 token
*
* @returns 当前坐席信息
*/
export async function getCurrentAgent(): Promise<Agent> {
const response: AxiosResponse = await apiClient.get('/agents/me')
return response.data.data
}
/**
* 更新坐席状态
* 坐席可以切换为 online/busy/offline
*
* @param status - 新的坐席状态: online/busy/offline
* @returns 更新后的坐席信息
*/
export async function updateAgentStatus(status: string): Promise<Agent> {
const response: AxiosResponse = await apiClient.put('/agents/me/status', {
status,
})
return response.data.data
}
/**
* 获取坐席列表
* 用于转接选择时展示可用的坐席列表
*
* @param status - 按状态过滤(可选): online/busy/offline
* @returns 坐席列表
*/
export async function getAgents(status?: string): Promise<AgentListData> {
const params: Record<string, string> = {}
if (status) {
params.status = status
}
const response: AxiosResponse = await apiClient.get('/agents', { params })
return response.data.data
}
// --------------------------------------------------------------------------
// OTP 双因素认证
// --------------------------------------------------------------------------
/** OTP 绑定响应 */
export interface OtpBindData {
/** 二维码图片(base64 */
qr_code: string
/** 密钥(手动输入用) */
secret: string
}
/** OTP 验证响应 */
export interface OtpVerifyData {
/** 是否已启用 */
otp_enabled: boolean
/** 消息 */
message: string
}
/**
* 绑定 OTP
* 为当前坐席生成 OTP 密钥和二维码
* 返回二维码(base64)和密钥供手动输入
*
* @returns OTP 绑定信息(二维码和密钥)
*/
export async function bindOtp(): Promise<OtpBindData> {
const response: AxiosResponse = await apiClient.post('/agents/otp-bind')
return response.data.data
}
/**
* 验证并启用 OTP
* 用户输入 OTP 码验证成功后,启用 OTP
*
* @param userId - 坐席ID
* @param otpCode - OTP 动态码
* @returns 验证结果
*/
export async function verifyOtp(userId: string, otpCode: string): Promise<OtpVerifyData> {
const response: AxiosResponse = await apiClient.post('/agents/otp-verify', {
user_id: userId,
otp_code: otpCode,
})
return response.data.data
}
/**
* 解绑 OTP
* 解绑后 otp_secret 和 otp_enabled 都清空
*
* @returns 解绑结果
*/
export async function unbindOtp(): Promise<{ message: string }> {
const response: AxiosResponse = await apiClient.post('/agents/otp-unbind')
return response.data.data
}
+347
View File
@@ -0,0 +1,347 @@
// =============================================================================
// 企微IT智能服务台 — 会话 API 调用模块
// =============================================================================
// 说明:封装与会话相关的所有 HTTP 请求
// 对应后端 API/api/conversations
// 包括:获取会话列表、会话详情、接单、结单、置顶、代办、转接
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Schema 保持一致
// --------------------------------------------------------------------------
/** 会话标签集合(对应后端 ConversationTags */
export interface ConversationTags {
/** 招手标记(员工说"转人工"或点击敲桌子按钮) */
hand_raise: boolean
/** 需介入标记(追问超过N轮) */
need_intervene: boolean
/** 情绪标记: neutral/worried/angry/urgent */
emotion: string
/** 触发情绪标记的关键词列表 */
emotion_keywords: string[]
/** 追问轮次计数 */
repeat_count: number
}
/** 会话对象(对应后端 ConversationResponse */
export interface Conversation {
/** 会话ID */
id: string
/** 企微员工UserID */
employee_id: string
/** 员工姓名 */
employee_name: string
/** 部门 */
department: string
/** 岗位 */
position: string
/** 等级 */
level: string
/** 会话状态: ai_handling/queued/serving/resolved */
status: string
/** VIP标记 */
is_vip: boolean
/** 置顶标记 */
is_pinned: boolean
/** 代办标记 */
is_todo: boolean
/** 紧急度评分(1-5 */
urgency_score: number
/** 标签集合 */
tags: ConversationTags
/** 分配的坐席ID */
assigned_agent_id: string | null
/** 最后消息时间 */
last_message_at: string | null
/** 最后消息摘要 */
last_message_summary: string
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
/** 是否为当前坐席的会话 */
is_mine: boolean
/** 分配的坐席姓名(其他坐席会话显示用) */
assigned_agent_name: string | null
/** 是否可以接手(其他坐席已接单的会话为 True) */
can_grab: boolean
/** 协作坐席ID列表 */
collaborating_agent_ids: string[]
/** 协作坐席姓名映射(agent_id → name */
collaborating_agent_names: Record<string, string>
/** 是否为协作坐席(非主责) */
is_collaborator: boolean
/** 影响范围(受影响人数,0=未评估) */
impact_scope: number
/** 阻断性标记(问题是否阻断员工正常工作流程) */
is_blocking: boolean
/** 情绪状态(normal/worried/angry/urgent */
emotion_state: string
/** 被邀请参与会话的人员列表(邀请功能 P0-09~P0-11 */
participants: ParticipantInfo[]
}
/** 参与者信息(邀请功能) */
export interface ParticipantInfo {
/** 企微员工UserID 或部门ID */
id: string
/** 姓名 或 部门名称 */
name: string
/** 部门(仅员工类型有) */
department: string
/** 类型: employee(个人)或 department(部门) */
type: 'employee' | 'department'
/** 头像URL(从企微通讯录或employees表获取,无头像时为空字符串) */
avatar?: string
/** 是否已加入(通过链接加入后为 true) */
joined?: boolean
/** 加入时间(ISO 格式字符串) */
joined_at?: string
}
/** 会话列表响应(对应后端 ConversationListResponse */
export interface ConversationListData {
/** 会话列表 */
items: Conversation[]
/** 总数 */
total: number
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 获取坐席会话列表
* 支持按状态和坐席ID过滤,按紧急度排序
*
* @param params - 查询参数
* @param params.status - 按状态过滤(可选)
* @param params.agent_id - 按坐席ID过滤(可选)
* @param params.page - 页码(从1开始)
* @param params.page_size - 每页数量
* @returns 会话列表数据
*/
export async function getConversations(params?: {
status?: string
agent_id?: string
page?: number
page_size?: number
}): Promise<ConversationListData> {
const response: AxiosResponse = await apiClient.get('/conversations', { params })
return response.data.data
}
/**
* 获取会话详情
*
* @param conversationId - 会话ID
* @returns 会话详情
*/
export async function getConversation(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.get(`/conversations/${conversationId}`)
return response.data.data
}
/**
* 坐席接单(接入会话)
* 将会话状态从 queued 改为 serving
*
* @param conversationId - 会话ID
* @param agentId - 接单的坐席ID
* @returns 更新后的会话信息
*/
export async function assignConversation(conversationId: string, agentId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/assign`, {
agent_id: agentId,
})
return response.data.data
}
/**
* 结单
* 将会话状态改为 resolved
*
* @param conversationId - 会话ID
* @returns 更新后的会话信息
*/
export async function resolveConversation(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/resolve`)
return response.data.data
}
/**
* 切换置顶状态
* 每次调用切换:置顶→取消置顶,取消置顶→置顶
*
* @param conversationId - 会话ID
* @returns 更新后的会话信息
*/
export async function togglePin(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/pin`)
return response.data.data
}
/**
* 切换代办状态
* 每次调用切换:代办→取消代办,取消代办→代办
*
* @param conversationId - 会话ID
* @returns 更新后的会话信息
*/
export async function toggleTodo(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/todo`)
return response.data.data
}
/**
* 转接会话到另一个坐席
*
* @param conversationId - 会话ID
* @param targetAgentId - 目标坐席ID
* @returns 更新后的会话信息
*/
export async function transferConversation(conversationId: string, targetAgentId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/transfer`, {
agent_id: targetAgentId,
})
return response.data.data
}
/**
* 接手其他坐席的会话(抢单)
* 接手后原坐席自动释放,会话变为当前坐席的
*
* @param conversationId - 会话ID
* @returns 接手后的会话信息
*/
export async function grabConversation(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/grab`)
return response.data.data
}
/**
* 摇人 — 邀请坐席加入协作
* 邀请后坐席B将出现在协作列表中,可查看和回复但不能结单
*
* @param conversationId - 会话ID
* @param agentId - 被邀请的坐席ID
* @returns 更新后的会话信息
*/
export async function inviteCollaborator(
conversationId: string,
agentId: string
): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/invite`,
{ agent_id: agentId }
)
return response.data.data
}
/**
* 退出协作
* 坐席从协作列表中移除
*
* @param conversationId - 会话ID
* @returns 更新后的会话信息
*/
export async function leaveCollaboration(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/leave`)
return response.data.data
}
// --------------------------------------------------------------------------
// 邀请功能 APIP0-09~P0-11
// --------------------------------------------------------------------------
/** 邀请参与者请求参数 */
export interface InviteParticipantParams {
/** 被邀请人列表 */
participants: Array<{
id: string
name: string
department?: string
type: 'employee' | 'department'
}>
/** 历史消息共享模式: recent10/all/none */
history_mode?: 'recent10' | 'all' | 'none'
}
/**
* 邀请员工/部门加入会话(P0-09)
* 向被邀请人发送企微卡片通知,含「加入会话」按钮
*
* @param conversationId - 会话ID
* @param params - 邀请参数(含被邀请人列表和历史共享模式)
* @returns 更新后的会话信息
*/
export async function inviteParticipant(
conversationId: string,
params: InviteParticipantParams
): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/invite-participant`,
params
)
return response.data.data
}
/**
* 被邀请人加入会话(P0-10
* 点击企微卡片链接后调用,更新 joined 状态
*
* @param conversationId - 会话ID
* @param employeeId - 加入的员工企微UserID
* @returns 更新后的会话信息
*/
export async function joinConversation(
conversationId: string,
employeeId: string
): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/join`,
{ employee_id: employeeId }
)
return response.data.data
}
/**
* 移除参与者(P0-11
* 只有主责坐席可以移除
*
* @param conversationId - 会话ID
* @param userId - 被移除的员工UserID
* @returns 更新后的会话信息
*/
export async function removeParticipant(
conversationId: string,
userId: string
): Promise<Conversation> {
const response: AxiosResponse = await apiClient.delete(
`/conversations/${conversationId}/participants/${userId}`
)
return response.data.data
}
/**
* 参与者主动退出会话
*
* @param conversationId - 会话ID
* @param employeeId - 退出的员工企微UserID
* @returns 更新后的会话信息
*/
export async function leaveAsParticipant(
conversationId: string,
employeeId: string
): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/leave-participant`,
{ employee_id: employeeId }
)
return response.data.data
}
+117
View File
@@ -0,0 +1,117 @@
// =============================================================================
// 企微IT智能服务台 — 坐席工作台 Axios 实例与拦截器
// =============================================================================
// 说明:创建 Axios 实例,配置:
// 1. 请求基础 URL
// 2. 请求拦截器(添加认证头等)
// 3. 响应拦截器(统一错误处理)
// =============================================================================
import axios from 'axios'
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
// ElementPlus 消息提示
import { ElMessage } from 'element-plus'
// --------------------------------------------------------------------------
// 创建 Axios 实例
// --------------------------------------------------------------------------
const apiClient: AxiosInstance = axios.create({
// 基础 URL:所有请求会自动加上这个前缀
// 开发环境通过 Vite proxy 转发到后端
baseURL: '/api',
// 请求超时时间(20秒,原10秒)
// 原因:图片/文件上传、AI消息处理等场景后端处理需要更多时间
// 修复截图发送超时Bug
timeout: 20000,
// 默认请求头
headers: {
'Content-Type': 'application/json',
},
})
// --------------------------------------------------------------------------
// 请求拦截器
// --------------------------------------------------------------------------
// 在每个请求发送前执行,用于添加认证信息等
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从 localStorage 获取坐席 token,添加到请求头
const token = localStorage.getItem('agent_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
// 请求配置错误时直接返回
return Promise.reject(error)
}
)
// --------------------------------------------------------------------------
// 响应拦截器
// --------------------------------------------------------------------------
// 在每个响应返回后执行,用于统一处理错误
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
// 从响应中提取业务数据
const res = response.data
// 统一响应格式:{code: 0, data: {}, message: "success"}
// code === 0 表示业务成功
if (res.code !== 0) {
// 业务错误:显示错误消息
ElMessage.error(res.message || '请求失败')
// 特殊错误码处理
if (res.code === 1002) {
// 未授权:跳转到登录页
// 动态导入避免循环依赖
import('@/router').then(router => {
router.default.push('/login')
})
}
// 返回 rejected Promise,让调用方的 catch 能捕获
return Promise.reject(new Error(res.message || '请求失败'))
}
// 业务成功:返回完整响应(调用方从 response.data.data 获取业务数据)
return response
},
(error) => {
// 网络错误或服务器错误(HTTP 状态码非 2xx)
let message = '网络异常,请稍后重试'
if (error.response) {
// 服务器返回了错误状态码
switch (error.response.status) {
case 401:
message = '未授权,请重新登录'
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求的资源不存在'
break
case 500:
message = '服务器内部错误'
break
default:
message = `请求失败 (${error.response.status})`
}
} else if (error.code === 'ECONNABORTED') {
// 请求超时
message = '请求超时,请稍后重试'
}
// 显示错误提示
ElMessage.error(message)
return Promise.reject(error)
}
)
// 导出 Axios 实例,供 API 模块使用
export default apiClient
+232
View File
@@ -0,0 +1,232 @@
// =============================================================================
// 企微IT智能服务台 — 消息 API 调用模块
// =============================================================================
// 说明:封装与消息相关的所有 HTTP 请求
// 对应后端 API/api/conversations/{id}/messages
// 包括:获取消息列表、发送消息、轮询新消息
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Schema 保持一致
// --------------------------------------------------------------------------
/** 消息对象(对应后端 MessageResponse */
export interface Message {
/** 消息ID */
id: string
/** 所属会话ID */
conversation_id: string
/** 发送者类型: employee/agent/ai/system */
sender_type: string
/** 发送者ID */
sender_id: string
/** 发送者姓名 */
sender_name: string
/** 消息内容 */
content: string
/** 消息类型: text/image/voice/video/file/location */
msg_type: string
/** 是否为AI建议 */
ai_suggestion: boolean
/** 是否已读 */
is_read: boolean
/** 创建时间 */
created_at: string
/** 企微媒体文件ID(非文本消息时使用) */
media_id?: string
/** 媒体文件访问URL(M1 新增:本地存储的文件URL) */
media_url?: string
/** 文件名(文件消息时使用) */
file_name?: string
/** 文件大小(字节) */
file_size?: number
/** 扩展元数据(pic_url/format/location 等) */
extra_data?: Record<string, any>
/** 引用回复:被回复的消息ID(M1 新增) */
reply_to_id?: string
/** 消息状态:sending/sent/delivered/readM2 新增) */
status?: string
/** 可撤回截止时间(M2 新增) */
recallable_until?: string
}
/** 消息列表响应(对应后端 MessageListResponse */
export interface MessageListData {
/** 消息列表 */
items: Message[]
/** 是否还有更多历史消息 */
has_more: boolean
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 获取会话消息列表(分页)
* 默认返回最新的 limit 条消息
* 支持向上加载历史消息(通过 before 参数指定消息ID)
*
* @param conversationId - 会话ID
* @param params - 查询参数
* @param params.limit - 每页消息数量(默认50)
* @param params.before - 加载此消息ID之前的消息(向上翻页)
* @returns 消息列表数据
*/
export async function getMessages(
conversationId: string,
params?: {
limit?: number
before?: string
}
): Promise<MessageListData> {
const response: AxiosResponse = await apiClient.get(
`/conversations/${conversationId}/messages`,
{ params }
)
return response.data.data
}
/**
* 坐席发送消息
* 消息同时存入数据库和调用企微API发送给员工
*
* @param conversationId - 会话ID
* @param content - 消息内容
* @param msgType - 消息类型(默认 text
* @param options - 可选的文件/图片消息参数
* @returns 发送的消息对象
*/
export async function sendMessage(
conversationId: string,
content: string,
msgType: string = 'text',
options?: {
media_url?: string
file_name?: string
file_size?: number
reply_to_id?: string
}
): Promise<Message> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/messages`,
{
content,
msg_type: msgType,
...options,
},
{
// 图片/文件消息后端处理可能较慢(存储 + 企微API),增加超时到30秒
// 修复截图发送超时BugapiClient默认10s不够
timeout: 30000,
}
)
return response.data.data
}
/**
* 坐席轮询新消息
* 前端每 3-5 秒调用一次,获取上次轮询后的新消息
*
* @param conversationId - 会话ID
* @param afterMessageId - 上次轮询的最后一消息ID(返回此之后的消息)
* @returns 新消息列表数据
*/
export async function pollMessages(
conversationId: string,
afterMessageId?: string
): Promise<MessageListData> {
const params: Record<string, string> = {}
if (afterMessageId) {
params.after_message_id = afterMessageId
}
const response: AxiosResponse = await apiClient.get(
`/conversations/${conversationId}/messages/poll`,
{ params }
)
return response.data.data
}
/**
* 撤回消息(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
}
/**
* 上传图片
*
* @param file - 图片文件
* @returns 上传结果
*/
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 上传结果
*/
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
}
+205
View File
@@ -0,0 +1,205 @@
// =============================================================================
// 企微IT智能服务台 — 快速回复模板 API 调用模块
// =============================================================================
// 说明:封装与快速回复模板相关的所有 HTTP 请求
// 对应后端 API/api/quick-replies
// 包括:获取模板列表、创建模板、更新模板、删除模板
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Schema 保持一致
// --------------------------------------------------------------------------
/** 快速回复模板对象(对应后端 QuickReplyResponse */
export interface QuickReply {
/** 模板ID */
id: string
/** 分类:账号/网络/软件/硬件/通用 */
category: string
/** 模板标题 */
title: string
/** 模板内容(支持 {employee_name} 等变量) */
content: string
/** 可用变量列表 */
variables: string[]
/** 排序权重 */
sort_order: number
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/** 快速回复列表响应 */
export interface QuickReplyListData {
/** 模板列表 */
items: QuickReply[]
}
/** 创建模板参数 */
export interface QuickReplyCreateParams {
/** 分类(默认"通用" */
category?: string
/** 模板标题 */
title: string
/** 模板内容 */
content: string
/** 可用变量列表 */
variables?: string[]
/** 排序权重 */
sort_order?: number
}
/** 更新模板参数(所有字段可选) */
export interface QuickReplyUpdateParams {
/** 分类 */
category?: string
/** 模板标题 */
title?: string
/** 模板内容 */
content?: string
/** 可用变量列表 */
variables?: string[]
/** 排序权重 */
sort_order?: number
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 获取快速回复模板列表
* 支持按分类过滤,按 sort_order 排序
*
* @param category - 按分类过滤(可选)
* @returns 模板列表
*/
export async function getQuickReplies(category?: string): Promise<QuickReplyListData> {
const params: Record<string, string> = {}
if (category) {
params.category = category
}
const response: AxiosResponse = await apiClient.get('/quick-replies', { params })
return response.data.data
}
/**
* 创建快速回复模板
*
* @param data - 创建参数
* @returns 创建的模板
*/
export async function createQuickReply(data: QuickReplyCreateParams): Promise<QuickReply> {
const response: AxiosResponse = await apiClient.post('/quick-replies', data)
return response.data.data
}
/**
* 更新快速回复模板
* 只更新传入的字段(部分更新)
*
* @param templateId - 模板ID
* @param data - 更新参数
* @returns 更新后的模板
*/
export async function updateQuickReply(templateId: string, data: QuickReplyUpdateParams): Promise<QuickReply> {
const response: AxiosResponse = await apiClient.put(`/quick-replies/${templateId}`, data)
return response.data.data
}
/**
* 删除快速回复模板
*
* @param templateId - 模板ID
* @returns 无数据
*/
export async function deleteQuickReply(templateId: string): Promise<void> {
await apiClient.delete(`/quick-replies/${templateId}`)
}
// --------------------------------------------------------------------------
// 坐席备注 API(与快速回复在同一模块方便管理)
// 对应后端 API/api/agent-notes
// --------------------------------------------------------------------------
/** 备注对象 */
export interface AgentNote {
/** 备注ID */
id: string
/** 所属会话ID */
conversation_id: string
/** 坐席ID */
agent_id: string
/** 备注内容 */
content: string
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/** 备注列表响应 */
export interface AgentNoteListData {
/** 备注列表 */
items: AgentNote[]
}
/**
* 获取员工的所有备注
* 通过员工ID查找其所有会话的备注
*
* @param employeeId - 员工企微 UserID
* @returns 备注列表
*/
export async function getAgentNotes(employeeId: string): Promise<AgentNoteListData> {
const response: AxiosResponse = await apiClient.get(`/agent-notes/${employeeId}`)
return response.data.data
}
/**
* 添加坐席备注
*
* @param conversationId - 会话ID
* @param agentId - 坐席ID
* @param content - 备注内容
* @returns 创建的备注
*/
export async function createAgentNote(
conversationId: string,
agentId: string,
content: string
): Promise<AgentNote> {
const response: AxiosResponse = await apiClient.post('/agent-notes', {
conversation_id: conversationId,
agent_id: agentId,
content,
})
return response.data.data
}
/**
* 更新坐席备注
*
* @param noteId - 备注ID
* @param content - 新的备注内容
* @returns 更新后的备注
*/
export async function updateAgentNote(noteId: string, content: string): Promise<AgentNote> {
const response: AxiosResponse = await apiClient.put(`/agent-notes/${noteId}`, {
content,
})
return response.data.data
}
/**
* 删除坐席备注
*
* @param noteId - 备注ID
*/
export async function deleteAgentNote(noteId: string): Promise<void> {
await apiClient.delete(`/agent-notes/${noteId}`)
}
+51
View File
@@ -0,0 +1,51 @@
// =============================================================================
// 企微IT智能服务台 — 系统管理 API 调用模块
// =============================================================================
// 说明:封装系统级配置管理的 HTTP 请求
// 对应后端 API/api/system/emergency-mode
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义
// --------------------------------------------------------------------------
/** 应急模式状态响应 */
export interface EmergencyModeData {
/** 是否启用应急模式 */
emergency_mode: boolean
/** 启用时的引导文案(仅开启时返回) */
employee_service_guide?: string
}
/** 切换应急模式请求 */
export interface EmergencyModeToggle {
/** 是否启用应急模式 */
emergency_mode: boolean
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 查询应急模式状态
*/
export async function getEmergencyMode(): Promise<EmergencyModeData> {
const response: AxiosResponse = await apiClient.get('/system/emergency-mode')
return response.data.data
}
/**
* 切换应急模式开关
*
* @param enabled - true 开启应急模式,false 关闭
*/
export async function toggleEmergencyMode(enabled: boolean): Promise<EmergencyModeData> {
const response: AxiosResponse = await apiClient.put('/system/emergency-mode', {
emergency_mode: enabled,
})
return response.data.data
}
+91
View File
@@ -0,0 +1,91 @@
// =============================================================================
// 企微IT智能服务台 — 待办事项 API 调用模块
// =============================================================================
// 说明:封装与待办事项相关的所有 HTTP 请求
// 对应后端 API/api/todo-items
// 包括:获取待办列表、获取待办详情、更新待办状态
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Schema 保持一致
// --------------------------------------------------------------------------
/** 待办事项对象(对应后端 TodoItemResponse */
export interface TodoItemData {
/** 待办唯一标识 */
id: string
/** 待办类型: ticket/approval/device */
type: string
/** 待办标题 */
title: string
/** 优先级: urgent/high/normal */
priority: string
/** 详细描述(JSON */
description: Record<string, any>
/** 状态: pending/processing/resolved */
status: string
/** 分配的坐席ID */
assigned_agent_id: string | null
/** 企业微信企业ID */
corp_id: string
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/** 待办事项列表响应 */
export interface TodoItemListData {
/** 待办事项列表 */
items: TodoItemData[]
/** 总数 */
total: number
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 获取当前坐席待办列表
*
* @param params - 查询参数
* @param params.status - 按状态过滤(可选)
* @param params.priority - 按优先级过滤(可选)
* @returns 待办事项列表数据
*/
export async function getTodoItems(params?: {
status?: string
priority?: string
}): Promise<TodoItemListData> {
const response: AxiosResponse = await apiClient.get('/todo-items', { params })
return response.data.data
}
/**
* 获取待办事项详情
*
* @param id - 待办事项ID
* @returns 待办事项详情
*/
export async function getTodoItem(id: string): Promise<TodoItemData> {
const response: AxiosResponse = await apiClient.get(`/todo-items/${id}`)
return response.data.data
}
/**
* 更新待办事项状态
*
* @param id - 待办事项ID
* @param status - 新状态(pending/processing/resolved
* @returns 更新后的待办事项
*/
export async function updateTodoStatus(id: string, status: string): Promise<TodoItemData> {
const response: AxiosResponse = await apiClient.put(`/todo-items/${id}/status`, {
status,
})
return response.data.data
}
+116
View File
@@ -0,0 +1,116 @@
// =============================================================================
// 企微IT智能服务台 — 排查模板 API 调用模块
// =============================================================================
// 说明:封装与排查模板相关的所有 HTTP 请求
// 对应后端 API/api/troubleshooting-templates
// 包括:获取模板列表、获取模板详情
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Schema 保持一致
// --------------------------------------------------------------------------
/** 排查步骤路径节点 */
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
}
/** 排查模板对象(对应后端 TroubleshootingTemplateResponse */
export interface TroubleshootingTemplate {
/** 模板唯一标识 */
id: string
/** 模板名称 */
name: string
/** 分类: vpn/email/system/account */
category: string
/** 排障步骤路径 */
path_steps: PathStep[]
/** 流程图定义 */
flowchart: FlowchartNode
/** 是否启用 */
is_active: boolean
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/** 排查模板列表响应 */
export interface TroubleshootingTemplateListData {
/** 模板列表 */
items: TroubleshootingTemplate[]
/** 总数 */
total: number
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 获取排查模板列表
* 支持按分类过滤
*
* @param params - 查询参数
* @param params.category - 按分类过滤(可选)
* @returns 排查模板列表数据
*/
export async function getTroubleshootingTemplates(params?: {
category?: string
}): Promise<TroubleshootingTemplateListData> {
const response: AxiosResponse = await apiClient.get('/troubleshooting-templates', { params })
return response.data.data
}
/**
* 获取排查模板详情
*
* @param id - 模板ID
* @returns 排查模板详情
*/
export async function getTroubleshootingTemplate(id: string): Promise<TroubleshootingTemplate> {
const response: AxiosResponse = await apiClient.get(`/troubleshooting-templates/${id}`)
return response.data.data
}
/**
* 更新员工 IT 技能等级
*
* @param employeeId - 员工记录ID
* @param itLevel - 新的IT技能等级
* @returns 更新后的员工信息
*/
export async function updateEmployeeItLevel(
employeeId: string,
itLevel: string
): Promise<Record<string, any>> {
const response: AxiosResponse = await apiClient.put(
`/employees/${employeeId}/it-level`,
{ it_level: itLevel, source: 'manual' }
)
return response.data.data
}
+69
View File
@@ -0,0 +1,69 @@
// =============================================================================
// 企微IT智能服务台 — 文件上传 API 调用模块
// =============================================================================
// 说明:封装文件上传相关的 HTTP 请求
// 对应后端 API/api/upload
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义
// --------------------------------------------------------------------------
/** 上传响应数据 */
export interface UploadResponse {
/** 文件访问URL(前端用于展示/下载) */
url: string
/** 原始文件名(显示用) */
filename: string
/** 文件大小(字节) */
file_size: number
/** 消息类型(image 或 file */
msg_type: 'image' | 'file'
/** 文件扩展名 */
extension: string
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 上传文件到服务器
*
* 处理流程:
* 1. 前端选择文件(点击上传 / 粘贴图片 / 拖拽文件)
* 2. 调用此函数将文件上传到后端
* 3. 后端返回文件URL和元数据
* 4. 前端调用 sendMessage 将文件URL作为消息发送
*
* @param file - 要上传的文件对象(File / Blob)
* @returns 上传响应数据(包含文件URL、文件名等)
*/
export async function uploadFile(file: File | Blob): Promise<UploadResponse> {
// 构建 FormDatamultipart/form-data 格式,后端用 UploadFile 接收)
const formData = new FormData()
if (file instanceof File) {
// File 对象自带 name 属性,直接 append
formData.append('file', file)
} else {
// Blob 对象没有 name 属性,必须手动指定文件名
// 不指定文件名时,浏览器默认用 "blob" 作为文件名,后端无法识别扩展名
const fileName = `paste-image-${Date.now()}.png`
formData.append('file', file, fileName)
}
const response: AxiosResponse = await apiClient.post('/upload', formData, {
// 必须显式删除 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
// 原因:apiClient 实例默认设置了 'Content-Type': 'application/json'
// 如果不覆盖,Axios 会保留 application/json,后端无法解析 FormData 中的 file 字段
headers: {
'Content-Type': undefined,
},
timeout: 60000,
})
return response.data.data
}
+91
View File
@@ -0,0 +1,91 @@
// =============================================================================
// 企微IT智能服务台 — AI Wingman API 调用模块
// =============================================================================
// 说明:封装与 AI Wingman(坐席智能副驾驶)相关的 HTTP 请求
// 对应后端 API/api/conversations/{id}/wingman
// 包括:生成草稿回复、生成会话摘要、生成标签建议
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 Wingman API 响应格式保持一致
// --------------------------------------------------------------------------
/** 草稿回复结果 */
export interface DraftResult {
/** 草稿内容 */
content: string
/** 置信度(0-1 */
confidence: number
/** 生成推理说明 */
reasoning: string
}
/** 会话摘要结果 */
export interface SummaryResult {
/** 问题描述 */
problem: string
/** 原因分析 */
cause: string
/** 解决方案 */
solution: string
}
/** 标签建议结果 */
export interface TagsResult {
/** 建议标签列表 */
suggested_tags: string[]
/** 分类 */
category: string
/** 优先级: low/medium/high */
priority: string
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 生成 AI 草稿回复
* 基于当前会话的消息历史,生成坐席可以采纳的草稿回复
*
* @param conversationId - 会话ID
* @returns 草稿回复结果
*/
export async function generateDraft(conversationId: string): Promise<DraftResult> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/wingman/draft`
)
return response.data.data
}
/**
* 生成会话自动摘要
* 基于完整对话生成结构化摘要,包含问题、原因、解决方案
* 通常在结单时调用
*
* @param conversationId - 会话ID
* @returns 会话摘要结果
*/
export async function generateSummary(conversationId: string): Promise<SummaryResult> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/wingman/summary`
)
return response.data.data
}
/**
* 生成自动标签建议
* 基于对话内容建议标签分类
*
* @param conversationId - 会话ID
* @returns 标签建议结果
*/
export async function suggestTags(conversationId: string): Promise<TagsResult> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/wingman/tags`
)
return response.data.data
}
@@ -0,0 +1,240 @@
<!-- =============================================================================
// 企微IT智能服务台 — AI助手面板容器组件(v5.3 重构)
// =============================================================================
// 说明:坐席工作台右侧的AI助手面板
// 功能:上下两区域布局
// 上方 ~1/3:🤖 AI 智能推荐区(1-3张推荐卡片)
// 下方 ~2/3:快速回复区(复用 QuickReplyPanel
// ============================================================================= -->
<template>
<div class="ai-assistant-panel">
<!-- ================================================================== -->
<!-- 上方 ~1/3 区域 🤖 AI 智能推荐区 -->
<!-- ================================================================== -->
<div class="ai-recommend-section">
<!-- 标题栏 -->
<div class="ai-recommend-header">
<span class="ai-recommend-bar"></span>
<span class="ai-recommend-title">🤖 AI 智能推荐</span>
</div>
<!-- 推荐卡片列表 -->
<div v-if="recommendations.length > 0" class="ai-recommend-list">
<AiSuggestReply
v-for="(rec, index) in recommendations"
:key="index"
:recommendation="rec"
:index="index"
@select="handleSelectRecommendation"
/>
</div>
<!-- 无推荐时提示 -->
<div v-else class="ai-recommend-empty">
暂无推荐
</div>
</div>
<!-- 分隔线 -->
<div class="ai-section-divider"></div>
<!-- ================================================================== -->
<!-- 下方 ~2/3 区域 快速回复区 -->
<!-- ================================================================== -->
<div class="quick-reply-section">
<QuickReplyPanel @use-template="handleUseTemplate" />
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed, onMounted } from 'vue'
import AiSuggestReply from './AiSuggestReply.vue'
import QuickReplyPanel from './QuickReplyPanel.vue'
import { useConversationStore } from '@/stores/conversation'
import type { DraftResult } from '@/api/wingman'
// ============================================================================
// 类型
// ============================================================================
/** AI 推荐项类型 */
interface AiRecommendation {
/** 方案名称 */
title: string
/** 推荐内容 */
content: string
/** 置信度(0-1 */
confidence: number
}
// ============================================================================
// 状态
// ============================================================================
/** 会话 Store */
const conversationStore = useConversationStore()
/** AI 推荐列表(Mock 数据,后续由 AI 引擎填充) */
const recommendations = ref<AiRecommendation[]>([])
// ============================================================================
// 计算属性
// ============================================================================
/**
* 从当前会话的 AI 草稿生成推荐列表
* 将 draft 数据映射为推荐卡片格式
*/
const loadRecommendationsFromDraft = computed(() => {
const convId = conversationStore.currentConversationId
if (!convId) return []
// 尝试从 AI 草稿缓存中获取推荐
const convDrafts = conversationStore.aiDrafts.get(convId)
if (!convDrafts || convDrafts.size === 0) return []
const result: AiRecommendation[] = []
convDrafts.forEach((draft: DraftResult) => {
result.push({
title: 'AI 回复建议',
content: draft.content,
confidence: draft.confidence,
})
})
return result
})
// ============================================================================
// 方法
// ============================================================================
/**
* 处理 AI 推荐卡片的选择
* 将推荐内容填充到对话输入框
*
* @param content - 推荐内容
*/
function handleSelectRecommendation(content: string): void {
conversationStore.pendingReplyText = content
}
/**
* 处理快速回复模板的"使用"事件
* 将模板内容填充到对话输入框
*
* @param content - 模板内容(已替换变量)
*/
function handleUseTemplate(content: string): void {
conversationStore.pendingReplyText = content
}
/**
* 加载 AI 推荐数据
* 优先从草稿缓存获取,否则使用 Mock 数据
*/
function loadRecommendations(): void {
const fromDraft = loadRecommendationsFromDraft.value
if (fromDraft.length > 0) {
recommendations.value = fromDraft
return
}
// Mock 数据:当无真实 AI 推荐时展示示例
if (conversationStore.currentConversationId) {
recommendations.value = [
{
title: '重置密码流程',
content: '您好,密码重置可通过企业门户自助操作:访问 portal.company.com → 忘记密码 → 按提示完成重置。如无法自助,请提供工号,我帮您后台重置。',
confidence: 0.92,
},
{
title: 'VPN 连接故障排查',
content: 'VPN 连接问题常见原因:1) 网络切换后未重连;2) 证书过期。请先尝试:断开 VPN → 重启客户端 → 重新连接。如仍失败,请提供错误截图。',
confidence: 0.85,
},
]
} else {
recommendations.value = []
}
}
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
loadRecommendations()
})
</script>
<style scoped>
.ai-assistant-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ---- 上方 AI 推荐区 ---- */
.ai-recommend-section {
flex: 0 0 auto;
max-height: 33%;
min-height: 80px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.ai-recommend-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
flex-shrink: 0;
}
.ai-recommend-bar {
width: 3px;
height: 12px;
background-color: var(--accent);
border-radius: 2px;
flex-shrink: 0;
}
.ai-recommend-title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.ai-recommend-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 8px;
}
.ai-recommend-empty {
text-align: center;
padding: 16px;
color: var(--text-tertiary);
font-size: 13px;
}
/* ---- 分隔线 ---- */
.ai-section-divider {
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
/* ---- 下方快速回复区 ---- */
.quick-reply-section {
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>
@@ -0,0 +1,210 @@
<!-- =============================================================================
// 企微IT智能服务台 — AI 智能推荐卡片组件(v5.3 重构)
// =============================================================================
// 说明:右栏上方 AI 推荐区的单张推荐卡片
// 功能:
// 1. 方案名称 + 置信度药丸
// 2. 推荐内容(2行截断)
// 3. Ctrl+N 快捷键提示
// 4. 点击 → conversationStore.pendingReplyText = content
// ============================================================================= -->
<template>
<div
class="ai-suggest-card"
@click="handleSelect"
>
<!-- 卡片头方案名称 + 置信度药丸 -->
<div class="ai-card-header">
<span class="ai-card-title">{{ recommendation.title }}</span>
<span class="ai-card-confidence" :style="confidenceStyle">
{{ confidencePercent }}%
</span>
</div>
<!-- 卡片文本2行截断 -->
<div class="ai-card-text">
{{ recommendation.content }}
</div>
<!-- 快捷键提示 -->
<div class="ai-card-shortcut">
Ctrl+{{ index + 1 }}
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
// ============================================================================
// 类型
// ============================================================================
/** AI 推荐项类型(与 AiAssistantPanel 一致) */
interface AiRecommendation {
/** 方案名称 */
title: string
/** 推荐内容 */
content: string
/** 置信度(0-1 */
confidence: number
}
// ============================================================================
// Props
// ============================================================================
interface Props {
/** 推荐数据 */
recommendation: AiRecommendation
/** 推荐索引(0-based,用于快捷键 Ctrl+1/2/3 */
index: number
}
const props = defineProps<Props>()
// ============================================================================
// Emits
// ============================================================================
interface Emits {
/** 选中推荐事件 */
(e: 'select', content: string): void
}
const emit = defineEmits<Emits>()
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
// ============================================================================
// 计算属性
// ============================================================================
/** 置信度百分比(整数) */
const confidencePercent = computed<number>(() => {
return Math.round(props.recommendation.confidence * 100)
})
/** 置信度药丸样式(颜色根据置信度变化) */
const confidenceStyle = computed(() => {
const percent = confidencePercent.value
let bgColor = 'var(--accent-soft)'
let textColor = 'var(--accent)'
if (percent >= 85) {
bgColor = 'var(--accent-soft)'
textColor = 'var(--accent)'
} else if (percent >= 60) {
bgColor = 'rgba(230, 162, 60, 0.1)'
textColor = 'var(--color-warning)'
} else {
bgColor = 'rgba(144, 147, 153, 0.1)'
textColor = 'var(--text-tertiary)'
}
return {
backgroundColor: bgColor,
color: textColor,
}
})
// ============================================================================
// 方法
// ============================================================================
/**
* 处理推荐卡片的选择
* 将推荐内容填充到对话输入框
*/
function handleSelect(): void {
conversationStore.pendingReplyText = props.recommendation.content
emit('select', props.recommendation.content)
}
</script>
<style scoped>
.ai-suggest-card {
padding: 10px 12px;
margin-bottom: 6px;
border-radius: var(--radius-md);
background-color: var(--bg-tertiary);
border: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.ai-suggest-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
}
.ai-suggest-card:active {
transform: scale(0.99);
}
/* ---- 卡片头 ---- */
.ai-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ai-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.ai-card-confidence {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 10px;
margin-left: 8px;
line-height: 1.6;
}
/* ---- 卡片文本 ---- */
.ai-card-text {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- 快捷键提示 ---- */
.ai-card-shortcut {
position: absolute;
top: 8px;
right: 8px;
font-size: 10px;
color: var(--text-tertiary);
opacity: 0;
transition: opacity 0.2s;
}
.ai-suggest-card:hover .ai-card-shortcut {
opacity: 1;
}
</style>
@@ -0,0 +1,279 @@
<!-- =============================================================================
// 企微IT智能服务台 — 操作步骤组件
// =============================================================================
// 说明:展示按问题分类的解决步骤
// 第一步使用硬编码的步骤数据
// 后续步骤从后端配置获取
// ============================================================================= -->
<template>
<div class="operation-steps">
<!-- 问题分类选择 -->
<el-select
v-model="selectedCategory"
placeholder="选择问题分类"
class="category-select"
>
<el-option
v-for="cat in categories"
:key="cat.name"
:label="cat.label"
:value="cat.name"
/>
</el-select>
<!-- 操作步骤列表 -->
<div v-if="currentSteps.length > 0" class="step-list">
<div
v-for="(step, index) in currentSteps"
:key="index"
class="step-card"
>
<div class="step-card-inner">
<!-- 步骤序号 -->
<span class="step-number">
{{ index + 1 }}
</span>
<!-- 步骤内容 -->
<div class="step-content">
<div class="step-title">{{ step.title }}</div>
<div class="step-desc">{{ step.description }}</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="steps-empty">
请选择问题分类查看操作步骤
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed } from 'vue'
// ============================================================================
// 类型定义
// ============================================================================
/** 操作步骤 */
interface Step {
/** 步骤标题 */
title: string
/** 步骤详细说明 */
description: string
}
/** 问题分类 */
interface Category {
/** 分类名称(英文key */
name: string
/** 分类显示名称 */
label: string
/** 该分类下的操作步骤 */
steps: Step[]
}
// ============================================================================
// 硬编码的操作步骤数据(第一步)
// ============================================================================
const categories: Category[] = [
{
name: 'vpn',
label: '🌐 VPN连接问题',
steps: [
{
title: '确认VPN账号状态',
description: '在IT管理后台检查该员工的VPN账号是否已开通、是否过期、是否被锁定。',
},
{
title: '检查客户端版本',
description: '确认员工使用的是最新版VPN客户端,旧版本可能存在兼容性问题。',
},
{
title: '检查网络环境',
description: '确认员工当前网络可以访问公司VPN网关(可用 ping 测试),排除防火墙拦截。',
},
{
title: '重置VPN密码',
description: '如密码错误,在管理后台重置VPN密码,通知员工通过自助门户重新设置。',
},
{
title: '查看VPN日志',
description: '如以上步骤未能解决,要求员工发送VPN客户端日志,转交网络组排查。',
},
],
},
{
name: 'account',
label: '🔑 账号登录问题',
steps: [
{
title: '确认账号状态',
description: '检查该员工的AD/统一认证账号是否正常(未锁定、未过期)。',
},
{
title: '检查密码是否过期',
description: '确认密码是否超过90天未修改,提醒员工通过SSO门户修改密码。',
},
{
title: '检查多因素认证',
description: '确认MFA设备是否正常绑定,如更换手机需重新绑定。',
},
{
title: '检查账号权限',
description: '确认员工是否有所需系统的访问权限,如无权限需要走审批流程。',
},
{
title: '清除浏览器缓存',
description: '部分登录问题由浏览器缓存引起,指导员工清除缓存或使用无痕模式。',
},
],
},
{
name: 'permission',
label: '🔐 权限申请问题',
steps: [
{
title: '确认权限类型',
description: '了解员工需要申请哪类权限(系统权限/文件权限/网络权限)。',
},
{
title: '检查现有权限',
description: '在对应系统中检查员工当前已有的权限,避免重复申请。',
},
{
title: '引导走审批流程',
description: '提供对应的审批链接,指导员工提交权限申请。审批链接可在"审批流程"面板查看。',
},
{
title: '紧急权限加急',
description: '如为紧急情况,联系对应系统管理员临时开通,后续补审批。',
},
],
},
{
name: 'device',
label: '💻 设备故障问题',
steps: [
{
title: '远程诊断',
description: '通过远程工具连接员工电脑,检查设备状态、系统日志、硬件状态。',
},
{
title: '常见问题排查',
description: '蓝屏:检查最近驱动更新;慢:检查磁盘空间和内存;无法开机:检查电源。',
},
{
title: '重启相关服务',
description: '尝试重启相关系统服务或进程,很多问题可通过重启解决。',
},
{
title: '创建维修工单',
description: '如无法远程解决,创建硬件维修工单,安排现场支持或设备更换。',
},
],
},
{
name: 'software',
label: '📦 软件安装问题',
steps: [
{
title: '确认软件在允许列表中',
description: '检查该软件是否在公司允许安装的软件清单中,不在清单的需要走审批。',
},
{
title: '检查系统要求',
description: '确认员工电脑满足软件的最低系统要求(操作系统版本、内存、磁盘空间)。',
},
{
title: '提供下载链接',
description: '从公司软件库提供正版安装包和授权码,下载链接可在"软件下载"面板查看。',
},
{
title: '远程协助安装',
description: '如员工不会安装,通过远程工具协助安装和配置。',
},
],
},
]
// ============================================================================
// 状态
// ============================================================================
/** 当前选中的分类 */
const selectedCategory = ref('')
// ============================================================================
// 计算属性
// ============================================================================
/** 当前分类的操作步骤 */
const currentSteps = computed(() => {
const cat = categories.find(c => c.name === selectedCategory.value)
return cat?.steps || []
})
</script>
<style scoped>
.operation-steps {
padding: 12px;
}
.category-select {
width: 100%;
margin-bottom: 12px;
}
.step-list {
}
.step-card-inner {
display: flex;
align-items: flex-start;
gap: 8px;
}
.step-number {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
background-color: var(--accent);
color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.step-content {
}
.step-title {
font-weight: 500;
color: var(--text-primary);
font-size: 13px;
}
.step-desc {
color: var(--text-secondary);
font-size: 12px;
margin-top: 2px;
line-height: 1.6;
}
.steps-empty {
text-align: center;
padding: 20px;
color: var(--text-tertiary);
}
</style>
@@ -0,0 +1,746 @@
<!-- =============================================================================
// 企微IT智能服务台 — 快速回复组件(v5.3 终版 · 三层渐进导航)
// =============================================================================
// 导航结构:L1(7分类网格) → L2(chip子分类) → L3(条目列表) → Enter填入
// 键盘操作:Alt+1~7(L1) → 数字(L2/L3) → Enter填入 → ←/Backspace返回 → /搜索
// ============================================================================= -->
<template>
<div class="qr-panel">
<!-- ================================================================ -->
<!-- 搜索栏置顶 -->
<!-- ================================================================ -->
<div class="qr-search">
<el-input
ref="searchRef"
v-model="searchQuery"
placeholder="搜索快速回复 / Alt+目录数字"
size="small"
clearable
:prefix-icon="SearchIcon"
class="qr-search-input"
@keydown.stop
/>
</div>
<!-- ================================================================ -->
<!-- 面包屑导航 -->
<!-- ================================================================ -->
<div class="qr-breadcrumb">
<span v-if="navState.l1Index >= 0" class="bc-back" @click="goBack">
返回
</span>
<template v-if="navState.l1Index >= 0">
<span
class="bc-item"
:class="{ 'bc-active': navState.l2Index < 0 }"
@click="resetToL1"
>{{ catName(navState.l1Index) }}</span>
<template v-if="navState.l2Index >= 0">
<span class="bc-sep"></span>
<span class="bc-item bc-active">{{ subName(navState.l1Index, navState.l2Index) }}</span>
</template>
</template>
<span class="bc-placeholder" v-else>选择一个分类开始浏览</span>
</div>
<!-- ================================================================ -->
<!-- L1 一级分类7列网格按钮上下排列强制一行 -->
<!-- ================================================================ -->
<div v-show="showL1" class="qr-l1-grid">
<button
v-for="(cat, i) in qrData"
:key="i"
class="qr-l1-btn"
:class="{ active: navState.l1Index === i }"
@click="selectL1(i)"
>
<span class="l1-num">{{ i + 1 }}</span>
<span class="l1-name">{{ cat.name }}</span>
</button>
</div>
<!-- ================================================================ -->
<!-- L2 二级子分类chip 横向流式 -->
<!-- ================================================================ -->
<div v-show="showL2" class="qr-l2-row">
<button
v-for="(sub, i) in currentSubs"
:key="i"
class="qr-l2-chip"
:class="{ selected: navState.l2Index === i }"
@click="selectL2(i)"
>
<span class="l2-num">{{ i + 1 }}</span>
<span>{{ sub.name }}</span>
</button>
</div>
<!-- ================================================================ -->
<!-- L3 回复列表 -->
<!-- ================================================================ -->
<div v-show="showL3" class="qr-l3-scroll">
<div v-if="filteredItems.length === 0" class="qr-empty">
{{ searchQuery ? '无匹配结果' : '暂无回复模板' }}
</div>
<div v-else class="qr-l3-list">
<div
v-for="(item, i) in filteredItems"
:key="i"
class="qr-l3-item"
:class="{ selected: navState.selectedIndex === i }"
:ref="(el) => { if (el) itemRefs[i] = el as HTMLElement }"
@click="selectL3(i)"
@mouseenter="navState.selectedIndex = i"
>
<span class="qr-l3-num">{{ i + 1 }}</span>
<div class="qr-l3-body">
<div class="qr-l3-title">{{ item.title }}</div>
<div class="qr-l3-content">{{ item.content }}</div>
</div>
</div>
</div>
</div>
<!-- ================================================================ -->
<!-- 选中预览条 -->
<!-- ================================================================ -->
<div v-if="selectedPreview" class="qr-selected-bar" @click="fillSelected">
<span class="qr-selected-label">已选</span>
<span class="qr-selected-text">{{ selectedPreview }}</span>
<span class="qr-selected-enter">Enter 填入</span>
</div>
<!-- ================================================================ -->
<!-- 底部键盘指南 -->
<!-- ================================================================ -->
<div class="qr-keyboard-guide">
<span><kbd>Alt+1-7</kbd> 一级</span>
<span class="qr-guide-sep">|</span>
<span><kbd>数字</kbd> 选子项</span>
<span class="qr-guide-sep">|</span>
<span><kbd>Enter</kbd> 填入</span>
<span class="qr-guide-sep">|</span>
<span><kbd></kbd> 返回</span>
<span class="qr-guide-sep">|</span>
<span><kbd>/</kbd> 搜索</span>
</div>
</div>
</template>
<!-- ==================================================================== -->
<!-- Script -->
<!-- ==================================================================== -->
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { Search as SearchIcon } from '@element-plus/icons-vue'
import { useConversationStore } from '@/stores/conversation'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { qrData, type QrCategory, type QrItem } from '@/data/qrData'
// ========================================================================
// Interface
// ========================================================================
interface Emits {
(e: 'use-template', content: string): void
}
const emit = defineEmits<Emits>()
// ========================================================================
// State
// ========================================================================
const conversationStore = useConversationStore()
const searchRef = ref<InstanceType<typeof import('element-plus')['ElInput']> | null>(null)
const itemRefs = ref<Record<number, HTMLElement>>({})
/** 搜索关键词 */
const searchQuery = ref('')
/**
* 导航状态机
* l1Index: -1 = 初始/L1选择中; >=0 = 已选L1分类
* l2Index: -1 = L2选择中; >=0 = 已选L2子分类,展示L3
* selectedIndex: L3列表中的选中索引
*/
interface NavState {
l1Index: number
l2Index: number
selectedIndex: number
}
const navState = ref<NavState>({
l1Index: -1,
l2Index: -1,
selectedIndex: 0,
})
// ========================================================================
// Computed
// ========================================================================
/** 是否显示 L1 网格 */
const showL1 = computed(() => navState.value.l1Index < 0)
/** 是否显示 L2 chip 行 */
const showL2 = computed(() => navState.value.l1Index >= 0 && navState.value.l2Index < 0)
/** 是否显示 L3 列表 */
const showL3 = computed(() => navState.value.l1Index >= 0 && navState.value.l2Index >= 0)
/** 当前 L1 分类 */
const currentCategory = computed<QrCategory | null>(() => {
if (navState.value.l1Index < 0) return null
return qrData[navState.value.l1Index] ?? null
})
/** 当前 L2 子分类列表 */
const currentSubs = computed(() => {
const cat = currentCategory.value
return cat ? cat.subs : []
})
/** 当前 L3 条目列表 */
const currentItems = computed(() => {
const cat = currentCategory.value
if (!cat || navState.value.l2Index < 0) return []
const sub = cat.subs[navState.value.l2Index]
return sub ? sub.items : []
})
/** 搜索过滤后的 L3 条目 */
const filteredItems = computed<QrItem[]>(() => {
const items = currentItems.value
if (!searchQuery.value.trim()) return items
const q = searchQuery.value.trim().toLowerCase()
return items.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
item.content.toLowerCase().includes(q)
)
})
/** 当前选中的条目文案预览 */
const selectedPreview = computed(() => {
if (!showL3.value) return null
const items = filteredItems.value
if (items.length === 0) return null
const idx = navState.value.selectedIndex
if (idx < 0 || idx >= items.length) return null
return items[idx].title
})
// ========================================================================
// Helpers
// ========================================================================
function catName(index: number): string {
return qrData[index]?.name ?? ''
}
function subName(l1: number, l2: number): string {
return qrData[l1]?.subs[l2]?.name ?? ''
}
// ========================================================================
// Navigation Methods
// ========================================================================
/** 选择 L1 分类 → 进入 L2 */
function selectL1(index: number): void {
if (index < 0 || index >= qrData.length) return
navState.value = { l1Index: index, l2Index: -1, selectedIndex: 0 }
searchQuery.value = ''
}
/** 选择 L2 子分类 → 进入 L3 */
function selectL2(index: number): void {
const cat = currentCategory.value
if (!cat || index < 0 || index >= cat.subs.length) return
navState.value.l2Index = index
navState.value.selectedIndex = 0
searchQuery.value = ''
nextTick(() => scrollToSelected())
}
/** 点击 L3 条目 → 直接填入 */
function selectL3(index: number): void {
const items = filteredItems.value
if (index < 0 || index >= items.length) return
fillContent(items[index].content)
}
/** 将选中条目填入输入框 */
function fillSelected(): void {
const items = filteredItems.value
const idx = navState.value.selectedIndex
if (idx < 0 || idx >= items.length) return
fillContent(items[idx].content)
}
/** 填入内容(含变量替换) */
function fillContent(content: string): void {
const conv = conversationStore.currentConversation
const variables: Record<string, string> = {}
if (conv) {
variables.employee_name = conv.employee_name || ''
variables.department = conv.department || ''
variables.position = conv.position || ''
}
let result = content
for (const [key, value] of Object.entries(variables)) {
result = result.replaceAll(`{${key}}`, value)
}
emit('use-template', result)
}
/** 返回上一级 */
function goBack(): void {
if (navState.value.l2Index >= 0) {
// L3 → L2
navState.value.l2Index = -1
navState.value.selectedIndex = 0
} else if (navState.value.l1Index >= 0) {
// L2 → L1
navState.value.l1Index = -1
navState.value.selectedIndex = 0
}
}
/** 回到 L1(点击面包屑中的 L1) */
function resetToL1(): void {
if (navState.value.l2Index >= 0) {
navState.value.l2Index = -1
navState.value.selectedIndex = 0
}
}
/** 滚动选中项到视图 */
function scrollToSelected(): void {
nextTick(() => {
const el = itemRefs.value[navState.value.selectedIndex]
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
})
}
// ========================================================================
// Keyboard Navigation (数字键选择)
// ========================================================================
function handleDigitKey(digit: number): void {
if (showL1.value) {
// L1: 数字键选择分类
if (digit >= 1 && digit <= qrData.length) {
selectL1(digit - 1)
}
} else if (showL2.value) {
// L2: 数字键选择子分类
const subs = currentSubs.value
if (digit >= 1 && digit <= subs.length) {
selectL2(digit - 1)
}
} else if (showL3.value) {
// L3: 数字键选择条目
const items = filteredItems.value
if (digit >= 1 && digit <= items.length) {
selectL3(digit - 1)
}
}
}
function navigateUpDown(direction: 'up' | 'down'): void {
if (showL3.value) {
const len = filteredItems.value.length
if (len === 0) return
if (direction === 'up') {
navState.value.selectedIndex =
navState.value.selectedIndex > 0 ? navState.value.selectedIndex - 1 : len - 1
} else {
navState.value.selectedIndex =
navState.value.selectedIndex < len - 1 ? navState.value.selectedIndex + 1 : 0
}
scrollToSelected()
}
}
function confirmSelection(): void {
if (showL3.value) {
fillSelected()
}
}
function focusSearch(): void {
searchRef.value?.focus()
}
// ========================================================================
// Keyboard Shortcuts Registration
// ========================================================================
// 注册 Alt+1~7 分类切换
function handleCategoryShortcut(index: number): void {
if (index >= 0 && index < qrData.length) {
selectL1(index)
}
}
// 注册全局键盘快捷键
useKeyboardShortcuts({
onQuickReplyCategory: handleCategoryShortcut,
onQuickReplyDigit: handleDigitKey,
onQuickReplyBack: goBack,
onQuickReplyNavigate: navigateUpDown,
onQuickReplyConfirm: confirmSelection,
onFocusSearch: focusSearch,
})
// ========================================================================
// Watchers — 搜索词变化后重置选中
// ========================================================================
watch(searchQuery, () => {
navState.value.selectedIndex = 0
})
// ========================================================================
// Lifecycle — 初始进入默认展示 L1
// ========================================================================
onMounted(() => {
navState.value = { l1Index: -1, l2Index: -1, selectedIndex: 0 }
})
</script>
<!-- ==================================================================== -->
<!-- Styles -->
<!-- ==================================================================== -->
<style scoped>
.qr-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ---- 搜索栏 ---- */
.qr-search {
padding: 6px 10px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-color);
}
.qr-search-input {
width: 100%;
}
.qr-search-input :deep(.el-input__wrapper) {
background-color: var(--bg-tertiary);
border-radius: var(--radius-md);
box-shadow: none !important;
border: 1px solid var(--border-light);
font-size: 12px;
}
.qr-search-input :deep(.el-input__wrapper:hover) {
border-color: var(--accent);
}
.qr-search-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent) !important;
}
.qr-search-input :deep(.el-input__prefix-inner) {
color: var(--text-tertiary);
}
/* ---- 面包屑 ---- */
.qr-breadcrumb {
padding: 5px 10px;
flex-shrink: 0;
font-size: 11px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-light);
background: var(--bg-tertiary);
min-height: 26px;
}
.bc-back {
cursor: pointer;
color: var(--accent);
font-weight: 500;
padding: 1px 6px;
border-radius: 3px;
transition: 0.2s;
}
.bc-back:hover {
background: var(--accent-soft);
}
.bc-sep {
color: var(--text-placeholder);
}
.bc-item {
color: var(--text-secondary);
transition: 0.2s;
cursor: default;
}
.bc-item.bc-active {
font-weight: 600;
color: var(--text-primary);
}
.bc-item:not(.bc-active) {
cursor: pointer;
}
.bc-item:not(.bc-active):hover {
color: var(--accent);
}
.bc-placeholder {
color: var(--text-tertiary);
font-style: italic;
}
/* ---- L1 一级分类:7列网格,强制一行,按钮内上下排列 ---- */
.qr-l1-grid {
padding: 4px 6px 6px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-light);
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.qr-l1-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 5px 2px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: 0.15s;
font-size: 10px;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-light);
min-width: 0;
overflow: hidden;
}
.qr-l1-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.qr-l1-btn.active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
font-weight: 600;
}
.l1-num {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
border: 1px solid var(--border-light);
}
.qr-l1-btn.active .l1-num {
background: var(--accent);
color: var(--bg-secondary);
border-color: var(--accent);
}
.l1-name {
font-size: 10px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* ---- L2 二级子分类 chip ---- */
.qr-l2-row {
padding: 5px 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-light);
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.qr-l2-chip {
display: flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border-radius: 12px;
cursor: pointer;
transition: 0.15s;
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border-light);
white-space: nowrap;
}
.qr-l2-chip:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-light);
}
.qr-l2-chip.selected {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
font-weight: 600;
}
.l2-num {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg-hover);
color: var(--text-tertiary);
font-size: 10px;
font-weight: 600;
}
.qr-l2-chip.selected .l2-num {
background: var(--accent);
color: var(--bg-secondary);
}
/* ---- L3 列表 ---- */
.qr-l3-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.qr-empty {
text-align: center;
padding: 24px 12px;
color: var(--text-tertiary);
font-size: 12px;
}
.qr-l3-list {
padding: 2px 0;
}
.qr-l3-item {
display: flex;
gap: 6px;
padding: 7px 10px;
cursor: pointer;
transition: background 0.15s;
border-left: 3px solid transparent;
}
.qr-l3-item:hover {
background: var(--bg-hover);
}
.qr-l3-item.selected {
background: var(--accent-soft);
border-left-color: var(--accent);
}
.qr-l3-num {
flex-shrink: 0;
width: 16px;
text-align: right;
font-size: 10px;
color: var(--text-tertiary);
margin-top: 1px;
}
.qr-l3-body {
flex: 1;
min-width: 0;
overflow: hidden;
}
.qr-l3-title {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qr-l3-content {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.4;
}
/* ---- 选中预览条 ---- */
.qr-selected-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-top: 1px solid var(--border-light);
background: var(--bg-accent-soft);
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.qr-selected-bar:hover {
background: var(--bg-hover);
}
.qr-selected-label {
color: var(--text-tertiary);
flex-shrink: 0;
}
.qr-selected-text {
flex: 1;
color: var(--accent);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qr-selected-enter {
color: var(--text-placeholder);
font-size: 10px;
flex-shrink: 0;
}
/* ---- 键盘指南 ---- */
.qr-keyboard-guide {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 8px;
border-top: 1px solid var(--border-light);
background: var(--bg-secondary);
flex-shrink: 0;
font-size: 10px;
color: var(--text-tertiary);
flex-wrap: wrap;
}
.qr-keyboard-guide kbd {
display: inline-block;
padding: 0 3px;
font-size: 9px;
font-family: inherit;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 2px;
color: var(--text-secondary);
line-height: 1.4;
}
.qr-guide-sep {
color: var(--text-placeholder);
}
</style>
@@ -0,0 +1,168 @@
<!-- =============================================================================
// 企微IT智能服务台 — 风险提示组件
// =============================================================================
// 说明:显示系统当前已知故障/风险信息
// 第一步:显示"当前无已知故障"占位
// 预留接口,后续从后端获取故障列表
// 红色警告样式预留
// ============================================================================= -->
<template>
<div class="risk-alert-wrapper">
<!-- 正常状态无故障 -->
<div class="risk-alert-ok">
<div class="risk-alert-ok__icon"></div>
<div class="risk-alert-ok__title">当前无已知故障</div>
<div class="risk-alert-ok__desc">
所有系统正常运行
</div>
</div>
<!-- 预留的故障列表区域 -->
<!--
后续步骤会从后端获取故障数据结构如下
<div v-for="alert in alerts" class="risk-alert-card">
<div class="risk-alert-level">{{ alert.level }}</div>
<div class="risk-alert-title">{{ alert.title }}</div>
<div class="risk-alert-desc">{{ alert.description }}</div>
<div class="risk-alert-time">{{ alert.time }}</div>
</div>
-->
<!-- 风险监控说明 -->
<div class="risk-alert-scope">
<div class="risk-alert-scope__title">
📋 风险监控范围
</div>
<ul class="risk-alert-scope__list">
<li>VPN 网关连通性</li>
<li>邮件系统服务状态</li>
<li>AD 域控制器状态</li>
<li>网络核心设备告警</li>
<li>业务系统可用性</li>
</ul>
</div>
<!-- 红色警告样式预留注释形式后续启用 -->
<!--
<div class="risk-alert-danger">
<div class="risk-alert-danger__header">
<span class="risk-alert-danger__icon">🔴</span>
<span class="risk-alert-danger__title">VPN 网关异常</span>
</div>
<div class="risk-alert-danger__desc">
VPN 网关 B 节点响应超时影响华南地区员工连接
网络组正在排查预计 30 分钟内修复
</div>
<div class="risk-alert-danger__time">
🕐 发现时间2025-01-15 10:30
</div>
</div>
-->
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 风险提示组件 — 第一步为静态占位
// ============================================================================
// 后续步骤需要:
// 1. 从后端 GET /api/system-alerts 获取故障列表
// 2. 定期轮询(每30秒)
// 3. 有故障时显示红色警告卡片
// 4. 按严重程度排序(P0 > P1 > P2
// ============================================================================
</script>
<style scoped>
.risk-alert-wrapper {
padding: 12px;
}
/* 正常状态(无故障) */
.risk-alert-ok {
padding: 20px;
text-align: center;
border-radius: 8px;
background-color: var(--success-soft);
border: 1px solid var(--success-soft);
}
.risk-alert-ok__icon {
font-size: 36px;
margin-bottom: 8px;
}
.risk-alert-ok__title {
font-weight: 500;
color: var(--color-success);
font-size: 15px;
}
.risk-alert-ok__desc {
color: var(--text-tertiary);
font-size: 12px;
margin-top: 4px;
}
/* 风险监控说明 */
.risk-alert-scope {
margin-top: 16px;
padding: 12px;
background-color: var(--bg-tertiary);
border-radius: 6px;
}
.risk-alert-scope__title {
font-weight: 500;
color: var(--text-secondary);
font-size: 13px;
margin-bottom: 8px;
}
.risk-alert-scope__list {
padding-left: 20px;
color: var(--text-tertiary);
font-size: 12px;
line-height: 2;
}
/* 红色警告样式预留 */
/*
.risk-alert-danger {
padding: 16px;
border-radius: 8px;
background-color: var(--danger-soft);
border: 1px solid var(--danger-soft);
margin-bottom: 12px;
}
.risk-alert-danger__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.risk-alert-danger__icon {
font-size: 20px;
}
.risk-alert-danger__title {
font-weight: 600;
color: var(--color-danger);
}
.risk-alert-danger__desc {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
.risk-alert-danger__time {
color: var(--text-tertiary);
font-size: 12px;
margin-top: 8px;
}
*/
</style>
@@ -0,0 +1,445 @@
<!-- =============================================================================
// 企微IT智能服务台 — 用户信息面板组件
// =============================================================================
// 说明:显示当前会话员工的详细信息
// 功能:
// 1. 员工基本信息(部门/职位/机构)
// 2. VIP 标记
// 3. 历史咨询模式(如"近30天3次VPN问题"
// 4. 坐席备注区(可添加/编辑/删除备注)
// ============================================================================= -->
<template>
<div class="user-info-panel">
<!-- 加载中 -->
<div v-if="!conversation" class="empty-hint">
请先选择一个会话
</div>
<template v-else>
<!-- ================================================================ -->
<!-- 员工基本信息 -->
<!-- ================================================================ -->
<div class="section-block">
<div class="user-header">
<!-- 头像 -->
<div class="user-avatar">
{{ conversation.employee_name?.charAt(conversation.employee_name.length - 1) || '?' }}
</div>
<div>
<div class="user-name">
{{ conversation.employee_name || '未知' }}
<!-- VIP 标记 -->
<el-tag v-if="conversation.is_vip" type="danger" size="small" effect="dark" style="margin-left: 4px;">
VIP
</el-tag>
</div>
<div class="user-id">
{{ conversation.employee_id }}
</div>
</div>
</div>
<!-- 详细信息 -->
<div class="info-card">
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="部门">
{{ conversation.department || '未知' }}
</el-descriptions-item>
<el-descriptions-item label="岗位">
{{ conversation.position || '未知' }}
</el-descriptions-item>
<el-descriptions-item label="等级">
{{ conversation.level || '未知' }}
</el-descriptions-item>
<el-descriptions-item label="紧急度">
<div class="urgency-stars">
<span
v-for="i in 5"
:key="i"
class="urgency-star"
:class="{ empty: i > conversation.urgency_score }"
></span>
</div>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<!-- ================================================================ -->
<!-- 历史咨询模式 -->
<!-- ================================================================ -->
<div class="section-block">
<div class="section-label">
📊 历史咨询模式
</div>
<div class="info-card">
<!-- tags 中推断历史模式 -->
<div v-if="conversation.tags?.repeat_count && conversation.tags.repeat_count > 0" style="margin-bottom: 4px;">
<el-tag size="small" type="warning">🔄 追问 {{ conversation.tags.repeat_count }} </el-tag>
</div>
<div v-if="conversation.tags?.emotion && conversation.tags.emotion !== 'neutral'" style="margin-bottom: 4px;">
<el-tag size="small" type="danger">
{{ emotionHistoryLabel }}
</el-tag>
</div>
<div v-if="conversation.tags?.hand_raise" style="margin-bottom: 4px;">
<el-tag size="small" type="warning">🙋 已要求转人工</el-tag>
</div>
<!-- 无特殊标记 -->
<div
v-if="(!conversation.tags?.repeat_count || conversation.tags.repeat_count === 0)
&& (!conversation.tags?.emotion || conversation.tags.emotion === 'neutral')
&& !conversation.tags?.hand_raise"
class="no-tag-hint"
>
暂无特殊咨询模式标记
</div>
</div>
</div>
<!-- ================================================================ -->
<!-- 坐席备注区 -->
<!-- ================================================================ -->
<div>
<div class="note-header">
<span class="section-label">📝 坐席备注</span>
<el-button type="primary" size="small" text @click="showAddNote">
<el-icon><Plus /></el-icon>
添加
</el-button>
</div>
<!-- 备注列表 -->
<div v-if="notes.length > 0">
<div
v-for="note in notes"
:key="note.id"
class="note-item"
>
<!-- 备注内容 -->
<div class="note-content">{{ note.content }}</div>
<!-- 备注元信息 -->
<div class="note-meta">
<span class="note-time">{{ formatNoteTime(note.created_at) }}</span>
<div style="display: flex; gap: 4px;">
<el-button type="warning" size="small" text @click="showEditNote(note)">编辑</el-button>
<el-button type="danger" size="small" text @click="handleDeleteNote(note)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 无备注 -->
<div v-else class="empty-hint">暂无备注</div>
</div>
<!-- ================================================================ -->
<!-- 添加/编辑备注弹窗 -->
<!-- ================================================================ -->
<el-dialog v-model="noteDialogVisible" :title="isEditingNote ? '编辑备注' : '添加备注'" width="400px">
<el-input
v-model="noteContent"
type="textarea"
:rows="4"
placeholder="输入备注内容..."
/>
<template #footer>
<el-button @click="noteDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="noteSubmitting" @click="handleNoteSubmit">确定</el-button>
</template>
</el-dialog>
</template>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useConversationStore } from '@/stores/conversation'
import { useAgentStore } from '@/stores/agent'
import {
getAgentNotes,
createAgentNote,
updateAgentNote,
deleteAgentNote,
} from '@/api/quickReply'
import type { AgentNote } from '@/api/quickReply'
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
const agentStore = useAgentStore()
/** 当前会话 */
const conversation = computed(() => conversationStore.currentConversation)
/** 备注列表 */
const notes = ref<AgentNote[]>([])
/** 备注弹窗是否可见 */
const noteDialogVisible = ref(false)
/** 是否编辑模式 */
const isEditingNote = ref(false)
/** 正在编辑的备注ID */
const editingNoteId = ref('')
/** 备注内容 */
const noteContent = ref('')
/** 是否正在提交备注 */
const noteSubmitting = ref(false)
// ============================================================================
// 计算属性
// ============================================================================
/** 情绪历史标签 */
const emotionHistoryLabel = computed(() => {
const emotion = conversation.value?.tags?.emotion
const map: Record<string, string> = {
urgent: '🔴 情绪紧急',
angry: '😡 情绪愤怒',
worried: '😟 情绪担忧',
}
return map[emotion || ''] || ''
})
// ============================================================================
// 方法
// ============================================================================
/**
* 加载备注列表
*/
async function loadNotes(): Promise<void> {
if (!conversation.value) return
try {
const data = await getAgentNotes(conversation.value.employee_id)
notes.value = data.items
} catch (error) {
console.error('获取备注失败:', error)
}
}
/**
* 显示添加备注弹窗
*/
function showAddNote(): void {
isEditingNote.value = false
editingNoteId.value = ''
noteContent.value = ''
noteDialogVisible.value = true
}
/**
* 显示编辑备注弹窗
*/
function showEditNote(note: AgentNote): void {
isEditingNote.value = true
editingNoteId.value = note.id
noteContent.value = note.content
noteDialogVisible.value = true
}
/**
* 提交备注(新增或编辑)
*/
async function handleNoteSubmit(): Promise<void> {
if (!noteContent.value.trim()) {
ElMessage.warning('请输入备注内容')
return
}
noteSubmitting.value = true
try {
if (isEditingNote.value) {
// 编辑
await updateAgentNote(editingNoteId.value, noteContent.value.trim())
ElMessage.success('备注已更新')
} else {
// 新增
if (!conversation.value) return
await createAgentNote(
conversation.value.id,
agentStore.userId,
noteContent.value.trim()
)
ElMessage.success('备注已添加')
}
noteDialogVisible.value = false
// 重新加载备注
await loadNotes()
} catch (error) {
console.error('保存备注失败:', error)
} finally {
noteSubmitting.value = false
}
}
/**
* 删除备注
*/
async function handleDeleteNote(note: AgentNote): Promise<void> {
try {
await ElMessageBox.confirm('确定要删除此备注吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await deleteAgentNote(note.id)
ElMessage.success('备注已删除')
await loadNotes()
} catch {
// 用户取消
}
}
/**
* 格式化备注时间
*/
function formatNoteTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
const month = date.getMonth() + 1
const day = date.getDate()
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hours}:${minutes}`
}
// ============================================================================
// 监听
// ============================================================================
// 切换会话时重新加载备注
watch(
() => conversationStore.currentConversationId,
() => {
loadNotes()
},
{ immediate: true }
)
</script>
<style scoped>
/* 面板容器 */
.user-info-panel {
padding: 12px;
}
/* 空提示文字 */
.empty-hint {
text-align: center;
padding: 12px 20px;
color: var(--text-tertiary);
font-size: 12px;
}
/* 区块间距 */
.section-block {
margin-bottom: 16px;
}
/* 区块标题 */
.section-label {
font-weight: 500;
color: var(--text-primary);
font-size: 13px;
margin-bottom: 8px;
}
/* 用户头部:头像+名称 */
.user-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
/* 头像 */
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--accent);
color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
flex-shrink: 0;
}
/* 用户名 */
.user-name {
font-weight: 600;
color: var(--text-primary);
font-size: 15px;
}
/* 用户ID */
.user-id {
font-size: 12px;
color: var(--text-tertiary);
}
/* 信息卡片(浅色背景区块) */
.info-card {
background-color: var(--bg-tertiary);
border-radius: 6px;
padding: 12px;
}
/* 无标记提示 */
.no-tag-hint {
color: var(--text-tertiary);
font-size: 12px;
}
/* 备注头部 */
.note-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
/* 备注条目 */
.note-item {
padding: 8px 12px;
margin-bottom: 6px;
border-radius: 6px;
background-color: var(--bg-tertiary);
border-left: 3px solid var(--accent);
}
/* 备注内容 */
.note-content {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
}
/* 备注元信息 */
.note-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
}
/* 备注时间 */
.note-time {
font-size: 11px;
color: var(--text-placeholder);
}
</style>
@@ -0,0 +1,168 @@
<!-- =============================================================================
// 企微IT智能服务台 — AI 草稿气泡组件(内嵌在对话流中)
// =============================================================================
// 说明:在员工消息下方显示 AI 草稿气泡
// 特殊样式:浅蓝底色(#F0F9FF) + 左侧蓝色竖线 + 🤖 AI建议 标记
// 操作按钮:[采纳]/[编辑]/[忽略]
// ============================================================================= -->
<template>
<transition name="ai-draft-fade">
<div v-if="visible" class="ai-draft-bubble">
<!-- 标题行 -->
<div class="ai-draft-header">
<span class="ai-draft-icon">🤖</span>
<span class="ai-draft-label">AI建议</span>
<span v-if="confidence > 0" class="ai-draft-confidence">
置信度 {{ Math.round(confidence * 100) }}%
</span>
</div>
<!-- 草稿内容 -->
<div class="ai-draft-content">
{{ content }}
</div>
<!-- 操作按钮 -->
<div class="ai-draft-actions">
<el-button size="small" type="primary" @click="handleAccept">
采纳
</el-button>
<el-button size="small" @click="handleEdit">
编辑
</el-button>
<el-button size="small" text @click="handleIgnore">
忽略
</el-button>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useConversationStore } from '@/stores/conversation'
// ============================================================================
// Props
// ============================================================================
interface Props {
/** 会话ID */
conversationId: string
/** 消息ID(草稿关联的员工消息) */
messageId: string
/** 草稿内容 */
content: string
/** 置信度 */
confidence: number
}
const props = defineProps<Props>()
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
/** 是否可见(用于淡出动画) */
const visible = ref(true)
// ============================================================================
// 方法
// ============================================================================
/**
* 采纳草稿 — 将内容填入 ReplyBox
*/
function handleAccept(): void {
conversationStore.acceptDraft(props.conversationId, props.messageId)
ElMessage.success('草稿已填入回复框')
}
/**
* 编辑草稿 — 将内容填入 ReplyBox 并聚焦
*/
function handleEdit(): void {
conversationStore.editDraft(props.conversationId, props.messageId)
ElMessage.success('草稿已填入回复框,可直接编辑')
}
/**
* 忽略草稿 — 带淡出动画移除
*/
function handleIgnore(): void {
// 触发淡出动画
visible.value = false
// 动画结束后从 store 移除
setTimeout(() => {
conversationStore.ignoreDraft(props.conversationId, props.messageId)
}, 300)
}
</script>
<style scoped>
.ai-draft-bubble {
margin-top: 4px;
margin-left: 4px;
margin-bottom: 8px;
background-color: var(--accent-soft);
border-left: 3px solid var(--accent);
border-radius: 6px;
padding: 10px 12px;
max-width: 85%;
position: relative;
}
.ai-draft-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
}
.ai-draft-icon {
font-size: 14px;
}
.ai-draft-label {
font-size: 12px;
font-weight: 600;
color: var(--accent);
}
.ai-draft-confidence {
font-size: 11px;
color: var(--text-tertiary);
margin-left: 4px;
}
.ai-draft-content {
font-size: 13px;
color: var(--text-primary);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.ai-draft-actions {
display: flex;
gap: 4px;
margin-top: 8px;
}
/* 淡出动画 */
.ai-draft-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.ai-draft-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
@@ -0,0 +1,258 @@
<!-- =============================================================================
// 企微IT智能服务台 — AI 推荐回复(内联)组件
// =============================================================================
// 说明:在聊天消息流中,最后一条用户消息之后、坐席回复之前
// 显示条件:仅坐席未回复时(最后一条消息是用户发的)
// 交互:点击卡片或 Ctrl+1/2/3 → 填入回复输入框
// ============================================================================= -->
<template>
<div v-if="shouldShow" class="ai-recommend-inline">
<div class="ai-recommend-inline__header">
<span class="ai-recommend-inline__title">🤖 AI 推荐回复</span>
</div>
<div class="ai-recommend-inline__cards">
<div
v-for="(rec, index) in recommendations"
:key="index"
class="ai-recommend-card"
:class="{ 'is-hovered': hoveredIndex === index }"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = -1"
@click="handleSelect(index)"
>
<div class="ai-recommend-card__header">
<span class="ai-recommend-card__name">{{ rec.name }}</span>
<span class="ai-recommend-card__confidence">{{ rec.confidence }}%</span>
</div>
<div class="ai-recommend-card__text">{{ rec.content }}</div>
<div class="ai-recommend-card__shortcut">Ctrl+{{ index + 1 }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed, watch } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import type { Message } from '@/api/message'
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
/** 当前 hover 的卡片索引 */
const hoveredIndex = ref(-1)
/** AI 推荐数据 */
interface AiRecommendation {
/** 方案名称 */
name: string
/** 置信度百分比 */
confidence: number
/** 推荐内容 */
content: string
}
/** 推荐列表(Mock 数据,后续对接 wingman API */
const recommendations = ref<AiRecommendation[]>([])
// ============================================================================
// 计算属性
// ============================================================================
/** 是否应该显示推荐(最后一条消息是用户发的,且坐席未回复) */
const shouldShow = computed(() => {
const msgs = conversationStore.messages
if (msgs.length === 0) return false
const lastMsg = msgs[msgs.length - 1]
// 最后一条消息是用户发的(employee),说明坐席还没回复
return lastMsg.sender_type === 'employee'
})
// ============================================================================
// Mock 数据生成
// ============================================================================
/**
* 根据 Messages 生成 Mock 推荐数据
* P0 阶段使用 Mock,后续对接 wingman API
*/
function generateMockRecommendations(messages: Message[]): void {
if (messages.length === 0) {
recommendations.value = []
return
}
// 从最后一条用户消息提取关键词
const lastUserMsg = [...messages].reverse().find(m => m.sender_type === 'employee')
const content = lastUserMsg?.content?.toLowerCase() || ''
// 基于关键词匹配 Mock 推荐方案
if (content.includes('vpn') || content.includes('连接')) {
recommendations.value = [
{ name: 'VPN 重连方案', confidence: 92, content: '请先断开 VPN 连接,等待10秒后重新连接。如仍然失败,请尝试切换到备用线路。' },
{ name: 'VPN 密码重置', confidence: 78, content: 'VPN 登录失败可能是密码过期导致,建议重置 VPN 密码后重试。' },
{ name: '网络环境检查', confidence: 65, content: '请检查当前网络环境是否为公司内网,VPN 仅在公司外网使用。' },
]
} else if (content.includes('邮箱') || content.includes('邮件')) {
recommendations.value = [
{ name: '邮箱容量清理', confidence: 88, content: '您的邮箱容量已接近上限,建议清理大附件邮件或联系IT申请扩容。' },
{ name: '邮箱配置检查', confidence: 75, content: '请检查 Outlook 账户设置是否正确,服务器地址为 mail.company.com。' },
{ name: '邮箱重置密码', confidence: 60, content: '如邮箱无法登录,可能是密码过期。请访问自助门户重置密码。' },
]
} else if (content.includes('系统') || content.includes('登录') || content.includes('账号')) {
recommendations.value = [
{ name: '账号解锁方案', confidence: 85, content: '您的账号可能因多次输入错误密码被锁定,请在自助门户解锁或联系IT处理。' },
{ name: '密码重置指引', confidence: 72, content: '请访问密码自助重置页面,按照提示完成密码重置。新密码需满足复杂度要求。' },
{ name: 'SSO 单点登录', confidence: 55, content: '推荐使用企业SSO单点登录,可统一管理各系统账号。' },
]
} else {
// 通用推荐
recommendations.value = [
{ name: '通用排查方案', confidence: 80, content: '请先尝试清除浏览器缓存后重试,如问题仍存在请提供详细报错截图。' },
{ name: '远程协助方案', confidence: 65, content: '如自助排查无法解决,坐席可发起远程协助,请保持电脑开机状态。' },
{ name: '工单转交方案', confidence: 45, content: '该问题可能需要二线团队处理,我将为您创建工单并转交相关团队。' },
]
}
}
// ============================================================================
// 方法
// ============================================================================
/**
* 选中推荐方案 → 填入回复输入框
*
* @param index - 推荐方案索引
*/
function handleSelect(index: number): void {
if (index < 0 || index >= recommendations.value.length) return
conversationStore.pendingReplyText = recommendations.value[index].content
}
// ============================================================================
// 暴露方法供快捷键调用
// ============================================================================
/**
* 通过索引选择推荐(供 useKeyboardShortcuts 调用)
*/
function selectByIndex(index: number): void {
handleSelect(index)
}
defineExpose({ selectByIndex })
// ============================================================================
// 监听消息变化 → 重新生成 Mock 推荐
// ============================================================================
watch(
() => conversationStore.messages.length,
() => {
generateMockRecommendations(conversationStore.messages)
},
{ immediate: true }
)
</script>
<style scoped>
/* AI 推荐容器 */
.ai-recommend-inline {
margin: 12px 0;
border: 1px dashed var(--accent);
border-radius: var(--radius-lg);
background: var(--bg-accent-soft);
padding: 12px 16px;
}
.ai-recommend-inline__header {
margin-bottom: 10px;
}
.ai-recommend-inline__title {
font-size: 13px;
font-weight: 600;
color: var(--accent);
}
/* 推荐卡片容器 */
.ai-recommend-inline__cards {
display: flex;
gap: 10px;
}
/* 推荐卡片 */
.ai-recommend-card {
flex: 1;
min-width: 0;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.ai-recommend-card:hover,
.ai-recommend-card.is-hovered {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.ai-recommend-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ai-recommend-card__name {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
/* 置信度药丸 */
.ai-recommend-card__confidence {
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: var(--accent);
color: var(--bg-secondary);
font-weight: 600;
}
/* 推荐文本(2 行截断) */
.ai-recommend-card__text {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* 快捷键提示 */
.ai-recommend-card__shortcut {
margin-top: 6px;
font-size: 10px;
color: var(--text-placeholder);
text-align: right;
}
/* 响应式:小屏幕下卡片竖排 */
@media (max-width: 768px) {
.ai-recommend-inline__cards {
flex-direction: column;
}
}
</style>
@@ -0,0 +1,583 @@
<!-- =============================================================================
// 企微IT智能服务台 — 对话区组件
// =============================================================================
// 说明:坐席工作台中间的对话区
// 功能:
// 1. 顶部 UserInfoBar(替代原标题栏,含6卡片展开详情)
// 2. 排查步骤栏(UserInfoBar 下方,始终可见)
// 3. 协作信息行
// 4. 消息列表
// 5. 底部回复输入框
// ============================================================================= -->
<template>
<div style="height: 100%; display: flex; flex-direction: column;">
<!-- ================================================================== -->
<!-- 顶部用户信息栏替代原标题栏 -->
<!-- ================================================================== -->
<UserInfoBar
ref="userInfoBarRef"
:conversation="conversationStore.currentConversation"
:available-agents="agentStore.availableAgents"
:can-invite-collaborator="canInviteCollaborator"
@assign="handleAssign"
@resolve="handleResolve"
@toggle-pin="handleTogglePin"
@toggle-todo="handleToggleTodo"
@transfer="handleTransfer"
@invite="inviteDialogVisible = true"
/>
<!-- ================================================================== -->
<!-- 排查步骤栏紧跟用户信息栏下方始终可见 -->
<!-- ================================================================== -->
<TroubleshootBar />
<!-- ================================================================== -->
<!-- 参与者面板有参与者时显示 -->
<!-- ================================================================== -->
<ParticipantBar
v-if="hasAnyParticipants"
:participants="currentConv?.participants || []"
:agent-name="currentConv?.assigned_agent_name || '坐席'"
:is-primary-agent="isPrimaryAgent"
:collaborating-agent-ids="currentConv?.collaborating_agent_ids || []"
:collaborating-agent-names="currentConv?.collaborating_agent_names || {}"
@invite="showInviteParticipantDialog = true"
@remove="handleRemoveParticipant"
/>
<!-- ================================================================== -->
<!-- 协作信息行仅展示 -->
<!-- ================================================================== -->
<div
v-if="collaborationInfoText"
style="
padding: 4px 20px;
background: var(--bg-accent-soft);
border-bottom: 1px solid var(--border-light);
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
"
>
{{ collaborationInfoText }}
</div>
<!-- ================================================================== -->
<!-- 消息列表flex: 1 占满剩余空间 -->
<!-- ================================================================== -->
<div ref="messageListRef" class="message-list-scroll">
<!-- 加载中 -->
<div v-if="conversationStore.loadingMessages" style="text-align: center; padding: 20px;">
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
<div style="margin-top: 8px; color: var(--text-tertiary); font-size: 12px;">加载消息中...</div>
</div>
<!-- 消息列表 -->
<template v-else>
<MessageBubble
v-for="msg in conversationStore.messages"
:key="msg.id"
:message="msg"
@reply="handleReplyTo"
@scroll-to-message="scrollToMessage"
/>
<!-- 空消息 -->
<div
v-if="conversationStore.messages.length === 0"
style="text-align: center; padding: 40px; color: var(--text-tertiary);"
>
暂无消息
</div>
</template>
</div>
<!-- ================================================================== -->
<!-- 输入指示器某人在输入时显示 -->
<!-- ================================================================== -->
<div v-if="typingText" class="typing-indicator">
<span class="typing-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
<span class="typing-text">{{ typingText }}</span>
</div>
<!-- ================================================================== -->
<!-- 回复输入框在消息列表下方排查步骤上方 -->
<!-- ================================================================== -->
<ReplyBox
v-if="conversationStore.currentConversation?.status !== 'resolved'"
:reply-to-message="replyToMessage"
@send="handleSend"
@cancel-reply="replyToMessage = null"
/>
<!-- 已结单提示 -->
<div
v-else
style="
padding: 12px 16px;
border-top: 1px solid var(--border-color);
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
background-color: var(--bg-tertiary);
"
>
该会话已结单无法发送消息
</div>
<!-- 摇人弹窗 -->
<InviteDialog
v-model="inviteDialogVisible"
:exclude-agent-ids="excludeAgentIds"
@confirm="handleInvite"
/>
<!-- 邀请员工弹窗 -->
<InviteParticipantDialog
v-model="showInviteParticipantDialog"
:conversation-id="conversationStore.currentConversation?.id || ''"
:existing-participant-ids="existingParticipantIds"
@success="onInviteParticipantSuccess"
/>
<!-- 结单摘要确认弹窗 -->
<el-dialog
v-model="summaryDialogVisible"
title="会话摘要确认"
width="480px"
:close-on-click-modal="false"
>
<div v-if="conversationStore.loadingSummary" style="text-align: center; padding: 20px;">
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
<div style="margin-top: 8px; color: var(--text-tertiary);">正在生成 AI 摘要...</div>
</div>
<div v-else>
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
以下为 AI 自动生成的会话摘要请确认或修改后提交
</p>
<el-form label-position="top" size="small">
<el-form-item label="问题">
<el-input v-model="summaryForm.problem" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="原因">
<el-input v-model="summaryForm.cause" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="解决方案">
<el-input v-model="summaryForm.solution" type="textarea" :rows="2" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="summaryDialogVisible = false">取消结单</el-button>
<el-button type="primary" @click="handleConfirmSummary">确认结单</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { useConversationStore } from '@/stores/conversation'
import { useAgentStore } from '@/stores/agent'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import type { Message } from '@/api/message'
import MessageBubble from './MessageBubble.vue'
import ReplyBox from './ReplyBox.vue'
import InviteDialog from '@/components/conversation/InviteDialog.vue'
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
import ParticipantBar from '@/components/conversation/ParticipantBar.vue'
import UserInfoBar from './UserInfoBar.vue'
import TroubleshootBar from './TroubleshootBar.vue'
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
const agentStore = useAgentStore()
/** 当前会话(简化空值检查) */
const currentConv = computed(() => conversationStore.currentConversation)
/** 消息列表DOM引用(用于自动滚动) */
const messageListRef = ref<HTMLElement | null>(null)
/** UserInfoBar 组件引用 */
const userInfoBarRef = ref<InstanceType<typeof UserInfoBar> | null>(null)
/** 摇人弹窗可见性 */
const inviteDialogVisible = ref(false)
/** 邀请员工弹窗可见性 */
const showInviteParticipantDialog = ref(false)
/** 引用回复:当前正在回复的消息(null 表示普通发送) */
const replyToMessage = ref<Message | null>(null)
/** 结单摘要确认弹窗可见性 */
const summaryDialogVisible = ref(false)
/** 结单摘要表单 */
const summaryForm = ref({
problem: '',
cause: '',
solution: '',
})
// ============================================================================
// 计算属性
// ============================================================================
/** 是否可以摇人(邀请协作坐席) */
const canInviteCollaborator = computed(() => {
const conv = conversationStore.currentConversation
if (!conv || conv.status !== 'serving') return false
return conv.is_mine || conv.is_collaborator
})
/** 当前坐席是否为主责坐席(邀请功能权限控制) */
const isPrimaryAgent = computed(() => {
const conv = conversationStore.currentConversation
if (!conv) return false
return conv.assigned_agent_id === agentStore.userId
})
/** 是否有参与者或协作坐席(决定是否显示 ParticipantBar */
const hasAnyParticipants = computed(() => {
const conv = conversationStore.currentConversation
if (!conv) return false
return (conv.participants?.length || 0) > 0 || (conv.collaborating_agent_ids?.length || 0) > 0
})
/** 排除的坐席ID列表(主责坐席 + 已在协作中的坐席 + 自己) */
const excludeAgentIds = computed(() => {
const conv = conversationStore.currentConversation
if (!conv) return []
const exclude = new Set<string>()
if (conv.assigned_agent_id) exclude.add(conv.assigned_agent_id)
for (const aid of conv.collaborating_agent_ids || []) {
exclude.add(aid)
}
exclude.add(agentStore.userId)
return Array.from(exclude)
})
/** 协作信息文本(主责 + 协作坐席) */
const collaborationInfoText = computed(() => {
const conv = conversationStore.currentConversation
if (!conv || conv.status !== 'serving') return ''
const parts: string[] = []
if (conv.assigned_agent_name) {
parts.push(`主责:${conv.assigned_agent_name}`)
}
const collabIds = conv.collaborating_agent_ids || []
if (collabIds.length > 0) {
const names = collabIds.map(
aid => conv.collaborating_agent_names?.[aid] || '未知'
)
parts.push(`协作:${names.join('、')}`)
}
return parts.join(' | ')
})
/** 输入指示器文本(如 "张三正在输入..." */
const typingText = computed(() => {
const convId = conversationStore.currentConversationId
if (!convId) return ''
return conversationStore.getTypingText(convId)
})
// ============================================================================
// 快捷键
// ============================================================================
useKeyboardShortcuts({
// AI 推荐已移至右边栏,快捷键在 AiAssistantPanel 中处理
})
// ============================================================================
// 方法
// ============================================================================
/**
* 接单
*/
async function handleAssign(): Promise<void> {
if (!conversationStore.currentConversation) return
try {
await conversationStore.assignConv(
conversationStore.currentConversation.id,
agentStore.userId
)
ElMessage.success('接单成功')
} catch (error) {
console.error('接单失败:', error)
}
}
/**
* 结单
* 先确认 → 生成 AI 摘要 → 弹出摘要确认对话框 → 最终结单
*/
async function handleResolve(): Promise<void> {
if (!conversationStore.currentConversation) return
try {
await ElMessageBox.confirm('确定要结单吗?结单后无法继续回复。', '确认结单', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const convId = conversationStore.currentConversation.id
summaryDialogVisible.value = true
await conversationStore.fetchSummary(convId)
if (conversationStore.currentSummary) {
summaryForm.value.problem = conversationStore.currentSummary.problem
summaryForm.value.cause = conversationStore.currentSummary.cause
summaryForm.value.solution = conversationStore.currentSummary.solution
} else {
summaryForm.value = { problem: '', cause: '', solution: '' }
}
} catch {
// 用户取消
}
}
/**
* 确认摘要并完成结单
*/
async function handleConfirmSummary(): Promise<void> {
if (!conversationStore.currentConversation) return
try {
await conversationStore.resolveConv(conversationStore.currentConversation.id)
summaryDialogVisible.value = false
ElMessage.success('已结单')
} catch (error) {
console.error('结单失败:', error)
}
}
/**
* 置顶/取消置顶
*/
async function handleTogglePin(): Promise<void> {
if (!conversationStore.currentConversation) return
try {
await conversationStore.togglePinConv(conversationStore.currentConversation.id)
} catch (error) {
console.error('切换置顶失败:', error)
}
}
/**
* 代办/取消代办
*/
async function handleToggleTodo(): Promise<void> {
if (!conversationStore.currentConversation) return
try {
await conversationStore.toggleTodoConv(conversationStore.currentConversation.id)
} catch (error) {
console.error('切换代办失败:', error)
}
}
/**
* 转接
*/
async function handleTransfer(targetAgentId: string): Promise<void> {
if (!conversationStore.currentConversation) return
try {
await ElMessageBox.confirm('确定要转接给其他坐席吗?', '确认转接', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await conversationStore.transferConv(
conversationStore.currentConversation.id,
targetAgentId
)
ElMessage.success('转接成功')
} catch {
// 用户取消
}
}
/**
* 摇人(邀请协作坐席)
*/
async function handleInvite(agentId: string): Promise<void> {
if (!conversationStore.currentConversation) return
try {
await conversationStore.inviteToConversation(
conversationStore.currentConversation.id,
agentId
)
inviteDialogVisible.value = false
ElMessage.success('已发送摇人邀请')
} catch (error: any) {
ElMessage.error(error?.message || '摇人失败')
}
}
/** 已在参与者列表中的ID(排除,避免重复邀请) */
const existingParticipantIds = computed(() => {
const conv = conversationStore.currentConversation
if (!conv) return []
const ids: string[] = [conv.employee_id]
conv.participants?.forEach(p => ids.push(p.id))
return ids
})
/** 邀请员工成功回调 */
function onInviteParticipantSuccess(): void {
conversationStore.fetchConversations()
}
/** 移除参与者 */
async function handleRemoveParticipant(userId: string): Promise<void> {
if (!conversationStore.currentConversation) return
try {
await ElMessageBox.confirm('确定移除该参与者?', '移除确认', {
type: 'warning',
})
await conversationStore.removeParticipantFromConv(
conversationStore.currentConversation.id,
userId
)
ElMessage.success('已移除参与者')
} catch {
// 用户取消
}
}
/**
* 处理消息回复(点击消息气泡上的回复按钮)
* 做什么:设置 replyToMessage,在输入框上方显示引用摘要
*/
function handleReplyTo(message: Message): void {
replyToMessage.value = message
}
/**
* 发送消息(含引用回复)
* 如果 replyToMessage 不为空,在发送时附带 reply_to_id
*/
async function handleSend(content: string): Promise<void> {
const replyToId = replyToMessage.value?.id || undefined
await conversationStore.sendReply(content, replyToId)
// 发送成功后清除引用回复
replyToMessage.value = null
}
/**
* 滚动到指定消息(点击引用回复摘要时触发)
*/
function scrollToMessage(messageId: string): void {
const msgIndex = conversationStore.messages.findIndex(m => m.id === messageId)
if (msgIndex >= 0 && messageListRef.value) {
const messageEls = messageListRef.value.querySelectorAll('.message-row')
const targetEl = messageEls[msgIndex] as HTMLElement
if (targetEl) {
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
targetEl.style.transition = 'background 0.3s'
targetEl.style.background = 'var(--accent-soft)'
setTimeout(() => {
targetEl.style.background = ''
}, 1500)
}
}
}
/**
* 自动滚动到底部
*/
function scrollToBottom(): void {
nextTick(() => {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
}
})
}
// ============================================================================
// 监听
// ============================================================================
// 消息变化时自动滚动到底部
watch(
() => conversationStore.messages.length,
() => {
scrollToBottom()
}
)
// 切换会话时自动滚动到底部 + 重置 UserInfoBar
watch(
() => conversationStore.currentConversationId,
() => {
scrollToBottom()
agentStore.loadAvailableAgents()
// 重置 UserInfoBar 的展开状态
userInfoBarRef.value?.resetForNewConversation()
}
)
</script>
<style scoped>
/* 组件样式在 global.css 中定义 */
/* 输入指示器样式 */
.typing-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 16px;
font-size: 12px;
color: var(--text-tertiary);
background: var(--bg-primary);
flex-shrink: 0;
min-height: 24px;
}
.typing-dots {
display: inline-flex;
gap: 2px;
align-items: center;
}
.typing-dots .dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-tertiary);
animation: typing-bounce 1.4s infinite ease-in-out both;
}
.typing-dots .dot:nth-child(1) { animation-delay: 0s; }
.typing-dots .dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dots .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.typing-text {
font-style: italic;
}
</style>
@@ -0,0 +1,261 @@
<!-- =============================================================================
// 企微IT智能服务台 — 决策树递归节点组件
// =============================================================================
// 说明:递归渲染决策树节点
// 节点类型:
// - step: 圆形编号节点 + 连接线 + 标签
// - decision: 黄底方块 "❓ 判断" + 判断文字 + 是/否分支
// ============================================================================= -->
<template>
<div class="flowchart-node" :class="[`flowchart-node--${node.type}`, `flowchart-node--${node.status || 'pending'}`]">
<!-- 步骤节点 -->
<div v-if="node.type === 'step'" class="flowchart-step">
<div class="flowchart-step__indicator" :class="statusClass">
<span class="flowchart-step__number">{{ stepIndex }}</span>
</div>
<div class="flowchart-step__connector" :class="statusClass"></div>
<div class="flowchart-step__label" :class="statusClass">{{ node.label }}</div>
</div>
<!-- 判断节点 -->
<div v-if="node.type === 'decision'" class="flowchart-decision">
<div class="flowchart-decision__box">
<span class="flowchart-decision__icon"></span>
<span class="flowchart-decision__text">{{ node.label }}</span>
</div>
<!-- 分支 -->
<div class="flowchart-decision__branches">
<!-- "是" 分支 -->
<div v-if="node.yes_branch" class="flowchart-branch flowchart-branch--yes">
<div class="flowchart-branch__label"></div>
<div class="flowchart-branch__connector"></div>
<div class="flowchart-branch__content">
<FlowchartNode
:node="node.yes_branch"
:base-index="stepIndex"
/>
</div>
</div>
<!-- "否" 分支 -->
<div v-if="node.no_branch" class="flowchart-branch flowchart-branch--no">
<div class="flowchart-branch__label"></div>
<div class="flowchart-branch__connector"></div>
<div class="flowchart-branch__content">
<FlowchartNode
:node="node.no_branch"
:base-index="stepIndex"
/>
</div>
</div>
</div>
</div>
<!-- 子节点列表step 类型的 children -->
<div v-if="node.type === 'step' && node.children && node.children.length > 0" class="flowchart-node__children">
<FlowchartNode
v-for="(child, idx) in node.children"
:key="child.id"
:node="child"
:base-index="stepIndex + idx + 1"
/>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
import type { FlowchartNode as FlowchartNodeType } from '@/api/troubleshooting'
// ============================================================================
// Props
// ============================================================================
interface Props {
/** 节点数据 */
node: FlowchartNodeType
/** 基础序号(用于步骤编号) */
baseIndex?: number
}
const props = withDefaults(defineProps<Props>(), {
baseIndex: 1,
})
// ============================================================================
// 计算属性
// ============================================================================
/** 步骤序号(1-based */
const stepIndex = computed(() => {
return props.baseIndex
})
/** 状态样式类 */
const statusClass = computed(() => {
return `flowchart-status--${props.node.status || 'pending'}`
})
</script>
<style scoped>
/* 节点容器 */
.flowchart-node {
margin-bottom: 4px;
}
/* ===== 步骤节点 ===== */
.flowchart-step {
display: flex;
align-items: center;
gap: 8px;
}
/* 步骤指示器(圆形编号) */
.flowchart-step__indicator {
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 11px;
font-weight: 700;
color: var(--bg-secondary);
}
.flowchart-step__indicator.flowchart-status--done {
background: var(--color-success);
}
.flowchart-step__indicator.flowchart-status--current {
background: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
}
.flowchart-step__indicator.flowchart-status--pending {
background: var(--bg-active);
color: var(--text-tertiary);
}
/* 连接线 */
.flowchart-step__connector {
width: 2px;
height: 16px;
margin-left: 10px;
}
.flowchart-step__connector.flowchart-status--done {
background: var(--color-success);
}
.flowchart-step__connector.flowchart-status--current {
background: var(--accent);
}
.flowchart-step__connector.flowchart-status--pending {
background: var(--border-color);
}
/* 步骤标签 */
.flowchart-step__label {
font-size: 13px;
line-height: 1.5;
}
.flowchart-step__label.flowchart-status--done {
color: var(--color-success);
}
.flowchart-step__label.flowchart-status--current {
color: var(--accent);
font-weight: 600;
}
.flowchart-step__label.flowchart-status--pending {
color: var(--text-tertiary);
}
/* 子节点容器 */
.flowchart-node__children {
margin-left: 11px;
padding-left: 12px;
border-left: 2px solid var(--border-color);
}
/* ===== 判断节点 ===== */
.flowchart-decision {
margin-bottom: 4px;
}
.flowchart-decision__box {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--warning-soft);
border: 1px solid var(--warning-soft);
border-radius: var(--radius-md);
font-size: 13px;
color: var(--color-warning);
font-weight: 500;
margin-bottom: 8px;
}
.flowchart-decision__icon {
font-size: 14px;
}
.flowchart-decision__text {
font-size: 13px;
}
/* 分支容器 */
.flowchart-decision__branches {
display: flex;
gap: 16px;
margin-top: 4px;
}
/* 分支 */
.flowchart-branch {
flex: 1;
min-width: 0;
}
.flowchart-branch__label {
font-size: 11px;
font-weight: 600;
margin-bottom: 4px;
padding: 1px 8px;
border-radius: 8px;
display: inline-block;
}
.flowchart-branch--yes .flowchart-branch__label {
background: var(--success-soft);
color: var(--color-success);
}
.flowchart-branch--no .flowchart-branch__label {
background: var(--danger-soft);
color: var(--color-danger);
}
.flowchart-branch__connector {
width: 1px;
height: 8px;
margin-left: 16px;
border-left: 2px dashed var(--border-color);
}
.flowchart-branch__content {
padding-left: 12px;
border-left: 2px dashed var(--border-color);
}
</style>
@@ -0,0 +1,806 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席端输入框组件
// =============================================================================
// 说明:坐席端回复消息的输入区域
// 功能:
// - 输入框默认3行高度,自动扩展(max-height: 150px
// - 底部显示字数统计(当前/最大,如:120/500)
// - 右下角发送按钮(icon)
// - Enter键发送,Shift+Enter换行
// - 空内容时禁用发送按钮
// - 支持粘贴图片/文件上传
// - 快捷工具栏(表情/图片/截图/文件/语音/快速回复)
// ============================================================================= -->
<template>
<div class="input-box" ref="replyBoxRef">
<!-- 引用回复预览回复某条消息时显示 -->
<div v-if="replyToMessage" class="reply-preview">
<div class="reply-preview-bar"></div>
<div class="reply-preview-content">
<span class="reply-preview-sender">{{ replyToMessage.sender_name || '未知' }}</span>
<span class="reply-preview-text">{{ replyToMessage.content?.substring(0, 60) }}{{ replyToMessage.content?.length > 60 ? '...' : '' }}</span>
</div>
<button class="reply-preview-close" title="取消回复" @click="$emit('cancelReply')"></button>
</div>
<!-- 快捷工具栏 -->
<div class="chat-toolbar">
<div class="emoji-wrapper">
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
😊
<span class="tb-tip">表情</span>
</button>
<!-- 自定义中文表情选择面板 -->
<div v-if="showEmojiPicker" class="emoji-picker-popup">
<div class="emoji-grid">
<button
v-for="emoji in commonEmojis"
:key="emoji"
class="emoji-grid__item"
@click="onEmojiSelect(emoji)"
>{{ emoji }}</button>
</div>
</div>
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
</div>
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
🖼
<span class="tb-tip">图片</span>
</button>
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
<span class="tb-tip">截图</span>
</button>
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
📎
<span class="tb-tip">文件</span>
</button>
<button class="tb-btn" title="语音" @click="handleToolbarClick('voice')">
🎤
<span class="tb-tip">语音</span>
</button>
<div class="tb-sep"></div>
<button class="tb-btn" title="快速回复" @click="handleToolbarClick('quickReply')">
<span class="tb-tip">快速回复</span>
</button>
</div>
<!-- 输入行 -->
<div class="chat-input-card">
<textarea
ref="inputRef"
v-model="inputText"
class="chat-input"
placeholder="输入回复内容... (Enter发送,Shift+Enter换行)"
:style="{ height: textareaHeight + 'px' }"
:disabled="!conversationStore.currentConversation"
@keydown="handleKeydown"
@input="handleInput"
@paste="handlePaste"
></textarea>
<!-- 发送按钮 icon样式 -->
<button
class="btn-send"
:class="{ 'btn-send--active': canSend }"
:disabled="!canSend"
@click="handleSend"
>
<svg v-if="!conversationStore.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>
<van-loading v-else size="16px" color="#fff" />
</button>
</div>
<!-- 字数统计 -->
<div class="input-box__counter">
{{ charCount }}/{{ maxChars }}
</div>
<!-- 邀请弹窗 -->
<InviteParticipantDialog
v-model="showInviteDialog"
:conversation-id="conversationStore.currentConversation?.id || ''"
:existing-participant-ids="existingParticipantIds"
@success="onInviteSuccess"
/>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInputRef"
type="file"
style="display: none"
@change="handleFileSelect"
/>
<!-- 截图编辑器 -->
<ScreenshotEditor
v-if="showScreenshotEditor"
:screenshot-canvas="screenshotCanvas"
@confirm="onScreenshotConfirm"
@cancel="onScreenshotCancel"
/>
</div>
</template>
<script setup lang="ts">
/**
* InputBox 坐席端输入框组件
* 输入框默认3行高度,自动扩展
* 底部显示字数统计,右下角发送按钮
* Enter发送,Shift+Enter换行
*/
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import html2canvas from 'html2canvas-pro'
import { useConversationStore } from '@/stores/conversation'
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
import ScreenshotEditor from './ScreenshotEditor.vue'
import { uploadFile } from '@/api/upload'
import { sendMessage } from '@/api/message'
import type { Message } from '@/api/message'
// ============================================================================
// 工具函数
// ============================================================================
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)
}
// ============================================================================
// Props & Emits
// ============================================================================
interface Props {
/** 引用回复:正在回复的消息 */
replyToMessage?: Message | null
}
interface Emits {
(e: 'send', content: string): void
(e: 'cancelReply'): void
}
withDefaults(defineProps<Props>(), {
replyToMessage: null,
})
const emit = defineEmits<Emits>()
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
/** 输入框文本 */
const inputText = ref('')
/** 输入框 DOM 引用 */
const inputRef = ref<HTMLTextAreaElement | null>(null)
/** 隐藏文件输入框 DOM 引用 */
const fileInputRef = ref<HTMLInputElement | null>(null)
/** ReplyBox 容器 DOM 引用 */
const replyBoxRef = ref<HTMLElement | null>(null)
/** textarea 高度 */
const textareaHeight = ref(60)
/** 最大字符数 */
const maxChars = 500
/** 是否显示表情面板 */
const showEmojiPicker = ref(false)
/** 邀请弹窗是否可见 */
const showInviteDialog = ref(false)
/** 截图编辑器是否可见 */
const showScreenshotEditor = ref(false)
/** html2canvas 生成的截图 Canvas */
let screenshotCanvas: HTMLCanvasElement | null = null
/** 常用表情列表 */
const commonEmojis = [
'😀','😃','😄','😁','😆','😅','🤣','😂',
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
'😚','😙','😋','😛','😜','🤪','😝','🤑',
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
'😶','😏','😒','🙄','😬','😮','🤯','😲',
'😳','🥺','😢','😭','😤','😠','😡','🤬',
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
'❤️','🧡','💛','💚','💙','💜','💯','✅',
]
/** 当前字符数 */
const charCount = computed(() => inputText.value.length))
/** 是否可以发送 */
const canSend = computed(() => {
return inputText.value.trim().length > 0 && !conversationStore.loading && charCount.value <= maxChars
})
/** 已在参与者列表中的ID */
const existingParticipantIds = computed(() => {
const conv = conversationStore.currentConversation
if (!conv) return []
const ids: string[] = [conv.employee_id]
if (conv.participants) {
ids.push(...conv.participants.map((p: any) => p.id))
}
return ids
})
// ============================================================================
// 监听
// ============================================================================
watch(
() => conversationStore.pendingReplyText,
(newVal) => {
if (newVal) {
inputText.value = newVal
conversationStore.pendingReplyText = ''
nextTick(() => {
inputRef.value?.focus()
})
}
}
)
// ============================================================================
// 输入框高度自适应
// ============================================================================
function handleInput(): void {
nextTick(() => {
if (inputRef.value) {
const scrollHeight = inputRef.value.scrollHeight
const newHeight = Math.min(Math.max(scrollHeight, 60), 150)
textareaHeight.value = newHeight
}
})
}
// ============================================================================
// 键盘事件
// ============================================================================
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
}
// ============================================================================
// 发送消息
// ============================================================================
async function handleSend(): Promise<void> {
const content = inputText.value.trim()
if (!content || conversationStore.loading) return
try {
emit('send', content)
inputText.value = ''
textareaHeight.value = 60
} catch (error) {
console.error('发送消息失败:', error)
}
}
// ============================================================================
// 粘贴事件
// ============================================================================
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> {
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
try {
ElMessage.info('图片上传中...')
const result = await uploadFile(file)
const newMsg = await sendMessage(convId, '[图片]', 'image', {
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
conversationStore.messages.push(newMsg)
ElMessage.success('图片发送成功')
} catch (error: any) {
console.error('图片上传失败:', error)
ElMessage.error(
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'图片上传失败,请重试'
)
}
}
async function handleFileUpload(file: File | Blob): Promise<void> {
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
try {
const fileName = file instanceof File ? file.name : '文件'
ElMessage.info(`正在上传: ${fileName}`)
const result = await uploadFile(file)
const newMsg = await sendMessage(convId, `[文件] ${result.filename}`, 'file', {
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
conversationStore.messages.push(newMsg)
ElMessage.success('文件发送成功')
} catch (error: any) {
console.error('文件发送失败:', error)
ElMessage.error(
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
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
for (const file of Array.from(files)) {
try {
if (file === files[0]) {
ElMessage.info(`正在上传: ${file.name}`)
}
const result = await uploadFile(file)
const isImage = result.msg_type === 'image'
const newMsg = await sendMessage(
convId,
isImage ? '[图片]' : `[文件] ${file.name}`,
isImage ? 'image' : 'file',
{
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
}
)
conversationStore.messages.push(newMsg)
ElMessage.success(`${file.name} 发送成功`)
} catch (error: any) {
console.error('文件上传失败:', error)
ElMessage.error(
`${file.name} 上传失败: ${
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'请重试'
}`
)
}
}
input.value = ''
}
// ============================================================================
// 工具栏
// ============================================================================
function handleToolbarClick(action: string): void {
switch (action) {
case 'image':
if (fileInputRef.value) {
fileInputRef.value.accept = 'image/*'
fileInputRef.value.multiple = true
fileInputRef.value.click()
}
break
case 'file':
if (fileInputRef.value) {
fileInputRef.value.accept = ''
fileInputRef.value.multiple = true
fileInputRef.value.click()
}
break
case 'quickReply':
inputRef.value?.focus()
break
case 'screenshot':
handleScreenshot()
break
default:
const actionMap: Record<string, string> = {
voice: '语音消息功能开发中',
}
ElMessage.info(actionMap[action] || '功能开发中')
}
}
async function handleScreenshot(): Promise<void> {
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
try {
ElMessage.info('正在截取页面...')
const canvas = await html2canvas(document.body, {
useCORS: true,
scale: window.devicePixelRatio || 1,
logging: false,
backgroundColor: '#ffffff',
})
screenshotCanvas = canvas
showScreenshotEditor.value = true
} catch (error) {
console.error('截图失败:', error)
ElMessage.error('截图失败,请重试')
}
}
async function onScreenshotConfirm(blob: Blob): Promise<void> {
const convId = conversationStore.currentConversation?.id
if (!convId) return
try {
ElMessage.info('截图上传中...')
const result = await uploadFile(blob)
const newMsg = await sendMessage(convId, '[截图]', 'image', {
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
conversationStore.messages.push(newMsg)
ElMessage.success('截图发送成功')
} catch (error: any) {
console.error('[InputBox] 截图发送失败:', error)
ElMessage.error(
`截图发送失败:${
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'未知错误'
}`
)
} finally {
showScreenshotEditor.value = false
screenshotCanvas = null
}
}
function onScreenshotCancel(): void {
showScreenshotEditor.value = false
screenshotCanvas = null
}
function onEmojiSelect(emoji: string): void {
inputText.value += emoji
showEmojiPicker.value = false
nextTick(() => {
inputRef.value?.focus()
})
}
function onInviteSuccess(): void {
conversationStore.fetchConversations()
}
</script>
<style scoped>
/* 整体容器 */
.input-box {
padding: 0 12px 8px 12px;
background: var(--bg-primary);
flex-shrink: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* 快捷工具栏 */
.chat-toolbar {
display: flex;
align-items: center;
gap: 1px;
padding: 8px 6px 4px 6px;
flex-shrink: 0;
}
.tb-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
border: none;
background: transparent;
border-radius: var(--radius);
cursor: pointer;
font-size: 14px;
color: var(--text-muted);
transition: all 0.2s;
position: relative;
}
.tb-btn:hover {
background: var(--accent-soft);
color: var(--accent);
}
.tb-tip {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 6px;
background: var(--text-primary);
color: var(--bg-secondary);
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
}
.tb-btn:hover .tb-tip {
display: block;
}
.tb-sep {
width: 1px;
height: 14px;
background: var(--border);
margin: 0 6px;
flex-shrink: 0;
}
/* 输入卡片 */
.chat-input-card {
display: flex;
align-items: flex-end;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: border-color 0.25s, box-shadow 0.25s;
}
.chat-input-card:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
/* 输入框 */
.chat-input {
flex: 1;
background: transparent;
border: none;
padding: 10px 0 10px 14px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
resize: none;
min-height: 60px;
max-height: 150px;
outline: none;
line-height: 1.5;
overflow-y: auto;
}
.chat-input:focus {
outline: none;
}
.chat-input::placeholder {
color: var(--text-muted);
}
/* 发送按钮 */
.btn-send {
width: 36px;
height: 36px;
border: none;
background: var(--bg-tertiary);
border-radius: 50%;
cursor: not-allowed;
display: flex;
align-items: center;
justify-content: center;
margin: 4px 6px 4px 0;
flex-shrink: 0;
transition: all 0.25s;
padding: 0;
}
.btn-send--active {
background: linear-gradient(135deg, var(--accent), var(--purple));
cursor: pointer;
}
.btn-send--active:hover {
opacity: 0.9;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.send-icon {
width: 18px;
height: 18px;
color: var(--text-placeholder);
}
.btn-send--active .send-icon {
color: #fff;
}
/* 字数统计 */
.input-box__counter {
text-align: right;
font-size: 11px;
color: var(--text-placeholder);
margin-top: 4px;
}
/* 表情选择器 */
.emoji-wrapper {
position: relative;
}
.emoji-picker-popup {
position: absolute;
bottom: 36px;
left: 0;
z-index: 100;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
max-height: 240px;
overflow-y: auto;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 32px);
gap: 2px;
}
.emoji-grid__item {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
outline: none;
padding: 0;
}
.emoji-grid__item:hover {
background: var(--accent-soft);
}
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
/* 引用回复预览 */
.reply-preview {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.reply-preview-bar {
width: 3px;
height: 28px;
border-radius: 2px;
background: var(--accent);
flex-shrink: 0;
}
.reply-preview-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.reply-preview-sender {
font-size: 12px;
color: var(--accent);
font-weight: 500;
}
.reply-preview-text {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reply-preview-close {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 4px;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
}
.reply-preview-close:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
</style>
@@ -0,0 +1,80 @@
<!-- =============================================================================
// 企微IT智能服务台 — IT 等级徽标组件
// =============================================================================
// 说明:7 级段位徽标,用于 UserInfoBar 和其他位置
// 等级:bronze / silver / gold / platinum / diamond / star / king
// 样式:CSS 类名在 global.css 中定义
// ============================================================================= -->
<template>
<span
class="it-badge"
:class="[level, `it-badge-${size}`]"
:title="levelInfo.title"
>
{{ levelInfo.icon }}
</span>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
// ============================================================================
// Props
// ============================================================================
interface Props {
/** IT 等级:bronze/silver/gold/platinum/diamond/star/king */
level: string
/** 尺寸变体:sm(12px) / md(14px) / lg(16px) */
size?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
})
// ============================================================================
// 等级元数据
// ============================================================================
/** 等级配置表 */
const LEVEL_MAP: Record<string, { icon: string; name: string; lv: number; title: string }> = {
bronze: { icon: '🛡️', name: '青铜', lv: 1, title: '青铜 Lv.1 — IT基础薄弱,需要详细指导' },
silver: { icon: '🥈', name: '白银', lv: 2, title: '白银 Lv.2 — 能完成基本操作,需协助复杂问题' },
gold: { icon: '🥇', name: '黄金', lv: 3, title: '黄金 Lv.3 — 熟悉常见操作,可独立解决一般问题' },
platinum: { icon: '⭐', name: '铂金', lv: 4, title: '铂金 Lv.4 — 熟练使用办公软件,能自助排查常见故障' },
diamond: { icon: '💎', name: '钻石', lv: 5, title: '钻石 Lv.5 — 具备一定技术能力,能理解技术解释' },
star: { icon: '🌟', name: '星耀', lv: 6, title: '星耀 Lv.6 — IT能力较强,可自行解决大部分问题' },
king: { icon: '👑', name: '王者', lv: 7, title: '王者 Lv.7 — IT达人级别,仅少数问题需协助' },
}
/** 计算等级信息 */
const levelInfo = computed(() => {
return LEVEL_MAP[props.level] || LEVEL_MAP['silver']
})
</script>
<style scoped>
/* 尺寸变体 */
.it-badge-sm {
width: 16px;
height: 16px;
font-size: 9px;
}
.it-badge-md {
width: 18px;
height: 18px;
font-size: 10px;
}
.it-badge-lg {
width: 22px;
height: 22px;
font-size: 12px;
}
</style>
@@ -0,0 +1,485 @@
<!-- =============================================================================
// 企微IT智能服务台 — 消息气泡组件
// =============================================================================
// 说明:单条消息的气泡展示
// 根据 sender_type 区分:
// - employee: 靠左灰底
// - agent: 靠右蓝底白字
// - ai: 靠左绿底+AI标签
// - system: 居中灰字
// 图片消息显示缩略图,文件消息显示文件图标+名称
// 时间戳显示
// 对于 employee 消息,如果存在 AI 草稿,在下方显示草稿气泡
// ============================================================================= -->
<template>
<div class="message-row" :class="`message-row-${message.sender_type}`">
<!-- 系统消息居中灰字 -->
<template v-if="message.sender_type === 'system'">
<div class="message-bubble message-system">
{{ message.content }}
</div>
</template>
<!-- 非系统消息 -->
<template v-else>
<!-- 发送者名称 -->
<div class="message-sender-name">
{{ senderLabel }}
<!-- AI消息带AI标签 -->
<span v-if="message.sender_type === 'ai'" class="ai-tag">AI</span>
</div>
<!-- 消息气泡 -->
<div class="message-bubble" :class="`message-${message.sender_type}`"
@mouseenter="showCopyBtn = true"
@mouseleave="showCopyBtn = false"
>
<!-- 引用回复摘要当此消息回复了某条消息时显示 -->
<div v-if="message.reply_to_id && replyToContent" class="reply-quote" @click.stop="$emit('scrollToMessage', message.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="message.msg_type === 'text'">
<div style="white-space: pre-wrap;">{{ message.content }}</div>
</template>
<!-- 图片消息显示缩略图可点击查看大图 -->
<template v-else-if="message.msg_type === 'image'">
<div class="image-message" @click="previewImage">
<img
v-if="message.media_url || message.extra_data?.pic_url"
:src="message.media_url || message.extra_data?.pic_url"
:alt="message.file_name || '图片'"
class="image-thumbnail"
loading="lazy"
/>
<!-- 无URL时显示占位卡片 -->
<div v-else class="media-card">
<div class="media-icon">🖼</div>
<div class="media-info">
<span class="media-type-label">图片消息</span>
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
</div>
</div>
</div>
</template>
<!-- 文件消息显示文件卡片 -->
<template v-else-if="message.msg_type === 'file'">
<a
v-if="message.media_url"
:href="message.media_url"
target="_blank"
class="media-card media-card-link"
>
<div class="media-icon">📎</div>
<div class="media-info">
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</a>
<!-- 无URL时显示纯卡片 -->
<div v-else class="media-card">
<div class="media-icon">📎</div>
<div class="media-info">
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</div>
</template>
<!-- 其他非文本消息语音/视频/位置等显示通用媒体卡片 -->
<template v-else>
<div class="media-card">
<div class="media-icon">{{ mediaIcon }}</div>
<div class="media-info">
<span class="media-type-label">{{ mediaTypeLabel }}</span>
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</div>
</template>
<!-- 操作按钮组hover 显示复制 + 回复 -->
<div v-if="showCopyBtn" class="bubble-actions">
<button
v-if="message.msg_type === 'text'"
class="action-btn"
title="复制消息"
@click.stop="copyMessage"
>
{{ copySuccess ? '✓' : '📋' }}
</button>
<button
class="action-btn"
title="回复"
@click.stop="$emit('reply', message)"
>
</button>
</div>
</div>
<!-- 时间戳 -->
<div class="message-time">{{ formatMessageTime }}</div>
<!-- AI 草稿气泡仅对员工消息显示 -->
<AiDraftBubble
v-if="message.sender_type === 'employee' && draftForMessage"
:conversation-id="message.conversation_id"
:message-id="message.id"
:content="draftForMessage.content"
:confidence="draftForMessage.confidence"
/>
</template>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed, ref } from 'vue'
import { useClipboard } from '@vueuse/core'
import type { Message } from '@/api/message'
import type { DraftResult } from '@/api/wingman'
import { useConversationStore } from '@/stores/conversation'
import AiDraftBubble from './AiDraftBubble.vue'
// ============================================================================
// Props
// ============================================================================
interface Props {
/** 消息对象 */
message: Message
}
const props = defineProps<Props>()
// 事件(模板中使用 $emit 触发)
defineEmits<{
/** 点击引用回复摘要,滚动到被回复的消息 */
(e: 'scrollToMessage', messageId: string): void
/** 点击回复按钮,回复某条消息 */
(e: 'reply', message: Message): void
}>()
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
// ============================================================================
// 剪贴板相关(消息复制功能)
// ============================================================================
/** useClipboardVueUse 提供的剪贴板操作组合函数 */
const { copy } = useClipboard()
/** 是否显示复制按钮(鼠标悬停时显示) */
const showCopyBtn = ref(false)
/** 复制成功反馈标识(1.5秒后自动消失) */
const copySuccess = ref(false)
/**
* 复制消息内容到剪贴板。
* 使用 VueUse 的 useClipboard 封装,兼容各浏览器。
* 复制成功后显示 ✓ 图标 1.5 秒。
*/
async function copyMessage(): Promise<void> {
try {
await copy(props.message.content)
copySuccess.value = true
setTimeout(() => {
copySuccess.value = false
}, 1500)
} catch (err) {
console.error('复制失败:', err)
}
}
// ============================================================================
// 计算属性
// ============================================================================
/** 发送者标签文字 */
const senderLabel = computed(() => {
const labelMap: Record<string, string> = {
employee: props.message.sender_name || '员工',
agent: props.message.sender_name || '我',
ai: 'AI助手',
}
return labelMap[props.message.sender_type] || '未知'
})
/** 格式化消息时间 */
const formatMessageTime = computed(() => {
if (!props.message.created_at) return ''
const date = new Date(props.message.created_at)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
})
/** 获取当前消息的 AI 草稿数据 */
const draftForMessage = computed<DraftResult | null>(() => {
if (props.message.sender_type !== 'employee') return null
if (!props.message.conversation_id) return null
return conversationStore.getDraft(
props.message.conversation_id,
props.message.id
)
})
// ==================================================================
// 非文本消息相关计算属性
// ==================================================================
/** 消息类型对应的 Emoji 图标 */
const mediaIcon = computed(() => {
const icons: Record<string, string> = {
image: '🖼️',
voice: '🎤',
video: '🎬',
file: '📎',
location: '📍',
}
return icons[props.message.msg_type] || '📄'
})
/** 消息类型对应的中文标签 */
const mediaTypeLabel = computed(() => {
const labels: Record<string, string> = {
image: '图片消息',
voice: '语音消息',
video: '视频消息',
file: '文件消息',
location: '位置消息',
}
return labels[props.message.msg_type] || '媒体消息'
})
/**
* 格式化文件大小为人类可读字符串。
*
* @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.message.media_url || props.message.extra_data?.pic_url
if (url) {
window.open(url, '_blank')
}
}
// ============================================================================
// 引用回复相关计算属性
// ============================================================================
/** 被回复的消息内容摘要(截取前50字) */
const replyToContent = computed(() => {
if (!props.message.reply_to_id) return ''
// 从 store 的消息列表中查找被回复的消息
const repliedMsg = conversationStore.messages.find(
(m: Message) => m.id === props.message.reply_to_id
)
if (!repliedMsg) return '...'
// 截取前50字作为摘要
const text = repliedMsg.content || ''
return text.length > 50 ? text.substring(0, 50) + '...' : text
})
/** 被回复的消息发送者 */
const replyToSender = computed(() => {
if (!props.message.reply_to_id) return ''
const repliedMsg = conversationStore.messages.find(
(m: Message) => m.id === props.message.reply_to_id
)
if (!repliedMsg) return ''
return repliedMsg.sender_name || '未知'
})
</script>
<style scoped>
/* ============================================================================
// 非文本消息媒体卡片样式
// ============================================================================ */
.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: 180px;
max-width: 260px;
}
.media-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
}
.media-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.media-type-label {
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
}
.media-filename {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-size {
font-size: 11px;
color: var(--text-placeholder);
}
/* 操作按钮组 — 悬停在消息气泡上时显示 */
.message-bubble {
position: relative;
}
.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);
}
/* ============================================================================
// 图片消息样式
// ============================================================================ */
.image-message {
cursor: pointer;
border-radius: 8px;
overflow: hidden;
max-width: 280px;
}
.image-thumbnail {
display: block;
max-width: 280px;
max-height: 200px;
object-fit: contain;
border-radius: 6px;
transition: opacity 0.2s;
}
.image-thumbnail:hover {
opacity: 0.9;
}
/* 文件消息卡片链接样式 */
.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);
}
/* ============================================================================
// 引用回复样式
// ============================================================================ */
.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;
}
</style>
@@ -0,0 +1,635 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席端消息气泡组件
// =============================================================================
// 说明:单条消息的气泡展示
// 功能:
// - 长按/右键弹出操作菜单:复制、撤回(2分钟内)、删除
// - 消息状态显示:发送中、已发送、已送达、已读
// - 时间戳显示规则:同日期只显示时间,不同日期显示月日时间
// - 消息类型:文本、图片、文件、语音、系统消息
// ============================================================================= -->
<template>
<!-- 系统消息居中灰色文字 -->
<div v-if="message.sender_type === 'system'" class="message-item message-item--system">
<span class="message-item__system-text">{{ message.content }}</span>
</div>
<!-- 非系统消息 -->
<div
v-else
class="message-row"
:class="`message-row-${message.sender_type}`"
@contextmenu.prevent="showContextMenu"
>
<!-- 发送者名称 -->
<div v-if="message.sender_type !== 'agent'" class="message-sender-name">
{{ senderLabel }}
<span v-if="message.sender_type === 'ai'" class="ai-tag">AI</span>
</div>
<!-- 消息气泡 -->
<div class="message-bubble" :class="`message-${message.sender_type}`">
<!-- 引用回复摘要 -->
<div v-if="message.reply_to_id && replyToContent" class="reply-quote" @click.stop="$emit('scrollToMessage', message.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="message.msg_type === 'text'">
<div style="white-space: pre-wrap;">{{ message.content }}</div>
</template>
<!-- 图片消息 -->
<template v-else-if="message.msg_type === 'image'">
<div class="image-message" @click="previewImage">
<img
v-if="message.media_url || message.extra_data?.pic_url"
:src="message.media_url || message.extra_data?.pic_url"
:alt="message.file_name || '图片'"
class="image-thumbnail"
loading="lazy"
/>
<div v-else class="media-card">
<div class="media-icon">🖼</div>
<div class="media-info">
<span class="media-type-label">图片消息</span>
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
</div>
</div>
</div>
</template>
<!-- 文件消息 -->
<template v-else-if="message.msg_type === 'file'">
<a
v-if="message.media_url"
:href="message.media_url"
target="_blank"
class="media-card media-card-link"
>
<div class="media-icon">📎</div>
<div class="media-info">
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</a>
<div v-else class="media-card">
<div class="media-icon">📎</div>
<div class="media-info">
<span class="media-type-label">{{ message.file_name || '文件消息' }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</div>
</template>
<!-- 其他消息 -->
<template v-else>
<div class="media-card">
<div class="media-icon">{{ mediaIcon }}</div>
<div class="media-info">
<span class="media-type-label">{{ mediaTypeLabel }}</span>
<span v-if="message.file_name" class="media-filename">{{ message.file_name }}</span>
<span v-if="message.file_size" class="media-size">{{ formatFileSize(message.file_size) }}</span>
</div>
</div>
</template>
<!-- 操作按钮hover 显示 -->
<div v-if="showActions" class="bubble-actions">
<button
v-if="message.msg_type === 'text'"
class="action-btn"
title="复制消息"
@click.stop="copyMessage"
>
{{ copySuccess ? '✓' : '📋' }}
</button>
<button
class="action-btn"
title="回复"
@click.stop="$emit('reply', message)"
>
</button>
</div>
<!-- 消息状态 -->
<div v-if="showStatus" class="message-status">
{{ statusText }}
</div>
</div>
<!-- 时间戳 -->
<div class="message-time">{{ formatMessageTime }}</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="message.sender_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 消息气泡组件
* 长按/右键弹出操作菜单
* 消息状态显示
* 时间戳显示规则
*/
import { computed, ref } from 'vue'
import { useClipboard } from '@vueuse/core'
import { ElMessage } from 'element-plus'
import type { Message } from '@/api/message'
import { useConversationStore } from '@/stores/conversation'
const props = defineProps<{
message: Message
}>()
const emit = defineEmits<{
(e: 'scrollToMessage', messageId: string): void
(e: 'reply', message: Message): void
(e: 'recall', messageId: string): void
(e: 'delete', messageId: string): void
}>()
const conversationStore = useConversationStore()
const { copy } = useClipboard()
/** 是否显示操作按钮 */
const showActions = ref(false)
/** 操作菜单位置 */
const contextMenuVisible = ref(false)
const contextMenuStyle = ref<Record<string, string>>({})
/** 复制成功反馈 */
const copySuccess = ref(false)
// ============================================================================
// 计算属性
// ============================================================================
/** 发送者标签 */
const senderLabel = computed(() => {
const labelMap: Record<string, string> = {
employee: props.message.sender_name || '员工',
agent: props.message.sender_name || '我',
ai: 'AI助手',
}
return labelMap[props.message.sender_type] || '未知'
})
/** 格式化时间 */
const formatMessageTime = computed(() => {
if (!props.message.created_at) return ''
const date = new Date(props.message.created_at)
const now = new Date()
const isSameDay = date.toDateString() === now.toDateString()
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
if (isSameDay) {
return `${hours}:${minutes}`
} else {
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
})
/** 是否显示状态 */
const showStatus = computed(() => {
return props.message.sender_type === 'agent' && props.message.status
})
/** 状态文本 */
const statusText = computed(() => {
const statusMap: Record<string, string> = {
sending: '发送中',
sent: '已发送',
delivered: '已送达',
read: '已读',
}
return statusMap[props.message.status || ''] || ''
})
/** 是否可以撤回 */
const canRecall = computed(() => {
if (props.message.sender_type !== 'agent') return false
if (!props.message.created_at) return false
const createdAt = new Date(props.message.created_at)
const now = new Date()
const diffMs = now.getTime() - createdAt.getTime()
const diffMinutes = diffMs / (1000 * 60)
return diffMinutes <= 2
})
/** 媒体图标 */
const mediaIcon = computed(() => {
const icons: Record<string, string> = {
image: '🖼️',
voice: '🎤',
video: '🎬',
file: '📎',
location: '📍',
}
return icons[props.message.msg_type] || '📄'
})
/** 媒体类型标签 */
const mediaTypeLabel = computed(() => {
const labels: Record<string, string> = {
image: '图片消息',
voice: '语音消息',
video: '视频消息',
file: '文件消息',
location: '位置消息',
}
return labels[props.message.msg_type] || '媒体消息'
})
/** 引用回复内容 */
const replyToContent = computed(() => {
if (!props.message.reply_to_id) return ''
const repliedMsg = conversationStore.messages.find(
(m: Message) => m.id === props.message.reply_to_id
)
if (!repliedMsg) return '...'
const text = repliedMsg.content || ''
return text.length > 50 ? text.substring(0, 50) + '...' : text
})
/** 引用回复发送者 */
const replyToSender = computed(() => {
if (!props.message.reply_to_id) return ''
const repliedMsg = conversationStore.messages.find(
(m: Message) => m.id === props.message.reply_to_id
)
if (!repliedMsg) return ''
return repliedMsg.sender_name || '未知'
})
// ============================================================================
// 操作菜单
// ============================================================================
function showContextMenu(event: MouseEvent): void {
contextMenuStyle.value = {
left: `${event.clientX}px`,
top: `${event.clientY}px`,
}
contextMenuVisible.value = true
}
function closeContextMenu(): void {
contextMenuVisible.value = false
}
// ============================================================================
// 消息操作
// ============================================================================
async function copyMessage(): Promise<void> {
try {
await copy(props.message.content)
copySuccess.value = true
ElMessage.success('已复制')
closeContextMenu()
setTimeout(() => {
copySuccess.value = false
}, 1500)
} catch (err) {
console.error('复制失败:', err)
}
}
function recallMessage(): void {
emit('recall', props.message.id)
closeContextMenu()
}
function deleteMessage(): void {
emit('delete', props.message.id)
closeContextMenu()
}
// ============================================================================
// 工具方法
// ============================================================================
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.message.media_url || props.message.extra_data?.pic_url
if (url) {
window.open(url, '_blank')
}
}
</script>
<style scoped>
/* ============================================================================
// 消息行
// ============================================================================ */
.message-row {
display: flex;
flex-direction: column;
padding: 4px 16px;
max-width: 100%;
position: relative;
}
.message-row-employee {
align-items: flex-start;
}
.message-row-agent {
align-items: flex-end;
}
.message-row-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-sender-name {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 3px;
}
.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 {
max-width: 75%;
padding: 10px 14px;
border-radius: 12px;
word-break: break-word;
line-height: 1.5;
position: relative;
}
.message-employee {
background-color: var(--color-agent-bg);
border: 1px solid var(--color-agent-border);
}
.message-agent {
background-color: var(--color-employee-bg);
border-top-right-radius: 4px;
}
.message-ai {
background-color: var(--color-ai-bg);
border-top-left-radius: 4px;
}
/* 消息时间 */
.message-time {
font-size: 10px;
color: var(--text-placeholder);
margin-top: 3px;
}
/* 引用回复 */
.reply-quote {
display: flex;
align-items: stretch;
gap: 6px;
padding: 6px 8px;
margin-bottom: 6px;
background: var(--bg-tertiary);
border-radius: 4px;
cursor: pointer;
}
.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;
}
.reply-quote-sender {
color: var(--accent);
font-weight: 500;
margin-right: 4px;
}
/* 图片消息 */
.image-message {
cursor: pointer;
border-radius: 8px;
overflow: hidden;
max-width: 280px;
}
.image-thumbnail {
display: block;
max-width: 280px;
max-height: 200px;
object-fit: contain;
border-radius: 6px;
}
/* 媒体卡片 */
.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: 180px;
max-width: 260px;
}
.media-icon {
font-size: 24px;
line-height: 1;
flex-shrink: 0;
}
.media-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.media-type-label {
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
}
.media-filename {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-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);
}
/* 操作按钮 */
.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);
}
/* 消息状态 */
.message-status {
position: absolute;
bottom: 4px;
right: 8px;
font-size: 10px;
color: var(--text-placeholder);
}
/* 操作菜单 */
.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;
}
</style>
@@ -0,0 +1,983 @@
<!-- =============================================================================
// 企微IT智能服务台 — 回复输入框组件(v5.4 圆角卡片+工具栏+拖拽调整)
// =============================================================================
// 说明:坐席回复消息的输入区域
// 功能:
// 1. 上方拖拽手柄调整输入区高度(textarea 同步伸缩)
// 2. 快捷工具栏(表情/图片/截图/文件/语音/远程协助/快速回复)
// 3. 输入框+发送按钮合为圆角卡片,渐变蓝紫发送按钮
// 4. Enter 发送,Shift+Enter 换行
// 5. 支持粘贴图片/拖拽文件上传(预留接口)
// ============================================================================= -->
<template>
<div class="reply-box" ref="replyBoxRef">
<!-- 上方拖拽手柄调整输入区高度 -->
<div
class="input-resize-handle"
:class="{ dragging: isResizing }"
@mousedown="startResize"
title="拖拽调整输入框高度"
></div>
<!-- 引用回复预览回复某条消息时显示 -->
<div v-if="replyToMessage" class="reply-preview">
<div class="reply-preview-bar"></div>
<div class="reply-preview-content">
<span class="reply-preview-sender">{{ replyToMessage.sender_name || '未知' }}</span>
<span class="reply-preview-text">{{ replyToMessage.content?.substring(0, 60) }}{{ replyToMessage.content?.length > 60 ? '...' : '' }}</span>
</div>
<button class="reply-preview-close" title="取消回复" @click="$emit('cancelReply')"></button>
</div>
<!-- 快捷工具栏 -->
<div class="chat-toolbar">
<div class="emoji-wrapper">
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
😊
<span class="tb-tip">表情</span>
</button>
<!-- 自定义中文表情选择面板8x8 网格64个常用表情 -->
<div v-if="showEmojiPicker" class="emoji-picker-popup">
<div class="emoji-grid">
<button
v-for="emoji in commonEmojis"
:key="emoji"
class="emoji-grid__item"
@click="onEmojiSelect(emoji)"
>{{ emoji }}</button>
</div>
</div>
<!-- 点击表情面板外部关闭 -->
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
</div>
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
🖼
<span class="tb-tip">图片</span>
</button>
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
<span class="tb-tip">截图</span>
</button>
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
📎
<span class="tb-tip">文件</span>
</button>
<button class="tb-btn" title="语音" @click="handleToolbarClick('voice')">
🎤
<span class="tb-tip">语音</span>
</button>
<div class="tb-sep"></div>
<button class="tb-btn" title="邀请员工/部门" @click="showInviteDialog = true">
👥
<span class="tb-tip">邀请</span>
</button>
<button class="tb-btn" title="远程协助" @click="handleToolbarClick('remote')">
🖥
<span class="tb-tip">远程协助</span>
</button>
<button class="tb-btn" title="快速回复" @click="handleToolbarClick('quickReply')">
<span class="tb-tip">快速回复</span>
</button>
</div>
<!-- 输入行圆角卡片textarea + 发送按钮 -->
<div class="chat-input-card">
<textarea
ref="inputRef"
v-model="inputText"
class="chat-input"
placeholder="输入回复内容... (Enter发送,Shift+Enter换行)"
:style="{ height: textareaHeight + 'px' }"
@keydown="handleKeydown"
@paste="handlePaste"
></textarea>
<button
class="btn-send"
:disabled="!inputText.trim()"
@click="handleSend"
>
</button>
</div>
<!-- 邀请员工/部门弹窗 -->
<InviteParticipantDialog
v-model="showInviteDialog"
:conversation-id="conversationStore.currentConversation?.id || ''"
:existing-participant-ids="existingParticipantIds"
@success="onInviteSuccess"
/>
<!-- 隐藏的文件输入框图片/文件上传用由工具栏按钮触发 -->
<input
ref="fileInputRef"
type="file"
style="display: none"
@change="handleFileSelect"
/>
<!-- 截图区域选择编辑器对标微信/企微截图体验 -->
<ScreenshotEditor
v-if="showScreenshotEditor"
:screenshot-canvas="screenshotCanvas"
@confirm="onScreenshotConfirm"
@cancel="onScreenshotCancel"
/>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, watch, nextTick, onUnmounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import html2canvas from 'html2canvas-pro'
import { useConversationStore } from '@/stores/conversation'
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
import ScreenshotEditor from './ScreenshotEditor.vue'
import { uploadFile } from '@/api/upload'
import { sendMessage } from '@/api/message'
import type { Message } from '@/api/message'
import { useWebSocket } from '@/composables/useWebSocket'
// ============================================================================
// 工具函数:安全提取错误详情(防止 [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)
}
// ============================================================================
// Props & Emits
// ============================================================================
interface Props {
/** 引用回复:正在回复的消息(null 表示普通发送) */
replyToMessage?: Message | null
}
interface Emits {
/** 发送消息事件 */
(e: 'send', content: string): void
/** 取消引用回复 */
(e: 'cancelReply'): void
}
withDefaults(defineProps<Props>(), {
replyToMessage: null,
})
const emit = defineEmits<Emits>()
// ============================================================================
// 状态
// ============================================================================
/** 输入框文本 */
const inputText = ref('')
/** 输入框 DOM 引用 */
const inputRef = ref<HTMLTextAreaElement | null>(null)
/** 隐藏文件输入框 DOM 引用(用于触发系统文件选择器) */
const fileInputRef = ref<HTMLInputElement | null>(null)
/** ReplyBox 容器 DOM 引用 */
const replyBoxRef = ref<HTMLElement | null>(null)
/** 会话 Store */
const conversationStore = useConversationStore()
/** WebSocket 组合函数(用于发送 typing 事件) */
const { sendTyping } = useWebSocket()
/** textarea 高度(px),默认3行约60px */
const textareaHeight = ref(60)
/** 是否正在拖拽调整高度 */
const isResizing = ref(false)
/** 邀请弹窗是否可见 */
const showInviteDialog = ref(false)
/** 截图编辑器是否可见 */
const showScreenshotEditor = ref(false)
/** html2canvas 生成的完整页面截图 Canvas 对象(传给 ScreenshotEditor */
let screenshotCanvas: HTMLCanvasElement | null = null
const showEmojiPicker = ref(false)
/**
* 常用表情列表(8行x8列 = 64 个常用表情)
* 覆盖日常沟通场景,与 H5 端保持一致
*/
const commonEmojis = [
'😀','😃','😄','😁','😆','😅','🤣','😂',
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
'😚','😙','😋','😛','😜','🤪','😝','🤑',
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
'😶','😏','😒','🙄','😬','😮','🤯','😲',
'😳','🥺','😢','😭','😤','😠','😡','🤬',
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
'❤️','🧡','💛','💚','💙','💜','💯','✅',
]
/** 已在参与者列表中的ID(排除已有员工和参与者) */
const existingParticipantIds = computed(() => {
const conv = conversationStore.currentConversation
if (!conv) return []
const ids: string[] = [conv.employee_id] // 排除发起咨询的员工自己
if (conv.participants) {
ids.push(...conv.participants.map((p: any) => p.id))
}
return ids
})
/** 邀请成功回调 */
function onInviteSuccess(): void {
// 刷新会话列表(重新拉取最新数据)
conversationStore.fetchConversations()
}
/** 表情选择回调 — 将选中的表情插入到输入框 */
function onEmojiSelect(emoji: string): void {
inputText.value += emoji
showEmojiPicker.value = false
nextTick(() => {
inputRef.value?.focus()
})
}
/** 拖拽相关临时变量 */
let resizeStartY = 0
let resizeStartHeight = 0
let resizeStartTextareaHeight = 0
// ============================================================================
// 监听
// ============================================================================
/**
* 监听 pendingReplyText 变化
* 当快速回复模板设置待填充文本时,自动填充到输入框
*/
watch(
() => conversationStore.pendingReplyText,
(newVal) => {
if (newVal) {
inputText.value = newVal
conversationStore.pendingReplyText = ''
nextTick(() => {
inputRef.value?.focus()
})
}
}
)
/**
* 监听输入框文本变化,发送 typing 事件
* 当用户输入文字时,通知 WebSocket 让其他人看到"正在输入..."
* 节流:3 秒内最多发送一次(在 sendTyping 内部控制)
*/
watch(inputText, () => {
const convId = conversationStore.currentConversation?.id
if (convId && inputText.value.trim()) {
sendTyping(convId)
}
})
// ============================================================================
// 拖拽调整输入区高度
// ============================================================================
/** 开始拖拽 */
function startResize(e: MouseEvent): void {
e.preventDefault()
isResizing.value = true
resizeStartY = e.clientY
resizeStartHeight = replyBoxRef.value?.offsetHeight || 80
resizeStartTextareaHeight = textareaHeight.value
document.addEventListener('mousemove', onResizeMove)
document.addEventListener('mouseup', onResizeEnd)
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
}
/** 拖拽中 */
function onResizeMove(e: MouseEvent): void {
if (!isResizing.value) return
// 向上拖 → 输入区变高(delta为负),向下拖 → 变矮
const dy = resizeStartY - e.clientY
const newBoxH = Math.max(80, Math.min(400, resizeStartHeight + dy))
const delta = newBoxH - resizeStartHeight
const newTaH = Math.max(60, Math.min(300, resizeStartTextareaHeight + delta))
textareaHeight.value = newTaH
}
/** 结束拖拽 */
function onResizeEnd(): void {
isResizing.value = false
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
// ============================================================================
// 方法
// ============================================================================
/**
* 处理键盘事件
* Enter 发送消息,Shift+Enter 换行
*/
function handleKeydown(event: KeyboardEvent): void {
// Enter 且没有按 Shift → 发送
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSend()
}
// Shift+Enter → 默认换行,不处理
}
/**
* 发送消息
*/
async function handleSend(): Promise<void> {
const content = inputText.value.trim()
if (!content) return
try {
emit('send', content)
inputText.value = ''
// 恢复 textarea 高度(3行默认)
textareaHeight.value = 60
} catch (error) {
console.error('发送消息失败:', error)
}
}
/**
* 处理粘贴事件
* 做什么:检测剪贴板中的图片或文件,自动上传并发送消息
* 为什么:用户需要从剪贴板直接粘贴图片/文件,不用每次点按钮
*
* 支持:
* 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)) {
// 情况1:剪贴板包含文件(图片/任意文件)
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 // 每次粘贴只处理第一个文件
}
// 情况2:剪贴板包含图片类型数据(如截图工具复制的PNG)
if (item.type.startsWith('image/') && item.kind === 'string') {
// 某些浏览器将截图放在 item.getAsFile() 中,上面 'file' 分支已覆盖
// 这里仅作防御性检查
event.preventDefault()
const file = item.getAsFile()
if (file) {
await handleImageUpload(file)
return
}
}
}
// 纯文本:不拦截,浏览器默认行为(插入文本到输入框)
}
/**
* 上传图片并发送图片消息
*
* @param file - 图片文件对象(File 或 Blob
*/
async function handleImageUpload(file: File | Blob): Promise<void> {
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
try {
ElMessage.info('图片上传中...')
// 1. 上传图片到服务器
const result = await uploadFile(file)
// 2. 发送图片消息
const newMsg = await sendMessage(convId, '[图片]', 'image', {
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
// 3. 把新消息加入 store,让界面立即刷新显示
conversationStore.messages.push(newMsg)
ElMessage.success('图片发送成功')
} catch (error: any) {
console.error('图片上传失败:', error)
ElMessage.error(
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'图片上传失败,请重试'
)
}
}
/**
* 上传文件并发送文件消息
* 做什么:处理非图片文件的上传和发送
* 为什么:粘贴功能需要支持任意文件类型,不仅限于图片
*
* @param file - 文件对象(File 或 Blob
*/
async function handleFileUpload(file: File | Blob): Promise<void> {
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
try {
const fileName = file instanceof File ? file.name : '文件'
ElMessage.info(`正在上传: ${fileName}`)
// 1. 上传文件到服务器
const result = await uploadFile(file)
// 2. 发送文件消息
const newMsg = await sendMessage(convId, `[文件] ${result.filename}`, 'file', {
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
// 3. 把新消息加入 store,让界面立即刷新显示
conversationStore.messages.push(newMsg)
ElMessage.success('文件发送成功')
} catch (error: any) {
console.error('文件发送失败:', error)
ElMessage.error(
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
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
for (const file of Array.from(files)) {
try {
// 显示上传中提示(仅第一个文件显示)
if (file === files[0]) {
ElMessage.info(`正在上传: ${file.name}`)
}
// 1. 上传文件到服务器
const result = await uploadFile(file)
// 2. 发送文件消息
const isImage = result.msg_type === 'image'
const newMsg = await sendMessage(
convId,
isImage ? '[图片]' : `[文件] ${file.name}`,
isImage ? 'image' : 'file',
{
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
}
)
// 3. 把新消息加入 store,让界面立即刷新显示
conversationStore.messages.push(newMsg)
ElMessage.success(`${file.name} 发送成功`)
} catch (error: any) {
console.error('文件上传失败:', error)
ElMessage.error(
`${file.name} 上传失败: ${
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'请重试'
}`
)
}
}
// 重置 input,允许重复选择同一文件
input.value = ''
}
/**
* 快捷工具栏按钮点击处理
* 各功能对应的实际行为
*/
function handleToolbarClick(action: string): void {
switch (action) {
case 'image':
// 图片上传:触发文件选择器(限定图片类型)
if (fileInputRef.value) {
fileInputRef.value.accept = 'image/*'
fileInputRef.value.multiple = true
fileInputRef.value.click()
}
break
case 'file':
// 文件上传:触发文件选择器(不限类型)
if (fileInputRef.value) {
fileInputRef.value.accept = ''
fileInputRef.value.multiple = true
fileInputRef.value.click()
}
break
case 'quickReply':
// 快速回复按钮:聚焦输入框(快速回复面板已在右栏可用)
inputRef.value?.focus()
break
case 'screenshot':
// 截图功能:截取页面后进入区域选择模式(对标微信/企微)
handleScreenshot()
break
default:
// 其他功能暂未实现
const actionMap: Record<string, string> = {
voice: '语音消息功能开发中',
remote: '远程协助功能开发中',
}
ElMessage.info(actionMap[action] || '功能开发中')
}
}
/**
* 处理截图功能
*
* 做什么:使用 html2canvas 截取当前页面内容,弹出预览,确认后上传发送
*
* 为什么用 html2canvas
* - Screen Capture APIgetDisplayMedia)在企微桌面端被限制,不稳定
* - html2canvas 纯前端渲染,不依赖任何浏览器 API,所有环境都可用
* - 适合客服场景:坐席想截取当前对话内容发给用户
*
* 交互流程:点击截图按钮 → 截取页面 → 弹出预览对话框 → 确认发送 / 取消
*/
async function handleScreenshot(): Promise<void> {
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
try {
ElMessage.info('正在截取页面...')
// 1. 截取整个页面
const canvas = await html2canvas(document.body, {
useCORS: true,
scale: window.devicePixelRatio || 1,
logging: false,
backgroundColor: '#ffffff',
})
// 2. 保存 canvas 并显示截图编辑器(对标微信/企微)
screenshotCanvas = canvas
showScreenshotEditor.value = true
} catch (error) {
console.error('截图失败:', error)
ElMessage.error('截图失败,请重试')
}
}
/**
* 确认发送截图
* 上传截图并发送图片消息
*/
async function onScreenshotConfirm(blob: Blob): Promise<void> {
const convId = conversationStore.currentConversation?.id
if (!convId) return
try {
ElMessage.info('截图上传中...')
const result = await uploadFile(blob)
const newMsg = await sendMessage(convId, '[截图]', 'image', {
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
// 把新消息加入 store,让界面立即刷新显示
conversationStore.messages.push(newMsg)
ElMessage.success('截图发送成功')
} catch (error: any) {
console.error('[ReplyBox] 截图发送失败:', error)
const errMsg =
formatErrorDetail(error?.response?.data?.detail) ||
error?.message ||
'未知错误'
ElMessage.error(`截图发送失败:${errMsg}`)
} finally {
showScreenshotEditor.value = false
screenshotCanvas = null
}
}
/**
* 截图编辑器取消回调
*/
function onScreenshotCancel(): void {
showScreenshotEditor.value = false
screenshotCanvas = null
}
// 组件卸载时清理拖拽事件
onUnmounted(() => {
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeEnd)
})
</script>
<style scoped>
/* 整体容器 */
.reply-box {
padding: 0 12px 8px 12px;
background: var(--bg-primary);
flex-shrink: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* 上方拖拽手柄 */
.input-resize-handle {
height: 4px;
cursor: row-resize;
background: var(--border);
transition: background 0.2s;
margin: 0 -12px 0 -12px;
flex-shrink: 0;
}
.input-resize-handle:hover,
.input-resize-handle.dragging {
background: var(--accent);
}
/* 快捷工具栏 */
.chat-toolbar {
display: flex;
align-items: center;
gap: 1px;
padding: 8px 6px 4px 6px;
flex-shrink: 0;
}
.tb-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
border: none;
background: transparent;
border-radius: var(--radius);
cursor: pointer;
font-size: 14px;
color: var(--text-muted);
transition: all 0.2s;
position: relative;
}
.tb-btn:hover {
background: var(--accent-soft);
color: var(--accent);
}
.tb-btn:active {
transform: scale(0.92);
}
/* 工具提示气泡 */
.tb-tip {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 6px;
background: var(--text-primary);
color: var(--bg-secondary);
font-size: 10px;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
box-shadow: var(--shadow);
}
.tb-tip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: var(--text-primary);
}
.tb-btn:hover .tb-tip {
display: block;
}
/* 工具栏分隔线 */
.tb-sep {
width: 1px;
height: 14px;
background: var(--border);
margin: 0 6px;
flex-shrink: 0;
}
/* 输入卡片(textarea + 发送按钮,统一圆角卡片) */
.chat-input-card {
display: flex;
align-items: flex-end;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: border-color 0.25s, box-shadow 0.25s;
box-shadow: var(--shadow-sm);
}
.chat-input-card:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
/* 输入框 — 默认3行(60px),最大300px,自适应内容高度 */
.chat-input {
flex: 1;
background: transparent;
border: none;
padding: 10px 0 10px 14px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
resize: none;
min-height: 60px;
max-height: 300px;
outline: none;
line-height: 1.5;
overflow-y: auto;
}
.chat-input:focus {
outline: none;
}
.chat-input::placeholder {
color: var(--text-muted);
}
/* 发送按钮 — 内嵌于卡片右侧,渐变蓝紫 */
.btn-send {
padding: 8px 18px;
margin: 4px 6px 4px 0;
background: linear-gradient(135deg, var(--accent), var(--purple));
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s;
flex-shrink: 0;
letter-spacing: 0.5px;
}
.btn-send:hover:not(:disabled) {
opacity: 0.9;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.btn-send:active:not(:disabled) {
transform: scale(0.95);
}
.btn-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 表情选择器弹出层(自定义中文表情网格) */
.emoji-wrapper {
position: relative;
}
.emoji-picker-popup {
position: absolute;
bottom: 36px;
left: 0;
z-index: 100;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
max-height: 240px;
overflow-y: auto;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
/* 8x8 表情网格 */
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 32px);
gap: 2px;
}
.emoji-grid__item {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
outline: none;
padding: 0;
}
.emoji-grid__item:hover {
background: var(--accent-soft);
}
.emoji-grid__item:active {
transform: scale(1.2);
}
/* 表情面板遮罩(点击外部关闭) */
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
/* ============================================================================
// 引用回复预览样式
// ============================================================================ */
.reply-preview {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.reply-preview-bar {
width: 3px;
height: 28px;
border-radius: 2px;
background: var(--accent);
flex-shrink: 0;
}
.reply-preview-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.reply-preview-sender {
font-size: 12px;
color: var(--accent);
font-weight: 500;
}
.reply-preview-text {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reply-preview-close {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 4px;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
}
.reply-preview-close:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
</style>
@@ -0,0 +1,556 @@
<template>
<!--
ScreenshotEditor - 区域选择截图组件
对标微信/企微截图体验
1. 全屏遮罩背景显示页面截图变暗
2. 鼠标变成十字拖拽框选区域
3. 释放后显示选区和工具栏确认/取消/重新选择
4. 确认后裁剪选中区域并 emit 回去
-->
<div class="screenshot-editor-overlay">
<!-- 背景页面截图变暗 -->
<canvas
ref="canvasBgRef"
class="screenshot-editor-bg"
></canvas>
<!-- 选区绘制层跟随鼠标拖拽 -->
<div
class="screenshot-selection-layer"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<!-- 暗色遮罩挖空选区 -->
<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>
<!-- 8个拖拽手柄调整后发送 -->
<div
v-if="selectionComplete"
class="screenshot-handle screenshot-handle--tl"
@mousedown.stop="onHandleMouseDown($event, 'tl')"
></div>
<div
v-if="selectionComplete"
class="screenshot-handle screenshot-handle--tr"
@mousedown.stop="onHandleMouseDown($event, 'tr')"
></div>
<div
v-if="selectionComplete"
class="screenshot-handle screenshot-handle--bl"
@mousedown.stop="onHandleMouseDown($event, 'bl')"
></div>
<div
v-if="selectionComplete"
class="screenshot-handle screenshot-handle--br"
@mousedown.stop="onHandleMouseDown($event, 'br')"
></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">
按住鼠标拖拽选择截图区域ESC 取消
</div>
</div>
</template>
<script setup lang="ts">
/**
* ScreenshotEditor 组件
* 做什么:实现微信/企微风格的区域截图功能
* 为什么:用户反馈截图不好用,需要对标微信/企微的截图体验
*
* 交互流程:
* 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 { ElMessage } from 'element-plus'
// ========== 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 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
// 调整手柄状态
let resizingHandle = '' // 正在拖拽的手柄:tl/tr/bl/br
let resizeStartX = 0
let resizeStartY = 0
let resizeStartStartX = 0
let resizeStartStartY = 0
let resizeStartEndX = 0
let resizeStartEndY = 0
// ========== 计算属性 ==========
/** 选区左坐标(取 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 {} // 未选区时全暗
}
// 使用 box-shadow 实现挖空效果(外围暗色,选区明亮)
const left = selectionLeft.value
const top = selectionTop.value
const width = selectionWidth.value
const height = selectionHeight.value
return {
boxShadow: `0 0 0 9999px rgba(0, 0, 0, 0.5)`,
// 选区位置用透明
background: selecting.value
? 'rgba(0, 0, 0, 0.5)'
: 'transparent',
// 用 clip-path 挖空选区
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()
})
// 监听 ESC 取消
document.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
})
// 监听 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 onMouseDown(e: MouseEvent): void {
// 如果选区已完成,不重新开始(除非点在了选区外)
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
}
}
/** 调整手柄:鼠标按下 */
function onHandleMouseDown(e: MouseEvent, handle: string): void {
resizingHandle = handle
resizeStartX = e.clientX
resizeStartY = e.clientY
resizeStartStartX = startX.value
resizeStartStartY = startY.value
resizeStartEndX = endX.value
resizeStartEndY = endY.value
document.addEventListener('mousemove', onHandleMouseMove)
document.addEventListener('mouseup', onHandleMouseUp)
}
/** 调整手柄:鼠标移动 */
function onHandleMouseMove(e: MouseEvent): void {
const dx = e.clientX - resizeStartX
const dy = e.clientY - resizeStartY
switch (resizingHandle) {
case 'tl':
startX.value = resizeStartStartX + dx
startY.value = resizeStartStartY + dy
break
case 'tr':
endX.value = resizeStartEndX + dx
startY.value = resizeStartStartY + dy
break
case 'bl':
startX.value = resizeStartStartX + dx
endY.value = resizeStartEndY + dy
break
case 'br':
endX.value = resizeStartEndX + dx
endY.value = resizeStartEndY + dy
break
}
}
/** 调整手柄:鼠标释放 */
function onHandleMouseUp(): void {
resizingHandle = ''
document.removeEventListener('mousemove', onHandleMouseMove)
document.removeEventListener('mouseup', onHandleMouseUp)
}
/** 重置选区 */
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) {
ElMessage.error('截图数据丢失,请重试')
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) {
ElMessage.error('截图裁剪失败(无法创建画布)')
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) {
ElMessage.error('截图生成失败(裁剪结果为空),请重试')
return
}
emit('confirm', blob)
resetSelection()
} catch (err) {
console.error('[ScreenshotEditor] 确认截图失败:', err)
ElMessage.error('截图确认失败,请重试')
}
}
/** 暴露方法给父组件 */
defineExpose({
resetSelection,
})
</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;
}
/* 暗色遮罩(未选区时全暗,选区后挖空)
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-handle {
position: absolute;
width: 10px;
height: 10px;
background: #1989fa;
border: 2px solid #fff;
border-radius: 50%;
z-index: 30;
}
.screenshot-handle--tl { top: -5px; left: -5px; cursor: nw-resize; }
.screenshot-handle--tr { top: -5px; right: -5px; cursor: ne-resize; }
.screenshot-handle--bl { bottom: -5px; left: -5px; cursor: sw-resize; }
.screenshot-handle--br { bottom: -5px; right: -5px; cursor: se-resize; }
/* 工具栏 */
.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,258 @@
<!-- =============================================================================
// 企微IT智能服务台 — 任务详情视图组件
// =============================================================================
// 说明:点击左侧待办事项时,中间栏从聊天视图切换为任务类型专属页面
// 功能:根据 todoItem.type 渲染三种子视图:
// 1. 📋 运维工单页 (ticket)
// 2. 📝 审批单页 (approval)
// 3. 🖥 设备异常页 (device)
// ============================================================================= -->
<template>
<div class="task-detail-view">
<!-- ================================================================== -->
<!-- 顶部返回按钮 + 任务标题 + 类型标签 -->
<!-- ================================================================== -->
<div class="tdv-header">
<button class="tdv-back-btn" @click="handleGoBack">
<span class="tdv-back-arrow"></span>
<span>返回会话</span>
</button>
<div class="tdv-header-center">
<h2 class="tdv-title">{{ todoItem.title }}</h2>
<span class="tdv-type-tag" :class="`tdv-type-${todoItem.type}`">
{{ typeLabelMap[todoItem.type] || todoItem.type }}
</span>
</div>
<div class="tdv-header-right">
<span class="tdv-priority" :class="`tdv-priority-${todoItem.priority}`">
{{ priorityLabelMap[todoItem.priority] || todoItem.priority }}
</span>
</div>
</div>
<!-- ================================================================== -->
<!-- 内容区根据类型渲染不同子视图 -->
<!-- ================================================================== -->
<div class="tdv-content">
<!-- 📋 运维工单页 -->
<TicketDetail
v-if="todoItem.type === 'ticket'"
:todo-item="todoItem"
@action="handleAction"
/>
<!-- 📝 审批单页 -->
<ApprovalDetail
v-else-if="todoItem.type === 'approval'"
:todo-item="todoItem"
@action="handleAction"
/>
<!-- 🖥 设备异常页 -->
<DeviceDetail
v-else-if="todoItem.type === 'device'"
:todo-item="todoItem"
@action="handleAction"
/>
<!-- 未知类型 fallback -->
<div v-else class="tdv-unknown">
<p>未知的任务类型{{ todoItem.type }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ElMessage } from 'element-plus'
import { useConversationStore } from '@/stores/conversation'
import type { TodoItemData } from '@/api/todo'
import TicketDetail from './task/TicketDetail.vue'
import ApprovalDetail from './task/ApprovalDetail.vue'
import DeviceDetail from './task/DeviceDetail.vue'
// ============================================================================
// Props
// ============================================================================
interface Props {
/** 当前选中的待办事项 */
todoItem: TodoItemData
}
defineProps<Props>()
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
// ============================================================================
// 映射
// ============================================================================
/** 类型标签映射 */
const typeLabelMap: Record<string, string> = {
ticket: '📋 运维工单',
approval: '📝 审批单',
device: '🖥 设备异常',
}
/** 优先级标签映射 */
const priorityLabelMap: Record<string, string> = {
urgent: '🔴 紧急',
high: '🟡 高',
normal: '🟢 普通',
}
// ============================================================================
// 方法
// ============================================================================
/**
* 返回会话视图
*/
function handleGoBack(): void {
conversationStore.workspaceView = 'chat'
}
/**
* 处理操作按钮点击(Mock 模式:仅 toast 提示)
*
* @param action - 操作标识
*/
function handleAction(action: string): void {
ElMessage.success(`操作成功:${action}`)
}
</script>
<style scoped>
.task-detail-view {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--bg-primary);
}
/* ---- 顶部 ---- */
.tdv-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
}
.tdv-back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background-color: var(--bg-secondary);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.tdv-back-btn:hover {
background-color: var(--bg-hover);
color: var(--accent);
border-color: var(--accent);
}
.tdv-back-arrow {
font-size: 14px;
font-weight: 600;
}
.tdv-header-center {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.tdv-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.tdv-type-tag {
flex-shrink: 0;
font-size: 12px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-weight: 500;
}
.tdv-type-ticket {
background-color: rgba(64, 158, 255, 0.1);
color: var(--accent);
}
.tdv-type-approval {
background-color: rgba(230, 162, 60, 0.1);
color: var(--color-warning);
}
.tdv-type-device {
background-color: rgba(245, 108, 108, 0.1);
color: var(--color-danger);
}
.tdv-header-right {
flex-shrink: 0;
}
.tdv-priority {
font-size: 13px;
font-weight: 500;
}
.tdv-priority-urgent {
color: var(--color-danger);
}
.tdv-priority-high {
color: var(--color-warning);
}
.tdv-priority-normal {
color: var(--color-success);
}
/* ---- 内容区 ---- */
.tdv-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px 20px;
}
/* ---- 未知类型 ---- */
.tdv-unknown {
text-align: center;
padding: 40px;
color: var(--text-tertiary);
}
</style>
@@ -0,0 +1,647 @@
<!-- =============================================================================
// 企微IT智能服务台 — 排查步骤栏组件
// =============================================================================
// 说明:位于聊天输入框下方,始终可见(不可整体收起)
// 包含:
// 1. 默认视图:横向路径方块(①→②→③→④→⑤)
// 2. 展开视图:完整决策树(递归渲染)
// 数据:从 troubleshooting API 获取模板,默认选中第一个
// ============================================================================= -->
<template>
<div class="troubleshoot-bar">
<!-- ================================================================== -->
<!-- 栏头标题 + 模板选择 + 内联路径 + 三角切换 -->
<!-- ================================================================== -->
<div class="troubleshoot-bar__header">
<span class="troubleshoot-bar__title">🔧 排查步骤</span>
<!-- 模板选择下拉 -->
<el-select
v-model="selectedTemplateId"
size="small"
style="width: 140px;"
@change="handleTemplateChange"
>
<el-option
v-for="tpl in templates"
:key="tpl.id"
:label="tpl.name"
:value="tpl.id"
/>
</el-select>
<!-- 内联路径步骤 -->
<div class="troubleshoot-bar__path-inline">
<template v-for="(step, index) in currentPathSteps" :key="index">
<span
class="path-step-inline"
:class="`path-step-inline--${step.status}`"
@click="handleStepClick(index)"
>
{{ index + 1 }} {{ step.label }}
</span>
<span v-if="index < currentPathSteps.length - 1" class="path-arrow-inline"></span>
</template>
</div>
<!-- 展开全流程图三角图标 -->
<span
class="troubleshoot-bar__toggle"
@click="toggleFlowchart"
:title="isFlowchartExpanded ? '收起全流程图' : '展开全流程图'"
>{{ isFlowchartExpanded ? '▼' : '▶' }}</span>
</div>
<!-- ================================================================== -->
<!-- 展开视图决策树 -->
<!-- ================================================================== -->
<div
class="troubleshoot-bar__flowchart"
:class="{ 'is-expanded': isFlowchartExpanded }"
>
<div class="troubleshoot-bar__flowchart-inner">
<FlowchartNode
v-if="currentFlowchart"
:node="currentFlowchart"
:base-index="1"
/>
<div v-else class="troubleshoot-bar__flowchart-empty">
暂无流程图数据
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import FlowchartNode from './FlowchartNode.vue'
import {
getTroubleshootingTemplates,
} from '@/api/troubleshooting'
import type { TroubleshootingTemplate, PathStep, FlowchartNode as FlowchartNodeType } from '@/api/troubleshooting'
import { useConversationStore } from '@/stores/conversation'
// ============================================================================
// 状态
// ============================================================================
const conversationStore = useConversationStore()
/** 是否展开全流程图 */
const isFlowchartExpanded = ref(false)
/** 模板列表 */
const templates = ref<TroubleshootingTemplate[]>([])
/** 当前选中的模板 ID */
const selectedTemplateId = ref<string>('')
/** 当前模板的路径步骤(本地状态,可由坐席推进) */
const localPathSteps = ref<PathStep[]>([])
/** 当前模板的流程图数据 */
const localFlowchart = ref<FlowchartNodeType | null>(null)
// ============================================================================
// 计算属性
// ============================================================================
/** 当前显示的路径步骤 */
const currentPathSteps = computed(() => {
return localPathSteps.value
})
/** 当前显示的流程图 */
const currentFlowchart = computed(() => {
return localFlowchart.value
})
// ============================================================================
// 方法
// ============================================================================
/**
* 切换全流程图展开/收起
*/
function toggleFlowchart(): void {
isFlowchartExpanded.value = !isFlowchartExpanded.value
}
/**
* 模板选择变更
*/
function handleTemplateChange(templateId: string): void {
const tpl = templates.value.find(t => t.id === templateId)
if (tpl) {
localPathSteps.value = [...tpl.path_steps]
localFlowchart.value = tpl.flowchart || null
}
}
/**
* 点击路径方块 → 将该步骤设为 current
*
* @param index - 步骤索引
*/
function handleStepClick(index: number): void {
const steps = localPathSteps.value
if (!steps || index < 0 || index >= steps.length) return
// 更新步骤状态:index 之前的为 done,当前为 current,之后的为 pending
const updated = steps.map((step, i) => ({
...step,
status: i < index ? 'done' as const : i === index ? 'current' as const : 'pending' as const,
}))
localPathSteps.value = updated
// 同步更新流程图节点状态
if (localFlowchart.value) {
updateFlowchartStatus(localFlowchart.value, index)
}
ElMessage.success(`当前步骤:${steps[index].label}`)
}
/**
* 递归更新流程图节点状态
*/
function updateFlowchartStatus(node: FlowchartNodeType, currentStepIndex: number): void {
// 简单线性匹配:按遍历顺序给步骤编号
let counter = 0
function walk(n: FlowchartNodeType): void {
if (n.type === 'step') {
if (counter < currentStepIndex) {
n.status = 'done'
} else if (counter === currentStepIndex) {
n.status = 'current'
} else {
n.status = 'pending'
}
counter++
if (n.children) {
for (const child of n.children) {
walk(child)
}
}
} else if (n.type === 'decision') {
// 判断节点保持原状态
n.status = 'pending'
if (n.yes_branch) walk(n.yes_branch)
if (n.no_branch) walk(n.no_branch)
}
}
walk(node)
}
/**
* 加载排查模板列表
*/
async function loadTemplates(): Promise<void> {
try {
const data = await getTroubleshootingTemplates()
templates.value = data.items
// 默认选中第一个模板
if (data.items.length > 0) {
selectedTemplateId.value = data.items[0].id
localPathSteps.value = [...data.items[0].path_steps]
localFlowchart.value = data.items[0].flowchart || null
}
} catch (error) {
console.error('获取排查模板失败:', error)
// 使用内置 Mock 数据
loadFallbackMockData()
}
}
/**
* 内置 Mock 数据(API 不可用时的降级方案)
*/
function loadFallbackMockData(): void {
const mockTemplates: TroubleshootingTemplate[] = [
{
id: 'tpl-vpn',
name: 'VPN连接故障',
category: 'vpn',
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
path_steps: [
{ label: '确认VPN版本', status: 'done' },
{ label: '清除缓存重连', status: 'current' },
{ label: '远程排查', status: 'pending' },
{ label: '升级客户端', status: 'pending' },
{ label: '回访确认', status: 'pending' },
],
flowchart: {
id: 'fc-vpn-1',
type: 'step',
label: '确认VPN客户端版本',
status: 'done',
children: [
{
id: 'fc-vpn-2',
type: 'decision',
label: '版本是否为最新?',
status: 'pending',
yes_branch: {
id: 'fc-vpn-3',
type: 'step',
label: '清除DNS缓存并重连',
status: 'current',
children: [
{
id: 'fc-vpn-4',
type: 'decision',
label: '重连是否成功?',
status: 'pending',
yes_branch: {
id: 'fc-vpn-5',
type: 'step',
label: '回访确认',
status: 'pending',
},
no_branch: {
id: 'fc-vpn-6',
type: 'step',
label: '发起远程协助',
status: 'pending',
children: [
{
id: 'fc-vpn-7',
type: 'decision',
label: '远程能否解决?',
status: 'pending',
yes_branch: {
id: 'fc-vpn-8',
type: 'step',
label: '回访确认并结单',
status: 'pending',
},
no_branch: {
id: 'fc-vpn-9',
type: 'step',
label: '升级至二线团队',
status: 'pending',
},
},
],
},
},
],
},
no_branch: {
id: 'fc-vpn-10',
type: 'step',
label: '升级VPN客户端到最新版',
status: 'pending',
children: [
{
id: 'fc-vpn-11',
type: 'step',
label: '重试连接',
status: 'pending',
},
],
},
},
],
},
},
{
id: 'tpl-email',
name: '邮箱登录故障',
category: 'email',
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
path_steps: [
{ label: '确认邮箱状态', status: 'done' },
{ label: '重置密码', status: 'current' },
{ label: '检查配置', status: 'pending' },
{ label: '清理缓存', status: 'pending' },
{ label: '回访确认', status: 'pending' },
],
flowchart: {
id: 'fc-email-1',
type: 'step',
label: '确认邮箱账号状态',
status: 'done',
children: [
{
id: 'fc-email-2',
type: 'decision',
label: '账号是否被锁定?',
status: 'pending',
yes_branch: {
id: 'fc-email-3',
type: 'step',
label: '解锁账号并重置密码',
status: 'current',
},
no_branch: {
id: 'fc-email-4',
type: 'step',
label: '检查Outlook配置',
status: 'pending',
children: [
{
id: 'fc-email-5',
type: 'decision',
label: '配置是否正确?',
status: 'pending',
yes_branch: {
id: 'fc-email-6',
type: 'step',
label: '清理Outlook缓存',
status: 'pending',
},
no_branch: {
id: 'fc-email-7',
type: 'step',
label: '重新配置Outlook',
status: 'pending',
},
},
],
},
},
],
},
},
{
id: 'tpl-system',
name: '系统登录异常',
category: 'system',
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
path_steps: [
{ label: '确认系统状态', status: 'current' },
{ label: '清除浏览器缓存', status: 'pending' },
{ label: '更换浏览器', status: 'pending' },
{ label: '检查网络权限', status: 'pending' },
{ label: '回访确认', status: 'pending' },
],
flowchart: {
id: 'fc-sys-1',
type: 'step',
label: '确认系统服务是否正常',
status: 'current',
children: [
{
id: 'fc-sys-2',
type: 'decision',
label: '系统服务是否正常?',
status: 'pending',
yes_branch: {
id: 'fc-sys-3',
type: 'step',
label: '清除浏览器缓存',
status: 'pending',
children: [
{
id: 'fc-sys-4',
type: 'decision',
label: '清除后是否恢复?',
status: 'pending',
yes_branch: {
id: 'fc-sys-5',
type: 'step',
label: '回访确认并结单',
status: 'pending',
},
no_branch: {
id: 'fc-sys-6',
type: 'step',
label: '更换浏览器重试',
status: 'pending',
},
},
],
},
no_branch: {
id: 'fc-sys-7',
type: 'step',
label: '联系运维检查服务端',
status: 'pending',
},
},
],
},
},
{
id: 'tpl-account',
name: '账号权限问题',
category: 'account',
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
path_steps: [
{ label: '确认权限需求', status: 'current' },
{ label: '提交审批', status: 'pending' },
{ label: '配置权限', status: 'pending' },
{ label: '验证权限', status: 'pending' },
{ label: '回访确认', status: 'pending' },
],
flowchart: {
id: 'fc-acc-1',
type: 'step',
label: '确认权限需求与合规性',
status: 'current',
children: [
{
id: 'fc-acc-2',
type: 'decision',
label: '权限是否符合策略?',
status: 'pending',
yes_branch: {
id: 'fc-acc-3',
type: 'step',
label: '提交权限审批流程',
status: 'pending',
children: [
{
id: 'fc-acc-4',
type: 'step',
label: '审批通过后配置权限',
status: 'pending',
},
],
},
no_branch: {
id: 'fc-acc-5',
type: 'step',
label: '建议替代方案或申请特批',
status: 'pending',
},
},
],
},
},
]
templates.value = mockTemplates
if (mockTemplates.length > 0) {
selectedTemplateId.value = mockTemplates[0].id
localPathSteps.value = [...mockTemplates[0].path_steps]
localFlowchart.value = mockTemplates[0].flowchart
}
}
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
loadTemplates()
})
// 切换会话时重置步骤进度
watch(
() => conversationStore.currentConversationId,
() => {
// 重新加载当前模板
handleTemplateChange(selectedTemplateId.value)
isFlowchartExpanded.value = false
}
)
</script>
<style scoped>
/* 主容器 */
.troubleshoot-bar {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
/* 栏头 — 紧凑单行:标题 + 下拉 + 内联路径 + 三角图标 */
.troubleshoot-bar__header {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 8px;
min-height: 36px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-light);
}
.troubleshoot-bar__header:hover {
background: var(--bg-hover);
}
.troubleshoot-bar__title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0;
}
/* 内联路径步骤(flex: 1 填充剩余空间) */
.troubleshoot-bar__path-inline {
display: flex;
align-items: center;
gap: 0;
flex: 1;
overflow: hidden;
}
/* 紧凑步骤标签 */
.path-step-inline {
padding: 2px 7px;
background: var(--bg-tertiary);
border: 1px solid var(--border-light);
border-radius: 3px;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
transition: all 0.2s;
}
.path-step-inline:hover {
border-color: var(--accent);
color: var(--accent);
}
/* 已完成 */
.path-step-inline--done {
background: var(--color-success);
border-color: var(--color-success);
color: var(--bg-secondary);
}
/* 当前 */
.path-step-inline--current {
background: var(--bg-accent-soft);
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
/* 待处理 */
.path-step-inline--pending {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
/* 内联箭头 */
.path-arrow-inline {
display: flex;
align-items: center;
color: var(--text-tertiary);
font-size: 10px;
padding: 0 3px;
flex-shrink: 0;
}
/* 三角切换图标 */
.troubleshoot-bar__toggle {
font-size: 12px;
color: var(--accent);
cursor: pointer;
padding: 4px 6px;
border-radius: 3px;
transition: all 0.2s;
user-select: none;
flex-shrink: 0;
line-height: 1;
}
.troubleshoot-bar__toggle:hover {
background: var(--bg-accent-soft);
}
/* 展开流程图区域 */
.troubleshoot-bar__flowchart {
max-height: 0;
overflow: hidden;
transition: max-height 0.35s ease;
}
.troubleshoot-bar__flowchart.is-expanded {
max-height: 800px;
overflow-y: auto;
}
.troubleshoot-bar__flowchart-inner {
padding: 12px 16px 16px;
border-top: 1px solid var(--border-light);
}
.troubleshoot-bar__flowchart-empty {
text-align: center;
color: var(--text-tertiary);
font-size: 12px;
padding: 16px 0;
}
</style>
@@ -0,0 +1,816 @@
<!-- =============================================================================
// 企微IT智能服务台 — 用户信息栏组件
// =============================================================================
// 说明:替代 ChatArea.vue 中原有的顶部标题栏
// 功能:
// 1. 常驻区(收起状态):头像+姓名+IT等级+chips+展开箭头
// 2. 展开详情区(6 卡片 3 列 grid)
// 3. 右侧操作按钮(转接/结单/摇人等)
// ============================================================================= -->
<template>
<div class="user-info-bar">
<!-- ================================================================== -->
<!-- 常驻区点击整行展开/收起 -->
<!-- ================================================================== -->
<div class="user-info-bar__persistent" @click="toggleExpand">
<!-- 左侧头像 + 姓名 + IT等级 + 箭头 -->
<div class="user-info-bar__left">
<!-- 头像 -->
<div class="user-info-bar__avatar">
{{ avatarText }}
</div>
<!-- 姓名·部门岗位 + IT 等级 -->
<div class="user-info-bar__name-group">
<span class="user-info-bar__name">{{ conversation?.employee_name || '未知' }}</span>
<span
v-if="conversation?.department || conversation?.position"
class="user-info-bar__dept"
>
· {{ conversation?.department || '' }}{{ conversation?.position ? ' ' + conversation.position : '' }}
</span>
<ItLevelBadge :level="employeeItLevel" size="sm" />
</div>
<!-- 展开/收起箭头 -->
<span class="user-info-bar__arrow" :class="{ 'is-expanded': isExpanded }"></span>
</div>
<!-- 中间信息 chips -->
<div class="user-info-bar__chips" @click.stop>
<!-- 情绪状态 chip -->
<span
class="info-chip"
:class="emotionChipClass"
>
😟 {{ emotionLabel }}
</span>
<!-- 等待时长 chip -->
<span class="info-chip info-chip--gray">
{{ waitTimeLabel }}
</span>
<!-- 对话轮次 chip -->
<span class="info-chip info-chip--gray">
💬 {{ turnCount }}
</span>
<!-- IT等级 chip -->
<span class="info-chip info-chip--accent">
🖥 {{ levelName }} Lv.{{ levelNumber }}
</span>
<!-- 重复标记 chip有重复时才显示 -->
<span
v-if="repeatCount > 0"
class="info-chip info-chip--red"
>
🔁 重复×{{ repeatCount }}
</span>
<!-- 备注标记 chip有备注时才显示 -->
<span
v-if="hasNotes"
class="info-chip info-chip--purple"
>
📝 备注
</span>
</div>
<!-- 右侧操作按钮 -->
<div class="user-info-bar__actions" @click.stop>
<!-- 接单按钮 -->
<el-button
v-if="conversation?.status === 'queued'"
type="success"
size="small"
@click="$emit('assign')"
>
<el-icon><Check /></el-icon>
接单
</el-button>
<!-- 置顶/取消置顶 -->
<el-button
size="small"
:type="conversation?.is_pinned ? 'warning' : 'default'"
@click="$emit('toggle-pin')"
>
{{ conversation?.is_pinned ? '取消置顶' : '📌 置顶' }}
</el-button>
<!-- 代办/取消代办 -->
<el-button
size="small"
:type="conversation?.is_todo ? 'warning' : 'default'"
@click="$emit('toggle-todo')"
>
{{ conversation?.is_todo ? '取消代办' : '📋 代办' }}
</el-button>
<!-- 转接 -->
<el-dropdown trigger="click" @command="(cmd: string) => $emit('transfer', cmd)">
<el-button size="small" type="info">
<el-icon><Sort /></el-icon>
转接
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="agent in availableAgents"
:key="agent.user_id"
:command="agent.user_id"
>
{{ agent.name }} ({{ agent.current_load }}/{{ agent.max_load }})
</el-dropdown-item>
<el-dropdown-item v-if="availableAgents.length === 0" disabled>
暂无可用坐席
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 摇人 -->
<el-button
v-if="canInviteCollaborator"
size="small"
type="primary"
@click="$emit('invite')"
>
🤝 摇人
</el-button>
<!-- 结单 -->
<el-button
v-if="conversation?.status !== 'resolved'"
type="danger"
size="small"
@click="$emit('resolve')"
>
<el-icon><CircleClose /></el-icon>
结单
</el-button>
</div>
</div>
<!-- ================================================================== -->
<!-- 展开详情区6 卡片 3 grid -->
<!-- ================================================================== -->
<div
class="user-info-bar__detail"
:class="{ 'is-expanded': isExpanded }"
>
<div class="user-info-bar__detail-inner">
<!-- 卡片 1: 情绪状态 -->
<div class="detail-card">
<div class="detail-card__header">情绪状态</div>
<div class="detail-card__body">
<div class="detail-card__emotion">
<span class="detail-card__emotion-emoji">{{ emotionEmoji }}</span>
<span class="detail-card__emotion-text">{{ emotionLabel }}</span>
</div>
<div class="detail-card__desc">{{ emotionDesc }}</div>
</div>
</div>
<!-- 卡片 2: 会话详情 -->
<div class="detail-card">
<div class="detail-card__header">会话详情</div>
<div class="detail-card__body">
<div class="detail-card__stat">
<span class="detail-card__stat-icon"></span>
<span>等待时长<strong>{{ waitTimeLabel }}</strong></span>
</div>
<div class="detail-card__stat">
<span class="detail-card__stat-icon">💬</span>
<span>对话轮次<strong>{{ turnCount }} </strong></span>
</div>
</div>
</div>
<!-- 卡片 3: 问题分析 -->
<div class="detail-card">
<div class="detail-card__header">问题分析</div>
<div class="detail-card__body">
<div class="detail-card__stat">
<span class="detail-card__stat-icon">🔁</span>
<span>{{ repeatCount > 0 ? '重复问题' : '非重复问题' }}</span>
</div>
<div class="detail-card__stat">
<span>7天内反馈<strong>{{ weekFeedbackCount }} </strong></span>
</div>
</div>
</div>
<!-- 卡片 4: IT技能等级 -->
<div class="detail-card">
<div class="detail-card__header">IT技能等级</div>
<div class="detail-card__body">
<div class="detail-card__level-row">
<ItLevelBadge :level="employeeItLevel" size="md" />
<span class="detail-card__level-name">{{ levelName }} Lv.{{ levelNumber }}</span>
<el-button
size="small"
type="primary"
link
@click.stop="showItLevelSelector = !showItLevelSelector"
>
调整
</el-button>
</div>
<div class="detail-card__level-desc">{{ levelDesc }}</div>
<!-- IT 等级选择器下拉 -->
<div
v-if="showItLevelSelector"
class="it-level-selector"
@click.stop
>
<div
v-for="(info, key) in IT_LEVEL_MAP"
:key="key"
class="it-level-selector__item"
:class="{ 'is-active': employeeItLevel === key }"
@click="handleItLevelChange(key)"
>
<ItLevelBadge :level="key" size="sm" />
<span>{{ info.name }} Lv.{{ info.lv }}</span>
<span v-if="employeeItLevel === key" class="it-level-selector__check"></span>
</div>
</div>
</div>
</div>
<!-- 卡片 5: 历史工单 -->
<div class="detail-card">
<div class="detail-card__header">历史工单</div>
<div class="detail-card__body">
<div class="detail-card__stat">
<span>30天内工单<strong>{{ monthlyTicketCount }}</strong></span>
</div>
<div class="detail-card__stat">
<span>类型分布{{ ticketTypeDistribution }}</span>
</div>
</div>
</div>
<!-- 卡片 6: 其他备注 -->
<div class="detail-card">
<div class="detail-card__header">其他备注</div>
<div class="detail-card__body">
<div v-if="notesText" class="detail-card__notes">
{{ notesText }}
</div>
<div v-else class="detail-card__notes detail-card__notes--empty">
暂无备注
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Check, Sort, CircleClose } from '@element-plus/icons-vue'
import ItLevelBadge from './ItLevelBadge.vue'
import { updateEmployeeItLevel } from '@/api/troubleshooting'
import type { Conversation } from '@/api/conversation'
// ============================================================================
// Props & Emits
// ============================================================================
interface AgentInfo {
user_id: string
name: string
current_load: number
max_load: number
}
interface Props {
/** 当前会话 */
conversation: Conversation | null
/** 可用坐席列表 */
availableAgents: AgentInfo[]
/** 是否可以摇人 */
canInviteCollaborator: boolean
}
const props = withDefaults(defineProps<Props>(), {
availableAgents: () => [],
canInviteCollaborator: false,
})
interface Emits {
(e: 'assign'): void
(e: 'resolve'): void
(e: 'toggle-pin'): void
(e: 'toggle-todo'): void
(e: 'transfer', agentId: string): void
(e: 'invite'): void
}
// emit 已声明但未使用(保留以备将来扩展)
defineEmits<Emits>()
// ============================================================================
// 状态
// ============================================================================
/** 是否展开详情 */
const isExpanded = ref(false)
/** 是否显示 IT 等级选择器 */
const showItLevelSelector = ref(false)
/** 员工 IT 等级(本地状态,可由坐席调整) */
const employeeItLevel = ref('silver')
/** 员工备注(本地 Mock */
const employeeNotes = ref<Record<string, any>>({})
// ============================================================================
// 等级元数据
// ============================================================================
const IT_LEVEL_MAP: Record<string, { name: string; lv: number; desc: string }> = {
bronze: { name: '青铜', lv: 1, desc: 'IT基础薄弱,需要详细指导' },
silver: { name: '白银', lv: 2, desc: '能完成基本操作,需协助复杂问题' },
gold: { name: '黄金', lv: 3, desc: '熟悉常见操作,可独立解决一般问题' },
platinum: { name: '铂金', lv: 4, desc: '熟练使用办公软件,能自助排查常见故障' },
diamond: { name: '钻石', lv: 5, desc: '具备一定技术能力,能理解技术解释' },
star: { name: '星耀', lv: 6, desc: 'IT能力较强,可自行解决大部分问题' },
king: { name: '王者', lv: 7, desc: 'IT达人级别,仅少数问题需协助' },
}
// ============================================================================
// 计算属性
// ============================================================================
/** 头像文字(取姓名前两个字) */
const avatarText = computed(() => {
const name = props.conversation?.employee_name || '?'
return name.slice(0, 2)
})
/** 情绪标签 */
const emotionLabel = computed(() => {
const state = props.conversation?.emotion_state || 'normal'
const map: Record<string, string> = {
normal: '正常',
worried: '担忧',
angry: '愤怒',
urgent: '紧急',
}
return map[state] || '正常'
})
/** 情绪 emoji */
const emotionEmoji = computed(() => {
const state = props.conversation?.emotion_state || 'normal'
const map: Record<string, string> = {
normal: '😊',
worried: '😟',
angry: '😡',
urgent: '🔴',
}
return map[state] || '😊'
})
/** 情绪描述 */
const emotionDesc = computed(() => {
const state = props.conversation?.emotion_state || 'normal'
const map: Record<string, string> = {
normal: '情绪平稳,正常沟通',
worried: '语气急促,连续追问进度',
angry: '措辞激烈,多次表达不满',
urgent: '问题严重影响工作,要求立即处理',
}
return map[state] || '情绪平稳'
})
/** 情绪 chip 样式 */
const emotionChipClass = computed(() => {
const state = props.conversation?.emotion_state || 'normal'
if (state === 'normal') return 'info-chip--gray'
if (state === 'angry' || state === 'urgent') return 'info-chip--red'
return 'info-chip--yellow'
})
/** 等待时长标签 */
const waitTimeLabel = computed(() => {
// 从会话创建时间计算等待时长
const createdAt = props.conversation?.created_at
if (!createdAt) return '0分钟'
const diffMs = Date.now() - new Date(createdAt).getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 60) return `${diffMin}分钟`
const hours = Math.floor(diffMin / 60)
const mins = diffMin % 60
return `${hours}小时${mins}分钟`
})
/** 对话轮次(从消息数估算) */
const turnCount = computed(() => {
const tags = props.conversation?.tags
return (tags?.repeat_count || 0) + 1
})
/** 重复次数 */
const repeatCount = computed(() => {
return props.conversation?.tags?.repeat_count || 0
})
/** 是否有备注 */
const hasNotes = computed(() => {
return Object.keys(employeeNotes.value).length > 0
})
/** 备注文本 */
const notesText = computed(() => {
const notes = employeeNotes.value
const parts: string[] = []
if (notes.pregnant) parts.push('孕妇')
if (notes.disabled) parts.push('残疾/行动不便')
if (notes.preference) parts.push(notes.preference)
if (notes.custom) parts.push(notes.custom)
return parts.join('') || ''
})
/** 等级名称 */
const levelName = computed(() => {
return IT_LEVEL_MAP[employeeItLevel.value]?.name || '白银'
})
/** 等级编号 */
const levelNumber = computed(() => {
return IT_LEVEL_MAP[employeeItLevel.value]?.lv || 2
})
/** 等级描述 */
const levelDesc = computed(() => {
return IT_LEVEL_MAP[employeeItLevel.value]?.desc || ''
})
/** 7天内反馈次数(Mock */
const weekFeedbackCount = computed(() => {
return props.conversation?.tags?.repeat_count || 0
})
/** 30天内工单数量(Mock */
const monthlyTicketCount = computed(() => {
return Math.floor(Math.random() * 5) + 1
})
/** 工单类型分布(Mock */
const ticketTypeDistribution = computed(() => {
return 'VPN 2次,邮箱 1次'
})
// ============================================================================
// 方法
// ============================================================================
/**
* 切换展开/收起
*/
function toggleExpand(): void {
isExpanded.value = !isExpanded.value
// 收起时关闭 IT 等级选择器
if (!isExpanded.value) {
showItLevelSelector.value = false
}
}
/**
* 调整 IT 等级
*
* @param level - 新等级
*/
async function handleItLevelChange(level: string): Promise<void> {
const employeeId = props.conversation?.employee_id
if (!employeeId) {
ElMessage.warning('无法获取员工信息')
return
}
try {
await updateEmployeeItLevel(employeeId, level)
employeeItLevel.value = level
showItLevelSelector.value = false
ElMessage.success(`IT 等级已调整为 ${IT_LEVEL_MAP[level]?.name || level}`)
} catch (error) {
console.error('更新 IT 等级失败:', error)
// 降级:即使 API 失败也本地更新
employeeItLevel.value = level
showItLevelSelector.value = false
}
}
// ============================================================================
// 监听会话切换
// ============================================================================
/** 当会话变化时重置状态 */
function resetForNewConversation(): void {
isExpanded.value = false
showItLevelSelector.value = false
// 从 conversation 初始化 IT 等级
if (props.conversation) {
employeeItLevel.value = props.conversation.level || 'silver'
}
}
// 暴露给父组件
defineExpose({ resetForNewConversation })
</script>
<style scoped>
/* 主容器 */
.user-info-bar {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
/* 常驻区 */
.user-info-bar__persistent {
display: flex;
align-items: center;
padding: 10px 20px;
cursor: pointer;
gap: 12px;
transition: background 0.2s;
}
.user-info-bar__persistent:hover {
background: var(--bg-hover);
}
/* 左侧区域 */
.user-info-bar__left {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* 头像 */
.user-info-bar__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
/* 姓名组 */
.user-info-bar__name-group {
display: flex;
align-items: center;
gap: 6px;
}
.user-info-bar__name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.user-info-bar__dept {
font-size: 12px;
color: var(--text-tertiary);
}
/* 展开箭头 */
.user-info-bar__arrow {
font-size: 10px;
color: var(--text-tertiary);
transition: transform 0.35s ease;
cursor: pointer;
}
.user-info-bar__arrow.is-expanded {
transform: rotate(90deg);
}
/* 信息 chips */
.user-info-bar__chips {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
overflow-x: auto;
}
/* chip 通用样式 */
.info-chip {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
white-space: nowrap;
flex-shrink: 0;
}
.info-chip--gray {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-light);
}
.info-chip--yellow {
background: var(--warning-soft);
color: var(--color-warning);
border: 1px solid var(--warning-soft);
}
.info-chip--red {
background: var(--danger-soft);
color: var(--color-danger);
border: 1px solid var(--danger-soft);
}
.info-chip--purple {
background: var(--purple-soft);
color: var(--purple);
border: 1px solid var(--purple-soft);
}
.info-chip--accent {
background: var(--bg-accent-soft);
color: var(--accent);
border: 1px solid var(--accent);
}
/* 右侧操作按钮 */
.user-info-bar__actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* 展开详情区 */
.user-info-bar__detail {
max-height: 0;
overflow: hidden;
transition: max-height 0.35s ease;
}
.user-info-bar__detail.is-expanded {
max-height: 600px;
}
.user-info-bar__detail-inner {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 12px 20px 16px;
}
/* 详情卡片 */
.detail-card {
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.detail-card__header {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 8px;
}
.detail-card__body {
font-size: 13px;
color: var(--text-primary);
line-height: 1.6;
}
/* 情绪展示 */
.detail-card__emotion {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.detail-card__emotion-emoji {
font-size: 20px;
}
.detail-card__emotion-text {
font-weight: 600;
}
.detail-card__desc {
font-size: 12px;
color: var(--text-tertiary);
}
/* 统计行 */
.detail-card__stat {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.detail-card__stat-icon {
font-size: 14px;
}
/* 等级行 */
.detail-card__level-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.detail-card__level-name {
font-weight: 600;
}
.detail-card__level-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 4px;
}
/* IT 等级选择器 */
.it-level-selector {
margin-top: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
box-shadow: var(--shadow-md);
max-height: 240px;
overflow-y: auto;
}
.it-level-selector__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
color: var(--text-primary);
transition: background 0.15s;
}
.it-level-selector__item:hover {
background: var(--bg-hover);
}
.it-level-selector__item.is-active {
background: var(--bg-accent-soft);
color: var(--accent);
font-weight: 600;
}
.it-level-selector__check {
margin-left: auto;
color: var(--accent);
}
/* 备注文本 */
.detail-card__notes {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
white-space: pre-line;
}
.detail-card__notes--empty {
color: var(--text-placeholder);
font-style: italic;
}
/* 响应式:小屏2列 */
@media (max-width: 1200px) {
.user-info-bar__detail-inner {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
@@ -0,0 +1,242 @@
<!-- =============================================================================
// 企微IT智能服务台 — 审批单详情子视图
// =============================================================================
// 说明:TaskDetailView 中 type=approval 的子视图
// 功能:
// 1. 审批内容卡片(申请人/类型/预算/附件)
// 2. 审批意见输入区(textarea
// 3. 底部操作按钮(审批通过/拒绝审批/转交审批)
// ============================================================================= -->
<template>
<div class="approval-detail">
<!-- ================================================================== -->
<!-- 审批内容卡片 -->
<!-- ================================================================== -->
<div class="tic-card">
<div class="tic-card-title">📝 审批内容</div>
<div class="tic-row">
<span class="tic-label">申请人</span>
<span class="tic-value">{{ todoItem.description?.applicant || '—' }}</span>
</div>
<div class="tic-row">
<span class="tic-label">类型</span>
<span class="tic-value">{{ todoItem.description?.approval_type || '—' }}</span>
</div>
<div class="tic-row">
<span class="tic-label">预算</span>
<span class="tic-value">{{ todoItem.description?.budget ? `¥${todoItem.description.budget}` : '—' }}</span>
</div>
<div class="tic-row">
<span class="tic-label">附件</span>
<span class="tic-value">
<span v-if="todoItem.description?.attachments?.length">
<span v-for="(att, idx) in todoItem.description.attachments" :key="idx" class="apv-attachment">
📎 {{ att }}
</span>
</span>
<span v-else></span>
</span>
</div>
<div class="tic-row">
<span class="tic-label">说明</span>
<span class="tic-value tic-desc">{{ todoItem.description?.detail || todoItem.description?.description || '—' }}</span>
</div>
</div>
<!-- ================================================================== -->
<!-- 审批意见输入区 -->
<!-- ================================================================== -->
<div class="tic-card">
<div class="tic-card-title"> 审批意见</div>
<el-input
v-model="approvalComment"
type="textarea"
:rows="4"
placeholder="请输入审批意见..."
resize="none"
class="apv-textarea"
/>
</div>
<!-- ================================================================== -->
<!-- 底部操作按钮 -->
<!-- ================================================================== -->
<div class="tic-actions">
<button class="tic-action-btn tic-action-success" @click="$emit('action', '审批通过')">
审批通过
</button>
<button class="tic-action-btn tic-action-danger" @click="$emit('action', '拒绝审批')">
拒绝审批
</button>
<button class="tic-action-btn" @click="$emit('action', '转交审批')">
🔄 转交审批
</button>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref } from 'vue'
import type { TodoItemData } from '@/api/todo'
// ============================================================================
// Props
// ============================================================================
interface Props {
/** 当前选中的待办事项 */
todoItem: TodoItemData
}
defineProps<Props>()
// ============================================================================
// Emits
// ============================================================================
interface Emits {
/** 操作按钮事件 */
(e: 'action', action: string): void
}
defineEmits<Emits>()
// ============================================================================
// 状态
// ============================================================================
/** 审批意见文本 */
const approvalComment = ref<string>('')
</script>
<style scoped>
.approval-detail {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ---- 通用卡片 ---- */
.tic-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.tic-card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.tic-row {
display: flex;
align-items: flex-start;
padding: 5px 0;
}
.tic-label {
width: 70px;
flex-shrink: 0;
font-size: 13px;
color: var(--text-tertiary);
}
.tic-value {
flex: 1;
font-size: 13px;
color: var(--text-primary);
word-break: break-word;
}
.tic-desc {
line-height: 1.5;
white-space: pre-wrap;
}
/* ---- 附件标签 ---- */
.apv-attachment {
display: inline-block;
margin-right: 8px;
margin-bottom: 4px;
font-size: 12px;
color: var(--accent);
cursor: pointer;
}
.apv-attachment:hover {
text-decoration: underline;
}
/* ---- 审批意见输入区 ---- */
.apv-textarea :deep(.el-textarea__inner) {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.5;
}
.apv-textarea :deep(.el-textarea__inner:focus) {
border-color: var(--accent);
}
/* ---- 操作按钮 ---- */
.tic-actions {
display: flex;
gap: 8px;
padding: 8px 0;
flex-wrap: wrap;
}
.tic-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.tic-action-btn:hover {
background-color: var(--bg-hover);
border-color: var(--accent);
color: var(--accent);
}
.tic-action-success {
background-color: var(--color-success);
color: var(--bg-secondary);
border-color: var(--color-success);
}
.tic-action-success:hover {
opacity: 0.9;
color: var(--bg-secondary);
}
.tic-action-danger {
background-color: var(--color-danger);
color: var(--bg-secondary);
border-color: var(--color-danger);
}
.tic-action-danger:hover {
opacity: 0.9;
color: var(--bg-secondary);
}
</style>
@@ -0,0 +1,332 @@
<!-- =============================================================================
// 企微IT智能服务台 — 设备异常详情子视图
// =============================================================================
// 说明:TaskDetailView 中 type=device 的子视图
// 功能:
// 1. 设备状态网格(2×3 grid: 设备名称/型号/在线状态/最后在线/IP/告警次数)
// 2. 处理记录卡片
// 3. 底部操作按钮(一键开单/派工/标记恢复/加入巡检)
// ============================================================================= -->
<template>
<div class="device-detail">
<!-- ================================================================== -->
<!-- 设备状态网格 -->
<!-- ================================================================== -->
<div class="tic-card">
<div class="tic-card-title">🖥 设备状态</div>
<div class="dev-grid">
<div class="dev-grid-row">
<div class="dev-grid-cell">
<span class="dev-grid-label">设备名称</span>
<span class="dev-grid-value">{{ todoItem.description?.device_name || todoItem.title }}</span>
</div>
<div class="dev-grid-cell">
<span class="dev-grid-label">型号</span>
<span class="dev-grid-value">{{ todoItem.description?.device_model || '—' }}</span>
</div>
</div>
<div class="dev-grid-row">
<div class="dev-grid-cell">
<span class="dev-grid-label">在线状态</span>
<span class="dev-grid-value">
<span class="dev-status-dot" :class="onlineStatusClass"></span>
{{ onlineStatusText }}
</span>
</div>
<div class="dev-grid-cell">
<span class="dev-grid-label">最后在线</span>
<span class="dev-grid-value">{{ todoItem.description?.last_online || '—' }}</span>
</div>
</div>
<div class="dev-grid-row">
<div class="dev-grid-cell">
<span class="dev-grid-label">IP 地址</span>
<span class="dev-grid-value">{{ todoItem.description?.ip_address || '—' }}</span>
</div>
<div class="dev-grid-cell">
<span class="dev-grid-label">告警次数</span>
<span class="dev-grid-value" :class="alarmCountClass">
{{ todoItem.description?.alarm_count ?? 0 }}
</span>
</div>
</div>
</div>
</div>
<!-- ================================================================== -->
<!-- 处理记录卡片 -->
<!-- ================================================================== -->
<div class="tic-card">
<div class="tic-card-title">📋 处理记录</div>
<div v-if="records.length > 0" class="dev-records">
<div v-for="(rec, idx) in records" :key="idx" class="dev-record-item">
<div class="dev-record-time">{{ rec.time }}</div>
<div class="dev-record-content">{{ rec.content }}</div>
</div>
</div>
<div v-else class="dev-records-empty">
暂无处理记录
</div>
</div>
<!-- ================================================================== -->
<!-- 底部操作按钮 -->
<!-- ================================================================== -->
<div class="tic-actions">
<button class="tic-action-btn" @click="$emit('action', '一键开单')">📝 一键开单</button>
<button class="tic-action-btn" @click="$emit('action', '派工')">🚚 派工</button>
<button class="tic-action-btn tic-action-success" @click="$emit('action', '标记恢复')"> 标记恢复</button>
<button class="tic-action-btn" @click="$emit('action', '加入巡检')">📅 加入巡检</button>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
import type { TodoItemData } from '@/api/todo'
// ============================================================================
// Props
// ============================================================================
interface Props {
/** 当前选中的待办事项 */
todoItem: TodoItemData
}
const props = defineProps<Props>()
// ============================================================================
// Emits
// ============================================================================
interface Emits {
/** 操作按钮事件 */
(e: 'action', action: string): void
}
defineEmits<Emits>()
// ============================================================================
// 计算属性
// ============================================================================
/** 在线状态 CSS 类 */
const onlineStatusClass = computed<string>(() => {
const status = props.todoItem.description?.online_status
if (status === 'normal' || status === 'online') return 'status-normal'
if (status === 'warning') return 'status-warning'
if (status === 'offline' || status === 'error') return 'status-error'
// 根据 priority 推断
if (props.todoItem.priority === 'urgent') return 'status-error'
if (props.todoItem.priority === 'high') return 'status-warning'
return 'status-normal'
})
/** 在线状态文本 */
const onlineStatusText = computed<string>(() => {
const status = props.todoItem.description?.online_status
if (status === 'normal' || status === 'online') return '正常'
if (status === 'warning') return '告警'
if (status === 'offline' || status === 'error') return '异常'
// 根据 priority 推断
if (props.todoItem.priority === 'urgent') return '异常'
if (props.todoItem.priority === 'high') return '告警'
return '正常'
})
/** 告警次数 CSS 类 */
const alarmCountClass = computed<string>(() => {
const count = props.todoItem.description?.alarm_count ?? 0
if (count > 5) return 'alarm-critical'
if (count > 0) return 'alarm-warning'
return 'alarm-normal'
})
/** 处理记录列表 */
const records = computed<Array<{ time: string; content: string }>>(() => {
const descRecords = props.todoItem.description?.records
if (Array.isArray(descRecords)) {
return descRecords.map((r: any) => ({
time: r.time || r.created_at || '—',
content: r.content || r.action || '—',
}))
}
// Mock 数据
if (props.todoItem.status === 'processing') {
return [
{ time: '10:32', content: '坐席已接单,正在排查中' },
]
}
return []
})
</script>
<style scoped>
.device-detail {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ---- 通用卡片 ---- */
.tic-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.tic-card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
/* ---- 设备状态网格 ---- */
.dev-grid {
display: flex;
flex-direction: column;
gap: 0;
}
.dev-grid-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.dev-grid-cell {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-light);
}
/* 去掉最后一行底部边框 */
.dev-grid-row:last-child .dev-grid-cell {
border-bottom: none;
}
/* 每行第二个 cell 加左边框 */
.dev-grid-cell:nth-child(2) {
border-left: 1px solid var(--border-light);
}
.dev-grid-label {
font-size: 12px;
color: var(--text-tertiary);
}
.dev-grid-value {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
/* ---- 在线状态指示点 ---- */
.dev-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dev-status-dot.status-normal { background-color: var(--color-success); }
.dev-status-dot.status-warning { background-color: var(--color-warning); }
.dev-status-dot.status-error { background-color: var(--color-danger); }
/* ---- 告警次数颜色 ---- */
.alarm-normal { color: var(--color-success); }
.alarm-warning { color: var(--color-warning); }
.alarm-critical { color: var(--color-danger); font-weight: 600; }
/* ---- 处理记录 ---- */
.dev-records {
display: flex;
flex-direction: column;
gap: 6px;
}
.dev-record-item {
display: flex;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid var(--border-light);
}
.dev-record-item:last-child {
border-bottom: none;
}
.dev-record-time {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
min-width: 48px;
}
.dev-record-content {
font-size: 13px;
color: var(--text-primary);
}
.dev-records-empty {
text-align: center;
padding: 12px;
color: var(--text-tertiary);
font-size: 13px;
}
/* ---- 操作按钮 ---- */
.tic-actions {
display: flex;
gap: 8px;
padding: 8px 0;
flex-wrap: wrap;
}
.tic-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.tic-action-btn:hover {
background-color: var(--bg-hover);
border-color: var(--accent);
color: var(--accent);
}
.tic-action-success {
background-color: var(--color-success);
color: var(--bg-secondary);
border-color: var(--color-success);
}
.tic-action-success:hover {
opacity: 0.9;
color: var(--bg-secondary);
}
</style>
@@ -0,0 +1,306 @@
<!-- =============================================================================
// 企微IT智能服务台 — 运维工单详情子视图
// =============================================================================
// 说明:TaskDetailView 中 type=ticket 的子视图
// 功能:
// 1. 工单描述卡片(标题/类型/优先级/上报人/上报时间/描述)
// 2. 处理进度卡片(状态/接单人/SLA倒计时)
// 3. 底部操作按钮(接单/开始处理/结单/转派)
// ============================================================================= -->
<template>
<div class="ticket-detail">
<!-- ================================================================== -->
<!-- 工单描述卡片 -->
<!-- ================================================================== -->
<div class="tic-card">
<div class="tic-card-title">📋 工单描述</div>
<div class="tic-row">
<span class="tic-label">标题</span>
<span class="tic-value">{{ todoItem.title }}</span>
</div>
<div class="tic-row">
<span class="tic-label">类型</span>
<span class="tic-value">{{ todoItem.description?.ticket_type || '运维' }}</span>
</div>
<div class="tic-row">
<span class="tic-label">优先级</span>
<span class="tic-value">
<span v-if="todoItem.priority === 'urgent'" class="tic-priority urgent">🔴 紧急</span>
<span v-else-if="todoItem.priority === 'high'" class="tic-priority high">🟡 </span>
<span v-else class="tic-priority normal">🟢 普通</span>
</span>
</div>
<div class="tic-row">
<span class="tic-label">上报人</span>
<span class="tic-value">{{ todoItem.description?.reporter || '—' }}</span>
</div>
<div class="tic-row">
<span class="tic-label">上报时间</span>
<span class="tic-value">{{ formatTime(todoItem.created_at) }}</span>
</div>
<div class="tic-row">
<span class="tic-label">描述</span>
<span class="tic-value tic-desc">{{ todoItem.description?.detail || todoItem.description?.description || '—' }}</span>
</div>
</div>
<!-- ================================================================== -->
<!-- 处理进度卡片 -->
<!-- ================================================================== -->
<div class="tic-card">
<div class="tic-card-title">📊 处理进度</div>
<div class="tic-row">
<span class="tic-label">状态</span>
<span class="tic-value">
<span class="tic-status-badge" :class="`tic-status-${todoItem.status}`">
{{ statusLabelMap[todoItem.status] || todoItem.status }}
</span>
</span>
</div>
<div class="tic-row">
<span class="tic-label">接单人</span>
<span class="tic-value">{{ todoItem.assigned_agent_id ? `坐席 ${todoItem.assigned_agent_id.slice(0, 8)}` : '未接单' }}</span>
</div>
<div class="tic-row">
<span class="tic-label">SLA</span>
<span class="tic-value">
<span class="tic-sla" :class="slaLevel">{{ slaText }}</span>
</span>
</div>
</div>
<!-- ================================================================== -->
<!-- 底部操作按钮 -->
<!-- ================================================================== -->
<div class="tic-actions">
<button class="tic-action-btn" @click="$emit('action', '接单')">📥 接单</button>
<button class="tic-action-btn" @click="$emit('action', '开始处理')">🔧 开始处理</button>
<button class="tic-action-btn tic-action-primary" @click="$emit('action', '结单')"> 结单</button>
<button class="tic-action-btn" @click="$emit('action', '转派')">🔄 转派</button>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
import type { TodoItemData } from '@/api/todo'
// ============================================================================
// Props
// ============================================================================
interface Props {
/** 当前选中的待办事项 */
todoItem: TodoItemData
}
const props = defineProps<Props>()
// ============================================================================
// Emits
// ============================================================================
interface Emits {
/** 操作按钮事件 */
(e: 'action', action: string): void
}
defineEmits<Emits>()
// ============================================================================
// 映射
// ============================================================================
/** 状态标签映射 */
const statusLabelMap: Record<string, string> = {
pending: '待处理',
processing: '处理中',
resolved: '已解决',
}
// ============================================================================
// 计算属性
// ============================================================================
/** SLA 倒计时(Mock */
const slaText = computed<string>(() => {
const desc = props.todoItem.description
if (desc?.sla_remaining) return desc.sla_remaining
// Mock: 根据 priority 生成 SLA
if (props.todoItem.priority === 'urgent') return '0h 32m'
if (props.todoItem.priority === 'high') return '2h 15m'
return '23h 45m'
})
/** SLA 等级(颜色指示) */
const slaLevel = computed<string>(() => {
const text = slaText.value
if (text.startsWith('0h') || text.includes('超时')) return 'sla-overdue'
// 提取小时数判断
const hourMatch = text.match(/(\d+)h/)
if (hourMatch) {
const hours = parseInt(hourMatch[1], 10)
if (hours <= 1) return 'sla-warning'
}
return 'sla-normal'
})
// ============================================================================
// 方法
// ============================================================================
/**
* 格式化时间
*
* @param isoString - ISO 时间字符串
* @returns 格式化后的时间字符串
*/
function formatTime(isoString: string): string {
if (!isoString) return '—'
try {
const date = new Date(isoString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return isoString
}
}
</script>
<style scoped>
.ticket-detail {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ---- 通用卡片 ---- */
.tic-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.tic-card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.tic-row {
display: flex;
align-items: flex-start;
padding: 5px 0;
}
.tic-label {
width: 70px;
flex-shrink: 0;
font-size: 13px;
color: var(--text-tertiary);
}
.tic-value {
flex: 1;
font-size: 13px;
color: var(--text-primary);
word-break: break-word;
}
.tic-desc {
line-height: 1.5;
white-space: pre-wrap;
}
/* ---- 优先级 ---- */
.tic-priority.urgent { color: var(--color-danger); font-weight: 600; }
.tic-priority.high { color: var(--color-warning); font-weight: 600; }
.tic-priority.normal { color: var(--color-success); }
/* ---- 状态徽标 ---- */
.tic-status-badge {
display: inline-block;
font-size: 12px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-weight: 500;
}
.tic-status-pending {
background-color: rgba(230, 162, 60, 0.1);
color: var(--color-warning);
}
.tic-status-processing {
background-color: rgba(64, 158, 255, 0.1);
color: var(--accent);
}
.tic-status-resolved {
background-color: rgba(103, 194, 58, 0.1);
color: var(--color-success);
}
/* ---- SLA ---- */
.tic-sla {
font-weight: 600;
font-size: 13px;
}
.tic-sla.sla-normal { color: var(--color-success); }
.tic-sla.sla-warning { color: var(--color-warning); }
.tic-sla.sla-overdue { color: var(--color-danger); }
/* ---- 操作按钮 ---- */
.tic-actions {
display: flex;
gap: 8px;
padding: 8px 0;
flex-wrap: wrap;
}
.tic-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.tic-action-btn:hover {
background-color: var(--bg-hover);
border-color: var(--accent);
color: var(--accent);
}
.tic-action-primary {
background-color: var(--accent);
color: var(--bg-secondary);
border-color: var(--accent);
}
.tic-action-primary:hover {
background-color: var(--accent-hover);
color: var(--bg-secondary);
}
</style>
@@ -0,0 +1,501 @@
<!-- =============================================================================
// 企微IT智能服务台 — 会话列表项组件(v5.4 头像+圆点+缩略头像)
// =============================================================================
// 说明:单个会话项的展示
// 包含:头像(渐变色) + 新消息圆点(3色) + 姓名 + 标签 + 优先级图标
// + 最后消息摘要 + 紧急度星级 + 处理对象缩略头像
// 已结单会话名字变灰、半透明
// 置顶显示📌图标,代办显示📋图标
// ============================================================================= -->
<template>
<div
class="conversation-item"
:class="{
active: active,
resolved: conversation.status === 'resolved',
'other-agent': !conversation.is_mine && conversation.status === 'serving',
}"
@click="$emit('click')"
>
<!-- 头像含新消息圆点 -->
<div class="conv-avatar-wrap">
<div class="conversation-avatar" :class="avatarColorClass">
{{ avatarText }}
</div>
<!-- 新消息圆点有新消息时显示3色区分优先级 -->
<span
v-if="hasNewMessage"
class="new-msg-dot"
:class="newMsgDotClass"
:title="newMsgDotTitle"
></span>
</div>
<!-- 信息区 -->
<div class="conversation-info">
<!-- 第一行姓名 + 标签 + 优先级图标 -->
<div class="conversation-name">
<!-- 置顶图标 -->
<span v-if="conversation.is_pinned" title="已置顶">📌</span>
<!-- 代办图标 -->
<span v-if="conversation.is_todo" title="代办">📋</span>
<!-- 姓名 -->
<span class="text-ellipsis">{{ conversation.employee_name || '未知' }}</span>
<!-- VIP标签 -->
<span v-if="conversation.is_vip" class="tag-badge tag-badge-vip">VIP</span>
<!-- 招手标签 -->
<span v-if="conversation.tags?.hand_raise" class="tag-badge tag-badge-hand-raise">招手</span>
<!-- 需介入标签 -->
<span v-if="conversation.tags?.need_intervene" class="tag-badge tag-badge-need-intervene">🔔需介入</span>
<!-- 情绪标签 -->
<span
v-if="conversation.tags?.emotion && conversation.tags.emotion !== 'neutral'"
class="tag-badge"
:class="emotionBadgeClass"
>
{{ emotionLabel }}
</span>
<!-- 其他坐席姓名标签 -->
<span
v-if="conversation.assigned_agent_name && !conversation.is_mine && conversation.status === 'serving'"
class="tag-badge tag-badge-agent"
>
{{ conversation.assigned_agent_name }}
</span>
<!-- 优先级图标组右侧排列 -->
<span class="priority-icons">
<span
v-for="pi in visiblePriorityIcons"
:key="pi.key"
class="priority-icon"
:class="pi.cssClass"
:title="pi.title"
:style="{ backgroundColor: pi.bg }"
>
{{ pi.icon }}
</span>
</span>
</div>
<!-- 第二行最后消息摘要 + 时间 -->
<div class="conversation-summary-row">
<span class="conversation-summary">{{ conversation.last_message_summary || '暂无消息' }}</span>
<span class="conversation-time">{{ formatTime }}</span>
</div>
<!-- 第三行紧急度 + 接手按钮 -->
<div class="conversation-meta">
<!-- 紧急度星级 -->
<div class="urgency-stars">
<span
v-for="i in 5"
:key="i"
class="urgency-star"
:class="{ empty: i > conversation.urgency_score }"
></span>
</div>
<!-- 接手按钮仅其他坐席的会话显示 -->
<el-button
v-if="showGrab && conversation.can_grab"
type="primary"
size="small"
link
class="grab-btn"
@click.stop="$emit('grab')"
>
接手
</el-button>
<!-- 退出按钮仅协作会话显示 -->
<el-button
v-if="showLeave"
type="danger"
size="small"
link
class="leave-btn"
@click.stop="$emit('leave')"
>
退出
</el-button>
</div>
</div>
<!-- 处理对象缩略头像右侧 -->
<div
v-if="showTargetAvatar"
class="conv-target-avatar"
:class="targetAvatarColorClass"
:title="targetAvatarTitle"
>
{{ targetAvatarText }}
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
import type { Conversation } from '@/api/conversation'
// ============================================================================
// 优先级图标配置
// ============================================================================
interface PriorityIconDef {
key: string
icon: string
cssClass: string
bg: string
highThreshold?: number
title: string
}
const PRIORITY_ICONS: PriorityIconDef[] = [
{
key: 'is_blocking',
icon: '⛔',
cssClass: 'pi-blocked',
bg: 'var(--color-danger)',
title: '阻断性问题',
},
{
key: 'impact_scope',
icon: '👥',
cssClass: 'pi-impact',
bg: 'var(--color-warning)',
highThreshold: 5,
title: '影响范围广',
},
{
key: 'role_level',
icon: '⭐',
cssClass: 'pi-role',
bg: 'var(--purple)',
title: '高角色等级',
},
{
key: 'is_repeat',
icon: '🔁',
cssClass: 'pi-repeat',
bg: 'var(--color-warning)',
title: '重复问题',
},
]
// ============================================================================
// 头像渐变色映射(根据姓名首字 hash 分配颜色)
// ============================================================================
const AVATAR_COLORS = ['av-blue', 'av-green', 'av-orange', 'av-purple', 'av-red', 'av-teal', 'av-pink'] as const
const TARGET_COLORS = ['ta-blue', 'ta-green', 'ta-orange', 'ta-purple', 'ta-red', 'ta-teal', 'ta-pink'] as const
/** 根据字符串计算颜色索引(稳定的 hash) */
function colorIndex(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
}
return Math.abs(hash) % AVATAR_COLORS.length
}
// ============================================================================
// Props & Emits
// ============================================================================
interface Props {
/** 会话对象 */
conversation: Conversation
/** 是否为当前选中的会话 */
active: boolean
/** 是否显示接手按钮(其他坐席会话区传入 true) */
showGrab?: boolean
/** 是否显示退出按钮(协作会话区传入 true) */
showLeave?: boolean
/** 会话所属分区: my/colleague/history(影响缩略头像显示逻辑) */
section?: string
}
const props = withDefaults(defineProps<Props>(), {
showGrab: false,
showLeave: false,
section: 'my',
})
defineEmits<{
click: []
grab: []
leave: []
}>()
// ============================================================================
// 计算属性
// ============================================================================
/** 头像文字(取姓名最后一个字) */
const avatarText = computed(() => {
const name = props.conversation.employee_name
if (!name) return '?'
return name.charAt(name.length - 1)
})
/** 头像渐变色 CSS 类 */
const avatarColorClass = computed(() => {
const name = props.conversation.employee_name || 'unknown'
return AVATAR_COLORS[colorIndex(name)]
})
/** 是否有新消息(基于未读数或状态判断) */
const hasNewMessage = computed(() => {
// 排队中/招手/需介入 = 有新消息
if (props.conversation.status === 'queued') return true
if (props.conversation.tags?.hand_raise) return true
if (props.conversation.tags?.need_intervene) return true
// 已结单 = 无新消息
if (props.conversation.status === 'resolved') return false
// 其他活跃会话默认显示普通蓝色圆点
return props.conversation.status === 'serving'
})
/** 新消息圆点 CSS 类(紧急红/普通蓝/低优灰) */
const newMsgDotClass = computed(() => {
// 紧急情况(招手/阻断性/需介入/愤怒)= 红色
if (
props.conversation.tags?.hand_raise ||
props.conversation.is_blocking ||
props.conversation.tags?.need_intervene ||
props.conversation.tags?.emotion === 'angry' ||
props.conversation.tags?.emotion === 'urgent'
) {
return 'dot-urgent'
}
// 已结单 = 无圆点
if (props.conversation.status === 'resolved') return ''
// 排队中 = 蓝色
if (props.conversation.status === 'queued') return 'dot-normal'
// 普通服务中 = 蓝色
return 'dot-normal'
})
/** 圆点 hover 提示文字 */
const newMsgDotTitle = computed(() => {
if (newMsgDotClass.value === 'dot-urgent') return '紧急新消息'
if (newMsgDotClass.value === 'dot-normal') return '有新消息'
return '新消息'
})
/** 是否显示右侧处理对象缩略头像 */
const showTargetAvatar = computed(() => {
// 历史会话不显示缩略头像
if (props.section === 'history') return false
// 我的会话和同事会话都显示
return true
})
/** 缩略头像文字 */
const targetAvatarText = computed(() => {
if (props.section === 'colleague') {
// 同事会话:显示坐席姓名最后字
return props.conversation.assigned_agent_name
? props.conversation.assigned_agent_name.charAt(props.conversation.assigned_agent_name.length - 1)
: '?'
}
// 我的会话:显示员工姓名最后字
const name = props.conversation.employee_name
return name ? name.charAt(name.length - 1) : '?'
})
/** 缩略头像颜色 */
const targetAvatarColorClass = computed(() => {
if (props.section === 'colleague') {
const name = props.conversation.assigned_agent_name || 'unknown'
return TARGET_COLORS[colorIndex(name)]
}
const name = props.conversation.employee_name || 'unknown'
return TARGET_COLORS[colorIndex(name)]
})
/** 缩略头像 hover 提示 */
const targetAvatarTitle = computed(() => {
if (props.section === 'colleague') {
return `坐席:${props.conversation.assigned_agent_name || '未知'}`
}
return props.conversation.employee_name || '未知'
})
/** 情绪标签的 CSS 类 */
const emotionBadgeClass = computed(() => {
const emotion = props.conversation.tags?.emotion
if (emotion === 'urgent') return 'tag-badge-emotion-urgent'
if (emotion === 'angry') return 'tag-badge-emotion-angry'
if (emotion === 'worried') return 'tag-badge-emotion-worried'
return ''
})
/** 情绪标签文字 */
const emotionLabel = computed(() => {
const emotionMap: Record<string, string> = {
urgent: '🔴紧急',
angry: '😡愤怒',
worried: '😟担忧',
}
return emotionMap[props.conversation.tags?.emotion || ''] || ''
})
/** 格式化时间显示 */
const formatTime = computed(() => {
const timeStr = props.conversation.last_message_at
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
// 1分钟内:刚刚
if (diffMin < 1) return '刚刚'
// 1小时内:X分钟前
if (diffMin < 60) return `${diffMin}分钟前`
// 今天:显示时间 HH:mm
if (date.toDateString() === now.toDateString()) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
// 昨天:昨天
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
// 更早:MM/DD
return `${date.getMonth() + 1}/${date.getDate()}`
})
/**
* 可见的优先级图标列表
* 根据会话属性动态计算哪些图标应该显示
*/
const visiblePriorityIcons = computed(() => {
const result: Array<PriorityIconDef & { title: string }> = []
const conv = props.conversation
for (const def of PRIORITY_ICONS) {
let visible = false
let title = def.title
switch (def.key) {
case 'is_blocking':
visible = !!conv.is_blocking
break
case 'impact_scope':
// impact_scope >= highThreshold(5) 时显示
visible = (conv.impact_scope || 0) >= (def.highThreshold || 5)
if (visible) {
title = `影响范围: ${conv.impact_scope}`
}
break
case 'role_level':
// 高等级用户 (level 包含总监/VP/C* 等关键词) 时显示
visible = isHighRoleLevel(conv)
break
case 'is_repeat':
// 重复追问 (repeat_count >= 3) 时显示
visible = (conv.tags?.repeat_count || 0) >= 3
if (visible) {
title = `重复追问: ${conv.tags.repeat_count}`
}
break
}
if (visible) {
result.push({ ...def, title })
}
}
return result
})
/**
* 判断是否为高角色等级
* 基于员工 level 字段判断是否包含总监/VP/CXO 等高级别关键词
*/
function isHighRoleLevel(conv: Conversation): boolean {
const highLevelKeywords = ['总监', 'VP', 'CIO', 'CTO', 'CFO', 'CEO', '总裁', '副总', '高级总监']
const level = conv.level || ''
return highLevelKeywords.some(kw => level.includes(kw))
}
</script>
<style scoped>
/* 其他坐席会话样式:稍微灰色 */
.conversation-item.other-agent {
opacity: 0.8;
}
.conversation-item.other-agent .conversation-avatar {
opacity: 0.8;
}
/* 摘要行(含时间和文本) */
.conversation-summary-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.conversation-summary-row .conversation-summary {
flex: 1;
min-width: 0;
}
.conversation-summary-row .conversation-time {
flex-shrink: 0;
font-size: 10px;
color: var(--text-placeholder);
}
/* 接手按钮样式 */
.grab-btn {
font-size: 12px;
padding: 0 4px;
margin-left: auto;
white-space: nowrap;
}
/* 退出按钮样式 */
.leave-btn {
font-size: 12px;
padding: 0 4px;
margin-left: auto;
white-space: nowrap;
}
/* 优先级图标组 */
.priority-icons {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
/* 优先级图标 — 16×16px 圆角方块 */
.priority-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 3px;
font-size: 8px;
line-height: 1;
flex-shrink: 0;
}
/* 阻断性图标闪烁提示 */
.priority-icon.pi-blocked {
animation: pi-blink 2s ease-in-out infinite;
}
@keyframes pi-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
</style>
@@ -0,0 +1,308 @@
<!-- =============================================================================
// 企微IT智能服务台 — 会话列表组件(v5.4 无折叠版)
// =============================================================================
// 说明:坐席工作台左侧的会话列表
// 功能:
// 1. 顶部搜索栏 + 快捷筛选标签(全部/待处理/进行中/已完成)
// 2. 扁平会话列表:我的会话 → 同事会话 → 历史会话(无折叠,始终全部展开)
// 3. 底部挂载 TodoPanel
// ============================================================================= -->
<template>
<div class="conversation-list-root">
<!-- 搜索栏 -->
<div class="sidebar-search">
<el-input
v-model="searchKeyword"
placeholder="搜索用户、关键词..."
prefix-icon="Search"
clearable
size="default"
/>
<!-- 快捷筛选标签 -->
<div class="filter-tags">
<span
v-for="tag in filterTags"
:key="tag.key"
class="filter-tag"
:class="{ active: activeFilter === tag.key }"
@click="activeFilter = tag.key"
>
{{ tag.label }}
</span>
</div>
</div>
<!-- 会话列表滚动区v5.4: 扁平列表无分类折叠 -->
<div class="conversation-list-scroll">
<!-- 加载中 -->
<div v-if="conversationStore.loadingConversations" class="loading-state">
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
<div class="loading-text">加载中...</div>
</div>
<!-- 我的会话始终展开 -->
<ConversationItem
v-for="conv in filteredMy"
:key="conv.id"
:conversation="conv"
:active="conv.id === conversationStore.currentConversationId"
:show-grab="conv.status === 'serving' && !conv.is_mine && !conv.is_collaborator"
:show-leave="conv.is_collaborator && conv.status === 'serving'"
section="my"
@click="conversationStore.selectConversation(conv.id)"
@grab="handleGrab(conv)"
@leave="handleLeave(conv)"
/>
<!-- 同事会话始终展开 -->
<ConversationItem
v-for="conv in filteredColleague"
:key="conv.id"
:conversation="conv"
:active="conv.id === conversationStore.currentConversationId"
:show-grab="conv.status === 'serving' && !conv.is_mine"
section="colleague"
@click="conversationStore.selectConversation(conv.id)"
@grab="handleGrab(conv)"
/>
<!-- 历史会话始终展开 -->
<ConversationItem
v-for="conv in filteredHistory"
:key="conv.id"
:conversation="conv"
:active="conv.id === conversationStore.currentConversationId"
section="history"
@click="conversationStore.selectConversation(conv.id)"
/>
<!-- 空状态 -->
<div
v-if="!conversationStore.loadingConversations && conversationStore.conversations.length === 0"
class="empty-state"
>
<el-empty description="暂无会话" :image-size="80" />
</div>
</div>
<!-- 底部待办面板 -->
<TodoPanel />
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useConversationStore } from '@/stores/conversation'
import { useTodoStore } from '@/stores/todo'
import ConversationItem from './ConversationItem.vue'
import TodoPanel from './TodoPanel.vue'
import type { Conversation } from '@/api/conversation'
// ============================================================================
// 筛选标签定义
// ============================================================================
interface FilterTag {
key: string
label: string
}
const filterTags: FilterTag[] = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'active', label: '进行中' },
{ key: 'done', label: '已完成' },
]
// ============================================================================
// 状态
// ============================================================================
/** 会话 Store */
const conversationStore = useConversationStore()
/** 待办 Store */
const todoStore = useTodoStore()
/** 搜索关键词 */
const searchKeyword = ref('')
/** 当前筛选标签 */
const activeFilter = ref<string>('all')
// ============================================================================
// 计算属性
// ============================================================================
/**
* 搜索 + 标签综合过滤函数
* 同时匹配关键词和筛选标签条件
*/
function applyFilters(conversations: Conversation[]): Conversation[] {
let result = conversations
// 关键词过滤
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.trim().toLowerCase()
result = result.filter(conv =>
conv.employee_name.toLowerCase().includes(keyword) ||
conv.department.toLowerCase().includes(keyword) ||
conv.last_message_summary.toLowerCase().includes(keyword)
)
}
// 标签过滤
if (activeFilter.value !== 'all') {
result = result.filter(conv => {
switch (activeFilter.value) {
case 'pending':
return conv.status === 'queued'
case 'active':
return conv.status === 'serving' || conv.status === 'ai_handling'
case 'done':
return conv.status === 'resolved'
default:
return true
}
})
}
return result
}
/** 我的会话(过滤后) */
const filteredMy = computed(() =>
applyFilters(conversationStore.myConversations)
)
/** 同事会话(过滤后) */
const filteredColleague = computed(() =>
applyFilters(conversationStore.colleagueConversations)
)
/** 历史会话(过滤后) */
const filteredHistory = computed(() =>
applyFilters(conversationStore.historyConversations)
)
// ============================================================================
// 方法
// ============================================================================
/**
* 接手其他坐席的会话
*/
async function handleGrab(conv: Conversation): Promise<void> {
const agentName = conv.assigned_agent_name || '其他坐席'
try {
await ElMessageBox.confirm(
`确定要接手 ${agentName} 的会话吗?接手后该会话将归您处理。`,
'接手确认',
{
confirmButtonText: '确认接手',
cancelButtonText: '取消',
type: 'warning',
}
)
await conversationStore.grabConv(conv.id)
ElMessage.success('接手成功')
} catch (error: any) {
if (error !== 'cancel' && error?.message) {
ElMessage.error(error.message || '接手失败')
}
}
}
/**
* 退出协作
*/
async function handleLeave(conv: Conversation): Promise<void> {
const ownerName = conv.assigned_agent_name || '主责坐席'
try {
await ElMessageBox.confirm(
`确定要退出 ${ownerName} 的协作会话吗?`,
'退出确认',
{
confirmButtonText: '确认退出',
cancelButtonText: '取消',
type: 'warning',
}
)
await conversationStore.leaveConvCollaboration(conv.id)
ElMessage.success('已退出协作')
} catch (error: any) {
if (error !== 'cancel' && error?.message) {
ElMessage.error(error.message || '退出失败')
}
}
}
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
todoStore.fetchTodoList()
})
</script>
<style scoped>
/* 根容器 */
.conversation-list-root {
height: 100%;
display: flex;
flex-direction: column;
}
/* 加载状态 */
.loading-state {
padding: 20px;
text-align: center;
}
.loading-text {
margin-top: 8px;
color: var(--text-tertiary);
font-size: 12px;
}
/* 空状态 */
.empty-state {
padding: 40px 20px;
text-align: center;
}
/* 快捷筛选标签 */
.filter-tags {
display: flex;
gap: 4px;
margin-top: 8px;
flex-wrap: wrap;
}
.filter-tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
cursor: pointer;
user-select: none;
transition: all 0.2s;
background-color: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid transparent;
}
.filter-tag:hover {
background-color: var(--bg-hover);
}
.filter-tag.active {
background-color: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
</style>
@@ -0,0 +1,310 @@
<!-- =============================================================================
// 企微IT智能服务台 — 摇人选人弹窗组件
// =============================================================================
// 说明:坐席点击「摇人」后弹出,供其搜索并选择要邀请的在线坐席
// 功能:
// 1. 搜索框:按坐席姓名模糊搜索
// 2. 坐席列表:显示在线/忙碌状态、当前负载
// 3. 选中确认:选中目标坐席后点击「确认」触发邀请
// 4. 排除自己、主责坐席和已在协作中的坐席
// ============================================================================= -->
<template>
<el-dialog
v-model="visible"
title="🤝 摇人 — 邀请坐席协作"
width="450px"
:close-on-click-modal="false"
destroy-on-close
>
<!-- 搜索框 -->
<el-input
v-model="searchText"
placeholder="搜索坐席姓名..."
prefix-icon="Search"
clearable
style="margin-bottom: 12px;"
/>
<!-- 坐席列表 -->
<div class="agent-list">
<div v-if="filteredAgents.length === 0" class="empty-hint">
暂无可邀请的在线坐席
</div>
<div
v-for="agent in filteredAgents"
:key="agent.user_id"
class="agent-item"
:class="{ selected: selectedAgentId === agent.user_id, busy: agent.current_load >= agent.max_load }"
@click="selectAgent(agent)"
>
<div class="agent-avatar">{{ getAvatar(agent.name) }}</div>
<div class="agent-info">
<div class="agent-name">
{{ agent.name }}
<el-tag
v-if="agent.current_load >= agent.max_load"
type="danger"
size="small"
effect="plain"
>
忙碌
</el-tag>
<el-tag
v-else-if="agent.current_load === 0"
type="success"
size="small"
effect="plain"
>
空闲
</el-tag>
<span v-else class="load-text">
负载 {{ agent.current_load }}/{{ agent.max_load }}
</span>
</div>
<div class="agent-sub" v-if="agent.current_load <= agent.max_load * 0.6">
<el-icon><Star /></el-icon> 推荐
</div>
</div>
<el-icon v-if="selectedAgentId === agent.user_id" class="check-icon" color="var(--accent)">
<Check />
</el-icon>
</div>
</div>
<!-- 底部按钮 -->
<template #footer>
<div class="dialog-footer">
<div class="selected-hint" v-if="selectedAgent">
已选{{ selectedAgent.name }}
</div>
<div>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedAgentId" :loading="submitting" @click="handleConfirm">
确认邀请
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, computed, watch } from 'vue'
import { useAgentStore } from '@/stores/agent'
import type { Agent } from '@/api/agent'
// ============================================================================
// Props & Emits
// ============================================================================
interface Props {
/** 弹窗是否可见(由父组件通过 v-model 控制) */
modelValue: boolean
/** 排除的坐席ID列表(主责坐席 + 已在协作中的坐席) */
excludeAgentIds?: string[]
}
const props = withDefaults(defineProps<Props>(), {
excludeAgentIds: () => [],
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'confirm': [agentId: string]
}>()
// ============================================================================
// 状态
// ============================================================================
const agentStore = useAgentStore()
/** 弹窗可见性(双向绑定) */
const visible = ref(props.modelValue)
/** 搜索关键词 */
const searchText = ref('')
/** 选中的坐席ID */
const selectedAgentId = ref<string | null>(null)
/** 是否正在提交 */
const submitting = ref(false)
// ============================================================================
// 监听
// ============================================================================
/** 监听弹窗打开:刷新坐席列表 */
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
// 打开弹窗时重置搜索和选中状态
searchText.value = ''
selectedAgentId.value = null
// 刷新在线坐席列表
agentStore.loadAvailableAgents()
}
})
/** 同步内部 visible 变化到外部 */
watch(visible, (val) => {
emit('update:modelValue', val)
})
// ============================================================================
// 计算属性
// ============================================================================
/** 当前选中的坐席对象 */
const selectedAgent = computed(() => {
if (!selectedAgentId.value) return null
return agentStore.availableAgents.find(a => a.user_id === selectedAgentId.value) || null
})
/** 过滤后的坐席列表(排除已过滤的 + 按搜索词筛选) */
const filteredAgents = computed(() => {
const excludeSet = new Set(props.excludeAgentIds)
let agents = agentStore.availableAgents.filter(a =>
// 排除已过滤的坐席
!excludeSet.has(a.user_id) &&
// 仅显示在线坐席
a.status === 'online'
)
// 按搜索词过滤
const keyword = searchText.value.trim().toLowerCase()
if (keyword) {
agents = agents.filter(a => a.name.toLowerCase().includes(keyword))
}
// 排序:空闲优先 → 负载低优先
return agents.sort((a, b) => {
if (a.current_load === 0 && b.current_load > 0) return -1
if (b.current_load === 0 && a.current_load > 0) return 1
return a.current_load - b.current_load
})
})
// ============================================================================
// 方法
// ============================================================================
/** 头像文字(取姓名最后一个字) */
function getAvatar(name: string): string {
if (!name) return '?'
return name.charAt(name.length - 1)
}
/** 选中坐席 */
function selectAgent(agent: Agent): void {
selectedAgentId.value = agent.user_id
}
/** 确认邀请 */
async function handleConfirm(): Promise<void> {
if (!selectedAgentId.value) return
submitting.value = true
emit('confirm', selectedAgentId.value)
// 父组件负责调用 store.inviteToConversation,成功后关闭弹窗
// 这里先不关闭,等父组件确认成功后再关
}
</script>
<style scoped>
.agent-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: 6px;
}
.agent-item {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
transition: background 0.15s;
}
.agent-item:last-child {
border-bottom: none;
}
.agent-item:hover {
background: var(--bg-tertiary);
}
.agent-item.selected {
background: var(--accent-soft);
}
.agent-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
flex-shrink: 0;
margin-right: 10px;
}
.agent-info {
flex: 1;
min-width: 0;
}
.agent-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
}
.load-text {
font-size: 12px;
color: var(--text-tertiary);
}
.agent-sub {
font-size: 12px;
color: var(--color-success);
display: flex;
align-items: center;
gap: 2px;
margin-top: 2px;
}
.check-icon {
font-size: 18px;
flex-shrink: 0;
}
.empty-hint {
padding: 24px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.selected-hint {
font-size: 13px;
color: var(--accent);
}
</style>
@@ -0,0 +1,443 @@
<!-- =============================================================================
// 企微IT智能服务台 — 邀请员工/部门加入会话弹窗组件
// =============================================================================
// 说明:主责坐席点击「邀请」后弹出,选择要邀请的员工或部门
// 功能:
// 1. 搜索框:按姓名/工号模糊搜索员工
// 2. 已选列表:显示已选中的被邀请人
// 3. 历史共享模式选择:最近10条/全部/不共享
// 4. 确认邀请:发送企微卡片通知给被邀请人
// 区别:和「摇人」不同,摇人选的是坐席,这里选的是员工/部门
// ============================================================================= -->
<template>
<el-dialog
v-model="visible"
title="📋 邀请 — 邀请员工/部门加入会话"
width="520px"
:close-on-click-modal="false"
destroy-on-close
>
<!-- 搜索框 -->
<el-input
v-model="searchText"
placeholder="搜索员工姓名或工号..."
prefix-icon="Search"
clearable
style="margin-bottom: 12px;"
@keyup.enter="handleSearch"
/>
<!-- 搜索结果 / 手动输入 -->
<div class="search-area">
<div v-if="searchResults.length === 0 && searchText" class="empty-hint">
未找到匹配的员工可直接输入添加
</div>
<div v-if="searchResults.length === 0 && !searchText" class="empty-hint">
请输入姓名或工号搜索或直接手动添加
</div>
<div
v-for="person in searchResults"
:key="person.id"
class="person-item"
:class="{ selected: isSelected(person.id) }"
@click="toggleSelect(person)"
>
<div class="person-avatar">
<img
v-if="person.avatar"
:src="person.avatar"
:alt="person.name"
class="avatar-img"
@error="onAvatarError($event)"
/>
<span v-else class="avatar-letter">{{ getAvatar(person.name) }}</span>
</div>
<div class="person-info">
<div class="person-name">{{ person.name }}</div>
<div class="person-dept">{{ person.department || '未知部门' }}</div>
</div>
<el-icon v-if="isSelected(person.id)" class="check-icon" color="var(--accent)">
<Check />
</el-icon>
</div>
<!-- 手动添加 -->
<div class="manual-add" v-if="searchText && searchResults.length === 0">
<el-button type="primary" link @click="addManualPerson">
<el-icon><Plus /></el-icon>
手动添加{{ searchText }}
</el-button>
</div>
</div>
<!-- 已选列表 -->
<div class="selected-area" v-if="selectedPeople.length > 0">
<div class="selected-header">
<span>已选 {{ selectedPeople.length }} </span>
<el-button type="primary" link size="small" @click="selectedPeople = []">清空</el-button>
</div>
<div class="selected-tags">
<el-tag
v-for="p in selectedPeople"
:key="p.id"
closable
effect="plain"
@close="removeSelected(p.id)"
>
{{ p.name }}{{ p.department ? `(${p.department})` : '' }}
</el-tag>
</div>
</div>
<!-- 历史共享模式 -->
<div class="history-mode">
<span class="history-label">历史消息共享</span>
<el-radio-group v-model="historyMode" size="small">
<el-radio-button value="recent10">最近10条</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="none">不共享</el-radio-button>
</el-radio-group>
</div>
<!-- 底部按钮 -->
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:disabled="selectedPeople.length === 0"
:loading="submitting"
@click="handleConfirm"
>
确认邀请{{ selectedPeople.length }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, watch } from 'vue'
import { Check, Plus } from '@element-plus/icons-vue'
import { inviteParticipant } from '@/api/conversation'
import type { ParticipantInfo } from '@/api/conversation'
import { ElMessage } from 'element-plus'
// ============================================================================
// Props & Emits
// ============================================================================
interface Props {
/** 弹窗是否可见(由父组件通过 v-model 控制) */
modelValue: boolean
/** 当前会话ID */
conversationId: string
/** 已在参与者列表中的ID(排除,避免重复邀请) */
existingParticipantIds?: string[]
}
const props = withDefaults(defineProps<Props>(), {
existingParticipantIds: () => [],
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'success': [conversation: any]
}>()
// ============================================================================
// 状态
// ============================================================================
/** 弹窗可见性 */
const visible = ref(props.modelValue)
/** 搜索关键词 */
const searchText = ref('')
/** 搜索结果(Mock 数据,阶段二对接企微通讯录API后替换) */
const searchResults = ref<ParticipantInfo[]>([])
/** 已选人员列表 */
const selectedPeople = ref<ParticipantInfo[]>([])
/** 历史共享模式 */
const historyMode = ref<'recent10' | 'all' | 'none'>('recent10')
/** 是否正在提交 */
const submitting = ref(false)
// ============================================================================
// Mock 员工数据(阶段二对接企微通讯录API后替换)
// ============================================================================
const mockEmployees: ParticipantInfo[] = [
{ id: 'zhangsan', name: '张三', department: '研发一部', type: 'employee', avatar: '' },
{ id: 'lisi', name: '李四', department: '市场部', type: 'employee', avatar: '' },
{ id: 'wangwu', name: '王五', department: '运维部', type: 'employee', avatar: '' },
{ id: 'zhaoliu', name: '赵六', department: '人力资源部', type: 'employee', avatar: '' },
{ id: 'qianqi', name: '钱七', department: '财务部', type: 'employee', avatar: '' },
{ id: 'sunba', name: '孙八', department: '产品部', type: 'employee', avatar: '' },
{ id: 'zhoujiu', name: '周九', department: '行政部', type: 'employee', avatar: '' },
{ id: 'wushi', name: '吴十', department: '法务部', type: 'employee', avatar: '' },
]
// ============================================================================
// 监听
// ============================================================================
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
// 打开弹窗时重置
searchText.value = ''
searchResults.value = []
selectedPeople.value = []
historyMode.value = 'recent10'
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
// ============================================================================
// 方法
// ============================================================================
/** 头像文字(取姓名最后一个字) */
function getAvatar(name: string): string {
if (!name) return '?'
return name.charAt(name.length - 1)
}
/** 头像加载失败时隐藏 img,降级显示首字母 */
function onAvatarError(event: Event): void {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
/** 搜索员工(阶段一用 Mock 数据,阶段二替换为企微通讯录API) */
function handleSearch(): void {
const keyword = searchText.value.trim().toLowerCase()
if (!keyword) {
searchResults.value = []
return
}
// Mock: 按姓名/工号模糊匹配
const existingSet = new Set(props.existingParticipantIds)
searchResults.value = mockEmployees.filter(
e => !existingSet.has(e.id) &&
(e.name.toLowerCase().includes(keyword) || e.id.toLowerCase().includes(keyword))
)
}
/** 判断是否已选中 */
function isSelected(id: string): boolean {
return selectedPeople.value.some(p => p.id === id)
}
/** 切换选中状态 */
function toggleSelect(person: ParticipantInfo): void {
const idx = selectedPeople.value.findIndex(p => p.id === person.id)
if (idx >= 0) {
selectedPeople.value.splice(idx, 1)
} else {
selectedPeople.value.push(person)
}
}
/** 从已选列表移除 */
function removeSelected(id: string): void {
const idx = selectedPeople.value.findIndex(p => p.id === id)
if (idx >= 0) {
selectedPeople.value.splice(idx, 1)
}
}
/** 手动添加搜索不到的员工 */
function addManualPerson(): void {
const name = searchText.value.trim()
if (!name) return
// 用搜索词作为姓名和ID
const person: ParticipantInfo = {
id: `manual_${Date.now()}`,
name,
department: '',
type: 'employee',
}
if (!isSelected(person.id)) {
selectedPeople.value.push(person)
}
searchText.value = ''
searchResults.value = []
}
/** 确认邀请 */
async function handleConfirm(): Promise<void> {
if (selectedPeople.value.length === 0) return
submitting.value = true
try {
const result = await inviteParticipant(props.conversationId, {
participants: selectedPeople.value.map(p => ({
id: p.id,
name: p.name,
department: p.department,
type: p.type,
})),
history_mode: historyMode.value,
})
ElMessage.success(`已邀请 ${selectedPeople.value.length} 人加入会话`)
emit('success', result)
visible.value = false
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '邀请失败'
ElMessage.error(msg)
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.search-area {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: 6px;
margin-bottom: 12px;
}
.person-item {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
transition: background 0.15s;
}
.person-item:last-child {
border-bottom: none;
}
.person-item:hover {
background: var(--bg-tertiary);
}
.person-item.selected {
background: var(--accent-soft);
}
.person-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
margin-right: 10px;
overflow: hidden;
position: relative;
}
.person-avatar .avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
}
.person-avatar .avatar-letter {
color: #fff;
line-height: 1;
}
.person-info {
flex: 1;
min-width: 0;
}
.person-name {
font-size: 14px;
font-weight: 500;
}
.person-dept {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
.check-icon {
font-size: 18px;
flex-shrink: 0;
}
.empty-hint {
padding: 20px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
.manual-add {
padding: 8px 12px;
border-top: 1px dashed var(--border-light);
}
.selected-area {
margin-bottom: 12px;
padding: 10px;
border: 1px solid var(--border-light);
border-radius: 6px;
background: var(--bg-tertiary);
}
.selected-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.history-mode {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.history-label {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,302 @@
<!-- =============================================================================
// 企微IT智能服务台 — 参与者面板组件
// =============================================================================
// 说明:在聊天区顶部展示当前会话的参与者列表,提供邀请和移除入口
// 功能:
// 1. 显示参与者列表(坐席 + 协作坐席 + 被邀请员工)含头像
// 2. 主责坐席可点击「邀请」按钮打开邀请弹窗
// 3. 主责坐席可移除参与者
// 4. 参与者加入/退出状态实时更新
// 位置:放在 UserInfoBar 下方,聊天消息区上方
// ============================================================================= -->
<template>
<div class="participant-bar" v-if="hasParticipants">
<!-- 参与者列表 -->
<div class="participant-bar__list">
<span class="participant-bar__label">
{{ totalParticipantCount }}人参与:
</span>
<!-- 主责坐席始终第一个显示 -->
<div class="participant-item participant-item--primary">
<div class="participant-avatar participant-avatar--primary">
<span class="avatar-letter">{{ agentName ? agentName.charAt(agentName.length - 1) : '席' }}</span>
</div>
<span class="participant-name">{{ agentName }}(主责)</span>
</div>
<!-- 协作坐席 -->
<div
v-for="aid in collaboratingAgentIds"
:key="'collab-' + aid"
class="participant-item participant-item--collab"
>
<div class="participant-avatar participant-avatar--collab">
<span class="avatar-letter">{{ getAgentName(aid).charAt(getAgentName(aid).length - 1) }}</span>
</div>
<span class="participant-name">{{ getAgentName(aid) }}(协作)</span>
</div>
<!-- 被邀请参与者 -->
<div
v-for="p in participants"
:key="'p-' + p.id"
class="participant-item"
:class="{ 'participant-item--pending': !p.joined }"
>
<!-- 头像 avatar img无则首字母降级 -->
<div class="participant-avatar" :class="p.joined ? '' : 'participant-avatar--pending'">
<img
v-if="p.avatar"
:src="p.avatar"
:alt="p.name"
class="avatar-img"
@error="onAvatarError($event)"
/>
<span v-else class="avatar-letter">{{ p.name.charAt(p.name.length - 1) }}</span>
</div>
<span class="participant-name">
{{ p.name }}{{ p.type === 'employee' ? '' : `(${p.type})` }}
<span v-if="!p.joined" class="pending-hint">待加入</span>
</span>
<!-- 移除按钮仅主责坐席可见 -->
<el-icon
v-if="isPrimaryAgent"
class="remove-icon"
@click.stop="handleRemove(p.id)"
>
<Close />
</el-icon>
</div>
</div>
<!-- 操作按钮 -->
<div class="participant-bar__actions">
<!-- 邀请按钮仅主责坐席可见 -->
<el-button
v-if="isPrimaryAgent"
size="small"
type="primary"
link
@click="$emit('invite')"
>
+ 邀请
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
import { Close } from '@element-plus/icons-vue'
import type { ParticipantInfo } from '@/api/conversation'
// ============================================================================
// Props & Emits
// ============================================================================
interface Props {
/** 参与者列表(不含主责坐席,从 currentConversation.participants 获取) */
participants: ParticipantInfo[]
/** 主责坐席姓名 */
agentName: string
/** 当前登录坐席是否为主责坐席 */
isPrimaryAgent: boolean
/** 协作坐席ID列表 */
collaboratingAgentIds?: string[]
/** 协作坐席姓名映射(agent_id → name */
collaboratingAgentNames?: Record<string, string>
}
const props = withDefaults(defineProps<Props>(), {
collaboratingAgentIds: () => [],
collaboratingAgentNames: () => ({}),
})
const emit = defineEmits<{
/** 点击邀请按钮 */
'invite': []
/** 移除参与者(主责坐席操作) */
'remove': [userId: string]
}>()
// ============================================================================
// 计算属性
// ============================================================================
/** 是否有参与者(排除只有坐席自己一个人的情况) */
const hasParticipants = computed(() => {
return props.participants.length > 0 || props.collaboratingAgentIds.length > 0
})
/** 总参与者数量(主责 + 协作 + 被邀请) */
const totalParticipantCount = computed(() => {
// 主责坐席 1人 + 协作坐席 + 被邀请参与者
return 1 + props.collaboratingAgentIds.length + props.participants.length
})
// ============================================================================
// 方法
// ============================================================================
/** 获取协作坐席姓名(从映射表查找,找不到则显示ID) */
function getAgentName(agentId: string): string {
return props.collaboratingAgentNames?.[agentId] || agentId
}
/** 移除参与者(仅主责坐席可操作) */
function handleRemove(userId: string): void {
if (!props.isPrimaryAgent) return
emit('remove', userId)
}
/** 头像加载失败时隐藏 img,降级显示首字母 */
function onAvatarError(event: Event): void {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
</script>
<style scoped>
.participant-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: var(--bg-tertiary, #f5f7fa);
border-bottom: 1px solid var(--border-light, #e4e7ed);
font-size: 13px;
flex-shrink: 0;
}
.participant-bar__list {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
overflow-x: auto;
}
/* 隐藏滚动条但保留滚动功能 */
.participant-bar__list::-webkit-scrollbar {
display: none;
}
.participant-bar__label {
color: var(--text-secondary, #909399);
white-space: nowrap;
font-size: 12px;
flex-shrink: 0;
}
/* 参与者条目 */
.participant-item {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
padding: 2px 6px;
border-radius: 12px;
background: var(--bg-secondary, #fff);
border: 1px solid var(--border-light, #e4e7ed);
transition: background 0.15s;
}
.participant-item:hover {
background: var(--bg-tertiary, #f5f7fa);
}
.participant-item--primary {
border-color: var(--accent, #3b82f6);
background: rgba(59, 130, 246, 0.06);
}
.participant-item--collab {
border-color: var(--success, #67c23a);
background: rgba(103, 194, 58, 0.06);
}
.participant-item--pending {
opacity: 0.7;
}
/* 头像容器 */
.participant-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
background: var(--accent, #3b82f6);
}
.participant-avatar--primary {
background: var(--accent, #3b82f6);
}
.participant-avatar--collab {
background: var(--success, #67c23a);
}
.participant-avatar--pending {
background: var(--text-tertiary, #c0c4cc);
}
/* 头像图片 */
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
}
/* 头像首字降级 */
.avatar-letter {
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 1;
}
/* 参与者姓名 */
.participant-name {
font-size: 12px;
white-space: nowrap;
color: var(--text-primary, #303133);
}
.pending-hint {
font-size: 10px;
color: var(--text-tertiary, #c0c4cc);
margin-left: 2px;
}
/* 移除图标 */
.remove-icon {
font-size: 12px;
color: var(--text-tertiary, #c0c4cc);
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s;
}
.remove-icon:hover {
color: var(--danger, #f56c6c);
}
.participant-bar__actions {
flex-shrink: 0;
margin-left: 8px;
}
</style>
@@ -0,0 +1,359 @@
<!-- =============================================================================
// 企微IT智能服务台 — 待办事项面板组件
// =============================================================================
// 说明:左栏底部挂载的待办面板
// 功能:
// 1. 显示待办列表(优先级圆点 + 文本 + 类型标签 + 时间)
// 2. 点击条目 → todoStore.selectTodoItem + workspaceView = 'task'
// 3. 底部显示坐席在线统计
// ============================================================================= -->
<template>
<div class="todo-panel">
<!-- 标题行 -->
<div class="todo-header">
<span class="todo-title">📋 待办事项</span>
<span v-if="todoStore.urgentCount > 0" class="todo-urgent-badge">
{{ todoStore.urgentCount }}
</span>
</div>
<!-- 待办列表 -->
<div class="todo-list">
<div
v-for="item in todoStore.pendingTodos"
:key="item.id"
class="todo-item"
:class="{ 'todo-item-active': todoStore.currentTodoItem?.id === item.id }"
@click="handleTodoClick(item)"
>
<!-- 优先级圆点 -->
<span
class="todo-priority-dot"
:class="`priority-${item.priority}`"
></span>
<!-- 文本 -->
<span class="todo-text text-ellipsis">{{ item.title }}</span>
<!-- 类型标签 -->
<span class="todo-type-tag" :class="`type-${item.type}`">
{{ typeLabel(item.type) }}
</span>
<!-- 时间 -->
<span class="todo-time">{{ formatTodoTime(item.created_at) }}</span>
<!-- v5.4: 上报人缩略头像 -->
<div
class="ki-avatar"
:class="todoAvatarColor(item)"
:title="todoAvatarTitle(item)"
>
{{ todoAvatarText(item) }}
</div>
</div>
<!-- 空状态 -->
<div v-if="todoStore.pendingTodos.length === 0 && !todoStore.loading" class="todo-empty">
暂无待办
</div>
</div>
<!-- 底部坐席在线统计 -->
<div class="todo-footer">
<span class="agent-stat">
<span class="agent-dot dot-online"></span>
<span>{{ onlineAgents }} 在线</span>
</span>
<span class="agent-stat">
<span class="agent-dot dot-busy"></span>
<span>{{ busyAgents }} 忙碌</span>
</span>
<span class="agent-stat">
<span class="agent-dot dot-offline"></span>
<span>{{ offlineAgents }} 离线</span>
</span>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todo'
import { useConversationStore } from '@/stores/conversation'
import { getAgentStats } from '@/mock/data'
import type { TodoItemData } from '@/api/todo'
// ============================================================================
// Store
// ============================================================================
const todoStore = useTodoStore()
const conversationStore = useConversationStore()
// ============================================================================
// 坐席在线统计(从 mock 数据计算,后续接入 agentStore 实时数据)
// ============================================================================
const stats = getAgentStats()
const onlineAgents = ref(stats.onlineAgents)
const busyAgents = ref(stats.busyAgents)
const offlineAgents = ref(stats.offlineAgents)
// ============================================================================
// 方法
// ============================================================================
/** 类型标签文字映射 */
function typeLabel(type: string): string {
const map: Record<string, string> = {
ticket: '工单',
approval: '审批',
device: '设备',
}
return map[type] || type
}
/** 格式化待办时间 */
function formatTodoTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin}分钟前`
if (date.toDateString() === now.toDateString()) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
return `${date.getMonth() + 1}/${date.getDate()}`
}
/** 点击待办条目 */
function handleTodoClick(item: TodoItemData): void {
todoStore.selectTodoItem(item)
conversationStore.workspaceView = 'task'
}
// ============================================================================
// v5.4: 待办缩略头像辅助函数
// ============================================================================
const KI_COLORS = ['ka-blue', 'ka-green', 'ka-orange', 'ka-purple', 'ka-red'] as const
/** 根据标题 hash 分配头像颜色 */
function kiColorIndex(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
}
return Math.abs(hash) % KI_COLORS.length
}
/** 缩略头像文字(取标题中第一个中文或部门首字) */
function todoAvatarText(item: TodoItemData): string {
const title = item.title || ''
// 提取标题中" - "后面的部门名首字
const dashIdx = title.indexOf(' - ')
if (dashIdx >= 0) {
const dept = title.substring(0, dashIdx).trim()
return dept.charAt(dept.length - 1)
}
return title.charAt(0)
}
/** 缩略头像颜色 */
function todoAvatarColor(item: TodoItemData): string {
return KI_COLORS[kiColorIndex(item.title || 'default')]
}
/** 缩略头像 hover 提示 */
function todoAvatarTitle(item: TodoItemData): string {
const title = item.title || ''
const dashIdx = title.indexOf(' - ')
if (dashIdx >= 0) {
return title.substring(0, dashIdx).trim()
}
return title
}
</script>
<style scoped>
/* 面板容器 */
.todo-panel {
border-top: 1px solid var(--border-color);
max-height: 220px;
display: flex;
flex-direction: column;
flex-shrink: 0;
background-color: var(--bg-secondary);
}
/* 标题行 */
.todo-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.todo-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.todo-urgent-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 16px;
padding: 0 5px;
border-radius: 8px;
font-size: 10px;
font-weight: 700;
background-color: var(--color-danger);
color: var(--bg-secondary);
}
/* 待办列表 */
.todo-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* 待办条目 */
.todo-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.todo-item:hover {
background-color: var(--bg-hover);
}
.todo-item-active {
background-color: var(--bg-accent-soft);
}
/* 优先级圆点 */
.todo-priority-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.todo-priority-dot.priority-urgent {
background-color: var(--color-danger);
}
.todo-priority-dot.priority-high {
background-color: var(--color-warning);
}
.todo-priority-dot.priority-normal {
background-color: var(--text-placeholder);
}
/* 文本 */
.todo-text {
flex: 1;
font-size: 12px;
color: var(--text-primary);
min-width: 0;
}
/* 类型标签 */
.todo-type-tag {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
line-height: 1.4;
font-weight: 500;
flex-shrink: 0;
border: 1px solid;
}
/* 工单 — 蓝色 */
.todo-type-tag.type-ticket {
color: var(--accent);
background-color: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
/* 审批 — 紫色 */
.todo-type-tag.type-approval {
color: var(--purple);
background-color: var(--purple-soft);
border-color: color-mix(in srgb, var(--purple) 30%, transparent);
}
/* 设备 — 橙色 */
.todo-type-tag.type-device {
color: var(--color-warning);
background-color: var(--warning-soft);
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
}
/* 时间 */
.todo-time {
font-size: 10px;
color: var(--text-placeholder);
flex-shrink: 0;
white-space: nowrap;
}
/* 空状态 */
.todo-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
}
/* 底部坐席统计 */
.todo-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
border-top: 1px solid var(--border-light);
flex-shrink: 0;
}
.agent-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-tertiary);
}
/* 状态圆点 */
.agent-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-online {
background-color: var(--color-success);
}
.dot-busy {
background-color: var(--color-warning);
}
.dot-offline {
background-color: var(--text-placeholder);
}
</style>
@@ -0,0 +1,446 @@
<!-- =============================================================================
// 企微IT智能服务台 — 顶栏组件
// =============================================================================
// 说明:独立顶栏组件,从 Workspace.vue 顶部栏抽离
// 包含:Logo + 标题 + 主题切换开关 + 坐席状态 + 登出
// ============================================================================= -->
<template>
<header class="top-bar">
<!-- ==================================================================== -->
<!-- 主顶栏 -->
<!-- ==================================================================== -->
<div class="top-bar-main">
<!-- 左侧Logo + 标题 -->
<div class="top-bar-left">
<span class="logo-block">IT</span>
<span class="title-gradient">IT智能服务台</span>
<span class="subtitle">· 坐席工作台 AI驱动 · 多系统对接 · 一站式处理</span>
</div>
<!-- 右侧主题切换开关 + 坐席状态 + 登出 -->
<div class="top-bar-right">
<!-- 主题切换开关 滑轨 🌙匹配原型v5.3 -->
<div
class="theme-switch"
:title="themeStore.currentTheme === 'light' ? '切换到深色模式' : '切换到浅色模式'"
@click="onThemeSwitch"
>
<span class="switch-icon"></span>
<div class="switch-track">
<div class="switch-thumb"></div>
</div>
<span class="switch-icon">🌙</span>
</div>
<!-- 坐席状态切换 -->
<el-dropdown trigger="click" @command="handleStatusChange">
<span style="cursor: pointer; display: flex; align-items: center; gap: 4px;">
<el-tag :type="statusTagType" size="small" effect="dark">
{{ statusLabel }}
</el-tag>
<span class="agent-name">{{ agentStore.agentName }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="online">
🟢 在线 接收新会话
</el-dropdown-item>
<el-dropdown-item command="busy">
🟡 忙碌 不接新会话
</el-dropdown-item>
<el-dropdown-item command="offline">
离线 不接收任何会话
</el-dropdown-item>
<el-dropdown-item divided command="otp">
🔐 OTP二次验证
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 登出按钮 -->
<el-button text type="danger" @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
登出
</el-button>
<!-- 小屏幕下显示/隐藏助手面板 -->
<el-button
class="assistant-toggle-btn"
text
@click="$emit('toggleAssistant')"
>
<el-icon><Operation /></el-icon>
</el-button>
</div>
</div>
</header>
<!-- ==================================================================== -->
<!-- OTP 设置对话框 -->
<!-- ==================================================================== -->
<el-dialog
v-model="otpDialogVisible"
title="OTP二次验证设置"
width="400px"
:close-on-click-modal="false"
>
<div v-if="otpLoading" v-loading="otpLoading" style="min-height: 200px;"></div>
<div v-else-if="otpBindData">
<!-- 已绑定状态 -->
<template v-if="isOtpBound">
<el-result icon="success" title="OTP已绑定">
<template #sub-title>
<p>当前账号已绑定OTP二次验证</p>
<p style="color: var(--text-tertiary); font-size: 12px;">
密钥{{ otpBindData.secret }}
</p>
</template>
</el-result>
<el-button type="danger" @click="handleUnbindOtp">解绑OTP</el-button>
</template>
<!-- 未绑定状态显示二维码 -->
<template v-else>
<div style="text-align: center;">
<p style="margin-bottom: 16px;">请使用身份验证器如Google Authenticator扫码绑定</p>
<img :src="otpBindData.qr_code" alt="OTP二维码" style="width: 200px; height: 200px; margin: 0 auto;" />
<el-divider>或手动输入密钥</el-divider>
<el-input v-model="otpBindData.secret" readonly>
<template #append>
<el-button @click="copyToClipboard(otpBindData.secret)">复制</el-button>
</template>
</el-input>
<el-divider>验证启用</el-divider>
<el-input v-model="otpInputCode" placeholder="输入6位OTP码" maxlength="6" style="width: 200px;" />
<el-button type="primary" style="margin-top: 12px;" @click="handleVerifyOtp">
验证并启用
</el-button>
</div>
</template>
</div>
</el-dialog>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAgentStore } from '@/stores/agent'
import { useThemeStore } from '@/stores/theme'
import { useWebSocket } from '@/composables/useWebSocket'
import { useConversationStore } from '@/stores/conversation'
import { bindOtp, verifyOtp, unbindOtp } from '@/api/agent'
// ============================================================================
// 事件
// ============================================================================
defineEmits<{
(e: 'toggleAssistant'): void
}>()
// ============================================================================
// 状态
// ============================================================================
/** 坐席 Store */
const agentStore = useAgentStore()
/** 主题 Store */
const themeStore = useThemeStore()
/** 会话 Store */
const conversationStore = useConversationStore()
/** WebSocket 组合式函数 */
const { disconnect: disconnectWs } = useWebSocket()
// ============================================================================
// OTP 双因素认证
// ============================================================================
/** OTP 对话框可见性 */
const otpDialogVisible = ref(false)
/** OTP 绑定数据(二维码和密钥) */
const otpBindData = ref<{ qr_code: string; secret: string } | null>(null)
/** 用户输入的 OTP 码 */
const otpInputCode = ref('')
/** OTP 加载状态 */
const otpLoading = ref(false)
// 复制到剪贴板
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
/** 是否已绑定 OTP */
const isOtpBound = ref(false)
/**
* 打开 OTP 设置对话框
*/
async function handleOpenOtp(): Promise<void> {
otpLoading.value = true
otpDialogVisible.value = true
otpInputCode.value = ''
try {
const data = await bindOtp()
otpBindData.value = data
isOtpBound.value = !!data.secret
} catch (error) {
console.error('获取OTP绑定信息失败:', error)
ElMessage.error('获取OTP绑定信息失败')
} finally {
otpLoading.value = false
}
}
/**
* 验证并启用 OTP
*/
async function handleVerifyOtp(): Promise<void> {
if (!otpInputCode.value || otpInputCode.value.length < 6) {
ElMessage.warning('请输入6位OTP码')
return
}
otpLoading.value = true
try {
await verifyOtp(agentStore.userId, otpInputCode.value)
ElMessage.success('OTP验证成功,已启用二次验证')
otpDialogVisible.value = false
} catch (error) {
console.error('OTP验证失败:', error)
} finally {
otpLoading.value = false
}
}
/**
* 解绑 OTP
*/
async function handleUnbindOtp(): Promise<void> {
try {
await ElMessageBox.confirm('确定要解绑OTP吗?解绑后将不再需要二次验证。', '提示', {
confirmButtonText: '确定解绑',
cancelButtonText: '取消',
type: 'warning',
})
otpLoading.value = true
await unbindOtp()
ElMessage.success('OTP已解绑')
otpDialogVisible.value = false
} catch (error) {
if ((error as Error)?.message?.includes('cancel')) {
// 用户取消
} else {
console.error('解绑OTP失败:', error)
}
} finally {
otpLoading.value = false
}
}
// ============================================================================
// 计算属性
// ============================================================================
/** 坐席状态标签文字 */
const statusLabel = computed(() => {
const statusMap: Record<string, string> = {
online: '在线',
busy: '忙碌',
offline: '离线',
}
return statusMap[agentStore.agentStatus] || '离线'
})
/** 坐席状态标签类型 */
const statusTagType = computed(() => {
const typeMap: Record<string, string> = {
online: 'success',
busy: 'warning',
offline: 'info',
}
return typeMap[agentStore.agentStatus] || 'info'
})
// ============================================================================
// 方法
// ============================================================================
/**
* 主题开关切换回调(el-switch 的 @change
* 直接调用 themeStore.toggleTheme()watch 会自动同步开关状态
*/
function onThemeSwitch(): void {
themeStore.toggleTheme()
}
/**
* 切换坐席状态
*/
async function handleStatusChange(status: string): Promise<void> {
if (status === 'otp') {
// 打开OTP设置
await handleOpenOtp()
return
}
try {
await agentStore.changeStatus(status)
ElMessage.success(`已切换为${statusLabel.value}`)
} catch (error) {
console.error('切换状态失败:', error)
}
}
/**
* 登出
*/
async function handleLogout(): Promise<void> {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
disconnectWs()
conversationStore.stopAllPolling()
agentStore.logout()
} catch {
// 用户取消
}
}
</script>
<style scoped>
.top-bar {
flex-shrink: 0;
}
.top-bar-main {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.top-bar-left {
display: flex;
align-items: center;
gap: 8px;
}
.logo-block {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), var(--accent));
color: var(--bg-secondary);
font-size: 14px;
font-weight: 800;
letter-spacing: -0.5px;
flex-shrink: 0;
}
.title-gradient {
font-size: 17px;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
white-space: nowrap;
}
.subtitle {
font-size: 13px;
color: var(--text-tertiary);
white-space: nowrap;
}
.top-bar-right {
display: flex;
align-items: center;
gap: 12px;
}
.theme-toggle-btn {
font-size: 18px;
padding: 4px 8px;
}
.agent-name {
font-size: 14px;
color: var(--text-secondary);
}
/* 主题切换滑轨样式(匹配原型v5.3) */
.theme-switch {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.theme-switch .switch-icon {
font-size: 15px;
}
.theme-switch .switch-track {
width: 40px;
height: 22px;
background: var(--border-light);
border-radius: 11px;
position: relative;
transition: background 0.3s;
}
[data-theme="dark"] .theme-switch .switch-track {
background: var(--accent);
}
.theme-switch .switch-thumb {
width: 18px;
height: 18px;
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(18px);
}
/* 小屏幕下显示助手切换按钮 */
.assistant-toggle-btn {
display: none;
}
@media (max-width: 1024px) {
.assistant-toggle-btn {
display: inline-flex;
}
}
</style>
@@ -0,0 +1,193 @@
// =============================================================================
// 企微IT智能服务台 — 快捷键管理组合式函数(v5.3 终版)
// =============================================================================
// 快捷键列表:
// Ctrl+1/2/3: AI 推荐填入
// Alt+1~7: 快速回复一级分类切换
// 数字键(1-9): 快速回复二/三级条目选择
// ↑↓: 快速回复三级条目导航
// Enter: 快速回复确认填入
// ←/Backspace: 返回上一级
// /: 聚焦搜索框
// 规则:仅在输入框未聚焦时生效
// =============================================================================
import { onMounted, onUnmounted } from 'vue'
// ==========================================================================
// 类型定义
// ==========================================================================
type ShortcutCallback = (event: KeyboardEvent) => void
interface ShortcutEntry {
ctrl?: boolean
alt?: boolean
shift?: boolean
key: string
handler: ShortcutCallback
}
interface UseKeyboardShortcutsOptions {
/** AI 推荐填入(Ctrl+1/2/3 */
onAiRecommend?: (index: number) => void
/** 快速回复一级分类切换(Alt+1~7) */
onQuickReplyCategory?: (index: number) => void
/** 快速回复二/三级数字键选择(1-9) */
onQuickReplyDigit?: (digit: number) => void
/** 返回上级(←/Backspace */
onQuickReplyBack?: () => void
/** 快速回复条目导航(↑↓) */
onQuickReplyNavigate?: (direction: 'up' | 'down') => void
/** 快速回复确认填入(Enter) */
onQuickReplyConfirm?: () => void
/** 聚焦搜索框(/ */
onFocusSearch?: () => void
}
// ==========================================================================
// Helpers
// ==========================================================================
/**
* 判断当前焦点是否在输入元素上
*/
function isInputFocused(): boolean {
const active = document.activeElement
if (!active) return false
const tagName = (active as HTMLElement).tagName?.toLowerCase()
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true
}
if ((active as HTMLElement).isContentEditable) {
return true
}
const role = (active as HTMLElement).getAttribute?.('role')
if (role === 'textbox' || role === 'combobox' || role === 'searchbox') {
return true
}
// Element Plus 内部元素
if ((active as HTMLElement).closest?.('.el-input') || (active as HTMLElement).closest?.('.el-textarea')) {
return true
}
return false
}
/**
* 快捷键管理组合式函数
*
* 在组件挂载时注册全局快捷键,卸载时自动清除。
* 输入框聚焦时仅允许 Ctrl 组合键生效,避免与正常输入冲突。
*/
export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}): void {
const shortcuts: ShortcutEntry[] = []
function registerShortcuts(): void {
// Ctrl+1/2/3: AI 推荐填入
if (options.onAiRecommend) {
shortcuts.push(
{ ctrl: true, key: '1', handler: () => options.onAiRecommend!(0) },
{ ctrl: true, key: '2', handler: () => options.onAiRecommend!(1) },
{ ctrl: true, key: '3', handler: () => options.onAiRecommend!(2) },
)
}
// Alt+1~7: 快速回复一级分类切换
if (options.onQuickReplyCategory) {
for (let i = 1; i <= 7; i++) {
shortcuts.push({
alt: true,
key: String(i),
handler: () => options.onQuickReplyCategory!(i - 1),
})
}
}
// 数字键 1-9: 快速回复二/三级条目选择
if (options.onQuickReplyDigit) {
for (let i = 1; i <= 9; i++) {
shortcuts.push({
key: String(i),
handler: () => options.onQuickReplyDigit!(i),
})
}
}
// ←/Backspace: 返回上一级
if (options.onQuickReplyBack) {
shortcuts.push(
{ key: 'Backspace', handler: () => options.onQuickReplyBack!() },
{ key: 'ArrowLeft', handler: () => options.onQuickReplyBack!() },
)
}
// ↑↓: 条目导航
if (options.onQuickReplyNavigate) {
shortcuts.push(
{ key: 'ArrowUp', handler: () => options.onQuickReplyNavigate!('up') },
{ key: 'ArrowDown', handler: () => options.onQuickReplyNavigate!('down') },
)
}
// Enter: 确认填入
if (options.onQuickReplyConfirm) {
shortcuts.push({ key: 'Enter', handler: () => options.onQuickReplyConfirm!() })
}
// /: 聚焦搜索框
if (options.onFocusSearch) {
shortcuts.push({ key: '/', handler: () => options.onFocusSearch!() })
}
}
/**
* 全局键盘事件处理器
*/
function handleKeydown(event: KeyboardEvent): void {
const inputActive = isInputFocused()
for (const entry of shortcuts) {
// 主键匹配
if (entry.key !== event.key) continue
// 修饰键匹配
const ctrlMatch = entry.ctrl ? (event.ctrlKey || event.metaKey) : !event.ctrlKey && !event.metaKey
const altMatch = entry.alt ? event.altKey : !event.altKey
const shiftMatch = entry.shift ? event.shiftKey : !event.shiftKey
if (!ctrlMatch || !altMatch || !shiftMatch) continue
// 输入框聚焦时的过滤规则:
// - 总是允许 Ctrl 组合键(Ctrl+1/2/3
// - 不允许 Enter(与输入冲突)
// - 不允许 / (在搜索框中输入 / 是正常的)
if (inputActive) {
if (entry.ctrl) {
event.preventDefault()
entry.handler(event)
return
}
if (entry.key === 'Enter') continue
if (entry.key === '/') continue
if (entry.key === 'Backspace') continue
// 其他键在输入框中正常输入,不拦截
continue
}
// 非输入框:执行回调
event.preventDefault()
entry.handler(event)
return
}
}
registerShortcuts()
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
}
@@ -0,0 +1,169 @@
// =============================================================================
// 企微IT智能服务台 — 屏幕截图组合函数
// =============================================================================
// 说明:实现屏幕截图功能,优先使用浏览器 Screen Capture API
// 在不支持或有问题(如企微桌面端限制)时降级到系统截图方案。
//
// 两个方案:
// 方案A(优先): navigator.mediaDevices.getDisplayMedia()
// - 优点:可直接在浏览器内完成截图,体验好
// - 缺点:企微桌面端可能限制此 API(非 HTTPS 或 localhost 外不可用)
// 方案B(降级):提示用户用系统截图工具(Win+Shift+S / Cmd+Shift+4
// 然后 Ctrl+V 粘贴到输入框(已有 handlePaste 实现)
//
// 使用方式:
// const { captureScreen, isCapturing, isScreenCaptureSupported, captureFallback }
// = useScreenCapture()
// - captureScreen():尝试方案A,失败时不自动降级(由调用方决定是否提示)
// - isScreenCaptureSupported():检测是否支持方案A
// - 调用方在 captureScreen() 返回 null 时,自行提示用户用系统截图
// =============================================================================
import { ref } from 'vue'
/** 是否正在截图(用于 UI 状态展示) */
const isCapturing = ref(false)
/**
* 检测浏览器是否支持 Screen Capture API
* @returns 是否支持
*/
export function isScreenCaptureSupported(): boolean {
return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia)
}
/**
* 截取屏幕/窗口/标签页
*
* 做什么:调用浏览器 Screen Capture API,让用户选择要截取的目标,
* 然后从视频流中捕获一帧画面,转为 Blob 返回。
*
* 为什么用 Screen Capture API
* - 浏览器原生支持,无需第三方库
* - 可以截取整个屏幕、应用窗口或浏览器标签页
* - 企微桌面端基于 Chromium 内核,完全支持
*
* @returns 截图的 Blob 对象(PNG 格式),失败返回 null
*/
export async function captureScreen(): Promise<Blob | null> {
// 不支持 Screen Capture API → 提示用户用系统截图
if (!isScreenCaptureSupported()) {
console.warn('[useScreenCapture] 浏览器不支持 Screen Capture API')
return null
}
isCapturing.value = true
try {
// ------------------------------------------------------------------
// 1. 请求屏幕共享权限(浏览器弹出选择器:屏幕/窗口/标签页)
// ------------------------------------------------------------------
// video: true — 只请求视频流(不需要音频)
// @ts-ignore — Chrome 支持 preferLabel 等非标准选项
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
})
// ------------------------------------------------------------------
// 2. 从视频流中获取第一帧画面
// ------------------------------------------------------------------
const track = stream.getVideoTracks()[0]
if (!track) {
console.warn('[useScreenCapture] 没有获取到视频轨道')
return null
}
// 使用 ImageCapture APIChrome 59+)获取高质量截图
// 如果不支持 ImageCapture,则回退到 Canvas 绘制方案
let blob: Blob | null = null
if ('ImageCapture' in window) {
try {
// ImageCapture API — 直接从视频轨道抓帧,质量更高
const imageCapture = new ImageCapture(track)
// @ts-ignore — grabFrame 是 ImageCapture 标准方法,但 TS 类型定义可能不完整
const bitmap = await imageCapture.grabFrame()
// 绘制到 Canvas → 导出 PNG Blob
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(bitmap, 0, 0)
blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/png')
})
}
bitmap.close() // 释放位图资源
} catch (imageCaptureError) {
console.warn('[useScreenCapture] ImageCapture 失败,回退到 Canvas 方案:', imageCaptureError)
}
}
// ImageCapture 失败或不可用 → Canvas 方案
if (!blob) {
const video = document.createElement('video')
video.srcObject = stream
video.muted = true // 静音播放(避免系统声音输出)
// 等待视频元数据加载完成
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => resolve()
video.onerror = () => reject(new Error('视频加载失败'))
video.play() // 必须调用 play 才能获取帧
})
// 短暂等待确保有画面帧可用
await new Promise((r) => setTimeout(r, 200))
// 绘制当前帧到 Canvas
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(video, 0, 0)
blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/png')
})
}
// 清理 video 元素
video.pause()
video.srcObject = null
}
// ------------------------------------------------------------------
// 3. 停止屏幕共享(关闭视频流)
// ------------------------------------------------------------------
stream.getTracks().forEach((t) => t.stop())
return blob
} catch (error: any) {
// 用户取消屏幕选择 → 不是错误,静默处理
if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') {
console.log('[useScreenCapture] 用户取消了屏幕选择')
return null
}
console.error('[useScreenCapture] 截图失败:', error)
return null
} finally {
isCapturing.value = false
}
}
/**
* 组合函数返回值
*/
export function useScreenCapture() {
return {
/** 是否正在截图 */
isCapturing,
/** 截取屏幕 */
captureScreen,
/** 检测浏览器支持 */
isScreenCaptureSupported,
}
}
@@ -0,0 +1,61 @@
// =============================================================================
// 企微IT智能服务台 — 主题切换 composable
// =============================================================================
// 说明:提供浅色/深色主题切换功能
// 核心功能:
// 1. applyTheme(theme) — 设置 document.documentElement data-theme + localStorage
// 2. getInitialTheme() — 从 localStorage 读取,默认 'light'
// 3. 初始化时自动调用 applyTheme
// =============================================================================
/** 主题类型 */
export type ThemeMode = 'light' | 'dark'
/** localStorage 存储键 */
const THEME_STORAGE_KEY = 'it_desk_theme'
/**
* 应用主题到 DOM
* 设置 document.documentElement 的 data-theme 属性,并持久化到 localStorage
*
* @param theme - 目标主题 ('light' | 'dark')
*/
export function applyTheme(theme: ThemeMode): void {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(THEME_STORAGE_KEY, theme)
}
/**
* 获取初始主题
* 从 localStorage 读取已保存的主题偏好,默认返回 'light'
*
* @returns 当前主题模式
*/
export function getInitialTheme(): ThemeMode {
const saved = localStorage.getItem(THEME_STORAGE_KEY)
if (saved === 'dark' || saved === 'light') {
return saved
}
// 检测系统偏好
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
}
/**
* 主题切换 composable
* 封装主题切换逻辑,初始化时自动应用已保存的主题
*
* @returns { applyTheme, getInitialTheme }
*/
export function useTheme() {
// 初始化时立即应用已保存的主题
const initialTheme = getInitialTheme()
applyTheme(initialTheme)
return {
applyTheme,
getInitialTheme,
}
}
@@ -0,0 +1,473 @@
// =============================================================================
// 企微IT智能服务台 — WebSocket 组合式函数
// =============================================================================
// 说明:封装 WebSocket 连接管理,提供:
// 1. 自动连接 + 断线重连(指数退避,最大 30 秒)
// 2. 心跳保活(每 30 秒发送 ping)
// 3. 事件分发:收到消息后根据 type 调用对应 store 方法
// 4. 降级策略:WS 断连时自动启动轮询 fallback,WS 重连后自动停止轮询
//
// 使用方式:
// const { connect, disconnect } = useWebSocket()
// onMounted(() => connect())
// onUnmounted(() => disconnect())
// =============================================================================
import { useAgentStore } from '@/stores/agent'
import { useConversationStore } from '@/stores/conversation'
// --------------------------------------------------------------------------
// 常量配置
// --------------------------------------------------------------------------
/** 心跳间隔(毫秒):每 30 秒发送一次 ping,保持连接存活 */
const HEARTBEAT_INTERVAL = 30000
/** 最大重连延迟(毫秒):指数退避上限 30 秒 */
const MAX_RECONNECT_DELAY = 30000
/** 重连延迟基数(毫秒):首次重连等待 1 秒 */
const RECONNECT_BASE_DELAY = 1000
/**
* WebSocket 组合式函数
*
* 核心职责:
* - 管理 WebSocket 连接的生命周期(建立、维持、断开、重连)
* - 处理服务端推送的实时事件,分发到对应的 store
* - 实现 WS → 轮询的自动降级和恢复
*
* 为什么用组合式函数(composable)而不是全局单例:
* - 遵循 Vue3 的组合式 API 模式,与组件生命周期绑定
* - 可以在多个组件中复用,同时保持状态隔离(如果需要)
* - 方便在 onMounted/onUnmounted 中调用 connect/disconnect
*/
export function useWebSocket() {
// ==========================================================================
// 内部状态
// ==========================================================================
/** 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
// ==========================================================================
// 连接管理
// ==========================================================================
/**
* 建立 WebSocket 连接
*
* 做什么:根据当前坐席ID构建 WS URL,建立连接,注册事件处理函数
* 为什么:坐席登录后需要实时接收新消息和会话变更事件
*
* 连接 URL 格式:
* - 开发环境:ws://localhost:5173/ws/{agentId}Vite 代理转发到后端)
* - 生产环境:wss://domain.com/ws/{agentId}(自动检测协议)
*/
function connect(): void {
const agentStore = useAgentStore()
const agentId = agentStore.userId
// 如果没有坐席ID,说明未登录,不建立连接
if (!agentId) {
console.warn('[WebSocket] 未登录,跳过连接')
return
}
// 如果已有连接,先断开
if (ws) {
disconnect()
}
// 重置主动断开标记
intentionalDisconnect = false
// 构建 WebSocket URL
// 开发环境:直接连后端 8000 端口(避免 Vite WS 代理兼容性问题)
// 生产环境:通过同源 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/${agentId}`
console.log(`[WebSocket] 正在连接: ${wsUrl}`)
ws = new WebSocket(wsUrl)
// ----------------------------------------------------------------------
// 连接成功
// ----------------------------------------------------------------------
ws.onopen = () => {
console.log('[WebSocket] 连接成功')
// 重置重连计数
reconnectAttempts = 0
// 启动心跳
startHeartbeat()
// WS 已连接,停止轮询 fallback
// 为什么:WS 连接正常时不需要轮询,减少不必要的 HTTP 请求
const conversationStore = useConversationStore()
conversationStore.stopAllPolling()
}
// ----------------------------------------------------------------------
// 收到消息
// ----------------------------------------------------------------------
ws.onmessage = (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data)
handleMessage(msg)
} catch (error) {
console.error('[WebSocket] 消息解析失败:', error)
}
}
// ----------------------------------------------------------------------
// 连接关闭
// ----------------------------------------------------------------------
ws.onclose = () => {
console.log('[WebSocket] 连接关闭')
// 停止心跳
stopHeartbeat()
// 清空 ws 引用
ws = null
// 如果不是主动断开,启动降级和重连
if (!intentionalDisconnect) {
// WS 断连,启动轮询 fallback
// 为什么:WS 不可用时,仍需通过轮询获取最新数据,保证坐席能看到新消息
const conversationStore = useConversationStore()
conversationStore.startAllPolling()
// 尝试重连
scheduleReconnect()
}
}
// ----------------------------------------------------------------------
// 连接错误
// ----------------------------------------------------------------------
ws.onerror = (error: Event) => {
console.error('[WebSocket] 连接错误:', error)
// onclose 会自动触发,这里不需要额外处理
// 只记录日志,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('[WebSocket] 已主动断开连接')
}
// ==========================================================================
// 心跳保活
// ==========================================================================
/**
* 启动心跳定时器
*
* 做什么:每 HEARTBEAT_INTERVAL 毫秒发送一次 ping 消息
* 为什么:防止中间代理(Nginx、CDN 等)因空闲超时断开 WebSocket 连接
*/
function startHeartbeat(): void {
// 先清理旧定时器(避免重复)
stopHeartbeat()
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }))
// 注意:不发 console.log,避免频繁输出
}
}, 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秒后
* 第5次重连:16秒后
* 第6次及以后:30秒后(达到上限)
*/
function scheduleReconnect(): void {
// 如果已主动断开,不重连
if (intentionalDisconnect) return
// 计算延迟(指数退避)
const delay = Math.min(
RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts),
MAX_RECONNECT_DELAY
)
reconnectAttempts++
console.log(
`[WebSocket] 将在 ${delay / 1000} 秒后重连(第 ${reconnectAttempts} 次)`
)
// 清理旧的重连定时器
if (reconnectTimer) {
clearTimeout(reconnectTimer)
}
// 安排重连
reconnectTimer = setTimeout(() => {
console.log('[WebSocket] 正在重连...')
connect()
}, delay)
}
// ==========================================================================
// 消息处理(事件分发)
// ==========================================================================
/**
* 处理从 WebSocket 收到的消息
*
* 做什么:根据消息的 type 字段,调用对应的 store 方法处理
* 为什么:不同类型的事件需要不同的处理逻辑,集中分发便于维护
*
* 消息类型:
* - new_message: 新消息事件,由 message_router 触发
* - conversation_updated: 会话状态变更事件,由 session_service 触发
* - pong: 心跳响应,忽略
*
* @param msg - WebSocket 消息对象,包含 type 和 data 字段
*/
function handleMessage(msg: { type: string; data?: any }): void {
const conversationStore = useConversationStore()
switch (msg.type) {
case 'new_message':
// 新消息事件:追加到消息列表 + 刷新会话列表 + 播放提示音
if (msg.data) {
conversationStore.handleNewMessage(msg.data)
// 播放新消息提示音(Web Audio API,零依赖)
playNotificationSound()
}
break
case 'conversation_updated':
// 会话状态变更事件:刷新会话列表
if (msg.data) {
conversationStore.handleConversationUpdated(msg.data)
}
break
case 'collaborator_invited':
// 摇人邀请(定向推送)事件:被邀请坐席收到通知 + 刷新列表
if (msg.data) {
conversationStore.handleCollaboratorInvited(msg.data)
// 弹窗通知被邀请的坐席(useWebSocket 不依赖组件,通过 ElNotification 全局弹窗)
const agentStore = useAgentStore()
if (msg.data.invitee_agent_id === agentStore.userId) {
import('element-plus').then(({ ElNotification }) => {
ElNotification({
title: '🔔 摇人邀请',
message: `坐席 ${msg.data.inviter_agent_id} 邀请你协助处理会话「${msg.data.employee_name || '未知'}\n${msg.data.last_message_summary || ''}`,
type: 'info',
duration: 0, // 不自动关闭
position: 'top-right',
onClick: () => {
conversationStore.selectConversation(msg.data.conversation_id)
}
})
})
}
}
break
case 'collaborator_joined':
case 'collaborator_left':
// 协作关系变更(广播)事件:刷新会话列表
conversationStore.handleCollaboratorChanged()
break
// ==================================================================
// 邀请功能事件(P0-09~P0-11
// ==================================================================
case 'participant_invited':
// 参与者被邀请:刷新会话列表(更新 participants 字段)
conversationStore.handleCollaboratorChanged()
break
case 'participant_joined':
// 参与者加入:刷新会话列表 + 系统消息
conversationStore.handleCollaboratorChanged()
break
case 'participant_removed':
case 'participant_left':
// 参与者移除/退出:刷新会话列表
conversationStore.handleCollaboratorChanged()
break
case 'pong':
// 心跳响应,不需要处理
break
case 'typing':
// 输入指示器事件:某人正在输入
// 过滤掉自己的 typing 事件,只显示其他人的
if (msg.data) {
const agentStore = useAgentStore()
if (msg.data.sender_id !== agentStore.userId) {
conversationStore.handleTypingEvent(msg.data)
}
}
break
default:
console.warn(`[WebSocket] 未知消息类型: ${msg.type}`)
}
}
// ==========================================================================
// 新消息提示音(Web Audio API,零依赖,无需额外文件)
// ==========================================================================
let audioCtx: AudioContext | null = null
/**
* 播放新消息提示音
*
* 做什么:收到新消息时播放短促提示音
* 为什么:坐席需要即时感知新消息到达
* 怎么做:用 Web Audio API 合成一个短促的双音提示(无需外部音频文件)
*/
function playNotificationSound(): void {
try {
if (!audioCtx) {
audioCtx = new AudioContext()
}
// 双音提示:两段简短的正弦波(类似「叮咚」)
const now = audioCtx.currentTime
// 第一声「叮」(880Hz0.1秒)
const osc1 = audioCtx.createOscillator()
const gain1 = audioCtx.createGain()
osc1.type = 'sine'
osc1.frequency.value = 880
gain1.gain.setValueAtTime(0.3, now)
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.15)
osc1.connect(gain1)
gain1.connect(audioCtx.destination)
osc1.start(now)
osc1.stop(now + 0.15)
// 第二声「咚」(660Hz0.15秒,延迟0.1秒)
const osc2 = audioCtx.createOscillator()
const gain2 = audioCtx.createGain()
osc2.type = 'sine'
osc2.frequency.value = 660
gain2.gain.setValueAtTime(0.3, now + 0.1)
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.3)
osc2.connect(gain2)
gain2.connect(audioCtx.destination)
osc2.start(now + 0.1)
osc2.stop(now + 0.3)
} catch {
// AudioContext 不可用时静默降级(不影响消息接收)
}
}
// ==========================================================================
// 发送 typing 事件(输入指示器)
// ==========================================================================
/** 上次发送 typing 的时间戳(节流:每 3 秒最多发一次) */
let lastTypingSentAt = 0
/** typing 节流间隔(毫秒) */
const TYPING_THROTTLE_MS = 3000
/**
* 发送 typing 事件到后端
*
* 做什么:坐席在输入框打字时,通知后端广播给其他参与者
* 为什么:让员工/其他坐席看到「坐席正在输入...」提示
* 怎么做:通过 WebSocket 发送 { type: "typing", conversation_id, sender_name }
*
* 节流机制:每 3 秒最多发送一次,避免频繁广播
*
* @param conversationId - 当前会话ID
*/
function sendTyping(conversationId: string): void {
if (!ws || ws.readyState !== WebSocket.OPEN) return
// 节流:3 秒内不重复发送
const now = Date.now()
if (now - lastTypingSentAt < TYPING_THROTTLE_MS) return
lastTypingSentAt = now
const agentStore = useAgentStore()
ws.send(JSON.stringify({
type: 'typing',
conversation_id: conversationId,
sender_name: agentStore.agentName || '坐席',
}))
}
// ==========================================================================
// 返回
// ==========================================================================
return {
/** 建立 WebSocket 连接 */
connect,
/** 主动断开 WebSocket 连接(登出时调用) */
disconnect,
/** 发送 typing 事件(输入指示器,3 秒节流) */
sendTyping,
}
}
+941
View File
@@ -0,0 +1,941 @@
// 快速回复层级数据 — 从 IT支持知识库2026-4-24.docx 自动提取
// 7大类 / 子类 / 回复模板
export interface QrItem {
title: string
content: string
}
export interface QrSubCategory {
name: string
items: QrItem[]
}
export interface QrCategory {
name: string
subs: QrSubCategory[]
}
export const qrData: QrCategory[] =
[
{
"name": "电脑",
"subs": [
{
"name": "硬件设备",
"items": [
{
"title": "笔记本电脑电池续航异常",
"content": "健康评估标准:剩余容量<70%或循环次数>500次。\n获取报告步骤::\nWindowscmd中输入 powercfg /batteryreport,查看报告中的“CYCLE COUNT”。\nMac:按住Option键点击苹果菜单→系统信息→电源→查看“循环计数”。\n将报告留言分享,等待人工坐席进一步评估。"
},
{
"title": "办公电脑常见问题处理(黑屏、警报)",
"content": "排查步骤\n1. 观察电源指示灯:确认电脑的电源指示灯是否亮起或闪烁。\n2. 强制关机重启:长按电源键约15-20秒,直到电源指示灯完全熄灭,等待几秒钟后,再次按下电源键尝试开机。"
},
{
"title": "办公电脑常见问题处理(死机、卡顿)",
"content": "排查步骤:\n1. 检查系统资源:按Ctrl+Shift+Esc打开任务管理器,结束占用高的非必要进程。\n2. 强制重启:长按电源键15-20秒至指示灯熄灭,等待后重新开机。"
}
]
},
{
"name": "Windows系统",
"items": [
{
"title": "Windows本地账户密码修改",
"content": "路径:设置→帐户→登录选项→密码→更改,按提示完成。"
},
{
"title": "办公电脑功能异常(无声音、屏幕显示、键盘热键)",
"content": "排查步骤:\n1. 检查驱动:设备管理器查看是否有异常设备(黄色/红色标志)。\n2. 重装驱动:联想电脑使用官方工具;其他品牌从官网下载最新驱动。"
},
{
"title": "办公电脑麦克风无声音",
"content": "排查步骤:\n1. 设置默认设备:右键任务栏扬声器图标→声音设置,确保麦克风设为默认输入设备。\n2. 授予权限:在Windows搜索“麦克风隐私设置”,开启麦克风访问权限及对应应用(如企业微信、小鱼)的权限。\n3. 调整属性:在设备属性中调整音量和麦克风增强,禁用独占模式。"
},
{
"title": "Windows电脑和Office许可证过期|激活|即将到期",
"content": "适用场景:激活过期/失败/即将到期。\n操作步骤:\n1. 下载工具:https://drive.weixin.qq.com/s?k=AAoA1wcYAAcmKeQnWG\n2. 运行工具,按需取消选项(如不需激活Office)。\n3. 点击“开始”处理。"
},
{
"title": "电脑C盘空间不足",
"content": "操作步骤:\n1. 打开企业微信,进入【设置】→【文档/文件管理】→【文件储存位置】。\n2. 点击【更改】,选择其他盘符的目录作为新存储路径。"
},
{
"title": "U盘、移动硬盘无法弹出报错“弹出USB大容量存储设备时出问题”",
"content": "故障现象:\n弹出U盘提示“该设备正在使用中、请关闭可能使用该设备的所有程序或窗口,然后重试”\n解决方法:\n将电脑关机后再拔出硬盘"
},
{
"title": "办公电脑系统初始密码",
"content": "总部新电脑:Windows系统无密码(直接回车)\n电脑开机密码是独立的,不与内部统一员工账密一致。"
},
{
"title": "电脑开机密码重置",
"content": "重置电脑开机需使用专用工具由IT支持人员进行现场处理,总部员工请携带设备前往121室,区域同事请联系本地兼职网络协助处理"
}
]
},
{
"name": "鸿蒙系统",
"items": [
{
"title": "公司办公IT环境不支持鸿蒙系统的软硬件清单",
"content": "软件功能类:\n火绒安全、税友安全助手、企业微信-同事吧(发帖、回复)"
}
]
}
]
},
{
"name": "软件",
"subs": [
{
"name": "常用工具",
"items": [
{
"title": "常用办公软件下载地址",
"content": "常用办公软件下载地址:https://drive.weixin.qq.com/s?k=AAoA1wcYAAcVScZYR4"
},
{
"title": "压缩工具",
"content": "7-Zip是一款免费开源高压缩比的压缩软件,支持7z、ZIP、RAR、CAB、GZIP、BZIP2和TAR等格式。此软件压缩的压缩比要比普通ZIP文件高30-50%。\n7-Zip 客户端下载地址:https://sparanoid.com/lab/7z/download.html"
}
]
},
{
"name": "企业微信",
"items": [
{
"title": "企业微信综合信息",
"content": "企业微信账号同时与个人微信、手机同步绑定"
},
{
"title": "企微手机聊天记录迁移到电脑",
"content": "企业微信:打开企业微信---我---设置---通用---聊天记录迁移(手机和电脑连接同一网络热点)"
},
{
"title": "企业微信显示手机号码修改",
"content": "操作路径:企业微信手机端→设置→账号与安全→手机号→更换手机号,按提示完成。"
},
{
"title": "企业微信账号登录异常",
"content": "处理方案:\n1. 账号限制/封禁:通过官方申诉链接处理:https://work.weixin.qq.com/webapp/kefuSelfService/page 。\n2. 设备超限:卸载当前版本,重启后下载最新版安装:https://work.weixin.qq.com/#indexDownload"
},
{
"title": "企业微信消息接收延迟",
"content": "排查步骤:\n1. 确认文件存储路径:企业微信→设置→存储管理。\n2. 退出企业微信,删除WXWork存储路径下的Global文件夹。"
},
{
"title": "企业微信客户相关功能限制(客户群/朋友圈/外部联系人",
"content": "“亿企赢总部“企微主要作为内部沟通渠道,限制添加外部联系人、客户、客户群等客户营销、服务支持功能。\n“亿企赢”主体:用于客户联系。\n“亿企赢总部”主体:仅限内部沟通。\n如有上述需求请切换至“亿企赢”企微主体进行操作,或由“亿企赢”企微主体账号客户&项目经理账号建立客户群,再添加“亿企赢总部”相关人员入群。"
}
]
},
{
"name": "企业邮箱",
"items": [
{
"title": "税友企业邮箱访问方式与账号密码认证方式",
"content": "通过第三方邮件客户端配置POP、SMTP、IMAP协议访问,需使用邮箱专用安全密码\n通过Coremail客户端配置POP、SMTP、IMAP协议访问,需使用邮箱专用安全密码\n通过Coremail客户端配置Coremail协议访问,需使用统一员工账号密码\n通过税友企业邮箱网页登录使用统一员工账号密码+短信认证"
},
{
"title": "税友企业邮箱密码修改或重置",
"content": "注意:通过WEB网页登录企业邮箱与邮件客户端收发邮件所配置密码并不相同,访问WEB地址和使用Coremail客户端采用的是员工统一账号密码(与eHR、税友家园登录密码相同),其他第三方邮件客户端配置的是邮件客户端安全专用密码,请根据实际情况选择不同密码修改重置方式。\n员工统一账号密码重置入口:\nhttp://192.168.9.87:8080/employee-center/resetPwd.jsp\n第三方邮件客户端邮件客户端专用密码生成和重置入口:\n使用员工统一账号密码+短信验证码登录WEB邮箱https://mail.servyou.com.cn/\n设置(齿轮图标)-安全设置-客户端安全登录-“生成专用密码”\n设置密码名称(便于区分使用软件或对象)\n获取(复制)16位密码和邮件客户端配置(按需)"
},
{
"title": "邮箱客户端安全登录专用密码介绍",
"content": "客户端专用密码是用于登录第三方邮件客户端(例如Outlook、Foxmail、邮件App等)时使用的专属密码\n适合客户端通过以下协议使用:POP、IMAP、SMTP、Pushmail、CalDAV、CardDAV\n“客户端专用密码”仅在生成时可见,支持设置多个,切勿使用其它方式保存,以防泄露\n邮件客户端专用密码需通过登录邮件服务器网站进行申请和获取"
},
{
"title": "税友企业邮件地址",
"content": "税友企业邮件网址: https://mail.servyou.com.cn"
},
{
"title": "税友邮箱网站无法登入",
"content": "步骤:\n1. 先登录税友家园( https://oa.servyou-it.com/)验证账号。\n2. 若密码错误,通过http://192.168.9.87:8080/employee-center/resetPwd.jsp重置。\n3. 重置后等待10分钟重试邮箱登录。"
},
{
"title": "税友邮箱已发送邮件召回",
"content": "条件:仅限发送给公司内部员工且对方未读的邮件。\n操作:登录网页版邮箱(https://mail.servyou.com.cn)→自助查询→发信查询→点击“召回邮件”。"
},
{
"title": "邮箱客户端配置",
"content": "邮件客户端选择和下载\nCoremail邮件客户端 https://www.coremail.cn/download.html\nFoxmail邮件客户端 https://www.foxmail.com/win/\n企业微信邮件应用 路径:企业微信客户端-邮件\n生成邮件客户端专用密码:登录网页版邮箱( https://mail.servyou.com.cn/ )→个人设置→安全设置→客户端安全登录→生成16位专用密码。\n配置客户端:\n收发服务器地址:mail.servyou.com.cn\n协议和端口:POP收件协议 995SSL)、SMTP发件协议465(SSL)\n密码使用生成的专用密码。\n详细指南参考:https://doc.weixin.qq.com/doc/w3_AU8AjwZhAIgBx1RxfT7SRqnW0yN7i"
},
{
"title": "使用邮件客户端本地保留历史收发邮件",
"content": "说明:根据公司信息安全管理要求,企业邮箱服务器邮件仅保留14天,14天到期邮件将被清除且无法恢复。如有经常随时查阅历史邮件和有邮件存档需求,应避免只使用WEB方式访问邮件网站收发邮件,同时避免使用配置imap、Coremail协议的邮件客户端如:企业微信邮件、Coremail邮件客户端),而应选择配置POP收件协议 的邮件客户端管理邮件。\n解决方案:\n根据需要选择下载安装  Foxmail、Coremail、网易邮箱大师等邮件客户端,Coremail邮件客户端配置过程邮件协议不要默认选择Coremail。\n2.登录企业邮件网址https://mail.servyou.com.cn. 通过路径”设置(齿轮图标)-安全设置-客户端安全登录“,申请邮件客户端专用密码\n3.正确邮件客户端邮件服务器地址、收发邮件服务器地址和端口、邮件账号和邮件客户端专用密码"
},
{
"title": "Foxmail邮箱收发异常“不知道这样的主机”",
"content": "处理步骤:\n1. 打开Foxmail,右键邮箱名→设置→账号→服务器。\n2. 修改服务器地址为mail.servyou.com.cn,端口收件995SSL)、发件465SSL)。"
},
{
"title": "税友邮箱WEB登录异常“用户名或密码错误,或登录受到限制”",
"content": "解决步骤:\n1. 重置密码:http://192.168.9.87:8080/employee-center/resetPwd.jsp\n2. 尝试登录税友家园( https://oa.servyou-it.com/ )验证账号正常后,重试邮箱登录。"
},
{
"title": "外部邮件漏收&被拦截",
"content": "排查步骤:\n1.使用私人邮箱或请同事给自己发送一封邮件,确认有些客户端设置是否正确。\n2.检查邮件客户端垃圾邮件(箱),确定是否被邮件客户端拦截\n3使用员工账户中心密码+短信信验证码,登录企业邮箱WEB页面 https://mail.servyou.com.cn ,检查“其他文件-垃圾邮件下是否有所需邮件\n如以上检查确认无法收到,请IT支持人工坐席联系邮件运维,启动“邮件防火墙筛查”"
},
{
"title": "公共邮箱申请流程(新建|回收|停用)",
"content": "申请链接:https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=公共邮箱账号申请\n具体审批执行情况请联系工单处理人。"
},
{
"title": "Coremail邮箱显示脱机",
"content": "请右键点击账号信息,选择“设为联机模式”。如果操作后仍未恢复,请确认账号和密码输入是否正确。"
}
]
},
{
"name": "税友云盘",
"items": [
{
"title": "税友云盘网址和客户端下载",
"content": "税友云盘网址: https://ypan.dc.servyou-it.com\n登录窗口左下角点击“下载客户端”\n注:税友云盘暂不支持手机移动端"
},
{
"title": "税友企业云盘账号解冻",
"content": "税友云盘(企业云盘)\n云盘账号解冻联系谢聪利申请解冻。"
},
{
"title": "税友云盘更新失败",
"content": "访问https://ypan.dc.servyou-it.com/user/login ,在登录页面左下角下载最新版安装。"
},
{
"title": "税友云盘密码错误",
"content": "使用员工统一认证账号密码+短信二次认证,用户名与税友家园、EHR系统一致,忘记密码可使用员工统一认证账号密码重置方式进行重置"
},
{
"title": "税友云盘文件夹访问权限申请",
"content": "税友云盘文件夹权限管理由各部门及项目指定空间管理员分管,云盘文件夹目录创建与权限调整需联系所属的管理员。\n税友云盘部门和项目管理员名单:https://doc.weixin.qq.com/sheet/e3_m_aOPqWFhxgwDR?scode=AAoA1wcYAAcVgz1ud7AQgAuAYMANY&tab=BB08J2"
}
]
},
{
"name": "企微微盘",
"items": [
{
"title": "企微微盘上传本地文件提示“微盘容量已满,无法上传文件,开通微盘高级功能,可提升容量。”应该如何处理?",
"content": "因企业微信-微盘收费政策发生重大调整,费用较之前上涨6倍。前期经过与各客群沟通,当前“文档”功能在绝大多数工作场景中已能够替代“微盘”,因此先暂停微盘的续费工作。已安排各客群调研实际需求,后续将根据调研的结果评估续费方案。现阶段的影响以及安排如下:\n一、到期影响(2026年3月14日起)\n1.微盘:到期后将无法上传新本地文件,空间已有文件可正常访问、下载,短时间不被删除。\n2.文档:“文档”的在线编辑、上传及共享等功能 不受此次调整影响。在线文档大小不占用微盘容量。\n二、 后续使用指引\n1.主要替代方案:请各部门及员工将后续新增的文档存储、分享需求,通过企业微信“文档”功能中实现。\n2.特殊需求处理:如确有特殊业务必须使用微盘,请由部门接口人汇总评估需求必要性。\n3.文档高级会员:部分原微盘需求将转移至“文档”后新增高级会员,公司将按必要性进行引导与管理,具体采购流程和管理方案另行通知。\n三、 咨询与支持\n请各位同事知悉并提前做好工作安排,如有疑问可统一咨询: 企微“智能IT助手”,各中心接口人将负责本部门内的宣导与部门内个性化实施。\n微盘&文档常见问题答疑文档链接:https://doc.weixin.qq.com/doc/w3_AJAAAQaUAI4CN6WEkNQg7RZWP4F2Z?scode=AAoA1wcYAAcO7CE2NAAJAAAQaUAI4\n微盘管理部门接口人:"
},
{
"title": "为什么企微微盘容量到期后,公司不再统一续费?",
"content": ""
},
{
"title": "因企业微信-微盘收费政策发生重大调整,费用较之前上涨6倍。前期经过与各客群沟通,当前“文档”功能在绝大多数工作场景中已能够替代“微盘”,因此先暂停微盘的续费工作。",
"content": ""
},
{
"title": "企微微盘容量到期后,原有空间内的文件有什么影响?",
"content": "到期后企微空间将无法上传新本地文件,空间已有文件可正常访问、下载,短时间不被删除。"
},
{
"title": "企微微盘空间内的文件能够保留多久?",
"content": "企微空间内的文件暂时不会删除,如果企微官方调整文件保存策略,会提前通知"
},
{
"title": "企微微盘没有扩容的情况下,每个人平均有的是多少?",
"content": "按照集团企微账号共享容量100GB,集团现有约7000人均分,大概14MB/ 人"
},
{
"title": "如何查看企微微盘已用容量",
"content": "路径:【电脑端->微盘->左下角->个人容量】\n将鼠标悬停在已用容量位置,可查看:微盘版本(企业)、账号类型(个人)、已用容量(个人)、剩余容量(企业)。"
}
]
},
{
"name": "企微文档",
"items": [
{
"title": "在线文档里插入本地图片和其他文件,所占用的是什么应用的容量?",
"content": "在线文档上传的本地文件只会占用“文档”容量,"
},
{
"title": "视频/音频可以转成企微微盘在线文档吗?",
"content": "只有word、Excel、演示、PPT不可以转为在线文档,其他格式无法转为在线文档"
},
{
"title": "企微文档容量如何计算?",
"content": "文档仅占用创建者的容量,文档容量根据文档正文、文档中插入的文件、图片以及版本历史记录综合计算,具体类型包括:\n文档、表格、幻灯片、智能表格、思维导图、流程图:文档正文、文档中插入的本地文件、图片、表格函数、图表等\n收集表、汇报:填写者提交的内容,包含正文、文件、图片、签名等\n版本历史记录计入文档容量:在线文档会自动保留历史版本,方便查看编辑记录,可以随时找回历史内容,避免数据丢失。文档容量将根据版本历史的大小综合计算。"
},
{
"title": "企微文档中插入的文件是否占用企微微盘容量?",
"content": "文档中插入的文件仅占用文档容量,不会占用微盘容量。"
},
{
"title": "企微文档容量如何提升?",
"content": "基础版个人总容量上限为 1G,开通文档高级功能后,文档容量提升至无限。"
},
{
"title": "如何查看已用企微文档容量情况?",
"content": "成员可在【手机端->文档->右上角的“+”->更多->关于文档】中查看文档已用容量。"
},
{
"title": "如何释放已经占用的企微「文档」容量",
"content": "方法一:删除过期文档,进入「文档 > 全部 > 我的文档」,这里将展示占用本人容量的所有文档,可以按大小排序,可自行操作删除。\n方法二:删除文档中的图片和文件,打开本人创建的文档,删除文档中已插入的图片、文件。\n方法三:删除通过汇报上传的文件,在「微盘 ->我的空间->选择对应的汇报」操作删除汇报中的文件、图片。删除后,一般10分钟左右就能释放对应的容量。注:需汇报创建者操作。\n方法四:文档版本历史记录文档瘦身,进入进入「文档 > 设置> 生成副本」,删除原文档保留副本文档\n方法五:移交文档、文件(夹)所有权给文档高级会员,将文档(文件夹)移动至个人空间,选中文件(夹)右键>转接所有权(所转交文件占用的空间会移交给接收人)\n温馨提示:\n(1)文档容量非实时更新,会在第二天更新。\n(2)文档删除后,可以在【文档->全部->回收站】中恢复对应的文档,非高级账号的文档在回收站会保留7天,高级账号的文档在回收站会保留180天。"
},
{
"title": "企微文档提示:“文档容量已满,因此你无法在该文档中插入图片”",
"content": "异常原因:插入图片所在文档所有者,企微文档免费额度已满,需由当前文档创建者购买收费高级功能\n出于数据安全和成本考虑,公司不提倡大范围使用企微在线文档,部门或个人如坚持使用,需自行购买。"
},
{
"title": "企微文档所有者查看方式",
"content": "文档窗口右上角“三杠”图标"
},
{
"title": "企微文档高级功能购买链接",
"content": "https://work.weixin.qq.com/mall/wedoc?wws=19"
},
{
"title": "企业微信共享文件删除恢复",
"content": "路径:微盘→我的文件→左下角三点菜单→回收站→选择文件→还原。"
},
{
"title": "企业微信文档报错“未知错误”",
"content": "解决方式:\n1. 关闭网络代理:Internet选项→连接→局域网设置→取消代理服务器勾选。\n2. 更新企业微信版本:左下角“关于”中检查更新。"
}
]
},
{
"name": "文档中心",
"items": [
{
"title": "Confluence文档中心网址",
"content": "文档中心 https://docs.dc.servyou-it.com"
}
]
},
{
"name": "网页浏览",
"items": [
{
"title": "Edge&谷歌浏览器无法打开网页,错误代码: STATUS_STACK_BUFFER_OVERRUN”",
"content": "【问题原因】\n浏览器更新后与税友安全助手组件冲突\n【影响范围】\nMicrosoft Edge 、谷歌浏览器\n【处理办法】\n下载并安装“浏览器修复补丁”,重启浏览器后即可恢复。\n下载地址:浏览器修复补丁"
}
]
}
]
},
{
"name": "外设",
"subs": [
{
"name": "打印复印",
"items": [
{
"title": "杭州总部刷卡打印机安装",
"content": "1.登录页面右下角“客户端下载”下载驱动,http://printer.oa.servyou-it.com/printhub/ui/sign/login.htm\nWindows:选择“柯美原厂驱动”\nMac:选择“PrintDriver”,\n打印时,Windows用户选择打印机名称 KM_Printer,MAC用户选择打印机名称 FollowMe-Black\n输入服务器地址:printer.oa.servyou-it.com:80 绑定“统一员工账号密码”,填写完成后点击“校验”并确定\n3.首次使用刷卡取件,可前往任意楼层刷卡打印机,在提示位置刷卡后,输入员工账号和密码进行认证绑定。\n详细操作请参考文档《统一刷卡打印机安装使用说明》统一刷卡打印机安装使用说明\nhttps://doc.weixin.qq.com/doc/w3_APQA0gb5AAgUUYPrXy8QAGRQfMDgx?scode=AAoA1wcYAAcuo1wd2hAPQA0gb5AAg&qt_source=Search&qt_report_identifier=1763972439462&version=5.0.2.6008&platform=win"
},
{
"title": "杭州总部刷卡打印机复印操作",
"content": "步骤:\n1. 刷卡后点击“复印”功能。\n2. 按提示操作,完成后取件口取件。\n身份证复印支持双面模式。\n详细操作请参考文档《统一刷卡打印机安装使用说明》https://doc.weixin.qq.com/doc/w3_APQA0gb5AAgUUYPrXy8QAGRQfMDgx?scode=AAoA1wcYAAcuo1wd2hAPQA0gb5AAg&qt_source=Search&qt_report_identifier=1763972439462&version=5.0.2.6008&platform=win"
},
{
"title": "杭州总部刷卡打印机扫描操作",
"content": "步骤:\n1. 刷卡后点击屏幕“扫描”功能。\n2. 选择扫描方式:多页用“进纸器”,单页/厚重文件用“平板”。\n3. 扫描文件发送至个人邮箱。详情操作参考https://doc.weixin.qq.com/doc/w3_APQA0gb5AAgUUYPrXy8QAGRQfMDgx 。"
},
{
"title": "总部刷卡打印驱动下载",
"content": "总部刷卡打印中心网址 http://printer.oa.servyou-it.com/printhub/ui/sign/login.htm"
},
{
"title": "杭州总部打印彩色稿件",
"content": "Windows操作系统直接打印,Mac OS系统选择名称“”ColourPrine”打印机,\n打印任务完成后至杭州总部亿企赢大厦彩色打印机放置楼层为5、10、15、20层刷卡取件即可"
},
{
"title": "杭州总部刷卡打印机卡纸、缺墨",
"content": "总部员工改用其他楼层打印设备,并留言告知异常设备位置,安排处理。"
},
{
"title": "杭州总部刷卡打印机显示未连接",
"content": "尝试重启电脑后重试打印。"
},
{
"title": "杭州总部刷卡打印机缺纸处理",
"content": "总部员工可改用其他楼层打印设备,或自行补充备用纸(设备下方防潮柜柜内可取)。部门批量打印需至资产办公室领用。"
},
{
"title": "杭州总部刷卡打印机取件异常",
"content": "原因一:员工账号密码更新后,客户端密码未同步修改更新。\n检测步骤:\nWindows:任务栏打印机图标(蓝色大拇指)→配置→校验密码。\nMac:应用程序→PrinterLogin→校验账号密码。\n原因二:30分钟内未及时取件,打印任务超30分钟未取件自动取消\n操作步骤:重新打印,30分钟内取件"
},
{
"title": "总部刷卡打印客户端,配置页面提示“验证失败!用户名或密码错误”",
"content": "原因:员工账户中心员工密码到期或更新后,刷卡打印客户端未同步更新\n处理步骤:更新密码后,点击校验,提示“校验成功!”后,点击确认"
},
{
"title": "总部刷卡打印客户端,配置页面提示“验证失败!用户名或密码错误次数达到系统上限,现已被锁定...\"",
"content": "原因:密码错误输入超过3次\n处理步骤:确认员工账号密码正确(可在税友家园、eHR尝试登录),在5分钟后使用正确密码进行校验"
}
]
},
{
"name": "网络会议",
"items": [
{
"title": "小鱼易连客户端下载",
"content": "小鱼易连客户端支持Windows、MAC、Linux(统信、麒麟),请根据所运行操作系统选择下载不同客户端。 小鱼易连下载中心:https://www.xylink.com/download"
},
{
"title": "小鱼固定方云会议室预约",
"content": "操作路径:运行小鱼易连软件→会议→我的会议→新建→预约会议。详情参考《小鱼云会议用户使用指南》。\nhttps://doc.weixin.qq.com/doc/w3_AJAAAQaUAI429WiTgHnRU0I0O5ItO?scode=AAoA1wcYAAcNyzXMF6AJAAAQaUAI4&qt_source=Search&qt_report_identifier=1764120639847&version=5.0.2.6008&platform=win"
},
{
"title": "小鱼固定方云会议预约信息查询",
"content": "小鱼固定方云会议预约信息查询需桌面IT支持人工坐席处理,请按一下步骤进行操作。\n回复“IT”获取桌面IT支持人工支持链接\n点击IT支持人工支持链接进入人工坐席咨询窗口\n输入需要查询的小鱼固定方会议室号,会议时间区间\n耐心等待人工支持坐席回复"
},
{
"title": "小鱼云会议使用方法",
"content": "详情参考《小鱼云会议用户使用指南》。\nhttps://doc.weixin.qq.com/doc/w3_AJAAAQaUAI429WiTgHnRU0I0O5ItO?scode=AAoA1wcYAAcNyzXMF6AJAAAQaUAI4&qt_source=Search&qt_report_identifier=1764120639847&version=5.0.2.6008&platform=win"
},
{
"title": "小鱼固定方云会议室号及主持人密码",
"content": "25方:会议号9083894961,密码348124,主持密码569149\n50方:会议号9083284868,密码502892,主持密码625067\n100方:会议号9083261987,密码359615,主持密码374852"
},
{
"title": "小鱼直播权限申请",
"content": "无需申请,新建直播即可,无人数限制。"
},
{
"title": "小鱼云会议室录像和会议统计提取",
"content": "企业云会议室:登录一站式运维平台-服务目录-IT支持服务-活动与会议支持,支持级别\"资料下载“,服务内容“录像下载”或“活动统计”补充信息会议号,以及会议直至时间。\n个人云会议室:客户端→文件夹→我的文件夹查看历史录制。正常情况支持人员会在1小时内处理完成,请关注“一站式运维平台”工单完工消息提醒,通过我的工单-我的创建-查看并获取下载链接"
},
{
"title": "企业微信会议(腾讯会议)不可用",
"content": "受企业微信商业政策调整影响,公司决定2023-8-1停止企业微信会议功能,企微会议功能关闭后,企微音频/视频通话+屏幕分享(企业内限16人,企业外1对1),集团全体员工可使用手机号+短信方式登录使用小鱼易连会议,30方及以下会议可使用小鱼终端号、个人云会议号,>30~100方会议需预约小鱼企业云会议号"
}
]
},
{
"name": "会议电视",
"items": [
{
"title": "会议室屏幕投屏操作步骤",
"content": "标准会议室(如:总部办公楼层5~21楼)\n使用电视遥控器打开电视\n将投屏线(转接头)连接至电脑HDMI|Type-C接口\n大型视频会议室(总部409、410)\n黑色遥控器打开电视\n银色遥控器打开小鱼终端\n将投屏器连接至电脑\n点击弹出投屏程序,或者运行投屏器存储盘符下的投屏程序\n根据提示操作一键投屏\n超大型会议室(总部124、126、401、404、405、409)\n超大会议室设备使用,请通过“一站式运维平台-IT支持服务-员工服务入口-活动与会议技术支持”提前一天预约现场技术支持"
},
{
"title": "会议室电视机无法开启",
"content": "1. 近距离使用遥控器重试。\n2. 检查电视机背面或侧面电源键。\n确认电源连接正常。"
},
{
"title": "会议室HDMI连接线或转接头缺失",
"content": "请转人工联系“IT”服务号"
},
{
"title": "会议室电视机无法投屏",
"content": "1. 重新插拔投屏线。\n用遥控器切换电视信号源。"
}
]
},
{
"name": "网络电话",
"items": [
{
"title": "网络电话机故障",
"content": "拔插电源线,等待3分钟后重插,启动后重试(重启约需1分钟)。"
}
]
},
{
"name": "碎纸机",
"items": [
{
"title": "碎纸机使用方法",
"content": "确认碎纸机已通电并处于待机状态,电源指示灯正常亮起。\n将待销毁的纸质文件整齐放入进纸口,避免折叠或过厚。\n按下“运行”按钮,碎纸机将自动开始工作,直至完成处理。\n文件粉碎完成后,机器会自动停止。"
},
{
"title": "碎纸机异常无反应",
"content": "依次检查电源插头、碎纸箱是否扣紧"
},
{
"title": "碎纸机卡纸处理",
"content": "单次碎纸上限一般8张普通复印纸,取出卡纸后,插拔电源重新启动"
}
]
}
]
},
{
"name": "网络",
"subs": [
{
"name": "有线无线",
"items": [
{
"title": "iPad如何连接总部办公WiFi网络",
"content": "不支持员工认证方式。短期用访客码申请;长期需提交工单“终端设备网络准入申请”加白处理。\nhttp://devops.dc.servyou-it.com/dashboard,服务台-服务目录-IT支持服务-员工服务入口-终端设备网络准入申请"
},
{
"title": "员工手机怎么连接公司内网?",
"content": "打开手机搜索无线网络\n发现并连接servyou网络后,浏览器输入http://www.baidu.com等网址\n耐心等待30秒左右,触发弹出账号密码认证界面,依次输入员工账号密码和动态短信验证码登录,确保认证页面自动弹出,不要手动输入网址。\n注:\n新入职员工,请确认账号信息是否已同步,建议入职次日再尝试连接。\n苹果手机请使用QQ浏览器打开认证页面,避免使用Safari。如使用Safari,可尝试点击“显示详细信息”后访问。"
},
{
"title": "手机连接公司网络提示“未获取到手机号,请与管理员联系”",
"content": "新员工入职当天账号信息未完全同步,需第二天才可正常使用"
},
{
"title": "访客在公司总部如何联网",
"content": "申请访客码:\n1. 临时访客设备连接servyou网络,浏览器弹出认证界面后点击“申请访客码”(有效期24小时)。\n2. 拜访对象邮箱收到邮件,点击允许接入。\n3. 手机接收访客码并登录。"
},
{
"title": "电脑端税友安全助手登录异常“**认证失败,网络已断开”",
"content": "原因:账号密码输入错误、密码过期或税友安全助手安装后未重启电脑。\n解决:\n1. 重置密码: http://192.168.9.87:8080/employee-center/resetPwd.jsp\n2. 助手界面点击“注销”,手动重输密码。若无效则需重启电脑。"
},
{
"title": "手机连公司内网异常“账号/密码情误或认证被拒绝!请再次确认验证码,或者重置密码”",
"content": "确保认证界面自动弹出,勿手动输入网址。建议使用QQ浏览器,Safari可尝试“显示详细信息”后访问。"
},
{
"title": "员工办公电脑总部连接办公网络",
"content": "步骤:\n1. 连接SERVYOU无线或有线网络。\n2. 访问192.168.1.53下载安装税友安全助手。\n3. 重启电脑后登录助手(账号为邮箱前缀,密码同邮箱)。"
},
{
"title": "互联网部分网页无法访问【Windows】",
"content": "使用办公网络时,部分网页无法访问,可能因代理服务器设置异常导致。\n解决办法:\n1.检查DNS设置\n右键点击Windows 图标--“网络连接”-打开“更改适配器选项”--选择“以太网”或者“WLAN”-右键“属性”--选择“Internet协议版本 4TCP/IP4)”-点击“属性”-选择“使用下面的DNS服务器地址”-首选DNS服务器和备用DNS服务器---输入“10.253.0.55”(公司内网专用的 DNS)和“223.5.5.5”(阿里云公共 DNS)—单击“确定”。\n2.检查代理设置\n以 Edge浏览器为例“菜单>设置>显示高级设置>更改代理设置> LAN 设置 并取消选中”为 LAN 使用代理服务器“复选框。\n办公网络异常修复"
},
{
"title": "互联网部分网页无法访问【Mac】",
"content": "使用办公网络时,部分网页无法访问,可能因代理服务器设置异常导致。\n报错信息:\n代理服务器出现问题,或者地址有误。\n解决办法:\n1.检查DNS设置\n单击菜单栏右上角的“ Apple”图标,-选择“系统偏好设置”-选择“网络”,点击连接的网络(比如Wi-Fi)--------选择“高级”,在弹出的选框中点击“DNS”选项卡,然后点击左下角【+】图标,手动添加DNS地址。如:10.253.0.55(公司内网专用的 DNS)和223.5.5.5(阿里云公共 DNS)。\n2.取消所有代理协议勾选\n单击菜单栏右上角的“ Apple”图标,-------选择“系统偏好设置”----------选择“网络”,点击连接的网络,比如是Wi-Fi--------选择“高级”,在弹出的选框中点击“DNS”选项卡,取消所有协议前的勾选项”总部办公互联网出口IP地址\n电信:115.227.36.10;联通:180.178.252.186。更新信息见税友家园公告。"
}
]
},
{
"name": "零信任",
"items": [
{
"title": "SSL VPN升级零信任 aTrust通知",
"content": "自2026年3月19日起,因SSL VPN设备架构调整,为保障协议兼容性、性能与稳定性,SSL VPN更新升级为零信任 aTrust,请各位同事在更新升级后使用。"
},
{
"title": "SSLVPN与零信任区别",
"content": "SSL VPN是深信服传统的远程访问解决方案,EasyConnect是其客户端名称;而零信任是一种更先进的安全理念,aTrust则是深信服基于此理念推出的、用于替代和升级SSL VPN的具体产品。"
},
{
"title": "Windows操作系统SSLVPN客户端EasyConnect自动升级零信任aTrust指引",
"content": "步骤1:打开原 SSLVPN客户端easeconnect,并输入https://vpn.servyou.com.cn,点击“连接”\n步骤2:客户端登录,提示版本更新,点击“立即更新”\n步骤3:等待客户端自动完成更新和安装,完成客户端自动打开新的客户端\n步骤4:通过新的客户端sTrust,接入设置输入:https://vpn.servyou.com.cn,点击“确定接入”,然后输入账号密码登录"
},
{
"title": "Mac os操作系统SSLVPN客户端自动升级零信任aTrust指引",
"content": "Macy原客户端easeconnect首次登录后,会提示版本不匹配,需要下载新版本,下载后双击客户端安装文件完成安装即可。\n步骤1:客户端输入https://vpn.servyou.com.cn,会提示版本不匹配,点击“下载更新”\n步骤2:跳转的页面点击“立即下载”\n步骤3:双击已下载的客户端安装文件,根据提示完成安装\n步骤4:客户端安装完成后,新老客户端会同时存在,打开“atrust”客户端,并输入https://vpn.servyou.com.cn登录"
},
{
"title": "atrust客户端无法建立连接?",
"content": "请按以下步骤排查:\n退出客户端重新登录\n重启电脑后再次尝试\n检查本地网络是否正常\n确认未连接其他VPN软件"
},
{
"title": "atrust客户端登录成功后但无法访问内部系统怎么办?",
"content": "可能原因包括:\n本地缓存未刷新\nDNS缓存未更新\n权限问题\n建议:\n断开连接后重新登录\n执行DNS刷新(Windowsipconfig /flushdns"
},
{
"title": "零信任aTrust客户端下载地址",
"content": "Windows客户端下载:\nhttps://atrustcdn.sangfor.com/standard/windows/2.5.16.20/aTrustInstaller.exe\nMac客户端下载:\nhttps://atrustcdn.sangfor.com/standard/mac/2.5.16.20/aTrustInstaller.pkg\n安卓、苹果手机移动客户端下载:\n应用商店搜索“aTrust”app"
},
{
"title": "零信任访问非公共资源权限申请",
"content": "申请路径:打开一张式运维平台-服务目录-IT支持服务-员工零信任账号申请,类型选“权限申请”,根据资源类型选择测试资源或其他资源,其他咨询填写网址/IP/端口。\n申请地址:http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7"
},
{
"title": "零信任aTrust客户端登录提示“用户名或密码错误,您还有 次尝试的机会”",
"content": "原因一:没有申请过零信任账户,账户不存在\n申请方式:登录移动端企业微信,企业微信→工作台→一站式运维平台→服务目录→IT支持服务→员工零信任账号申请。\n原因二:密码输入错误、忘记密码或者申请账号后首次登录\n解决办法:需登录页https://vpn.servyou.com.cn点击“忘记密码”,用户名使用邮箱前缀,根据提示重置密码\n原因三:用户名输入错误或填写了员工账户中心密码\n解决办法:零信任账号与员工账户中心账号使用不同身份认证体系,如:aTrust用户名与虽然税友家园、邮箱前缀相同,但深信服aTrust采用独立密码管理规则,重置过程也与统一员工账号密码不同步"
},
{
"title": "零信任(原VPN)登录异常“账号禁用”",
"content": "360天未登录使用aTrust会导致账号被禁用,登录运维平台-员工零信任账号申请-申请类型“账号解禁\"\nhttp://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7"
},
{
"title": "零信任密码重置“用户信息匹配失败,请联系管理员,...”",
"content": "申请开通账号(零信任账号并非入职默认开通,如有办公需求,需登录移动端企业微信,企业微信→工作台→一站式运维平台→服务目录→IT支持服务→员工零信任账号申请。)\n检查手机号码填写正确,已更换手机号,请提交员工零信任账号申请,备注填写更换的新手机号)\n检查用户名是否正确,用户名为邮箱前缀,且字母均为小写"
},
{
"title": "零信任登录异常”账号锁定“",
"content": "密码输入错误3次后系统锁定账户,不进行任何操作10分钟自动解锁。等待期间勿操作以免重置锁定计时。"
},
{
"title": "零信任员工账号申请",
"content": "员工可以因出差、居家办公等情况单独申请零信任员工账号。\n办公内网申请方式: 一站式运维平台→服务目录→IT支持服务→员工零信任账号申请(http://devops.dc.servyou-it.com\n公司外部申请方式:登录移动端企业微信,企业微信→工作台→一站式运维平台→服务目录→IT支持服务→员工零信任账号申请。\n处理同事将会在工作时间1小时内接单,并在当天下班前处理完成,请耐心等待,处理进度请关注“一站式运维平台”企微应用消息提醒。"
},
{
"title": "零信任无法收到验证短信",
"content": "检查短信应用下所有信息目录,查看是否被垃圾信息、推广信息过滤\n重启手机。\n3. 机主发送短信“11111”至10690999申请解除黑名单。"
},
{
"title": "零信任验证手机号码更改",
"content": "通过一站式运维平台提交“员工零信任账号申请”工单,备注新旧手机号。手机端路径:企业微信→工作台→一站式运维平台。"
},
{
"title": "零信任登录提示异常“网络请求异常,请稍后重试”",
"content": "原因和解决办法:\n一般是网络波动导致,切换自己手机热点测试使用。"
},
{
"title": "零信任登录提示异常“路由连接失败”",
"content": "原因:网络冲突或DNS缓存。\n解决:\n使用外部网络(如手机热点)测试。\nMac:网络设置中添加DNS 10.253.0.55和223.5.5.5。"
},
{
"title": "零信任登录提示异常“选路连接失败,可能当前连接网络异常,请稍后重试”",
"content": "服务器地址栏需要完整输入 https://vpn.servyou.com.cn,不能省略https://,也不能填写为http://vpn.servyou.com.cn\n因安全和网络原因限制,集团总部(杭州)办公网络禁止连接零信任\n部分税局、酒店或其他无线网络波动或限制,\n解决方法:可尝试重启电脑后,使用手机热点连接网络,重新登录零信任"
},
{
"title": "零信任aTrust客户端支持桌面操作系统清单",
"content": "【Windows系统】\nWindows 7~11\n【Mac os】\nMacOS10.13~10.15Mac11.x~Mac OS 14.x\n【Linux/国产系统】\nUOSV20 For X86、ARM、MIPS、Loongarch\n麒麟(V10/V10 SP1For X86、ARM、MIPS\n麒麟(V10 SP1For Loongarch\nUbuntu 16、18、20、22、24 For X86\n中科方德(5.0-G220/5.0-G220H For X86、ARM、Loongarch\n注意:\n已发布版本中,windows11 arm架构的电脑不支持使用工作空间,同时不支持麒麟server系统、中标麒麟系统、deepin系统、centos系统接入。"
}
]
}
]
},
{
"name": "安全",
"subs": [
{
"name": "税友安全助手",
"items": [
{
"title": "税友安全助手卸载操作",
"content": "卸载税友安全助手后将无法正常在杭州总部进行网络访问,请确认税友安全助手卸载原因,如:电脑更换、离职、离开杭州总部工作\n2.通过以下方式获取卸载动态码\nwindows系统:下载IT提供卸载助手脚本 https://drive.weixin.qq.com/s?k=AAoA1wcYAAch0J2Cxe,直接双击运行后生成动态码,回复生成的动态码,填入桌面IT支持回复的卸载码进行卸载\nmac os系统:右键右上角的安全助手图标,点击卸载,随后提供动态码,\n3.将生成的动态码,通过智能IT助手 人工服务,提供生成的动态码,获取回复的卸载码进行卸载"
},
{
"title": "Window系统下载安装“税友安全助手”",
"content": "步骤:\n连接servyou网络,访问http://192.168.1.53 ,员工电脑通道-点击提示链接,下载安装“税友安装助手”。\n2. 安装后重启电脑,在任务栏右下角打开助手登录。\n税友安全助手下载链接 http://192.168.1.53:8099/portal/redirect/nacc/"
},
{
"title": "MAC OS系统下载安装“税友安全助手",
"content": "步骤:\n1. 连接servyou网络,访问http://192.168.1.53/portal/redirect/nacc/下载。\n2. 根据系统版本选择安装项(如MAC OS 14以上选70133)。\n3. 运行安装程序,按系统提示授权(点击“是”/“仍要打开”)。\n4. 输入开机密码(盲输),完成安装后重启电脑。"
},
{
"title": "税友安全助手打开方式和查看运行状态图标",
"content": "Windows系统:右下角任务栏图标;Mac:右上角菜单栏图标。"
},
{
"title": "Mac OS系统安装税友助手报错“身份不明的开发者”",
"content": "解决:\n1. 系统偏好设置→安全性与隐私→允许安装。\n2. 输入开机密码(盲输),完成安装后重启。"
}
]
},
{
"name": "火绒安全",
"items": [
{
"title": "火绒安全终端下载安装",
"content": "火绒安全是公司指定使用的杀毒软件,请根据情况选择安装版本:\n总部员工:请选择火绒终端安全企业版,下载地址:\nWindows系统: http://huorong.oa.servyou-it.com/deploy/installer.exe\nMacOS系统: http://huorong.oa.servyou-it.com/deploy/mac-inst.dmg\n安装过程中控制中心地址设置: http://huorong.oa.servyou-it.com:80\n区域员工:请选择火绒安全软件个人版\nWindows系统 https://www.huorong.cn/person5.html"
},
{
"title": "火绒安全如何卸载",
"content": "火绒安全卸载:向IT支持人工说明卸载原因获取输入卸载码,打开Windows系统控制面板-程序和功能,选择火绒终端安全管理系统安全终端-右键卸载,输入获取的卸载码"
},
{
"title": "火绒安全如何退出",
"content": "向IT支持人工说明退出原因获取卸载密码(火绒安全管理员密码),屏幕右下角火绒图标,点击“退出火绒”"
}
]
},
{
"name": "员工账户中心",
"items": [
{
"title": "员工账户密码重置和修改",
"content": "步骤:\n1. 访问http://192.168.9.87:8080/employee-center/resetPwd.jsp重置,密码需10位以上含大小写字母、数字、符号中的三种。\n2. 同步修改本地客户端(如总部刷卡打印机客户端、税友安全助手)密码。\n3.如不确认原密码或者原密码忘记,重置方式请选择“短信验证码重置”\n注意事项\n-员工账户密码有效期为90天,密码到期前3天会通过消息进行提醒,到期后未更新将重置为随机密码,需通过短信验证码重置方式找回\n-已经无法联网情况,可以借用同事电脑或者通过手机热点连接零信任后执行密码修改操作\n-使用独立密码的零信任和邮件客户端,无需修改重置"
}
]
},
{
"name": "风险应对",
"items": [
{
"title": "终端安全风险预警",
"content": "如果您遇到网络诈骗、网络攻击、恶意病毒、钓鱼邮件、账号被盗、信息泄露等安全问题,或已点击链接,请立即点击链接向“信息安全支持”进行反馈.https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAAtkP_ODMcv53bGE5x5M9YYw"
},
{
"title": "重大安全活动相关信息和管理要求",
"content": "活动期间禁用社交软件,公共服务策略调整详见公告链接。\n活动时间以税友家园通知为准,或咨询“信息安全支持”服务号"
},
{
"title": "重大安全活动期间软件限制“微信无法登录“",
"content": "本答案适用于重大安全活动期间,当前时期可能不适用,活动期间范围请关注税友家园公告\n活动期间禁止使用微信/QQ/脉脉等,需通过工单申请特殊权限。\n申请路径:一站式运维平台→集团内部服务→其他服务→办公及远程接入网络安全策略申请。https://devops.dc.servyou-it.com/itsm/service/workbench\n=gf4ljb\n如有疑问请联系企业微信“员工服务-信息安全支持”"
},
{
"title": "远程控制软件使用限制与特殊申请(向日葵、Todesk、Teamivew、Teamview",
"content": "根据 2023年第【13】号《税友集团信息安全管理制度》第二十二条,2.2.7. 远程办公中规定,禁用使用远程工具(包括不限于向日葵)访问个人办公电脑。即不得使用远程工具用于员工远程办公用途。特殊情况需通过工单申请:申请路径:一站式运维平台→集团内部服务→其他服务→办公及远程接入网络安全策略申请,如有疑问请企业微信联系“员工服务-信息安全支持”。https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%8A%9E%E5%85%AC%E5%8F%8A%E8%BF%9C%E7%A8%8B%E6%8E%A5%E5%85%A5%E7%BD%91%E7%BB%9C%E5%AE%89%E5%85%A8%E7%AD%96%E7%95%A5%E7%94%B3%E8%AF%B7\n向日葵软件下载地址:https://sunlogin.oray.com/download"
}
]
}
]
},
{
"name": "资产",
"subs": [
{
"name": "硬件资产",
"items": [
{
"title": "个人名下公司资产及资产详细信息查询",
"content": "方式一:登录eHR系统,进入“个人信息-个人固定资产”页面,可查询领用设备清单、资产名称、资产编号、规格\n方式二:查看设备固定资产标签,一般在设备底部或侧边,包含资产名称、启用时间、资产编号、型号"
},
{
"title": "办公电脑升级和汰换流程",
"content": "操作步骤:\n1.自行提交“IT资产升级申请”,申请流程路径:企业微信-工作台-审批-IT资产升级申请\n2.审批通过后,总部同事至122室办理;区域同事联系当地资产管理员。\n注意事项:\n升级申请内存、硬盘、显示器,其容量、尺寸和型号需符合《IT资产配置标准》中的岗位要求,特殊超标申请需部门领导审批。\n办公电脑启用时间达到五年,可以申请整机汰换(Mac电脑汰换周期暂定未8年)\n《IT资产配置标准》文档链接:https://oa.servyou-it.com/spa/document/index.jsp?id=3471&router=1#/main/document/detail"
},
{
"title": "公司领用办公电脑使用或启用年限已满5年,可以申请延期使用吗?",
"content": "公司电脑启用年限满5年不是强制汰换要求,可以继续使用,无需办理延期申请."
},
{
"title": "办公IT资产借用/退还",
"content": "可借用设备类型:办公电脑、显示器、小鱼会议终端、会议音箱\n申请审批流程入口:企业微信-审批-资产借用申请\n领取/退还地点:\n总部同事至税友亿企赢大厦122室办理;\n区域同事联系当地资产管理员。"
},
{
"title": "办公IT资产领用/退还",
"content": "可领用设备类型:办公电脑、显示器、键盘、鼠标、网线、话机\n申请审批流程入口:企业微信-审批-资产领用申请\n注:键盘、鼠标、网线、话机等低值易耗品无需提交申请流程\n领取/退还地点:\n总部同事至税友亿企赢大厦122室办理;\n区域同事联系当地资产管理员。"
},
{
"title": "系统维修工具借用",
"content": "借用工具类型:系统安装U盘、螺丝刀、移动硬盘(盒)\n借用流程:\n1.回复“IT”获取桌面IT支持人工支持链接\n2.点击IT支持人工支持链接进入人工坐席咨询窗口\n3.说明需要借用的工具类型、使用地点、使用时间\n4.耐心等待人工支持坐席回复,确认设备库存。\n5.总部员工前往121室进行借用登记,区域同事联系资产管理员"
}
]
},
{
"name": "软件资产",
"items": [
{
"title": "公司限制使用的商业软件清单",
"content": "正版化严控清单(包括但不限于):\nXshell/Xftp/Xmanager、InterBase、Delphi、MyEclipse、CINEMA4D、Anaconda、Fiddler、Navicat、VMware全系列、UltraEdit、HP Loadrunner、Adobe全系列(如Acrobat、Acrobat Reader、Photoshop、lllustrator、After Effects、Premiere、Lightroom、Audition、InDesign、Adobe XD等)。"
},
{
"title": "parallelsDesktop软件使用限制与替代方案",
"content": "公司实行软件正版化管理,收费软件需经需求评估后安装。\n替代方案:建议使用VirtualBox。\n资源包下载(含Win7/Win10纯净版及VB安装包):\n链接:https://pan.baidu.com/s/1ly-3vDMOh48yRXRo-b-hBg 提取码:serv\n安装指南:在VirtualBox中通过“管理→导入虚拟电脑”直接导入系统。"
},
{
"title": "visio软件使用限制与替代方案",
"content": "公司实行软件正版化管理,收费软件需经需求评估后安装。\n替代方案:\n1. 仅需读取Visio文档:使用Microsoft Visio查看器(https://www.microsoft.com/zh-cn/download/confirmation.aspx?id=51188 )。\n2. 需编辑文档且可接受非vsdx格式:使用ProcessOn在线工具(https://www.processon.com/i/5c99dd75e4b0180f6ee6c615 )。注:敏感信息勿用。\n3. 必须输出vsdx格式(如外部分享):走正式审批流程,路径:企业微信→工作台→审批→商业软件服务申请,费用2040元由部门分摊,需事业部总经理审批。"
},
{
"title": "微软office软件使用限制与替代方案",
"content": "公司推行软件正版化政策,禁止安装盗版软件。安装Microsoft Office需部门分摊费用3070元,申请流程:\n路径:企业微信→工作台→审批→商业软件服务申请。\n审批要求:需事业部总经理批准。\n建议:若无特殊需求,优先安装WPS作为替代方案。"
}
]
},
{
"name": "自备电脑",
"items": [
{
"title": "自备电脑申请及审核",
"content": "步骤:\n1. 确认岗位在《自备电脑补贴岗位清单》内。\n2. 电脑配置需高于公司标准。\n3. eHR系统→流程申请→自备电脑使用申请/变更,提交购买凭证。\n4. 半工作日内完成配置审核。详情见《自备电脑使用及补贴管理办法》。\n详情请查看《自备电脑使用及补贴管理办法》\nhttps://oa.servyou-it.com/spa/document/index2file.jsp?id=75578&versionId=78041&imagefileId=96908&router=1#/main/document/fileView"
},
{
"title": "所有在公司使用的自备电脑的都需要登记?不领取补贴但使用自备电脑的员工是否需要登记?",
"content": "根据公司管理要求,所有在公司使用的自备电脑的员工,都需要进行自备电脑信息登记"
},
{
"title": "自备电脑购买二手电脑如何计算购买时间?",
"content": "二手按电脑电脑首次销售发票开具时间开始计算,如果无法提供购买发票,则按设备出厂时间计算,也可按官网查询的首次购买,以及设备激活、保修开始时间计算"
},
{
"title": "自备电脑无法提供销售发票或者发票遗失如何计算购买时间?",
"content": "可以使用平台订单、收据、转账记录作为购买凭证时间参考依据(不包含二手电脑二次销售凭证),如果没有购买凭证参考依据,则使用设备出产时间"
},
{
"title": "自备电脑发票时间、出厂时间、购买记录不一致如何计算?",
"content": "补贴发放截止时间采纳的优先次序为 , 发票时间>购买记录>出厂日期"
},
{
"title": "使用配件自行组装自备电脑如何计算出厂时间",
"content": "按整机或主要配件(CPU、主板)任一主要配件购买凭证或出厂日期计算"
},
{
"title": "自备电脑如何查看通过电脑序列号查询生产日期",
"content": "联想 https://pre.wx.lenovo.com.cn/wordpress/?p=1456 https://newsupport.lenovo.com.cn/guardeploySearch.html?fromsource=guanwang&_ga=2.67510865.1168833807.1598233691-1876919846.1595491060\nhttps://newthink.lenovo.com.cn/guarantee.html?v=329b114e91fec9e2336126dfd1b6ff42\nDell https://www.dell.com/support/contents/zh-cn/article/product-support/self-support-knowledgebase/locate-service-tag/notebook https://www.dell.com/support/contractservices/zh-cn/\nHP https://support.hp.com/cn-zh/document/ish_2898769-2609229-16 https://support.hp.com/cn-zh/check-warranty\n微软 https://support.microsoft.com/zh-cn/surface/%E6%9F%A5%E6%89%BE-surface-%E8%AE%BE%E5%A4%87%E5%92%8C%E9%85%8D%E4%BB%B6%E6%88%96microsoft%E9%85%8D%E4%BB%B6%E7%9A%84%E5%BA%8F%E5%88%97%E5%8F%B7-6c0abc0c-2b45-247d-f959-70e504e55fa5 https://mybusinessservice.surface.com/en-US/CheckWarranty/CheckWarrantyhttps://support.microsoft.com/zh-cn/surface/surface-%E4%BF%9D%E4%BF%AE-%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E8%A7%A3%E7%AD%94-1217913a-2692-424e-a5c4-0eb0de84f05a\n小米 https://www.mi.com/service/notebook/drivers https://47wke3.smartapps.baidu.com/?_chatQuery=%E5%B0%8F%E7%B1%B3%E6%80%8E%E4%B9%88%E6%9F%A5%E5%87%BA%E5%8E%82%E6%97%A5%E6%9C%9F&searchid=14302220643046412854&_chatParams=%7B%22agent_id%22%3A%22592d7%22%2C%22content_build_id%22%3A%2218852dd5%22%2C%22from%22%3A%22q2c%22%2C%22token%22%3A%22alVvR3EyL3lWVnpwRk02ZFVSUG9GUzhkMkNZTDFwa0IySVJBUS9ORUxob2cyb0pObjdmVDhXQVJteEpqWjVMY2VMVzRoVmtBejBjRWdnNEdTNG5MclVQUGRIc3ZLa1QvMFhSQUdLMmhPRVVveHRQT3AvQUhTSldHTEdqU2NPa0NkUDJVNEU1MEVxK0o2UGg5czJjQ09CWUQzcVh6elRFVGJiNitpNmFvakxzPQ%3D%3D%22%2C%22chat_no_login%22%3Atrue%7D&_swebScene=3711000610001000\n宏基 https://community.acer.com/cn/kb/articles/863-%E5%BA%8F%E5%88%97%E5%8F%B7%E6%88%96snid%E5%8F%B7 https://www.acer.com.cn/myhelp.html?type=3&serverid=143\n华为 https://consumer.huawei.com/cn/support/content/zh-cn00688529/ https://consumer.huawei.com/cn/support/warranty-query/\n华硕 https://www.asus.com.cn/support/article/566/ https://www.asus.com.cn/support/warranty-status-inquiry/\n神州 机器底部有一个lOT http://www.hasee.com/after/index\n苹果\nhttps://support.apple.com/zh-cn/102767 https://checkcoverage.apple.com/user-consent"
},
{
"title": "自备电脑补贴岗位是如何设定的?",
"content": "自备电脑补贴岗位范围,是针对对电脑性能有较高性能需求技术岗位,以及部分特殊需求岗位;对于这部分性能要求较高的开发和测试岗位,一方面我们提高了这些岗位公司配发电脑标准,同时保留了自备电脑补充策略供员工自由选择"
},
{
"title": "自备电脑管理办法提到较高性能的技术岗位是如何确定的?",
"content": "根据总部历年员工满意度调查中员工反馈,以及IT资产配置标准评估过程中电脑内存CPU报警统计信息,开发和测试岗位所使用电脑的CPU和内存报警次数和时长远高于其他岗位(80%内存CPU报警阈值),开发和测试类岗位与其他岗位相比,对电脑性能有明显较高要求。"
},
{
"title": "自备电脑补贴岗位以后还会有新增或变更吗?",
"content": "参考《IT资产配置标准》中岗位与设备变更和执行反馈意见,由管理部门共同商议修订,并在EHR系统同步更新。"
},
{
"title": "自备电脑配置是否符合IT资产配置标准如何判断?",
"content": "自备电脑配置审核标准要求,主要看配置是否达到购买日期或补贴发放历史年度IT资产配置标准\n●当前自备电脑配置需满足任职岗位的当前公司配发电脑配置最低标准\n●CPU主要看是否同级&同代(i3\\i5\\i7\\i9)同代(八代、十代、11代、12代...),跨级跨带可酌情增加和降低审核标准(每相差1年一代按1年累积计算)。\n●内存和硬盘不符情况下,可提供升级后的配置信息截图或升级配件购买记录"
},
{
"title": "\"自备电脑补贴到期截止时间是怎么计算的?",
"content": "●6/10前所有现有领取补贴员工需进行登记,不登记电脑信息,不发放补贴\n●当前使用自备电脑购买日期<5年,补贴领取截止时间为当前使用电脑从购买之日起5年\n●当前使用自备电脑购买日期>5年,停止补贴发放\n●非补贴岗位6/3日期后购买的电脑不享受存量自备电脑补贴政策"
},
{
"title": "自备电脑补贴年限规定?",
"content": "单台自备电脑电脑补贴有效期为由购买日期计算至5年截止。"
},
{
"title": "自备电脑补贴岗位内的人员,后续电脑更换了需要怎么操作",
"content": "单台自备电脑补贴期限最多为5年,到期后自动停发补贴。若要继续申请补贴,新购或更换设备后,须在5个工作日内在EHR系统重新提交“自备电脑使用申请”,并提供购买凭证(发票、收据)或出厂日期证明。"
},
{
"title": "\"自备电脑电脑补贴岗位外的补贴时间是到什么时候结束?",
"content": "自备电脑补贴岗位外正在享受补贴的员工,继续享受补贴至当前使用自备电脑补贴有效期截止;"
},
{
"title": "自备补贴到期了,不想用公司配发电脑,可以继续使用自备电脑吗?",
"content": "可以继续使用自备电脑,但无法领取补贴,需遵守自备电脑管理要求,进行自备电脑登记,纳入自备电脑台账管理。登录eHR系统- 流程申请-自备电脑使用申请进行登记。"
},
{
"title": "\"入职、离职、调岗,自备电脑与公司电脑直接切换当月,自备电脑使用不足1月,补贴金额怎么计算",
"content": "自然月内累计使用自备电脑办公≥15天,按100元/月标准随工资发放补贴。"
},
{
"title": "自备电脑补贴到期后,如何申请公司电脑?",
"content": "自备电脑申请路径:EHR系统个人信息-个人固定资产查看中-进行报备。"
},
{
"title": "实习生可以申请自备电脑补贴吗?",
"content": "实习生不在自备电脑补贴范围内"
},
{
"title": "\"自备电脑如果使用的MAC电脑或者AMD 或非intel CPU应该如何评估?",
"content": "与同类intel芯片做比较,在20%性能差异范围内,可使用通用查询工具AI或CPU天梯图查询相关性能对比信息,酌情综合评估是否满足岗位工作需要。"
},
{
"title": "\"购买的自备电脑原始配置没有达到岗位要求,后续通过升级后达到配置要求,可以获得补贴吗?",
"content": "通过后续升级后达到岗位IT资产配置标准符合补贴资格条件,硬盘容量可以通过外置连接方式升级,但内置硬盘应为固态硬盘,且固态硬盘容量不低于256GB。"
}
]
}
]
},
{
"name": "其他",
"subs": [
{
"name": "活动支持",
"items": [
{
"title": "会议室预定",
"content": "总部会议室:企业微信→工作台→“会议室预定”应用。\n区域会议室(北京/石家庄等):企业微信→工作台→“会议室”应用。"
},
{
"title": "活动与会议技术支持预约",
"content": "提交预约工单(需至少提前1天):https://oa.servyou-it.com/spa/portal/static/index.html#/main/portal/portal-8-34 ,选择“重要活动支持预约”。"
}
]
},
{
"name": "党工",
"items": [
{
"title": "税友家园",
"content": "税友家园网址: https://oa.servyou-it.com\n账号密码认证方式:统一员工账号密码\n税友家园登录异常\n情况一:新员工入职次日方可登录,请耐心等待。\n情况二:账号密码错误,通过重置密码解决(http://192.168.9.87:8080/employee-center/resetPwd.jsp)。"
},
{
"title": "官网地址",
"content": "税友集团官网地址:https://www.servyou.com.cn\n亿企赢官网地址:https://www.17win.com\n亿企鑫福官网地址:https://17xinfu.com"
}
]
},
{
"name": "人力资源",
"items": [
{
"title": "人力相关问题咨询(考勤、薪资、保险等)",
"content": "通过企业微信→员工服务→“人力资源共享服务咨询”联系人力资源部门。https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAAvSRL5i5b_Xia8vCmFc2gRw"
},
{
"title": "人力资源管理平台",
"content": "人力资源管理平台别名:税友eHR、EHR\n网站地址: https://ehr.dc.servyou-it.com\n应用路径:企业微信-工作台-税友eHR\n账号密码认证方式:统一员工账号密码,账号同企业邮箱的前缀"
},
{
"title": "员工个人手机号更改",
"content": "联系HR在EHR系统中修改。通过企业微信→员工服务→“人力资源共享服务咨询”联系人力资源部门。https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAAvSRL5i5b_Xia8vCmFc2gRw"
},
{
"title": "网络学院相关信息",
"content": "企业微信入口(推荐方式): 企业微信-工作台-网络学院\n电脑端访问:https://servyoulearning.yunxuetang.cn\n手机端链接:https://servyoulearning.yunxuetang.cn/m\n新入职当日13:00后生成账号,若无法登录请次日重试。\n实习生无网络学院账号,会定期禁用,转正后可以进入学习。 如有网络学院相关疑问可咨询刘馨月。"
},
{
"title": "新员工入职IT指引手册获取",
"content": "新员工入职IT指引手册/指南地址:https://doc.weixin.qq.com/doc/w3_AU8AjwZhAIgyNSkHK3OTjWueJe1oa?scode=AAoA1wcYAAcD09JltaAQgAuAYMANY"
}
]
},
{
"name": "财务",
"items": [
{
"title": "财务工作相关问题",
"content": "请联系财务中心或企业微信→员工服务→“总部报销服务台”。\n常见问题参考:\n财务软件安装:参考《财务人员工作环境安装指南》。\n差旅报销:通过企业微信→通讯录→员工服务→“总部报销服务台”咨询。\n金蝶EAS打印中断:重启软件或电脑后重试。\n金蝶安装:方式一:访问 http://10.90.5.92/down/kingdee.exe或http://192.168.2.67:6888/eassso/login ,点击帮助按钮获取安装包。使用问题咨询宋会讲。"
},
{
"title": "财务共享平台地址",
"content": "财务共享平台,旧地址192.168.9.215已下线,可以访问新域名:http://cwgx.oa.servyou-it.com/"
},
{
"title": "手机企微无法访问“总部差旅报销”",
"content": "问题现象:打开后报错“Whitelabel Error Page...Status=403”。\n解决步骤:\n1. 清理缓存:企业微信APP→头像→设置→通用→存储空间→清理缓存。\n退出并重新登录企业微信。"
},
{
"title": "手机企微滴滴打车权限开通/管理",
"content": "企微联系应用管理员:刘红霞"
}
]
},
{
"name": "物业",
"items": [
{
"title": "物业服务相关问题(工牌、门禁、停车等)",
"content": "联系人指引:\n咖啡馆/食堂超市:于闻婧\n停车/保洁:谭欣\n补卡/餐卡:陈乐\n三楼食堂包厢:王蕊\n会议接待:袁丽丽\n其他问题:咨询物业服务号。https://work.weixin.qq.com/nl/innerkfid/ikfCtcYBwAAUtkMyOToCZqe42ZBDupVEQ"
}
]
},
{
"name": "运维",
"items": [
{
"title": "一站式运维平台综合信息",
"content": "登录地址: http://devops.dc.servyou-it.com\n一站式运维平台使用统一员工账号密码+短信认证\n运维平台相关问题请咨询企业微信联系【员工服务号:工单系统技术支持】如:运维平台无法登录\n运维平台账号密码登录二次认证密码获取方法详见:运维平台登录说明https://doc.weixin.qq.com/doc/w3_AM4AvwYHAKkhd6GOfh1SAe8ID9Kbv?scode=AAoA1wcYAAcl8IQiqyAM4AvwYHAKk&qt_source=Search&qt_report_identifier=1764050011765&version=5.0.2.6008&platform=win\n维平台企微认证免扫描失效处理:\nchrome浏览器输入网址chrome://flags/#block-insecure-private-network-requests,搜索 Local Network Access Checks,改成Disabled\nedge浏览器输入网址edge://flags/#block-insecure-private-network-requests"
},
{
"title": "JumpServer堡垒机综合信息",
"content": "堡垒机访问权限申请:通过一站式运维系统提交申请,紧急情况联系工单处理人。\nhttp://devops.dc.servyou-it.com/itsm/service/workbench"
},
{
"title": "GitLab相关问题",
"content": "1. 账号锁定:5分钟后自动解锁;若忘记密码,请通过“员工账号密码重置”功能操作。\n2. 二次验证码手机更换:联系吴云鹏修改。\n3. 系统后台问题:联系吴云鹏处理。"
},
{
"title": "阿里云综合信息",
"content": "1.阿里云账号问题咨询:方笑\n2.阿里云账号验证mfa:陈伟章"
}
]
},
{
"name": "产研",
"items": [
{
"title": "Walle瓦力平台综合信息",
"content": "平台介绍:\nwalle 是提供集 接口文档自动生成、接口文档查看、接口调试、接口Mock、接口测试用例、接口调用代码生成、对外提供在线/离线文档 等功能的 自动化、智能化的综合性接口管理平台。可以提升研发接口开发中各阶段的效率与减少协作时的沟通成本,并助力团队制定符合团队的研发流程与规范。适合公司 GB 端及各分公司研发同学使用。\n功能介绍:\n接口生成与使用流程图\n平台账号密码:\n访问 walle 平台, 使用线上的 员工邮箱前缀 、 邮箱密码 登录 (eg: 账号 liaobl 密码:xxxxxx), 可以在 全部项目 页面 查看所有项目, 可以随意查看项目的接口文档,如果需要创建项目或对接口进行调试、Mock、修改等操作,需要找【于程程】或项目负责人 添加权限\n联系支持:\n使用过程有任何问题或者需求的可以直接联系[于程程],如:更换手机、需要获取新的二次认证二维码等\n如需及时了解 walle 平台的更新状态,可加入 【Walle 金牌服务群】,入群请联系[于程程]发送入群邀请"
}
]
},
{
"name": "运营",
"items": [
{
"title": "税友内管系统综合信息",
"content": "别名:小蚂蚁、小蜜蜂\n客户端不支持苹果操作系统安装运行\n税友内管系统客户端下载地址:https://drive.weixin.qq.com/s?k=AAoA1wcYAAcLEI7DnM\n内管系统咨询支持:倪银飞。"
},
{
"title": "基础运营平台综合信息",
"content": "基础运营平台别名BOSS\n基础运营平台地址:https://boss.dc.servyou-it.com/#/\n账号密码认证方式:统一员工账号密码\n登录提示账号密码错误:\n优先检查账号密码是否过期\n检查电脑右下角系统时间是否准确,若时间存在偏差,请手动同步时间"
},
{
"title": "快速查数工具综合信息",
"content": "快速查数工具别名QQT\n账号密码登录:账号密码与内部统一员工账密一致。\n使用问题咨询:联系公共数据团队:李晓刚(17682348007)、朱文赵(15088664612)。如:若二次认证失败\n报表权限开通:查询广场-选择对应报表操作列“申请”按钮,报表管理员会进行审批。"
}
]
}
]
}
]
+55
View File
@@ -0,0 +1,55 @@
// =============================================================================
// 企微IT智能服务台 — 坐席工作台应用入口
// =============================================================================
// 说明:Vue3 应用入口文件,负责:
// 1. 创建 Vue 应用实例
// 2. 注册 ElementPlus 组件库
// 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'
// ElementPlus 组件库
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// ElementPlus 中文语言包
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
// ElementPlus 图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// 全局样式
import './styles/global.css'
// 创建 Vue 应用实例
const app = createApp(App)
// --------------------------------------------------------------------------
// 注册 ElementPlus 图标组件
// --------------------------------------------------------------------------
// 遍历所有图标,全局注册为组件,方便在模板中直接使用
// 如 <el-icon><ChatDotRound /></el-icon>
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// --------------------------------------------------------------------------
// 注册插件
// --------------------------------------------------------------------------
// Pinia: 状态管理(存储会话列表、坐席信息等)
app.use(createPinia())
// Vue Router: 路由管理(页面跳转)
app.use(router)
// ElementPlus: UI 组件库(表格、表单、对话框等)+ 中文语言包
app.use(ElementPlus, { locale: zhCn })
// --------------------------------------------------------------------------
// 挂载应用到 DOM
// --------------------------------------------------------------------------
// 将 Vue 应用挂载到 index.html 中的 #app 元素
app.mount('#app')
+846
View File
@@ -0,0 +1,846 @@
// =============================================================================
// IT智能服务台 — 统一模拟数据源
// =============================================================================
// 说明:为所有模块提供丰富的 mock 数据,覆盖各种状态/优先级/边界场景
// 当后端 API 不可用时,Store 可回退使用此数据,确保前端可独立运行和演示
// =============================================================================
import type { Conversation, ConversationListData } from '../api/conversation'
import type { Message, MessageListData } from '../api/message'
import type { TodoItemData, TodoItemListData } from '../api/todo'
import type { Agent, AgentListData, LoginData } from '../api/agent'
import type { DraftResult, SummaryResult, TagsResult } from '../api/wingman'
// =========================================================================
// 1. 会话列表 mock — 10条,覆盖全部状态/优先级/标记组合
// =========================================================================
const now = new Date('2026-06-06T10:30:00+08:00')
const min = (n: number) => new Date(now.getTime() - n * 60000).toISOString()
const hour = (n: number) => new Date(now.getTime() - n * 3600000).toISOString()
export const mockConversations: Conversation[] = [
// --- 我的会话 (is_mine=true) ---
{
id: 'conv-001',
employee_id: 'zhangwei',
employee_name: '张伟',
department: '研发一部',
position: '高级工程师',
level: 'gold',
status: 'serving',
is_vip: true,
is_pinned: false,
is_todo: false,
urgency_score: 5,
tags: {
hand_raise: true,
need_intervene: false,
emotion: 'anxious',
emotion_keywords: ['试了3次', '都不行', '紧急'],
repeat_count: 3,
},
assigned_agent_id: 'agent-1',
last_message_at: min(2).toString(),
last_message_summary: '按你说的操作了,已经可以正常连接了',
created_at: hour(1).toString(),
updated_at: min(2).toString(),
is_mine: true,
assigned_agent_name: '宋献',
can_grab: false,
collaborating_agent_ids: [],
collaborating_agent_names: {},
is_collaborator: false,
impact_scope: 12,
is_blocking: true,
emotion_state: 'anxious',
participants: [],
},
{
id: 'conv-002',
employee_id: 'chenfang',
employee_name: '陈芳',
department: '市场部',
position: '部门经理',
level: 'silver',
status: 'serving',
is_vip: false,
is_pinned: false,
is_todo: false,
urgency_score: 4,
tags: {
hand_raise: false,
need_intervene: false,
emotion: 'worried',
emotion_keywords: ['着急', '客户等着'],
repeat_count: 1,
},
assigned_agent_id: 'agent-1',
last_message_at: min(8).toString(),
last_message_summary: '好的,我等您排查结果',
created_at: hour(2).toString(),
updated_at: min(8).toString(),
is_mine: true,
assigned_agent_name: '宋献',
can_grab: false,
collaborating_agent_ids: [],
collaborating_agent_names: {},
is_collaborator: false,
impact_scope: 3,
is_blocking: false,
emotion_state: 'worried',
participants: [],
},
{
id: 'conv-003',
employee_id: 'lina',
employee_name: '李娜',
department: '行政部',
position: '行政专员',
level: 'bronze',
status: 'queued',
is_vip: false,
is_pinned: false,
is_todo: false,
urgency_score: 2,
tags: {
hand_raise: false,
need_intervene: false,
emotion: 'calm',
emotion_keywords: [],
repeat_count: 0,
},
assigned_agent_id: null,
last_message_at: min(15).toString(),
last_message_summary: '打印机卡纸了,怎么处理?',
created_at: min(15).toString(),
updated_at: min(15).toString(),
is_mine: true,
assigned_agent_name: null,
can_grab: false,
collaborating_agent_ids: [],
collaborating_agent_names: {},
is_collaborator: false,
impact_scope: 1,
is_blocking: false,
emotion_state: 'calm',
participants: [],
},
{
id: 'conv-004',
employee_id: 'wanglei',
employee_name: '王磊',
department: '财务部',
position: '财务主管',
level: 'silver',
status: 'serving',
is_vip: false,
is_pinned: false,
is_todo: false,
urgency_score: 5,
tags: {
hand_raise: true,
need_intervene: true,
emotion: 'urgent',
emotion_keywords: ['报税截止', '马上', '很急'],
repeat_count: 5,
},
assigned_agent_id: 'agent-1',
last_message_at: min(5).toString(),
last_message_summary: '税控系统还是登录不上,下午要报税了',
created_at: hour(3).toString(),
updated_at: min(5).toString(),
is_mine: true,
assigned_agent_name: '宋献',
can_grab: false,
collaborating_agent_ids: [],
collaborating_agent_names: {},
is_collaborator: false,
impact_scope: 8,
is_blocking: true,
emotion_state: 'urgent',
participants: [],
},
{
id: 'conv-005',
employee_id: 'liuyang',
employee_name: '刘洋',
department: '销售部',
position: '销售总监',
level: 'platinum',
status: 'serving',
is_vip: true,
is_pinned: true,
is_todo: false,
urgency_score: 5,
tags: {
hand_raise: true,
need_intervene: false,
emotion: 'angry',
emotion_keywords: ['太慢了', '耽误时间', '投诉'],
repeat_count: 4,
},
assigned_agent_id: 'agent-1',
last_message_at: min(3).toString(),
last_message_summary: '这个问题已经拖了三天了,请尽快解决',
created_at: hour(24).toString(),
updated_at: min(3).toString(),
is_mine: true,
assigned_agent_name: '宋献',
can_grab: false,
collaborating_agent_ids: ['agent-2'],
collaborating_agent_names: { 'agent-2': '刘明' },
is_collaborator: false,
impact_scope: 20,
is_blocking: true,
emotion_state: 'angry',
participants: [
{ id: 'zhaoliu', name: '赵六', department: '人力资源部', type: 'employee', avatar: '', joined: true, joined_at: min(10).toString() },
{ id: 'qianqi', name: '钱七', department: '财务部', type: 'employee', avatar: '', joined: false },
],
},
{
id: 'conv-006',
employee_id: 'sunli',
employee_name: '孙丽',
department: '法务部',
position: '法务专员',
level: 'bronze',
status: 'queued',
is_vip: false,
is_pinned: false,
is_todo: false,
urgency_score: 3,
tags: {
hand_raise: false,
need_intervene: false,
emotion: 'confused',
emotion_keywords: ['不懂', '怎么操作'],
repeat_count: 2,
},
assigned_agent_id: null,
last_message_at: min(20).toString(),
last_message_summary: '合同管理系统登录后看不到审批列表',
created_at: min(20).toString(),
updated_at: min(20).toString(),
is_mine: true,
assigned_agent_name: null,
can_grab: false,
collaborating_agent_ids: [],
collaborating_agent_names: {},
is_collaborator: false,
impact_scope: 1,
is_blocking: false,
emotion_state: 'confused',
participants: [],
},
// --- 同事会话 (is_collaborator=true 或 由其他坐席处理) ---
{
id: 'conv-007',
employee_id: 'zhaomin',
employee_name: '赵敏',
department: '人力资源部',
position: 'HR经理',
level: 'silver',
status: 'ai_handling',
is_vip: false,
is_pinned: false,
is_todo: false,
urgency_score: 2,
tags: {
hand_raise: false,
need_intervene: false,
emotion: 'calm',
emotion_keywords: [],
repeat_count: 0,
},
assigned_agent_id: 'agent-2',
last_message_at: min(30).toString(),
last_message_summary: '新员工入职需要开通账号权限',
created_at: min(30).toString(),
updated_at: min(30).toString(),
is_mine: false,
assigned_agent_name: '刘明',
can_grab: true,
collaborating_agent_ids: [],
collaborating_agent_names: {},
is_collaborator: false,
impact_scope: 0,
is_blocking: false,
emotion_state: 'calm',
participants: [],
},
{
id: 'conv-008',
employee_id: 'huangqiang',
employee_name: '黄强',
department: '产品部',
position: '产品经理',
level: 'bronze',
status: 'ai_handling',
is_vip: false,
is_pinned: false,
is_todo: false,
urgency_score: 2,
tags: {
hand_raise: true,
need_intervene: false,
emotion: 'neutral',
emotion_keywords: [],
repeat_count: 1,
},
assigned_agent_id: null,
last_message_at: min(25).toString(),
last_message_summary: 'Figma 插件无法安装,需要管理员权限',
created_at: min(25).toString(),
updated_at: min(25).toString(),
is_mine: false,
assigned_agent_name: null,
can_grab: true,
collaborating_agent_ids: [],
collaborating_agent_names: {},
is_collaborator: false,
impact_scope: 0,
is_blocking: false,
emotion_state: 'neutral',
participants: [],
},
{
id: 'conv-009',
employee_id: 'chenjing_collab',
employee_name: '周杰',
department: '运维部',
position: '运维工程师',
level: 'gold',
status: 'serving',
is_vip: false,
is_pinned: false,
is_todo: false,
urgency_score: 3,
tags: {
hand_raise: false,
need_intervene: false,
emotion: 'calm',
emotion_keywords: [],
repeat_count: 0,
},
assigned_agent_id: 'agent-3',
last_message_at: min(10).toString(),
last_message_summary: '内网服务器 SSH 连接超时',
created_at: min(10).toString(),
updated_at: min(10).toString(),
is_mine: false,
assigned_agent_name: '陈静',
can_grab: true,
collaborating_agent_ids: ['agent-1'],
collaborating_agent_names: { 'agent-1': '宋献' },
is_collaborator: true,
impact_scope: 5,
is_blocking: false,
emotion_state: 'calm',
participants: [
{ id: 'sunba', name: '孙八', department: '产品部', type: 'employee', avatar: '', joined: true, joined_at: min(8).toString() },
],
},
// --- 历史会话 ---
{
id: 'conv-010',
employee_id: 'wuming',
employee_name: '吴明',
department: '客服部',
position: '客服专员',
level: 'bronze',
status: 'resolved',
is_vip: false,
is_pinned: false,
is_todo: false,
urgency_score: 2,
tags: {
hand_raise: false,
need_intervene: false,
emotion: 'calm',
emotion_keywords: [],
repeat_count: 0,
},
assigned_agent_id: 'agent-1',
last_message_at: hour(2).toString(),
last_message_summary: '耳机没声音,重装驱动后已恢复',
created_at: hour(3).toString(),
updated_at: hour(2).toString(),
is_mine: true,
assigned_agent_name: '宋献',
can_grab: false,
collaborating_agent_ids: [],
collaborating_agent_names: {},
is_collaborator: false,
impact_scope: 1,
is_blocking: false,
emotion_state: 'calm',
participants: [],
},
]
// =========================================================================
// 2. 聊天消息 mock — 12条,覆盖多种 msg_type 和 sender_type
// =========================================================================
const MSG = (id: string, created_at: string): Pick<Message, 'id' | 'conversation_id' | 'created_at'> => ({
id,
conversation_id: 'conv-001',
created_at,
})
export const mockMessages: Message[] = [
{
...MSG('msg-01', min(55)),
sender_type: 'system',
sender_id: 'system',
sender_name: '系统',
content: '',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
extra_data: { system_notice: '2026年6月6日 10:25 — 会话开始' },
},
{
...MSG('msg-02', min(54)),
sender_type: 'employee',
sender_id: 'zhangwei',
sender_name: '张伟',
content: 'VPN连接失败了,一直提示"无法连接到服务器",已经试了3次都不行。需要访问内网OA系统处理紧急审批。',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
},
{
...MSG('msg-03', min(50)),
sender_type: 'agent',
sender_id: 'agent-1',
sender_name: '宋献',
content: '您好张工,请问您使用的是 AnyConnect 客户端还是 SSL VPN 网页版?方便的话请提供一下您的客户端版本号,我帮您排查。',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
},
{
...MSG('msg-04', min(48)),
sender_type: 'employee',
sender_id: 'zhangwei',
sender_name: '张伟',
content: 'AnyConnect 4.10,上个月刚升级的。用同事电脑试了也不行,应该不是客户端的问题吧?',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
},
{
...MSG('msg-05', min(45)),
sender_type: 'ai',
sender_id: 'wingman-ai',
sender_name: 'AI助手',
content: '系统检测到 AnyConnect 4.10 版本存在已知证书兼容性问题。根据知识库记录,建议升级到 4.14 版本或使用 SSL VPN 网页版作为临时方案。',
msg_type: 'text',
ai_suggestion: true,
is_read: true,
},
{
...MSG('msg-06', min(42)),
sender_type: 'agent',
sender_id: 'agent-1',
sender_name: '宋献',
content: '收到。4.10 版本确实有个已知的证书兼容性问题,会导致连接超时。我先远程帮您排查一下,请先在电脑上允许远程桌面连接。',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
},
{
...MSG('msg-07', min(40)),
sender_type: 'employee',
sender_id: 'zhangwei',
sender_name: '张伟',
content: '好的,远程需要什么连接码?',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
},
{
...MSG('msg-08', min(38)),
sender_type: 'agent',
sender_id: 'agent-1',
sender_name: '宋献',
content: '远程连接码:RD-8862,请在企业微信上确认远程协助请求。',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
},
{
...MSG('msg-09', min(35)),
sender_type: 'employee',
sender_id: 'zhangwei',
sender_name: '张伟',
content: '[图片] 截图发你了,这个报错提示你看一下',
msg_type: 'image',
ai_suggestion: false,
is_read: true,
media_url: 'https://via.placeholder.com/800x600/202030/00d4ff?text=VPN+Error+Screenshot',
extra_data: { thumbnail_url: 'https://via.placeholder.com/200x150/202030/00d4ff?text=VPN+Error' },
},
{
...MSG('msg-10', min(30)),
sender_type: 'agent',
sender_id: 'agent-1',
sender_name: '宋献',
content: '看到了,错误码 ERR_CERT_EXPIRED 确实是证书过期导致的。请按以下步骤操作:\n1. 打开控制面板 → Internet选项 → 内容 → 清除SSL状态\n2. 打开 AnyConnect → 设置 → 清除缓存\n3. 重启 AnyConnect 客户端\n4. 如果还不行,我帮您推远程升级包到 4.14 版本',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
},
{
...MSG('msg-11', min(25)),
sender_type: 'employee',
sender_id: 'zhangwei',
sender_name: '张伟',
content: '按你说的操作了!清除SSL状态后重新连接成功了!太感谢了!',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
},
{
...MSG('msg-12', min(20)),
sender_type: 'system',
sender_id: 'system',
sender_name: '系统',
content: '会话已解决,满意度评价待用户反馈。',
msg_type: 'text',
ai_suggestion: false,
is_read: true,
extra_data: { system_notice: '会话状态: resolved | 处理时长: 35分钟' },
},
]
// =========================================================================
// 3. 坐席信息 mock — 5人
// =========================================================================
export const mockCurrentAgent: Agent = {
id: 'agent-1',
user_id: 'songxian',
name: '宋献',
status: 'online',
current_load: 3,
max_load: 5,
created_at: hour(720).toString(),
updated_at: min(5).toString(),
}
export const mockAgentList: Agent[] = [
mockCurrentAgent,
{
id: 'agent-2',
user_id: 'liuming',
name: '刘明',
status: 'busy',
current_load: 5,
max_load: 5,
created_at: hour(700).toString(),
updated_at: min(5).toString(),
},
{
id: 'agent-3',
user_id: 'chenjing',
name: '陈静',
status: 'online',
current_load: 2,
max_load: 5,
created_at: hour(680).toString(),
updated_at: min(5).toString(),
},
{
id: 'agent-4',
user_id: 'zhaolei',
name: '赵磊',
status: 'online',
current_load: 1,
max_load: 5,
created_at: hour(600).toString(),
updated_at: min(5).toString(),
},
{
id: 'agent-5',
user_id: 'wangfang',
name: '王芳',
status: 'offline',
current_load: 0,
max_load: 5,
created_at: hour(500).toString(),
updated_at: hour(1).toString(),
},
]
export const mockLoginData: LoginData = {
...mockCurrentAgent,
token: 'mock-jwt-token-session-20260606',
}
/** 坐席统计(从列表派生) */
export function getAgentStats() {
const all = mockAgentList
return {
onlineAgents: all.filter(a => a.status === 'online').length,
busyAgents: all.filter(a => a.status === 'busy').length,
offlineAgents: all.filter(a => a.status === 'offline').length,
}
}
// =========================================================================
// 4. 待办列表 mock — 5条,覆盖 3 种类型 + 3 种优先级
// =========================================================================
export const mockTodos: TodoItemData[] = [
{
id: 'todo-001',
type: 'ticket',
title: 'CEO办公室 — 投屏设备故障',
priority: 'urgent',
description: {
ticket_id: 'TKT-20260606001',
reporter: '王莉',
reporter_title: '张总秘书',
reporter_dept: 'CEO办公室',
issue_type: '硬件故障',
detail: 'CEO办公室会议室的投屏设备无法连接,下午2点有重要客户演示。已尝试重启设备3次,仍无法投屏。急需IT人员到现场处理。',
sla_deadline: new Date(now.getTime() + 12 * 60000).toISOString(),
location: '12楼 CEO会议室',
},
status: 'pending',
assigned_agent_id: null,
corp_id: 'corp-001',
created_at: min(5).toString(),
updated_at: min(5).toString(),
},
{
id: 'todo-002',
type: 'ticket',
title: '财务部 — 税控系统无法登录',
priority: 'urgent',
description: {
ticket_id: 'TKT-20260606002',
reporter: '王磊',
reporter_title: '财务主管',
reporter_dept: '财务部',
issue_type: '系统登录',
detail: '财务部税控系统(eTax)自今早9点起无法登录,提示"证书验证失败"。今天是报税截止日,下午3点前必须完成申报,否则会产生滞纳金。已联系税局客服,确认非税局端问题。',
sla_deadline: new Date(now.getTime() + 180 * 60000).toISOString(),
affected_users: 8,
},
status: 'pending',
assigned_agent_id: null,
corp_id: 'corp-001',
created_at: min(12).toString(),
updated_at: min(12).toString(),
},
{
id: 'todo-003',
type: 'approval',
title: 'IT采购申请 — 研发部笔记本电脑',
priority: 'high',
description: {
approval_id: 'APP-20260606003',
applicant: '李研发经理',
applicant_dept: '研发一部',
approval_type: 'IT设备采购',
budget: 86400,
budget_currency: 'CNY',
items: [
{ name: 'MacBook Pro 14" M4 Pro', qty: 6, unit_price: 12999, subtotal: 77994 },
{ name: 'USB-C 扩展坞', qty: 6, unit_price: 899, subtotal: 5394 },
{ name: '内胆包', qty: 6, unit_price: 169, subtotal: 1014 },
],
reason: '新入职 Python 后端团队标配开发机,替代旧的 ThinkPad T480(已服役4年)',
attachments: ['采购清单_20260606.xlsx', '报价单_Apple授权经销商.pdf'],
approver_chain: ['直属上级', 'IT总监', '财务审批'],
},
status: 'pending',
assigned_agent_id: 'agent-1',
corp_id: 'corp-001',
created_at: min(28).toString(),
updated_at: min(28).toString(),
},
{
id: 'todo-004',
type: 'device',
title: '会议室A — 投影仪离线告警',
priority: 'high',
description: {
device_id: 'DEV-20260606007',
device_name: '会议室A-投影仪',
model: 'Epson CB-W42',
serial_number: 'X3KP-78901234',
location: '6楼 会议室A',
ip: '192.168.10.88',
mac: '00:1B:44:11:3A:B7',
status: 'offline',
last_online: hour(2).toString(),
alert_count: 3,
alert_message: '设备连续3次 ping 不通,疑似网络或电源问题',
},
status: 'pending',
assigned_agent_id: 'agent-1',
corp_id: 'corp-001',
created_at: min(45).toString(),
updated_at: min(30).toString(),
},
{
id: 'todo-005',
type: 'approval',
title: '新员工入职 — 设备申领审批',
priority: 'normal',
description: {
approval_id: 'APP-20260606008',
applicant: '赵敏',
applicant_dept: '人力资源部',
approval_type: '新员工设备申领',
budget: 15000,
budget_currency: 'CNY',
items: [
{ name: 'ThinkPad X1 Carbon Gen 12', qty: 1, unit_price: 10999, subtotal: 10999 },
{ name: 'Dell 27" 4K 显示器 U2723QE', qty: 1, unit_price: 3599, subtotal: 3599 },
{ name: '罗技 MX Keys 键盘 + MX Master 3S 鼠标', qty: 1, unit_price: 1299, subtotal: 1299 },
],
reason: '下周一(6月9日)新入职市场总监,需提前准备办公设备',
new_hire_name: '林峰',
new_hire_position: '市场总监',
onboard_date: '2026-06-09',
},
status: 'pending',
assigned_agent_id: 'agent-1',
corp_id: 'corp-001',
created_at: min(35).toString(),
updated_at: min(35).toString(),
},
]
// =========================================================================
// 5. 用户画像 mock — 张伟完整信息
// =========================================================================
export interface EmployeeProfile {
name: string
department: string
position: string
it_level: string
it_level_name: string
it_level_lv: number
it_level_desc: string
vip: boolean
emotion_state: string
emotion_desc: string
tags: {
repeat_count: number
is_repeated: boolean
period_days: number
hand_raise: boolean
need_intervene: boolean
}
impact_scope: number
is_blocking: boolean
notes: string
monthly_tickets: Array<{ type: string; count: number }>
total_tickets_30d: number
join_date: string
device_model: string
preferred_contact_time: string
}
export const mockEmployeeProfile: EmployeeProfile = {
name: '张伟',
department: '研发一部',
position: '高级工程师',
it_level: 'gold',
it_level_name: '黄金',
it_level_lv: 3,
it_level_desc: '具备基础排障能力,能独立完成常规IT操作',
vip: true,
emotion_state: 'anxious',
emotion_desc: '语气急促,多次提到"紧急"和"试了很多次"',
tags: {
repeat_count: 3,
is_repeated: true,
period_days: 7,
hand_raise: true,
need_intervene: false,
},
impact_scope: 12,
is_blocking: true,
notes: '孕晚期(37周),远程办公;偏好下午14:00-16:00沟通;遇到技术问题时容易焦虑,需要耐心引导和清晰的分步指导',
monthly_tickets: [
{ type: 'VPN', count: 2 },
{ type: '企业邮箱', count: 1 },
],
total_tickets_30d: 3,
join_date: '2023-03-15',
device_model: 'ThinkPad X1 Carbon Gen 11',
preferred_contact_time: '14:00-16:00',
}
// =========================================================================
// 6. AI 推荐补充 mock
// =========================================================================
export const mockAiPanelDrafts: DraftResult[] = [
{
content: '您好!VPN连接问题通常有以下几种可能:\n1. 客户端版本兼容性问题(AnyConnect 4.10 有已知Bug\n2. SSL证书缓存过期\n3. 网络配置变更\n请先提供您的客户端版本号,我来帮您逐步排查。',
confidence: 0.92,
reasoning: '基于用户描述的VPN连接失败关键词和历史工单分析',
},
{
content: '建议方案:\n1. 按 Win+R 打开运行 → 输入 inetcpl.cpl → 内容 → 清除SSL状态\n2. 打开 AnyConnect → 设置 → Diagnostics → Clear Caches\n3. 重启 AnyConnect 重新连接\n如果以上步骤无效,请告知,我将远程协助。',
confidence: 0.88,
reasoning: '匹配知识库"AnyConnect 4.10 证书兼容性问题"方案',
},
{
content: '您好!邮箱登录异常通常有以下原因:\n1. 密码过期(90天强制更换)\n2. 账号被临时锁定(多次密码错误)\n3. 客户端配置错误\n请先确认是否收到密码过期提醒邮件?',
confidence: 0.88,
reasoning: '匹配知识库"企业邮箱登录异常"常用排查流程',
},
{
content: '打印驱动重装步骤:\n1. 打开"设置 → 蓝牙和其他设备 → 打印机和扫描仪"\n2. 找到问题打印机 → 删除设备\n3. 访问 \\\\print-server\\drivers 下载最新驱动\n4. 安装后重新添加打印机\n需要我远程协助吗?',
confidence: 0.78,
reasoning: '匹配知识库"打印机驱动重装"标准流程',
},
]
export const mockAiSummary: SummaryResult = {
problem: 'AnyConnect VPN 客户端无法连接内网,提示"无法连接到服务器"',
cause: '客户端版本为 4.10,存在已知的 SSL 证书兼容性问题,导致证书链验证失败后连接超时',
solution: '指导用户清除 SSL 状态缓存后重新连接成功。建议后续统一推送升级至 4.14 版本避免类似问题。',
}
export const mockAiTags: TagsResult = {
suggested_tags: ['VPN', '版本兼容', '远程排查', '证书问题'],
category: '办公网络',
priority: 'high',
}
// =========================================================================
// 7. 导出聚合列表类型
// =========================================================================
export const mockConversationListData: ConversationListData = {
items: mockConversations,
total: mockConversations.length,
}
export const mockMessageListData: MessageListData = {
items: mockMessages,
has_more: false,
}
export const mockTodoListData: TodoItemListData = {
items: mockTodos,
total: mockTodos.length,
}
export const mockAgentListData: AgentListData = {
items: mockAgentList,
}
+88
View File
@@ -0,0 +1,88 @@
// =============================================================================
// 企微IT智能服务台 — 坐席工作台路由配置
// =============================================================================
// 说明:定义页面路由映射
// 包括:
// 1. /login → 登录页(简单的用户名密码表单)
// 2. /workspace → 坐席工作台(需要认证)
// 3. / → 重定向到 /workspace
// =============================================================================
import { createRouter, createWebHistory } from 'vue-router'
// --------------------------------------------------------------------------
// 路由配置
// --------------------------------------------------------------------------
const routes = [
{
// 根路径重定向到工作台
path: '/',
redirect: '/workspace',
},
{
// 登录页面
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '坐席登录', requiresAuth: false },
},
{
// 坐席工作台主页面
path: '/workspace',
name: 'Workspace',
component: () => import('@/views/Workspace.vue'),
meta: { title: '坐席工作台', requiresAuth: true },
},
]
// --------------------------------------------------------------------------
// 创建路由实例
// --------------------------------------------------------------------------
// createWebHistory: 使用 HTML5 History 模式,基础路径 /itagent/(与IT数据平台共享域名)
const router = createRouter({
history: createWebHistory('/itagent/'),
routes,
})
// --------------------------------------------------------------------------
// 路由守卫 — 检查登录状态
// --------------------------------------------------------------------------
// 访问需要认证的页面前,检查 localStorage 中是否有 token
// 没有 token 则跳转到登录页
router.beforeEach((to, _from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - IT智能服务台`
}
// ========================================================================
// Portal Token 传递:从 URL 参数 ?token=xxx 读取并保存到 localStorage
// ========================================================================
const urlParams = new URLSearchParams(window.location.search)
const urlToken = urlParams.get('token')
if (urlToken) {
// 保存 token 到坐席端 localStorage key
localStorage.setItem('agent_token', urlToken)
// 同时保存到 portal_token key(方便跨端共享)
localStorage.setItem('portal_token', urlToken)
// 清除 URL 参数,避免刷新页面重复读取
const cleanUrl = window.location.pathname
window.history.replaceState({}, '', cleanUrl)
}
// 检查是否需要认证
const requiresAuth = to.meta.requiresAuth !== false // 默认需要认证
const token = localStorage.getItem('agent_token')
if (requiresAuth && !token) {
// 需要认证但没有 token,跳转到 Portal 统一入口
window.location.href = '/itportal/'
} else if (to.path === '/login' && token) {
// 已登录用户访问登录页,跳转到工作台
next({ path: '/workspace' })
} else {
next()
}
})
export default router
+251
View File
@@ -0,0 +1,251 @@
// =============================================================================
// 企微IT智能服务台 — 坐席状态管理(Pinia Store
// =============================================================================
// 说明:管理坐席登录状态、当前坐席信息、坐席状态切换
// 核心功能:
// 1. 当前登录坐席信息
// 2. 登录/登出方法
// 3. 坐席状态(online/busy/offline
// 4. Token 管理(localStorage 存储)
// =============================================================================
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Agent } from '@/api/agent'
import { login as apiLogin, getCurrentAgent, updateAgentStatus, getAgents } from '@/api/agent'
import { mockLoginData, mockCurrentAgent, mockAgentListData } from '@/mock/data'
import router from '@/router'
// --------------------------------------------------------------------------
// Token 存储 key
// --------------------------------------------------------------------------
const TOKEN_KEY = 'agent_token'
const PORTAL_TOKEN_KEY = 'portal_token'
const AGENT_USER_ID_KEY = 'agent_user_id'
// --------------------------------------------------------------------------
// Store 定义
// --------------------------------------------------------------------------
export const useAgentStore = defineStore('agent', () => {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 当前登录的坐席信息 */
const agentInfo = ref<Agent | null>(null)
/** 认证 token — 优先从 agent_token 读取,降级读取 portal_token */
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY) || localStorage.getItem(PORTAL_TOKEN_KEY))
/** 坐席用户ID */
const agentUserId = ref<string | null>(localStorage.getItem(AGENT_USER_ID_KEY))
/** 是否正在登录 */
const logging = ref<boolean>(false)
/** 可转接的坐席列表(用于转接功能) */
const availableAgents = ref<Agent[]>([])
// ==========================================================================
// 计算属性
// ==========================================================================
/** 是否已登录 */
const isLoggedIn = computed(() => !!token.value && !!agentInfo.value)
/** 坐席状态 */
const agentStatus = computed(() => agentInfo.value?.status || 'offline')
/** 坐席姓名 */
const agentName = computed(() => agentInfo.value?.name || '')
/** 坐席IDuser_id */
const userId = computed(() => agentInfo.value?.user_id || agentUserId.value || '')
// ==========================================================================
// 方法
// ==========================================================================
/**
* 坐席登录
* 调用后端登录 API,获取坐席信息和 token
* admin 角色需要 OTP 二次验证
* 登录成功后自动跳转到工作台页面
*
* @param inputUserId - 企微用户ID
* @param inputName - 坐席姓名
* @param otpCode - OTP 动态码(可选)
* @returns 登录数据(包含 require_otp 标记)
*/
async function login(inputUserId: string, inputName: string, otpCode?: string): Promise<any> {
try {
logging.value = true
const data = await apiLogin(inputUserId, inputName, otpCode)
// 检查是否需要 OTP 验证
if ('require_otp' in data && data.require_otp) {
// 返回 data,让 Login.vue 处理 require_otp
logging.value = false
return data
}
// 保存登录信息
token.value = data.token
agentUserId.value = data.user_id
localStorage.setItem(TOKEN_KEY, data.token)
localStorage.setItem(AGENT_USER_ID_KEY, data.user_id)
// 更新 Axios 默认请求头(添加 Authorization
// 注意:apiClient 拦截器中会从 localStorage 读取 token
// 保存坐席信息(去掉 token 字段)
const { token: _token, ...agentData } = data
agentInfo.value = agentData as Agent
// 跳转到工作台
router.push('/workspace')
} catch (error) {
console.error('登录失败:', error)
// 使用 mock 数据作为 fallback(开发/演示用)
if (import.meta.env.DEV) {
console.warn('[Mock] 使用模拟登录数据')
const { token: _t, ...agentData } = mockLoginData
token.value = mockLoginData.token
agentUserId.value = mockLoginData.user_id
agentInfo.value = agentData as Agent
localStorage.setItem(TOKEN_KEY, mockLoginData.token)
localStorage.setItem(AGENT_USER_ID_KEY, mockLoginData.user_id)
router.push('/workspace')
return
}
throw error
} finally {
logging.value = false
}
}
/**
* 坐席登出
* 清除本地存储的登录信息,跳转到登录页
*/
function logout(): void {
// 清除状态
token.value = null
agentUserId.value = null
agentInfo.value = null
// 清除 localStorage
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(AGENT_USER_ID_KEY)
// 跳转到登录页
router.push('/login')
}
/**
* 刷新当前坐席信息
* 从后端获取最新的坐席数据
*/
async function refreshAgentInfo(): Promise<void> {
try {
if (!token.value) return
const data = await getCurrentAgent()
agentInfo.value = data
} catch (error) {
console.error('获取坐席信息失败:', error)
// 使用 mock 数据作为 fallback(开发/演示用)
if (import.meta.env.DEV && !agentInfo.value) {
console.warn('[Mock] 使用模拟坐席信息')
agentInfo.value = mockCurrentAgent
}
// 如果是 401 未授权,说明 token 过期,需要重新登录
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { status?: number } }
if (axiosError.response?.status === 401) {
logout()
}
}
}
}
/**
* 切换坐席状态
*
* @param newStatus - 新状态: online/busy/offline
*/
async function changeStatus(newStatus: string): Promise<void> {
try {
const data = await updateAgentStatus(newStatus)
agentInfo.value = data
} catch (error) {
console.error('更新坐席状态失败:', error)
}
}
/**
* 加载可转接的坐席列表
* 只获取在线的坐席(排除自己)
*/
async function loadAvailableAgents(): Promise<void> {
try {
const data = await getAgents('online')
// 排除自己
availableAgents.value = data.items.filter(
a => a.user_id !== agentInfo.value?.user_id
)
} catch (error) {
console.error('获取坐席列表失败:', error)
// 使用 mock 数据作为 fallback(开发/演示用)
if (import.meta.env.DEV) {
console.warn('[Mock] 使用模拟坐席列表')
availableAgents.value = mockAgentListData.items.filter(
a => a.user_id !== agentInfo.value?.user_id
)
}
}
}
/**
* 初始化:检查是否已登录
* 如果 localStorage 有 token,尝试获取坐席信息
*/
async function initAuth(): Promise<void> {
const savedToken = localStorage.getItem(TOKEN_KEY)
if (savedToken) {
token.value = savedToken
agentUserId.value = localStorage.getItem(AGENT_USER_ID_KEY)
try {
await refreshAgentInfo()
} catch {
// token 无效,清除
logout()
}
}
}
// ==========================================================================
// 返回
// ==========================================================================
return {
// 状态
agentInfo,
token,
agentUserId,
logging,
availableAgents,
// 计算属性
isLoggedIn,
agentStatus,
agentName,
userId,
// 方法
login,
logout,
refreshAgentInfo,
changeStatus,
loadAvailableAgents,
initAuth,
}
})
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
// =============================================================================
// 企微IT智能服务台 — 快速回复状态管理(Pinia Store
// =============================================================================
// 说明:管理快速回复模板列表、按分类展示、CRUD 操作
// 核心功能:
// 1. 模板列表(按分类)
// 2. CRUD 操作(创建、读取、更新、删除)
// 3. 变量替换({employee_name} 等)
// =============================================================================
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { QuickReply } from '@/api/quickReply'
import {
getQuickReplies,
createQuickReply,
updateQuickReply,
deleteQuickReply,
} from '@/api/quickReply'
import type { QuickReplyCreateParams, QuickReplyUpdateParams } from '@/api/quickReply'
// --------------------------------------------------------------------------
// Store 定义
// --------------------------------------------------------------------------
export const useQuickReplyStore = defineStore('quickReply', () => {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 所有快速回复模板列表 */
const templates = ref<QuickReply[]>([])
/** 是否正在加载 */
const loading = ref<boolean>(false)
// ==========================================================================
// 计算属性
// ==========================================================================
/**
* 按分类分组的模板
* 返回 { 分类名: 模板列表 } 的结构,用于 ElCollapse 折叠展示
*/
const templatesByCategory = computed(() => {
const grouped: Record<string, QuickReply[]> = {}
for (const tpl of templates.value) {
if (!grouped[tpl.category]) {
grouped[tpl.category] = []
}
grouped[tpl.category].push(tpl)
}
return grouped
})
/**
* 所有分类名列表(去重)
*/
const categories = computed(() => {
return Object.keys(templatesByCategory.value)
})
// ==========================================================================
// 方法
// ==========================================================================
/**
* 加载快速回复模板列表
* 从后端 API 获取所有模板数据
*/
async function fetchTemplates(): Promise<void> {
try {
loading.value = true
const data = await getQuickReplies()
templates.value = data.items
} catch (error) {
console.error('获取快速回复模板失败:', error)
} finally {
loading.value = false
}
}
/**
* 创建快速回复模板
*
* @param data - 创建参数
* @returns 创建的模板
*/
async function addTemplate(data: QuickReplyCreateParams): Promise<QuickReply | null> {
try {
const newTemplate = await createQuickReply(data)
// 重新加载列表
await fetchTemplates()
return newTemplate
} catch (error) {
console.error('创建快速回复模板失败:', error)
return null
}
}
/**
* 更新快速回复模板
*
* @param templateId - 模板ID
* @param data - 更新参数
* @returns 更新后的模板
*/
async function editTemplate(templateId: string, data: QuickReplyUpdateParams): Promise<QuickReply | null> {
try {
const updated = await updateQuickReply(templateId, data)
// 重新加载列表
await fetchTemplates()
return updated
} catch (error) {
console.error('更新快速回复模板失败:', error)
return null
}
}
/**
* 删除快速回复模板
*
* @param templateId - 模板ID
*/
async function removeTemplate(templateId: string): Promise<void> {
try {
await deleteQuickReply(templateId)
// 重新加载列表
await fetchTemplates()
} catch (error) {
console.error('删除快速回复模板失败:', error)
}
}
/**
* 替换模板中的变量
* 支持 {employee_name}、{department} 等变量占位符
*
* @param templateContent - 模板内容(含变量占位符)
* @param variables - 变量键值对,如 { employee_name: '张三', department: '技术部' }
* @returns 替换后的内容
*
* @example
* replaceVariables('您好 {employee_name},您的问题是...', { employee_name: '张三' })
* // 返回: '您好 张三,您的问题是...'
*/
function replaceVariables(
templateContent: string,
variables: Record<string, string>
): string {
let result = templateContent
for (const [key, value] of Object.entries(variables)) {
// 使用全局替换,替换所有 {key} 形式的占位符
result = result.replaceAll(`{${key}}`, value)
}
return result
}
// ==========================================================================
// 返回
// ==========================================================================
return {
// 状态
templates,
loading,
// 计算属性
templatesByCategory,
categories,
// 方法
fetchTemplates,
addTemplate,
editTemplate,
removeTemplate,
replaceVariables,
}
})
+54
View File
@@ -0,0 +1,54 @@
// =============================================================================
// 企微IT智能服务台 — 主题 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,
}
})
+123
View File
@@ -0,0 +1,123 @@
// =============================================================================
// 企微IT智能服务台 — 待办事项状态管理(Pinia Store
// =============================================================================
// 说明:管理坐席工作台的待办事项列表、当前选中的待办、加载状态
// 核心功能:
// 1. 获取待办列表
// 2. 选中待办事项(联动 conversationStore.workspaceView = 'task'
// 3. 更新待办状态
// =============================================================================
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { TodoItemData } from '@/api/todo'
import { getTodoItems, updateTodoStatus } from '@/api/todo'
import { mockTodoListData } from '@/mock/data'
// --------------------------------------------------------------------------
// Store 定义
// --------------------------------------------------------------------------
export const useTodoStore = defineStore('todo', () => {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 待办事项列表 */
const todoList = ref<TodoItemData[]>([])
/** 当前选中的待办事项 */
const currentTodoItem = ref<TodoItemData | null>(null)
/** 是否正在加载 */
const loading = ref<boolean>(false)
// ==========================================================================
// 计算属性
// ==========================================================================
/** 紧急待办数量 */
const urgentCount = computed(() => {
return todoList.value.filter(t => t.priority === 'urgent').length
})
/** 高优先级待办数量 */
const highCount = computed(() => {
return todoList.value.filter(t => t.priority === 'high').length
})
/** 待处理待办列表(状态为 pending 或 processing */
const pendingTodos = computed(() => {
return todoList.value.filter(t => t.status === 'pending' || t.status === 'processing')
})
// ==========================================================================
// 方法
// ==========================================================================
/**
* 获取待办列表
* 调用后端 API 获取当前坐席的待办事项
*/
async function fetchTodoList(): Promise<void> {
try {
loading.value = true
const data = await getTodoItems()
todoList.value = data.items
} catch (error) {
console.error('获取待办列表失败:', error)
// 使用 mock 数据作为 fallback(开发/演示用)
if (import.meta.env.DEV) {
console.warn('[Mock] 使用模拟待办数据')
todoList.value = mockTodoListData.items
}
} finally {
loading.value = false
}
}
/**
* 选中待办事项
* 设置 currentTodoItem,并触发 workspaceView 切换为 'task'
*
* @param item - 要选中的待办事项
*/
function selectTodoItem(item: TodoItemData): void {
currentTodoItem.value = item
}
/**
* 更新待办状态
*
* @param id - 待办事项ID
* @param status - 新状态
*/
async function updateTodoItemStatus(id: string, status: string): Promise<void> {
try {
await updateTodoStatus(id, status)
// 刷新列表
await fetchTodoList()
} catch (error) {
console.error('更新待办状态失败:', error)
}
}
// ==========================================================================
// 返回
// ==========================================================================
return {
// 状态
todoList,
currentTodoItem,
loading,
// 计算属性
urgentCount,
highCount,
pendingTodos,
// 方法
fetchTodoList,
selectTodoItem,
updateTodoItemStatus,
}
})
+819
View File
@@ -0,0 +1,819 @@
/* =============================================================================
* 企微IT智能服务台 — 坐席工作台全局样式
* =============================================================================
* 说明:全局基础样式,包括:
* 1. CSS 变量(主题色、间距等)
* 2. 全局重置样式
* 3. 通用工具类
* 4. 坐席工作台专用样式
* ============================================================================= */
/* --------------------------------------------------------------------------
* 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);
/* 扩展色 */
--purple: #8b5cf6;
--purple-soft: #ede9fe;
--orange: #f97316;
--orange-soft: #fff7ed;
/* VIP 标记色 */
--color-vip: #ef4444;
/* 招手标记色 */ /* 修改:术语替换 举手→招手 */
--color-hand-raise: #f59e0b;
/* 需介入标记色 */
--color-need-intervene: #ef4444;
/* 情绪标记色 */
--color-emotion-neutral: #94a3b8;
--color-emotion-worried: #f59e0b;
--color-emotion-angry: #ef4444;
--color-emotion-urgent: #ef4444;
/* AI 标记色 */
--color-ai: #22c55e;
/* ---- 圆角 — 企微风格偏圆润 ---- */
--radius-sm: 4px;
--radius: 8px;
--radius-md: 8px;
--radius-lg: 12px;
/* ---- 间距 ---- */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* ---- 阴影 ---- */
--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);
/* ---- 三栏布局尺寸 ---- */
--sidebar-width: 280px;
--assistant-panel-width: 320px;
}
/* ---- 深色主题覆盖(同步原型 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;
--purple: #a78bfa;
--purple-soft: #2d2060;
--orange: #fb923c;
--orange-soft: #3d1f08;
--color-vip: #f87171;
--color-hand-raise: #fbbf24;
--color-need-intervene: #f87171;
--color-emotion-neutral: #8ba1b7;
--color-emotion-worried: #fbbf24;
--color-emotion-angry: #f87171;
--color-emotion-urgent: #f87171;
--color-ai: #34d399;
--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;
transition: background 0.3s, color 0.3s;
}
#app {
width: 100%;
height: 100%;
}
/* --------------------------------------------------------------------------
* 通用工具类
* -------------------------------------------------------------------------- */
/* 文本省略 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Flex 布局 */
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
/* 标签样式 */
.tag-vip {
color: var(--color-vip);
font-weight: bold;
}
.tag-hand-raise {
color: var(--color-hand-raise);
font-weight: bold;
}
.tag-need-intervene {
color: var(--color-need-intervene);
font-weight: bold;
}
.tag-ai {
color: var(--color-ai);
font-weight: bold;
}
/* --------------------------------------------------------------------------
* 坐席工作台专用样式
* -------------------------------------------------------------------------- */
/* 三栏布局容器:纵向(顶栏 + 三栏区域) */
.workspace-layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
overflow: hidden;
}
/* 三栏区域:顶栏下方,横向排列左/中/右栏 */
.workspace-body {
flex: 1;
display: flex;
overflow: hidden;
min-height: 0;
}
/* 左栏:会话列表(v5.4: 去掉 border-right,拖拽手柄替代) */
.workspace-sidebar {
width: var(--sidebar-width);
min-width: 200px;
height: 100%;
border-right: none;
background-color: var(--bg-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* 中栏:对话区 */
.workspace-main {
flex: 1;
height: 100%;
background-color: var(--bg-primary);
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0; /* 防止 flex 子元素溢出 */
}
/* 右栏:AI助手面板(v5.4: 去掉 border-left,拖拽手柄替代) */
.workspace-assistant {
width: var(--assistant-panel-width);
min-width: 220px;
height: 100%;
border-left: none;
background-color: var(--bg-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* ===== v5.4: 拖拽手柄(栏间调整宽度) ===== */
.resize-handle {
width: 6px;
cursor: col-resize;
background: var(--border);
position: relative;
flex-shrink: 0;
transition: background 0.2s;
z-index: 10;
}
.resize-handle:hover,
.resize-handle.dragging {
background: var(--accent);
}
.resize-handle::after {
content: '⋮';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
color: var(--text-muted);
opacity: 0;
transition: opacity 0.2s;
}
.resize-handle:hover::after,
.resize-handle.dragging::after {
opacity: 1;
}
/* 顶部标题栏 */
.workspace-header {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
/* 侧边栏搜索框 */
.sidebar-search {
padding: 12px;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
/* 会话列表滚动区 */
.conversation-list-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* 消息列表滚动区 */
.message-list-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
}
/* 消息气泡通用样式 */
.message-bubble {
max-width: 70%;
padding: 10px 14px;
border-radius: 8px;
line-height: 1.5;
word-break: break-word;
position: relative;
}
/* 员工消息气泡 — 靠左灰底 */
.message-employee {
background-color: var(--bg-tertiary);
color: var(--text-primary);
align-self: flex-start;
border-top-left-radius: 2px;
}
/* 坐席消息气泡 — 靠右蓝底白字 */
.message-agent {
background-color: var(--accent);
color: var(--bg-secondary);
align-self: flex-end;
border-top-right-radius: 2px;
}
/* AI消息气泡 — 靠左绿底 */
.message-ai {
background-color: var(--bg-accent-soft);
color: var(--text-primary);
align-self: flex-start;
border-top-left-radius: 2px;
border: 1px solid var(--border-light);
}
/* 系统消息气泡 — 居中灰字 */
.message-system {
background-color: transparent;
color: var(--text-tertiary);
align-self: center;
font-size: 12px;
padding: 4px 12px;
}
/* 消息行 */
.message-row {
display: flex;
margin-bottom: 16px;
flex-direction: column;
}
.message-row-employee {
align-items: flex-start;
}
.message-row-agent {
align-items: flex-end;
}
.message-row-ai {
align-items: flex-start;
}
.message-row-system {
align-items: center;
}
/* 消息发送者名称 */
.message-sender-name {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 4px;
}
/* 消息时间戳 */
.message-time {
font-size: 11px;
color: var(--text-placeholder);
margin-top: 4px;
}
/* AI标签 */
.ai-tag {
display: inline-block;
background-color: var(--color-ai);
color: var(--bg-secondary);
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
margin-left: 6px;
vertical-align: middle;
}
/* 会话项(v5.4: flex 布局含头像+内容+缩略头像) */
.conversation-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin: 1px 6px;
cursor: pointer;
transition: background-color 0.2s;
border-radius: var(--radius);
border: 1px solid transparent;
position: relative;
}
.conversation-item:hover {
background-color: var(--bg-hover);
}
.conversation-item.active {
background-color: var(--accent-soft);
border-color: var(--accent);
}
.conversation-item.resolved {
opacity: 0.6;
}
.conversation-item.resolved .conversation-name {
color: var(--text-tertiary);
}
/* ===== v5.4: 会话头像容器(含新消息圆点) ===== */
.conv-avatar-wrap {
position: relative;
flex-shrink: 0;
}
/* 会话项头像 — 渐变色圆形 */
.conversation-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
/* 渐变色变体 */
.conversation-avatar.av-blue { background: linear-gradient(135deg, #3b82f6, #6366f1); }
.conversation-avatar.av-green { background: linear-gradient(135deg, #22c55e, #14b8a6); }
.conversation-avatar.av-orange { background: linear-gradient(135deg, #f97316, #eab308); }
.conversation-avatar.av-purple { background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.conversation-avatar.av-red { background: linear-gradient(135deg, #ef4444, #f97316); }
.conversation-avatar.av-teal { background: linear-gradient(135deg, #0ea5e9, #6366f1); }
.conversation-avatar.av-pink { background: linear-gradient(135deg, #ec4899, #f43f5e); }
/* ===== v5.4: 新消息圆点(在头像右上角) ===== */
.new-msg-dot {
position: absolute;
top: -1px;
right: -1px;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--bg-secondary);
z-index: 2;
}
.new-msg-dot.dot-urgent { background: var(--color-danger); }
.new-msg-dot.dot-normal { background: var(--accent); }
.new-msg-dot.dot-muted { background: var(--text-muted); }
/* ===== v5.4: 处理对象缩略头像(会话项右侧) ===== */
.conv-target-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
margin-left: auto;
}
.conv-target-avatar.ta-blue { background: linear-gradient(135deg, #3b82f6, #6366f1); }
.conv-target-avatar.ta-green { background: linear-gradient(135deg, #22c55e, #14b8a6); }
.conv-target-avatar.ta-orange { background: linear-gradient(135deg, #f97316, #eab308); }
.conv-target-avatar.ta-purple { background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.conv-target-avatar.ta-red { background: linear-gradient(135deg, #ef4444, #f97316); }
.conv-target-avatar.ta-teal { background: linear-gradient(135deg, #0ea5e9, #6366f1); }
.conv-target-avatar.ta-pink { background: linear-gradient(135deg, #ec4899, #f43f5e); }
/* 会话项信息区(v5.4: flex:1 中间内容区) */
.conversation-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.conversation-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 4px;
}
.conversation-summary {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
.conversation-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.conversation-time {
font-size: 11px;
color: var(--text-placeholder);
}
/* 分区标题 */
.section-title {
padding: 8px 16px;
font-size: 12px;
color: var(--text-tertiary);
background-color: var(--bg-primary);
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.section-title:hover {
background-color: var(--bg-hover);
}
/* ===== v5.4: 待办事项缩略头像 ===== */
.todo-item .ki-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
margin-left: auto;
}
.ki-avatar.ka-blue { background: linear-gradient(135deg, #3b82f6, #6366f1); }
.ki-avatar.ka-green { background: linear-gradient(135deg, #22c55e, #14b8a6); }
.ki-avatar.ka-orange { background: linear-gradient(135deg, #f97316, #eab308); }
.ki-avatar.ka-purple { background: linear-gradient(135deg, #8b5cf6, #ec4899); }
.ki-avatar.ka-red { background: linear-gradient(135deg, #ef4444, #f97316); }
/* 标签徽章 — 使用语义色变量,深浅色自动适配 */
.tag-badge {
display: inline-block;
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
line-height: 1.5;
font-weight: 500;
}
.tag-badge-vip {
background-color: var(--danger-soft);
color: var(--color-danger);
border: 1px solid var(--danger-soft);
}
.tag-badge-hand-raise {
background-color: var(--warning-soft);
color: var(--color-warning);
border: 1px solid var(--warning-soft);
}
.tag-badge-need-intervene {
background-color: var(--danger-soft);
color: var(--color-danger);
border: 1px solid var(--danger-soft);
}
.tag-badge-emotion-urgent {
background-color: var(--danger-soft);
color: var(--color-danger);
border: 1px solid var(--danger-soft);
}
.tag-badge-emotion-angry {
background-color: var(--danger-soft);
color: var(--color-danger);
border: 1px solid var(--danger-soft);
}
.tag-badge-emotion-worried {
background-color: var(--warning-soft);
color: var(--color-warning);
border: 1px solid var(--warning-soft);
}
/* 紧急度星级 */
.urgency-stars {
display: inline-flex;
align-items: center;
gap: 1px;
}
.urgency-star {
font-size: 12px;
color: var(--color-warning);
}
.urgency-star.empty {
color: var(--text-placeholder);
}
/* 回复输入框区域 */
.reply-box {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
}
/* 输入框在 flex 容器里占满宽度 */
.reply-box > div:first-child {
display: flex;
gap: 8px;
align-items: flex-end;
}
.reply-box .el-input {
flex: 1;
min-width: 0;
}
/* 输入框 textarea 最大高度限制 + 滚动 */
.reply-box .el-textarea__inner {
max-height: 200px;
overflow-y: auto;
}
/* 操作步骤卡片 */
.step-card {
padding: 10px 14px;
margin-bottom: 8px;
border-radius: 6px;
background-color: var(--bg-primary);
border-left: 3px solid var(--accent);
}
/* 登录页样式 */
.login-container {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
.login-title {
text-align: center;
margin-bottom: 32px;
}
.login-title h1 {
font-size: 24px;
color: var(--text-primary);
margin-bottom: 8px;
}
.login-title p {
font-size: 14px;
color: var(--text-tertiary);
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--text-placeholder);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* --------------------------------------------------------------------------
* IT 等级徽标(7 级)
* -------------------------------------------------------------------------- */
.it-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
line-height: 1;
color: var(--bg-secondary);
flex-shrink: 0;
}
.it-badge.bronze { background: linear-gradient(135deg, #cd7f32, #a0522d); }
.it-badge.silver { background: linear-gradient(135deg, #c0c0c0, #8e8e8e); }
.it-badge.gold { background: linear-gradient(135deg, #ffd700, #daa520); }
.it-badge.platinum { background: linear-gradient(135deg, #e5e4e2, #b0b0b0); }
.it-badge.diamond { background: linear-gradient(135deg, #b9f2ff, #00bfff); color: #0a2540; }
.it-badge.star { background: linear-gradient(135deg, #ff6b6b, #ee5a24); }
.it-badge.king { background: linear-gradient(135deg, #f093fb, #f5576c, #ffd700); animation: king-glow 2s ease-in-out infinite; }
/* 王者发光动画 */
@keyframes king-glow {
0%, 100% {
box-shadow: 0 0 4px rgba(245, 87, 108, 0.4);
}
50% {
box-shadow: 0 0 12px rgba(245, 87, 108, 0.8), 0 0 20px rgba(240, 147, 251, 0.4);
}
}
/* 响应式:小屏幕下右栏可折叠 */
@media (max-width: 1024px) {
.workspace-assistant {
position: absolute;
right: 0;
top: 0;
z-index: 100;
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1);
transform: translateX(100%);
transition: transform 0.3s ease;
}
.workspace-assistant.visible {
transform: translateX(0);
}
}
+170
View File
@@ -0,0 +1,170 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席登录页面
// =============================================================================
// 说明:坐席登录页面,简单的用户名+姓名表单
// 登录成功后跳转到工作台页面
// 第一步不做密码验证,仅输入用户ID和姓名即可登录
// ============================================================================= -->
<template>
<div class="login-container">
<div class="login-card">
<!-- 标题区域 -->
<div class="login-title">
<h1>🛠 IT智能服务台</h1>
<p>坐席工作台 · 登录</p>
</div>
<!-- 登录表单 -->
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-position="top"
@submit.prevent="handleLogin"
>
<!-- 企微用户ID -->
<el-form-item label="企微用户ID" prop="userId">
<el-input
v-model="loginForm.userId"
placeholder="请输入企微用户ID"
prefix-icon="User"
size="large"
clearable
/>
</el-form-item>
<!-- 坐席姓名 -->
<el-form-item label="姓名" prop="name">
<el-input
v-model="loginForm.name"
placeholder="请输入您的姓名"
prefix-icon="UserFilled"
size="large"
clearable
/>
</el-form-item>
<!-- OTP 动态码admin 角色需要 -->
<el-form-item v-if="requireOtp" label="OTP动态码" prop="otpCode">
<el-input
v-model="loginForm.otpCode"
placeholder="请输入Google Authenticator中的6位动态码"
prefix-icon="Lock"
size="large"
maxlength="6"
clearable
@keyup.enter="handleLogin"
/>
</el-form-item>
<!-- 登录按钮 -->
<el-form-item>
<el-button
type="primary"
size="large"
:loading="agentStore.logging"
style="width: 100%"
@click="handleLogin"
>
{{ agentStore.logging ? '登录中...' : '登 录' }}
</el-button>
</el-form-item>
</el-form>
<!-- 提示信息 -->
<div class="login-hint">
使用企微账号登录姓名将自动获取
</div>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useAgentStore } from '@/stores/agent'
// ============================================================================
// 状态
// ============================================================================
/** 坐席 Store */
const agentStore = useAgentStore()
/** 表单引用 */
const loginFormRef = ref<FormInstance>()
/** 登录表单数据 */
const loginForm = reactive({
/** 企微用户ID */
userId: '',
/** 坐席姓名 */
name: '',
/** OTP 动态码 */
otpCode: '',
})
/** 是否需要 OTP 验证 */
const requireOtp = ref(false)
/** 表单校验规则 */
const loginRules = reactive<FormRules>({
userId: [
{ required: true, message: '请输入企微用户ID', trigger: 'blur' },
{ min: 1, max: 64, message: '用户ID长度为1-64个字符', trigger: 'blur' },
],
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 1, max: 128, message: '姓名长度为1-128个字符', trigger: 'blur' },
],
})
// ============================================================================
// 方法
// ============================================================================
/**
* 处理登录
* 1. 校验表单
* 2. 调用登录 API
* 3. 如果返回 require_otp,显示 OTP 输入框
* 4. 用户输入 OTP 后再次登录
* 5. 成功后自动跳转
*/
async function handleLogin(): Promise<void> {
// 表单校验
if (!loginFormRef.value) return
const valid = await loginFormRef.value.validate().catch(() => false)
if (!valid) return
try {
const data = await agentStore.login(loginForm.userId, loginForm.name, loginForm.otpCode || undefined)
// 检查是否需要 OTP 验证
if (data && 'require_otp' in data && data.require_otp) {
requireOtp.value = true
ElMessage.warning('请输入OTP动态码')
return
}
ElMessage.success('登录成功')
} catch (error: any) {
// 错误信息已在 Axios 拦截器中显示
console.error('登录失败:', error)
}
}
</script>
<style scoped>
.login-hint {
text-align: center;
color: var(--text-tertiary);
font-size: 12px;
margin-top: 16px;
}
</style>
+227
View File
@@ -0,0 +1,227 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席工作台主页面(v5.4 拖拽手柄版)
// =============================================================================
// 说明:坐席工作台主页面,三栏布局
// 左栏(280px):会话列表
// 中栏(flex-1):对话区
// 右栏(320px)AI助手面板
// v5.4: 左右栏与中间栏之间添加拖拽手柄,可手动调整栏宽
// ============================================================================= -->
<template>
<div class="workspace-layout" :data-theme="themeStore.currentTheme">
<!-- ==================================================================== -->
<!-- 顶栏组件置顶 -->
<!-- ==================================================================== -->
<TopBar ref="topBarRef" @toggleAssistant="assistantVisible = !assistantVisible" />
<!-- ==================================================================== -->
<!-- 三栏区域顶栏下方横向排列 + 拖拽手柄 -->
<!-- ==================================================================== -->
<div class="workspace-body">
<!-- 左栏会话列表 -->
<aside class="workspace-sidebar" ref="leftSidebarRef">
<ConversationList />
</aside>
<!-- 拖拽手柄左栏中间栏 -->
<div
class="resize-handle"
:class="{ dragging: leftDragging }"
@mousedown="startLeftResize"
></div>
<!-- 中栏对话区 / 任务详情视图 -->
<main class="workspace-main">
<!-- 聊天视图当前选中会话且 workspaceView chat -->
<ChatArea v-if="conversationStore.workspaceView === 'chat' && conversationStore.currentConversation" />
<!-- 任务详情视图workspaceView task 且有待办事项 -->
<TaskDetailView v-else-if="conversationStore.workspaceView === 'task' && todoStore.currentTodoItem" :todo-item="todoStore.currentTodoItem" />
<!-- 未选中会话时的占位 -->
<div v-else class="empty-chat-area">
<el-empty description="请从左侧选择一个会话开始服务" />
</div>
</main>
<!-- 拖拽手柄中间栏右栏 -->
<div
v-if="assistantVisible"
class="resize-handle"
:class="{ dragging: rightDragging }"
@mousedown="startRightResize"
></div>
<!-- 右栏AI助手面板 -->
<aside v-if="assistantVisible" class="workspace-assistant" ref="rightSidebarRef">
<AiAssistantPanel />
</aside>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 导入
// ============================================================================
import { ref, onMounted, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { useAgentStore } from '@/stores/agent'
import { useQuickReplyStore } from '@/stores/quickReply'
import { useThemeStore } from '@/stores/theme'
import { useWebSocket } from '@/composables/useWebSocket'
import { useTheme } from '@/composables/useTheme'
import ConversationList from '@/components/conversation/ConversationList.vue'
import ChatArea from '@/components/chat/ChatArea.vue'
import TaskDetailView from '@/components/chat/TaskDetailView.vue'
import AiAssistantPanel from '@/components/assistant/AiAssistantPanel.vue'
import TopBar from '@/components/layout/TopBar.vue'
import { useTodoStore } from '@/stores/todo'
// ============================================================================
// 状态
// ============================================================================
/** 会话 Store */
const conversationStore = useConversationStore()
/** 坐席 Store */
const agentStore = useAgentStore()
/** 快速回复 Store */
const quickReplyStore = useQuickReplyStore()
/** 主题 Store */
const themeStore = useThemeStore()
/** 待办 Store */
const todoStore = useTodoStore()
/** 初始化主题(立即应用已保存的主题) */
useTheme()
/** WebSocket 组合式函数(自动连接 + 断线重连 + 心跳 + 降级) */
const { connect: connectWs, disconnect: disconnectWs } = useWebSocket()
/** TopBar 组件引用 */
const topBarRef = ref<InstanceType<typeof TopBar> | null>(null)
/** 助手面板是否可见(默认可见) */
const assistantVisible = ref(true)
// ===== 拖拽手柄状态 =====
const leftSidebarRef = ref<HTMLElement | null>(null)
const rightSidebarRef = ref<HTMLElement | null>(null)
const leftDragging = ref(false)
const rightDragging = ref(false)
let leftStartX = 0
let leftStartWidth = 0
let rightStartX = 0
let rightStartWidth = 0
// ============================================================================
// 拖拽手柄方法
// ============================================================================
/** 开始左栏拖拽 */
function startLeftResize(e: MouseEvent): void {
e.preventDefault()
if (!leftSidebarRef.value) return
leftDragging.value = true
leftStartX = e.clientX
leftStartWidth = leftSidebarRef.value.offsetWidth
document.addEventListener('mousemove', onLeftResizeMove)
document.addEventListener('mouseup', onLeftResizeEnd)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
function onLeftResizeMove(e: MouseEvent): void {
if (!leftDragging.value || !leftSidebarRef.value) return
const dx = e.clientX - leftStartX
const newW = Math.max(200, Math.min(500, leftStartWidth + dx))
leftSidebarRef.value.style.width = newW + 'px'
leftSidebarRef.value.style.minWidth = newW + 'px'
}
function onLeftResizeEnd(): void {
leftDragging.value = false
document.removeEventListener('mousemove', onLeftResizeMove)
document.removeEventListener('mouseup', onLeftResizeEnd)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
/** 开始右栏拖拽 */
function startRightResize(e: MouseEvent): void {
e.preventDefault()
if (!rightSidebarRef.value) return
rightDragging.value = true
rightStartX = e.clientX
rightStartWidth = rightSidebarRef.value.offsetWidth
document.addEventListener('mousemove', onRightResizeMove)
document.addEventListener('mouseup', onRightResizeEnd)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
function onRightResizeMove(e: MouseEvent): void {
if (!rightDragging.value || !rightSidebarRef.value) return
const dx = rightStartX - e.clientX
const newW = Math.max(220, Math.min(500, rightStartWidth + dx))
rightSidebarRef.value.style.width = newW + 'px'
rightSidebarRef.value.style.minWidth = newW + 'px'
}
function onRightResizeEnd(): void {
rightDragging.value = false
document.removeEventListener('mousemove', onRightResizeMove)
document.removeEventListener('mouseup', onRightResizeEnd)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
// ============================================================================
// 生命周期
// ============================================================================
onMounted(async () => {
// 初始化主题
themeStore.initTheme()
// 初始化坐席信息
await agentStore.initAuth()
// 加载快速回复模板
await quickReplyStore.fetchTemplates()
// 加载会话列表(DEV 环境下有 Mock 数据兜底)
await conversationStore.fetchConversations()
// 自动选中第一个会话(如果没有已选中的会话)
if (conversationStore.conversations.length > 0 && !conversationStore.currentConversationId) {
await conversationStore.selectConversation(conversationStore.conversations[0].id)
}
// 连接 WebSocket 接收实时更新
connectWs()
})
onUnmounted(() => {
// 断开 WebSocket
disconnectWs()
// 停止轮询(作为兜底)
conversationStore.stopAllPolling()
// 清理拖拽事件
document.removeEventListener('mousemove', onLeftResizeMove)
document.removeEventListener('mouseup', onLeftResizeEnd)
document.removeEventListener('mousemove', onRightResizeMove)
document.removeEventListener('mouseup', onRightResizeEnd)
})
</script>
<style scoped>
/* 空对话区占位 */
.empty-chat-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
+2
View File
@@ -0,0 +1,2 @@
$output = C:/Program Files/nodejs/node.exe d:/资料/03-项目开发/wecom_it_smart_desk/frontend-agent/node_modules/vue-tsc/bin/vue-tsc.js 2>&1
$output
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2021", "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"]
}
+56
View File
@@ -0,0 +1,56 @@
// =============================================================================
// 企微IT智能服务台 — 坐席工作台 Vite 配置
// =============================================================================
// 说明:Vite 构建工具配置,定义开发服务器、构建输出等
// =============================================================================
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// Vite 配置
// https://vitejs.dev/config/
export default defineConfig({
// 生产环境基础路径(部署在 /itagent/ 子路径下,与IT数据平台共享域名)
base: '/itagent/',
// Vue3 插件
plugins: [vue()],
// 开发服务器配置
server: {
// 开发服务器端口(避免和H5前端冲突)
port: 5173,
// 自动打开浏览器
open: true,
// API 代理:将 /api 请求转发到后端,解决开发环境跨域问题
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
// 本地开发剥离 /api 前缀,因为后端路由不包含 /api(生产 nginx 负责剥离)
rewrite: (path) => path.replace(/^\/api/, ''),
},
// WebSocket 代理:将 /ws 请求转发到后端 WebSocket 服务
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
// 构建配置
build: {
// 输出目录
outDir: 'dist',
// 静态资源内联阈值(小于4KB的资源会被base64内联)
assetsInlineLimit: 4096,
},
// 路径别名
resolve: {
alias: {
// 使用 @ 指向 src 目录,方便导入
'@': '/src',
},
},
})