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:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5端会议室预定 API
|
||||
// =============================================================================
|
||||
// 说明:封装会议室预定相关的 API 调用,供 H5 端使用
|
||||
// - 复用 H5 端的 apiClient(baseURL=/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 },
|
||||
})
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<!-- 展开内容:BasicInfoCard(CPU/内存/硬盘默认隐藏,避免焦虑) -->
|
||||
<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' | null(null = 全部折叠) */
|
||||
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('该功能正在开发中')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 | 粘贴截图: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 推送了操作卡片到侧边栏
|
||||
// 做什么:将推荐卡片数据存入 store,RightPanel 的 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 信息写入 store,UI 切换到已关闭视图
|
||||
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}`)
|
||||
}
|
||||
|
||||
@@ -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.vue:wx.agentConfig 调用模式(但签名用错了,已修正)
|
||||
// - ContactCard.vue:wx.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工单URL:https://devops.dc.servyou-it.com/...(不含 template_id)
|
||||
*
|
||||
* @param url 待判断的URL
|
||||
* @returns true=企微审批URL, false=其他URL(ITSM等)
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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(.*)*',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user