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:
@@ -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
|
||||
│
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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";若有关联工单则同时触发工单关单流程
|
||||
|
||||
**子页面 5:OTA 升级(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 端
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user