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:
co-authored by
Claude Opus 4.7
parent
6d2e885fd0
commit
e586461af7
@@ -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 任务执行结果 = fail(warn)',
|
||||
@@ -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 },
|
||||
])
|
||||
|
||||
@@ -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('删除成功')
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
/**
|
||||
* 实施运维小程序 / 工单 mock
|
||||
* - 列表(按状态分段,类型筛选)
|
||||
* - 列表(按 4 段状态,来源筛选)
|
||||
* - 详情(含客户报障附件 + 处理时间线)
|
||||
* - 接单 / 关单 / 提交维修上报
|
||||
*
|
||||
* 字段命名与状态枚举对齐后台 device-center/ops/workOrder:
|
||||
* - status:pending 待派单 / assigned 已派单 / processing 处理中 / closed 已关单
|
||||
* - source:customer 客户报障 / 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',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
+12
-6
@@ -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,
|
||||
|
||||
+6
@@ -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 })
|
||||
|
||||
+58
-25
@@ -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,
|
||||
|
||||
+1
-3
@@ -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 })
|
||||
|
||||
+2
-2
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = customerId,value = 该客户下的场所列表) */
|
||||
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]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? '已启用' : '未启用'" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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_TABS:4 段状态 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 直接切换状态) */
|
||||
/** 工单状态 Tab(4 段,不含"全部") */
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,50 +1,80 @@
|
||||
/**
|
||||
* 实施运维小程序 / 工单 类型定义
|
||||
*
|
||||
* 字段命名与状态枚举对齐后台 device-center/ops/workOrder:
|
||||
* - WorkOrderStatus:pending 待派单 / assigned 已派单 / processing 处理中 / closed 已关单
|
||||
* - OrderSource:customer 客户报障 / 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[]
|
||||
|
||||
@@ -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 的首个 section(hero 圆角伪元素向下延伸 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 是 computed,keyword 变化即触发过滤;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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user