feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3

## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS

## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS

## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)

## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code

## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)

## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过

## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务

## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记已实施
- 新增架构图/时序图/类图(mermaid)

## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
This commit is contained in:
Simon
2026-07-13 02:17:03 +08:00
parent bea288e414
commit 449c6d4875
176 changed files with 46637 additions and 4805 deletions
+123
View File
@@ -0,0 +1,123 @@
// =============================================================================
// 企微IT智能服务台 — H5 端关闭机制 API
// =============================================================================
// 说明:封装五种关闭场景的 API 调用
// POST /api/h5/conversations/current/resolve — 员工确认AI已解决
// POST /api/h5/conversations/current/close — 员工主动关闭
// POST /api/h5/conversations/current/resolve/confirm — 员工确认/拒绝坐席结单
// POST /api/h5/conversations/current/reopen — 24h内重开
// =============================================================================
import apiClient from '@/api'
// -------------------------------------------------------------------------
// 类型定义
// -------------------------------------------------------------------------
/** 会话状态(前端统一使用) */
export type ConversationStatus = 'ai_handling' | 'waiting' | 'serving' | 'pending_close' | 'resolved'
/** 关闭方 */
export type ResolvedBy = 'employee' | 'agent' | 'system'
/** 关闭方式 */
export type ResolvedMethod = 'ai_self' | 'agent_confirm' | 'employee_initiative' | 'system_timeout' | 'dissatisfied'
/** 已关闭会话信息 */
export interface ResolvedConversation {
/** 会话ID */
id: string
/** 会话状态 */
status: ConversationStatus
/** 关闭方 */
resolved_by?: ResolvedBy
/** 关闭方式 */
resolved_method?: ResolvedMethod
/** 解决摘要 */
resolve_summary?: string
/** 关联原会话ID(重开时) */
reference_conversation_id?: string
/** 附加消息 */
message?: string
/** 是否重开 */
is_reopen?: boolean
}
// -------------------------------------------------------------------------
// API 方法
// -------------------------------------------------------------------------
/**
* 员工确认AI已解决问题
* POST /api/h5/conversations/current/resolve
*
* 触发场景:
* - 对话流中"已解决"确认卡片按钮
* - AI检测到关闭关键词后推送的确认卡片
*
* 状态转换:ai_handling → resolved
*
* @param resolveSummary 解决摘要(可选)
* @returns 已关闭的会话信息
*/
export function selfResolve(resolveSummary?: string): Promise<ResolvedConversation> {
return apiClient.post('/h5/conversations/current/resolve', {
resolve_summary: resolveSummary || null,
})
}
/**
* 员工主动关闭会话
* POST /api/h5/conversations/current/close
*
* 适用场景:问题自行解决 / 不想继续等待 / 已通过其他渠道解决
*
* 状态转换:任意活跃状态 → resolved
*
* @param closeReason 关闭原因(可选)
* @returns 已关闭的会话信息
*/
export function employeeClose(closeReason?: string): Promise<ResolvedConversation> {
return apiClient.post('/h5/conversations/current/close', {
close_reason: closeReason || null,
})
}
/**
* 员工确认或拒绝坐席的结单请求
* POST /api/h5/conversations/current/resolve/confirm
*
* 坐席发起结单后 → 会话进入 pending_close → 员工确认或拒绝
* - confirm: pending_close → resolved(坐席结单+员工确认)
* - reject: pending_close → serving(恢复服务)
* - 5分钟内不响应:系统自动关闭
*
* @param action confirm=确认 / reject=拒绝
* @param reason 拒绝原因(拒绝时可选)
* @returns 更新后的会话信息
*/
export function resolveConfirm(
action: 'confirm' | 'reject',
reason?: string,
): Promise<ResolvedConversation> {
return apiClient.post('/h5/conversations/current/resolve/confirm', {
action,
reason: reason || null,
})
}
/**
* 24小时内重开已关闭的会话
* POST /api/h5/conversations/current/reopen
*
* 创建新会话并关联原会话ID,用于上下文继承。
* 新会话状态为 ai_handling。
*
* @param originalConversationId 原会话ID
* @returns 新创建的会话信息(含 is_reopen=true
*/
export function reopenConversation(originalConversationId: string): Promise<ResolvedConversation> {
return apiClient.post('/h5/conversations/current/reopen', {
original_conversation_id: originalConversationId,
})
}
+2 -2
View File
@@ -61,8 +61,8 @@ export interface ConversationInfo {
/** 消息类型 */
export type MessageType = 'employee' | 'agent' | 'ai' | 'system'
/** 消息内容类型(text/image/file/voice/video/location/approval_card/byod_card/contact_card 等) */
export type MsgContentType = 'text' | 'image' | 'file' | 'voice' | 'video' | 'location' | 'approval_card' | 'byod_card' | 'contact_card'
/** 消息内容类型(text/image/file/voice/video/location/approval_card/byod_card/contact_card/ai_structured 等) */
export type MsgContentType = 'text' | 'image' | 'file' | 'voice' | 'video' | 'location' | 'approval_card' | 'byod_card' | 'contact_card' | 'ai_structured'
/** 单条消息 */
export interface Message {
+81
View File
@@ -0,0 +1,81 @@
// =============================================================================
// 企微IT智能服务台 — H5 端 IT 健康 API
// =============================================================================
// 说明:封装 IT 健康信息查询接口
// GET /api/h5/it-health — 获取当前员工终端的 IT 健康信息
// 数据来源:联软(设备信息) + 火绒(安全状态) + 资产服务(资产编号)
// 降级策略:联软/火绒未配置时返回 Mock 数据
// =============================================================================
import apiClient from '@/api'
// -------------------------------------------------------------------------
// 类型定义
// -------------------------------------------------------------------------
/** 安全检查状态 */
export type SecurityStatus = 'pass' | 'warning' | 'danger' | 'pending'
/** 磁盘分区信息 */
export interface DiskInfo {
/** 卷标(如 "硬盘C盘" */
label: string
/** 使用率 % */
usage: number
/** 已用容量 */
used: string
/** 总容量 */
total: string
}
/** 当前设备信息 */
export interface CurrentDevice {
device_name: string
is_online: boolean
asset_tag: string
activate_date: string
ip_address: string
public_ip: string
location: string
os: string
mac: string
uptime: string
cpu: { usage: number; model: string }
memory: { usage: number; total: string }
disks: DiskInfo[]
security_checks: { status: SecurityStatus }[]
compliance_checks: { status: SecurityStatus }[]
health_score: number
}
/** 其他设备信息 */
export interface OtherDevice {
device_type: string
device_name: string
last_login_time: string
last_login_location: string
}
/** IT 健康信息响应 */
export interface ITHealthResponse {
current_device: CurrentDevice
other_devices: OtherDevice[]
/** 数据来源:real(真实数据)或 mock(降级数据) */
data_source: 'real' | 'mock'
generated_at: string
}
// -------------------------------------------------------------------------
// API 方法
// -------------------------------------------------------------------------
/**
* 获取当前员工终端的 IT 健康信息。
* 后端会自动根据 employee_id 查询联软/火绒/资产服务。
* 如果集成未配置,返回 Mock 降级数据。
*
* @returns IT 健康信息(设备信息 + 安全状态 + 合规检查)
*/
export function getITHealth(): Promise<ITHealthResponse> {
return apiClient.get('/h5/it-health')
}
-150
View File
@@ -1,150 +0,0 @@
// =============================================================================
// 企微IT智能服务台 — H5端会议室预定 API
// =============================================================================
// 说明:封装会议室预定相关的 API 调用,供 H5 端使用
// - 复用 H5 端的 apiClientbaseURL=/api,自动添加 Bearer Token
// - 后端代理路径:/itportal/meetingroom/...
// - 响应拦截器已自动解包 res.data,这里直接返回业务数据
// =============================================================================
import apiClient from '@/api'
// ==========================================================================
// 类型定义
// ==========================================================================
/** 会议室实时状态 */
export type RoomStatus = 'free' | 'busy' | 'starting_soon'
/** 会议室信息 */
export interface Meetingroom {
meetingroom_id: number
name: string
capacity: number
location: string
devices: number[]
need_approval: number
}
/** 预定记录 */
export interface Booking {
booking_id: string
subject: string
booker: string
booker_name?: string
start_time: string
end_time: string
status: number
}
/** 会议室实时状态响应 */
export interface RoomStatusResponse {
status: RoomStatus
current_meeting: Booking | null
next_meeting: Booking | null
minutes_to_next: number | null
bookings: Booking[]
}
/** 预定请求参数 */
export interface BookRequest {
meetingroom_id: number
subject: string
start_time: string
end_time: string
booker: string
attendees?: string[]
}
/** 预定响应 */
export interface BookResponse {
booking_id: string
}
/** 预定详情 */
export interface BookingDetail {
booking_id: string
subject: string
booker: string
booker_name?: string
attendees?: string[]
start_time: string
end_time: string
}
// ==========================================================================
// API 方法
// ==========================================================================
/**
* 获取会议室列表
* @param city 城市(可选)
* @param building 楼宇(可选)
* @param floor 楼层(可选)
*/
export async function getMeetingroomList(
city?: string,
building?: string,
floor?: string,
): Promise<{ rooms: Meetingroom[] }> {
const params: Record<string, string> = {}
if (city) params.city = city
if (building) params.building = building
if (floor) params.floor = floor
return await apiClient.get('/itportal/meetingroom/list', { params })
}
/**
* 获取指定日期的预定状态
* @param meetingroomId 会议室ID
* @param date 日期 YYYY-MM-DD(可选,默认今天)
*/
export async function getBookingInfo(
meetingroomId: number,
date?: string,
): Promise<{ bookings: Booking[] }> {
const params: Record<string, string> = {}
if (date) params.date = date
return await apiClient.get(`/itportal/meetingroom/${meetingroomId}/booking`, { params })
}
/**
* 获取当前实时状态
* @param meetingroomId 会议室ID
*/
export async function getRoomStatus(meetingroomId: number): Promise<RoomStatusResponse> {
return await apiClient.get(`/itportal/meetingroom/${meetingroomId}/status`)
}
/**
* 预定会议室
* @param data 预定请求参数
*/
export async function bookMeetingroom(data: BookRequest): Promise<BookResponse> {
return await apiClient.post('/itportal/meetingroom/book', data)
}
/**
* 取消预定
* @param bookingId 预定ID
* @param meetingroomId 会议室ID
*/
export async function cancelBooking(bookingId: string, meetingroomId: number): Promise<void> {
await apiClient.delete(`/itportal/meetingroom/booking/${bookingId}`, {
params: { meetingroom_id: meetingroomId },
})
}
/**
* 获取预定详情
* @param bookingId 预定ID
* @param meetingroomId 会议室ID
*/
export async function getBookingDetail(
bookingId: string,
meetingroomId: number,
): Promise<BookingDetail> {
return await apiClient.get(`/itportal/meetingroom/booking/${bookingId}/detail`, {
params: { meetingroom_id: meetingroomId },
})
}
+109
View File
@@ -0,0 +1,109 @@
// =============================================================================
// 企微IT智能服务台 — H5 端排队状态 API
// =============================================================================
// 说明:封装排队综合查询接口
// GET /api/h5/queue/status — 员工端综合排队状态
// =============================================================================
import apiClient from '@/api'
// -------------------------------------------------------------------------
// 类型定义
// -------------------------------------------------------------------------
/** 排队段位 */
export type QueueSegment = 'vip' | 'completed' | 'incomplete' | 'none'
/** 排队信息 */
export interface QueueInfo {
/** 排队位置(从1开始,0表示不在队列中) */
position: number
/** 所在段位 */
segment: QueueSegment
/** 段位中文名称 */
segment_label: string
/** 前面排队人数 */
ahead_count: number
/** 预计等待秒数 */
estimated_wait_sec: number
/** 预计等待时间文本(如"约5分钟" */
estimated_wait_text: string
/** 插队优先级(0-2,每答3题+1) */
queue_priority: number
}
/** 平台统计 */
export interface PlatformStats {
/** 活跃会话总数(ai_handling + queued + serving */
total_active: number
/** 排队中人数 */
queued: number
/** 服务中人数 */
serving: number
/** AI处理中人数 */
ai_handling: number
}
/** 积分信息 */
export interface PointsInfo {
/** 当前积分 */
points: number
/** 等级名称 */
level_name: string
/** 等级序号(1-5 */
level_index: number
/** 距离下一级还需要的积分 */
to_next_level: number
/** 下一级名称 */
next_level_name: string | null
}
/** 答题进度 */
export interface QuizProgress {
/** 本会话已答题数 */
answered_in_session: number
/** 当前插队优先级 */
queue_priority: number
/** 最大插队优先级(固定2 */
max_priority: number
/** 距离下次插队还需答题数(3-answered%3 */
remaining_for_next_jump: number
/** 是否还能继续插队 */
can_jump_more: boolean
}
/** 综合排队状态响应 */
export interface QueueStatusResponse {
/** 会话状态(ai_handling/queued/serving/pending_close/None */
conversation_status: string | null
/** 排队信息 */
queue: QueueInfo
/** 平台统计 */
platform: PlatformStats
/** 积分信息 */
points: PointsInfo
/** 答题进度 */
quiz: QuizProgress
/** 是否信息已锁定 */
info_locked: boolean
}
// -------------------------------------------------------------------------
// API 方法
// -------------------------------------------------------------------------
/**
* 获取综合排队状态
* GET /api/h5/queue/status?employee_id=xxx
*
* 包含排队位置、段位、平台统计、答题进度、积分信息。
* 供 QueueWaiting.vue 初始化和轮询刷新使用。
*
* @param employeeId 员工企微 UserID
* @returns 综合排队状态数据
*/
export function getQueueStatus(employeeId: string): Promise<QueueStatusResponse> {
return apiClient.get('/h5/queue/status', {
params: { employee_id: employeeId },
})
}
+187
View File
@@ -0,0 +1,187 @@
// =============================================================================
// 企微IT智能服务台 — H5 端答题系统 API
// =============================================================================
// 说明:排队等待期间的答题+积分接口
// GET /api/h5/quiz/question — 获取下一道题(双模式自动选择)
// POST /api/h5/quiz/answer — 提交答案(正误+积分+插队+下一题)
// GET /api/h5/quiz/history — 答题历史记录和积分
// =============================================================================
import apiClient from '@/api'
// -------------------------------------------------------------------------
// 类型定义
// -------------------------------------------------------------------------
/** 题目类型 */
export type QuestionType = 'knowledge' | 'diagnostic'
/** 难度等级 */
export type Difficulty = 'easy' | 'medium' | 'hard'
/** 题目数据 */
export interface QuizQuestion {
/** 题目ID */
question_id: string
/** 题目类型(knowledge=IT知识题 / diagnostic=诊断题) */
type: QuestionType
/** 题目文本 */
question: string
/** 选项列表 */
options: string[]
/** 难度 */
difficulty: Difficulty
/** 分类标签 */
category?: string
/** 关联问题分类(诊断题专用) */
problem_category?: string
/** 正确答案索引(仅答题结果返回) */
correct_index?: number
/** 答案解析 */
explanation?: string
}
/** 提交答案请求 */
export interface AnswerRequest {
employee_id: string
question_id: string
selected_index: number
conversation_id?: string | null
}
/** 提交答案响应 */
export interface AnswerResult {
/** 是否答对 */
is_correct: boolean
/** 正确答案索引 */
correct_index: number
/** 答案解析 */
explanation: string
/** 答题后积分 */
points: number
/** 积分变化(+10或0 */
points_delta: number
/** 等级名称 */
level_name: string
/** 等级序号 */
level_index: number
/** 本会话已答题数 */
answered_in_session: number
/** 当前插队优先级 */
queue_priority: number
/** 插队优先级是否变化 */
queue_priority_changed: boolean
/** 新排队位置(插队后) */
new_position?: number
/** 距离下次插队还需答题数 */
remaining_for_next_jump: number
/** 是否还能继续插队 */
can_jump_more: boolean
/** 下一道题(可能为 null=无更多题目) */
next_question: QuizQuestion | null
}
/** 答题历史条目 */
export interface QuizHistoryItem {
/** 记录ID */
answer_id: string
/** 题目ID */
question_id: string
/** 题目文本 */
question: string
/** 题目类型 */
type: QuestionType
/** 选项列表 */
options: string[]
/** 选中索引 */
selected_index: number
/** 正确索引 */
correct_index: number
/** 是否正确 */
is_correct: boolean
/** 答题时间 */
answered_at: string
}
/** 答题历史响应 */
export interface QuizHistoryResponse {
/** 当前页码 */
page: number
/** 每页数量 */
page_size: number
/** 总条数 */
total: number
/** 历史记录列表 */
items: QuizHistoryItem[]
/** 当前积分 */
points: number
/** 等级名称 */
level_name: string
/** 等级序号 */
level_index: number
}
// -------------------------------------------------------------------------
// API 方法
// -------------------------------------------------------------------------
/**
* 获取下一道题(双模式自动选择)
* GET /api/h5/quiz/question?employee_id=xxx&conversation_id=xxx
*
* 模式选择由后端自动完成:
* - 排队中且 info_locked=false → 诊断题(答案附加到会话上下文)
* - 排队中且 info_locked=true → IT知识题
* - 非排队 → IT知识题
*
* @param employeeId 员工ID
* @param conversationId 当前会话ID(可选,排队时传入可获取诊断题)
* @returns 题目数据
*/
export function getQuizQuestion(
employeeId: string,
conversationId?: string | null,
): Promise<QuizQuestion> {
return apiClient.get('/h5/quiz/question', {
params: {
employee_id: employeeId,
conversation_id: conversationId || undefined,
},
})
}
/**
* 提交答案
* POST /api/h5/quiz/answer
*
* 处理流程:判定正误 → 记录答题 → 更新积分 → 更新插队优先级 → 返回下一题
*
* @param data 答案请求体
* @returns 答题结果+下一题
*/
export function submitQuizAnswer(data: AnswerRequest): Promise<AnswerResult> {
return apiClient.post('/h5/quiz/answer', data)
}
/**
* 获取答题历史记录和积分
* GET /api/h5/quiz/history?employee_id=xxx&page=1&page_size=20
*
* @param employeeId 员工ID
* @param page 页码(默认1
* @param pageSize 每页数量(默认20
* @returns 答题历史+积分信息
*/
export function getQuizHistory(
employeeId: string,
page: number = 1,
pageSize: number = 20,
): Promise<QuizHistoryResponse> {
return apiClient.get('/h5/quiz/history', {
params: {
employee_id: employeeId,
page,
page_size: pageSize,
},
})
}
+108
View File
@@ -0,0 +1,108 @@
// =============================================================================
// 企微IT智能服务台 — H5 端分诊 API 封装
// =============================================================================
// 说明:封装 H5 端 5 个分诊交互 API 调用
// =============================================================================
import request from './index'
/** 分诊选项 */
export interface TriageOption {
label: string
probability?: number
}
/** 分诊步骤 */
export interface TriageStep {
question: string
options: TriageOption[]
}
/** 发起分诊请求参数 */
export interface TriageStartParams {
conversation_id: string
question: string
}
/** 发起分诊响应 */
export interface TriageStartResult {
triage_id: string
steps: TriageStep[]
total: number
confidence?: number
urgency: string
suggested_route?: string
status?: string
message?: string
}
/** 提交步骤响应 */
export interface TriageStepResult {
next_step: TriageStep | null
collected_context: string[]
}
/** 转人工响应 */
export interface TriageTransferResult {
conversation_id: string
status: string
}
/** 分诊完成响应 */
export interface TriageCompleteResult {
reply: string
confidence: number
}
/**
* 发起分诊
* POST /api/h5/triage/start
*/
export function startTriage(params: TriageStartParams): Promise<TriageStartResult> {
return request.post('/h5/triage/start', params)
}
/**
* 提交步骤选择
* POST /api/h5/triage/step
*/
export function submitTriageStep(
triage_id: string,
step_index: number,
selected_label: string,
): Promise<TriageStepResult> {
return request.post('/h5/triage/step', { triage_id, step_index, selected_label })
}
/**
* 跳过步骤
* POST /api/h5/triage/skip
*/
export function skipTriageStep(
triage_id: string,
step_index: number,
): Promise<{ next_step: TriageStep | null }> {
return request.post('/h5/triage/skip', { triage_id, step_index })
}
/**
* 转人工
* POST /api/h5/triage/transfer
*/
export function transferTriageToHuman(
triage_id: string,
context: string[],
): Promise<TriageTransferResult> {
return request.post('/h5/triage/transfer', { triage_id, context })
}
/**
* 分诊完成
* POST /api/h5/triage/complete
*/
export function completeTriage(
triage_id: string,
context: string[],
): Promise<TriageCompleteResult> {
return request.post('/h5/triage/complete', { triage_id, context })
}
+31 -13
View File
@@ -19,6 +19,22 @@ import apiClient from '@/api'
// 类型定义
// ---------------------------------------------------------------------------
/**
* agent_config 签名(wx.agentConfig 用)
*
* 做什么:包含 wx.agentConfig() 所需的签名信息
* 与 jsapi 签名的区别:使用 agent_config_ticket 计算,不是 jsapi_ticket
* 适用场景:thirdPartyOpenPage 等需要"应用身份"的接口
*/
export interface AgentConfigSignature {
/** 时间戳(与 jsapi 签名共用) */
timestamp: string | number
/** 随机字符串(独立生成,与 jsapi 签名的 nonce_str 不同) */
nonce_str: string
/** agent_config 签名(用 agent_config_ticket 计算) */
signature: string
}
/**
* JS-SDK 签名配置(后端返回格式)
*
@@ -40,6 +56,8 @@ export interface JsapiConfig {
nonce_str: string
/** 签名(后端根据 url + corp_id + timestamp + nonce_str 生成) */
signature: string
/** agent_config 签名(仅 with_agent_config=true 时返回) */
agent_config?: AgentConfigSignature
}
// ---------------------------------------------------------------------------
@@ -53,7 +71,7 @@ export interface JsapiConfig {
*
* 流程:
* 1. 获取当前页面 URL(去除 hash 部分)
* 2. 调用后端 GET /api/wecom/jsapi-config?url=xxx
* 2. 调用后端 GET /api/wecom/jsapi-config?url=xxx&with_agent_config=true
* 3. 后端用该 URL 生成签名,返回 corp_id/timestamp/nonce_str/signature
*
* 为什么需要 URL
@@ -61,27 +79,27 @@ export interface JsapiConfig {
* 如果 URL 不一致,wx.config 会失败(invalid signature)。
*
* @param url - 需要签名的页面 URL(通常是 window.location.href.split('#')[0]
* @returns 签名配置对象(corp_id, agent_id, timestamp, nonce_str, signature
* @param withAgentConfig - 是否同时获取 agent_config 签名(thirdPartyOpenPage 等需要
* @returns 签名配置对象(corp_id, agent_id, timestamp, nonce_str, signature, agent_config?
*
* @example
* // 仅 wx.config(如录音功能)
* const config = await getJsapiConfig(window.location.href.split('#')[0])
* window.wx.config({
* beta: true,
* appId: config.corp_id, // 注意:传 corp_id
* timestamp: config.timestamp,
* nonceStr: config.nonce_str, // 注意:下划线 → 驼峰映射
* signature: config.signature,
* jsApiList: ['startRecord', 'stopRecord', 'translateVoice'],
* })
*
* // wx.config + wx.agentConfig(如审批原生打开)
* const config = await getJsapiConfig(window.location.href.split('#')[0], true)
*/
export async function getJsapiConfig(url: string): Promise<JsapiConfig> {
export async function getJsapiConfig(
url: string,
withAgentConfig: boolean = false,
): Promise<JsapiConfig> {
// 调用后端接口,params 会被拼接到 URL 查询参数中
// 实际请求:GET /api/wecom/jsapi-config?url=https%3A%2F%2F...
// 实际请求:GET /api/wecom/jsapi-config?url=https%3A%2F%2F...&with_agent_config=true
//
// apiClient 拦截器已处理 { code: 0, data: {...} } 格式:
// - code === 0:拦截器返回 res.data(即签名配置对象)
// - code !== 0:拦截器自动 showToast 并 reject
return apiClient.get('/wecom/jsapi-config', {
params: { url },
params: { url, with_agent_config: withAgentConfig },
})
}
+233 -113
View File
@@ -3,12 +3,14 @@
=============================================================================
说明AI 分诊式回复卡片将复杂问题拆分为分步选择题//含概率推荐
卡片置顶悬浮展示支持专家模式切换
D4 硬约束
- 一次给几步由 AI 判复杂度自适应
- 概率展示为百分比不披露原始 confidence 给员工
- 专家模式默认关分步老手可开一把梭
- 卡片置顶/悬浮
对接后端通过 useTriage composable 管理状态调用后端 API
============================================================================= -->
<template>
@@ -18,7 +20,7 @@
<div class="triage-card__step-badge">
<span class="triage-card__step-icon">🤖</span>
<span class="triage-card__step-text">
AI 分诊 {{ currentStep }}/{{ totalSteps }}
AI 分诊 {{ currentStepNumber }}/{{ totalSteps }}
</span>
</div>
<div class="triage-card__header-right">
@@ -37,8 +39,16 @@
</div>
</div>
<!-- 超时提示 -->
<div v-if="isTimeout" class="triage-card__body">
<div class="triage-card__timeout">
<van-icon name="warning-o" size="20" color="#ee0a24" />
<span class="triage-card__timeout-text">{{ errorMessage || '分诊超时,已自动转人工' }}</span>
</div>
</div>
<!-- 题目区域 -->
<div v-show="!collapsed" class="triage-card__body">
<div v-else v-show="!collapsed" class="triage-card__body">
<!-- 问题文本 -->
<div class="triage-card__question">
Q: {{ currentQuestion }}
@@ -51,28 +61,28 @@
:key="idx"
class="triage-card__option"
:class="{
'triage-card__option--selected': option.selected,
'triage-card__option--recommended': option.recommended && !option.selected,
'triage-card__option--excluded': option.excluded,
'triage-card__option--selected': selectedOptionIndex === idx,
'triage-card__option--recommended': isRecommended(option.label) && selectedOptionIndex !== idx,
'triage-card__option--excluded': isExcluded(option.label),
}"
@click="selectOption(idx)"
>
<div class="triage-card__option-radio">
<span v-if="option.selected" class="triage-card__option-dot" />
<span v-if="selectedOptionIndex === idx" class="triage-card__option-dot" />
</div>
<span class="triage-card__option-label">{{ option.label }}</span>
<!-- 概率推荐标签仅百分比不披露原始confidence -->
<span
v-if="option.probability !== undefined"
class="triage-card__option-probability"
:class="{ 'triage-card__option-probability--high': option.probability >= 70 }"
:class="{ 'triage-card__option-probability--high': getProbabilityPercent(option.probability) >= 70 }"
>
{{ option.probability }}%
{{ getProbabilityPercent(option.probability) }}%
</span>
<!-- 推荐标记 -->
<span v-if="option.recommended" class="triage-card__option-recommend">推荐</span>
<span v-if="isRecommended(option.label)" class="triage-card__option-recommend">推荐</span>
<!-- 排除标记 -->
<span v-if="option.excluded" class="triage-card__option-excluded">已排除</span>
<span v-if="isExcluded(option.label)" class="triage-card__option-excluded">已排除</span>
</div>
</div>
@@ -91,97 +101,130 @@
</div>
<!-- 专家模式下一步展示 -->
<div v-if="expertMode && nextSteps.length > 0" class="triage-card__expert-steps">
<div v-if="expertMode && nextStepsPreview.length > 0" class="triage-card__expert-steps">
<div class="triage-card__expert-steps-title">📋 后续步骤预览</div>
<div
v-for="(step, idx) in nextSteps"
v-for="(step, idx) in nextStepsPreview"
:key="idx"
class="triage-card__expert-step-item"
>
<span class="triage-card__expert-step-num">{{ idx + 1 + currentStep }}</span>
<span class="triage-card__expert-step-num">{{ idx + 1 + currentStepNumber }}</span>
{{ step.question }}
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div v-show="!collapsed" class="triage-card__footer">
<div v-if="!isTimeout" v-show="!collapsed" class="triage-card__footer">
<van-button
round
type="default"
size="small"
:loading="loading"
@click="handleSkip"
>
跳过
</van-button>
<van-button
v-if="!isLastStep"
round
type="primary"
size="small"
:disabled="!hasSelection"
:disabled="selectedOptionIndex === -1"
:loading="loading"
@click="handleConfirm"
>
{{ isLastStep ? '完成' : '下一步' }}
下一步
</van-button>
<van-button
v-else
round
type="success"
size="small"
:disabled="selectedOptionIndex === -1"
:loading="loading"
@click="handleComplete"
>
完成
</van-button>
<van-button
round
type="warning"
size="small"
plain
@click="handleTransfer"
>
转人工
</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
/** 单个选项结构 */
interface TriageOption {
/** 选项标签 */
label: string
/** 是否被用户选中 */
selected: boolean
/** AI 推荐概率(百分比 0-100,不披露原始confidence */
probability?: number
/** 坐席推荐标记 */
recommended?: boolean
/** 坐席排除标记 */
excluded?: boolean
}
/** 单个分诊步骤 */
interface TriageStep {
/** 问题文本 */
question: string
/** 选项列表 */
options: TriageOption[]
/** 该步骤已收集的上下文 */
collectedContext: string[]
}
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import { useTriage } from '@/composables/useTriage'
import type { TriageStep, TriageOption } from '@/api/triage'
/** 组件属性 */
const props = defineProps<{
/** 是否可见 */
visible: boolean
/** 当前步骤序号(1-based */
step: number
/** 总步骤数 */
total: number
/** 所有步骤数据 */
steps: TriageStep[]
/** AI 置信度(内部使用,不展示给员工) */
confidence?: number
/** 会话ID(发起分诊时需要 */
conversationId?: string
/** 问题文本(发起分诊时需要) */
question?: string
}>()
/** 组件事件 */
const emit = defineEmits<{
/** 确认当前步骤 */
(e: 'confirm', stepIndex: number, selectedLabel: string): void
/** 跳过当前步骤 */
(e: 'skip', stepIndex: number): void
/** 专家模式切换 */
(e: 'expert-mode-change', enabled: boolean): void
/** 分诊完成(AI 回复) */
(e: 'complete', reply: string, confidence: number): void
/** 转人工 */
(e: 'transfer-human', context: string[]): void
/** 超时转人工 */
(e: 'timeout'): void
/** 分诊关闭 */
(e: 'close'): void
/** 专家模式切换 */
(e: 'expert-mode-change', enabled: boolean): void
}>()
// ===========================================================================
// 状态
// useTriage composable
// ===========================================================================
const {
triageId,
currentStepIndex,
triageSteps,
totalSteps,
collectedContext,
confidence,
urgency,
status,
errorMessage,
finalReply,
isTimeout,
excludedLabels,
recommendedLabel,
currentStep,
currentStepNumber,
isLastStep,
isTriaging,
isLoading,
startTriageFlow,
submitStep,
skipStep,
complete,
transferToHuman,
setExcludedOptions,
setRecommendedOption,
reset,
} = useTriage()
// ===========================================================================
// 本地状态
// ===========================================================================
/** 是否折叠卡片 */
@@ -190,52 +233,74 @@ const collapsed = ref(false)
/** 专家模式开关(默认关) */
const expertMode = ref(false)
/** 当前步骤序号(0-based */
const currentStepIndex = computed(() => Math.max(0, Math.min(props.step - 1, props.steps.length - 1)))
/** 当前选中的选项索引(-1=未选中 */
const selectedOptionIndex = ref<number>(-1)
/** 当前步骤数据 */
const currentStepData = computed(() => props.steps[currentStepIndex.value] ?? null)
/** 加载状态 */
const loading = ref<boolean>(false)
// ===========================================================================
// 计算属性
// ===========================================================================
/** 当前问题文本 */
const currentQuestion = computed(() => currentStepData.value?.question ?? '')
const currentQuestion = computed(() => currentStep.value?.question ?? '')
/** 当前选项列表 */
const currentOptions = computed(() => currentStepData.value?.options ?? [])
/** 已收集上下文(所有步骤的上下文汇总) */
const collectedContext = computed(() => {
const ctx: string[] = []
for (let i = 0; i <= currentStepIndex.value; i++) {
const step = props.steps[i]
if (step?.collectedContext) {
ctx.push(...step.collectedContext)
}
}
return ctx
})
/** 总步骤数 */
const totalSteps = computed(() => props.total || props.steps.length)
/** 当前步骤(1-based 展示用) */
const currentStep = computed(() => currentStepIndex.value + 1)
/** 是否有选中项 */
const hasSelection = computed(() => currentOptions.value.some(o => o.selected))
/** 是否为最后一步 */
const isLastStep = computed(() => currentStep.value >= totalSteps.value)
const currentOptions = computed<TriageOption[]>(() => currentStep.value?.options ?? [])
/** 后续步骤预览(专家模式) */
const nextSteps = computed(() => {
const nextStepsPreview = computed<TriageStep[]>(() => {
if (!expertMode.value || isLastStep.value) return []
return props.steps.slice(currentStepIndex.value + 1)
const startIdx = currentStepIndex.value + 1
return triageSteps.value.slice(startIdx)
})
// ===========================================================================
// 监听 visible + question 变化,自动发起分诊
// ===========================================================================
watch(
() => [props.visible, props.question, props.conversationId],
async ([vis, q, cid]) => {
if (vis && q && cid && !triageId.value) {
// 自动发起分诊
loading.value = true
const ok = await startTriageFlow(cid as string, q as string)
loading.value = false
if (!ok && isTimeout.value) {
emit('timeout')
}
}
},
{ immediate: true },
)
// 监听步骤变化,重置选中项
watch(currentStepIndex, () => {
selectedOptionIndex.value = -1
})
// ===========================================================================
// 方法
// ===========================================================================
/** 概率转百分比 */
function getProbabilityPercent(prob: number): number {
return Math.round(prob * 100)
}
/** 是否被坐席排除 */
function isExcluded(label: string): boolean {
return excludedLabels.value.includes(label)
}
/** 是否被坐席推荐 */
function isRecommended(label: string): boolean {
return recommendedLabel.value === label && recommendedLabel.value !== ''
}
/** 折叠/展开卡片 */
function toggleCollapse() {
collapsed.value = !collapsed.value
@@ -243,27 +308,70 @@ function toggleCollapse() {
/** 选择选项 */
function selectOption(idx: number) {
// 被排除的选项不可选
const option = currentOptions.value[idx]
if (option?.excluded) return
if (!option || isExcluded(option.label)) return
// 单选模式:取消其他,选中当前
currentOptions.value.forEach((o, i) => {
o.selected = i === idx && !o.selected
})
// 单选模式
selectedOptionIndex.value = selectedOptionIndex.value === idx ? -1 : idx
}
/** 确认当前步骤 */
function handleConfirm() {
const selected = currentOptions.value.find(o => o.selected)
if (selected) {
emit('confirm', currentStepIndex.value, selected.label)
/** 确认当前步骤(下一步) */
async function handleConfirm() {
if (selectedOptionIndex.value === -1) return
const selected = currentOptions.value[selectedOptionIndex.value]
if (!selected) return
loading.value = true
const hasNext = await submitStep(selected.label)
loading.value = false
if (!hasNext && !isLastStep.value) {
// 后端没有返回下一步,但我们还有预生成的步骤
// 直接移动到下一步
}
}
/** 跳过当前步骤 */
function handleSkip() {
emit('skip', currentStepIndex.value)
async function handleSkip() {
loading.value = true
const hasNext = await skipStep()
loading.value = false
if (!hasNext && isLastStep.value) {
showToast('已是最后一步')
}
}
/** 完成分诊 */
async function handleComplete() {
if (selectedOptionIndex.value === -1) return
const selected = currentOptions.value[selectedOptionIndex.value]
if (!selected) return
// 先提交最后一步选择
loading.value = true
await submitStep(selected.label)
// 然后完成分诊
const reply = await complete()
loading.value = false
if (reply) {
emit('complete', reply, confidence.value ?? 0)
}
}
/** 转人工 */
async function handleTransfer() {
loading.value = true
const ok = await transferToHuman()
loading.value = false
if (ok) {
emit('transfer-human', [...collectedContext.value])
}
}
/** 专家模式切换 */
@@ -273,20 +381,19 @@ function onExpertModeChange(enabled: boolean) {
/** 暴露方法供父组件调用 */
defineExpose({
/** 设置坐席排除项 */
/** 设置坐席排除项WS 接收后调用) */
setExcludedOptions(labels: string[]) {
currentOptions.value.forEach(o => {
if (labels.includes(o.label)) {
o.excluded = true
o.selected = false
}
})
setExcludedOptions(labels)
},
/** 设置坐席推荐项 */
/** 设置坐席推荐项WS 接收后调用) */
setRecommendedOption(label: string) {
currentOptions.value.forEach(o => {
o.recommended = o.label === label
})
setRecommendedOption(label)
},
/** 重置分诊状态 */
reset() {
reset()
selectedOptionIndex.value = -1
collapsed.value = false
},
})
</script>
@@ -360,6 +467,19 @@ defineExpose({
padding: 12px 14px;
}
/* 超时提示 */
.triage-card__timeout {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
}
.triage-card__timeout-text {
font-size: 14px;
color: #ee0a24;
}
.triage-card__question {
font-size: 15px;
font-weight: 500;
@@ -0,0 +1,260 @@
<!--
=============================================================================
// 企微IT智能服务台 — H5 申请流程
=============================================================================
// 说明:卡片式入口,点击触发企微审批流程
// 1. 常用流程:密码重置、电脑升级、VPN申请、软件安装、设备申领
// 2. 更多:展开完整审批流程列表(12类型18流程)
// 3. 如果员工有进行中的申请,卡片上显示角标提示
//
// 数据来源:Mock 数据,后续对接后端审批 API + 企微 thirdPartyOpenPage
// =============================================================================
-->
<template>
<div class="app-process">
<!-- 标题栏 -->
<div class="app-process__header">
<span class="header__title">申请流程</span>
</div>
<!-- 流程卡片网格 -->
<div class="app-process__grid">
<button
v-for="item in topItems"
:key="item.key"
class="flow-card"
@click="handleFlow(item)"
>
<!-- 进行中角标 -->
<span v-if="item.pendingCount > 0" class="flow-card__badge">
{{ item.pendingCount }}
</span>
<span class="flow-card__icon">{{ item.icon }}</span>
<span class="flow-card__name">{{ item.name }}</span>
<span class="flow-card__desc">{{ item.desc }}</span>
</button>
<!-- 更多 -->
<button class="flow-card flow-card--more" @click="showMore = !showMore">
<span class="flow-card__icon">{{ showMore ? '▴' : '▾' }}</span>
<span class="flow-card__name">{{ showMore ? '收起' : '更多' }}</span>
<span class="flow-card__desc">全部流程</span>
</button>
</div>
<!-- 更多流程展开 -->
<div v-if="showMore" class="app-process__more">
<button
v-for="item in moreItems"
:key="item.key"
class="more-item"
@click="handleFlow(item)"
>
<span class="more-item__icon">{{ item.icon }}</span>
<span class="more-item__name">{{ item.name }}</span>
<span v-if="item.pendingCount > 0" class="more-item__badge">{{ item.pendingCount }}</span>
<span class="more-item__arrow"></span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// ── 类型定义 ──
interface FlowItem {
key: string
name: string
desc: string
icon: string
pendingCount: number // 进行中的申请数量
}
// ── Mock 数据 ──
const topItems = ref<FlowItem[]>([
{ key: 'password_reset', name: '密码重置', desc: 'AD域账号', icon: '🔑', pendingCount: 0 },
{ key: 'pc_upgrade', name: '电脑升级', desc: '硬件升级', icon: '💻', pendingCount: 0 },
{ key: 'vpn_apply', name: 'VPN申请', desc: '远程接入', icon: '🔒', pendingCount: 1 },
{ key: 'software_install', name: '软件安装', desc: '审批流程', icon: '📦', pendingCount: 0 },
{ key: 'device_request', name: '设备申领', desc: '新员工', icon: '🖥️', pendingCount: 0 },
])
const moreItems = ref<FlowItem[]>([
{ key: 'email_config', name: '邮箱配置', desc: '', icon: '📧', pendingCount: 0 },
{ key: 'permission_apply', name: '权限申请', desc: '', icon: '🛡️', pendingCount: 0 },
{ key: 'remote_work', name: '远程办公', desc: '', icon: '🏠', pendingCount: 0 },
{ key: 'account_unlock', name: '账号解锁', desc: '', icon: '🔓', pendingCount: 0 },
{ key: 'data_recovery', name: '数据恢复', desc: '', icon: '💾', pendingCount: 0 },
{ key: 'network_report', name: '网络报修', desc: '', icon: '🌐', pendingCount: 0 },
{ key: 'phone_config', name: '电话配置', desc: '', icon: '📞', pendingCount: 0 },
{ key: 'office_supplies', name: '办公用品', desc: '', icon: '📎', pendingCount: 0 },
])
// ── 状态 ──
const showMore = ref(false)
// ── 方法 ──
/** 处理流程点击 — 后续对接企微审批 thirdPartyOpenPage */
function handleFlow(item: FlowItem) {
// TODO: 对接企微审批
// 1. 调用后端获取 templateId + thirdNo
// 2. 调用 wx.invoke('thirdPartyOpenPage', { oaType:'10001', templateId, thirdNo })
console.log('[Flow] 打开审批流程:', item.key, item.name)
}
</script>
<style scoped>
.app-process {
background: var(--bg-primary, #fff);
border-radius: var(--border-radius-lg, 12px);
border: 1px solid var(--border-color, #e5e7eb);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.app-process__header {
display: flex;
align-items: center;
}
.header__title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1f2937);
}
/* 流程卡片网格 */
.app-process__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.flow-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
border-radius: var(--border-radius-md, 8px);
background: var(--bg-tertiary, #f9fafb);
border: 1px solid var(--border-color, #e5e7eb);
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
position: relative;
}
.flow-card:hover {
background: var(--bg-secondary, #f5f5f5);
border-color: var(--accent, #07C160);
}
.flow-card:active {
transform: scale(0.96);
}
/* 进行中角标 */
.flow-card__badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.flow-card__icon {
font-size: 16px;
}
.flow-card__name {
font-size: 11px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.flow-card__desc {
font-size: 9px;
color: var(--text-tertiary, #9ca3af);
}
.flow-card--more {
background: transparent;
border-style: dashed;
}
/* 更多流程列表 */
.app-process__more {
display: flex;
flex-direction: column;
gap: 2px;
border-top: 1px solid var(--border-color, #e5e7eb);
padding-top: 6px;
}
.more-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--border-radius-md, 8px);
background: transparent;
border: none;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
}
.more-item:hover {
background: var(--bg-tertiary, #f9fafb);
}
.more-item__icon {
font-size: 14px;
flex-shrink: 0;
}
.more-item__name {
flex: 1;
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
text-align: left;
}
.more-item__badge {
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.more-item__arrow {
font-size: 14px;
color: var(--text-tertiary, #9ca3af);
}
</style>
@@ -57,8 +57,14 @@
import { computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { useWecomApproval } from '@/composables/useWecomApproval'
const store = useConversationStore()
// 企微审批原生打开 composable
// 企微审批URL → wx.invoke('thirdPartyOpenPage') 原生打开
// ITSM工单URL → window.location.href 同窗口导航
const { openUrl } = useWecomApproval()
/** 加载状态:当审批链接列表为空且未初始化时视为加载中 */
const loading = computed(() => {
return store.approvalLinks.length === 0 && !store.initialized
@@ -69,12 +75,13 @@ const groupedLinks = computed(() => store.approvalLinksByCategory)
/**
* 打开审批流程链接
* 在企微内置浏览器中打开目标链接
* 通过 useWecomApproval.openUrl 智能路由:
* 企微审批 → 企微内原生打开(不另开窗口)
* ITSM工单 → 同窗口导航
* @param url 审批流程链接地址
*/
function openLink(url: string): void {
// 在企微 WebView 中直接使用 window.open 即可在内置浏览器中打开
window.open(url, '_blank')
async function openLink(url: string): Promise<void> {
await openUrl(url)
}
</script>
@@ -0,0 +1,604 @@
<!--
=============================================================================
// 企微IT智能服务台 — H5 基本信息卡片
=============================================================================
// 说明:展示当前设备的 IT 基本信息
// 1. Tab 切换:当前设备 / 其他设备
// 2. 当前设备:电脑名称、健康评分、资产编号、启用时间、内网IP、公网出口、
// 办公地点、操作系统、MAC地址、运行时长、CPU/内存/硬盘(分区)进度条
// 3. 其他设备:设备类型、设备名称、最后登录时间、最后登录地点
// 4. 健康评分:0-100,颜色随等级变化(绿/黄/红)
//
// 数据来源:GET /api/h5/it-health(联软+火绒+资产服务聚合)
// API 不可用时降级为 Mock 数据
// =============================================================================
-->
<template>
<div class="basic-info-card">
<!-- 加载中骨架屏 -->
<div v-if="loading" class="basic-info-card__loading">
<span>正在获取设备信息...</span>
</div>
<template v-else>
<!-- ====== Tab 切换 ====== -->
<div class="basic-info-card__tabs">
<button
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'current' }"
@click="activeTab = 'current'"
>
当前设备
</button>
<button
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'other' }"
@click="activeTab = 'other'"
>
其他设备 ({{ otherDevices.length }})
</button>
</div>
<!-- ====== 当前设备 ====== -->
<div v-if="activeTab === 'current'" class="basic-info-card__current">
<!-- 设备名称行 -->
<div class="device-header">
<div class="device-header__left">
<span class="device-header__dot" :class="currentDevice.isOnline ? 'dot--online' : 'dot--offline'"></span>
<span class="device-header__name">{{ currentDevice.deviceName }}</span>
</div>
<span class="device-header__status" :class="currentDevice.isOnline ? 'status--online' : 'status--offline'">
{{ currentDevice.isOnline ? '在线' : '离线' }}
</span>
</div>
<!-- 信息网格 + 健康评分 -->
<div class="info-grid">
<div class="info-grid__main">
<div class="info-row">
<span class="info-row__label">资产编号</span>
<span class="info-row__value">{{ currentDevice.assetTag || '-' }}</span>
<span class="info-row__label info-row__label--indent">启用时间</span>
<span class="info-row__value">{{ currentDevice.activateDate || '-' }}</span>
</div>
<div class="info-row">
<span class="info-row__label">内网IP</span>
<span class="info-row__value">{{ currentDevice.ipAddress || '-' }}</span>
<span class="info-row__label info-row__label--indent">公网出口</span>
<span class="info-row__value">{{ currentDevice.publicIp || '-' }}</span>
</div>
<div class="info-row">
<span class="info-row__label">办公地点</span>
<span class="info-row__value">{{ currentDevice.location || '-' }}</span>
<span class="info-row__label info-row__label--indent">操作系统</span>
<span class="info-row__value">{{ currentDevice.os || '-' }}</span>
</div>
<div class="info-row">
<span class="info-row__label">MAC地址</span>
<span class="info-row__value">{{ currentDevice.mac || '-' }}</span>
<span class="info-row__label info-row__label--indent">运行时长</span>
<span class="info-row__value">{{ currentDevice.uptime || '-' }}</span>
</div>
</div>
<!-- 健康评分徽章 -->
<div class="health-badge" :class="`health-badge--${scoreLevel}`">
<span class="health-badge__score">{{ healthScore }}</span>
<span class="health-badge__label">健康评分</span>
</div>
</div>
<!-- 性能进度条 -->
<div class="perf-section" v-if="currentDevice.cpu?.model || currentDevice.memory?.total || currentDevice.disks?.length">
<!-- CPU -->
<div class="perf-item" v-if="currentDevice.cpu?.model">
<div class="perf-item__header">
<span class="perf-item__label">CPU</span>
<span class="perf-item__value">{{ currentDevice.cpu.usage }}% · {{ currentDevice.cpu.model }}</span>
</div>
<div class="perf-item__bar">
<div class="perf-item__fill" :class="getUsageBarClass(currentDevice.cpu.usage)" :style="{ width: currentDevice.cpu.usage + '%' }"></div>
</div>
</div>
<!-- 内存 -->
<div class="perf-item" v-if="currentDevice.memory?.total">
<div class="perf-item__header">
<span class="perf-item__label">内存</span>
<span class="perf-item__value">{{ currentDevice.memory.usage }}% · {{ currentDevice.memory.total }}</span>
</div>
<div class="perf-item__bar">
<div class="perf-item__fill" :class="getUsageBarClass(currentDevice.memory.usage)" :style="{ width: currentDevice.memory.usage + '%' }"></div>
</div>
</div>
<!-- 硬盘分区 -->
<div class="perf-item" v-for="disk in currentDevice.disks" :key="disk.label">
<div class="perf-item__header">
<span class="perf-item__label">{{ disk.label }}</span>
<span class="perf-item__value">{{ disk.usage }}% · {{ disk.used }}/{{ disk.total }}</span>
</div>
<div class="perf-item__bar">
<div class="perf-item__fill" :class="getUsageBarClass(disk.usage)" :style="{ width: disk.usage + '%' }"></div>
</div>
</div>
</div>
<!-- 数据来源标识 -->
<div v-if="dataSource === 'mock'" class="data-source-badge">
数据来源模拟集成未配置
</div>
</div>
<!-- ====== 其他设备列表 ====== -->
<div v-else class="basic-info-card__other">
<div v-if="otherDevices.length === 0" class="other-empty">
暂无其他设备
</div>
<div v-else class="other-list">
<div v-for="device in otherDevices" :key="device.deviceName" class="other-item">
<div class="other-item__left">
<span class="other-item__type">{{ device.deviceType }}</span>
<span class="other-item__name">{{ device.deviceName }}</span>
</div>
<div class="other-item__right">
<span class="other-item__time">{{ device.lastLoginTime }}</span>
<span class="other-item__location">{{ device.lastLoginLocation }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getITHealth } from '@/api/it-health'
// ── 前端组件内部类型(camelCase,与模板一致) ──
interface DiskInfo {
label: string
usage: number
used: string
total: string
}
interface SecurityCheck {
status: 'pass' | 'warning' | 'danger' | 'pending'
}
interface DeviceInfo {
deviceName: string
isOnline: boolean
assetTag: string
activateDate: string
ipAddress: string
publicIp: string
location: string
os: string
mac: string
uptime: string
cpu: { usage: number; model: string }
memory: { usage: number; total: string }
disks: DiskInfo[]
securityChecks: SecurityCheck[]
complianceChecks: SecurityCheck[]
healthScore: number
}
interface OtherDeviceInfo {
deviceType: string
deviceName: string
lastLoginTime: string
lastLoginLocation: string
}
// ── 状态 ──
const activeTab = ref<'current' | 'other'>('current')
const loading = ref(true)
const dataSource = ref<'real' | 'mock'>('mock')
const currentDevice = ref<DeviceInfo>({
deviceName: '',
isOnline: true,
assetTag: '',
activateDate: '',
ipAddress: '',
publicIp: '',
location: '',
os: '',
mac: '',
uptime: '',
cpu: { usage: 0, model: '' },
memory: { usage: 0, total: '' },
disks: [],
securityChecks: [],
complianceChecks: [],
healthScore: 100,
})
const otherDevices = ref<OtherDeviceInfo[]>([])
// ── 生命周期 ──
onMounted(async () => {
await loadITHealth()
})
// ── 方法 ──
/** 加载 IT 健康数据(后端 snake_case → 前端 camelCase 映射) */
async function loadITHealth() {
loading.value = true
try {
const data = await getITHealth()
if (data) {
const d = data.current_device
currentDevice.value = {
deviceName: d.device_name,
isOnline: d.is_online,
assetTag: d.asset_tag,
activateDate: d.activate_date,
ipAddress: d.ip_address,
publicIp: d.public_ip,
location: d.location,
os: d.os,
mac: d.mac,
uptime: d.uptime,
cpu: d.cpu,
memory: d.memory,
disks: d.disks,
securityChecks: d.security_checks,
complianceChecks: d.compliance_checks,
healthScore: d.health_score ?? 100,
}
otherDevices.value = (data.other_devices || []).map((d: any) => ({
deviceType: d.device_type || '设备',
deviceName: d.device_name || '',
lastLoginTime: d.last_login_time || '',
lastLoginLocation: d.last_login_location || '',
}))
dataSource.value = data.data_source || 'mock'
}
} catch (error) {
console.warn('[BasicInfoCard] IT 健康数据获取失败,使用降级数据:', error)
dataSource.value = 'mock'
} finally {
loading.value = false
}
}
// ── 计算属性 ──
/**
* IT 健康评分(0-100
* 优先使用后端计算的 healthScore,降级为前端计算
*/
const healthScore = computed(() => {
// 后端已计算 healthScore
if (currentDevice.value.healthScore !== undefined) {
return currentDevice.value.healthScore
}
// 降级:前端计算(与后端算法一致)
let score = 100
for (const check of currentDevice.value.securityChecks) {
if (check.status === 'danger') score -= 15
else if (check.status === 'warning') score -= 8
}
for (const check of currentDevice.value.complianceChecks) {
if (check.status === 'warning') score -= 5
else if (check.status === 'danger') score -= 10
}
if (!currentDevice.value.isOnline) score = Math.round(score * 0.6)
return Math.max(0, score)
})
const scoreLevel = computed(() => {
const s = healthScore.value
if (s >= 80) return 'good'
if (s >= 60) return 'fair'
return 'poor'
})
function getUsageBarClass(usage: number): string {
if (usage > 85) return 'fill--danger'
if (usage > 60) return 'fill--warning'
return 'fill--normal'
}
</script>
<style scoped>
.basic-info-card {
background: var(--bg-primary, #fff);
border-radius: var(--border-radius-lg, 12px);
border: 1px solid var(--border-color, #e5e7eb);
overflow: hidden;
}
/* ====== Tab 切换 ====== */
.basic-info-card__tabs {
display: flex;
gap: 4px;
padding: 6px;
background: var(--bg-tertiary, #f9fafb);
}
.tab-btn {
flex: 1;
padding: 6px 12px;
border: none;
border-radius: var(--border-radius-md, 8px);
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
background: transparent;
color: var(--text-secondary, #6b7280);
}
.tab-btn--active {
background: var(--accent, #07C160);
color: #fff;
}
/* ====== 当前设备 ====== */
.basic-info-card__current {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.device-header__left {
display: flex;
align-items: center;
gap: 6px;
}
.device-header__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot--online {
background: var(--color-success, #22c55e);
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
}
.dot--offline {
background: var(--border-color, #d1d5db);
}
.device-header__name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1f2937);
}
.device-header__status {
font-size: 11px;
font-weight: 500;
}
.status--online { color: var(--color-success, #22c55e); }
.status--offline { color: var(--text-tertiary, #9ca3af); }
/* 信息网格 */
.info-grid {
display: flex;
gap: 10px;
}
.info-grid__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.info-row {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
flex-wrap: wrap;
}
.info-row__label {
color: var(--text-tertiary, #9ca3af);
flex-shrink: 0;
}
.info-row__label--indent {
margin-left: 8px;
}
.info-row__value {
color: var(--text-primary, #1f2937);
font-weight: 500;
}
/* 健康评分徽章 */
.health-badge {
width: 72px;
flex-shrink: 0;
border-radius: var(--border-radius-md, 8px);
padding: 6px 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
}
.health-badge--good {
background: rgba(34, 197, 94, 0.06);
border: 1px solid rgba(34, 197, 94, 0.15);
}
.health-badge--fair {
background: rgba(245, 158, 11, 0.06);
border: 1px solid rgba(245, 158, 11, 0.15);
}
.health-badge--poor {
background: rgba(239, 68, 68, 0.06);
border: 1px solid rgba(239, 68, 68, 0.15);
}
.health-badge__score {
font-size: 24px;
font-weight: 800;
line-height: 1;
}
.health-badge--good .health-badge__score { color: var(--color-success, #22c55e); }
.health-badge--fair .health-badge__score { color: var(--color-warning, #f59e0b); }
.health-badge--poor .health-badge__score { color: var(--color-danger, #ef4444); }
.health-badge__label {
font-size: 9px;
color: var(--text-tertiary, #9ca3af);
}
/* 性能进度条 */
.perf-section {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--bg-tertiary, #f9fafb);
border-radius: var(--border-radius-md, 8px);
padding: 8px 10px;
}
.perf-item {
display: flex;
flex-direction: column;
gap: 3px;
}
.perf-item__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.perf-item__label {
font-size: 11px;
color: var(--text-tertiary, #9ca3af);
font-weight: 500;
}
.perf-item__value {
font-size: 11px;
color: var(--text-secondary, #6b7280);
font-weight: 500;
}
.perf-item__bar {
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.perf-item__fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.fill--normal { background: var(--color-success, #22c55e); }
.fill--warning { background: var(--color-warning, #f59e0b); }
.fill--danger { background: var(--color-danger, #ef4444); }
/* ====== 其他设备 ====== */
.basic-info-card__other {
padding: 8px 12px;
}
.other-empty {
text-align: center;
padding: 24px 0;
font-size: 12px;
color: var(--text-tertiary, #9ca3af);
}
.other-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.other-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: var(--border-radius-md, 8px);
background: var(--bg-tertiary, #f9fafb);
}
.other-item__left {
display: flex;
flex-direction: column;
gap: 2px;
}
.other-item__type {
font-size: 10px;
color: var(--text-tertiary, #9ca3af);
}
.other-item__name {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.other-item__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.other-item__time {
font-size: 11px;
color: var(--text-secondary, #6b7280);
}
.other-item__location {
font-size: 10px;
color: var(--text-tertiary, #9ca3af);
}
/* ====== 加载状态 ====== */
.basic-info-card__loading {
padding: 24px 12px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary, #9ca3af);
}
/* ====== 数据来源标识 ====== */
.data-source-badge {
font-size: 10px;
color: var(--color-warning, #f59e0b);
text-align: center;
padding: 4px 0;
border-top: 1px dashed var(--border-color, #e5e7eb);
}
</style>
@@ -0,0 +1,308 @@
<!--
=============================================================================
// 企微IT智能服务台 — H5 动态推荐卡片组件(v2.0 新增)
=============================================================================
// 说明:渲染 AI 推荐的操作卡片,显示在右边栏底部"智能推荐"标签页
// 数据来源:store.dynamicRecommendations(由 WS dynamic_recommend 事件推送)
// 卡片类型:
// - approval: 审批流程入口(点击打开企微审批表单)
// - action: 操作建议(点击执行对应操作)
// - info: 信息展示(纯文字说明,不可点击)
// 交互:
// - 最多 3 张卡片,超过时自动移除最旧的
// - 每张卡片可关闭(× 按钮),关闭后从 store 移除
// - 用户查看时清除未读计数(Badge 红点消失)
// =============================================================================
-->
<template>
<div class="dynamic-recommend">
<!-- 无推荐时显示空状态 -->
<div v-if="recommendations.length === 0" class="dynamic-recommend__empty">
<div class="dynamic-recommend__empty-icon">💡</div>
<p class="dynamic-recommend__empty-text">暂无智能推荐</p>
<p class="dynamic-recommend__empty-hint"> Duckula 提问后将显示相关推荐</p>
</div>
<!-- 推荐卡片列表 -->
<div v-else class="dynamic-recommend__list">
<div
v-for="rec in recommendations"
:key="rec.recommend_id"
class="recommend-card"
:class="`recommend-card--${rec.card_type}`"
>
<!-- 卡片头部类型图标 + 标题 + 关闭按钮 -->
<div class="recommend-card__header">
<span class="recommend-card__icon">{{ cardIcon(rec.card_type) }}</span>
<span class="recommend-card__title">{{ rec.title }}</span>
<button
class="recommend-card__close"
title="关闭推荐"
@click="handleClose(rec.recommend_id)"
>
×
</button>
</div>
<!-- 卡片描述 -->
<p v-if="rec.description" class="recommend-card__desc">{{ rec.description }}</p>
<!-- 卡片操作按钮 -->
<button
v-if="rec.card_type === 'approval' && rec.approval_type"
class="recommend-card__btn"
@click="handleApprovalClick(rec)"
>
打开审批表单
</button>
<button
v-else-if="rec.card_type === 'action'"
class="recommend-card__btn"
@click="handleActionClick(rec)"
>
立即执行
</button>
<!-- 置信度指示器可选 debug 模式显示 -->
<div v-if="showConfidence" class="recommend-card__confidence">
置信度: {{ Math.round(rec.confidence * 100) }}%
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* DynamicRecommend 动态推荐卡片组件
*
* 渲染 AI 推荐的操作卡片,从 conversation store 的 dynamicRecommendations 读取数据。
* 当组件挂载时清除未读计数(用户已看到推荐)。
*/
import { onMounted, computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
const store = useConversationStore()
// 从 store 获取推荐列表(响应式)
const recommendations = computed(() => store.dynamicRecommendations)
// 是否显示置信度(可通过 URL 参数 ?debug=true 开启)
const showConfidence = computed(() => {
return new URLSearchParams(window.location.search).has('debug')
})
/**
* 获取卡片类型对应的图标
*/
function cardIcon(cardType: string): string {
const icons: Record<string, string> = {
approval: '📋',
action: '⚡',
info: '️',
}
return icons[cardType] || '💡'
}
/**
* 关闭推荐卡片
*/
function handleClose(recommendId: string): void {
store.removeRecommend(recommendId)
}
/**
* 点击审批类型推荐 — 打开企微审批表单
* 做什么:调用 useWecomApproval composable 打开原生审批表单
* 为什么:审批入口从聊天流移到侧边栏,点击即跳转企微原生审批页面
*/
function handleApprovalClick(rec: {
recommend_id: string
approval_type?: string
title: string
}): void {
if (!rec.approval_type) return
// 触发审批表单打开(通过 store 事件或直接调用 composable
// 当前实现:emit 事件让父组件处理
console.log('[DynamicRecommend] 打开审批表单:', rec.approval_type)
// TODO: 接入 useWecomApproval composable 打开原生审批表单
// 该接入在 Phase 5 坐席端适配时统一处理
}
/**
* 点击操作类型推荐 — 执行对应操作
*/
function handleActionClick(rec: {
recommend_id: string
title: string
card_type: string
}): void {
console.log('[DynamicRecommend] 执行操作:', rec.title)
// TODO: 根据 rec 中的 action 字段路由到对应操作
// 可能的操作:打开软件安装页、打开诊断工具等
}
// 组件挂载时清除未读计数
onMounted(() => {
store.clearUnreadRecommend()
})
</script>
<style scoped>
/* ====== 容器 ====== */
.dynamic-recommend {
padding: 8px 10px;
height: 100%;
overflow-y: auto;
}
/* ====== 空状态 ====== */
.dynamic-recommend__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 4px;
}
.dynamic-recommend__empty-icon {
font-size: 32px;
opacity: 0.5;
}
.dynamic-recommend__empty-text {
font-size: 14px;
color: var(--text-secondary, #6b7280);
font-weight: 500;
margin: 0;
}
.dynamic-recommend__empty-hint {
font-size: 12px;
color: var(--text-placeholder, #9ca3af);
margin: 0;
}
/* ====== 推荐卡片列表 ====== */
.dynamic-recommend__list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ====== 单张推荐卡片 ====== */
.recommend-card {
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 10px;
padding: 12px;
transition: box-shadow 0.2s, border-color 0.2s;
}
.recommend-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border-color: var(--accent, #07C160);
}
/* 审批类型卡片左侧绿色条 */
.recommend-card--approval {
border-left: 3px solid var(--accent, #07C160);
}
/* 操作类型卡片左侧蓝色条 */
.recommend-card--action {
border-left: 3px solid #3b82f6;
}
/* 信息类型卡片左侧灰色条 */
.recommend-card--info {
border-left: 3px solid var(--text-placeholder, #9ca3af);
}
/* ── 卡片头部 ── */
.recommend-card__header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.recommend-card__icon {
font-size: 16px;
flex-shrink: 0;
}
.recommend-card__title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1f2937);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-card__close {
width: 20px;
height: 20px;
border: none;
background: transparent;
font-size: 16px;
color: var(--text-placeholder, #9ca3af);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
padding: 0;
flex-shrink: 0;
}
.recommend-card__close:hover {
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-secondary, #6b7280);
}
/* ── 卡片描述 ── */
.recommend-card__desc {
font-size: 12px;
color: var(--text-secondary, #6b7280);
line-height: 1.5;
margin: 0 0 8px;
}
/* ── 卡片操作按钮 ── */
.recommend-card__btn {
width: 100%;
padding: 7px 12px;
background: var(--accent, #07C160);
color: #fff;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
-webkit-tap-highlight-color: transparent;
}
.recommend-card__btn:hover {
opacity: 0.9;
}
.recommend-card__btn:active {
opacity: 0.7;
}
/* ── 置信度(debug) ── */
.recommend-card__confidence {
margin-top: 6px;
font-size: 10px;
color: var(--text-placeholder, #9ca3af);
text-align: right;
}
</style>
@@ -0,0 +1,789 @@
<!--
=============================================================================
// 企微IT智能服务台 — H5 IT 健康概况仪表盘
=============================================================================
// 说明:展示当前员工所有办公设备的 IT 健康状况
// 1. 顶部摘要条:员工名 + 部门 + IT 健康评分(0-100) + 设备总数/在线数
// 2. 设备卡片列表:每台设备一个卡片,含:
// a. 设备名 + 在线状态指示灯
// b. 网络信息(IP / 交换机)
// c. 办公地点
// d. 性能进度条(CPU / 内存 / 磁盘)
// e. 安全检查项(火绒安装/系统补丁/高危软件/病毒状态/内部攻击[接入中]/网络代理[接入中])
// f. 合规检查项(未登记自备电脑/未审批商业软件)
//
// 数据来源:当前为 Mock 数据,后续对接 GET /api/h5/it-health?employee_id=xxx
// =============================================================================
-->
<template>
<div class="health-dashboard">
<!-- ====== 顶部摘要条 ====== -->
<div class="health-dashboard__summary" :class="`health-dashboard__summary--${scoreLevel}`">
<div class="summary__left">
<span class="summary__name">{{ employeeInfo.name }}</span>
<span class="summary__dept">{{ employeeInfo.department }}</span>
</div>
<div class="summary__right">
<span class="summary__score" :class="`summary__score--${scoreLevel}`">{{ healthScore }}</span>
<span class="summary__score-label">健康评分</span>
</div>
</div>
<!-- 设备总数/在线数 -->
<div class="health-dashboard__device-count">
<span class="device-count__total">
<span class="device-count__num">{{ devices.length }}</span> 台设备
</span>
<span class="device-count__divider">·</span>
<span class="device-count__online">
<span class="device-count__online-dot"></span>
{{ onlineDeviceCount }} 台在线
</span>
</div>
<!-- ====== 设备卡片列表 ====== -->
<div class="health-dashboard__device-list">
<div
v-for="device in devices"
:key="device.deviceName"
class="device-card"
:class="{ 'device-card--offline': !device.isOnline }"
>
<!-- 左侧竖条在线/离线指示 -->
<div class="device-card__status-bar" :class="device.isOnline ? 'device-card__status-bar--online' : 'device-card__status-bar--offline'"></div>
<div class="device-card__body">
<!-- 设备头部 -->
<div class="device-card__header">
<div class="device-card__name-area">
<span class="device-card__status-dot" :class="device.isOnline ? 'device-card__status-dot--online' : 'device-card__status-dot--offline'"></span>
<span class="device-card__name">{{ device.deviceName }}</span>
</div>
<span class="device-card__status-text" :class="device.isOnline ? 'device-card__status-text--online' : 'device-card__status-text--offline'">
{{ device.isOnline ? '在线' : '离线' }}
</span>
</div>
<!-- 网络信息 -->
<div class="device-card__info-row">
<span class="info-row__icon">🌐</span>
<span class="info-row__label">IP</span>
<span class="info-row__value">{{ device.ipAddress }}</span>
<span class="info-row__divider">|</span>
<span class="info-row__label">交换机</span>
<span class="info-row__value">{{ device.switchName }}</span>
</div>
<!-- 办公地点 -->
<div class="device-card__info-row">
<span class="info-row__icon">📍</span>
<span class="info-row__label">地点</span>
<span class="info-row__value">{{ device.location }}</span>
</div>
<!-- 性能进度条 -->
<div class="device-card__performance">
<!-- CPU -->
<div class="perf-item">
<div class="perf-item__header">
<span class="perf-item__label">CPU</span>
<span class="perf-item__value">{{ device.cpu.usage }}%</span>
</div>
<div class="perf-item__bar">
<div
class="perf-item__fill"
:class="getUsageBarClass(device.cpu.usage)"
:style="{ width: device.cpu.usage + '%' }"
></div>
</div>
<div class="perf-item__model">{{ device.cpu.model }}</div>
</div>
<!-- 内存 -->
<div class="perf-item">
<div class="perf-item__header">
<span class="perf-item__label">内存</span>
<span class="perf-item__value">{{ device.memory.usage }}% / {{ device.memory.total }}</span>
</div>
<div class="perf-item__bar">
<div
class="perf-item__fill"
:class="getUsageBarClass(device.memory.usage)"
:style="{ width: device.memory.usage + '%' }"
></div>
</div>
</div>
<!-- 磁盘 -->
<div class="perf-item">
<div class="perf-item__header">
<span class="perf-item__label">磁盘</span>
<span class="perf-item__value">{{ device.disk.usage }}% / {{ device.disk.total }}</span>
</div>
<div class="perf-item__bar">
<div
class="perf-item__fill"
:class="getUsageBarClass(device.disk.usage)"
:style="{ width: device.disk.usage + '%' }"
></div>
</div>
</div>
</div>
<!-- 安全检查 -->
<div class="device-card__section-title">🛡 安全风险</div>
<div class="device-card__check-grid">
<div
v-for="check in device.securityChecks"
:key="check.key"
class="check-item"
:class="{
'check-item--pass': check.status === 'pass',
'check-item--warning': check.status === 'warning',
'check-item--danger': check.status === 'danger',
'check-item--pending': check.status === 'pending',
}"
>
<span class="check-item__icon">{{ check.icon }}</span>
<div class="check-item__content">
<span class="check-item__label">{{ check.label }}</span>
<span class="check-item__detail">{{ check.detail }}</span>
</div>
<span class="check-item__status-icon">
<template v-if="check.status === 'pass'"></template>
<template v-else-if="check.status === 'warning'"></template>
<template v-else-if="check.status === 'danger'"></template>
<template v-else>🔗</template>
</span>
</div>
</div>
<!-- 合规检查 -->
<div class="device-card__section-title">📋 合规检查</div>
<div class="device-card__check-grid">
<div
v-for="check in device.complianceChecks"
:key="check.key"
class="check-item"
:class="{
'check-item--pass': check.status === 'pass',
'check-item--warning': check.status === 'warning',
'check-item--danger': check.status === 'danger',
}"
>
<span class="check-item__icon">{{ check.icon }}</span>
<div class="check-item__content">
<span class="check-item__label">{{ check.label }}</span>
<span class="check-item__detail">{{ check.detail }}</span>
</div>
<span class="check-item__status-icon">
<template v-if="check.status === 'pass'"></template>
<template v-else-if="check.status === 'warning'"></template>
<template v-else></template>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 底部提示 -->
<div class="health-dashboard__footer">
<span class="footer__text">数据更新于 {{ lastUpdateText }}</span>
<span class="footer__refresh" @click="refreshData">🔄 刷新</span>
</div>
</div>
</template>
<script setup lang="ts">
/**
* ITHealthDashboard IT 健康概况仪表盘
*
* 展示当前员工所有办公设备的 IT 健康状况,包括:
* - 设备在线状态、网络信息、办公地点
* - CPU/内存/磁盘 使用率
* - 安全风险评估(火绒安装、系统补丁、高危软件、病毒状态、内部攻击[接入中]、网络代理[接入中])
* - 合规检查(未登记自备电脑、未审批商业软件)
*
* 当前使用 Mock 数据,后续对接后端 API
*/
import { ref, computed } from 'vue'
// ── 类型定义 ──
/** 安全检查状态 */
type CheckStatus = 'pass' | 'warning' | 'danger' | 'pending'
/** 安全检查项 */
interface SecurityCheck {
key: string
label: string // 如 "火绒安装"
icon: string // emoji 图标
status: CheckStatus // 检查状态
detail: string // 详情说明
dataSourceReady: boolean // 数据源是否就绪(false = 显示"接入中"
}
/** 合规检查项 */
interface ComplianceCheck {
key: string
label: string
icon: string
status: 'pass' | 'warning' | 'danger'
detail: string
}
/** 设备健康信息 */
interface DeviceHealth {
deviceName: string
isOnline: boolean
ipAddress: string
switchName: string
location: string
cpu: { usage: number; model: string }
memory: { usage: number; total: string }
disk: { usage: number; total: string }
securityChecks: SecurityCheck[]
complianceChecks: ComplianceCheck[]
}
// ── Mock 数据 ──
/** 员工信息(后续从 store 获取) */
const employeeInfo = ref({
name: '宋献',
department: 'IT支持组',
})
/** Mock 设备数据 */
const devices = ref<DeviceHealth[]>([
{
deviceName: 'DESKTOP-SIMON01',
isOnline: true,
ipAddress: '10.90.5.21',
switchName: 'SW-Floor5-01',
location: '杭州总部 5楼 研发区',
cpu: { usage: 23, model: 'Intel i7-12700' },
memory: { usage: 45, total: '16GB' },
disk: { usage: 67, total: '512GB' },
securityChecks: [
{ key: 'huorong', label: '火绒安装', icon: '🛡️', status: 'pass', detail: '已安装 v2.4.1', dataSourceReady: true },
{ key: 'patches', label: '系统补丁', icon: '🔧', status: 'warning', detail: '2个高危补丁', dataSourceReady: true },
{ key: 'risk_software', label: '高危软件', icon: '📦', status: 'pass', detail: '未发现', dataSourceReady: true },
{ key: 'virus', label: '病毒状态', icon: '🦠', status: 'pass', detail: '无病毒事件', dataSourceReady: true },
{ key: 'internal_attack', label: '内部攻击', icon: '⚔️', status: 'pending', detail: '接入中', dataSourceReady: false },
{ key: 'network_proxy', label: '网络代理', icon: '🌐', status: 'pending', detail: '接入中', dataSourceReady: false },
],
complianceChecks: [
{ key: 'byod', label: '自备电脑', icon: '💻', status: 'pass', detail: '公司配发设备' },
{ key: 'unapproved_sw', label: '未审批软件', icon: '📋', status: 'warning', detail: '发现1个未审批软件' },
],
},
{
deviceName: 'NB-SIMON02',
isOnline: false,
ipAddress: '10.90.5.22',
switchName: '—',
location: '杭州总部 5楼 研发区',
cpu: { usage: 0, model: 'Intel i5-1240P' },
memory: { usage: 0, total: '16GB' },
disk: { usage: 82, total: '256GB' },
securityChecks: [
{ key: 'huorong', label: '火绒安装', icon: '🛡️', status: 'danger', detail: '未安装', dataSourceReady: true },
{ key: 'patches', label: '系统补丁', icon: '🔧', status: 'danger', detail: '5个高危补丁', dataSourceReady: true },
{ key: 'risk_software', label: '高危软件', icon: '📦', status: 'warning', detail: '发现2个高危软件', dataSourceReady: true },
{ key: 'virus', label: '病毒状态', icon: '🦠', status: 'pass', detail: '无病毒事件', dataSourceReady: true },
{ key: 'internal_attack', label: '内部攻击', icon: '⚔️', status: 'pending', detail: '接入中', dataSourceReady: false },
{ key: 'network_proxy', label: '网络代理', icon: '🌐', status: 'pending', detail: '接入中', dataSourceReady: false },
],
complianceChecks: [
{ key: 'byod', label: '自备电脑', icon: '💻', status: 'warning', detail: '疑似自备电脑未登记' },
{ key: 'unapproved_sw', label: '未审批软件', icon: '📋', status: 'pass', detail: '未发现' },
],
},
])
/** 最后更新时间 */
const lastUpdate = ref(new Date())
// ── 计算属性 ──
/** 在线设备数量 */
const onlineDeviceCount = computed(() => devices.value.filter(d => d.isOnline).length)
/**
* IT 健康评分(0-100
* 计算规则:
* - 每台设备基础分 100
* - 火绒未安装: -20
* - 高危补丁: 每个扣 5
* - 高危软件: 每个 -10
* - 病毒未处理: 每个扣 15
* - 合规问题: 每个 -5
* - 最终分 = 所有设备平均分(离线设备按 60% 权重计算)
*/
const healthScore = computed(() => {
if (devices.value.length === 0) return 100
const scores = devices.value.map(device => {
let score = 100
// 安全扣分
for (const check of device.securityChecks) {
if (check.status === 'danger') {
if (check.key === 'huorong') score -= 20
else if (check.key === 'virus') score -= 15
else score -= 10
} else if (check.status === 'warning') {
if (check.key === 'patches') score -= 5 // 高危补丁每个扣5(detail中有数量,简化处理)
else score -= 8
}
}
// 合规扣分
for (const check of device.complianceChecks) {
if (check.status === 'warning') score -= 5
else if (check.status === 'danger') score -= 10
}
// 离线设备权重降低
if (!device.isOnline) score = Math.round(score * 0.6)
return Math.max(0, score)
})
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length
return Math.round(avg)
})
/** 评分等级(用于颜色) */
const scoreLevel = computed(() => {
const score = healthScore.value
if (score >= 80) return 'good'
if (score >= 60) return 'fair'
return 'poor'
})
/** 最后更新时间文本 */
const lastUpdateText = computed(() => {
const h = lastUpdate.value.getHours().toString().padStart(2, '0')
const m = lastUpdate.value.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
})
// ── 方法 ──
/**
* 获取使用率进度条样式类
* <60%: 绿色(正常)
* 60-85%: 橙色(警告)
* >85%: 红色(危险)
*/
function getUsageBarClass(usage: number): string {
if (usage > 85) return 'perf-item__fill--danger'
if (usage > 60) return 'perf-item__fill--warning'
return 'perf-item__fill--normal'
}
/**
* 刷新数据(Mock:更新时间戳)
* 后续对接 API 时替换为真实请求
*/
function refreshData(): void {
lastUpdate.value = new Date()
// TODO: 后续替换为 API 调用
// const employeeId = employeeStore.employeeId
// const data = await getITHealth(employeeId)
// devices.value = data.devices
}
</script>
<style scoped>
/* ====== 仪表盘容器 ====== */
.health-dashboard {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding: 10px 12px 8px;
gap: 8px;
}
/* ====== 顶部摘要条 ====== */
.health-dashboard__summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border-radius: var(--border-radius-lg);
transition: background 0.3s;
}
/* 评分等级背景色 */
.health-dashboard__summary--good {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(34, 197, 94, 0.02));
border: 1px solid rgba(34, 197, 94, 0.2);
}
.health-dashboard__summary--fair {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), rgba(245, 158, 11, 0.02));
border: 1px solid rgba(245, 158, 11, 0.2);
}
.health-dashboard__summary--poor {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08), rgba(239, 68, 68, 0.02));
border: 1px solid rgba(239, 68, 68, 0.2);
}
.summary__left {
display: flex;
flex-direction: column;
gap: 2px;
}
.summary__name {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.summary__dept {
font-size: 12px;
color: var(--text-tertiary);
}
.summary__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.summary__score {
font-size: 28px;
font-weight: 800;
line-height: 1;
}
.summary__score--good { color: var(--color-success, #22c55e); }
.summary__score--fair { color: var(--color-warning, #f59e0b); }
.summary__score--poor { color: var(--color-danger, #ef4444); }
.summary__score-label {
font-size: 10px;
color: var(--text-tertiary);
}
/* ====== 设备总数 ====== */
.health-dashboard__device-count {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
padding: 0 2px;
}
.device-count__num {
font-weight: 700;
color: var(--text-primary);
}
.device-count__divider {
color: var(--text-tertiary);
}
.device-count__online {
display: flex;
align-items: center;
gap: 4px;
}
.device-count__online-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-success, #22c55e);
}
/* ====== 设备卡片列表 ====== */
.health-dashboard__device-list {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ====== 单个设备卡片 ====== */
.device-card {
display: flex;
background: var(--bg-secondary, #fff);
border-radius: var(--border-radius-lg, 12px);
overflow: hidden;
border: 1px solid var(--border-color, #e5e7eb);
}
.device-card--offline {
opacity: 0.75;
}
/* 左侧竖条 */
.device-card__status-bar {
width: 4px;
flex-shrink: 0;
}
.device-card__status-bar--online {
background: var(--color-success, #22c55e);
}
.device-card__status-bar--offline {
background: var(--border-color, #d1d5db);
}
.device-card__body {
flex: 1;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* 设备头部 */
.device-card__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.device-card__name-area {
display: flex;
align-items: center;
gap: 6px;
}
.device-card__status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.device-card__status-dot--online {
background: var(--color-success, #22c55e);
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
}
.device-card__status-dot--offline {
background: var(--border-color, #d1d5db);
}
.device-card__name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1f2937);
}
.device-card__status-text {
font-size: 11px;
font-weight: 500;
}
.device-card__status-text--online {
color: var(--color-success, #22c55e);
}
.device-card__status-text--offline {
color: var(--text-tertiary, #9ca3af);
}
/* 信息行 */
.device-card__info-row {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary, #6b7280);
flex-wrap: wrap;
}
.info-row__icon {
font-size: 12px;
}
.info-row__label {
color: var(--text-tertiary, #9ca3af);
}
.info-row__value {
color: var(--text-primary, #1f2937);
font-weight: 500;
}
.info-row__divider {
color: var(--border-color, #e5e7eb);
}
/* 性能进度条 */
.device-card__performance {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--bg-primary, #f9fafb);
border-radius: var(--border-radius-md, 8px);
padding: 8px 10px;
}
.perf-item {
display: flex;
flex-direction: column;
gap: 3px;
}
.perf-item__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.perf-item__label {
font-size: 11px;
color: var(--text-tertiary, #9ca3af);
font-weight: 500;
}
.perf-item__value {
font-size: 11px;
color: var(--text-secondary, #6b7280);
font-weight: 600;
}
.perf-item__bar {
height: 4px;
background: var(--bg-tertiary, #f3f4f6);
border-radius: 2px;
overflow: hidden;
}
.perf-item__fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.perf-item__fill--normal {
background: var(--color-success, #22c55e);
}
.perf-item__fill--warning {
background: var(--color-warning, #f59e0b);
}
.perf-item__fill--danger {
background: var(--color-danger, #ef4444);
}
.perf-item__model {
font-size: 10px;
color: var(--text-tertiary, #9ca3af);
}
/* 区块标题 */
.device-card__section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #6b7280);
margin-top: 2px;
}
/* 检查项网格 */
.device-card__check-grid {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
}
/* 单个检查项 */
.check-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: var(--border-radius-md, 8px);
border: 1px solid transparent;
transition: background 0.2s;
}
.check-item--pass {
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.15);
}
.check-item--warning {
background: rgba(245, 158, 11, 0.04);
border-color: rgba(245, 158, 11, 0.15);
}
.check-item--danger {
background: rgba(239, 68, 68, 0.04);
border-color: rgba(239, 68, 68, 0.15);
}
/* "接入中"占位:虚线边框 + 灰色 */
.check-item--pending {
background: var(--bg-tertiary, #f9fafb);
border: 1px dashed var(--border-color, #e5e7eb);
opacity: 0.7;
}
.check-item__icon {
font-size: 14px;
flex-shrink: 0;
}
.check-item__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.check-item__label {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.check-item__detail {
font-size: 10px;
color: var(--text-tertiary, #9ca3af);
}
.check-item--pending .check-item__detail {
color: var(--text-tertiary, #9ca3af);
font-style: italic;
}
.check-item__status-icon {
font-size: 13px;
flex-shrink: 0;
}
/* ====== 底部 ====== */
.health-dashboard__footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 2px 0;
font-size: 11px;
color: var(--text-tertiary, #9ca3af);
}
.footer__refresh {
cursor: pointer;
color: var(--accent, #07C160);
transition: opacity 0.2s;
}
.footer__refresh:hover {
opacity: 0.7;
}
.footer__refresh:active {
opacity: 0.5;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,305 +1,218 @@
<!-- =============================================================================
// 企微IT智能服务台 — H5用户端右侧面板
// =============================================================================
// 说明:桌面端右侧面板,三段式布局:
// 1. 上方:AI推送区(根据排查步骤和会话内容动态推送)
// 2. 中部:固定常用资源标签页(资源申请流程入口、常用必装软件)
// 3. 下方:趣味问答(答对可提高用户积分和等级)
// 注意:此面板仅在桌面端(≥500px)显示,手机端隐藏
// ============================================================================= -->
<!--
=============================================================================
// 企微IT智能服务台 — H5用户端右侧面板 v2.0
=============================================================================
// 说明:桌面端右侧面板,v2.0 手风琴 + 底部标签页布局:
//
// ┌─────────────────────────────────────┐
// │ ▸ 设备信息(默认折叠) │ ← 手风琴区域(互斥)
// │ ▸ 自助诊断(默认折叠) │
// ├─────────────────────────────────────┤
// │ 智能推荐(AI 动态推荐,始终可见) │ ← 推荐区域(flex:1)
// │ │
// │ (DynamicRecommend 组件) │
// │ │
// ├─────────────────────────────────────┤
// │ 排队等待(折叠/展开) │ ← 底部固定
// └─────────────────────────────────────┘
//
// v2.0 变更(2026-07-12):
// - 上层从"3个子模块平铺"改为"手风琴互斥折叠"
// - 设备信息↔自助诊断互斥,展开一个自动收起另一个
// - 新增"智能推荐"区域(DynamicRecommend 组件,Badge 红点提示)
// - 底部推荐区域始终可见,不随手风琴折叠
// v2.1 变更(2026-07-13):
// - 删除"软件安装"和"资源权限"标签页,全面 AI 化
// - 推荐区域从标签页改为直接展示,移除标签栏
//
// 注意:此面板仅在桌面端(≥500px)显示,手机端隐藏
// =============================================================================
-->
<template>
<div class="right-panel">
<!-- ====== 上方AI推送区 ====== -->
<div class="right-panel__section right-panel__ai-push">
<div class="right-panel__section-header">
<span class="right-panel__section-icon">🤖</span>
<span class="right-panel__section-title">AI 推荐</span>
</div>
<div class="right-panel__section-body">
<!-- 推荐卡片列表根据排查步骤和会话内容动态推送 -->
<div
v-for="item in aiPushItems"
:key="item.id"
class="ai-push-card"
:class="`ai-push-card--${item.type}`"
@click="handlePushClick(item)"
>
<div class="ai-push-card__header">
<span class="ai-push-card__icon">{{ item.icon }}</span>
<span class="ai-push-card__type-label">{{ item.typeLabel }}</span>
</div>
<div class="ai-push-card__title">{{ item.title }}</div>
<div v-if="item.subtitle" class="ai-push-card__subtitle">{{ item.subtitle }}</div>
<!-- ====== 手风琴区域设备信息 自助诊断互斥====== -->
<div class="right-panel__accordion">
<!-- 设备信息默认折叠 -->
<div class="accordion-item" :class="{ 'accordion-item--active': activeAccordion === 'device' }">
<div class="accordion-item__header" @click="toggleAccordion('device')">
<span class="accordion-item__icon">💻</span>
<span class="accordion-item__title">设备信息</span>
<span class="accordion-item__toggle">{{ activeAccordion === 'device' ? '▾' : '▸' }}</span>
</div>
<!-- 暂无推荐 -->
<div v-if="aiPushItems.length === 0" class="right-panel__empty">
<span>💡 对话过程中会自动推送相关资源</span>
<!-- 展开内容BasicInfoCardCPU/内存/硬盘默认隐藏避免焦虑 -->
<div v-show="activeAccordion === 'device'" class="accordion-item__content">
<BasicInfoCard />
</div>
</div>
<!-- 自助诊断默认折叠 -->
<div class="accordion-item" :class="{ 'accordion-item--active': activeAccordion === 'diagnosis' }">
<div class="accordion-item__header" @click="toggleAccordion('diagnosis')">
<span class="accordion-item__icon">🩺</span>
<span class="accordion-item__title">自助诊断</span>
<span class="accordion-item__toggle">{{ activeAccordion === 'diagnosis' ? '▾' : '▸' }}</span>
</div>
<!-- 展开内容SelfDiagnosis -->
<div v-show="activeAccordion === 'diagnosis'" class="accordion-item__content">
<SelfDiagnosis />
</div>
</div>
</div>
<!-- ====== 中部常用资源标签页 ====== -->
<div class="right-panel__section right-panel__resources">
<div class="right-panel__section-header">
<span class="right-panel__section-icon">📚</span>
<span class="right-panel__section-title">常用资源</span>
</div>
<!-- 标签切换 -->
<div class="right-panel__tabs">
<button
class="right-panel__tab"
:class="{ 'right-panel__tab--active': activeResourceTab === 'process' }"
@click="activeResourceTab = 'process'"
>申请流程</button>
<button
class="right-panel__tab"
:class="{ 'right-panel__tab--active': activeResourceTab === 'software' }"
@click="activeResourceTab = 'software'"
>必装软件</button>
</div>
<!-- 申请流程标签页内容 -->
<div v-if="activeResourceTab === 'process'" class="right-panel__tab-content">
<div
v-for="item in processItems"
:key="item.id"
class="resource-item"
@click="handleProcessClick(item)"
>
<span class="resource-item__icon">{{ item.icon }}</span>
<div class="resource-item__info">
<span class="resource-item__title">{{ item.title }}</span>
<span v-if="item.desc" class="resource-item__desc">{{ item.desc }}</span>
</div>
<span class="resource-item__arrow"></span>
</div>
</div>
<!-- 必装软件标签页内容 -->
<div v-if="activeResourceTab === 'software'" class="right-panel__tab-content">
<div
v-for="item in softwareItems"
:key="item.id"
class="resource-item"
@click="handleSoftwareClick(item)"
>
<span class="resource-item__icon">{{ item.icon }}</span>
<div class="resource-item__info">
<span class="resource-item__title">{{ item.title }}</span>
<span v-if="item.desc" class="resource-item__desc">{{ item.desc }}</span>
</div>
<span class="resource-item__arrow"></span>
</div>
</div>
<!-- ====== 分隔线 ====== -->
<div class="right-panel__divider"></div>
<!-- ====== 智能推荐区域始终可见flex:1====== -->
<div class="right-panel__recommend-section">
<DynamicRecommend />
</div>
<!-- ====== 下方趣味问答 ====== -->
<div class="right-panel__section right-panel__quiz">
<div class="right-panel__section-header">
<span class="right-panel__section-icon">🎯</span>
<span class="right-panel__section-title">趣味问答</span>
<span class="right-panel__quiz-score">🏆 {{ userScore }}</span>
</div>
<div class="right-panel__section-body">
<template v-if="currentQuiz">
<div class="quiz-question">{{ currentQuiz.question }}</div>
<div class="quiz-options">
<button
v-for="(option, idx) in currentQuiz.options"
:key="idx"
class="quiz-option"
:class="{
'quiz-option--correct': quizAnswered && idx === currentQuiz.correctIndex,
'quiz-option--wrong': quizAnswered && quizSelectedIndex === idx && idx !== currentQuiz.correctIndex
}"
:disabled="quizAnswered"
@click="handleQuizAnswer(idx)"
>
<span class="quiz-option__label">{{ optionLabels[idx] }}</span>
<span class="quiz-option__text">{{ option }}</span>
</button>
</div>
<!-- 答题结果 -->
<div v-if="quizAnswered" class="quiz-result">
<span v-if="quizSelectedIndex === currentQuiz.correctIndex" class="quiz-result--correct">
答对啦+10积分
</span>
<span v-else class="quiz-result--wrong">
答错了正确答案是 {{ optionLabels[currentQuiz.correctIndex] }}
</span>
</div>
</template>
<div v-else class="right-panel__empty">
<span>暂无问答题目</span>
<!-- ====== 分隔线 ====== -->
<div class="right-panel__divider"></div>
<!-- ====== 排队等待折叠/展开 v1 一致====== -->
<div class="right-panel__queue-section" :class="{ 'right-panel__queue-section--collapsed': !isQueueExpanded }">
<!-- 折叠态紧凑提示条 -->
<div v-if="!isQueueExpanded" class="queue-collapsed-bar" @click="toggleQueue">
<div class="queue-collapsed-bar__left">
<span class="queue-collapsed-bar__icon">{{ isQueued ? '⏳' : '✅' }}</span>
<span v-if="!isQueued" class="queue-collapsed-bar__text">当前无排队</span>
<span v-else class="queue-collapsed-bar__text">
排队中
<span v-if="queuePosition > 0" class="queue-collapsed-bar__position">#{{ queuePosition }}</span>
</span>
</div>
<span class="queue-collapsed-bar__toggle">{{ isQueued ? '展开查看' : '展开' }} </span>
</div>
<!-- 展开态标题栏 + 完整 QueueWaiting -->
<template v-else>
<div class="queue-header" @click="toggleQueue">
<span class="queue-header__icon"></span>
<span class="queue-header__title">排队等待</span>
<span v-if="isQueued" class="queue-header__badge"></span>
<span class="queue-header__toggle">收起 </span>
</div>
<!-- QueueWaiting 始终挂载 v-show 控制显隐保证轮询和 WS 事件不中断 -->
<div v-show="isQueueExpanded" class="right-panel__queue-content">
<QueueWaiting ref="queueWaitingRef" />
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
/**
* RightPanel 右侧面板组件
* 三段式布局:AI推送区 / 常用资源标签页 / 趣味问答
* RightPanel 右侧面板组件 v2.1
*
* v2.1 布局(2026-07-13 改造):
* - 手风琴区域:设备信息 ↔ 自助诊断(互斥折叠,均默认折叠)
* - 智能推荐区域:DynamicRecommend 组件(始终可见,无标签切换)
* - 排队等待:折叠/展开(与 v1 一致)
*
* 手风琴互斥逻辑:
* - activeAccordion 为 'device' | 'diagnosis' | null
* - 点击已展开的项 → 折叠(设为 null)
* - 点击未展开的项 → 展开该项(同时折叠另一项)
*
* 智能推荐逻辑:
* - 推荐区域始终可见(无标签栏切换)
* - 有新推荐时自动清除未读计数(内容已直接展示)
*
* 仅在桌面端(≥500px)显示
*/
import { ref, computed } from 'vue'
// 阶段二接入 Dify 动态推送时启用:
// import { useConversationStore } from '@/stores/conversation'
// const store = useConversationStore() // 阶段二接入 Dify 动态推送时启用
import { ref, computed, watch } from 'vue'
import BasicInfoCard from './BasicInfoCard.vue'
import SelfDiagnosis from './SelfDiagnosis.vue'
import DynamicRecommend from './DynamicRecommend.vue'
import QueueWaiting from './QueueWaiting.vue'
import { useConversationStore } from '@/stores/conversation'
// ── AI推送区 ──
const store = useConversationStore()
/** AI推送条目类型 */
interface AiPushItem {
id: string
type: 'guide' | 'process' | 'download' // 处理指南/申请流程/软件下载
icon: string
typeLabel: string
title: string
subtitle?: string
}
// ============================================================================
// 手风琴状态(设备信息 ↔ 自助诊断 互斥)
// ============================================================================
/** AI推送数据(阶段一使用静态数据,阶段二接入Dify动态推送 */
const aiPushItems = computed<AiPushItem[]>(() => {
// TODO: 阶段二根据排查步骤和会话内容动态生成推送
// 当前使用示例数据
return [
{
id: 'push-1',
type: 'guide',
icon: '📖',
typeLabel: '处理指南',
title: 'WiFi连接问题处理指南',
subtitle: '已解决28次类似问题',
},
{
id: 'push-2',
type: 'process',
icon: '📋',
typeLabel: '申请流程',
title: '网络连接申请流程',
subtitle: '在线申请,1-3个工作日',
},
{
id: 'push-3',
type: 'download',
icon: '💾',
typeLabel: '软件下载',
title: '无线网卡驱动下载',
subtitle: '适用于 Windows 10/11',
},
]
})
/** 当前展开的手风琴项:'device' | 'diagnosis' | nullnull = 全部折叠 */
const activeAccordion = ref<'device' | 'diagnosis' | null>(null)
/**
* 点击AI推送卡片
* 切换手风琴项展开/折叠
* - 点击已展开的项 → 折叠(设为 null)
* - 点击未展开的项 → 展开该项,同时折叠另一项(互斥)
*/
function handlePushClick(item: AiPushItem): void {
// TODO: 阶段二实现推送跳转
console.log('[RightPanel] AI推送点击:', item.title)
}
// ── 常用资源标签页 ──
/** 当前激活的资源标签页 */
const activeResourceTab = ref<'process' | 'software'>('process')
/** 申请流程列表 */
const processItems = ref([
{ id: 'p-1', icon: '💻', title: 'IT设备申请', desc: '电脑/显示器/外设' },
{ id: 'p-2', icon: '🔐', title: '权限申请', desc: '系统/文件夹/VPN' },
{ id: 'p-3', icon: '🌐', title: 'VPN申请', desc: '远程办公网络' },
{ id: 'p-4', icon: '📧', title: '邮箱别名申请', desc: '别名/分发组' },
])
/** 必装软件列表 */
const softwareItems = ref([
{ id: 's-1', icon: '📝', title: 'Office 365', desc: 'Word/Excel/PPT' },
{ id: 's-2', icon: '📄', title: 'Adobe Acrobat', desc: 'PDF阅读/编辑' },
{ id: 's-3', icon: '💬', title: '企业微信', desc: '即时通讯/协作' },
{ id: 's-4', icon: '🛡️', title: '火绒安全', desc: '杀毒/终端防护' },
])
/**
* 点击申请流程项
*/
function handleProcessClick(item: { id: string; title: string }): void {
// TODO: 阶段二实现流程跳转
console.log('[RightPanel] 申请流程点击:', item.title)
}
/**
* 点击软件下载项
*/
function handleSoftwareClick(item: { id: string; title: string }): void {
// TODO: 阶段二实现软件下载
console.log('[RightPanel] 软件下载点击:', item.title)
}
// ── 趣味问答 ──
/** 问答题目类型 */
interface QuizQuestion {
id: string
question: string
options: string[]
correctIndex: number // 正确答案的索引
}
/** 选项标签 */
const optionLabels = ['A', 'B', 'C', 'D']
/** 用户积分 */
const userScore = ref(0)
/** 是否已答题 */
const quizAnswered = ref(false)
/** 用户选择的答案索引 */
const quizSelectedIndex = ref(-1)
/** 问答题目列表(阶段一使用静态数据) */
const quizQuestions = ref<QuizQuestion[]>([
{
id: 'q-1',
question: 'IT服务台电话分机号是?',
options: ['8001', '8002', '8003', '8004'],
correctIndex: 0,
},
{
id: 'q-2',
question: '电脑无法连接WiFi时,首先应该检查什么?',
options: ['重启路由器', 'WiFi适配器是否禁用', '联系网络管理员', '重新安装系统'],
correctIndex: 1,
},
{
id: 'q-3',
question: 'VPN申请一般需要几个工作日?',
options: ['1个工作日', '1-3个工作日', '3-5个工作日', '5个工作日以上'],
correctIndex: 1,
},
])
/** 当前问答题目 */
const currentQuiz = computed<QuizQuestion | null>(() => {
return quizQuestions.value[0] || null
})
/**
* 用户回答问答
* @param index - 用户选择的选项索引
*/
function handleQuizAnswer(index: number): void {
if (quizAnswered.value) return
quizAnswered.value = true
quizSelectedIndex.value = index
// 答对加10分
if (currentQuiz.value && index === currentQuiz.value.correctIndex) {
userScore.value += 10
function toggleAccordion(item: 'device' | 'diagnosis'): void {
if (activeAccordion.value === item) {
// 已展开 → 折叠
activeAccordion.value = null
} else {
// 未展开 → 展开(互斥:自动折叠另一项)
activeAccordion.value = item
}
}
// ============================================================================
// 智能推荐未读计数
// ============================================================================
/**
* 监听未读推荐计数变化:
* - 推荐区域始终可见,有新推荐时自动清除未读计数
* 为什么:用户无需切换标签即可看到推荐,红点无意义
*/
watch(() => store.unreadRecommendCount, (newVal) => {
if (newVal > 0) {
store.clearUnreadRecommend()
}
})
// ============================================================================
// 排队折叠/展开(与 v1 一致,保持不变)
// ============================================================================
/** QueueWaiting 组件引用(用于 WS 事件转发 + 获取排队位置) */
const queueWaitingRef = ref<InstanceType<typeof QueueWaiting> | null>(null)
/** 是否正在排队(后端 queued/ai_handling 都映射为前端 waiting + 无坐席分配) */
const isQueued = computed(() => {
const status = store.currentConversation?.status
return status === 'waiting' && store.currentConversation?.agent_id === ''
})
/** 排队位置(从 QueueWaiting 组件获取,用于折叠态显示) */
const queuePosition = computed(() => {
return queueWaitingRef.value?.currentPosition || 0
})
/** 排队区域是否展开 */
const isQueueExpanded = ref(false)
/**
* 监听排队状态变化:
* - 开始排队 → 自动展开
* - 排队结束 → 不自动折叠(保持用户当前状态)
*/
watch(isQueued, (newVal) => {
if (newVal) {
isQueueExpanded.value = true
}
}, { immediate: true })
/**
* 切换排队区域展开/折叠
*/
function toggleQueue(): void {
isQueueExpanded.value = !isQueueExpanded.value
}
// 暴露 QueueWaiting ref 供父组件转发 WS 事件
defineExpose({
queueWaitingRef,
})
</script>
<style scoped>
@@ -308,322 +221,200 @@ function handleQuizAnswer(index: number): void {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-secondary);
border-left: 1px solid var(--border-color);
background-color: var(--bg-secondary, #f5f5f5);
border-left: 1px solid var(--border-color, #e5e7eb);
overflow: hidden;
}
/* ====== 面板区域通用样式 ====== */
.right-panel__section {
border-bottom: 1px solid var(--border-color);
}
.right-panel__section-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.right-panel__section-icon {
font-size: 16px;
}
.right-panel__section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.right-panel__section-body {
padding: 10px 14px;
}
.right-panel__empty {
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
padding: 16px 0;
}
/* ====== AI推送区 ====== */
.right-panel__ai-push {
flex: 1;
overflow-y: auto;
}
.ai-push-card {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.ai-push-card:last-child {
margin-bottom: 0;
}
.ai-push-card:hover {
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
}
.ai-push-card:active {
transform: scale(0.98);
}
.ai-push-card__header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.ai-push-card__icon {
font-size: 14px;
}
.ai-push-card__type-label {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
font-weight: 500;
}
/* 处理指南类型 */
.ai-push-card--guide .ai-push-card__type-label {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
/* 申请流程类型 */
.ai-push-card--process .ai-push-card__type-label {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
/* 软件下载类型 */
.ai-push-card--download .ai-push-card__type-label {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.ai-push-card__title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
}
.ai-push-card__subtitle {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
}
/* ====== 常用资源标签页 ====== */
.right-panel__resources {
flex: 1;
overflow-y: auto;
}
.right-panel__tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
}
.right-panel__tab {
flex: 1;
padding: 8px 12px;
border: none;
background: transparent;
font-size: 13px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s;
border-bottom: 2px solid transparent;
font-family: inherit;
}
.right-panel__tab--active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.right-panel__tab:hover:not(.right-panel__tab--active) {
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.right-panel__tab-content {
padding: 8px 14px;
}
.resource-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
margin-bottom: 4px;
}
.resource-item:last-child {
margin-bottom: 0;
}
.resource-item:hover {
background: var(--bg-tertiary);
}
.resource-item:active {
background: var(--border-color);
}
.resource-item__icon {
font-size: 20px;
/* ============================================================================
// 手风琴区域
// ============================================================================ */
.right-panel__accordion {
flex-shrink: 0;
}
.resource-item__info {
flex: 1;
min-width: 0;
}
.resource-item__title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
display: block;
}
.resource-item__desc {
font-size: 11px;
color: var(--text-tertiary);
display: block;
margin-top: 2px;
}
.resource-item__arrow {
font-size: 14px;
color: var(--text-tertiary);
flex-shrink: 0;
}
/* ====== 趣味问答 ====== */
.right-panel__quiz {
flex-shrink: 0;
}
.right-panel__quiz-score {
margin-left: auto;
font-size: 12px;
color: var(--color-warning);
font-weight: 600;
}
.quiz-question {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 10px;
}
.quiz-options {
display: flex;
flex-direction: column;
}
.accordion-item {
background: var(--bg-primary, #fff);
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.accordion-item--active {
/* 展开态最大高度限制(防止挤占底部标签页空间) */
max-height: 50%;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 手风琴标题栏 */
.accordion-item__header {
display: flex;
align-items: center;
gap: 6px;
}
.quiz-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
padding: 10px 14px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
text-align: left;
transition: background 0.2s;
-webkit-tap-highlight-color: transparent;
}
.quiz-option:hover:not(:disabled) {
border-color: var(--accent);
background: var(--accent-soft);
.accordion-item__header:hover {
background: var(--bg-tertiary, #f9fafb);
}
.quiz-option:active:not(:disabled) {
transform: scale(0.98);
.accordion-item__header:active {
background: var(--border-color, #e5e7eb);
}
.quiz-option:disabled {
cursor: default;
}
/* 正确选项 */
.quiz-option--correct {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.1);
}
/* 错误选项 */
.quiz-option--wrong {
border-color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.quiz-option__label {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--bg-tertiary);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
.accordion-item__icon {
font-size: 15px;
flex-shrink: 0;
}
.quiz-option--correct .quiz-option__label {
background: #22c55e;
color: #fff;
}
.quiz-option--wrong .quiz-option__label {
background: #ef4444;
color: #fff;
}
.quiz-option__text {
font-size: 13px;
color: var(--text-primary);
}
.quiz-result {
margin-top: 8px;
font-size: 13px;
.accordion-item__title {
font-size: 14px;
font-weight: 600;
text-align: center;
color: var(--text-primary, #1f2937);
flex: 1;
}
.quiz-result--correct {
color: #22c55e;
.accordion-item__toggle {
font-size: 12px;
color: var(--text-placeholder, #9ca3af);
flex-shrink: 0;
}
.quiz-result--wrong {
color: #ef4444;
/* 手风琴展开内容 */
.accordion-item__content {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 0 10px 8px;
}
/* ============================================================================
// 分隔线
// ============================================================================ */
.right-panel__divider {
height: 1px;
background: var(--border-color, #e5e7eb);
flex-shrink: 0;
}
/* ============================================================================
// 智能推荐区域(v2.1:原标签页区域简化为单一直接展示)
// ============================================================================ */
.right-panel__recommend-section {
flex: 1;
overflow: hidden;
min-height: 0;
position: relative;
}
/* ============================================================================
// 排队等待区域(与 v1 一致)
// ============================================================================ */
.right-panel__queue-section {
flex-shrink: 0;
border-top: 1px solid transparent;
transition: all 0.3s ease;
max-height: 60%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.right-panel__queue-section--collapsed {
max-height: 48px;
}
/* ── 折叠态提示条 ── */
.queue-collapsed-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
cursor: pointer;
transition: background 0.2s;
background: var(--bg-primary, #fff);
}
.queue-collapsed-bar:hover { background: var(--bg-tertiary, #f9fafb); }
.queue-collapsed-bar:active { background: var(--border-color, #e5e7eb); }
.queue-collapsed-bar__left {
display: flex;
align-items: center;
gap: 6px;
}
.queue-collapsed-bar__icon { font-size: 14px; }
.queue-collapsed-bar__text {
font-size: 13px;
color: var(--text-secondary, #6b7280);
font-weight: 500;
}
.queue-collapsed-bar__position {
font-size: 15px;
font-weight: 700;
color: var(--color-warning, #f59e0b);
margin-left: 2px;
}
.queue-collapsed-bar__toggle {
font-size: 12px;
color: var(--accent, #07C160);
font-weight: 500;
}
/* ── 展开态标题栏 ── */
.queue-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
cursor: pointer;
transition: background 0.2s;
background: var(--bg-primary, #fff);
border-bottom: 1px solid var(--border-color, #e5e7eb);
position: relative;
}
.queue-header:hover { background: var(--bg-tertiary, #f9fafb); }
.queue-header__icon { font-size: 15px; }
.queue-header__title { font-size: 14px; font-weight: 600; color: var(--text-primary, #1f2937); }
.queue-header__toggle {
margin-left: auto;
font-size: 12px;
color: var(--accent, #07C160);
font-weight: 500;
}
.queue-header__badge {
position: absolute;
top: 8px;
left: 26px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-danger, #ef4444);
animation: badge-pulse 1.5s ease-in-out infinite;
}
@keyframes badge-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.3); }
}
/* ── 排队内容区域 ── */
.right-panel__queue-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>
@@ -0,0 +1,512 @@
<!--
=============================================================================
// 企微IT智能服务台 — H5 风险标签页
=============================================================================
// 说明:展示员工 IT 风险信息,三 Tab 切换 + 折叠手风琴
// 1. 重要信息:域账号密码到期、零信任账号冻结、税友云盘冻结、税友邮箱状态
// 2. 安全风险:火绒安装、系统补丁、高危软件、病毒状态、内部攻击(接入中)、网络代理(接入中)
// 3. 合规自查:未登记自备电脑、未审批商业软件
//
// 折叠策略:标题+状态摘要(默认折叠,点击展开详情)
// Tab 标题带数字徽章(异常数量)
// 重要信息为员工维度(非设备维度),其他两个为设备维度
//
// 数据来源:Mock 数据,后续对接后端 IT 健康 API
// =============================================================================
-->
<template>
<div class="risk-tabs">
<!-- ====== Tab 切换 ====== -->
<div class="risk-tabs__bar">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === tab.key }"
@click="activeTab = tab.key"
>
{{ tab.label }}
<span v-if="tab.badge > 0" class="tab-btn__badge">{{ tab.badge }}</span>
</button>
</div>
<!-- ====== 重要信息 ====== -->
<div v-if="activeTab === 'important'" class="risk-tabs__content">
<div
v-for="item in importantItems"
:key="item.key"
class="risk-item"
:class="`risk-item--${item.level}`"
>
<div class="risk-item__header" @click="toggleItem(item.key)">
<span class="risk-item__icon">{{ item.icon }}</span>
<span class="risk-item__name">{{ item.label }}</span>
<span class="risk-item__status" :class="`risk-item__status--${item.level}`">
{{ item.statusText }}
</span>
<span class="risk-item__toggle">{{ expandedItems.has(item.key) ? '▴' : '▾' }}</span>
</div>
<!-- 展开详情 -->
<div v-if="expandedItems.has(item.key)" class="risk-item__detail">
<div v-for="line in item.detailLines" :key="line" class="detail-line">
{{ line }}
</div>
<button v-if="item.actionText" class="risk-item__action" @click.stop="handleAction(item)">
{{ item.actionText }}
</button>
</div>
</div>
</div>
<!-- ====== 安全风险 ====== -->
<div v-else-if="activeTab === 'security'" class="risk-tabs__content">
<div
v-for="item in securityItems"
:key="item.key"
class="risk-item"
:class="[
`risk-item--${item.level}`,
{ 'risk-item--pending': item.level === 'pending' }
]"
>
<div class="risk-item__header" @click="toggleItem(item.key)">
<span class="risk-item__icon">{{ item.icon }}</span>
<span class="risk-item__name">{{ item.label }}</span>
<span class="risk-item__status" :class="`risk-item__status--${item.level}`">
{{ item.statusText }}
</span>
<span class="risk-item__toggle">{{ expandedItems.has(item.key) ? '▴' : '▾' }}</span>
</div>
<div v-if="expandedItems.has(item.key)" class="risk-item__detail">
<div v-for="line in item.detailLines" :key="line" class="detail-line">
{{ line }}
</div>
<button v-if="item.actionText" class="risk-item__action" @click.stop="handleAction(item)">
{{ item.actionText }}
</button>
</div>
</div>
</div>
<!-- ====== 合规自查 ====== -->
<div v-else-if="activeTab === 'compliance'" class="risk-tabs__content">
<div
v-for="item in complianceItems"
:key="item.key"
class="risk-item"
:class="`risk-item--${item.level}`"
>
<div class="risk-item__header" @click="toggleItem(item.key)">
<span class="risk-item__icon">{{ item.icon }}</span>
<span class="risk-item__name">{{ item.label }}</span>
<span class="risk-item__status" :class="`risk-item__status--${item.level}`">
{{ item.statusText }}
</span>
<span class="risk-item__toggle">{{ expandedItems.has(item.key) ? '▴' : '▾' }}</span>
</div>
<div v-if="expandedItems.has(item.key)" class="risk-item__detail">
<div v-for="line in item.detailLines" :key="line" class="detail-line">
{{ line }}
</div>
<button v-if="item.actionText" class="risk-item__action" @click.stop="handleAction(item)">
{{ item.actionText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
// ── 类型定义 ──
type RiskLevel = 'normal' | 'warning' | 'danger' | 'pending'
interface RiskItem {
key: string
label: string
icon: string
level: RiskLevel // normal/warning/danger/pending
statusText: string // 状态文字(如"5天"、"正常"、"未安装"
detailLines: string[] // 展开后的详情行
actionText: string // 操作按钮文字(空则不显示)
}
// ── Mock 数据 ──
/** 重要信息(员工维度) */
const importantItems = ref<RiskItem[]>([
{
key: 'ad_password',
label: '域账号密码到期',
icon: '🔑',
level: 'warning',
statusText: '5天',
detailLines: [
'到期时间: 2026-07-17 14:30',
'修改方式: Ctrl+Alt+Del → 更改密码',
'密码规则: 至少8位, 含大小写字母+数字+特殊字符',
],
actionText: '查看修改指南',
},
{
key: 'zero_trust',
label: '零信任账号',
icon: '🛡️',
level: 'normal',
statusText: '正常',
detailLines: [
'账号状态: 正常',
'最近登录: 2026-07-12 08:45',
'登录设备: DESKTOP-SIMON01',
],
actionText: '',
},
{
key: 'cloud_disk',
label: '税友云盘',
icon: '☁️',
level: 'normal',
statusText: '正常',
detailLines: [
'状态: 正常',
'已用容量: 12.3GB / 50GB',
'最近同步: 2026-07-12 09:00',
],
actionText: '',
},
{
key: 'email_status',
label: '税友邮箱',
icon: '📧',
level: 'normal',
statusText: '正常',
detailLines: [
'邮箱: songxian@servyou.com.cn',
'密码到期: 2026-08-15',
'容量: 2.1GB / 5GB',
],
actionText: '',
},
])
/** 安全风险(设备维度) */
const securityItems = ref<RiskItem[]>([
{
key: 'huorong',
label: '火绒安装',
icon: '🛡️',
level: 'normal',
statusText: '已安装',
detailLines: [
'版本: v2.4.1',
'病毒库: 2026-07-12',
'最近全盘扫描: 2026-07-01',
],
actionText: '',
},
{
key: 'patches',
label: '系统补丁',
icon: '🔧',
level: 'warning',
statusText: '2个高危',
detailLines: [
'KB5037: Windows 安全更新 (高危)',
'KB5039: .NET Framework 更新 (高危)',
'建议: 尽快安装, 可联系IT协助',
],
actionText: '申请安装补丁',
},
{
key: 'risk_software',
label: '高危软件',
icon: '📦',
level: 'normal',
statusText: '未发现',
detailLines: [
'扫描时间: 2026-07-12 09:00',
'结果: 未发现高危软件',
],
actionText: '',
},
{
key: 'virus',
label: '病毒状态',
icon: '🦠',
level: 'normal',
statusText: '安全',
detailLines: [
'状态: 无病毒事件',
'最近扫描: 2026-07-01 全盘扫描',
'实时防护: 已开启',
],
actionText: '',
},
{
key: 'internal_attack',
label: '内部攻击',
icon: '⚔️',
level: 'pending',
statusText: '接入中',
detailLines: [
'数据源: SIEM/IDS (接入中)',
'预计完成: 2026-Q4',
'当前状态: 暂无数据',
],
actionText: '',
},
{
key: 'network_proxy',
label: '网络代理',
icon: '🌐',
level: 'pending',
statusText: '接入中',
detailLines: [
'数据源: 联软网络准入 (接入中)',
'预计完成: 2026-Q4',
'当前状态: 暂无数据',
],
actionText: '',
},
])
/** 合规自查(设备维度) */
const complianceItems = ref<RiskItem[]>([
{
key: 'byod',
label: '自备电脑',
icon: '💻',
level: 'normal',
statusText: '公司配发',
detailLines: [
'设备类型: 公司配发设备',
'资产编号: SY-PC-00821',
'登记状态: 已登记',
],
actionText: '',
},
{
key: 'unapproved_sw',
label: '未审批商业软件',
icon: '📋',
level: 'warning',
statusText: '1个未审批',
detailLines: [
'软件: Adobe Photoshop 2024 (未审批)',
'发现时间: 2026-07-10',
'建议: 提交软件安装审批申请',
],
actionText: '提交审批申请',
},
])
// ── 状态 ──
const activeTab = ref<'important' | 'security' | 'compliance'>('important')
/** 展开的条目 key 集合 */
const expandedItems = reactive(new Set<string>())
// ── 计算属性 ──
/** 各 Tab 的异常数量 */
const tabs = computed(() => {
const countAbnormal = (items: RiskItem[]) =>
items.filter(i => i.level === 'warning' || i.level === 'danger').length
return [
{ key: 'important' as const, label: '重要信息', badge: countAbnormal(importantItems.value) },
{ key: 'security' as const, label: '安全风险', badge: countAbnormal(securityItems.value) },
{ key: 'compliance' as const, label: '合规自查', badge: countAbnormal(complianceItems.value) },
]
})
// ── 方法 ──
/** 切换条目展开/折叠 */
function toggleItem(key: string) {
if (expandedItems.has(key)) {
expandedItems.delete(key)
} else {
expandedItems.add(key)
}
}
/** 处理操作按钮点击 */
function handleAction(item: RiskItem) {
// TODO: 后续对接具体操作
console.log('[Risk] action:', item.key, item.actionText)
}
</script>
<style scoped>
.risk-tabs {
background: var(--bg-primary, #fff);
border-radius: var(--border-radius-lg, 12px);
border: 1px solid var(--border-color, #e5e7eb);
overflow: hidden;
}
/* ====== Tab 切换 ====== */
.risk-tabs__bar {
display: flex;
gap: 4px;
padding: 6px;
background: var(--bg-tertiary, #f9fafb);
}
.tab-btn {
flex: 1;
padding: 5px 8px;
border: none;
border-radius: var(--border-radius-md, 8px);
font-size: 11px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
background: transparent;
color: var(--text-secondary, #6b7280);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.tab-btn--active {
background: var(--accent, #07C160);
color: #fff;
}
.tab-btn__badge {
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.tab-btn--active .tab-btn__badge {
background: rgba(255, 255, 255, 0.3);
}
/* ====== 内容区 ====== */
.risk-tabs__content {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* ====== 风险条目 ====== */
.risk-item {
border-radius: var(--border-radius-md, 8px);
border: 1px solid transparent;
overflow: hidden;
}
.risk-item--normal {
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.12);
}
.risk-item--warning {
background: rgba(245, 158, 11, 0.04);
border-color: rgba(245, 158, 11, 0.12);
}
.risk-item--danger {
background: rgba(239, 68, 68, 0.04);
border-color: rgba(239, 68, 68, 0.12);
}
.risk-item--pending {
background: var(--bg-tertiary, #f9fafb);
border: 1px dashed var(--border-color, #e5e7eb);
opacity: 0.7;
}
/* 条目头部 */
.risk-item__header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
cursor: pointer;
transition: background 0.2s;
}
.risk-item__header:hover {
background: rgba(0, 0, 0, 0.02);
}
.risk-item__icon {
font-size: 14px;
flex-shrink: 0;
}
.risk-item__name {
flex: 1;
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.risk-item__status {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
}
.risk-item__status--normal { background: rgba(34, 197, 94, 0.15); color: #16a34a; }
.risk-item__status--warning { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.risk-item__status--danger { background: rgba(239, 68, 68, 0.15); color: #dc2626; }
.risk-item__status--pending { color: var(--text-tertiary, #9ca3af); font-style: italic; }
.risk-item__toggle {
font-size: 12px;
color: var(--text-tertiary, #9ca3af);
flex-shrink: 0;
}
/* 条目展开详情 */
.risk-item__detail {
padding: 0 10px 8px 30px;
border-top: 1px solid rgba(0, 0, 0, 0.04);
padding-top: 6px;
margin-top: 2px;
}
.detail-line {
font-size: 11px;
color: var(--text-secondary, #6b7280);
line-height: 1.6;
}
.risk-item__action {
margin-top: 6px;
font-size: 11px;
font-weight: 500;
font-family: inherit;
padding: 4px 12px;
border-radius: var(--border-radius-md, 8px);
border: none;
background: var(--accent, #07C160);
color: #fff;
cursor: pointer;
transition: opacity 0.2s;
}
.risk-item__action:hover { opacity: 0.85; }
</style>
@@ -0,0 +1,924 @@
<!--
=============================================================================
// 企微IT智能服务台 — H5 自助诊断(六标签页统一诊断)
=============================================================================
// 说明:将网络联通、账号权限、设备硬件、系统软件、终端安全、合规检查
// 合并在同一区域,用 3×2 网格标签页区分
//
// 两种展示模式:
// 1. 诊断 chips(网络联通/系统软件 Tab):点击执行诊断 → 原地展开结果
// 2. 折叠手风琴(账号权限/设备硬件/终端安全/合规检查 Tab):
// 默认折叠只显示标题+状态摘要,点击展开详情
//
// 一键诊断:仅执行当前 Tab 的 chips 诊断项
// 数据来源:Mock 数据,后续对接后端诊断 API + IT 健康 API
// =============================================================================
-->
<template>
<div class="self-diag">
<!-- 标题栏 -->
<div class="self-diag__header">
<span class="header__title">自助诊断</span>
<button
v-if="currentTab.diagItems.length > 0"
class="header__btn"
:disabled="isRunningAll"
@click="runAllDiag"
>
{{ isRunningAll ? '诊断中...' : '一键诊断' }}
</button>
</div>
<!-- Tab 3×2 网格 -->
<div class="self-diag__tabs">
<button
v-for="tab in tabs"
:key="tab.key"
class="tab-btn"
:class="{ 'tab-btn--active': activeTabKey === tab.key }"
@click="activeTabKey = tab.key"
>
{{ tab.label }}
<span v-if="tab.badge > 0" class="tab-btn__badge">{{ tab.badge }}</span>
</button>
</div>
<!-- ====== Tab 内容区 ====== -->
<div class="self-diag__content">
<!-- 诊断 chips diagItems 时显示 -->
<template v-if="currentTab.diagItems.length > 0">
<div class="diag-chips">
<button
v-for="item in currentTab.diagItems"
:key="item.key"
class="diag-chip"
:class="`diag-chip--${item.status}`"
:disabled="item.status === 'running'"
@click="runSingle(item.key)"
>
<span class="diag-chip__name">{{ item.label }}</span>
<span class="diag-chip__icon">
<template v-if="item.status === 'running'">...</template>
<template v-else-if="item.status === 'pass'"></template>
<template v-else-if="item.status === 'warning'">!</template>
<template v-else-if="item.status === 'fail'"></template>
<template v-else></template>
</span>
</button>
</div>
<!-- 诊断结果原地展开 -->
<div v-if="hasDiagResults" class="diag-results">
<div class="diag-results__label">诊断结果</div>
<div
v-for="item in currentTab.diagItems.filter(d => d.status === 'pass' || d.status === 'warning' || d.status === 'fail')"
:key="item.key"
class="result-item"
:class="`result-item--${item.status}`"
>
<div class="result-item__header">
<span class="result-item__name">{{ item.label }}</span>
<span class="result-item__status" :class="`result-item__status--${item.status}`">
{{ diagStatusText(item.status) }}
</span>
</div>
<div v-if="item.detail" class="result-item__detail">{{ item.detail }}</div>
</div>
</div>
</template>
<!-- 折叠手风琴 riskItems 时显示 -->
<template v-if="currentTab.riskItems.length > 0">
<div class="risk-list">
<div
v-for="item in currentTab.riskItems"
:key="item.key"
class="risk-item"
:class="[
`risk-item--${item.level}`,
{ 'risk-item--pending': item.level === 'pending' }
]"
>
<!-- 折叠头部 -->
<div class="risk-item__header" @click="toggleRiskItem(item.key)">
<span class="risk-item__icon">{{ item.icon }}</span>
<span class="risk-item__name">{{ item.label }}</span>
<span class="risk-item__status" :class="`risk-item__status--${item.level}`">
{{ item.statusText }}
</span>
<span class="risk-item__toggle">{{ expandedRisks.has(item.key) ? '▴' : '▾' }}</span>
</div>
<!-- 展开详情 -->
<div v-if="expandedRisks.has(item.key)" class="risk-item__detail">
<div v-for="line in item.detailLines" :key="line" class="detail-line">
{{ line }}
</div>
<button v-if="item.actionText" class="risk-item__action" @click.stop="handleRiskAction(item)">
{{ item.actionText }}
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
// ── 类型定义 ──
/** 诊断状态:idle=未执行 | running=执行中 | pass=通过 | warning=警告 | fail=失败 */
type DiagStatus = 'idle' | 'running' | 'pass' | 'warning' | 'fail'
/** 风险等级:normal=正常 | warning=警告 | danger=危险 | pending=接入中 */
type RiskLevel = 'normal' | 'warning' | 'danger' | 'pending'
/** 诊断项(chips,点击执行诊断) */
interface DiagItem {
key: string
label: string
status: DiagStatus
detail: string
}
/** 风险项(折叠手风琴,展示状态信息) */
interface RiskItem {
key: string
label: string
icon: string
level: RiskLevel
statusText: string
detailLines: string[]
actionText: string
}
/** 标签页 */
interface DiagTab {
key: string
label: string
diagItems: DiagItem[]
riskItems: RiskItem[]
}
// ── Mock 数据 ──
/** 所有标签页数据 */
const tabsData = ref<DiagTab[]>([
// ── Tab 1: 网络联通 ──
{
key: 'network',
label: '网络联通',
diagItems: [
{ key: 'net_connect', label: '网络连通性', status: 'idle', detail: '' },
{ key: 'net_dns', label: 'DNS解析', status: 'idle', detail: '' },
{ key: 'net_gateway', label: '网关检测', status: 'idle', detail: '' },
{ key: 'net_proxy', label: '代理检测', status: 'idle', detail: '' },
{ key: 'net_vpn', label: 'VPN诊断', status: 'idle', detail: '' },
{ key: 'net_fileshare', label: '文件共享', status: 'idle', detail: '' },
],
riskItems: [],
},
// ── Tab 2: 账号权限 ──
{
key: 'account',
label: '账号权限',
diagItems: [],
riskItems: [
{
key: 'ad_password',
label: '域账号密码到期',
icon: '🔑',
level: 'warning',
statusText: '5天',
detailLines: [
'到期时间: 2026-07-17 14:30',
'修改方式: Ctrl+Alt+Del → 更改密码',
'密码规则: 至少8位, 含大小写字母+数字+特殊字符',
],
actionText: '查看修改指南',
},
{
key: 'zero_trust',
label: '零信任账号',
icon: '🛡️',
level: 'normal',
statusText: '正常',
detailLines: [
'账号状态: 正常',
'最近登录: 2026-07-12 08:45',
'登录设备: DESKTOP-SIMON01',
],
actionText: '',
},
{
key: 'cloud_disk',
label: '税友云盘',
icon: '☁️',
level: 'normal',
statusText: '正常',
detailLines: [
'状态: 正常',
'已用容量: 12.3GB / 50GB',
'最近同步: 2026-07-12 09:00',
],
actionText: '',
},
{
key: 'email_status',
label: '税友邮箱',
icon: '📧',
level: 'normal',
statusText: '正常',
detailLines: [
'邮箱: songxian@servyou.com.cn',
'密码到期: 2026-08-15',
'容量: 2.1GB / 5GB',
],
actionText: '',
},
],
},
// ── Tab 3: 设备硬件 ──
{
key: 'hardware',
label: '设备硬件',
diagItems: [],
riskItems: [
{
key: 'hw_cpu',
label: 'CPU 使用率',
icon: '🔲',
level: 'normal',
statusText: '23%',
detailLines: [
'型号: Intel i7-12700 (12核)',
'当前使用率: 23%',
'温度: 52°C (正常)',
'阈值: 使用率 >85% 或温度 >85°C 触发警告',
],
actionText: '',
},
{
key: 'hw_memory',
label: '内存健康',
icon: '📏',
level: 'normal',
statusText: '45%',
detailLines: [
'总容量: 16GB',
'已用: 7.2GB (45%)',
'类型: DDR5-4800',
'阈值: 使用率 >85% 触发警告',
],
actionText: '',
},
{
key: 'hw_disk',
label: '硬盘SMART',
icon: '💿',
level: 'warning',
statusText: '1个警告',
detailLines: [
'C盘 (SSD 256GB): SMART 正常, 使用率 67%',
'D盘 (SSD 256GB): SMART 正常, 使用率 43%',
'警告: C盘剩余空间不足 84GB (<100GB)',
'建议: 清理临时文件或迁移大文件至D盘',
],
actionText: '查看清理建议',
},
{
key: 'hw_driver',
label: '驱动状态',
icon: '🔌',
level: 'normal',
statusText: '正常',
detailLines: [
'显卡驱动: NVIDIA 552.22 (最新)',
'网卡驱动: Intel 12.2.1 (最新)',
'声卡驱动: Realtek 6.0 (最新)',
'蓝牙驱动: 正常',
],
actionText: '',
},
{
key: 'hw_peripheral',
label: '外设连接',
icon: '🖱️',
level: 'normal',
statusText: '3个设备',
detailLines: [
'显示器: DELL U2723QE (USB-C 连接)',
'键鼠: 罗技 MX Keys + MX Master (蓝牙)',
'打印机: \\\\\print-srv\\\HP-5F (网络)',
'摄像头: 内置 (正常)',
],
actionText: '',
},
],
},
// ── Tab 4: 系统软件 ──
{
key: 'system',
label: '系统软件',
diagItems: [
{ key: 'sys_service', label: '系统服务', status: 'idle', detail: '' },
{ key: 'sys_cert', label: '证书检测', status: 'idle', detail: '' },
],
riskItems: [
{
key: 'sys_patches',
label: '系统补丁',
icon: '🔧',
level: 'warning',
statusText: '2个高危',
detailLines: [
'KB5037: Windows 安全更新 (高危)',
'KB5039: .NET Framework 更新 (高危)',
'建议: 尽快安装, 可联系IT协助',
],
actionText: '申请安装补丁',
},
{
key: 'sys_risk_sw',
label: '高危软件',
icon: '📦',
level: 'normal',
statusText: '未发现',
detailLines: [
'扫描时间: 2026-07-12 09:00',
'结果: 未发现高危软件',
],
actionText: '',
},
{
key: 'sys_email_client',
label: '邮件客户端',
icon: '📧',
level: 'warning',
statusText: '配置异常',
detailLines: [
'客户端: Outlook 2019',
'问题: 邮箱密码可能已过期',
'建议: 检查邮箱密码或重新配置',
],
actionText: '查看配置指南',
},
{
key: 'sys_printer',
label: '打印机状态',
icon: '🖨️',
level: 'normal',
statusText: '正常',
detailLines: [
'默认打印机: \\\\\print-srv\\\HP-5F',
'状态: 在线, 墨粉充足',
'队列: 0 个任务',
],
actionText: '',
},
],
},
// ── Tab 5: 终端安全 ──
{
key: 'security',
label: '终端安全',
diagItems: [],
riskItems: [
{
key: 'sec_huorong',
label: '火绒安装',
icon: '🛡️',
level: 'normal',
statusText: '已安装',
detailLines: [
'版本: v2.4.1',
'病毒库: 2026-07-12',
'最近全盘扫描: 2026-07-01',
],
actionText: '',
},
{
key: 'sec_virus',
label: '病毒状态',
icon: '🦠',
level: 'normal',
statusText: '安全',
detailLines: [
'状态: 无病毒事件',
'最近扫描: 2026-07-01 全盘扫描',
'实时防护: 已开启',
],
actionText: '',
},
{
key: 'sec_internal',
label: '内部攻击',
icon: '⚔️',
level: 'pending',
statusText: '接入中',
detailLines: [
'数据源: SIEM/IDS (接入中)',
'预计完成: 2026-Q4',
'当前状态: 暂无数据',
],
actionText: '',
},
{
key: 'sec_proxy',
label: '网络代理',
icon: '🌐',
level: 'pending',
statusText: '接入中',
detailLines: [
'数据源: 联软网络准入 (接入中)',
'预计完成: 2026-Q4',
'当前状态: 暂无数据',
],
actionText: '',
},
],
},
// ── Tab 6: 合规检查 ──
{
key: 'compliance',
label: '合规检查',
diagItems: [],
riskItems: [
{
key: 'comp_byod',
label: '自备电脑',
icon: '💻',
level: 'normal',
statusText: '公司配发',
detailLines: [
'设备类型: 公司配发设备',
'资产编号: SY-PC-00821',
'登记状态: 已登记',
],
actionText: '',
},
{
key: 'comp_unapproved',
label: '未审批商业软件',
icon: '📋',
level: 'warning',
statusText: '1个未审批',
detailLines: [
'软件: Adobe Photoshop 2024 (未审批)',
'发现时间: 2026-07-10',
'建议: 提交软件安装审批申请',
],
actionText: '提交审批申请',
},
],
},
])
// ── 状态 ──
/** 当前激活的 Tab key */
const activeTabKey = ref('network')
/** 展开的风险条目 key 集合 */
const expandedRisks = reactive(new Set<string>())
/** 是否正在批量执行诊断 */
const isRunningAll = ref(false)
// ── 计算属性 ──
/** 当前 Tab 对象 */
const currentTab = computed(() => {
return tabsData.value.find(t => t.key === activeTabKey.value) || tabsData.value[0]
})
/** 所有 Tab 的元信息(含异常徽章数字) */
const tabs = computed(() => {
return tabsData.value.map(tab => {
const diagAbnormal = tab.diagItems.filter(
d => d.status === 'warning' || d.status === 'fail'
).length
const riskAbnormal = tab.riskItems.filter(
r => r.level === 'warning' || r.level === 'danger'
).length
return {
key: tab.key,
label: tab.label,
badge: diagAbnormal + riskAbnormal,
}
})
})
/** 当前 Tab 是否有诊断结果 */
const hasDiagResults = computed(() => {
return currentTab.value.diagItems.some(
d => d.status === 'pass' || d.status === 'warning' || d.status === 'fail'
)
})
// ── Mock 诊断结果 ──
/** Mock 诊断结果映射 */
const mockDiagResults: Record<string, { status: DiagStatus; detail: string }> = {
net_connect: { status: 'pass', detail: 'ping 10.90.5.1 -> 2ms, 丢包率 0%' },
net_dns: { status: 'pass', detail: 'DNS解析正常, 解析时间 3ms' },
net_gateway: { status: 'pass', detail: '默认网关 10.90.5.1 可达' },
net_proxy: { status: 'warning', detail: '检测到系统代理 127.0.0.1:8080, 可能影响内网访问' },
net_vpn: { status: 'fail', detail: 'aTrust VPN 连接超时 (10s), 可能需要重新认证' },
net_fileshare: { status: 'pass', detail: '文件服务器 \\\\file-srv 可访问' },
sys_service: { status: 'pass', detail: '关键服务运行正常 (5/5): DHCP, DNS, Windows Update, Firewall, Defender' },
sys_cert: { status: 'fail', detail: '发现1个过期证书: CN=old-ca (过期日: 2025-12-01), 建议更新' },
}
// ── 方法 ──
/** 执行单个诊断(Mock: 模拟延迟后返回结果) */
async function runSingle(key: string) {
const item = currentTab.value.diagItems.find(d => d.key === key)
if (!item || item.status === 'running') return
item.status = 'running'
item.detail = ''
// Mock: 模拟诊断延迟 800ms~1.5s
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 700))
const result = mockDiagResults[key]
if (result) {
item.status = result.status
item.detail = result.detail
} else {
item.status = 'pass'
item.detail = '诊断完成'
}
}
/** 一键诊断:批量执行当前 Tab 的所有诊断项 */
async function runAllDiag() {
if (currentTab.value.diagItems.length === 0) return
isRunningAll.value = true
for (const item of currentTab.value.diagItems) {
await runSingle(item.key)
}
isRunningAll.value = false
}
/** 切换风险条目展开/折叠 */
function toggleRiskItem(key: string) {
if (expandedRisks.has(key)) {
expandedRisks.delete(key)
} else {
expandedRisks.add(key)
}
}
/** 处理风险项操作按钮 */
function handleRiskAction(item: RiskItem) {
// TODO: 后续对接具体操作
console.log('[Diag] risk action:', item.key, item.actionText)
}
/** 诊断状态文字 */
function diagStatusText(status: DiagStatus): string {
switch (status) {
case 'pass': return '通过'
case 'warning': return '警告'
case 'fail': return '失败'
default: return ''
}
}
</script>
<style scoped>
.self-diag {
background: var(--bg-primary, #fff);
border-radius: var(--border-radius-lg, 12px);
border: 1px solid var(--border-color, #e5e7eb);
overflow: hidden;
}
/* ====== 标题栏 ====== */
.self-diag__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px 6px;
}
.header__title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1f2937);
}
.header__btn {
font-size: 11px;
font-weight: 500;
font-family: inherit;
padding: 4px 10px;
border-radius: var(--border-radius-md, 8px);
border: none;
background: var(--accent, #07C160);
color: #fff;
cursor: pointer;
transition: opacity 0.2s;
}
.header__btn:hover { opacity: 0.85; }
.header__btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ====== Tab 栏 3x2 网格 ====== */
.self-diag__tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 0 6px 6px;
}
.tab-btn {
padding: 6px 4px;
border: none;
border-radius: var(--border-radius-md, 8px);
font-size: 11px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
background: var(--bg-tertiary, #f9fafb);
color: var(--text-secondary, #6b7280);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
position: relative;
}
.tab-btn:hover {
background: var(--bg-secondary, #f5f5f5);
}
.tab-btn--active {
background: var(--accent, #07C160);
color: #fff;
}
.tab-btn__badge {
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.tab-btn--active .tab-btn__badge {
background: rgba(255, 255, 255, 0.3);
}
/* ====== 内容区 ====== */
.self-diag__content {
padding: 6px 10px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
/* ====== 诊断 chips ====== */
.diag-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.diag-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
border-radius: var(--border-radius-md, 8px);
font-size: 11px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.15);
color: #16a34a;
}
.diag-chip--pass {
background: rgba(34, 197, 94, 0.08);
border-color: rgba(34, 197, 94, 0.3);
color: #16a34a;
}
.diag-chip--warning {
background: rgba(245, 158, 11, 0.08);
border-color: rgba(245, 158, 11, 0.3);
color: #d97706;
}
.diag-chip--fail {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.3);
color: #dc2626;
}
.diag-chip--running {
opacity: 0.6;
cursor: wait;
}
.diag-chip:hover { opacity: 0.85; }
.diag-chip:disabled { cursor: not-allowed; }
.diag-chip__icon {
font-size: 12px;
font-weight: 700;
}
/* ====== 诊断结果 ====== */
.diag-results {
display: flex;
flex-direction: column;
gap: 4px;
border-top: 1px solid var(--border-color, #e5e7eb);
padding-top: 6px;
}
.diag-results__label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary, #9ca3af);
}
.result-item {
padding: 6px 8px;
border-radius: var(--border-radius-md, 8px);
border: 1px solid transparent;
}
.result-item--pass {
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.15);
}
.result-item--warning {
background: rgba(245, 158, 11, 0.04);
border-color: rgba(245, 158, 11, 0.15);
}
.result-item--fail {
background: rgba(239, 68, 68, 0.04);
border-color: rgba(239, 68, 68, 0.15);
}
.result-item__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.result-item__name {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.result-item__status {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
}
.result-item__status--pass { background: rgba(34, 197, 94, 0.15); color: #16a34a; }
.result-item__status--warning { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.result-item__status--fail { background: rgba(239, 68, 68, 0.15); color: #dc2626; }
.result-item__detail {
font-size: 11px;
color: var(--text-secondary, #6b7280);
margin-top: 2px;
}
/* ====== 风险折叠手风琴 ====== */
.risk-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.risk-item {
border-radius: var(--border-radius-md, 8px);
border: 1px solid transparent;
overflow: hidden;
}
.risk-item--normal {
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.12);
}
.risk-item--warning {
background: rgba(245, 158, 11, 0.04);
border-color: rgba(245, 158, 11, 0.12);
}
.risk-item--danger {
background: rgba(239, 68, 68, 0.04);
border-color: rgba(239, 68, 68, 0.12);
}
.risk-item--pending {
background: var(--bg-tertiary, #f9fafb);
border: 1px dashed var(--border-color, #e5e7eb);
opacity: 0.7;
}
.risk-item__header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
cursor: pointer;
transition: background 0.2s;
}
.risk-item__header:hover {
background: rgba(0, 0, 0, 0.02);
}
.risk-item__icon {
font-size: 14px;
flex-shrink: 0;
}
.risk-item__name {
flex: 1;
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.risk-item__status {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 4px;
}
.risk-item__status--normal { background: rgba(34, 197, 94, 0.15); color: #16a34a; }
.risk-item__status--warning { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.risk-item__status--danger { background: rgba(239, 68, 68, 0.15); color: #dc2626; }
.risk-item__status--pending { color: var(--text-tertiary, #9ca3af); font-style: italic; }
.risk-item__toggle {
font-size: 12px;
color: var(--text-tertiary, #9ca3af);
flex-shrink: 0;
}
.risk-item__detail {
padding: 0 10px 8px 30px;
border-top: 1px solid rgba(0, 0, 0, 0.04);
padding-top: 6px;
margin-top: 2px;
}
.detail-line {
font-size: 11px;
color: var(--text-secondary, #6b7280);
line-height: 1.6;
}
.risk-item__action {
margin-top: 6px;
font-size: 11px;
font-weight: 500;
font-family: inherit;
padding: 4px 12px;
border-radius: var(--border-radius-md, 8px);
border: none;
background: var(--accent, #07C160);
color: #fff;
cursor: pointer;
transition: opacity 0.2s;
}
.risk-item__action:hover { opacity: 0.85; }
</style>
@@ -0,0 +1,664 @@
<!--
=============================================================================
// 企微IT智能服务台 — H5 软件下载与申请
=============================================================================
// 说明:Tab 切换:软件下载安装 / 资源权限申请
// 1. 软件下载安装:搜索栏 + Top5 常用 + 全部软件列表
// 三种状态:推送安装(火绒静默) / 下载安装 / 已安装
// 2. 资源权限申请:卡片式入口(密码重置/电脑升级等)+ 更多展开
// 进行中的申请显示角标
//
// 数据来源:Mock 数据,后续对接后端软件目录 API + 企微审批 API
// =============================================================================
-->
<template>
<div class="sw-apply">
<!-- Tab 切换栏 -->
<div class="sw-apply__tabs">
<button
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'software' }"
@click="activeTab = 'software'"
>
软件下载安装
</button>
<button
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'apply' }"
@click="activeTab = 'apply'"
>
资源权限申请
<span v-if="pendingApplyCount > 0" class="tab-btn__badge">{{ pendingApplyCount }}</span>
</button>
</div>
<!-- ====== 软件下载安装 ====== -->
<div v-if="activeTab === 'software'" class="sw-apply__content">
<!-- 搜索栏 -->
<div class="search-bar">
<span class="search-bar__icon">🔍</span>
<input
v-model="searchKeyword"
class="search-bar__input"
type="text"
placeholder="搜索软件名称..."
/>
</div>
<!-- 搜索结果 -->
<div v-if="searchKeyword && filteredResults.length > 0" class="search-results">
<div class="search-results__label">搜索结果 ({{ filteredResults.length }})</div>
<div
v-for="item in filteredResults"
:key="item.key"
class="sw-item"
:class="`sw-item--${item.installStatus}`"
>
<div class="sw-item__info">
<span class="sw-item__name">{{ item.name }}</span>
<span class="sw-item__version">{{ item.version }}</span>
</div>
<button class="sw-item__btn" @click="handleInstall(item)">
{{ btnText(item) }}
</button>
</div>
</div>
<!-- 无搜索结果 -->
<div v-else-if="searchKeyword && filteredResults.length === 0" class="search-empty">
未找到 "{{ searchKeyword }}" 相关软件
</div>
<!-- Top5 常用无搜索词时显示 -->
<template v-else>
<div class="sw-apply__label">最常用 Top {{ topSoftware.length }}</div>
<div class="sw-top-grid">
<button
v-for="item in topSoftware"
:key="item.key"
class="sw-card"
:class="`sw-card--${item.installStatus}`"
@click="handleInstall(item)"
>
<span class="sw-card__name">{{ item.name }}</span>
<span class="sw-card__version">{{ item.version }}</span>
<span class="sw-card__badge" :class="`sw-card__badge--${item.installStatus}`">
{{ badgeText(item.installStatus) }}
</span>
</button>
</div>
<!-- 全部软件折叠 -->
<button class="sw-apply__expand" @click="showAllSoftware = !showAllSoftware">
{{ showAllSoftware ? '收起全部软件' : `展开全部软件 (${allSoftware.length})` }} {{ showAllSoftware ? '' : '' }}
</button>
<div v-if="showAllSoftware" class="sw-all-list">
<div
v-for="item in allSoftware"
:key="item.key"
class="sw-item"
:class="`sw-item--${item.installStatus}`"
>
<div class="sw-item__info">
<span class="sw-item__name">{{ item.name }}</span>
<span class="sw-item__version">{{ item.version }}</span>
</div>
<button class="sw-item__btn" @click="handleInstall(item)">
{{ btnText(item) }}
</button>
</div>
</div>
</template>
</div>
<!-- ====== 资源权限申请 ====== -->
<div v-else class="sw-apply__content">
<!-- 流程卡片网格 -->
<div class="flow-grid">
<button
v-for="item in topFlows"
:key="item.key"
class="flow-card"
@click="handleFlow(item)"
>
<span v-if="item.pendingCount > 0" class="flow-card__badge">{{ item.pendingCount }}</span>
<span class="flow-card__icon">{{ item.icon }}</span>
<span class="flow-card__name">{{ item.name }}</span>
<span class="flow-card__desc">{{ item.desc }}</span>
</button>
<!-- 更多 -->
<button class="flow-card flow-card--more" @click="showMoreFlows = !showMoreFlows">
<span class="flow-card__icon">{{ showMoreFlows ? '▴' : '▾' }}</span>
<span class="flow-card__name">{{ showMoreFlows ? '收起' : '更多' }}</span>
<span class="flow-card__desc">全部流程</span>
</button>
</div>
<!-- 更多流程展开 -->
<div v-if="showMoreFlows" class="flow-more">
<button
v-for="item in moreFlows"
:key="item.key"
class="more-item"
@click="handleFlow(item)"
>
<span class="more-item__icon">{{ item.icon }}</span>
<span class="more-item__name">{{ item.name }}</span>
<span v-if="item.pendingCount > 0" class="more-item__badge">{{ item.pendingCount }}</span>
<span class="more-item__arrow"></span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// ── Tab 状态 ──
const activeTab = ref<'software' | 'apply'>('software')
// ── 软件安装数据 ──
type InstallStatus = 'push' | 'download' | 'installed'
interface SoftwareItem {
key: string
name: string
version: string
installMethod: 'push' | 'download'
installStatus: InstallStatus
category: string
}
const topSoftware = ref<SoftwareItem[]>([
{ key: 'wps', name: 'WPS Office', version: '12.1.0', installMethod: 'push', installStatus: 'push', category: '办公' },
{ key: 'wecom', name: '企业微信', version: '4.1.30', installMethod: 'push', installStatus: 'push', category: '通讯' },
{ key: 'chrome', name: 'Chrome', version: '126.0', installMethod: 'download', installStatus: 'download', category: '浏览器' },
{ key: 'pdf', name: 'PDF阅读器', version: '2024.2', installMethod: 'push', installStatus: 'push', category: '办公' },
{ key: '7zip', name: '7-Zip', version: '24.07', installMethod: 'push', installStatus: 'installed', category: '工具' },
])
const allSoftware = ref<SoftwareItem[]>([
{ key: 'vscode', name: 'VS Code', version: '1.90', installMethod: 'download', installStatus: 'download', category: '开发' },
{ key: 'idea', name: 'IntelliJ IDEA', version: '2024.1', installMethod: 'download', installStatus: 'download', category: '开发' },
{ key: 'git', name: 'Git', version: '2.45', installMethod: 'push', installStatus: 'push', category: '开发' },
{ key: 'node', name: 'Node.js', version: '22.x', installMethod: 'download', installStatus: 'download', category: '开发' },
{ key: 'dingtalk', name: '钉钉', version: '7.5', installMethod: 'push', installStatus: 'push', category: '通讯' },
{ key: 'feishu', name: '飞书', version: '7.5', installMethod: 'download', installStatus: 'download', category: '通讯' },
{ key: 'foxit', name: '福昕PDF', version: '2024.2', installMethod: 'push', installStatus: 'push', category: '办公' },
{ key: 'wechat', name: '微信', version: '3.9', installMethod: 'download', installStatus: 'download', category: '通讯' },
])
const searchKeyword = ref('')
const showAllSoftware = ref(false)
const filteredResults = computed(() => {
if (!searchKeyword.value) return []
const kw = searchKeyword.value.toLowerCase()
return [...topSoftware.value, ...allSoftware.value].filter(
item => item.name.toLowerCase().includes(kw) || item.category.toLowerCase().includes(kw)
)
})
function badgeText(status: InstallStatus): string {
switch (status) {
case 'push': return '推送安装'
case 'download': return '下载安装'
case 'installed': return '已安装'
}
}
function btnText(item: SoftwareItem): string {
switch (item.installStatus) {
case 'push': return '推送安装'
case 'download': return '下载'
case 'installed': return '已安装'
}
}
function handleInstall(item: SoftwareItem) {
if (item.installStatus === 'installed') return
// TODO: 后续对接
// push: 调用后端火绒推送 API -> 静默安装 -> 轮询状态 -> 更新为 installed
// download: 打开下载链接 / 企微内置浏览器下载
console.log('[Software] 安装:', item.key, item.name, item.installMethod)
if (item.installMethod === 'push') {
item.installStatus = 'installed'
}
}
// ── 申请流程数据 ──
interface FlowItem {
key: string
name: string
desc: string
icon: string
pendingCount: number
}
const topFlows = ref<FlowItem[]>([
{ key: 'password_reset', name: '密码重置', desc: 'AD域账号', icon: '🔑', pendingCount: 0 },
{ key: 'pc_upgrade', name: '电脑升级', desc: '硬件升级', icon: '💻', pendingCount: 0 },
{ key: 'vpn_apply', name: 'VPN申请', desc: '远程接入', icon: '🔒', pendingCount: 1 },
{ key: 'software_install', name: '软件安装', desc: '审批流程', icon: '📦', pendingCount: 0 },
{ key: 'device_request', name: '设备申领', desc: '新员工', icon: '🖥️', pendingCount: 0 },
])
const moreFlows = ref<FlowItem[]>([
{ key: 'email_config', name: '邮箱配置', desc: '', icon: '📧', pendingCount: 0 },
{ key: 'permission_apply', name: '权限申请', desc: '', icon: '🛡️', pendingCount: 0 },
{ key: 'remote_work', name: '远程办公', desc: '', icon: '🏠', pendingCount: 0 },
{ key: 'account_unlock', name: '账号解锁', desc: '', icon: '🔓', pendingCount: 0 },
{ key: 'data_recovery', name: '数据恢复', desc: '', icon: '💾', pendingCount: 0 },
{ key: 'network_report', name: '网络报修', desc: '', icon: '🌐', pendingCount: 0 },
{ key: 'phone_config', name: '电话配置', desc: '', icon: '📞', pendingCount: 0 },
{ key: 'office_supplies', name: '办公用品', desc: '', icon: '📎', pendingCount: 0 },
])
const showMoreFlows = ref(false)
/** 待审批数量(角标) */
const pendingApplyCount = computed(() => {
return [...topFlows.value, ...moreFlows.value].reduce((sum, f) => sum + f.pendingCount, 0)
})
function handleFlow(item: FlowItem) {
// TODO: 对接企微审批 thirdPartyOpenPage
console.log('[Flow] 打开审批流程:', item.key, item.name)
}
</script>
<style scoped>
.sw-apply {
background: var(--bg-primary, #fff);
border-radius: var(--border-radius-lg, 12px);
border: 1px solid var(--border-color, #e5e7eb);
overflow: hidden;
}
/* ====== Tab 切换栏 ====== */
.sw-apply__tabs {
display: flex;
gap: 4px;
padding: 6px;
background: var(--bg-tertiary, #f9fafb);
}
.tab-btn {
flex: 1;
padding: 6px 12px;
border: none;
border-radius: var(--border-radius-md, 8px);
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
background: transparent;
color: var(--text-secondary, #6b7280);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.tab-btn--active {
background: var(--accent, #07C160);
color: #fff;
}
.tab-btn__badge {
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.tab-btn--active .tab-btn__badge {
background: rgba(255, 255, 255, 0.3);
}
/* ====== 内容区 ====== */
.sw-apply__content {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* ── 搜索栏 ── */
.search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: var(--border-radius-md, 8px);
background: var(--bg-tertiary, #f9fafb);
border: 1px solid var(--border-color, #e5e7eb);
}
.search-bar__icon {
font-size: 13px;
opacity: 0.5;
}
.search-bar__input {
flex: 1;
border: none;
background: transparent;
font-size: 12px;
font-family: inherit;
outline: none;
color: var(--text-primary, #1f2937);
}
.search-bar__input::placeholder {
color: var(--text-tertiary, #9ca3af);
}
/* ── 搜索结果 ── */
.search-results__label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary, #9ca3af);
margin-bottom: 2px;
}
.search-empty {
text-align: center;
padding: 16px 0;
font-size: 12px;
color: var(--text-tertiary, #9ca3af);
}
/* ── 标签 ── */
.sw-apply__label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary, #9ca3af);
}
/* ── Top5 卡片网格 ── */
.sw-top-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.sw-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
border-radius: var(--border-radius-md, 8px);
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.sw-card:hover { transform: scale(1.02); }
.sw-card:active { transform: scale(0.96); }
.sw-card--push {
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.15);
}
.sw-card--download {
background: rgba(245, 158, 11, 0.04);
border-color: rgba(245, 158, 11, 0.15);
}
.sw-card--installed {
background: var(--bg-tertiary, #f9fafb);
border-color: var(--border-color, #e5e7eb);
opacity: 0.6;
}
.sw-card__name {
font-size: 11px;
font-weight: 500;
color: var(--text-primary, #1f2937);
text-align: center;
}
.sw-card__version {
font-size: 9px;
color: var(--text-tertiary, #9ca3af);
}
.sw-card__badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 500;
}
.sw-card__badge--push { background: rgba(34, 197, 94, 0.15); color: #16a34a; }
.sw-card__badge--download { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.sw-card__badge--installed { background: rgba(107, 114, 128, 0.15); color: #6b7280; }
/* ── 展开/收起按钮 ── */
.sw-apply__expand {
font-size: 11px;
font-family: inherit;
color: var(--accent, #07C160);
background: none;
border: none;
cursor: pointer;
text-align: center;
padding: 4px 0;
transition: opacity 0.2s;
}
.sw-apply__expand:hover { opacity: 0.7; }
/* ── 软件条目(搜索结果 + 全部列表通用) ── */
.sw-all-list,
.search-results {
display: flex;
flex-direction: column;
gap: 2px;
}
.sw-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: var(--border-radius-md, 8px);
border: 1px solid transparent;
}
.sw-item--push {
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.1);
}
.sw-item--download {
background: rgba(245, 158, 11, 0.04);
border-color: rgba(245, 158, 11, 0.1);
}
.sw-item--installed {
opacity: 0.5;
}
.sw-item__info {
display: flex;
flex-direction: column;
gap: 1px;
}
.sw-item__name {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.sw-item__version {
font-size: 10px;
color: var(--text-tertiary, #9ca3af);
}
.sw-item__btn {
font-size: 11px;
font-weight: 500;
font-family: inherit;
padding: 4px 10px;
border-radius: var(--border-radius-md, 8px);
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.sw-item--push .sw-item__btn {
background: var(--color-success, #22c55e);
color: #fff;
}
.sw-item--download .sw-item__btn {
background: var(--color-warning, #f59e0b);
color: #fff;
}
.sw-item--installed .sw-item__btn {
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-tertiary, #9ca3af);
cursor: default;
}
.sw-item__btn:hover { opacity: 0.85; }
/* ── 申请流程卡片网格 ── */
.flow-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.flow-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
border-radius: var(--border-radius-md, 8px);
background: var(--bg-tertiary, #f9fafb);
border: 1px solid var(--border-color, #e5e7eb);
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
position: relative;
}
.flow-card:hover {
background: var(--bg-secondary, #f5f5f5);
border-color: var(--accent, #07C160);
}
.flow-card:active { transform: scale(0.96); }
.flow-card__badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.flow-card__icon { font-size: 16px; }
.flow-card__name {
font-size: 11px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.flow-card__desc {
font-size: 9px;
color: var(--text-tertiary, #9ca3af);
}
.flow-card--more {
background: transparent;
border-style: dashed;
}
/* ── 更多流程列表 ── */
.flow-more {
display: flex;
flex-direction: column;
gap: 2px;
border-top: 1px solid var(--border-color, #e5e7eb);
padding-top: 6px;
}
.more-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--border-radius-md, 8px);
background: transparent;
border: none;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
}
.more-item:hover { background: var(--bg-tertiary, #f9fafb); }
.more-item__icon { font-size: 14px; flex-shrink: 0; }
.more-item__name {
flex: 1;
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
text-align: left;
}
.more-item__badge {
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.more-item__arrow {
font-size: 14px;
color: var(--text-tertiary, #9ca3af);
}
</style>
@@ -0,0 +1,418 @@
<!--
=============================================================================
// 企微IT智能服务台 — H5 软件安装
=============================================================================
// 说明:员工可自助安装常用软件
// 1. 搜索栏:搜索软件目录
// 2. Top5 常用:按使用频率排序
// 3. 三种安装状态:
// a. 推送安装(火绒静默安装,绿底)
// b. 下载安装(自行下载安装,黄底)
// c. 已安装(灰底)
//
// 数据来源:Mock 数据,后续对接后端软件目录 API + 火绒推送 API
// =============================================================================
-->
<template>
<div class="software-install">
<!-- 标题栏 -->
<div class="software-install__header">
<span class="header__title">软件安装</span>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<span class="search-bar__icon">🔍</span>
<input
v-model="searchKeyword"
class="search-bar__input"
type="text"
placeholder="搜索软件名称..."
/>
</div>
<!-- 搜索结果有搜索词时显示 -->
<div v-if="searchKeyword && filteredResults.length > 0" class="search-results">
<div class="search-results__label">搜索结果 ({{ filteredResults.length }})</div>
<div
v-for="item in filteredResults"
:key="item.key"
class="sw-item"
:class="`sw-item--${item.installStatus}`"
>
<div class="sw-item__info">
<span class="sw-item__name">{{ item.name }}</span>
<span class="sw-item__version">{{ item.version }}</span>
</div>
<button class="sw-item__btn" @click="handleInstall(item)">
{{ btnText(item) }}
</button>
</div>
</div>
<!-- 无搜索结果 -->
<div v-else-if="searchKeyword && filteredResults.length === 0" class="search-empty">
未找到 "{{ searchKeyword }}" 相关软件
</div>
<!-- Top5 常用无搜索词时显示 -->
<template v-else>
<div class="software-install__label">最常用 Top {{ topItems.length }}</div>
<div class="software-install__top-grid">
<button
v-for="item in topItems"
:key="item.key"
class="sw-card"
:class="`sw-card--${item.installStatus}`"
@click="handleInstall(item)"
>
<span class="sw-card__name">{{ item.name }}</span>
<span class="sw-card__version">{{ item.version }}</span>
<span class="sw-card__badge" :class="`sw-card__badge--${item.installStatus}`">
{{ badgeText(item.installStatus) }}
</span>
</button>
</div>
<!-- 全部软件折叠 -->
<button class="software-install__expand" @click="showAll = !showAll">
{{ showAll ? '收起全部软件' : `展开全部软件 (${allItems.length})` }} {{ showAll ? '' : '' }}
</button>
<div v-if="showAll" class="software-install__all-list">
<div
v-for="item in allItems"
:key="item.key"
class="sw-item"
:class="`sw-item--${item.installStatus}`"
>
<div class="sw-item__info">
<span class="sw-item__name">{{ item.name }}</span>
<span class="sw-item__version">{{ item.version }}</span>
</div>
<button class="sw-item__btn" @click="handleInstall(item)">
{{ btnText(item) }}
</button>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// ── 类型定义 ──
type InstallStatus = 'push' | 'download' | 'installed'
interface SoftwareItem {
key: string
name: string
version: string
installMethod: 'push' | 'download' // 推送安装 or 下载安装
installStatus: InstallStatus
category: string
}
// ── Mock 数据 ──
/** Top5 最常用软件 */
const topItems = ref<SoftwareItem[]>([
{ key: 'wps', name: 'WPS Office', version: '12.1.0', installMethod: 'push', installStatus: 'push', category: '办公' },
{ key: 'wecom', name: '企业微信', version: '4.1.30', installMethod: 'push', installStatus: 'push', category: '通讯' },
{ key: 'chrome', name: 'Chrome', version: '126.0', installMethod: 'download', installStatus: 'download', category: '浏览器' },
{ key: 'pdf', name: 'PDF阅读器', version: '2024.2', installMethod: 'push', installStatus: 'push', category: '办公' },
{ key: '7zip', name: '7-Zip', version: '24.07', installMethod: 'push', installStatus: 'installed', category: '工具' },
])
/** 全部软件 */
const allItems = ref<SoftwareItem[]>([
{ key: 'vscode', name: 'VS Code', version: '1.90', installMethod: 'download', installStatus: 'download', category: '开发' },
{ key: 'idea', name: 'IntelliJ IDEA', version: '2024.1', installMethod: 'download', installStatus: 'download', category: '开发' },
{ key: 'git', name: 'Git', version: '2.45', installMethod: 'push', installStatus: 'push', category: '开发' },
{ key: 'node', name: 'Node.js', version: '22.x', installMethod: 'download', installStatus: 'download', category: '开发' },
{ key: 'dingtalk', name: '钉钉', version: '7.5', installMethod: 'push', installStatus: 'push', category: '通讯' },
{ key: 'feishu', name: '飞书', version: '7.5', installMethod: 'download', installStatus: 'download', category: '通讯' },
{ key: 'foxit', name: '福昕PDF', version: '2024.2', installMethod: 'push', installStatus: 'push', category: '办公' },
{ key: 'wechat', name: '微信', version: '3.9', installMethod: 'download', installStatus: 'download', category: '通讯' },
])
// ── 状态 ──
const searchKeyword = ref('')
const showAll = ref(false)
// ── 计算属性 ──
const filteredResults = computed(() => {
if (!searchKeyword.value) return []
const kw = searchKeyword.value.toLowerCase()
return [...topItems.value, ...allItems.value].filter(
item => item.name.toLowerCase().includes(kw) || item.category.toLowerCase().includes(kw)
)
})
// ── 方法 ──
function badgeText(status: InstallStatus): string {
switch (status) {
case 'push': return '推送安装'
case 'download': return '下载安装'
case 'installed': return '已安装'
}
}
function btnText(item: SoftwareItem): string {
switch (item.installStatus) {
case 'push': return '推送安装'
case 'download': return '下载'
case 'installed': return '已安装'
}
}
/** 处理安装/下载 */
function handleInstall(item: SoftwareItem) {
if (item.installStatus === 'installed') return
// TODO: 后续对接
// push: 调用后端火绒推送 API → 静默安装 → 轮询状态 → 更新为 installed
// download: 打开下载链接 / 企微内置浏览器下载
console.log('[Software] 安装:', item.key, item.name, item.installMethod)
// Mock: 推送安装后更新状态
if (item.installMethod === 'push') {
item.installStatus = 'installed'
}
}
</script>
<style scoped>
.software-install {
background: var(--bg-primary, #fff);
border-radius: var(--border-radius-lg, 12px);
border: 1px solid var(--border-color, #e5e7eb);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.software-install__header {
display: flex;
align-items: center;
}
.header__title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1f2937);
}
/* 搜索栏 */
.search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: var(--border-radius-md, 8px);
background: var(--bg-tertiary, #f9fafb);
border: 1px solid var(--border-color, #e5e7eb);
}
.search-bar__icon {
font-size: 13px;
opacity: 0.5;
}
.search-bar__input {
flex: 1;
border: none;
background: transparent;
font-size: 12px;
font-family: inherit;
outline: none;
color: var(--text-primary, #1f2937);
}
.search-bar__input::placeholder {
color: var(--text-tertiary, #9ca3af);
}
/* 标签 */
.software-install__label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary, #9ca3af);
}
/* Top5 卡片网格 */
.software-install__top-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.sw-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 4px;
border-radius: var(--border-radius-md, 8px);
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.sw-card:hover { transform: scale(1.02); }
.sw-card:active { transform: scale(0.96); }
.sw-card--push {
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.15);
}
.sw-card--download {
background: rgba(245, 158, 11, 0.04);
border-color: rgba(245, 158, 11, 0.15);
}
.sw-card--installed {
background: var(--bg-tertiary, #f9fafb);
border-color: var(--border-color, #e5e7eb);
opacity: 0.6;
}
.sw-card__name {
font-size: 11px;
font-weight: 500;
color: var(--text-primary, #1f2937);
text-align: center;
}
.sw-card__version {
font-size: 9px;
color: var(--text-tertiary, #9ca3af);
}
.sw-card__badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 500;
}
.sw-card__badge--push { background: rgba(34, 197, 94, 0.15); color: #16a34a; }
.sw-card__badge--download { background: rgba(245, 158, 11, 0.15); color: #d97706; }
.sw-card__badge--installed { background: rgba(107, 114, 128, 0.15); color: #6b7280; }
/* 展开/收起按钮 */
.software-install__expand {
font-size: 11px;
font-family: inherit;
color: var(--accent, #07C160);
background: none;
border: none;
cursor: pointer;
text-align: center;
padding: 4px 0;
transition: opacity 0.2s;
}
.software-install__expand:hover { opacity: 0.7; }
/* 全部软件列表 */
.software-install__all-list {
display: flex;
flex-direction: column;
gap: 2px;
}
/* 通用软件条目(搜索结果 + 全部列表) */
.sw-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: var(--border-radius-md, 8px);
border: 1px solid transparent;
}
.sw-item--push {
background: rgba(34, 197, 94, 0.04);
border-color: rgba(34, 197, 94, 0.1);
}
.sw-item--download {
background: rgba(245, 158, 11, 0.04);
border-color: rgba(245, 158, 11, 0.1);
}
.sw-item--installed {
opacity: 0.5;
}
.sw-item__info {
display: flex;
flex-direction: column;
gap: 1px;
}
.sw-item__name {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #1f2937);
}
.sw-item__version {
font-size: 10px;
color: var(--text-tertiary, #9ca3af);
}
.sw-item__btn {
font-size: 11px;
font-weight: 500;
font-family: inherit;
padding: 4px 10px;
border-radius: var(--border-radius-md, 8px);
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.sw-item--push .sw-item__btn {
background: var(--color-success, #22c55e);
color: #fff;
}
.sw-item--download .sw-item__btn {
background: var(--color-warning, #f59e0b);
color: #fff;
}
.sw-item--installed .sw-item__btn {
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-tertiary, #9ca3af);
cursor: default;
}
.sw-item__btn:hover { opacity: 0.85; }
/* 搜索结果 */
.search-results__label {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary, #9ca3af);
margin-bottom: 2px;
}
.search-empty {
text-align: center;
padding: 16px 0;
font-size: 12px;
color: var(--text-tertiary, #9ca3af);
}
</style>
@@ -56,6 +56,7 @@
import { ref, computed, onMounted } from 'vue'
import { showToast } from 'vant'
import { getApprovalKeywords, createApprovalJump, type ApprovalKeyword } from '@/api/conversation'
import { useWecomApproval } from '@/composables/useWecomApproval'
// ==========================================================================
// Props 定义
@@ -70,6 +71,13 @@ const props = withDefaults(defineProps<Props>(), {
approvalType: '',
})
// ==========================================================================
// 企微审批原生打开 composable
// ==========================================================================
// 做什么:封装 wx.invoke('thirdPartyOpenPage') 原生打开审批的逻辑
// 内部自动判断:企微审批→原生打开 / ITSM工单→同窗口导航 / 非企微→降级
const { openUrl } = useWecomApproval()
// ==========================================================================
// 审批选项配置(按 approval_type 分组)
// ==========================================================================
@@ -168,14 +176,19 @@ onMounted(() => {
/**
* 选择审批选项
* 优先使用 option.url 直接跳转;没有 url 时走后端模板匹配逻辑(fallback)。
* 企微审批URL → useWecomApproval.openUrl() 原生打开(企微内不跳转网页)
* ITSM工单URL → openUrl() 内部自动同窗口导航
* 无URL时走后端模板匹配逻辑(fallback)
*
* @param option 选中的审批选项
*/
async function handleSelect(option: ApprovalOption): Promise<void> {
// 优先使用 option.url 直接跳转(同窗口导航,企微 webview 原生提供返回按钮)
// 优先使用 option.url,通过 openUrl 智能路由:
// 企微审批URL → wx.invoke('thirdPartyOpenPage') 原生打开
// ITSM工单URL → window.location.href 同窗口导航
// 非企微环境 → window.location.href 降级
if (option.url) {
window.location.href = option.url
await openUrl(option.url)
return
}
@@ -188,9 +201,9 @@ async function handleSelect(option: ApprovalOption): Promise<void> {
if (matchedTemplate) {
if (matchedTemplate.type === 'jump') {
// 跳转审批(同窗口导航)
// 跳转审批:后端返回URL后通过 openUrl 路由
const result = await createApprovalJump(matchedTemplate.template_id)
window.location.href = result.url
await openUrl(result.url)
} else {
// API提交 — 后续实现
showToast('该功能正在开发中')
+46 -108
View File
@@ -2,11 +2,12 @@
// 企微IT智能服务台 — H5用户端对话区面板
// =============================================================================
// 说明:对话区主面板,包含:
// 1. 标题栏(IT智能服务台 + 坐席状态 + 🔔呼叫 + 主题切换)
// 1. 标题栏(IT智能服务台 + 坐席状态 + 主题切换)
// 2. 排查步骤(固定在消息区顶部,不随滚动消失)
// 3. 消息列表(自动滚动到底部)
// 4. 消息类型渲染:员工(右蓝) / 坐席(左白) / AI(左绿+AI标签) / 系统(居中灰)
// 5. 底部输入栏(工具栏+输入+发送+引导条)
// 5. 底部输入栏(工具栏+人工按钮+输入+发送+引导条)
// 注意:"人工"呼叫按钮已移至 InputBar 组件中(发送键上方)
// ============================================================================= -->
<template>
@@ -27,26 +28,6 @@
</span>
</div>
<div class="chat-panel__header-actions">
<!-- 🏢 会议室预定入口 -->
<button
class="chat-panel__bell-btn"
title="会议室预定"
@click="goMeetingroom"
>
<span class="chat-panel__bell-icon">🏢</span>
<span class="chat-panel__bell-text">会议室</span>
</button>
<!-- 🔔 呼叫坐席按钮 -->
<button
v-if="store.canCallAgent"
class="chat-panel__bell-btn"
:disabled="!store.isLoggedIn || store.shaking"
title="摇铃呼叫人工坐席"
@click="showCallModal = true"
>
<span class="chat-panel__bell-icon">🔔</span>
<span class="chat-panel__bell-text">呼叫</span>
</button>
<!-- 主题切换开关 滑轨 🌙 -->
<div
class="theme-switch"
@@ -95,7 +76,7 @@
<div v-else-if="store.messages.length === 0" class="chat-panel__empty">
<div class="chat-panel__empty-icon">💬</div>
<p class="chat-panel__empty-text">暂无消息</p>
<p class="chat-panel__empty-hint">输入问题咨询 🔔 摇铃呼叫坐席</p>
<p class="chat-panel__empty-hint">输入问题咨询点击下方"人工坐席"按钮</p>
</div>
<!-- 消息列表 -->
@@ -108,15 +89,15 @@
</template>
</div>
<!-- 底部输入栏 -->
<InputBar />
<!-- 底部输入栏"人工坐席"呼叫按钮 位于发送键和语音按钮上方 -->
<!-- 2026-07-12 改造"人工坐席"按钮不再弹 CallAgentModal 浮窗
而是直接调用 store.shakeAgent()后端返回趣味话术作为系统消息插入
这样去掉了"摇铃呼叫我"的卡通动画回归简洁直接 -->
<InputBar @call-agent="handleDirectCall" />
<!-- 呼叫坐席弹窗描述问题 AI确认 动画 -->
<CallAgentModal
:visible="showCallModal"
@update:visible="showCallModal = $event"
@call-success="handleCallSuccess"
/>
<!-- 已移除 CallAgentModal 摇铃动画浮窗不再展示
保留组件文件以便未来如需恢复
-->
<!-- 满意度评价弹窗P1-25 -->
<EvaluationDialog
@@ -149,13 +130,14 @@
*/
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showLoadingToast, showToast, closeToast } from 'vant'
import { useConversationStore } from '@/stores/conversation'
import { useThemeStore } from '@/stores/theme'
import { useEmployeeStore } from '@/stores/employee'
import MessageBubble from './MessageBubble.vue'
import InputBar from './InputBar.vue'
import CallAgentModal from './CallAgentModal.vue'
// 2026-07-12 改造:不再 import CallAgentModal(弹窗动画已移除)
// import CallAgentModal from './CallAgentModal.vue'
import TroubleshootFlow from './TroubleshootFlow.vue'
import ParticipantStrip from './ParticipantStrip.vue'
import ParticipantList from './ParticipantList.vue'
@@ -164,12 +146,6 @@ import EvaluationDialog from './EvaluationDialog.vue'
const store = useConversationStore()
const themeStore = useThemeStore()
const employeeStore = useEmployeeStore()
const router = useRouter()
/** 跳转到会议室预定页面 */
function goMeetingroom(): void {
router.push('/meetingroom')
}
/** 头像是否加载失败(兜底显示首字母) */
const avatarFailed = ref<boolean>(false)
@@ -188,9 +164,6 @@ function onAvatarError(): void {
/** 消息列表容器的 DOM 引用 */
const messageListRef = ref<HTMLElement | null>(null)
/** 是否显示「呼叫坐席」弹窗 */
const showCallModal = ref<boolean>(false)
/** 满意度评价弹窗显示状态 */
const showEvaluationDialog = ref<boolean>(false)
@@ -226,9 +199,37 @@ function handleScroll(): void {
shouldAutoScroll.value = scrollHeight - scrollTop - clientHeight < 100
}
/** 呼叫成功回调:刷新会话状态 */
function handleCallSuccess(): void {
store.fetchCurrentConversation()
/**
* 2026-07-12 新增:直接处理"人工坐席"按钮点击
* 替代原来的 CallAgentModal 弹窗(去掉了 7 种摇铃动画场景)
* 流程:
* 1. 校验会话已就绪
* 2. 显示 Loading Toast(短时反馈)
* 3. 调用 store.shakeAgent() 走后端分配逻辑
* 4. 成功后 toast 关闭,后端返回的 funny_phrase 会被 store 自动 push 为系统消息
* 5. 失败 toast 关闭并提示
*/
async function handleDirectCall(): Promise<void> {
if (!store.currentConversation) {
showToast('会话未就绪,请稍后再试')
return
}
showLoadingToast({
message: '正在呼叫人工坐席...',
forbidClick: true,
duration: 0,
})
try {
await store.shakeAgent()
closeToast()
// 系统消息已由 store.shakeAgent 内部 push(含 "已为您呼叫人工坐席,请稍等!")
} catch (e) {
closeToast()
showToast('呼叫失败,请稍后重试')
console.error('[ChatPanel] handleDirectCall failed:', e)
}
}
/** 评价提交成功回调 */
@@ -425,69 +426,6 @@ onMounted(() => {
white-space: nowrap;
}
/* 🔔 呼叫坐席按钮(标题栏) */
.chat-panel__bell-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--color-warning, #FF9800);
background: linear-gradient(135deg, #FFF8E1, #FFE082);
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
font-size: 13px;
font-weight: 500;
color: #E65100;
animation: bell-idle 2s ease-in-out infinite;
-webkit-tap-highlight-color: transparent;
outline: none;
}
.chat-panel__bell-btn:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
animation: bell-ring 0.5s ease-in-out;
}
.chat-panel__bell-btn:active:not(:disabled) {
transform: scale(0.95);
}
.chat-panel__bell-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
animation: none;
}
.chat-panel__bell-icon {
font-size: 16px;
line-height: 1;
}
.chat-panel__bell-text {
font-size: 12px;
}
/* 静止时轻微摇摆 */
@keyframes bell-idle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-3deg); }
75% { transform: rotate(3deg); }
}
/* 悬停时快速摇铃 */
@keyframes bell-ring {
0%, 100% { transform: rotate(0) scale(1.05); }
15% { transform: rotate(-10deg) scale(1.05); }
30% { transform: rotate(8deg) scale(1.05); }
45% { transform: rotate(-6deg) scale(1.05); }
60% { transform: rotate(4deg) scale(1.05); }
75% { transform: rotate(-2deg) scale(1.05); }
90% { transform: rotate(1deg) scale(1.05); }
}
/* 排查步骤:固定在消息区顶部 */
.chat-panel__troubleshoot-fixed {
flex-shrink: 0;
+276 -54
View File
@@ -2,13 +2,13 @@
// 企微IT智能服务台 — H5用户端输入栏组件
// =============================================================================
// 说明:底部输入栏,固定在消息列表下方,包含:
// [工具栏:表情/文件] [文本输入框] [发送按钮]
// [工具栏:表情/文件] [人工按钮] [文本输入框] [发送按钮]
// - "人工"按钮位于发送键上方,三态:disabled/active/urgent
// - 输入框默认3行可见,高度随内容动态适应
// - 输入框顶部拖拽手柄可手动调节高度
// - Enter 发送,Shift+Enter 换行
// - 支持粘贴图片上传(Ctrl+V 粘贴截图)
// - 支持图片/文件选择上传
// - 底部引导条提示摇铃功能
// ============================================================================= -->
<template>
@@ -24,6 +24,7 @@
<!-- 输入区域工具栏 + 输入框 + 发送按钮 -->
<div class="input-bar__row">
<!-- 工具栏表情/文件 + 截图快捷键提示 -->
<!-- 工具栏左侧放图标右侧放截图提示PC显示/移动端隐藏 -->
<div class="input-bar__toolbar">
<button class="input-bar__tool-btn" title="表情" @click="handleEmoji">
<span>😊</span>
@@ -31,8 +32,8 @@
<button class="input-bar__tool-btn" title="文件" @click="handleFile">
<span>📎</span>
</button>
<!-- 截图快捷键引导提示用户用企微快捷键截图后粘贴发送 -->
<span class="input-bar__shortcut-hint">召唤截图Alt+Shift+A&nbsp;&nbsp;|&nbsp;&nbsp;粘贴截图Ctrl+V</span>
<!-- 截图快捷键引导PC 端显示移动端隐藏CSS 媒体查询 -->
<span class="input-bar__shortcut-hint">截图->粘贴Alt+Shift+A-Ctrl+V ---> Ctrl+V</span>
</div>
<!-- 表情选择面板简易版常用 Emoji 网格 -->
@@ -55,7 +56,8 @@
<span v-else>正在语音输入... 点击停止</span>
</div>
<!-- 输入行输入框 + 语音按钮 + 发送按钮 -->
<!-- 输入行输入框 + 语音按钮 + "人工坐席"按钮 + 发送按钮 -->
<!-- "人工坐席"按钮位于"语音转文字图标"上方按要求调整布局 -->
<div class="input-bar__input-row">
<!-- 文本输入框 默认3行自适应内容高度 -->
<van-field
@@ -71,45 +73,59 @@
@paste="handlePaste"
/>
<!-- 语音按钮发送按钮左边双策略企微JS-SDK 百度ASR -->
<!-- 做什么点击开始录音/聆听再次点击停止并转文字 -->
<!-- 识别中禁用按钮防止重复点击显示表示识别进行中 -->
<!-- 什么 v-if 而不是 v-show不支持语音时完全移除不占位 -->
<button
v-if="showVoiceButton"
class="input-bar__voice-btn"
:class="{ 'input-bar__voice-btn--recording': isVoiceActive }"
:title="isTranscribing ? '正在识别...' : (isVoiceActive ? '点击停止' : '语音输入 (Ctrl+D)')"
:disabled="isTranscribing"
@mousedown.prevent="handleVoiceToggle"
@click.prevent="handleVoiceToggle"
@touchstart.prevent="handleVoiceToggle"
>
{{ isTranscribing ? '⏳' : (isVoiceActive ? '⏹' : '🎤') }}
</button>
<!-- 输入栏右侧控件区垂直堆叠 (语音按钮 人工坐席 发送) -->
<div class="input-bar__controls">
<!-- 语音按钮 -->
<!-- 什么点击开始录音/聆听再次点击停止并转文字 -->
<!-- 识别中禁用按钮防止重复点击显示表示识别进行中 -->
<!-- 为什么用 v-if 而不是 v-show不支持语音时完全移除不占位 -->
<button
v-if="showVoiceButton"
class="input-bar__voice-btn"
:class="{ 'input-bar__voice-btn--recording': isVoiceActive }"
:title="isTranscribing ? '正在识别...' : (isVoiceActive ? '点击停止' : '语音输入 (Ctrl+D)')"
:disabled="isTranscribing"
@mousedown.prevent="handleVoiceToggle"
@click.prevent="handleVoiceToggle"
@touchstart.prevent="handleVoiceToggle"
>
{{ isTranscribing ? '⏳' : (isVoiceActive ? '⏹' : '🎤') }}
</button>
<!-- 发送按钮 -->
<van-button
class="input-bar__send-btn"
type="primary"
size="small"
:disabled="!canSend"
:loading="store.loading"
@click="handleSend"
>
发送
</van-button>
<!-- "人工坐席"呼叫按钮 位于语音按钮下方发送键上方 -->
<!-- 三态disabled(AI回复<3次) / active(可呼叫) / urgent(紧急关键词直通) -->
<!-- 2026-07-12文案统一为"人工坐席"三态都用同一文案 -->
<button
v-if="showCallAgentBtn"
class="call-agent-btn"
:class="callAgentBtnClass"
:disabled="callAgentState === 'disabled'"
@click="handleCallAgent"
:title="callAgentBtnTitle"
>
<span class="call-agent-btn__icon">{{ callAgentBtnIcon }}</span>
<span class="call-agent-btn__text">{{ callAgentBtnText }}</span>
</button>
<!-- 发送按钮 -->
<van-button
class="input-bar__send-btn"
type="primary"
size="small"
:disabled="!canSend"
:loading="store.loading"
@click="handleSend"
>
发送
</van-button>
</div>
</div>
</div>
<!-- 底部引导条根据是否可呼叫坐席切换文案 -->
<div v-if="store.canCallAgent" class="input-bar__guide input-bar__guide--active">
点击呼叫召唤人工坐席
</div>
<!-- 表情面板打开时的半透明遮罩点击关闭表情面板 -->
<!-- 表情面板遮罩点击外部关闭 -->
<div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></div>
<div v-else class="input-bar__guide">
<!-- 底部引导条 active 态显示鼓励用户继续描述问题 -->
<div v-else-if="callAgentState === 'active'" class="input-bar__guide">
请描述你遇到的问题AI 助手会帮你分析 💡
</div>
@@ -163,11 +179,113 @@ function formatErrorDetail(detail: any): string {
}
const store = useConversationStore()
// 保留 emit 声明供阶段二使用(呼叫坐席事件将改为由标题栏传菜铃触发)
defineEmits<{
// emit 声明:呼叫坐席事件(由 ChatPanel 处理,显示 CallAgentModal
const emit = defineEmits<{
(e: 'call-agent'): void
}>()
// ============================================================================
// "人工"呼叫按钮 — 三态逻辑
// ============================================================================
/**
* 按钮状态:
* - hidden: 不显示(会话已关闭或不存在)
* - disabled: 显示但禁用(AI回复 < 3次,无紧急关键词)
* - active: 可点击呼叫(AI回复 >= 3次,无紧急关键词)
* - urgent: 紧急直通(检测到紧急关键词,无需等待3轮)
*
* 紧急关键词(与后端 URGENCY_HIGH_KEYWORDS 保持一致):
* 电脑无法启动 / 网络无法连接 / 多人不能上网 / 无法上网 / 开不了机 / 连不上网 / 全部断网
*/
/** 紧急关键词列表 */
const URGENT_KEYWORDS = [
'电脑无法启动', '网络无法连接', '多人不能上网',
'无法上网', '开不了机', '连不上网', '全部断网',
]
/** 按钮状态 */
const callAgentState = computed<'hidden' | 'disabled' | 'active' | 'urgent'>(() => {
const conv = store.currentConversation
if (!conv) return 'hidden'
// 已关闭/已服务中的会话不显示按钮
if (conv.status === 'resolved') return 'hidden'
if (conv.status === 'serving') return 'hidden'
// 检测最新用户消息中是否包含紧急关键词
const hasUrgent = checkUrgentKeywords()
if (hasUrgent) return 'urgent'
// AI回复 >= 3次 可呼叫
if (store.canCallAgent) return 'active'
// 未达3次,禁用
return 'disabled'
})
/** 是否显示"人工"按钮 */
const showCallAgentBtn = computed(() => callAgentState.value !== 'hidden')
/** 按钮CSS类 */
const callAgentBtnClass = computed(() => ({
'call-agent-btn--disabled': callAgentState.value === 'disabled',
'call-agent-btn--active': callAgentState.value === 'active',
'call-agent-btn--urgent': callAgentState.value === 'urgent',
}))
/** 按钮图标 */
const callAgentBtnIcon = computed(() => {
if (callAgentState.value === 'urgent') return '🚨'
if (callAgentState.value === 'active') return '🎧'
return '🔒'
})
/**
* 按钮文字 — 2026-07-12 统一改为"人工坐席"
* 三态(urgent/active/disabled)共用同一文案,简化用户认知
*/
const callAgentBtnText = computed(() => {
return '人工坐席'
})
/**
* 按钮 title 提示 — 区分状态给出不同的解释
* disabled: 解释为什么不能点;active/urgent: 说明点击效果
*/
const callAgentBtnTitle = computed(() => {
if (callAgentState.value === 'urgent') return '检测到紧急问题,直接呼叫人工坐席'
if (callAgentState.value === 'active') return '点击呼叫人工坐席'
return '再多描述几句话即可激活'
})
/**
* 检查最新用户消息是否包含紧急关键词
* 扫描最近5条用户消息
*/
function checkUrgentKeywords(): boolean {
const recentMessages = store.messages
.filter(m => m.message_type === 'employee')
.slice(-5)
for (const msg of recentMessages) {
const content = msg.content?.toLowerCase() || ''
for (const keyword of URGENT_KEYWORDS) {
if (content.includes(keyword.toLowerCase())) return true
}
}
return false
}
/**
* 处理"人工"按钮点击
* emit call-agent 事件,由 ChatPanel 处理(显示 CallAgentModal
*/
function handleCallAgent(): void {
if (callAgentState.value === 'disabled') return
emit('call-agent')
}
/** 输入框文本 */
const inputText = ref<string>('')
@@ -817,6 +935,11 @@ function handleInputResizeStart(event: MouseEvent): void {
user-select: none;
}
/* 2026-07-12 新增:移动端隐藏截图快捷键说明(要求:移动端不显示) */
@media (max-width: 768px) {
.input-bar__shortcut-hint { display: none; }
}
/* ── 表情选择面板 ── */
.emoji-panel {
position: relative;
@@ -899,13 +1022,23 @@ function handleInputResizeStart(event: MouseEvent): void {
transform: scale(0.92);
}
/* 输入行:输入框 + 发送按钮 */
/* 输入行:输入框 + 控件区(语音+人工坐席+发送) */
.input-bar__input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
/* 2026-07-12 新增:右侧控件垂直堆叠容器
布局:语音按钮(顶) → 人工坐席按钮(中) → 发送按钮(底) */
.input-bar__controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
/* ==========================================================================
文本输入框
========================================================================== */
@@ -1002,6 +1135,107 @@ function handleInputResizeStart(event: MouseEvent): void {
animation: none;
}
/* ==========================================================================
"人工坐席"呼叫按钮(位于语音按钮下方,发送键上方)
三态:disabled / active / urgent — 文案统一为"人工坐席"
2026-07-12 重构:原本单独占一行的 .input-bar__call-agent-row 已废弃,
按钮改为放在 .input-bar__controls 垂直堆叠容器内
========================================================================== */
/* 旧的 call-agent-row 保留兜底(不渲染但样式兼容) */
.input-bar__call-agent-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 4px 0;
}
.call-agent-btn {
display: flex;
align-items: center;
justify-content: center; /* 2026-07-12 调整:在垂直堆叠容器内水平居中 */
gap: 4px; /* 收紧间距 */
padding: 4px 12px; /* 收紧内边距(垂直堆叠需要紧凑) */
min-width: 64px; /* 保证按钮宽度一致 */
border-radius: 14px;
border: 1.5px solid var(--border-color);
background: var(--bg-secondary);
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
outline: none;
}
/* 禁用态:灰色 */
.call-agent-btn--disabled {
opacity: 0.5;
cursor: not-allowed;
color: var(--text-tertiary);
}
/* 激活态:accent 绿色 */
.call-agent-btn--active {
border-color: var(--accent);
background: var(--bg-accent-soft);
color: var(--accent);
}
.call-agent-btn--active:hover {
background: var(--accent);
color: #fff;
}
.call-agent-btn--active:active {
transform: scale(0.96);
}
/* 紧急态:红色脉冲 */
.call-agent-btn--urgent {
border-color: var(--color-danger);
background: rgba(239, 68, 68, 0.1);
color: var(--color-danger);
animation: urgent-pulse 1.5s ease-in-out infinite;
}
.call-agent-btn--urgent:hover {
background: var(--color-danger);
color: #fff;
}
.call-agent-btn--urgent:active {
transform: scale(0.96);
}
@keyframes urgent-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
.call-agent-btn__icon {
font-size: 14px;
}
.call-agent-btn__text {
font-size: 13px;
}
/* 引导提示文字 */
.call-agent-hint {
font-size: 11px;
color: var(--text-tertiary);
}
.call-agent-hint--triage {
color: var(--color-warning);
}
/* ==========================================================================
语音状态提示条(百度ASR内联录音时显示在输入框上方)
========================================================================== */
@@ -1033,16 +1267,4 @@ function handleInputResizeStart(event: MouseEvent): void {
padding-top: 2px;
}
/* 呼叫坐席通道已开启(更醒目) */
.input-bar__guide--active {
color: var(--color-warning);
font-weight: 500;
animation: guide-pulse 1.5s ease-in-out infinite;
}
@keyframes guide-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
</style>
@@ -79,6 +79,23 @@
<p class="message-bubble__text" style="white-space: pre-wrap;">{{ msg.content }}</p>
</template>
<!-- v2.0: AI 结构化消息 文字内容 + 交互式选项按钮 -->
<template v-else-if="msg.msg_type === 'ai_structured'">
<!-- 文字部分与普通文本消息一致 -->
<p class="message-bubble__text" style="white-space: pre-wrap;">{{ msg.content }}</p>
<!-- 选项按钮列表 extra_data.options 读取 -->
<div v-if="msg.extra_data?.options?.length" class="ai-options">
<button
v-for="(option, idx) in msg.extra_data.options"
:key="idx"
class="ai-options__btn"
@click.stop="handleOptionSelect(option)"
>
{{ option.label || option.value }}
</button>
</div>
</template>
<!-- 图片消息显示缩略图可点击查看大图 -->
<template v-else-if="msg.msg_type === 'image'">
<div class="image-message" @click="previewImage">
@@ -172,12 +189,17 @@ import type { Message } from '@/api/conversation'
import ApprovalCardModal from './ApprovalCardModal.vue'
import ByodSubsidyCard from './ByodSubsidyCard.vue'
import ContactCard from './ContactCard.vue'
// v2.0: 导入 conversation store,用于选项按钮点击回传
import { useConversationStore } from '@/stores/conversation'
const props = defineProps<{
/** 消息对象 */
msg: Message
}>()
// v2.0: 获取 conversation store 实例(用于选项按钮点击回传 sendOptionSelect
const conversationStore = useConversationStore()
// 引用回复点击事件(模板中使用 $emit 触发)
defineEmits<{
/** 点击引用回复摘要,滚动到被回复的消息 */
@@ -227,9 +249,20 @@ const bubbleClass = computed(() => {
} else if (props.msg.status === 'failed') {
classes.push('message-bubble--failed')
}
// v2.0: AI 思考指示器脉冲动画
if (props.msg.message_type === 'ai' && isThinking.value) {
classes.push('message-bubble--thinking')
}
return classes.join(' ')
})
/** v2.0: 判断是否为 AI 思考指示器消息 */
const isThinking = computed(() => {
if (props.msg.message_type !== 'ai') return false
const content = props.msg.content || ''
return content.startsWith('正在思考') || content.startsWith('仍在思考')
})
/** 消息内容的 CSS 类名 */
const contentClass = computed(() => {
return `message-bubble__content--${props.msg.message_type}`
@@ -333,6 +366,22 @@ function previewImage(): void {
window.open(url, '_blank')
}
}
// ============================================================================
// v2.0: AI 结构化消息 — 选项按钮点击处理
// ============================================================================
/**
* 处理用户点击 AI 结构化消息中的选项按钮。
* 做什么:调用 store.sendOptionSelect(),将用户选择作为消息发送,
* 后端接收后转化为 Dify user message 继续对话。
* 为什么:实现交互式排查闭环——AI 提问 → 用户点选项 → AI 继续推理
*
* @param option - 选项对象 { value, label }
*/
function handleOptionSelect(option: { value: string; label: string }): void {
conversationStore.sendOptionSelect(option.value, option.label || option.value)
}
</script>
<style scoped>
@@ -648,6 +697,53 @@ function previewImage(): void {
border-color: var(--accent);
}
/* ============================================================================
// v2.0: AI 思考指示器 — 脉冲动画
// ============================================================================ */
.message-bubble--thinking .message-bubble__content {
animation: thinking-pulse 1.5s ease-in-out infinite;
}
@keyframes thinking-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* ============================================================================
// v2.0: AI 结构化消息 — 交互式选项按钮
// ============================================================================ */
.ai-options {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
/* AI 气泡内选项按钮:半透明白底,hover 加深 */
.ai-options__btn {
padding: 8px 14px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
color: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.ai-options__btn:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
}
.ai-options__btn:active {
background: rgba(255, 255, 255, 0.35);
}
/* ============================================================================
// 操作按钮组 — 悬停在消息气泡上时显示
// ============================================================================ */
@@ -1,244 +0,0 @@
<!-- =============================================================================
// 企微IT智能服务台 — H5用户端消息列表组件
// =============================================================================
// 说明:消息列表容器,包含:
// - 进入会话自动标记已读
// - 会话列表显示最后更新时间
// - 未读消息角标
// - 消息轮询
// ============================================================================= -->
<template>
<div
ref="listRef"
class="message-list"
@scroll="handleScroll"
>
<!-- 消息列表 -->
<MessageItem
v-for="msg in messages"
:key="msg.message_id"
:msg="msg"
@recall="handleRecall"
@delete="handleDelete"
/>
<!-- 加载更多指示器 -->
<div v-if="loading" class="message-list__loading">
<van-loading size="20px" />
</div>
<!-- 无消息提示 -->
<div v-if="!loading && messages.length === 0" class="message-list__empty">
<div class="message-list__empty-icon">💬</div>
<p>暂无消息</p>
<p class="message-list__empty-hint">输入问题咨询 🔔 摇铃呼叫坐席</p>
</div>
</div>
</template>
<script setup lang="ts">
/**
* MessageList 消息列表组件
* 进入会话自动标记已读
* 消息轮询
*/
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { showToast } from 'vant'
import { useConversationStore } from '@/stores/conversation'
import { pollMessages, markConversationRead, recallMessage, deleteMessage } from '@/api/message'
import MessageItem from './MessageItem.vue'
const store = useConversationStore()
/** 消息列表 DOM 引用 */
const listRef = ref<HTMLElement | null>(null)
/** 加载状态 */
const loading = ref(false)
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 消息列表 */
const messages = ref(store.messages)
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
// 进入会话自动标记已读
markAsRead()
// 启动轮询
startPolling()
})
onUnmounted(() => {
stopPolling()
})
// ============================================================================
// 监听
// ============================================================================
watch(
() => store.messages,
(newMessages) => {
messages.value = newMessages
},
{ deep: true }
)
// ============================================================================
// 方法
// ============================================================================
/** 标记已读 */
async function markAsRead(): Promise<void> {
const convId = store.currentConversation?.conversation_id
if (!convId) return
try {
await markConversationRead(convId)
} catch (error) {
console.error('标记已读失败:', error)
}
}
/** 启动轮询 */
function startPolling(): void {
pollTimer = setInterval(async () => {
await fetchNewMessages()
}, 3000)
}
/** 停止轮询 */
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/** 获取新消息 */
async function fetchNewMessages(): Promise<void> {
if (store.loading) return
const convId = store.currentConversation?.conversation_id
if (!convId) return
try {
const lastMsg = messages.value[messages.value.length - 1]
const afterMessageId = lastMsg?.message_id
const newMessages = await pollMessages(afterMessageId)
if (newMessages && newMessages.length > 0) {
// 添加新消息到列表
for (const msg of newMessages) {
store.messages.push(msg)
}
// 自动滚动到底部
scrollToBottom()
}
} catch (error) {
console.error('获取新消息失败:', error)
}
}
/** 滚动到底部 */
function scrollToBottom(): void {
nextTick(() => {
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight
}
})
}
/** 处理滚动 */
function handleScroll(): void {
// 可以在这里实现加载更多历史消息
}
/** 处理撤回 */
async function handleRecall(messageId: string): Promise<void> {
try {
await recallMessage(messageId)
showToast('消息已撤回')
// 从列表中移除消息
const index = messages.value.findIndex((m: any) => m.message_id === messageId)
if (index !== -1) {
messages.value.splice(index, 1)
}
} catch (error: any) {
showToast(error?.message || '撤回失败')
}
}
/** 处理删除 */
async function handleDelete(messageId: string): Promise<void> {
try {
await deleteMessage(messageId)
showToast('消息已删除')
// 从列表中移除消息
const index = messages.value.findIndex((m: any) => m.message_id === messageId)
if (index !== -1) {
messages.value.splice(index, 1)
}
} catch (error: any) {
showToast(error?.message || '删除失败')
}
}
// 辅助函数:nextTick
function nextTick(fn: () => void): void {
setTimeout(fn, 0)
}
</script>
<style scoped>
/* ============================================================================
// 消息列表容器
// ============================================================================ */
.message-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
/* 加载中 */
.message-list__loading {
display: flex;
justify-content: center;
padding: 16px;
}
/* 空状态 */
.message-list__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
}
.message-list__empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.message-list__empty p {
color: var(--text-primary);
margin: 0;
}
.message-list__empty-hint {
font-size: 12px;
color: var(--text-placeholder);
margin-top: 8px;
}
</style>
@@ -0,0 +1,320 @@
<!-- =============================================================================
// 企微IT智能服务台 — 坐席结单确认卡片
// =============================================================================
// 说明:坐席发起结单后,员工端收到 pending_close_request WS事件,
// 在消息流中插入此卡片,让员工确认或拒绝结单。
//
// 交互流程:
// 1. 坐席点击"结单"→ 后端将会话状态改为 pending_close
// 2. WS 推送 pending_close_request 事件 → 前端插入此卡片
// 3. 员工点击"确认解决"→ POST /resolve/confirm {action:confirm}
// 4. 员工点击"继续处理"→ POST /resolve/confirm {action:reject}
// 5. 5分钟内不响应 → 后端自动关闭
//
// 倒计时:5分钟(300秒),到 0 时后端自动关闭
// ============================================================================= -->
<template>
<div class="resolve-confirm-card">
<div class="card__header">
<span class="card__icon"></span>
<span class="card__title">坐席确认问题已解决</span>
</div>
<div class="card__body">
<!-- 坐席摘要 -->
<div v-if="resolveSummary" class="card__summary">
<div class="summary__label">处理摘要</div>
<div class="summary__text">{{ resolveSummary }}</div>
</div>
<!-- 24h重开提示 -->
<div class="card__reopen-hint">
<span class="hint__icon"></span>
<span>关闭后 24 小时内可重新打开此会话</span>
</div>
<!-- 倒计时 -->
<div v-if="countdown > 0" class="card__countdown" :class="{ 'card__countdown--urgent': countdown <= 60 }">
<span class="countdown__icon"></span>
<span>{{ countdownText }}后自动关闭</span>
</div>
</div>
<!-- 按钮区 -->
<div class="card__actions">
<button
class="card__btn card__btn--confirm"
:disabled="submitting"
@click="handleConfirm"
>
{{ submitting ? '提交中…' : '确认解决' }}
</button>
<button
class="card__btn card__btn--reject"
:disabled="submitting"
@click="handleReject"
>
继续处理
</button>
</div>
</div>
</template>
<script setup lang="ts">
/**
* ResolveConfirmCard 坐席结单确认卡片
*
* 功能:
* 1. 显示坐席的结单摘要
* 2. 倒计时5分钟自动关闭
* 3. 员工确认(POST /resolve/confirm action=confirm
* 4. 员工拒绝(POST /resolve/confirm action=reject
* 5. 24小时重开提示
*
* 数据来源:WS事件 pending_close_request
*/
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { showToast } from 'vant'
import { resolveConfirm } from '@/api/closing'
// ── Props ──
defineProps<{
/** 坐席结单摘要 */
resolveSummary?: string
/** 会话ID */
conversationId?: string
}>()
// ── Emits ──
const emit = defineEmits<{
/** 确认结单成功 */
(e: 'confirmed'): void
/** 拒绝结单成功 */
(e: 'rejected'): void
/** 倒计时结束(自动关闭) */
(e: 'timeout'): void
}>()
// ── 状态 ──
/** 提交中 */
const submitting = ref(false)
/** 倒计时秒数(5分钟=300秒) */
const countdown = ref(300)
/** 倒计时定时器 */
let countdownTimer: ReturnType<typeof setInterval> | null = null
/** 倒计时文本 */
const countdownText = computed(() => {
const min = Math.floor(countdown.value / 60)
const sec = countdown.value % 60
if (min > 0) {
return `${min}${sec.toString().padStart(2, '0')}`
}
return `${sec}`
})
// ── 方法 ──
/** 确认结单 */
async function handleConfirm(): Promise<void> {
if (submitting.value) return
submitting.value = true
try {
await resolveConfirm('confirm')
showToast('已确认,会话关闭')
emit('confirmed')
} catch (err) {
console.error('[ResolveConfirmCard] 确认结单失败:', err)
showToast('确认失败,请重试')
} finally {
submitting.value = false
}
}
/** 拒绝结单 */
async function handleReject(): Promise<void> {
if (submitting.value) return
submitting.value = true
try {
await resolveConfirm('reject')
showToast('已恢复服务')
emit('rejected')
} catch (err) {
console.error('[ResolveConfirmCard] 拒绝结单失败:', err)
showToast('操作失败,请重试')
} finally {
submitting.value = false
}
}
// ── 生命周期 ──
onMounted(() => {
// 启动倒计时
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
emit('timeout')
}
}, 1000)
})
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<style scoped>
.resolve-confirm-card {
background: var(--bg-secondary);
border-radius: var(--border-radius-lg);
padding: 16px;
margin: 8px 12px;
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
/* 头部 */
.card__header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.card__icon {
font-size: 18px;
}
.card__title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
/* 主体 */
.card__body {
margin-bottom: 12px;
}
/* 摘要 */
.card__summary {
background: var(--bg-primary);
border-radius: var(--border-radius-md);
padding: 10px 12px;
margin-bottom: 10px;
}
.summary__label {
font-size: 11px;
color: var(--text-tertiary);
margin-bottom: 4px;
}
.summary__text {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
}
/* 重开提示 */
.card__reopen-hint {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.hint__icon {
font-size: 13px;
}
/* 倒计时 */
.card__countdown {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.card__countdown--urgent {
color: var(--color-danger);
font-weight: 600;
}
.countdown__icon {
font-size: 13px;
}
/* 按钮区 */
.card__actions {
display: flex;
gap: 8px;
}
.card__btn {
flex: 1;
padding: 8px 16px;
border-radius: var(--border-radius-md);
border: none;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
outline: none;
}
.card__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 确认按钮 — accent 绿色 */
.card__btn--confirm {
background: var(--accent);
color: #fff;
}
.card__btn--confirm:hover:not(:disabled) {
background: var(--accent-hover);
}
.card__btn--confirm:active:not(:disabled) {
transform: scale(0.98);
}
/* 拒绝按钮 — 灰色描边 */
.card__btn--reject {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.card__btn--reject:hover:not(:disabled) {
background: var(--bg-tertiary);
}
.card__btn--reject:active:not(:disabled) {
transform: scale(0.98);
}
</style>
@@ -34,6 +34,31 @@ const MAX_RECONNECT_DELAY = 30000
/** 重连延迟基数(毫秒):首次重连等待 1 秒 */
const RECONNECT_BASE_DELAY = 1000
/**
* 模块级 WebSocket 实例引用(v2.0 新增)
* 做什么:保存当前 WS 连接实例,供 sendWsMessage 函数在 composable 外部使用
* 为什么:store 中的 sendOptionSelect 需要通过 WS 发送消息,
* 但 ws 变量原本是 composable 闭包内的局部变量,外部无法访问
*/
let wsInstance: WebSocket | null = null
/**
* 通过 WebSocket 发送消息(v2.0 新增)
* 做什么:将数据对象序列化为 JSON 并通过 WS 发送
* 为什么:store 中的 sendOptionSelect 等函数需要在 composable 外部发送 WS 消息
*
* @param data - 要发送的数据对象
* @returns true=发送成功,false=WS 未连接或未就绪
*/
export function sendWsMessage(data: object): boolean {
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
wsInstance.send(JSON.stringify(data))
return true
}
console.warn('[H5 WS] WebSocket 未连接,无法发送消息:', data)
return false
}
/**
* H5员工端 WebSocket 组合式函数
*
@@ -111,6 +136,8 @@ export function useH5WebSocket() {
// ISS-B2 修复: 使用 WebSocket subprotocol 传递 token(与坐席端一致)
// 浏览器原生 WebSocket API 第2参数是 protocols,服务端从 sec-websocket-protocol 头读取 bearer.{token}
ws = new WebSocket(wsUrl, [`bearer.${token}`])
// v2.0: 同步到模块级变量,供 sendWsMessage 使用
wsInstance = ws
// ----------------------------------------------------------------------
// 连接成功
@@ -197,6 +224,8 @@ export function useH5WebSocket() {
ws.close()
ws = null
}
// v2.0: 清除模块级引用
wsInstance = null
// 重置重连计数
reconnectAttempts = 0
@@ -381,10 +410,79 @@ export function useH5WebSocket() {
}
break
// ==================================================================
// v2.0 新增:AI 思考指示器(blocking 模式替代流式)
// ==================================================================
case 'ai_thinking':
// AI 正在思考:显示"正在思考..."占位气泡
// 做什么:在消息列表中创建/更新一个思考中状态的 AI 气泡
// 为什么:blocking 模式下 Dify 返回需要 3-8 秒,需要即时反馈
if (msg.data) {
store.handleAiThinking(msg.data)
}
break
// ==================================================================
// v2.0 新增:动态推荐(侧边栏卡片推送)
// ==================================================================
case 'dynamic_recommend':
// AI 推送了操作卡片到侧边栏
// 做什么:将推荐卡片数据存入 storeRightPanel 的 DynamicRecommend 组件渲染
// 为什么:审批/操作入口从聊天流移到侧边栏,与文字回复强关联但不打断对话
if (msg.data) {
store.handleDynamicRecommend(msg.data)
}
break
case 'pong':
// 心跳响应,不需要处理
break
// ==================================================================
// 排队位置更新(答题插队 / 会话关闭导致队列前移)
// ==================================================================
case 'queue_position_update':
// 队列位置变化:更新 store 中的排队状态
// 做什么:将新的排队位置信息写入 store,QueueWaiting 组件会响应式更新
if (msg.data) {
store.handleQueuePositionUpdate(msg.data)
}
break
// ==================================================================
// 会话关闭事件(AI自助 / 坐席结单+员工确认 / 超时关闭 / 员工主动关闭)
// ==================================================================
case 'conversation_resolved':
// 会话已关闭:更新会话状态为 resolved
// 做什么:将 resolved 信息写入 storeUI 切换到已关闭视图
if (msg.data) {
store.handleConversationResolved(msg.data)
}
break
// ==================================================================
// 坐席发起结单 — 员工端弹出确认卡片
// ==================================================================
case 'pending_close_request':
// 坐席发起了结单,会话进入 pending_close 状态
// 做什么:在消息流中插入 ResolveConfirmCard 组件
// 员工需在 5 分钟内确认或拒绝,否则系统自动关闭
if (msg.data) {
store.handlePendingCloseRequest(msg.data)
}
break
// ==================================================================
// 诊断答题答案附加到会话上下文
// ==================================================================
case 'quiz_diagnostic_answer':
// 诊断题答案已附加到会话上下文
// 做什么:在消息流中显示一条系统消息,告知用户诊断信息已传递给坐席
if (msg.data) {
store.handleQuizDiagnosticAnswer(msg.data)
}
break
default:
console.warn(`[H5 WS] 未知消息类型: ${msg.type}`)
}
+318
View File
@@ -0,0 +1,318 @@
// =============================================================================
// 企微IT智能服务台 — H5 端分诊状态管理 Composable
// =============================================================================
// 说明:管理分诊交互的完整状态,封装所有分诊 API 调用。
// 状态:当前步骤、已收集上下文、分诊ID、分诊步骤数据等
// 方法:startTriage, submitStep, skipStep, completeTriage, transferToHuman
// =============================================================================
import { ref, computed, readonly } from 'vue'
import {
startTriage,
submitTriageStep,
skipTriageStep,
transferTriageToHuman,
completeTriage,
type TriageStep,
type TriageStartResult,
} from '@/api/triage'
/** 分诊状态 */
export type TriageStatus = 'idle' | 'loading' | 'triaging' | 'completed' | 'transferred' | 'timeout' | 'error'
/** useTriage composable — 分诊状态管理 */
export function useTriage() {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 分诊会话ID */
const triageId = ref<string>('')
/** 当前步骤序号(0-based */
const currentStepIndex = ref<number>(0)
/** 所有分诊步骤数据 */
const triageSteps = ref<TriageStep[]>([])
/** 总步骤数 */
const totalSteps = ref<number>(0)
/** 已收集的上下文 */
const collectedContext = ref<string[]>([])
/** AI 置信度 */
const confidence = ref<number | null>(null)
/** 紧急度 */
const urgency = ref<string>('medium')
/** AI 建议路由 */
const suggestedRoute = ref<string | null>(null)
/** 分诊状态 */
const status = ref<TriageStatus>('idle')
/** 错误消息 */
const errorMessage = ref<string>('')
/** 最终 AI 回复 */
const finalReply = ref<string>('')
/** 是否超时自动转人工 */
const isTimeout = ref<boolean>(false)
/** 被坐席排除的选项标签 */
const excludedLabels = ref<string[]>([])
/** 坐席推荐的选项标签 */
const recommendedLabel = ref<string>('')
// ==========================================================================
// 计算属性
// ==========================================================================
/** 当前步骤数据 */
const currentStep = computed<TriageStep | null>(() => {
if (currentStepIndex.value < triageSteps.value.length) {
return triageSteps.value[currentStepIndex.value]
}
return null
})
/** 当前步骤序号(1-based,展示用) */
const currentStepNumber = computed(() => currentStepIndex.value + 1)
/** 是否为最后一步 */
const isLastStep = computed(() => currentStepNumber.value >= totalSteps.value)
/** 是否有下一步 */
const hasNextStep = computed(() => currentStepIndex.value < triageSteps.value.length - 1)
/** 是否正在进行分诊 */
const isTriaging = computed(() => status.value === 'triaging')
/** 是否加载中 */
const isLoading = computed(() => status.value === 'loading')
// ==========================================================================
// 方法
// ==========================================================================
/**
* 发起分诊
* @param conversationId 会话ID
* @param question 员工问题文本
* @returns 是否成功
*/
async function startTriageFlow(conversationId: string, question: string): Promise<boolean> {
status.value = 'loading'
errorMessage.value = ''
isTimeout.value = false
try {
const result: TriageStartResult = await startTriage({
conversation_id: conversationId,
question,
})
// 超时自动转人工
if (result.status === 'timeout') {
status.value = 'timeout'
isTimeout.value = true
errorMessage.value = result.message || '分诊超时,已自动转人工'
return false
}
triageId.value = result.triage_id
triageSteps.value = result.steps || []
totalSteps.value = result.total || result.steps.length
confidence.value = result.confidence ?? null
urgency.value = result.urgency || 'medium'
suggestedRoute.value = result.suggested_route || null
currentStepIndex.value = 0
collectedContext.value = []
status.value = 'triaging'
return true
} catch (e: any) {
status.value = 'error'
errorMessage.value = e?.message || '发起分诊失败'
return false
}
}
/**
* 提交步骤选择
* @param selectedLabel 选择的选项标签
* @returns 是否还有下一步
*/
async function submitStep(selectedLabel: string): Promise<boolean> {
if (!triageId.value) return false
try {
const result = await submitTriageStep(
triageId.value,
currentStepIndex.value,
selectedLabel,
)
// 记录已收集上下文
collectedContext.value = result.collected_context || collectedContext.value
// 移动到下一步
if (result.next_step) {
// 如果后端返回了下一步数据,更新步骤列表
if (currentStepIndex.value + 1 < triageSteps.value.length) {
triageSteps.value[currentStepIndex.value + 1] = result.next_step
} else {
triageSteps.value.push(result.next_step)
}
currentStepIndex.value++
return true
}
// 没有下一步,分诊完成
return false
} catch (e: any) {
errorMessage.value = e?.message || '提交步骤失败'
return false
}
}
/**
* 跳过当前步骤
* @returns 是否还有下一步
*/
async function skipStep(): Promise<boolean> {
if (!triageId.value) return false
try {
const result = await skipTriageStep(triageId.value, currentStepIndex.value)
if (result.next_step) {
if (currentStepIndex.value + 1 < triageSteps.value.length) {
triageSteps.value[currentStepIndex.value + 1] = result.next_step
} else {
triageSteps.value.push(result.next_step)
}
currentStepIndex.value++
return true
}
return false
} catch (e: any) {
errorMessage.value = e?.message || '跳过步骤失败'
return false
}
}
/**
* 分诊完成,获取 AI 最终回复
* @returns AI 回复文本
*/
async function complete(): Promise<string> {
if (!triageId.value) return ''
try {
const result = await completeTriage(triageId.value, collectedContext.value)
finalReply.value = result.reply
confidence.value = result.confidence
status.value = 'completed'
return result.reply
} catch (e: any) {
errorMessage.value = e?.message || '分诊完成失败'
status.value = 'error'
return ''
}
}
/**
* 转人工
* @returns 是否成功
*/
async function transferToHuman(): Promise<boolean> {
if (!triageId.value) return false
try {
await transferTriageToHuman(triageId.value, collectedContext.value)
status.value = 'transferred'
return true
} catch (e: any) {
errorMessage.value = e?.message || '转人工失败'
return false
}
}
/**
* 设置坐席排除的选项(通过 WS 接收)
* @param labels 要排除的选项标签列表
*/
function setExcludedOptions(labels: string[]): void {
excludedLabels.value = labels
}
/**
* 设置坐席推荐的选项(通过 WS 接收)
* @param label 推荐的选项标签
*/
function setRecommendedOption(label: string): void {
recommendedLabel.value = label
}
/**
* 重置分诊状态
*/
function reset(): void {
triageId.value = ''
currentStepIndex.value = 0
triageSteps.value = []
totalSteps.value = 0
collectedContext.value = []
confidence.value = null
urgency.value = 'medium'
suggestedRoute.value = null
status.value = 'idle'
errorMessage.value = ''
finalReply.value = ''
isTimeout.value = false
excludedLabels.value = []
recommendedLabel.value = ''
}
return {
// 状态(只读)
triageId: readonly(triageId),
currentStepIndex: readonly(currentStepIndex),
triageSteps: readonly(triageSteps),
totalSteps: readonly(totalSteps),
collectedContext: readonly(collectedContext),
confidence: readonly(confidence),
urgency: readonly(urgency),
suggestedRoute: readonly(suggestedRoute),
status: readonly(status),
errorMessage: readonly(errorMessage),
finalReply: readonly(finalReply),
isTimeout: readonly(isTimeout),
excludedLabels: readonly(excludedLabels),
recommendedLabel: readonly(recommendedLabel),
// 计算属性
currentStep,
currentStepNumber,
isLastStep,
hasNextStep,
isTriaging,
isLoading,
// 方法
startTriageFlow,
submitStep,
skipStep,
complete,
transferToHuman,
setExcludedOptions,
setRecommendedOption,
reset,
}
}
@@ -0,0 +1,499 @@
// =============================================================================
// 企微IT智能服务台 — 企微审批原生打开 composable(H5 员工端)
// =============================================================================
// 说明:封装企微 JS-SDK 的审批原生打开流程,提供:
// 1. init():幂等初始化(加载脚本 + wx.config + wx.agentConfig 双鉴权)
// 2. openUrl(url):智能路由——企微审批URL用 thirdPartyOpenPage 原生打开,
// ITSM工单等其他URL用 window.location.href 同窗口导航
// 3. isWecomEnv():检测是否在企微内置浏览器中
//
// 核心价值:
// 用户点击审批选项后,企微审批在企微内原生打开(不跳转网页、不另开窗口),
// 用户体验与企微原生审批一致。
//
// 降级策略:
// 所有失败路径(非企微环境/SDK加载失败/鉴权失败/invoke失败)
// 统一降级为 window.location.href,确保用户始终能打开审批页面。
//
// 参考实现:
// - useWecomVoice.ts:幂等 init + 脚本加载 + wx.config 超时保护模式
// - EmergencyDispatcher.vuewx.agentConfig 调用模式(但签名用错了,已修正)
// - ContactCard.vuewx.invoke 调用模式
// =============================================================================
import { ref } from 'vue'
import { getJsapiConfig } from '@/api/wecom'
import type { WxThirdPartyOpenPageParams } from '@/types/wecom-jssdk'
// ---------------------------------------------------------------------------
// 常量
// ---------------------------------------------------------------------------
/** 企微 JS-SDK 脚本地址(与 useWecomVoice 共用) */
const JWEIXIN_SCRIPT_URL = 'https://res.wx.qq.com/open/js/jweixin-1.2.0.js'
/** wx.config 超时时间(毫秒)— 企微 WebView 中 ready/error 可能都不触发 */
const WX_CONFIG_TIMEOUT = 8000
/** wx.agentConfig 超时时间(毫秒) */
const WX_AGENT_CONFIG_TIMEOUT = 8000
/** 脚本加载超时时间(毫秒) */
const SCRIPT_LOAD_TIMEOUT = 10000
/** wx.invoke 超时时间(毫秒)— 回调可能不触发 */
const WX_INVOKE_TIMEOUT = 8000
/**
* wx.config 需要注册的 jsApiList
*
* agentConfig:注册为"应用身份"接口
* thirdPartyOpenPage:原生打开审批页面
* checkJsApi:检查接口可用性(诊断用)
*/
const WX_CONFIG_API_LIST = [
'agentConfig',
'thirdPartyOpenPage',
'checkJsApi',
]
/**
* wx.agentConfig 需要注册的 jsApiList
*
* thirdPartyOpenPage 是应用级接口,必须在 agentConfig 中注册
*/
const WX_AGENT_CONFIG_API_LIST = [
'thirdPartyOpenPage',
]
// ---------------------------------------------------------------------------
// composable 实现
// ---------------------------------------------------------------------------
/**
* 企微审批原生打开 composable
*
* 使用方式:
* const { openUrl, isWecomEnv } = useWecomApproval()
* await openUrl('https://app.work.weixin.qq.com/...?template_id=XXX')
*
* 特点:
* - 幂等:init() 只执行一次,后续调用即时响应
* - 自动降级:任何失败都降级为 window.location.href
* - 智能路由:自动识别企微审批URL vs ITSM工单URL
*/
export function useWecomApproval() {
/** 是否已初始化完成(config + agentConfig 均成功) */
const isReady = ref(false)
/** 是否正在初始化中 */
const isLoading = ref(false)
/** 错误信息(初始化失败时设置) */
const error = ref<string | null>(null)
/** 内部标志:防止重复初始化 */
let initialized = false
// ------------------------------------------------------------------------
// 工具函数
// ------------------------------------------------------------------------
/**
* 检测是否在企微内置浏览器中
*
* 判断依据:User-Agent 包含 wxwork
* 注意:PC 企微和手机企微的 UA 都包含 wxwork
*/
function isWecomEnv(): boolean {
return /wxwork/i.test(navigator.userAgent)
}
/**
* 从审批URL中解析 template_id
*
* 企微审批URL格式:
* https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=XXX&...
*
* @param url 审批URL
* @returns template_id 字符串,解析失败返回 null
*/
function parseTemplateId(url: string): string | null {
const match = url.match(/template_id=([^&]+)/)
return match ? match[1] : null
}
/**
* 判断URL是否为企微审批URL
*
* 企微审批URL特征:包含 template_id 参数
* ITSM工单URLhttps://devops.dc.servyou-it.com/...(不含 template_id
*
* @param url 待判断的URL
* @returns true=企微审批URL, false=其他URLITSM等)
*/
function isWecomApprovalUrl(url: string): boolean {
return url.includes('template_id=')
}
// ------------------------------------------------------------------------
// 内部方法:脚本加载
// ------------------------------------------------------------------------
/**
* 动态加载企微 JS-SDK 脚本
*
* 幂等性:如果 window.wx 已存在,直接 resolve
* 超时保护:10 秒未加载完成则 reject
*
* 与 useWecomVoice.ts 的 loadJweixinScript 逻辑一致,
* 但作为独立函数存在(避免 composable 间耦合)。
*/
function loadJweixinScript(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (typeof window.wx !== 'undefined') {
resolve()
return
}
const timer = setTimeout(() => {
script.onload = null
script.onerror = null
reject(new Error('加载企微 JS-SDK 脚本超时'))
}, SCRIPT_LOAD_TIMEOUT)
const script = document.createElement('script')
script.src = JWEIXIN_SCRIPT_URL
script.async = true
script.onload = () => {
clearTimeout(timer)
if (typeof window.wx !== 'undefined') {
resolve()
} else {
reject(new Error('JS-SDK 脚本已加载但 window.wx 未挂载'))
}
}
script.onerror = () => {
clearTimeout(timer)
reject(new Error('加载企微 JS-SDK 脚本失败'))
}
document.head.appendChild(script)
})
}
// ------------------------------------------------------------------------
// 内部方法:wx.config 鉴权
// ------------------------------------------------------------------------
/**
* 调用 wx.config 进行企业身份鉴权
*
* 关键点:
* 1. 超时保护 — 企微 WebView 中 ready/error 可能都不触发
* 2. settled 标志 — 防止 ready 和 error 都触发时重复 resolve/reject
* 3. beta: true — 开启内测接口(agentConfig 需要)
*
* 参考:useWecomVoice.ts 第152-227行
*/
function wxConfig(config: {
corp_id: string
timestamp: string | number
nonce_str: string
signature: string
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
const wx = window.wx
if (!wx || typeof wx.config !== 'function') {
reject(new Error('wx.config 不可用,JS-SDK 未正确加载'))
return
}
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
reject(new Error(`wx.config 鉴权超时(${WX_CONFIG_TIMEOUT / 1000}秒无响应)`))
}
}, WX_CONFIG_TIMEOUT)
wx.ready(() => {
if (!settled) {
settled = true
clearTimeout(timer)
resolve()
}
})
wx.error((err: { errMsg: string }) => {
if (!settled) {
settled = true
clearTimeout(timer)
reject(new Error(`wx.config 失败: ${err.errMsg}`))
}
})
wx.config({
beta: true,
debug: false,
appId: config.corp_id,
timestamp: config.timestamp,
nonceStr: config.nonce_str,
signature: config.signature,
jsApiList: WX_CONFIG_API_LIST,
})
})
}
// ------------------------------------------------------------------------
// 内部方法:wx.agentConfig 鉴权
// ------------------------------------------------------------------------
/**
* 调用 wx.agentConfig 进行应用身份鉴权
*
* 关键修正(vs EmergencyDispatcher.vue):
* EmergencyDispatcher.vue 错误地将 jsapi 签名复用于 agentConfig
* 本方法使用后端返回的 agent_config 签名(正确做法)。
*
* 注意:
* 1. 必须在 wx.config 成功后调用
* 2. signature 使用 agent_config_ticket 计算,不是 jsapi_ticket
* 3. 超时保护 — agentConfig 也可能不触发回调
*/
function wxAgentConfig(config: {
corp_id: string
agent_id: string
timestamp: string | number
agent_config: {
timestamp: string | number
nonce_str: string
signature: string
}
}): Promise<void> {
return new Promise<void>((resolve, reject) => {
const wx = window.wx
if (!wx || typeof (wx as any).agentConfig !== 'function') {
reject(new Error('wx.agentConfig 不可用'))
return
}
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
reject(new Error(`wx.agentConfig 超时(${WX_AGENT_CONFIG_TIMEOUT / 1000}秒无响应)`))
}
}, WX_AGENT_CONFIG_TIMEOUT)
;(wx as any).agentConfig({
corpid: config.corp_id,
agentid: config.agent_id,
timestamp: config.agent_config.timestamp,
nonceStr: config.agent_config.nonce_str,
signature: config.agent_config.signature,
jsApiList: WX_AGENT_CONFIG_API_LIST,
success: () => {
if (!settled) {
settled = true
clearTimeout(timer)
resolve()
}
},
fail: (err: { errMsg: string }) => {
if (!settled) {
settled = true
clearTimeout(timer)
reject(new Error(`wx.agentConfig 失败: ${err.errMsg}`))
}
},
})
})
}
// ------------------------------------------------------------------------
// 内部方法:wx.invoke('thirdPartyOpenPage')
// ------------------------------------------------------------------------
/**
* 通过 wx.invoke 调用 thirdPartyOpenPage 原生打开审批
*
* 做什么:在企微内原生打开指定模板的审批表单
*
* 参数说明:
* - oaType '10001':发起审批(打开空白表单让用户填写)
* - templateId:从审批URL中解析的模板ID
* - thirdNo:每次调用生成唯一ID(防止重复)
* - extData.fieldList 传空数组(不预填表单数据)
*
* 超时保护:
* wx.invoke 的回调可能不触发(尤其在 agentConfig 失败时),
* 设 8 秒超时,超时后降级为 window.location.href
*/
function invokeThirdPartyOpenPage(templateId: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const wx = window.wx
if (!wx || typeof (wx as any).invoke !== 'function') {
resolve(false)
return
}
const params: WxThirdPartyOpenPageParams = {
oaType: '10001',
templateId: templateId,
thirdNo: `itsmartdesk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
extData: { fieldList: [] },
}
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
resolve(false)
}
}, WX_INVOKE_TIMEOUT)
;(wx as any).invoke(
'thirdPartyOpenPage',
params,
(res: { err_msg: string }) => {
if (!settled) {
settled = true
clearTimeout(timer)
// err_msg 格式:'thirdPartyOpenPage:ok' 或 'thirdPartyOpenPage:fail'
resolve(res.err_msg === 'thirdPartyOpenPage:ok')
}
},
)
})
}
// ------------------------------------------------------------------------
// 公开方法:init
// ------------------------------------------------------------------------
/**
* 幂等初始化 JS-SDK
*
* 流程:
* 1. 加载 JS-SDK 脚本
* 2. 获取双签名(jsapi + agent_config
* 3. wx.config 企业身份鉴权
* 4. wx.agentConfig 应用身份鉴权
*
* 幂等性:首次调用执行完整流程,后续调用直接返回
*
* @throws 初始化失败时抛出错误(调用方应捕获并降级)
*/
async function init(): Promise<void> {
if (initialized) {
return
}
isLoading.value = true
error.value = null
try {
// Step 1: 加载 JS-SDK 脚本
await loadJweixinScript()
// Step 2: 获取双签名(with_agent_config=true
const currentUrl = window.location.href.split('#')[0]
const config = await getJsapiConfig(currentUrl, true)
if (!config.agent_config) {
throw new Error('后端未返回 agent_config 签名')
}
// Step 3: wx.config 企业身份鉴权
await wxConfig(config)
// Step 4: wx.agentConfig 应用身份鉴权
// 此时 config.agent_config 已确认非空(上面已检查)
await wxAgentConfig({
corp_id: config.corp_id,
agent_id: config.agent_id,
timestamp: config.timestamp,
agent_config: config.agent_config,
})
initialized = true
isReady.value = true
} catch (err: any) {
error.value = err?.message || 'JS-SDK 初始化失败'
// 不抛出错误,由 openUrl 统一处理降级
} finally {
isLoading.value = false
}
}
// ------------------------------------------------------------------------
// 公开方法:openUrl
// ------------------------------------------------------------------------
/**
* 打开审批链接(智能路由)
*
* 路由逻辑:
* 1. 非企微环境 → window.location.href(降级)
* 2. ITSM工单URL(不含 template_id)→ window.location.href(同窗口导航)
* 3. 企微审批URL → init() → wx.invoke('thirdPartyOpenPage') 原生打开
* 4. 原生打开失败 → window.location.href(降级)
*
* 所有降级路径统一为 window.location.href,确保用户始终能打开审批页面。
*
* @param url 审批链接
*/
async function openUrl(url: string): Promise<void> {
// 1. 非企微环境 → 直接同窗口导航
if (!isWecomEnv()) {
window.location.href = url
return
}
// 2. 非企微审批URL(ITSM工单等)→ 同窗口导航
if (!isWecomApprovalUrl(url)) {
window.location.href = url
return
}
// 3. 企微审批URL → 尝试原生打开
const templateId = parseTemplateId(url)
if (!templateId) {
// URL格式异常,降级为同窗口导航
window.location.href = url
return
}
// 4. 初始化 JS-SDK(幂等,首次有加载延迟)
if (!initialized) {
await init()
}
// 5. 如果初始化失败,降级为同窗口导航
if (!isReady.value) {
window.location.href = url
return
}
// 6. 调用 thirdPartyOpenPage 原生打开审批
const success = await invokeThirdPartyOpenPage(templateId)
// 7. 原生打开失败 → 降级为同窗口导航
if (!success) {
window.location.href = url
}
}
return {
isReady,
isLoading,
error,
isWecomEnv,
openUrl,
init,
}
}
-7
View File
@@ -89,13 +89,6 @@ const routes = [
component: () => import('@/views/VideoIntro.vue'),
meta: { title: '智能IT服务', requiresAuth: false },
},
// 会议室预定页面(员工可查看会议室状态、预定/取消会议室)
{
path: '/meetingroom',
name: 'MeetingroomView',
component: () => import('@/views/MeetingroomView.vue'),
meta: { title: '会议室预定', requiresAuth: true },
},
// 404 兜底:未匹配的路径重定向到首页
{
path: '/:pathMatch(.*)*',
+378 -44
View File
@@ -21,7 +21,7 @@ import {
shake,
getApprovalLinks,
getSoftwareDownloads,
detectApprovalIntent,
// v2.0: detectApprovalIntent 已移除(审批意图识别统一由后端处理)
leaveAsParticipant as leaveAsParticipantApi,
type UserInfo,
type ConversationInfo,
@@ -35,6 +35,8 @@ import {
type ParticipantItem,
} from '@/api/conversation'
import { useEmployeeStore } from '@/stores/employee'
// v2.0: 导入 WS 发送函数(用于选项选择回传)
import { sendWsMessage } from '@/composables/useH5WebSocket'
// --------------------------------------------------------------------------
// 本地缓存配置
@@ -186,6 +188,29 @@ export const useConversationStore = defineStore('conversation', () => {
/** 参与者面板是否展开 */
const participantPanelVisible = ref<boolean>(false)
// ==========================================================================
// v2.0 新增:动态推荐状态(侧边栏卡片推送)
// ==========================================================================
/**
* 动态推荐卡片列表(由后端 WS dynamic_recommend 推送)
* 做什么:存储 AI 推荐的审批/操作入口卡片,供 RightPanel 的 DynamicRecommend 组件渲染
* 为什么:审批卡片从聊天流移到侧边栏,与文字回复强关联但不打断对话
* 最多保留 3 张,超过时移除最旧的
*/
const dynamicRecommendations = ref<Array<{
recommend_id: string
card_type: string
title: string
description: string
approval_type?: string
confidence: number
message_id: string
conversation_id: string
}>>([])
/** 动态推荐未查看数量(用于 Badge 红点提示) */
const unreadRecommendCount = ref<number>(0)
// ==========================================================================
// 流式 AI 回复(打字机)临时状态 — 改造方案 A
// ==========================================================================
@@ -453,11 +478,9 @@ export const useConversationStore = defineStore('conversation', () => {
return
}
// 异步检测审批意图(不阻塞消息发送流程)
// 员工发消息 → 消息正常发送 → 异步检测审批意图 → 如果命中则在消息列表中插入审批卡片
checkApprovalIntent(content).catch((err) => {
console.warn('[Store] 审批意图检测异常:', err)
})
// v2.0 改造(2026-07-13):删除前端 checkApprovalIntent 异步调用
// 审批意图识别已统一由后端 process_h5_ai_reply → get_structured_reply 处理
// 结果通过 WS dynamic_recommend 推送到侧边栏,不再由前端独立调用
// ========================================================================
// 步骤1:乐观更新 - 立即添加临时消息到列表
@@ -743,7 +766,8 @@ export const useConversationStore = defineStore('conversation', () => {
// 如果已分配坐席,更新话术内容
if (data.assign_result === 'assigned' && data.assigned_agent_id) {
systemMsg.content = `${data.funny_phrase}\n\n🎉 坐席已为您服务,请稍候...`
// 2026-07-12 改造:去掉 🎉 emoji,文案与后端 funny_phrases.connected 保持一致
systemMsg.content = `${data.funny_phrase}\n\n坐席正在查看您的信息,请等待处理回复!`
// 更新会话状态
if (currentConversation.value) {
currentConversation.value.status = 'serving'
@@ -782,41 +806,10 @@ export const useConversationStore = defineStore('conversation', () => {
}
}
/**
* 异步检测审批意图
* 调用后端 /approval/detect-intent 接口,后端先用关键词预过滤,
* 命中后调 Dify 做意图识别。如果检测到审批意图(is_approval_request === true),
* 在消息列表中插入一条审批卡片消息(不阻塞消息发送流程)。
*
* @param text 用户输入的消息文本
*/
async function checkApprovalIntent(text: string): Promise<void> {
try {
const response = await detectApprovalIntent(text)
console.log('[Store] 审批意图检测结果:', response)
if (response.is_approval_request) {
// 在消息列表中插入审批卡片消息(作为消息流内联卡片,不是弹窗)
const approvalCardMessage: Message = {
message_id: `approval_card_${Date.now()}`,
conversation_id: currentConversation.value?.conversation_id || '',
message_type: 'system',
msg_type: 'approval_card',
content: '',
sender_name: '',
created_at: new Date().toISOString(),
extra_data: {
approval_type: response.approval_type,
confidence: response.confidence,
},
}
messages.value.push(approvalCardMessage)
console.log('[Store] 已插入审批卡片消息, approval_type:', response.approval_type)
}
} catch (error) {
console.warn('[Store] 审批意图检测失败:', error)
}
}
// v2.0 改造(2026-07-13):checkApprovalIntent() 已删除
// 审批意图识别统一由后端 process_h5_ai_reply → get_structured_reply 处理
// 结果通过 WS dynamic_recommend 推送到侧边栏 DynamicRecommend 组件
// 前端不再独立调用 /approval/detect-intent 接口
/** 关闭审批卡片弹窗(兼容旧调用,新流程中审批卡片为内联消息,无需关闭) */
function closeApprovalCard(): void {
@@ -1072,6 +1065,7 @@ export const useConversationStore = defineStore('conversation', () => {
ai_reply_count: number
can_call_agent: boolean
conversation_status: string
extra_data?: Record<string, any> // v2.0: 结构化消息的 options/action
}): void {
if (currentConversation.value?.conversation_id !== data.conversation_id) return
@@ -1084,6 +1078,8 @@ export const useConversationStore = defineStore('conversation', () => {
sender_name: data.sender_name || 'Duckula(达寇拉)',
created_at: new Date().toISOString(),
status: 'sent',
// v2.0: 传递结构化数据(options 按钮列表、action 卡片信息)
extra_data: data.extra_data || undefined,
}
// 用真实消息替换占位气泡(若存在)
@@ -1158,8 +1154,148 @@ export const useConversationStore = defineStore('conversation', () => {
streamingAiMessageId.value = null
}
// ==========================================================================
// v2.0 新增:AI 思考指示器 + 动态推荐 + 选项回传
// ==========================================================================
/**
* 取消流式气泡(WS 断连时调用
* 处理 WS ai_thinking 消息(v2.0 新增
* 做什么:在消息列表中创建/更新一个"正在思考..."的 AI 占位气泡
* 为什么:blocking 模式下 Dify 返回需要 3-8 秒,需要即时反馈让用户知道 AI 在工作
*
* @param data - { conversation_id, status? }
* - status 未定义:首次思考指示,创建占位气泡显示"正在思考..."
* - status = "still_thinking"15 秒后更新为"仍在思考..."
*/
function handleAiThinking(data: { conversation_id: string; status?: string }): void {
if (currentConversation.value?.conversation_id !== data.conversation_id) return
// 如果已有占位气泡(streamingAiMessageId 或 thinking 气泡),更新文字
const thinkingId = streamingAiMessageId.value
const idx = thinkingId
? messages.value.findIndex(m => m.message_id === thinkingId)
: -1
const thinkingText = data.status === 'still_thinking'
? '仍在思考中,请稍候...'
: '正在思考...'
if (idx !== -1) {
// 更新已有占位气泡的文字
messages.value[idx].content = thinkingText
} else {
// 创建新的占位气泡
const tempId = `ai_thinking_${data.conversation_id}_${Date.now()}`
streamingAiMessageId.value = tempId
messages.value.push({
message_id: tempId,
conversation_id: data.conversation_id,
message_type: 'ai',
msg_type: 'text',
content: thinkingText,
sender_name: 'Duckula(达寇拉)',
created_at: new Date().toISOString(),
})
}
}
/**
* 处理 WS dynamic_recommend 消息(v2.0 新增)
* 做什么:将 AI 推荐的操作卡片数据存入 dynamicRecommendations
* RightPanel 的 DynamicRecommend 组件会响应式渲染
* 为什么:审批/操作入口从聊天流移到侧边栏,与文字回复强关联但不打断对话
*
* @param data - 推荐卡片数据 { recommend_id, card_type, title, description, ... }
*/
function handleDynamicRecommend(data: {
recommend_id: string
card_type: string
title: string
description: string
approval_type?: string
confidence: number
message_id: string
conversation_id: string
}): void {
// 添加到推荐列表(最多 3 张,超过时移除最旧的)
dynamicRecommendations.value.push(data)
if (dynamicRecommendations.value.length > 3) {
dynamicRecommendations.value.shift()
}
// 增加未读计数(用于 Badge 红点提示)
unreadRecommendCount.value += 1
console.log('[Store] 动态推荐已接收:', data.title, '未读:', unreadRecommendCount.value)
}
/**
* 清除未读推荐计数(用户查看侧边栏推荐时调用)
*/
function clearUnreadRecommend(): void {
unreadRecommendCount.value = 0
}
/**
* 移除指定的推荐卡片(用户点击 × 关闭时调用)
*/
function removeRecommend(recommendId: string): void {
const idx = dynamicRecommendations.value.findIndex(r => r.recommend_id === recommendId)
if (idx !== -1) {
dynamicRecommendations.value.splice(idx, 1)
}
}
/**
* 发送选项选择(v2.0 新增)
* 做什么:用户点击聊天气泡中的选项按钮后,通过 WS 发送 option_select 消息
* 后端接收后转化为 Dify user message,继续对话
* 为什么:实现交互式排查的闭环——AI 提问 → 用户点选项 → AI 继续推理
*
* @param optionValue - 选项的 value 字段(如 "has_code"
* @param optionLabel - 选项的 label 字段(如 "有错误代码"),作为用户消息显示
*/
function sendOptionSelect(optionValue: string, optionLabel: string): void {
const convId = currentConversation.value?.conversation_id
if (!convId) return
// 1. 在消息列表中显示用户的选择(作为员工消息)
const employeeStore = useEmployeeStore()
messages.value.push({
message_id: `option_select_${Date.now()}`,
conversation_id: convId,
message_type: 'employee',
msg_type: 'text',
content: optionLabel,
sender_name: employeeStore.employeeName || '我',
created_at: new Date().toISOString(),
status: 'sent',
})
// 2. 通过 WS 发送 option_select 消息(v2.0 已接入)
// 后端接收后转化为 Dify user message,继续对话
// WS 消息格式:{ type: "option_select", data: { conversation_id, option_value, option_label } }
const sent = sendWsMessage({
type: 'option_select',
data: {
conversation_id: convId,
option_value: optionValue,
option_label: optionLabel,
},
})
if (!sent) {
// WS 未连接,降级为普通消息发送(HTTP API)
console.warn('[Store] WS 未连接,选项选择降级为 HTTP 消息发送')
// 通过现有的 sendMessage API 发送,后端会走正常 AI 回复流程
sendMessage({ content: optionLabel, msg_type: 'text' }).catch((err: unknown) => {
console.error('[Store] 选项选择 HTTP 降级发送失败:', err)
})
}
console.log('[Store] 选项选择已发送:', optionValue, optionLabel)
}
/**
* 取消流式占位气泡
* 做什么:若 WS 在流式推送中途断开,移除未完成的占位气泡,
* 交由 3 秒轮询兜底重新拉取完整 AI 消息
* 为什么:避免断连后留下半成品打字气泡
@@ -1173,6 +1309,184 @@ export const useConversationStore = defineStore('conversation', () => {
streamingAiMessageId.value = null
}
// ==========================================================================
// 新增:排队/关闭机制 WS 事件处理
// ==========================================================================
/** 排队位置更新数据(来自 WS queue_position_update 事件) */
const queuePositionData = ref<{
position: number
segment: string
ahead_count: number
queue_priority: number
} | null>(null)
/** 坐席结单请求(来自 WS pending_close_request 事件) */
const pendingCloseRequest = ref<{
conversation_id: string
resolve_summary: string
agent_name: string
} | null>(null)
/** 会话已关闭信息(来自 WS conversation_resolved 事件) */
const resolvedInfo = ref<{
conversation_id: string
resolved_by: string
resolved_method: string
resolve_summary: string
} | null>(null)
/**
* 处理排队位置更新事件
* WS event: queue_position_update
*/
function handleQueuePositionUpdate(data: {
conversation_id: string
position: number
segment: string
ahead_count: number
queue_priority: number
}): void {
if (currentConversation.value?.conversation_id !== data.conversation_id) return
queuePositionData.value = {
position: data.position,
segment: data.segment,
ahead_count: data.ahead_count,
queue_priority: data.queue_priority,
}
}
/**
* 处理会话关闭事件
* WS event: conversation_resolved
*/
function handleConversationResolved(data: {
conversation_id: string
status: string
resolved_by: string
resolved_method: string
resolve_summary: string
}): void {
if (currentConversation.value?.conversation_id !== data.conversation_id) return
// 更新会话状态
if (currentConversation.value) {
currentConversation.value.status = 'resolved'
}
resolvedInfo.value = {
conversation_id: data.conversation_id,
resolved_by: data.resolved_by,
resolved_method: data.resolved_method,
resolve_summary: data.resolve_summary || '',
}
// 清除结单请求卡片
pendingCloseRequest.value = null
// 添加系统消息
const resolveMsg: Message = {
message_id: `resolve_${data.conversation_id}_${Date.now()}`,
conversation_id: data.conversation_id,
message_type: 'system',
msg_type: 'text',
content: getResolveMessageText(data.resolved_method),
sender_name: '系统',
created_at: new Date().toISOString(),
status: 'sent',
}
messages.value.push(resolveMsg)
}
/**
* 处理坐席结单请求
* WS event: pending_close_request
*/
function handlePendingCloseRequest(data: {
conversation_id: string
resolve_summary: string
agent_name: string
}): void {
if (currentConversation.value?.conversation_id !== data.conversation_id) return
// 更新会话状态为 pending_close
if (currentConversation.value) {
currentConversation.value.status = 'waiting' as any // pending_close 映射
}
pendingCloseRequest.value = {
conversation_id: data.conversation_id,
resolve_summary: data.resolve_summary || '',
agent_name: data.agent_name || '坐席',
}
// 添加系统消息提示
const systemMsg: Message = {
message_id: `pending_close_${data.conversation_id}_${Date.now()}`,
conversation_id: data.conversation_id,
message_type: 'system',
msg_type: 'text',
content: `📋 ${data.agent_name || '坐席'}发起了结单请求,请确认`,
sender_name: '系统',
created_at: new Date().toISOString(),
status: 'sent',
}
messages.value.push(systemMsg)
}
/**
* 处理诊断答题答案附加到上下文
* WS event: quiz_diagnostic_answer
*/
function handleQuizDiagnosticAnswer(data: {
question: string
selected_answer: string
conversation_id: string
}): void {
// 添加系统消息,告知用户诊断信息已传递
const systemMsg: Message = {
message_id: `quiz_diag_${data.conversation_id}_${Date.now()}`,
conversation_id: data.conversation_id,
message_type: 'system',
msg_type: 'text',
content: `📝 诊断信息已传递给坐席:${data.question}${data.selected_answer}`,
sender_name: '系统',
created_at: new Date().toISOString(),
status: 'sent',
}
messages.value.push(systemMsg)
}
/** 清除结单请求(确认或拒绝后调用) */
function clearPendingCloseRequest(): void {
pendingCloseRequest.value = null
}
/** 清除关闭信息(重开会话后调用) */
function clearResolvedInfo(): void {
resolvedInfo.value = null
queuePositionData.value = null
}
/**
* 根据关闭方式生成系统消息文本
*/
function getResolveMessageText(resolvedMethod: string): string {
if (resolvedMethod === 'ai_self') {
return '✅ 问题已解决,会话已关闭。24小时内可重新打开。'
}
if (resolvedMethod === 'agent_confirm') {
return '✅ 坐席已结单,会话已关闭。24小时内可重新打开。'
}
if (resolvedMethod === 'employee_initiative') {
return '✅ 会话已关闭。24小时内可重新打开。'
}
if (resolvedMethod === 'system_timeout') {
return '⏰ 会话因长时间无响应已自动关闭。24小时内可重新打开。'
}
return '✅ 会话已关闭。24小时内可重新打开。'
}
/**
* 初始化应用
* 1. 获取用户信息
@@ -1325,7 +1639,7 @@ export const useConversationStore = defineStore('conversation', () => {
stopPolling,
shakeAgent,
fetchApprovalLinks,
checkApprovalIntent,
// v2.0: checkApprovalIntent 已删除(审批意图识别统一由后端处理)
closeApprovalCard,
showApprovalCard,
fetchSoftwareDownloads,
@@ -1347,5 +1661,25 @@ export const useConversationStore = defineStore('conversation', () => {
handleAiReply,
handleAiReplyFailed,
cancelStreamingBubble,
// v2.0 新增:AI 思考指示器 + 动态推荐 + 选项回传
handleAiThinking,
handleDynamicRecommend,
clearUnreadRecommend,
removeRecommend,
sendOptionSelect,
dynamicRecommendations,
unreadRecommendCount,
// 排队/关闭机制 WS 事件处理
queuePositionData,
pendingCloseRequest,
resolvedInfo,
handleQueuePositionUpdate,
handleConversationResolved,
handlePendingCloseRequest,
handleQuizDiagnosticAnswer,
clearPendingCloseRequest,
clearResolvedInfo,
}
})
-27
View File
@@ -79,9 +79,6 @@
--color-system-text: #94a3b8;
/* 系统消息背景 */
--color-system-bg: #f0f2f5;
/* 摇人按钮绿色渐变 — 企微风格 */
--color-shake-start: #07C160;
--color-shake-end: #06ae56;
/* 呼叫引导条文字 */
--color-guide-active: #f97316;
@@ -168,8 +165,6 @@
--color-ai-tag-text: #34d399;
--color-system-text: #5c7185;
--color-system-bg: #1a2736;
--color-shake-start: #FF6B35;
--color-shake-end: #FF8F5E;
--color-guide-active: #fb923c;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
@@ -231,28 +226,6 @@ textarea {
-webkit-tap-highlight-color: transparent;
}
/* --------------------------------------------------------------------------
* 摇人按钮摇晃动画
* -------------------------------------------------------------------------- */
@keyframes shake {
0% { transform: rotate(0deg); }
10% { transform: rotate(-15deg); }
20% { transform: rotate(15deg); }
30% { transform: rotate(-10deg); }
40% { transform: rotate(10deg); }
50% { transform: rotate(-5deg); }
60% { transform: rotate(5deg); }
70% { transform: rotate(-2deg); }
80% { transform: rotate(2deg); }
90% { transform: rotate(0deg); }
100% { transform: rotate(0deg); }
}
/* 摇人按钮动画类 */
.shake-animation {
animation: shake 0.6s ease-in-out;
}
/* --------------------------------------------------------------------------
* 通用工具类
* -------------------------------------------------------------------------- */
+98
View File
@@ -135,6 +135,78 @@ export interface WxCheckJsApiOptions {
fail?: (error: WxError) => void
}
// ---------------------------------------------------------------------------
// wx.agentConfig() 的参数选项
// ---------------------------------------------------------------------------
/**
* wx.agentConfig() 的参数配置
*
* 做什么:应用身份鉴权,比 wx.config 更高权限
*
* 与 wx.config 的区别:
* - wx.config 用 jsapi_ticket 签名 → 企业身份
* - wx.agentConfig 用 agent_config_ticket 签名 → 应用身份
* - thirdPartyOpenPage 等接口需要应用身份,必须调 agentConfig
*
* 注意字段命名:
* - corpid / agentid 是小写无下划线(企微规范)
* - nonceStr 是驼峰(与 config 一致)
*/
export interface WxAgentConfigOptions {
/** 企业 CorpID */
corpid: string
/** 应用 AgentID */
agentid: string
/** 时间戳(后端生成,单位秒) */
timestamp: string | number
/** 随机字符串(驼峰命名) */
nonceStr: string
/** agent_config 签名(用 agent_config_ticket 计算) */
signature: string
/** 需要使用的应用级 JS 接口列表 */
jsApiList: string[]
/** 鉴权成功回调 */
success?: (res: any) => void
/** 鉴权失败回调 */
fail?: (error: WxError) => void
}
// ---------------------------------------------------------------------------
// wx.invoke('thirdPartyOpenPage') 的参数选项
// ---------------------------------------------------------------------------
/**
* thirdPartyOpenPage 的参数
*
* 做什么:在企微内原生打开审批页面(发起审批 / 查看审批详情)
*
* 前提条件:
* 1. 已调用 wx.config() 企业身份鉴权
* 2. 已调用 wx.agentConfig() 应用身份鉴权
* 3. 应用具有审批权限
*
* oaType 说明:
* - '10001':发起审批(打开空白审批表单让用户填写)
* - '10002':查看审批详情(需要 extData 传入审批单信息)
*/
export interface WxThirdPartyOpenPageParams {
/** 审批操作类型:10001=发起审批, 10002=查看审批详情 */
oaType: '10001' | '10002'
/** 审批模板 ID(企微管理后台创建审批模板时生成) */
templateId: string
/** 开发者自定义唯一审批单号(不可重复,用于标识本次打开) */
thirdNo: string
/** 详情数据(查看审批详情时传入,发起审批可传空 fieldList) */
extData?: {
fieldList: Array<{
title?: string
type?: 'text' | 'link'
value?: string
}>
}
}
// ---------------------------------------------------------------------------
// 企微 JS-SDK 核心接口 Wx
// ---------------------------------------------------------------------------
@@ -191,6 +263,32 @@ export interface Wx {
* 做什么:检查当前客户端是否支持指定的 JS 接口
*/
checkJsApi(options: WxCheckJsApiOptions): void
/**
* 应用身份鉴权
* 做什么:传入 agent_config_ticket 签名,完成应用身份鉴权
* 注意:必须先调 wx.config 成功后才能调 agentConfig
* thirdPartyOpenPage 等接口需要应用身份
*/
agentConfig(options: WxAgentConfigOptions): void
/**
* 调用企微原生接口
* 做什么:通过 invoke 调用企微提供的各种原生能力
*
* 已知可用的 apiName
* - 'openEnterpriseChat':打开企微聊天(ContactCard.vue 已使用)
* - 'thirdPartyOpenPage':原生打开审批页面(本项目中用于审批卡片)
*
* @param apiName 接口名称
* @param params 接口参数(结构因 apiName 而异)
* @param callback 回调函数,res.err_msg 格式为 'apiName:ok' 或 'apiName:fail'
*/
invoke(
apiName: string,
params: Record<string, any>,
callback: (res: { err_msg: string; [key: string]: any }) => void,
): void
}
// ---------------------------------------------------------------------------
-920
View File
@@ -1,920 +0,0 @@
<!--
=============================================================================
企微IT智能服务台 H5端会议室预定页面
=============================================================================
说明员工在手机端查看会议室状态预定/取消会议室
- 会议室列表支持搜索/筛选
- 会议室详情实时状态 + 今日预定时间轴
- 预定操作选择时段输入主题
- 取消预定仅可取消自己的预定
- 使用 Vant 组件库
=============================================================================
-->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog, showLoadingToast, closeToast } from 'vant'
import {
getMeetingroomList,
getRoomStatus,
bookMeetingroom,
cancelBooking,
type Meetingroom,
type RoomStatusResponse,
type Booking,
type RoomStatus,
} from '@/api/meetingroom'
import { useEmployeeStore } from '@/stores/employee'
const router = useRouter()
const employeeStore = useEmployeeStore()
// ==========================================================================
// 状态
// ==========================================================================
/** 会议室列表 */
const roomList = ref<Meetingroom[]>([])
/** 搜索关键词 */
const searchKeyword = ref('')
/** 选中的会议室 */
const selectedRoom = ref<Meetingroom | null>(null)
/** 会议室状态数据 */
const roomStatus = ref<RoomStatusResponse | null>(null)
/** 加载中 */
const loading = ref(false)
/** 是否显示预定弹窗 */
const showBookingPopup = ref(false)
/** 是否显示会议室详情 */
const showDetailPopup = ref(false)
/** 预定表单 */
const bookingForm = ref({
subject: '',
duration: 30,
customStartTime: '',
useCustomStart: false,
})
/** 提交中 */
const submitting = ref(false)
// ==========================================================================
// 计算属性
// ==========================================================================
/** 过滤后的会议室列表 */
const filteredRooms = computed(() => {
if (!searchKeyword.value) return roomList.value
const kw = searchKeyword.value.toLowerCase()
return roomList.value.filter(
(r) =>
r.name.toLowerCase().includes(kw) ||
(r.location && r.location.toLowerCase().includes(kw)),
)
})
/** 当前选中会议室的状态 */
const currentStatus = computed<RoomStatus>(() => roomStatus.value?.status ?? 'free')
/** 状态文字 */
const statusText = computed(() => {
switch (currentStatus.value) {
case 'free':
return '空闲中'
case 'busy':
return '使用中'
case 'starting_soon':
return '即将开始'
default:
return '未知'
}
})
/** 状态颜色 */
const statusColor = computed(() => {
switch (currentStatus.value) {
case 'free':
return '#07C160'
case 'busy':
return '#FF6B6B'
case 'starting_soon':
return '#FFA502'
default:
return '#969799'
}
})
/** 可选时长 */
const durationOptions = [
{ label: '30分钟', value: 30 },
{ label: '1小时', value: 60 },
{ label: '1.5小时', value: 90 },
{ label: '2小时', value: 120 },
]
/** 计算最早可用开始时间 */
const earliestStartTime = computed(() => {
const now = new Date()
const minutes = now.getMinutes()
now.setMinutes(minutes <= 30 ? 30 : 60, 0, 0)
const bookings = roomStatus.value?.bookings?.filter((b) => b.status === 0) || []
let startTime = now
for (const booking of bookings) {
const bookingStart = new Date(booking.start_time)
const bookingEnd = new Date(booking.end_time)
if (startTime >= bookingEnd) continue
if (startTime < bookingStart) {
const available = (bookingStart.getTime() - startTime.getTime()) / 60000
if (available >= bookingForm.value.duration) break
}
startTime = new Date(bookingEnd)
}
return startTime
})
/** 实际开始时间 */
const startTime = computed(() => {
if (bookingForm.value.useCustomStart && bookingForm.value.customStartTime) {
const [h, m] = bookingForm.value.customStartTime.split(':').map(Number)
const d = new Date()
d.setHours(h, m, 0, 0)
return d
}
return earliestStartTime.value
})
/** 结束时间 */
const endTime = computed(() => {
return new Date(startTime.value.getTime() + bookingForm.value.duration * 60000)
})
/** 时间冲突检查 */
const hasConflict = computed(() => {
const start = startTime.value
const end = endTime.value
const bookings = roomStatus.value?.bookings?.filter((b) => b.status === 0) || []
for (const booking of bookings) {
const bStart = new Date(booking.start_time)
const bEnd = new Date(booking.end_time)
if (start < bEnd && end > bStart) return true
}
return false
})
/** 可选开始时间列表 */
const availableStartTimes = computed(() => {
const times: { label: string; value: string }[] = []
const now = new Date()
for (let h = 8; h <= 21; h++) {
for (let m = 0; m < 60; m += 30) {
const d = new Date()
d.setHours(h, m, 0, 0)
if (d < now) continue
times.push({
label: `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`,
value: `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`,
})
}
}
return times
})
/** 当前登录用户ID */
const currentUserId = computed(() => employeeStore.employeeId || '')
// ==========================================================================
// 方法
// ==========================================================================
/** 格式化时间 */
function formatTime(isoTime: string): string {
const d = new Date(isoTime)
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
/** 格式化 Date 为 HH:MM */
function formatTimeDate(d: Date): string {
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
/** 加载会议室列表 */
async function loadRoomList() {
loading.value = true
try {
const data = await getMeetingroomList()
roomList.value = data.rooms || []
} catch (e: any) {
showToast(e.message || '获取会议室列表失败')
} finally {
loading.value = false
}
}
/** 查看会议室详情 */
async function viewRoomDetail(room: Meetingroom) {
selectedRoom.value = room
showDetailPopup.value = true
loading.value = true
try {
const data = await getRoomStatus(room.meetingroom_id)
roomStatus.value = data
} catch (e: any) {
showToast(e.message || '获取会议室状态失败')
} finally {
loading.value = false
}
}
/** 刷新会议室状态 */
async function refreshStatus() {
if (!selectedRoom.value) return
try {
const data = await getRoomStatus(selectedRoom.value.meetingroom_id)
roomStatus.value = data
} catch (e: any) {
showToast(e.message || '刷新失败')
}
}
/** 打开预定弹窗 */
function openBookingPopup() {
if (!employeeStore.isAuthenticated) {
showToast('请先登录')
return
}
bookingForm.value = {
subject: `${employeeStore.employeeName || '我'}的会议`,
duration: 30,
customStartTime: '',
useCustomStart: false,
}
showBookingPopup.value = true
}
/** 确认预定 */
async function confirmBooking() {
if (!selectedRoom.value) return
if (!bookingForm.value.subject.trim()) {
showToast('请输入会议主题')
return
}
if (hasConflict.value) {
showToast('所选时段与已有预定冲突')
return
}
submitting.value = true
try {
await bookMeetingroom({
meetingroom_id: selectedRoom.value.meetingroom_id,
subject: bookingForm.value.subject.trim(),
start_time: startTime.value.toISOString(),
end_time: endTime.value.toISOString(),
booker: currentUserId.value,
})
showToast({ message: '预定成功', type: 'success' })
showBookingPopup.value = false
// 刷新状态
await refreshStatus()
} catch (e: any) {
showToast(e.message || '预定失败')
} finally {
submitting.value = false
}
}
/** 取消预定 */
async function handleCancelBooking(booking: Booking) {
if (!selectedRoom.value) return
if (booking.booker !== currentUserId.value) {
showToast('只能取消自己的预定')
return
}
try {
await showConfirmDialog({
title: '取消预定',
message: `确定取消"${booking.subject}"吗?`,
})
showLoadingToast({ message: '取消中...', forbidClick: true, duration: 0 })
await cancelBooking(booking.booking_id, selectedRoom.value.meetingroom_id)
closeToast()
showToast({ message: '已取消', type: 'success' })
// 刷新状态
await refreshStatus()
} catch (e: any) {
// 用户取消确认对话框时不报错
if (e === 'cancel' || e?.message === 'cancel') return
closeToast()
showToast(e?.message || '取消失败')
}
}
/** 返回上一页 */
function goBack() {
router.back()
}
/** 快速填充主题 */
function quickSubject(text: string) {
bookingForm.value.subject = text
}
// ==========================================================================
// 生命周期
// ==========================================================================
onMounted(() => {
loadRoomList()
})
</script>
<template>
<div class="meetingroom-page">
<!-- 顶部导航栏 -->
<van-nav-bar
title="会议室预定"
left-arrow
fixed
placeholder
@click-left="goBack"
/>
<!-- 搜索栏 -->
<div class="search-bar">
<van-search
v-model="searchKeyword"
placeholder="搜索会议室名称或位置"
shape="round"
/>
</div>
<!-- 会议室列表 -->
<div class="room-list">
<van-loading v-if="loading && roomList.length === 0" class="page-loading" size="24px">
加载中...
</van-loading>
<van-empty
v-else-if="filteredRooms.length === 0"
description="暂无会议室"
/>
<div
v-for="room in filteredRooms"
:key="room.meetingroom_id"
class="room-card"
@click="viewRoomDetail(room)"
>
<div class="room-card__header">
<span class="room-card__name">{{ room.name }}</span>
<van-icon name="arrow" class="room-card__arrow" />
</div>
<div class="room-card__info">
<span v-if="room.location" class="room-card__location">
<van-icon name="location-o" /> {{ room.location }}
</span>
<span class="room-card__capacity">
<van-icon name="friends-o" /> {{ room.capacity }}
</span>
</div>
</div>
</div>
<!-- 会议室详情弹窗 -->
<van-popup
v-model:show="showDetailPopup"
position="bottom"
:style="{ height: '85%' }"
round
closeable
close-icon-position="top-left"
>
<div v-if="selectedRoom" class="detail-popup">
<!-- 会议室信息 -->
<div class="detail-header">
<h2 class="detail-title">{{ selectedRoom.name }}</h2>
<p v-if="selectedRoom.location" class="detail-location">
<van-icon name="location-o" /> {{ selectedRoom.location }}
</p>
<p class="detail-capacity">
<van-icon name="friends-o" /> 容纳 {{ selectedRoom.capacity }}
</p>
</div>
<!-- 实时状态 -->
<div v-if="roomStatus" class="status-card" :style="{ borderColor: statusColor }">
<div class="status-card__indicator" :style="{ backgroundColor: statusColor }" />
<div class="status-card__info">
<span class="status-card__text" :style="{ color: statusColor }">
{{ statusText }}
</span>
<span v-if="currentStatus === 'free' && roomStatus.next_meeting" class="status-card__desc">
下一场: {{ roomStatus.next_meeting.subject }}
({{ formatTime(roomStatus.next_meeting.start_time) }})
</span>
<span v-else-if="currentStatus === 'busy' && roomStatus.current_meeting" class="status-card__desc">
{{ roomStatus.current_meeting.subject }}
({{ formatTime(roomStatus.current_meeting.start_time) }} -
{{ formatTime(roomStatus.current_meeting.end_time) }})
</span>
<span v-else-if="currentStatus === 'starting_soon' && roomStatus.next_meeting" class="status-card__desc">
{{ roomStatus.next_meeting.subject }} 即将开始
</span>
<span v-else class="status-card__desc">今日剩余时段空闲</span>
</div>
<van-icon
name="refresh"
class="status-card__refresh"
@click="refreshStatus"
/>
</div>
<!-- 预定操作按钮 -->
<div class="detail-actions">
<van-button
type="primary"
block
round
:disabled="currentStatus === 'busy'"
@click="openBookingPopup"
>
{{ currentStatus === 'busy' ? '使用中,无法预定' : '立即预定' }}
</van-button>
</div>
<!-- 今日预定列表 -->
<div class="booking-list">
<h3 class="booking-list__title">今日预定</h3>
<van-loading v-if="loading" size="20px">加载中...</van-loading>
<van-empty
v-else-if="!roomStatus?.bookings || roomStatus.bookings.filter(b => b.status === 0).length === 0"
description="今日暂无预定"
image-size="80"
/>
<div
v-for="booking in roomStatus?.bookings?.filter(b => b.status === 0)"
:key="booking.booking_id"
class="booking-item"
>
<div class="booking-item__time">
<div class="booking-item__start">{{ formatTime(booking.start_time) }}</div>
<div class="booking-item__end">{{ formatTime(booking.end_time) }}</div>
</div>
<div class="booking-item__divider" />
<div class="booking-item__info">
<div class="booking-item__subject">{{ booking.subject }}</div>
<div class="booking-item__booker">
{{ booking.booker_name || booking.booker }}
</div>
</div>
<van-button
v-if="booking.booker === currentUserId"
size="small"
type="danger"
plain
round
@click.stop="handleCancelBooking(booking)"
>
取消
</van-button>
</div>
</div>
</div>
</van-popup>
<!-- 预定弹窗 -->
<van-popup
v-model:show="showBookingPopup"
position="bottom"
round
closeable
close-icon-position="top-left"
:style="{ height: '70%' }"
>
<div class="booking-popup">
<h2 class="booking-popup__title">预定会议室</h2>
<div v-if="selectedRoom" class="booking-popup__room">
{{ selectedRoom.name }}
</div>
<!-- 会议主题 -->
<van-cell-group inset>
<van-field
v-model="bookingForm.subject"
label="主题"
placeholder="请输入会议主题"
maxlength="50"
/>
</van-cell-group>
<!-- 快速主题 -->
<div class="quick-subjects">
<van-tag
v-for="preset in ['临时会议', '项目讨论', '客户沟通', '团队周会']"
:key="preset"
size="medium"
plain
type="primary"
@click="quickSubject(preset)"
>
{{ preset }}
</van-tag>
</div>
<!-- 预定时长 -->
<div class="form-section">
<div class="form-label">预定时长</div>
<div class="duration-options">
<van-button
v-for="opt in durationOptions"
:key="opt.value"
size="small"
:type="bookingForm.duration === opt.value ? 'primary' : 'default'"
round
@click="bookingForm.duration = opt.value"
>
{{ opt.label }}
</van-button>
</div>
</div>
<!-- 开始时间 -->
<div class="form-section">
<div class="form-label">开始时间</div>
<div class="start-options">
<van-button
size="small"
:type="!bookingForm.useCustomStart ? 'primary' : 'default'"
round
@click="bookingForm.useCustomStart = false"
>
最近空闲 ({{ formatTimeDate(startTime) }})
</van-button>
<van-button
size="small"
:type="bookingForm.useCustomStart ? 'primary' : 'default'"
round
@click="bookingForm.useCustomStart = true"
>
自定义
</van-button>
</div>
<div v-if="bookingForm.useCustomStart" class="custom-time-picker">
<van-button
v-for="t in availableStartTimes"
:key="t.value"
size="mini"
:type="bookingForm.customStartTime === t.value ? 'primary' : 'default'"
@click="bookingForm.customStartTime = t.value"
>
{{ t.label }}
</van-button>
</div>
</div>
<!-- 时间预览 -->
<div class="time-preview">
<div class="time-preview__slot">
{{ formatTimeDate(startTime) }} - {{ formatTimeDate(endTime) }}
</div>
<div class="time-preview__duration">{{ bookingForm.duration }}分钟</div>
</div>
<!-- 冲突提示 -->
<div v-if="hasConflict" class="conflict-tip">
<van-icon name="warning-o" /> 所选时段与已有预定冲突
</div>
<!-- 确认按钮 -->
<div class="booking-confirm">
<van-button
type="primary"
block
round
:loading="submitting"
:disabled="hasConflict || !bookingForm.subject.trim()"
@click="confirmBooking"
>
确认预定
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.meetingroom-page {
min-height: 100vh;
background: #f7f8fa;
}
/* 搜索栏 */
.search-bar {
position: sticky;
top: 46px;
z-index: 10;
background: #fff;
}
/* 会议室列表 */
.room-list {
padding: 8px 12px 16px;
}
.page-loading {
display: flex;
justify-content: center;
padding: 40px 0;
}
/* 会议室卡片 */
.room-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.2s;
}
.room-card:active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.room-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.room-card__name {
font-size: 16px;
font-weight: 600;
color: #323233;
}
.room-card__arrow {
color: #c8c9cc;
}
.room-card__info {
display: flex;
gap: 16px;
font-size: 13px;
color: #969799;
}
.room-card__info .van-icon {
vertical-align: -2px;
margin-right: 2px;
}
/* 详情弹窗 */
.detail-popup {
padding: 20px 16px 40px;
overflow-y: auto;
height: 100%;
}
.detail-header {
text-align: center;
margin-bottom: 20px;
}
.detail-title {
font-size: 20px;
font-weight: 700;
color: #323233;
margin: 0 0 8px;
}
.detail-location,
.detail-capacity {
font-size: 13px;
color: #969799;
margin: 2px 0;
}
.detail-location .van-icon,
.detail-capacity .van-icon {
vertical-align: -2px;
}
/* 状态卡片 */
.status-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 2px solid;
border-radius: 12px;
margin-bottom: 16px;
background: #fafafa;
}
.status-card__indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.status-card__info {
flex: 1;
min-width: 0;
}
.status-card__text {
font-size: 18px;
font-weight: 700;
display: block;
}
.status-card__desc {
font-size: 13px;
color: #969799;
display: block;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-card__refresh {
font-size: 20px;
color: #969799;
cursor: pointer;
}
/* 预定操作 */
.detail-actions {
margin-bottom: 20px;
}
/* 预定列表 */
.booking-list {
margin-top: 8px;
}
.booking-list__title {
font-size: 15px;
font-weight: 600;
color: #323233;
margin: 0 0 12px;
}
.booking-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f7f8fa;
border-radius: 10px;
margin-bottom: 8px;
}
.booking-item__time {
text-align: center;
min-width: 48px;
}
.booking-item__start {
font-size: 15px;
font-weight: 700;
color: #323233;
}
.booking-item__end {
font-size: 12px;
color: #969799;
}
.booking-item__divider {
width: 1px;
height: 32px;
background: #ebedf0;
}
.booking-item__info {
flex: 1;
min-width: 0;
}
.booking-item__subject {
font-size: 14px;
font-weight: 500;
color: #323233;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.booking-item__booker {
font-size: 12px;
color: #969799;
margin-top: 2px;
}
/* 预定弹窗 */
.booking-popup {
padding: 20px 16px 40px;
overflow-y: auto;
height: 100%;
}
.booking-popup__title {
font-size: 18px;
font-weight: 700;
text-align: center;
margin: 0 0 4px;
}
.booking-popup__room {
text-align: center;
font-size: 14px;
color: #969799;
margin-bottom: 16px;
}
.quick-subjects {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding: 8px 16px;
}
.form-section {
padding: 12px 16px;
}
.form-label {
font-size: 14px;
color: #646566;
margin-bottom: 8px;
}
.duration-options,
.start-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.custom-time-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
max-height: 120px;
overflow-y: auto;
padding: 8px;
background: #f7f8fa;
border-radius: 8px;
}
.time-preview {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
margin: 8px 16px;
background: #ebf5ff;
border-radius: 10px;
}
.time-preview__slot {
font-size: 18px;
font-weight: 700;
color: #1989fa;
}
.time-preview__duration {
font-size: 16px;
font-weight: 600;
color: #07C160;
}
.conflict-tip {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
color: #ee0a24;
font-size: 13px;
}
.booking-confirm {
padding: 16px;
margin-top: auto;
}
</style>