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:
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/terminal/favicon.ico" />
|
||||
<link rel="icon" href="/itterminal/favicon.ico" />
|
||||
<meta name="viewport" content="width=1920, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<title>会议室预定 · IT智能服务台</title>
|
||||
|
||||
Generated
+3194
File diff suppressed because it is too large
Load Diff
@@ -9,19 +9,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"axios": "^1.7.0",
|
||||
"pinia": "^2.1.0",
|
||||
"axios": "^1.7.0"
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.3.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vue-tsc": "^2.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"@types/node": "^20.0.0"
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ import type {
|
||||
Meetingroom,
|
||||
QrcodeResponse,
|
||||
ScanStatusResponse,
|
||||
RepairRequest,
|
||||
RepairResponse,
|
||||
GuideItem,
|
||||
GuideListResponse,
|
||||
} from '@/types/meetingroom'
|
||||
|
||||
// 创建 Axios 实例
|
||||
@@ -134,4 +138,35 @@ export async function getScanStatus(ticket: string): Promise<ScanStatusResponse>
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 报修 & 操作指南 API(Phase 3 新增)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 提交设备报修
|
||||
* - 终端用户在小鱼终端上填写故障描述后调用
|
||||
* - 后端会创建 IT 工单 + 通知管理员 + WS 广播给坐席
|
||||
*/
|
||||
export async function submitRepair(data: RepairRequest): Promise<RepairResponse> {
|
||||
return await apiClient.post('/itportal/meetingroom/repair', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作指南列表
|
||||
* @param category 可选设备类型过滤(不传则返回全部)
|
||||
*/
|
||||
export async function getGuides(category?: string): Promise<GuideListResponse> {
|
||||
const params: Record<string, string> = {}
|
||||
if (category) params.category = category
|
||||
return await apiClient.get('/itportal/meetingroom/guides', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 按设备类型获取操作指南
|
||||
* @param category 设备类型(projector/video_conf/aircon/phone/other)
|
||||
*/
|
||||
export async function getGuidesByCategory(category: string): Promise<GuideListResponse> {
|
||||
return await apiClient.get(`/itportal/meetingroom/guides/${category}`)
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — 小鱼易联终端 H5 Schema 封装
|
||||
// =============================================================================
|
||||
// 说明:封装小鱼易联终端内置浏览器提供的 H5 Schema 协议
|
||||
// - ainemo-call:// 发起视频呼叫
|
||||
// - ainemo-pstncall:// 发起电话呼叫
|
||||
// - ainemo-close://closePage 关闭 H5 页面返回终端主页
|
||||
// - callEnd() 终端注入的 JS 回调,通话结束时触发
|
||||
//
|
||||
// 参考:小鱼易联 H5 应用开发文档
|
||||
// 适用型号:NE20/NE60/NE80/NE90/AE2060(确认支持 H5 应用)
|
||||
// 可能不支持:ME55S(部分固件)、NE2005(老型号,需降级为二维码方案)
|
||||
// =============================================================================
|
||||
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
/** 小鱼终端型号枚举 */
|
||||
export type TerminalModel = 'NE20' | 'NE60' | 'NE80' | 'NE90' | 'AE2060' | 'ME55S' | 'NE2005' | 'unknown'
|
||||
|
||||
/** 是否在小鱼终端环境中 */
|
||||
export const isXylinkTerminal = ref<boolean>(false)
|
||||
|
||||
/** 终端型号 */
|
||||
export const terminalModel = ref<TerminalModel>('unknown')
|
||||
|
||||
/**
|
||||
* 通过 User-Agent 检测小鱼终端型号
|
||||
*
|
||||
* 小鱼终端内置浏览器的 UA 通常包含 "Ainemo" 或型号标识
|
||||
* 例如:Mozilla/5.0 ... Ainemo/NE90 ... 或包含 xylink/NE60 等
|
||||
*/
|
||||
function detectTerminalModel(): TerminalModel {
|
||||
const ua = navigator.userAgent
|
||||
|
||||
// 优先匹配 UA 中明确标注的型号
|
||||
const modelMap: Record<string, TerminalModel> = {
|
||||
'NE20': 'NE20',
|
||||
'NE60': 'NE60',
|
||||
'NE80': 'NE80',
|
||||
'NE90': 'NE90',
|
||||
'AE2060': 'AE2060',
|
||||
'ME55S': 'ME55S',
|
||||
'NE2005': 'NE2005',
|
||||
}
|
||||
|
||||
for (const [key, model] of Object.entries(modelMap)) {
|
||||
if (ua.includes(key)) {
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:UA 包含 Ainemo/xylink 但未识别到具体型号
|
||||
if (ua.includes('Ainemo') || ua.includes('xylink') || ua.includes('XYLink')) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前终端是否支持 H5 应用(用于 NE2005 降级判断)
|
||||
*
|
||||
* NE2005 为老型号,不支持 H5 自定义应用
|
||||
* ME55S 部分固件可能支持,需根据实际情况调整
|
||||
*/
|
||||
export function isH5Supported(): boolean {
|
||||
const model = terminalModel.value
|
||||
// 明确不支持的型号
|
||||
const unsupported: TerminalModel[] = ['NE2005']
|
||||
return !unsupported.includes(model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 H5 页面,返回终端主界面
|
||||
*
|
||||
* 通过 location.href 跳转 ainemo-close://closePage 协议
|
||||
* 终端浏览器收到此协议后会关闭当前 H5 页面
|
||||
*/
|
||||
export function closePage(): void {
|
||||
try {
|
||||
window.location.href = 'ainemo-close://closePage'
|
||||
} catch (e) {
|
||||
console.warn('[useTerminal] closePage 失败,可能不在小鱼终端环境中:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起视频呼叫
|
||||
*
|
||||
* @param number 对方号码(小鱼号或 SIP 地址)
|
||||
*/
|
||||
export function callTerminal(number: string): void {
|
||||
try {
|
||||
window.location.href = `ainemo-call://${number}`
|
||||
} catch (e) {
|
||||
console.warn('[useTerminal] callTerminal 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起电话呼叫(PSTN)
|
||||
*
|
||||
* @param phoneNumber 电话号码
|
||||
*/
|
||||
export function callPhone(phoneNumber: string): void {
|
||||
try {
|
||||
window.location.href = `ainemo-pstncall://${phoneNumber}`
|
||||
} catch (e) {
|
||||
console.warn('[useTerminal] callPhone 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册通话结束回调
|
||||
*
|
||||
* 小鱼终端在通话结束时会调用 window.callEnd() 方法
|
||||
* 我们通过重写该方法来接收回调
|
||||
*
|
||||
* @param callback 通话结束时的回调函数
|
||||
* @returns 取消注册的清理函数
|
||||
*/
|
||||
export function onCallEnd(callback: () => void): () => void {
|
||||
// 保存原始的 callEnd(如果存在)
|
||||
const originalCallEnd = (window as any).callEnd
|
||||
|
||||
;(window as any).callEnd = function () {
|
||||
console.log('[useTerminal] 通话结束回调触发')
|
||||
try {
|
||||
callback()
|
||||
} finally {
|
||||
// 如果终端本身需要原始回调,保留调用
|
||||
if (typeof originalCallEnd === 'function') {
|
||||
originalCallEnd.call(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
;(window as any).callEnd = originalCallEnd || undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useTerminal composable
|
||||
*
|
||||
* 提供小鱼终端 H5 Schema 的完整封装:
|
||||
* - 自动检测终端型号和 H5 支持能力
|
||||
* - closePage / callTerminal / callPhone 操作
|
||||
* - onCallEnd 通话结束监听
|
||||
* - 组件卸载时自动清理回调
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { model, h5Supported, close, call, onCallEnded } = useTerminal()
|
||||
*
|
||||
* // 如果终端不支持 H5,降级显示二维码
|
||||
* if (!h5Supported.value) {
|
||||
* showQRCodeFallback.value = true
|
||||
* }
|
||||
*
|
||||
* // 注册通话结束回调
|
||||
* onCallEnded(() => {
|
||||
* console.log('通话已结束')
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useTerminal() {
|
||||
/** 是否已初始化 */
|
||||
const initialized = ref(false)
|
||||
|
||||
/** 终端环境检测 */
|
||||
const isTerminal = ref(false)
|
||||
/** 终端型号 */
|
||||
const model = ref<TerminalModel>('unknown')
|
||||
/** 是否支持 H5 应用 */
|
||||
const h5Supported = ref(true)
|
||||
|
||||
/** 通话结束清理函数 */
|
||||
let cleanupCallEnd: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* 初始化终端检测
|
||||
* 在 onMounted 时自动调用
|
||||
*/
|
||||
function init() {
|
||||
if (initialized.value) return
|
||||
|
||||
// 检测 UA
|
||||
const ua = navigator.userAgent
|
||||
isTerminal.value = ua.includes('Ainemo') || ua.includes('xylink') || ua.includes('XYLink')
|
||||
isXylinkTerminal.value = isTerminal.value
|
||||
|
||||
// 检测型号
|
||||
model.value = detectTerminalModel()
|
||||
terminalModel.value = model.value
|
||||
|
||||
// 检测 H5 支持能力
|
||||
h5Supported.value = isH5Supported()
|
||||
|
||||
initialized.value = true
|
||||
|
||||
console.log('[useTerminal] 终端检测完成:', {
|
||||
isTerminal: isTerminal.value,
|
||||
model: model.value,
|
||||
h5Supported: h5Supported.value,
|
||||
ua: ua,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理通话结束回调
|
||||
if (cleanupCallEnd) {
|
||||
cleanupCallEnd()
|
||||
cleanupCallEnd = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
/** 是否在小鱼终端环境中 */
|
||||
isTerminal,
|
||||
/** 终端型号 */
|
||||
model,
|
||||
/** 是否支持 H5 应用 */
|
||||
h5Supported,
|
||||
/** 关闭页面 */
|
||||
close: closePage,
|
||||
/** 发起视频呼叫 */
|
||||
call: callTerminal,
|
||||
/** 发起电话呼叫 */
|
||||
callPhone,
|
||||
/** 注册通话结束回调 */
|
||||
onCallEnded: (callback: () => void) => {
|
||||
cleanupCallEnd = onCallEnd(callback)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
// 企微IT智能服务台 — 终端前端路由配置
|
||||
// =============================================================================
|
||||
// 说明:定义终端页面路由
|
||||
// /terminal/:sn → StatusView(会议室状态展示主页)
|
||||
// /terminal/:sn/book → BookingView(快速预定弹窗,可选路由)
|
||||
// /itterminal/:sn → StatusView(会议室状态展示主页)
|
||||
// /itterminal/:sn/book → BookingView(快速预定弹窗,可选路由)
|
||||
// /itterminal/:sn/repair → RepairView(设备报修页面)
|
||||
// /itterminal/:sn/guide → GuideView(操作指南页面)
|
||||
// =============================================================================
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
@@ -22,6 +24,18 @@ const routes = [
|
||||
component: () => import('@/views/BookingView.vue'),
|
||||
meta: { title: '快速预定' },
|
||||
},
|
||||
{
|
||||
path: '/:sn/repair',
|
||||
name: 'RepairView',
|
||||
component: () => import('@/views/RepairView.vue'),
|
||||
meta: { title: '设备报修' },
|
||||
},
|
||||
{
|
||||
path: '/:sn/guide',
|
||||
name: 'GuideView',
|
||||
component: () => import('@/views/GuideView.vue'),
|
||||
meta: { title: '操作指南' },
|
||||
},
|
||||
// 默认重定向(无SN时提示联系管理员)
|
||||
{
|
||||
path: '/',
|
||||
@@ -32,7 +46,7 @@ const routes = [
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/terminal/'),
|
||||
history: createWebHistory('/itterminal/'),
|
||||
routes,
|
||||
})
|
||||
|
||||
|
||||
@@ -180,3 +180,66 @@ export interface ApiResponse<T = unknown> {
|
||||
/** 消息 */
|
||||
message: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 报修 & 操作指南(Phase 3 新增)
|
||||
// =============================================================================
|
||||
|
||||
/** 报修请求参数 */
|
||||
export interface RepairRequest {
|
||||
/** 终端序列号 */
|
||||
terminal_sn: string
|
||||
/** 会议室ID */
|
||||
meetingroom_id: number
|
||||
/** 会议室名称 */
|
||||
meetingroom_name: string
|
||||
/** 设备类型(projector/video_conf/aircon/desk_chair/network/other) */
|
||||
device_type: string
|
||||
/** 故障描述 */
|
||||
fault_description: string
|
||||
/** 报修人姓名(可选,匿名报修时为空) */
|
||||
reporter_name?: string
|
||||
/** 报修人userid(可选) */
|
||||
reporter_userid?: string
|
||||
}
|
||||
|
||||
/** 报修响应 */
|
||||
export interface RepairResponse {
|
||||
/** 报修记录ID */
|
||||
repair_id: number
|
||||
/** 关联的工单会话ID */
|
||||
conversation_id: string
|
||||
/** 处理状态(0=待处理) */
|
||||
status: number
|
||||
}
|
||||
|
||||
/** 操作指南条目 */
|
||||
export interface GuideItem {
|
||||
/** 指南ID */
|
||||
id: number
|
||||
/** 设备类型分类 */
|
||||
category: string
|
||||
/** 指南标题 */
|
||||
title: string
|
||||
/** 简要操作步骤(终端大屏展示) */
|
||||
brief: string
|
||||
/** 详细文档URL(二维码目标) */
|
||||
detail_url: string
|
||||
/** 图标emoji */
|
||||
icon: string
|
||||
}
|
||||
|
||||
/** 操作指南列表响应 */
|
||||
export interface GuideListResponse {
|
||||
guides: GuideItem[]
|
||||
}
|
||||
|
||||
/** 设备类型选项(报修表单使用) */
|
||||
export interface DeviceTypeOption {
|
||||
/** 设备类型值 */
|
||||
value: string
|
||||
/** 显示标签 */
|
||||
label: string
|
||||
/** 图标emoji */
|
||||
icon: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 终端操作指南页面
|
||||
=============================================================================
|
||||
说明:小鱼终端大屏操作指南页面
|
||||
- 按设备类型分类展示操作指南
|
||||
- 每条指南包含:图标 + 标题 + 简要步骤(终端大屏展示)
|
||||
- 提供二维码(手机扫码查看详细文档)
|
||||
- 双模式:终端展示简要 + 二维码获取详细
|
||||
- 1920×1080 横屏深色主题适配
|
||||
=============================================================================
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { getGuides } from '@/api/meetingroom'
|
||||
import { useTerminal } from '@/composables/useTerminal'
|
||||
import type { GuideItem, DeviceTypeOption } from '@/types/meetingroom'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
// ==========================================================================
|
||||
// 路由 & Store & 终端
|
||||
// ==========================================================================
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useTerminalStore()
|
||||
const { close: closePage } = useTerminal()
|
||||
|
||||
// ==========================================================================
|
||||
// 分类标签
|
||||
// ==========================================================================
|
||||
|
||||
/** 设备分类列表(与后端 seed 数据对应) */
|
||||
const categoryTabs: DeviceTypeOption[] = [
|
||||
{ value: '', label: '全部', icon: '📋' },
|
||||
{ value: 'projector', label: '投影仪', icon: '📽️' },
|
||||
{ value: 'video_conf', label: '视频会议', icon: '📹' },
|
||||
{ value: 'aircon', label: '空调', icon: '❄️' },
|
||||
{ value: 'phone', label: '电话', icon: '📞' },
|
||||
{ value: 'other', label: '其他', icon: '🔧' },
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 状态
|
||||
// ==========================================================================
|
||||
|
||||
/** 当前选中的分类 */
|
||||
const activeCategory = ref<string>('')
|
||||
/** 指南列表 */
|
||||
const guides = ref<GuideItem[]>([])
|
||||
/** 加载中 */
|
||||
const loading = ref(false)
|
||||
/** 错误信息 */
|
||||
const errorMsg = ref('')
|
||||
/** 二维码缓存(detail_url → dataURL) */
|
||||
const qrCodeCache = ref<Map<string, string>>(new Map())
|
||||
/** 当前展开二维码的指南ID */
|
||||
const expandedGuideId = ref<number | null>(null)
|
||||
|
||||
// ==========================================================================
|
||||
// 计算属性
|
||||
// ==========================================================================
|
||||
|
||||
/** 终端SN */
|
||||
const terminalSn = computed(() => route.params.sn as string || store.terminalSn)
|
||||
|
||||
/** 按当前分类过滤的指南列表 */
|
||||
const filteredGuides = computed(() => {
|
||||
if (!activeCategory.value) return guides.value
|
||||
return guides.value.filter(g => g.category === activeCategory.value)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 方法
|
||||
// ==========================================================================
|
||||
|
||||
/** 加载指南数据 */
|
||||
async function loadGuides() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const result = await getGuides()
|
||||
guides.value = result.guides || []
|
||||
} catch (e: any) {
|
||||
errorMsg.value = `加载指南失败: ${e.message || e}`
|
||||
console.error('[GuideView] loadGuides error:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换分类 */
|
||||
function switchCategory(category: string) {
|
||||
activeCategory.value = category
|
||||
}
|
||||
|
||||
/** 切换二维码展开/收起 */
|
||||
async function toggleQRCode(guide: GuideItem) {
|
||||
if (expandedGuideId.value === guide.id) {
|
||||
expandedGuideId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
expandedGuideId.value = guide.id
|
||||
|
||||
// 如果有 detail_url 且尚未生成二维码,则生成
|
||||
if (guide.detail_url && !qrCodeCache.value.has(guide.detail_url)) {
|
||||
await generateQRCode(guide.detail_url)
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成二维码 */
|
||||
async function generateQRCode(url: string) {
|
||||
try {
|
||||
// 生成二维码为 data URL(深色背景适配:白底黑码)
|
||||
const dataUrl = await QRCode.toDataURL(url, {
|
||||
width: 300,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
qrCodeCache.value.set(url, dataUrl)
|
||||
} catch (e) {
|
||||
console.error('[GuideView] 生成二维码失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回状态页 */
|
||||
function goBack() {
|
||||
const sn = route.params.sn as string
|
||||
router.replace(`/${sn}`)
|
||||
}
|
||||
|
||||
/** 关闭页面(小鱼终端返回主页) */
|
||||
function handleClose() {
|
||||
closePage()
|
||||
}
|
||||
|
||||
/** 获取指南分类的显示标签 */
|
||||
function getCategoryLabel(category: string): string {
|
||||
const found = categoryTabs.find(t => t.value === category)
|
||||
return found ? found.label : category
|
||||
}
|
||||
|
||||
/** 获取指南分类的图标 */
|
||||
function getCategoryIcon(category: string): string {
|
||||
const found = categoryTabs.find(t => t.value === category)
|
||||
return found ? found.icon : '📋'
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 生命周期
|
||||
// ==========================================================================
|
||||
|
||||
onMounted(async () => {
|
||||
// 如果终端绑定信息未加载,尝试加载
|
||||
if (!store.meetingroomInfo && terminalSn.value) {
|
||||
store.loadBinding(terminalSn.value)
|
||||
}
|
||||
// 加载指南数据
|
||||
await loadGuides()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-screen flex items-center justify-center bg-bg-primary p-4 md:p-8">
|
||||
<div class="bg-bg-card rounded-2xl border border-border-subtle shadow-2xl w-full max-w-[1100px] max-h-[92vh] overflow-hidden flex flex-col animate-slide-up">
|
||||
<!-- ================================================================ -->
|
||||
<!-- 标题栏 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-border-subtle">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-text-primary">📋 操作指南</h2>
|
||||
<p class="text-sm text-text-secondary mt-1">
|
||||
{{ store.meetingroomName }}
|
||||
<span v-if="store.location"> · {{ store.location }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-base text-text-secondary border border-border-subtle hover:text-text-primary transition-colors"
|
||||
@click="handleClose"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
class="text-text-secondary hover:text-text-primary text-2xl"
|
||||
@click="goBack"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 分类标签栏 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-3 px-4 md:px-6 py-4 border-b border-border-subtle">
|
||||
<button
|
||||
v-for="tab in categoryTabs"
|
||||
:key="tab.value"
|
||||
class="flex items-center gap-2 px-5 py-2 rounded-lg text-base font-medium transition-all"
|
||||
:class="activeCategory === tab.value
|
||||
? 'bg-status-free text-white'
|
||||
: 'bg-bg-input text-text-secondary border border-border-subtle hover:text-text-primary'"
|
||||
@click="switchCategory(tab.value)"
|
||||
>
|
||||
<span class="text-xl">{{ tab.icon }}</span>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 内容区 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center h-full">
|
||||
<div class="w-12 h-12 border-4 border-border-subtle border-t-status-free rounded-full animate-spin mb-4" />
|
||||
<p class="text-lg text-text-secondary">正在加载指南...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-else-if="errorMsg" class="flex flex-col items-center justify-center h-full">
|
||||
<div class="text-5xl mb-4">😕</div>
|
||||
<p class="text-xl text-text-secondary">{{ errorMsg }}</p>
|
||||
<button
|
||||
class="mt-4 px-6 py-2 rounded-lg bg-status-free text-white text-button"
|
||||
@click="loadGuides"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="filteredGuides.length === 0" class="flex flex-col items-center justify-center h-full">
|
||||
<div class="text-5xl mb-4">📭</div>
|
||||
<p class="text-xl text-text-secondary">暂无操作指南</p>
|
||||
<p class="text-base text-text-muted mt-2">
|
||||
管理员可在后台添加指南内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 指南列表 -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="guide in filteredGuides"
|
||||
:key="guide.id"
|
||||
class="bg-bg-input rounded-xl border border-border-subtle overflow-hidden transition-all hover:border-border-subtle"
|
||||
>
|
||||
<!-- 指南条目 -->
|
||||
<div class="flex items-start gap-4 p-5">
|
||||
<!-- 图标 -->
|
||||
<div class="flex-shrink-0 w-14 h-14 rounded-xl bg-bg-card flex items-center justify-center text-3xl">
|
||||
{{ guide.icon || getCategoryIcon(guide.category) }}
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="text-xl font-bold text-text-primary">{{ guide.title }}</h3>
|
||||
<span class="px-2 py-0.5 text-xs rounded bg-bg-card text-text-secondary">
|
||||
{{ getCategoryLabel(guide.category) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-base text-text-secondary whitespace-pre-line">
|
||||
{{ guide.brief }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 二维码按钮(仅有 detail_url 时显示) -->
|
||||
<button
|
||||
v-if="guide.detail_url"
|
||||
class="flex-shrink-0 flex flex-col items-center gap-1 px-4 py-3 rounded-xl border-2 transition-all"
|
||||
:class="expandedGuideId === guide.id
|
||||
? 'border-status-free bg-status-free/10'
|
||||
: 'border-border-subtle hover:border-status-free'"
|
||||
@click="toggleQRCode(guide)"
|
||||
>
|
||||
<span class="text-2xl">{{ expandedGuideId === guide.id ? '📂' : '📱' }}</span>
|
||||
<span class="text-xs text-text-secondary">
|
||||
{{ expandedGuideId === guide.id ? '收起' : '扫码' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 二维码展开区 -->
|
||||
<div
|
||||
v-if="expandedGuideId === guide.id && guide.detail_url"
|
||||
class="px-5 pb-5 pt-2 border-t border-border-subtle"
|
||||
>
|
||||
<div class="flex items-center gap-6">
|
||||
<!-- 二维码图片 -->
|
||||
<div class="flex-shrink-0 bg-white p-3 rounded-xl">
|
||||
<img
|
||||
v-if="qrCodeCache.has(guide.detail_url)"
|
||||
:src="qrCodeCache.get(guide.detail_url)"
|
||||
alt="二维码"
|
||||
class="w-32 h-32"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-32 h-32 flex items-center justify-center text-text-muted text-sm"
|
||||
>
|
||||
生成中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示文字 -->
|
||||
<div>
|
||||
<div class="text-lg text-text-primary font-medium mb-1">
|
||||
📱 手机扫码查看详细文档
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
使用企业微信或微信扫描二维码
|
||||
</div>
|
||||
<div class="text-sm text-text-muted mt-2 break-all">
|
||||
{{ guide.detail_url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 底部操作栏 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="flex items-center justify-end gap-4 p-6 border-t border-border-subtle">
|
||||
<button
|
||||
class="px-8 py-3 rounded-xl text-button font-medium bg-bg-input text-text-secondary border border-border-subtle hover:text-text-primary transition-colors"
|
||||
@click="goBack"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,345 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 终端设备报修页面
|
||||
=============================================================================
|
||||
说明:小鱼终端大屏设备报修页面
|
||||
- 设备类型选择(投影仪/视频会议设备/空调/桌椅/网络/其他)
|
||||
- 故障描述文本输入(支持快捷选项)
|
||||
- 匿名报修(未登录时也可提交)
|
||||
- 提交后创建 IT 工单 + 通知管理员
|
||||
- 成功/失败反馈,自动返回状态页
|
||||
- 1920×1080 横屏深色主题适配
|
||||
=============================================================================
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { submitRepair } from '@/api/meetingroom'
|
||||
import { useTerminal } from '@/composables/useTerminal'
|
||||
import type { DeviceTypeOption } from '@/types/meetingroom'
|
||||
|
||||
// ==========================================================================
|
||||
// 路由 & Store & 终端
|
||||
// ==========================================================================
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useTerminalStore()
|
||||
const { close: closePage } = useTerminal()
|
||||
|
||||
// ==========================================================================
|
||||
// 设备类型选项
|
||||
// ==========================================================================
|
||||
|
||||
/** 设备类型列表(与后端 repair_service.py 的 DEVICE_TYPE_LABELS 对应) */
|
||||
const deviceTypeOptions: DeviceTypeOption[] = [
|
||||
{ value: 'projector', label: '投影仪', icon: '📽️' },
|
||||
{ value: 'video_conf', label: '视频会议设备', icon: '📹' },
|
||||
{ value: 'aircon', label: '空调', icon: '❄️' },
|
||||
{ value: 'desk_chair', label: '桌椅', icon: '🪑' },
|
||||
{ value: 'network', label: '网络', icon: '🌐' },
|
||||
{ value: 'other', label: '其他设备', icon: '🔧' },
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 表单状态
|
||||
// ==========================================================================
|
||||
|
||||
/** 选中的设备类型 */
|
||||
const selectedDeviceType = ref<string>('')
|
||||
/** 故障描述 */
|
||||
const faultDescription = ref('')
|
||||
/** 提交中 */
|
||||
const submitting = ref(false)
|
||||
/** 提交结果 */
|
||||
const submitResult = ref<{ success: boolean; repairId?: number; message: string } | null>(null)
|
||||
|
||||
/** 快捷故障描述选项 */
|
||||
const quickFaultOptions = [
|
||||
'无法开机',
|
||||
'画面模糊',
|
||||
'声音异常',
|
||||
'连接失败',
|
||||
'设备老化',
|
||||
'其他问题',
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 计算属性
|
||||
// ==========================================================================
|
||||
|
||||
/** 终端SN */
|
||||
const terminalSn = computed(() => route.params.sn as string || store.terminalSn)
|
||||
|
||||
/** 会议室信息 */
|
||||
const meetingroomId = computed(() => store.meetingroomInfo?.meetingroom_id ?? null)
|
||||
const meetingroomName = computed(() => store.meetingroomName)
|
||||
|
||||
/** 是否可提交 */
|
||||
const canSubmit = computed(() => {
|
||||
return selectedDeviceType.value !== '' && faultDescription.value.trim().length > 0 && !submitting.value
|
||||
})
|
||||
|
||||
/** 选中设备类型的显示信息 */
|
||||
const selectedDeviceInfo = computed(() => {
|
||||
return deviceTypeOptions.find(d => d.value === selectedDeviceType.value)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 方法
|
||||
// ==========================================================================
|
||||
|
||||
/** 选择设备类型 */
|
||||
function selectDeviceType(value: string) {
|
||||
selectedDeviceType.value = value
|
||||
}
|
||||
|
||||
/** 快速填充故障描述 */
|
||||
function quickFillFault(text: string) {
|
||||
// 如果已有内容,追加而不是覆盖
|
||||
if (faultDescription.value && !faultDescription.value.includes(text)) {
|
||||
faultDescription.value = `${faultDescription.value};${text}`
|
||||
} else {
|
||||
faultDescription.value = text
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交报修 */
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit.value) return
|
||||
if (!meetingroomId.value) {
|
||||
submitResult.value = { success: false, message: '会议室信息缺失,无法提交报修' }
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
submitResult.value = null
|
||||
|
||||
try {
|
||||
const result = await submitRepair({
|
||||
terminal_sn: terminalSn.value,
|
||||
meetingroom_id: meetingroomId.value,
|
||||
meetingroom_name: meetingroomName.value,
|
||||
device_type: selectedDeviceType.value,
|
||||
fault_description: faultDescription.value.trim(),
|
||||
// 如果已登录,附带用户信息;未登录则匿名提交
|
||||
reporter_name: store.isLoggedIn ? store.loginUser?.name : undefined,
|
||||
reporter_userid: store.isLoggedIn ? store.loginUser?.userid : undefined,
|
||||
})
|
||||
|
||||
submitResult.value = {
|
||||
success: true,
|
||||
repairId: result.repair_id,
|
||||
message: '报修已提交,IT人员将尽快处理',
|
||||
}
|
||||
|
||||
// 3秒后自动返回状态页
|
||||
setTimeout(() => {
|
||||
goBack()
|
||||
}, 3000)
|
||||
} catch (e: any) {
|
||||
submitResult.value = {
|
||||
success: false,
|
||||
message: e.message || '提交失败,请稍后重试或联系IT管理员',
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回状态页 */
|
||||
function goBack() {
|
||||
const sn = route.params.sn as string
|
||||
router.replace(`/${sn}`)
|
||||
}
|
||||
|
||||
/** 关闭页面(小鱼终端返回主页) */
|
||||
function handleClose() {
|
||||
closePage()
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 生命周期
|
||||
// ==========================================================================
|
||||
|
||||
onMounted(() => {
|
||||
// 如果终端绑定信息未加载,尝试加载
|
||||
if (!store.meetingroomInfo && terminalSn.value) {
|
||||
store.loadBinding(terminalSn.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-screen flex items-center justify-center bg-bg-primary p-4 md:p-8">
|
||||
<div class="bg-bg-card rounded-2xl border border-border-subtle shadow-2xl w-full max-w-[900px] max-h-[92vh] overflow-y-auto animate-slide-up">
|
||||
<!-- ================================================================ -->
|
||||
<!-- 标题栏 -->
|
||||
<!-- ================================================================ -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-border-subtle">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-text-primary">🔧 设备报修</h2>
|
||||
<p class="text-sm text-text-secondary mt-1">
|
||||
{{ meetingroomName }}
|
||||
<span v-if="store.location"> · {{ store.location }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-base text-text-secondary border border-border-subtle hover:text-text-primary transition-colors"
|
||||
@click="handleClose"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
class="text-text-secondary hover:text-text-primary text-2xl"
|
||||
@click="goBack"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 提交成功状态 -->
|
||||
<!-- ================================================================ -->
|
||||
<div v-if="submitResult?.success" class="p-12 text-center">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<h3 class="text-3xl font-bold text-status-free mb-3">报修已提交</h3>
|
||||
<p class="text-lg text-text-secondary mb-2">
|
||||
{{ submitResult.message }}
|
||||
</p>
|
||||
<p v-if="submitResult.repairId" class="text-base text-text-muted">
|
||||
报修编号: #{{ submitResult.repairId }}
|
||||
</p>
|
||||
<p class="text-sm text-text-muted mt-4">
|
||||
3 秒后自动返回...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 报修表单 -->
|
||||
<!-- ================================================================ -->
|
||||
<div v-else class="p-6 space-y-6">
|
||||
<!-- 报修人信息 -->
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-lg text-text-secondary min-w-[100px]">报修人</span>
|
||||
<div class="px-4 py-2 bg-bg-input rounded-lg text-lg text-text-primary">
|
||||
<span v-if="store.isLoggedIn">
|
||||
{{ store.loginUser?.name || store.loginUser?.userid }}
|
||||
</span>
|
||||
<span v-else class="text-text-muted">匿名报修(终端提交)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备类型选择 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<span class="text-lg text-text-secondary min-w-[100px]">设备类型</span>
|
||||
<span class="text-sm text-text-muted">请选择故障设备</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 ml-0 md:ml-[116px]">
|
||||
<button
|
||||
v-for="opt in deviceTypeOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-3 px-5 py-4 rounded-xl border-2 transition-all"
|
||||
:class="selectedDeviceType === opt.value
|
||||
? 'border-status-free bg-status-free/10 text-text-primary'
|
||||
: 'border-border-subtle bg-bg-input text-text-secondary hover:text-text-primary hover:border-border-subtle'"
|
||||
@click="selectDeviceType(opt.value)"
|
||||
>
|
||||
<span class="text-3xl">{{ opt.icon }}</span>
|
||||
<span class="text-lg font-medium">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 故障描述 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<span class="text-lg text-text-secondary min-w-[100px]">故障描述</span>
|
||||
<span class="text-sm text-text-muted">请描述故障现象</span>
|
||||
</div>
|
||||
<div class="ml-0 md:ml-[116px]">
|
||||
<textarea
|
||||
v-model="faultDescription"
|
||||
placeholder="请输入故障描述,例如:投影仪无法开机,指示灯不亮..."
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
class="w-full px-4 py-3 bg-bg-input rounded-xl text-lg text-text-primary border border-border-subtle focus:border-status-free focus:outline-none resize-none"
|
||||
/>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<!-- 快捷选项 -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="quick in quickFaultOptions"
|
||||
:key="quick"
|
||||
class="px-3 py-1 text-sm bg-bg-input text-text-secondary rounded border border-border-subtle hover:text-text-primary hover:border-status-free transition-colors"
|
||||
@click="quickFillFault(quick)"
|
||||
>
|
||||
{{ quick }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 字数统计 -->
|
||||
<span class="text-sm text-text-muted">
|
||||
{{ faultDescription.length }} / 500
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选中设备预览 -->
|
||||
<div
|
||||
v-if="selectedDeviceInfo"
|
||||
class="flex items-center gap-3 p-4 bg-bg-input rounded-xl"
|
||||
>
|
||||
<span class="text-3xl">{{ selectedDeviceInfo.icon }}</span>
|
||||
<div>
|
||||
<div class="text-lg text-text-primary font-medium">
|
||||
{{ selectedDeviceInfo.label }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
将创建IT工单并通知管理员
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交失败提示 -->
|
||||
<div
|
||||
v-if="submitResult && !submitResult.success"
|
||||
class="p-4 bg-status-busy/10 border border-status-busy/30 rounded-xl text-status-busy text-lg"
|
||||
>
|
||||
⚠ {{ submitResult.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- 底部操作栏 -->
|
||||
<!-- ================================================================ -->
|
||||
<div
|
||||
v-if="!submitResult?.success"
|
||||
class="flex items-center justify-end gap-4 p-6 border-t border-border-subtle"
|
||||
>
|
||||
<button
|
||||
class="px-8 py-3 rounded-xl text-button font-medium bg-bg-input text-text-secondary border border-border-subtle hover:text-text-primary transition-colors"
|
||||
@click="goBack"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-10 py-3 rounded-xl text-button font-bold transition-all"
|
||||
:class="[
|
||||
canSubmit
|
||||
? 'bg-status-free text-white hover:scale-105'
|
||||
: 'bg-text-muted text-text-secondary cursor-not-allowed',
|
||||
]"
|
||||
:disabled="!canSubmit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ submitting ? '提交中...' : '提交报修' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -184,6 +184,18 @@ function goBooking() {
|
||||
router.push(`/${sn}/book`)
|
||||
}
|
||||
|
||||
/** 跳转到设备报修页面 */
|
||||
function goRepair() {
|
||||
const sn = route.params.sn as string
|
||||
router.push(`/${sn}/repair`)
|
||||
}
|
||||
|
||||
/** 跳转到操作指南页面 */
|
||||
function goGuide() {
|
||||
const sn = route.params.sn as string
|
||||
router.push(`/${sn}/guide`)
|
||||
}
|
||||
|
||||
/** 登录成功回调 */
|
||||
function onLoginSuccess() {
|
||||
showQrLogin.value = false
|
||||
@@ -272,32 +284,32 @@ onUnmounted(() => {
|
||||
<!-- ================================================================ -->
|
||||
<!-- 正常显示 -->
|
||||
<!-- ================================================================ -->
|
||||
<div v-else class="flex-1 flex flex-col p-8">
|
||||
<div v-else class="flex-1 flex flex-col p-4 md:p-8">
|
||||
<!-- ============================================================ -->
|
||||
<!-- 顶部栏:会议室名称 + 位置 + 时间 + 连接状态 -->
|
||||
<!-- ============================================================ -->
|
||||
<header class="flex items-start justify-between mb-6">
|
||||
<header class="flex items-start justify-between mb-4 md:mb-6">
|
||||
<!-- 左侧:会议室信息 -->
|
||||
<div>
|
||||
<h1 class="text-room-name font-bold text-text-primary">
|
||||
<h1 class="text-2xl md:text-room-name font-bold text-text-primary">
|
||||
{{ store.meetingroomName }}
|
||||
</h1>
|
||||
<p v-if="store.location" class="text-lg text-text-secondary mt-1">
|
||||
<p v-if="store.location" class="text-base md:text-lg text-text-secondary mt-1">
|
||||
📍 {{ store.location }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:时间 + 连接状态 -->
|
||||
<div class="text-right">
|
||||
<div class="text-5xl font-bold text-text-primary font-mono">
|
||||
<div class="text-3xl md:text-5xl font-bold text-text-primary font-mono">
|
||||
{{ formattedTime }}
|
||||
</div>
|
||||
<div class="text-lg text-text-secondary mt-1">
|
||||
<div class="text-sm md:text-lg text-text-secondary mt-1">
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-end gap-2">
|
||||
<span
|
||||
class="inline-flex items-center text-sm"
|
||||
class="inline-flex items-center text-xs md:text-sm"
|
||||
:class="wsConnected ? 'text-status-free' : isPollingMode ? 'text-status-soon' : 'text-status-busy'"
|
||||
>
|
||||
<span
|
||||
@@ -313,56 +325,56 @@ onUnmounted(() => {
|
||||
<!-- ============================================================ -->
|
||||
<!-- 主体内容区 -->
|
||||
<!-- ============================================================ -->
|
||||
<main class="flex-1 flex gap-6">
|
||||
<main class="flex-1 flex flex-col md:flex-row gap-4 md:gap-6">
|
||||
<!-- ======================================================== -->
|
||||
<!-- 左侧:状态展示区(占 60%) -->
|
||||
<!-- 左侧:状态展示区(终端占 60%,移动端全宽) -->
|
||||
<!-- ======================================================== -->
|
||||
<div class="flex-[3] flex flex-col">
|
||||
<!-- 大状态展示 -->
|
||||
<div
|
||||
class="flex-1 rounded-2xl border-2 flex flex-col items-center justify-center transition-all duration-500"
|
||||
class="flex-1 rounded-2xl border-2 flex flex-col items-center justify-center transition-all duration-500 p-4 md:p-0"
|
||||
:style="{
|
||||
borderColor: statusColor,
|
||||
backgroundColor: `${statusColor}10`,
|
||||
}"
|
||||
>
|
||||
<!-- 状态图标 -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-2 md:mb-4">
|
||||
<div
|
||||
class="w-24 h-24 rounded-full flex items-center justify-center"
|
||||
class="w-16 h-16 md:w-24 md:h-24 rounded-full flex items-center justify-center"
|
||||
:style="{ backgroundColor: statusColor }"
|
||||
>
|
||||
<span v-if="currentStatus === 'free'" class="text-6xl">✓</span>
|
||||
<span v-else-if="currentStatus === 'busy'" class="text-6xl">●</span>
|
||||
<span v-else class="text-6xl">⏰</span>
|
||||
<span v-if="currentStatus === 'free'" class="text-4xl md:text-6xl">✓</span>
|
||||
<span v-else-if="currentStatus === 'busy'" class="text-4xl md:text-6xl">●</span>
|
||||
<span v-else class="text-4xl md:text-6xl">⏰</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态文字 -->
|
||||
<div
|
||||
class="text-status-main font-bold"
|
||||
class="text-4xl md:text-status-main font-bold"
|
||||
:style="{ color: statusColor }"
|
||||
>
|
||||
{{ store.statusText }}
|
||||
</div>
|
||||
|
||||
<!-- 状态描述 -->
|
||||
<div class="mt-4 text-xl text-text-secondary text-center max-w-2xl">
|
||||
<div class="mt-3 md:mt-4 text-base md:text-xl text-text-secondary text-center max-w-2xl">
|
||||
{{ statusDescription }}
|
||||
</div>
|
||||
|
||||
<!-- 当前会议详情 -->
|
||||
<div
|
||||
v-if="currentMeeting"
|
||||
class="mt-6 px-8 py-4 bg-bg-card rounded-xl border border-border-subtle"
|
||||
class="mt-4 md:mt-6 px-4 md:px-8 py-3 md:py-4 bg-bg-card rounded-xl border border-border-subtle"
|
||||
>
|
||||
<div class="text-lg text-text-primary font-medium">
|
||||
<div class="text-base md:text-lg text-text-primary font-medium">
|
||||
{{ currentMeeting.subject }}
|
||||
</div>
|
||||
<div class="text-base text-text-secondary mt-1">
|
||||
<div class="text-sm md:text-base text-text-secondary mt-1">
|
||||
{{ formatTime(currentMeeting.start_time) }} -
|
||||
{{ formatTime(currentMeeting.end_time) }}
|
||||
<span v-if="currentMeeting.booker_name" class="ml-4">
|
||||
<span v-if="currentMeeting.booker_name" class="ml-2 md:ml-4">
|
||||
预定人: {{ currentMeeting.booker_name }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -371,7 +383,7 @@ onUnmounted(() => {
|
||||
<!-- 下一个会议提示 -->
|
||||
<div
|
||||
v-if="nextMeeting && currentStatus !== 'busy'"
|
||||
class="mt-3 text-lg text-text-secondary"
|
||||
class="mt-2 md:mt-3 text-base md:text-lg text-text-secondary"
|
||||
>
|
||||
下一场: {{ nextMeeting.subject }}
|
||||
({{ formatTime(nextMeeting.start_time) }} -
|
||||
@@ -380,9 +392,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区 -->
|
||||
<div class="mt-6 flex items-center justify-center gap-4">
|
||||
<div class="mt-4 md:mt-6 flex flex-wrap items-center justify-center gap-2 md:gap-4">
|
||||
<button
|
||||
class="px-10 py-4 rounded-xl text-button font-bold transition-all hover:scale-105"
|
||||
class="px-6 md:px-10 py-3 md:py-4 rounded-xl text-base md:text-button font-bold transition-all hover:scale-105"
|
||||
:class="store.isLoggedIn
|
||||
? 'bg-status-free text-white hover:bg-status-free/80'
|
||||
: 'bg-bg-card text-text-secondary border-2 border-border-subtle'"
|
||||
@@ -391,16 +403,32 @@ onUnmounted(() => {
|
||||
{{ store.isLoggedIn ? '立即预定' : '扫码登录预定' }}
|
||||
</button>
|
||||
|
||||
<!-- 设备报修按钮(无需登录,匿名提交) -->
|
||||
<button
|
||||
class="px-5 md:px-8 py-3 md:py-4 rounded-xl text-base md:text-button font-medium bg-bg-card text-text-secondary border-2 border-border-subtle transition-all hover:scale-105 hover:text-status-busy hover:border-status-busy"
|
||||
@click="goRepair"
|
||||
>
|
||||
🔧 设备报修
|
||||
</button>
|
||||
|
||||
<!-- 操作指南按钮 -->
|
||||
<button
|
||||
class="px-5 md:px-8 py-3 md:py-4 rounded-xl text-base md:text-button font-medium bg-bg-card text-text-secondary border-2 border-border-subtle transition-all hover:scale-105 hover:text-status-soon hover:border-status-soon"
|
||||
@click="goGuide"
|
||||
>
|
||||
📋 操作指南
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="store.isLoggedIn"
|
||||
class="px-8 py-4 rounded-xl text-button font-medium bg-bg-card text-text-secondary border-2 border-border-subtle transition-all hover:scale-105 hover:text-text-primary"
|
||||
class="px-4 md:px-6 py-3 md:py-4 rounded-xl text-base md:text-button font-medium bg-bg-card text-text-secondary border-2 border-border-subtle transition-all hover:scale-105 hover:text-text-primary"
|
||||
@click="showQrLogin = false; store.logout()"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-6 py-4 rounded-xl text-button font-medium bg-bg-card text-text-secondary border-2 border-border-subtle transition-all hover:scale-105"
|
||||
class="px-4 md:px-6 py-3 md:py-4 rounded-xl text-base md:text-button font-medium bg-bg-card text-text-secondary border-2 border-border-subtle transition-all hover:scale-105"
|
||||
@click="refreshStatus"
|
||||
>
|
||||
刷新
|
||||
@@ -408,17 +436,17 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 已登录用户提示 -->
|
||||
<div v-if="store.isLoggedIn" class="mt-3 text-center text-sm text-text-muted">
|
||||
<div v-if="store.isLoggedIn" class="mt-2 md:mt-3 text-center text-xs md:text-sm text-text-muted">
|
||||
已登录: {{ store.loginUser?.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- 右侧:时间轴 + 预定列表(占 40%) -->
|
||||
<!-- 右侧:时间轴 + 预定列表(终端占 40%,移动端全宽) -->
|
||||
<!-- ======================================================== -->
|
||||
<div class="flex-[2] flex flex-col gap-6">
|
||||
<div class="flex-[2] flex flex-col gap-4 md:gap-6">
|
||||
<!-- 时间轴卡片 -->
|
||||
<div class="bg-bg-card rounded-2xl p-6 border border-border-subtle">
|
||||
<div class="bg-bg-card rounded-2xl p-4 md:p-6 border border-border-subtle">
|
||||
<Timeline
|
||||
:bookings="store.bookings"
|
||||
:current-status="currentStatus"
|
||||
@@ -426,36 +454,36 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 今日预定列表 -->
|
||||
<div class="bg-bg-card rounded-2xl p-6 border border-border-subtle flex-1 overflow-hidden flex flex-col">
|
||||
<h3 class="text-timeline text-text-secondary font-medium mb-4">
|
||||
<div class="bg-bg-card rounded-2xl p-4 md:p-6 border border-border-subtle flex-1 overflow-hidden flex flex-col">
|
||||
<h3 class="text-base md:text-timeline text-text-secondary font-medium mb-3 md:mb-4">
|
||||
今日预定列表
|
||||
</h3>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-3">
|
||||
<div class="flex-1 overflow-y-auto space-y-2 md:space-y-3">
|
||||
<div
|
||||
v-for="booking in store.bookings.filter(b => b.status === 0)"
|
||||
:key="booking.booking_id"
|
||||
class="flex items-center gap-4 p-4 rounded-xl bg-bg-input hover:bg-bg-card-hover transition-colors"
|
||||
class="flex items-center gap-3 md:gap-4 p-3 md:p-4 rounded-xl bg-bg-input hover:bg-bg-card-hover transition-colors"
|
||||
>
|
||||
<!-- 时间 -->
|
||||
<div class="text-center min-w-[80px]">
|
||||
<div class="text-lg font-bold text-text-primary">
|
||||
<div class="text-center min-w-[60px] md:min-w-[80px]">
|
||||
<div class="text-base md:text-lg font-bold text-text-primary">
|
||||
{{ formatTime(booking.start_time) }}
|
||||
</div>
|
||||
<div class="text-sm text-text-muted">
|
||||
<div class="text-xs md:text-sm text-text-muted">
|
||||
{{ formatTime(booking.end_time) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="w-px h-12 bg-border-subtle" />
|
||||
<div class="w-px h-10 md:h-12 bg-border-subtle" />
|
||||
|
||||
<!-- 会议信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-lg text-text-primary truncate">
|
||||
<div class="text-base md:text-lg text-text-primary truncate">
|
||||
{{ booking.subject }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary mt-1">
|
||||
<div class="text-xs md:text-sm text-text-secondary mt-1">
|
||||
<span v-if="booking.booker_name">{{ booking.booker_name }}</span>
|
||||
<span v-else>{{ booking.booker }}</span>
|
||||
</div>
|
||||
@@ -474,7 +502,7 @@ onUnmounted(() => {
|
||||
class="flex flex-col items-center justify-center h-full text-text-muted"
|
||||
>
|
||||
<div class="text-4xl mb-3">📅</div>
|
||||
<p class="text-lg">今日暂无预定</p>
|
||||
<p class="text-base md:text-lg">今日暂无预定</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,10 @@ export default {
|
||||
'button-text': ['24px', '1.2'], // 操作按钮
|
||||
},
|
||||
screens: {
|
||||
// 移动端(手机竖屏)
|
||||
'mobile': '480px',
|
||||
// 平板
|
||||
'tablet': '768px',
|
||||
// 终端大屏分辨率
|
||||
'terminal': '1920px',
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/terminal/',
|
||||
base: '/itterminal/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
|
||||
Reference in New Issue
Block a user