feat(ctrt): 完成 CTRT-01~03 响应契约统一 - 三端拦截器+portal_token清理+调用点适配

This commit is contained in:
Simon
2026-07-07 22:56:49 +08:00
parent d56a9a6079
commit 6f0fbbb066
48 changed files with 6183 additions and 613 deletions
+28
View File
@@ -0,0 +1,28 @@
# =============================================================================
# 企微IT智能服务台 — H5前端开发版 Docker 镜像
# =============================================================================
# 说明:基于 node:20 开发模式,支持代码热更新(volume mount 源码)
# 用途:本地开发,代码修改自动生效
# =============================================================================
FROM node:20-slim
# 安装 pnpm
RUN npm install -g pnpm
# 设置工作目录
WORKDIR /app
# 复制依赖文件
COPY package.json pnpm-lock.yaml* ./
# 安装依赖
RUN pnpm install --frozen-lockfile
# 复制源码(后续通过 volume mount 更新)
COPY . .
# 暴露端口
EXPOSE 5174
# 启动开发服务器(热更新)
CMD ["pnpm", "dev", "--host"]
+5
View File
@@ -7,18 +7,21 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActionConfirmDialog: typeof import('./src/components/ActionConfirmDialog.vue')['default']
AiHelperPanel: typeof import('./src/components/assistant/AiHelperPanel.vue')['default']
ApprovalCardModal: typeof import('./src/components/chat/ApprovalCardModal.vue')['default']
ApprovalLinks: typeof import('./src/components/assistant/ApprovalLinks.vue')['default']
CallAgentModal: typeof import('./src/components/chat/CallAgentModal.vue')['default']
ChatPanel: typeof import('./src/components/chat/ChatPanel.vue')['default']
ComingSoon: typeof import('./src/components/assistant/ComingSoon.vue')['default']
EvaluationDialog: typeof import('./src/components/chat/EvaluationDialog.vue')['default']
InputBar: typeof import('./src/components/chat/InputBar.vue')['default']
InputBox: typeof import('./src/components/chat/InputBox.vue')['default']
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
MessageItem: typeof import('./src/components/chat/MessageItem.vue')['default']
MessageList: typeof import('./src/components/chat/MessageList.vue')['default']
ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default']
ResolveFeedback: typeof import('./src/components/ResolveFeedback.vue')['default']
RightPanel: typeof import('./src/components/assistant/RightPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@@ -29,10 +32,12 @@ declare module 'vue' {
TroubleshootProgress: typeof import('./src/components/chat/TroubleshootProgress.vue')['default']
VanButton: typeof import('vant/es')['Button']
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
VanDialog: typeof import('vant/es')['Dialog']
VanEmpty: typeof import('vant/es')['Empty']
VanField: typeof import('vant/es')['Field']
VanIcon: typeof import('vant/es')['Icon']
VanLoading: typeof import('vant/es')['Loading']
VanPopup: typeof import('vant/es')['Popup']
VanRate: typeof import('vant/es')['Rate']
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
<!-- 移动端视口设置(适配企微 WebView) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<!-- CSP 安全策略 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://*;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;" />
<!-- 页面标题 -->
<title>智能IT支持服务台</title>
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
+1140
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -3,7 +3,10 @@
"version": "1.0.0",
"private": true,
"description": "企微智能IT支持服务台 - H5用户端前端",
"engines": { "node": ">=20.0.0 <21.0.0", "pnpm": ">=9.0.0" },
"engines": {
"node": ">=20.0.0 <21.0.0",
"pnpm": ">=9.0.0"
},
"packageManager": "pnpm@9.15.0",
"scripts": {
"dev": "vite",
@@ -14,6 +17,8 @@
"dependencies": {
"@vueuse/core": "^14.3.0",
"axios": "^1.7.0",
"cropperjs": "^2.1.1",
"fabric": "^7.4.0",
"html2canvas-pro": "^2.0.4",
"pinia": "^2.1.0",
"vant": "^4.8.0",
+126
View File
@@ -0,0 +1,126 @@
// =============================================================================
// 企微IT智能服务台 — 阶段5 自动化闭环 API 调用模块(H5 员工端)
// =============================================================================
// 说明:封装员工侧自动化会话相关 HTTP 请求。
// 约定:与坐席端一致,baseURL 已在 api/index.ts 配置为 /api
// 这里只写相对路径 /itportal/automation/...;响应拦截器已解包为 data。
// 注意:本模块返回「内层 data」(与 h5 其它 api 模块一致),调用方直接使用。
// =============================================================================
import apiClient from '@/api'
// --------------------------------------------------------------------------
// 类型定义(与后端 Schema / 坐席端保持一致)
// --------------------------------------------------------------------------
export interface AutomationAction {
id: string
session_id: string
action_index: number
action_type: string
adapter: string
risk_level: string
title: string
description: string
status: string
payload: Record<string, any> | null
result: Record<string, any> | null
error: string | null
approved_by: string | null
approved_at: string | null
}
export interface ApprovalTicket {
id: string
action_id: string
session_id: string
approver_id: string | null
channel: string
status: string
reason: string | null
decision_note: string | null
decided_at: string | null
}
export interface AutomationSession {
id: string
conversation_id: string | null
employee_id: string
agent_id: string | null
scenario_key: string | null
status: string
mode: string
confidence: number
title: string
intent: Record<string, any> | null
current_action_id: string | null
auto_close_at: string | null
resolved_at: string | null
closed_by: string | null
meta: Record<string, any> | null
actions: AutomationAction[]
approval: ApprovalTicket | null
created_at: string | null
updated_at: string | null
}
/** 发起自动化会话请求体 */
export interface StartSessionPayload {
conversation_id?: string | null
employee_id: string
description: string
scenario_key?: string
}
/** 员工侧高危动作二次确认请求体 */
export interface ConfirmPayload {
confirmed: boolean
note?: string
}
/** 标记已解决 / 静默关单反馈请求体 */
export interface ResolvePayload {
satisfied?: boolean
note?: string
}
// --------------------------------------------------------------------------
// 员工端接口(路径与后端契约 §15.3 一致)
// --------------------------------------------------------------------------
/** 发起自动化会话(员工侧触发) */
export async function startAutomationSession(
payload: StartSessionPayload,
): Promise<AutomationSession> {
const res = await apiClient.post('/itportal/automation/sessions/start', payload)
return res as AutomationSession
}
/** 会话详情 */
export async function getAutomationSession(sessionId: string): Promise<AutomationSession> {
const res = await apiClient.get(`/itportal/automation/sessions/${sessionId}`)
return res as AutomationSession
}
/** 单个动作详情(用于二次确认弹窗展示) */
export async function getAutomationAction(actionId: string): Promise<AutomationAction> {
const res = await apiClient.get(`/itportal/automation/actions/${actionId}`)
return res as AutomationAction
}
/** 员工侧高危动作二次确认(P1:员工确认/取消执行) */
export async function confirmAutomationAction(
actionId: string,
payload: ConfirmPayload,
): Promise<AutomationAction> {
const res = await apiClient.post(`/itportal/automation/actions/${actionId}/confirm`, payload)
return res as AutomationAction
}
/** 标记会话已解决 / 提交静默关单反馈 */
export async function resolveAutomationSession(
sessionId: string,
payload: ResolvePayload,
): Promise<AutomationSession> {
const res = await apiClient.post(`/itportal/automation/sessions/${sessionId}/resolved`, payload)
return res as AutomationSession
}
+149 -24
View File
@@ -40,8 +40,8 @@ export interface ConversationInfo {
employee_id: string
/** 员工姓名(会话发起人) */
employee_name: string
/** 会话状态:waiting(排队中) / serving(服务中) / closed(已结单) */
status: 'waiting' | 'serving' | 'closed'
/** 会话状态:waiting(排队中) / serving(服务中) / resolved(已结单) */
status: 'waiting' | 'serving' | 'resolved'
/** 坐席 ID(未接入时为空) */
agent_id: string
/** 坐席名称(未接入时为空) */
@@ -131,6 +131,10 @@ export interface ShakeResponse {
/** 会话状态:queued(排队中) / serving(服务中) / closed(已结单) */
status: string
}
/** 分配结果:assigned(已分配坐席) / queued(排队中) / assign_failed(分配失败) */
assign_result?: string
/** 已分配的坐席ID(当 assign_result 为 assigned 时) */
assigned_agent_id?: string
}
/** 审批流程链接 */
@@ -243,8 +247,8 @@ function mapMessages(rawList: any[]): Message[] {
// -------------------------------------------------------------------------
// API 方法
// -------------------------------------------------------------------------
// 注意:响应拦截器返回 response.data(即 {code, data, message} 包装对象)
// API 函数通过 await + response.data 取出业务数据(与原始工作代码一致)
// 注意:响应拦截器返回 response(即 {code, data, message} 包装对象)
// API 函数通过 await + response 取出业务数据(与原始工作代码一致)
// -------------------------------------------------------------------------
/**
@@ -255,7 +259,7 @@ function mapMessages(rawList: any[]): Message[] {
*/
export async function getUser(): Promise<UserInfo> {
const response: any = await apiClient.get('/h5/user')
return response.data
return response
}
/**
@@ -265,7 +269,7 @@ export async function getUser(): Promise<UserInfo> {
*/
export async function getCurrentConversation(): Promise<ConversationInfo | null> {
const response: any = await apiClient.get('/h5/conversations/current')
return response.data
return response
}
/**
@@ -281,14 +285,14 @@ export async function sendMessage(data: SendMessageRequest): Promise<SendMessage
timeout: 30000,
})
// 注意:apiClient 拦截器返回的是 {code: 0, data: {...}, message: "success"} 包装对象,
// 需要通过 response.data 获取实际业务数据
// 需要通过 response 获取实际业务数据
// 修复字段映射:后端返回 id/sender_typeH5前端期望 message_id/message_type
return {
user_message: mapMessage(response.data.user_message),
ai_reply: response.data.ai_reply ? mapMessage(response.data.ai_reply) : response.data.ai_reply,
is_guidance: response.data.is_guidance,
ai_reply_count: response.data.ai_reply_count,
can_call_agent: response.data.can_call_agent,
user_message: mapMessage(response.user_message),
ai_reply: response.ai_reply ? mapMessage(response.ai_reply) : response.ai_reply,
is_guidance: response.is_guidance,
ai_reply_count: response.ai_reply_count,
can_call_agent: response.can_call_agent,
}
}
@@ -300,13 +304,45 @@ export async function sendMessage(data: SendMessageRequest): Promise<SendMessage
*/
export async function pollMessages(params?: PollMessagesParams): Promise<Message[]> {
const response: any = await apiClient.get('/h5/conversations/current/messages/poll', { params })
// response.data = { items: [...], has_more: bool }
const data = response.data
// response = { items: [...], has_more: bool }
const data = response
const rawItems = data?.items || data || []
// 修复字段映射:后端返回 id/sender_typeH5前端期望 message_id/message_type
return mapMessages(rawItems)
}
/** 获取消息列表请求参数 */
export interface GetMessagesParams {
/** 每页消息数量(默认50 */
limit?: number
/** 获取此消息ID之前的消息(向上翻页) */
before?: string
}
/** 消息列表响应 */
export interface MessageListData {
items: Message[]
has_more: boolean
}
/**
* 获取消息列表(历史消息)
* 获取当前会话的消息历史记录,支持分页向上翻页
* @param params 查询参数(limit 和 before
* @returns 消息列表数据
*/
export async function getMessages(params?: GetMessagesParams): Promise<MessageListData> {
const response: any = await apiClient.get('/h5/conversations/current/messages', { params })
// response = { items: [...], has_more: bool }
const data = response
const rawItems = data?.items || []
// 修复字段映射:后端返回 id/sender_typeH5前端期望 message_id/message_type
return {
items: mapMessages(rawItems),
has_more: data?.has_more || false,
}
}
/**
* 摇人 — 一键呼叫 IT 坐席
* 触发转人工流程,返回趣味话术和会话状态
@@ -315,7 +351,7 @@ export async function pollMessages(params?: PollMessagesParams): Promise<Message
*/
export async function shake(data: ShakeRequest): Promise<ShakeResponse> {
const response: any = await apiClient.post('/h5/conversations/current/shake', data)
return response.data
return response
}
/**
@@ -325,8 +361,8 @@ export async function shake(data: ShakeRequest): Promise<ShakeResponse> {
*/
export async function getApprovalLinks(): Promise<ApprovalLink[]> {
const response: any = await apiClient.get('/h5/approval-links')
// response.data = { items: [...] } 或 [...]
const data = response.data
// response = { items: [...] } 或 [...]
const data = response
return (data?.items || data || []) as ApprovalLink[]
}
@@ -349,7 +385,7 @@ export interface ApprovalKeyword {
*/
export async function getApprovalKeywords(): Promise<ApprovalKeyword[]> {
const response: any = await apiClient.get('/approval/keywords')
return response.data || []
return response || []
}
/**
@@ -359,7 +395,7 @@ export async function getApprovalKeywords(): Promise<ApprovalKeyword[]> {
*/
export async function createApprovalJump(templateId: string): Promise<{ url: string; template_name: string }> {
const response: any = await apiClient.post('/approval/jump', { template_id: templateId })
return response.data
return response
}
/**
@@ -369,8 +405,8 @@ export async function createApprovalJump(templateId: string): Promise<{ url: str
*/
export async function getSoftwareDownloads(): Promise<SoftwareDownload[]> {
const response: any = await apiClient.get('/h5/software-downloads')
// response.data = { items: [...] } 或 [...]
const data = response.data
// response = { items: [...] } 或 [...]
const data = response
return (data?.items || data || []) as SoftwareDownload[]
}
@@ -394,7 +430,7 @@ export async function joinConversation(
const response: any = await apiClient.post(
`/h5/conversations/${conversationId}/join`
)
return response.data
return response
}
/**
@@ -410,7 +446,7 @@ export async function leaveAsParticipant(
const response: any = await apiClient.post(
`/h5/conversations/${conversationId}/leave-participant`
)
return response.data
return response
}
/**
@@ -426,6 +462,95 @@ export async function getParticipants(
const response: any = await apiClient.get(
`/h5/conversations/${conversationId}/participants`
)
const data = response.data
const data = response
return data?.participants || []
}
// -------------------------------------------------------------------------
// 摇人按钮 - 呼叫坐席
// -------------------------------------------------------------------------
export interface CallAgentResponse {
code: number
message: string
data?: {
conversation_id: string
status: 'waiting' | 'serving'
queue_position?: number
estimated_wait_seconds?: number
}
}
/**
* 呼叫坐席(摇人按钮)
* 用户点击摇人按钮后,触发转人工流程
*/
export async function callAgent(): Promise<CallAgentResponse> {
const response: any = await apiClient.post('/h5/conversations/current/call-agent', {})
return response || { code: -1, message: '网络错误' }
}
// -------------------------------------------------------------------------
// 满意度评价 API (P1-25)
// -------------------------------------------------------------------------
/** 评价提交请求 */
export interface EvaluationSubmitRequest {
/** 星级评分(1-5 */
star_rating: number
/** 表情评价(satisfied/neutral/dissatisfied */
emoji: string
/** 文字反馈(可选) */
feedback_text?: string
}
/** 评价记录 */
export interface EvaluationRecord {
/** 评价ID */
id: string
/** 会话ID */
conversation_id: string
/** 员工ID */
employee_id: string
/** 员工姓名 */
employee_name: string
/** 星级评分 */
star_rating: number
/** 表情评价 */
emoji: string
/** 文字反馈 */
feedback_text?: string
/** 评价时间 */
created_at: string
}
/**
* 提交满意度评价
* 员工对已结束的会话进行满意度评价
* @param conversationId - 会话ID
* @param data - 评价内容
* @returns 评价记录
*/
export async function submitEvaluation(
conversationId: string,
data: EvaluationSubmitRequest
): Promise<EvaluationRecord> {
const response: any = await apiClient.post(
`/conversation/${conversationId}/evaluate`,
data
)
return response
}
/**
* 获取会话的评价记录
* @param conversationId - 会话ID
* @returns 评价记录(如果已评价)
*/
export async function getEvaluation(
conversationId: string
): Promise<EvaluationRecord | null> {
const response: any = await apiClient.get(
`/conversation/${conversationId}/evaluation`
)
return response
}
+47 -7
View File
@@ -67,8 +67,8 @@ export interface OAuthAuthorizeResponse {
// -------------------------------------------------------------------------
// API 方法
// -------------------------------------------------------------------------
// 注意:响应拦截器返回 response.data(即 {code, data, message} 包装对象)
// API 函数通过 await + response.data 取出业务数据(与原始工作代码一致)
// 注意:响应拦截器返回 response(即 {code, data, message} 包装对象)
// API 函数通过 await + response 取出业务数据(与原始工作代码一致)
// -------------------------------------------------------------------------
/**
@@ -83,8 +83,8 @@ export interface OAuthAuthorizeResponse {
export async function oauthCallback(data: OAuthCallbackRequest): Promise<OAuthCallbackResponse> {
const response: any = await apiClient.post('/h5/oauth/callback', data)
// response = {code:0, data: {token:"...", ...}, message:"success"}(拦截器返回值)
// response.data = 业务数据 {token:"...", employee_id:"...", ...}
return response.data
// response = 业务数据 {token:"...", employee_id:"...", ...}
return response
}
/**
@@ -98,7 +98,7 @@ export async function oauthCallback(data: OAuthCallbackRequest): Promise<OAuthCa
*/
export async function mockLogin(data: { employee_id: string; employee_name?: string }): Promise<OAuthCallbackResponse> {
const response: any = await apiClient.post('/h5/mock-login', data)
return response.data
return response
}
/**
@@ -109,7 +109,7 @@ export async function mockLogin(data: { employee_id: string; employee_name?: str
*/
export async function getEmployeeInfo(): Promise<EmployeeInfo> {
const response: any = await apiClient.get('/h5/me')
return response.data
return response
}
/**
@@ -123,5 +123,45 @@ export async function getOAuthAuthorizeUrl(): Promise<OAuthAuthorizeResponse> {
const response: any = await apiClient.get('/h5/oauth/authorize', {
params: { redirect_uri: redirectUri },
})
return response.data
return response
}
// -------------------------------------------------------------------------
// 密码管理
// -------------------------------------------------------------------------
/** 修改密码请求参数 */
export interface ChangePasswordRequest {
/** 旧密码 */
old_password: string
/** 新密码 */
new_password: string
}
/** 重置密码请求参数 */
export interface ResetPasswordRequest {
/** 新密码 */
new_password: string
}
/**
* 修改当前坐席密码
* 需要验证旧密码
* @param data 包含旧密码和新密码的请求参数
* @returns 修改结果
*/
export async function changePassword(data: ChangePasswordRequest): Promise<{ message: string }> {
const response: any = await apiClient.post('/agents/password', data)
return response
}
/**
* 管理员重置坐席密码
* @param userId 坐席用户ID
* @param newPassword 新密码
* @returns 重置结果
*/
export async function adminResetPassword(userId: string, newPassword: string): Promise<{ message: string }> {
const response: any = await apiClient.post(`/admin/agents/${userId}/reset-password`, { new_password: newPassword })
return response
}
+9 -17
View File
@@ -44,15 +44,6 @@ apiClient.interceptors.request.use(
config.headers['Authorization'] = `Bearer ${token}`
}
// 兼容过渡:如果同时存在 employee_id 且无 token,则仍然发送 X-Employee-Id
// 这确保了在 token 过期但 localStorage 中仍有旧数据的降级场景
if (!token) {
const employeeId = localStorage.getItem('employee_id')
if (employeeId) {
config.headers['X-Employee-Id'] = employeeId
}
}
return config
},
(error) => {
@@ -77,23 +68,24 @@ apiClient.interceptors.response.use(
// 后端 _get_current_employee 在 Redis 查不到 token 时返回此码
if (res.code === 1002) {
handleAuthExpired('biz1002')
return Promise.reject(new Error(res.message || '未授权'))
return Promise.reject({ code: 1002, message: res.message || '未授权' })
}
// 普通业务错误:显示轻提示
showToast(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
return Promise.reject({ code: res.code, message: res.message || '请求失败' })
}
// 业务成功:返回 response.data(即 {code, data, message} 包装对象
// API 函数通过 response.data 取出业务数据(与原始工作代码一致)
return response.data
// 业务成功:Scheme A — 直接返回 inner data(三端统一契约
return res.data
},
async (error) => {
// 网络错误或服务器错误
// 网络错误或服务器错误:统一 reject {code, message}CTRT-03
let message = '网络异常,请稍后重试'
let code = -1
if (error.response) {
code = error.response.status
switch (error.response.status) {
case 401:
// HTTP 401Token 过期或无效(FastAPI 直接返回的 HTTP 状态码)
@@ -116,11 +108,11 @@ apiClient.interceptors.response.use(
}
// 显示轻提示(401 时不显示通用提示,因为会自动跳转授权)
if (!error.response || error.response.status !== 401) {
if (code !== 401) {
showToast(message)
}
return Promise.reject(error)
return Promise.reject({ code, message })
}
)
+6 -6
View File
@@ -33,7 +33,7 @@ export async function recallMessage(messageId: string): Promise<any> {
const response: AxiosResponse = await apiClient.post(
`/messages/${messageId}/recall`
)
return response.data
return response
}
/**
@@ -46,7 +46,7 @@ export async function deleteMessage(messageId: string): Promise<any> {
const response: AxiosResponse = await apiClient.delete(
`/messages/${messageId}`
)
return response.data
return response
}
/**
@@ -59,7 +59,7 @@ export async function markConversationRead(conversationId: string): Promise<any>
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/mark-read`
)
return response.data
return response
}
/**
@@ -77,7 +77,7 @@ export async function pollMessages(afterMessageId?: string): Promise<Message[]>
'/h5/conversations/current/messages/poll',
{ params }
)
const data = response.data.data
const data = response
const items = data?.items || []
// 映射后端字段到前端字段
return items.map((item: any) => ({
@@ -121,7 +121,7 @@ export async function uploadImage(file: File): Promise<{
},
}
)
return response.data.data
return response
}
/**
@@ -148,5 +148,5 @@ export async function uploadMessageFile(file: File): Promise<{
},
}
)
return response.data.data
return response
}
@@ -103,7 +103,7 @@ export async function getTroubleshootingTemplates(
page_size: params?.page_size || 20,
},
})
return response.data.data
return response
}
/**
@@ -116,5 +116,5 @@ export async function getTroubleshootingTemplate(
id: string,
): Promise<TroubleshootingTemplate> {
const response: AxiosResponse = await apiClient.get(`/troubleshooting-templates/${id}`)
return response.data.data
return response
}
+2 -2
View File
@@ -77,8 +77,8 @@ async function uploadWithRetry(formData: FormData, maxRetries: number = 3): Prom
})
// 响应拦截器已确保 code === 0
// response = {code:0, data: {url:"...",...}, message:"success"}(拦截器返回值)
// response.data = 业务数据 {url:"...", filename:"...", ...}
return response.data as UploadResponse
// response = 业务数据 {url:"...", filename:"...", ...}
return response as UploadResponse
} catch (err) {
if (attempt === maxRetries) throw err
// 指数退避:1s, 2s, 4s
@@ -0,0 +1,148 @@
<!-- =============================================================================
// 企微IT智能服务台 — P1 员工侧高危动作二次确认弹窗
// =============================================================================
// 说明:当自动化处置遇到高危/写操作时,由 WS automation.action_required 触发,
// 弹窗展示动作详情,员工确认执行或取消。
// 约定:Vant4 van-dialog(受控 v-model:show),emit confirm/cancel。
// ============================================================================= -->
<template>
<van-dialog
v-model:show="show"
title="高危操作确认"
:show-confirm-button="true"
:show-cancel-button="true"
confirm-button-text="确认执行"
cancel-button-text="暂不执行"
confirm-button-color="#ee0a24"
@confirm="onConfirm"
@cancel="onCancel"
>
<div v-if="action" class="confirm-body">
<div class="risk-row">
<van-tag :type="riskTagType" mark>{{ riskLabel }}</van-tag>
</div>
<div class="action-title">{{ action.title }}</div>
<div class="action-desc">{{ action.description }}</div>
<div v-if="action.payload" class="payload-block">
<div class="block-label">操作参数</div>
<pre class="payload-pre">{{ prettyPayload }}</pre>
</div>
<p class="warn-tip">该操作将实际执行请确认信息无误后再继续</p>
</div>
<div v-else class="confirm-body">
<p class="warn-tip">暂无动作详情</p>
</div>
</van-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AutomationAction } from '@/api/automation'
const props = defineProps<{
visible: boolean
action: AutomationAction | null
}>()
const emit = defineEmits<{
(e: 'confirm', payload: { confirmed: boolean; note?: string }): void
(e: 'cancel'): void
(e: 'update:visible', value: boolean): void
}>()
// 受控显示:与父组件 v-model:visible 同步
const show = computed({
get: () => props.visible,
set: (v: boolean) => emit('update:visible', v),
})
const riskLabel = computed(() => {
switch (props.action?.risk_level) {
case 'read':
return '只读'
case 'low':
return '低风险'
case 'write':
return '写操作'
case 'high':
return '高危'
default:
return props.action?.risk_level || '未知'
}
})
const riskTagType = computed<'primary' | 'success' | 'warning' | 'danger'>(() => {
switch (props.action?.risk_level) {
case 'read':
return 'primary'
case 'low':
return 'success'
case 'write':
return 'warning'
case 'high':
return 'danger'
default:
return 'primary'
}
})
const prettyPayload = computed(() => {
try {
return JSON.stringify(props.action?.payload, null, 2)
} catch {
return String(props.action?.payload)
}
})
function onConfirm(): void {
emit('confirm', { confirmed: true })
}
function onCancel(): void {
emit('cancel')
}
</script>
<style scoped>
.confirm-body {
padding: 16px;
}
.risk-row {
margin-bottom: 8px;
}
.action-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #323233);
}
.action-desc {
font-size: 13px;
color: var(--text-tertiary, #969799);
margin-top: 4px;
}
.payload-block {
margin-top: 10px;
}
.block-label {
font-size: 12px;
color: var(--text-tertiary, #969799);
margin-bottom: 4px;
}
.payload-pre {
background: var(--bg-secondary, #f7f8fa);
border-radius: 8px;
padding: 8px;
font-size: 12px;
max-height: 160px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.warn-tip {
font-size: 12px;
color: #ee0a24;
margin: 10px 0 0;
}
</style>
@@ -0,0 +1,118 @@
<!-- =============================================================================
// 企微IT智能服务台 — 「已解决」反馈 / 静默关单提示
// =============================================================================
// 说明:会话 resolved 后展示,告知员工将静默自动关单,并收集满意度反馈。
// 约定:Vant4 van-popup(底部弹出),emit feedback { satisfied, note }。
// ============================================================================= -->
<template>
<van-popup
v-model:show="show"
position="bottom"
round
:style="{ padding: '20px' }"
@close="onClose"
>
<div class="resolve-feedback">
<div class="title">问题已解决 🎉</div>
<p class="desc">
本次自动化处置已完成如无异议会话将在
<b>{{ autoCloseText }}</b>
后自动关闭静默关单
</p>
<div class="question">本次服务是否解决了您的问题</div>
<div class="btns">
<van-button type="primary" block @click="submit(true)">已解决满意</van-button>
<van-button block class="btn-secondary" @click="submit(false)">仍未解决</van-button>
</div>
<van-field
v-model="note"
type="textarea"
rows="2"
placeholder="补充意见(可选)"
class="note-field"
/>
</div>
</van-popup>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { AutomationSession } from '@/api/automation'
const props = defineProps<{
visible: boolean
session: AutomationSession | null
}>()
const emit = defineEmits<{
(e: 'feedback', payload: { satisfied: boolean; note?: string }): void
(e: 'update:visible', value: boolean): void
}>()
const show = computed({
get: () => props.visible,
set: (v: boolean) => emit('update:visible', v),
})
const note = ref('')
// 静默关单倒计时文本
const autoCloseText = computed(() => {
const t = props.session?.auto_close_at
if (!t) return '稍后'
const diff = new Date(t).getTime() - Date.now()
if (diff <= 0) return '即将'
const mins = Math.floor(diff / 60000)
const secs = Math.floor((diff % 60000) / 1000)
return mins > 0 ? `${mins}${secs}` : `${secs}`
})
function submit(satisfied: boolean): void {
emit('feedback', { satisfied, note: note.value || undefined })
note.value = ''
show.value = false
}
function onClose(): void {
// 弹窗关闭(员工未反馈)不强制提交,仅同步可见状态
emit('update:visible', false)
}
</script>
<style scoped>
.resolve-feedback {
text-align: center;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #323233);
}
.desc {
font-size: 13px;
color: var(--text-tertiary, #969799);
margin: 10px 0;
line-height: 1.6;
}
.question {
font-size: 14px;
font-weight: 500;
margin: 12px 0 8px;
color: var(--text-primary, #323233);
}
.btns {
display: flex;
flex-direction: column;
gap: 10px;
}
.btn-secondary {
margin-left: 0;
}
.note-field {
margin-top: 12px;
background: var(--bg-secondary, #f7f8fa);
border-radius: 8px;
}
</style>
@@ -258,7 +258,15 @@
<span>正在通知 IT 坐席...</span>
</div>
<div v-if="sendSuccess" class="call-modal__success">
呼叫成功坐席马上就来
<template v-if="assignResult === 'assigned'">
🎉 呼叫成功坐席已为您服务
</template>
<template v-else-if="assignResult === 'queued'">
坐席正忙您已进入排队请耐心等待...
</template>
<template v-else>
呼叫成功坐席马上就来
</template>
</div>
</div>
@@ -339,20 +347,25 @@ watch(() => props.visible, (newVal) => {
}
})
// 分配结果状态
const assignResult = ref<string>('')
async function startCall(): Promise<void> {
selectedScene.value = pickScene()
sending.value = true
sendSuccess.value = false
assignResult.value = ''
try {
await store.shakeAgent()
const result = await store.shakeAgent()
assignResult.value = result
sendSuccess.value = true
emit('call-success')
// 3秒后自动关闭
// 4秒后自动关闭(给用户时间阅读分配结果)
setTimeout(() => {
if (sendSuccess.value) handleClose()
}, 4000)
}, 5000)
} catch (err) {
// 发送失败,关闭弹窗
handleClose()
+255 -1
View File
@@ -49,6 +49,28 @@
</div>
<span class="switch-icon">🌙</span>
</div>
<!-- 用户头像 / 菜单 -->
<div class="user-menu">
<button class="user-avatar-btn" @click="showUserMenu = !showUserMenu">
<span class="user-avatar">{{ employeeStore.employeeInfo?.employee_name?.charAt(0) || '?' }}</span>
</button>
<!-- 用户菜单下拉 -->
<div v-if="showUserMenu" class="user-dropdown">
<div class="user-dropdown__info">
<div class="user-dropdown__name">{{ employeeStore.employeeInfo?.employee_name }}</div>
<div class="user-dropdown__id">{{ employeeStore.employeeInfo?.employee_id }}</div>
</div>
<div class="user-dropdown__divider"></div>
<div class="user-dropdown__item" @click="openChangePasswordDialog">
<span class="user-dropdown__icon">🔑</span>
修改密码
</div>
<div class="user-dropdown__item user-dropdown__item--danger" @click="handleLogout">
<span class="user-dropdown__icon">🚪</span>
退出登录
</div>
</div>
</div>
</div>
</div>
@@ -119,6 +141,46 @@
@select="handleApprovalSelect"
/>
<!-- 满意度评价弹窗P1-25 -->
<EvaluationDialog
v-model="showEvaluationDialog"
:conversation-id="evaluationConversationId"
@submitted="handleEvaluationSubmitted"
/>
<!-- 修改密码弹窗 -->
<van-dialog
v-model:show="showChangePasswordDialog"
title="修改密码"
show-cancel-button
confirm-button-text="确认修改"
@confirm="handleChangePassword"
>
<div class="change-password-form">
<van-field
v-model="changePasswordForm.oldPassword"
type="password"
label="旧密码"
placeholder="请输入旧密码"
:rules="[{ required: true, message: '请输入旧密码' }]"
/>
<van-field
v-model="changePasswordForm.newPassword"
type="password"
label="新密码"
placeholder="请输入新密码(6-128位)"
:rules="[{ required: true, message: '请输入新密码' }]"
/>
<van-field
v-model="changePasswordForm.confirmPassword"
type="password"
label="确认密码"
placeholder="请再次输入新密码"
:rules="[{ required: true, message: '请再次输入新密码' }]"
/>
</div>
</van-dialog>
</div>
</template>
@@ -130,28 +192,104 @@
* 排查步骤固定在消息区顶部,不随消息滚动消失
*/
import { ref, watch, nextTick, onMounted } from 'vue'
import { ref, reactive, watch, nextTick, onMounted } from 'vue'
import { showToast } from 'vant'
import { useConversationStore } from '@/stores/conversation'
import { useThemeStore } from '@/stores/theme'
import { useEmployeeStore } from '@/stores/employee'
import { changePassword } from '@/api/employee'
import MessageBubble from './MessageBubble.vue'
import InputBar from './InputBar.vue'
import CallAgentModal from './CallAgentModal.vue'
import ApprovalCardModal from './ApprovalCardModal.vue'
import TroubleshootFlow from './TroubleshootFlow.vue'
import ParticipantList from './ParticipantList.vue'
import EvaluationDialog from './EvaluationDialog.vue'
const store = useConversationStore()
const themeStore = useThemeStore()
const employeeStore = useEmployeeStore()
/** 消息列表容器的 DOM 引用 */
const messageListRef = ref<HTMLElement | null>(null)
// 用户菜单状态
const showUserMenu = ref<boolean>(false)
const showChangePasswordDialog = ref<boolean>(false)
const changePasswordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
/** 是否显示「呼叫坐席」弹窗 */
const showCallModal = ref<boolean>(false)
/** 满意度评价弹窗显示状态 */
const showEvaluationDialog = ref<boolean>(false)
/** 当前待评价的会话ID */
const evaluationConversationId = ref<string>('')
/** 会话状态(用于检测会话结束) */
const previousConversationStatus = ref<string>('')
/** 是否应该自动滚动到底部(用户手动上滚时暂停自动滚动) */
const shouldAutoScroll = ref<boolean>(true)
// ==========================================================================
// 用户菜单功能
// ==========================================================================
/** 打开修改密码对话框 */
function openChangePasswordDialog(): void {
showUserMenu.value = false
changePasswordForm.oldPassword = ''
changePasswordForm.newPassword = ''
changePasswordForm.confirmPassword = ''
showChangePasswordDialog.value = true
}
/** 提交修改密码 */
async function handleChangePassword(): Promise<void> {
// 验证密码
if (!changePasswordForm.oldPassword) {
showToast('请输入旧密码')
return
}
if (!changePasswordForm.newPassword || changePasswordForm.newPassword.length < 6) {
showToast('新密码长度不能少于6位')
return
}
if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) {
showToast('两次输入的密码不一致')
return
}
if (changePasswordForm.oldPassword === changePasswordForm.newPassword) {
showToast('新密码不能与旧密码相同')
return
}
try {
await changePassword({
old_password: changePasswordForm.oldPassword,
new_password: changePasswordForm.newPassword,
})
showToast('密码修改成功')
showChangePasswordDialog.value = false
} catch (error: any) {
console.error('修改密码失败:', error)
const msg = error?.response?.data?.message || error?.message || '修改密码失败'
showToast(msg)
}
}
/** 退出登录 */
function handleLogout(): void {
showUserMenu.value = false
employeeStore.logout()
}
/**
* 滚动到消息列表底部
* 使用 nextTick 确保 DOM 更新后再滚动
@@ -186,6 +324,12 @@ function handleApprovalSelect(option: any): void {
store.closeApprovalCard()
}
/** 评价提交成功回调 */
function handleEvaluationSubmitted(): void {
console.log('[ChatPanel] 评价已提交')
// 可以在这里添加其他逻辑,如显示感谢等
}
// 监听消息列表变化,自动滚动到底部
watch(
() => store.messages.length,
@@ -194,9 +338,31 @@ watch(
}
)
// 监听会话状态变化,弹出评价弹窗
watch(
() => store.currentConversation?.status,
(newStatus, oldStatus) => {
// 会话从"服务中"变为"已结单"时,弹出评价弹窗
if (oldStatus === 'serving' && newStatus === 'resolved') {
const convId = store.currentConversation?.conversation_id
if (convId) {
// 延迟3秒弹出评价弹窗
setTimeout(() => {
evaluationConversationId.value = convId
showEvaluationDialog.value = true
}, 3000)
}
}
// 记录当前状态
previousConversationStatus.value = newStatus || ''
}
)
// 组件挂载后滚动到底部
onMounted(() => {
scrollToBottom()
// 记录初始会话状态
previousConversationStatus.value = store.currentConversation?.status || ''
})
</script>
@@ -288,6 +454,94 @@ onMounted(() => {
gap: 12px;
}
/* 用户菜单 */
.user-menu {
position: relative;
}
.user-avatar-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--accent, #07C160);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.user-avatar {
line-height: 1;
}
.user-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--bg-secondary);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 160px;
z-index: 1000;
overflow: hidden;
}
.user-dropdown__info {
padding: 12px 16px;
background: var(--bg-tertiary);
}
.user-dropdown__name {
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
}
.user-dropdown__id {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
.user-dropdown__divider {
height: 1px;
background: var(--border-color);
}
.user-dropdown__item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
transition: background 0.2s;
}
.user-dropdown__item:hover {
background: var(--bg-tertiary);
}
.user-dropdown__item--danger {
color: var(--color-danger, #F56C6C);
}
.user-dropdown__icon {
font-size: 16px;
}
/* 修改密码表单 */
.change-password-form {
padding: 8px 0;
}
/* 🔔 呼叫坐席按钮(标题栏) */
.chat-panel__bell-btn {
display: flex;
@@ -0,0 +1,340 @@
<!--
企微IT智能服务台 H5用户端满意度评价弹窗
说明会话结束后弹出的满意度评价对话框
功能
1. 5星评分必选
2. 表情选择满意/一般/不满意必选
3. 文字反馈输入框可选限200字
4. 提交评价
-->
<template>
<van-popup
v-model:show="visible"
round
:close-on-click-overlay="false"
class="evaluation-popup"
>
<div class="evaluation-dialog">
<div class="evaluation-dialog__header">
<h3 class="evaluation-dialog__title">请对本次服务进行评价</h3>
<p class="evaluation-dialog__subtitle">您的评价对我们非常重要</p>
</div>
<div class="evaluation-dialog__section">
<div class="evaluation-dialog__label">服务评分</div>
<div class="star-rating">
<van-rate
v-model="formData.star_rating"
:count="5"
size="32"
color="#FFD21E"
void-color="#E5E5E5"
@change="handleStarChange"
/>
<span class="star-label">{{ starLabel }}</span>
</div>
</div>
<div class="evaluation-dialog__section">
<div class="evaluation-dialog__label">整体感受</div>
<div class="emoji-selector">
<div class="emoji-item" :class="{ 'emoji-item--selected': formData.emoji === 'satisfied' }" @click="selectEmoji('satisfied')">
<span class="emoji-icon">😀</span>
<span class="emoji-text">满意</span>
</div>
<div class="emoji-item" :class="{ 'emoji-item--selected': formData.emoji === 'neutral' }" @click="selectEmoji('neutral')">
<span class="emoji-icon">😐</span>
<span class="emoji-text">一般</span>
</div>
<div class="emoji-item" :class="{ 'emoji-item--selected': formData.emoji === 'dissatisfied' }" @click="selectEmoji('dissatisfied')">
<span class="emoji-icon">😞</span>
<span class="emoji-text">不满意</span>
</div>
</div>
</div>
<div class="evaluation-dialog__section">
<div class="evaluation-dialog__label">
改进建议
<span class="evaluation-dialog__optional">选填</span>
</div>
<van-field
v-model="formData.feedback_text"
type="textarea"
placeholder="您对本次服务有什么建议?"
maxlength="200"
show-word-limit
rows="3"
class="evaluation-dialog__textarea"
/>
</div>
<div class="evaluation-dialog__actions">
<van-button
type="primary"
size="large"
:loading="submitting"
:disabled="!canSubmit"
@click="handleSubmit"
>
提交评价
</van-button>
</div>
<div class="evaluation-dialog__skip">
<van-button size="small" type="default" :disabled="submitting" @click="handleSkip">
暂不评价
</van-button>
</div>
</div>
</van-popup>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import { submitEvaluation, getEvaluation } from '@/api/conversation'
interface Props {
modelValue: boolean
conversationId: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
conversationId: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'submitted'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const formData = ref({
star_rating: 0,
emoji: '',
feedback_text: '',
})
const submitting = ref(false)
const starLabel = computed(() => {
const labels: Record<number, string> = {
0: '',
1: '非常差',
2: '较差',
3: '一般',
4: '满意',
5: '非常满意',
}
return labels[formData.value.star_rating] || ''
})
const canSubmit = computed(() => {
return formData.value.star_rating > 0 && formData.value.emoji !== ''
})
watch(visible, async (val) => {
if (val && props.conversationId) {
try {
const evaluation = await getEvaluation(props.conversationId)
if (evaluation) {
visible.value = false
showToast('您已评价过该会话')
}
} catch (error) {
console.warn('[EvaluationDialog] 检查评价状态失败:', error)
}
}
})
function selectEmoji(emoji: string): void {
formData.value.emoji = emoji
}
function handleStarChange(value: number): void {
if (value >= 4) {
formData.value.emoji = 'satisfied'
} else if (value >= 2) {
formData.value.emoji = 'neutral'
} else {
formData.value.emoji = 'dissatisfied'
}
}
async function handleSubmit(): Promise<void> {
if (!canSubmit.value || submitting.value) return
submitting.value = true
try {
await submitEvaluation(props.conversationId, {
star_rating: formData.value.star_rating,
emoji: formData.value.emoji,
feedback_text: formData.value.feedback_text || undefined,
})
showToast('评价成功,感谢您的反馈!')
visible.value = false
emit('submitted')
} catch (error: any) {
console.error('[EvaluationDialog] 提交评价失败:', error)
const message = error?.response?.data?.message || '提交失败,请稍后重试'
showToast(message)
} finally {
submitting.value = false
}
}
function handleSkip(): void {
visible.value = false
}
function resetForm(): void {
formData.value = {
star_rating: 0,
emoji: '',
feedback_text: '',
}
submitting.value = false
}
defineExpose({
resetForm,
})
</script>
<style scoped>
.evaluation-popup {
width: 90%;
max-width: 360px;
}
.evaluation-dialog {
padding: 24px 20px 20px;
}
.evaluation-dialog__header {
text-align: center;
margin-bottom: 20px;
}
.evaluation-dialog__title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #333);
margin: 0 0 6px;
}
.evaluation-dialog__subtitle {
font-size: 13px;
color: var(--text-tertiary, #999);
margin: 0;
}
.evaluation-dialog__section {
margin-bottom: 20px;
}
.evaluation-dialog__label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #333);
margin-bottom: 10px;
display: flex;
align-items: center;
}
.evaluation-dialog__optional {
font-size: 12px;
font-weight: 400;
color: var(--text-tertiary, #999);
margin-left: 6px;
}
.star-rating {
display: flex;
align-items: center;
gap: 12px;
}
.star-label {
font-size: 14px;
color: var(--text-secondary, #666);
min-width: 48px;
}
.emoji-selector {
display: flex;
justify-content: space-between;
gap: 12px;
}
.emoji-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
background: var(--bg-tertiary, #f5f5f5);
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.emoji-item:hover {
background: var(--bg-secondary, #f0f0f0);
}
.emoji-item--selected {
background: var(--accent-soft, rgba(7, 193, 96, 0.1));
border-color: var(--accent, #07C160);
}
.emoji-icon {
font-size: 28px;
margin-bottom: 4px;
}
.emoji-text {
font-size: 12px;
color: var(--text-secondary, #666);
}
.emoji-item--selected .emoji-text {
color: var(--accent, #07C160);
font-weight: 500;
}
.evaluation-dialog__textarea {
background: var(--bg-tertiary, #f5f5f5);
border-radius: 8px;
padding: 12px;
}
.evaluation-dialog__actions {
margin-top: 24px;
}
.evaluation-dialog__actions .van-button--primary {
background: var(--accent, #07C160);
border-color: var(--accent, #07C160);
}
.evaluation-dialog__skip {
margin-top: 12px;
text-align: center;
}
.evaluation-dialog__skip .van-button--default {
color: var(--text-tertiary, #999);
border: none;
background: transparent;
}
</style>
+85 -2
View File
@@ -13,8 +13,22 @@
<template>
<div class="input-box">
<!-- 工具栏表情/文件/截图/快捷申请 (2026-07-05移除图片和拍照) -->
<!-- 工具栏摇人按钮/表情/文件/截图/快捷申请 (2026-07-05移除图片和拍照) -->
<div class="input-box__toolbar">
<!-- 摇人按钮 - 输入框左侧橙色渐变铃铛图标 -->
<button
v-if="store.canCallAgent"
class="input-box__tool-btn input-box__tool-btn--yaoren"
:class="{ 'input-box__tool-btn--calling': isCallingAgent }"
title="呼叫IT坐席"
:disabled="isCallingAgent"
@click="handleCallAgent"
>
<svg v-if="!isCallingAgent" class="yaoren-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C10.9 2 10 2.9 10 4V8C10 9.1 10.9 10 12 10C13.1 10 14 9.1 14 8V4C14 2.9 13.1 2 12 2ZM12 12C10.9 12 10 12.9 10 14V16C10 17.1 10.9 18 12 18C13.1 18 14 17.1 14 16V14C14 12.9 13.1 12 12 12ZM6 6H18V8H6V6ZM4 4V20H20V4H4Z"/>
</svg>
<span v-else class="yaoren-text">呼叫中...</span>
</button>
<button class="input-box__tool-btn" title="表情" @click="handleEmoji">
<span>😊</span>
</button>
@@ -22,7 +36,7 @@
<span>📎</span>
</button>
<button class="input-box__tool-btn" title="截图" @click="handleScreenshot">
<span></span>
<span>📷</span>
</button>
<button class="input-box__tool-btn input-box__tool-btn--accent" title="快捷申请" @click="handleQuickApply">
<span>📝</span>
@@ -117,6 +131,7 @@ import { showToast } from 'vant'
import html2canvas from 'html2canvas-pro'
import { useConversationStore } from '@/stores/conversation'
import { uploadFile } from '@/api/upload'
import { callAgent } from '@/api/conversation'
import ScreenshotEditor from './ScreenshotEditor.vue'
// ============================================================================
@@ -161,6 +176,9 @@ const showEmojiPanel = ref(false)
/** 截图编辑器是否可见 */
const showScreenshotEditor = ref(false)
/** 是否正在呼叫坐席中 */
const isCallingAgent = ref(false)
/** html2canvas 生成的截图 Canvas */
let screenshotCanvas: HTMLCanvasElement | null = null
@@ -411,6 +429,29 @@ function onScreenshotCancel(): void {
screenshotCanvas = null
}
// ============================================================================
// 摇人按钮 - 呼叫坐席
// ============================================================================
async function handleCallAgent(): Promise<void> {
if (isCallingAgent.value) return // 防止重复点击
isCallingAgent.value = true
try {
// 调用后端API触发转人工
const resp = await callAgent()
if (resp.code === 0) {
showToast({ message: '已为您呼叫坐席,请稍候...', position: 'bottom' })
} else {
showToast({ message: resp.message || '呼叫失败,请重试', position: 'bottom' })
}
} catch (error: any) {
console.error('呼叫坐席失败:', error)
showToast({ message: '呼叫失败,请重试', position: 'bottom' })
} finally {
isCallingAgent.value = false
}
}
// ============================================================================
// 快捷申请按钮
// ============================================================================
@@ -471,6 +512,48 @@ function handleQuickApply(): void {
border-color: var(--accent-hover, #06ad56);
}
/* 摇人按钮 - 橙色渐变铃铛图标 */
.input-box__tool-btn--yaoren {
width: 40px;
height: 32px;
border-radius: 16px;
background: linear-gradient(135deg, #FF6B35 0%, #FF8F5E 100%);
border: none;
cursor: pointer;
}
.input-box__tool-btn--yaoren:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.4);
}
.input-box__tool-btn--yaoren:active {
transform: scale(0.95);
}
/* 呼叫中状态 */
.input-box__tool-btn--calling {
animation: shake 0.6s ease-in-out infinite;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
.yaoren-icon {
width: 18px;
height: 18px;
color: white;
}
.yaoren-text {
font-size: 10px;
color: white;
font-weight: 500;
}
/* 输入区域 */
.input-box__area {
display: flex;
@@ -17,11 +17,11 @@
<div class="participant-item__avatar">
<!-- 有头像URL渲染<img>加载失败降级显示首字 -->
<img
v-if="ownerInfo.avatar"
v-if="ownerInfo.avatar && !ownerAvatarFailed"
:src="ownerInfo.avatar"
:alt="ownerInfo.name"
class="participant-item__avatar-img"
@error="onAvatarError($event)"
@error="ownerAvatarFailed = true"
/>
<span v-else class="participant-item__avatar-letter">
{{ avatarLetter(ownerInfo.name) }}
@@ -58,11 +58,11 @@
<div class="participant-item__avatar">
<!-- 有头像URL渲染<img>加载失败降级显示首字 -->
<img
v-if="p.avatar"
v-if="p.avatar && !failedIds[p.id]"
:src="p.avatar"
:alt="p.name"
class="participant-item__avatar-img"
@error="onAvatarError($event)"
@error="failedIds[p.id] = true"
/>
<span v-else class="participant-item__avatar-letter">
{{ avatarLetter(p.name) }}
@@ -123,6 +123,12 @@ const employeeStore = useEmployeeStore()
/** 退出操作进行中 */
const leaving = ref(false)
/** 头像加载失败标记:发起人单独标记(URL 过期/网络异常时降级显示首字) */
const ownerAvatarFailed = ref(false)
/** 头像加载失败标记:被邀请参与者按 id 记录(各自 URL 独立,可能单独过期) */
const failedIds = ref<Record<string, boolean>>({})
/** 当前登录用户 ID */
const currentUserId = computed(() => store.userInfo?.employee_id || '')
@@ -162,17 +168,6 @@ function avatarLetter(name: string): string {
return (name || '?').charAt(0)
}
/**
* 头像加载失败时的降级处理
* 做什么:隐藏 <img>,显示父容器的首字降级
* 为什么:企微头像 URL 可能过期或网络异常
*/
function onAvatarError(event: Event): void {
const img = event.target as HTMLImageElement
// 隐藏图片元素,让 CSS 显示首字降级
img.style.display = 'none'
}
/**
* 退出会话
* 做什么:被邀请人主动退出当前会话
File diff suppressed because it is too large Load Diff
+7
View File
@@ -69,6 +69,13 @@ const routes = [
component: H5PreviewView,
meta: { title: '员工自助', requiresAuth: false },
},
// 阶段5 自动化闭环 — 员工侧进度页
{
path: '/automation/:id',
name: 'AutomationProgress',
component: () => import('@/views/AutomationProgress.vue'),
meta: { title: '自动化处置', requiresAuth: true },
},
// 404 兜底:未匹配的路径重定向到首页
{
path: '/:pathMatch(.*)*',
+183
View File
@@ -0,0 +1,183 @@
// =============================================================================
// 企微IT智能服务台 — 阶段5 自动化闭环 状态管理(Pinia Store,H5 员工端)
// =============================================================================
// 说明:管理当前自动化会话详情、处置进展时间线、待确认高危动作,
// 以及专用 WebSocket 实时推送(/ws/automation/{sessionId})。
// 约定:与坐席端 stores/automation 保持一致的 WS 事件前缀 automation.* 与
// 连接方式(subprotocol 传递 token)。
// =============================================================================
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { showToast } from 'vant'
import type {
AutomationSession,
AutomationAction,
ConfirmPayload,
ResolvePayload,
} from '@/api/automation'
import {
getAutomationSession,
confirmAutomationAction,
resolveAutomationSession,
} from '@/api/automation'
// --------------------------------------------------------------------------
// WebSocket 辅助(C8:移除 portal_token
// --------------------------------------------------------------------------
function getH5Token(): string {
return localStorage.getItem('h5_token') || ''
}
function buildWsUrl(sessionId: string): string {
const token = getH5Token()
const isDev = import.meta.env.DEV
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
// 开发环境直连后端 8000(与坐席端一致);生产走同源 wss
const host = isDev ? 'localhost:8000' : window.location.host
return `${proto}//${host}/ws/automation/${sessionId}?token=${encodeURIComponent(token)}`
}
export interface ProgressEventItem {
type: string
time: string
data?: any
}
export const useAutomationStore = defineStore('automation', () => {
// ------------------------------------------------------------------------
// 状态
// ------------------------------------------------------------------------
const currentSession = ref<AutomationSession | null>(null)
const loading = ref(false)
const ws = ref<WebSocket | null>(null)
const wsConnected = ref(false)
const wsSessionId = ref<string | null>(null)
/** 处置进展时间线(WS automation.progress 累积) */
const progressEvents = ref<ProgressEventItem[]>([])
/** 当前需要员工二次确认的高危动作(WS automation.action_required 设置) */
const pendingAction = ref<AutomationAction | null>(null)
// ------------------------------------------------------------------------
// 计算属性
// ------------------------------------------------------------------------
const isResolved = computed(() => currentSession.value?.status === 'resolved')
const isHandoff = computed(() => currentSession.value?.status === 'handoff')
// ------------------------------------------------------------------------
// 数据加载
// ------------------------------------------------------------------------
async function fetchSession(sessionId: string): Promise<void> {
loading.value = true
try {
currentSession.value = await getAutomationSession(sessionId)
} finally {
loading.value = false
}
}
/** 员工确认/取消高危动作 */
async function confirmAction(actionId: string, payload: ConfirmPayload): Promise<void> {
await confirmAutomationAction(actionId, payload)
pendingAction.value = null
if (wsSessionId.value) await fetchSession(wsSessionId.value)
}
/** 标记已解决 / 静默关单反馈 */
async function resolveSession(sessionId: string, payload: ResolvePayload): Promise<void> {
await resolveAutomationSession(sessionId, payload)
if (wsSessionId.value) await fetchSession(wsSessionId.value)
}
// ------------------------------------------------------------------------
// WebSocket
// ------------------------------------------------------------------------
function connectWs(sessionId: string): void {
disconnectWs()
const url = buildWsUrl(sessionId)
const token = getH5Token()
const socket = new WebSocket(url, [`bearer.${token}`])
ws.value = socket
wsSessionId.value = sessionId
socket.onopen = () => {
wsConnected.value = true
}
socket.onmessage = (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data)
handleWsMessage(msg)
} catch (e) {
console.error('[automation H5 WS] 消息解析失败', e)
}
}
socket.onclose = () => {
wsConnected.value = false
}
socket.onerror = () => {
wsConnected.value = false
}
}
function disconnectWs(): void {
if (ws.value) {
ws.value.close()
ws.value = null
}
wsConnected.value = false
wsSessionId.value = null
}
function handleWsMessage(msg: { type: string; session_id?: string; data?: any }): void {
// 仅处理当前会话的事件
if (msg.session_id && wsSessionId.value && msg.session_id !== wsSessionId.value) {
return
}
switch (msg.type) {
case 'automation.progress':
// 进展更新:追加时间线并刷新详情
progressEvents.value.push({ type: msg.type, time: now(), data: msg.data })
if (wsSessionId.value) fetchSession(wsSessionId.value)
break
case 'automation.action_required':
// 需要员工二次确认的高危动作
pendingAction.value = (msg.data?.action as AutomationAction) || null
if (wsSessionId.value) fetchSession(wsSessionId.value)
break
case 'automation.resolved':
if (wsSessionId.value) fetchSession(wsSessionId.value)
showToast('问题已解决')
break
case 'automation.takeover':
// 被坐席接管:清空待确认动作
pendingAction.value = null
if (wsSessionId.value) fetchSession(wsSessionId.value)
break
case 'automation.error':
progressEvents.value.push({ type: msg.type, time: now(), data: msg.data })
showToast('自动化处置出现异常')
break
default:
break
}
}
function now(): string {
return new Date().toISOString()
}
return {
currentSession,
loading,
wsConnected,
progressEvents,
pendingAction,
isResolved,
isHandoff,
fetchSession,
confirmAction,
resolveSession,
connectWs,
disconnectWs,
}
})
+117 -26
View File
@@ -17,6 +17,7 @@ import {
getCurrentConversation,
sendMessage,
pollMessages,
getMessages,
shake,
getApprovalLinks,
getSoftwareDownloads,
@@ -211,7 +212,8 @@ export const useConversationStore = defineStore('conversation', () => {
/** 是否有活跃会话(会话未结单) */
const hasActiveConversation = computed(() => {
if (!currentConversation.value) return false
return currentConversation.value.status !== 'closed'
// resolved: 已结单(后端状态)
return currentConversation.value.status !== 'resolved'
})
/** 当前用户是否为被邀请的参与者(非原始员工) */
@@ -613,6 +615,56 @@ export const useConversationStore = defineStore('conversation', () => {
}
}
/**
* 获取消息列表(历史消息)
* 获取当前会话的完整消息历史,用于首次加载或翻页
* @param params 查询参数(limit 和 before
*/
async function fetchMessages(params?: { limit?: number; before?: string }): Promise<void> {
// 未登录或无活跃会话时不获取
if (!isLoggedIn.value || !hasActiveConversation.value) return
try {
const data = await getMessages(params)
if (data.items && data.items.length > 0) {
// 消息去重
const uniqueMessages = data.items.filter(msg => {
if (processedMessageIds.value.has(msg.message_id)) {
return false
}
trackProcessedMessageId(msg.message_id)
return true
})
if (uniqueMessages.length > 0) {
// 如果没有 before 参数(全量加载),直接替换消息列表
// 如果有 before 参数(翻页),追加到列表末尾
if (!params?.before) {
messages.value = uniqueMessages
} else {
messages.value.push(...uniqueMessages)
}
// 更新最后消息 ID
const lastMsg = uniqueMessages[uniqueMessages.length - 1]
if (lastMsg) {
lastMessageId.value = lastMsg.message_id
}
console.log('[Store] 获取到历史消息:', uniqueMessages.length, '条')
// 保存到缓存
const convId = currentConversation.value?.conversation_id
if (convId) {
saveMessagesToCache(convId, messages.value)
}
}
}
} catch (error) {
console.error('[Store] 获取历史消息失败:', error)
}
}
/**
* 启动消息轮询
* 使用 setInterval 每 3 秒轮询一次新消息
@@ -644,10 +696,11 @@ export const useConversationStore = defineStore('conversation', () => {
* 招手/敲桌子 — 呼叫 IT 坐席
* 调用后端招手接口,返回趣味话术
* 将话术以系统消息形式插入对话列表
* @returns 分配结果: assigned(已分配坐席) / queued(排队中) / assign_failed(分配失败)
*/
async function shakeAgent(): Promise<void> {
async function shakeAgent(): Promise<string> {
// 防止重复点击
if (shaking.value) return
if (shaking.value) return 'pending'
shaking.value = true
try {
@@ -670,12 +723,28 @@ export const useConversationStore = defineStore('conversation', () => {
}
messages.value.push(systemMsg)
// 如果已分配坐席,更新话术内容
if (data.assign_result === 'assigned' && data.assigned_agent_id) {
systemMsg.content = `${data.funny_phrase}\n\n🎉 坐席已为您服务,请稍候...`
// 更新会话状态
if (currentConversation.value) {
currentConversation.value.status = 'serving'
}
} else if (data.assign_result === 'queued') {
// 进入排队
systemMsg.content = `${data.funny_phrase}\n\n⏳ 当前无空闲坐席,您已进入排队,请耐心等待...`
}
// 如果招手后坐席已接入(status === 'serving'),刷新会话信息
if (data.conversation?.status === 'serving') {
await fetchCurrentConversation()
}
// 返回分配结果
return data.assign_result || 'queued'
} catch (error) {
console.error('[Store] 招手失败:', error)
return 'error'
} finally {
shaking.value = false
}
@@ -774,11 +843,11 @@ export const useConversationStore = defineStore('conversation', () => {
// (后端 /h5/conversations/current 理论上应返回刚加入的会话)
}
// 清空消息列表,重新加载
// 清空消息列表,重新加载历史消息
messages.value = []
lastMessageId.value = ''
// 立即拉取一次消息,避免等3秒轮询
await pollNewMessages()
// 获取完整的历史消息
await fetchMessages()
console.log('[Store] 已切换到邀请会话:', conversationId)
} catch (error) {
console.error('[Store] 切换会话失败:', error)
@@ -896,8 +965,9 @@ export const useConversationStore = defineStore('conversation', () => {
* 初始化应用
* 1. 获取用户信息
* 2. 获取当前会话
* 3. 加载审批链接和软件下载
* 4. 启动消息轮询
* 3. 获取消息历史(新增 fetchMessages
* 4. 加载审批链接和软件下载
* 5. 启动消息轮询
*/
async function initialize(): Promise<void> {
if (initialized.value) return
@@ -905,7 +975,7 @@ export const useConversationStore = defineStore('conversation', () => {
try {
console.log('[Store] 开始初始化应用...')
// ===== 步骤1:并行加载用户信息和会话(不等待消息) =====
// ===== 步骤1:并行加载用户信息和会话 =====
await Promise.all([
fetchUserInfo(),
fetchCurrentConversation(),
@@ -918,32 +988,52 @@ export const useConversationStore = defineStore('conversation', () => {
if (cached && cached.length > 0) {
console.log(`[Store] 加载缓存消息 ${cached.length}`)
messages.value = cached
// 从缓存更新最后消息ID
const lastCached = cached[cached.length - 1]
if (lastCached) {
lastMessageId.value = lastCached.message_id
}
}
}
// ===== 步骤3后台加载后端消息(不阻塞UI =====
// 使用 Promise.resolve().then() 让它在下一次事件循环执行,不阻塞主线程
// ===== 步骤3获取历史消息(新增,使用 getMessages API =====
// 后台加载,不阻塞 UI
Promise.resolve().then(async () => {
try {
const freshMessages = await pollMessages()
if (freshMessages.length > 0) {
// 合并缓存和最新消息
if (convId) {
messages.value = mergeMessages(messages.value, freshMessages)
const data = await getMessages({ limit: 50 })
if (data.items && data.items.length > 0) {
// 消息去重
const uniqueMessages = data.items.filter(msg => {
if (processedMessageIds.value.has(msg.message_id)) {
return false
}
trackProcessedMessageId(msg.message_id)
return true
})
if (uniqueMessages.length > 0) {
// 合并缓存和历史消息
if (convId && messages.value.length > 0) {
messages.value = mergeMessages(messages.value, uniqueMessages)
} else {
messages.value = uniqueMessages
}
// 更新最后消息ID
const lastMsg = uniqueMessages[uniqueMessages.length - 1]
if (lastMsg) {
lastMessageId.value = lastMsg.message_id
}
// 保存到缓存
saveMessagesToCache(convId, messages.value)
} else {
messages.value = freshMessages
if (convId) {
saveMessagesToCache(convId, messages.value)
}
console.log(`[Store] 历史消息已加载,共 ${messages.value.length}`)
}
// 更新最后消息ID
const lastMsg = freshMessages[freshMessages.length - 1]
if (lastMsg) {
lastMessageId.value = lastMsg.message_id
}
console.log(`[Store] 后端消息已合并,共 ${messages.value.length}`)
}
} catch (e) {
console.warn('[Store] 加载后端消息失败:', e)
console.warn('[Store] 加载历史消息失败:', e)
}
})
@@ -1016,6 +1106,7 @@ export const useConversationStore = defineStore('conversation', () => {
handleOAuthCallback,
fetchUserInfo,
fetchCurrentConversation,
fetchMessages,
sendNewMessage,
pollNewMessages,
startPolling,
+3 -4
View File
@@ -22,10 +22,9 @@ import {
import { registerAuthExpiredHandler } from '@/utils/authCallback'
// --------------------------------------------------------------------------
// localStorage Key 常量
// localStorage Key 常量C8:移除 portal_token
// --------------------------------------------------------------------------
const TOKEN_KEY = 'h5_token'
const PORTAL_TOKEN_KEY = 'portal_token'
const EMPLOYEE_ID_KEY = 'employee_id'
const EMPLOYEE_NAME_KEY = 'employee_name'
/** OAuth2 重定向计数器 key(防止无限重定向循环) */
@@ -42,8 +41,8 @@ export const useEmployeeStore = defineStore('employee', () => {
// 响应式状态
// ==========================================================================
/** 访问令牌(Bearer Token)— 优先从 h5_token 读取,降级读取 portal_token */
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || localStorage.getItem(PORTAL_TOKEN_KEY) || '')
/** 访问令牌(Bearer Token)— 从 h5_token 读取C8:移除 portal_token */
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || '')
// 页面刷新时:如果 token 存在且有效,重置重定向计数,避免残留计数导致误报"登录状态异常"
if (token.value && !isTokenExpired(token.value)) {
@@ -0,0 +1,287 @@
<!-- =============================================================================
// 企微IT智能服务台 — 阶段5 自动化进度页(H5 员工端)
// =============================================================================
// 说明:员工查看自动化会话的实时处置进展;当收到 automation.action_required
// 事件时弹出高危动作二次确认;会话 resolved 后展示「已解决」反馈。
// 约定:Vant4 组件 + 专用 WebSocketstore 内管理)。
// ============================================================================= -->
<template>
<div class="auto-progress">
<van-nav-bar :title="sessionTitle" left-arrow @click-left="onBack" />
<div class="content">
<van-loading v-if="store.loading" class="page-loading" type="spinner" color="#07c160" />
<template v-else-if="store.currentSession">
<!-- 状态概览 -->
<van-cell-group inset class="block">
<van-cell title="状态" :value="statusLabel" />
<van-cell title="场景" :value="session.scenario_key || '-'" />
<van-cell title="置信度" :value="confidenceText" />
<van-cell v-if="session.auto_close_at" title="自动关单" :value="autoCloseText" />
</van-cell-group>
<!-- 处置进展时间线 -->
<div class="section-title">处置进展</div>
<van-steps direction="vertical" :active="store.progressEvents.length">
<van-step
v-for="(ev, i) in store.progressEvents"
:key="i"
:title="eventTitle(ev)"
>
{{ formatTime(ev.time) }}
</van-step>
<van-step v-if="!store.progressEvents.length" title="正在初始化自动化处置…" />
</van-steps>
<!-- 当前动作列表 -->
<div class="section-title">执行动作</div>
<van-cell-group inset>
<van-cell
v-for="act in session.actions"
:key="act.id"
:title="act.title"
:label="act.description"
:value="actionStatusLabel(act)"
>
<template #icon>
<van-tag :type="riskTagType(act)" class="act-tag">{{ riskLabel(act) }}</van-tag>
</template>
</van-cell>
<van-cell v-if="!session.actions.length" title="暂无动作" />
</van-cell-group>
<!-- 高危动作二次确认弹窗 -->
<ActionConfirmDialog
:visible="!!store.pendingAction"
:action="store.pendingAction"
@confirm="onConfirm"
@cancel="onCancel"
/>
<!-- 已解决反馈 / 静默关单提示 -->
<ResolveFeedback
:visible="store.isResolved && !feedbackDismissed"
:session="store.currentSession"
@feedback="onFeedback"
@update:visible="(v: boolean) => { if (!v) feedbackDismissed = true }"
/>
</template>
<van-empty v-else description="会话不存在或已结束" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { useAutomationStore } from '@/stores/automation'
import ActionConfirmDialog from '@/components/ActionConfirmDialog.vue'
import ResolveFeedback from '@/components/ResolveFeedback.vue'
import type { AutomationAction, AutomationSession } from '@/api/automation'
const route = useRoute()
const router = useRouter()
const store = useAutomationStore()
const sessionId = computed(() => (route.params.id as string) || '')
const session = computed(() => store.currentSession as AutomationSession)
// 已解决反馈弹窗是否已 dismiss(避免 resolved 状态下反复弹出)
const feedbackDismissed = ref(false)
watch(
() => sessionId.value,
() => {
feedbackDismissed.value = false
},
)
const sessionTitle = computed(() => session.value?.title || '自动化处置')
const confidenceText = computed(() => `${Math.round((session.value?.confidence || 0) * 100)}%`)
const autoCloseText = computed(() => (session.value?.auto_close_at ? formatTime(session.value.auto_close_at) : '-'))
// --------------------------------------------------------------------------
// 生命周期:挂载拉详情 + 连接 WS,卸载断开
// --------------------------------------------------------------------------
onMounted(async () => {
if (!sessionId.value) {
showToast('缺少会话参数')
return
}
await store.fetchSession(sessionId.value)
store.connectWs(sessionId.value)
})
onUnmounted(() => store.disconnectWs())
// --------------------------------------------------------------------------
// 事件:确认 / 取消高危动作、已解决反馈
// --------------------------------------------------------------------------
async function onConfirm(payload: { confirmed: boolean; note?: string }): Promise<void> {
const action = store.pendingAction
if (!action) return
try {
await store.confirmAction(action.id, payload)
showToast(payload.confirmed ? '已确认执行' : '已取消执行')
} catch {
showToast('操作失败,请重试')
}
}
async function onCancel(): Promise<void> {
// 员工取消:以 confirmed=false 提交(不执行该高危动作)
const action = store.pendingAction
if (!action) return
try {
await store.confirmAction(action.id, { confirmed: false })
showToast('已取消执行')
} catch {
showToast('操作失败,请重试')
}
}
async function onFeedback(payload: { satisfied: boolean; note?: string }): Promise<void> {
if (!sessionId.value) return
try {
await store.resolveSession(sessionId.value, payload)
feedbackDismissed.value = true
showToast('感谢反馈')
} catch {
showToast('提交失败,请重试')
}
}
function onBack(): void {
router.back()
}
// --------------------------------------------------------------------------
// 展示辅助
// --------------------------------------------------------------------------
const statusLabel = computed(() => statusText(session.value?.status || ''))
function statusText(s: string): string {
switch (s) {
case 'created':
return '已创建'
case 'running':
return '执行中'
case 'paused':
return '已暂停'
case 'resolved':
return '已解决'
case 'handoff':
return '已转人工'
case 'error':
return '异常'
case 'closed':
return '已关闭'
default:
return s || '未知'
}
}
function riskLabel(a: AutomationAction): string {
switch (a.risk_level) {
case 'read':
return '只读'
case 'low':
return '低风险'
case 'write':
return '写操作'
case 'high':
return '高危'
default:
return a.risk_level
}
}
function riskTagType(a: AutomationAction): 'primary' | 'success' | 'warning' | 'danger' {
switch (a.risk_level) {
case 'read':
return 'primary'
case 'low':
return 'success'
case 'write':
return 'warning'
case 'high':
return 'danger'
default:
return 'primary'
}
}
function actionStatusLabel(a: AutomationAction): string {
switch (a.status) {
case 'pending':
case 'awaiting_approval':
return '待确认'
case 'approved':
return '已通过'
case 'rejected':
return '已取消'
case 'running':
return '执行中'
case 'done':
case 'executed':
return '已完成'
case 'failed':
return '失败'
case 'skipped':
return '已跳过'
default:
return a.status
}
}
function eventTitle(ev: { type: string }): string {
switch (ev.type) {
case 'automation.progress':
return '处置进展更新'
case 'automation.action_required':
return '需要您确认高危操作'
case 'automation.resolved':
return '问题已解决'
case 'automation.takeover':
return '已转人工坐席'
case 'automation.error':
return '处置异常'
default:
return ev.type
}
}
function formatTime(t: string): string {
try {
return new Date(t).toLocaleString('zh-CN', { hour12: false })
} catch {
return t
}
}
</script>
<style scoped>
.auto-progress {
min-height: 100vh;
background: var(--bg-secondary, #f7f8fa);
}
.content {
padding: 12px;
}
.page-loading {
display: flex;
justify-content: center;
padding: 40px 0;
}
.block {
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #323233);
margin: 16px 4px 8px;
}
.act-tag {
margin-right: 8px;
}
</style>
+115 -1
View File
@@ -52,8 +52,42 @@
通过后端 Mock 登录接口获取真实 Token<br />
正式上线后将使用企微 OAuth2 静默授权
</p>
<p class="forgot-password">
<a href="javascript:;" @click="handleForgotPassword">忘记密码</a>
</p>
</div>
</div>
<!-- 忘记密码弹窗通过企微扫码重置 -->
<van-dialog
v-model:show="showForgotPasswordDialog"
title="忘记密码"
show-cancel-button
confirm-button-text="确认重置"
:before-hide="resetForgotPasswordForm"
@confirm="handleResetPassword"
>
<div class="forgot-password-content">
<p class="forgot-password-tip">请用企业微信扫码验证身份</p>
<div class="wecom-qr-placeholder">
<span class="qr-icon">📱</span>
<span class="qr-text">企微二维码</span>
</div>
<van-field
v-model="forgotPasswordForm.newPassword"
type="password"
label="新密码"
placeholder="请输入新密码(6-128位)"
/>
<van-field
v-model="forgotPasswordForm.confirmPassword"
type="password"
label="确认密码"
placeholder="请再次输入新密码"
/>
</div>
</van-dialog>
</div>
</template>
@@ -63,7 +97,7 @@
* 测试阶段绕过企微 OAuth2,通过后端 mock-login 获取真实 Bearer Token
*/
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useEmployeeStore } from '@/stores/employee'
import { showToast } from 'vant'
@@ -80,6 +114,41 @@ const employeeName = ref<string>('')
/** 是否正在登录 */
const loading = ref<boolean>(false)
// ==========================================================================
// 忘记密码功能
// ==========================================================================
const showForgotPasswordDialog = ref<boolean>(false)
const forgotPasswordForm = reactive({
newPassword: '',
confirmPassword: '',
})
/** 打开忘记密码对话框 */
function handleForgotPassword(): void {
showForgotPasswordDialog.value = true
}
/** 重置忘记密码表单 */
function resetForgotPasswordForm(): void {
forgotPasswordForm.newPassword = ''
forgotPasswordForm.confirmPassword = ''
}
/** 处理密码重置(占位,实际需要企微扫码验证) */
function handleResetPassword(): void {
if (!forgotPasswordForm.newPassword || forgotPasswordForm.newPassword.length < 6) {
showToast('密码长度不能少于6位')
return
}
if (forgotPasswordForm.newPassword !== forgotPasswordForm.confirmPassword) {
showToast('两次输入的密码不一致')
return
}
// TODO: 实际实现需要通过企微扫码验证后调用后端API重置密码
showToast('该功能需要企微扫码验证支持')
showForgotPasswordDialog.value = false
}
/**
* 处理登录
* 调用后端 mock-login 接口获取真实 Bearer Token
@@ -179,4 +248,49 @@ async function handleLogin(): Promise<void> {
line-height: 1.6;
margin: 8px 0 0 0;
}
/* 忘记密码链接 */
.forgot-password {
text-align: center;
margin-top: 12px;
}
.forgot-password a {
font-size: 13px;
color: var(--accent, #07C160);
text-decoration: none;
}
/* 忘记密码弹窗内容 */
.forgot-password-content {
padding: 16px;
}
.forgot-password-tip {
text-align: center;
color: var(--text-tertiary);
font-size: 14px;
margin: 0 0 16px 0;
}
.wecom-qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 16px;
}
.qr-icon {
font-size: 48px;
margin-bottom: 8px;
}
.qr-text {
font-size: 14px;
color: var(--text-tertiary);
}
</style>