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

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

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

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

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

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

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

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

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

## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
This commit is contained in:
Simon
2026-07-13 02:17:03 +08:00
parent bea288e414
commit 449c6d4875
176 changed files with 46637 additions and 4805 deletions
+35
View File
@@ -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>
})
}
// =============================================================================
// 报修 & 操作指南 APIPhase 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)
},
}
}
+17 -3
View File
@@ -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
}
+345
View File
@@ -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>
+345
View File
@@ -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>
+69 -41
View File
@@ -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>