feat: 运维小程序巡检创建/我的账户/工作台库存与 OTA 列表落地 + 设备中心 mock 与字段完善

- 运维小程序新增: 巡检创建/巡检详情完善、我的-账户信息/修改密码、工作台-初始化列表/OTA列表/库存盘点创建与列表
- 工单/扫码页字段与流程细节调整,巡检 constants 与 types 抽离
- 设备中心告警/初始化/监测/库存/资产 mock handler 与字段类型统一完善
- nav.ts 同步新增菜单项

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fengpu
2026-07-10 16:34:28 +08:00
co-authored by Claude Opus 4.7
parent 6d2e885fd0
commit e586461af7
67 changed files with 4153 additions and 786 deletions
+2 -16
View File
@@ -5,7 +5,7 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
* 设备运维 / 告警管理 mock
* - 告警列表:转工单 / 忽略 / 批量处理
* - 告警详情:基础信息 + 报错日志(代码风格多行文本)
* - 设备分类下拉:末级分类名(与设备监测模块对齐)
* - 设备分类下拉:末级分类名(与设备监测模块对齐,分类树由 /admin/device/category/tree 统一提供
*/
type AlarmSource = 'runLog' | 'selfCheck' | 'inspection' | 'sla' | 'ota' | 'dataDistribute'
@@ -77,7 +77,7 @@ Caused by: com.yx.device.HardwareException: 压缩机响应超时 (3000ms)
},
{
id: 'alm-002', alarmCode: 'ALM20260703002', source: 'ota', level: 'warn',
deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)', deviceCategory: '智能手表',
deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)', deviceCategory: '华为-watch5',
content: 'OTA 升级失败:设备位于 v1.0.4,目标 v1.1.0,已回滚',
triggerTime: '2026-07-03 10:12:48', status: 'processing', handler: '李工程师',
triggerRule: 'OTA 任务执行结果 = failwarn',
@@ -211,17 +211,6 @@ Caused by: java.security.SignatureException: Signature does not match
},
]
const CATEGORY_OPTIONS: { name: string }[] = [
{ name: '智能货柜' },
{ name: '智能手表' },
{ name: '智能健康一体机' },
{ name: '体脂仪' },
{ name: '蓝牙秤' },
{ name: '健身车' },
{ name: '带鱼屏' },
{ name: '入库秤' },
]
const listAlarm = (ctx: MockContext): ApiResponse<AlarmRecord[]> => {
const params = getParams(ctx.params)
const filtered = alarmList.filter((item) => {
@@ -281,8 +270,6 @@ const batchHandle = (ctx: MockContext): ApiResponse<null> => {
return ok(type === 'workorder' ? '批量转工单成功' : '批量忽略成功')
}
const categoryOpts = (): ApiResponse<{ name: string }[]> => ok('查询成功', CATEGORY_OPTIONS)
registerMocks([
{ method: 'POST', url: '/admin/device/ops/alarm/list', handler: listAlarm },
{ method: 'POST', url: '/admin/device/ops/alarm/stats', handler: statsAlarm },
@@ -290,5 +277,4 @@ registerMocks([
{ method: 'POST', url: '/admin/device/ops/alarm/to-work-order', handler: toWorkOrder },
{ method: 'POST', url: '/admin/device/ops/alarm/ignore', handler: ignoreAlarm },
{ method: 'POST', url: '/admin/device/ops/alarm/batch', handler: batchHandle },
{ method: 'POST', url: '/admin/device/ops/alarm/category/options', handler: categoryOpts },
])
+8 -21
View File
@@ -6,14 +6,14 @@
* - info 详情(含类别 / 数据分发 / 软件版本详情)
* - set-version / batch-set-version 版本设置
* - export 异步导出
* - app/version/category/customer/site 下拉
* - app/version/customer/site 下拉
* - 设备分类下拉:末级分类名(与设备监测 / 告警模块对齐,分类树由 /admin/device/category/tree 统一提供)
*/
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
import type {
AppOption,
CustomerOption,
DeviceCategoryOption,
InitDetail,
InitItem,
SiteOption,
@@ -57,15 +57,6 @@ const VERSION_DATASET: VersionOption[] = [
{ id: 'ver-006', appId: 'app-004', version: 'V0.9.5', description: '净菜柜屏固件 App', upgradePoints: '柜门传感器识别优化', packageUrl: 'https://mock.example.com/pkg/app-004/v0.9.5.bin' },
]
/** 设备类别 mock(二级分类名) */
const CATEGORY_DATASET: DeviceCategoryOption[] = [
{ name: '体重秤' },
{ name: '体脂仪' },
{ name: '智能手表' },
{ name: '体检一体机' },
{ name: '净菜柜' },
]
/** 客户 mock */
const CUSTOMER_DATASET: CustomerOption[] = [
{ id: 'cus-001', name: '阳光养老院' },
@@ -86,7 +77,7 @@ const INIT_DATASET: InitItem[] = [
{
id: 'dev-001',
deviceCode: 'WT-2025-0001',
deviceCategory: '体重秤',
deviceCategory: '蓝牙秤',
deviceName: '智养体重秤',
serialNumber: 'SN-WT-2025-0001',
initStatus: 'pending',
@@ -128,7 +119,7 @@ const INIT_DATASET: InitItem[] = [
{
id: 'dev-003',
deviceCode: 'WT-2025-0003',
deviceCategory: '智能手表',
deviceCategory: '华为-watch5',
deviceName: '智养手表',
serialNumber: 'SN-WT-2025-0003',
initStatus: 'pending',
@@ -149,7 +140,7 @@ const INIT_DATASET: InitItem[] = [
{
id: 'dev-004',
deviceCode: 'HC-2025-0004',
deviceCategory: '体检一体机',
deviceCategory: '智能健康一体机',
deviceName: '智养体检一体机',
serialNumber: 'SN-HC-2025-0004',
initStatus: 'pending',
@@ -170,7 +161,7 @@ const INIT_DATASET: InitItem[] = [
{
id: 'dev-005',
deviceCode: 'CB-2025-0005',
deviceCategory: '净菜柜',
deviceCategory: '智能货柜',
deviceName: '智养净菜柜',
serialNumber: 'SN-CB-2025-0005',
initStatus: 'pending',
@@ -191,7 +182,7 @@ const INIT_DATASET: InitItem[] = [
{
id: 'dev-006',
deviceCode: 'WT-2025-0006',
deviceCategory: '体重秤',
deviceCategory: '蓝牙秤',
deviceName: '智养体重秤',
serialNumber: 'SN-WT-2025-0006',
initStatus: 'success',
@@ -233,7 +224,7 @@ const INIT_DATASET: InitItem[] = [
{
id: 'dev-008',
deviceCode: 'WT-2025-0008',
deviceCategory: '体重秤',
deviceCategory: '蓝牙秤',
deviceName: '智养体重秤',
serialNumber: 'SN-WT-2025-0008',
initStatus: 'success',
@@ -352,9 +343,6 @@ const versionOpts = (ctx: MockContext): ApiResponse<VersionOption[]> => {
return ok('ok', VERSION_DATASET.filter((v) => v.appId === params.appId))
}
/** 设备类别下拉 */
const categoryOpts = (): ApiResponse<DeviceCategoryOption[]> => ok('ok', CATEGORY_DATASET)
/** 客户下拉 */
const customerOpts = (): ApiResponse<CustomerOption[]> => ok('ok', CUSTOMER_DATASET)
@@ -372,7 +360,6 @@ registerMocks([
{ method: 'POST', url: BASE + '/export', handler: exportInit },
{ method: 'POST', url: BASE + '/app/options', handler: appOpts },
{ method: 'POST', url: BASE + '/version/options', handler: versionOpts },
{ method: 'POST', url: BASE + '/category/options', handler: categoryOpts },
{ method: 'POST', url: BASE + '/customer/options', handler: customerOpts },
{ method: 'POST', url: BASE + '/site/options', handler: siteOpts },
])
@@ -82,7 +82,7 @@ const monitorList: MonitorRecord[] = [
],
},
{
id: 'mon-002', deviceCode: 'WAT-W1-0001', deviceName: '健康站01手表', deviceCategory: '智能手表',
id: 'mon-002', deviceCode: 'WAT-W1-0001', deviceName: '健康站01手表', deviceCategory: '华为-watch5',
tenantName: '健康管理机构', siteName: '健康园区体检站', runStatus: 'repairing',
packageName: '智能手表 App', softwareVersion: 'V2.0.0',
lastHeartbeat: '2026-07-02 13:20:00', onlineRate: 78, alertCount: 2, onlineTime: '2026-06-22 09:30:00', isIot: true,
@@ -232,24 +232,9 @@ const remoteMonitor = (ctx: MockContext): ApiResponse<null> => {
return ok('远程重启指令已下发')
}
/** 设备分类下拉(末级分类名,与设备初始化模块对齐) */
const CATEGORY_OPTIONS: { name: string }[] = [
{ name: '智能货柜' },
{ name: '智能手表' },
{ name: '智能健康一体机' },
{ name: '体脂仪' },
{ name: '蓝牙秤' },
{ name: '健身车' },
{ name: '带鱼屏' },
{ name: '入库秤' },
]
const categoryOpts = (): ApiResponse<{ name: string }[]> => ok('查询成功', CATEGORY_OPTIONS)
registerMocks([
{ method: 'POST', url: '/admin/device/ops/monitor/list', handler: listMonitor },
{ method: 'POST', url: '/admin/device/ops/monitor/stats', handler: statsMonitor },
{ method: 'POST', url: '/admin/device/ops/monitor/info', handler: infoMonitor },
{ method: 'POST', url: '/admin/device/ops/monitor/remote', handler: remoteMonitor },
{ method: 'POST', url: '/admin/device/ops/monitor/category/options', handler: categoryOpts },
])
@@ -33,7 +33,7 @@ interface InventoryRecord {
inventoryType: 'full' | 'partial'
operator: string
inventoryTime: string
status: 'draft' | 'completed' | 'audited'
status: 'in_progress' | 'completed' | 'audited'
expectedCount: number
actualCount: number
surplusCount: number
@@ -170,19 +170,19 @@ const inventoryList: InventoryRecord[] = [
},
{
id: 'inv-006', inventoryCode: 'PD-20260701-006', warehouseAreaId: 'wh-floor1-small-shelf-2', warehouseAreaName: '1楼小型备品货架-2号储位',
inventoryType: 'partial', operator: '李工程师', inventoryTime: '2026-07-01 14:20:00', status: 'draft',
inventoryType: 'partial', operator: '李工程师', inventoryTime: '2026-07-01 14:20:00', status: 'in_progress',
expectedCount: 10, actualCount: 0, surplusCount: 0, lossCount: 0,
remark: '等待小程序扫码盘点', items: buildItems(0, 0, 10, 10),
},
{
id: 'inv-007', inventoryCode: 'PD-20260703-007', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位',
inventoryType: 'full', operator: '陈工程师', inventoryTime: '2026-07-03 10:00:00', status: 'draft',
inventoryType: 'full', operator: '陈工程师', inventoryTime: '2026-07-03 10:00:00', status: 'in_progress',
expectedCount: 8, actualCount: 0, surplusCount: 0, lossCount: 0,
remark: '本周新发起', items: buildItems(0, 0, 8, 8),
},
{
id: 'inv-008', inventoryCode: 'PD-20260705-008', warehouseAreaId: 'wh-floor1-small-shelf-1', warehouseAreaName: '1楼小型备品货架-1号储位',
inventoryType: 'full', operator: '张工程师', inventoryTime: '2026-07-05 16:30:00', status: 'draft',
inventoryType: 'full', operator: '张工程师', inventoryTime: '2026-07-05 16:30:00', status: 'in_progress',
expectedCount: 5, actualCount: 0, surplusCount: 0, lossCount: 0,
items: buildItems(0, 0, 5, 5),
},
@@ -250,7 +250,7 @@ const addRecord = (params: PageParams): ApiResponse<null> => {
inventoryType,
operator: '当前用户',
inventoryTime: nowStr(),
status: 'draft',
status: 'in_progress',
expectedCount,
actualCount: 0,
surplusCount: 0,
@@ -274,12 +274,12 @@ const auditRecord = (params: PageParams): ApiResponse<null> => {
return ok('审核完成,库存台账已按实盘数据修正')
}
/** 删除(仅 draft 可删) */
/** 删除(仅 in_progress 可删) */
const deleteById = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const idx = inventoryList.findIndex((item) => item.id === id)
if (idx >= 0) {
if (inventoryList[idx].status !== 'draft') return ok('仅草稿状态可删除')
if (inventoryList[idx].status !== 'in_progress') return ok('仅进行中状态可删除')
inventoryList.splice(idx, 1)
}
return ok('删除成功')
+126 -46
View File
@@ -1,13 +1,16 @@
/**
* 实施运维小程序 / 巡检 mock
* - 计划列表(4 状态
* - 计划列表(按状态过滤,派发/自建通过 planType 字段在卡片上区分,列表不预过滤
* - 任务详情(按设备 × 巡检项)
* - 提交项结果 + 完成任务
* - 创建自建巡检记录(基于客户 + 布点场所)
*/
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, RequestParameter } from '@axios'
import type {
InspectCreateForm,
InspectCreateItem,
InspectItem,
InspectItemSubmit,
InspectPlan,
@@ -24,45 +27,63 @@ const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
const getParams = <T = Record<string, unknown>>(params: RequestParameter): T =>
(params ?? {}) as T
/** 简易自增 id 生成器 */
const genId = (prefix: string): string =>
`${prefix}-${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`
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-001', name: '上海杨浦颐养中心 - 1号食堂月度巡检',
site: '上海杨浦 · 1号食堂', customerName: '上海杨浦颐养中心', siteName: '1号食堂',
deviceCategory: '智养餐饮设备', deviceCount: 5, status: 'doing', planType: 'dispatched',
deadline: '2026-07-15 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-002', name: '北京朝阳福利院 - 体检中心周巡检',
site: '北京朝阳 · 体检中心', customerName: '北京朝阳福利院', siteName: '体检中心',
deviceCategory: '智能穿戴设备', deviceCount: 12, status: 'todo', planType: 'dispatched',
deadline: '2026-07-12 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-003', name: '广州天河颐康医院 - 门诊食堂临时巡检',
site: '广州天河 · 门诊食堂', customerName: '广州天河颐康医院', siteName: '门诊食堂',
deviceCategory: '智养餐饮设备', deviceCount: 3, status: 'overdue', planType: 'dispatched',
deadline: '2026-07-08 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-004', name: '杭州西湖社区养老院 - 社区食堂月度巡检',
site: '杭州西湖 · 社区食堂', customerName: '杭州西湖社区养老院', siteName: '社区食堂',
deviceCategory: '智养餐饮设备', deviceCount: 4, status: 'done', planType: 'dispatched',
deadline: '2026-07-05 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-005', name: '重庆渝中颐养福利中心 - 康复健身房周巡检',
site: '重庆渝中 · 康复健身房', customerName: '重庆渝中颐养福利中心', siteName: '康复健身房',
deviceCategory: '健身器材设备', deviceCount: 8, status: 'todo', planType: 'dispatched',
deadline: '2026-07-13 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-006', name: '上海杨浦颐养中心 - 健康体检站月度巡检',
site: '上海杨浦 · 健康体检站', customerName: '上海杨浦颐养中心', siteName: '健康体检站',
deviceCategory: '健康监测设备', deviceCount: 2, status: 'doing', planType: 'dispatched',
deadline: '2026-07-14 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,
id: 'ip-007', name: '北京朝阳福利院 - 主楼食堂月度巡检',
site: '北京朝阳 · 主楼食堂', customerName: '北京朝阳福利院', siteName: '主楼食堂',
deviceCategory: '智养餐饮设备', deviceCount: 4, status: 'done', planType: 'dispatched',
deadline: '2026-07-04 18:00:00', inspector: '李工程师', itemCount: 6,
},
{
id: 'ip-adhoc-001', name: '上海杨浦颐养中心 - 1号食堂现场自建巡检',
site: '上海杨浦 · 1号食堂', customerName: '上海杨浦颐养中心', siteName: '1号食堂',
deviceCategory: '智养餐饮设备', deviceCount: 2, status: 'doing', planType: 'ad hoc',
deadline: '', inspector: '当前用户', itemCount: 5,
},
]
const buildItems = (): InspectItem[] => [
/** 标准巡检项预置(与新页面 default items 对齐,便于 detail 兜底构造) */
const buildDefaultItems = (): 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: '' },
@@ -71,57 +92,82 @@ const buildItems = (): InspectItem[] => [
{ id: 'it-6', name: '电源稳定性', type: 'function', standard: '无掉电告警', result: 'pending', remark: '' },
]
/** 自建巡检项(id 自动生成) */
const toInspectItems = (items: InspectCreateItem[]): InspectItem[] =>
items.map((it, idx) => ({
id: `it-${idx + 1}`,
name: it.name,
type: it.type,
standard: it.standard,
result: 'pending' as const,
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 },
{ code: 'CAB-SH-001', name: '1号净菜柜', items: buildDefaultItems(), scanned: true },
{ code: 'CAB-SH-002', name: '2号净菜柜', items: buildDefaultItems(), scanned: false },
{ code: 'CAB-SH-003', name: '3号净菜柜', items: buildDefaultItems(), 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 },
{ code: 'WATCH-BJ-014', name: '14号健康手表', items: buildDefaultItems().slice(0, 5), scanned: false },
{ code: 'WATCH-BJ-015', name: '15号健康手表', items: buildDefaultItems().slice(0, 5), scanned: false },
],
},
'ip-adhoc-001': {
plan: plans[7],
devices: [
{ code: 'CAB-SH-008', name: '8号净菜柜', items: buildDefaultItems().slice(0, 5), scanned: false },
{ code: 'CAB-SH-009', name: '9号净菜柜', items: buildDefaultItems().slice(0, 5), scanned: true },
],
},
}
registerMocks([
// 计划列表
// 计划列表(仅按状态过滤,派发/自建不预过滤,由前端卡片 tag 区分)
{
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
let rows = plans
if (status) rows = rows.filter((p) => p.status === status)
return ok('查询成功', rows)
},
},
// 任务详情
// 任务详情(兜底时把构造的 detail 写入缓存,保证后续 submit 能命中)
{
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 },
],
})
const planId = id ?? ''
// 命中缓存直接返回
if (taskDetailMap[planId]) return ok('查询成功', taskDetailMap[planId])
// 兜底:根据 plan 构造一个空任务,并写入缓存
const plan = plans.find((p) => p.id === planId)
if (!plan) return ok('计划不存在', null)
const fallback: InspectTaskDetail = {
plan,
devices: [
{
code: `${plan.id}-D001`,
name: '示例设备',
items: buildDefaultItems().slice(0, plan.itemCount),
scanned: false,
},
],
}
return ok('查询成功', detail)
taskDetailMap[planId] = fallback
return ok('查询成功', fallback)
},
},
// 提交巡检项结果
// 提交巡检项结果(容错:缓存不存在也返回成功,避免阻断流程)
{
url: '/mp/ops/inspect/submit',
method: 'POST',
@@ -136,8 +182,8 @@ registerMocks([
item.remark = remark
}
}
// 异常项自动转工单(mock 不实际创建,仅返回成功
return ok(result === 'abnormal' ? '已记录异常,并自动转工单' : '已记录', null)
// 异常项自动转工单(mock 不实际创建,仅返回提示文案
return ok(result === 'abnormal' ? '已记录异常,并自动转工单' : '已记录合格', null)
},
},
// 完成巡检任务
@@ -151,4 +197,38 @@ registerMocks([
return ok('巡检任务已完成', null)
},
},
// 创建自建巡检记录(基于客户 + 布点场所)
{
url: '/mp/ops/inspect/create',
method: 'POST',
handler: (ctx): ApiResponse<{ planId: string }> => {
const form = getParams<InspectCreateForm>(ctx.params)
const planId = genId('ip-adhoc')
const newPlan: InspectPlan = {
id: planId,
name: `${form.customerName} - ${form.siteName} 现场自建巡检`,
site: `${form.customerName} · ${form.siteName}`,
customerName: form.customerName,
siteName: form.siteName,
deviceCategory: form.deviceCategory,
deviceCount: form.devices.length,
status: 'doing',
planType: 'ad hoc',
deadline: '', // 自建记录无截止时间
inspector: '当前用户',
itemCount: form.items.length,
}
plans.unshift(newPlan)
taskDetailMap[planId] = {
plan: newPlan,
devices: form.devices.map((d) => ({
code: d.code,
name: d.name,
items: toInspectItems(form.items),
scanned: false,
})),
}
return ok('自建巡检记录已创建', { planId })
},
},
])
+52 -1
View File
@@ -7,7 +7,14 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, RequestParameter } from '@axios'
import type { MessageItem, MessageType, OfflineStatus, PersonalStats } from '@pages/miniprogram/ops/me/types'
import type {
ChangePasswordParams,
MessageItem,
MessageType,
OfflineStatus,
PersonalStats,
UserProfile,
} from '@pages/miniprogram/ops/me/types'
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
@@ -78,6 +85,23 @@ const offline: OfflineStatus = {
lastSyncTime: '2026-07-03 08:45:11',
}
// 当前账号信息(mock 写死,对应 Home.vue 顶部"李工程师 / 实施运维工程师")
const profile: UserProfile = {
userId: 'U-ENG-0015',
name: '李工程师',
role: '实施运维工程师',
jobNumber: 'OP-2024-0015',
phone: '138****6621',
department: '华东运维中心 · 上海大区',
jobTitle: '高级运维工程师',
region: '上海-浦东-张江',
hireDate: '2024-03-15',
lastLoginAt: '2026-07-09 21:12:34',
}
// mock 写死的旧密码,用于修改密码时的服务端校验
const MOCK_OLD_PASSWORD = 'old123456'
registerMocks([
// 个人统计
{
@@ -141,4 +165,31 @@ registerMocks([
return ok('清除完成', null)
},
},
// 账号信息
{
url: '/mp/ops/me/profile',
method: 'GET',
handler: (): ApiResponse<UserProfile> => ok('查询成功', profile),
},
// 修改密码
{
url: '/mp/ops/me/change-password',
method: 'POST',
handler: (ctx): ApiResponse<null> => {
const { oldPassword, newPassword } = getParams<ChangePasswordParams>(ctx.params)
if (oldPassword !== MOCK_OLD_PASSWORD) {
return { code: '50001', msg: '旧密码不正确', data: null }
}
if (newPassword === MOCK_OLD_PASSWORD) {
return { code: '50002', msg: '新密码不能与旧密码相同', data: null }
}
return ok('修改成功,请重新登录', null)
},
},
// 退出登录
{
url: '/mp/ops/me/logout',
method: 'POST',
handler: (): ApiResponse<null> => ok('已退出登录', null),
},
])
+58 -27
View File
@@ -18,6 +18,7 @@ import type {
OtaVersion,
RepairForm,
RunLogItem,
SimUnboundOption,
} from '@pages/miniprogram/ops/scan/types'
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
@@ -29,48 +30,48 @@ const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
const getParams = <T = Record<string, unknown>>(params: RequestParameter): T =>
(params ?? {}) as T
/** 设备档案数据集(覆盖 5 大顶级分类 × 4 状态) */
/** 设备档案数据集(覆盖 5 大顶级分类 × 4 状态,字段名对齐后台 monitor */
const deviceMap: Record<string, DeviceSummary> = {
'CAB-SH-001': {
id: 'dev-001', code: 'CAB-SH-001', name: '1号净菜柜(上海浦东)',
id: 'dev-001', deviceCode: 'CAB-SH-001', deviceName: '1号净菜柜(上海浦东)',
category: 'catering', model: 'NZG-200',
status: 'inUse', site: '上海浦东 · 张江食堂', tenant: 'CQ 能源集团',
firmwareVersion: 'V2.3.1', lastHeartbeat: '2026-07-03 09:30:11',
status: 'inUse', siteName: '上海浦东 · 张江食堂', tenantName: 'CQ 能源集团',
packageName: '净菜柜固件', softwareVersion: '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号健康手表(北京朝阳)',
id: 'dev-014', deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)',
category: 'wearable', model: 'WATCH5-PRO',
status: 'inUse', site: '北京朝阳 · 朝阳体检站', tenant: '北京健康管理机构',
firmwareVersion: 'v1.0.4', lastHeartbeat: '2026-07-03 09:32:48',
status: 'inUse', siteName: '北京朝阳 · 朝阳体检站', tenantName: '北京健康管理机构',
packageName: '健康手表App', softwareVersion: '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号跑步机(重庆渝中)',
id: 'dev-022', deviceCode: 'TREADMILL-CQ-002', deviceName: '2号跑步机(重庆渝中)',
category: 'fitness', model: 'TM-X1',
status: 'inUse', site: '重庆渝中 · 解放碑健身房', tenant: '重庆健身中心',
firmwareVersion: 'V1.5.2', lastHeartbeat: '2026-07-03 09:25:00',
status: 'inUse', siteName: '重庆渝中 · 解放碑健身房', tenantName: '重庆健身中心',
packageName: '健身器材固件', softwareVersion: '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号体脂仪(杭州西湖)',
id: 'dev-035', deviceCode: 'BODYFAT-HZ-005', deviceName: '5号体脂仪(杭州西湖)',
category: 'body', model: 'InBody-770',
status: 'inUse', site: '杭州西湖 · 西湖体检站', tenant: '杭州健康管理机构',
firmwareVersion: 'V3.0.1', lastHeartbeat: '2026-07-03 09:28:33',
status: 'inUse', siteName: '杭州西湖 · 西湖体检站', tenantName: '杭州健康管理机构',
packageName: '体脂仪App', softwareVersion: '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号体检一体机(深圳南山)',
id: 'dev-048', deviceCode: 'CHECK-SZ-003', deviceName: '3号体检一体机(深圳南山)',
category: 'health', model: 'ALL-IN-ONE-15',
status: 'inUse', site: '深圳南山 · 科技园体检站', tenant: '深圳健康管理机构',
firmwareVersion: 'V2.1.0', lastHeartbeat: '2026-07-03 09:20:11',
status: 'inUse', siteName: '深圳南山 · 科技园体检站', tenantName: '深圳健康管理机构',
packageName: '体检一体机App', softwareVersion: '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: '新装净菜柜(待激活)',
id: 'dev-091', deviceCode: 'CAB-NEW-001', deviceName: '新装净菜柜(待激活)',
category: 'catering', model: 'NZG-200',
status: 'inactive', site: '上海浦东 · 张江食堂', tenant: 'CQ 能源集团',
firmwareVersion: '—', lastHeartbeat: '—',
status: 'inactive', siteName: '上海浦东 · 张江食堂', tenantName: 'CQ 能源集团',
packageName: '—', softwareVersion: '—', lastHeartbeat: '—',
pendingWorkOrders: 0, isIot: true, dataDistributeEnabled: false,
},
}
@@ -85,14 +86,14 @@ const scanHistory = [
{ id: 'h-006', deviceCode: 'CHECK-SZ-003', deviceName: '3号体检一体机(深圳南山)', scannedAt: '2026-07-01 16:40:00', action: 'OTA 升级' },
]
/** OTA 可用版本 */
/** OTA 可用版本status 与后台 version VersionStatus 对齐:active 有效 / disabled 停用) */
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' },
{ version: 'v1.1.0', releaseNote: '新增睡眠分析、修复心率异常告警', status: 'active', size: '4.2MB' },
{ version: 'v1.0.5', releaseNote: '修复蓝牙连接稳定性(已停用)', status: 'disabled', size: '2.1MB' },
],
'CAB-SH-001': [
{ version: 'V2.4.0', releaseNote: '压缩机优化、温度校准算法升级', status: 'full', size: '8.5MB' },
{ version: 'V2.4.0', releaseNote: '压缩机优化、温度校准算法升级', status: 'active', size: '8.5MB' },
],
}
@@ -133,6 +134,20 @@ const updatesMap: Record<string, MonitorUpdateItem[]> = {
],
}
/** 未绑定 SIM 卡下拉数据集(设备初始化时选用,≤8 条) */
const unboundSims: SimUnboundOption[] = [
{ id: 'sim-001', iccid: '898604021920C0000001', phoneNo: '13800001001', carrier: 'mobile', packageType: '物联网 30M/月' },
{ id: 'sim-002', iccid: '898604021920C0000002', phoneNo: '13800001002', carrier: 'mobile', packageType: '物联网 100M/月' },
{ id: 'sim-003', iccid: '898601112020C0000003', phoneNo: '18600001003', carrier: 'unicom', packageType: '物联网 50M/月' },
{ id: 'sim-004', iccid: '898611112020C0000004', phoneNo: '18900001004', carrier: 'telecom', packageType: '物联网 50M/月' },
]
/** 虹软 activeKey 生成器(每次随机 8 位 hex,模拟自动获取并绑定) */
const genArcsoftKey = (): string => {
const seg = (): string => Math.random().toString(16).toUpperCase().slice(2, 6).padEnd(4, '0')
return `ARC-${seg()}-${seg()}-${seg()}`
}
registerMocks([
// 扫码识别设备
{
@@ -151,6 +166,22 @@ registerMocks([
method: 'GET',
handler: (): ApiResponse<typeof scanHistory> => ok('查询成功', scanHistory),
},
// 自动获取虹软人脸识别激活码
{
url: '/mp/ops/scan/arcsoft/fetch',
method: 'GET',
handler: (ctx): ApiResponse<{ activeKey: string }> => {
const { deviceCode } = getParams<{ deviceCode?: string }>(ctx.params)
if (!deviceCode) return ok('设备编码缺失', { activeKey: '' })
return ok('获取成功', { activeKey: genArcsoftKey() })
},
},
// 查询未绑定 SIM 卡列表
{
url: '/mp/ops/scan/sim/unbound',
method: 'GET',
handler: (): ApiResponse<SimUnboundOption[]> => ok('查询成功', unboundSims),
},
// 设备初始化
{
url: '/mp/ops/scan/init',
@@ -160,7 +191,7 @@ registerMocks([
const dev = deviceMap[form.deviceCode]
if (dev && form.result === 'success') {
dev.status = 'activated'
dev.firmwareVersion = form.firmwareBaseline
dev.softwareVersion = form.firmwareBaseline
}
return ok('初始化上报成功', null)
},
@@ -200,7 +231,7 @@ registerMocks([
const task: OtaTask = {
id: `ota-${Date.now()}`,
deviceCode,
currentVersion: dev?.firmwareVersion ?? '—',
currentVersion: dev?.softwareVersion ?? '—',
targetVersion,
progress: 0,
status: 'upgrading',
@@ -225,7 +256,7 @@ registerMocks([
if (task.progress >= 100) {
task.status = 'success'
const dev = deviceMap[deviceCode!]
if (dev) dev.firmwareVersion = task.targetVersion
if (dev) dev.softwareVersion = task.targetVersion
}
}
}
@@ -242,7 +273,7 @@ registerMocks([
if (task) {
task.status = 'rolledback'
const dev = deviceMap[deviceCode]
if (dev) dev.firmwareVersion = task.currentVersion
if (dev) dev.softwareVersion = task.currentVersion
}
return ok('回滚成功', null)
},
+321 -79
View File
@@ -1,11 +1,15 @@
/**
* 实施运维小程序 / 库存作业 mock
* - 收货入库:仓库下拉 + 设备详情 + 提交
* - 装机出库:客户下拉 + 提交
* - 库存盘点:盘点单详情 + 提交(带 items[].result
* - 库存查询:在库设备列表(只读
* - 收货入库:仓库 + 供应商 + 设备类别 + 批次 + 单价 + 提交
* - 装机出库:仓库 + 客户 + 出库类型(装机/调拨/报废) + 提交
* - 库存盘点:
* · 列表 /mp/ops/stock/inventory/list(按后台 ListItem 字段对齐:盘点单号 + 仓库区域 + 类型 + 状态 + 统计
* · 新建 /mp/ops/stock/inventory/create(仓库区域 + 类型 full/partial + 应盘品类 + 备注)
* · 详情 /mp/ops/stock/inventory/detail(按"末级分类 + 批次"维度返回应盘清单,对齐后台 InventoryItem
* · 提交 /mp/ops/stock/inventory/submit(按 categoryId+batchNo 维度提交实盘结果)
* - 库存查询:聚合维度列表(与后台 instock ListItem 对齐)
*
* 数据风格对齐后台设备中心 / 库存管理 4 个页面,但字段精简适配手机端
* 数据风格对齐后台设备中心 / 库存管理 4 个页面outbound / inbound / instock / inventory
*/
import { registerMocks } from '@axios/mockBus'
@@ -13,72 +17,112 @@ import type { ApiResponse, RequestParameter } from '@axios'
interface WarehouseOption { id: string; name: string }
interface CustomerOption { id: string; name: string }
interface SupplierOption { id: string; name: string }
interface CategoryOption { id: string; name: string }
interface SiteOption { id: string; name: string; customerId: string }
interface DeviceDetail {
deviceCode: string
deviceName: string
modelName: string
batchNo: string
categoryId?: string
categoryName?: string
activated?: boolean
}
/** 库存查询列表项(与后台 instock ListItem 聚合维度对齐) */
interface StockItem {
id: string
deviceCode: string
deviceName: string
modelName: string
categoryId: string
categoryName: string
batchNo: string
warehouseId: string
warehouseName: string
quantity: number
unit: string
status: 'instock' | 'checkout' | 'frozen'
inboundTime: string
supplierId: string
supplierName: string
warehouseAreaId: string
warehouseAreaName: string
stockQty: number
lockedQty: number
availableQty: number
healthLevel: 'sufficient' | 'warning' | 'out_of_stock'
updateTime: string
}
/** 盘点单状态:in_progress 进行中 / completed 已完成(待审核)/ audited 已审核 */
type InventoryStatus = 'in_progress' | 'completed' | 'audited'
/** 盘点类型:full 全量盘点 / partial 部分盘点 */
type InventoryType = 'full' | 'partial'
/** 应盘条目实盘结果(按"末级分类 + 批次"为粒度) */
type InventoryItemResult = 'matched' | 'surplus' | 'loss' | 'unchecked'
/** 应盘清单条目(按"设备类别末级分类 + 批次"聚合) */
interface InventoryItem {
deviceCode: string
deviceName: string
modelName: string
categoryId: string
categoryName: string
batchNo: string
expectedQuantity: number
actualQuantity: number
result: 'matched' | 'surplus' | 'loss' | 'unchecked'
result: InventoryItemResult
}
interface InventoryDetail {
/** 盘点单列表项(与后台 ListItem 字段对齐) */
interface InventoryListItem {
id: string
inventoryCode: string
warehouseId: string
warehouseName: string
inventoryType: 'full' | 'partial'
warehouseAreaId: string
warehouseAreaName: string
inventoryType: InventoryType
operator: string
inventoryTime: string
status: InventoryStatus
expectedCount: number
actualCount: number
surplusCount: number
lossCount: number
auditor?: string
auditTime?: string
remark?: string
}
/** 盘点单详情(含应盘清单) */
interface InventoryDetail extends InventoryListItem {
items: InventoryItem[]
}
/** 入库提交 body(与后台 inbound InboundPayload 对齐) */
interface InboundSubmitBody {
warehouseId: string
inboundType: 'purchase' | 'return' | 'transfer'
categoryId: string
supplierId: string
purchasePrice: number
batchNo?: string
remark?: string
deviceCodes: string[]
}
/** 出库提交 body(与后台 outbound OutboundPayload 对齐,无 repair */
interface OutboundSubmitBody {
warehouseId: string
outboundType: 'install' | 'repair' | 'scrap' | 'transfer'
outboundType: 'install' | 'transfer' | 'scrap'
customerId?: string
siteName?: string
remark?: string
deviceCodes: string[]
}
/** 新建盘点单提交 body(对齐后台 InventoryPayload */
interface InventoryCreateBody {
warehouseAreaId: string
inventoryType: InventoryType
categoryIds?: string[]
remark?: string
}
/** 盘点单提交 body(按 categoryId+batchNo 维度) */
interface InventorySubmitBody {
id: string
items: Array<{ deviceCode: string; actualQuantity: number; result: 'matched' | 'surplus' | 'loss' | 'unchecked' }>
items: Array<{ categoryId: string; batchNo: string; actualQuantity: number; result: 'matched' | 'surplus' | 'loss' }>
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
@@ -101,62 +145,168 @@ const customers: CustomerOption[] = [
{ id: 'cus-005', name: '广州天河体检站' },
]
/** 客户→布点场所映射(每个客户 2-3 个场所,对齐后台 customer/site 业务) */
const sites: SiteOption[] = [
{ id: 'site-001', customerId: 'cus-001', name: 'CQ 集团 · 总部食堂' },
{ id: 'site-002', customerId: 'cus-001', name: 'CQ 集团 · 江北员工餐厅' },
{ id: 'site-003', customerId: 'cus-002', name: '北京健康中心 · 一层大厅' },
{ id: 'site-004', customerId: 'cus-002', name: '北京健康中心 · 体检区' },
{ id: 'site-005', customerId: 'cus-003', name: '上海张江食堂 · 主餐厅' },
{ id: 'site-006', customerId: 'cus-003', name: '上海张江食堂 · 取餐区' },
{ id: 'site-007', customerId: 'cus-003', name: '上海张江食堂 · 健康驿站' },
{ id: 'site-008', customerId: 'cus-004', name: '杭州西湖社区 · 一楼服务台' },
{ id: 'site-009', customerId: 'cus-005', name: '广州天河体检站 · 候检区' },
]
const suppliers: SupplierOption[] = [
{ id: 'sup-001', name: '智养设备制造有限公司' },
{ id: 'sup-002', name: '杭州健康科技公司' },
{ id: 'sup-003', name: '深圳一体机厂商' },
{ id: 'sup-004', name: '北京穿戴设备供应商' },
]
const categories: CategoryOption[] = [
{ id: 'cat-cabinet', name: '净菜柜' },
{ id: 'cat-scale', name: '蓝牙秤' },
{ id: 'cat-bodyfat', name: '体脂仪' },
{ id: 'cat-inboundscale', name: '入库秤' },
{ id: 'cat-watch', name: '健康手表' },
{ id: 'cat-checkall', name: '体检一体机' },
]
const devicePool: DeviceDetail[] = [
{ deviceCode: 'DEV-0001', deviceName: '1号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-001', activated: true },
{ deviceCode: 'DEV-0002', deviceName: '蓝牙秤 A', modelName: 'CS-蓝牙秤 v3', batchNo: 'B2026-002', activated: false },
{ deviceCode: 'DEV-0003', deviceName: '体脂仪 1', modelName: 'BF-体脂仪 X1', batchNo: 'B2026-003', activated: true },
{ deviceCode: 'DEV-0004', deviceName: '入库秤 2', modelName: 'IS-入库秤 K2', batchNo: 'B2026-004', activated: false },
{ deviceCode: 'DEV-0005', deviceName: '2号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-005', activated: true },
{ deviceCode: 'DEV-0001', deviceName: '1号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-001', categoryId: 'cat-cabinet', categoryName: '净菜柜', activated: true },
{ deviceCode: 'DEV-0002', deviceName: '蓝牙秤 A', modelName: 'CS-蓝牙秤 v3', batchNo: 'B2026-002', categoryId: 'cat-scale', categoryName: '蓝牙秤', activated: false },
{ deviceCode: 'DEV-0003', deviceName: '体脂仪 1', modelName: 'BF-体脂仪 X1', batchNo: 'B2026-003', categoryId: 'cat-bodyfat', categoryName: '体脂仪', activated: true },
{ deviceCode: 'DEV-0004', deviceName: '入库秤 2', modelName: 'IS-入库秤 K2', batchNo: 'B2026-004', categoryId: 'cat-inboundscale', categoryName: '入库秤', activated: false },
{ deviceCode: 'DEV-0005', deviceName: '2号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-005', categoryId: 'cat-cabinet', categoryName: '净菜柜', activated: true },
]
/** 库存查询列表(聚合维度,8 条覆盖各类目 × 健康度) */
const stockList: StockItem[] = [
{ id: 'stk-001', deviceCode: 'DEV-0001', deviceName: '1号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-001', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库', quantity: 1, unit: '台', status: 'instock', inboundTime: '2026-06-01 10:15:00' },
{ id: 'stk-002', deviceCode: 'DEV-0002', deviceName: '蓝牙秤 A', modelName: 'CS-蓝牙秤 v3', batchNo: 'B2026-002', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库', quantity: 5, unit: '台', status: 'instock', inboundTime: '2026-06-05 11:00:00' },
{ id: 'stk-003', deviceCode: 'DEV-0003', deviceName: '体脂仪 1', modelName: 'BF-体脂仪 X1', batchNo: 'B2026-003', warehouseId: 'wh-floor2', warehouseName: '二楼备品仓库', quantity: 3, unit: '台', status: 'instock', inboundTime: '2026-06-10 09:30:00' },
{ id: 'stk-004', deviceCode: 'DEV-0004', deviceName: '入库秤 2', modelName: 'IS-入库秤 K2', batchNo: 'B2026-004', warehouseId: 'wh-floor1-large', warehouseName: '大型设备区', quantity: 2, unit: '台', status: 'frozen', inboundTime: '2026-06-12 14:00:00' },
{ id: 'stk-005', deviceCode: 'DEV-0005', deviceName: '2号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-005', warehouseId: 'wh-floor1-small', warehouseName: '小型设备区', quantity: 1, unit: '台', status: 'instock', inboundTime: '2026-06-15 16:30:00' },
{ id: 'stk-006', deviceCode: 'DEV-0006', deviceName: '体脂仪 2', modelName: 'BF-体脂仪 X1', batchNo: 'B2026-006', warehouseId: 'wh-floor2-check', warehouseName: '待检区', quantity: 1, unit: '台', status: 'instock', inboundTime: '2026-06-20 10:00:00' },
{ id: 'stk-007', deviceCode: 'DEV-0007', deviceName: '蓝牙秤 B', modelName: 'CS-蓝牙秤 v3', batchNo: 'B2026-007', warehouseId: 'wh-floor1-large-a', warehouseName: 'A号货架', quantity: 1, unit: '台', status: 'checkout', inboundTime: '2026-06-25 09:00:00' },
{ id: 'stk-008', deviceCode: 'DEV-0008', deviceName: '3号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-008', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库', quantity: 1, unit: '台', status: 'instock', inboundTime: '2026-06-28 13:45:00' },
{ id: 'stk-001', categoryId: 'cat-cabinet', categoryName: '净菜柜', batchNo: 'B2026-001', supplierId: 'sup-001', supplierName: '智养设备制造有限公司', warehouseAreaId: 'wh-floor1', warehouseAreaName: '一楼设备仓库', stockQty: 3, lockedQty: 0, availableQty: 3, healthLevel: 'sufficient', updateTime: '2026-06-28 13:45:00' },
{ id: 'stk-002', categoryId: 'cat-scale', categoryName: '蓝牙秤', batchNo: 'B2026-002', supplierId: 'sup-002', supplierName: '杭州健康科技公司', warehouseAreaId: 'wh-floor1', warehouseAreaName: '一楼设备仓库', stockQty: 5, lockedQty: 1, availableQty: 4, healthLevel: 'sufficient', updateTime: '2026-06-25 09:00:00' },
{ id: 'stk-003', categoryId: 'cat-bodyfat', categoryName: '体脂仪', batchNo: 'B2026-003', supplierId: 'sup-002', supplierName: '杭州健康科技公司', warehouseAreaId: 'wh-floor2', warehouseAreaName: '二楼备品仓库', stockQty: 3, lockedQty: 0, availableQty: 3, healthLevel: 'sufficient', updateTime: '2026-06-20 10:00:00' },
{ id: 'stk-004', categoryId: 'cat-inboundscale', categoryName: '入库秤', batchNo: 'B2026-004', supplierId: 'sup-001', supplierName: '智养设备制造有限公司', warehouseAreaId: 'wh-floor1-large', warehouseAreaName: '大型设备区', stockQty: 2, lockedQty: 1, availableQty: 1, healthLevel: 'warning', updateTime: '2026-06-18 14:30:00' },
{ id: 'stk-005', categoryId: 'cat-cabinet', categoryName: '净菜柜', batchNo: 'B2026-005', supplierId: 'sup-001', supplierName: '智养设备制造有限公司', warehouseAreaId: 'wh-floor1-small', warehouseAreaName: '小型设备区', stockQty: 1, lockedQty: 0, availableQty: 1, healthLevel: 'warning', updateTime: '2026-06-15 16:30:00' },
{ id: 'stk-006', categoryId: 'cat-bodyfat', categoryName: '体脂仪', batchNo: 'B2026-006', supplierId: 'sup-002', supplierName: '杭州健康科技公司', warehouseAreaId: 'wh-floor2-check', warehouseAreaName: '待检区', stockQty: 1, lockedQty: 0, availableQty: 1, healthLevel: 'warning', updateTime: '2026-06-10 09:30:00' },
{ id: 'stk-007', categoryId: 'cat-scale', categoryName: '蓝牙秤', batchNo: 'B2026-007', supplierId: 'sup-002', supplierName: '杭州健康科技公司', warehouseAreaId: 'wh-floor1-large-a', warehouseAreaName: 'A号货架', stockQty: 0, lockedQty: 0, availableQty: 0, healthLevel: 'out_of_stock', updateTime: '2026-06-05 11:00:00' },
{ id: 'stk-008', categoryId: 'cat-watch', categoryName: '健康手表', batchNo: 'B2026-008', supplierId: 'sup-004', supplierName: '北京穿戴设备供应商', warehouseAreaId: 'wh-floor1', warehouseAreaName: '一楼设备仓库', stockQty: 8, lockedQty: 0, availableQty: 8, healthLevel: 'sufficient', updateTime: '2026-07-01 10:15:00' },
]
const buildInventoryItems = (total: number, unchecked: number): InventoryItem[] => {
const items: InventoryItem[] = []
for (let i = 0; i < total; i++) {
items.push({
deviceCode: `DEV-${String(i + 1).padStart(4, '0')}`,
deviceName: `设备${i + 1}`,
modelName: ['智养净菜柜', '蓝牙秤', '体脂仪', '入库秤'][i % 4],
batchNo: `B2026-${String((i % 8) + 1).padStart(3, '0')}`,
expectedQuantity: 1,
actualQuantity: i < total - unchecked ? 1 : 0,
result: i < total - unchecked ? 'matched' : 'unchecked',
})
}
return items
}
/** 按"末级分类 + 批次"维度构造应盘清单 */
const buildInventoryItems = (
seeds: Array<{ categoryId: string; categoryName: string; batchNo: string; expected: number; result?: InventoryItemResult; actual?: number }>,
): InventoryItem[] =>
seeds.map((s) => ({
categoryId: s.categoryId,
categoryName: s.categoryName,
batchNo: s.batchNo,
expectedQuantity: s.expected,
actualQuantity: s.actual ?? (s.result === 'matched' ? s.expected : 0),
result: s.result ?? 'unchecked',
}))
/** 应盘种子数据(按仓库区域 + 在库台账虚构) */
const seedFloor1Small = [
{ categoryId: 'cat-cabinet', categoryName: '净菜柜', batchNo: 'B2026-005', expected: 1 },
{ categoryId: 'cat-bodyfat', categoryName: '体脂仪', batchNo: 'B2026-003', expected: 2 },
{ categoryId: 'cat-watch', categoryName: '健康手表', batchNo: 'B2026-008', expected: 4 },
]
const seedFloor1LargeA = [
{ categoryId: 'cat-scale', categoryName: '蓝牙秤', batchNo: 'B2026-002', expected: 5 },
{ categoryId: 'cat-inboundscale', categoryName: '入库秤', batchNo: 'B2026-004', expected: 2 },
{ categoryId: 'cat-watch', categoryName: '健康手表', batchNo: 'B2026-008', expected: 1 },
]
const seedFloor2 = [
{ categoryId: 'cat-bodyfat', categoryName: '体脂仪', batchNo: 'B2026-003', expected: 3 },
{ categoryId: 'cat-bodyfat', categoryName: '体脂仪', batchNo: 'B2026-006', expected: 1 },
]
/** 盘点单列表(覆盖 in_progress / completed / audited 三态) */
const inventoryRecords: InventoryDetail[] = [
{
id: 'inv-006', inventoryCode: 'PD-20260701-006',
warehouseId: 'wh-floor1-small', warehouseName: '小型设备区',
inventoryType: 'partial', inventoryTime: '2026-07-01 14:20:00',
expectedCount: 10, actualCount: 0, surplusCount: 0, lossCount: 0,
items: buildInventoryItems(10, 10),
id: 'inv-006',
inventoryCode: 'PD-20260708-006',
warehouseAreaId: 'wh-floor1-small',
warehouseAreaName: '小型设备区',
inventoryType: 'partial',
operator: '李工程师',
inventoryTime: '2026-07-08 14:20:00',
status: 'in_progress',
expectedCount: 7,
actualCount: 0,
surplusCount: 0,
lossCount: 0,
remark: '现场临时抽盘',
items: buildInventoryItems(seedFloor1Small),
},
{
id: 'inv-007', inventoryCode: 'PD-20260703-007',
warehouseId: 'wh-floor1-large-a', warehouseName: 'A号货架',
inventoryType: 'full', inventoryTime: '2026-07-03 10:00:00',
expectedCount: 8, actualCount: 3, surplusCount: 0, lossCount: 0,
items: buildInventoryItems(8, 5),
id: 'inv-007',
inventoryCode: 'PD-20260705-007',
warehouseAreaId: 'wh-floor1-large-a',
warehouseAreaName: 'A号货架',
inventoryType: 'full',
operator: '王工程师',
inventoryTime: '2026-07-05 10:00:00',
status: 'in_progress',
expectedCount: 8,
actualCount: 3,
surplusCount: 0,
lossCount: 0,
items: buildInventoryItems(
seedFloor1LargeA.map((s, i) => ({
...s,
result: i === 0 ? 'matched' : i === 1 ? 'matched' : 'unchecked',
})),
),
},
{
id: 'inv-008', inventoryCode: 'PD-20260705-008',
warehouseId: 'wh-floor2', warehouseName: '二楼备品仓库',
inventoryType: 'full', inventoryTime: '2026-07-05 16:30:00',
expectedCount: 5, actualCount: 0, surplusCount: 0, lossCount: 0,
items: buildInventoryItems(5, 5),
id: 'inv-008',
inventoryCode: 'PD-20260701-008',
warehouseAreaId: 'wh-floor2',
warehouseAreaName: '二楼备品仓库',
inventoryType: 'full',
operator: '李工程师',
inventoryTime: '2026-07-01 16:30:00',
status: 'completed',
expectedCount: 4,
actualCount: 4,
surplusCount: 1,
lossCount: 1,
remark: '月末例行全盘',
items: buildInventoryItems(
seedFloor2.map((s, i) => ({
...s,
result: i === 0 ? 'loss' : 'surplus',
actual: i === 0 ? 2 : 4,
})),
),
},
{
id: 'inv-009',
inventoryCode: 'PD-20260620-009',
warehouseAreaId: 'wh-floor1',
warehouseAreaName: '一楼设备仓库',
inventoryType: 'full',
operator: '王工程师',
inventoryTime: '2026-06-20 09:00:00',
status: 'audited',
expectedCount: 8,
actualCount: 8,
surplusCount: 0,
lossCount: 0,
auditor: '张主任',
auditTime: '2026-06-21 14:00:00',
remark: '账实相符,台账无误',
items: buildInventoryItems(
[
{ categoryId: 'cat-cabinet', categoryName: '净菜柜', batchNo: 'B2026-001', expected: 3, result: 'matched' },
{ categoryId: 'cat-scale', categoryName: '蓝牙秤', batchNo: 'B2026-002', expected: 5, result: 'matched' },
],
),
},
]
@@ -173,7 +323,29 @@ registerMocks([
method: 'POST',
handler: (): ApiResponse<CustomerOption[]> => ok('查询成功', customers),
},
// 设备详情(收货入库 / 装机出库扫码时查询
// 布点场所下拉(按 customerId 过滤
{
url: '/mp/ops/stock/site-options',
method: 'POST',
handler: (ctx): ApiResponse<SiteOption[]> => {
const { customerId } = getParams<{ customerId?: string }>(ctx.params)
if (!customerId) return ok('查询成功', [])
return ok('查询成功', sites.filter((s) => s.customerId === customerId))
},
},
// 供应商下拉
{
url: '/mp/ops/stock/supplier-options',
method: 'POST',
handler: (): ApiResponse<SupplierOption[]> => ok('查询成功', suppliers),
},
// 设备类别下拉(末级分类)
{
url: '/mp/ops/stock/category-options',
method: 'POST',
handler: (): ApiResponse<CategoryOption[]> => ok('查询成功', categories),
},
// 设备详情(收货入库 / 装机出库 / 盘点扫码时查询)
{
url: '/mp/ops/stock/device-detail',
method: 'POST',
@@ -198,6 +370,9 @@ registerMocks([
handler: (ctx): ApiResponse<null> => {
const body = getParams<InboundSubmitBody>(ctx.params)
if (!body.warehouseId) return ok('请选择入库仓库', null)
if (!body.categoryId) return ok('请选择设备类别', null)
if (!body.supplierId) return ok('请选择供应商', null)
if (!body.purchasePrice) return ok('请填写采购单价', null)
if (!body.deviceCodes?.length) return ok('请扫码添加设备', null)
return ok(`入库成功,新增 ${body.deviceCodes.length} 台到台账`, null)
},
@@ -214,7 +389,69 @@ registerMocks([
return ok(`出库成功,扣减 ${body.deviceCodes.length} 台库存`, null)
},
},
// 盘点单详情
// 盘点单列表(按状态筛选)
{
url: '/mp/ops/stock/inventory/list',
method: 'POST',
handler: (ctx): ApiResponse<InventoryListItem[]> => {
const { status, keyword } = getParams<{ status?: InventoryStatus; keyword?: string }>(ctx.params)
const filtered = inventoryRecords
.filter((r) => (status ? r.status === status : true))
.filter((r) => (keyword ? r.inventoryCode.toLowerCase().includes(keyword.toLowerCase()) : true))
// 详情接口才返回 items,列表接口剥离 items 避免冗余传输
.map(({ items: _items, ...rest }) => rest)
return ok('查询成功', filtered as InventoryListItem[])
},
},
// 新建盘点单(in_progress 状态加入内存列表)
{
url: '/mp/ops/stock/inventory/create',
method: 'POST',
handler: (ctx): ApiResponse<{ id: string; inventoryCode: string }> => {
const body = getParams<InventoryCreateBody>(ctx.params)
if (!body.warehouseAreaId) return ok('请选择仓库区域', null)
if (body.inventoryType === 'partial' && !body.categoryIds?.length) return ok('部分盘点请选择应盘品类', null)
const wh = warehouses.find((w) => w.id === body.warehouseAreaId) ?? { id: body.warehouseAreaId, name: '—' }
const newId = `inv-${String(inventoryRecords.length + 10).padStart(3, '0')}`
const seq = String(inventoryRecords.length + 10).padStart(3, '0')
const now = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
const time = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
const seed = body.inventoryType === 'partial'
? (body.categoryIds ?? []).map((cid) => {
const cat = categories.find((c) => c.id === cid)
const stk = stockList.find((s) => s.categoryId === cid && s.warehouseAreaId === body.warehouseAreaId)
return {
categoryId: cid,
categoryName: cat?.name ?? '—',
batchNo: stk?.batchNo ?? '—',
expected: stk?.stockQty ?? 0,
}
})
: stockList
.filter((s) => s.warehouseAreaId === body.warehouseAreaId)
.map((s) => ({ categoryId: s.categoryId, categoryName: s.categoryName, batchNo: s.batchNo, expected: s.stockQty }))
inventoryRecords.unshift({
id: newId,
inventoryCode: `PD-${stamp}-${seq}`,
warehouseAreaId: body.warehouseAreaId,
warehouseAreaName: wh.name,
inventoryType: body.inventoryType,
operator: '当前用户',
inventoryTime: time,
status: 'in_progress',
expectedCount: seed.reduce((acc, s) => acc + s.expected, 0),
actualCount: 0,
surplusCount: 0,
lossCount: 0,
remark: body.remark,
items: buildInventoryItems(seed),
})
return ok('盘点单已创建,进入进行中状态', { id: newId, inventoryCode: `PD-${stamp}-${seq}` })
},
},
// 盘点单详情(含应盘清单)
{
url: '/mp/ops/stock/inventory/detail',
method: 'POST',
@@ -233,16 +470,20 @@ registerMocks([
const body = getParams<InventorySubmitBody>(ctx.params)
const found = inventoryRecords.find((r) => r.id === body.id)
if (!found) return ok('盘点单不存在', null)
if (found.status === 'audited') return ok('已审核的盘点单不可再次提交', null)
body.items.forEach((submit) => {
const item = found.items.find((x) => x.deviceCode === submit.deviceCode)
const item = found.items.find(
(x) => x.categoryId === submit.categoryId && x.batchNo === submit.batchNo,
)
if (item) {
item.result = submit.result
item.actualQuantity = submit.actualQuantity
}
})
found.actualCount = body.items.filter((x) => x.result === 'matched' || x.result === 'surplus').length
found.surplusCount = body.items.filter((x) => x.result === 'surplus').length
found.lossCount = body.items.filter((x) => x.result === 'loss').length
found.actualCount = found.items.filter((x) => x.result === 'matched' || x.result === 'surplus').length
found.surplusCount = found.items.filter((x) => x.result === 'surplus').length
found.lossCount = found.items.filter((x) => x.result === 'loss').length
found.status = 'completed'
return ok('盘点已提交,等待后台审核修正台账', null)
},
},
@@ -251,16 +492,17 @@ registerMocks([
url: '/mp/ops/stock/list',
method: 'POST',
handler: (ctx): ApiResponse<StockItem[]> => {
const params = getParams<{ warehouseId?: string; status?: string; keyword?: string }>(ctx.params)
const params = getParams<{ warehouseId?: string; categoryId?: string; healthLevel?: string; keyword?: string }>(ctx.params)
const filtered = stockList.filter((x) => {
if (params.warehouseId && x.warehouseId !== params.warehouseId) return false
if (params.status && x.status !== params.status) return false
if (params.warehouseId && x.warehouseAreaId !== params.warehouseId) return false
if (params.categoryId && x.categoryId !== params.categoryId) return false
if (params.healthLevel && x.healthLevel !== params.healthLevel) return false
if (params.keyword) {
const kw = params.keyword.toLowerCase()
const hit =
x.deviceCode.toLowerCase().includes(kw) ||
x.deviceName.toLowerCase().includes(kw) ||
x.batchNo.toLowerCase().includes(kw)
x.categoryName.toLowerCase().includes(kw) ||
x.batchNo.toLowerCase().includes(kw) ||
x.supplierName.toLowerCase().includes(kw)
if (!hit) return false
}
return true
+75 -58
View File
@@ -1,19 +1,24 @@
/**
* 实施运维小程序 / 工单 mock
* - 列表(按状态分段,类型筛选)
* - 列表(按 4 段状态,来源筛选)
* - 详情(含客户报障附件 + 处理时间线)
* - 接单 / 关单 / 提交维修上报
*
* 字段命名与状态枚举对齐后台 device-center/ops/workOrder
* - statuspending 待派单 / assigned 已派单 / processing 处理中 / closed 已关单
* - sourcecustomer 客户报障 / inspection 巡检异常 / selfCheck 自检异常 / afterSale 售后转单
* - 列表字段:orderCode / siteName / customerName / source / faultLevel / faultType / faultDesc / slaDeadline / slaOverdue / reportTime
*/
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, RequestParameter } from '@axios'
import type {
OrderSource,
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> => ({
@@ -27,84 +32,98 @@ const getParams = <T = Record<string, unknown>>(params: RequestParameter): T =>
const workOrders: WorkOrderItem[] = [
{
id: 'wo-001', code: 'WO20260703001', title: '净菜柜压缩机故障维修', type: 'repair',
id: 'wo-001', orderCode: 'WO20260703001', source: 'customer',
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,
siteName: '上海浦东 · 张江食堂', customerName: 'CQ 能源集团',
faultLevel: 'high', faultType: 'mechanical',
faultDesc: '客户报修:制冷效果差,压缩机异响',
images: ['https://picsum.photos/200/150?random=1', 'https://picsum.photos/200/150?random=2'],
slaDeadline: '2026-07-03 18:00:00', reportTime: '2026-07-03 08:30:00',
assignee: '李工程师', slaOverdue: false,
},
{
id: 'wo-002', code: 'WO20260703002', title: '健康手表 OTA 失败处置', type: 'selfCheck',
id: 'wo-002', orderCode: 'WO20260703002', source: '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,
siteName: '北京朝阳 · 朝阳体检站', customerName: '北京健康管理机构',
faultLevel: 'medium', faultType: 'software',
faultDesc: 'OTA 升级失败已自动回滚,需现场排查',
images: [],
slaDeadline: '2026-07-03 20:00:00', reportTime: '2026-07-03 10:15:00',
assignee: '李工程师', slaOverdue: false,
},
{
id: 'wo-003', code: 'WO20260702018', title: '净菜柜货道错位复位', type: 'inspection',
id: 'wo-003', orderCode: 'WO20260702018', source: '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,
siteName: '广州天河 · 天银食堂', customerName: '广州餐饮集团',
faultLevel: 'medium', faultType: 'mechanical',
faultDesc: '巡检发现:货道 5 错位,需现场复位',
images: [],
slaDeadline: '2026-07-03 12:00:00', reportTime: '2026-07-02 16:30:00',
assignee: '王工程师', slaOverdue: true,
},
{
id: 'wo-004', code: 'WO20260702015', title: '体检一体机离线排查', type: 'sla',
id: 'wo-004', orderCode: 'WO20260702015', source: 'afterSale',
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,
siteName: '深圳南山 · 科技园体检站', customerName: '深圳健康管理机构',
faultLevel: 'high', faultType: 'network',
faultDesc: '连续 3 次心跳未上报,疑似网络故障',
images: [],
slaDeadline: '2026-07-02 18:00:00', reportTime: '2026-07-02 14:05:00',
assignee: '赵工程师', slaOverdue: 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-005', orderCode: 'WO20260702009', source: 'customer',
status: 'closed', deviceCode: 'CAB-SH-002', deviceName: '2号净菜柜(上海闵行)',
siteName: '上海闵行 · 莘庄食堂', customerName: 'CQ 能源集团',
faultLevel: 'medium', faultType: 'software',
faultDesc: '营养系统订阅 4 次重试失败,已人工重投成功',
images: [],
slaDeadline: '2026-07-02 16:00:00', reportTime: '2026-07-02 11:48:00',
assignee: '李工程师', slaOverdue: 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-006', orderCode: 'WO20260701022', source: 'customer',
status: 'closed', deviceCode: 'BODYFAT-HZ-005', deviceName: '5号体脂仪(杭州西湖)',
siteName: '杭州西湖 · 西湖体检站', customerName: '杭州健康管理机构',
faultLevel: 'low', faultType: 'electrical',
faultDesc: '电池电量低于 15%,已更换',
images: [],
slaDeadline: '2026-07-01 18:00:00', reportTime: '2026-07-01 17:20:00',
assignee: '赵工程师', slaOverdue: 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-007', orderCode: 'WO20260703005', source: 'customer',
status: 'assigned', deviceCode: 'TREADMILL-CQ-002', deviceName: '2号跑步机(重庆渝中)',
siteName: '重庆渝中 · 解放碑健身房', customerName: '重庆健身中心',
faultLevel: 'critical', faultType: 'electrical',
faultDesc: '连续 10 分钟过载,疑似电机故障',
images: [],
slaDeadline: '2026-07-03 22:00:00', reportTime: '2026-07-03 08:50:00',
assignee: '李工程师', slaOverdue: 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,
id: 'wo-008', orderCode: 'WO20260703008', source: 'selfCheck',
status: 'assigned', deviceCode: 'CAB-BJ-003', deviceName: '3号净菜柜(北京海淀)',
siteName: '北京海淀 · 中关村食堂', customerName: '北京餐饮集团',
faultLevel: 'medium', faultType: 'hardware',
faultDesc: '温度传感器读数漂移 2.4℃,需校准',
images: [],
slaDeadline: '2026-07-04 12:00:00', reportTime: '2026-07-01 09:15:00',
assignee: '王工程师', slaOverdue: 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: [
processLogs: [
{ 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: [
processLogs: [
{ 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: '系统' },
@@ -116,15 +135,14 @@ const detailMap: Record<string, Partial<WorkOrderDetail>> = {
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 filterBySource = (rows: WorkOrderItem[], source: OrderSource | ''): WorkOrderItem[] =>
source ? rows.filter((r) => r.source === source) : 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.orderCode.toLowerCase().includes(k) ||
r.deviceCode.toLowerCase().includes(k) ||
r.deviceName.toLowerCase().includes(k),
)
@@ -138,7 +156,7 @@ registerMocks([
handler: (ctx): ApiResponse<WorkOrderItem[]> => {
const p = getParams<WorkOrderListParams>(ctx.params)
let rows = filterByStatus(workOrders, p.status)
rows = filterByType(rows, p.type ?? '')
rows = filterBySource(rows, p.source ?? '')
rows = filterByKeyword(rows, p.keyword ?? '')
return ok('查询成功', rows)
},
@@ -152,8 +170,7 @@ registerMocks([
const base = workOrders.find((w) => w.id === id)
if (!base) return ok('工单不存在', null)
const ext = detailMap[id!] ?? {
faultImages: [],
processLog: [{ time: base.createdAt, action: '工单创建', operator: '系统' }],
processLogs: [{ time: base.reportTime, action: '工单创建', operator: '系统' }],
}
return ok('查询成功', { ...base, ...ext } as WorkOrderDetail)
},
@@ -179,7 +196,7 @@ registerMocks([
handler: (ctx): ApiResponse<null> => {
const { id } = getParams<{ id?: string }>(ctx.params)
const target = workOrders.find((w) => w.id === id)
if (target) target.status = 'done'
if (target) target.status = 'closed'
return ok('关单成功', null)
},
},
@@ -16,6 +16,8 @@ import type {
AlarmLevel,
AlarmSource,
AlarmStatus,
InitDeviceItem,
OtaTaskItem,
TodoStats,
WeekStats,
} from '@pages/miniprogram/ops/workbench/types'
@@ -153,6 +155,44 @@ const weekStats: WeekStats = {
monthInstallCount: 18,
}
/** 待初始化设备列表(4 条,对齐 todoStats.todayInitDevices */
const initDeviceList: InitDeviceItem[] = [
{
id: 'dev-091', deviceCode: 'CAB-NEW-001', deviceName: '新装净菜柜(待激活)',
model: 'NZG-200', siteName: '上海浦东 · 张江食堂', tenantName: 'CQ 能源集团',
inboundTime: '2026-07-02 14:00:00',
},
{
id: 'dev-092', deviceCode: 'CAB-NEW-002', deviceName: '2号新装净菜柜(待激活)',
model: 'NZG-200', siteName: '北京朝阳 · 朝阳体检站', tenantName: '北京健康管理机构',
inboundTime: '2026-07-02 15:20:00',
},
{
id: 'dev-093', deviceCode: 'WATCH-BJ-015', deviceName: '15号健康手表(待激活)',
model: 'WATCH5-PRO', siteName: '北京朝阳 · 朝阳体检站', tenantName: '北京健康管理机构',
inboundTime: '2026-07-03 08:30:00',
},
{
id: 'dev-094', deviceCode: 'BODYFAT-HZ-006', deviceName: '6号体脂仪(待激活)',
model: 'InBody-770', siteName: '杭州西湖 · 西湖体检站', tenantName: '杭州健康管理机构',
inboundTime: '2026-07-03 09:10:00',
},
]
/** 待升级 OTA 任务列表(2 条,对齐 todoStats.todayOtaTasks */
const otaTaskList: OtaTaskItem[] = [
{
id: 'ota-001', taskId: 'OTA20260703001', deviceCode: 'WATCH-BJ-014',
deviceName: '14号健康手表(北京朝阳)', currentVersion: 'v1.0.4', targetVersion: 'v1.1.0',
pushTime: '2026-07-03 09:00:00', status: 'failed',
},
{
id: 'ota-002', taskId: 'OTA20260703002', deviceCode: 'CAB-SH-001',
deviceName: '1号净菜柜(上海浦东)', currentVersion: 'V2.3.1', targetVersion: 'V2.4.0',
pushTime: '2026-07-03 09:05:00', status: 'pending',
},
]
registerMocks([
// 工作台今日待办统计
{
@@ -166,6 +206,18 @@ registerMocks([
method: 'POST',
handler: (): ApiResponse<WeekStats> => ok('查询成功', weekStats),
},
// 待初始化设备列表
{
url: '/mp/ops/workbench/init-list',
method: 'GET',
handler: (): ApiResponse<InitDeviceItem[]> => ok('查询成功', initDeviceList),
},
// 待升级 OTA 任务列表
{
url: '/mp/ops/workbench/ota-list',
method: 'GET',
handler: (): ApiResponse<OtaTaskItem[]> => ok('查询成功', otaTaskList),
},
// 告警列表
{
url: '/mp/ops/alarm/list',
+35
View File
@@ -1021,14 +1021,34 @@ export const miniprograms: NavMiniprogram[] = [
},
{
key: 'mp-ops-stock-inventory', label: '库存盘点', path: 'stock-inventory',
component: () => import('@pages/miniprogram/ops/workbench/stockInventoryList.vue'),
status: 'draft',
},
{
key: 'mp-ops-stock-inventory-detail', label: '盘点作业详情', path: 'stock-inventory-detail/:id',
component: () => import('@pages/miniprogram/ops/workbench/stockInventory.vue'),
status: 'draft',
},
{
key: 'mp-ops-stock-inventory-create', label: '新建盘点', path: 'stock-inventory-create',
component: () => import('@pages/miniprogram/ops/workbench/stockInventoryCreate.vue'),
status: 'draft',
},
{
key: 'mp-ops-stock-list', label: '库存查询', path: 'stock-list',
component: () => import('@pages/miniprogram/ops/workbench/stockList.vue'),
status: 'draft',
},
{
key: 'mp-ops-init-list', label: '待初始化设备', path: 'init-list',
component: () => import('@pages/miniprogram/ops/workbench/initList.vue'),
status: 'draft',
},
{
key: 'mp-ops-ota-list', label: '待升级任务', path: 'ota-list',
component: () => import('@pages/miniprogram/ops/workbench/otaList.vue'),
status: 'draft',
},
],
},
{
@@ -1111,6 +1131,11 @@ export const miniprograms: NavMiniprogram[] = [
component: () => import('@pages/miniprogram/ops/inspect/detail.vue'),
status: 'draft',
},
{
key: 'mp-ops-inspect-create', label: '新增巡检', path: 'inspect-create',
component: () => import('@pages/miniprogram/ops/inspect/inspectCreate.vue'),
status: 'draft',
},
],
},
{
@@ -1128,6 +1153,16 @@ export const miniprograms: NavMiniprogram[] = [
component: () => import('@pages/miniprogram/ops/me/messageCenter.vue'),
status: 'draft',
},
{
key: 'mp-ops-account-info', label: '账号信息', path: 'account-info',
component: () => import('@pages/miniprogram/ops/me/accountInfo.vue'),
status: 'draft',
},
{
key: 'mp-ops-change-password', label: '修改密码', path: 'change-password',
component: () => import('@pages/miniprogram/ops/me/changePassword.vue'),
status: 'draft',
},
],
},
],
@@ -1,11 +1,16 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { CategoryNode } from '../../category/types'
import type { CustomerOption, DeviceCategoryOption, DeviceItem, SearchForm, SiteOption } from '../types'
/** 查询设备列表(按顶级分类 Tab 过滤) */
export const list = (params: SearchForm & { category: string }): Promise<ApiResponse<DeviceItem[]>> =>
postRequest('axiosRequest', '/admin/device/manage/list', params)
/** 设备分类全树(与设备分类页 / 统计页同源,供筛选区树选择器加载) */
export const categoryTree = (keyword?: string): Promise<ApiResponse<CategoryNode[]>> =>
postRequest('axiosRequest', '/admin/device/category/tree', { keyword })
/** 删除设备(归档,不物理删除) */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/delete', { id })
@@ -28,14 +28,17 @@
</a-col>
<a-col :span="12">
<a-form-item label="设备类别" name="deviceCategory">
<a-select
<a-tree-select
v-model:value="form.deviceCategory"
:options="formOptions.deviceCategory"
show-search
:tree-data="categoryTreeData"
:field-names="categoryTreeFieldNames"
tree-default-expand-all
allow-clear
placeholder="请选择设备类别"
show-search
placeholder="请选择末级分类"
style="width: 100%"
:filter-option="filterOption"
:filter-tree-node="(input: string, node: { name?: string }) =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())"
@change="onCategoryChange"
/>
</a-form-item>
@@ -157,7 +160,8 @@
* modal-spec.md 强制项已遵循。
* 业务字段:
* - 通用字段:编码 / 类别 / 名称 / 序列号 / 客户 / 场所 / 投用状态 / 虹软授权码
* - 绑定 SIM 卡:仅当所选类别 networkMode === 'sim' 时展示
* - 设备类别:a-tree-select 加载设备分类全树(与统计页同源,value=name 保留契约),
* leaf-only 拦截非末级;所选类别 networkMode 决定 SIM 卡字段显隐
* - 软件包名 / 软件版本:不在本弹框采集,由后续"初始化设置"流程写入
* - 所属客户 / 场所 均为可搜索下拉,场所按客户联动过滤
* - 5 个 Tab 共用同一表单,无差异化字段区块
@@ -176,6 +180,8 @@ const {
formRef,
form,
formOptions,
categoryTreeData,
categoryTreeFieldNames,
deployStatusText,
showSimIccid,
rules,
@@ -2,10 +2,12 @@
* 设备管理 - 新增/编辑 弹框 - 接口层
* - 复用主页面 add/update 接口
* - 新增 info 接口供编辑回填
* - categoryTree 复用设备分类页全树接口(供弹框树选择器加载,并据此查找 networkMode
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { CategoryNode } from '../../../../../category/types'
import type { Form } from '../types'
/** 详情查询(编辑回填) */
@@ -19,3 +21,7 @@ export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>>
/** 编辑 */
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/update', params)
/** 设备分类全树(与设备分类页 / 统计页同源,供弹框树选择器加载) */
export const categoryTree = (keyword?: string): Promise<ApiResponse<CategoryNode[]>> =>
postRequest('axiosRequest', '/admin/device/category/tree', { keyword })
@@ -20,13 +20,17 @@
*/
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { categoryOptions, customerOptions, siteOptions } from '../../../../api'
import { add, edit, info } from '../api'
import type { DeviceCategoryOption, DeviceCategoryTab, DeployStatus, NetworkMode } from '../../../../types'
import { customerOptions, siteOptions } from '../../../../api'
import { add, categoryTree, edit, info } from '../api'
import type { CategoryNode } from '../../../../../category/types'
import type { DeviceCategoryTab, DeployStatus, NetworkMode } from '../../../../types'
import type { Form, FormOptions, ModalType, OpenContext, PageInfo } from '../types'
/** a-tree-select 字段映射(设备分类树节点;value=name 保留现有 deviceCategory 契约) */
export const CATEGORY_TREE_FIELD_NAMES = { label: 'name', value: 'name', children: 'children' }
/** 初始表单工厂 */
const createInitForm = (): Form => ({
id: undefined,
@@ -48,7 +52,6 @@ const createInitForm = (): Form => ({
/** 初始下拉选项工厂 */
const createInitOptions = (): FormOptions => ({
deviceCategory: [],
customer: [],
site: [],
})
@@ -59,6 +62,31 @@ const computeDeployStatus = (customerId: string, siteId: string): DeployStatus =
return 'deployed'
}
/** 递归查找树节点:按 name 匹配(用于按所选 deviceCategory 名查 networkMode */
const findNodeByName = (nodes: CategoryNode[], name: string): CategoryNode | null => {
for (const node of nodes) {
if (node.name === name) return node
if (node.children?.length) {
const found = findNodeByName(node.children, name)
if (found) return found
}
}
return null
}
/** 收集末级节点 name 集合(用于 leaf-only 拦截) */
const collectLeafNames = (nodes: CategoryNode[]): Set<string> => {
const result = new Set<string>()
const walk = (list: CategoryNode[]): void => {
list.forEach((node) => {
if (node.children?.length) walk(node.children)
else result.add(node.name)
})
}
walk(nodes)
return result
}
export const usePage = (emit: (e: 'load') => void) => {
const { message } = useAntdStaticMethods()
@@ -74,16 +102,19 @@ export const usePage = (emit: (e: 'load') => void) => {
const form = ref<Form>(createInitForm())
const formOptions = ref<FormOptions>(createInitOptions())
/** 设备分类全树(与统计页同源,弹框树选择器数据源;value=name 保留现有 deviceCategory 契约) */
const categoryTreeData = ref<CategoryNode[]>([])
/** 当前 Tab(控制设备类别下拉数据源) */
const activeTab = ref<DeviceCategoryTab>('catering')
/** 投用状态实时联动(只读展示) */
const deployStatusText = computed(() => (form.value.deployStatus === 'deployed' ? '投用' : '在库'))
/** 当前所选类别对应的 networkMode(决定 SIM 卡字段显隐) */
/** 当前所选类别对应的 networkMode(决定 SIM 卡字段显隐;从全树按 name 查找 */
const currentNetworkMode = computed<NetworkMode | undefined>(() => {
const matched = formOptions.value.deviceCategory.find((item) => item.value === form.value.deviceCategory)
return matched?.networkMode
if (!form.value.deviceCategory) return undefined
return findNodeByName(categoryTreeData.value, form.value.deviceCategory)?.networkMode
})
/** 是否展示绑定 SIM 卡输入 */
@@ -119,8 +150,16 @@ export const usePage = (emit: (e: 'load') => void) => {
onDeployFieldChange()
}
/** 设备类别变化时:若新类别 networkMode !== 'sim',清空已填的 simIccid */
/** 设备类别变化时:若新类别非末级则拒绝;若 networkMode !== 'sim',清空已填的 simIccid */
const onCategoryChange = (): void => {
/** leaf-only 拦截:非末级节点拒绝选中,保留原值 */
if (form.value.deviceCategory) {
const leafNames = collectLeafNames(categoryTreeData.value)
if (!leafNames.has(form.value.deviceCategory)) {
form.value.deviceCategory = ''
return
}
}
if (!showSimIccid.value) form.value.simIccid = ''
}
@@ -141,21 +180,14 @@ export const usePage = (emit: (e: 'load') => void) => {
pageInfo.visible = false
}
/** 按 activeTab 切换设备类别下拉 */
const refreshCategoryOptions = (tab: DeviceCategoryTab): void => {
categoryOptions(tab)
/** 拉取设备分类全树(复用设备分类页接口,与统计页同源;弹框树选择器 + networkMode 查找共用) */
const refreshCategoryTree = (): void => {
categoryTree()
.then((res) => {
if (res.code === '00000') {
const list = res.data ?? []
formOptions.value.deviceCategory = list.map((item: DeviceCategoryOption) => ({
label: item.name,
value: item.name,
networkMode: item.networkMode,
}))
}
if (res.code === '00000') categoryTreeData.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('设备类别下拉加载失败:', err)
console.error('设备分类树加载失败:', err)
})
}
@@ -216,8 +248,8 @@ export const usePage = (emit: (e: 'load') => void) => {
? `新增设备(${tabLabelMap[tab]}`
: '编辑设备'
/** 并发加载下拉数据 */
refreshCategoryOptions(tab)
/** 并发加载下拉数据(设备分类树为全树,无需按 Tab 刷新) */
refreshCategoryTree()
refreshCustomerOptions()
const requests: Promise<unknown>[] = []
@@ -230,9 +262,8 @@ export const usePage = (emit: (e: 'load') => void) => {
if (infoRes?.code === '00000') {
const data = infoRes.data
form.value = { ...createInitForm(), ...data }
/** 编辑时按详情回填的 category 同步 activeTab,并刷新对应类别下拉 */
/** 编辑时按详情回填的 category 同步 activeTab(仅用于标题展示) */
activeTab.value = data.category
refreshCategoryOptions(data.category)
/** 客户已选定,按 customerId 加载场所下拉(便于编辑回显) */
if (data.customerId) refreshSiteOptions(data.customerId)
}
@@ -307,6 +338,8 @@ export const usePage = (emit: (e: 'load') => void) => {
form,
formOptions,
activeTab,
categoryTreeData,
categoryTreeFieldNames: CATEGORY_TREE_FIELD_NAMES,
deployStatusText,
showSimIccid,
rules,
@@ -10,7 +10,7 @@
*/
import type { SelectProps } from 'ant-design-vue'
import type { DeviceCategoryTab, DeployStatus, NetworkMode } from '../../../../types'
import type { DeviceCategoryTab, DeployStatus } from '../../../../types'
export type ModalType = 'add' | 'edit'
@@ -59,8 +59,6 @@ export interface Form {
/** 下拉选项 */
export interface FormOptions {
/** 设备类别(按 activeTab 切换) */
deviceCategory: { label: string; value: string; networkMode: NetworkMode }[]
/** 所属客户 */
customer: { label: string; value: string }[]
/** 所属场所(按 customerId 联动) */
@@ -4,13 +4,17 @@ import type { TablePaginationConfig } from 'ant-design-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import { categoryOptions, customerOptions, exportList, importList, list, remove, siteOptions } from '../api'
import type { CategoryNode } from '../../category/types'
import { categoryTree, customerOptions, exportList, importList, list, remove, siteOptions } from '../api'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from '../component/drawer/info/info.vue'
import type { CustomerOption, DeviceCategoryOption, DeviceCategoryTab, DeviceItem, SearchForm, SiteOption } from '../types'
import type { CustomerOption, DeviceCategoryTab, DeviceItem, SearchForm, SiteOption } from '../types'
import { DEPLOY_STATUS_OPTIONS, createSearchKey } from './useSearch'
import { useTable } from './useTable'
/** a-tree-select 字段映射(设备分类树节点,与统计页保持一致) */
export const CATEGORY_TREE_FIELD_NAMES = { label: 'name', value: 'name', children: 'children' }
/** 5 个 Tab 元信息(与设备分类预置值对齐) */
export const TAB_LIST: { key: DeviceCategoryTab; label: string }[] = [
{ key: 'catering', label: '智养餐饮设备' },
@@ -54,8 +58,8 @@ export const usePage = () => {
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
/** 设备类别下拉(按当前 Tab 联动刷新 */
const deviceCategoryOptions = ref<DeviceCategoryOption[]>([])
/** 设备分类全树(与统计页同源,筛选区树选择器数据源;value 用 name 保留现有 deviceCategory 契约 */
const categoryTreeData = ref<CategoryNode[]>([])
/** 所属客户下拉(可搜索) */
const customerOptionList = ref<CustomerOption[]>([])
/** 所属场所下拉(按 customerId 联动过滤) */
@@ -94,14 +98,14 @@ export const usePage = () => {
})
}
/** 拉取设备类别下拉(按当前 Tab */
const refreshCategoryOptions = (): void => {
categoryOptions(activeTab.value)
/** 拉取设备分类全树(复用设备分类页接口,与统计页同源 */
const refreshCategoryTree = (keyword?: string): void => {
categoryTree(keyword)
.then((res) => {
if (res.code === '00000') deviceCategoryOptions.value = res.data ?? []
if (res.code === '00000') categoryTreeData.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('设备类别下拉加载失败:', err)
console.error('设备分类树加载失败:', err)
})
}
@@ -149,13 +153,12 @@ export const usePage = () => {
listRequest()
}
/** 切换 Tab:重置搜索 + 刷新设备类别下拉 */
/** 切换 Tab:重置搜索(树选择器数据源为全树,无需按 Tab 刷新) */
const switchTab = (keyOrEvent: string | { target: { value: string } }): void => {
const key = typeof keyOrEvent === 'string' ? keyOrEvent : keyOrEvent.target.value
activeTab.value = key as DeviceCategoryTab
Object.assign(search, createSearchKey())
siteOptionList.value = []
refreshCategoryOptions()
table.pagination.current = 1
listRequest()
}
@@ -271,7 +274,7 @@ export const usePage = () => {
}
onMounted(() => {
refreshCategoryOptions()
refreshCategoryTree()
refreshCustomerOptions()
const q = route.query.inboundCode
if (typeof q === 'string' && q) {
@@ -293,7 +296,8 @@ export const usePage = () => {
infoDrawerRef,
tabList: TAB_LIST,
deployStatusOptions: DEPLOY_STATUS_OPTIONS,
deviceCategoryOptions,
categoryTreeData,
categoryTreeFieldNames: CATEGORY_TREE_FIELD_NAMES,
customerOptionList,
siteOptionList,
inboundCodeFilter,
@@ -303,6 +307,7 @@ export const usePage = () => {
onCustomerChange,
refreshCustomerOptions,
refreshSiteOptions,
refreshCategoryTree,
dataSourceChange,
handleAction,
exportListData,
@@ -33,15 +33,17 @@
/>
</a-form-item>
<a-form-item label="设备类别">
<a-select
<a-tree-select
v-model:value="search.deviceCategory"
:options="deviceCategorySelectOptions"
:field-names="{ label: 'name', value: 'name' }"
:tree-data="categoryTreeData"
:field-names="categoryTreeFieldNames"
show-search
allow-clear
placeholder="全部"
style="width: 180px"
:filter-option="filterOption"
tree-default-expand-all
placeholder="全部类别"
style="width: 220px"
:filter-tree-node="(input: string, node: { name?: string }) =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="投用状态">
@@ -137,7 +139,8 @@ const {
infoDrawerRef,
tabList,
deployStatusOptions,
deviceCategoryOptions,
categoryTreeData,
categoryTreeFieldNames,
customerOptionList,
siteOptionList,
inboundCodeFilter,
@@ -154,11 +157,6 @@ const {
listRequest,
} = usePage()
/** 设备类别下拉选项映射为 a-select 期望的 {label,value} 结构(value 用 name */
const deviceCategorySelectOptions = computed(() =>
deviceCategoryOptions.value.map((item) => ({ label: item.name, value: item.name })),
)
/** 客户下拉搜索回调 */
const onCustomerSearch = (kw: string): void => {
refreshCustomerOptions(kw)
@@ -20,14 +20,17 @@
<a-input v-model:value="search.deviceCode" v-no-space allow-clear placeholder="请输入设备编码" style="width: 200px" />
</a-form-item>
<a-form-item label="设备分类">
<a-select
<a-tree-select
v-model:value="search.deviceCategory"
:options="categoryOpts"
:tree-data="categoryTreeData"
:field-names="CATEGORY_TREE_FIELD_NAMES"
show-search
allow-clear
tree-default-expand-all
placeholder="全部分类"
style="width: 160px"
:filter-option="filterOption"
style="width: 220px"
:filter-tree-node="(input: string, node: { name?: string }) =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="来源">
@@ -73,23 +76,26 @@
* 业务对齐 §3.6 设备运维 → 告警管理:
* - 运行/自检/巡检/SLA/OTA/数据分发失败 等告警统一处置
* - 告警详情:基础信息 + 报错日志(代码风格多行文本)
* - 设备分类筛选:a-tree-select 加载设备分类全树(与统计页同源,value=name 保留 deviceCategory 契约)
*/
import { onMounted, ref } from 'vue'
import { NotificationOutlined, StopOutlined } from '@ant-design/icons-vue'
import type { CategoryNode } from '../../../asset/category/types'
import FilterBar from '@components/FilterBar.vue'
import TableCard from '@components/TableCard.vue'
import StatCard from '@components/StatCard.vue'
import { filterOption } from '@utils/antDesign/select'
import InfoDrawer from './component/drawer/info/info.vue'
import {
ALARM_LEVEL_OPTIONS,
ALARM_SOURCE_OPTIONS,
ALARM_STATUS_OPTIONS,
type DeviceCategoryOption,
} from './types'
import { usePage } from './init/usePage'
import * as api from './api'
/** a-tree-select 字段映射(与统计页保持一致;value=name 保留 deviceCategory 契约) */
const CATEGORY_TREE_FIELD_NAMES = { label: 'name', value: 'name', children: 'children' }
const {
pageLoading,
search,
@@ -105,21 +111,19 @@ const {
batchHandle,
} = usePage()
/** 设备分类下拉(末级分类名,与设备监测模块对齐 */
const categoryOpts = ref<{ label: string; value: string }[]>([])
/** 设备分类全树(与统计页同源 */
const categoryTreeData = ref<CategoryNode[]>([])
const loadCategoryOpts = (): void => {
api.categoryOptions()
const loadCategoryTree = (): void => {
api.categoryTree()
.then((res) => {
if (res.code === '00000') {
categoryOpts.value = (res.data ?? []).map((i: DeviceCategoryOption) => ({ label: i.name, value: i.name }))
}
if (res.code === '00000') categoryTreeData.value = res.data ?? []
})
.catch((err: unknown) => console.error('设备分类下拉失败:', err))
.catch((err: unknown) => console.error('设备分类树加载失败:', err))
}
onMounted(() => {
loadCategoryOpts()
loadCategoryTree()
})
</script>
@@ -3,11 +3,12 @@
* - list 告警列表 / batchHandle 批量处理 / ignore 忽略 / toWorkOrder 转工单
* - info 告警详情(含报错日志)
* - stats 顶部统计
* - categoryOptions 设备分类下拉(末级分类名
* - categoryTree 设备分类全树(复用设备分类页接口,供筛选区树选择器加载
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { AlarmDetail, AlarmItem, AlarmStats, DeviceCategoryOption, SearchForm } from '../types'
import type { CategoryNode } from '../../../asset/category/types'
import type { AlarmDetail, AlarmItem, AlarmStats, SearchForm } from '../types'
const BASE = '/admin/device/ops/alarm'
@@ -33,6 +34,6 @@ export const ignore = (id: string): Promise<ApiResponse<null>> =>
export const batchHandle = (ids: string[], type: 'ignore' | 'workorder'): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/batch', { ids, type })
/** 设备分类下拉(末级分类名,与设备监测模块对齐 */
export const categoryOptions = (): Promise<ApiResponse<DeviceCategoryOption[]>> =>
postRequest('axiosRequest', BASE + '/category/options', {})
/** 设备分类全树(与设备分类页 / 统计页同源,供筛选区树选择器加载;value=name 保留 deviceCategory 契约 */
export const categoryTree = (keyword?: string): Promise<ApiResponse<CategoryNode[]>> =>
postRequest('axiosRequest', '/admin/device/category/tree', { keyword })
@@ -54,17 +54,11 @@ export interface AlarmDetail extends AlarmItem {
errorLog: string
}
/** 设备分类下拉选项(末级分类名) */
export interface DeviceCategoryOption {
/** 末级分类名(作为 value */
name: string
}
/** 查询表单 */
export interface SearchForm extends BaseSearchForm {
/** 设备编码 */
deviceCode?: string
/** 设备分类(末级分类名) */
/** 设备分类(末级分类名,与设备分类树叶子节点 name 对齐 */
deviceCategory?: string
source?: AlarmSource
level?: AlarmLevel
@@ -6,14 +6,15 @@
* - batchSetVersion 批量版本设置
* - exportList 异步导出
* - appOptions / versionOptions 应用名+版本号下拉
* - customerOptions / siteOptions / categoryOptions 筛选下拉
* - customerOptions / siteOptions 筛选下拉
* - categoryTree 设备分类全树(复用设备分类页接口,供筛选区树选择器加载)
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { CategoryNode } from '../../../asset/category/types'
import type {
AppOption,
CustomerOption,
DeviceCategoryOption,
InitDetail,
InitItem,
SearchForm,
@@ -59,10 +60,6 @@ export const appOptions = (): Promise<ApiResponse<AppOption[]>> =>
export const versionOptions = (appId: string): Promise<ApiResponse<VersionOption[]>> =>
postRequest('axiosRequest', BASE + '/version/options', { appId })
/** 设备类别下拉(二级分类名) */
export const categoryOptions = (): Promise<ApiResponse<DeviceCategoryOption[]>> =>
postRequest('axiosRequest', BASE + '/category/options', {})
/** 所属客户下拉(可搜索) */
export const customerOptions = (keyword?: string): Promise<ApiResponse<CustomerOption[]>> =>
postRequest('axiosRequest', BASE + '/customer/options', { keyword })
@@ -70,3 +67,7 @@ export const customerOptions = (keyword?: string): Promise<ApiResponse<CustomerO
/** 布点场所下拉(按 customerId 联动) */
export const siteOptions = (customerId: string, keyword?: string): Promise<ApiResponse<SiteOption[]>> =>
postRequest('axiosRequest', BASE + '/site/options', { customerId, keyword })
/** 设备分类全树(与设备分类页 / 统计页 / 告警页同源,供筛选区树选择器加载;value=name 保留 deviceCategory 契约) */
export const categoryTree = (keyword?: string): Promise<ApiResponse<CategoryNode[]>> =>
postRequest('axiosRequest', '/admin/device/category/tree', { keyword })
@@ -2,14 +2,17 @@
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="设备类别">
<a-select
<a-tree-select
v-model:value="search.deviceCategory"
:options="categoryOpts"
:tree-data="categoryTreeData"
:field-names="CATEGORY_TREE_FIELD_NAMES"
show-search
allow-clear
placeholder="全部类别"
style="width: 160px"
:filter-option="filterOption"
tree-default-expand-all
placeholder="全部分类"
style="width: 220px"
:filter-tree-node="(input: string, node: { name?: string }) =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="设备编码">
@@ -115,6 +118,7 @@
*/
import { DownloadOutlined, SettingOutlined } from '@ant-design/icons-vue'
import { onMounted, ref } from 'vue'
import type { CategoryNode } from '../../../asset/category/types'
import FilterBar from '@components/FilterBar.vue'
import TableCard from '@components/TableCard.vue'
import VersionSetModal from './component/modal/versionSet/versionSet.vue'
@@ -123,8 +127,6 @@ import {
DEPLOY_STATUS_OPTIONS,
INIT_STATUS_OPTIONS,
type CustomerOption,
type DeviceCategoryOption,
type InitItem,
type SiteOption,
} from './types'
import { usePage } from './init/usePage'
@@ -132,6 +134,9 @@ import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { filterOption } from '@utils/antDesign/select'
import * as api from './api'
/** a-tree-select 字段映射(与统计页 / 告警页保持一致;value=name 保留 deviceCategory 契约) */
const CATEGORY_TREE_FIELD_NAMES = { label: 'name', value: 'name', children: 'children' }
const {
pageLoading,
search,
@@ -151,22 +156,20 @@ const {
const { message } = useAntdStaticMethods()
/** 设备类别下拉 */
const categoryOpts = ref<DeviceCategoryOption[]>([])
/** 设备分类全树(与统计页 / 告警页同源) */
const categoryTreeData = ref<CategoryNode[]>([])
/** 所属客户下拉 */
const customerOpts = ref<CustomerOption[]>([])
/** 布点场所下拉(按客户联动) */
const siteOpts = ref<SiteOption[]>([])
/** 拉取设备类别下拉 */
const loadCategoryOpts = (): void => {
api.categoryOptions()
/** 拉取设备分类全树 */
const loadCategoryTree = (): void => {
api.categoryTree()
.then((res) => {
if (res.code === '00000') {
categoryOpts.value = (res.data ?? []).map((i) => ({ label: i.name, value: i.name }))
}
if (res.code === '00000') categoryTreeData.value = res.data ?? []
})
.catch((err: unknown) => console.error('设备类别下拉失败:', err))
.catch((err: unknown) => console.error('设备分类树加载失败:', err))
}
/** 拉取所属客户下拉 */
@@ -197,7 +200,7 @@ const onCustomerChange = (customerId: string | undefined): void => {
}
onMounted(() => {
loadCategoryOpts()
loadCategoryTree()
loadCustomerOpts()
listRequest()
})
@@ -5,6 +5,7 @@
* - 列表展示所有待初始化设备(设备视角,非任务视角)
* - 仅维护「软件版本设置」一个写操作(单条 / 批量)
* - 其他元数据(分类 / 数据分发 / 数据上报)在对应模块维护,此处只读展示
* - 设备类别:末级分类名(与设备监测 / 告警模块对齐,由 categoryTree 接口动态拉取)
*/
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
@@ -22,7 +23,7 @@ export interface InitItem {
id: string
/** 设备编码 */
deviceCode: string
/** 设备类别(级分类名) */
/** 设备类别(级分类名,与设备分类树叶子节点 name 对齐 */
deviceCategory: string
/** 设备名称 */
deviceName: string
@@ -105,7 +106,7 @@ export interface InitDetail extends InitItem {
export interface SearchForm extends BaseSearchForm {
/** 设备编码 */
deviceCode?: string
/** 设备类别(级分类名) */
/** 设备类别(级分类名,与设备分类树叶子节点 name 对齐 */
deviceCategory?: string
/** 初始化状态 */
initStatus?: InitStatus
@@ -159,12 +160,6 @@ export interface SiteOption {
customerId: string
}
/** 设备类别下拉选项 */
export interface DeviceCategoryOption {
/** 二级分类名(作为 value */
name: string
}
/** 初始化状态下拉 */
export const INIT_STATUS_OPTIONS: { label: string; value: InitStatus }[] = [
{ label: '待初始化', value: 'pending' },
@@ -4,12 +4,12 @@
* - stats 顶部统计卡
* - info 详情(含 6 Tab 聚合数据)
* - remoteAction 远程下发:重启
* - categoryOptions 设备分类下拉(末级分类名
* - categoryTree 设备分类全树(复用设备分类页接口,供筛选区树选择器加载
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { CategoryNode } from '../../../asset/category/types'
import type {
DeviceCategoryOption,
MonitorDetail,
MonitorItem,
MonitorStats,
@@ -31,6 +31,6 @@ export const info = (id: string): Promise<ApiResponse<MonitorDetail>> =>
export const remoteAction = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', BASE + '/remote', { id, type: 'restart' })
/** 设备分类下拉(末级分类名,与设备初始化模块对齐 */
export const categoryOptions = (): Promise<ApiResponse<DeviceCategoryOption[]>> =>
postRequest('axiosRequest', BASE + '/category/options', {})
/** 设备分类全树(与设备分类页 / 统计页同源,供筛选区树选择器加载;value=name 保留 deviceCategory 契约 */
export const categoryTree = (keyword?: string): Promise<ApiResponse<CategoryNode[]>> =>
postRequest('axiosRequest', '/admin/device/category/tree', { keyword })
@@ -23,14 +23,17 @@
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="设备编码 / 名称" style="width: 200px" />
</a-form-item>
<a-form-item label="设备分类">
<a-select
<a-tree-select
v-model:value="search.deviceCategory"
:options="categoryOpts"
:tree-data="categoryTreeData"
:field-names="CATEGORY_TREE_FIELD_NAMES"
show-search
allow-clear
tree-default-expand-all
placeholder="全部分类"
style="width: 160px"
:filter-option="filterOption"
style="width: 220px"
:filter-tree-node="(input: string, node: { name?: string }) =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="运行状态">
@@ -73,17 +76,21 @@
* 业务对齐 §3.6 设备运维 → 设备监测:
* - 主列表:设备运行状态总览
* - 详情页(多 Tab 聚合):实施/自检/巡检/运行日志/更新记录/维修记录
* - 设备分类筛选:a-tree-select 加载设备分类全树(与统计页同源,value=name 保留 deviceCategory 契约)
*/
import { onMounted, ref } from 'vue'
import type { CategoryNode } from '../../../asset/category/types'
import FilterBar from '@components/FilterBar.vue'
import TableCard from '@components/TableCard.vue'
import StatCard from '@components/StatCard.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { RUN_STATUS_OPTIONS, type DeviceCategoryOption } from './types'
import { RUN_STATUS_OPTIONS } from './types'
import { usePage } from './init/usePage'
import { filterOption } from '@utils/antDesign/select'
import * as api from './api'
/** a-tree-select 字段映射(与统计页保持一致;value=name 保留 deviceCategory 契约) */
const CATEGORY_TREE_FIELD_NAMES = { label: 'name', value: 'name', children: 'children' }
const {
pageLoading,
search,
@@ -96,21 +103,19 @@ const {
handleAction,
} = usePage()
/** 设备分类下拉(末级分类名,与设备初始化模块对齐 */
const categoryOpts = ref<{ label: string; value: string }[]>([])
/** 设备分类全树(与统计页同源 */
const categoryTreeData = ref<CategoryNode[]>([])
const loadCategoryOpts = (): void => {
api.categoryOptions()
const loadCategoryTree = (): void => {
api.categoryTree()
.then((res) => {
if (res.code === '00000') {
categoryOpts.value = (res.data ?? []).map((i: DeviceCategoryOption) => ({ label: i.name, value: i.name }))
}
if (res.code === '00000') categoryTreeData.value = res.data ?? []
})
.catch((err: unknown) => console.error('设备分类下拉失败:', err))
.catch((err: unknown) => console.error('设备分类树加载失败:', err))
}
onMounted(() => {
loadCategoryOpts()
loadCategoryTree()
})
</script>
@@ -39,7 +39,7 @@ export interface MonitorItem {
/** 查询表单 */
export interface SearchForm extends BaseSearchForm {
/** 设备分类(末级分类名) */
/** 设备分类(末级分类名,与设备分类树叶子节点 name 对齐 */
deviceCategory?: string
runStatus?: RunStatus
tenantName?: string
@@ -47,12 +47,6 @@ export interface SearchForm extends BaseSearchForm {
hasAlert?: boolean
}
/** 设备分类下拉选项(末级分类名) */
export interface DeviceCategoryOption {
/** 末级分类名(作为 value */
name: string
}
/** 运行状态下拉 */
export const RUN_STATUS_OPTIONS: { label: string; value: RunStatus }[] = [
{ label: '在用', value: 'online' },
@@ -18,6 +18,6 @@ export const add = (params: InventoryPayload): Promise<ApiResponse<null>> =>
export const audit = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/audit', { id })
/** 删除盘点单(仅草稿状态可删) */
/** 删除盘点单(仅进行中状态可删) */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/delete', { id })
@@ -62,8 +62,8 @@
type="info"
show-icon
:message="form.inventoryType === 'partial'
? '提交后系统按所选品类 + 该区域当前在库台账生成应盘清单(按『设备类别末级分类 + 批次』聚合),盘点单进入草稿状态;小程序扫码完成实盘后变已完成;后台审核后修正库存台账。'
: '提交后系统按该区域当前在库台账生成应盘清单(按『设备类别末级分类 + 批次』聚合),盘点单进入草稿状态;小程序扫码完成实盘后变已完成;后台审核后修正库存台账。'"
? '提交后系统按所选品类 + 该区域当前在库台账生成应盘清单(按『设备类别末级分类 + 批次』聚合),盘点单进入进行中状态;小程序扫码完成实盘后变已完成;后台审核后修正库存台账。'
: '提交后系统按该区域当前在库台账生成应盘清单(按『设备类别末级分类 + 批次』聚合),盘点单进入进行中状态;小程序扫码完成实盘后变已完成;后台审核后修正库存台账。'"
style="margin-top: 8px"
/>
</a-form>
@@ -10,8 +10,8 @@ import type InfoDrawer from '../component/drawer/info/info.vue'
/**
* 库存盘点页主编排:
* - 列表 + 新增(选仓库+类型)+ 查看详情(抽屉)+ 审核 + 删除(仅草稿可删)
* - 状态流转:草稿 → 已完成(小程序扫码提交后)→ 已审核(后台审核后修正库存台账)
* - 列表 + 新增(选仓库+类型)+ 查看详情(抽屉)+ 审核 + 删除(仅进行中可删)
* - 状态流转:进行中 → 已完成(小程序扫码提交后)→ 已审核(后台审核后修正库存台账)
* - 实盘扫码动作在小程序端做,后台只读查看实盘数据并审核
*/
export const usePage = () => {
@@ -105,10 +105,10 @@ export const usePage = () => {
})
}
/** 删除(仅 draft 可删) */
/** 删除(仅 in_progress 可删) */
const deleteRecord = (record: ListItem): void => {
if (record.status !== 'draft') {
void message.warning('仅"草稿"状态的盘点单可删除')
if (record.status !== 'in_progress') {
void message.warning('仅"进行中"状态的盘点单可删除')
return
}
Modal.confirm({
@@ -44,9 +44,9 @@
size="small"
@click="handleAction('audit', record)"
>审核</a-button>
<a-divider v-if="record.status === 'draft'" type="vertical" />
<a-divider v-if="record.status === 'in_progress'" type="vertical" />
<a-button
v-if="record.status === 'draft'"
v-if="record.status === 'in_progress'"
type="link"
size="small"
danger
@@ -1,7 +1,7 @@
import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types'
/** 盘点单状态:draft 草稿 / completed 已完成(待审核)/ audited 已审核 */
export type InventoryStatus = 'draft' | 'completed' | 'audited'
/** 盘点单状态:in_progress 进行中 / completed 已完成(待审核)/ audited 已审核 */
export type InventoryStatus = 'in_progress' | 'completed' | 'audited'
/** 盘点类型:full 全量盘点 / partial 部分盘点 */
export type InventoryType = 'full' | 'partial'
@@ -84,7 +84,7 @@ export interface InventoryPayload {
}
export const INVENTORY_STATUS_OPTIONS: Array<{ label: string; value: InventoryStatus }> = [
{ label: '草稿', value: 'draft' },
{ label: '进行中', value: 'in_progress' },
{ label: '已完成', value: 'completed' },
{ label: '已审核', value: 'audited' },
]
@@ -95,7 +95,7 @@ export const INVENTORY_TYPE_OPTIONS: Array<{ label: string; value: InventoryType
]
export const INVENTORY_STATUS_COLOR: Record<InventoryStatus, string> = {
draft: 'default',
in_progress: 'processing',
completed: 'warning',
audited: 'success',
}
+54 -12
View File
@@ -1,6 +1,10 @@
<template>
<div class="inspect-home">
<van-nav-bar title="巡检" />
<van-nav-bar title="巡检">
<template #right>
<van-icon name="plus" size="18" @click="goCreate" />
</template>
</van-nav-bar>
<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" />
@@ -11,7 +15,7 @@
<van-empty
v-if="!list.length && !loading"
image="search"
description="该状态下暂无巡检任务"
description="该状态下暂无巡检任务,点击右上角 + 新建"
class="inspect-home__empty"
/>
<div
@@ -22,23 +26,49 @@
@click="goDetail(plan.id)"
>
<div class="ip-card__header">
<van-tag :type="statusTagType(plan.status)" size="medium">{{ statusText(plan.status) }}</van-tag>
<div class="ip-card__tags">
<van-tag :type="statusTagType(plan.status)" size="medium">{{ statusText(plan.status) }}</van-tag>
<van-tag
v-if="plan.planType === 'ad hoc'"
plain
type="warning"
size="medium"
>自建</van-tag>
<van-tag
v-else
plain
type="primary"
size="medium"
>派发</van-tag>
</div>
<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="friends-o" />
<span>{{ plan.customerName }}</span>
</div>
<div class="ip-card__meta">
<van-icon name="location-o" />
<span>{{ plan.site }}</span>
<span>{{ plan.siteName }}</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' }">
<span
v-if="plan.deadline"
class="ip-card__deadline"
:class="{ 'ip-card__deadline--over': plan.status === 'overdue' }"
>
<van-icon name="clock-o" />
截止{{ plan.deadline }}
</span>
<span v-else class="ip-card__deadline ip-card__deadline--adhoc">
<van-icon name="clock-o" />
自建记录 · 无截止时间
</span>
<span class="ip-card__inspector">{{ plan.inspector }}</span>
</div>
</div>
@@ -50,12 +80,15 @@
<script setup lang="ts">
/**
* 实施运维小程序 / 巡检计划列表
* - 4 状态分段 Tab(未开始 / 进行中 / 已完成 / 已逾期)
* - 派发任务与自建记录统一展示,通过卡片 tag 区分来源
* - 4 状态分段 Tab(全部 / 未开始 / 进行中 / 已完成 / 已逾期)
* - 右上角 "+" 按钮进入新增巡检页
* - 列表项点击进入任务详情
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listInspectPlans } from './api'
import { inspectStatusTagType, inspectStatusText } from './constants'
import type { InspectPlan, InspectPlanStatus } from './types'
const router = useRouter()
@@ -75,17 +108,17 @@ const statusTabs: Array<{ label: string; value: InspectPlanStatus | '' }> = [
{ 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 statusText = inspectStatusText
const statusTagType = inspectStatusTagType
const goDetail = (id: string): void => {
router.push(`/miniprogram/ops/inspect-detail/${id}`)
}
const goCreate = (): void => {
router.push('/miniprogram/ops/inspect-create')
}
const loadList = (): void => {
listInspectPlans({ status: activeTab.value })
.then((res) => {
@@ -149,6 +182,11 @@ onMounted(() => {
margin-bottom: 6px;
}
&__tags {
display: flex;
gap: 6px;
}
&__count {
font-size: var(--font-xs);
color: var(--text-secondary);
@@ -190,6 +228,10 @@ onMounted(() => {
&--over {
color: var(--danger);
}
&--adhoc {
color: var(--warning);
}
}
&__inspector {
+15 -2
View File
@@ -2,9 +2,16 @@
* 实施运维小程序 / 巡检 接口
*/
import { getRequest, postRequest } from '@axios'
import type { InspectItemSubmit, InspectPlan, InspectPlanStatus, InspectTaskDetail } from './types'
import type {
InspectCreateForm,
InspectCreateResult,
InspectItemSubmit,
InspectPlan,
InspectPlanStatus,
InspectTaskDetail,
} from './types'
/** 巡检计划列表 */
/** 巡检计划列表(按状态过滤,派发/自建通过卡片 tag 区分,列表不预过滤) */
export const listInspectPlans = (
params: { status?: InspectPlanStatus | '' },
): Promise<{ code: string; msg: string; data: InspectPlan[] }> =>
@@ -27,3 +34,9 @@ export const finishInspectTask = (
planId: string,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/inspect/finish', { planId })
/** 创建自建巡检记录(无需预派发计划) */
export const createInspectRecord = (
params: InspectCreateForm,
): Promise<{ code: string; msg: string; data: InspectCreateResult }> =>
postRequest('axiosRequest', '/mp/ops/inspect/create', params)
@@ -0,0 +1,142 @@
/**
* 实施运维小程序 / 巡检 字典常量
*
* 字典与后台 device-center/ops/inspect 对齐:
* - INSPECT_CUSTOMER_OPTS:客户单位(巡检对象所属客户)
* - INSPECT_SITE_BY_CUSTOMER:按客户分组的布点场所
* - DEVICE_CATEGORY_OPTS:设备类目(智养餐饮设备/智能穿戴/体重测量/健身器材/健康监测)
* - INSPECT_ITEM_TYPE_OPTS:巡检项类型(外观/功能/网络/传感器/固件)
* - DEFAULT_INSPECT_ITEMS:默认预置 6 项标准巡检项
*
* 用法:
* import { INSPECT_CUSTOMER_OPTS, inspectItemTypeText } from './constants'
* <van-picker :columns="INSPECT_CUSTOMER_OPTS" />
*/
import type { InspectCreateItem, InspectItemType, InspectPlanStatus, InspectPlanType } from './types'
/** 巡检客户下拉项(实施运维服务的客户单位) */
export const INSPECT_CUSTOMER_OPTS: Array<{ text: string; value: string }> = [
{ text: '上海杨浦颐养中心', value: 'cust-sh-001' },
{ text: '北京朝阳福利院', value: 'cust-bj-002' },
{ text: '广州天河颐康医院', value: 'cust-gz-003' },
{ text: '杭州西湖社区养老院', value: 'cust-hz-004' },
{ text: '重庆渝中颐养福利中心', value: 'cust-cq-005' },
]
/** 按客户分组布点场所(key = customerIdvalue = 该客户下的场所列表) */
export const INSPECT_SITE_BY_CUSTOMER: Record<string, Array<{ text: string; value: string; category: string }>> = {
'cust-sh-001': [
{ text: '1号食堂', value: 'site-sh-001', category: '智养餐饮设备' },
{ text: '健康体检站', value: 'site-sh-002', category: '智能穿戴设备' },
{ text: '康复健身房', value: 'site-sh-003', category: '健身器材设备' },
],
'cust-bj-002': [
{ text: '主楼食堂', value: 'site-bj-001', category: '智养餐饮设备' },
{ text: '体检中心', value: 'site-bj-002', category: '体重测量设备' },
],
'cust-gz-003': [
{ text: '门诊食堂', value: 'site-gz-001', category: '智养餐饮设备' },
{ text: '住院部监测站', value: 'site-gz-002', category: '健康监测设备' },
],
'cust-hz-004': [
{ text: '社区食堂', value: 'site-hz-001', category: '智养餐饮设备' },
{ text: '社区体检点', value: 'site-hz-002', category: '智能穿戴设备' },
],
'cust-cq-005': [
{ text: '中心食堂', value: 'site-cq-001', category: '智养餐饮设备' },
{ text: '康复健身房', value: 'site-cq-002', category: '健身器材设备' },
],
}
/** 客户 ID → 客户名称查询 */
export const customerNameById = (id: string): string =>
INSPECT_CUSTOMER_OPTS.find((c) => c.value === id)?.text ?? ''
/** 场所 ID → 场所信息查询(含名称与默认设备类目) */
export const siteInfoById = (
id: string,
): { text: string; value: string; category: string } | undefined => {
for (const list of Object.values(INSPECT_SITE_BY_CUSTOMER)) {
const found = list.find((s) => s.value === id)
if (found) return found
}
return undefined
}
/** 设备类目下拉项 */
export const DEVICE_CATEGORY_OPTS: Array<{ text: string; value: string }> = [
{ text: '智养餐饮设备', value: '智养餐饮设备' },
{ text: '智能穿戴设备', value: '智能穿戴设备' },
{ text: '体重测量设备', value: '体重测量设备' },
{ text: '健身器材设备', value: '健身器材设备' },
{ text: '健康监测设备', value: '健康监测设备' },
]
/** 巡检项类型下拉项(供自定义巡检项时选择类型) */
export const INSPECT_ITEM_TYPE_OPTS: Array<{ text: string; value: InspectItemType }> = [
{ text: '外观', value: 'appearance' },
{ text: '功能', value: 'function' },
{ text: '网络', value: 'network' },
{ text: '传感器', value: 'sensor' },
{ text: '固件', value: 'firmware' },
]
/** 默认预置 6 项标准巡检项(与原 buildItems 一致,便于派发任务与自建记录统一检查口径) */
export const DEFAULT_INSPECT_ITEMS: InspectCreateItem[] = [
{ name: '设备外观', type: 'appearance', standard: '无明显损伤 / 变形' },
{ name: '功能测试', type: 'function', standard: '按键 / 显示正常' },
{ name: '网络连通', type: 'network', standard: '心跳正常上报' },
{ name: '传感器读数', type: 'sensor', standard: '温度 / 湿度在阈值内' },
{ name: '固件版本', type: 'firmware', standard: '与最新版本一致' },
{ name: '电源稳定性', type: 'function', standard: '无掉电告警' },
]
/** 巡检项类型 → 中文文本 */
export const inspectItemTypeText = (t: InspectItemType): string =>
({
appearance: '外观',
function: '功能',
network: '网络',
sensor: '传感器',
firmware: '固件',
})[t]
/** 巡检项类型 → Vant Tag 类型 */
export const inspectItemTypeTagType = (
t: InspectItemType,
): 'primary' | 'warning' | 'success' | 'default' =>
t === 'function'
? 'primary'
: t === 'sensor'
? 'warning'
: t === 'network'
? 'success'
: 'default'
/** 巡检计划状态 → 中文文本 */
export const inspectStatusText = (s: InspectPlanStatus): string =>
({
todo: '未开始',
doing: '进行中',
done: '已完成',
overdue: '已逾期',
})[s]
/** 巡检计划状态 → Vant Tag 类型 */
export const inspectStatusTagType = (
s: InspectPlanStatus,
): 'default' | 'primary' | 'success' | 'danger' =>
s === 'todo'
? 'default'
: s === 'doing'
? 'primary'
: s === 'done'
? 'success'
: 'danger'
/** 巡检计划类型 → 中文文本 */
export const inspectPlanTypeText = (t: InspectPlanType): string =>
({
dispatched: '派发任务',
'ad hoc': '自建记录',
})[t]
+60 -11
View File
@@ -12,13 +12,16 @@
<!-- 计划头部 -->
<div class="inspect-detail__header">
<van-tag :type="statusTagType(detail.plan.status)">{{ statusText(detail.plan.status) }}</van-tag>
<van-tag v-if="detail.plan.planType === 'ad hoc'" plain type="warning">自建</van-tag>
<van-tag v-else plain type="primary">派发</van-tag>
<span class="inspect-detail__name">{{ detail.plan.name }}</span>
<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>
<span v-if="detail.plan.deadline" class="inspect-detail__deadline">截止{{ detail.plan.deadline }}</span>
<span v-else class="inspect-detail__deadline inspect-detail__deadline--adhoc">自建记录 · 无截止</span>
</div>
<!-- 设备切换 -->
@@ -53,11 +56,27 @@
</div>
<div class="inspect-item__standard">标准{{ item.standard }}</div>
<div v-if="item.result !== 'pending'" class="inspect-item__remark">
{{ item.remark || '无备注' }}
<van-icon
:name="item.result === 'pass' ? 'success' : 'warning-o'"
:class="item.result === 'pass' ? 'inspect-item__remark-icon--pass' : 'inspect-item__remark-icon--abnormal'"
/>
<span>{{ item.remark || '无备注' }}</span>
</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>
<van-button
size="small"
type="success"
plain
:loading="submittingId === item.id"
@click="onSubmitItem(item, 'pass')"
>合格</van-button>
<van-button
size="small"
type="danger"
plain
:loading="submittingId === item.id"
@click="onSubmitItem(item, 'abnormal')"
>异常</van-button>
</div>
</div>
</div>
@@ -89,6 +108,8 @@ const router = useRouter()
const detail = ref<InspectTaskDetail | null>(null)
const activeDeviceIdx = ref(0)
/** 当前正在提交的巡检项 ID,用于按钮 loading 态防连点 */
const submittingId = ref<string>('')
const currentDevice = computed(() => detail.value?.devices[activeDeviceIdx.value] ?? null)
@@ -123,8 +144,11 @@ const onScanDevice = (): void => {
const onSubmitItem = (item: InspectItem, result: 'pass' | 'abnormal'): void => {
if (!detail.value) return
// 防连点:同一项正在提交时直接忽略
if (submittingId.value === item.id) return
const dev = detail.value.devices[activeDeviceIdx.value]
if (!dev) return
submittingId.value = item.id
submitInspectItem({
planId: detail.value.plan.id,
deviceCode: dev.code,
@@ -135,16 +159,22 @@ const onSubmitItem = (item: InspectItem, result: 'pass' | 'abnormal'): void => {
.then((res) => {
if (res.code === '00000') {
item.result = result
item.remark = result === 'abnormal' ? '已转工单' : '合格'
item.remark = result === 'abnormal' ? '已记录异常,并自动转工单' : '合格'
if (result === 'abnormal') {
showFailToast('已记录异常并自动转工单')
showFailToast({ message: '已记录异常并自动转工单', duration: 1500 })
} else {
showSuccessToast('已记录合格')
showSuccessToast({ message: '已记录合格', duration: 1500 })
}
} else {
showFailToast({ message: res.msg || '提交失败', duration: 1500 })
}
})
.catch(() => undefined)
.finally(() => undefined)
.catch(() => {
showFailToast({ message: '网络异常,提交失败', duration: 1500 })
})
.finally(() => {
submittingId.value = ''
})
}
const onFinish = (): void => {
@@ -152,13 +182,13 @@ const onFinish = (): void => {
// 校验所有项已填报
const pending = detail.value.devices.flatMap((d) => d.items).filter((i) => i.result === 'pending')
if (pending.length > 0) {
showFailToast(`还有 ${pending.length} 项未填报`)
showFailToast({ message: `还有 ${pending.length} 项未填报`, duration: 1500 })
return
}
finishInspectTask(detail.value.plan.id)
.then((res) => {
if (res.code === '00000') {
showSuccessToast('巡检任务已完成')
showSuccessToast({ message: '巡检任务已完成', duration: 1500 })
router.back()
}
})
@@ -239,6 +269,10 @@ onMounted(() => {
&__deadline {
margin-left: auto;
&--adhoc {
color: var(--warning);
}
}
&__items {
@@ -296,6 +330,21 @@ onMounted(() => {
&__remark {
font-size: var(--font-xs);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
:deep(.van-icon) {
font-size: 14px;
}
&-icon--pass {
color: var(--success);
}
&-icon--abnormal {
color: var(--danger);
}
}
&__actions {
@@ -0,0 +1,502 @@
<template>
<div class="inspect-create">
<van-nav-bar title="新增巡检" left-arrow @click-left="onBack" />
<van-form @submit="onSubmit">
<!-- 基础信息 -->
<van-cell-group inset title="基础信息">
<van-field
:model-value="customerDisplay"
name="customerId"
label="巡检客户"
placeholder="请选择客户单位"
readonly
is-link
:rules="[{ required: true, message: '请选择客户单位' }]"
@click="showCustomerPicker = true"
/>
<van-field
:model-value="siteDisplay"
name="siteId"
label="布点场所"
placeholder="请选择布点场所"
readonly
is-link
:disabled="!form.customerId"
:rules="[{ required: true, message: '请选择布点场所' }]"
@click="openSitePicker"
/>
<van-field
:model-value="deviceCategoryDisplay"
name="deviceCategory"
label="设备类目"
placeholder="请选择设备类目"
readonly
is-link
:rules="[{ required: true, message: '请选择设备类目' }]"
@click="showCategoryPicker = true"
/>
<van-field
v-model="form.remark"
name="remark"
label="备注"
type="textarea"
placeholder="选填,最多 200 字"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
</van-cell-group>
<!-- 设备清单 -->
<van-cell-group inset title="设备清单">
<div v-if="!form.devices.length" class="inspect-create__empty">
请添加至少 1 台设备
</div>
<div
v-for="(d, idx) in form.devices"
:key="idx"
class="inspect-create__device"
>
<div class="inspect-create__device-head">
<span class="inspect-create__device-idx">#{{ idx + 1 }}</span>
<van-icon name="delete-o" class="inspect-create__device-del" @click="removeDevice(idx)" />
</div>
<van-field
v-model="d.code"
label="设备编码"
placeholder="扫码或手动输入"
clearable
:rules="[{ required: true, message: '请填写设备编码' }]"
/>
<van-field
v-model="d.name"
label="设备名称"
placeholder="请输入设备名称"
clearable
:rules="[{ required: true, message: '请填写设备名称' }]"
/>
</div>
<div class="inspect-create__add-row">
<van-button size="small" plain type="primary" icon="plus" @click="addDevice">手动添加</van-button>
<van-button size="small" plain type="primary" icon="scan" @click="onScanAdd">扫码添加</van-button>
</div>
</van-cell-group>
<!-- 巡检项配置 -->
<van-cell-group inset title="巡检项配置">
<div v-if="!enabledItems.length" class="inspect-create__empty">
请启用至少 1 项巡检项
</div>
<div
v-for="(item, idx) in form.items"
:key="idx"
class="inspect-create__item"
:class="{ 'inspect-create__item--disabled': !item.enabled }"
>
<van-cell center>
<template #title>
<van-checkbox v-model="item.enabled" shape="square">
{{ item.name || '未命名巡检项' }}
</van-checkbox>
</template>
<template #right-icon>
<van-icon name="delete-o" class="inspect-create__item-del" @click="removeItem(idx)" />
</template>
</van-cell>
<template v-if="item.enabled">
<van-field
v-model="item.name"
label="巡检项名称"
placeholder="如:设备外观"
:rules="[{ required: true, message: '请填写巡检项名称' }]"
/>
<van-field
:model-value="itemTypeDisplay(item.type)"
label="类型"
placeholder="请选择类型"
readonly
is-link
@click="openItemTypePicker(idx)"
/>
<van-field
v-model="item.standard"
label="判定标准"
placeholder="如:无明显损伤 / 变形"
:rules="[{ required: true, message: '请填写判定标准' }]"
/>
</template>
</div>
<div class="inspect-create__add-row">
<van-button size="small" plain type="primary" icon="plus" @click="addItem">+ 自定义巡检项</van-button>
</div>
</van-cell-group>
<div class="inspect-create__submit">
<van-button block type="primary" native-type="submit">创建并开始巡检</van-button>
</div>
</van-form>
<!-- 客户选择 -->
<van-popup v-model:show="showCustomerPicker" position="bottom" round>
<van-picker
:columns="INSPECT_CUSTOMER_OPTS"
@confirm="onCustomerConfirm"
@cancel="showCustomerPicker = false"
/>
</van-popup>
<!-- 布点场所选择 -->
<van-popup v-model:show="showSitePicker" position="bottom" round>
<van-picker
:columns="siteColumns"
@confirm="onSiteConfirm"
@cancel="showSitePicker = false"
/>
</van-popup>
<!-- 设备类目选择 -->
<van-popup v-model:show="showCategoryPicker" position="bottom" round>
<van-picker
:columns="DEVICE_CATEGORY_OPTS"
@confirm="onCategoryConfirm"
@cancel="showCategoryPicker = false"
/>
</van-popup>
<!-- 巡检项类型选择 -->
<van-popup v-model:show="showItemTypePicker" position="bottom" round>
<van-picker
:columns="INSPECT_ITEM_TYPE_OPTS"
@confirm="onItemTypeConfirm"
@cancel="showItemTypePicker = false"
/>
</van-popup>
<!-- 扫码添加模拟 -->
<van-dialog
v-model:show="scanDialogShow"
title="扫码添加设备"
show-cancel-button
:confirm-button-text="'识别并添加'"
@confirm="onScanConfirm"
>
<div class="inspect-create__scan-tip">原型演示输入设备编码模拟扫码识别设备</div>
<van-field
v-model="scanCodeInput"
placeholder="请输入设备编码"
clearable
/>
<div class="inspect-create__scan-presets">
<van-tag
v-for="preset in SCAN_PRESETS"
:key="preset.code"
plain
type="primary"
size="medium"
@click="scanCodeInput = preset.code"
>
{{ preset.code }}
</van-tag>
</div>
</van-dialog>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 新增巡检(自建巡检记录)
* - 不依赖后台预派发计划,运维工程师现场即可发起
* - 表单:客户单位 / 布点场所 / 设备类目 / 备注
* - 设备清单:手动添加 + 扫码添加(原型用编码识别模拟)
* - 巡检项配置:6 项默认预置 + 可自定义 / 启用 / 删除
* - 提交后跳转 detail.vue 进入逐项填报流程
*/
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { showFailToast, showSuccessToast } from 'vant'
import { createInspectRecord } from './api'
import {
DEFAULT_INSPECT_ITEMS,
DEVICE_CATEGORY_OPTS,
INSPECT_CUSTOMER_OPTS,
INSPECT_ITEM_TYPE_OPTS,
INSPECT_SITE_BY_CUSTOMER,
inspectItemTypeText,
} from './constants'
import type { InspectCreateForm, InspectCreateItem, InspectItemType } from './types'
const router = useRouter()
/** 巡检项配置项(带启用开关,提交时仅传启用的项) */
interface ItemConfig extends InspectCreateItem {
enabled: boolean
}
const form = reactive<InspectCreateForm & { items: ItemConfig[] }>({
customerId: '',
customerName: '',
siteId: '',
siteName: '',
deviceCategory: '',
remark: '',
devices: [],
items: DEFAULT_INSPECT_ITEMS.map((it) => ({ ...it, enabled: true })),
})
const showCustomerPicker = ref(false)
const showSitePicker = ref(false)
const showCategoryPicker = ref(false)
const showItemTypePicker = ref(false)
const itemTypeEditingIdx = ref(-1)
const scanDialogShow = ref(false)
const scanCodeInput = ref('')
/** 扫码预设设备(mock 静态数据,避免跨页面依赖 scan 模块) */
const SCAN_PRESETS: Array<{ code: string; name: string }> = [
{ code: 'CAB-SH-001', name: '1号净菜柜' },
{ code: 'CAB-SH-002', name: '2号净菜柜' },
{ code: 'WATCH-BJ-014', name: '14号健康手表' },
{ code: 'TREADMILL-CQ-002', name: '2号跑步机' },
]
/** 设备编码 → 名称 查询表(用于扫码识别) */
const codeNameMap: Record<string, string> = SCAN_PRESETS.reduce(
(acc, cur) => {
acc[cur.code] = cur.name
return acc
},
{} as Record<string, string>,
)
const customerDisplay = computed(() => form.customerName || '')
const siteDisplay = computed(() => form.siteName || '')
const deviceCategoryDisplay = computed(() => form.deviceCategory || '')
/** 当前客户下的布点场所列表 */
const siteColumns = computed(() => INSPECT_SITE_BY_CUSTOMER[form.customerId] ?? [])
const enabledItems = computed(() => form.items.filter((it) => it.enabled))
const itemTypeDisplay = (t: InspectItemType): string => inspectItemTypeText(t)
/** 切换客户时清空已选场所与设备类目(避免脏数据) */
watch(
() => form.customerId,
() => {
form.siteId = ''
form.siteName = ''
form.deviceCategory = ''
},
)
const onBack = (): void => {
router.back()
}
const onCustomerConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
form.customerId = selectedOptions[0]?.value ?? ''
form.customerName = selectedOptions[0]?.text ?? ''
showCustomerPicker.value = false
}
const openSitePicker = (): void => {
if (!form.customerId) {
showFailToast('请先选择客户单位')
return
}
showSitePicker.value = true
}
const onSiteConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string; category: string }> }): void => {
form.siteId = selectedOptions[0]?.value ?? ''
form.siteName = selectedOptions[0]?.text ?? ''
// 自动带入场所对应的设备类目(用户可后续手动覆盖)
if (selectedOptions[0]?.category) form.deviceCategory = selectedOptions[0].category
showSitePicker.value = false
}
const onCategoryConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
form.deviceCategory = selectedOptions[0]?.value ?? ''
showCategoryPicker.value = false
}
const openItemTypePicker = (idx: number): void => {
itemTypeEditingIdx.value = idx
showItemTypePicker.value = true
}
const onItemTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: InspectItemType }> }): void => {
const idx = itemTypeEditingIdx.value
if (idx >= 0 && form.items[idx]) {
form.items[idx].type = selectedOptions[0]?.value ?? 'appearance'
}
showItemTypePicker.value = false
itemTypeEditingIdx.value = -1
}
const addDevice = (): void => {
form.devices.push({ code: '', name: '' })
}
const removeDevice = (idx: number): void => {
form.devices.splice(idx, 1)
}
const onScanAdd = (): void => {
scanCodeInput.value = ''
scanDialogShow.value = true
}
const onScanConfirm = (): void => {
const code = scanCodeInput.value.trim()
if (!code) {
showFailToast('请输入设备编码')
return
}
// 已添加过则提示
if (form.devices.some((d) => d.code === code)) {
showFailToast('该设备已在清单中')
return
}
form.devices.push({
code,
name: codeNameMap[code] ?? '',
})
showSuccessToast('已添加 1 台设备')
}
const addItem = (): void => {
form.items.push({
name: '',
type: 'appearance',
standard: '',
enabled: true,
})
}
const removeItem = (idx: number): void => {
form.items.splice(idx, 1)
}
const onSubmit = (): void => {
// 二次校验:设备和巡检项至少各 1
if (!form.devices.length) {
showFailToast('请添加至少 1 台设备')
return
}
const submitItems: InspectCreateItem[] = form.items
.filter((it) => it.enabled)
.map(({ name, type, standard }) => ({ name, type, standard }))
if (!submitItems.length) {
showFailToast('请启用至少 1 项巡检项')
return
}
const payload: InspectCreateForm = {
customerId: form.customerId,
customerName: form.customerName,
siteId: form.siteId,
siteName: form.siteName,
deviceCategory: form.deviceCategory,
remark: form.remark,
devices: form.devices.map(({ code, name }) => ({ code, name })),
items: submitItems,
}
createInspectRecord(payload)
.then((res) => {
if (res.code === '00000' && res.data) {
showSuccessToast('已创建,开始巡检')
router.replace(`/miniprogram/ops/inspect-detail/${res.data.planId}`)
}
})
.catch(() => undefined)
.finally(() => undefined)
}
</script>
<style scoped lang="less">
.inspect-create {
min-height: 100%;
background: var(--background);
padding-bottom: 80px;
&__empty {
padding: 16px;
text-align: center;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__device {
background: #fafbfc;
border-radius: var(--radius-md);
margin: 8px 16px 8px 16px;
padding-bottom: 4px;
border: 1px solid var(--border);
}
&__device-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-bottom: 1px dashed var(--border);
}
&__device-idx {
font-size: var(--font-xs);
color: var(--primary);
font-weight: 500;
}
&__device-del {
font-size: 18px;
color: var(--danger);
}
&__item {
background: #fafbfc;
border-radius: var(--radius-md);
margin: 8px 16px;
padding-bottom: 4px;
border: 1px solid var(--border);
&--disabled {
opacity: 0.5;
}
}
&__item-del {
font-size: 18px;
color: var(--danger);
}
&__add-row {
display: flex;
gap: 8px;
padding: 8px 16px 12px;
}
&__submit {
padding: 16px;
}
&__scan-tip {
padding: 8px 16px;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__scan-presets {
padding: 0 16px 12px;
display: flex;
flex-wrap: wrap;
gap: 6px;
.van-tag {
cursor: pointer;
}
}
}
</style>
@@ -5,6 +5,9 @@
/** 巡检计划状态 */
export type InspectPlanStatus = 'todo' | 'doing' | 'done' | 'overdue'
/** 巡检计划类型:派发任务 / 自建记录 */
export type InspectPlanType = 'dispatched' | 'ad hoc'
/** 巡检项类型 */
export type InspectItemType = 'appearance' | 'function' | 'network' | 'sensor' | 'firmware'
@@ -16,9 +19,15 @@ export interface InspectPlan {
id: string
name: string
site: string
/** 客户单位名称(巡检对象所属客户,如"上海杨浦颐养中心" */
customerName: string
/** 布点场所名称(客户下的具体安装点,如"1号食堂" */
siteName: string
deviceCategory: string
deviceCount: number
status: InspectPlanStatus
/** 计划类型:派发任务 / 自建记录;老数据未传时由后端兜底为 'dispatched' */
planType: InspectPlanType
deadline: string
inspector: string
itemCount: number
@@ -53,3 +62,47 @@ export interface InspectItemSubmit {
result: 'pass' | 'abnormal'
remark: string
}
/** 自建巡检 - 设备清单项(提交前) */
export interface InspectCreateDevice {
/** 设备编码(扫码或手动输入) */
code: string
/** 设备名称 */
name: string
}
/** 自建巡检 - 巡检项配置项(提交前) */
export interface InspectCreateItem {
/** 巡检项名称 */
name: string
/** 巡检项类型 */
type: InspectItemType
/** 判定标准 */
standard: string
}
/** 自建巡检提交表单 */
export interface InspectCreateForm {
/** 客户单位 ID */
customerId: string
/** 客户单位名称 */
customerName: string
/** 布点场所 ID */
siteId: string
/** 布点场所名称 */
siteName: string
/** 设备类目 */
deviceCategory: string
/** 备注(选填) */
remark: string
/** 设备清单(至少 1 台) */
devices: InspectCreateDevice[]
/** 启用的巡检项配置(至少 1 项) */
items: InspectCreateItem[]
}
/** 自建巡检创建结果 */
export interface InspectCreateResult {
/** 新建的 planId */
planId: string
}
+89 -7
View File
@@ -50,8 +50,8 @@
@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="user-circle-o" is-link @click="goAccountInfo" />
<van-cell title="修改密码" icon="closed-eye" is-link @click="goChangePassword" />
<van-cell title="退出登录" icon="revoke" is-link @click="onLogout" />
</van-cell-group>
</div>
@@ -76,6 +76,26 @@
</div>
</div>
</van-popup>
<!-- 退出登录弹层自定义样式原型仅作示例不真正清登录态 -->
<van-popup
v-model:show="showLogout"
round
closeable
close-icon-position="top-right"
:style="{ width: '80%', maxWidth: '320px', padding: '24px 20px 20px' }"
class="me-home__logout"
>
<div class="me-home__logout-icon">
<van-icon name="warning-o" />
</div>
<div class="me-home__logout-title">退出登录</div>
<div class="me-home__logout-desc">确认退出当前账号<br />退出后需重新登录</div>
<div class="me-home__logout-actions">
<van-button block plain @click="showLogout = false">取消</van-button>
<van-button block type="danger" @click="onLogoutConfirm">确认退出</van-button>
</div>
</van-popup>
</div>
</template>
@@ -86,14 +106,15 @@
* - 功能入口:消息中心 / 离线缓存 / 账号 / 修改密码 / 退出
* - 离线缓存弹层
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import { showSuccessToast } from 'vant'
import {
clearOffline,
getOfflineStatus,
getPersonalStats,
listMessages,
logout,
syncOffline,
} from './api'
import type { OfflineStatus, PersonalStats } from './types'
@@ -108,19 +129,42 @@ const offline = reactive<OfflineStatus>({
lastSyncTime: '—',
})
const showOffline = ref(false)
const showLogout = ref(false)
const unreadCount = ref(0)
const goMessages = (): void => {
router.push('/miniprogram/ops/message-center')
}
// 右上角设置图标 + 账号信息 cell:跳账号信息子页
const onSetting = (): void => {
// 原型阶段未实现,用普通 toast 提示,避免误导为失败
showToast('原型阶段未实现')
router.push('/miniprogram/ops/account-info')
}
const goAccountInfo = (): void => {
router.push('/miniprogram/ops/account-info')
}
const goChangePassword = (): void => {
router.push('/miniprogram/ops/change-password')
}
// 退出登录:仅弹出确认弹层(原型不真正清登录态、不跳门禁页)
const onLogout = (): void => {
showToast('原型阶段未实现')
showLogout.value = true
}
// 确认退出:调用登出接口 → 仅 toast 提示,原型阶段不真正退出登录
const onLogoutConfirm = (): void => {
showLogout.value = false
logout()
.then((res) => {
if (res.code === '00000') {
showSuccessToast('已退出登录(原型示例)')
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const onSync = (): void => {
@@ -282,6 +326,44 @@ onMounted(() => {
flex-direction: column;
gap: 8px;
}
&__logout {
text-align: center;
:deep(.van-popup__close-icon) {
font-size: 18px;
color: var(--text-placeholder);
top: 12px;
right: 12px;
}
}
&__logout-icon {
:deep(.van-icon) {
font-size: 48px;
color: var(--warning);
}
margin-bottom: 12px;
}
&__logout-title {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
&__logout-desc {
font-size: var(--font-sm);
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 20px;
}
&__logout-actions {
display: flex;
gap: 12px;
}
}
.me-stat {
@@ -0,0 +1,142 @@
<template>
<div class="account-info">
<van-nav-bar title="账号信息" left-arrow @click-left="onBack">
<template #right>
<span class="account-info__edit" @click="onChangePassword">修改密码</span>
</template>
</van-nav-bar>
<van-skeleton :loading="loading" :row="6" :avatar="true" class="account-info__skeleton">
<template #default>
<!-- 用户卡 -->
<div class="account-info__user">
<van-icon name="user-o" class="account-info__avatar" />
<div class="account-info__user-meta">
<div class="account-info__name">{{ profile?.name ?? '—' }}</div>
<div class="account-info__role">{{ profile?.role ?? '—' }}</div>
</div>
<van-tag type="primary" plain size="medium">{{ profile?.jobNumber ?? '—' }}</van-tag>
</div>
<!-- 详细字段 -->
<van-cell-group inset class="account-info__group">
<van-cell title="用户 ID" :value="profile?.userId ?? '—'" />
<van-cell title="姓名" :value="profile?.name ?? '—'" />
<van-cell title="角色" :value="profile?.role ?? '—'" />
<van-cell title="工号" :value="profile?.jobNumber ?? '—'" />
<van-cell title="手机号" :value="profile?.phone ?? '—'" />
<van-cell title="所属部门" :value="profile?.department ?? '—'" />
<van-cell title="岗位" :value="profile?.jobTitle ?? '—'" />
<van-cell title="上次登录" :value="profile?.lastLoginAt ?? '—'" />
</van-cell-group>
</template>
</van-skeleton>
<div class="account-info__tip">如需修改账号信息请联系上级管理员在后台维护</div>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 账号信息
* - 展示当前账号的工号 / 手机号 / 部门 / 岗位 / 区域 / 入职 / 上次登录等
* - 原型阶段信息为只读,不提供编辑入口
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getProfile } from './api'
import type { UserProfile } from './types'
const router = useRouter()
const profile = ref<UserProfile | null>(null)
const loading = ref(true)
const onBack = (): void => {
router.back()
}
const onChangePassword = (): void => {
router.push('/miniprogram/ops/change-password')
}
const loadProfile = (): void => {
getProfile()
.then((res) => {
if (res.code === '00000') profile.value = res.data
})
.catch(() => undefined)
.finally(() => {
loading.value = false
})
}
onMounted(() => {
loadProfile()
})
</script>
<style scoped lang="less">
.account-info {
min-height: 100%;
background: var(--background);
padding-bottom: 16px;
&__skeleton {
background: #fff;
padding: 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-meta {
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;
}
&__group {
margin-top: 8px;
}
&__edit {
font-size: var(--font-sm);
color: var(--primary);
}
&__tip {
margin: 12px 16px 0;
font-size: var(--font-xs);
color: var(--text-placeholder);
text-align: center;
}
}
</style>
+22 -1
View File
@@ -2,7 +2,14 @@
* 实施运维小程序 / 我的 接口
*/
import { getRequest, postRequest } from '@axios'
import type { MessageItem, MessageType, OfflineStatus, PersonalStats } from './types'
import type {
ChangePasswordParams,
MessageItem,
MessageType,
OfflineStatus,
PersonalStats,
UserProfile,
} from './types'
/** 个人统计 */
export const getPersonalStats = (): Promise<{ code: string; msg: string; data: PersonalStats }> =>
@@ -33,3 +40,17 @@ export const syncOffline = (): Promise<{ code: string; msg: string; data: null }
/** 清除离线缓存 */
export const clearOffline = (): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/me/offline-clear')
/** 账号信息 */
export const getProfile = (): Promise<{ code: string; msg: string; data: UserProfile }> =>
getRequest('axiosRequest', '/mp/ops/me/profile')
/** 修改密码 */
export const changePassword = (
params: ChangePasswordParams,
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/me/change-password', params)
/** 退出登录 */
export const logout = (): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/me/logout')
@@ -0,0 +1,149 @@
<template>
<div class="change-pwd">
<van-nav-bar title="修改密码" left-arrow @click-left="onBack" />
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.oldPassword"
name="oldPassword"
label="旧密码"
placeholder="请输入旧密码"
type="password"
:rules="[{ required: true, message: '请输入旧密码' }]"
/>
<van-field
v-model="form.newPassword"
name="newPassword"
label="新密码"
placeholder="6-20 位,包含字母与数字"
type="password"
:rules="[
{ required: true, message: '请输入新密码' },
{ pattern: /^(?![0-9]+$)(?![a-zA-Z]+$)[a-zA-Z0-9]{6,20}$/, message: '6-20 位,字母与数字组合' },
]"
/>
<van-field
v-model="form.confirmPassword"
name="confirmPassword"
label="确认密码"
placeholder="请再次输入新密码"
type="password"
:rules="[
{ required: true, message: '请再次输入新密码' },
{ validator: validateConfirm, message: '两次输入的密码不一致' },
]"
/>
</van-cell-group>
<div class="change-pwd__rules">
<div class="change-pwd__rules-title">密码规则</div>
<ul>
<li>长度 6-20 </li>
<li>必须同时包含字母与数字</li>
<li>新密码不能与旧密码相同</li>
</ul>
</div>
<div class="change-pwd__submit">
<van-button block type="primary" native-type="submit" :loading="submitting">
确认修改
</van-button>
</div>
</van-form>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 修改密码
* - 旧密码 / 新密码 / 确认密码三段
* - 前端校验:非空、6-20 位字母数字组合、两次一致
* - 服务端校验:旧密码是否正确、新密码是否与旧密码相同(mock 写死旧密码 old123456
*/
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { changePassword } from './api'
const router = useRouter()
const submitting = ref(false)
const form = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
// 确认密码自定义校验:必须与新密码完全一致
const validateConfirm = (value: string): true | string => {
if (value !== form.newPassword) return '两次输入的密码不一致'
return true
}
const onBack = (): void => {
router.back()
}
const onSubmit = (): void => {
// 前端额外校验:新密码不能与旧密码相同(van-field rules 不易跨字段比较,放此处兜底)
if (form.newPassword === form.oldPassword) {
showToast('新密码不能与旧密码相同')
return
}
submitting.value = true
changePassword({ oldPassword: form.oldPassword, newPassword: form.newPassword })
.then((res) => {
if (res.code === '00000') {
showSuccessToast('修改成功,请重新登录')
form.oldPassword = ''
form.newPassword = ''
form.confirmPassword = ''
// 修改密码后退出到门禁页(原型简化处理:清空本地登录态)
setTimeout(() => {
router.replace('/gate')
}, 600)
} else {
showToast(res.msg)
}
})
.catch(() => undefined)
.finally(() => {
submitting.value = false
})
}
</script>
<style scoped lang="less">
.change-pwd {
min-height: 100%;
background: var(--background);
padding-bottom: 16px;
&__rules {
margin: 12px 16px 0;
padding: 12px 16px;
background: #fff7e6;
border-radius: var(--radius-md);
font-size: var(--font-xs);
color: var(--text-secondary);
&-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
ul {
margin: 0;
padding-left: 16px;
line-height: 1.6;
}
}
&__submit {
padding: 24px 16px 0;
}
}
</style>
+20
View File
@@ -40,3 +40,23 @@ export interface OfflineStatus {
pendingCount: number
lastSyncTime: string
}
/** 账号信息 */
export interface UserProfile {
userId: string // 用户 ID
name: string // 姓名
role: string // 角色名(展示用)
jobNumber: string // 工号
phone: string // 手机号
department: string // 所属部门
jobTitle: string // 岗位
region: string // 所属区域
hireDate: string // 入职日期
lastLoginAt: string // 上次登录时间
}
/** 修改密码入参 */
export interface ChangePasswordParams {
oldPassword: string // 旧密码
newPassword: string // 新密码
}
+11
View File
@@ -11,6 +11,7 @@ import type {
OtaVersion,
RepairForm,
ScanHistoryItem,
SimUnboundOption,
} from './types'
/** 扫码识别设备(按设备编码查摘要) */
@@ -29,6 +30,16 @@ export const submitInit = (
): Promise<{ code: string; msg: string; data: null }> =>
postRequest('axiosRequest', '/mp/ops/scan/init', params)
/** 自动获取虹软人脸识别激活码(设备初始化时调用) */
export const fetchArcsoftKey = (
deviceCode: string,
): Promise<{ code: string; msg: string; data: { activeKey: string } }> =>
getRequest('axiosRequest', '/mp/ops/scan/arcsoft/fetch', { deviceCode })
/** 查询未绑定 SIM 卡列表(设备初始化时下拉选用) */
export const listUnboundSims = (): Promise<{ code: string; msg: string; data: SimUnboundOption[] }> =>
getRequest('axiosRequest', '/mp/ops/scan/sim/unbound')
/** 提交实施记录上报 */
export const submitImplRecord = (
params: ImplRecordForm,
@@ -156,7 +156,7 @@ const loadDevice = (): void => {
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
deviceName.value = res.data.name
deviceName.value = res.data.deviceName
}
})
.catch(() => undefined)
+98 -18
View File
@@ -13,17 +13,33 @@
<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" />
v-model="form.arcsoftKey"
name="arcsoftKey"
label="虹软识别码"
placeholder="点右侧按钮自动获取或手动输入"
:rules="[{ required: true, message: '请获取或填写虹软识别码' }]"
>
<template #button>
<van-button
size="small"
type="primary"
:loading="arcsoftLoading"
@click="onFetchArcsoft"
>
自动获取
</van-button>
</template>
</van-field>
<van-field
v-model="simLabel"
name="simIccid"
label="绑定 SIM 卡"
placeholder="请选择未绑定的 SIM 卡"
readonly
is-link
:rules="[{ required: true, message: '请选择 SIM 卡' }]"
@click="showSimPicker = true"
/>
<van-field
v-model="form.siteConfig"
name="siteConfig"
@@ -89,21 +105,30 @@
@cancel="showFirmwarePicker = false"
/>
</van-popup>
<!-- SIM 卡选择 -->
<van-popup v-model:show="showSimPicker" position="bottom" round>
<van-picker
:columns="simColumns"
@confirm="onSimConfirm"
@cancel="showSimPicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 设备初始化
* - 现场激活设备:绑定密钥 / 烧录证书 / 预置场所 / 固件基线
* - 现场激活设备:虹软识别码 / SIM 卡 / 预置场所 / 固件基线
* - 结果上报(成功 / 失败 + 失败原因)
*/
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { scanDevice, submitInit } from './api'
import { showSuccessToast, showFailToast } from 'vant'
import { fetchArcsoftKey, listUnboundSims, scanDevice, submitInit } from './api'
import { formatNow } from '@utils/datetime'
import type { InitForm } from './types'
import type { InitForm, SimUnboundOption } from './types'
const route = useRoute()
const router = useRouter()
@@ -111,11 +136,14 @@ const router = useRouter()
const deviceName = ref('')
const showSitePicker = ref(false)
const showFirmwarePicker = ref(false)
const showSimPicker = ref(false)
const arcsoftLoading = ref(false)
const simList = ref<SimUnboundOption[]>([])
const form = reactive<InitForm>({
deviceCode: '',
bindKey: '',
certBurned: false,
arcsoftKey: '',
simIccid: '',
siteConfig: '',
firmwareBaseline: '',
result: 'success',
@@ -124,6 +152,22 @@ const form = reactive<InitForm>({
operateTime: '',
})
const carrierText = (c: SimUnboundOption['carrier']): string =>
({ mobile: '移动', unicom: '联通', telecom: '电信', broadnet: '广域' })[c]
const simLabel = computed(() => {
if (!form.simIccid) return ''
const hit = simList.value.find((s) => s.iccid === form.simIccid)
return hit ? `${hit.iccid} · ${carrierText(hit.carrier)} · ${hit.packageType}` : form.simIccid
})
const simColumns = computed(() =>
simList.value.map((s) => ({
text: `${s.iccid} · ${carrierText(s.carrier)} · ${s.packageType}`,
value: s.iccid,
})),
)
const siteOpts = [
{ text: '上海浦东 · 张江食堂', value: '上海浦东 · 张江食堂' },
{ text: '北京朝阳 · 朝阳体检站', value: '北京朝阳 · 朝阳体检站' },
@@ -149,6 +193,32 @@ const onFirmwareConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text:
showFirmwarePicker.value = false
}
const onSimConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
form.simIccid = selectedOptions[0]?.value ?? ''
showSimPicker.value = false
}
const onFetchArcsoft = (): void => {
if (!form.deviceCode) {
showFailToast('设备编码缺失,无法获取')
return
}
arcsoftLoading.value = true
fetchArcsoftKey(form.deviceCode)
.then((res) => {
if (res.code === '00000' && res.data?.activeKey) {
form.arcsoftKey = res.data.activeKey
showSuccessToast('已自动获取虹软识别码')
} else {
showFailToast(res.msg || '获取失败')
}
})
.catch(() => undefined)
.finally(() => {
arcsoftLoading.value = false
})
}
const onSubmit = (): void => {
submitInit(form)
.then((res) => {
@@ -167,17 +237,27 @@ const loadDevice = (): void => {
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
deviceName.value = res.data.name
form.siteConfig = res.data.site
deviceName.value = res.data.deviceName
form.siteConfig = res.data.siteName
}
})
.catch(() => undefined)
.finally(() => undefined)
}
const loadSims = (): void => {
listUnboundSims()
.then((res) => {
if (res.code === '00000') simList.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
form.operateTime = formatNow()
loadDevice()
loadSims()
})
</script>
+6 -5
View File
@@ -11,8 +11,8 @@
<template v-else>
<!-- 设备头部 -->
<div class="scan-monitor__header">
<div class="scan-monitor__name">{{ monitor.summary.name }}</div>
<div class="scan-monitor__code">{{ monitor.summary.code }}</div>
<div class="scan-monitor__name">{{ monitor.summary.deviceName }}</div>
<div class="scan-monitor__code">{{ monitor.summary.deviceCode }}</div>
<van-tag :type="statusTagType(monitor.summary.status)">{{ statusText(monitor.summary.status) }}</van-tag>
</div>
@@ -24,9 +24,10 @@
<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.siteName" />
<van-cell title="所属租户" :value="monitor.summary.tenantName" />
<van-cell title="应用名称" :value="monitor.summary.packageName" />
<van-cell title="软件版本" :value="monitor.summary.softwareVersion" />
<van-cell title="最近心跳" :value="monitor.summary.lastHeartbeat" />
<van-cell title="是否 IOT 设备" :value="monitor.summary.isIot ? '是' : '否'" />
<van-cell title="数据分发" :value="monitor.summary.dataDistributeEnabled ? '已启用' : '未启用'" />
+5 -5
View File
@@ -117,11 +117,11 @@ const resultText = computed(() => {
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 versionTagType = (s: OtaVersion['status']): 'primary' | 'default' =>
s === 'active' ? 'primary' : 'default'
const versionStatusText = (s: OtaVersion['status']): string =>
({ full: '全量', gray: '灰度', stopped: '已停用' })[s]
({ active: '有效', disabled: '已停用' })[s]
const onBack = (): void => {
router.back()
@@ -169,8 +169,8 @@ const loadDevice = (): void => {
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
deviceName.value = res.data.name
currentVersion.value = res.data.firmwareVersion
deviceName.value = res.data.deviceName
currentVersion.value = res.data.softwareVersion
}
})
.catch(() => undefined)
+7 -14
View File
@@ -86,7 +86,7 @@
</van-form>
<van-popup v-model:show="showFaultType" position="bottom" round>
<van-picker :columns="faultTypeOpts" @confirm="onFaultTypeConfirm" @cancel="showFaultType = false" />
<van-picker :columns="FAULT_TYPE_OPTS" @confirm="onFaultTypeConfirm" @cancel="showFaultType = false" />
</van-popup>
</div>
</template>
@@ -95,6 +95,7 @@
/**
* 实施运维小程序 / 维修上报(无工单独立新增入口)
* - 关联工单可选填(区别于工单关单前上报,那里工单 ID 必填且不可改)
* - 故障类型与后台 FaultType 枚举一致(hardware/software/network/mechanical/electrical/other
* - 表单字段与工单关单前维修上报一致
*/
import { computed, onMounted, reactive, ref } from 'vue'
@@ -102,6 +103,8 @@ import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { scanDevice, submitRepair } from './api'
import { formatNow } from '@utils/datetime'
import { FAULT_TYPE_OPTS, faultTypeText as toFaultTypeText } from '@pages/miniprogram/ops/workOrder/constants'
import type { FaultType } from '@pages/miniprogram/ops/workOrder/types'
import type { RepairForm } from './types'
const route = useRoute()
@@ -125,23 +128,13 @@ const form = reactive<RepairForm>({
repairTime: '',
})
const faultTypeOpts = [
{ text: '硬件故障', value: '硬件故障' },
{ text: '软件故障', value: '软件故障' },
{ text: '网络故障', value: '网络故障' },
{ text: '传感器故障', value: '传感器故障' },
{ text: '电源故障', value: '电源故障' },
{ text: '机械故障', value: '机械故障' },
{ text: '其他', value: '其他' },
]
const faultTypeText = computed(() => form.faultType)
const faultTypeText = computed(() => (form.faultType ? toFaultTypeText(form.faultType as FaultType) : ''))
const onBack = (): void => {
router.back()
}
const onFaultTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
const onFaultTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: FaultType }> }): void => {
form.faultType = selectedOptions[0]?.value ?? ''
showFaultType.value = false
}
@@ -178,7 +171,7 @@ const loadDevice = (): void => {
scanDevice(code)
.then((res) => {
if (res.code === '00000' && res.data) {
deviceName.value = res.data.name
deviceName.value = res.data.deviceName
}
})
.catch(() => undefined)
+8 -7
View File
@@ -14,16 +14,17 @@
<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 class="scene-menu__device-name">{{ device.deviceName }}</div>
<div class="scene-menu__device-code">{{ device.deviceCode }}</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><span>应用</span>{{ device.packageName }}</div>
<div><span>版本</span>{{ device.softwareVersion }}</div>
<div><span>场所</span>{{ device.siteName }}</div>
<div><span>租户</span>{{ device.tenantName }}</div>
</div>
<div v-if="device.pendingWorkOrders > 0" class="scene-menu__device-alert">
<van-icon name="warning-o" />
@@ -98,7 +99,7 @@ const ALL_ACTIONS: SceneAction[] = [
},
{
key: 'repair', label: '维修上报', desc: '关联工单 / 无工单独立新增',
icon: 'tools-o', path: (id) => `/miniprogram/ops/repair/${id}`,
icon: 'warn-o', path: (id) => `/miniprogram/ops/repair/${id}`,
statuses: ['inUse'],
},
{
@@ -153,7 +154,7 @@ const onBack = (): void => {
const onAction = (action: SceneAction): void => {
if (!device.value) return
router.push(action.path(device.value.code))
router.push(action.path(device.value.deviceCode))
}
const loadDevice = (): void => {
+39 -10
View File
@@ -13,20 +13,34 @@ export type DeviceCategory =
| 'fitness' // 健身器材设备
| 'health' // 健康监测设备
/** 扫码后的设备摘要(场景菜单用) */
/** 扫码后的设备摘要(场景菜单用,字段名与后台 device-center/ops/monitor 对齐 */
export interface DeviceSummary {
id: string
code: string
name: string
/** 设备编码 */
deviceCode: string
/** 设备名称 */
deviceName: string
/** 顶级分类(ops 现场视角,与后台末级分类名不同) */
category: DeviceCategory
/** 设备型号(ops 现场视角) */
model: string
/** 运行状态(ops 现场 4 态,回写后台时映射 runStatus */
status: DeviceStatus
site: string
tenant: string
firmwareVersion: string
/** 所属场所 */
siteName: string
/** 所属租户 */
tenantName: string
/** 应用名称(与后台 monitor.packageName 对齐) */
packageName: string
/** 软件版本号(与后台 monitor.softwareVersion 对齐) */
softwareVersion: string
/** 最近心跳时间 */
lastHeartbeat: string
/** 待处理工单数 */
pendingWorkOrders: number
/** 是否物联网设备 */
isIot: boolean
/** 是否启用数据分发 */
dataDistributeEnabled: boolean
}
@@ -58,8 +72,10 @@ export interface ImplRecordForm {
/** 设备初始化表单 */
export interface InitForm {
deviceCode: string
bindKey: string
certBurned: boolean
/** 虹软人脸识别激活码(点"自动获取"调接口拿,也允许手动输入) */
arcsoftKey: string
/** 绑定的 SIM 卡 ICCID(从"未绑定 SIM 卡"下拉选) */
simIccid: string
siteConfig: string
firmwareBaseline: string
result: 'success' | 'fail'
@@ -68,6 +84,19 @@ export interface InitForm {
operateTime: string
}
/** 未绑定 SIM 卡下拉选项(设备初始化时选用) */
export interface SimUnboundOption {
id: string
/** SIM 卡 ICCID(提交时回传) */
iccid: string
/** 手机号 */
phoneNo: string
/** 运营商:mobile 移动 / unicom 联通 / telecom 电信 / broadnet 广域 */
carrier: 'mobile' | 'unicom' | 'telecom' | 'broadnet'
/** 套餐类型 */
packageType: string
}
/** 维修上报表单(无工单独立入口) */
export interface RepairForm {
deviceCode: string
@@ -82,11 +111,11 @@ export interface RepairForm {
repairTime: string
}
/** OTA 可用版本 */
/** OTA 可用版本(状态与后台 device-center/ops/version VersionStatus 对齐:active 有效 / disabled 停用) */
export interface OtaVersion {
version: string
releaseNote: string
status: 'gray' | 'full' | 'stopped'
status: 'active' | 'disabled'
size: string
}
+42 -20
View File
@@ -2,15 +2,15 @@
<div class="wo-home">
<van-nav-bar title="工单" />
<!-- 状态分段 Tab -->
<!-- 状态分段 Tab待派单 / 已派单 / 处理中 / 已关单 -->
<van-tabs v-model:active="activeTab" sticky @change="onTabChange">
<van-tab v-for="t in WORK_ORDER_STATUS_TABS" :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="WORK_ORDER_TYPE_OPTS" @change="reload" />
<van-dropdown-item v-model="filter.source" :options="ORDER_SOURCE_OPTS" @change="reload" />
</van-dropdown-menu>
<van-search v-model="filter.keyword" placeholder="搜索工单号 / 设备" shape="round" @search="reload" />
</div>
@@ -28,15 +28,15 @@
v-for="item in list"
:key="item.id"
class="wo-card"
:class="{ 'wo-card--over': item.overSla }"
:class="{ 'wo-card--over': item.slaOverdue }"
@click="goDetail(item.id)"
>
<div class="wo-card__header">
<van-tag :type="workOrderTypeTagType(item.type)" size="medium">{{ workOrderTypeText(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>
<van-tag :type="orderSourceTagType(item.source)" size="medium">{{ orderSourceText(item.source) }}</van-tag>
<span class="wo-card__code">{{ item.orderCode }}</span>
<van-tag :type="faultLevelTagType(item.faultLevel)" size="medium">{{ faultLevelText(item.faultLevel) }}</van-tag>
<van-tag v-if="item.slaOverdue" 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>
@@ -44,14 +44,17 @@
</div>
<div class="wo-card__site">
<van-icon name="location-o" />
<span>{{ item.site }}</span>
<span>{{ item.siteName }}</span>
<span class="wo-card__dot">·</span>
<span>{{ item.customerName }}</span>
</div>
<div class="wo-card__desc">{{ item.faultDesc }}</div>
<div class="wo-card__footer">
<span class="wo-card__sla" :class="{ 'wo-card__sla--over': item.overSla }">
<span class="wo-card__sla" :class="{ 'wo-card__sla--over': item.slaOverdue }">
<van-icon name="clock-o" />
SLA{{ item.slaDeadline }}
</span>
<span class="wo-card__assignee">{{ item.assignee }}</span>
<span class="wo-card__assignee">{{ item.assignee ?? '—' }}</span>
</div>
</div>
</van-list>
@@ -65,20 +68,22 @@
<script setup lang="ts">
/**
* 实施运维小程序 / 工单列表
* - 3 段状态 Tab(待处理 / 处理中 / 已完成
* - 类型筛选 + 关键字
* - 4 段状态 Tab(待派单 / 已派单 / 处理中 / 已关单
* - 来源筛选 + 关键字
* - 列表项点击进入详情
*/
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listWorkOrders } from './api'
import {
ORDER_SOURCE_OPTS,
faultLevelTagType,
faultLevelText,
orderSourceTagType,
orderSourceText,
WORK_ORDER_STATUS_TABS,
WORK_ORDER_TYPE_OPTS,
workOrderTypeTagType,
workOrderTypeText,
} from './constants'
import type { WorkOrderItem, WorkOrderListParams, WorkOrderStatus, WorkOrderType } from './types'
import type { OrderSource, WorkOrderItem, WorkOrderListParams, WorkOrderStatus } from './types'
const router = useRouter()
@@ -89,8 +94,8 @@ const finished = ref(true)
const refreshing = ref(false)
const activeTab = ref<WorkOrderStatus>('pending')
const filter = reactive<{ type: WorkOrderType | ''; keyword: string }>({
type: '',
const filter = reactive<{ source: OrderSource | ''; keyword: string }>({
source: '',
keyword: '',
})
@@ -101,7 +106,7 @@ const goDetail = (id: string): void => {
const loadList = (): void => {
const params: WorkOrderListParams = {
status: activeTab.value,
type: filter.type,
source: filter.source,
keyword: filter.keyword,
}
listWorkOrders(params)
@@ -211,6 +216,23 @@ onMounted(() => {
margin-bottom: 8px;
}
&__dot {
margin: 0 2px;
color: var(--text-placeholder);
}
&__desc {
font-size: var(--font-xs);
color: var(--text-secondary);
margin-bottom: 8px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__footer {
display: flex;
align-items: center;
@@ -1,53 +1,108 @@
/**
* 实施运维小程序 / 工单 字典常量
*
* 设计目的:把工单列表页和详情页里分散的类型 / 状态映射集中管理,
* 避免 .vue 文件之间复制粘贴相同的 { repair: '报修' } 字典。
* 字典与后台 device-center/ops/workOrder 对齐:
* - WORK_ORDER_STATUS_TABS4 段状态 Tab(待派单/已派单/处理中/已关单)
* - ORDER_SOURCE_OPTS:工单来源(客户报障/巡检异常/自检异常/售后转单)
* - FAULT_LEVEL_OPTS:故障等级(低/中/高/紧急)
* - FAULT_TYPE_OPTS:故障类型(硬件/软件/网络/机械/电气/其他)
*
* 用法:
* import { WORK_ORDER_TYPE_OPTS, workOrderTypeText } from './constants'
* <van-tag :type="workOrderTypeTagType(item.type)">{{ workOrderTypeText(item.type) }}</van-tag>
* import { ORDER_SOURCE_OPTS, orderSourceText } from './constants'
* <van-tag :type="orderSourceTagType(item.source)">{{ orderSourceText(item.source) }}</van-tag>
*/
import type { WorkOrderStatus, WorkOrderType } from './types'
import type { FaultLevel, FaultType, OrderSource, WorkOrderStatus } from './types'
/** 工单状态下拉项(用于分段 Tab;不含"全部"——分段 Tab 直接切换状态 */
/** 工单状态 Tab4 段,不含"全部" */
export const WORK_ORDER_STATUS_TABS: Array<{ label: string; value: WorkOrderStatus }> = [
{ label: '待处理', value: 'pending' },
{ label: '待派单', value: 'pending' },
{ label: '已派单', value: 'assigned' },
{ label: '处理中', value: 'processing' },
{ label: '已完成', value: 'done' },
{ label: '已关单', value: 'closed' },
]
/** 工单类型下拉项(含"全部"占位) */
export const WORK_ORDER_TYPE_OPTS: Array<{ text: string; value: WorkOrderType | '' }> = [
{ text: '全部类型', value: '' },
{ text: '报修', value: 'repair' },
/** 工单来源下拉项(含"全部"占位) */
export const ORDER_SOURCE_OPTS: Array<{ text: string; value: OrderSource | '' }> = [
{ text: '全部来源', value: '' },
{ text: '客户报障', value: 'customer' },
{ text: '巡检异常', value: 'inspection' },
{ text: '自检异常', value: 'selfCheck' },
{ text: 'SLA 超时', value: 'sla' },
{ text: '售后转单', value: 'afterSale' },
]
/** 工单类型 → 中文文本 */
export const workOrderTypeText = (t: WorkOrderType): string =>
({
repair: '报修',
inspection: '巡检',
selfCheck: '自检',
sla: 'SLA',
afterSale: '售后',
})[t]
/** 工单类型 → Vant Tag 类型 */
export const workOrderTypeTagType = (t: WorkOrderType): 'primary' | 'warning' | 'danger' | 'default' =>
t === 'repair' ? 'danger' : t === 'inspection' ? 'primary' : t === 'selfCheck' ? 'warning' : 'default'
/** 故障类型下拉项(维修上报用,与 scan/repair.vue 保持一致) */
export const FAULT_TYPE_OPTS: Array<{ text: string; value: string }> = [
{ text: '硬件故障', value: '硬件故障' },
{ text: '软件故障', value: '软件故障' },
{ text: '网络故障', value: '网络故障' },
{ text: '传感器故障', value: '传感器故障' },
{ text: '电源故障', value: '电源故障' },
{ text: '机械故障', value: '机械故障' },
{ text: '其他', value: '其他' },
/** 故障等级下拉项 */
export const FAULT_LEVEL_OPTS: Array<{ text: string; value: FaultLevel }> = [
{ text: '低', value: 'low' },
{ text: '中', value: 'medium' },
{ text: '高', value: 'high' },
{ text: '紧急', value: 'critical' },
]
/** 故障类型下拉项(与后台 FAULT_TYPE_OPTIONS 一致) */
export const FAULT_TYPE_OPTS: Array<{ text: string; value: FaultType }> = [
{ text: '硬件故障', value: 'hardware' },
{ text: '软件故障', value: 'software' },
{ text: '网络故障', value: 'network' },
{ text: '机械故障', value: 'mechanical' },
{ text: '电气故障', value: 'electrical' },
{ text: '其他', value: 'other' },
]
/** 工单状态 → 中文文本 */
export const workOrderStatusText = (s: WorkOrderStatus): string =>
({
pending: '待派单',
assigned: '已派单',
processing: '处理中',
closed: '已关单',
})[s]
/** 工单状态 → Vant Tag 类型 */
export const workOrderStatusTagType = (
s: WorkOrderStatus,
): 'primary' | 'warning' | 'danger' | 'success' | 'default' =>
s === 'pending'
? 'danger'
: s === 'assigned'
? 'warning'
: s === 'processing'
? 'primary'
: s === 'closed'
? 'success'
: 'default'
/** 工单来源 → 中文文本 */
export const orderSourceText = (s: OrderSource): string =>
({
customer: '客户报障',
inspection: '巡检异常',
selfCheck: '自检异常',
afterSale: '售后转单',
})[s]
/** 工单来源 → Vant Tag 类型 */
export const orderSourceTagType = (
s: OrderSource,
): 'primary' | 'warning' | 'danger' | 'default' =>
s === 'customer' ? 'danger' : s === 'inspection' ? 'primary' : s === 'selfCheck' ? 'warning' : 'default'
/** 故障等级 → 中文文本 */
export const faultLevelText = (l: FaultLevel): string =>
({ low: '低', medium: '中', high: '高', critical: '紧急' })[l]
/** 故障等级 → Vant Tag 类型 */
export const faultLevelTagType = (
l: FaultLevel,
): 'primary' | 'warning' | 'danger' | 'default' =>
l === 'critical' ? 'danger' : l === 'high' ? 'warning' : l === 'medium' ? 'primary' : 'default'
/** 故障类型 → 中文文本 */
export const faultTypeText = (t: FaultType): string =>
({
hardware: '硬件故障',
software: '软件故障',
network: '网络故障',
mechanical: '机械故障',
electrical: '电气故障',
other: '其他',
})[t]
+32 -22
View File
@@ -11,15 +11,16 @@
<template v-else>
<!-- 头部 -->
<div class="wo-detail__header" :class="{ 'wo-detail__header--over': detail.overSla }">
<div class="wo-detail__header" :class="{ 'wo-detail__header--over': detail.slaOverdue }">
<div class="wo-detail__title-row">
<van-tag :type="workOrderTypeTagType(detail.type)" size="large">{{ workOrderTypeText(detail.type) }}</van-tag>
<span class="wo-detail__code">{{ detail.code }}</span>
<van-tag :type="orderSourceTagType(detail.source)" size="large">{{ orderSourceText(detail.source) }}</van-tag>
<van-tag :type="faultLevelTagType(detail.faultLevel)" size="large">{{ faultLevelText(detail.faultLevel) }}</van-tag>
<span class="wo-detail__code">{{ detail.orderCode }}</span>
</div>
<div class="wo-detail__title">{{ detail.title }}</div>
<div class="wo-detail__sla" :class="{ 'wo-detail__sla--over': detail.overSla }">
<div class="wo-detail__title">{{ detail.deviceName }}</div>
<div class="wo-detail__sla" :class="{ 'wo-detail__sla--over': detail.slaOverdue }">
<van-icon name="clock-o" />
<span v-if="detail.status === 'done'">已关单</span>
<span v-if="detail.status === 'closed'">已关单</span>
<span v-else>SLA 截止{{ detail.slaDeadline }}</span>
</div>
</div>
@@ -29,24 +30,26 @@
<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 title="所属场所" :value="detail.siteName" />
<van-cell title="所属户" :value="detail.customerName" />
<van-cell title="故障类型" :value="faultTypeText(detail.faultType)" />
<van-cell title="故障等级" :value="faultLevelText(detail.faultLevel)" />
<van-cell title="责任人" :value="detail.assignee ?? '—'" />
<van-cell title="报障时间" :value="detail.reportTime" />
</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 class="wo-detail__desc">{{ detail.faultDesc }}</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">
<div v-if="detail.images.length" class="wo-detail__images">
<van-image
v-for="(img, idx) in detail.faultImages"
v-for="(img, idx) in detail.images"
:key="idx"
:src="img"
width="30%"
@@ -62,15 +65,15 @@
<div class="wo-detail__block-title">处理记录</div>
<van-steps
direction="vertical"
:active="detail.processLog.length - 1"
:active="detail.processLogs.length - 1"
active-color="#1890ff"
inactive-color="#999"
>
<van-step v-for="(log, idx) in detail.processLog" :key="idx">
<van-step v-for="(log, idx) in detail.processLogs" :key="idx">
<div class="wo-detail__step-time">{{ log.time }}</div>
<div class="wo-detail__step-action">
<span class="wo-detail__step-action-text">{{ log.action }}</span>
<span v-if="idx === detail.processLog.length - 1 && detail.status !== 'done'" class="wo-detail__step-tag wo-detail__step-tag--current">最新</span>
<span v-if="idx === detail.processLogs.length - 1 && detail.status !== 'closed'" class="wo-detail__step-tag wo-detail__step-tag--current">最新</span>
<span v-else-if="idx === 0" class="wo-detail__step-tag wo-detail__step-tag--start">起始</span>
</div>
<div class="wo-detail__step-operator">{{ log.operator }}</div>
@@ -80,7 +83,7 @@
<!-- 底部操作 -->
<OpsBottomActions>
<van-button v-if="detail.status === 'pending'" block type="primary" @click="onAccept">接单</van-button>
<van-button v-if="detail.status === 'pending' || detail.status === 'assigned'" 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>
@@ -93,18 +96,24 @@
<script setup lang="ts">
/**
* 实施运维小程序 / 工单详情
* - 头部:类型 / 编码 / 标题 / SLA 倒计时
* - 头部:来源 / 故障等级 / 工单号 / 设备名称 / SLA
* - 设备 + 场所信息(可跳设备监测)
* - 故障描述 + 客户原话 + 故障图片
* - 处理时间线
* - 底部操作:接单 / 维修上报 / 关单
* - 底部操作:接单(待派单/已派单)/ 维修上报 + 关单(处理中)
*/
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import OpsBottomActions from '@components/OpsBottomActions.vue'
import { acceptWorkOrder, closeWorkOrder, getWorkOrderDetail } from './api'
import { workOrderTypeTagType, workOrderTypeText } from './constants'
import { acceptWorkOrder, getWorkOrderDetail } from './api'
import {
faultLevelTagType,
faultLevelText,
faultTypeText,
orderSourceTagType,
orderSourceText,
} from './constants'
import type { WorkOrderDetail } from './types'
const route = useRoute()
@@ -129,7 +138,8 @@ const onAccept = (): void => {
showSuccessToast('接单成功')
loadDetail()
} else {
showFailToast(res.msg)
// 接单失败提示(使用 showToast,避免引用未导入的 showFailToast
showToast(res.msg || '接单失败')
}
})
.catch(() => undefined)
@@ -13,7 +13,7 @@
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.faultType"
:model-value="faultTypeDisplay"
name="faultType"
label="故障类型"
placeholder="请选择故障类型"
@@ -87,7 +87,7 @@
<!-- 故障类型选择 -->
<van-popup v-model:show="showFaultType" position="bottom" round>
<van-picker
:columns="faultTypeOpts"
:columns="FAULT_TYPE_OPTS"
@confirm="onFaultTypeConfirm"
@cancel="showFaultType = false"
/>
@@ -98,7 +98,7 @@
<script setup lang="ts">
/**
* 实施运维小程序 / 维修上报(工单关单前入口)
* - 表单:故障类型 / 描述 / 维修方式 / 图片 / 工时 / 备件 / 维修人 / 时间
* - 表单:故障类型(与后台 FaultType 枚举一致) / 描述 / 维修方式 / 图片 / 工时 / 备件 / 维修人 / 时间
* - 提交后回写工单 + 维修记录
*/
import { computed, onMounted, reactive, ref } from 'vue'
@@ -106,7 +106,8 @@ import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { getWorkOrderDetail, submitRepairReport } from './api'
import { formatNow } from '@utils/datetime'
import type { RepairReportForm } from './types'
import { FAULT_TYPE_OPTS, faultTypeText } from './constants'
import type { FaultType, RepairReportForm } from './types'
const route = useRoute()
const router = useRouter()
@@ -117,7 +118,7 @@ const deviceName = ref('')
const form = reactive<RepairReportForm>({
workOrderId: '',
deviceCode: '',
faultType: '',
faultType: '' as FaultType,
faultDescription: '',
repairMethod: '',
repairImages: [],
@@ -131,21 +132,14 @@ 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: '其他' },
]
/** 故障类型显示文本(form.faultType 存枚举值,显示用 faultTypeText 映射) */
const faultTypeDisplay = computed(() => (form.faultType ? faultTypeText(form.faultType) : ''))
const onBack = (): void => {
router.back()
}
const onFaultTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
const onFaultTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: FaultType }> }): void => {
form.faultType = selectedOptions[0]?.value ?? ''
showFaultType.value = false
}
@@ -179,7 +173,7 @@ const loadWorkOrder = (): void => {
if (res.code === '00000') {
form.deviceCode = res.data.deviceCode
deviceName.value = res.data.deviceName
form.faultDescription = res.data.description
form.faultDescription = res.data.faultDesc
}
})
.catch(() => undefined)
+47 -17
View File
@@ -1,50 +1,80 @@
/**
* 实施运维小程序 / 工单 类型定义
*
* 字段命名与状态枚举对齐后台 device-center/ops/workOrder
* - WorkOrderStatuspending 待派单 / assigned 已派单 / processing 处理中 / closed 已关单
* - OrderSourcecustomer 客户报障 / inspection 巡检异常 / self-check 自检异常 / after-sale 售后转单
* - FaultLevel / FaultType:与后台枚举一致
* - 列表/详情字段:orderCode / siteName / customerName / source / faultLevel / faultType / faultDesc / slaDeadline / slaOverdue / reportTime
*/
/** 工单状态 */
export type WorkOrderStatus = 'pending' | 'processing' | 'done'
/** 工单状态:待派单 → 已派单 → 处理中 → 已关单 */
export type WorkOrderStatus = 'pending' | 'assigned' | 'processing' | 'closed'
/** 工单类型 */
export type WorkOrderType = 'repair' | 'inspection' | 'selfCheck' | 'sla' | 'afterSale'
/** 工单来源 */
export type OrderSource = 'customer' | 'inspection' | 'selfCheck' | 'afterSale'
/** 故障等级 */
export type FaultLevel = 'low' | 'medium' | 'high' | 'critical'
/** 故障类型 */
export type FaultType = 'hardware' | 'software' | 'network' | 'mechanical' | 'electrical' | 'other'
/** 工单列表项 */
export interface WorkOrderItem {
id: string
code: string
title: string
type: WorkOrderType
/** 工单号 */
orderCode: string
/** 工单来源 */
source: OrderSource
/** 工单状态 */
status: WorkOrderStatus
/** 设备编码 */
deviceCode: string
/** 设备名称 */
deviceName: string
site: string
tenant: string
description: string
/** 所属场所 */
siteName: string
/** 所属客户 */
customerName: string
/** 故障等级 */
faultLevel: FaultLevel
/** 故障类型 */
faultType: FaultType
/** 故障描述 */
faultDesc: string
/** 故障图片 URL 列表 */
images: string[]
/** SLA 截止时间 */
slaDeadline: string
createdAt: string
/** 客户报障时间 */
reportTime: string
/** 责任人 */
assignee: string
overSla: boolean
/** 是否超 SLA */
slaOverdue: boolean
}
/** 工单详情(含客户报障附件 + 处理记录) */
export interface WorkOrderDetail extends WorkOrderItem {
faultImages: string[]
/** 客户原声(语音/文字描述) */
customerVoice?: string
processLog: Array<{ time: string; action: string; operator: string }>
/** 处理记录时间线 */
processLogs: Array<{ time: string; action: string; operator: string }>
}
/** 工单列表筛选 */
export interface WorkOrderListParams {
status: WorkOrderStatus
type?: WorkOrderType | ''
source?: OrderSource | ''
keyword?: string
}
/** 维修上报表单 */
/** 维修上报表单 */
export interface RepairReportForm {
workOrderId: string
deviceCode: string
faultType: string
faultType: FaultType
faultDescription: string
repairMethod: string
repairImages: string[]
+13 -4
View File
@@ -40,13 +40,13 @@
<div class="todo-card__count">{{ todo.todayInspections }}</div>
<div class="todo-card__sub">已完成 {{ todo.inspectionDone }}</div>
</div>
<div class="todo-card" @click="goScan">
<div class="todo-card" @click="goInitList">
<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">
<div class="todo-card" @click="goOtaList">
<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>
@@ -153,8 +153,12 @@ const goInspect = (): void => {
router.push('/miniprogram/ops/inspect')
}
const goScan = (): void => {
router.push('/miniprogram/ops/scan')
const goInitList = (): void => {
router.push('/miniprogram/ops/init-list')
}
const goOtaList = (): void => {
router.push('/miniprogram/ops/ota-list')
}
const goMessages = (): void => {
@@ -249,6 +253,11 @@ onMounted(() => {
padding: 0 12px;
}
// 紧邻 hero 的首个 sectionhero 圆角伪元素向下延伸 12px,留出避让空间避免"今日待办"标题被遮)
&__hero + &__section {
padding-top: 12px;
}
&__section-title {
font-size: var(--font-base);
font-weight: 600;
@@ -7,6 +7,8 @@ import type {
AlarmHandleParams,
AlarmItem,
AlarmListParams,
InitDeviceItem,
OtaTaskItem,
TodoStats,
WeekStats,
} from './types'
@@ -19,6 +21,14 @@ export const getTodoStats = (): Promise<{ code: string; msg: string; data: TodoS
export const getWeekStats = (): Promise<{ code: string; msg: string; data: WeekStats }> =>
postRequest('axiosRequest', '/mp/ops/workbench/week-stats')
/** 待初始化设备列表(工作台"待初始化"卡跳转目标) */
export const listInitDevices = (): Promise<{ code: string; msg: string; data: InitDeviceItem[] }> =>
getRequest('axiosRequest', '/mp/ops/workbench/init-list')
/** 待升级 OTA 任务列表(工作台"OTA 任务"卡跳转目标) */
export const listOtaTasks = (): Promise<{ code: string; msg: string; data: OtaTaskItem[] }> =>
getRequest('axiosRequest', '/mp/ops/workbench/ota-list')
/** 告警列表 */
export const listAlarms = (
params: AlarmListParams,
@@ -0,0 +1,217 @@
<template>
<div class="init-list">
<!-- 顶部导航 -->
<van-nav-bar title="待初始化设备" left-arrow @click-left="onBack" />
<!-- 搜索框 -->
<div class="init-list__search">
<van-search
v-model="keyword"
placeholder="搜索设备编码 / 名称 / 型号 / 场所"
shape="round"
@search="onSearch"
/>
</div>
<!-- 列表支持下拉刷新 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadList"
>
<van-empty
v-if="!filteredList.length && !loading"
image="search"
description="暂无符合条件的待初始化设备"
class="init-list__empty"
/>
<div
v-for="item in filteredList"
:key="item.id"
class="init-card"
@click="goInit(item.deviceCode)"
>
<div class="init-card__header">
<van-icon name="desktop-o" class="init-card__icon" />
<div class="init-card__main">
<div class="init-card__name">{{ item.deviceName }}</div>
<div class="init-card__code">{{ item.deviceCode }}</div>
</div>
<van-tag type="warning" size="medium">待激活</van-tag>
</div>
<div class="init-card__meta">
<div><span>型号</span>{{ item.model }}</div>
<div><span>场所</span>{{ item.siteName }}</div>
<div><span>租户</span>{{ item.tenantName }}</div>
<div><span>入库</span>{{ item.inboundTime }}</div>
</div>
<div class="init-card__footer">
<span class="init-card__hint">点击进入设备初始化</span>
<van-icon name="arrow" />
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 待初始化设备列表
* - 工作台"待初始化"卡跳转目标
* - 列表项点击进入 scan/init.vue(复用已有初始化流程页)
* - 顶部搜索框按设备编码 / 名称 / 型号 / 场所模糊过滤
*/
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listInitDevices } from './api'
import type { InitDeviceItem } from './types'
const router = useRouter()
const list = ref<InitDeviceItem[]>([])
const loading = ref(false)
// finished 固定为 true:mock 数据一次性返回,无分页;保留 van-list 是为后续接入真接口预留
const finished = ref(true)
const refreshing = ref(false)
const keyword = ref('')
const filteredList = computed<InitDeviceItem[]>(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return list.value
return list.value.filter((d) =>
[d.deviceCode, d.deviceName, d.model, d.siteName]
.join(' ')
.toLowerCase()
.includes(kw),
)
})
const onBack = (): void => {
router.back()
}
const goInit = (deviceCode: string): void => {
router.push(`/miniprogram/ops/init/${deviceCode}`)
}
const onSearch = (): void => {
// filteredList 是 computedkeyword 变化即触发过滤;onSearch 仅占位让 van-search 触发回车行为
}
const loadList = (): void => {
listInitDevices()
.then((res) => {
if (res.code === '00000') list.value = res.data
})
.catch(() => undefined)
.finally(() => {
loading.value = false
finished.value = true
refreshing.value = false
})
}
const onRefresh = (): void => {
loadList()
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="less">
.init-list {
padding: 0 0 24px;
background: var(--background);
min-height: 100%;
&__search {
background: #fff;
border-bottom: 1px solid var(--border);
:deep(.van-search) {
padding: 8px 12px;
}
}
&__empty {
padding: 32px 0;
background: transparent;
}
}
.init-card {
margin: 8px 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
border-left: 3px solid var(--warning);
&:active {
background: var(--background);
}
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
&__icon {
font-size: 22px;
color: var(--warning);
}
&__main {
flex: 1;
min-width: 0;
}
&__name {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__code {
font-family: monospace;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-top: 2px;
}
&__meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
font-size: var(--font-xs);
color: var(--text-secondary);
padding: 8px 0;
border-top: 1px dashed var(--border);
span {
color: var(--text-placeholder);
margin-right: 6px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px dashed var(--border);
padding-top: 8px;
color: var(--primary);
font-size: var(--font-xs);
}
}
</style>
@@ -0,0 +1,291 @@
<template>
<div class="ota-list">
<!-- 顶部导航 -->
<van-nav-bar title="待升级任务" left-arrow @click-left="onBack" />
<!-- 顶部统计卡 -->
<div class="ota-list__stats">
<div class="stat-mini stat-mini--primary">
<div class="stat-mini__value">{{ list.length }}</div>
<div class="stat-mini__label">待升级</div>
</div>
<div class="ota-list__tip">点击任务进入 OTA 升级流程页</div>
</div>
<!-- 状态筛选 -->
<div class="ota-list__filter">
<van-dropdown-menu>
<van-dropdown-item v-model="filter.status" :options="statusOpts" @change="reload" />
</van-dropdown-menu>
</div>
<!-- 列表支持下拉刷新 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadList"
>
<van-empty
v-if="!filteredList.length && !loading"
image="search"
description="暂无符合条件的升级任务"
class="ota-list__empty"
/>
<div
v-for="item in filteredList"
:key="item.id"
class="ota-card"
:class="`ota-card--${item.status}`"
@click="goOta(item.deviceCode)"
>
<div class="ota-card__header">
<van-tag :type="statusTagType(item.status)" size="medium">{{ statusText(item.status) }}</van-tag>
<span class="ota-card__task-id">{{ item.taskId }}</span>
<span class="ota-card__time">{{ item.pushTime }}</span>
</div>
<div class="ota-card__device">
<van-icon name="desktop-o" />
<span class="ota-card__device-code">{{ item.deviceCode }}</span>
<span class="ota-card__device-name">{{ item.deviceName }}</span>
</div>
<div class="ota-card__version">
<span class="ota-card__version-current">{{ item.currentVersion }}</span>
<van-icon name="arrow" />
<span class="ota-card__version-target">{{ item.targetVersion }}</span>
</div>
<div class="ota-card__footer">
<span class="ota-card__hint">点击进入升级流程</span>
<van-icon name="arrow" />
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 待升级 OTA 任务列表
* - 工作台"OTA 任务"卡跳转目标
* - 列表项点击进入 scan/ota.vue(复用已有 OTA 升级流程页)
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listOtaTasks } from './api'
import type { OtaTaskItem } from './types'
const router = useRouter()
const list = ref<OtaTaskItem[]>([])
const loading = ref(false)
const finished = ref(true)
const refreshing = ref(false)
type StatusFilter = '' | 'pending' | 'upgrading' | 'failed'
const filter = reactive<{ status: StatusFilter }>({ status: '' })
const statusOpts = [
{ text: '全部状态', value: '' },
{ text: '待执行', value: 'pending' },
{ text: '升级中', value: 'upgrading' },
{ text: '失败重试', value: 'failed' },
]
const filteredList = computed<OtaTaskItem[]>(() => {
if (!filter.status) return list.value
return list.value.filter((t) => t.status === filter.status)
})
const statusText = (s: OtaTaskItem['status']): string =>
({ pending: '待执行', upgrading: '升级中', failed: '失败重试' })[s]
const statusTagType = (s: OtaTaskItem['status']): 'primary' | 'warning' | 'danger' =>
s === 'pending' ? 'primary' : s === 'upgrading' ? 'warning' : 'danger'
const onBack = (): void => {
router.back()
}
const goOta = (deviceCode: string): void => {
router.push(`/miniprogram/ops/ota/${deviceCode}`)
}
const loadList = (): void => {
listOtaTasks()
.then((res) => {
if (res.code === '00000') list.value = res.data
})
.catch(() => undefined)
.finally(() => {
loading.value = false
finished.value = true
refreshing.value = false
})
}
const reload = (): void => {
loading.value = true
finished.value = false
loadList()
}
const onRefresh = (): void => {
reload()
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="less">
.ota-list {
padding: 0 0 24px;
background: var(--background);
min-height: 100%;
&__stats {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #fff;
border-bottom: 1px solid var(--border);
}
&__tip {
flex: 1;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__filter {
background: #fff;
border-bottom: 1px solid var(--border);
}
&__empty {
padding: 32px 0;
background: transparent;
}
}
.stat-mini {
background: var(--background);
border-radius: var(--radius-md);
padding: 10px 14px;
text-align: center;
min-width: 80px;
&--primary {
background: #e6f7ff;
}
&__value {
font-size: 24px;
font-weight: 600;
color: var(--primary);
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
&__label {
margin-top: 2px;
font-size: var(--font-xs);
color: var(--text-secondary);
}
}
.ota-card {
margin: 8px 12px;
background: #fff;
border-radius: var(--radius-md);
padding: 12px;
box-shadow: var(--shadow-sm);
border-left: 3px solid var(--primary);
&--pending { border-left-color: var(--primary); }
&--upgrading { border-left-color: var(--warning); }
&--failed { border-left-color: var(--danger); }
&:active {
background: var(--background);
}
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
&__task-id {
font-family: monospace;
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);
}
&__device {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--font-xs);
color: var(--text-secondary);
margin-bottom: 10px;
}
&__device-code {
font-family: monospace;
color: var(--primary);
}
&__device-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__version {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-top: 1px dashed var(--border);
font-size: var(--font-sm);
}
&__version-current {
color: var(--text-secondary);
font-family: monospace;
}
&__version-target {
color: var(--primary);
font-family: monospace;
font-weight: 600;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px dashed var(--border);
padding-top: 8px;
color: var(--primary);
font-size: var(--font-xs);
}
}
</style>
@@ -3,7 +3,7 @@
<!-- 顶部导航 -->
<van-nav-bar title="入库登记" left-arrow @click-left="onBack" />
<!-- 顶部信息仓库选择 + 入库类型 -->
<!-- 顶部信息仓库 + 类型 + 类别 + 供应商 + 批次 + 单价 -->
<div class="stock-inbound__form">
<van-cell-group inset>
<van-field
@@ -22,12 +22,43 @@
is-link
@click="openTypePicker"
/>
<van-field
label="设备类别"
:model-value="form.categoryLabel"
placeholder="请选择末级分类"
readonly
is-link
@click="openCategoryPicker"
/>
<van-field
label="供应商"
:model-value="form.supplierLabel"
placeholder="请选择供应商"
readonly
is-link
@click="openSupplierPicker"
/>
<van-field
v-model="form.batchNo"
label="批次号"
placeholder="如:B2026-001"
maxlength="30"
/>
<van-field
v-model="form.purchasePrice"
label="采购单价"
type="number"
placeholder="元/台"
style="width: 100%"
>
<template #button>/</template>
</van-field>
<van-field label="经办人" :model-value="form.operator" placeholder="自动带入" readonly />
<van-field
v-model="form.remark"
label="备注"
type="textarea"
placeholder="可填写采购单号 / 供应商等"
placeholder="可填写采购单号等"
rows="2"
autosize
maxlength="120"
@@ -104,7 +135,7 @@
</div>
<!-- 仓库选择 -->
<van-popup v-model:show="picker.warehouse" position="bottom" teleport="body">
<van-popup v-model:show="picker.warehouse" position="bottom" round>
<van-picker
:columns="warehouseColumns"
title="选择入库仓库"
@@ -114,7 +145,7 @@
</van-popup>
<!-- 类型选择 -->
<van-popup v-model:show="picker.type" position="bottom" teleport="body">
<van-popup v-model:show="picker.type" position="bottom" round>
<van-picker
:columns="inboundTypeColumns"
title="选择入库类型"
@@ -122,14 +153,35 @@
@cancel="picker.type = false"
/>
</van-popup>
<!-- 设备类别选择 -->
<van-popup v-model:show="picker.category" position="bottom" round>
<van-picker
:columns="categoryColumns"
title="选择设备类别"
@confirm="onCategoryConfirm"
@cancel="picker.category = false"
/>
</van-popup>
<!-- 供应商选择 -->
<van-popup v-model:show="picker.supplier" position="bottom" round>
<van-picker
:columns="supplierColumns"
title="选择供应商"
@confirm="onSupplierConfirm"
@cancel="picker.supplier = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 收货入库
* - 现场扫码逐台登记(扫码进入时自动带入 deviceId)
* - 选择仓库 + 入库类型 + 备注
* - 字段对齐后台 device-center/stock/inbound InboundPayload
* 仓库 + 入库类型 + 设备类别(末级分类) + 供应商 + 批次号 + 采购单价 + 备注
* - 现场扫码逐台登记(扫码进入时自动带入 deviceCode
* - 提交后库存台账在库数量增加,并向后台入库管理追加一条入库单
*/
import { computed, onMounted, reactive, ref } from 'vue'
@@ -150,12 +202,18 @@ interface InboundForm {
warehouseId: string
warehouseLabel: string
inboundType: 'purchase' | 'return' | 'transfer'
categoryId: string
categoryLabel: string
supplierId: string
supplierLabel: string
batchNo: string
purchasePrice: number | undefined
operator: string
remark: string
items: InboundItem[]
}
interface WarehouseOption {
interface Option {
id: string
name: string
}
@@ -174,6 +232,12 @@ const form = reactive<InboundForm>({
warehouseId: '',
warehouseLabel: '',
inboundType: 'purchase',
categoryId: '',
categoryLabel: '',
supplierId: '',
supplierLabel: '',
batchNo: '',
purchasePrice: undefined,
operator: '当前用户',
remark: '',
items: [],
@@ -182,9 +246,13 @@ const form = reactive<InboundForm>({
const picker = reactive({
warehouse: false,
type: false,
category: false,
supplier: false,
})
const warehouseList = ref<WarehouseOption[]>([])
const warehouseList = ref<Option[]>([])
const categoryList = ref<Option[]>([])
const supplierList = ref<Option[]>([])
const submitting = ref(false)
const inboundTypeColumns = [
@@ -196,8 +264,20 @@ const inboundTypeColumns = [
const warehouseColumns = computed(() =>
warehouseList.value.map((w) => ({ text: w.name, value: w.id })),
)
const categoryColumns = computed(() =>
categoryList.value.map((c) => ({ text: c.name, value: c.id })),
)
const supplierColumns = computed(() =>
supplierList.value.map((s) => ({ text: s.name, value: s.id })),
)
const canSubmit = computed(() => !!form.warehouseId && form.items.length > 0)
const canSubmit = computed(() =>
!!form.warehouseId &&
!!form.categoryId &&
!!form.supplierId &&
!!form.purchasePrice &&
form.items.length > 0,
)
const inboundTypeText = (type: InboundForm['inboundType']): string =>
inboundTypeColumns.find((t) => t.value === type)?.text ?? ''
@@ -210,6 +290,14 @@ const openTypePicker = (): void => {
picker.type = true
}
const openCategoryPicker = (): void => {
picker.category = true
}
const openSupplierPicker = (): void => {
picker.supplier = true
}
const onWarehouseConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
const opt = selectedOptions[0]
if (opt) {
@@ -227,6 +315,24 @@ const onTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: str
picker.type = false
}
const onCategoryConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
const opt = selectedOptions[0]
if (opt) {
form.categoryId = opt.value
form.categoryLabel = opt.text
}
picker.category = false
}
const onSupplierConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
const opt = selectedOptions[0]
if (opt) {
form.supplierId = opt.value
form.supplierLabel = opt.text
}
picker.supplier = false
}
/** 删除已扫设备 */
const removeItem = (idx: number): void => {
form.items.splice(idx, 1)
@@ -236,12 +342,12 @@ const onBack = (): void => {
router.back()
}
/** 跳到扫码 Tab,扫码后带 deviceId 回跳 */
/** 跳到扫码 Tab,扫码后带 deviceCode 回跳 */
const goScan = (): void => {
router.push('/miniprogram/ops/scan')
}
/** 通过 query.deviceId 自动加进清单(从扫码场景菜单进入) */
/** 通过 query.deviceCode 自动加进清单(从扫码场景菜单进入) */
const loadScanDevice = (): void => {
const deviceCode = String(route.query.deviceCode ?? '')
if (!deviceCode) return
@@ -266,9 +372,9 @@ const fetchDeviceDetail = (deviceCode: string): void => {
.finally(() => undefined)
}
const loadWarehouses = (): void => {
postRequest<WarehouseOption[]>('axiosRequest', '/mp/ops/stock/warehouse-options', {})
.then((res: ApiResponse<WarehouseOption[]>) => {
const loadOptions = (): void => {
postRequest<Option[]>('axiosRequest', '/mp/ops/stock/warehouse-options', {})
.then((res: ApiResponse<Option[]>) => {
if (res.code === '00000' && res.data) {
warehouseList.value = res.data
if (res.data[0]) {
@@ -279,13 +385,25 @@ const loadWarehouses = (): void => {
})
.catch(() => undefined)
.finally(() => undefined)
postRequest<Option[]>('axiosRequest', '/mp/ops/stock/category-options', {})
.then((res: ApiResponse<Option[]>) => {
if (res.code === '00000' && res.data) categoryList.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
postRequest<Option[]>('axiosRequest', '/mp/ops/stock/supplier-options', {})
.then((res: ApiResponse<Option[]>) => {
if (res.code === '00000' && res.data) supplierList.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
const submit = (): void => {
if (!canSubmit.value) return
showDialog({
title: '确认提交入库',
message: `仓库:${form.warehouseLabel}\n类型:${inboundTypeText(form.inboundType)}\n设备:${form.items.length}`,
message: `仓库:${form.warehouseLabel}\n类型:${inboundTypeText(form.inboundType)}\n类别:${form.categoryLabel}\n供应商:${form.supplierLabel}\n设备:${form.items.length}`,
showCancelButton: true,
confirmButtonText: '确认入库',
cancelButtonText: '再看看',
@@ -301,6 +419,10 @@ const doSubmit = (): void => {
postRequest<null>('axiosRequest', '/mp/ops/stock/inbound/submit', {
warehouseId: form.warehouseId,
inboundType: form.inboundType,
categoryId: form.categoryId,
supplierId: form.supplierId,
purchasePrice: form.purchasePrice,
batchNo: form.batchNo,
remark: form.remark,
deviceCodes: form.items.map((x) => x.deviceCode),
})
@@ -309,6 +431,7 @@ const doSubmit = (): void => {
showSuccessToast(res.msg || '入库已提交')
form.items = []
form.remark = ''
form.batchNo = ''
} else {
showNotify({ type: 'danger', message: res.msg || '提交失败' })
}
@@ -320,7 +443,7 @@ const doSubmit = (): void => {
}
onMounted(() => {
loadWarehouses()
loadOptions()
loadScanDevice()
})
</script>
@@ -7,9 +7,18 @@
<div class="stock-inventory__form">
<van-cell-group inset>
<van-field label="盘点单号" :model-value="detail.inventoryCode" readonly />
<van-field label="盘点仓库" :model-value="detail.warehouseName" readonly />
<van-field label="仓库区域" :model-value="detail.warehouseAreaName" readonly />
<van-field label="盘点类型" :model-value="inventoryTypeText(detail.inventoryType)" readonly />
<van-field label="发起人" :model-value="detail.operator" readonly />
<van-field label="发起时间" :model-value="detail.inventoryTime" readonly />
<van-field label="状态" :model-value="statusText(detail.status)" readonly>
<template #right-icon>
<van-tag :type="statusTagType(detail.status)">{{ statusText(detail.status) }}</van-tag>
</template>
</van-field>
<van-field v-if="detail.auditor" label="审核人" :model-value="detail.auditor" readonly />
<van-field v-if="detail.auditTime" label="审核时间" :model-value="detail.auditTime" readonly />
<van-field v-if="detail.remark" label="备注" :model-value="detail.remark" readonly type="textarea" rows="1" autosize />
</van-cell-group>
</div>
@@ -36,17 +45,17 @@
<!-- 搜索 -->
<van-search
v-model="keyword"
placeholder="搜索设备编码 / 名称"
placeholder="搜索分类 / 批次"
shape="round"
class="stock-inventory__search"
/>
<!-- 应盘清单扫码标记实盘结果 -->
<!-- 应盘清单"末级分类 + 批次"维度 -->
<van-pull-refresh v-model="refreshing" @refresh="reload">
<van-empty v-if="!filtered.length" image="search" description="暂无应盘设备" />
<van-empty v-if="!filtered.length" image="search" description="暂无应盘条目" />
<div
v-for="item in filtered"
:key="item.deviceCode"
:key="`${item.categoryId}-${item.batchNo}`"
class="inv-item"
:class="`inv-item--${item.result}`"
>
@@ -55,37 +64,48 @@
<van-icon name="balance-list-o" />
</div>
<div class="inv-item__info">
<div class="inv-item__code">{{ item.deviceCode }}</div>
<div class="inv-item__name">{{ item.deviceName }}</div>
<div class="inv-item__cat">{{ item.categoryName }}</div>
<div class="inv-item__meta">
<span>{{ item.modelName }}</span>
<span>批次 {{ item.batchNo ?? '—' }}</span>
<span class="inv-item__dot">·</span>
<span>{{ item.batchNo }}</span>
<span>应盘 {{ item.expectedQuantity }}</span>
<span class="inv-item__dot">·</span>
<span>实盘 {{ item.actualQuantity }}</span>
</div>
</div>
<div class="inv-item__tag">
<van-tag :type="resultTagType(item.result)">{{ resultText(item.result) }}</van-tag>
</div>
</div>
<div class="inv-item__actions">
<van-button
v-for="opt in actionOptions"
:key="opt.value"
size="small"
:type="resultBtnType(opt.value)"
:plain="item.result !== opt.value"
hairline
class="inv-item__action"
@click="markInventory(item, opt.value)"
>
{{ opt.label }}
</van-button>
<!-- 实盘数量编辑 + 结果切换 -->
<div v-if="isInProgress" class="inv-item__actions">
<van-stepper
v-model="item.actualQuantity"
:min="0"
:max="999"
:disabled="item.result === 'unchecked'"
@change="onActualChange(item)"
/>
<div class="inv-item__btns">
<van-button
v-for="opt in actionOptions"
:key="opt.value"
size="small"
:type="resultBtnType(opt.value)"
:plain="item.result !== opt.value"
hairline
class="inv-item__action"
@click="markInventory(item, opt.value)"
>
{{ opt.label }}
</van-button>
</div>
</div>
</div>
</van-pull-refresh>
<!-- 底部固定操作区 -->
<div class="stock-inventory__footer">
<!-- 底部固定操作区仅草稿状态显示 -->
<div v-if="isInProgress" class="stock-inventory__footer">
<van-button
type="primary"
icon="scan"
@@ -114,25 +134,26 @@
<script setup lang="ts">
/**
* 实施运维小程序 / 库存盘点
* - 列表展示当前盘点单的应盘清单
* 实施运维小程序 / 盘点作业详情
* - 应盘清单按"末级分类 + 批次"维度展示(对齐后台 InventoryItem 字段)
* - 4 种实盘结果:matched(账实相符) / surplus(盘盈) / loss(盘亏) / unchecked(未盘)
* - 扫码进入时自动判 matched;手动可改 surplus / loss
* - 扫码进入时通过 categoryId+batchNo 定位应盘条目,自动判 matched + actualQuantity=expectedQuantity
* - 草稿状态可编辑;已完成/已审核只读
* - 全部 unchecked 处理完才能提交(状态进入 completed,转后台审核)
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showDialog, showNotify, showSuccessToast } from 'vant'
import { postRequest } from '@axios'
import type { ApiResponse } from '@axios'
type InventoryStatus = 'in_progress' | 'completed' | 'audited'
type InventoryType = 'full' | 'partial'
type InventoryResult = 'matched' | 'surplus' | 'loss' | 'unchecked'
interface InventoryItem {
deviceCode: string
deviceName: string
modelName: string
categoryId: string
categoryName: string
batchNo: string
expectedQuantity: number
actualQuantity: number
@@ -142,17 +163,31 @@ interface InventoryItem {
interface InventoryDetail {
id: string
inventoryCode: string
warehouseId: string
warehouseName: string
warehouseAreaId: string
warehouseAreaName: string
inventoryType: InventoryType
operator: string
inventoryTime: string
status: InventoryStatus
expectedCount: number
actualCount: number
surplusCount: number
lossCount: number
auditor?: string
auditTime?: string
remark?: string
items: InventoryItem[]
}
interface ScanDetail {
deviceCode: string
deviceName: string
modelName: string
batchNo: string
categoryId?: string
categoryName?: string
}
const route = useRoute()
const router = useRouter()
@@ -163,10 +198,12 @@ const submitting = ref(false)
const detail = reactive<InventoryDetail>({
id: '',
inventoryCode: '',
warehouseId: '',
warehouseName: '',
warehouseAreaId: '',
warehouseAreaName: '',
inventoryType: 'full',
operator: '',
inventoryTime: '',
status: 'in_progress',
expectedCount: 0,
actualCount: 0,
surplusCount: 0,
@@ -180,13 +217,15 @@ const actionOptions: Array<{ label: string; value: InventoryResult }> = [
{ label: '盘亏', value: 'loss' },
]
const isInProgress = computed(() => detail.status === 'in_progress')
const filtered = computed<InventoryItem[]>(() => {
if (!keyword.value.trim()) return detail.items
const kw = keyword.value.trim().toLowerCase()
return detail.items.filter(
(x) =>
x.deviceCode.toLowerCase().includes(kw) ||
x.deviceName.toLowerCase().includes(kw),
x.categoryName.toLowerCase().includes(kw) ||
(x.batchNo ?? '').toLowerCase().includes(kw),
)
})
@@ -194,38 +233,45 @@ const doneCount = computed(() => detail.items.filter((x) => x.result !== 'unchec
const canSubmit = computed(() => detail.expectedCount > 0 && doneCount.value === detail.expectedCount)
const inventoryTypeText = (type: InventoryType): string =>
type === 'full' ? '全' : '部分盘点'
type === 'full' ? '全量盘点' : '部分盘点'
const statusText = (s: InventoryStatus): string =>
({ in_progress: '进行中', completed: '已完成', audited: '已审核' })[s]
const statusTagType = (s: InventoryStatus): 'primary' | 'warning' | 'success' =>
({ in_progress: 'primary', completed: 'warning', audited: 'success' })[s]
const resultText = (r: InventoryResult): string => ({
matched: '相符',
surplus: '盘盈',
loss: '盘亏',
unchecked: '未盘',
}[r])
})[r]
const resultTagType = (r: InventoryResult): 'success' | 'warning' | 'danger' | 'default' => ({
matched: 'success',
surplus: 'warning',
loss: 'danger',
unchecked: 'default',
}[r] as 'success' | 'warning' | 'danger' | 'default')
})[r] as 'success' | 'warning' | 'danger' | 'default'
const resultBtnType = (r: InventoryResult): 'primary' | 'warning' | 'danger' => ({
matched: 'primary',
surplus: 'warning',
loss: 'danger',
}[r] as 'primary' | 'warning' | 'danger')
})[r] as 'primary' | 'warning' | 'danger'
/** 切换实盘结果(点击卡片体循环切换;点击底部按钮直接设置) */
const markInventory = (item: InventoryItem, action: 'cycle' | InventoryResult): void => {
if (!isInProgress.value) return
if (action === 'cycle') {
// 点击卡片体按"未盘 → 相符 → 盘盈 → 盘亏 → 未盘"循环
const order: InventoryResult[] = ['unchecked', 'matched', 'surplus', 'loss']
const idx = order.indexOf(item.result)
item.result = order[(idx + 1) % order.length]
} else {
item.result = action
}
// 自动调整数量与汇总
// 自动调整实盘数量
if (item.result === 'matched') {
item.actualQuantity = item.expectedQuantity
} else if (item.result === 'surplus') {
@@ -238,6 +284,15 @@ const markInventory = (item: InventoryItem, action: 'cycle' | InventoryResult):
recalcSummary()
}
/** 手动改实盘数量时,按"实盘 vs 应盘"自动判结果 */
const onActualChange = (item: InventoryItem): void => {
if (!isInProgress.value) return
if (item.actualQuantity === item.expectedQuantity) item.result = 'matched'
else if (item.actualQuantity > item.expectedQuantity) item.result = 'surplus'
else if (item.actualQuantity < item.expectedQuantity) item.result = 'loss'
recalcSummary()
}
const recalcSummary = (): void => {
let matched = 0
let surplus = 0
@@ -268,7 +323,7 @@ const submit = (): void => {
if (!canSubmit.value) return
showDialog({
title: '确认完成盘点',
message: `相符 ${detail.actualCount - detail.surplusCount} / 盘盈 ${detail.surplusCount} / 盘亏 ${detail.lossCount} `,
message: `相符 ${detail.actualCount - detail.surplusCount} / 盘盈 ${detail.surplusCount} / 盘亏 ${detail.lossCount} `,
showCancelButton: true,
confirmButtonText: '提交',
cancelButtonText: '再看看',
@@ -282,7 +337,8 @@ const doSubmit = (): void => {
postRequest<null>('axiosRequest', '/mp/ops/stock/inventory/submit', {
id: detail.id,
items: detail.items.map((x) => ({
deviceCode: x.deviceCode,
categoryId: x.categoryId,
batchNo: x.batchNo,
actualQuantity: x.actualQuantity,
result: x.result,
})),
@@ -302,15 +358,16 @@ const doSubmit = (): void => {
}
const loadDetail = (): void => {
const id = String(route.query.id ?? route.params.id ?? '')
const id = String(route.params.id ?? route.query.id ?? '')
if (!id) {
showNotify({ type: 'warning', message: '未指定盘点单,从库存作业页进入' })
showNotify({ type: 'warning', message: '未指定盘点单,请从盘点列表进入' })
return
}
postRequest<InventoryDetail>('axiosRequest', '/mp/ops/stock/inventory/detail', { id })
.then((res: ApiResponse<InventoryDetail>) => {
if (res.code === '00000' && res.data) {
Object.assign(detail, res.data)
loadScanDevice()
} else {
showNotify({ type: 'danger', message: res.msg || '盘点单查询失败' })
}
@@ -319,22 +376,46 @@ const loadDetail = (): void => {
.finally(() => undefined)
}
/** 通过 query.deviceCode 自动加进清单(从扫码场景菜单进入) */
const loadScanDevice = (): void => {
const deviceCode = String(route.query.deviceCode ?? '')
if (!deviceCode) return
const target = detail.items.find((x) => x.deviceCode === deviceCode)
if (target) {
target.result = 'matched'
target.actualQuantity = target.expectedQuantity
recalcSummary()
showSuccessToast(`已盘点:${deviceCode}`)
}
postRequest<ScanDetail>('axiosRequest', '/mp/ops/stock/device-detail', { deviceCode })
.then((res: ApiResponse<ScanDetail>) => {
if (res.code === '00000' && res.data && res.data.categoryId) {
const target = detail.items.find(
(x) => x.categoryId === res.data!.categoryId && x.batchNo === res.data!.batchNo,
)
if (target) {
target.result = 'matched'
target.actualQuantity = target.expectedQuantity
recalcSummary()
showSuccessToast(`已盘点:${deviceCode}`)
} else {
showNotify({ type: 'warning', message: `设备 ${deviceCode} 不在应盘清单内` })
}
} else {
showNotify({ type: 'danger', message: res.msg || '设备查询失败' })
}
})
.catch(() => undefined)
.finally(() => undefined)
}
onMounted(() => {
loadDetail()
loadScanDevice()
})
// 同一组件复用时(不同 :id 之间跳转),onMounted 不再触发,需要 watch 重新加载
watch(
() => route.params.id,
(id, prevId) => {
if (id && id !== prevId) {
keyword.value = ''
loadDetail()
}
},
)
</script>
<style scoped lang="less">
@@ -436,23 +517,17 @@ onMounted(() => {
min-width: 0;
}
&__code {
&__cat {
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
&__name {
margin-top: 2px;
font-size: var(--font-sm);
color: var(--text-secondary);
}
&__meta {
margin-top: 2px;
margin-top: 4px;
font-size: var(--font-xs);
color: var(--text-placeholder);
font-variant-numeric: tabular-nums;
}
&__dot {
@@ -461,6 +536,15 @@ onMounted(() => {
&__actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--border-light);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
&__btns {
display: flex;
gap: 6px;
}
@@ -0,0 +1,293 @@
<template>
<div class="inv-create">
<!-- 顶部导航 -->
<van-nav-bar title="新建盘点" left-arrow @click-left="onBack" />
<van-form @submit="onSubmit">
<!-- 基础信息 -->
<van-cell-group inset title="基础信息">
<van-field
:model-value="form.warehouseAreaLabel"
name="warehouseAreaId"
label="仓库区域"
placeholder="请选择仓库区域(任意层级,含其下所有子区域)"
readonly
is-link
:rules="[{ required: true, message: '请选择仓库区域' }]"
@click="showWarehousePicker = true"
/>
<van-field label="盘点类型" name="inventoryType">
<template #input>
<van-radio-group v-model="form.inventoryType" direction="horizontal">
<van-radio name="full">全量盘点</van-radio>
<van-radio name="partial">部分盘点</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
v-if="form.inventoryType === 'partial'"
:model-value="categoryLabel"
name="categoryIds"
label="应盘品类"
placeholder="请选择应盘品类(可多选)"
readonly
is-link
:rules="[{ required: true, message: '部分盘点请选择应盘品类' }]"
@click="openCategoryPicker"
/>
<van-field
v-model="form.remark"
name="remark"
label="备注"
type="textarea"
placeholder="可选,盘点任务说明(最多 200 字)"
rows="2"
autosize
maxlength="200"
show-word-limit
/>
</van-cell-group>
<!-- 提示信息 -->
<div class="inv-create__tip">
<van-icon name="info-o" />
<span v-if="form.inventoryType === 'partial'">
提交后系统按所选品类 + 该区域当前在库台账生成应盘清单"末级分类 + 批次"聚合盘点单进入草稿状态小程序扫码完成实盘后变已完成后台审核后修正库存台账
</span>
<span v-else>
提交后系统按该区域当前在库台账生成应盘清单"末级分类 + 批次"聚合盘点单进入草稿状态小程序扫码完成实盘后变已完成后台审核后修正库存台账
</span>
</div>
<div class="inv-create__submit">
<van-button block type="primary" native-type="submit" :loading="submitting">
创建盘点单
</van-button>
</div>
</van-form>
<!-- 仓库区域选择 -->
<van-popup v-model:show="showWarehousePicker" position="bottom" round>
<van-picker
:columns="warehouseColumns"
title="选择仓库区域"
@confirm="onWarehouseConfirm"
@cancel="showWarehousePicker = false"
/>
</van-popup>
<!-- 应盘品类多选 CheckboxGroup + Cell 模拟 tree-select -->
<van-popup v-model:show="showCategoryPicker" position="bottom" round>
<div class="inv-create__category-popup">
<div class="inv-create__category-head">
<span>应盘品类可多选</span>
<van-button size="small" type="primary" @click="onCategoryConfirm">确定</van-button>
</div>
<van-checkbox-group v-model="categoryCheck">
<div
v-for="cat in categoryList"
:key="cat.id"
class="inv-create__category-item"
@click="toggleCategory(cat.id)"
>
<van-checkbox :name="cat.id" shape="square" />
<span class="inv-create__category-name">{{ cat.name }}</span>
</div>
</van-checkbox-group>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 新建盘点任务
* - 字段对齐后台 device-center/stock/inventory addOrEdit
* 仓库区域 + 类型(full | partial+ 应盘品类(partial 时多选)+ 备注
* - 提交后系统按在库台账生成应盘清单(按"末级分类 + 批次"聚合),盘点单进入草稿状态
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showFailToast, showSuccessToast } from 'vant'
import { postRequest } from '@axios'
import type { ApiResponse } from '@axios'
interface Option { id: string; name: string }
const router = useRouter()
const form = reactive<{
warehouseAreaId: string
warehouseAreaLabel: string
inventoryType: 'full' | 'partial'
categoryIds: string[]
remark: string
}>({
warehouseAreaId: '',
warehouseAreaLabel: '',
inventoryType: 'full',
categoryIds: [],
remark: '',
})
const showWarehousePicker = ref(false)
const showCategoryPicker = ref(false)
const categoryCheck = ref<string[]>([])
const submitting = ref(false)
const warehouseList = ref<Option[]>([])
const categoryList = ref<Option[]>([])
const warehouseColumns = computed(() =>
warehouseList.value.map((w) => ({ text: w.name, value: w.id })),
)
const categoryLabel = computed(() =>
form.categoryIds
.map((id) => categoryList.value.find((c) => c.id === id)?.name ?? '')
.filter(Boolean)
.join('、'),
)
const onBack = (): void => {
router.back()
}
const onWarehouseConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
const opt = selectedOptions[0]
if (opt) {
form.warehouseAreaId = opt.value
form.warehouseAreaLabel = opt.text
}
showWarehousePicker.value = false
}
const openCategoryPicker = (): void => {
categoryCheck.value = [...form.categoryIds]
showCategoryPicker.value = true
}
const toggleCategory = (id: string): void => {
const idx = categoryCheck.value.indexOf(id)
if (idx >= 0) categoryCheck.value.splice(idx, 1)
else categoryCheck.value.push(id)
}
const onCategoryConfirm = (): void => {
form.categoryIds = [...categoryCheck.value]
showCategoryPicker.value = false
}
const loadOptions = (): void => {
postRequest<Option[]>('axiosRequest', '/mp/ops/stock/warehouse-options', {})
.then((res: ApiResponse<Option[]>) => {
if (res.code === '00000' && res.data) warehouseList.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
postRequest<Option[]>('axiosRequest', '/mp/ops/stock/category-options', {})
.then((res: ApiResponse<Option[]>) => {
if (res.code === '00000' && res.data) categoryList.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
const onSubmit = (): void => {
if (!form.warehouseAreaId) {
showFailToast('请选择仓库区域')
return
}
if (form.inventoryType === 'partial' && !form.categoryIds.length) {
showFailToast('部分盘点请选择应盘品类')
return
}
submitting.value = true
postRequest<{ id: string; inventoryCode: string }>('axiosRequest', '/mp/ops/stock/inventory/create', {
warehouseAreaId: form.warehouseAreaId,
inventoryType: form.inventoryType,
categoryIds: form.inventoryType === 'partial' ? form.categoryIds : undefined,
remark: form.remark,
})
.then((res: ApiResponse<{ id: string; inventoryCode: string }>) => {
if (res.code === '00000' && res.data) {
showSuccessToast('已创建,进入盘点作业')
router.replace(`/miniprogram/ops/stock-inventory-detail/${res.data.id}`)
} else {
showFailToast(res.msg || '创建失败')
}
})
.catch(() => undefined)
.finally(() => {
submitting.value = false
})
}
onMounted(() => {
loadOptions()
})
</script>
<style scoped lang="less">
.inv-create {
min-height: 100%;
background: var(--background);
padding-bottom: 24px;
&__tip {
margin: 12px 16px;
padding: 10px 12px;
background: #fffbe6;
border-radius: var(--radius-md);
font-size: var(--font-xs);
color: var(--text-secondary);
display: flex;
gap: 6px;
align-items: flex-start;
.van-icon {
color: var(--warning);
flex-shrink: 0;
margin-top: 2px;
}
}
&__submit {
padding: 16px;
}
&__category-popup {
padding: 12px 0;
max-height: 50vh;
overflow-y: auto;
}
&__category-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 10px;
border-bottom: 1px solid var(--border-light);
font-size: var(--font-sm);
font-weight: 600;
color: var(--text-primary);
}
&__category-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
&:active {
background: var(--background);
}
}
&__category-name {
font-size: var(--font-sm);
color: var(--text-primary);
}
}
</style>
@@ -0,0 +1,284 @@
<template>
<div class="inventory-list">
<!-- 顶部导航 -->
<van-nav-bar title="盘点作业" left-arrow @click-left="onBack">
<template #right>
<van-icon name="plus" size="18" @click="goCreate" />
</template>
</van-nav-bar>
<!-- 状态分段 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>
<!-- 搜索 -->
<van-search
v-model="keyword"
placeholder="搜索盘点单号"
shape="round"
@search="reload"
@clear="reload"
/>
<!-- 列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadList">
<van-empty
v-if="!list.length && !loading"
image="search"
description="暂无盘点任务,点击右上角 + 新建"
/>
<div
v-for="item in list"
:key="item.id"
class="inv-card"
:class="`inv-card--${item.status}`"
@click="goDetail(item.id)"
>
<div class="inv-card__header">
<span class="inv-card__code">{{ item.inventoryCode }}</span>
<div class="inv-card__tags">
<van-tag :type="typeTagType(item.inventoryType)" plain size="medium">
{{ typeText(item.inventoryType) }}
</van-tag>
<van-tag :type="statusTagType(item.status)" size="medium">
{{ statusText(item.status) }}
</van-tag>
</div>
</div>
<div class="inv-card__name">
<van-icon name="shop-o" />
<span>{{ item.warehouseAreaName }}</span>
</div>
<div class="inv-card__stats">
<div class="inv-card__stat">
<span class="inv-card__stat-label">应盘</span>
<span class="inv-card__stat-value">{{ item.expectedCount }}</span>
</div>
<div class="inv-card__stat inv-card__stat--blue">
<span class="inv-card__stat-label">已盘</span>
<span class="inv-card__stat-value">{{ item.actualCount }}</span>
</div>
<div class="inv-card__stat inv-card__stat--orange">
<span class="inv-card__stat-label">盘盈</span>
<span class="inv-card__stat-value">{{ item.surplusCount }}</span>
</div>
<div class="inv-card__stat inv-card__stat--red">
<span class="inv-card__stat-label">盘亏</span>
<span class="inv-card__stat-value">{{ item.lossCount }}</span>
</div>
</div>
<div class="inv-card__footer">
<span class="inv-card__operator">
<van-icon name="user-o" />
{{ item.operator }}
</span>
<span class="inv-card__time">{{ item.inventoryTime }}</span>
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 盘点任务列表
* - 列出当前用户参与的盘点单(草稿 / 已完成 / 已审核)
* - 右上角 + 进入新建盘点
* - 点击卡片进入盘点作业详情:草稿可继续盘 / 已完成只读 / 已审核只读
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { postRequest } from '@axios'
import type { ApiResponse } from '@axios'
type InventoryStatus = 'in_progress' | 'completed' | 'audited'
type InventoryType = 'full' | 'partial'
interface InventoryListItem {
id: string
inventoryCode: string
warehouseAreaId: string
warehouseAreaName: string
inventoryType: InventoryType
operator: string
inventoryTime: string
status: InventoryStatus
expectedCount: number
actualCount: number
surplusCount: number
lossCount: number
}
const router = useRouter()
const list = ref<InventoryListItem[]>([])
const loading = ref(false)
const finished = ref(true)
const refreshing = ref(false)
const activeTab = ref<InventoryStatus | ''>('')
const keyword = ref('')
const statusTabs: Array<{ label: string; value: InventoryStatus | '' }> = [
{ label: '全部', value: '' },
{ label: '进行中', value: 'in_progress' },
{ label: '已完成', value: 'completed' },
{ label: '已审核', value: 'audited' },
]
const statusText = (s: InventoryStatus): string => ({ in_progress: '进行中', completed: '已完成', audited: '已审核' })[s]
const statusTagType = (s: InventoryStatus): 'primary' | 'warning' | 'success' =>
({ in_progress: 'primary', completed: 'warning', audited: 'success' })[s]
const typeText = (t: InventoryType): string => ({ full: '全量', partial: '部分' })[t]
const typeTagType = (t: InventoryType): 'primary' | 'warning' =>
({ full: 'primary', partial: 'warning' })[t]
const goCreate = (): void => {
router.push('/miniprogram/ops/stock-inventory-create')
}
const goDetail = (id: string): void => {
router.push(`/miniprogram/ops/stock-inventory-detail/${id}`)
}
const onBack = (): void => {
router.back()
}
const loadList = (): void => {
loading.value = true
postRequest<InventoryListItem[]>('axiosRequest', '/mp/ops/stock/inventory/list', {
status: activeTab.value,
keyword: keyword.value.trim(),
})
.then((res: ApiResponse<InventoryListItem[]>) => {
if (res.code === '00000' && res.data) list.value = res.data
})
.catch(() => undefined)
.finally(() => {
loading.value = false
refreshing.value = false
})
}
const onTabChange = (): void => {
list.value = []
loading.value = true
finished.value = true
loadList()
}
const reload = (): void => {
list.value = []
loading.value = true
finished.value = true
loadList()
}
const onRefresh = (): void => {
reload()
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="less">
.inventory-list {
min-height: 100%;
background: var(--background);
padding-bottom: 16px;
}
.inv-card {
margin: 8px 12px;
padding: 12px;
background: #fff;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border-left: 3px solid var(--primary);
&--in_progress { border-left-color: var(--primary); }
&--completed { border-left-color: var(--warning); }
&--audited { border-left-color: var(--success); }
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__code {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
&__tags {
display: flex;
gap: 6px;
}
&__name {
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-sm);
color: var(--text-secondary);
}
&__stats {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(4, 1fr);
text-align: center;
padding: 8px 0;
border-top: 1px dashed var(--border-light);
border-bottom: 1px dashed var(--border-light);
}
&__stat {
display: flex;
flex-direction: column;
gap: 2px;
&--blue &__value { color: var(--primary); }
&--orange &__value { color: #fa8c16; }
&--red &__value { color: var(--danger); }
}
&__stat-label {
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__stat-value {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
&__footer {
margin-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
&__operator {
display: flex;
align-items: center;
gap: 4px;
}
}
</style>
@@ -7,11 +7,12 @@
<div class="stock-list__filter">
<van-dropdown-menu>
<van-dropdown-item v-model="filter.warehouseId" :options="warehouseOpts" @change="reload" />
<van-dropdown-item v-model="filter.status" :options="statusOpts" @change="reload" />
<van-dropdown-item v-model="filter.categoryId" :options="categoryOpts" @change="reload" />
<van-dropdown-item v-model="filter.healthLevel" :options="healthOpts" @change="reload" />
</van-dropdown-menu>
<van-search
v-model="filter.keyword"
placeholder="搜索设备编码 / 名称 / 批次号"
placeholder="搜索分类 / 批次 / 供应商"
shape="round"
@search="reload"
/>
@@ -34,28 +35,29 @@
v-for="item in list"
:key="item.id"
class="stock-card"
:class="`stock-card--${item.status}`"
@click="goDetail(item)"
:class="`stock-card--${item.healthLevel}`"
>
<div class="stock-card__header">
<span class="stock-card__code">{{ item.deviceCode }}</span>
<van-tag :type="statusTagType(item.status)">{{ statusText(item.status) }}</van-tag>
<span class="stock-card__cat">{{ item.categoryName }}</span>
<van-tag :type="healthTagType(item.healthLevel)">{{ healthText(item.healthLevel) }}</van-tag>
</div>
<div class="stock-card__name">{{ item.deviceName }}</div>
<div class="stock-card__meta">
<span>{{ item.modelName }}</span>
<span>批次 {{ item.batchNo ?? '—' }}</span>
<span class="stock-card__dot">·</span>
<span>{{ item.batchNo }}</span>
<span>{{ item.supplierName }}</span>
</div>
<div class="stock-card__footer">
<div class="stock-card__wh">
<van-icon name="shop-o" />
<span>{{ item.warehouseName }}</span>
<span>{{ item.warehouseAreaName }}</span>
</div>
<div class="stock-card__qty">
在库 <strong>{{ item.quantity }}</strong> {{ item.unit }}
在库 <strong>{{ item.stockQty }}</strong>
<span class="stock-card__sub">可用 {{ item.availableQty }}</span>
<span v-if="item.lockedQty > 0" class="stock-card__lock">锁定 {{ item.lockedQty }}</span>
</div>
</div>
<div class="stock-card__time">更新于 {{ item.updateTime ?? '—' }}</div>
</div>
</van-list>
</van-pull-refresh>
@@ -65,9 +67,9 @@
<script setup lang="ts">
/**
* 实施运维小程序 / 库存查询
* - 只读查询当前在库设备清单(数据来自后台"在库库存"页面
* - 支持按仓库 / 状态筛选 + 关键字搜索
* - 点击卡片跳设备详情(暂跳扫码 → 设备监测)
* - 只读查询当前聚合库存清单(数据来自后台 device-center/stock/instock ListItem
* - 支持按仓库 / 类别 / 健康度筛选 + 关键字搜索
* - 字段:categoryName / batchNo / supplierName / warehouseAreaName / stockQty / lockedQty / availableQty / healthLevel / updateTime
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -76,16 +78,18 @@ import type { ApiResponse } from '@axios'
interface StockItem {
id: string
deviceCode: string
deviceName: string
modelName: string
categoryId: string
categoryName: string
batchNo: string
warehouseId: string
warehouseName: string
quantity: number
unit: string
status: 'instock' | 'checkout' | 'frozen'
inboundTime: string
supplierId: string
supplierName: string
warehouseAreaId: string
warehouseAreaName: string
stockQty: number
lockedQty: number
availableQty: number
healthLevel: 'sufficient' | 'warning' | 'out_of_stock'
updateTime: string
}
interface Option {
@@ -95,14 +99,16 @@ interface Option {
const router = useRouter()
const filter = reactive<{ warehouseId: string; status: string; keyword: string }>({
const filter = reactive<{ warehouseId: string; categoryId: string; healthLevel: string; keyword: string }>({
warehouseId: '',
status: '',
categoryId: '',
healthLevel: '',
keyword: '',
})
const list = ref<StockItem[]>([])
const warehouseList = ref<Option[]>([])
const categoryList = ref<Option[]>([])
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false) // van-list 首次挂载时主动触发 @load;mock 一次返回全部
@@ -112,24 +118,29 @@ const warehouseOpts = computed(() => [
...warehouseList.value.map((w) => ({ text: w.name, value: w.id })),
])
const statusOpts = [
{ text: '全部状态', value: '' },
{ text: '在库', value: 'instock' },
{ text: '已出库', value: 'checkout' },
{ text: '冻结', value: 'frozen' },
const categoryOpts = computed(() => [
{ text: '全部类别', value: '' },
...categoryList.value.map((c) => ({ text: c.name, value: c.id })),
])
const healthOpts = [
{ text: '全部健康度', value: '' },
{ text: '充足', value: 'sufficient' },
{ text: '断货预警', value: 'warning' },
{ text: '暂无库存', value: 'out_of_stock' },
]
const statusText = (s: StockItem['status']): string => ({
instock: '在库',
checkout: '已出库',
frozen: '冻结',
}[s])
const healthText = (h: StockItem['healthLevel']): string => ({
sufficient: '充足',
warning: '断货预警',
out_of_stock: '暂无库存',
}[h])
const statusTagType = (s: StockItem['status']): 'success' | 'default' | 'warning' => ({
instock: 'success',
checkout: 'default',
frozen: 'warning',
}[s] as 'success' | 'default' | 'warning')
const healthTagType = (h: StockItem['healthLevel']): 'success' | 'warning' | 'default' => ({
sufficient: 'success',
warning: 'warning',
out_of_stock: 'default',
}[h] as 'success' | 'warning' | 'default')
const loadList = (): void => {
loading.value = true
@@ -147,13 +158,19 @@ const loadList = (): void => {
})
}
const loadWarehouses = (): void => {
const loadOptions = (): void => {
postRequest<Option[]>('axiosRequest', '/mp/ops/stock/warehouse-options', {})
.then((res: ApiResponse<Option[]>) => {
if (res.code === '00000' && res.data) warehouseList.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
postRequest<Option[]>('axiosRequest', '/mp/ops/stock/category-options', {})
.then((res: ApiResponse<Option[]>) => {
if (res.code === '00000' && res.data) categoryList.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
const reload = (): void => {
@@ -169,12 +186,8 @@ const onBack = (): void => {
router.back()
}
const goDetail = (item: StockItem): void => {
router.push({ path: '/miniprogram/ops/scan', query: { deviceCode: item.deviceCode } })
}
onMounted(() => {
loadWarehouses()
loadOptions()
})
</script>
@@ -197,8 +210,8 @@ onMounted(() => {
box-shadow: var(--shadow-sm);
border-left: 3px solid var(--success);
&--checkout { border-left-color: var(--border); opacity: 0.7; }
&--frozen { border-left-color: var(--warning); }
&--warning { border-left-color: var(--warning); }
&--out_of_stock { border-left-color: var(--border); opacity: 0.7; }
&__header {
display: flex;
@@ -206,21 +219,14 @@ onMounted(() => {
justify-content: space-between;
}
&__code {
&__cat {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
&__name {
margin-top: 4px;
font-size: var(--font-sm);
color: var(--text-secondary);
}
&__meta {
margin-top: 2px;
margin-top: 4px;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
@@ -258,5 +264,21 @@ onMounted(() => {
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
}
}
&__sub {
margin-left: 6px;
color: var(--text-secondary);
}
&__lock {
margin-left: 6px;
color: var(--warning);
}
&__time {
margin-top: 6px;
font-size: var(--font-xs);
color: var(--text-placeholder);
}
}
</style>
@@ -33,10 +33,13 @@
/>
<van-field
v-if="form.outboundType === 'install'"
v-model="form.siteName"
label="装机场所"
placeholder="如:上海浦东·张江食堂"
maxlength="40"
label="布点场所"
:model-value="form.siteName"
:placeholder="form.customerId ? '请选择布点场所' : '请先选择装机客户'"
readonly
is-link
:disabled="!form.customerId"
@click="openSitePicker"
/>
<van-field label="经办人" :model-value="form.operator" placeholder="自动带入" readonly />
<van-field
@@ -119,7 +122,7 @@
</van-button>
</div>
<van-popup v-model:show="picker.warehouse" position="bottom" teleport="body">
<van-popup v-model:show="picker.warehouse" position="bottom" round>
<van-picker
:columns="warehouseColumns"
title="选出库仓库"
@@ -128,7 +131,7 @@
/>
</van-popup>
<van-popup v-model:show="picker.type" position="bottom" teleport="body">
<van-popup v-model:show="picker.type" position="bottom" round>
<van-picker
:columns="outboundTypeColumns"
title="选出库类型"
@@ -137,7 +140,7 @@
/>
</van-popup>
<van-popup v-model:show="picker.customer" position="bottom" teleport="body">
<van-popup v-model:show="picker.customer" position="bottom" round>
<van-picker
:columns="customerColumns"
title="选装机客户"
@@ -145,13 +148,23 @@
@cancel="picker.customer = false"
/>
</van-popup>
<van-popup v-model:show="picker.site" position="bottom" round>
<van-picker
:columns="siteColumns"
title="选布点场所"
:disabled="!form.customerId"
@confirm="onSiteConfirm"
@cancel="picker.site = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
/**
* 实施运维小程序 / 装机出库
* - 出库类型:装机 / 维修备件 / 报废 / 调拨出库
* - 出库类型:装机 / 调拨 / 报废(与后台 device-center/stock/outbound OutboundType 对齐,无"维修备件"
* - 装机类型必填客户 + 装机场所(后续可触发"设备初始化"流程)
* - 提交后从在库台账扣减数量,向后台出库管理追加一条出库单
*/
@@ -172,7 +185,7 @@ interface OutboundItem {
interface OutboundForm {
warehouseId: string
warehouseLabel: string
outboundType: 'install' | 'repair' | 'scrap' | 'transfer'
outboundType: 'install' | 'transfer' | 'scrap'
customerId: string
customerLabel: string
siteName: string
@@ -186,6 +199,12 @@ interface Option {
name: string
}
interface SiteOption {
id: string
name: string
customerId: string
}
const route = useRoute()
const router = useRouter()
@@ -205,17 +224,18 @@ const picker = reactive({
warehouse: false,
type: false,
customer: false,
site: false,
})
const warehouseList = ref<Option[]>([])
const customerList = ref<Option[]>([])
const siteList = ref<SiteOption[]>([])
const submitting = ref(false)
const outboundTypeColumns = [
{ text: '装机出库', value: 'install' },
{ text: '维修备件', value: 'repair' },
{ text: '报废处理', value: 'scrap' },
{ text: '调拨出库', value: 'transfer' },
{ text: '报废处理', value: 'scrap' },
]
const warehouseColumns = computed(() =>
@@ -224,6 +244,9 @@ const warehouseColumns = computed(() =>
const customerColumns = computed(() =>
customerList.value.map((c) => ({ text: c.name, value: c.id })),
)
const siteColumns = computed(() =>
siteList.value.map((s) => ({ text: s.name, value: s.id })),
)
const canSubmit = computed(() => {
if (!form.warehouseId || form.items.length === 0) return false
@@ -263,13 +286,43 @@ const onTypeConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: str
const onCustomerConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
const opt = selectedOptions[0]
if (opt) {
if (opt && opt.value !== form.customerId) {
// 客户变更:清空布点场所并拉取新客户的场所列表
form.customerId = opt.value
form.customerLabel = opt.text
form.siteName = ''
loadSites(form.customerId)
}
picker.customer = false
}
const openSitePicker = (): void => {
if (!form.customerId) {
showNotify({ type: 'warning', message: '请先选择装机客户' })
return
}
picker.site = true
}
const onSiteConfirm = ({ selectedOptions }: { selectedOptions: Array<{ text: string; value: string }> }): void => {
const opt = selectedOptions[0]
if (opt) form.siteName = opt.text
picker.site = false
}
const loadSites = (customerId: string): void => {
if (!customerId) {
siteList.value = []
return
}
postRequest<SiteOption[]>('axiosRequest', '/mp/ops/stock/site-options', { customerId })
.then((res: ApiResponse<SiteOption[]>) => {
if (res.code === '00000' && res.data) siteList.value = res.data
})
.catch(() => undefined)
.finally(() => undefined)
}
const removeItem = (idx: number): void => {
form.items.splice(idx, 1)
}
@@ -81,3 +81,29 @@ export interface AlarmHandleParams {
action: AlarmHandleAction
reason: string
}
/** 待初始化设备列表项(工作台"待初始化"卡跳转目标) */
export interface InitDeviceItem {
id: string
deviceCode: string
deviceName: string
model: string
siteName: string
tenantName: string
/** 入库时间(待激活设备未上线,用心跳无意义,改展示入库时间) */
inboundTime: string
}
/** 待升级 OTA 任务列表项(工作台"OTA 任务"卡跳转目标) */
export interface OtaTaskItem {
id: string
taskId: string
deviceCode: string
deviceName: string
currentVersion: string
targetVersion: string
/** 任务下发时间 */
pushTime: string
/** 任务状态:pending 待执行 / upgrading 升级中 / failed 失败重试 */
status: 'pending' | 'upgrading' | 'failed'
}