feat(ops): 实施运维小程序 5 个 Tab 全量落地 + mock 数据

- workbench: 告警列表/详情子页 + 统计卡 + 待办聚合
- workOrder: 工单列表/详情/维修上报纸页
- scan: 场景菜单 + 初始化/实施记录/维修上报/OTA/监测 5 子页
- inspect: 巡检任务列表 + 详情子页
- me: 个人中心 + 消息中心子页
- 配套 api.ts/types.ts 与 5 个 mock handler
- nav.ts 同步补全 ops 小程序页面路由
- 同步更新设备管理服务器功能现状文档 §8
This commit is contained in:
fengpu
2026-07-03 20:40:30 +08:00
parent 74d1488d3b
commit af0dc7013f
36 changed files with 6043 additions and 55 deletions
+22 -5
View File
@@ -199,11 +199,28 @@ yx-platform-prototype/
│ │ │ ├── make/Home.vue
│ │ │ └── me/Home.vue
│ │ └── ops/ 实施运维小程序(仅运维工程师)
│ │ ├── workbench/Home.vue
│ │ ├── workOrder/Home.vue
│ │ ├── scan/Home.vue
│ │ ├── inspect/Home.vue
│ │ ── me/Home.vue
│ │ ├── workbench/ 工作台 Tab(含告警入口)
│ │ ├── Home.vue
│ │ │ ├── alarmList.vue 告警列表子页
│ │ │ └── alarmDetail.vue 告警详情子页
│ │ ── workOrder/ 工单 Tab
│ │ │ ├── Home.vue
│ │ │ ├── detail.vue 工单详情子页
│ │ │ └── repairReport.vue 关单前维修上报纸页
│ │ ├── scan/ 扫码 Tab(6 个子操作)
│ │ │ ├── Home.vue
│ │ │ ├── sceneMenu.vue 场景操作菜单
│ │ │ ├── init.vue 设备初始化
│ │ │ ├── implRecord.vue 实施记录上报
│ │ │ ├── repair.vue 维修上报(双入口)
│ │ │ ├── ota.vue OTA 升级
│ │ │ └── monitor.vue 设备监测(多 Tab 简化版)
│ │ ├── inspect/ 巡检 Tab
│ │ │ ├── Home.vue
│ │ │ └── detail.vue 巡检任务详情
│ │ └── me/ 我的 Tab
│ │ ├── Home.vue
│ │ └── messageCenter.vue 消息中心子页
│ └── hardware/ 硬件业务页面
│ └── cabinet/Home.vue
+10
View File
@@ -64,3 +64,13 @@ import './device-center/distribute/config'
import './device-center/distribute/record'
// 设备中心 - 数据分发 / 失败重投(死信队列,含手动重投、永久丢弃)
import './device-center/distribute/deadletter'
// 实施运维小程序 - 工作台 + 告警(今日待办 / 本周统计 / 告警列表 + 详情 + 处置)
import './miniprogram/ops/workbench'
// 实施运维小程序 - 工单(列表 3 段 + 详情 + 接单/关单/维修上报)
import './miniprogram/ops/workOrder'
// 实施运维小程序 - 扫码(识别 / 历史 / 初始化 / 实施记录 / 维修上报 / OTA / 设备监测 5 Tab
import './miniprogram/ops/scan'
// 实施运维小程序 - 巡检(计划列表 4 状态 + 任务详情逐项填报 + 异常转工单)
import './miniprogram/ops/inspect'
// 实施运维小程序 - 我的(个人统计 + 消息中心 + 离线缓存)
import './miniprogram/ops/me'
@@ -0,0 +1,154 @@
/**
* 实施运维小程序 / 巡检 mock
* - 计划列表(4 状态)
* - 任务详情(按设备 × 巡检项)
* - 提交项结果 + 完成任务
*/
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, RequestParameter } from '@axios'
import type {
InspectItem,
InspectItemSubmit,
InspectPlan,
InspectPlanStatus,
InspectTaskDetail,
} from '@pages/miniprogram/ops/inspect/types'
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
const getParams = <T = Record<string, unknown>>(params: RequestParameter): T =>
(params ?? {}) as T
const plans: InspectPlan[] = [
{
id: 'ip-001', name: '上海浦东张江食堂 - 月度巡检', site: '上海浦东 · 张江食堂',
deviceCategory: '智养餐饮设备', deviceCount: 5, status: 'doing',
deadline: '2026-07-03 18:00:00', inspector: '李工程师', itemCount: 6,
},
{
id: 'ip-002', name: '北京朝阳体检站 - 周巡检', site: '北京朝阳 · 朝阳体检站',
deviceCategory: '智能穿戴设备', deviceCount: 12, status: 'todo',
deadline: '2026-07-04 12:00:00', inspector: '李工程师', itemCount: 5,
},
{
id: 'ip-003', name: '广州天银食堂 - 临时巡检', site: '广州天河 · 天银食堂',
deviceCategory: '智养餐饮设备', deviceCount: 3, status: 'overdue',
deadline: '2026-07-02 18:00:00', inspector: '王工程师', itemCount: 6,
},
{
id: 'ip-004', name: '杭州西湖体检站 - 月度巡检', site: '杭州西湖 · 西湖体检站',
deviceCategory: '体重测量设备', deviceCount: 4, status: 'done',
deadline: '2026-07-01 18:00:00', inspector: '赵工程师', itemCount: 5,
},
{
id: 'ip-005', name: '重庆解放碑健身房 - 周巡检', site: '重庆渝中 · 解放碑健身房',
deviceCategory: '健身器材设备', deviceCount: 8, status: 'todo',
deadline: '2026-07-05 12:00:00', inspector: '李工程师', itemCount: 5,
},
{
id: 'ip-006', name: '深圳科技园体检站 - 月度巡检', site: '深圳南山 · 科技园体检站',
deviceCategory: '健康监测设备', deviceCount: 2, status: 'doing',
deadline: '2026-07-04 18:00:00', inspector: '赵工程师', itemCount: 7,
},
{
id: 'ip-007', name: '上海闵行莘庄食堂 - 月度巡检', site: '上海闵行 · 莘庄食堂',
deviceCategory: '智养餐饮设备', deviceCount: 4, status: 'done',
deadline: '2026-06-30 18:00:00', inspector: '李工程师', itemCount: 6,
},
]
const buildItems = (): InspectItem[] => [
{ id: 'it-1', name: '设备外观', type: 'appearance', standard: '无明显损伤 / 变形', result: 'pending', remark: '' },
{ id: 'it-2', name: '功能测试', type: 'function', standard: '按键 / 显示正常', result: 'pending', remark: '' },
{ id: 'it-3', name: '网络连通', type: 'network', standard: '心跳正常上报', result: 'pending', remark: '' },
{ id: 'it-4', name: '传感器读数', type: 'sensor', standard: '温度 / 湿度在阈值内', result: 'pending', remark: '' },
{ id: 'it-5', name: '固件版本', type: 'firmware', standard: '与最新版本一致', result: 'pending', remark: '' },
{ id: 'it-6', name: '电源稳定性', type: 'function', standard: '无掉电告警', result: 'pending', remark: '' },
]
const taskDetailMap: Record<string, InspectTaskDetail> = {
'ip-001': {
plan: plans[0],
devices: [
{ code: 'CAB-SH-001', name: '1号净菜柜', items: buildItems(), scanned: true },
{ code: 'CAB-SH-002', name: '2号净菜柜', items: buildItems(), scanned: false },
{ code: 'CAB-SH-003', name: '3号净菜柜', items: buildItems(), scanned: false },
],
},
'ip-002': {
plan: plans[1],
devices: [
{ code: 'WATCH-BJ-014', name: '14号健康手表', items: buildItems().slice(0, 5), scanned: false },
{ code: 'WATCH-BJ-015', name: '15号健康手表', items: buildItems().slice(0, 5), scanned: false },
],
},
}
registerMocks([
// 计划列表
{
url: '/mp/ops/inspect/list',
method: 'POST',
handler: (ctx): ApiResponse<InspectPlan[]> => {
const { status } = getParams<{ status?: InspectPlanStatus | '' }>(ctx.params)
const rows = status ? plans.filter((p) => p.status === status) : plans
return ok('查询成功', rows)
},
},
// 任务详情
{
url: '/mp/ops/inspect/detail',
method: 'GET',
handler: (ctx): ApiResponse<InspectTaskDetail | null> => {
const { id } = getParams<{ id?: string }>(ctx.params)
const detail = taskDetailMap[id ?? '']
if (!detail) {
// 兜底:构造一个空任务
const plan = plans.find((p) => p.id === id)
if (!plan) return ok('计划不存在', null)
return ok('查询成功', {
plan,
devices: [
{ code: `${plan.id}-D001`, name: '示例设备', items: buildItems().slice(0, plan.itemCount), scanned: false },
],
})
}
return ok('查询成功', detail)
},
},
// 提交巡检项结果
{
url: '/mp/ops/inspect/submit',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const { planId, deviceCode, itemId, result, remark } = getParams<InspectItemSubmit>(ctx.params)
const task = taskDetailMap[planId]
if (task) {
const dev = task.devices.find((d) => d.code === deviceCode)
const item = dev?.items.find((i) => i.id === itemId)
if (item) {
item.result = result
item.remark = remark
}
}
// 异常项自动转工单(mock 不实际创建,仅返回成功)
return ok(result === 'abnormal' ? '已记录异常,并自动转工单' : '已记录', null)
},
},
// 完成巡检任务
{
url: '/mp/ops/inspect/finish',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const { planId } = getParams<{ planId: string }>(ctx.params)
const plan = plans.find((p) => p.id === planId)
if (plan) plan.status = 'done'
return ok('巡检任务已完成', null)
},
},
])
+144
View File
@@ -0,0 +1,144 @@
/**
* 实施运维小程序 / 我的 mock
* - 个人统计(月度工单 / 巡检 / 装机 / OTA + SLA 达成率)
* - 消息中心(7 类消息)
* - 离线缓存状态 + 同步 / 清除
*/
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, RequestParameter } from '@axios'
import type { MessageItem, MessageType, OfflineStatus, PersonalStats } from '@pages/miniprogram/ops/me/types'
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
const getParams = <T = Record<string, unknown>>(params: RequestParameter): T =>
(params ?? {}) as T
const stats: PersonalStats = {
monthOrders: 23,
monthInspections: 18,
monthInstalls: 6,
monthOta: 9,
avgProcessHours: 4.5,
slaRate: '92.5%',
}
const messages: MessageItem[] = [
{
id: 'msg-001', type: 'alarmCritical', title: '严重告警 · 净菜柜压缩机故障',
content: 'CAB-SH-001 自检失败:压缩机停转 3 次/小时',
createdAt: '2026-07-03 09:25:02', read: false,
link: '/miniprogram/ops/alarm-detail/alm-001',
},
{
id: 'msg-002', type: 'workOrder', title: '工单派单 · 净菜柜维修',
content: 'WO20260703001 已派给您,SLA 截止 18:00',
createdAt: '2026-07-03 09:00:00', read: false,
link: '/miniprogram/ops/work-order-detail/wo-001',
},
{
id: 'msg-003', type: 'ota', title: 'OTA 任务下发 · 健康手表',
content: 'WATCH-BJ-014 目标版本 v1.1.0,请前往升级',
createdAt: '2026-07-03 08:30:00', read: false,
link: '/miniprogram/ops/ota/WATCH-BJ-014',
},
{
id: 'msg-004', type: 'inspect', title: '巡检任务下发 · 张江食堂',
content: '上海浦东张江食堂月度巡检,截止 7-3 18:00',
createdAt: '2026-07-03 08:00:00', read: true,
link: '/miniprogram/ops/inspect-detail/ip-001',
},
{
id: 'msg-005', type: 'overSla', title: '工单超 SLA · 货道错位复位',
content: 'WO20260702018 已超 SLA,请尽快处置',
createdAt: '2026-07-02 18:00:00', read: true,
link: '/miniprogram/ops/work-order-detail/wo-003',
},
{
id: 'msg-006', type: 'alarmDead', title: '数据分发死信告警',
content: '营养系统订阅通道累积死信 12 条,请处理',
createdAt: '2026-07-02 11:50:00', read: true,
link: '/miniprogram/ops/alarm-detail/alm-005',
},
{
id: 'msg-007', type: 'system', title: '系统公告 · OTA 升级策略调整',
content: '7 月 5 日起灰度比例默认 10%,详见运营公告',
createdAt: '2026-07-01 10:00:00', read: true,
},
]
const offline: OfflineStatus = {
enabled: true,
size: '12.3MB',
pendingCount: 2,
lastSyncTime: '2026-07-03 08:45:11',
}
registerMocks([
// 个人统计
{
url: '/mp/ops/me/stats',
method: 'POST',
handler: (): ApiResponse<PersonalStats> => ok('查询成功', stats),
},
// 消息列表
{
url: '/mp/ops/me/messages',
method: 'POST',
handler: (ctx): ApiResponse<MessageItem[]> => {
const { type } = getParams<{ type?: MessageType | '' }>(ctx.params)
const rows = type ? messages.filter((m) => m.type === type) : messages
return ok('查询成功', rows)
},
},
// 标记已读
{
url: '/mp/ops/me/message-read',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const { id } = getParams<{ id: string }>(ctx.params)
const target = messages.find((m) => m.id === id)
if (target) target.read = true
return ok('已标记已读', null)
},
},
// 全部已读
{
url: '/mp/ops/me/message-read-all',
method: 'POST',
handler: (): ApiResponse<null> => {
messages.forEach((m) => { m.read = true })
return ok('已全部标记已读', null)
},
},
// 离线缓存状态
{
url: '/mp/ops/me/offline-status',
method: 'GET',
handler: (): ApiResponse<OfflineStatus> => ok('查询成功', offline),
},
// 手动同步
{
url: '/mp/ops/me/offline-sync',
method: 'POST',
handler: (): ApiResponse<null> => {
offline.pendingCount = 0
offline.lastSyncTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
return ok('同步完成', null)
},
},
// 清除缓存
{
url: '/mp/ops/me/offline-clear',
method: 'POST',
handler: (): ApiResponse<null> => {
offline.size = '0MB'
offline.pendingCount = 0
return ok('清除完成', null)
},
},
])
+272
View File
@@ -0,0 +1,272 @@
/**
* 实施运维小程序 / 扫码 mock
* - 扫码识别设备(按编码查摘要)
* - 扫码历史
* - 设备初始化 / 实施记录 / 维修上报 / OTA 升级 / 设备监测
*/
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, RequestParameter } from '@axios'
import type {
DeviceMonitor,
DeviceSummary,
ImplRecordForm,
InitForm,
MonitorRepairItem,
MonitorUpdateItem,
OtaTask,
OtaVersion,
RepairForm,
RunLogItem,
} from '@pages/miniprogram/ops/scan/types'
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
const getParams = <T = Record<string, unknown>>(params: RequestParameter): T =>
(params ?? {}) as T
/** 设备档案数据集(覆盖 5 大顶级分类 × 4 状态) */
const deviceMap: Record<string, DeviceSummary> = {
'CAB-SH-001': {
id: 'dev-001', code: 'CAB-SH-001', name: '1号净菜柜(上海浦东)',
category: 'catering', model: 'NZG-200',
status: 'inUse', site: '上海浦东 · 张江食堂', tenant: 'CQ 能源集团',
firmwareVersion: 'V2.3.1', lastHeartbeat: '2026-07-03 09:30:11',
pendingWorkOrders: 1, isIot: true, dataDistributeEnabled: false,
},
'WATCH-BJ-014': {
id: 'dev-014', code: 'WATCH-BJ-014', name: '14号健康手表(北京朝阳)',
category: 'wearable', model: 'WATCH5-PRO',
status: 'inUse', site: '北京朝阳 · 朝阳体检站', tenant: '北京健康管理机构',
firmwareVersion: 'v1.0.4', lastHeartbeat: '2026-07-03 09:32:48',
pendingWorkOrders: 1, isIot: true, dataDistributeEnabled: true,
},
'TREADMILL-CQ-002': {
id: 'dev-022', code: 'TREADMILL-CQ-002', name: '2号跑步机(重庆渝中)',
category: 'fitness', model: 'TM-X1',
status: 'inUse', site: '重庆渝中 · 解放碑健身房', tenant: '重庆健身中心',
firmwareVersion: 'V1.5.2', lastHeartbeat: '2026-07-03 09:25:00',
pendingWorkOrders: 1, isIot: true, dataDistributeEnabled: true,
},
'BODYFAT-HZ-005': {
id: 'dev-035', code: 'BODYFAT-HZ-005', name: '5号体脂仪(杭州西湖)',
category: 'body', model: 'InBody-770',
status: 'inUse', site: '杭州西湖 · 西湖体检站', tenant: '杭州健康管理机构',
firmwareVersion: 'V3.0.1', lastHeartbeat: '2026-07-03 09:28:33',
pendingWorkOrders: 0, isIot: true, dataDistributeEnabled: true,
},
'CHECK-SZ-003': {
id: 'dev-048', code: 'CHECK-SZ-003', name: '3号体检一体机(深圳南山)',
category: 'health', model: 'ALL-IN-ONE-15',
status: 'inUse', site: '深圳南山 · 科技园体检站', tenant: '深圳健康管理机构',
firmwareVersion: 'V2.1.0', lastHeartbeat: '2026-07-03 09:20:11',
pendingWorkOrders: 1, isIot: true, dataDistributeEnabled: true,
},
'CAB-NEW-001': {
id: 'dev-091', code: 'CAB-NEW-001', name: '新装净菜柜(待激活)',
category: 'catering', model: 'NZG-200',
status: 'inactive', site: '上海浦东 · 张江食堂', tenant: 'CQ 能源集团',
firmwareVersion: '—', lastHeartbeat: '—',
pendingWorkOrders: 0, isIot: true, dataDistributeEnabled: false,
},
}
/** 扫码历史(最近 6 条) */
const scanHistory = [
{ id: 'h-001', deviceCode: 'CAB-SH-001', deviceName: '1号净菜柜(上海浦东)', scannedAt: '2026-07-03 09:10:00', action: '查看档案' },
{ id: 'h-002', deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)', scannedAt: '2026-07-03 08:45:00', action: '维修上报' },
{ id: 'h-003', deviceCode: 'BODYFAT-HZ-005', deviceName: '5号体脂仪(杭州西湖)', scannedAt: '2026-07-02 17:30:00', action: '巡检打卡' },
{ id: 'h-004', deviceCode: 'CAB-NEW-001', deviceName: '新装净菜柜(待激活)', scannedAt: '2026-07-02 14:20:00', action: '设备初始化' },
{ id: 'h-005', deviceCode: 'TREADMILL-CQ-002', deviceName: '2号跑步机(重庆渝中)', scannedAt: '2026-07-02 10:15:00', action: '查看档案' },
{ id: 'h-006', deviceCode: 'CHECK-SZ-003', deviceName: '3号体检一体机(深圳南山)', scannedAt: '2026-07-01 16:40:00', action: 'OTA 升级' },
]
/** OTA 可用版本 */
const otaVersionsMap: Record<string, OtaVersion[]> = {
'WATCH-BJ-014': [
{ version: 'v1.1.0', releaseNote: '新增睡眠分析、修复心率异常告警', status: 'full', size: '4.2MB' },
{ version: 'v1.0.5', releaseNote: '修复蓝牙连接稳定性', status: 'gray', size: '2.1MB' },
],
'CAB-SH-001': [
{ version: 'V2.4.0', releaseNote: '压缩机优化、温度校准算法升级', status: 'full', size: '8.5MB' },
],
}
/** OTA 任务(按设备编码存) */
const otaTaskMap: Record<string, OtaTask> = {}
/** 运行日志(按设备编码存) */
const runLogsMap: Record<string, RunLogItem[]> = {
'CAB-SH-001': [
{ id: 'log-1', time: '2026-07-03 09:30:11', event: '心跳上报', level: 'info' },
{ id: 'log-2', time: '2026-07-03 09:25:08', event: '自检告警:压缩机停转', level: 'error' },
{ id: 'log-3', time: '2026-07-03 09:00:00', event: '开机', level: 'info' },
{ id: 'log-4', time: '2026-07-03 08:30:00', event: '温度漂移 2.4℃', level: 'warn' },
],
'WATCH-BJ-014': [
{ id: 'log-1', time: '2026-07-03 09:32:48', event: '心跳上报', level: 'info' },
{ id: 'log-2', time: '2026-07-03 09:15:00', event: 'OTA 升级失败已回滚', level: 'error' },
{ id: 'log-3', time: '2026-07-03 08:00:00', event: '开机', level: 'info' },
],
}
/** 维修记录 */
const repairsMap: Record<string, MonitorRepairItem[]> = {
'CAB-SH-001': [
{ id: 'rep-1', time: '2026-06-15 14:20:00', faultType: '硬件故障', repairMethod: '更换压缩机', repairman: '李工程师', workOrderId: 'WO20260615003' },
{ id: 'rep-2', time: '2026-05-20 10:30:00', faultType: '传感器故障', repairMethod: '校准温度传感器', repairman: '王工程师', workOrderId: 'WO20260520001' },
],
'BODYFAT-HZ-005': [
{ id: 'rep-1', time: '2026-07-01 17:20:00', faultType: '电源故障', repairMethod: '更换电池', repairman: '赵工程师', workOrderId: 'WO20260701022' },
],
}
/** 更新记录 */
const updatesMap: Record<string, MonitorUpdateItem[]> = {
'WATCH-BJ-014': [
{ id: 'upd-1', time: '2026-07-03 10:12:48', fromVersion: 'v1.0.4', toVersion: 'v1.1.0', result: 'rolledback', operator: '系统' },
{ id: 'upd-2', time: '2026-06-15 09:00:00', fromVersion: 'v1.0.3', toVersion: 'v1.0.4', result: 'success', operator: '李工程师' },
],
}
registerMocks([
// 扫码识别设备
{
url: '/mp/ops/scan/scan',
method: 'GET',
handler: (ctx): ApiResponse<DeviceSummary | null> => {
const { deviceCode } = getParams<{ deviceCode?: string }>(ctx.params)
const dev = deviceMap[deviceCode ?? '']
if (!dev) return ok('未找到设备,请联系管理员', null)
return ok('识别成功', dev)
},
},
// 扫码历史
{
url: '/mp/ops/scan/history',
method: 'GET',
handler: (): ApiResponse<typeof scanHistory> => ok('查询成功', scanHistory),
},
// 设备初始化
{
url: '/mp/ops/scan/init',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const form = getParams<InitForm>(ctx.params)
const dev = deviceMap[form.deviceCode]
if (dev && form.result === 'success') {
dev.status = 'activated'
dev.firmwareVersion = form.firmwareBaseline
}
return ok('初始化上报成功', null)
},
},
// 实施记录上报
{
url: '/mp/ops/scan/impl-record',
method: 'POST',
handler: (): ApiResponse<null> => ok('实施记录上报成功', null),
},
// 维修上报(无工单独立入口)
{
url: '/mp/ops/scan/repair',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const _form = getParams<RepairForm>(ctx.params)
void _form
return ok('维修上报成功', null)
},
},
// 可用 OTA 版本
{
url: '/mp/ops/scan/ota-versions',
method: 'GET',
handler: (ctx): ApiResponse<OtaVersion[]> => {
const { deviceCode } = getParams<{ deviceCode?: string }>(ctx.params)
return ok('查询成功', otaVersionsMap[deviceCode ?? ''] ?? [])
},
},
// 下发 OTA 任务
{
url: '/mp/ops/scan/ota-push',
method: 'POST',
handler: (ctx): ApiResponse<OtaTask> => {
const { deviceCode, targetVersion } = getParams<{ deviceCode: string; targetVersion: string }>(ctx.params)
const dev = deviceMap[deviceCode]
const task: OtaTask = {
id: `ota-${Date.now()}`,
deviceCode,
currentVersion: dev?.firmwareVersion ?? '—',
targetVersion,
progress: 0,
status: 'upgrading',
failReason: '',
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
}
otaTaskMap[deviceCode] = task
return ok('OTA 任务下发成功', task)
},
},
// 查询 OTA 任务进度
{
url: '/mp/ops/scan/ota-task',
method: 'GET',
handler: (ctx): ApiResponse<OtaTask | null> => {
const { deviceCode } = getParams<{ deviceCode?: string }>(ctx.params)
const task = otaTaskMap[deviceCode ?? '']
if (task) {
// 模拟进度推进
if (task.status === 'upgrading' && task.progress < 100) {
task.progress = Math.min(100, task.progress + 25)
if (task.progress >= 100) {
task.status = 'success'
const dev = deviceMap[deviceCode!]
if (dev) dev.firmwareVersion = task.targetVersion
}
}
}
return ok('查询成功', task ?? null)
},
},
// OTA 回滚
{
url: '/mp/ops/scan/ota-rollback',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const { deviceCode } = getParams<{ deviceCode: string }>(ctx.params)
const task = otaTaskMap[deviceCode]
if (task) {
task.status = 'rolledback'
const dev = deviceMap[deviceCode]
if (dev) dev.firmwareVersion = task.currentVersion
}
return ok('回滚成功', null)
},
},
// 设备监测详情
{
url: '/mp/ops/scan/monitor',
method: 'GET',
handler: (ctx): ApiResponse<DeviceMonitor | null> => {
const { deviceCode } = getParams<{ deviceCode?: string }>(ctx.params)
const dev = deviceMap[deviceCode ?? '']
if (!dev) return ok('设备不存在', null)
const monitor: DeviceMonitor = {
summary: dev,
runLogs: runLogsMap[deviceCode!] ?? [
{ id: 'log-default', time: dev.lastHeartbeat, event: '心跳上报', level: 'info' },
],
alarms: dev.pendingWorkOrders > 0
? [{ id: 'alm-link', content: '该设备有告警,点击查看', level: 'warn', triggerTime: dev.lastHeartbeat }]
: [],
repairs: repairsMap[deviceCode!] ?? [],
updates: updatesMap[deviceCode!] ?? [],
}
return ok('查询成功', monitor)
},
},
])
@@ -0,0 +1,197 @@
/**
* 实施运维小程序 / 工单 mock
* - 列表(按状态分段,类型筛选)
* - 详情(含客户报障附件 + 处理时间线)
* - 接单 / 关单 / 提交维修上报
*/
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, RequestParameter } from '@axios'
import type {
RepairReportForm,
WorkOrderDetail,
WorkOrderItem,
WorkOrderListParams,
WorkOrderStatus,
WorkOrderType,
} from '@pages/miniprogram/ops/workOrder/types'
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
const getParams = <T = Record<string, unknown>>(params: RequestParameter): T =>
(params ?? {}) as T
const workOrders: WorkOrderItem[] = [
{
id: 'wo-001', code: 'WO20260703001', title: '净菜柜压缩机故障维修', type: 'repair',
status: 'pending', deviceCode: 'CAB-SH-001', deviceName: '1号净菜柜(上海浦东)',
site: '上海浦东 · 张江食堂', tenant: 'CQ 能源集团',
description: '客户报修:制冷效果差,压缩机异响',
slaDeadline: '2026-07-03 18:00:00', createdAt: '2026-07-03 08:30:00',
assignee: '李工程师', overSla: false,
},
{
id: 'wo-002', code: 'WO20260703002', title: '健康手表 OTA 失败处置', type: 'selfCheck',
status: 'processing', deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)',
site: '北京朝阳 · 朝阳体检站', tenant: '北京健康管理机构',
description: 'OTA 升级失败已自动回滚,需现场排查',
slaDeadline: '2026-07-03 20:00:00', createdAt: '2026-07-03 10:15:00',
assignee: '李工程师', overSla: false,
},
{
id: 'wo-003', code: 'WO20260702018', title: '净菜柜货道错位复位', type: 'inspection',
status: 'pending', deviceCode: 'CAB-GZ-007', deviceName: '7号净菜柜(广州天河)',
site: '广州天河 · 天银食堂', tenant: '广州餐饮集团',
description: '巡检发现:货道 5 错位,需现场复位',
slaDeadline: '2026-07-03 12:00:00', createdAt: '2026-07-02 16:30:00',
assignee: '王工程师', overSla: true,
},
{
id: 'wo-004', code: 'WO20260702015', title: '体检一体机离线排查', type: 'sla',
status: 'processing', deviceCode: 'CHECK-SZ-003', deviceName: '3号体检一体机(深圳南山)',
site: '深圳南山 · 科技园体检站', tenant: '深圳健康管理机构',
description: '连续 3 次心跳未上报,疑似网络故障',
slaDeadline: '2026-07-02 18:00:00', createdAt: '2026-07-02 14:05:00',
assignee: '赵工程师', overSla: true,
},
{
id: 'wo-005', code: 'WO20260702009', title: '数据分发死信重投', type: 'repair',
status: 'done', deviceCode: 'CAB-SH-002', deviceName: '2号净菜柜(上海闵行)',
site: '上海闵行 · 莘庄食堂', tenant: 'CQ 能源集团',
description: '营养系统订阅 4 次重试失败,已人工重投成功',
slaDeadline: '2026-07-02 16:00:00', createdAt: '2026-07-02 11:48:00',
assignee: '李工程师', overSla: false,
},
{
id: 'wo-006', code: 'WO20260701022', title: '体脂仪电池更换', type: 'repair',
status: 'done', deviceCode: 'BODYFAT-HZ-005', deviceName: '5号体脂仪(杭州西湖)',
site: '杭州西湖 · 西湖体检站', tenant: '杭州健康管理机构',
description: '电池电量低于 15%,已更换',
slaDeadline: '2026-07-01 18:00:00', createdAt: '2026-07-01 17:20:00',
assignee: '赵工程师', overSla: false,
},
{
id: 'wo-007', code: 'WO20260703005', title: '跑步机过载维修', type: 'repair',
status: 'pending', deviceCode: 'TREADMILL-CQ-002', deviceName: '2号跑步机(重庆渝中)',
site: '重庆渝中 · 解放碑健身房', tenant: '重庆健身中心',
description: '连续 10 分钟过载,疑似电机故障',
slaDeadline: '2026-07-03 22:00:00', createdAt: '2026-07-03 08:50:00',
assignee: '李工程师', overSla: false,
},
{
id: 'wo-008', code: 'WO20260703008', title: '净菜柜温度校准', type: 'selfCheck',
status: 'processing', deviceCode: 'CAB-BJ-003', deviceName: '3号净菜柜(北京海淀)',
site: '北京海淀 · 中关村食堂', tenant: '北京餐饮集团',
description: '温度传感器读数漂移 2.4℃,需校准',
slaDeadline: '2026-07-04 12:00:00', createdAt: '2026-07-01 09:15:00',
assignee: '王工程师', overSla: false,
},
]
const detailMap: Record<string, Partial<WorkOrderDetail>> = {
'wo-001': {
faultImages: ['https://picsum.photos/200/150?random=1', 'https://picsum.photos/200/150?random=2'],
customerVoice: '机器一直嗡嗡响,东西放进去不凉',
processLog: [
{ time: '2026-07-03 08:30:00', action: '客户报修', operator: '客户' },
{ time: '2026-07-03 08:35:00', action: '系统创建工单', operator: '系统' },
{ time: '2026-07-03 09:00:00', action: '派单给李工程师', operator: '系统' },
],
},
'wo-002': {
faultImages: [],
processLog: [
{ time: '2026-07-03 10:12:48', action: 'OTA 升级失败自动告警', operator: '系统' },
{ time: '2026-07-03 10:13:30', action: '自动回滚到 v1.0.4', operator: '系统' },
{ time: '2026-07-03 10:20:00', action: '生成工单并派单', operator: '系统' },
{ time: '2026-07-03 10:25:00', action: '李工程师接单', operator: '李工程师' },
],
},
}
const filterByStatus = (rows: WorkOrderItem[], status: WorkOrderStatus): WorkOrderItem[] =>
rows.filter((r) => r.status === status)
const filterByType = (rows: WorkOrderItem[], type: WorkOrderType | ''): WorkOrderItem[] =>
type ? rows.filter((r) => r.type === type) : rows
const filterByKeyword = (rows: WorkOrderItem[], kw: string): WorkOrderItem[] => {
if (!kw) return rows
const k = kw.toLowerCase()
return rows.filter((r) =>
r.code.toLowerCase().includes(k) ||
r.title.toLowerCase().includes(k) ||
r.deviceCode.toLowerCase().includes(k) ||
r.deviceName.toLowerCase().includes(k),
)
}
registerMocks([
// 工单列表
{
url: '/mp/ops/work-order/list',
method: 'POST',
handler: (ctx): ApiResponse<WorkOrderItem[]> => {
const p = getParams<WorkOrderListParams>(ctx.params)
let rows = filterByStatus(workOrders, p.status)
rows = filterByType(rows, p.type ?? '')
rows = filterByKeyword(rows, p.keyword ?? '')
return ok('查询成功', rows)
},
},
// 工单详情
{
url: '/mp/ops/work-order/detail',
method: 'GET',
handler: (ctx): ApiResponse<WorkOrderDetail | null> => {
const { id } = getParams<{ id?: string }>(ctx.params)
const base = workOrders.find((w) => w.id === id)
if (!base) return ok('工单不存在', null)
const ext = detailMap[id!] ?? {
faultImages: [],
processLog: [{ time: base.createdAt, action: '工单创建', operator: '系统' }],
}
return ok('查询成功', { ...base, ...ext } as WorkOrderDetail)
},
},
// 接单
{
url: '/mp/ops/work-order/accept',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const { id } = getParams<{ id?: string }>(ctx.params)
const target = workOrders.find((w) => w.id === id)
if (target) {
target.status = 'processing'
target.assignee = '当前用户'
}
return ok('接单成功', null)
},
},
// 关单
{
url: '/mp/ops/work-order/close',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const { id } = getParams<{ id?: string }>(ctx.params)
const target = workOrders.find((w) => w.id === id)
if (target) target.status = 'done'
return ok('关单成功', null)
},
},
// 提交维修上报
{
url: '/mp/ops/work-order/repair-report',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const _form = getParams<RepairReportForm>(ctx.params)
// 原型阶段仅记录日志,不实际更新工单状态
void _form
return ok('维修上报成功', null)
},
},
])
@@ -0,0 +1,233 @@
/**
* 实施运维小程序 / 工作台 + 告警 mock
* - 今日待办 5 卡统计
* - 本周统计 3 卡
* - 告警列表 + 详情 + 处置
*
* 与 PC 端告警管理(device-center/ops/alarm.ts)数据风格一致但字段精简,符合小程序端只读查看场景。
*/
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, RequestParameter } from '@axios'
import type {
AlarmDetail,
AlarmHandleAction,
AlarmItem,
AlarmLevel,
AlarmSource,
AlarmStatus,
TodoStats,
WeekStats,
} from '@pages/miniprogram/ops/workbench/types'
interface AlarmListParams {
level?: AlarmLevel | ''
source?: AlarmSource | ''
status?: AlarmStatus | ''
keyword?: string
}
interface AlarmHandleBody {
id: string
action: AlarmHandleAction
reason: string
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
const getParams = <T = Record<string, unknown>>(params: RequestParameter): T =>
(params ?? {}) as T
/** 告警数据集(8 条,覆盖 6 来源 × 3 级别 × 4 状态) */
const alarmList: AlarmItem[] = [
{
id: 'alm-001', alarmCode: 'ALM20260703001', source: 'selfCheck', level: 'critical',
deviceCode: 'CAB-SH-001', deviceName: '1号净菜柜(上海浦东)',
content: '自检失败:压缩机停转 3 次/小时,疑似硬件故障',
triggerTime: '2026-07-03 09:24:11', status: 'pending', handler: null,
},
{
id: 'alm-002', alarmCode: 'ALM20260703002', source: 'ota', level: 'warn',
deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)',
content: 'OTA 升级失败:v1.0.4 → v1.1.0 失败已回滚',
triggerTime: '2026-07-03 10:12:48', status: 'processing', handler: '李工程师',
},
{
id: 'alm-003', alarmCode: 'ALM20260702018', source: 'inspection', level: 'warn',
deviceCode: 'CAB-GZ-007', deviceName: '7号净菜柜(广州天河)',
content: '巡检异常:货道 5 错位,建议现场复位',
triggerTime: '2026-07-02 16:30:22', status: 'workorder', handler: '王工程师',
},
{
id: 'alm-004', alarmCode: 'ALM20260702015', source: 'sla', level: 'critical',
deviceCode: 'CHECK-SZ-003', deviceName: '3号体检一体机(深圳南山)',
content: 'SLA 超时:连续 3 次心跳未上报,离线 25 分钟',
triggerTime: '2026-07-02 14:05:09', status: 'ignored', handler: '赵工程师',
},
{
id: 'alm-005', alarmCode: 'ALM20260702009', source: 'dataDistribute', level: 'warn',
deviceCode: 'CAB-SH-002', deviceName: '2号净菜柜(上海闵行)',
content: '数据分发失败:营养系统订阅 4 次重试失败',
triggerTime: '2026-07-02 11:48:33', status: 'workorder', handler: '李工程师',
},
{
id: 'alm-006', alarmCode: 'ALM20260701022', source: 'runLog', level: 'info',
deviceCode: 'BODYFAT-HZ-005', deviceName: '5号体脂仪(杭州西湖)',
content: '运行日志:电池电量低于 15%,建议安排更换',
triggerTime: '2026-07-01 17:20:00', status: 'ignored', handler: null,
},
{
id: 'alm-007', alarmCode: 'ALM20260701008', source: 'selfCheck', level: 'warn',
deviceCode: 'CAB-BJ-003', deviceName: '3号净菜柜(北京海淀)',
content: '自检告警:温度传感器读数漂移 2.4℃,建议校准',
triggerTime: '2026-07-01 09:15:42', status: 'workorder', handler: '王工程师',
},
{
id: 'alm-008', alarmCode: 'ALM20260703005', source: 'runLog', level: 'critical',
deviceCode: 'TREADMILL-CQ-002', deviceName: '2号跑步机(重庆渝中)',
content: '运行异常:连续 10 分钟过载,疑似电机故障',
triggerTime: '2026-07-03 08:50:17', status: 'pending', handler: null,
},
]
/** 告警详情扩展信息 */
const alarmDetailMap: Record<string, Partial<AlarmDetail>> = {
'alm-001': {
deviceModel: '智养净菜柜 V2 / 型号 NZG-200',
site: '上海浦东 · 张江食堂',
tenant: 'CQ 能源集团',
processTimeline: [
{ time: '2026-07-03 09:24:11', action: '告警触发(自检失败)', operator: '系统' },
{ time: '2026-07-03 09:25:02', action: '推送至运维工程师手机', operator: '系统' },
],
},
'alm-002': {
deviceModel: '华为 Watch 5 / 型号 WATCH5-PRO',
site: '北京朝阳 · 朝阳体检站',
tenant: '北京健康管理机构',
processTimeline: [
{ time: '2026-07-03 10:12:48', action: 'OTA 升级失败', operator: '系统' },
{ time: '2026-07-03 10:13:30', action: '自动回滚到 v1.0.4', operator: '系统' },
{ time: '2026-07-03 10:20:00', action: '工程师接单处理', operator: '李工程师' },
],
},
}
const filterAlarms = (rows: AlarmItem[], params: AlarmListParams): AlarmItem[] =>
rows.filter((r) => {
if (params.level && r.level !== params.level) return false
if (params.source && r.source !== params.source) return false
if (params.status && r.status !== params.status) return false
if (params.keyword) {
const kw = params.keyword.toLowerCase()
const hit =
r.deviceCode.toLowerCase().includes(kw) ||
r.deviceName.toLowerCase().includes(kw) ||
r.content.toLowerCase().includes(kw) ||
r.alarmCode.toLowerCase().includes(kw)
if (!hit) return false
}
return true
})
const todoStats: TodoStats = {
pendingWorkOrders: 5,
overSlaOrders: 1,
todayInspections: 8,
inspectionDone: 3,
todayOtaTasks: 2,
todayInitDevices: 4,
pendingAlarms: 6,
criticalAlarms: 2,
}
const weekStats: WeekStats = {
weekOrders: 23,
avgProcessHours: 4.5,
weekInspectionRate: '87.5%',
weekInspectionAbnormalRate: '12.5%',
monthInstallCount: 18,
}
registerMocks([
// 工作台今日待办统计
{
url: '/mp/ops/workbench/todo-stats',
method: 'POST',
handler: (): ApiResponse<TodoStats> => ok('查询成功', todoStats),
},
// 工作台本周统计
{
url: '/mp/ops/workbench/week-stats',
method: 'POST',
handler: (): ApiResponse<WeekStats> => ok('查询成功', weekStats),
},
// 告警列表
{
url: '/mp/ops/alarm/list',
method: 'POST',
handler: (ctx): ApiResponse<AlarmItem[]> => {
const params = getParams<AlarmListParams>(ctx.params)
return ok('查询成功', filterAlarms(alarmList, params))
},
},
// 告警详情
{
url: '/mp/ops/alarm/detail',
method: 'GET',
handler: (ctx): ApiResponse<AlarmDetail | null> => {
const { id } = getParams<{ id?: string }>(ctx.params)
const base = alarmList.find((a) => a.id === id)
if (!base) return ok('告警不存在', null)
const ext = alarmDetailMap[id!] ?? {
deviceModel: '—',
site: '—',
tenant: '—',
processTimeline: [{ time: base.triggerTime, action: '告警触发', operator: '系统' }],
}
return ok('查询成功', { ...base, ...ext } as AlarmDetail)
},
},
// 告警处置
{
url: '/mp/ops/alarm/handle',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const { id, action, reason } = getParams<AlarmHandleBody>(ctx.params)
const target = alarmList.find((a) => a.id === id)
if (!target) return ok('告警不存在', null)
const actionText: Record<AlarmHandleAction, string> = {
toWorkOrder: '转工单',
ignore: '忽略',
resolve: '标记已处理',
}
const nextStatus: Record<AlarmHandleAction, AlarmStatus> = {
toWorkOrder: 'workorder',
ignore: 'ignored',
resolve: 'processing',
}
target.status = nextStatus[action]
target.handler = '当前用户'
// 处置时间线追加到详情扩展信息(详情查询时合并展示)
const ext = alarmDetailMap[id!] ?? {
deviceModel: '—',
site: '—',
tenant: '—',
processTimeline: [{ time: target.triggerTime, action: '告警触发', operator: '系统' }],
}
ext.processTimeline = (ext.processTimeline ?? []).concat([
{
time: new Date().toISOString().slice(0, 19).replace('T', ' '),
action: `${actionText[action]}${reason || '无说明'}`,
operator: '当前用户',
},
])
alarmDetailMap[id!] = ext
return ok('处置成功', null)
},
},
])
@@ -4,7 +4,7 @@
>
> 老系统参考后台地址:https://device.shuziweidao.com/canteen/home,登录账号:admin,登录密码:Q4Zz64285886@629
>
> _Last updated: 2026-07-02_
> _Last updated: 2026-07-03_
---
@@ -717,18 +717,25 @@
```
实施运维小程序
├─ 工作台 今日待办、统计卡、消息入口
├─ 工作台 今日待办(含待处理告警卡)、统计卡、快捷入口、告警入口
│ └─ 子页:告警列表 / 告警详情
├─ 工单 工单列表 + 详情 + 处理 + 关单上报
│ └─ 子页:工单详情 / 维修上报
├─ ⚡ 扫码 中间凸起按钮,扫设备后弹"场景操作菜单"
│ └─ 子页:场景菜单 / 设备初始化 / 实施记录上报 / 维修上报 / OTA 升级 / 设备监测
├─ 巡检 巡检计划 + 任务打卡 + 结果上报
│ └─ 子页:巡检任务详情
└─ 我的 个人统计、消息中心、离线缓存设置
└─ 子页:消息中心
```
> **设计理由**:现场最高频动作是"扫码识别设备",把扫码独立成 Tab 可直达,扫完按设备状态动态弹出可执行操作,符合一线人员肌肉记忆。其余 4 个 Tab 分别承载任务接收(工作台)、被动处置(工单)、主动作业(巡检)、个人空间(我的)。
>
> **告警不独立成 Tab 的原因**:告警不是高频主动操作(运维人员一天查看数次即可),独立成 Tab 会挤占底部 5 个位置;将入口放在工作台首页"待处理告警卡"+ 我的 → 消息中心推送兜底,既保证可见性又不破坏 5 Tab 节奏。
### 8.3 扫码后场景菜单(核心交互)
@@ -737,28 +744,29 @@
| 设备状态 | 弹出的可选操作 |
|---------|--------------|
| 未激活(仅入库) | 「设备初始化」 |
| 已激活未装机 | 「实施记录上报」「设备初始化(重做)」 |
| 在用且无待处理工单 | 「巡检打卡」「维修上报」「OTA 升级」「查看档案」 |
| 已激活未装机 | 「实施记录上报」「设备初始化(重做)」「设备监测」 |
| 在用且无待处理工单 | 「巡检打卡」「维修上报」「OTA 升级」「设备监测」 |
| 在用且有待处理工单 | 顶部红点提示「该设备有 N 条待处理工单」+ 上述操作 |
| 已报废 / 已退租 | 仅「查看档案」 |
| 已报废 / 已退租 | 仅「设备监测」(只读) |
**操作菜单项**(共 6 项,按状态过滤显示):
1. **设备初始化**:扫码 → 烧录证书 / 预置场所配置 / 固件基线版本 → 上报初始化结果
1. **设备初始化**:扫码 → 烧录证书 / 预置场所配置 / 固件基线版本 / 初始化结果上报(成功 / 失败 + 失败原因)→ 失败可重做
2. **实施记录上报**:扫码 → 选实施类型(新装 / 迁移 / 拆除 / 复位)→ 拍照 → 客户签收 → 提交
3. **巡检打卡**:扫码确认到点 → 填报巡检项 → 异常项自动转工单
4. **维修上报**:扫码 → 关联工单(如有)→ 填故障类型 / 维修方式 / 工时 / 备件 / 图片 → 提交
4. **维修上报**:扫码 → **双入口**(关联工单上报 / 无工单独立新增)→ 填故障类型 / 维修方式 / 工时 / 备件 / 图片 → 提交
5. **OTA 升级**:扫码 → 选目标版本 → 下发升级任务 → 监控进度 → 失败可回滚
6. **查看档案**:扫码 → 设备台账卡片 + 近期运行日志 + 历史维修履历(聚合自设备监测详情页多 Tab+ 当前租约状态
6. **设备监测**:扫码 → 多 Tab 简化版(概览 / 运行日志 / 告警 / 维修记录 / 更新记录),参考后台"设备运维 → 设备监测.详情页"做只读简化
### 8.4 各 Tab 模块详细说明
#### 8.4.1 工作台
**今日待办**
**今日待办**5 张卡,红点表示需立即处置)
- 今日待处理工单数(含超 SLA 标红)
- 今日巡检任务数(含已完成 / 未完成)
- 今日 OTA 任务数
- 今日待初始化设备数
- **待处理告警数**(含严重告警数标红,点击进入告警列表子页)
**统计卡**
- 本周处理工单数 / 平均处理时长
@@ -767,6 +775,8 @@
**快捷入口**:扫码、消息中心、离线任务、个人统计
**告警入口**:工作台首页"待处理告警卡"点击 → 告警列表子页(详见 §8.4.6);严重告警同时通过微信订阅消息推送 + 我的 → 消息中心兜底
#### 8.4.2 工单
**工单列表**
@@ -795,7 +805,41 @@
**异常处理**
- 二维码无法识别 → 手动输入设备编码
- 设备不存在 → 提示"未找到设备,请联系管理员"
- 设备已报废 → 仅显示档案查看
- 设备已报废 → 仅显示设备监测(只读)
**子页面 1:场景操作菜单(scene-menu**
- 底部操作面板,按 §8.3 设备状态过滤显示 6 项操作
- 顶部展示设备摘要卡(编码 / 型号 / 场所 / 状态 / 待处理工单数红点)
**子页面 2:设备初始化(init** — 现场激活设备
- 表单字段:设备编码(扫码带入,只读)、绑定密钥、证书烧录状态、预置场所配置(默认带入设备档案的所属场所,可改)、固件基线版本(下拉选择)、初始化结果(成功 / 失败)、失败原因(失败时必填)、操作人、操作时间
- 操作:提交上报、保存草稿(离线缓存)
- 与 PC 端衔接:上报后回写"设备运维 → 设备初始化"任务,设备状态从"未激活"流转为"已激活未装机"
**子页面 3:实施记录上报(impl-record**
- 表单字段:设备编码、实施类型(新装 / 迁移 / 拆除 / 复位)、实施前后状态对比、实施图片(最多 9 张)、客户签收(电子签名 / 拍照)、实施人、实施时间
- 操作:提交、保存草稿
**子页面 4:维修上报(repair)** — 双入口
- **入口 A 关联工单**:从工单详情 → 关单前维修上报进入,工单 ID 自动带入不可改
- **入口 B 无工单独立新增**:从扫码场景菜单进入,工单 ID 可选填(如现场临时维修未走工单流程)
- 表单字段:设备编码、关联工单 ID(可选)、故障类型、故障描述、维修方式、维修图片(最多 9 张)、工时(小时)、备件消耗(备件名 + 数量,可多行)、维修人、维修时间
- 操作:提交、保存草稿
- 与 PC 端衔接:上报后回写"设备运维 → 设备监测.详情.维修记录 Tab";若有关联工单则同时触发工单关单流程
**子页面 5OTA 升级(ota**
- 流程:选目标版本(从版本管理拉取该设备型号的可用版本)→ 下发升级任务 → 监控进度(进度条 + 状态)→ 结果(成功 / 失败 / 已回滚)→ 失败可一键回滚
- 字段:设备编码、当前版本、目标版本、升级计划时间、升级进度、结果状态、失败原因
- 与 PC 端衔接:升级记录回写"设备运维 → 设备监测.详情.更新记录 Tab";版本档案来自"设备运维 → 版本管理"
**子页面 6:设备监测(monitor)** — 多 Tab 简化版(参考后台设备运维 → 设备监测.详情页)
- **Tab 1 概览**:设备基础卡(编码 / 型号 / 所属租户 / 场所 / 运行状态 / 最近心跳时间 / 固件版本 / 是否 IOT / 是否启用数据分发)
- **Tab 2 运行日志**:最近 50 条运行埋点(开机关机 / 心跳 / 关键事件流水),按时间倒序
- **Tab 3 告警**:该设备当前告警列表(未处理 / 处理中状态),点击进告警详情子页(§8.4.6)
- **Tab 4 维修记录**:该设备历史维修档案(时间 / 故障类型 / 维修方式 / 维修人),只读
- **Tab 5 更新记录**:该设备 OTA 升级历史(时间 / 目标版本 / 结果),只读
- 不放实施记录 / 自检记录 / 巡检记录 3 个 Tab:小程序端不做历史台账查看,避免过载
- 与 PC 端关系:只读聚合,所有数据来自 PC 端"设备资产 → 设备管理"+"设备运维 → 设备监测.详情页"多 Tab
#### 8.4.4 巡检
@@ -817,12 +861,16 @@
- 本月工单处理数 / 巡检完成数 / 装机数 / OTA 升级数
- 工单平均处理时长、SLA 达成率
**消息中心**
**消息中心**(子页面 message-center.vue
- 工单派单通知
- 巡检任务下发通知
- OTA 任务下发通知
- OTA 失败告警
- 巡检逾期告警
- 自检异常告警
- 严重告警推送(与工作台告警入口互补,作为推送兜底)
- 系统公告
- 按类型筛选 + 全部已读操作
**离线缓存设置**
- 开关:启用离线缓存(默认开)
@@ -832,6 +880,24 @@
**设置**:账号信息、修改密码、退出登录
#### 8.4.6 告警管理(挂工作台下)
> 告警入口在工作台首页"待处理告警卡",子页面独立组织。告警作为运维人员被动接收的异常事件视图,与工单(主动处置)互补。
**告警列表(alarm-list**
- 字段:告警 ID、告警来源、告警级别、设备编码、告警内容、触发时间、状态
- 筛选:按级别(提示 / 警告 / 严重)、来源(运行日志 / 自检 / 巡检 / SLA 超时 / OTA 失败 / 数据分发失败)、状态(未处理 / 处理中 / 已忽略 / 已转工单)、时间范围
- 顶部统计卡:今日新增告警 / 待处理告警 / 严重告警数
- 严重告警在列表项标红 + 顶部红点
- 操作:点击进入告警详情
**告警详情(alarm-detail**
- 字段:告警 ID、来源、级别、设备编码(点击可跳转设备监测子页)、告警内容、触发时间、当前状态、处理人、处理记录时间线
- 操作:转工单(自动带告警上下文,跳转工单详情)、忽略(必填忽略原因)、标记已处理(必填处理说明)、查看设备监测(跳转 §8.4.3 子页 6)
- 与 PC 端衔接:所有处置操作回写"设备运维 → 告警管理";转工单后告警状态变为"已转工单",工单 ID 回显
**与 PC 端映射**:PC 端"设备运维 → 告警管理"。小程序端只做"查看 + 转工单 + 简单处置",不替代 PC 端的批量处理 / 复杂筛选 / 业务日志视图。
### 8.5 消息推送设计
**推送通道**:微信小程序订阅消息(按微信能力,需用户授权)+ 应用内消息中心
@@ -846,6 +912,8 @@
| OTA 失败告警 | 升级任务失败 | 设备编码、失败原因 |
| 巡检逾期告警 | 巡检任务逾期 | 任务名、逾期时长 |
| 自检异常告警 | 设备自检异常 | 设备编码、异常项 |
| **严重告警** 🆕 | 告警级别 = 严重 | 告警 ID、设备编码、告警内容、来源 |
| **数据分发死信告警** 🆕 | 死信累积超阈值 | 设备编码、下游系统、累积条数 |
**用户授权策略**
- 首次登录引导用户一次性授权所有订阅类型
@@ -862,6 +930,7 @@
- 待上报的实施记录
- 待上报的初始化结果
- 已扫码但未提交的 OTA 升级确认
- 告警处置结果(忽略 / 标记已处理的离线提交)
**不缓存的数据**
- 工单列表(实时拉取,避免误派单)
@@ -881,16 +950,18 @@
| 小程序模块 | 小程序动作 | PC 端对应位置 | 数据流向 |
|----------|----------|------------|---------|
| 工作台 | 查看今日待办 | 工作台 → 实时监控大屏(个人视角) | PC → 小程序(只读) |
| 工作台 | 查看今日待办(含待处理告警卡) | 工作台 → 实时监控大屏(个人视角)+ 设备运维 → 告警管理(告警数) | PC → 小程序(只读) |
| 工作台 → 告警列表 | 查看 / 筛选告警 | 设备运维 → 告警管理 | PC → 小程序(只读) |
| 工作台 → 告警详情 | 转工单 / 忽略 / 标记已处理 | 设备运维 → 告警管理 + 工单管理 | 小程序 → PC |
| 工单 | 接单 / 处理 / 关单上报 | 设备运维 → 工单管理 | 小程序 → PC |
| 工单(关单时) | 上报维修记录 | 设备运维 → 设备监测.详情.维修记录 Tab | 小程序 → PC |
| 扫码 → 设备初始化 | 烧录证书 / 预置配置 / 上报 | 设备运维 → 设备初始化 | 小程序 → PC |
| 扫码 → 实施记录上报 | 装机打卡 / 拍照 / 签收 | 设备运维 → 设备监测.详情.实施记录 Tab | 小程序 → PC |
| 扫码 → 维修上报 | 关联工单 + 填写维修信息 | 设备运维 → 设备监测.详情.维修记录 Tab | 小程序 → PC |
| 扫码 → 维修上报(双入口) | 关联工单 / 无工单独立新增维修记录 | 设备运维 → 设备监测.详情.维修记录 Tab + 工单管理(关单) | 小程序 → PC |
| 扫码 → OTA 升级 | 下发 / 监控 / 回滚 | 设备运维 → 设备监测.详情.更新记录 Tab + 版本管理 | 双向 |
| 扫码 → 查看档案 | 只读查看 | 设备资产 → 设备管理 + 设备运维 → 设备监测.详情页(多 Tab 聚合) | PC → 小程序(只读) |
| 扫码 → 设备监测 | 只读查看设备多视角(概览/日志/告警/维修/更新) | 设备资产 → 设备管理 + 设备运维 → 设备监测.详情页(多 Tab 聚合) | PC → 小程序(只读) |
| 巡检 | 打卡 / 填报 / 异常转工单 | 设备运维 → 设备监测.详情.巡检记录 Tab | 小程序 → PC |
| 我的 → 消息中心 | 接收派单 / 告警 | 设备运维 → 告警管理(告警源) | PC → 小程序 |
| 我的 → 消息中心 | 接收派单 / 告警推送 | 设备运维 → 告警管理(告警源)+ 工单管理(派单) | PC → 小程序 |
### 8.8 关键设计原则
@@ -900,3 +971,5 @@
4. **不做管理类操作**:订单 / 租约 / 客户 / 系统配置等管理类功能不进小程序,保持现场作业专注
5. **PC 端可替代**:所有小程序操作在 PC 端也能完成(用于复核 / 补录 / 数据修正),小程序不是唯一入口
6. **权限隔离**:仅实施运维工程师角色可登录小程序,管理员 / 客户经理 / 销售等角色无小程序权限
7. **告警与工单互补**:告警是被动接收的异常事件视图(在工作台入口),工单是主动处置的流转中心;告警可一键转工单衔接流转,避免在两个域间重复操作
8. **设备监测只读简化**:小程序端不做全量历史台账查看(实施/自检/巡检记录 3 Tab 不放),只保留运维现场最高频的 5 个视角(概览/运行日志/告警/维修记录/更新记录),完整版在 PC 端
+70
View File
@@ -951,6 +951,18 @@ export const miniprograms: NavMiniprogram[] = [
component: () => import('@pages/miniprogram/ops/workbench/Home.vue'),
status: 'draft',
},
subPages: [
{
key: 'mp-ops-alarm-list', label: '告警列表', path: 'alarm-list',
component: () => import('@pages/miniprogram/ops/workbench/alarmList.vue'),
status: 'draft',
},
{
key: 'mp-ops-alarm-detail', label: '告警详情', path: 'alarm-detail/:id',
component: () => import('@pages/miniprogram/ops/workbench/alarmDetail.vue'),
status: 'draft',
},
],
},
{
key: 'work-order',
@@ -961,6 +973,18 @@ export const miniprograms: NavMiniprogram[] = [
component: () => import('@pages/miniprogram/ops/workOrder/Home.vue'),
status: 'draft',
},
subPages: [
{
key: 'mp-ops-work-order-detail', label: '工单详情', path: 'work-order-detail/:id',
component: () => import('@pages/miniprogram/ops/workOrder/detail.vue'),
status: 'draft',
},
{
key: 'mp-ops-repair-report', label: '维修上报', path: 'repair-report/:id',
component: () => import('@pages/miniprogram/ops/workOrder/repairReport.vue'),
status: 'draft',
},
],
},
{
key: 'scan',
@@ -971,6 +995,38 @@ export const miniprograms: NavMiniprogram[] = [
component: () => import('@pages/miniprogram/ops/scan/Home.vue'),
status: 'draft',
},
subPages: [
{
key: 'mp-ops-scan-scene-menu', label: '场景操作菜单', path: 'scene-menu/:deviceId',
component: () => import('@pages/miniprogram/ops/scan/sceneMenu.vue'),
status: 'draft',
},
{
key: 'mp-ops-scan-init', label: '设备初始化', path: 'init/:deviceId',
component: () => import('@pages/miniprogram/ops/scan/init.vue'),
status: 'draft',
},
{
key: 'mp-ops-scan-impl-record', label: '实施记录上报', path: 'impl-record/:deviceId',
component: () => import('@pages/miniprogram/ops/scan/implRecord.vue'),
status: 'draft',
},
{
key: 'mp-ops-scan-repair', label: '维修上报', path: 'repair/:deviceId',
component: () => import('@pages/miniprogram/ops/scan/repair.vue'),
status: 'draft',
},
{
key: 'mp-ops-scan-ota', label: 'OTA 升级', path: 'ota/:deviceId',
component: () => import('@pages/miniprogram/ops/scan/ota.vue'),
status: 'draft',
},
{
key: 'mp-ops-scan-monitor', label: '设备监测', path: 'monitor/:deviceId',
component: () => import('@pages/miniprogram/ops/scan/monitor.vue'),
status: 'draft',
},
],
},
{
key: 'inspect',
@@ -981,6 +1037,13 @@ export const miniprograms: NavMiniprogram[] = [
component: () => import('@pages/miniprogram/ops/inspect/Home.vue'),
status: 'draft',
},
subPages: [
{
key: 'mp-ops-inspect-detail', label: '巡检任务详情', path: 'inspect-detail/:id',
component: () => import('@pages/miniprogram/ops/inspect/detail.vue'),
status: 'draft',
},
],
},
{
key: 'me',
@@ -991,6 +1054,13 @@ export const miniprograms: NavMiniprogram[] = [
component: () => import('@pages/miniprogram/ops/me/Home.vue'),
status: 'draft',
},
subPages: [
{
key: 'mp-ops-message-center', label: '消息中心', path: 'message-center',
component: () => import('@pages/miniprogram/ops/me/messageCenter.vue'),
status: 'draft',
},
],
},
],
},
+171 -7
View File
@@ -1,12 +1,176 @@
<template>
<AppPlaceholder
title="实施运维小程序 - 巡检"
name="miniprogram/ops/inspect"
>
占位巡检计划列表 + 巡检任务详情扫码打卡 / 逐项填报 / 异常转工单详见 src/docs/device/设备管理服务器功能现状.md §8.4.4
</AppPlaceholder>
<div class="inspect-home">
<van-nav-bar title="巡检" />
<van-tabs v-model:active="activeTab" sticky @change="onTabChange">
<van-tab v-for="t in statusTabs" :key="t.value" :name="t.value" :title="t.label" />
</van-tabs>
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
<div
v-for="plan in list"
:key="plan.id"
class="ip-card"
:class="`ip-card--${plan.status}`"
@click="goDetail(plan.id)"
>
<div class="ip-card__header">
<van-tag :type="statusTagType(plan.status)" size="medium">{{ statusText(plan.status) }}</van-tag>
<span class="ip-card__count">{{ plan.deviceCount }} 台设备</span>
</div>
<div class="ip-card__name">{{ plan.name }}</div>
<div class="ip-card__meta">
<van-icon name="location-o" />
<span>{{ plan.site }}</span>
</div>
<div class="ip-card__meta">
<van-icon name="apps-o" />
<span>{{ plan.deviceCategory }}</span>
</div>
<div class="ip-card__footer">
<span class="ip-card__deadline" :class="{ 'ip-card__deadline--over': plan.status === 'overdue' }">
<van-icon name="clock-o" />
截止{{ plan.deadline }}
</span>
<span class="ip-card__inspector">{{ plan.inspector }}</span>
</div>
</div>
</van-list>
</div>
</template>
<script setup lang="ts">
/** 实施运维小程序 巡检 Tab 首页占位 */
/**
* 实施运维小程序 / 巡检计划列表
* - 4 状态分段 Tab(未开始 / 进行中 / 已完成 / 已逾期)
* - 列表项点击进入任务详情
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listInspectPlans } from './api'
import type { InspectPlan, InspectPlanStatus } from './types'
const router = useRouter()
const list = ref<InspectPlan[]>([])
const loading = ref(false)
const finished = ref(true)
const activeTab = ref<InspectPlanStatus | ''>('')
const statusTabs: Array<{ label: string; value: InspectPlanStatus | '' }> = [
{ label: '全部', value: '' },
{ label: '未开始', value: 'todo' },
{ label: '进行中', value: 'doing' },
{ label: '已完成', value: 'done' },
{ label: '已逾期', value: 'overdue' },
]
const statusText = (s: InspectPlanStatus): string => ({
todo: '未开始', doing: '进行中', done: '已完成', overdue: '已逾期',
})[s]
const statusTagType = (s: InspectPlanStatus): 'default' | 'primary' | 'success' | 'danger' =>
s === 'todo' ? 'default' : s === 'doing' ? 'primary' : s === 'done' ? 'success' : 'danger'
const goDetail = (id: string): void => {
router.push(`/miniprogram/ops/inspect-detail/${id}`)
}
const loadList = (): void => {
listInspectPlans({ status: activeTab.value })
.then((res) => {
if (res.code === '00000') list.value = res.data
})
.catch(() => undefined)
.finally(() => {
loading.value = false
finished.value = true
})
}
const onTabChange = (): void => {
list.value = []
loading.value = true
finished.value = false
loadList()
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="less">
.inspect-home {
min-height: 100%;
background: var(--background);
padding-bottom: 16px;
}
.ip-card {
margin: 8px 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
border-left: 3px solid var(--primary);
&--overdue { border-left-color: var(--danger); }
&--done { border-left-color: var(--success); }
&--todo { border-left-color: var(--text-placeholder); }
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
&__count {
font-size: var(--font-xs);
color: var(--text-secondary);
}
&__name {
font-size: var(--font-base);
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
margin-bottom: 8px;
}
&__meta {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-xs);
color: var(--text-placeholder);
margin-bottom: 4px;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px dashed var(--border);
padding-top: 6px;
margin-top: 6px;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__deadline {
display: flex;
align-items: center;
gap: 4px;
&--over {
color: var(--danger);
}
}
&__inspector {
color: var(--text-secondary);
}
}
</style>
+29
View File
@@ -0,0 +1,29 @@
/**
* 实施运维小程序 / 巡检 接口
*/
import { getRequest, postRequest } from '@axios'
import type { InspectItemSubmit, InspectPlan, InspectPlanStatus, InspectTaskDetail } from './types'
/** 巡检计划列表 */
export const listInspectPlans = (
params: { status?: InspectPlanStatus | '' },
): Promise<{ code: string; msg: string; data: InspectPlan[] }> =>
postRequest('axiosRequest', '/mp/ops/inspect/list', params)
/** 巡检任务详情 */
export const getInspectDetail = (
id: string,
): Promise<{ code: string; msg: string; data: InspectTaskDetail }> =>
getRequest('axiosRequest', '/mp/ops/inspect/detail', { id })
/** 提交巡检项结果 */
export const submitInspectItem = (
params: InspectItemSubmit,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/inspect/submit', params)
/** 完成巡检任务(提交全部结果) */
export const finishInspectTask = (
planId: string,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/inspect/finish', { planId })
@@ -0,0 +1,281 @@
<template>
<div v-if="detail" class="inspect-detail">
<van-nav-bar :title="detail.plan.name" left-arrow @click-left="onBack" />
<!-- 计划头部 -->
<div class="inspect-detail__header">
<van-tag :type="statusTagType(detail.plan.status)">{{ statusText(detail.plan.status) }}</van-tag>
<span class="inspect-detail__count">{{ detail.devices.length }} 台设备 / {{ detail.plan.itemCount }} </span>
</div>
<div class="inspect-detail__meta">
<van-icon name="location-o" />
<span>{{ detail.plan.site }}</span>
<span class="inspect-detail__deadline">截止{{ detail.plan.deadline }}</span>
</div>
<!-- 设备切换 -->
<van-tabs v-model:active="activeDeviceIdx" sticky>
<van-tab
v-for="(d, idx) in detail.devices"
:key="d.code"
:name="idx"
:title="`#${idx + 1} ${d.name}`"
/>
</van-tabs>
<!-- 当前设备巡检项 -->
<div v-if="currentDevice" class="inspect-detail__items">
<div class="inspect-detail__device-info">
<div>
<span class="inspect-detail__device-code">{{ currentDevice.code }}</span>
<van-tag v-if="currentDevice.scanned" type="success" size="small">已扫码</van-tag>
<van-button v-else size="mini" type="primary" plain @click="onScanDevice">扫码打卡</van-button>
</div>
</div>
<div
v-for="item in currentDevice.items"
:key="item.id"
class="inspect-item"
:class="`inspect-item--${item.result}`"
>
<div class="inspect-item__header">
<van-tag plain :type="itemTypeTagType(item.type)" size="small">{{ itemTypeText(item.type) }}</van-tag>
<span class="inspect-item__name">{{ item.name }}</span>
</div>
<div class="inspect-item__standard">标准{{ item.standard }}</div>
<div v-if="item.result !== 'pending'" class="inspect-item__remark">
{{ item.remark || '无备注' }}
</div>
<div v-else class="inspect-item__actions">
<van-button size="small" type="success" plain @click="onSubmitItem(item, 'pass')">合格</van-button>
<van-button size="small" type="danger" plain @click="onSubmitItem(item, 'abnormal')">异常</van-button>
</div>
</div>
</div>
<!-- 底部完成按钮 -->
<div class="inspect-detail__actions">
<van-button block type="primary" @click="onFinish">完成巡检任务</van-button>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 巡检任务详情
* - 设备切换 Tab + 逐项填报(合格 / 异常)
* - 异常项自动转工单(mock 接口返回提示)
* - 完成任务按钮
*/
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showFailToast } from 'vant'
import { finishInspectTask, getInspectDetail, submitInspectItem } from './api'
import type { InspectItem, InspectItemType, InspectPlanStatus, InspectTaskDetail } from './types'
const route = useRoute()
const router = useRouter()
const detail = ref<InspectTaskDetail | null>(null)
const activeDeviceIdx = ref(0)
const currentDevice = computed(() => detail.value?.devices[activeDeviceIdx.value] ?? null)
const statusText = (s: InspectPlanStatus): string => ({
todo: '未开始', doing: '进行中', done: '已完成', overdue: '已逾期',
})[s]
const statusTagType = (s: InspectPlanStatus): 'default' | 'primary' | 'success' | 'danger' =>
s === 'todo' ? 'default' : s === 'doing' ? 'primary' : s === 'done' ? 'success' : 'danger'
const itemTypeText = (t: InspectItemType): string => ({
appearance: '外观',
function: '功能',
network: '网络',
sensor: '传感器',
firmware: '固件',
})[t]
const itemTypeTagType = (t: InspectItemType): 'primary' | 'warning' | 'success' | 'default' =>
t === 'function' ? 'primary' : t === 'sensor' ? 'warning' : t === 'network' ? 'success' : 'default'
const onBack = (): void => {
router.back()
}
const onScanDevice = (): void => {
if (!detail.value) return
const dev = detail.value.devices[activeDeviceIdx.value]
if (dev) dev.scanned = true
showSuccessToast('扫码打卡成功')
}
const onSubmitItem = (item: InspectItem, result: 'pass' | 'abnormal'): void => {
if (!detail.value) return
const dev = detail.value.devices[activeDeviceIdx.value]
if (!dev) return
submitInspectItem({
planId: detail.value.plan.id,
deviceCode: dev.code,
itemId: item.id,
result,
remark: result === 'abnormal' ? '需现场处置' : '合格',
})
.then((res) => {
if (res.code === '00000') {
item.result = result
item.remark = result === 'abnormal' ? '已转工单' : '合格'
if (result === 'abnormal') {
showFailToast('已记录异常并自动转工单')
} else {
showSuccessToast('已记录合格')
}
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const onFinish = (): void => {
if (!detail.value) return
// 校验所有项已填报
const pending = detail.value.devices.flatMap((d) => d.items).filter((i) => i.result === 'pending')
if (pending.length > 0) {
showFailToast(`还有 ${pending.length} 项未填报`)
return
}
finishInspectTask(detail.value.plan.id)
.then((res) => {
if (res.code === '00000') {
showSuccessToast('巡检任务已完成')
router.back()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadDetail = (): void => {
const id = String(route.params.id ?? '')
getInspectDetail(id)
.then((res) => {
if (res.code === '00000' && res.data) detail.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadDetail()
})
</script>
<style scoped lang="less">
.inspect-detail {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
}
&__count {
flex: 1;
font-size: var(--font-xs);
color: var(--text-secondary);
}
&__meta {
display: flex;
align-items: center;
gap: 6px;
padding: 0 16px 12px;
background: #fff;
font-size: var(--font-xs);
color: var(--text-placeholder);
border-bottom: 1px solid var(--border);
}
&__deadline {
margin-left: auto;
}
&__items {
padding: 8px 12px;
}
&__device-info {
background: #fff;
border-radius: var(--radius-md);
padding: 8px 12px;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-xs);
}
&__device-code {
font-family: monospace;
color: var(--primary);
margin-right: 8px;
}
&__actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px env(safe-area-inset-bottom);
background: #fff;
border-top: 1px solid var(--border);
}
}
.inspect-item {
background: #fff;
border-radius: var(--radius-md);
padding: 12px;
margin-bottom: 8px;
border-left: 3px solid var(--text-placeholder);
&--pass { border-left-color: var(--success); background: #f6ffed; }
&--abnormal { border-left-color: var(--danger); background: #fff1f0; }
&--na { border-left-color: var(--text-placeholder); }
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
&__name {
flex: 1;
font-size: var(--font-sm);
color: var(--text-primary);
}
&__standard {
font-size: var(--font-xs);
color: var(--text-placeholder);
margin-bottom: 8px;
}
&__remark {
font-size: var(--font-xs);
color: var(--text-secondary);
}
&__actions {
display: flex;
gap: 8px;
}
}
</style>
@@ -0,0 +1,55 @@
/**
* 实施运维小程序 / 巡检 类型定义
*/
/** 巡检计划状态 */
export type InspectPlanStatus = 'todo' | 'doing' | 'done' | 'overdue'
/** 巡检项类型 */
export type InspectItemType = 'appearance' | 'function' | 'network' | 'sensor' | 'firmware'
/** 巡检项结果 */
export type InspectItemResult = 'pending' | 'pass' | 'abnormal' | 'na'
/** 巡检计划 */
export interface InspectPlan {
id: string
name: string
site: string
deviceCategory: string
deviceCount: number
status: InspectPlanStatus
deadline: string
inspector: string
itemCount: number
}
/** 巡检项 */
export interface InspectItem {
id: string
name: string
type: InspectItemType
standard: string
result: InspectItemResult
remark: string
}
/** 巡检任务详情 */
export interface InspectTaskDetail {
plan: InspectPlan
devices: Array<{
code: string
name: string
items: InspectItem[]
scanned: boolean
}>
}
/** 巡检项提交参数 */
export interface InspectItemSubmit {
planId: string
deviceCode: string
itemId: string
result: 'pass' | 'abnormal'
remark: string
}
+299 -7
View File
@@ -1,12 +1,304 @@
<template>
<AppPlaceholder
title="实施运维小程序 - 我的"
name="miniprogram/ops/me"
>
占位个人统计 / 消息中心 / 离线缓存设置 / 设置详见 src/docs/device/设备管理服务器功能现状.md §8.4.5
</AppPlaceholder>
<div class="me-home">
<van-nav-bar title="我的" />
<!-- 用户卡 -->
<div class="me-home__user">
<van-icon name="user-o" class="me-home__avatar" />
<div class="me-home__user-info">
<div class="me-home__name">李工程师</div>
<div class="me-home__role">实施运维工程师</div>
</div>
<van-icon name="setting-o" class="me-home__setting" @click="onSetting" />
</div>
<!-- 本月统计 -->
<div v-if="stats" class="me-home__section">
<div class="me-home__section-title">本月统计</div>
<div class="me-home__stats-grid">
<div class="me-stat">
<div class="me-stat__value">{{ stats.monthOrders }}</div>
<div class="me-stat__label">工单处理</div>
</div>
<div class="me-stat">
<div class="me-stat__value">{{ stats.monthInspections }}</div>
<div class="me-stat__label">巡检完成</div>
</div>
<div class="me-stat">
<div class="me-stat__value">{{ stats.monthInstalls }}</div>
<div class="me-stat__label">装机</div>
</div>
<div class="me-stat">
<div class="me-stat__value">{{ stats.monthOta }}</div>
<div class="me-stat__label">OTA 升级</div>
</div>
</div>
<div class="me-home__stats-extra">
<div>平均处理时长<b>{{ stats.avgProcessHours }}h</b></div>
<div>SLA 达成率<b>{{ stats.slaRate }}</b></div>
</div>
</div>
<!-- 功能入口 -->
<div class="me-home__section">
<van-cell-group inset>
<van-cell
title="消息中心"
icon="chat-o"
is-link
:value="unreadCount ? `${unreadCount}` : ''"
@click="goMessages"
/>
<van-cell title="离线缓存" icon="records-o" is-link @click="showOffline = true" />
<van-cell title="账号信息" icon="user-circle-o" is-link @click="onSetting" />
<van-cell title="修改密码" icon="closed-eye" is-link @click="onSetting" />
<van-cell title="退出登录" icon="revoke" is-link @click="onLogout" />
</van-cell-group>
</div>
<!-- 离线缓存弹层 -->
<van-popup v-model:show="showOffline" position="bottom" round :style="{ height: '40%' }">
<div class="me-home__offline">
<div class="me-home__offline-title">离线缓存</div>
<van-cell-group inset>
<van-cell title="启用离线缓存">
<template #right-icon>
<van-switch v-model="offline.enabled" />
</template>
</van-cell>
<van-cell title="缓存占用" :value="offline.size" />
<van-cell title="待上传任务" :value="`${offline.pendingCount} 条`" />
<van-cell title="最近同步" :value="offline.lastSyncTime" />
</van-cell-group>
<div class="me-home__offline-actions">
<van-button block type="primary" @click="onSync">手动同步</van-button>
<van-button block type="danger" plain @click="onClear">清除缓存</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
/** 实施运维小程序 我的 Tab 首页占位 */
/**
* 实施运维小程序 / 我的首页
* - 用户卡 + 本月统计 4 卡
* - 功能入口:消息中心 / 离线缓存 / 账号 / 修改密码 / 退出
* - 离线缓存弹层
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast, showFailToast } from 'vant'
import {
clearOffline,
getOfflineStatus,
getPersonalStats,
listMessages,
syncOffline,
} from './api'
import type { OfflineStatus, PersonalStats } from './types'
const router = useRouter()
const stats = ref<PersonalStats | null>(null)
const offline = reactive<OfflineStatus>({
enabled: true,
size: '—',
pendingCount: 0,
lastSyncTime: '—',
})
const showOffline = ref(false)
const unreadCount = ref(0)
const goMessages = (): void => {
router.push('/miniprogram/ops/message-center')
}
const onSetting = (): void => {
showFailToast('原型阶段未实现')
}
const onLogout = (): void => {
showFailToast('原型阶段未实现')
}
const onSync = (): void => {
syncOffline()
.then((res) => {
if (res.code === '00000') {
showSuccessToast('同步完成')
loadOffline()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const onClear = (): void => {
clearOffline()
.then((res) => {
if (res.code === '00000') {
showSuccessToast('清除完成')
loadOffline()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadStats = (): void => {
getPersonalStats()
.then((res) => {
if (res.code === '00000') stats.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadOffline = (): void => {
getOfflineStatus()
.then((res) => {
if (res.code === '00000') {
Object.assign(offline, res.data)
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadUnread = (): void => {
listMessages({})
.then((res) => {
if (res.code === '00000') {
unreadCount.value = res.data.filter((m) => !m.read).length
}
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadStats()
loadOffline()
loadUnread()
})
</script>
<style scoped lang="less">
.me-home {
min-height: 100%;
background: var(--background);
padding-bottom: 16px;
&__user {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fff;
margin-bottom: 8px;
}
&__avatar {
font-size: 40px;
color: #fff;
background: var(--primary);
border-radius: 50%;
width: 48px;
height: 48px;
line-height: 48px;
text-align: center;
}
&__user-info {
flex: 1;
}
&__name {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
}
&__role {
font-size: var(--font-sm);
color: var(--text-secondary);
margin-top: 2px;
}
&__setting {
font-size: 22px;
color: var(--text-secondary);
}
&__section {
margin-bottom: 8px;
}
&__section-title {
padding: 12px 16px 6px;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
}
&__stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 0 12px;
}
&__stats-extra {
display: flex;
justify-content: space-around;
padding: 12px 16px;
background: #fff;
margin: 8px 12px 0;
border-radius: var(--radius-md);
font-size: var(--font-xs);
color: var(--text-secondary);
b {
color: var(--primary);
}
}
&__offline {
padding: 16px 0;
}
&__offline-title {
font-size: var(--font-lg);
font-weight: 600;
text-align: center;
padding-bottom: 12px;
}
&__offline-actions {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
}
.me-stat {
background: #fff;
border-radius: var(--radius-md);
padding: 12px 4px;
text-align: center;
&__value {
font-size: 20px;
font-weight: 600;
color: var(--primary);
}
&__label {
margin-top: 2px;
font-size: var(--font-xs);
color: var(--text-secondary);
}
}
</style>
+35
View File
@@ -0,0 +1,35 @@
/**
* 实施运维小程序 / 我的 接口
*/
import { getRequest, postRequest } from '@axios'
import type { MessageItem, MessageType, OfflineStatus, PersonalStats } from './types'
/** 个人统计 */
export const getPersonalStats = (): Promise<{ code: string; msg: string; data: PersonalStats }> =>
postRequest('axiosRequest', '/mp/ops/me/stats')
/** 消息列表 */
export const listMessages = (
params: { type?: MessageType | '' },
): Promise<{ code: string; msg: string; data: MessageItem[] }> =>
postRequest('axiosRequest', '/mp/ops/me/messages', params)
/** 标记消息已读 */
export const readMessage = (id: string): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/me/message-read', { id })
/** 全部已读 */
export const readAllMessages = (): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/me/message-read-all')
/** 离线缓存状态 */
export const getOfflineStatus = (): Promise<{ code: string; msg: string; data: OfflineStatus }> =>
getRequest('axiosRequest', '/mp/ops/me/offline-status')
/** 手动同步离线缓存 */
export const syncOffline = (): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/me/offline-sync')
/** 清除离线缓存 */
export const clearOffline = (): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/me/offline-clear')
@@ -0,0 +1,192 @@
<template>
<div class="msg-center">
<van-nav-bar title="消息中心" left-arrow @click-left="onBack">
<template #right>
<span class="msg-center__read-all" @click="onReadAll">全部已读</span>
</template>
</van-nav-bar>
<van-tabs v-model:active="activeType" sticky @change="onTabChange">
<van-tab v-for="t in typeTabs" :key="t.value" :name="t.value" :title="t.label" />
</van-tabs>
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
<div
v-for="msg in list"
:key="msg.id"
class="msg-card"
:class="{ 'msg-card--unread': !msg.read }"
@click="onRead(msg)"
>
<div class="msg-card__header">
<van-tag :type="typeTagType(msg.type)" size="medium">{{ typeText(msg.type) }}</van-tag>
<span class="msg-card__time">{{ msg.createdAt }}</span>
<span v-if="!msg.read" class="msg-card__dot" />
</div>
<div class="msg-card__title">{{ msg.title }}</div>
<div class="msg-card__content">{{ msg.content }}</div>
</div>
</van-list>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 消息中心
* - 7 类消息筛选 Tab
* - 全部已读
* - 点击消息标记已读 + 跳转对应详情
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { listMessages, readAllMessages, readMessage } from './api'
import type { MessageItem, MessageType } from './types'
const router = useRouter()
const list = ref<MessageItem[]>([])
const loading = ref(false)
const finished = ref(true)
const activeType = ref<MessageType | ''>('')
const typeTabs: Array<{ label: string; value: MessageType | '' }> = [
{ label: '全部', value: '' },
{ label: '工单', value: 'workOrder' },
{ label: '巡检', value: 'inspect' },
{ label: 'OTA', value: 'ota' },
{ label: '严重告警', value: 'alarmCritical' },
{ label: '死信', value: 'alarmDead' },
{ label: '超 SLA', value: 'overSla' },
{ label: '公告', value: 'system' },
]
const typeText = (t: MessageType): string => ({
workOrder: '工单',
inspect: '巡检',
ota: 'OTA',
alarmCritical: '严重',
alarmDead: '死信',
overSla: '超SLA',
system: '公告',
})[t]
const typeTagType = (t: MessageType): 'danger' | 'primary' | 'warning' | 'default' =>
t === 'alarmCritical' || t === 'overSla' ? 'danger'
: t === 'alarmDead' ? 'warning'
: t === 'system' ? 'default'
: 'primary'
const onBack = (): void => {
router.back()
}
const onTabChange = (): void => {
list.value = []
loading.value = true
finished.value = false
loadList()
}
const onRead = (msg: MessageItem): void => {
if (!msg.read) {
readMessage(msg.id)
.then((res) => {
if (res.code === '00000') msg.read = true
})
.catch(() => undefined)
.finally(() => undefined)
}
if (msg.link) {
router.push(msg.link)
}
}
const onReadAll = (): void => {
readAllMessages()
.then((res) => {
if (res.code === '00000') {
showSuccessToast('已全部已读')
list.value.forEach((m) => { m.read = true })
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadList = (): void => {
listMessages({ type: activeType.value })
.then((res) => {
if (res.code === '00000') list.value = res.data
})
.catch(() => undefined)
.finally(() => {
loading.value = false
finished.value = true
})
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="less">
.msg-center {
min-height: 100%;
background: var(--background);
padding-bottom: 16px;
&__read-all {
font-size: var(--font-sm);
color: var(--primary);
}
}
.msg-card {
margin: 8px 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
position: relative;
&--unread {
border-left: 3px solid var(--primary);
}
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
&__time {
flex: 1;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
&__title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
line-height: 1.4;
}
&__content {
font-size: var(--font-xs);
color: var(--text-secondary);
line-height: 1.5;
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
/**
* 实施运维小程序 / 我的 类型定义
*/
/** 个人统计 */
export interface PersonalStats {
monthOrders: number
monthInspections: number
monthInstalls: number
monthOta: number
avgProcessHours: number
slaRate: string
}
/** 消息类型 */
export type MessageType =
| 'workOrder' // 工单派单
| 'inspect' // 巡检下发
| 'ota' // OTA 下发
| 'alarmCritical' // 严重告警
| 'alarmDead' // 死信告警
| 'overSla' // 超 SLA
| 'system' // 系统公告
/** 消息项 */
export interface MessageItem {
id: string
type: MessageType
title: string
content: string
createdAt: string
read: boolean
link?: string
}
/** 离线缓存状态 */
export interface OfflineStatus {
enabled: boolean
size: string
pendingCount: number
lastSyncTime: string
}
+198 -7
View File
@@ -1,12 +1,203 @@
<template>
<AppPlaceholder
title="实施运维小程序 - 扫码"
name="miniprogram/ops/scan"
>
占位全屏取景框 + 历史扫码 + 扫码后场景菜单按设备状态动态过滤详见 src/docs/device/设备管理服务器功能现状.md §8.3 / §8.4.3
</AppPlaceholder>
<div class="scan-home">
<!-- 取景框 -->
<div class="scan-home__view">
<div class="scan-home__frame">
<div class="scan-home__corner scan-home__corner--tl" />
<div class="scan-home__corner scan-home__corner--tr" />
<div class="scan-home__corner scan-home__corner--bl" />
<div class="scan-home__corner scan-home__corner--br" />
<div class="scan-home__line" />
</div>
<div class="scan-home__tip">将设备机身二维码对准框内</div>
<van-button class="scan-home__album" icon="photo-o" plain size="small" @click="onManualInput">
手动输入编码
</van-button>
</div>
<!-- 模拟扫码按钮原型无真扫码能力 -->
<div class="scan-home__sim">
<van-button type="primary" block @click="onSimScan('CAB-SH-001')">模拟扫码 · 净菜柜</van-button>
<van-button type="primary" block @click="onSimScan('WATCH-BJ-014')">模拟扫码 · 健康手表</van-button>
<van-button type="primary" block @click="onSimScan('CAB-NEW-001')">模拟扫码 · 待激活设备</van-button>
</div>
<!-- 历史扫码 -->
<div class="scan-home__history">
<div class="scan-home__history-title">最近扫码</div>
<van-cell
v-for="item in history"
:key="item.id"
:title="item.deviceName"
:label="`${item.deviceCode} · ${item.scannedAt}`"
is-link
@click="onRescan(item.deviceCode)"
>
<template #right-icon>
<van-tag plain type="primary">{{ item.action }}</van-tag>
</template>
</van-cell>
</div>
<!-- 手动输入对话框 -->
<van-dialog
v-model:show="inputShow"
title="手动输入设备编码"
show-cancel-button
@confirm="onInputConfirm"
>
<van-field v-model="inputCode" placeholder="请输入设备编码" />
</van-dialog>
</div>
</template>
<script setup lang="ts">
/** 实施运维小程序 扫码 Tab 首页占位 */
/**
* 实施运维小程序 / 扫码首页
* - 全屏取景框(原型无真扫码,提供模拟按钮)
* - 历史扫码列表
* - 手动输入设备编码兜底
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showFailToast } from 'vant'
import { listScanHistory, scanDevice } from './api'
import type { ScanHistoryItem } from './types'
const router = useRouter()
const history = ref<ScanHistoryItem[]>([])
const inputShow = ref(false)
const inputCode = ref('')
const goSceneMenu = (deviceCode: string): void => {
router.push(`/miniprogram/ops/scene-menu/${deviceCode}`)
}
const onSimScan = (code: string): void => {
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
goSceneMenu(code)
} else {
showFailToast(res.msg)
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const onManualInput = (): void => {
inputCode.value = ''
inputShow.value = true
}
const onInputConfirm = (): void => {
if (!inputCode.value.trim()) return
onSimScan(inputCode.value.trim())
}
const onRescan = (deviceCode: string): void => {
onSimScan(deviceCode)
}
const loadHistory = (): void => {
listScanHistory()
.then((res) => {
if (res.code === '00000') history.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadHistory()
})
</script>
<style scoped lang="less">
.scan-home {
min-height: 100%;
background: #000;
color: #fff;
display: flex;
flex-direction: column;
&__view {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
background: linear-gradient(180deg, #1a1a1a 0%, #000 100%);
}
&__frame {
position: relative;
width: 220px;
height: 220px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
&__corner {
position: absolute;
width: 24px;
height: 24px;
border: 3px solid var(--primary);
&--tl { top: -1px; left: -1px; border-right: none; border-bottom: none; }
&--tr { top: -1px; right: -1px; border-left: none; border-bottom: none; }
&--bl { bottom: -1px; left: -1px; border-right: none; border-top: none; }
&--br { bottom: -1px; right: -1px; border-left: none; border-top: none; }
}
&__line {
position: absolute;
top: 50%;
left: 8px;
right: 8px;
height: 2px;
background: var(--primary);
box-shadow: 0 0 8px var(--primary);
animation: scan-line 2s ease-in-out infinite;
}
@keyframes scan-line {
0%, 100% { transform: translateY(-100px); }
50% { transform: translateY(100px); }
}
&__tip {
margin-top: 24px;
font-size: var(--font-sm);
color: rgba(255, 255, 255, 0.7);
}
&__album {
margin-top: 16px;
:deep(.van-button__text) { color: var(--primary); }
}
&__sim {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
background: var(--background);
}
&__history {
background: #fff;
padding-bottom: 12px;
}
&__history-title {
padding: 12px 16px 6px;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
background: #fff;
}
}
</style>
+72
View File
@@ -0,0 +1,72 @@
/**
* 实施运维小程序 / 扫码 接口
*/
import { getRequest, postRequest } from '@axios'
import type {
DeviceMonitor,
DeviceSummary,
ImplRecordForm,
InitForm,
OtaTask,
OtaVersion,
RepairForm,
ScanHistoryItem,
} from './types'
/** 扫码识别设备(按设备编码查摘要) */
export const scanDevice = (
deviceCode: string,
): Promise<{ code: string; msg: string; data: DeviceSummary | null }> =>
getRequest('axiosRequest', '/mp/ops/scan/scan', { deviceCode })
/** 扫码历史 */
export const listScanHistory = (): Promise<{ code: string; msg: string; data: ScanHistoryItem[] }> =>
getRequest('axiosRequest', '/mp/ops/scan/history')
/** 提交设备初始化 */
export const submitInit = (
params: InitForm,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/scan/init', params)
/** 提交实施记录上报 */
export const submitImplRecord = (
params: ImplRecordForm,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/scan/impl-record', params)
/** 提交维修上报(无工单独立入口) */
export const submitRepair = (
params: RepairForm,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/scan/repair', params)
/** 获取可用 OTA 版本 */
export const listOtaVersions = (
deviceCode: string,
): Promise<{ code: string; msg: string; data: OtaVersion[] }> =>
getRequest('axiosRequest', '/mp/ops/scan/ota-versions', { deviceCode })
/** 下发 OTA 升级任务 */
export const pushOtaTask = (
params: { deviceCode: string; targetVersion: string },
): Promise<{ code: string; msg: string; data: OtaTask }> =>
postRequest('axiosRequest', '/mp/ops/scan/ota-push', params)
/** 查询 OTA 任务进度 */
export const getOtaTask = (
deviceCode: string,
): Promise<{ code: string; msg: string; data: OtaTask | null }> =>
getRequest('axiosRequest', '/mp/ops/scan/ota-task', { deviceCode })
/** OTA 回滚 */
export const rollbackOta = (
deviceCode: string,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/scan/ota-rollback', { deviceCode })
/** 设备监测详情(多 Tab 聚合) */
export const getDeviceMonitor = (
deviceCode: string,
): Promise<{ code: string; msg: string; data: DeviceMonitor | null }> =>
getRequest('axiosRequest', '/mp/ops/scan/monitor', { deviceCode })
@@ -0,0 +1,208 @@
<template>
<div class="impl-record">
<van-nav-bar title="实施记录上报" left-arrow @click-left="onBack" />
<div class="impl-record__device">
<van-icon name="desktop-o" />
<div>
<div class="impl-record__device-name">{{ deviceName }}</div>
<div class="impl-record__device-code">{{ form.deviceCode }}</div>
</div>
</div>
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="implTypeText"
name="implType"
label="实施类型"
placeholder="请选择实施类型"
readonly
is-link
:rules="[{ required: true, message: '请选择实施类型' }]"
@click="showTypePicker = true"
/>
<van-field
v-model="form.beforeStatus"
name="beforeStatus"
label="实施前状态"
placeholder="如:未装机"
:rules="[{ required: true, message: '请填写实施前状态' }]"
/>
<van-field
v-model="form.afterStatus"
name="afterStatus"
label="实施后状态"
placeholder="如:已装机在用"
:rules="[{ required: true, message: '请填写实施后状态' }]"
/>
<van-field name="images" label="实施图片">
<template #input>
<van-uploader v-model="imageFileList" :max-count="9" multiple />
</template>
</van-field>
<van-field
v-model="form.customerSign"
name="customerSign"
label="客户签收"
placeholder="电子签名 / 拍照签收"
readonly
is-link
@click="onSign"
/>
<van-field
v-model="form.remark"
name="remark"
label="备注"
type="textarea"
placeholder="补充说明(可选)"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
<van-field v-model="form.operator" label="实施人" readonly />
<van-field v-model="form.operateTime" label="实施时间" readonly />
</van-cell-group>
<div class="impl-record__submit">
<van-button block type="primary" native-type="submit">提交上报</van-button>
</div>
</van-form>
<van-popup v-model:show="showTypePicker" position="bottom" round>
<van-picker
:columns="typeOpts"
@confirm="onTypeConfirm"
@cancel="showTypePicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 实施记录上报
* - 实施类型:新装 / 迁移 / 拆除 / 复位
* - 实施前后状态对比 + 图片 + 客户签收
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { scanDevice, submitImplRecord } from './api'
import type { ImplRecordForm, ImplType } from './types'
const route = useRoute()
const router = useRouter()
const deviceName = ref('')
const imageFileList = ref<{ url?: string }[]>([])
const showTypePicker = ref(false)
const form = reactive<ImplRecordForm>({
deviceCode: '',
implType: 'install',
beforeStatus: '',
afterStatus: '',
images: [],
customerSign: '',
operator: '当前用户',
operateTime: '',
remark: '',
})
const typeOpts = [
{ text: '新装', value: 'install' },
{ text: '迁移', value: 'move' },
{ text: '拆除', value: 'remove' },
{ text: '复位', value: 'reset' },
]
const implTypeText = computed(() => {
const hit = typeOpts.find((o) => o.value === form.implType)
return hit?.text ?? ''
})
const onBack = (): void => {
router.back()
}
const onTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: ImplType }> }): void => {
form.implType = selectedOptions[0]?.value ?? 'install'
showTypePicker.value = false
}
const onSign = (): void => {
form.customerSign = '已签收(电子签名)'
}
const onSubmit = (): void => {
form.images = imageFileList.value.map((f) => f.url ?? '').filter(Boolean)
submitImplRecord(form)
.then((res) => {
if (res.code === '00000') {
showSuccessToast('实施记录上报成功')
router.back()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadDevice = (): void => {
const code = String(route.params.deviceId ?? '')
form.deviceCode = code
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
deviceName.value = res.data.name
}
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
const now = new Date()
form.operateTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:00`
loadDevice()
})
</script>
<style scoped lang="less">
.impl-record {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__device {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
margin-bottom: 8px;
.van-icon {
font-size: 22px;
color: var(--primary);
}
}
&__device-name {
font-size: var(--font-sm);
color: var(--text-primary);
}
&__device-code {
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__submit {
padding: 16px;
}
}
</style>
+220
View File
@@ -0,0 +1,220 @@
<template>
<div class="scan-init">
<van-nav-bar title="设备初始化" left-arrow @click-left="onBack" />
<div class="scan-init__device">
<van-icon name="desktop-o" />
<div>
<div class="scan-init__device-name">{{ deviceName }}</div>
<div class="scan-init__device-code">{{ form.deviceCode }}</div>
</div>
</div>
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.bindKey"
name="bindKey"
label="绑定密钥"
placeholder="请输入设备绑定密钥"
:rules="[{ required: true, message: '请填写绑定密钥' }]"
/>
<van-field name="certBurned" label="证书烧录">
<template #input>
<van-switch v-model="form.certBurned" />
</template>
</van-field>
<van-field
v-model="form.siteConfig"
name="siteConfig"
label="预置场所"
placeholder="请选择预置场所"
readonly
is-link
:rules="[{ required: true, message: '请选择预置场所' }]"
@click="showSitePicker = true"
/>
<van-field
v-model="form.firmwareBaseline"
name="firmwareBaseline"
label="固件基线版本"
placeholder="请选择固件基线版本"
readonly
is-link
:rules="[{ required: true, message: '请选择固件基线版本' }]"
@click="showFirmwarePicker = true"
/>
<van-field name="result" label="初始化结果">
<template #input>
<van-radio-group v-model="form.result" direction="horizontal">
<van-radio name="success">成功</van-radio>
<van-radio name="fail">失败</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
v-if="form.result === 'fail'"
v-model="form.failReason"
name="failReason"
label="失败原因"
type="textarea"
placeholder="请填写失败原因"
rows="2"
autosize
:rules="[{ required: true, message: '请填写失败原因' }]"
/>
<van-field v-model="form.operator" label="操作人" readonly />
<van-field v-model="form.operateTime" label="操作时间" readonly />
</van-cell-group>
<div class="scan-init__submit">
<van-button block type="primary" native-type="submit">提交上报</van-button>
</div>
</van-form>
<!-- 场所选择 -->
<van-popup v-model:show="showSitePicker" position="bottom" round>
<van-picker
:columns="siteOpts"
@confirm="onSiteConfirm"
@cancel="showSitePicker = false"
/>
</van-popup>
<!-- 固件版本选择 -->
<van-popup v-model:show="showFirmwarePicker" position="bottom" round>
<van-picker
:columns="firmwareOpts"
@confirm="onFirmwareConfirm"
@cancel="showFirmwarePicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 设备初始化
* - 现场激活设备:绑定密钥 / 烧录证书 / 预置场所 / 固件基线
* - 结果上报(成功 / 失败 + 失败原因)
*/
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { scanDevice, submitInit } from './api'
import type { InitForm } from './types'
const route = useRoute()
const router = useRouter()
const deviceName = ref('')
const showSitePicker = ref(false)
const showFirmwarePicker = ref(false)
const form = reactive<InitForm>({
deviceCode: '',
bindKey: '',
certBurned: false,
siteConfig: '',
firmwareBaseline: '',
result: 'success',
failReason: '',
operator: '当前用户',
operateTime: '',
})
const siteOpts = [
{ text: '上海浦东 · 张江食堂', value: '上海浦东 · 张江食堂' },
{ text: '北京朝阳 · 朝阳体检站', value: '北京朝阳 · 朝阳体检站' },
{ text: '广州天河 · 天银食堂', value: '广州天河 · 天银食堂' },
]
const firmwareOpts = [
{ text: 'V2.3.1(稳定版)', value: 'V2.3.1' },
{ text: 'V2.4.0(最新版)', value: 'V2.4.0' },
]
const onBack = (): void => {
router.back()
}
const onSiteConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
form.siteConfig = selectedOptions[0]?.value ?? ''
showSitePicker.value = false
}
const onFirmwareConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
form.firmwareBaseline = selectedOptions[0]?.value ?? ''
showFirmwarePicker.value = false
}
const onSubmit = (): void => {
submitInit(form)
.then((res) => {
if (res.code === '00000') {
showSuccessToast('初始化上报成功')
router.back()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadDevice = (): void => {
const code = String(route.params.deviceId ?? '')
form.deviceCode = code
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
deviceName.value = res.data.name
form.siteConfig = res.data.site
}
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
const now = new Date()
form.operateTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:00`
loadDevice()
})
</script>
<style scoped lang="less">
.scan-init {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__device {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
margin-bottom: 8px;
.van-icon {
font-size: 22px;
color: var(--primary);
}
}
&__device-name {
font-size: var(--font-sm);
color: var(--text-primary);
}
&__device-code {
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__submit {
padding: 16px;
}
}
</style>
+219
View File
@@ -0,0 +1,219 @@
<template>
<div v-if="monitor" class="scan-monitor">
<van-nav-bar title="设备监测" left-arrow @click-left="onBack" />
<!-- 设备头部 -->
<div class="scan-monitor__header">
<div class="scan-monitor__name">{{ monitor.summary.name }}</div>
<div class="scan-monitor__code">{{ monitor.summary.code }}</div>
<van-tag :type="statusTagType(monitor.summary.status)">{{ statusText(monitor.summary.status) }}</van-tag>
</div>
<van-tabs v-model:active="activeTab" sticky>
<van-tab v-for="t in tabs" :key="t.key" :name="t.key" :title="t.label" :badge="t.badge" />
</van-tabs>
<!-- 概览 -->
<div v-if="activeTab === 'overview'" class="scan-monitor__tab">
<van-cell-group inset>
<van-cell title="型号" :value="monitor.summary.model" />
<van-cell title="所属场所" :value="monitor.summary.site" />
<van-cell title="所属租户" :value="monitor.summary.tenant" />
<van-cell title="固件版本" :value="monitor.summary.firmwareVersion" />
<van-cell title="最近心跳" :value="monitor.summary.lastHeartbeat" />
<van-cell title="是否 IOT 设备" :value="monitor.summary.isIot ? '是' : '否'" />
<van-cell title="数据分发" :value="monitor.summary.dataDistributeEnabled ? '已启用' : '未启用'" />
</van-cell-group>
</div>
<!-- 运行日志 -->
<div v-if="activeTab === 'runLog'" class="scan-monitor__tab">
<van-cell-group inset>
<van-cell
v-for="log in monitor.runLogs"
:key="log.id"
:title="log.event"
:label="log.time"
>
<template #right-icon>
<van-tag :type="logTagType(log.level)" size="small">{{ logLevelText(log.level) }}</van-tag>
</template>
</van-cell>
</van-cell-group>
</div>
<!-- 告警 -->
<div v-if="activeTab === 'alarm'" class="scan-monitor__tab">
<div v-if="!monitor.alarms.length" class="scan-monitor__empty">暂无告警</div>
<van-cell-group inset>
<van-cell
v-for="a in monitor.alarms"
:key="a.id"
:title="a.content"
:label="a.triggerTime"
is-link
@click="goAlarm"
>
<template #right-icon>
<van-tag :type="a.level === 'critical' ? 'danger' : 'warning'" size="small">
{{ a.level === 'critical' ? '严重' : a.level === 'warn' ? '警告' : '提示' }}
</van-tag>
</template>
</van-cell>
</van-cell-group>
</div>
<!-- 维修记录 -->
<div v-if="activeTab === 'repair'" class="scan-monitor__tab">
<div v-if="!monitor.repairs.length" class="scan-monitor__empty">暂无维修记录</div>
<van-cell-group inset>
<van-cell
v-for="r in monitor.repairs"
:key="r.id"
:title="r.faultType"
:label="`${r.time} · ${r.repairman} · 工单 ${r.workOrderId}`"
>
<template #value>
<span class="scan-monitor__repair-method">{{ r.repairMethod }}</span>
</template>
</van-cell>
</van-cell-group>
</div>
<!-- 更新记录 -->
<div v-if="activeTab === 'update'" class="scan-monitor__tab">
<div v-if="!monitor.updates.length" class="scan-monitor__empty">暂无更新记录</div>
<van-cell-group inset>
<van-cell
v-for="u in monitor.updates"
:key="u.id"
:title="`${u.fromVersion} → ${u.toVersion}`"
:label="`${u.time} · ${u.operator}`"
>
<template #right-icon>
<van-tag :type="updateTagType(u.result)" size="small">{{ updateResultText(u.result) }}</van-tag>
</template>
</van-cell>
</van-cell-group>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 设备监测(多 Tab 简化版)
* - Tab 1 概览:设备基础卡
* - Tab 2 运行日志:最近 N 条运行埋点
* - Tab 3 告警:该设备当前告警
* - Tab 4 维修记录:历史维修档案
* - Tab 5 更新记录:OTA 升级历史
*/
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getDeviceMonitor } from './api'
import type { DeviceMonitor, DeviceStatus, RunLogItem } from './types'
const route = useRoute()
const router = useRouter()
const monitor = ref<DeviceMonitor | null>(null)
const activeTab = ref<'overview' | 'runLog' | 'alarm' | 'repair' | 'update'>('overview')
const tabs = computed(() => {
if (!monitor.value) return []
return [
{ key: 'overview', label: '概览', badge: '' },
{ key: 'runLog', label: '运行日志', badge: '' },
{ key: 'alarm', label: '告警', badge: monitor.value.alarms.length || '' },
{ key: 'repair', label: '维修记录', badge: '' },
{ key: 'update', label: '更新记录', badge: '' },
]
})
const statusText = (s: DeviceStatus): string => ({
inactive: '未激活',
activated: '已激活',
inUse: '在用',
scrapped: '已报废',
})[s]
const statusTagType = (s: DeviceStatus): 'primary' | 'warning' | 'success' | 'default' =>
s === 'inUse' ? 'success' : s === 'activated' ? 'primary' : s === 'inactive' ? 'warning' : 'default'
const logLevelText = (lv: RunLogItem['level']): string => ({ info: '信息', warn: '警告', error: '错误' })[lv]
const logTagType = (lv: RunLogItem['level']): 'primary' | 'warning' | 'danger' =>
lv === 'error' ? 'danger' : lv === 'warn' ? 'warning' : 'primary'
const updateResultText = (r: 'success' | 'failed' | 'rolledback'): string =>
({ success: '成功', failed: '失败', rolledback: '已回滚' })[r]
const updateTagType = (r: 'success' | 'failed' | 'rolledback'): 'success' | 'danger' | 'warning' =>
r === 'success' ? 'success' : r === 'failed' ? 'danger' : 'warning'
const onBack = (): void => {
router.back()
}
const goAlarm = (): void => {
router.push('/miniprogram/ops/alarm-list')
}
const loadMonitor = (): void => {
const code = String(route.params.deviceId ?? '')
getDeviceMonitor(code)
.then((res) => {
if (res.code === '00000') monitor.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadMonitor()
})
</script>
<style scoped lang="less">
.scan-monitor {
min-height: 100%;
background: var(--background);
padding-bottom: 16px;
&__header {
background: #fff;
padding: 12px 16px;
text-align: center;
border-bottom: 1px solid var(--border);
}
&__name {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
}
&__code {
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
margin: 4px 0 8px;
}
&__tab {
padding-top: 8px;
}
&__empty {
padding: 32px 16px;
text-align: center;
color: var(--text-placeholder);
font-size: var(--font-sm);
}
&__repair-method {
font-size: var(--font-xs);
color: var(--text-secondary);
}
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<template>
<div class="scan-ota">
<van-nav-bar title="OTA 升级" left-arrow @click-left="onBack" />
<div class="scan-ota__device">
<van-icon name="desktop-o" />
<div class="scan-ota__device-main">
<div class="scan-ota__device-name">{{ deviceName }}</div>
<div class="scan-ota__device-code">{{ deviceCode }}</div>
<div class="scan-ota__device-version">当前版本{{ currentVersion }}</div>
</div>
</div>
<!-- 升级中状态 -->
<div v-if="task && task.status === 'upgrading'" class="scan-ota__progress">
<div class="scan-ota__progress-title">升级中</div>
<van-progress :percentage="task.progress" stroke-width="8" />
<div class="scan-ota__progress-info">
{{ task.currentVersion }} {{ task.targetVersion }}
</div>
<van-button block type="default" class="scan-ota__refresh" @click="refreshProgress">刷新进度</van-button>
</div>
<!-- 升级完成 / 失败 / 回滚状态 -->
<div v-else-if="task" class="scan-ota__result">
<van-icon
:name="resultIcon"
:class="`scan-ota__result-icon scan-ota__result-icon--${task.status}`"
/>
<div class="scan-ota__result-text">{{ resultText }}</div>
<div v-if="task.status === 'failed'" class="scan-ota__result-reason">{{ task.failReason || '未知原因' }}</div>
<div class="scan-ota__result-actions">
<van-button v-if="task.status === 'failed'" block type="warning" @click="onRollback">回滚</van-button>
<van-button v-if="task.status === 'success'" block type="primary" @click="onBack">完成</van-button>
</div>
</div>
<!-- 选版本下发 -->
<div v-else class="scan-ota__select">
<div class="scan-ota__select-title">可用版本</div>
<van-radio-group v-model="targetVersion">
<van-cell-group inset>
<van-cell
v-for="(v, idx) in versions"
:key="idx"
:title="v.version"
:label="v.releaseNote"
clickable
@click="targetVersion = v.version"
>
<template #right-icon>
<van-tag :type="versionTagType(v.status)" size="medium">{{ versionStatusText(v.status) }}</van-tag>
<van-radio :name="v.version" style="margin-left: 8px" />
</template>
<template #value>
<span class="scan-ota__version-size">{{ v.size }}</span>
</template>
</van-cell>
</van-cell-group>
</van-radio-group>
<div class="scan-ota__select-actions">
<van-button block type="primary" :disabled="!targetVersion" @click="onPush">下发升级任务</van-button>
</div>
</div>
<van-dialog v-model:show="rollbackConfirm" title="确认回滚" show-cancel-button @confirm="doRollback">
<div class="scan-ota__rollback-tip">将回滚到 {{ task?.currentVersion }}</div>
</van-dialog>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / OTA 升级
* - 流程:选目标版本 → 下发任务 → 监控进度 → 结果(成功 / 失败 / 已回滚)→ 失败可回滚
*/
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showFailToast } from 'vant'
import {
getOtaTask,
listOtaVersions,
pushOtaTask,
rollbackOta,
scanDevice,
} from './api'
import type { OtaTask, OtaVersion } from './types'
const route = useRoute()
const router = useRouter()
const deviceCode = ref('')
const deviceName = ref('')
const currentVersion = ref('—')
const versions = ref<OtaVersion[]>([])
const targetVersion = ref('')
const task = ref<OtaTask | null>(null)
const rollbackConfirm = ref(false)
const resultIcon = computed(() => {
if (!task.value) return ''
return task.value.status === 'success' ? 'checked' : task.value.status === 'failed' ? 'close' : 'replay'
})
const resultText = computed(() => {
if (!task.value) return ''
return task.value.status === 'success' ? '升级成功' : task.value.status === 'failed' ? '升级失败' : '已回滚'
})
const versionTagType = (s: OtaVersion['status']): 'primary' | 'warning' | 'default' =>
s === 'full' ? 'primary' : s === 'gray' ? 'warning' : 'default'
const versionStatusText = (s: OtaVersion['status']): string =>
({ full: '全量', gray: '灰度', stopped: '已停用' })[s]
const onBack = (): void => {
router.back()
}
const refreshProgress = (): void => {
loadTask()
}
const onPush = (): void => {
if (!targetVersion.value) {
showFailToast('请选择目标版本')
return
}
pushOtaTask({ deviceCode: deviceCode.value, targetVersion: targetVersion.value })
.then((res) => {
if (res.code === '00000') {
task.value = res.data
showSuccessToast('已下发升级任务')
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const onRollback = (): void => {
rollbackConfirm.value = true
}
const doRollback = (): void => {
rollbackOta(deviceCode.value)
.then((res) => {
if (res.code === '00000') {
showSuccessToast('回滚成功')
loadTask()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadDevice = (): void => {
const code = String(route.params.deviceId ?? '')
deviceCode.value = code
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
deviceName.value = res.data.name
currentVersion.value = res.data.firmwareVersion
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadVersions = (): void => {
listOtaVersions(deviceCode.value)
.then((res) => {
if (res.code === '00000') versions.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadTask = (): void => {
getOtaTask(deviceCode.value)
.then((res) => {
if (res.code === '00000') task.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadDevice()
loadVersions()
loadTask()
})
</script>
<style scoped lang="less">
.scan-ota {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__device {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
margin-bottom: 8px;
.van-icon {
font-size: 22px;
color: var(--primary);
}
}
&__device-main {
flex: 1;
}
&__device-name {
font-size: var(--font-sm);
color: var(--text-primary);
}
&__device-code {
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__device-version {
font-size: var(--font-xs);
color: var(--text-placeholder);
margin-top: 2px;
}
&__progress {
margin: 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 16px;
text-align: center;
}
&__progress-title {
font-size: var(--font-base);
color: var(--text-primary);
margin-bottom: 12px;
}
&__progress-info {
margin-top: 12px;
font-size: var(--font-sm);
color: var(--text-secondary);
}
&__refresh {
margin-top: 16px;
}
&__result {
margin: 24px 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 24px 16px;
text-align: center;
}
&__result-icon {
font-size: 48px;
&--success { color: var(--success); }
&--failed { color: var(--danger); }
&--rolledback { color: var(--warning); }
}
&__result-text {
margin-top: 12px;
font-size: var(--font-lg);
color: var(--text-primary);
}
&__result-reason {
margin-top: 8px;
font-size: var(--font-sm);
color: var(--danger);
}
&__result-actions {
margin-top: 24px;
display: flex;
gap: 8px;
}
&__select {
margin-top: 8px;
background: #fff;
padding: 12px 0;
}
&__select-title {
padding: 0 16px 8px;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
}
&__version-size {
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__select-actions {
padding: 16px;
}
&__rollback-tip {
padding: 16px;
text-align: center;
color: var(--text-secondary);
font-size: var(--font-sm);
}
}
</style>
+244
View File
@@ -0,0 +1,244 @@
<template>
<div class="scan-repair">
<van-nav-bar title="维修上报" left-arrow @click-left="onBack" />
<div class="scan-repair__device">
<van-icon name="desktop-o" />
<div>
<div class="scan-repair__device-name">{{ deviceName }}</div>
<div class="scan-repair__device-code">{{ form.deviceCode }}</div>
</div>
</div>
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.workOrderId"
name="workOrderId"
label="关联工单"
placeholder="可选,填写工单号"
>
<template #button>
<van-button size="small" type="primary" plain @click="goWorkOrderList">选择工单</van-button>
</template>
</van-field>
<van-field
v-model="faultTypeText"
name="faultType"
label="故障类型"
placeholder="请选择故障类型"
readonly
is-link
:rules="[{ required: true, message: '请选择故障类型' }]"
@click="showFaultType = true"
/>
<van-field
v-model="form.faultDescription"
name="faultDescription"
label="故障描述"
type="textarea"
placeholder="请描述故障现象"
rows="2"
autosize
maxlength="200"
show-word-limit
:rules="[{ required: true, message: '请填写故障描述' }]"
/>
<van-field
v-model="form.repairMethod"
name="repairMethod"
label="维修方式"
placeholder="请填写维修方式"
:rules="[{ required: true, message: '请填写维修方式' }]"
/>
<van-field name="images" label="维修图片">
<template #input>
<van-uploader v-model="imageFileList" :max-count="9" multiple />
</template>
</van-field>
<van-field
v-model="workHoursText"
name="workHours"
label="工时(小时)"
type="number"
placeholder="请输入工时"
:rules="[{ required: true, message: '请输入工时' }]"
/>
<van-field name="spareParts" label="备件消耗">
<template #input>
<div class="scan-repair__parts">
<div v-for="(part, idx) in form.spareParts" :key="idx" class="scan-repair__part">
<van-field v-model="part.name" placeholder="备件名" class="scan-repair__part-name" />
<van-field v-model.number="part.qty" type="number" placeholder="数量" class="scan-repair__part-qty" />
<van-icon name="delete-o" @click="removePart(idx)" />
</div>
<van-button size="small" type="primary" plain @click="addPart">+ 添加备件</van-button>
</div>
</template>
</van-field>
<van-field v-model="form.repairman" label="维修人" readonly />
<van-field v-model="form.repairTime" label="维修时间" readonly />
</van-cell-group>
<div class="scan-repair__submit">
<van-button block type="primary" native-type="submit">提交上报</van-button>
</div>
</van-form>
<van-popup v-model:show="showFaultType" position="bottom" round>
<van-picker :columns="faultTypeOpts" @confirm="onFaultTypeConfirm" @cancel="showFaultType = false" />
</van-popup>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 维修上报(无工单独立新增入口)
* - 关联工单可选填(区别于工单关单前上报,那里工单 ID 必填且不可改)
* - 表单字段与工单关单前维修上报一致
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { scanDevice, submitRepair } from './api'
import type { RepairForm } from './types'
const route = useRoute()
const router = useRouter()
const deviceName = ref('')
const imageFileList = ref<{ url?: string }[]>([])
const workHoursText = ref('')
const showFaultType = ref(false)
const form = reactive<RepairForm>({
deviceCode: '',
workOrderId: '',
faultType: '',
faultDescription: '',
repairMethod: '',
images: [],
workHours: 0,
spareParts: [],
repairman: '当前用户',
repairTime: '',
})
const faultTypeOpts = [
{ text: '硬件故障', value: '硬件故障' },
{ text: '软件故障', value: '软件故障' },
{ text: '网络故障', value: '网络故障' },
{ text: '传感器故障', value: '传感器故障' },
{ text: '电源故障', value: '电源故障' },
{ text: '机械故障', value: '机械故障' },
{ text: '其他', value: '其他' },
]
const faultTypeText = computed(() => form.faultType)
const onBack = (): void => {
router.back()
}
const onFaultTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
form.faultType = selectedOptions[0]?.value ?? ''
showFaultType.value = false
}
const addPart = (): void => {
form.spareParts.push({ name: '', qty: 1 })
}
const removePart = (idx: number): void => {
form.spareParts.splice(idx, 1)
}
const goWorkOrderList = (): void => {
router.push('/miniprogram/ops/work-order')
}
const onSubmit = (): void => {
form.workHours = Number(workHoursText.value) || 0
form.images = imageFileList.value.map((f) => f.url ?? '').filter(Boolean)
submitRepair(form)
.then((res) => {
if (res.code === '00000') {
showSuccessToast('维修上报成功')
router.back()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadDevice = (): void => {
const code = String(route.params.deviceId ?? '')
form.deviceCode = code
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
deviceName.value = res.data.name
}
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
const now = new Date()
form.repairTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:00`
loadDevice()
})
</script>
<style scoped lang="less">
.scan-repair {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__device {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
margin-bottom: 8px;
.van-icon {
font-size: 22px;
color: var(--primary);
}
}
&__device-name {
font-size: var(--font-sm);
color: var(--text-primary);
}
&__device-code {
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__parts {
width: 100%;
}
&__part {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
&__part-name { flex: 2; }
&__part-qty { flex: 1; }
&__submit {
padding: 16px;
}
}
</style>
@@ -0,0 +1,231 @@
<template>
<div v-if="device" class="scene-menu">
<van-nav-bar title="场景操作" left-arrow @click-left="onBack" />
<!-- 设备摘要卡 -->
<div class="scene-menu__device">
<div class="scene-menu__device-header">
<van-icon name="desktop-o" class="scene-menu__device-icon" />
<div class="scene-menu__device-main">
<div class="scene-menu__device-name">{{ device.name }}</div>
<div class="scene-menu__device-code">{{ device.code }}</div>
</div>
<van-tag :type="statusTagType(device.status)">{{ statusText(device.status) }}</van-tag>
</div>
<div class="scene-menu__device-meta">
<div><span>型号</span>{{ device.model }}</div>
<div><span>固件</span>{{ device.firmwareVersion }}</div>
<div><span>场所</span>{{ device.site }}</div>
<div><span>租户</span>{{ device.tenant }}</div>
</div>
<div v-if="device.pendingWorkOrders > 0" class="scene-menu__device-alert">
<van-icon name="warning-o" />
<span>该设备有 {{ device.pendingWorkOrders }} 条待处理工单</span>
</div>
</div>
<!-- 操作菜单 -->
<div class="scene-menu__actions">
<div class="scene-menu__actions-title">可执行操作</div>
<van-cell-group inset>
<van-cell
v-for="action in availableActions"
:key="action.key"
:title="action.label"
:label="action.desc"
is-link
@click="onAction(action)"
>
<template #icon>
<van-icon :name="action.icon" class="scene-menu__action-icon" />
</template>
</van-cell>
</van-cell-group>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 扫码场景操作菜单
* - 设备摘要卡(编码 / 型号 / 状态 / 待处理工单红点)
* - 6 项操作按设备状态过滤显示
*/
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { scanDevice } from './api'
import type { DeviceStatus, DeviceSummary } from './types'
const route = useRoute()
const router = useRouter()
const device = ref<DeviceSummary | null>(null)
interface SceneAction {
key: string
label: string
desc: string
icon: string
path: (deviceId: string) => string
statuses: DeviceStatus[]
}
const ALL_ACTIONS: SceneAction[] = [
{
key: 'init', label: '设备初始化', desc: '烧录证书 / 预置配置 / 固件基线',
icon: 'setting-o', path: (id) => `/miniprogram/ops/init/${id}`,
statuses: ['inactive', 'activated'],
},
{
key: 'impl', label: '实施记录上报', desc: '装机 / 迁移 / 拆除 / 复位',
icon: 'records-o', path: (id) => `/miniprogram/ops/impl-record/${id}`,
statuses: ['activated', 'inUse'],
},
{
key: 'inspect', label: '巡检打卡', desc: '确认到点 + 逐项填报',
icon: 'todo-list-o', path: () => `/miniprogram/ops/inspect`,
statuses: ['inUse'],
},
{
key: 'repair', label: '维修上报', desc: '关联工单 / 无工单独立新增',
icon: 'tools-o', path: (id) => `/miniprogram/ops/repair/${id}`,
statuses: ['inUse'],
},
{
key: 'ota', label: 'OTA 升级', desc: '选版本 + 下发 + 监控 + 回滚',
icon: 'upgrade', path: (id) => `/miniprogram/ops/ota/${id}`,
statuses: ['inUse'],
},
{
key: 'monitor', label: '设备监测', desc: '多 Tab 简化版(概览 / 日志 / 告警 / 维修 / 更新)',
icon: 'eye-o', path: (id) => `/miniprogram/ops/monitor/${id}`,
statuses: ['activated', 'inUse', 'scrapped'],
},
]
const availableActions = computed<SceneAction[]>(() => {
if (!device.value) return []
return ALL_ACTIONS.filter((a) => a.statuses.includes(device.value!.status))
})
const statusText = (s: DeviceStatus): string => ({
inactive: '未激活',
activated: '已激活',
inUse: '在用',
scrapped: '已报废',
})[s]
const statusTagType = (s: DeviceStatus): 'primary' | 'warning' | 'success' | 'default' =>
s === 'inUse' ? 'success' : s === 'activated' ? 'primary' : s === 'inactive' ? 'warning' : 'default'
const onBack = (): void => {
router.back()
}
const onAction = (action: SceneAction): void => {
if (!device.value) return
router.push(action.path(device.value.code))
}
const loadDevice = (): void => {
const code = String(route.params.deviceId ?? '')
scanDevice(code)
.then((res) => {
if (res.code === '00000') device.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadDevice()
})
</script>
<style scoped lang="less">
.scene-menu {
min-height: 100%;
background: var(--background);
&__device {
margin: 8px 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 14px 12px;
box-shadow: var(--shadow-sm);
}
&__device-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
&__device-icon {
font-size: 28px;
color: var(--primary);
}
&__device-main {
flex: 1;
}
&__device-name {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
&__device-code {
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__device-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
font-size: var(--font-xs);
color: var(--text-secondary);
span {
color: var(--text-placeholder);
margin-right: 6px;
}
}
&__device-alert {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 8px 10px;
background: #fff1f0;
border-radius: var(--radius-sm);
font-size: var(--font-xs);
color: var(--danger);
}
&__actions {
margin-top: 8px;
padding: 12px 0;
background: #fff;
}
&__actions-title {
padding: 0 16px 8px;
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
}
&__action-icon {
margin-right: 8px;
font-size: 20px;
color: var(--primary);
}
}
</style>
+140
View File
@@ -0,0 +1,140 @@
/**
* 实施运维小程序 / 扫码 类型定义
*/
/** 设备运行状态 */
export type DeviceStatus = 'inactive' | 'activated' | 'inUse' | 'scrapped'
/** 设备顶级分类 */
export type DeviceCategory =
| 'catering' // 智养餐饮设备
| 'body' // 体重测量设备
| 'wearable' // 智能穿戴设备
| 'fitness' // 健身器材设备
| 'health' // 健康监测设备
/** 扫码后的设备摘要(场景菜单用) */
export interface DeviceSummary {
id: string
code: string
name: string
category: DeviceCategory
model: string
status: DeviceStatus
site: string
tenant: string
firmwareVersion: string
lastHeartbeat: string
pendingWorkOrders: number
isIot: boolean
dataDistributeEnabled: boolean
}
/** 扫码历史记录 */
export interface ScanHistoryItem {
id: string
deviceCode: string
deviceName: string
scannedAt: string
action: string
}
/** 实施类型 */
export type ImplType = 'install' | 'move' | 'remove' | 'reset'
/** 实施记录表单 */
export interface ImplRecordForm {
deviceCode: string
implType: ImplType
beforeStatus: string
afterStatus: string
images: string[]
customerSign: string
operator: string
operateTime: string
remark: string
}
/** 设备初始化表单 */
export interface InitForm {
deviceCode: string
bindKey: string
certBurned: boolean
siteConfig: string
firmwareBaseline: string
result: 'success' | 'fail'
failReason: string
operator: string
operateTime: string
}
/** 维修上报表单(无工单独立入口) */
export interface RepairForm {
deviceCode: string
workOrderId: string
faultType: string
faultDescription: string
repairMethod: string
images: string[]
workHours: number
spareParts: Array<{ name: string; qty: number }>
repairman: string
repairTime: string
}
/** OTA 可用版本 */
export interface OtaVersion {
version: string
releaseNote: string
status: 'gray' | 'full' | 'stopped'
size: string
}
/** OTA 升级任务 */
export interface OtaTask {
id: string
deviceCode: string
currentVersion: string
targetVersion: string
progress: number
status: 'pending' | 'upgrading' | 'success' | 'failed' | 'rolledback'
failReason: string
createdAt: string
}
/** 设备监测 - 运行日志 */
export interface RunLogItem {
id: string
time: string
event: string
level: 'info' | 'warn' | 'error'
}
/** 设备监测 - 维修记录 */
export interface MonitorRepairItem {
id: string
time: string
faultType: string
repairMethod: string
repairman: string
workOrderId: string
}
/** 设备监测 - 更新记录 */
export interface MonitorUpdateItem {
id: string
time: string
fromVersion: string
toVersion: string
result: 'success' | 'failed' | 'rolledback'
operator: string
}
/** 设备监测详情(多 Tab 聚合) */
export interface DeviceMonitor {
summary: DeviceSummary
runLogs: RunLogItem[]
alarms: Array<{ id: string; content: string; level: 'info' | 'warn' | 'critical'; triggerTime: string }>
repairs: MonitorRepairItem[]
updates: MonitorUpdateItem[]
}
+256 -7
View File
@@ -1,12 +1,261 @@
<template>
<AppPlaceholder
title="实施运维小程序 - 工单"
name="miniprogram/ops/work-order"
>
占位工单列表待处理 / 处理中 / 已完成+ 工单详情 + 接单 / 处理 / 关单上报详见 src/docs/device/设备管理服务器功能现状.md §8.4.2
</AppPlaceholder>
<div class="wo-home">
<van-nav-bar title="工单" />
<!-- 状态分段 Tab -->
<van-tabs v-model:active="activeTab" sticky @change="onTabChange">
<van-tab v-for="t in statusTabs" :key="t.value" :name="t.value" :title="t.label" />
</van-tabs>
<!-- 类型筛选 -->
<div class="wo-home__filter">
<van-dropdown-menu>
<van-dropdown-item v-model="filter.type" :options="typeOpts" @change="reload" />
</van-dropdown-menu>
<van-search v-model="filter.keyword" placeholder="搜索工单号 / 设备" shape="round" @search="reload" />
</div>
<!-- 列表 -->
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
<div
v-for="item in list"
:key="item.id"
class="wo-card"
:class="{ 'wo-card--over': item.overSla }"
@click="goDetail(item.id)"
>
<div class="wo-card__header">
<van-tag :type="typeTagType(item.type)" size="medium">{{ typeText(item.type) }}</van-tag>
<span class="wo-card__code">{{ item.code }}</span>
<van-tag v-if="item.overSla" type="danger" size="medium"> SLA</van-tag>
</div>
<div class="wo-card__title">{{ item.title }}</div>
<div class="wo-card__device">
<van-icon name="desktop-o" />
<span class="wo-card__device-code">{{ item.deviceCode }}</span>
<span class="wo-card__device-name">{{ item.deviceName }}</span>
</div>
<div class="wo-card__site">
<van-icon name="location-o" />
<span>{{ item.site }}</span>
</div>
<div class="wo-card__footer">
<span class="wo-card__sla" :class="{ 'wo-card__sla--over': item.overSla }">
<van-icon name="clock-o" />
SLA{{ item.slaDeadline }}
</span>
<span class="wo-card__assignee">{{ item.assignee }}</span>
</div>
</div>
</van-list>
<!-- 扫码建单按钮浮动 -->
<div class="wo-home__add" @click="goScan">
<van-icon name="scan-o" />
<span>扫码建单</span>
</div>
</div>
</template>
<script setup lang="ts">
/** 实施运维小程序 工单 Tab 首页占位 */
/**
* 实施运维小程序 / 工单列表
* - 3 段状态 Tab(待处理 / 处理中 / 已完成)
* - 类型筛选 + 关键字
* - 列表项点击进入详情
* - 浮动按钮:扫码建单(跳扫码 Tab)
*/
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listWorkOrders } from './api'
import type { WorkOrderItem, WorkOrderListParams, WorkOrderStatus, WorkOrderType } from './types'
const router = useRouter()
const list = ref<WorkOrderItem[]>([])
const loading = ref(false)
const finished = ref(true)
const activeTab = ref<WorkOrderStatus>('pending')
const filter = reactive<{ type: WorkOrderType | ''; keyword: string }>({
type: '',
keyword: '',
})
const statusTabs: Array<{ label: string; value: WorkOrderStatus }> = [
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'processing' },
{ label: '已完成', value: 'done' },
]
const typeOpts = [
{ text: '全部类型', value: '' },
{ text: '报修', value: 'repair' },
{ text: '巡检异常', value: 'inspection' },
{ text: '自检异常', value: 'selfCheck' },
{ text: 'SLA 超时', value: 'sla' },
{ text: '售后转单', value: 'afterSale' },
]
const typeText = (t: WorkOrderType): string => ({
repair: '报修',
inspection: '巡检',
selfCheck: '自检',
sla: 'SLA',
afterSale: '售后',
})[t]
const typeTagType = (t: WorkOrderType): 'primary' | 'warning' | 'danger' | 'default' =>
t === 'repair' ? 'danger' : t === 'inspection' ? 'primary' : t === 'selfCheck' ? 'warning' : 'default'
const goDetail = (id: string): void => {
router.push(`/miniprogram/ops/work-order-detail/${id}`)
}
const goScan = (): void => {
router.push('/miniprogram/ops/scan')
}
const loadList = (): void => {
const params: WorkOrderListParams = {
status: activeTab.value,
type: filter.type,
keyword: filter.keyword,
}
listWorkOrders(params)
.then((res) => {
if (res.code === '00000') list.value = res.data
})
.catch(() => undefined)
.finally(() => {
loading.value = false
finished.value = true
})
}
const reload = (): void => {
list.value = []
loading.value = true
finished.value = false
loadList()
}
const onTabChange = (): void => {
reload()
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="less">
.wo-home {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__filter {
background: #fff;
border-bottom: 1px solid var(--border);
}
&__add {
position: fixed;
right: 16px;
bottom: 80px;
background: var(--primary);
color: #fff;
border-radius: 24px;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-sm);
box-shadow: var(--shadow-md);
}
}
.wo-card {
margin: 8px 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
border-left: 3px solid var(--primary);
&--over {
border-left-color: var(--danger);
background: #fff1f0;
}
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
&__code {
flex: 1;
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
}
&__title {
font-size: var(--font-base);
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
line-height: 1.4;
}
&__device {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-bottom: 4px;
}
&__device-code {
font-family: monospace;
color: var(--primary);
}
&__site {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-xs);
color: var(--text-placeholder);
margin-bottom: 8px;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px dashed var(--border);
padding-top: 6px;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__sla {
display: flex;
align-items: center;
gap: 4px;
&--over {
color: var(--danger);
}
}
&__assignee {
color: var(--text-secondary);
}
}
</style>
@@ -0,0 +1,40 @@
/**
* 实施运维小程序 / 工单 接口
*/
import { getRequest, postRequest } from '@axios'
import type {
RepairReportForm,
WorkOrderDetail,
WorkOrderItem,
WorkOrderListParams,
} from './types'
/** 工单列表 */
export const listWorkOrders = (
params: WorkOrderListParams,
): Promise<{ code: string; msg: string; data: WorkOrderItem[] }> =>
postRequest('axiosRequest', '/mp/ops/work-order/list', params)
/** 工单详情 */
export const getWorkOrderDetail = (
id: string,
): Promise<{ code: string; msg: string; data: WorkOrderDetail }> =>
getRequest('axiosRequest', '/mp/ops/work-order/detail', { id })
/** 接单 */
export const acceptWorkOrder = (
id: string,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/work-order/accept', { id })
/** 关单 */
export const closeWorkOrder = (
id: string,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/work-order/close', { id })
/** 提交维修上报 */
export const submitRepairReport = (
params: RepairReportForm,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/work-order/repair-report', params)
@@ -0,0 +1,273 @@
<template>
<div v-if="detail" class="wo-detail">
<van-nav-bar title="工单详情" left-arrow @click-left="onBack" />
<!-- 头部 -->
<div class="wo-detail__header" :class="{ 'wo-detail__header--over': detail.overSla }">
<div class="wo-detail__title-row">
<van-tag :type="typeTagType(detail.type)" size="large">{{ typeText(detail.type) }}</van-tag>
<span class="wo-detail__code">{{ detail.code }}</span>
</div>
<div class="wo-detail__title">{{ detail.title }}</div>
<div class="wo-detail__sla" :class="{ 'wo-detail__sla--over': detail.overSla }">
<van-icon name="clock-o" />
<span v-if="detail.status === 'done'">已关单</span>
<span v-else>SLA 截止{{ detail.slaDeadline }}</span>
</div>
</div>
<!-- 设备 + 场所信息 -->
<div class="wo-detail__block">
<van-cell-group inset>
<van-cell title="设备编码" :value="detail.deviceCode" is-link @click="goMonitor" />
<van-cell title="设备名称" :value="detail.deviceName" />
<van-cell title="所属场所" :value="detail.site" />
<van-cell title="所属租户" :value="detail.tenant" />
<van-cell title="责任人" :value="detail.assignee" />
<van-cell title="创建时间" :value="detail.createdAt" />
</van-cell-group>
</div>
<!-- 故障描述 -->
<div class="wo-detail__block">
<div class="wo-detail__block-title">故障描述</div>
<div class="wo-detail__desc">{{ detail.description }}</div>
<div v-if="detail.customerVoice" class="wo-detail__voice">
<van-icon name="volume-o" />
<span>客户原话{{ detail.customerVoice }}</span>
</div>
<div v-if="detail.faultImages.length" class="wo-detail__images">
<van-image
v-for="(img, idx) in detail.faultImages"
:key="idx"
:src="img"
width="30%"
height="80"
fit="cover"
radius="6"
/>
</div>
</div>
<!-- 处理时间线 -->
<div class="wo-detail__block">
<div class="wo-detail__block-title">处理记录</div>
<van-steps direction="vertical" :active="detail.processLog.length - 1" active-color="#1890ff">
<van-step v-for="(log, idx) in detail.processLog" :key="idx">
<div class="wo-detail__step-time">{{ log.time }}</div>
<div class="wo-detail__step-action">{{ log.action }}</div>
<div class="wo-detail__step-operator">{{ log.operator }}</div>
</van-step>
</van-steps>
</div>
<!-- 底部操作 -->
<div class="wo-detail__actions">
<van-button v-if="detail.status === 'pending'" block type="primary" @click="onAccept">接单</van-button>
<template v-if="detail.status === 'processing'">
<van-button block type="warning" @click="onRepair">维修上报</van-button>
<van-button block type="primary" @click="onClose">关单</van-button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 工单详情
* - 头部:类型 / 编码 / 标题 / SLA 倒计时
* - 设备 + 场所信息(可跳设备监测)
* - 故障描述 + 客户原话 + 故障图片
* - 处理时间线
* - 底部操作:接单 / 维修上报 / 关单
*/
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showFailToast } from 'vant'
import { acceptWorkOrder, closeWorkOrder, getWorkOrderDetail } from './api'
import type { WorkOrderDetail, WorkOrderType } from './types'
const route = useRoute()
const router = useRouter()
const detail = ref<WorkOrderDetail | null>(null)
const typeText = (t: WorkOrderType): string => ({
repair: '报修',
inspection: '巡检',
selfCheck: '自检',
sla: 'SLA',
afterSale: '售后',
})[t]
const typeTagType = (t: WorkOrderType): 'primary' | 'warning' | 'danger' | 'default' =>
t === 'repair' ? 'danger' : t === 'inspection' ? 'primary' : t === 'selfCheck' ? 'warning' : 'default'
const onBack = (): void => {
router.back()
}
const goMonitor = (): void => {
if (!detail.value) return
router.push(`/miniprogram/ops/monitor/${detail.value.deviceCode}`)
}
const onAccept = (): void => {
if (!detail.value) return
acceptWorkOrder(detail.value.id)
.then((res) => {
if (res.code === '00000') {
showSuccessToast('接单成功')
loadDetail()
} else {
showFailToast(res.msg)
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const onRepair = (): void => {
if (!detail.value) return
router.push(`/miniprogram/ops/repair-report/${detail.value.id}`)
}
const onClose = (): void => {
if (!detail.value) return
// 关单前需先完成维修上报,这里提示
showFailToast('请先完成维修上报')
}
const loadDetail = (): void => {
const id = String(route.params.id ?? '')
getWorkOrderDetail(id)
.then((res) => {
if (res.code === '00000') detail.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadDetail()
})
</script>
<style scoped lang="less">
.wo-detail {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__header {
background: #fff;
padding: 12px 16px;
border-left: 4px solid var(--primary);
&--over {
border-left-color: var(--danger);
background: #fff1f0;
}
}
&__title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__code {
flex: 1;
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
}
&__title {
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
margin-bottom: 6px;
}
&__sla {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-sm);
color: var(--text-secondary);
&--over {
color: var(--danger);
}
}
&__block {
margin-top: 8px;
background: #fff;
padding: 12px 0 8px;
}
&__block-title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
padding: 0 16px 8px;
}
&__desc {
padding: 0 16px;
font-size: var(--font-base);
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 8px;
}
&__voice {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 0 16px;
font-size: var(--font-sm);
color: var(--text-secondary);
margin-bottom: 8px;
}
&__images {
display: flex;
gap: 6px;
padding: 0 16px;
}
&__step-time {
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__step-action {
font-size: var(--font-sm);
color: var(--text-primary);
margin-top: 2px;
}
&__step-operator {
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 8px;
padding: 12px 16px env(safe-area-inset-bottom);
background: #fff;
border-top: 1px solid var(--border);
}
}
</style>
@@ -0,0 +1,250 @@
<template>
<div class="repair-report">
<van-nav-bar title="维修上报" left-arrow @click-left="onBack" />
<div class="repair-report__device">
<van-icon name="desktop-o" />
<div>
<div class="repair-report__device-code">{{ form.deviceCode || '—' }}</div>
<div class="repair-report__device-name">{{ deviceName }}</div>
</div>
</div>
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.faultType"
name="faultType"
label="故障类型"
placeholder="请选择故障类型"
readonly
is-link
:rules="[{ required: true, message: '请选择故障类型' }]"
@click="showFaultType = true"
/>
<van-field
v-model="form.faultDescription"
name="faultDescription"
label="故障描述"
type="textarea"
placeholder="请描述故障现象"
rows="2"
autosize
maxlength="200"
show-word-limit
:rules="[{ required: true, message: '请填写故障描述' }]"
/>
<van-field
v-model="form.repairMethod"
name="repairMethod"
label="维修方式"
placeholder="请填写维修方式"
:rules="[{ required: true, message: '请填写维修方式' }]"
/>
<van-field name="repairImages" label="维修图片">
<template #input>
<van-uploader v-model="imageFileList" :max-count="9" multiple />
</template>
</van-field>
<van-field
v-model="workHoursText"
name="workHours"
label="工时(小时)"
type="number"
placeholder="请输入工时"
:rules="[{ required: true, message: '请输入工时' }]"
/>
<van-field name="spareParts" label="备件消耗">
<template #input>
<div class="repair-report__parts">
<div v-for="(part, idx) in form.spareParts" :key="idx" class="repair-report__part">
<van-field
v-model="part.name"
placeholder="备件名"
class="repair-report__part-name"
/>
<van-field
v-model.number="part.qty"
type="number"
placeholder="数量"
class="repair-report__part-qty"
/>
<van-icon name="delete-o" @click="removePart(idx)" />
</div>
<van-button size="small" type="primary" plain @click="addPart">+ 添加备件</van-button>
</div>
</template>
</van-field>
<van-field v-model="form.repairman" label="维修人" placeholder="维修人" readonly />
<van-field v-model="form.repairTime" label="维修时间" placeholder="自动取当前时间" readonly />
</van-cell-group>
<div class="repair-report__submit">
<van-button block type="primary" native-type="submit">提交上报</van-button>
</div>
</van-form>
<!-- 故障类型选择 -->
<van-popup v-model:show="showFaultType" position="bottom" round>
<van-picker
:columns="faultTypeOpts"
@confirm="onFaultTypeConfirm"
@cancel="showFaultType = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 维修上报(工单关单前入口)
* - 表单:故障类型 / 描述 / 维修方式 / 图片 / 工时 / 备件 / 维修人 / 时间
* - 提交后回写工单 + 维修记录
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { getWorkOrderDetail, submitRepairReport } from './api'
import type { RepairReportForm } from './types'
const route = useRoute()
const router = useRouter()
const workOrderId = computed(() => String(route.params.id ?? ''))
const deviceName = ref('')
const form = reactive<RepairReportForm>({
workOrderId: '',
deviceCode: '',
faultType: '',
faultDescription: '',
repairMethod: '',
repairImages: [],
workHours: 0,
spareParts: [],
repairman: '当前用户',
repairTime: '',
})
const imageFileList = ref<{ url?: string; status?: string }[]>([])
const workHoursText = ref('')
const showFaultType = ref(false)
const faultTypeOpts = [
{ text: '硬件故障', value: '硬件故障' },
{ text: '软件故障', value: '软件故障' },
{ text: '网络故障', value: '网络故障' },
{ text: '传感器故障', value: '传感器故障' },
{ text: '电源故障', value: '电源故障' },
{ text: '机械故障', value: '机械故障' },
{ text: '其他', value: '其他' },
]
const onBack = (): void => {
router.back()
}
const onFaultTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
form.faultType = selectedOptions[0]?.value ?? ''
showFaultType.value = false
}
const addPart = (): void => {
form.spareParts.push({ name: '', qty: 1 })
}
const removePart = (idx: number): void => {
form.spareParts.splice(idx, 1)
}
const onSubmit = (): void => {
form.workHours = Number(workHoursText.value) || 0
form.repairImages = imageFileList.value.map((f) => f.url ?? '').filter(Boolean)
form.workOrderId = workOrderId.value
submitRepairReport(form)
.then((res) => {
if (res.code === '00000') {
showSuccessToast('维修上报成功')
router.back()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadWorkOrder = (): void => {
getWorkOrderDetail(workOrderId.value)
.then((res) => {
if (res.code === '00000') {
form.deviceCode = res.data.deviceCode
deviceName.value = res.data.deviceName
form.faultDescription = res.data.description
}
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
const now = new Date()
form.repairTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:00`
loadWorkOrder()
})
</script>
<style scoped lang="less">
.repair-report {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__device {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
margin-bottom: 8px;
.van-icon {
font-size: 22px;
color: var(--primary);
}
}
&__device-code {
font-family: monospace;
font-size: var(--font-sm);
color: var(--primary);
}
&__device-name {
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__parts {
width: 100%;
}
&__part {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
&__part-name {
flex: 2;
}
&__part-qty {
flex: 1;
}
&__submit {
padding: 16px;
}
}
</style>
@@ -0,0 +1,55 @@
/**
* 实施运维小程序 / 工单 类型定义
*/
/** 工单状态 */
export type WorkOrderStatus = 'pending' | 'processing' | 'done'
/** 工单类型 */
export type WorkOrderType = 'repair' | 'inspection' | 'selfCheck' | 'sla' | 'afterSale'
/** 工单列表项 */
export interface WorkOrderItem {
id: string
code: string
title: string
type: WorkOrderType
status: WorkOrderStatus
deviceCode: string
deviceName: string
site: string
tenant: string
description: string
slaDeadline: string
createdAt: string
assignee: string
overSla: boolean
}
/** 工单详情(含客户报障附件 + 处理记录) */
export interface WorkOrderDetail extends WorkOrderItem {
faultImages: string[]
customerVoice?: string
processLog: Array<{ time: string; action: string; operator: string }>
}
/** 工单列表筛选 */
export interface WorkOrderListParams {
status: WorkOrderStatus
type?: WorkOrderType | ''
keyword?: string
}
/** 维修上报表单 */
export interface RepairReportForm {
workOrderId: string
deviceCode: string
faultType: string
faultDescription: string
repairMethod: string
repairImages: string[]
workHours: number
spareParts: Array<{ name: string; qty: number }>
repairman: string
repairTime: string
}
+349 -7
View File
@@ -1,12 +1,354 @@
<template>
<AppPlaceholder
title="实施运维小程序 - 工作台"
name="miniprogram/ops/workbench"
>
占位今日待办待处理工单 / 待巡检 / OTA / 待初始化统计卡快捷入口详见 src/docs/device/设备管理服务器功能现状.md §8.4.1
</AppPlaceholder>
<div class="ops-workbench">
<!-- 顶部问候 -->
<div class="ops-workbench__hero">
<div class="ops-workbench__hero-text">
<div class="ops-workbench__hello">早上好李工程师</div>
<div class="ops-workbench__date">{{ todayText }}</div>
</div>
<van-icon name="bell" class="ops-workbench__hero-bell" @click="goMessages" />
</div>
<!-- 待办卡告警卡跨整行 + 4 2 -->
<div v-if="todo" class="ops-workbench__section">
<div class="ops-workbench__section-title">今日待办</div>
<div
class="todo-card todo-card--alarm"
:class="{ 'todo-card--critical': todo.criticalAlarms > 0 }"
@click="goAlarmList"
>
<div class="todo-card__left">
<van-icon name="warning-o" class="todo-card__icon" />
<div>
<div class="todo-card__label">待处理告警</div>
<div class="todo-card__sub">严重 {{ todo.criticalAlarms }} </div>
</div>
</div>
<div class="todo-card__count">{{ todo.pendingAlarms }}</div>
</div>
<div class="ops-workbench__grid">
<div class="todo-card" @click="goWorkOrder">
<van-icon name="orders-o" class="todo-card__icon todo-card__icon--blue" />
<div class="todo-card__label">待处理工单</div>
<div class="todo-card__count">{{ todo.pendingWorkOrders }}</div>
<div v-if="todo.overSlaOrders > 0" class="todo-card__badge"> SLA {{ todo.overSlaOrders }}</div>
</div>
<div class="todo-card" @click="goInspect">
<van-icon name="todo-list-o" class="todo-card__icon todo-card__icon--green" />
<div class="todo-card__label">今日巡检</div>
<div class="todo-card__count">{{ todo.todayInspections }}</div>
<div class="todo-card__sub">已完成 {{ todo.inspectionDone }}</div>
</div>
<div class="todo-card" @click="goScan">
<van-icon name="scan-o" class="todo-card__icon todo-card__icon--orange" />
<div class="todo-card__label">待初始化</div>
<div class="todo-card__count">{{ todo.todayInitDevices }}</div>
<div class="todo-card__sub">扫码激活</div>
</div>
<div class="todo-card" @click="goScan">
<van-icon name="upgrade" class="todo-card__icon todo-card__icon--purple" />
<div class="todo-card__label">OTA 任务</div>
<div class="todo-card__count">{{ todo.todayOtaTasks }}</div>
<div class="todo-card__sub">待升级</div>
</div>
</div>
</div>
<!-- 本周统计 -->
<div v-if="week" class="ops-workbench__section">
<div class="ops-workbench__section-title">本周统计</div>
<div class="ops-workbench__grid ops-workbench__grid--3">
<div class="stat-card">
<div class="stat-card__value">{{ week.weekOrders }}</div>
<div class="stat-card__label">处理工单</div>
<div class="stat-card__sub">平均 {{ week.avgProcessHours }}h</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ week.weekInspectionRate }}</div>
<div class="stat-card__label">巡检完成率</div>
<div class="stat-card__sub">异常 {{ week.weekInspectionAbnormalRate }}</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ week.monthInstallCount }}</div>
<div class="stat-card__label">本月装机</div>
<div class="stat-card__sub"></div>
</div>
</div>
</div>
<!-- 快捷入口 -->
<div class="ops-workbench__section">
<div class="ops-workbench__section-title">快捷入口</div>
<div class="ops-workbench__grid ops-workbench__grid--4">
<div
v-for="entry in quickEntries"
:key="entry.key"
class="quick-entry"
@click="goPath(entry.path)"
>
<van-icon :name="entry.icon" class="quick-entry__icon" />
<div class="quick-entry__label">{{ entry.label }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/** 实施运维小程序 工作台 Tab 首页占位 */
/**
* 实施运维小程序 / 工作台首页
* - 5 张待办卡(含告警卡,告警作为重点跨整行展示)
* - 3 张本周统计卡
* - 4 个快捷入口
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getTodoStats, getWeekStats } from './api'
import type { QuickEntry, TodoStats, WeekStats } from './types'
const router = useRouter()
const todo = ref<TodoStats | null>(null)
const week = ref<WeekStats | null>(null)
const todayText = (() => {
const d = new Date()
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}${week}`
})()
const quickEntries: QuickEntry[] = [
{ key: 'scan', label: '扫码', icon: 'scan-o', path: '/miniprogram/ops/scan' },
{ key: 'message', label: '消息中心', icon: 'chat-o', path: '/miniprogram/ops/message-center' },
{ key: 'inspect', label: '巡检', icon: 'todo-list-o', path: '/miniprogram/ops/inspect' },
{ key: 'me', label: '个人统计', icon: 'user-o', path: '/miniprogram/ops/me' },
]
const goPath = (path: string): void => {
router.push(path)
}
const goAlarmList = (): void => {
router.push('/miniprogram/ops/alarm-list')
}
const goWorkOrder = (): void => {
router.push('/miniprogram/ops/work-order')
}
const goInspect = (): void => {
router.push('/miniprogram/ops/inspect')
}
const goScan = (): void => {
router.push('/miniprogram/ops/scan')
}
const goMessages = (): void => {
router.push('/miniprogram/ops/message-center')
}
const loadTodo = (): void => {
getTodoStats()
.then((res) => {
if (res.code === '00000') todo.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadWeek = (): void => {
getWeekStats()
.then((res) => {
if (res.code === '00000') week.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadTodo()
loadWeek()
})
</script>
<style scoped lang="less">
.ops-workbench {
padding: 12px 12px 24px;
background: var(--background);
min-height: 100%;
&__hero {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 4px 16px;
}
&__hero-text {
flex: 1;
}
&__hello {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
&__date {
margin-top: 4px;
font-size: var(--font-sm);
color: var(--text-secondary);
}
&__hero-bell {
font-size: 22px;
color: var(--text-secondary);
padding: 6px;
}
&__section {
margin-bottom: 16px;
}
&__section-title {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
margin: 8px 4px 10px;
}
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
&__grid--3 {
grid-template-columns: repeat(3, 1fr);
}
&__grid--4 {
grid-template-columns: repeat(4, 1fr);
}
}
.todo-card {
position: relative;
background: #fff;
border-radius: var(--radius-md);
padding: 14px 12px;
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
&--alarm {
flex-direction: row;
align-items: center;
justify-content: space-between;
border-left: 3px solid var(--warning);
margin-bottom: 10px;
}
&--critical {
border-left-color: var(--danger);
}
&__left {
display: flex;
align-items: center;
gap: 10px;
}
&__icon {
font-size: 22px;
color: var(--warning);
&--blue { color: var(--primary); }
&--green { color: var(--success); }
&--orange { color: #fa8c16; }
&--purple { color: #722ed1; }
}
&__label {
font-size: var(--font-sm);
color: var(--text-secondary);
}
&__count {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
&--alarm &__count {
font-size: 24px;
color: var(--danger);
}
&__sub {
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__badge {
position: absolute;
top: 8px;
right: 8px;
background: var(--danger);
color: #fff;
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
line-height: 1.5;
}
}
.stat-card {
background: #fff;
border-radius: var(--radius-md);
padding: 12px 8px;
text-align: center;
box-shadow: var(--shadow-sm);
&__value {
font-size: 20px;
font-weight: 600;
color: var(--primary);
}
&__label {
margin-top: 4px;
font-size: var(--font-xs);
color: var(--text-secondary);
}
&__sub {
margin-top: 2px;
font-size: 10px;
color: var(--text-placeholder);
}
}
.quick-entry {
background: #fff;
border-radius: var(--radius-md);
padding: 12px 4px;
text-align: center;
box-shadow: var(--shadow-sm);
&__icon {
font-size: 24px;
color: var(--primary);
}
&__label {
margin-top: 4px;
font-size: var(--font-xs);
color: var(--text-primary);
}
}
</style>
@@ -0,0 +1,257 @@
<template>
<div v-if="detail" class="alarm-detail">
<van-nav-bar title="告警详情" left-arrow @click-left="onBack" />
<!-- 告警头部 -->
<div class="alarm-detail__header" :class="`alarm-detail__header--${detail.level}`">
<van-tag :type="levelTagType(detail.level)" size="large">{{ levelText(detail.level) }}</van-tag>
<span class="alarm-detail__code">{{ detail.alarmCode }}</span>
<van-tag plain :type="statusTagType(detail.status)">{{ statusText(detail.status) }}</van-tag>
</div>
<!-- 告警内容 -->
<div class="alarm-detail__block">
<div class="alarm-detail__block-title">告警内容</div>
<div class="alarm-detail__content">{{ detail.content }}</div>
<div class="alarm-detail__meta">
<span>触发时间</span>
<span>{{ detail.triggerTime }}</span>
</div>
</div>
<!-- 设备信息 -->
<div class="alarm-detail__block">
<div class="alarm-detail__block-title">设备信息</div>
<van-cell-group inset>
<van-cell title="设备编码" :value="detail.deviceCode" />
<van-cell title="设备名称" :value="detail.deviceName" />
<van-cell title="型号" :value="detail.deviceModel" />
<van-cell title="所属场所" :value="detail.site" />
<van-cell title="所属租户" :value="detail.tenant" />
<van-cell title="查看设备监测" is-link @click="goMonitor" />
</van-cell-group>
</div>
<!-- 处理时间线 -->
<div class="alarm-detail__block">
<div class="alarm-detail__block-title">处理时间线</div>
<van-steps direction="vertical" :active="detail.processTimeline.length - 1" active-color="#1890ff">
<van-step v-for="(item, idx) in detail.processTimeline" :key="idx">
<div class="alarm-detail__step-time">{{ item.time }}</div>
<div class="alarm-detail__step-action">{{ item.action }}</div>
<div class="alarm-detail__step-operator">{{ item.operator }}</div>
</van-step>
</van-steps>
</div>
<!-- 处置操作 -->
<div v-if="detail.status === 'pending' || detail.status === 'processing'" class="alarm-detail__actions">
<van-button block type="danger" @click="onAction('toWorkOrder')">转工单</van-button>
<van-button block type="warning" @click="onAction('ignore')">忽略</van-button>
<van-button block type="primary" @click="onAction('resolve')">标记已处理</van-button>
</div>
<!-- 处置弹窗 -->
<van-dialog
v-model:show="dialogShow"
:title="dialogTitle"
show-cancel-button
@confirm="onConfirm"
>
<van-field
v-model="reason"
rows="2"
autosize
type="textarea"
:placeholder="reasonPlaceholder"
maxlength="100"
show-word-limit
/>
</van-dialog>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 告警详情
* - 头部:级别 / 告警编码 / 状态
* - 告警内容 + 设备信息(可跳设备监测子页)
* - 处理时间线
* - 处置:转工单 / 忽略 / 标记已处理(弹窗填写原因)
*/
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { getAlarmDetail, handleAlarm } from './api'
import type { AlarmDetail, AlarmHandleAction, AlarmLevel, AlarmStatus } from './types'
const route = useRoute()
const router = useRouter()
const detail = ref<AlarmDetail | null>(null)
const dialogShow = ref(false)
const dialogAction = ref<AlarmHandleAction>('resolve')
const reason = ref('')
const dialogTitle = computed(() => {
const map: Record<AlarmHandleAction, string> = {
toWorkOrder: '转工单',
ignore: '忽略告警',
resolve: '标记已处理',
}
return map[dialogAction.value]
})
const reasonPlaceholder = computed(() => {
const map: Record<AlarmHandleAction, string> = {
toWorkOrder: '请填写工单描述(可选)',
ignore: '请填写忽略原因(必填)',
resolve: '请填写处理说明(必填)',
}
return map[dialogAction.value]
})
const levelText = (lv: AlarmLevel): string => ({ critical: '严重', warn: '警告', info: '提示' })[lv]
const statusText = (s: AlarmStatus): string => ({
pending: '未处理',
processing: '处理中',
ignored: '已忽略',
workorder: '已转工单',
})[s]
const levelTagType = (lv: AlarmLevel): 'danger' | 'warning' | 'primary' =>
lv === 'critical' ? 'danger' : lv === 'warn' ? 'warning' : 'primary'
const statusTagType = (s: AlarmStatus): 'danger' | 'warning' | 'default' | 'primary' =>
s === 'pending' ? 'danger' : s === 'processing' ? 'primary' : s === 'workorder' ? 'warning' : 'default'
const onBack = (): void => {
router.back()
}
const goMonitor = (): void => {
if (!detail.value) return
router.push(`/miniprogram/ops/monitor/${detail.value.deviceCode}`)
}
const onAction = (action: AlarmHandleAction): void => {
dialogAction.value = action
reason.value = ''
dialogShow.value = true
}
const onConfirm = (): void => {
if (!detail.value) return
if ((dialogAction.value === 'ignore' || dialogAction.value === 'resolve') && !reason.value.trim()) {
return
}
handleAlarm({ id: detail.value.id, action: dialogAction.value, reason: reason.value.trim() })
.then((res) => {
if (res.code === '00000') {
showSuccessToast('处置成功')
loadDetail()
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadDetail = (): void => {
const id = String(route.params.id ?? '')
getAlarmDetail(id)
.then((res) => {
if (res.code === '00000') detail.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadDetail()
})
</script>
<style scoped lang="less">
.alarm-detail {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 16px 12px;
background: #fff;
border-left: 4px solid var(--warning);
&--critical { border-left-color: var(--danger); background: #fff1f0; }
&--info { border-left-color: var(--primary); }
}
&__code {
flex: 1;
font-family: monospace;
font-size: var(--font-sm);
color: var(--text-primary);
}
&__block {
margin-top: 8px;
background: #fff;
padding: 12px 0 8px;
}
&__block-title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
padding: 0 16px 8px;
}
&__content {
padding: 0 16px;
font-size: var(--font-base);
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 8px;
}
&__meta {
display: flex;
justify-content: space-between;
padding: 0 16px;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__step-time {
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__step-action {
font-size: var(--font-sm);
color: var(--text-primary);
margin-top: 2px;
}
&__step-operator {
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 8px;
padding: 12px 16px env(safe-area-inset-bottom);
background: #fff;
border-top: 1px solid var(--border);
}
}
</style>
@@ -0,0 +1,294 @@
<template>
<div class="alarm-list">
<!-- 顶部统计卡 -->
<div v-if="stats" class="alarm-list__stats">
<div class="stat-mini" :class="{ 'stat-mini--critical': stats.critical > 0 }">
<div class="stat-mini__value">{{ stats.critical }}</div>
<div class="stat-mini__label">严重</div>
</div>
<div class="stat-mini">
<div class="stat-mini__value">{{ stats.pending }}</div>
<div class="stat-mini__label">待处理</div>
</div>
<div class="stat-mini">
<div class="stat-mini__value">{{ stats.today }}</div>
<div class="stat-mini__label">今日新增</div>
</div>
</div>
<!-- 筛选 -->
<div class="alarm-list__filter">
<van-dropdown-menu>
<van-dropdown-item v-model="filter.level" :options="levelOpts" @change="reload" />
<van-dropdown-item v-model="filter.source" :options="sourceOpts" @change="reload" />
<van-dropdown-item v-model="filter.status" :options="statusOpts" @change="reload" />
</van-dropdown-menu>
<van-search
v-model="filter.keyword"
placeholder="搜索设备编码 / 告警内容"
shape="round"
@search="reload"
/>
</div>
<!-- 列表 -->
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadList"
>
<div
v-for="item in list"
:key="item.id"
class="alarm-card"
:class="`alarm-card--${item.level}`"
@click="goDetail(item.id)"
>
<div class="alarm-card__header">
<van-tag :type="levelTagType(item.level)" size="medium">{{ levelText(item.level) }}</van-tag>
<span class="alarm-card__source">{{ sourceText(item.source) }}</span>
<span class="alarm-card__time">{{ item.triggerTime }}</span>
</div>
<div class="alarm-card__content">{{ item.content }}</div>
<div class="alarm-card__device">
<van-icon name="desktop-o" />
<span class="alarm-card__device-code">{{ item.deviceCode }}</span>
<span class="alarm-card__device-name">{{ item.deviceName }}</span>
</div>
<div class="alarm-card__footer">
<van-tag plain :type="statusTagType(item.status)">{{ statusText(item.status) }}</van-tag>
<span v-if="item.handler" class="alarm-card__handler">处理人{{ item.handler }}</span>
</div>
</div>
</van-list>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 告警列表
* - 顶部 3 卡统计(严重 / 待处理 / 今日新增)
* - 筛选:级别 / 来源 / 状态 + 关键字
* - 列表项点击进入详情
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listAlarms } from './api'
import type { AlarmItem, AlarmLevel, AlarmSource, AlarmStatus, AlarmListParams } from './types'
const router = useRouter()
const list = ref<AlarmItem[]>([])
const loading = ref(false)
const finished = ref(true)
const filter = reactive<AlarmListParams>({
level: '',
source: '',
status: '',
keyword: '',
})
const levelOpts = [
{ text: '全部级别', value: '' },
{ text: '严重', value: 'critical' },
{ text: '警告', value: 'warn' },
{ text: '提示', value: 'info' },
]
const sourceOpts = [
{ text: '全部来源', value: '' },
{ text: '运行日志', value: 'runLog' },
{ text: '自检异常', value: 'selfCheck' },
{ text: '巡检异常', value: 'inspection' },
{ text: 'SLA 超时', value: 'sla' },
{ text: 'OTA 失败', value: 'ota' },
{ text: '数据分发失败', value: 'dataDistribute' },
]
const statusOpts = [
{ text: '全部状态', value: '' },
{ text: '未处理', value: 'pending' },
{ text: '处理中', value: 'processing' },
{ text: '已忽略', value: 'ignored' },
{ text: '已转工单', value: 'workorder' },
]
const stats = computed(() => {
const all = list.value
return {
critical: all.filter((a) => a.level === 'critical').length,
pending: all.filter((a) => a.status === 'pending').length,
today: all.filter((a) => a.triggerTime.startsWith('2026-07-03')).length,
}
})
const levelText = (lv: AlarmLevel): string => ({ critical: '严重', warn: '警告', info: '提示' })[lv]
const sourceText = (s: AlarmSource): string => ({
runLog: '运行日志',
selfCheck: '自检',
inspection: '巡检',
sla: 'SLA',
ota: 'OTA',
dataDistribute: '分发',
})[s]
const statusText = (s: AlarmStatus): string => ({
pending: '未处理',
processing: '处理中',
ignored: '已忽略',
workorder: '已转工单',
})[s]
const levelTagType = (lv: AlarmLevel): 'danger' | 'warning' | 'primary' =>
lv === 'critical' ? 'danger' : lv === 'warn' ? 'warning' : 'primary'
const statusTagType = (s: AlarmStatus): 'danger' | 'warning' | 'default' | 'primary' =>
s === 'pending' ? 'danger' : s === 'processing' ? 'primary' : s === 'workorder' ? 'warning' : 'default'
const goDetail = (id: string): void => {
router.push(`/miniprogram/ops/alarm-detail/${id}`)
}
const loadList = (): void => {
listAlarms(filter)
.then((res) => {
if (res.code === '00000') list.value = res.data
})
.catch(() => undefined)
.finally(() => {
loading.value = false
finished.value = true
})
}
const reload = (): void => {
list.value = []
loading.value = true
finished.value = false
loadList()
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="less">
.alarm-list {
padding: 0 0 24px;
background: var(--background);
min-height: 100%;
&__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 12px;
background: #fff;
}
&__filter {
background: #fff;
border-bottom: 1px solid var(--border);
}
}
.stat-mini {
background: var(--background);
border-radius: var(--radius-md);
padding: 10px 4px;
text-align: center;
&--critical {
background: #fff1f0;
}
&__value {
font-size: 22px;
font-weight: 600;
color: var(--primary);
}
&--critical &__value {
color: var(--danger);
}
&__label {
margin-top: 2px;
font-size: var(--font-xs);
color: var(--text-secondary);
}
}
.alarm-card {
margin: 8px 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
border-left: 3px solid var(--warning);
&--critical { border-left-color: var(--danger); }
&--info { border-left-color: var(--primary); }
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__source {
font-size: var(--font-xs);
color: var(--text-secondary);
background: var(--background);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
&__time {
margin-left: auto;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__content {
font-size: var(--font-sm);
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 8px;
}
&__device {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-bottom: 8px;
}
&__device-code {
font-family: monospace;
color: var(--primary);
}
&__device-name {
color: var(--text-secondary);
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px dashed var(--border);
padding-top: 6px;
}
&__handler {
font-size: var(--font-xs);
color: var(--text-placeholder);
}
}
</style>
@@ -0,0 +1,38 @@
/**
* 实施运维小程序 / 工作台 + 告警 接口
*/
import { getRequest, postRequest } from '@axios'
import type {
AlarmDetail,
AlarmHandleParams,
AlarmItem,
AlarmListParams,
TodoStats,
WeekStats,
} from './types'
/** 工作台今日待办统计 */
export const getTodoStats = (): Promise<{ code: string; msg: string; data: TodoStats }> =>
postRequest('axiosRequest', '/mp/ops/workbench/todo-stats')
/** 工作台本周统计 */
export const getWeekStats = (): Promise<{ code: string; msg: string; data: WeekStats }> =>
postRequest('axiosRequest', '/mp/ops/workbench/week-stats')
/** 告警列表 */
export const listAlarms = (
params: AlarmListParams,
): Promise<{ code: string; msg: string; data: AlarmItem[] }> =>
postRequest('axiosRequest', '/mp/ops/alarm/list', params)
/** 告警详情 */
export const getAlarmDetail = (
id: string,
): Promise<{ code: string; msg: string; data: AlarmDetail }> =>
getRequest('axiosRequest', '/mp/ops/alarm/detail', { id })
/** 告警处置 */
export const handleAlarm = (
params: AlarmHandleParams,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/alarm/handle', params)
@@ -0,0 +1,81 @@
/**
* 实施运维小程序 / 工作台 + 告警 类型定义
*/
/** 今日待办统计(工作台 5 张卡) */
export interface TodoStats {
pendingWorkOrders: number
overSlaOrders: number
todayInspections: number
inspectionDone: number
todayOtaTasks: number
todayInitDevices: number
pendingAlarms: number
criticalAlarms: number
}
/** 本周统计(工作台 3 张卡) */
export interface WeekStats {
weekOrders: number
avgProcessHours: number
weekInspectionRate: string
weekInspectionAbnormalRate: string
monthInstallCount: number
}
/** 快捷入口项 */
export interface QuickEntry {
key: string
label: string
icon: string
path: string
}
/** 告警来源 */
export type AlarmSource = 'runLog' | 'selfCheck' | 'inspection' | 'sla' | 'ota' | 'dataDistribute'
/** 告警级别 */
export type AlarmLevel = 'info' | 'warn' | 'critical'
/** 告警状态 */
export type AlarmStatus = 'pending' | 'processing' | 'ignored' | 'workorder'
/** 告警列表项 */
export interface AlarmItem {
id: string
alarmCode: string
source: AlarmSource
level: AlarmLevel
deviceCode: string
deviceName: string
content: string
triggerTime: string
status: AlarmStatus
handler: string | null
}
/** 告警详情(含时间线 + 设备扩展信息) */
export interface AlarmDetail extends AlarmItem {
deviceModel: string
site: string
tenant: string
processTimeline: Array<{ time: string; action: string; operator: string }>
}
/** 告警列表查询参数 */
export interface AlarmListParams {
level?: AlarmLevel | ''
source?: AlarmSource | ''
status?: AlarmStatus | ''
keyword?: string
}
/** 告警处置动作 */
export type AlarmHandleAction = 'toWorkOrder' | 'ignore' | 'resolve'
/** 告警处置参数 */
export interface AlarmHandleParams {
id: string
action: AlarmHandleAction
reason: string
}