feat: 设备中心详情/编辑全量完善 + SIM卡管理新增 + 公共选择器组件抽取

- 资产/库存/客户/第三方授权:详情抽屉与编辑弹框完善,mock 数据补齐
- 新增页面:SIM 卡管理(含绑定记录/绑定设备/导入弹框)、虹软批量导入、客户档案站点简报、出库详情抽屉、仓库库区库存汇总
- 公共组件:DeviceCategoryPicker / ExportTaskDrawer / WarehouseAreaPicker
- 运维小程序:工单/告警/库存作业页交互打磨
- 删除:库存退货弹框(returnGoods)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
fengpu
2026-07-08 18:45:19 +08:00
co-authored by Claude Opus 4.7
parent 7d8c126e26
commit 74a5419028
170 changed files with 6983 additions and 2383 deletions
+30
View File
@@ -34,3 +34,33 @@ export const departLevelTree = (params?: RequestParameter): Promise<ApiResponse<
*/
export const departListLevel = (params?: RequestParameter): Promise<ApiResponse<unknown[]>> =>
postRequest('axiosRequest', '/sys/depart/listLevel', params)
/**
* 设备分类树节点(最小子集类型,仅包含下拉/树形展示所需字段)
*
* 与 asset/category 模块的 CategoryNode 同源,但此处只暴露公共消费方需要的字段,
* 避免其他模块跨应用 import 设备分类模块的完整类型。
*/
export interface DeviceCategoryTreeNode {
/** 节点 ID */
id: string
/** 节点名称 */
name: string
/** 节点编码(D01 / D01A01 / ... */
code: string
/** 层级:1=顶级分类,2=二级分类,依此类推 */
level: number
/** 父节点 ID(顶级为空) */
parentId?: string
/** 子节点 */
children?: DeviceCategoryTreeNode[]
}
/**
* 获取设备分类全树(供其他模块作为"设备类别"下拉数据源使用)
*
* 数据源:/admin/device/category/tree(与设备分类菜单同接口)
* 调用方:虹软授权等需要按设备类别筛选/选择的页面
*/
export const deviceCategoryTree = (params?: RequestParameter): Promise<ApiResponse<DeviceCategoryTreeNode[]>> =>
postRequest('axiosRequest', '/admin/device/category/tree', params)
@@ -18,6 +18,10 @@ interface CategoryNode {
dataTransit?: boolean
/** 是否启用数据上报(仅 level >= 2 节点配置) */
dataReport?: boolean
/** 是否支持人脸识别(仅 level >= 2 节点配置;如食堂人脸支付、门禁识别等场景) */
faceRecognition?: boolean
/** 配网方式(仅 level >= 2 节点配置;可选,标记该分类下设备的主流入网方式) */
networkMode?: 'wifi' | 'bluetooth' | 'sim' | 'ethernet' | 'other_iot'
/** 厂商(字典 device_manufacturer 的 value;仅叶子节点配置) */
manufacturer?: number
/** 型号(仅叶子节点配置) */
@@ -40,6 +44,8 @@ interface CategoryPayload {
isIot?: boolean
dataTransit?: boolean
dataReport?: boolean
faceRecognition?: boolean
networkMode?: 'wifi' | 'bluetooth' | 'sim' | 'ethernet' | 'other_iot'
/** 叶子节点专属 */
manufacturer?: number
model?: string
@@ -90,50 +96,50 @@ const categoryTree: CategoryNode[] = [
id: TOP.catering, name: '智养餐饮设备', code: 'D01', level: 1, sortOrder: 1,
remark: '食堂类设备,业务数据不过平台', createTime: '2026-05-10 09:20:00',
children: [
{ id: 'cat-catering-plate', name: '吐盘机', code: 'D01A01', level: 2, parentId: TOP.catering, sortOrder: 1, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.shanghe, model: 'SH-TP-200', createTime: '2026-05-11 10:00:00' },
{ id: 'cat-catering-cabinet', name: '智能货柜', code: 'D01A02', level: 2, parentId: TOP.catering, sortOrder: 2, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.huawei, model: 'HW-CAB-100L', createTime: '2026-05-11 10:05:00' },
{ id: 'cat-catering-nutri-scale', name: '营养秤', code: 'D01A03', level: 2, parentId: TOP.catering, sortOrder: 3, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.yuyue, model: 'YY-NS-5KG', createTime: '2026-05-11 10:10:00' },
{ id: 'cat-catering-ratio-scale', name: '配比秤', code: 'D01A04', level: 2, parentId: TOP.catering, sortOrder: 4, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.yuyue, model: 'YY-RS-15KG', createTime: '2026-05-11 10:15:00' },
{ id: 'cat-catering-checkout', name: '结算台', code: 'D01A05', level: 2, parentId: TOP.catering, sortOrder: 5, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.huawei, model: 'HW-CK-21IN', createTime: '2026-05-11 10:20:00' },
{ id: 'cat-catering-inbound-scale', name: '入库秤', code: 'D01A06', level: 2, parentId: TOP.catering, sortOrder: 6, isIot: true, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.yuyue, model: 'YY-IS-30KG', createTime: '2026-05-11 10:25:00' },
{ id: 'cat-catering-band-screen', name: '带鱼屏', code: 'D01A07', level: 2, parentId: TOP.catering, sortOrder: 7, isIot: false, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.shanghe, model: 'SH-BS-25IN', createTime: '2026-05-11 10:30:00' },
{ id: 'cat-catering-baifei', name: '波菲炉', code: 'D01A08', level: 2, parentId: TOP.catering, sortOrder: 8, isIot: false, dataTransit: false, dataReport: false, manufacturer: MANUFACTURER.shanghe, model: 'SH-BF-4GN', createTime: '2026-05-11 10:35:00' },
{ id: 'cat-catering-plate', name: '吐盘机', code: 'D01A01', level: 2, parentId: TOP.catering, sortOrder: 1, isIot: true, dataTransit: false, dataReport: false, faceRecognition: false, networkMode: 'wifi', manufacturer: MANUFACTURER.shanghe, model: 'SH-TP-200', createTime: '2026-05-11 10:00:00' },
{ id: 'cat-catering-cabinet', name: '智能货柜', code: 'D01A02', level: 2, parentId: TOP.catering, sortOrder: 2, isIot: true, dataTransit: false, dataReport: false, faceRecognition: true, networkMode: 'wifi', manufacturer: MANUFACTURER.huawei, model: 'HW-CAB-100L', createTime: '2026-05-11 10:05:00' },
{ id: 'cat-catering-nutri-scale', name: '营养秤', code: 'D01A03', level: 2, parentId: TOP.catering, sortOrder: 3, isIot: true, dataTransit: false, dataReport: false, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.yuyue, model: 'YY-NS-5KG', createTime: '2026-05-11 10:10:00' },
{ id: 'cat-catering-ratio-scale', name: '配比秤', code: 'D01A04', level: 2, parentId: TOP.catering, sortOrder: 4, isIot: true, dataTransit: false, dataReport: false, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.yuyue, model: 'YY-RS-15KG', createTime: '2026-05-11 10:15:00' },
{ id: 'cat-catering-checkout', name: '结算台', code: 'D01A05', level: 2, parentId: TOP.catering, sortOrder: 5, isIot: true, dataTransit: false, dataReport: false, faceRecognition: true, networkMode: 'ethernet', manufacturer: MANUFACTURER.huawei, model: 'HW-CK-21IN', createTime: '2026-05-11 10:20:00' },
{ id: 'cat-catering-inbound-scale', name: '入库秤', code: 'D01A06', level: 2, parentId: TOP.catering, sortOrder: 6, isIot: true, dataTransit: false, dataReport: false, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.yuyue, model: 'YY-IS-30KG', createTime: '2026-05-11 10:25:00' },
{ id: 'cat-catering-band-screen', name: '带鱼屏', code: 'D01A07', level: 2, parentId: TOP.catering, sortOrder: 7, isIot: false, dataTransit: false, dataReport: false, faceRecognition: true, manufacturer: MANUFACTURER.shanghe, model: 'SH-BS-25IN', createTime: '2026-05-11 10:30:00' },
{ id: 'cat-catering-baifei', name: '波菲炉', code: 'D01A08', level: 2, parentId: TOP.catering, sortOrder: 8, isIot: false, dataTransit: false, dataReport: false, faceRecognition: false, manufacturer: MANUFACTURER.shanghe, model: 'SH-BF-4GN', createTime: '2026-05-11 10:35:00' },
],
},
{
id: TOP.body, name: '体重测量设备', code: 'D02', level: 1, sortOrder: 2,
remark: '业务数据过平台中转', createTime: '2026-05-10 09:25:00',
children: [
{ id: 'cat-body-all-in-one', name: '一体机', code: 'D02A01', level: 2, parentId: TOP.body, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.luojiefu, model: 'LJF-AIO-V3', createTime: '2026-05-11 11:00:00' },
{ id: 'cat-body-bt-scale', name: '蓝牙秤', code: 'D02A02', level: 2, parentId: TOP.body, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yuyue, model: 'YY-BT-180KG', createTime: '2026-05-11 11:05:00' },
{ id: 'cat-body-fat', name: '体脂仪', code: 'D02A03', level: 2, parentId: TOP.body, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.huawei, model: 'HW-FAT-BH', createTime: '2026-05-11 11:10:00' },
{ id: 'cat-body-all-in-one', name: '一体机', code: 'D02A01', level: 2, parentId: TOP.body, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, faceRecognition: true, networkMode: 'wifi', manufacturer: MANUFACTURER.luojiefu, model: 'LJF-AIO-V3', createTime: '2026-05-11 11:00:00' },
{ id: 'cat-body-bt-scale', name: '蓝牙秤', code: 'D02A02', level: 2, parentId: TOP.body, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.yuyue, model: 'YY-BT-180KG', createTime: '2026-05-11 11:05:00' },
{ id: 'cat-body-fat', name: '体脂仪', code: 'D02A03', level: 2, parentId: TOP.body, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.huawei, model: 'HW-FAT-BH', createTime: '2026-05-11 11:10:00' },
],
},
{
id: TOP.wearable, name: '智能穿戴设备', code: 'D03', level: 1, sortOrder: 3,
remark: '业务数据过平台中转', createTime: '2026-05-10 09:30:00',
children: [
{ id: 'cat-wearable-hw5', name: '华为-watch5', code: 'D03A01', level: 2, parentId: TOP.wearable, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.huawei, model: 'Watch-5', createTime: '2026-05-11 11:30:00' },
{ id: 'cat-wearable-hw4', name: '华为-watch4', code: 'D03A02', level: 2, parentId: TOP.wearable, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.huawei, model: 'Watch-4', createTime: '2026-05-11 11:35:00' },
{ id: 'cat-wearable-ljf01', name: '罗杰夫-LJF01', code: 'D03A03', level: 2, parentId: TOP.wearable, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.luojiefu, model: 'LJF-01', createTime: '2026-05-11 11:40:00' },
{ id: 'cat-wearable-hw5', name: '华为-watch5', code: 'D03A01', level: 2, parentId: TOP.wearable, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.huawei, model: 'Watch-5', createTime: '2026-05-11 11:30:00' },
{ id: 'cat-wearable-hw4', name: '华为-watch4', code: 'D03A02', level: 2, parentId: TOP.wearable, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.huawei, model: 'Watch-4', createTime: '2026-05-11 11:35:00' },
{ id: 'cat-wearable-ljf01', name: '罗杰夫-LJF01', code: 'D03A03', level: 2, parentId: TOP.wearable, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.luojiefu, model: 'LJF-01', createTime: '2026-05-11 11:40:00' },
],
},
{
id: TOP.fitness, name: '健身器材设备', code: 'D04', level: 1, sortOrder: 4,
remark: '业务数据过平台中转', createTime: '2026-05-10 09:35:00',
children: [
{ id: 'cat-fitness-treadmill', name: '跑步机', code: 'D04A01', level: 2, parentId: TOP.fitness, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yixiong, model: 'YX-TM-Pro', createTime: '2026-05-11 12:00:00' },
{ id: 'cat-fitness-elliptical', name: '椭圆机', code: 'D04A02', level: 2, parentId: TOP.fitness, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yixiong, model: 'YX-EL-X1', createTime: '2026-05-11 12:05:00' },
{ id: 'cat-fitness-bike', name: '健身车', code: 'D04A03', level: 2, parentId: TOP.fitness, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yixiong, model: 'YX-BK-M3', createTime: '2026-05-11 12:10:00' },
{ id: 'cat-fitness-treadmill', name: '跑步机', code: 'D04A01', level: 2, parentId: TOP.fitness, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'wifi', manufacturer: MANUFACTURER.yixiong, model: 'YX-TM-Pro', createTime: '2026-05-11 12:00:00' },
{ id: 'cat-fitness-elliptical', name: '椭圆机', code: 'D04A02', level: 2, parentId: TOP.fitness, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'wifi', manufacturer: MANUFACTURER.yixiong, model: 'YX-EL-X1', createTime: '2026-05-11 12:05:00' },
{ id: 'cat-fitness-bike', name: '健身车', code: 'D04A03', level: 2, parentId: TOP.fitness, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'wifi', manufacturer: MANUFACTURER.yixiong, model: 'YX-BK-M3', createTime: '2026-05-11 12:10:00' },
],
},
{
id: TOP.monitor, name: '健康监测设备', code: 'D05', level: 1, sortOrder: 5,
remark: '业务数据过平台中转', createTime: '2026-05-10 09:40:00',
children: [
{ id: 'cat-monitor-sugar', name: '血糖监测仪', code: 'D05A01', level: 2, parentId: TOP.monitor, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yuyue, model: 'YY-BG-530', createTime: '2026-05-11 12:30:00' },
{ id: 'cat-monitor-pressure', name: '高血压治疗仪', code: 'D05A02', level: 2, parentId: TOP.monitor, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.yuyue, model: 'YY-BP-680', createTime: '2026-05-11 12:35:00' },
{ id: 'cat-monitor-all-in-one', name: '智能健康一体机', code: 'D05A03', level: 2, parentId: TOP.monitor, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, manufacturer: MANUFACTURER.luojiefu, model: 'LJF-HM-Z1', createTime: '2026-05-11 12:40:00' },
{ id: 'cat-monitor-sugar', name: '血糖监测仪', code: 'D05A01', level: 2, parentId: TOP.monitor, sortOrder: 1, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'sim', manufacturer: MANUFACTURER.yuyue, model: 'YY-BG-530', createTime: '2026-05-11 12:30:00' },
{ id: 'cat-monitor-pressure', name: '高血压治疗仪', code: 'D05A02', level: 2, parentId: TOP.monitor, sortOrder: 2, isIot: true, dataTransit: true, dataReport: true, faceRecognition: false, networkMode: 'bluetooth', manufacturer: MANUFACTURER.yuyue, model: 'YY-BP-680', createTime: '2026-05-11 12:35:00' },
{ id: 'cat-monitor-all-in-one', name: '智能健康一体机', code: 'D05A03', level: 2, parentId: TOP.monitor, sortOrder: 3, isIot: true, dataTransit: true, dataReport: true, faceRecognition: true, networkMode: 'wifi', manufacturer: MANUFACTURER.luojiefu, model: 'LJF-HM-Z1', createTime: '2026-05-11 12:40:00' },
],
},
]
@@ -235,6 +241,11 @@ const handleAdd = (ctx: MockContext): ApiResponse<null> => {
newNode.isIot = payload.isIot ?? false
newNode.dataTransit = payload.dataTransit ?? false
newNode.dataReport = payload.dataReport ?? false
newNode.faceRecognition = payload.faceRecognition ?? false
/** 仅 IOT 设备携带配网方式 */
if (newNode.isIot) {
newNode.networkMode = payload.networkMode
}
/** 叶子节点(新建时必然无子)携带厂商/型号 */
newNode.manufacturer = payload.manufacturer
newNode.model = payload.model
@@ -264,6 +275,13 @@ const handleUpdate = (ctx: MockContext): ApiResponse<null> => {
node.isIot = payload.isIot ?? false
node.dataTransit = payload.dataTransit ?? false
node.dataReport = payload.dataReport ?? false
node.faceRecognition = payload.faceRecognition ?? false
/** 仅 IOT 设备更新配网方式;非 IOT 时清空历史残留值 */
if (node.isIot) {
node.networkMode = payload.networkMode
} else {
delete node.networkMode
}
/** 叶子节点(无子)才更新厂商/型号 */
if (!node.children?.length) {
node.manufacturer = payload.manufacturer
+152 -26
View File
@@ -1,5 +1,6 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
import { pushDevicesFromInbound } from './manage'
interface PageParams {
pageNum?: number
@@ -10,16 +11,22 @@ interface PageParams {
status?: string
inboundType?: string
supplierId?: string
categoryId?: string
warehouseAreaId?: string
[key: string]: unknown
}
interface InboundDetailItem {
deviceCode: string
serialNumber: string
}
interface InboundRecord {
id: string
inboundCode: string
deviceCode: string
deviceName: string
modelName: string
batchNo: string
categoryId: string
categoryName: string
batchNo?: string
supplierId: string
supplierName: string
quantity: number
@@ -27,9 +34,12 @@ interface InboundRecord {
inboundTime: string
operator: string
inboundType: 'purchase' | 'return' | 'transfer'
status: 'pending' | 'completed' | 'cancelled'
storageLocation?: string
status: 'pending' | 'completed' | 'audited' | 'cancelled'
warehouseAreaId?: string
warehouseAreaName?: string
remark?: string
/** 设备明细(已入库待审核时录入) */
items?: InboundDetailItem[]
}
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
@@ -63,15 +73,68 @@ const nowStr = (): string => {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/**
* 设备类别末级 ID → 名称映射(与 statistics mock / category mock 同源)
* 用于 add/update 时按 categoryId 反查 categoryName(模拟后端 join 分类表)
*/
const CATEGORY_LEAF_NAME: Record<string, string> = {
'cat-catering-plate': '吐盘机',
'cat-catering-cabinet': '智能货柜',
'cat-catering-nutri-scale': '营养秤',
'cat-catering-ratio-scale': '配比秤',
'cat-catering-checkout': '结算台',
'cat-catering-inbound-scale': '入库秤',
'cat-catering-band-screen': '带鱼屏',
'cat-catering-baifei': '波菲炉',
'cat-body-all-in-one': '一体机',
'cat-body-bt-scale': '蓝牙秤',
'cat-body-fat': '体脂仪',
'cat-wearable-hw5': '华为-watch5',
'cat-wearable-hw4': '华为-watch4',
'cat-wearable-ljf01': '罗杰夫-LJF01',
'cat-fitness-treadmill': '跑步机',
'cat-fitness-elliptical': '椭圆机',
'cat-fitness-bike': '健身车',
'cat-monitor-sugar': '血糖监测仪',
'cat-monitor-pressure': '高血压治疗仪',
'cat-monitor-all-in-one': '智能健康一体机',
}
/**
* 仓库区域末级节点 ID → 祖先链名称映射(与 warehouse mock 同源)
* 用于 add/update 时按 warehouseAreaId 反查 warehouseAreaName
*/
const WAREHOUSE_AREA_NAME: Record<string, string> = {
'wh-floor1-large-a-1': '1楼大型A号货架-1号储位',
'wh-floor1-large-a-2': '1楼大型A号货架-2号储位',
'wh-floor1-large-b-1': '1楼大型B号货架-1号储位',
'wh-floor1-small-shelf-1': '1楼小型备品货架-1号储位',
'wh-floor1-small-shelf-2': '1楼小型备品货架-2号储位',
'wh-floor2-check-1': '2楼待检区-临时储位',
}
/**
* 入库单 mock 数据集
* - 删除 deviceCode/deviceName/modelName/storageLocation 字段
* - 用 categoryId+categoryName 表达设备品类(末级分类)
* - 用 warehouseAreaId+warehouseAreaName 表达仓库区域(末级节点)
* - 入库类型含 return(回库),不再单独维护回库入口
*/
const inboundList: InboundRecord[] = [
{ id: 'inb-001', inboundCode: 'INB-20260601-001', deviceCode: 'CAB-A1-0003', deviceName: '智能货柜A1', modelName: '智能货柜', batchNo: 'B2026-001', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 5, purchasePrice: 4500, inboundTime: '2026-06-01 09:30:00', operator: '张三', inboundType: 'purchase', status: 'completed', storageLocation: 'A区-01', remark: '首次采购' },
{ id: 'inb-002', inboundCode: 'INB-20260605-002', deviceCode: 'WAT-W1-0002', deviceName: '鹏升手表W1', modelName: '华为-watch5', batchNo: 'B2026-002', supplierId: 'sup-002', supplierName: '鹏升电子', quantity: 10, purchasePrice: 880, inboundTime: '2026-06-05 14:20:00', operator: '李四', inboundType: 'purchase', status: 'completed', storageLocation: 'B区-02' },
{ id: 'inb-003', inboundCode: 'INB-20260610-003', deviceCode: 'CHK-M1-0002', deviceName: '华盛体检机', modelName: '智能健康一体机', batchNo: 'B2026-003', supplierId: 'sup-003', supplierName: '华盛医疗', quantity: 2, purchasePrice: 28000, inboundTime: '2026-06-10 11:00:00', operator: '王五', inboundType: 'purchase', status: 'pending', storageLocation: 'C区-03', remark: '等待验收' },
{ id: 'inb-004', inboundCode: 'INB-20260615-004', deviceCode: 'SCL-X1-0002', deviceName: '入库秤X1', modelName: '入库秤', batchNo: 'B2026-004', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 3, purchasePrice: 1200, inboundTime: '2026-06-15 16:45:00', operator: '赵六', inboundType: 'transfer', status: 'completed', storageLocation: 'A区-02', remark: '从杭州仓调拨' },
{ id: 'inb-005', inboundCode: 'INB-20260618-005', deviceCode: 'FAT-INBODY-0002', deviceName: 'InBody体脂秤', modelName: '体脂仪', batchNo: 'B2026-005', supplierId: 'sup-004', supplierName: 'InBody中国', quantity: 1, purchasePrice: 6500, inboundTime: '2026-06-18 10:15:00', operator: '孙七', inboundType: 'return', status: 'cancelled', storageLocation: 'D区-01', remark: '质量问题已退回' },
{ id: 'inb-006', inboundCode: 'INB-20260620-006', deviceCode: 'FIT-S1-0002', deviceName: '顿米健身车', modelName: '健身车', batchNo: 'B2026-006', supplierId: 'sup-005', supplierName: '顿米科技', quantity: 4, purchasePrice: 3200, inboundTime: '2026-06-20 09:00:00', operator: '张三', inboundType: 'purchase', status: 'completed', storageLocation: 'E区-01' },
{ id: 'inb-007', inboundCode: 'INB-20260622-007', deviceCode: 'SCR-P1-0002', deviceName: '大屏P1', modelName: '带鱼屏', batchNo: 'B2026-007', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 2, purchasePrice: 5800, inboundTime: '2026-06-22 14:30:00', operator: '李四', inboundType: 'purchase', status: 'pending', storageLocation: 'A区-03' },
{ id: 'inb-008', inboundCode: 'INB-20260625-008', deviceCode: 'SCL-W-0002', deviceName: '体重秤', modelName: '蓝牙秤', batchNo: 'B2026-008', supplierId: 'sup-002', supplierName: '鹏升电子', quantity: 6, purchasePrice: 480, inboundTime: '2026-06-25 11:45:00', operator: '王五', inboundType: 'purchase', status: 'completed', storageLocation: 'B区-03' },
{ id: 'inb-001', inboundCode: 'INB-20260601-001', categoryId: 'cat-catering-cabinet', categoryName: '智能货柜', batchNo: 'B2026-001', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 5, purchasePrice: 4500, inboundTime: '2026-06-01 09:30:00', operator: '张三', inboundType: 'purchase', status: 'completed', warehouseAreaId: 'wh-floor1-large-a-2', warehouseAreaName: '1楼大型A号货架-2号储位', remark: '首次采购', items: [
{ deviceCode: 'CAB-2026-001', serialNumber: 'SN-CAB-001' },
{ deviceCode: 'CAB-2026-002', serialNumber: 'SN-CAB-002' },
{ deviceCode: 'CAB-2026-003', serialNumber: 'SN-CAB-003' },
{ deviceCode: 'CAB-2026-004', serialNumber: 'SN-CAB-004' },
{ deviceCode: 'CAB-2026-005', serialNumber: 'SN-CAB-005' },
] },
{ id: 'inb-002', inboundCode: 'INB-20260605-002', categoryId: 'cat-wearable-hw5', categoryName: '华为-watch5', batchNo: 'B2026-002', supplierId: 'sup-002', supplierName: '鹏升电子', quantity: 10, purchasePrice: 880, inboundTime: '2026-06-05 14:20:00', operator: '李四', inboundType: 'purchase', status: 'completed', warehouseAreaId: 'wh-floor1-small-shelf-1', warehouseAreaName: '1楼小型备品货架-1号储位' },
{ id: 'inb-003', inboundCode: 'INB-20260610-003', categoryId: 'cat-monitor-all-in-one', categoryName: '智能健康一体机', batchNo: 'B2026-003', supplierId: 'sup-003', supplierName: '华盛医疗', quantity: 2, purchasePrice: 28000, inboundTime: '2026-06-10 11:00:00', operator: '王五', inboundType: 'purchase', status: 'pending', warehouseAreaId: 'wh-floor1-large-b-1', warehouseAreaName: '1楼大型B号货架-1号储位', remark: '等待验收' },
{ id: 'inb-004', inboundCode: 'INB-20260615-004', categoryId: 'cat-catering-inbound-scale', categoryName: '入库秤', batchNo: 'B2026-004', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 3, purchasePrice: 1200, inboundTime: '2026-06-15 16:45:00', operator: '赵六', inboundType: 'transfer', status: 'completed', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位', remark: '从杭州仓调拨' },
{ id: 'inb-005', inboundCode: 'INB-20260618-005', categoryId: 'cat-body-fat', categoryName: '体脂仪', batchNo: 'B2026-005', supplierId: 'sup-004', supplierName: 'InBody中国', quantity: 1, purchasePrice: 6500, inboundTime: '2026-06-18 10:15:00', operator: '孙七', inboundType: 'return', status: 'cancelled', warehouseAreaId: 'wh-floor2-check-1', warehouseAreaName: '2楼待检区-临时储位', remark: '客户退回,待检测' },
{ id: 'inb-006', inboundCode: 'INB-20260620-006', categoryId: 'cat-fitness-bike', categoryName: '健身车', batchNo: 'B2026-006', supplierId: 'sup-005', supplierName: '顿米科技', quantity: 4, purchasePrice: 3200, inboundTime: '2026-06-20 09:00:00', operator: '张三', inboundType: 'purchase', status: 'completed', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位' },
{ id: 'inb-007', inboundCode: 'INB-20260622-007', categoryId: 'cat-catering-band-screen', categoryName: '带鱼屏', batchNo: 'B2026-007', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', quantity: 2, purchasePrice: 5800, inboundTime: '2026-06-22 14:30:00', operator: '李四', inboundType: 'purchase', status: 'pending', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位' },
{ id: 'inb-008', inboundCode: 'INB-20260625-008', categoryId: 'cat-body-bt-scale', categoryName: '蓝牙秤', batchNo: 'B2026-008', supplierId: 'sup-002', supplierName: '鹏升电子', quantity: 6, purchasePrice: 480, inboundTime: '2026-06-25 11:45:00', operator: '王五', inboundType: 'purchase', status: 'completed', warehouseAreaId: 'wh-floor1-small-shelf-2', warehouseAreaName: '1楼小型备品货架-2号储位' },
]
const listPage = (ctx: MockContext): ApiResponse<InboundRecord[]> => {
@@ -80,7 +143,9 @@ const listPage = (ctx: MockContext): ApiResponse<InboundRecord[]> => {
if (params.status && item.status !== params.status) return false
if (params.inboundType && item.inboundType !== params.inboundType) return false
if (params.supplierId && item.supplierId !== params.supplierId) return false
if (params.keyword && !includes(item.inboundCode, params.keyword) && !includes(item.deviceCode, params.keyword) && !includes(item.deviceName, params.keyword)) return false
if (params.categoryId && item.categoryId !== params.categoryId) return false
if (params.warehouseAreaId && item.warehouseAreaId !== params.warehouseAreaId) return false
if (params.keyword && !includes(item.inboundCode, params.keyword)) return false
return true
})
if (params.column === 'inboundTime' && params.order) {
@@ -114,25 +179,56 @@ const genInboundCode = (): string => {
return `INB-${ymd}-${seq}`
}
/** 按 categoryId 反查末级分类名(模拟后端 join) */
const resolveCategoryName = (categoryId?: string): string => {
if (!categoryId) return ''
return CATEGORY_LEAF_NAME[categoryId] ?? ''
}
/** 按 warehouseAreaId 反查区域祖先链名(模拟后端 join) */
const resolveWarehouseAreaName = (warehouseAreaId?: string): string => {
if (!warehouseAreaId) return ''
return WAREHOUSE_AREA_NAME[warehouseAreaId] ?? ''
}
/** 解析 items 数组参数 */
const parseItems = (raw: unknown): InboundDetailItem[] => {
if (!Array.isArray(raw)) return []
return raw
.map((entry) => {
if (!entry || typeof entry !== 'object') return null
const obj = entry as { deviceCode?: unknown; serialNumber?: unknown }
return {
deviceCode: String(obj.deviceCode ?? '').trim(),
serialNumber: String(obj.serialNumber ?? '').trim(),
}
})
.filter((item): item is InboundDetailItem => item !== null && item.deviceCode.length > 0)
}
/** 新增 */
const addRecord = (params: PageParams): ApiResponse<null> => {
const categoryId = String(params.categoryId ?? '')
const warehouseAreaId = params.warehouseAreaId ? String(params.warehouseAreaId) : undefined
const items = parseItems(params.items)
const record: InboundRecord = {
id: `inb-${Date.now().toString(36)}`,
inboundCode: String(params.inboundCode ?? genInboundCode()),
deviceCode: String(params.deviceCode ?? ''),
deviceName: String(params.deviceName ?? ''),
modelName: String(params.modelName ?? ''),
batchNo: String(params.batchNo ?? ''),
categoryId,
categoryName: resolveCategoryName(categoryId),
batchNo: params.batchNo ? String(params.batchNo) : undefined,
supplierId: String(params.supplierId ?? ''),
supplierName: String(params.supplierName ?? ''),
quantity: Number(params.quantity ?? 1),
quantity: items.length || Number(params.quantity ?? 1),
purchasePrice: Number(params.purchasePrice ?? 0),
inboundTime: nowStr(),
operator: '当前用户',
inboundType: (params.inboundType as InboundRecord['inboundType']) ?? 'purchase',
status: 'completed',
storageLocation: params.storageLocation ? String(params.storageLocation) : undefined,
warehouseAreaId,
warehouseAreaName: resolveWarehouseAreaName(warehouseAreaId),
remark: params.remark ? String(params.remark) : undefined,
items,
}
inboundList.unshift(record)
return ok('新增成功')
@@ -143,25 +239,55 @@ const updateRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = inboundList.find((item) => item.id === id)
if (!record) return ok('入库单不存在')
if (params.deviceCode !== undefined) record.deviceCode = String(params.deviceCode)
if (params.deviceName !== undefined) record.deviceName = String(params.deviceName)
if (params.modelName !== undefined) record.modelName = String(params.modelName)
if (params.batchNo !== undefined) record.batchNo = String(params.batchNo)
if (params.categoryId !== undefined) {
record.categoryId = String(params.categoryId)
record.categoryName = resolveCategoryName(record.categoryId)
}
if (params.batchNo !== undefined) record.batchNo = params.batchNo ? String(params.batchNo) : undefined
if (params.supplierId !== undefined) record.supplierId = String(params.supplierId)
if (params.supplierName !== undefined) record.supplierName = String(params.supplierName)
if (params.quantity !== undefined) record.quantity = Number(params.quantity)
if (params.purchasePrice !== undefined) record.purchasePrice = Number(params.purchasePrice)
if (params.inboundType !== undefined) record.inboundType = params.inboundType as InboundRecord['inboundType']
if (params.storageLocation !== undefined) record.storageLocation = String(params.storageLocation)
if (params.warehouseAreaId !== undefined) {
record.warehouseAreaId = params.warehouseAreaId ? String(params.warehouseAreaId) : undefined
record.warehouseAreaName = resolveWarehouseAreaName(record.warehouseAreaId)
}
if (params.remark !== undefined) record.remark = params.remark ? String(params.remark) : undefined
if (params.items !== undefined) {
record.items = parseItems(params.items)
record.quantity = record.items.length || record.quantity
}
return ok('更新成功')
}
/**
* 审核:将入库单 status 从 completed → audited,并按 items 跨 handler 写设备台账
* - 仅 completed 状态可审核
* - 调用 manage mock 的 pushDevicesFromInbound 生成 N 条台账
*/
const auditRecord = (params: PageParams): ApiResponse<{ pushed: number }> => {
const id = String(params.id ?? '')
const record = inboundList.find((item) => item.id === id)
if (!record) return ok('入库单不存在')
if (record.status !== 'completed') return ok('仅已入库状态可审核')
if (!record.items?.length) return ok('设备明细为空,无法审核')
const pushed = pushDevicesFromInbound({
inboundCode: record.inboundCode,
categoryId: record.categoryId,
categoryName: record.categoryName,
items: record.items,
})
record.status = 'audited'
return ok(`审核成功,已生成 ${pushed} 条设备台账`, { pushed })
}
registerMocks([
{ method: 'POST', url: '/admin/device/stock/inbound/page', handler: listPage },
{ method: 'POST', url: '/admin/device/stock/inbound/info', handler: (ctx) => infoById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/add', handler: (ctx) => addRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/update', handler: (ctx) => updateRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/audit', handler: (ctx) => auditRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inbound/export', handler: () => ok('导出任务已提交') },
])
@@ -7,33 +7,28 @@ interface PageParams {
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
deviceType?: string
stockStatus?: string
categoryId?: string
warehouseAreaId?: string
healthLevel?: string
id?: string
returnQty?: number
storageLocation?: string
remark?: string
[key: string]: unknown
}
type StockStatus = 'instock' | 'inbound' | 'shipped' | 'returned'
type StockHealthLevel = 'sufficient' | 'warning' | 'out_of_stock'
interface StockRecord {
id: string
modelCode: string
modelName: string
deviceType: string
typeName: string
categoryId: string
categoryName: string
batchNo: string
supplierId: string
supplierName: string
purchasePrice: number
inboundTime: string
storageLocation: string
stockStatus: StockStatus
warehouseAreaId?: string
warehouseAreaName?: string
stockQty: number
inTransitQty: number
lockedQty: number
availableQty: number
shippedQty: number
healthLevel: StockHealthLevel
updateTime: string
}
@@ -50,29 +45,62 @@ const paginate = <T extends StockRecord>(list: T[], params: PageParams): ApiResp
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const nowStr = (): string => {
const d = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
/** 设备类别末级 ID → 名称映射(与 statistics/category mock 同源) */
const CATEGORY_LEAF_NAME: Record<string, string> = {
'cat-catering-plate': '吐盘机',
'cat-catering-cabinet': '智能货柜',
'cat-catering-nutri-scale': '营养秤',
'cat-catering-ratio-scale': '配比秤',
'cat-catering-checkout': '结算台',
'cat-catering-inbound-scale': '入库秤',
'cat-catering-band-screen': '带鱼屏',
'cat-catering-baifei': '波菲炉',
'cat-body-all-in-one': '一体机',
'cat-body-bt-scale': '蓝牙秤',
'cat-body-fat': '体脂仪',
'cat-wearable-hw5': '华为-watch5',
'cat-wearable-hw4': '华为-watch4',
'cat-wearable-ljf01': '罗杰夫-LJF01',
'cat-fitness-treadmill': '跑步机',
'cat-fitness-elliptical': '椭圆机',
'cat-fitness-bike': '健身车',
'cat-monitor-sugar': '血糖监测仪',
'cat-monitor-pressure': '高血压治疗仪',
'cat-monitor-all-in-one': '智能健康一体机',
}
const stockList: StockRecord[] = [
{ id: 'stk-001', modelCode: 'CABINET-A1', modelName: '智能货柜A1', deviceType: 'cabinet', typeName: '餐盘柜', batchNo: 'B2026-001', supplierName: '深圳智能设备供应商', purchasePrice: 8500, inboundTime: '2026-06-22 09:30:00', storageLocation: 'A区-01', stockStatus: 'instock', stockQty: 5, inTransitQty: 2, availableQty: 3, shippedQty: 2, updateTime: '2026-06-25 09:30:00' },
{ id: 'stk-002', modelCode: 'SCREEN-P1', modelName: '大屏P1', deviceType: 'screen', typeName: '大屏', batchNo: 'B2026-002', supplierName: '深圳智能设备供应商', purchasePrice: 12000, inboundTime: '2026-06-22 10:00:00', storageLocation: 'A区-02', stockStatus: 'instock', stockQty: 3, inTransitQty: 0, availableQty: 3, shippedQty: 1, updateTime: '2026-06-25 10:00:00' },
{ id: 'stk-003', modelCode: 'WATCH-W1-PEN', modelName: '鹏升手表W1', deviceType: 'watch', typeName: '手表', batchNo: 'B2026-003', supplierName: '鹏升电子', purchasePrice: 980, inboundTime: '2026-06-22 11:15:00', storageLocation: 'B区-01', stockStatus: 'instock', stockQty: 12, inTransitQty: 5, availableQty: 7, shippedQty: 6, updateTime: '2026-06-25 11:15:00' },
{ id: 'stk-004', modelCode: 'SCALE-X1', modelName: '入库秤X1', deviceType: 'scale-inbound', typeName: '入库秤', batchNo: 'B2026-004', supplierName: '深圳智能设备供应商', purchasePrice: 1800, inboundTime: '2026-06-23 13:45:00', storageLocation: 'B区-02', stockStatus: 'instock', stockQty: 2, inTransitQty: 0, availableQty: 2, shippedQty: 1, updateTime: '2026-06-25 13:45:00' },
{ id: 'stk-005', modelCode: 'SCALE-Y2', modelName: '配比秤Y2', deviceType: 'scale-ratio', typeName: '配比秤', batchNo: 'B2026-005', supplierName: '深圳智能设备供应商', purchasePrice: 2200, inboundTime: '2026-06-23 14:30:00', storageLocation: 'B区-03', stockStatus: 'inbound', stockQty: 1, inTransitQty: 1, availableQty: 1, shippedQty: 0, updateTime: '2026-06-25 14:30:00' },
{ id: 'stk-006', modelCode: 'FIT-S1-DUNMI', modelName: '顿米健身车S1', deviceType: 'fitness', typeName: '健身器材', batchNo: 'B2026-006', supplierName: '顿米科技', purchasePrice: 5600, inboundTime: '2026-06-23 15:00:00', storageLocation: 'C区-01', stockStatus: 'instock', stockQty: 4, inTransitQty: 0, availableQty: 4, shippedQty: 2, updateTime: '2026-06-25 15:00:00' },
{ id: 'stk-007', modelCode: 'CHK-M1-HUASHENG', modelName: '华盛体检机M1', deviceType: 'checkup', typeName: '体检机', batchNo: 'B2026-007', supplierName: '华盛医疗', purchasePrice: 32000, inboundTime: '2026-06-24 16:20:00', storageLocation: 'C区-02', stockStatus: 'inbound', stockQty: 0, inTransitQty: 1, availableQty: 0, shippedQty: 0, updateTime: '2026-06-25 16:20:00' },
{ id: 'stk-008', modelCode: 'FAT-INBODY', modelName: 'InBody体脂秤', deviceType: 'scale-fat', typeName: '体脂秤', batchNo: 'B2026-008', supplierName: 'InBody中国', purchasePrice: 6800, inboundTime: '2026-06-24 17:00:00', storageLocation: 'B区-04', stockStatus: 'shipped', stockQty: 0, inTransitQty: 0, availableQty: 0, shippedQty: 2, updateTime: '2026-06-25 17:00:00' },
/** 仓库区域末级节点 ID → 祖先链名称映射(与 warehouse mock 同源) */
const WAREHOUSE_AREA_NAME: Record<string, string> = {
'wh-floor1-large-a-1': '1楼大型A号货架-1号储位',
'wh-floor1-large-a-2': '1楼大型A号货架-2号储位',
'wh-floor1-large-b-1': '1楼大型B号货架-1号储位',
'wh-floor1-small-shelf-1': '1楼小型备品货架-1号储位',
'wh-floor1-small-shelf-2': '1楼小型备品货架-2号储位',
'wh-floor2-check-1': '2楼待检区-临时储位',
}
/**
* 在库库存数据集(导出供 warehouse 等模块跨 handler 反查状态使用)
* 健康度规则(mock 内自洽):stockQty = 0 → out_of_stocklockedQty >= stockQty - 1 且 stockQty <= 2 → warning;否则 sufficient
*/
export const stockList: StockRecord[] = [
{ id: 'stk-001', categoryId: 'cat-catering-cabinet', categoryName: '智能货柜', batchNo: 'B2026-001', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位', stockQty: 5, lockedQty: 2, availableQty: 3, healthLevel: 'sufficient', updateTime: '2026-06-25 09:30:00' },
{ id: 'stk-002', categoryId: 'cat-catering-band-screen', categoryName: '带鱼屏', batchNo: 'B2026-002', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', warehouseAreaId: 'wh-floor1-large-a-2', warehouseAreaName: '1楼大型A号货架-2号储位', stockQty: 3, lockedQty: 0, availableQty: 3, healthLevel: 'sufficient', updateTime: '2026-06-25 10:00:00' },
{ id: 'stk-003', categoryId: 'cat-wearable-hw5', categoryName: '华为-watch5', batchNo: 'B2026-003', supplierId: 'sup-002', supplierName: '鹏升电子', warehouseAreaId: 'wh-floor1-small-shelf-1', warehouseAreaName: '1楼小型备品货架-1号储位', stockQty: 12, lockedQty: 5, availableQty: 7, healthLevel: 'sufficient', updateTime: '2026-06-25 11:15:00' },
{ id: 'stk-004', categoryId: 'cat-catering-inbound-scale', categoryName: '入库秤', batchNo: 'B2026-004', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', warehouseAreaId: 'wh-floor1-small-shelf-2', warehouseAreaName: '1楼小型备品货架-2号储位', stockQty: 2, lockedQty: 1, availableQty: 1, healthLevel: 'warning', updateTime: '2026-06-25 13:45:00' },
{ id: 'stk-005', categoryId: 'cat-catering-ratio-scale', categoryName: '配比秤', batchNo: 'B2026-005', supplierId: 'sup-001', supplierName: '深圳智能设备供应商', warehouseAreaId: 'wh-floor1-large-b-1', warehouseAreaName: '1楼大型B号货架-1号储位', stockQty: 1, lockedQty: 0, availableQty: 1, healthLevel: 'warning', updateTime: '2026-06-25 14:30:00' },
{ id: 'stk-006', categoryId: 'cat-fitness-bike', categoryName: '健身车', batchNo: 'B2026-006', supplierId: 'sup-003', supplierName: '顿米科技', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位', stockQty: 4, lockedQty: 0, availableQty: 4, healthLevel: 'sufficient', updateTime: '2026-06-25 15:00:00' },
{ id: 'stk-007', categoryId: 'cat-monitor-all-in-one', categoryName: '智能健康一体机', batchNo: 'B2026-007', supplierId: 'sup-004', supplierName: '华盛医疗', warehouseAreaId: 'wh-floor2-check-1', warehouseAreaName: '2楼待检区-临时储位', stockQty: 0, lockedQty: 0, availableQty: 0, healthLevel: 'out_of_stock', updateTime: '2026-06-25 16:20:00' },
{ id: 'stk-008', categoryId: 'cat-body-fat', categoryName: '体脂仪', batchNo: 'B2026-008', supplierId: 'sup-005', supplierName: 'InBody中国', warehouseAreaId: 'wh-floor1-small-shelf-2', warehouseAreaName: '1楼小型备品货架-2号储位', stockQty: 0, lockedQty: 0, availableQty: 0, healthLevel: 'out_of_stock', updateTime: '2026-06-25 17:00:00' },
]
const listPage = (ctx: MockContext): ApiResponse<StockRecord[]> => {
const params = getParams(ctx.params)
const filtered = stockList.filter((item) => {
if (params.deviceType && item.deviceType !== params.deviceType) return false
if (params.stockStatus && item.stockStatus !== params.stockStatus) return false
if (params.keyword && !includes(item.modelCode, params.keyword) && !includes(item.modelName, params.keyword) && !includes(item.batchNo, params.keyword)) return false
if (params.categoryId && item.categoryId !== params.categoryId) return false
if (params.warehouseAreaId && item.warehouseAreaId !== params.warehouseAreaId) return false
if (params.healthLevel && item.healthLevel !== params.healthLevel) return false
if (params.keyword && !includes(item.batchNo, params.keyword) && !includes(item.supplierName, params.keyword)) return false
return true
})
if (params.column && params.order) {
@@ -95,24 +123,8 @@ const infoById = (params: PageParams): ApiResponse<StockRecord | null> => {
return ok('查询成功', record ?? null)
}
/** 回库登记:状态变更 + 数量调整 */
const returnRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = stockList.find((item) => item.id === id)
if (!record) return ok('库存记录不存在')
const returnQty = Number(params.returnQty ?? 0)
record.stockQty += returnQty
record.availableQty += returnQty
record.shippedQty = Math.max(0, record.shippedQty - returnQty)
record.stockStatus = 'returned'
if (params.storageLocation) record.storageLocation = String(params.storageLocation)
record.updateTime = nowStr()
return ok('回库登记成功')
}
registerMocks([
{ method: 'POST', url: '/admin/device/stock/instock/page', handler: listPage },
{ method: 'POST', url: '/admin/device/stock/instock/info', handler: (ctx) => infoById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/instock/return', handler: (ctx) => returnRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/instock/export', handler: () => ok('导出任务已提交') },
])
+460 -182
View File
@@ -2,56 +2,76 @@ import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext } from '@axios'
type CategoryTab = 'catering' | 'body' | 'wearable' | 'fitness' | 'monitor'
type RunStatus = 'online' | 'offline' | 'repairing' | 'scrapped'
type DeployStatus = 'in_stock' | 'deployed'
type NetworkMode = 'wifi' | 'bluetooth' | 'sim' | 'ethernet' | 'other_iot'
interface CategoryMeta {
/** 二级分类名称 */
name: string
/** 顶级分类 Tab */
category: CategoryTab
/** 是否 IOT 设备 */
isIot: boolean
/** 是否启用数据分发 */
dataTransit: boolean
/** 是否启用数据上报 */
dataReport: boolean
/** 配网方式 */
networkMode: NetworkMode
/** 厂商 */
manufacturer: string
/** 型号 */
model: string
}
interface DeviceItem {
id: string
deviceCode: string
name: string
modelName: string
serialNumber: string
deviceCategory: string
category: CategoryTab
tenantName: string
customerId: string
customerName: string
siteId: string
siteName: string
deployStatus: DeployStatus
runStatus: RunStatus
softwareVersion: string
onlineTime: string
deployTime: string
lastHeartbeat: string
arcLicense: string
simIccid: string
// 类别元数据(冗余在设备记录里,由后端按 deviceCategory 回填)
isIot: boolean
[key: string]: string | boolean | undefined
dataTransit: boolean
dataReport: boolean
networkMode: NetworkMode
manufacturer: string
model: string
/** 入库单号(审核入库单时写入,便于按入库单反查台账) */
inboundCode?: string
}
interface RunLogItem {
interface CustomerOption {
id: string
eventType: string
content: string
occurTime: string
name: string
}
interface RepairItem {
interface SiteOption {
id: string
workOrderNo: string
faultType: string
repairMethod: string
repairer: string
workHours: number
finishTime: string
}
interface DetailInfo extends DeviceItem {
runLogs?: RunLogItem[]
repairs?: RepairItem[]
name: string
customerId: string
}
interface ListParams {
keyword?: string
runStatus?: RunStatus
deployStatus?: DeployStatus
tenantName?: string
siteName?: string
deviceCategory?: string
customerId?: string
siteId?: string
category: CategoryTab
/** 入库单号(按入库单反查设备台账) */
inboundCode?: string
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
@@ -60,198 +80,406 @@ const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
data,
})
/** 依据户 / 场所自动判定投用状态 */
const computeDeployStatus = (tenantName: string, siteName: string): DeployStatus => {
if (!tenantName && !siteName) return 'in_stock'
/** 依据户 / 场所自动判定投用状态 */
const computeDeployStatus = (customerId: string, siteId: string): DeployStatus => {
if (!customerId && !siteId) return 'in_stock'
return 'deployed'
}
/**
* 设备类别元数据(与设备分类树 mock 数据对齐)
* - 用于新增/编辑时按所选 deviceCategory 冗余回填到设备记录
* - 用于设备类别下拉接口返回
*/
const CATEGORY_META: Record<CategoryTab, CategoryMeta[]> = {
catering: [
{ name: '吐盘机', category: 'catering', isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '商合', model: 'SH-TP-200' },
{ name: '智能货柜', category: 'catering', isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '华为', model: 'HW-CAB-100L' },
{ name: '营养秤', category: 'catering', isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-NS-5KG' },
{ name: '配比秤', category: 'catering', isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-RS-15KG' },
{ name: '结算台', category: 'catering', isIot: true, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '华为', model: 'HW-CK-21IN' },
{ name: '入库秤', category: 'catering', isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-IS-30KG' },
{ name: '带鱼屏', category: 'catering', isIot: false, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '商合', model: 'SH-BS-25IN' },
{ name: '波菲炉', category: 'catering', isIot: false, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '商合', model: 'SH-BF-4GN' },
],
body: [
{ name: '一体机', category: 'body', isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '罗杰夫', model: 'LJF-AIO-V3' },
{ name: '蓝牙秤', category: 'body', isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-BT-180KG' },
{ name: '体脂仪', category: 'body', isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'HW-FAT-BH' },
],
wearable: [
{ name: '华为-watch5', category: 'wearable', isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'Watch-5' },
{ name: '华为-watch4', category: 'wearable', isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'Watch-4' },
{ name: '罗杰夫-LJF01', category: 'wearable', isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '罗杰夫', model: 'LJF-01' },
],
fitness: [
{ name: '跑步机', category: 'fitness', isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-TM-Pro' },
{ name: '椭圆机', category: 'fitness', isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-EL-X1' },
{ name: '健身车', category: 'fitness', isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-BK-M3' },
],
monitor: [
{ name: '血糖监测仪', category: 'monitor', isIot: true, dataTransit: true, dataReport: true, networkMode: 'sim', manufacturer: '鱼跃', model: 'YY-BG-530' },
{ name: '高血压治疗仪', category: 'monitor', isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-BP-680' },
{ name: '智能健康一体机', category: 'monitor', isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '罗杰夫', model: 'LJF-HM-Z1' },
],
}
/** 客户下拉数据源(与 customer/archive mock 对齐,精简为 id+name */
const customerOptionList: CustomerOption[] = [
{ id: 'cust-001', name: '长庆能源总部食堂' },
{ id: 'cust-002', name: '第一采油厂' },
{ id: 'cust-003', name: '炼化分公司' },
{ id: 'cust-004', name: '健康示范园区' },
{ id: 'cust-005', name: '示范餐饮集团' },
{ id: 'cust-006', name: '健身中心B' },
]
/** 场所下拉数据源(与 customer/site mock 对齐,精简为 id+name+customerId */
const siteOptionList: SiteOption[] = [
{ id: 'site-001', name: '长庆总部一食堂', customerId: 'cust-001' },
{ id: 'site-002', name: '采油一厂食堂', customerId: 'cust-002' },
{ id: 'site-003', name: '炼化员工餐厅', customerId: 'cust-003' },
{ id: 'site-004', name: '健康园区体检站', customerId: 'cust-004' },
{ id: 'site-005', name: '总部食堂', customerId: 'cust-005' },
{ id: 'site-006', name: '东区食堂', customerId: 'cust-005' },
{ id: 'site-007', name: '体检站1', customerId: 'cust-004' },
{ id: 'site-008', name: '体检站2', customerId: 'cust-004' },
{ id: 'site-009', name: '健身房1', customerId: 'cust-006' },
]
/** 按 deviceCategory 名称查找类别元数据 */
const findCategoryMeta = (name: string): CategoryMeta | null => {
for (const tab of Object.keys(CATEGORY_META) as CategoryTab[]) {
const found = CATEGORY_META[tab].find((item) => item.name === name)
if (found) return found
}
return null
}
/** 按 deviceCategory 名称查找所属顶级分类 Tab */
const resolveCategoryTab = (name: string): CategoryTab => {
const meta = findCategoryMeta(name)
return meta?.category ?? 'catering'
}
/** 按 customerId 查询客户名称 */
const findCustomerName = (id: string): string =>
customerOptionList.find((item) => item.id === id)?.name ?? ''
/** 按 siteId 查询场所名称 */
const findSiteName = (id: string): string =>
siteOptionList.find((item) => item.id === id)?.name ?? ''
/** 按 Tab 维度的 mock 数据集(每类 ≤ 8 条) */
const dataset: Record<CategoryTab, DeviceItem[]> = {
catering: [
{
id: 'dev-c-1', deviceCode: 'CATERING-PM-0001', name: '一号吐盘机', modelName: '吐盘机', category: 'catering',
tenantName: '示范餐饮集团', siteName: '总部食堂', runStatus: 'online', softwareVersion: 'V1.2.0',
deployStatus: 'deployed', onlineTime: '2026-05-12 10:00:00', deployTime: '2026-05-20 09:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
trackNo: 'T-01', placement: '落地', boundScreen: '大屏-A1', stall: '主食档', clientIp: '192.168.10.11',
id: 'dev-c-1', deviceCode: 'CATERING-PM-0001', name: '一号吐盘机', serialNumber: 'SN-PM-0001',
deviceCategory: '吐盘机', category: 'catering',
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-005', siteName: '总部食堂',
deployStatus: 'deployed', softwareVersion: 'V1.2.0',
onlineTime: '2026-05-12 10:00:00', deployTime: '2026-05-20 09:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '商合', model: 'SH-TP-200',
},
{
id: 'dev-c-2', deviceCode: 'CATERING-CB-0002', name: '智能货柜-B02', modelName: '智能货柜', category: 'catering',
tenantName: '示范餐饮集团', siteName: '东区食堂', runStatus: 'online', softwareVersion: 'V2.0.1',
deployStatus: 'deployed', onlineTime: '2026-05-13 11:00:00', deployTime: '2026-05-22 10:00:00',
lastHeartbeat: '2026-07-02 09:31:00', isIot: true,
trackNo: 'T-02', placement: '嵌入式', boundScreen: '—', stall: '副食档', clientIp: '192.168.10.12',
id: 'dev-c-2', deviceCode: 'CATERING-CB-0002', name: '智能货柜-B02', serialNumber: 'SN-CB-0002',
deviceCategory: '智能货柜', category: 'catering',
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-006', siteName: '东区食堂',
deployStatus: 'deployed', softwareVersion: 'V2.0.1',
onlineTime: '2026-05-13 11:00:00', deployTime: '2026-05-22 10:00:00',
arcLicense: 'ARC-CAB-2026-0002', simIccid: '',
isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '华为', model: 'HW-CAB-100L',
},
{
id: 'dev-c-3', deviceCode: 'CATERING-NS-0003', name: '营养秤-03', modelName: '营养秤', category: 'catering',
tenantName: '', siteName: '', runStatus: 'offline', softwareVersion: 'V1.0.5',
deployStatus: 'in_stock', onlineTime: '2026-05-14 09:30:00', deployTime: '',
lastHeartbeat: '2026-07-01 18:20:00', isIot: true,
trackNo: '—', placement: '台面', boundScreen: '—', stall: '—', clientIp: '',
id: 'dev-c-3', deviceCode: 'CATERING-NS-0003', name: '', serialNumber: 'SN-NS-0003',
deviceCategory: '营养秤', category: 'catering',
customerId: '', customerName: '', siteId: '', siteName: '',
deployStatus: 'in_stock', softwareVersion: 'V1.0.5',
onlineTime: '2026-05-14 09:30:00', deployTime: '',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-NS-5KG',
},
{
id: 'dev-c-4', deviceCode: 'CATERING-CO-0004', name: '结算台-04', modelName: '结算台', category: 'catering',
tenantName: '健康机构A', siteName: '体检站1', runStatus: 'repairing', softwareVersion: 'V1.3.0',
deployStatus: 'deployed', onlineTime: '2026-05-15 14:00:00', deployTime: '2026-05-25 14:00:00',
lastHeartbeat: '2026-06-30 12:00:00', isIot: true,
trackNo: '—', placement: '落地', boundScreen: '结算屏-04', stall: '出口档', clientIp: '192.168.10.14',
id: 'dev-c-4', deviceCode: 'CATERING-CO-0004', name: '结算台-04', serialNumber: 'SN-CO-0004',
deviceCategory: '结算台', category: 'catering',
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-007', siteName: '体检站1',
deployStatus: 'deployed', softwareVersion: 'V1.3.0',
onlineTime: '2026-05-15 14:00:00', deployTime: '2026-05-25 14:00:00',
arcLicense: 'ARC-CK-2026-0004', simIccid: '',
isIot: true, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '华为', model: 'HW-CK-21IN',
},
{
id: 'dev-c-5', deviceCode: 'CATERING-RS-0005', name: '配比秤-05', serialNumber: 'SN-RS-0005',
deviceCategory: '配比秤', category: 'catering',
customerId: 'cust-002', customerName: '第一采油厂', siteId: 'site-002', siteName: '采油一厂食堂',
deployStatus: 'deployed', softwareVersion: 'V1.1.0',
onlineTime: '2026-05-16 09:00:00', deployTime: '2026-05-24 09:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-RS-15KG',
},
{
id: 'dev-c-6', deviceCode: 'CATERING-IS-0006', name: '入库秤-06', serialNumber: 'SN-IS-0006',
deviceCategory: '入库秤', category: 'catering',
customerId: 'cust-003', customerName: '炼化分公司', siteId: 'site-003', siteName: '炼化员工餐厅',
deployStatus: 'deployed', softwareVersion: 'V1.0.8',
onlineTime: '2026-05-17 10:30:00', deployTime: '2026-05-26 10:30:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-IS-30KG',
},
{
id: 'dev-c-7', deviceCode: 'CATERING-BS-0007', name: '带鱼屏-A1', serialNumber: 'SN-BS-0007',
deviceCategory: '带鱼屏', category: 'catering',
customerId: 'cust-001', customerName: '长庆能源总部食堂', siteId: 'site-001', siteName: '长庆总部一食堂',
deployStatus: 'deployed', softwareVersion: 'V2.3.0',
onlineTime: '2026-05-18 14:30:00', deployTime: '2026-05-28 14:30:00',
arcLicense: 'ARC-BS-2026-0007', simIccid: '',
isIot: false, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '商合', model: 'SH-BS-25IN',
},
{
id: 'dev-c-8', deviceCode: 'CATERING-BF-0008', name: '波菲炉-08', serialNumber: 'SN-BF-0008',
deviceCategory: '波菲炉', category: 'catering',
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-005', siteName: '总部食堂',
deployStatus: 'deployed', softwareVersion: 'V1.0.0',
onlineTime: '2026-05-19 11:00:00', deployTime: '2026-05-29 11:00:00',
arcLicense: '', simIccid: '',
isIot: false, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '商合', model: 'SH-BF-4GN',
},
{
id: 'dev-c-9', deviceCode: 'CATERING-CB-0009', name: '', serialNumber: 'SN-CB-0009',
deviceCategory: '智能货柜', category: 'catering',
customerId: '', customerName: '', siteId: '', siteName: '',
deployStatus: 'in_stock', softwareVersion: 'V2.0.1',
onlineTime: '2026-06-02 09:00:00', deployTime: '',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '华为', model: 'HW-CAB-100L',
},
{
id: 'dev-c-10', deviceCode: 'CATERING-PM-0010', name: '', serialNumber: 'SN-PM-0010',
deviceCategory: '吐盘机', category: 'catering',
customerId: '', customerName: '', siteId: '', siteName: '',
deployStatus: 'in_stock', softwareVersion: 'V1.2.0',
onlineTime: '2026-06-05 15:00:00', deployTime: '',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '商合', model: 'SH-TP-200',
},
],
body: [
{
id: 'dev-b-1', deviceCode: 'BODY-AIO-0001', name: '体检一体机-01', modelName: '一体机', category: 'body',
tenantName: '健康机构A', siteName: '体检站1', runStatus: 'online', softwareVersion: 'V3.1.0',
deployStatus: 'deployed', onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-18 09:00:00',
lastHeartbeat: '2026-07-02 09:32:00', isIot: true,
measureIndicators: '体重 / 体脂 / BMI', range: '0-200kg', accuracy: '±0.1kg', unit: 'kg', measureMethod: '站姿',
id: 'dev-b-1', deviceCode: 'BODY-AIO-0001', name: '体检一体机-01', serialNumber: 'SN-AIO-0001',
deviceCategory: '一体机', category: 'body',
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-007', siteName: '体检站1',
deployStatus: 'deployed', softwareVersion: 'V3.1.0',
onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-18 09:00:00',
arcLicense: 'ARC-AIO-2026-0001', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '罗杰夫', model: 'LJF-AIO-V3',
},
{
id: 'dev-b-2', deviceCode: 'BODY-BT-0002', name: '蓝牙秤-02', modelName: '蓝牙秤', category: 'body',
tenantName: '健康机构A', siteName: '体检站2', runStatus: 'online', softwareVersion: 'V2.4.0',
deployStatus: 'deployed', onlineTime: '2026-05-11 10:00:00', deployTime: '2026-05-19 10:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
measureIndicators: '体重', range: '0-150kg', accuracy: '±0.05kg', unit: 'kg', measureMethod: '站姿',
id: 'dev-b-2', deviceCode: 'BODY-BT-0002', name: '蓝牙秤-02', serialNumber: 'SN-BT-0002',
deviceCategory: '蓝牙秤', category: 'body',
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-008', siteName: '体检站2',
deployStatus: 'deployed', softwareVersion: 'V2.4.0',
onlineTime: '2026-05-11 10:00:00', deployTime: '2026-05-19 10:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-BT-180KG',
},
{
id: 'dev-b-3', deviceCode: 'BODY-FAT-0003', name: '体脂仪-03', modelName: '体脂仪', category: 'body',
tenantName: '', siteName: '', runStatus: 'offline', softwareVersion: 'V1.8.0',
deployStatus: 'in_stock', onlineTime: '2026-05-12 11:00:00', deployTime: '',
lastHeartbeat: '2026-07-01 16:00:00', isIot: true,
measureIndicators: '体重 / 体脂 / 水分', range: '0-200kg', accuracy: '±0.1kg', unit: 'kg', measureMethod: '站姿',
id: 'dev-b-3', deviceCode: 'BODY-FAT-0003', name: '', serialNumber: 'SN-FAT-0003',
deviceCategory: '体脂仪', category: 'body',
customerId: '', customerName: '', siteId: '', siteName: '',
deployStatus: 'in_stock', softwareVersion: 'V1.8.0',
onlineTime: '2026-05-12 11:00:00', deployTime: '',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'HW-FAT-BH',
},
],
wearable: [
{
id: 'dev-w-1', deviceCode: 'WEAR-HW5-0001', name: '华为手表5-老人甲', modelName: '华为-watch5', category: 'wearable',
tenantName: '健康机构A', siteName: '体检站1', runStatus: 'online', softwareVersion: 'V5.2.0',
deployStatus: 'deployed', onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
lastHeartbeat: '2026-07-02 09:33:00', isIot: true,
bandNo: 'BN-0001', simCard: '8986011224567890', boundUser: '张三 (UID-001)', battery: '85%', protocol: '4G',
id: 'dev-w-1', deviceCode: 'WEAR-HW5-0001', name: '华为手表5-老人甲', serialNumber: 'SN-HW5-0001',
deviceCategory: '华为-watch5', category: 'wearable',
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-007', siteName: '体检站1',
deployStatus: 'deployed', softwareVersion: 'V5.2.0',
onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'Watch-5',
},
{
id: 'dev-w-2', deviceCode: 'WEAR-HW4-0002', name: '华为手表4-老人乙', modelName: '华为-watch4', category: 'wearable',
tenantName: '健康机构A', siteName: '体检站2', runStatus: 'online', softwareVersion: 'V4.1.0',
deployStatus: 'deployed', onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-16 09:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
bandNo: 'BN-0002', simCard: '8986011224567891', boundUser: '李四 (UID-002)', battery: '62%', protocol: '4G',
id: 'dev-w-2', deviceCode: 'WEAR-HW4-0002', name: '华为手表4-老人乙', serialNumber: 'SN-HW4-0002',
deviceCategory: '华为-watch4', category: 'wearable',
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-008', siteName: '体检站2',
deployStatus: 'deployed', softwareVersion: 'V4.1.0',
onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-16 09:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'Watch-4',
},
{
id: 'dev-w-3', deviceCode: 'WEAR-LJF-0003', name: '罗杰夫-丙', modelName: '罗杰夫-LJF01', category: 'wearable',
tenantName: '示范餐饮集团', siteName: '总部食堂', runStatus: 'repairing', softwareVersion: 'V1.0.3',
deployStatus: 'deployed', onlineTime: '2026-05-11 09:00:00', deployTime: '2026-05-17 09:00:00',
lastHeartbeat: '2026-06-28 10:00:00', isIot: true,
bandNo: 'BN-0003', simCard: '8986011224567892', boundUser: '王五 (UID-003)', battery: '20%', protocol: 'BLE',
id: 'dev-w-3', deviceCode: 'WEAR-LJF-0003', name: '罗杰夫-丙', serialNumber: 'SN-LJF-0003',
deviceCategory: '罗杰夫-LJF01', category: 'wearable',
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-005', siteName: '总部食堂',
deployStatus: 'deployed', softwareVersion: 'V1.0.3',
onlineTime: '2026-05-11 09:00:00', deployTime: '2026-05-17 09:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '罗杰夫', model: 'LJF-01',
},
],
fitness: [
{
id: 'dev-f-1', deviceCode: 'FIT-TM-0001', name: '跑步机-01', modelName: '跑步机', category: 'fitness',
tenantName: '健身中心B', siteName: '健身房1', runStatus: 'online', softwareVersion: 'V2.0.0',
deployStatus: 'deployed', onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
equipmentType: '有氧', resistanceMode: '电磁', trainDataType: '时长 / 卡路里 / 距离', location: '一层东区',
id: 'dev-f-1', deviceCode: 'FIT-TM-0001', name: '跑步机-01', serialNumber: 'SN-TM-0001',
deviceCategory: '跑步机', category: 'fitness',
customerId: 'cust-006', customerName: '健身中心B', siteId: 'site-009', siteName: '健身房1',
deployStatus: 'deployed', softwareVersion: 'V2.0.0',
onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-TM-Pro',
},
{
id: 'dev-f-2', deviceCode: 'FIT-EL-0002', name: '椭圆机-02', modelName: '椭圆机', category: 'fitness',
tenantName: '健身中心B', siteName: '健身房1', runStatus: 'online', softwareVersion: 'V1.5.0',
deployStatus: 'deployed', onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
lastHeartbeat: '2026-07-02 09:31:00', isIot: true,
equipmentType: '有氧', resistanceMode: '电磁', trainDataType: '时长 / 卡路里', location: '一层东区',
id: 'dev-f-2', deviceCode: 'FIT-EL-0002', name: '椭圆机-02', serialNumber: 'SN-EL-0002',
deviceCategory: '椭圆机', category: 'fitness',
customerId: 'cust-006', customerName: '健身中心B', siteId: 'site-009', siteName: '健身房1',
deployStatus: 'deployed', softwareVersion: 'V1.5.0',
onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-EL-X1',
},
{
id: 'dev-f-3', deviceCode: 'FIT-BK-0003', name: '健身车-03', modelName: '健身车', category: 'fitness',
tenantName: '', siteName: '', runStatus: 'offline', softwareVersion: 'V1.2.0',
deployStatus: 'in_stock', onlineTime: '2026-05-10 09:00:00', deployTime: '',
lastHeartbeat: '2026-07-01 17:00:00', isIot: true,
equipmentType: '有氧', resistanceMode: '液压', trainDataType: '时长 / 卡路里', location: '',
id: 'dev-f-3', deviceCode: 'FIT-BK-0003', name: '', serialNumber: 'SN-BK-0003',
deviceCategory: '健身车', category: 'fitness',
customerId: '', customerName: '', siteId: '', siteName: '',
deployStatus: 'in_stock', softwareVersion: 'V1.2.0',
onlineTime: '2026-05-10 09:00:00', deployTime: '',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-BK-M3',
},
],
monitor: [
{
id: 'dev-m-1', deviceCode: 'MON-SG-0001', name: '血糖仪-01', modelName: '血糖监测仪', category: 'monitor',
tenantName: '健康机构A', siteName: '体检站1', runStatus: 'online', softwareVersion: 'V1.4.0',
deployStatus: 'deployed', onlineTime: '2026-05-07 09:00:00', deployTime: '2026-05-13 09:00:00',
lastHeartbeat: '2026-07-02 09:32:00', isIot: true,
monitorIndicators: '血糖', sampleFrequency: '按需', measureMethod: '采血', reportMode: '云端',
id: 'dev-m-1', deviceCode: 'MON-SG-0001', name: '血糖仪-01', serialNumber: 'SN-SG-0001',
deviceCategory: '血糖监测仪', category: 'monitor',
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-007', siteName: '体检站1',
deployStatus: 'deployed', softwareVersion: 'V1.4.0',
onlineTime: '2026-05-07 09:00:00', deployTime: '2026-05-13 09:00:00',
arcLicense: '', simIccid: '8986011224567890',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'sim', manufacturer: '鱼跃', model: 'YY-BG-530',
},
{
id: 'dev-m-2', deviceCode: 'MON-BP-0002', name: '高血压治疗仪-02', modelName: '高血压治疗仪', category: 'monitor',
tenantName: '健康机构A', siteName: '体检站2', runStatus: 'online', softwareVersion: 'V2.1.0',
deployStatus: 'deployed', onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
lastHeartbeat: '2026-07-02 09:30:00', isIot: true,
monitorIndicators: '血压 / 心率', sampleFrequency: '每5分钟', measureMethod: '袖带', reportMode: '本地',
id: 'dev-m-2', deviceCode: 'MON-BP-0002', name: '高血压治疗仪-02', serialNumber: 'SN-BP-0002',
deviceCategory: '高血压治疗仪', category: 'monitor',
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-008', siteName: '体检站2',
deployStatus: 'deployed', softwareVersion: 'V2.1.0',
onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-BP-680',
},
{
id: 'dev-m-3', deviceCode: 'MON-AIO-0003', name: '健康一体机-03', modelName: '智能健康一体机', category: 'monitor',
tenantName: '', siteName: '', runStatus: 'scrapped', softwareVersion: 'V3.0.0',
deployStatus: 'in_stock', onlineTime: '2026-05-09 09:00:00', deployTime: '',
lastHeartbeat: '2026-06-15 11:00:00', isIot: true,
monitorIndicators: '血压 / 血糖 / 血氧 / 心电', sampleFrequency: '按需', measureMethod: '光电', reportMode: '云端',
id: 'dev-m-3', deviceCode: 'MON-AIO-0003', name: '健康一体机-03', serialNumber: 'SN-AIO-0003',
deviceCategory: '智能健康一体机', category: 'monitor',
customerId: '', customerName: '', siteId: '', siteName: '',
deployStatus: 'in_stock', softwareVersion: 'V3.0.0',
onlineTime: '2026-05-09 09:00:00', deployTime: '',
arcLicense: '', simIccid: '',
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '罗杰夫', model: 'LJF-HM-Z1',
},
],
}
const handleList = (ctx: MockContext): ApiResponse<DeviceItem[]> => {
const params = (ctx.params ?? {}) as ListParams
/** 若按入库单号反查,跨 Tab 聚合 */
if (params.inboundCode) {
const kw = params.inboundCode.toLowerCase()
const all: DeviceItem[] = []
for (const cat of Object.keys(dataset) as CategoryTab[]) {
for (const item of dataset[cat]) {
if ((item.inboundCode ?? '').toLowerCase().includes(kw)) all.push(item)
}
}
return ok('查询成功', all)
}
let list = dataset[params.category] ?? []
if (params.keyword) {
const kw = params.keyword.toLowerCase()
list = list.filter(
(item) =>
item.deviceCode.toLowerCase().includes(kw) ||
item.name.toLowerCase().includes(kw),
(item.name ?? '').toLowerCase().includes(kw),
)
}
if (params.runStatus) {
list = list.filter((item) => item.runStatus === params.runStatus)
}
if (params.deployStatus) {
list = list.filter((item) => item.deployStatus === params.deployStatus)
}
if (params.tenantName) {
list = list.filter((item) => (item.tenantName ?? '').includes(params.tenantName as string))
if (params.deviceCategory) {
list = list.filter((item) => item.deviceCategory === params.deviceCategory)
}
if (params.siteName) {
list = list.filter((item) => (item.siteName ?? '').includes(params.siteName as string))
if (params.customerId) {
list = list.filter((item) => item.customerId === params.customerId)
}
if (params.siteId) {
list = list.filter((item) => item.siteId === params.siteId)
}
return ok('查询成功', list)
}
/** 按 deviceCategory 冗余回填类别元数据到设备记录 */
const applyCategoryMeta = (device: DeviceItem): DeviceItem => {
const meta = findCategoryMeta(device.deviceCategory)
if (!meta) return device
return {
...device,
category: meta.category,
isIot: meta.isIot,
dataTransit: meta.dataTransit,
dataReport: meta.dataReport,
networkMode: meta.networkMode,
manufacturer: meta.manufacturer,
model: meta.model,
}
}
const handleAdd = (ctx: MockContext): ApiResponse<null> => {
const payload = (ctx.params ?? {}) as DeviceItem
if (!payload.name) return ok('设备名称不能为空')
const category = payload.category
if (!payload.deviceCode) return ok('设备编码不能为空')
if (!payload.deviceCategory) return ok('设备类别不能为空')
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
const deployStatus = computeDeployStatus(payload.tenantName ?? '', payload.siteName ?? '')
const newItem: DeviceItem = {
const deployStatus = computeDeployStatus(payload.customerId ?? '', payload.siteId ?? '')
const customerName = findCustomerName(payload.customerId ?? '')
const siteName = findSiteName(payload.siteId ?? '')
const categoryTab = resolveCategoryTab(payload.deviceCategory)
const base: DeviceItem = {
...payload,
id: `dev-${category.slice(0, 1)}-${Date.now().toString(36)}`,
deviceCode: payload.deviceCode || `DEV-${Date.now()}`,
customerName,
siteName,
deployStatus,
onlineTime: payload.onlineTime || now,
deployTime: deployStatus === 'deployed' ? (payload.deployTime || now) : '',
lastHeartbeat: now,
isIot: true,
onlineTime: payload.onlineTime || now,
}
dataset[category].unshift(newItem)
const newItem: DeviceItem = {
...base,
id: `dev-${categoryTab.slice(0, 1)}-${Date.now().toString(36)}`,
category: categoryTab,
}
dataset[categoryTab].unshift(applyCategoryMeta(newItem))
return ok('新增成功')
}
const handleUpdate = (ctx: MockContext): ApiResponse<null> => {
const payload = (ctx.params ?? {}) as DeviceItem
if (!payload.id) return ok('ID 不能为空')
const category = payload.category
const idx = dataset[category].findIndex((item) => item.id === payload.id)
const categoryTab = resolveCategoryTab(payload.deviceCategory)
const idx = dataset[categoryTab].findIndex((item) => item.id === payload.id)
if (idx < 0) return ok('设备不存在')
/** 编辑时按租户 / 场所重新计算投用状态 */
const deployStatus = computeDeployStatus(payload.tenantName ?? '', payload.siteName ?? '')
dataset[category][idx] = {
...dataset[category][idx],
const deployStatus = computeDeployStatus(payload.customerId ?? '', payload.siteId ?? '')
const customerName = findCustomerName(payload.customerId ?? '')
const siteName = findSiteName(payload.siteId ?? '')
const merged: DeviceItem = {
...dataset[categoryTab][idx],
...payload,
customerName,
siteName,
category: categoryTab,
deployStatus,
deployTime: deployStatus === 'deployed' ? (payload.deployTime || dataset[category][idx].deployTime) : '',
deployTime: deployStatus === 'deployed' ? (payload.deployTime || dataset[categoryTab][idx].deployTime) : '',
}
dataset[categoryTab][idx] = applyCategoryMeta(merged)
return ok('更新成功')
}
@@ -274,64 +502,110 @@ const findDevice = (id: string): DeviceItem | null => {
return null
}
/** 生成运行日志(基于设备状态模拟) */
const buildRunLogs = (device: DeviceItem): RunLogItem[] => {
const logs: RunLogItem[] = [
{ id: 'log-1', eventType: '心跳', content: '设备心跳上报正常', occurTime: device.lastHeartbeat },
{ id: 'log-2', eventType: '心跳', content: '设备心跳上报正常', occurTime: '2026-07-02 08:30:00' },
{ id: 'log-3', eventType: '关键事件', content: '完成一次测量任务', occurTime: '2026-07-02 08:00:00' },
{ id: 'log-4', eventType: '开机', content: '设备开机上线', occurTime: device.onlineTime },
]
if (device.runStatus === 'offline') {
logs.unshift({ id: 'log-0', eventType: '离线', content: '设备连续 3 次心跳未上报,标记为离线', occurTime: device.lastHeartbeat })
}
if (device.runStatus === 'repairing') {
logs.unshift({ id: 'log-r', eventType: '异常', content: '硬件自检异常,已自动派单维修', occurTime: device.lastHeartbeat })
}
return logs.slice(0, 8)
}
/** 生成维修履历(按需) */
const buildRepairs = (device: DeviceItem): RepairItem[] => {
if (device.runStatus !== 'repairing' && device.runStatus !== 'scrapped') return []
return [
{
id: 'rep-1',
workOrderNo: 'WO-2026-0601-001',
faultType: '传感器异常',
repairMethod: '更换传感器模组',
repairer: '李师傅',
workHours: 2.5,
finishTime: '2026-06-30 18:00:00',
},
{
id: 'rep-2',
workOrderNo: 'WO-2026-0512-008',
faultType: '通讯模块故障',
repairMethod: '更换 4G 模块',
repairer: '王师傅',
workHours: 1.5,
finishTime: '2026-05-12 16:30:00',
},
]
}
const handleInfo = (ctx: MockContext): ApiResponse<DetailInfo> => {
const handleInfo = (ctx: MockContext): ApiResponse<DeviceItem> => {
const params = (ctx.params ?? {}) as { id?: string }
if (!params.id) return ok('ID 不能为空')
const device = findDevice(params.id)
if (!device) return ok('设备不存在')
const detail: DetailInfo = {
...device,
runLogs: buildRunLogs(device),
repairs: buildRepairs(device),
}
return ok('查询成功', detail)
return ok('查询成功', device)
}
/** 设备类别下拉(按顶级分类 Tab) */
const handleCategoryOptions = (ctx: MockContext): ApiResponse<{ name: string; category: CategoryTab; networkMode: NetworkMode }[]> => {
const params = (ctx.params ?? {}) as { category?: CategoryTab }
const tab = params.category ?? 'catering'
const list = (CATEGORY_META[tab] ?? []).map((item) => ({
name: item.name,
category: item.category,
networkMode: item.networkMode,
}))
return ok('查询成功', list)
}
/** 所属客户下拉(支持关键字搜索) */
const handleCustomerOptions = (ctx: MockContext): ApiResponse<CustomerOption[]> => {
const params = (ctx.params ?? {}) as { keyword?: string }
const kw = (params.keyword ?? '').toLowerCase()
const list = kw
? customerOptionList.filter((item) => item.name.toLowerCase().includes(kw))
: customerOptionList
return ok('查询成功', list)
}
/** 所属场所下拉(按 customerId 联动 + 关键字搜索) */
const handleSiteOptions = (ctx: MockContext): ApiResponse<SiteOption[]> => {
const params = (ctx.params ?? {}) as { customerId?: string; keyword?: string }
if (!params.customerId) return ok('查询成功', [])
const kw = (params.keyword ?? '').toLowerCase()
const list = siteOptionList.filter(
(item) =>
item.customerId === params.customerId &&
(!kw || item.name.toLowerCase().includes(kw)),
)
return ok('查询成功', list)
}
/** 异步导出:提交后立即返回任务已接收 */
const handleExport = (): ApiResponse<null> => ok('导出任务已提交,可在"查看导出任务"中查看进度')
/** 批量导入:原型阶段占位 */
const handleImport = (): ApiResponse<null> => ok('导入任务已提交,请稍后刷新列表查看结果')
/**
* 由入库单审核触发:按入库单的设备明细批量生成设备台账
* - 入库单 status=audited 时调用
* - 每条明细生成一条 DeviceIteminboundCode 回填入库单号
* - 默认 in_stock(待投用),无客户/场所关联
*/
export interface InboundDetailInput {
deviceCode: string
serialNumber: string
}
export interface InboundRecordInput {
inboundCode: string
categoryId: string
categoryName: string
items: InboundDetailInput[]
}
export const pushDevicesFromInbound = (record: InboundRecordInput): number => {
if (!record.items?.length) return 0
const tab = resolveCategoryTab(record.categoryName)
const meta = findCategoryMeta(record.categoryName)
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
let pushed = 0
for (const item of record.items) {
if (!item.deviceCode) continue
const newItem: DeviceItem = {
id: `dev-${tab.slice(0, 1)}-${Date.now().toString(36)}-${pushed}`,
deviceCode: item.deviceCode,
name: '',
serialNumber: item.serialNumber ?? '',
deviceCategory: record.categoryName,
category: tab,
customerId: '',
customerName: '',
siteId: '',
siteName: '',
deployStatus: 'in_stock',
softwareVersion: '',
onlineTime: now,
deployTime: '',
arcLicense: '',
simIccid: '',
isIot: meta?.isIot ?? false,
dataTransit: meta?.dataTransit ?? false,
dataReport: meta?.dataReport ?? false,
networkMode: meta?.networkMode ?? 'wifi',
manufacturer: meta?.manufacturer ?? '',
model: meta?.model ?? '',
inboundCode: record.inboundCode,
}
dataset[tab].unshift(newItem)
pushed += 1
}
return pushed
}
registerMocks([
{ method: 'POST', url: '/admin/device/manage/list', handler: handleList },
{ method: 'POST', url: '/admin/device/manage/info', handler: handleInfo },
@@ -339,4 +613,8 @@ registerMocks([
{ method: 'POST', url: '/admin/device/manage/update', handler: handleUpdate },
{ method: 'POST', url: '/admin/device/manage/delete', handler: handleDelete },
{ method: 'POST', url: '/admin/device/manage/export', handler: handleExport },
{ method: 'POST', url: '/admin/device/manage/import', handler: handleImport },
{ method: 'POST', url: '/admin/device/manage/category/options', handler: handleCategoryOptions },
{ method: 'POST', url: '/admin/device/manage/customer/options', handler: handleCustomerOptions },
{ method: 'POST', url: '/admin/device/manage/site/options', handler: handleSiteOptions },
])
@@ -9,24 +9,30 @@ interface PageParams {
keyword?: string
status?: string
outboundType?: string
categoryId?: string
warehouseAreaId?: string
customerId?: string
[key: string]: unknown
}
interface OutboundRecord {
id: string
outboundCode: string
deviceCode: string
deviceName: string
modelName: string
categoryId: string
categoryName: string
quantity: number
recipient: string
targetSite: string
targetCustomer: string
customerId: string
customerName: string
outboundType: 'install' | 'transfer' | 'scrap'
operator: string
outboundTime: string
signedTime?: string
cancelledTime?: string
status: 'pending' | 'completed' | 'signed' | 'cancelled'
warehouseAreaId?: string
warehouseAreaName?: string
remark?: string
}
@@ -49,15 +55,64 @@ const nowStr = (): string => {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 设备类别末级 ID → 名称映射(与 statistics/category mock 同源) */
const CATEGORY_LEAF_NAME: Record<string, string> = {
'cat-catering-plate': '吐盘机',
'cat-catering-cabinet': '智能货柜',
'cat-catering-nutri-scale': '营养秤',
'cat-catering-ratio-scale': '配比秤',
'cat-catering-checkout': '结算台',
'cat-catering-inbound-scale': '入库秤',
'cat-catering-band-screen': '带鱼屏',
'cat-catering-baifei': '波菲炉',
'cat-body-all-in-one': '一体机',
'cat-body-bt-scale': '蓝牙秤',
'cat-body-fat': '体脂仪',
'cat-wearable-hw5': '华为-watch5',
'cat-wearable-hw4': '华为-watch4',
'cat-wearable-ljf01': '罗杰夫-LJF01',
'cat-fitness-treadmill': '跑步机',
'cat-fitness-elliptical': '椭圆机',
'cat-fitness-bike': '健身车',
'cat-monitor-sugar': '血糖监测仪',
'cat-monitor-pressure': '高血压治疗仪',
'cat-monitor-all-in-one': '智能健康一体机',
}
/** 仓库区域末级节点 ID → 祖先链名称映射(与 warehouse mock 同源) */
const WAREHOUSE_AREA_NAME: Record<string, string> = {
'wh-floor1-large-a-1': '1楼大型A号货架-1号储位',
'wh-floor1-large-a-2': '1楼大型A号货架-2号储位',
'wh-floor1-large-b-1': '1楼大型B号货架-1号储位',
'wh-floor1-small-shelf-1': '1楼小型备品货架-1号储位',
'wh-floor1-small-shelf-2': '1楼小型备品货架-2号储位',
'wh-floor2-check-1': '2楼待检区-临时储位',
}
/** 客户 ID → 名称映射(与 manage/statistics mock 同源) */
const CUSTOMER_NAME: Record<string, string> = {
'cust-001': '长庆能源总部食堂',
'cust-002': '第一采油厂',
'cust-003': '炼化分公司',
'cust-004': '健康示范园区',
'cust-005': '示范餐饮集团',
'cust-006': '健身中心B',
'cust-007': '健康机构A',
}
const resolveCategoryName = (id?: string): string => (id ? CATEGORY_LEAF_NAME[id] ?? '' : '')
const resolveWarehouseAreaName = (id?: string): string => (id ? WAREHOUSE_AREA_NAME[id] ?? '' : '')
const resolveCustomerName = (id?: string): string => (id ? CUSTOMER_NAME[id] ?? '' : '')
const outboundList: OutboundRecord[] = [
{ id: 'oub-001', outboundCode: 'OUB-20260603-001', deviceCode: 'CAB-A1-0003', deviceName: '智能货柜A1', modelName: '智能货柜', quantity: 2, recipient: '王师傅', targetSite: '总部食堂', targetCustomer: '智养餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-03 10:00:00', signedTime: '2026-06-03 14:30:00', status: 'signed' },
{ id: 'oub-002', outboundCode: 'OUB-20260608-002', deviceCode: 'WAT-W1-0002', deviceName: '鹏升手表W1', modelName: '华为-watch5', quantity: 5, recipient: '李主任', targetSite: '健康站01', targetCustomer: '健康管理机构', outboundType: 'install', operator: '李四', outboundTime: '2026-06-08 15:30:00', signedTime: '2026-06-09 09:00:00', status: 'signed' },
{ id: 'oub-003', outboundCode: 'OUB-20260612-003', deviceCode: 'CHK-M1-0002', deviceName: '华盛体检机', modelName: '智能健康一体机', quantity: 1, recipient: '赵工', targetSite: '东区体检站', targetCustomer: '健康管理机构', outboundType: 'install', operator: '王五', outboundTime: '2026-06-12 09:15:00', status: 'completed' },
{ id: 'oub-004', outboundCode: 'OUB-20260616-004', deviceCode: 'SCL-X1-0002', deviceName: '入库秤X1', modelName: '入库秤', quantity: 1, recipient: '孙师傅', targetSite: '总部食堂', targetCustomer: '智养餐饮集团', outboundType: 'install', operator: '赵六', outboundTime: '2026-06-16 14:00:00', signedTime: '2026-06-16 18:00:00', status: 'signed' },
{ id: 'oub-005', outboundCode: 'OUB-20260620-005', deviceCode: 'FIT-S1-0002', deviceName: '顿米健身车', modelName: '健身车', quantity: 2, recipient: '周教练', targetSite: '总部健身房', targetCustomer: '智养餐饮集团', outboundType: 'transfer', operator: '孙七', outboundTime: '2026-06-20 11:45:00', status: 'cancelled', remark: '客户取消订单' },
{ id: 'oub-006', outboundCode: 'OUB-20260623-006', deviceCode: 'SCR-P1-0002', deviceName: '大屏P1', modelName: '带鱼屏', quantity: 1, recipient: '陈师傅', targetSite: '总部食堂', targetCustomer: '智养餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-23 16:20:00', status: 'pending' },
{ id: 'oub-007', outboundCode: 'OUB-20260626-007', deviceCode: 'SCL-W-0002', deviceName: '体重秤', modelName: '蓝牙秤', quantity: 3, recipient: '吴主任', targetSite: '健康站01', targetCustomer: '健康管理机构', outboundType: 'install', operator: '李四', outboundTime: '2026-06-26 10:30:00', signedTime: '2026-06-27 09:00:00', status: 'signed' },
{ id: 'oub-008', outboundCode: 'OUB-20260628-008', deviceCode: 'FAT-INBODY-0002', deviceName: 'InBody体脂秤', modelName: '体脂仪', quantity: 1, recipient: '郑医生', targetSite: '东区体检站', targetCustomer: '健康管理机构', outboundType: 'install', operator: '王五', outboundTime: '2026-06-28 13:10:00', status: 'completed' },
{ id: 'oub-001', outboundCode: 'OUB-20260603-001', categoryId: 'cat-catering-cabinet', categoryName: '智能货柜', quantity: 2, recipient: '王师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-03 10:00:00', signedTime: '2026-06-03 14:30:00', status: 'signed', warehouseAreaId: 'wh-floor1-large-a-2', warehouseAreaName: '1楼大型A号货架-2号储位' },
{ id: 'oub-002', outboundCode: 'OUB-20260608-002', categoryId: 'cat-wearable-hw5', categoryName: '华为-watch5', quantity: 5, recipient: '李主任', targetSite: '健康站01', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '李四', outboundTime: '2026-06-08 15:30:00', signedTime: '2026-06-09 09:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-small-shelf-1', warehouseAreaName: '1楼小型备品货架-1号储位' },
{ id: 'oub-003', outboundCode: 'OUB-20260612-003', categoryId: 'cat-monitor-all-in-one', categoryName: '智能健康一体机', quantity: 1, recipient: '赵工', targetSite: '东区体检站', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '王五', outboundTime: '2026-06-12 09:15:00', status: 'completed', warehouseAreaId: 'wh-floor1-large-b-1', warehouseAreaName: '1楼大型B号货架-1号储位' },
{ id: 'oub-004', outboundCode: 'OUB-20260616-004', categoryId: 'cat-catering-inbound-scale', categoryName: '入库秤', quantity: 1, recipient: '孙师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '赵六', outboundTime: '2026-06-16 14:00:00', signedTime: '2026-06-16 18:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位' },
{ id: 'oub-005', outboundCode: 'OUB-20260620-005', categoryId: 'cat-fitness-bike', categoryName: '健身车', quantity: 2, recipient: '周教练', targetSite: '总部健身房', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'transfer', operator: '孙七', outboundTime: '2026-06-20 11:45:00', cancelledTime: '2026-06-20 16:00:00', status: 'cancelled', remark: '客户取消订单', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位' },
{ id: 'oub-006', outboundCode: 'OUB-20260623-006', categoryId: 'cat-catering-band-screen', categoryName: '带鱼屏', quantity: 1, recipient: '陈师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-23 16:20:00', status: 'pending', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位' },
{ id: 'oub-007', outboundCode: 'OUB-20260626-007', categoryId: 'cat-body-bt-scale', categoryName: '蓝牙秤', quantity: 3, recipient: '吴主任', targetSite: '健康站01', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '李四', outboundTime: '2026-06-26 10:30:00', signedTime: '2026-06-27 09:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-small-shelf-2', warehouseAreaName: '1楼小型备品货架-2号储位' },
{ id: 'oub-008', outboundCode: 'OUB-20260628-008', categoryId: 'cat-body-fat', categoryName: '体脂仪', quantity: 1, recipient: '郑医生', targetSite: '东区体检站', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '王五', outboundTime: '2026-06-28 13:10:00', status: 'completed', warehouseAreaId: 'wh-floor2-check-1', warehouseAreaName: '2楼待检区-临时储位' },
]
const listPage = (ctx: MockContext): ApiResponse<OutboundRecord[]> => {
@@ -65,7 +120,10 @@ const listPage = (ctx: MockContext): ApiResponse<OutboundRecord[]> => {
const filtered = outboundList.filter((item) => {
if (params.status && item.status !== params.status) return false
if (params.outboundType && item.outboundType !== params.outboundType) return false
if (params.keyword && !includes(item.outboundCode, params.keyword) && !includes(item.deviceCode, params.keyword) && !includes(item.deviceName, params.keyword)) return false
if (params.categoryId && item.categoryId !== params.categoryId) return false
if (params.warehouseAreaId && item.warehouseAreaId !== params.warehouseAreaId) return false
if (params.customerId && item.customerId !== params.customerId) return false
if (params.keyword && !includes(item.outboundCode, params.keyword)) return false
return true
})
if (params.column === 'outboundTime' && params.order) {
@@ -98,20 +156,25 @@ const genOutboundCode = (): string => {
}
const addRecord = (params: PageParams): ApiResponse<null> => {
const categoryId = String(params.categoryId ?? '')
const customerId = String(params.customerId ?? '')
const warehouseAreaId = params.warehouseAreaId ? String(params.warehouseAreaId) : undefined
const record: OutboundRecord = {
id: `oub-${Date.now().toString(36)}`,
outboundCode: String(params.outboundCode ?? genOutboundCode()),
deviceCode: String(params.deviceCode ?? ''),
deviceName: String(params.deviceName ?? ''),
modelName: String(params.modelName ?? ''),
categoryId,
categoryName: resolveCategoryName(categoryId),
quantity: Number(params.quantity ?? 1),
recipient: String(params.recipient ?? ''),
targetSite: String(params.targetSite ?? ''),
targetCustomer: String(params.targetCustomer ?? ''),
customerId,
customerName: resolveCustomerName(customerId),
outboundType: (params.outboundType as OutboundRecord['outboundType']) ?? 'install',
operator: '当前用户',
outboundTime: nowStr(),
status: 'completed',
warehouseAreaId,
warehouseAreaName: resolveWarehouseAreaName(warehouseAreaId),
remark: params.remark ? String(params.remark) : undefined,
}
outboundList.unshift(record)
@@ -122,34 +185,55 @@ const updateRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = outboundList.find((item) => item.id === id)
if (!record) return ok('出库单不存在')
if (params.deviceCode !== undefined) record.deviceCode = String(params.deviceCode)
if (params.deviceName !== undefined) record.deviceName = String(params.deviceName)
if (params.modelName !== undefined) record.modelName = String(params.modelName)
if (params.categoryId !== undefined) {
record.categoryId = String(params.categoryId)
record.categoryName = resolveCategoryName(record.categoryId)
}
if (params.quantity !== undefined) record.quantity = Number(params.quantity)
if (params.recipient !== undefined) record.recipient = String(params.recipient)
if (params.targetSite !== undefined) record.targetSite = String(params.targetSite)
if (params.targetCustomer !== undefined) record.targetCustomer = String(params.targetCustomer)
if (params.customerId !== undefined) {
record.customerId = String(params.customerId)
record.customerName = resolveCustomerName(record.customerId)
}
if (params.outboundType !== undefined) record.outboundType = params.outboundType as OutboundRecord['outboundType']
if (params.warehouseAreaId !== undefined) {
record.warehouseAreaId = params.warehouseAreaId ? String(params.warehouseAreaId) : undefined
record.warehouseAreaName = resolveWarehouseAreaName(record.warehouseAreaId)
}
if (params.remark !== undefined) record.remark = params.remark ? String(params.remark) : undefined
return ok('更新成功')
}
/** 撤回(已出库 / 待出库 状态可撤回) */
/** 撤回(待出库状态可撤回) */
const withdrawRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = outboundList.find((item) => item.id === id)
if (!record) return ok('出库单不存在')
if (record.status === 'signed') return ok('已签收的出库单可撤回')
if (record.status !== 'pending') return ok('仅待出库状态的出库单可撤回')
record.status = 'cancelled'
record.cancelledTime = nowStr()
return ok('撤回成功')
}
/** 签收(已出库 → 已签收,写入签收时间) */
const signRecord = (params: PageParams): ApiResponse<null> => {
const id = String(params.id ?? '')
const record = outboundList.find((item) => item.id === id)
if (!record) return ok('出库单不存在')
if (record.status !== 'completed') return ok('仅已出库状态的出库单可签收')
record.status = 'signed'
record.signedTime = nowStr()
return ok('签收成功')
}
registerMocks([
{ method: 'POST', url: '/admin/device/stock/outbound/page', handler: listPage },
{ method: 'POST', url: '/admin/device/stock/outbound/info', handler: (ctx) => infoById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/add', handler: (ctx) => addRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/update', handler: (ctx) => updateRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/withdraw', handler: (ctx) => withdrawRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/sign', handler: (ctx) => signRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/outbound/export', handler: () => ok('导出任务已提交') },
])
@@ -7,15 +7,18 @@ type DeployStatus = 'in_stock' | 'deployed'
interface DeviceStatItem {
id: string
/** 末级分类名(list 接口按 categoryId 回填,不在 dataset 中冗余) */
categoryLabel: string
category: CategoryTab
/** 设备分类树节点 ID(叶子节点,用于按类别筛选命中子树) */
categoryId: string
name: string
deviceCode: string
isIot: boolean
deployStatus: DeployStatus
dataDispatch: boolean
dataReport: boolean
tenantName: string
customerId: string
customerName: string
siteId: string
siteName: string
softwareVersion: string
runStatus: RunStatus
@@ -24,15 +27,13 @@ interface DeviceStatItem {
}
interface SearchForm {
keyword?: string
category?: CategoryTab
/** 设备分类树节点 ID(命中该节点及其子树) */
categoryId?: string
deployStatus?: DeployStatus
runStatus?: RunStatus
isIot?: boolean
dataDispatch?: boolean
dataReport?: boolean
tenantName?: string
siteName?: string
customerId?: string
siteId?: string
softwareVersion?: string
onlineStart?: string
onlineEnd?: string
@@ -40,13 +41,29 @@ interface SearchForm {
deployEnd?: string
}
interface CustomerOption {
id: string
name: string
}
interface SiteOption {
id: string
name: string
customerId: string
}
interface SoftwareVersionOption {
value: string
label: string
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
/** 顶级分类中文标签 */
/** 顶级分类中文标签(用于推导 dataset.category 字段,不在列表展示) */
const CATEGORY_LABEL: Record<string, string> = {
catering: '智养餐饮设备',
body: '体重测量设备',
@@ -55,130 +72,200 @@ const CATEGORY_LABEL: Record<string, string> = {
monitor: '健康监测设备',
}
/** 复用 manage 数据集,扁平化为统计列表行(不直接 import manage.ts,避免模块耦合,原型阶段保持独立副本) */
const dataset: DeviceStatItem[] = [
/**
* 末级分类名映射(categoryId → 叶子名,与 category mock 树叶子节点 name 对齐)
* - dataset 只存 categoryIdlist 返回时按此映射回填 categoryLabel(模拟后端 join 分类表)
*/
const CATEGORY_LEAF_NAME: Record<string, string> = {
'cat-catering-plate': '吐盘机',
'cat-catering-cabinet': '智能货柜',
'cat-catering-nutri-scale': '营养秤',
'cat-catering-ratio-scale': '配比秤',
'cat-catering-checkout': '结算台',
'cat-catering-inbound-scale': '入库秤',
'cat-catering-band-screen': '带鱼屏',
'cat-catering-baifei': '波菲炉',
'cat-body-all-in-one': '一体机',
'cat-body-bt-scale': '蓝牙秤',
'cat-body-fat': '体脂仪',
'cat-wearable-hw5': '华为-watch5',
'cat-wearable-hw4': '华为-watch4',
'cat-wearable-ljf01': '罗杰夫-LJF01',
'cat-fitness-treadmill': '跑步机',
'cat-fitness-elliptical': '椭圆机',
'cat-fitness-bike': '健身车',
'cat-monitor-sugar': '血糖监测仪',
'cat-monitor-pressure': '高血压治疗仪',
'cat-monitor-all-in-one': '智能健康一体机',
}
/** 客户下拉数据源(与 manage mock 同源,保持 id+name 一致) */
const customerOptionList: CustomerOption[] = [
{ id: 'cust-001', name: '长庆能源总部食堂' },
{ id: 'cust-002', name: '第一采油厂' },
{ id: 'cust-003', name: '炼化分公司' },
{ id: 'cust-004', name: '健康示范园区' },
{ id: 'cust-005', name: '示范餐饮集团' },
{ id: 'cust-006', name: '健身中心B' },
{ id: 'cust-007', name: '健康机构A' },
]
/** 场所下拉数据源(与 manage mock 同源,按 customerId 联动) */
const siteOptionList: SiteOption[] = [
{ id: 'site-001', name: '长庆总部一食堂', customerId: 'cust-001' },
{ id: 'site-002', name: '采油一厂食堂', customerId: 'cust-002' },
{ id: 'site-003', name: '炼化员工餐厅', customerId: 'cust-003' },
{ id: 'site-004', name: '健康园区体检站', customerId: 'cust-004' },
{ id: 'site-005', name: '总部食堂', customerId: 'cust-005' },
{ id: 'site-006', name: '东区食堂', customerId: 'cust-005' },
{ id: 'site-007', name: '体检站1', customerId: 'cust-004' },
{ id: 'site-008', name: '体检站2', customerId: 'cust-004' },
{ id: 'site-009', name: '健身房1', customerId: 'cust-006' },
]
/**
* 复用 manage 数据集,扁平化为统计列表行(不直接 import manage.ts,避免模块耦合,原型阶段保持独立副本)
* - dataset 不冗余 categoryLabel(末级分类名),由 handleList 按 categoryId 用 CATEGORY_LEAF_NAME 回填
* - 与研发项目对齐:后端在 list 接口按 categoryId join 分类表返回末级分类名
*/
type DatasetRow = Omit<DeviceStatItem, 'categoryLabel'>
const dataset: DatasetRow[] = [
// 智养餐饮设备
{
id: 'dev-c-1', category: 'catering', categoryLabel: CATEGORY_LABEL.catering,
id: 'dev-c-1', category: 'catering', categoryId: 'cat-catering-plate',
name: '一号吐盘机', deviceCode: 'CATERING-PM-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '示范餐饮集团', siteName: '总部食堂', softwareVersion: 'V1.2.0',
deployStatus: 'deployed',
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-005', siteName: '总部食堂',
softwareVersion: 'V1.2.0',
runStatus: 'online', onlineTime: '2026-05-12 10:00:00', deployTime: '2026-05-20 09:00:00',
},
{
id: 'dev-c-2', category: 'catering', categoryLabel: CATEGORY_LABEL.catering,
id: 'dev-c-2', category: 'catering', categoryId: 'cat-catering-cabinet',
name: '智能货柜-B02', deviceCode: 'CATERING-CB-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '示范餐饮集团', siteName: '东区食堂', softwareVersion: 'V2.0.1',
deployStatus: 'deployed',
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-006', siteName: '东区食堂',
softwareVersion: 'V2.0.1',
runStatus: 'online', onlineTime: '2026-05-13 11:00:00', deployTime: '2026-05-22 10:00:00',
},
{
id: 'dev-c-3', category: 'catering', categoryLabel: CATEGORY_LABEL.catering,
id: 'dev-c-3', category: 'catering', categoryId: 'cat-catering-nutri-scale',
name: '营养秤-03', deviceCode: 'CATERING-NS-0003', isIot: true,
deployStatus: 'in_stock', dataDispatch: false, dataReport: false,
tenantName: '', siteName: '', softwareVersion: 'V1.0.5',
deployStatus: 'in_stock',
customerId: '', customerName: '', siteId: '', siteName: '',
softwareVersion: 'V1.0.5',
runStatus: 'offline', onlineTime: '2026-05-14 09:30:00', deployTime: '',
},
{
id: 'dev-c-4', category: 'catering', categoryLabel: CATEGORY_LABEL.catering,
id: 'dev-c-4', category: 'catering', categoryId: 'cat-catering-checkout',
name: '结算台-04', deviceCode: 'CATERING-CO-0004', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: false,
tenantName: '健康机构A', siteName: '体检站1', softwareVersion: 'V1.3.0',
deployStatus: 'deployed',
customerId: 'cust-007', customerName: '健康机构A', siteId: 'site-007', siteName: '体检站1',
softwareVersion: 'V1.3.0',
runStatus: 'repairing', onlineTime: '2026-05-15 14:00:00', deployTime: '2026-05-25 14:00:00',
},
// 体重测量设备
{
id: 'dev-b-1', category: 'body', categoryLabel: CATEGORY_LABEL.body,
id: 'dev-b-1', category: 'body', categoryId: 'cat-body-all-in-one',
name: '体检一体机-01', deviceCode: 'BODY-AIO-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站1', softwareVersion: 'V3.1.0',
deployStatus: 'deployed',
customerId: 'cust-007', customerName: '健康机构A', siteId: 'site-007', siteName: '体检站1',
softwareVersion: 'V3.1.0',
runStatus: 'online', onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-18 09:00:00',
},
{
id: 'dev-b-2', category: 'body', categoryLabel: CATEGORY_LABEL.body,
id: 'dev-b-2', category: 'body', categoryId: 'cat-body-bt-scale',
name: '蓝牙秤-02', deviceCode: 'BODY-BT-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站2', softwareVersion: 'V2.4.0',
deployStatus: 'deployed',
customerId: 'cust-007', customerName: '健康机构A', siteId: 'site-008', siteName: '体检站2',
softwareVersion: 'V2.4.0',
runStatus: 'online', onlineTime: '2026-05-11 10:00:00', deployTime: '2026-05-19 10:00:00',
},
{
id: 'dev-b-3', category: 'body', categoryLabel: CATEGORY_LABEL.body,
id: 'dev-b-3', category: 'body', categoryId: 'cat-body-fat',
name: '体脂仪-03', deviceCode: 'BODY-FAT-0003', isIot: true,
deployStatus: 'in_stock', dataDispatch: false, dataReport: false,
tenantName: '', siteName: '', softwareVersion: 'V1.8.0',
deployStatus: 'in_stock',
customerId: '', customerName: '', siteId: '', siteName: '',
softwareVersion: 'V1.8.0',
runStatus: 'offline', onlineTime: '2026-05-12 11:00:00', deployTime: '',
},
// 智能穿戴设备
{
id: 'dev-w-1', category: 'wearable', categoryLabel: CATEGORY_LABEL.wearable,
id: 'dev-w-1', category: 'wearable', categoryId: 'cat-wearable-hw5',
name: '华为手表5-老人甲', deviceCode: 'WEAR-HW5-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站1', softwareVersion: 'V5.2.0',
deployStatus: 'deployed',
customerId: 'cust-007', customerName: '健康机构A', siteId: 'site-007', siteName: '体检站1',
softwareVersion: 'V5.2.0',
runStatus: 'online', onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
},
{
id: 'dev-w-2', category: 'wearable', categoryLabel: CATEGORY_LABEL.wearable,
id: 'dev-w-2', category: 'wearable', categoryId: 'cat-wearable-hw4',
name: '华为手表4-老人乙', deviceCode: 'WEAR-HW4-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站2', softwareVersion: 'V4.1.0',
deployStatus: 'deployed',
customerId: 'cust-007', customerName: '健康机构A', siteId: 'site-008', siteName: '体检站2',
softwareVersion: 'V4.1.0',
runStatus: 'online', onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-16 09:00:00',
},
{
id: 'dev-w-3', category: 'wearable', categoryLabel: CATEGORY_LABEL.wearable,
id: 'dev-w-3', category: 'wearable', categoryId: 'cat-wearable-ljf01',
name: '罗杰夫-丙', deviceCode: 'WEAR-LJF-0003', isIot: true,
deployStatus: 'deployed', dataDispatch: false, dataReport: true,
tenantName: '示范餐饮集团', siteName: '总部食堂', softwareVersion: 'V1.0.3',
deployStatus: 'deployed',
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-005', siteName: '总部食堂',
softwareVersion: 'V1.0.3',
runStatus: 'repairing', onlineTime: '2026-05-11 09:00:00', deployTime: '2026-05-17 09:00:00',
},
// 健身器材设备
{
id: 'dev-f-1', category: 'fitness', categoryLabel: CATEGORY_LABEL.fitness,
id: 'dev-f-1', category: 'fitness', categoryId: 'cat-fitness-treadmill',
name: '跑步机-01', deviceCode: 'FIT-TM-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健身中心B', siteName: '健身房1', softwareVersion: 'V2.0.0',
deployStatus: 'deployed',
customerId: 'cust-006', customerName: '健身中心B', siteId: 'site-009', siteName: '健身房1',
softwareVersion: 'V2.0.0',
runStatus: 'online', onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
},
{
id: 'dev-f-2', category: 'fitness', categoryLabel: CATEGORY_LABEL.fitness,
id: 'dev-f-2', category: 'fitness', categoryId: 'cat-fitness-elliptical',
name: '椭圆机-02', deviceCode: 'FIT-EL-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: false,
tenantName: '健身中心B', siteName: '健身房1', softwareVersion: 'V1.5.0',
deployStatus: 'deployed',
customerId: 'cust-006', customerName: '健身中心B', siteId: 'site-009', siteName: '健身房1',
softwareVersion: 'V1.5.0',
runStatus: 'online', onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
},
{
id: 'dev-f-3', category: 'fitness', categoryLabel: CATEGORY_LABEL.fitness,
id: 'dev-f-3', category: 'fitness', categoryId: 'cat-fitness-bike',
name: '健身车-03', deviceCode: 'FIT-BK-0003', isIot: true,
deployStatus: 'in_stock', dataDispatch: false, dataReport: false,
tenantName: '', siteName: '', softwareVersion: 'V1.2.0',
deployStatus: 'in_stock',
customerId: '', customerName: '', siteId: '', siteName: '',
softwareVersion: 'V1.2.0',
runStatus: 'offline', onlineTime: '2026-05-10 09:00:00', deployTime: '',
},
// 健康监测设备
{
id: 'dev-m-1', category: 'monitor', categoryLabel: CATEGORY_LABEL.monitor,
id: 'dev-m-1', category: 'monitor', categoryId: 'cat-monitor-sugar',
name: '血糖仪-01', deviceCode: 'MON-SG-0001', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站1', softwareVersion: 'V1.4.0',
deployStatus: 'deployed',
customerId: 'cust-007', customerName: '健康机构A', siteId: 'site-007', siteName: '体检站1',
softwareVersion: 'V1.4.0',
runStatus: 'online', onlineTime: '2026-05-07 09:00:00', deployTime: '2026-05-13 09:00:00',
},
{
id: 'dev-m-2', category: 'monitor', categoryLabel: CATEGORY_LABEL.monitor,
id: 'dev-m-2', category: 'monitor', categoryId: 'cat-monitor-pressure',
name: '高血压治疗仪-02', deviceCode: 'MON-BP-0002', isIot: true,
deployStatus: 'deployed', dataDispatch: true, dataReport: true,
tenantName: '健康机构A', siteName: '体检站2', softwareVersion: 'V2.1.0',
deployStatus: 'deployed',
customerId: 'cust-007', customerName: '健康机构A', siteId: 'site-008', siteName: '体检站2',
softwareVersion: 'V2.1.0',
runStatus: 'online', onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
},
{
id: 'dev-m-3', category: 'monitor', categoryLabel: CATEGORY_LABEL.monitor,
id: 'dev-m-3', category: 'monitor', categoryId: 'cat-monitor-all-in-one',
name: '健康一体机-03', deviceCode: 'MON-AIO-0003', isIot: true,
deployStatus: 'in_stock', dataDispatch: false, dataReport: false,
tenantName: '', siteName: '', softwareVersion: 'V3.0.0',
deployStatus: 'in_stock',
customerId: '', customerName: '', siteId: '', siteName: '',
softwareVersion: 'V3.0.0',
runStatus: 'scrapped', onlineTime: '2026-05-09 09:00:00', deployTime: '',
},
]
/** 字符串包含匹配(空值匹配所有) */
const includes = (src: string, kw: string): boolean => (src ?? '').toLowerCase().includes(kw.toLowerCase())
/** 日期范围匹配(YYYY-MM-DD 字符串比较) */
const inRange = (val: string, start?: string, end?: string): boolean => {
if (!start && !end) return true
@@ -188,33 +275,58 @@ const inRange = (val: string, start?: string, end?: string): boolean => {
return true
}
/** 多维条件过滤 */
const filterData = (params: SearchForm): DeviceStatItem[] => {
/**
* 简化的设备分类树父子关系(与 category mock 同源,原型阶段独立副本避免跨模块耦合)
* - key: 节点 idvalue: 该节点的直接子节点 id 列表
* - 仅承载"父→子"关系,不复制节点业务字段;树结构变更时同步此处即可
*/
const CATEGORY_CHILDREN: Record<string, string[]> = {
'cat-catering': ['cat-catering-plate', 'cat-catering-cabinet', 'cat-catering-nutri-scale', 'cat-catering-ratio-scale', 'cat-catering-checkout', 'cat-catering-inbound-scale', 'cat-catering-band-screen', 'cat-catering-baifei'],
'cat-body': ['cat-body-all-in-one', 'cat-body-bt-scale', 'cat-body-fat'],
'cat-wearable': ['cat-wearable-hw5', 'cat-wearable-hw4', 'cat-wearable-ljf01'],
'cat-fitness': ['cat-fitness-treadmill', 'cat-fitness-elliptical', 'cat-fitness-bike'],
'cat-monitor': ['cat-monitor-sugar', 'cat-monitor-pressure', 'cat-monitor-all-in-one'],
}
/** 收集 nodeId 子树下所有 id(含自身,支持任意层级递归) */
const collectSubtreeIds = (nodeId: string): string[] => {
const result: string[] = [nodeId]
const stack = [...(CATEGORY_CHILDREN[nodeId] ?? [])]
while (stack.length) {
const id = stack.pop() as string
result.push(id)
stack.push(...(CATEGORY_CHILDREN[id] ?? []))
}
return result
}
/** 多维条件过滤(返回 DatasetRow[],未回填 categoryLabel */
const filterData = (params: SearchForm): DatasetRow[] => {
/** 选了分类节点时,预收集子树 id 集合,避免每条数据重复计算 */
const categoryHitIds = params.categoryId ? new Set(collectSubtreeIds(params.categoryId)) : null
return dataset.filter((item) => {
if (params.keyword) {
const kw = params.keyword
if (!includes(item.deviceCode, kw) && !includes(item.name, kw)) return false
}
if (params.category && item.category !== params.category) return false
if (categoryHitIds && !categoryHitIds.has(item.categoryId)) return false
if (params.deployStatus && item.deployStatus !== params.deployStatus) return false
if (params.runStatus && item.runStatus !== params.runStatus) return false
if (params.isIot !== undefined && item.isIot !== params.isIot) return false
if (params.dataDispatch !== undefined && item.dataDispatch !== params.dataDispatch) return false
if (params.dataReport !== undefined && item.dataReport !== params.dataReport) return false
if (params.tenantName && !includes(item.tenantName, params.tenantName)) return false
if (params.siteName && !includes(item.siteName, params.siteName)) return false
if (params.softwareVersion && !includes(item.softwareVersion, params.softwareVersion)) return false
if (params.customerId && item.customerId !== params.customerId) return false
if (params.siteId && item.siteId !== params.siteId) return false
if (params.softwareVersion && item.softwareVersion !== params.softwareVersion) return false
if (!inRange(item.onlineTime, params.onlineStart, params.onlineEnd)) return false
if (!inRange(item.deployTime, params.deployStart, params.deployEnd)) return false
return true
})
}
/** 列表查询 */
/** 列表查询:按 categoryId 回填末级分类名(模拟后端 join 分类表) */
const handleList = (ctx: MockContext): ApiResponse<DeviceStatItem[]> => {
const params = (ctx.params ?? {}) as SearchForm
const rows = filterData(params)
return ok('查询成功', rows)
const result: DeviceStatItem[] = rows.map((r) => ({
...r,
categoryLabel: CATEGORY_LEAF_NAME[r.categoryId] ?? '',
}))
return ok('查询成功', result)
}
/** 顶部统计卡:基于过滤后的数据集动态计算 */
@@ -239,8 +351,45 @@ const handleOverview = (ctx: MockContext): ApiResponse<{ overview: { key: string
/** 异步导出:提交后立即返回任务已接收 */
const handleExport = (): ApiResponse<null> => ok('导出任务已提交,可在"查看导出任务"中查看进度')
/** 所属客户下拉(支持关键字搜索) */
const handleCustomerOptions = (ctx: MockContext): ApiResponse<CustomerOption[]> => {
const params = (ctx.params ?? {}) as { keyword?: string }
const kw = (params.keyword ?? '').toLowerCase()
const list = kw
? customerOptionList.filter((item) => item.name.toLowerCase().includes(kw))
: customerOptionList
return ok('查询成功', list)
}
/** 所属场所下拉(按 customerId 联动 + 关键字搜索) */
const handleSiteOptions = (ctx: MockContext): ApiResponse<SiteOption[]> => {
const params = (ctx.params ?? {}) as { customerId?: string; keyword?: string }
if (!params.customerId) return ok('查询成功', [])
const kw = (params.keyword ?? '').toLowerCase()
const list = siteOptionList.filter(
(item) =>
item.customerId === params.customerId &&
(!kw || item.name.toLowerCase().includes(kw)),
)
return ok('查询成功', list)
}
/** 软件版本下拉(来自数据集 unique 值,支持关键字搜索) */
const handleSoftwareVersionOptions = (ctx: MockContext): ApiResponse<SoftwareVersionOption[]> => {
const params = (ctx.params ?? {}) as { keyword?: string }
const kw = (params.keyword ?? '').toLowerCase()
const uniqueVersions = Array.from(new Set(dataset.map((item) => item.softwareVersion).filter(Boolean)))
const list = uniqueVersions
.filter((v) => !kw || v.toLowerCase().includes(kw))
.map((v) => ({ value: v, label: v }))
return ok('查询成功', list)
}
registerMocks([
{ method: 'POST', url: '/admin/device/statistics/list', handler: handleList },
{ method: 'POST', url: '/admin/device/statistics/overview', handler: handleOverview },
{ method: 'POST', url: '/admin/device/statistics/export', handler: handleExport },
{ method: 'POST', url: '/admin/device/statistics/customer/options', handler: handleCustomerOptions },
{ method: 'POST', url: '/admin/device/statistics/site/options', handler: handleSiteOptions },
{ method: 'POST', url: '/admin/device/statistics/software-version/options', handler: handleSoftwareVersionOptions },
])
@@ -25,6 +25,14 @@ interface ArchiveRecord {
status: boolean
remark?: string
createTime: string
/** 数据分发地址 */
dispatchUrl: string
/** 鉴权账号 */
authAccount: string
/** 鉴权秘钥 */
authSecret: string
/** 鉴权接口地址 */
authApiUrl: string
}
interface ArchiveParams {
@@ -55,14 +63,14 @@ const paginate = <T extends ArchiveRecord>(list: T[], params: ArchiveParams): Ap
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const archiveList: ArchiveRecord[] = [
{ id: 'cust-001', code: 'CQ-001', name: '长庆能源总部食堂', type: 'enterprise', contactName: '王经理', contactPhone: '13900010001', email: 'wang@cq-energy.com', address: '西安市未央区凤城八路 1 号', expireTime: '2027-06-30', appCount: 5, siteCount: 3, status: true, remark: '集团总部客户', createTime: '2026-04-08 09:18:30' },
{ id: 'cust-002', code: 'OILF-01', name: '第一采油厂', type: 'enterprise', contactName: '刘主管', contactPhone: '13900010002', email: 'liu@oilfield-01.com', address: '延安市宝塔区河庄坪', expireTime: '2027-05-31', appCount: 4, siteCount: 2, status: true, createTime: '2026-04-14 10:24:48' },
{ id: 'cust-003', code: 'REF-01', name: '炼化分公司', type: 'enterprise', contactName: '陈主任', contactPhone: '13900010003', email: 'chen@refining.com', address: '兰州市西固区福利路 88 号', expireTime: '2026-12-31', appCount: 3, siteCount: 2, status: true, createTime: '2026-04-16 14:08:11' },
{ id: 'cust-004', code: 'HP-001', name: '健康示范园区', type: 'gov', contactName: '张医生', contactPhone: '13900010004', email: 'zhang@health-park.gov', address: '成都市高新区天府三街 199 号', expireTime: '2026-10-31', appCount: 6, siteCount: 2, status: false, remark: '园区示范项目', createTime: '2026-04-18 16:45:09' },
{ id: 'cust-005', code: 'EDU-001', name: '江南大学后勤处', type: 'edu', contactName: '李老师', contactPhone: '13900010005', email: 'li@jiangnan-edu.cn', address: '无锡市滨湖区蠡湖大道 1800 号', expireTime: '2027-09-01', appCount: 2, siteCount: 4, status: true, createTime: '2026-04-22 11:33:24' },
{ id: 'cust-006', code: 'MED-001', name: '市第一人民医院', type: 'medical', contactName: '孙主任', contactPhone: '13900010006', email: 'sun@first-hospital.com', address: '南京市鼓楼区中山路 321 号', expireTime: '2027-03-15', appCount: 3, siteCount: 1, status: true, createTime: '2026-04-25 09:12:46' },
{ id: 'cust-007', code: 'GOV-001', name: '市民政局养老服务科', type: 'gov', contactName: '周科长', contactPhone: '13900010007', email: 'zhou@civil-affairs.gov', address: '武汉市江岸区沿江大道 188 号', expireTime: '2027-08-31', appCount: 1, siteCount: 5, status: true, remark: '社区养老站点', createTime: '2026-04-28 15:47:18' },
{ id: 'cust-008', code: 'OTHER-001', name: '锦绣物业管理公司', type: 'other', contactName: '吴经理', contactPhone: '13900010008', email: 'wu@jinxiu-prop.com', address: '苏州市姑苏区干将东路 88 号', expireTime: '2026-11-30', appCount: 2, siteCount: 3, status: true, createTime: '2026-05-02 08:55:32' },
{ id: 'cust-001', code: 'CQ-001', name: '长庆能源总部食堂', type: 'enterprise', contactName: '王经理', contactPhone: '13900010001', email: 'wang@cq-energy.com', address: '西安市未央区凤城八路 1 号', expireTime: '2027-06-30', appCount: 5, siteCount: 3, status: true, remark: '集团总部客户', createTime: '2026-04-08 09:18:30', dispatchUrl: 'https://dispatch.cq-energy.com/v1/data', authAccount: 'cq_dispatch', authSecret: 'Cq#2026$Dispatch', authApiUrl: 'https://auth.cq-energy.com/v1/token' },
{ id: 'cust-002', code: 'OILF-01', name: '第一采油厂', type: 'enterprise', contactName: '刘主管', contactPhone: '13900010002', email: 'liu@oilfield-01.com', address: '延安市宝塔区河庄坪', expireTime: '2027-05-31', appCount: 4, siteCount: 2, status: true, createTime: '2026-04-14 10:24:48', dispatchUrl: 'https://dispatch.oilfield-01.com/v1/data', authAccount: 'oilf_dispatch', authSecret: 'Oilf#2026$Key', authApiUrl: 'https://auth.oilfield-01.com/v1/token' },
{ id: 'cust-003', code: 'REF-01', name: '炼化分公司', type: 'enterprise', contactName: '陈主任', contactPhone: '13900010003', email: 'chen@refining.com', address: '兰州市西固区福利路 88 号', expireTime: '2026-12-31', appCount: 3, siteCount: 2, status: true, createTime: '2026-04-16 14:08:11', dispatchUrl: 'https://dispatch.refining.com/v1/data', authAccount: 'ref_dispatch', authSecret: 'Ref#2026$Secret', authApiUrl: 'https://auth.refining.com/v1/token' },
{ id: 'cust-004', code: 'HP-001', name: '健康示范园区', type: 'gov', contactName: '张医生', contactPhone: '13900010004', email: 'zhang@health-park.gov', address: '成都市高新区天府三街 199 号', expireTime: '2026-10-31', appCount: 6, siteCount: 2, status: false, remark: '园区示范项目', createTime: '2026-04-18 16:45:09', dispatchUrl: 'https://dispatch.health-park.gov/v1/data', authAccount: 'hp_dispatch', authSecret: 'Hp#2026$Park', authApiUrl: 'https://auth.health-park.gov/v1/token' },
{ id: 'cust-005', code: 'EDU-001', name: '江南大学后勤处', type: 'edu', contactName: '李老师', contactPhone: '13900010005', email: 'li@jiangnan-edu.cn', address: '无锡市滨湖区蠡湖大道 1800 号', expireTime: '2027-09-01', appCount: 2, siteCount: 4, status: true, createTime: '2026-04-22 11:33:24', dispatchUrl: 'https://dispatch.jiangnan-edu.cn/v1/data', authAccount: 'edu_dispatch', authSecret: 'Edu#2026$Jn', authApiUrl: 'https://auth.jiangnan-edu.cn/v1/token' },
{ id: 'cust-006', code: 'MED-001', name: '市第一人民医院', type: 'medical', contactName: '孙主任', contactPhone: '13900010006', email: 'sun@first-hospital.com', address: '南京市鼓楼区中山路 321 号', expireTime: '2027-03-15', appCount: 3, siteCount: 1, status: true, createTime: '2026-04-25 09:12:46', dispatchUrl: 'https://dispatch.first-hospital.com/v1/data', authAccount: 'med_dispatch', authSecret: 'Med#2026$Hosp', authApiUrl: 'https://auth.first-hospital.com/v1/token' },
{ id: 'cust-007', code: 'GOV-001', name: '市民政局养老服务科', type: 'gov', contactName: '周科长', contactPhone: '13900010007', email: 'zhou@civil-affairs.gov', address: '武汉市江岸区沿江大道 188 号', expireTime: '2027-08-31', appCount: 1, siteCount: 5, status: true, remark: '社区养老站点', createTime: '2026-04-28 15:47:18', dispatchUrl: 'https://dispatch.civil-affairs.gov/v1/data', authAccount: 'gov_dispatch', authSecret: 'Gov#2026$Civil', authApiUrl: 'https://auth.civil-affairs.gov/v1/token' },
{ id: 'cust-008', code: 'OTHER-001', name: '锦绣物业管理公司', type: 'other', contactName: '吴经理', contactPhone: '13900010008', email: 'wu@jinxiu-prop.com', address: '苏州市姑苏区干将东路 88 号', expireTime: '2026-11-30', appCount: 2, siteCount: 3, status: true, createTime: '2026-05-02 08:55:32', dispatchUrl: 'https://dispatch.jinxiu-prop.com/v1/data', authAccount: 'other_dispatch', authSecret: 'Other#2026$Jx', authApiUrl: 'https://auth.jinxiu-prop.com/v1/token' },
]
/** 关键词搜索:编码 / 名称 / 联系人 */
@@ -115,6 +123,10 @@ const addArchive = (ctx: MockContext): ApiResponse<null> => {
status: typeof params.status === 'boolean' ? params.status : true,
remark: params.remark ? String(params.remark) : undefined,
createTime,
dispatchUrl: '',
authAccount: '',
authSecret: '',
authApiUrl: '',
}
archiveList.unshift(record)
return ok('新增成功')
@@ -3,27 +3,40 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 布点场所 mock 数据
* - 场所与租户为 N:1 关系同一户可拥有多个场所
* - customFlag 为运营自定义标识,用于数据中转寻址 / 同名场所区分
* - clientHeartbeat / mqHeartbeat 为只读展示,由设备中转上报回填
* - 场所与客户档案(archive为 N:1 关系同一户可拥有多个场所
* - customFlag 为运营自定义标识,用于数据中转寻址 / 同名场所区分(示例样式 A01A02A02
* - hasFrontendServer 标识是否部署前置服务器,仅 true 时详情额外展示「数据上报配置」
*/
interface SiteRecord {
id: string
name: string
code: string
tenantId: string
tenantName: string
/** 所属客户 ID */
customerId: string
/** 所属客户名称(联表回填) */
customerName: string
department?: string
address?: string
contactName?: string
contactPhone?: string
siteType: string
customFlag: string
clientHeartbeat: 'online' | 'offline'
mqHeartbeat: 'online' | 'offline'
/** 是否部署前置服务器 */
hasFrontendServer: boolean
/** 前置服务器状态(hasFrontendServer=true 时有意义) */
frontendServerStatus?: 'online' | 'offline'
/** 最后心跳时间 */
lastHeartbeatTime?: string
/** MQ 通讯地址 */
mqAddress?: string
/** 业务服务请求地址 */
businessServiceUrl?: string
/** 已挂载设备数 */
deviceCount: number
/** 启用状态 */
status: boolean
/** 创建时间 */
createTime: string
}
@@ -33,7 +46,7 @@ interface SiteParams {
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
tenantId?: string
customerId?: string
siteType?: string
status?: boolean
[key: string]: unknown
@@ -57,52 +70,64 @@ const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ co
const siteList: SiteRecord[] = [
{
id: 'site-001', name: '长庆总部一食堂', code: 'CQ-CTN-01', tenantId: 'tenant-001', tenantName: '长庆能源总部食堂',
id: 'site-001', name: '长庆总部一食堂', code: 'CQ-CTN-01', customerId: 'cust-001', customerName: '长庆能源总部食堂',
department: '总部后勤', address: '西安市未央区凤城八路 1 号', contactName: '王经理', contactPhone: '13900010001',
siteType: 'canteen', customFlag: 'CQ-HQ-CANTEEN-01',
clientHeartbeat: 'online', mqHeartbeat: 'online', deviceCount: 12, status: true, createTime: '2026-05-12 09:18:30',
siteType: 'canteen', customFlag: 'A01A02A01',
hasFrontendServer: true, frontendServerStatus: 'online', lastHeartbeatTime: '2026-07-08 09:18:30',
mqAddress: 'mq://10.10.1.21:61616', businessServiceUrl: 'http://10.10.1.22:8080/api',
deviceCount: 12, status: true, createTime: '2026-05-12 09:18:30',
},
{
id: 'site-002', name: '采油一厂食堂', code: 'OILF-01-CTN', tenantId: 'tenant-002', tenantName: '第一采油厂',
id: 'site-002', name: '采油一厂食堂', code: 'OILF-01-CTN', customerId: 'cust-002', customerName: '第一采油厂',
department: '采油厂综合办', address: '延安市宝塔区河庄坪', contactName: '刘主管', contactPhone: '13900010002',
siteType: 'canteen', customFlag: 'OILF-01-CANTEEN',
clientHeartbeat: 'online', mqHeartbeat: 'offline', deviceCount: 8, status: true, createTime: '2026-05-14 10:24:48',
siteType: 'canteen', customFlag: 'B02A03A02',
hasFrontendServer: true, frontendServerStatus: 'offline', lastHeartbeatTime: '2026-07-06 14:22:51',
mqAddress: 'mq://10.20.2.11:61616', businessServiceUrl: 'http://10.20.2.12:8080/api',
deviceCount: 8, status: true, createTime: '2026-05-14 10:24:48',
},
{
id: 'site-003', name: '炼化员工餐厅', code: 'REF-DIN-01', tenantId: 'tenant-003', tenantName: '炼化分公司',
id: 'site-003', name: '炼化员工餐厅', code: 'REF-DIN-01', customerId: 'cust-003', customerName: '炼化分公司',
department: '行政服务中心', address: '兰州市西固区福利路 88 号', contactName: '陈主任', contactPhone: '13900010003',
siteType: 'restaurant', customFlag: 'REF-DINING-01',
clientHeartbeat: 'offline', mqHeartbeat: 'offline', deviceCount: 6, status: true, createTime: '2026-05-16 14:08:11',
siteType: 'restaurant', customFlag: 'C03B02A01',
hasFrontendServer: false,
deviceCount: 6, status: true, createTime: '2026-05-16 14:08:11',
},
{
id: 'site-004', name: '健康园区体检站', code: 'HP-CHK-01', tenantId: 'tenant-004', tenantName: '健康示范园区',
id: 'site-004', name: '健康园区体检站', code: 'HP-CHK-01', customerId: 'cust-004', customerName: '健康示范园区',
department: '园区医务部', address: '成都市高新区天府三街 199 号', contactName: '张医生', contactPhone: '13900010004',
siteType: 'checkup-station', customFlag: 'HP-CHKUP-01',
clientHeartbeat: 'online', mqHeartbeat: 'online', deviceCount: 4, status: true, createTime: '2026-05-18 16:45:09',
siteType: 'checkup-station', customFlag: 'D04A01A03',
hasFrontendServer: true, frontendServerStatus: 'online', lastHeartbeatTime: '2026-07-08 10:05:14',
mqAddress: 'mq://10.30.3.31:61616', businessServiceUrl: 'http://10.30.3.32:8080/api',
deviceCount: 4, status: true, createTime: '2026-05-18 16:45:09',
},
{
id: 'site-005', name: '总部体重测量点 A', code: 'CQ-MSR-A', tenantId: 'tenant-001', tenantName: '长庆能源总部食堂',
id: 'site-005', name: '总部体重测量点 A', code: 'CQ-MSR-A', customerId: 'cust-001', customerName: '长庆能源总部食堂',
department: '总部后勤', address: '西安市未央区凤城八路 1 号 二楼', contactName: '李护士', contactPhone: '13900010005',
siteType: 'measure-point', customFlag: 'CQ-HQ-MEASURE-A',
clientHeartbeat: 'online', mqHeartbeat: 'online', deviceCount: 2, status: true, createTime: '2026-05-22 11:33:24',
siteType: 'measure-point', customFlag: 'A01B02A02',
hasFrontendServer: false,
deviceCount: 2, status: true, createTime: '2026-05-22 11:33:24',
},
{
id: 'site-006', name: '园区员工健身房', code: 'HP-GYM-01', tenantId: 'tenant-004', tenantName: '健康示范园区',
id: 'site-006', name: '园区员工健身房', code: 'HP-GYM-01', customerId: 'cust-004', customerName: '健康示范园区',
department: '园区工会', address: '成都市高新区天府三街 199 号 B 栋', contactName: '吴教练', contactPhone: '13900010006',
siteType: 'gym', customFlag: 'HP-GYM-01',
clientHeartbeat: 'offline', mqHeartbeat: 'online', deviceCount: 5, status: false, createTime: '2026-05-25 09:12:46',
siteType: 'gym', customFlag: 'D04C03A01',
hasFrontendServer: false,
deviceCount: 5, status: false, createTime: '2026-05-25 09:12:46',
},
{
id: 'site-007', name: '采油一厂社区站', code: 'OILF-01-COM', tenantId: 'tenant-002', tenantName: '第一采油厂',
id: 'site-007', name: '采油一厂社区站', code: 'OILF-01-COM', customerId: 'cust-002', customerName: '第一采油厂',
department: '矿区社区服务', address: '延安市宝塔区河庄坪社区', contactName: '孙社工', contactPhone: '13900010007',
siteType: 'community-station', customFlag: 'OILF-01-COMMUNITY',
clientHeartbeat: 'online', mqHeartbeat: 'online', deviceCount: 3, status: true, createTime: '2026-05-28 15:47:18',
siteType: 'community-station', customFlag: 'B02A04A01',
hasFrontendServer: true, frontendServerStatus: 'online', lastHeartbeatTime: '2026-07-08 08:51:02',
mqAddress: 'mq://10.20.4.21:61616', businessServiceUrl: 'http://10.20.4.22:8080/api',
deviceCount: 3, status: true, createTime: '2026-05-28 15:47:18',
},
{
id: 'site-008', name: '炼化二食堂', code: 'REF-CTN-02', tenantId: 'tenant-003', tenantName: '炼化分公司',
id: 'site-008', name: '炼化二食堂', code: 'REF-CTN-02', customerId: 'cust-003', customerName: '炼化分公司',
department: '行政服务中心', address: '兰州市西固区福利路 100 号', contactName: '周班长', contactPhone: '13900010008',
siteType: 'canteen', customFlag: 'REF-CANTEEN-02',
clientHeartbeat: 'online', mqHeartbeat: 'offline', deviceCount: 9, status: true, createTime: '2026-06-02 08:55:32',
siteType: 'canteen', customFlag: 'C03A02A02',
hasFrontendServer: false,
deviceCount: 9, status: true, createTime: '2026-06-02 08:55:32',
},
]
@@ -116,7 +141,7 @@ const listSite = (ctx: MockContext): ApiResponse<SiteRecord[]> => {
const params = getParams(ctx.params)
const filtered = siteList.filter((item) => {
if (!matchKeyword(item, params.keyword)) return false
if (params.tenantId && item.tenantId !== params.tenantId) return false
if (params.customerId && item.customerId !== params.customerId) return false
if (params.siteType && item.siteType !== params.siteType) return false
if (typeof params.status === 'boolean' && item.status !== params.status) return false
return true
@@ -130,32 +155,35 @@ const listSite = (ctx: MockContext): ApiResponse<SiteRecord[]> => {
return paginate(filtered, params)
}
/** 租户字典(与 tenant.ts mock 同源,用于 tenantId → tenantName 反查) */
const tenantDict: Record<string, string> = {
'tenant-001': '长庆能源总部食堂',
'tenant-002': '第一采油厂',
'tenant-003': '炼化分公司',
'tenant-004': '健康示范园区',
/** 客户档案字典(与 archive.ts mock 同源,用于 customerId → customerName 反查) */
const customerDict: Record<string, string> = {
'cust-001': '长庆能源总部食堂',
'cust-002': '第一采油厂',
'cust-003': '炼化分公司',
'cust-004': '健康示范园区',
'cust-005': '江南大学后勤处',
'cust-006': '市第一人民医院',
'cust-007': '市民政局养老服务科',
'cust-008': '锦绣物业管理公司',
}
const addSite = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = `site-${String(siteList.length + 1).padStart(3, '0')}`
const tenantId = String(params.tenantId ?? '')
const customerId = String(params.customerId ?? '')
const record: SiteRecord = {
id,
name: String(params.name ?? ''),
code: String(params.code ?? ''),
tenantId,
tenantName: params.tenantName ? String(params.tenantName) : (tenantDict[tenantId] ?? ''),
customerId,
customerName: params.customerName ? String(params.customerName) : (customerDict[customerId] ?? ''),
department: params.department ? String(params.department) : undefined,
address: params.address ? String(params.address) : undefined,
contactName: params.contactName ? String(params.contactName) : undefined,
contactPhone: params.contactPhone ? String(params.contactPhone) : undefined,
siteType: String(params.siteType ?? ''),
customFlag: String(params.customFlag ?? ''),
clientHeartbeat: 'offline',
mqHeartbeat: 'offline',
hasFrontendServer: false,
deviceCount: 0,
status: typeof params.status === 'boolean' ? params.status : true,
createTime: new Date().toISOString().replace('T', ' ').slice(0, 19),
@@ -171,10 +199,10 @@ const updateSite = (ctx: MockContext): ApiResponse<null> => {
if (!target) return ok('记录不存在', null)
if (params.name !== undefined) target.name = String(params.name)
if (params.code !== undefined) target.code = String(params.code)
if (params.tenantId !== undefined) {
const tid = String(params.tenantId)
target.tenantId = tid
target.tenantName = params.tenantName ? String(params.tenantName) : (tenantDict[tid] ?? target.tenantName)
if (params.customerId !== undefined) {
const cid = String(params.customerId)
target.customerId = cid
target.customerName = params.customerName ? String(params.customerName) : (customerDict[cid] ?? target.customerName)
}
target.department = params.department !== undefined ? String(params.department) : target.department
target.address = params.address !== undefined ? String(params.address) : target.address
@@ -114,10 +114,26 @@ const updateRecord = (params: PageParams): ApiResponse<null> => {
return ok('更新成功')
}
/** 供应商下拉(仅返回合作中的供应商,支持关键字搜索,供入库管理等模块复用) */
interface SupplierOption {
id: string
name: string
}
const handleSupplierOptions = (ctx: MockContext): ApiResponse<SupplierOption[]> => {
const params = getParams(ctx.params)
const kw = (params.keyword ?? '').toLowerCase()
const list = supplierList
.filter((item) => item.cooperationStatus === 'active')
.map((item) => ({ id: item.id, name: item.name }))
.filter((item) => (kw ? item.name.toLowerCase().includes(kw) : true))
return ok('查询成功', list)
}
registerMocks([
{ method: 'POST', url: '/admin/device/customer/supplier/page', handler: listPage },
{ method: 'POST', url: '/admin/device/customer/supplier/info', handler: (ctx) => infoById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/customer/supplier/add', handler: (ctx) => addRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/customer/supplier/update', handler: (ctx) => updateRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/customer/supplier/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/customer/supplier/options', handler: handleSupplierOptions },
])
@@ -0,0 +1,93 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 公共导出任务 mock 数据
*
* 设计:所有模块的"查看导出任务"抽屉统一走 /admin/device/export-tasks/list 接口
* 各模块通过 moduleKey 区分(sim / stock-inbound / stock-outbound / stock-instock / ...
* mock 数据按 moduleKey 返回不同前缀的任务名,模拟真实业务隔离
*/
type ExportTaskStatus = 'pending' | 'processing' | 'success' | 'failed'
interface ExportTaskRecord {
id: string
taskName: string
status: ExportTaskStatus
submitter: string
submitTime: string
finishTime: string
rowCount: number
fileSize: string
failReason?: string
}
interface ExportTaskParams {
moduleKey?: string
[key: string]: unknown
}
const getParams = (params: RequestParameter): ExportTaskParams => (params ?? {}) as ExportTaskParams
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
code: '00000',
msg,
data,
})
/** 各 moduleKey 对应的任务名前缀(用于 mock 数据展示) */
const TASK_NAME_PREFIX: Record<string, string> = {
sim: 'SIM卡列表',
arcsoft: '虹软授权列表',
'stock-inbound': '入库单列表',
'stock-outbound': '出库单列表',
'stock-instock': '在库库存列表',
'stock-inventory': '盘点清单列表',
}
/** 各 moduleKey 对应的 mock 任务数据集 */
const TASK_DATASET: Record<string, ExportTaskRecord[]> = {
sim: [
{ id: 'exp-sim-001', taskName: 'SIM卡列表_20260702_093000', status: 'success', submitter: '张敏', submitTime: '2026-07-02 09:30:00', finishTime: '2026-07-02 09:30:12', rowCount: 8, fileSize: '24.6 KB' },
{ id: 'exp-sim-002', taskName: 'SIM卡列表_20260705_145200', status: 'processing', submitter: '李强', submitTime: '2026-07-05 14:52:00', finishTime: '—', rowCount: 0, fileSize: '—' },
{ id: 'exp-sim-003', taskName: 'SIM卡列表_20260708_081500', status: 'failed', submitter: '王勇', submitTime: '2026-07-08 08:15:00', finishTime: '2026-07-08 08:15:08', rowCount: 0, fileSize: '—', failReason: '导出超时,请缩小查询范围后重试' },
],
arcsoft: [
{ id: 'exp-arc-001', taskName: '虹软授权列表_20260702_101500', status: 'success', submitter: '张敏', submitTime: '2026-07-02 10:15:00', finishTime: '2026-07-02 10:15:09', rowCount: 8, fileSize: '21.3 KB' },
{ id: 'exp-arc-002', taskName: '虹软授权列表_20260705_163000', status: 'processing', submitter: '李强', submitTime: '2026-07-05 16:30:00', finishTime: '—', rowCount: 0, fileSize: '—' },
{ id: 'exp-arc-003', taskName: '虹软授权列表_20260708_092000', status: 'failed', submitter: '王勇', submitTime: '2026-07-08 09:20:00', finishTime: '2026-07-08 09:20:07', rowCount: 0, fileSize: '—', failReason: '查询条件超限,请缩小时间范围后重试' },
],
'stock-inbound': [
{ id: 'exp-inb-001', taskName: '入库单列表_20260703_101500', status: 'success', submitter: '张敏', submitTime: '2026-07-03 10:15:00', finishTime: '2026-07-03 10:15:09', rowCount: 6, fileSize: '18.2 KB' },
{ id: 'exp-inb-002', taskName: '入库单列表_20260706_143000', status: 'processing', submitter: '李强', submitTime: '2026-07-06 14:30:00', finishTime: '—', rowCount: 0, fileSize: '—' },
],
'stock-outbound': [
{ id: 'exp-out-001', taskName: '出库单列表_20260704_112000', status: 'success', submitter: '王勇', submitTime: '2026-07-04 11:20:00', finishTime: '2026-07-04 11:20:11', rowCount: 5, fileSize: '15.8 KB' },
{ id: 'exp-out-002', taskName: '出库单列表_20260707_090500', status: 'failed', submitter: '李强', submitTime: '2026-07-07 09:05:00', finishTime: '2026-07-07 09:05:07', rowCount: 0, fileSize: '—', failReason: '查询条件超限,请缩小时间范围后重试' },
],
'stock-instock': [
{ id: 'exp-inst-001', taskName: '在库库存列表_20260705_160000', status: 'success', submitter: '张敏', submitTime: '2026-07-05 16:00:00', finishTime: '2026-07-05 16:00:08', rowCount: 12, fileSize: '32.5 KB' },
],
'stock-inventory': [
{ id: 'exp-inv-001', taskName: '盘点清单列表_20260706_173000', status: 'success', submitter: '王勇', submitTime: '2026-07-06 17:30:00', finishTime: '2026-07-06 17:30:14', rowCount: 20, fileSize: '45.7 KB' },
],
}
/** 列表查询:按 moduleKey 过滤;未识别的 moduleKey 返回空列表 */
const listExportTasks = (ctx: MockContext): ApiResponse<ExportTaskRecord[]> => {
const params = getParams(ctx.params)
const moduleKey = params.moduleKey ?? ''
const prefix = TASK_NAME_PREFIX[moduleKey] ?? '导出任务'
const dataset = TASK_DATASET[moduleKey] ?? []
// 标准化:保证未配置前缀时也有合理的任务名展示
const standardized = dataset.map((item) => ({
...item,
taskName: item.taskName.startsWith(prefix) ? item.taskName : `${prefix}_${item.id}`,
}))
return ok('查询成功', standardized)
}
registerMocks([
{ method: 'POST', url: '/admin/device/export-tasks/list', handler: listExportTasks },
])
@@ -9,14 +9,16 @@ interface PageParams {
keyword?: string
status?: string
inventoryType?: string
warehouseId?: string
warehouseAreaId?: string
id?: string
remark?: string
categoryIds?: string[]
[key: string]: unknown
}
interface InventoryItem {
deviceCode: string
deviceName: string
modelName: string
categoryId: string
categoryName: string
batchNo: string
expectedQuantity: number
actualQuantity: number
@@ -26,8 +28,8 @@ interface InventoryItem {
interface InventoryRecord {
id: string
inventoryCode: string
warehouseId: string
warehouseName: string
warehouseAreaId: string
warehouseAreaName: string
inventoryType: 'full' | 'partial'
operator: string
inventoryTime: string
@@ -55,28 +57,79 @@ const paginate = <T extends InventoryRecord>(list: T[], params: PageParams): Api
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
/** 生成应盘清单(mock:固定模板,每条记录复用 */
const buildItems = (surplus: number, loss: number, unchecked: number, total: number): InventoryItem[] => {
/** 仓库区域末级节点 ID → 祖先链名称映射(与 warehouse mock 同源 */
const WAREHOUSE_AREA_NAME: Record<string, string> = {
'wh-floor1-large-a-1': '1楼大型A号货架-1号储位',
'wh-floor1-large-a-2': '1楼大型A号货架-2号储位',
'wh-floor1-large-b-1': '1楼大型B号货架-1号储位',
'wh-floor1-small-shelf-1': '1楼小型备品货架-1号储位',
'wh-floor1-small-shelf-2': '1楼小型备品货架-2号储位',
'wh-floor2-check-1': '2楼待检区-临时储位',
}
const resolveWarehouseAreaName = (id?: string): string => (id ? WAREHOUSE_AREA_NAME[id] ?? '' : '')
/** 设备类别末级 ID → 名称映射(与 statistics/category mock 同源) */
const CATEGORY_LEAF_NAME: Record<string, string> = {
'cat-catering-plate': '吐盘机',
'cat-catering-cabinet': '智能货柜',
'cat-catering-nutri-scale': '营养秤',
'cat-catering-ratio-scale': '配比秤',
'cat-catering-checkout': '结算台',
'cat-catering-inbound-scale': '入库秤',
'cat-catering-band-screen': '带鱼屏',
'cat-catering-baifei': '波菲炉',
'cat-body-all-in-one': '一体机',
'cat-body-bt-scale': '蓝牙秤',
'cat-body-fat': '体脂仪',
'cat-wearable-hw5': '华为-watch5',
'cat-wearable-hw4': '华为-watch4',
'cat-wearable-ljf01': '罗杰夫-LJF01',
'cat-fitness-treadmill': '跑步机',
'cat-fitness-elliptical': '椭圆机',
'cat-fitness-bike': '健身车',
'cat-monitor-sugar': '血糖监测仪',
'cat-monitor-pressure': '高血压治疗仪',
'cat-monitor-all-in-one': '智能健康一体机',
}
/**
* 生成应盘清单模板(按"末级分类 + 批次"聚合,mock 用固定模板)
* - 不传 selectedCategoryIds:全量盘点,从全部分类中循环取
* - 传 selectedCategoryIds:部分盘点,仅按所选品类生成(每品类展开 1 条)
*/
const buildItems = (
surplus: number,
loss: number,
unchecked: number,
total: number,
selectedCategoryIds?: string[],
): InventoryItem[] => {
const allCategoryIds = Object.keys(CATEGORY_LEAF_NAME)
const useCategoryIds = (selectedCategoryIds && selectedCategoryIds.length)
? selectedCategoryIds
: allCategoryIds
const items: InventoryItem[] = []
for (let i = 0; i < total; i++) {
let actual = 1
const categoryId = useCategoryIds[i % useCategoryIds.length]
const expected = ((i + 1) % 5) + 1
let actual = expected
let result: InventoryItem['result'] = 'matched'
if (i < surplus) {
actual = 2
actual = expected + 1
result = 'surplus'
} else if (i < surplus + loss) {
actual = 0
actual = Math.max(0, expected - 1)
result = 'loss'
} else if (i < surplus + loss + unchecked) {
actual = 0
result = 'unchecked'
}
items.push({
deviceCode: `DEV-${String(i + 1).padStart(4, '0')}`,
deviceName: `设备${i + 1}`,
modelName: ['智能货柜', '蓝牙秤', '体脂仪', '入库秤'][i % 4],
categoryId,
categoryName: CATEGORY_LEAF_NAME[categoryId] ?? '',
batchNo: `B2026-${String((i % 8) + 1).padStart(3, '0')}`,
expectedQuantity: 1,
expectedQuantity: expected,
actualQuantity: actual,
result,
})
@@ -86,49 +139,49 @@ const buildItems = (surplus: number, loss: number, unchecked: number, total: num
const inventoryList: InventoryRecord[] = [
{
id: 'inv-001', inventoryCode: 'PD-20260601-001', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库',
id: 'inv-001', inventoryCode: 'PD-20260601-001', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位',
inventoryType: 'full', operator: '李工程师', inventoryTime: '2026-06-01 14:00:00', status: 'audited',
expectedCount: 12, actualCount: 11, surplusCount: 0, lossCount: 1, auditor: '王主管', auditTime: '2026-06-01 18:30:00',
remark: '盘亏 1 已转入工单核查', items: buildItems(0, 1, 0, 12),
remark: '盘亏 1 已转入工单核查', items: buildItems(0, 1, 0, 12),
},
{
id: 'inv-002', inventoryCode: 'PD-20260610-002', warehouseId: 'wh-floor2', warehouseName: '二楼备品仓库',
id: 'inv-002', inventoryCode: 'PD-20260610-002', warehouseAreaId: 'wh-floor1-small-shelf-1', warehouseAreaName: '1楼小型备品货架-1号储位',
inventoryType: 'partial', operator: '张工程师', inventoryTime: '2026-06-10 10:15:00', status: 'audited',
expectedCount: 8, actualCount: 9, surplusCount: 1, lossCount: 0, auditor: '王主管', auditTime: '2026-06-10 16:00:00',
remark: '盘盈 1 入库登记', items: buildItems(1, 0, 0, 8),
remark: '盘盈 1 入库登记', items: buildItems(1, 0, 0, 8),
},
{
id: 'inv-003', inventoryCode: 'PD-20260620-003', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库',
id: 'inv-003', inventoryCode: 'PD-20260620-003', warehouseAreaId: 'wh-floor1-large-a-2', warehouseAreaName: '1楼大型A号货架-2号储位',
inventoryType: 'full', operator: '李工程师', inventoryTime: '2026-06-20 15:30:00', status: 'completed',
expectedCount: 15, actualCount: 13, surplusCount: 0, lossCount: 2,
remark: '待审核,盘亏 2 待核查', items: buildItems(0, 2, 0, 15),
remark: '待审核,盘亏 2 待核查', items: buildItems(0, 2, 0, 15),
},
{
id: 'inv-004', inventoryCode: 'PD-20260625-004', warehouseId: 'wh-floor1-large', warehouseName: '大型设备区',
id: 'inv-004', inventoryCode: 'PD-20260625-004', warehouseAreaId: 'wh-floor1-large-b-1', warehouseAreaName: '1楼大型B号货架-1号储位',
inventoryType: 'partial', operator: '陈工程师', inventoryTime: '2026-06-25 09:00:00', status: 'completed',
expectedCount: 6, actualCount: 5, surplusCount: 1, lossCount: 2,
remark: '待审核', items: buildItems(1, 2, 0, 6),
},
{
id: 'inv-005', inventoryCode: 'PD-20260628-005', warehouseId: 'wh-floor2-check', warehouseName: '待检区',
id: 'inv-005', inventoryCode: 'PD-20260628-005', warehouseAreaId: 'wh-floor2-check-1', warehouseAreaName: '2楼待检区-临时储位',
inventoryType: 'full', operator: '张工程师', inventoryTime: '2026-06-28 11:45:00', status: 'completed',
expectedCount: 4, actualCount: 4, surplusCount: 0, lossCount: 0,
remark: '本期盘点结果一致', items: buildItems(0, 0, 0, 4),
},
{
id: 'inv-006', inventoryCode: 'PD-20260701-006', warehouseId: 'wh-floor1-small', warehouseName: '小型设备区',
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',
expectedCount: 10, actualCount: 0, surplusCount: 0, lossCount: 0,
remark: '等待小程序扫码盘点', items: buildItems(0, 0, 10, 10),
},
{
id: 'inv-007', inventoryCode: 'PD-20260703-007', warehouseId: 'wh-floor1-large-a', warehouseName: 'A号货架',
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',
expectedCount: 8, actualCount: 0, surplusCount: 0, lossCount: 0,
remark: '本周新发起', items: buildItems(0, 0, 8, 8),
},
{
id: 'inv-008', inventoryCode: 'PD-20260705-008', warehouseId: 'wh-floor2', warehouseName: '二楼备品仓库',
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',
expectedCount: 5, actualCount: 0, surplusCount: 0, lossCount: 0,
items: buildItems(0, 0, 5, 5),
@@ -140,7 +193,7 @@ const listPage = (ctx: MockContext): ApiResponse<InventoryRecord[]> => {
const filtered = inventoryList.filter((item) => {
if (params.status && item.status !== params.status) return false
if (params.inventoryType && item.inventoryType !== params.inventoryType) return false
if (params.warehouseId && item.warehouseId !== params.warehouseId) return false
if (params.warehouseAreaId && item.warehouseAreaId !== params.warehouseAreaId) return false
if (params.keyword && !includes(item.inventoryCode, params.keyword)) return false
return true
})
@@ -162,19 +215,6 @@ const infoById = (params: PageParams): ApiResponse<InventoryRecord | null> => {
return ok('查询成功', JSON.parse(JSON.stringify(record)))
}
/** 仓库下拉(mock 简化:固定 6 个常用仓库) */
const warehouseOptions = (): ApiResponse<Array<{ id: string; name: string }>> => {
const options = [
{ id: 'wh-floor1', name: '一楼设备仓库' },
{ id: 'wh-floor2', name: '二楼备品仓库' },
{ id: 'wh-floor1-large', name: '大型设备区' },
{ id: 'wh-floor1-small', name: '小型设备区' },
{ id: 'wh-floor2-check', name: '待检区' },
{ id: 'wh-floor1-large-a', name: 'A号货架' },
]
return ok('查询成功', options)
}
/** 生成盘点单号 */
const genInventoryCode = (): string => {
const d = new Date()
@@ -183,27 +223,43 @@ const genInventoryCode = (): string => {
return `PD-${ymd}-${seq}`
}
/** 新增(系统按仓库当前在库台账生成应盘清单,mock 用 8 条固定模板) */
const nowStr = (): string => {
const d = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/**
* 新增(系统按区域当前在库台账生成应盘清单)
* - 全量盘点:按区域所有在库台账生成(mock 用 8 条固定模板)
* - 部分盘点:按所选品类过滤后生成(mock 用所选品类数量与固定模板组合)
*/
const addRecord = (params: PageParams): ApiResponse<null> => {
const expectedCount = 8
const warehouseAreaId = String(params.warehouseAreaId ?? '')
const inventoryType = (params.inventoryType as InventoryRecord['inventoryType']) ?? 'full'
// 部分盘点:取所选品类 ID 列表(空数组降级为空清单提示)
const categoryIds = Array.isArray(params.categoryIds) ? params.categoryIds as string[] : []
const expectedCount = inventoryType === 'partial'
? Math.max(1, Math.min(categoryIds.length || 1, 8))
: 8
const record: InventoryRecord = {
id: `inv-${Date.now().toString(36)}`,
inventoryCode: genInventoryCode(),
warehouseId: String(params.warehouseId ?? ''),
warehouseName: String(params.warehouseName ?? ''),
inventoryType: (params.inventoryType as InventoryRecord['inventoryType']) ?? 'full',
warehouseAreaId,
warehouseAreaName: resolveWarehouseAreaName(warehouseAreaId),
inventoryType,
operator: '当前用户',
inventoryTime: new Date().toISOString().replace('T', ' ').slice(0, 19),
inventoryTime: nowStr(),
status: 'draft',
expectedCount,
actualCount: 0,
surplusCount: 0,
lossCount: 0,
remark: params.remark ? String(params.remark) : undefined,
items: buildItems(0, 0, expectedCount, expectedCount),
items: buildItems(0, 0, expectedCount, expectedCount, categoryIds),
}
inventoryList.unshift(record)
return ok('新增成功,已按仓库在库台账生成应盘清单')
return ok('新增成功,已按区域在库台账生成应盘清单')
}
/** 审核(已审核 → 库存台账以实盘数据为准) */
@@ -214,7 +270,7 @@ const auditRecord = (params: PageParams): ApiResponse<null> => {
if (record.status !== 'completed') return ok('仅已完成状态的盘点单可审核')
record.status = 'audited'
record.auditor = '当前用户'
record.auditTime = new Date().toISOString().replace('T', ' ').slice(0, 19)
record.auditTime = nowStr()
return ok('审核完成,库存台账已按实盘数据修正')
}
@@ -235,5 +291,4 @@ registerMocks([
{ method: 'POST', url: '/admin/device/stock/inventory/add', handler: (ctx) => addRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inventory/audit', handler: (ctx) => auditRecord(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inventory/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
{ method: 'POST', url: '/admin/device/stock/inventory/warehouse-options', handler: () => warehouseOptions() },
])
@@ -1,5 +1,6 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext } from '@axios'
import { stockList } from '../asset/instock'
interface WarehouseNode {
id: string
@@ -12,8 +13,8 @@ interface WarehouseNode {
/** 父级名称(仅 info 回填时注入) */
parentName?: string
sortOrder: number
/** 末级状态:仅叶子节点(无 children)有效 */
status?: 'idle' | 'occupied' | 'maintenance'
/** 末级状态:仅叶子节点(无 children)有效;由在库库存反查注入 */
status?: 'idle' | 'occupied'
remark?: string
children?: WarehouseNode[]
createTime?: string
@@ -28,7 +29,6 @@ interface WarehousePayload {
parentId?: string
parentName?: string
sortOrder: number
status?: 'idle' | 'occupied' | 'maintenance'
remark?: string
}
@@ -56,7 +56,9 @@ const TOP = {
*
* 字段规则:
* - 任意节点通用:name / code / level / parentId / sortOrder / remark
* - 仅末级节点(无 children):携带 status
* - 仅末级节点(无 children):status 由在库库存(instock mock)反查注入
* → 该末级储位存在 stockQty > 0 的在库记录 → 'occupied'
* → 否则 → 'idle'
* - 入库时区域必须选末级
*/
const warehouseTree: WarehouseNode[] = [
@@ -72,15 +74,15 @@ const warehouseTree: WarehouseNode[] = [
id: 'wh-floor1-large-a', name: 'A号货架', code: 'W01A01A01', level: 3, parentId: 'wh-floor1-large', sortOrder: 1,
createTime: '2026-05-11 10:05:00',
children: [
{ id: 'wh-floor1-large-a-1', name: '1号储位', code: 'W01A01A01A01', level: 4, parentId: 'wh-floor1-large-a', sortOrder: 1, status: 'idle', createTime: '2026-05-11 10:10:00' },
{ id: 'wh-floor1-large-a-2', name: '2号储位', code: 'W01A01A01A02', level: 4, parentId: 'wh-floor1-large-a', sortOrder: 2, status: 'occupied', createTime: '2026-05-11 10:15:00' },
{ id: 'wh-floor1-large-a-1', name: '1号储位', code: 'W01A01A01A01', level: 4, parentId: 'wh-floor1-large-a', sortOrder: 1, createTime: '2026-05-11 10:10:00' },
{ id: 'wh-floor1-large-a-2', name: '2号储位', code: 'W01A01A01A02', level: 4, parentId: 'wh-floor1-large-a', sortOrder: 2, createTime: '2026-05-11 10:15:00' },
],
},
{
id: 'wh-floor1-large-b', name: 'B号货架', code: 'W01A01A02', level: 3, parentId: 'wh-floor1-large', sortOrder: 2,
createTime: '2026-05-11 10:20:00',
children: [
{ id: 'wh-floor1-large-b-1', name: '1号储位', code: 'W01A01A02A01', level: 4, parentId: 'wh-floor1-large-b', sortOrder: 1, status: 'idle', createTime: '2026-05-11 10:25:00' },
{ id: 'wh-floor1-large-b-1', name: '1号储位', code: 'W01A01A02A01', level: 4, parentId: 'wh-floor1-large-b', sortOrder: 1, createTime: '2026-05-11 10:25:00' },
],
},
],
@@ -93,8 +95,8 @@ const warehouseTree: WarehouseNode[] = [
id: 'wh-floor1-small-shelf', name: '备品货架', code: 'W01A02A01', level: 3, parentId: 'wh-floor1-small', sortOrder: 1,
createTime: '2026-05-11 11:05:00',
children: [
{ id: 'wh-floor1-small-shelf-1', name: '1号储位', code: 'W01A02A01A01', level: 4, parentId: 'wh-floor1-small-shelf', sortOrder: 1, status: 'occupied', createTime: '2026-05-11 11:10:00' },
{ id: 'wh-floor1-small-shelf-2', name: '2号储位', code: 'W01A02A01A02', level: 4, parentId: 'wh-floor1-small-shelf', sortOrder: 2, status: 'maintenance', createTime: '2026-05-11 11:15:00' },
{ id: 'wh-floor1-small-shelf-1', name: '1号储位', code: 'W01A02A01A01', level: 4, parentId: 'wh-floor1-small-shelf', sortOrder: 1, createTime: '2026-05-11 11:10:00' },
{ id: 'wh-floor1-small-shelf-2', name: '2号储位', code: 'W01A02A01A02', level: 4, parentId: 'wh-floor1-small-shelf', sortOrder: 2, createTime: '2026-05-11 11:15:00' },
],
},
],
@@ -109,13 +111,31 @@ const warehouseTree: WarehouseNode[] = [
id: 'wh-floor2-check', name: '待检区', code: 'W02A01', level: 2, parentId: TOP.floor2, sortOrder: 1,
createTime: '2026-05-11 12:00:00',
children: [
{ id: 'wh-floor2-check-1', name: '临时储位', code: 'W02A01A01', level: 3, parentId: 'wh-floor2-check', sortOrder: 1, status: 'idle', createTime: '2026-05-11 12:05:00' },
{ id: 'wh-floor2-check-1', name: '临时储位', code: 'W02A01A01', level: 3, parentId: 'wh-floor2-check', sortOrder: 1, createTime: '2026-05-11 12:05:00' },
],
},
],
},
]
/** 末级节点状态反查:扫描在库库存,存在 stockQty > 0 记录即为 'occupied',否则 'idle' */
const resolveLeafStatus = (leafId: string): 'idle' | 'occupied' => {
const hasStock = stockList.some((item) => item.warehouseAreaId === leafId && item.stockQty > 0)
return hasStock ? 'occupied' : 'idle'
}
/** 递归注入末级状态(仅对叶子节点写入 status,非叶子节点不动) */
const injectStatus = (nodes: WarehouseNode[]): WarehouseNode[] => {
for (const node of nodes) {
if (!node.children || node.children.length === 0) {
node.status = resolveLeafStatus(node.id)
} else {
injectStatus(node.children)
}
}
return nodes
}
/** 按关键字过滤树(保留命中节点的祖先链) */
const filterTree = (nodes: WarehouseNode[], keyword: string): WarehouseNode[] => {
const lower = keyword.toLowerCase()
@@ -164,9 +184,6 @@ const findNode = (nodes: WarehouseNode[], id: string): WarehouseNode | null => {
return null
}
/** 判断节点是否为叶子(无 children 或 children 为空) */
const isLeafNode = (node: WarehouseNode): boolean => !node.children || node.children.length === 0
/** 生成新 ID */
const genId = (prefix: string): string =>
`${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
@@ -190,6 +207,8 @@ const genCode = (tree: WarehouseNode[], parentId?: string): string => {
const handleTree = (ctx: MockContext): ApiResponse<WarehouseNode[]> => {
const params = (ctx.params ?? {}) as { keyword?: string }
const data = cloneTree(warehouseTree)
/** 注入末级状态(按在库库存反查) */
injectStatus(data)
const result = params.keyword ? filterTree(data, params.keyword) : data
return ok('查询成功', result)
}
@@ -211,8 +230,7 @@ const handleAdd = (ctx: MockContext): ApiResponse<null> => {
remark: payload.remark,
createTime: now(),
}
/** 末级节点(新建必然无子)携带状态,默认空闲 */
newNode.status = payload.status ?? 'idle'
/** 末级节点状态由在库库存反查,新建时无在库记录即为 'idle'(不入库 status,等 tree 接口反查注入) */
if (payload.parentId) {
const parent = findNode(warehouseTree, payload.parentId)
@@ -234,10 +252,7 @@ const handleUpdate = (ctx: MockContext): ApiResponse<null> => {
node.name = payload.name
node.sortOrder = payload.sortOrder
node.remark = payload.remark
/** 末级节点才更新状态 */
if (isLeafNode(node)) {
node.status = payload.status ?? 'idle'
}
/** 末级节点状态由在库库存反查,不接受前端编辑 */
return ok('更新成功')
}
@@ -252,6 +267,10 @@ const handleInfo = (ctx: MockContext): ApiResponse<WarehouseNode> => {
const parent = findNode(warehouseTree, node.parentId)
result.parentName = parent?.name
}
/** 末级节点注入反查状态 */
if (!node.children || node.children.length === 0) {
result.status = resolveLeafStatus(node.id)
}
return ok('查询成功', result)
}
@@ -3,19 +3,28 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* 高德地图应用 mock 数据
* - 1 条记录 = 1 个高德开放平台应用(含 API Key + 安全钥)
* - 1 条记录 = 1 个高德开放平台应用(含 API Key + 安全钥)
* - key 不区分类型(Web服务 / JS API / 小程序等共用同一条记录)
* - status 为启用/停用开关,停用后前端调用将拒绝加载地图
*/
interface AmapRecord {
id: string
/** 应用软件名称 */
name: string
/** 高德 API Key */
apiKey: string
/** 安全秘钥 */
securityCode?: string
/** 认证企业 */
authenticatedCompany?: string
/** 用途说明 */
usage?: string
/** 启用状态 */
status: boolean
/** 备注 */
remark?: string
/** 创建时间 */
createTime: string
}
@@ -24,7 +33,8 @@ interface AmapParams {
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
apiKey?: string
appName?: string
status?: boolean
[key: string]: unknown
}
@@ -46,26 +56,27 @@ const paginate = <T extends AmapRecord>(list: T[], params: AmapParams): ApiRespo
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const amapList: AmapRecord[] = [
{ id: 'amap-001', name: '园区地图导航服务', apiKey: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', securityCode: 'yx-sec-2026-001', usage: '健康园区/食堂地图展示与路径规划', status: true, remark: '主应用,Web 端 JS API 调用', createTime: '2026-03-08 09:30:12' },
{ id: 'amap-002', name: '设备定位上报服务', apiKey: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7', securityCode: 'yx-sec-2026-002', usage: '货柜/体检设备 GPS 定位回传', status: true, createTime: '2026-03-12 14:22:45' },
{ id: 'amap-003', name: '地址解析与逆地理编码', apiKey: 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8', securityCode: 'yx-sec-2026-003', usage: '用户地址 → 坐标 / 坐标 → 地址', status: true, remark: 'Web 服务 REST API', createTime: '2026-03-15 10:08:33' },
{ id: 'amap-004', name: '小程序地图组件', apiKey: 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9', securityCode: 'yx-sec-2026-004', usage: 'C 端小程序内置地图与定位', status: true, createTime: '2026-03-20 16:45:09' },
{ id: 'amap-005', name: '食堂数据大屏地图', apiKey: 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', securityCode: 'yx-sec-2026-005', usage: '运营大屏热力图与分布展示', status: false, remark: '大屏重构中,临时停用', createTime: '2026-04-02 11:18:27' },
{ id: 'amap-006', name: '巡检路径追踪服务', apiKey: 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1', securityCode: 'yx-sec-2026-006', usage: '运维小程序巡检轨迹记录与回放', status: true, createTime: '2026-04-10 08:55:41' },
{ id: 'amap-007', name: '行政区域查询服务', apiKey: 'a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2', securityCode: 'yx-sec-2026-007', usage: '租户档案省市县三级联动', status: true, createTime: '2026-05-18 13:40:55' },
{ id: 'amap-008', name: '健康园区打卡围栏', apiKey: 'b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3', usage: '体检站电子围栏与自动打卡', status: false, remark: '围栏方案调整,暂停使用', createTime: '2026-06-22 15:12:18' },
{ id: 'amap-001', name: '园区地图导航服务', apiKey: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', securityCode: 'yx-sec-2026-001', authenticatedCompany: '上海寰汐科技有限公司', usage: '健康园区/食堂地图展示与路径规划', status: true, remark: '主应用,Web 端 JS API 调用', createTime: '2026-03-08 09:30:12' },
{ id: 'amap-002', name: '设备定位上报服务', apiKey: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7', securityCode: 'yx-sec-2026-002', authenticatedCompany: '上海寰汐科技有限公司', usage: '货柜/体检设备 GPS 定位回传', status: true, createTime: '2026-03-12 14:22:45' },
{ id: 'amap-003', name: '地址解析与逆地理编码', apiKey: 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8', securityCode: 'yx-sec-2026-003', authenticatedCompany: '上海寰汐科技有限公司', usage: '用户地址 → 坐标 / 坐标 → 地址', status: true, remark: 'Web 服务 REST API', createTime: '2026-03-15 10:08:33' },
{ id: 'amap-004', name: '小程序地图组件', apiKey: 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9', securityCode: 'yx-sec-2026-004', authenticatedCompany: '杭州智养餐饮联盟', usage: 'C 端小程序内置地图与定位', status: true, createTime: '2026-03-20 16:45:09' },
{ id: 'amap-005', name: '食堂数据大屏地图', apiKey: 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', securityCode: 'yx-sec-2026-005', authenticatedCompany: '杭州智养餐饮联盟', usage: '运营大屏热力图与分布展示', status: false, remark: '大屏重构中,临时停用', createTime: '2026-04-02 11:18:27' },
{ id: 'amap-006', name: '巡检路径追踪服务', apiKey: 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1', securityCode: 'yx-sec-2026-006', authenticatedCompany: '上海寰汐科技有限公司', usage: '运维小程序巡检轨迹记录与回放', status: true, createTime: '2026-04-10 08:55:41' },
{ id: 'amap-007', name: '行政区域查询服务', apiKey: 'a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2', securityCode: 'yx-sec-2026-007', authenticatedCompany: '杭州智养餐饮联盟', usage: '租户档案省市县三级联动', status: true, createTime: '2026-05-18 13:40:55' },
{ id: 'amap-008', name: '健康园区打卡围栏', apiKey: 'b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3', authenticatedCompany: '北京健康园区运营公司', usage: '体检站电子围栏与自动打卡', status: false, remark: '围栏方案调整,暂停使用', createTime: '2026-06-22 15:12:18' },
]
/** 关键词搜索:应用名称 / API Key / 用途说明 */
const matchKeyword = (item: AmapRecord, keyword?: string): boolean => {
if (!keyword) return true
return includes(item.name, keyword) || includes(item.apiKey, keyword) || includes(item.usage, keyword)
}
/** 高德 ApiKey 模糊匹配 */
const matchApiKey = (item: AmapRecord, apiKey?: string): boolean => includes(item.apiKey, apiKey)
/** 应用软件名称模糊匹配 */
const matchAppName = (item: AmapRecord, appName?: string): boolean => includes(item.name, appName)
const listAmap = (ctx: MockContext): ApiResponse<AmapRecord[]> => {
const params = getParams(ctx.params)
const filtered = amapList.filter((item) => {
if (!matchKeyword(item, params.keyword)) return false
if (!matchApiKey(item, params.apiKey)) return false
if (!matchAppName(item, params.appName)) return false
if (typeof params.status === 'boolean' && item.status !== params.status) return false
return true
})
@@ -95,6 +106,7 @@ const addAmap = (ctx: MockContext): ApiResponse<null> => {
name: String(params.name ?? ''),
apiKey: String(params.apiKey ?? ''),
securityCode: params.securityCode ? String(params.securityCode) : undefined,
authenticatedCompany: params.authenticatedCompany ? String(params.authenticatedCompany) : undefined,
usage: params.usage ? String(params.usage) : undefined,
status: typeof params.status === 'boolean' ? params.status : true,
remark: params.remark ? String(params.remark) : undefined,
@@ -112,6 +124,7 @@ const updateAmap = (ctx: MockContext): ApiResponse<null> => {
if (params.name !== undefined) target.name = String(params.name)
if (params.apiKey !== undefined) target.apiKey = String(params.apiKey)
target.securityCode = params.securityCode !== undefined ? (params.securityCode ? String(params.securityCode) : undefined) : target.securityCode
target.authenticatedCompany = params.authenticatedCompany !== undefined ? (params.authenticatedCompany ? String(params.authenticatedCompany) : undefined) : target.authenticatedCompany
target.usage = params.usage !== undefined ? (params.usage ? String(params.usage) : undefined) : target.usage
if (typeof params.status === 'boolean') target.status = params.status
target.remark = params.remark !== undefined ? (params.remark ? String(params.remark) : undefined) : target.remark
@@ -5,6 +5,8 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
* 虹软人脸授权 mock 数据
* - 1 条记录 = 1 台设备的激活码(每台设备硬件绑定一个 activeKey)
* - appId / sdkKey / sdkSecret 为虹软开发者平台应用凭证(同租户共享,但激活后不可换绑设备)
* - 设备类别 deviceCategoryId 引用设备分类树(/admin/device/category/tree)的叶子节点 id
* - 到期时间 expireTime 为空字符串表示"永久有效"(大部分虹软授权为永久授权)
* - 状态:未激活 inactive / 已激活 active / 已过期 expired / 已停用 disabled
*/
@@ -19,7 +21,12 @@ interface ArcRecord {
deviceSn: string
deviceName?: string
deviceModel?: string
/** 设备类别 ID(引用设备分类树叶子节点 id) */
deviceCategoryId: string
/** 设备类别名称(列表展示用,冗余存储) */
deviceCategoryName?: string
activateTime: string
/** 空字符串 = 永久有效 */
expireTime: string
status: ArcStatus
remark?: string
@@ -31,8 +38,12 @@ interface ArcParams {
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
activeKey?: string
deviceCategoryId?: string
deviceSn?: string
appId?: string
status?: ArcStatus
id?: string
[key: string]: unknown
}
@@ -53,26 +64,23 @@ const paginate = <T extends ArcRecord>(list: T[], params: ArcParams): ApiRespons
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const arcList: ArcRecord[] = [
{ id: 'arc-001', activeKey: 'ARC-ACT-CQ-001-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-001', deviceName: '总部一食堂货柜', deviceModel: 'SC-1080P', activateTime: '2026-04-10', expireTime: '2027-04-10', status: 'active', remark: '总部主入口人脸识别', createTime: '2026-04-10 09:18:30' },
{ id: 'arc-002', activeKey: 'ARC-ACT-CQ-002-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-002', deviceName: '总部二食堂货柜', deviceModel: 'SC-1080P', activateTime: '2026-04-12', expireTime: '2027-04-12', status: 'active', createTime: '2026-04-12 10:24:48' },
{ id: 'arc-003', activeKey: 'ARC-ACT-OILF-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-OILF-01', deviceName: '采油一厂食堂货柜', deviceModel: 'SC-720P', activateTime: '2026-04-15', expireTime: '2026-07-15', status: 'expired', remark: '授权过期,待续期', createTime: '2026-04-15 14:08:11' },
{ id: 'arc-004', activeKey: 'ARC-ACT-REF-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-REF-01', deviceName: '炼化员工餐厅货柜', deviceModel: 'SC-1080P', activateTime: '2026-04-18', expireTime: '2027-04-18', status: 'active', createTime: '2026-04-18 16:45:09' },
{ id: 'arc-005', activeKey: 'ARC-ACT-HP-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-HP-01', deviceName: '健康园区体检站终端', deviceModel: 'ST-1080P', activateTime: '2026-04-22', expireTime: '2027-04-22', status: 'active', createTime: '2026-04-22 11:33:24' },
{ id: 'arc-006', activeKey: 'ARC-ACT-HP-02-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-HP-02', deviceName: '园区健身房体测机', deviceModel: 'INB-1280', activateTime: '2026-04-25', expireTime: '2027-04-25', status: 'disabled', remark: '设备已下线,授权停用', createTime: '2026-04-25 09:12:46' },
{ id: 'arc-007', activeKey: 'ARC-ACT-MED-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-MED-01', deviceName: '市一医院营养秤终端', deviceModel: 'NS-1280', activateTime: '2026-04-28', expireTime: '2027-04-28', status: 'active', createTime: '2026-04-28 15:47:18' },
{ id: 'arc-008', activeKey: 'ARC-ACT-EDU-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-EDU-01', deviceName: '江南大学档口机', deviceModel: 'ST-1920P', activateTime: '', expireTime: '2027-08-31', status: 'inactive', remark: '设备入库未启用', createTime: '2026-05-02 08:55:32' },
{ id: 'arc-001', activeKey: 'ARC-ACT-CQ-001-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-001', deviceName: '总部一食堂货柜', deviceModel: 'SC-1080P', deviceCategoryId: 'cat-catering-cabinet', deviceCategoryName: '智能货柜', activateTime: '2026-04-10', expireTime: '', status: 'active', remark: '总部主入口人脸识别', createTime: '2026-04-10 09:18:30' },
{ id: 'arc-002', activeKey: 'ARC-ACT-CQ-002-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-002', deviceName: '总部二食堂货柜', deviceModel: 'SC-1080P', deviceCategoryId: 'cat-catering-cabinet', deviceCategoryName: '智能货柜', activateTime: '2026-04-12', expireTime: '', status: 'active', createTime: '2026-04-12 10:24:48' },
{ id: 'arc-003', activeKey: 'ARC-ACT-OILF-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-OILF-01', deviceName: '采油一厂食堂货柜', deviceModel: 'SC-720P', deviceCategoryId: 'cat-catering-cabinet', deviceCategoryName: '智能货柜', activateTime: '2026-04-15', expireTime: '2026-07-15', status: 'expired', remark: '授权过期,待续期', createTime: '2026-04-15 14:08:11' },
{ id: 'arc-004', activeKey: 'ARC-ACT-REF-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-REF-01', deviceName: '炼化员工餐厅体测机', deviceModel: 'AIO-V3', deviceCategoryId: 'cat-body-all-in-one', deviceCategoryName: '一体机', activateTime: '2026-04-18', expireTime: '', status: 'active', createTime: '2026-04-18 16:45:09' },
{ id: 'arc-005', activeKey: 'ARC-ACT-HP-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-HP-01', deviceName: '健康园区体检站终端', deviceModel: 'HM-Z1', deviceCategoryId: 'cat-monitor-all-in-one', deviceCategoryName: '智能健康一体机', activateTime: '2026-04-22', expireTime: '', status: 'active', createTime: '2026-04-22 11:33:24' },
{ id: 'arc-006', activeKey: 'ARC-ACT-HP-02-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-HP-02', deviceName: '园区健身房体测机', deviceModel: 'AIO-V3', deviceCategoryId: 'cat-body-all-in-one', deviceCategoryName: '一体机', activateTime: '2026-04-25', expireTime: '', status: 'disabled', remark: '设备已下线,授权停用', createTime: '2026-04-25 09:12:46' },
{ id: 'arc-007', activeKey: 'ARC-ACT-MED-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-MED-01', deviceName: '市一医院营养秤终端', deviceModel: 'NS-5KG', deviceCategoryId: 'cat-catering-nutri-scale', deviceCategoryName: '营养秤', activateTime: '2026-04-28', expireTime: '', status: 'active', createTime: '2026-04-28 15:47:18' },
{ id: 'arc-008', activeKey: 'ARC-ACT-EDU-01-2026', appId: '7JMH4F9x3KqYc8rN2L6tXbA1d', sdkKey: 'CZ8s4Fq9xR7nHm2Lp6Yv3KbN1aD5tX', sdkSecret: 'B3pK9rN2LqYc8rZ7mH4F6vXbA1dTs5T', deviceSn: 'CABINET-SN-EDU-01', deviceName: '江南大学档口机', deviceModel: 'BS-25IN', deviceCategoryId: 'cat-catering-band-screen', deviceCategoryName: '带鱼屏', activateTime: '', expireTime: '2027-08-31', status: 'inactive', remark: '设备入库未启用,短期试用授权', createTime: '2026-05-02 08:55:32' },
]
/** 关键词搜索:激活码 / 设备 SN / 设备名称 */
const matchKeyword = (item: ArcRecord, keyword?: string): boolean => {
if (!keyword) return true
return includes(item.activeKey, keyword) || includes(item.deviceSn, keyword) || includes(item.deviceName, keyword)
}
const listArc = (ctx: MockContext): ApiResponse<ArcRecord[]> => {
const params = getParams(ctx.params)
const filtered = arcList.filter((item) => {
if (!matchKeyword(item, params.keyword)) return false
if (params.activeKey && !includes(item.activeKey, params.activeKey)) return false
if (params.deviceCategoryId && item.deviceCategoryId !== params.deviceCategoryId) return false
if (params.deviceSn && !includes(item.deviceSn, params.deviceSn)) return false
if (params.appId && !includes(item.appId, params.appId)) return false
if (params.status && item.status !== params.status) return false
return true
})
@@ -97,15 +105,23 @@ const addArc = (ctx: MockContext): ApiResponse<null> => {
const now = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
const createTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
/**
* SDK Secret 为敏感字段,前端不录入;后端按 AppID 从同租户已有应用凭证中自动填充。
* 此处模拟该行为:从已有记录中按 appId 查 sdkSecret;查不到则留空字符串。
*/
const appId = String(params.appId ?? '')
const existed = arcList.find((item) => item.appId === appId)
const record: ArcRecord = {
id: `arc-${Date.now().toString(36)}`,
activeKey: String(params.activeKey ?? ''),
appId: String(params.appId ?? ''),
appId,
sdkKey: String(params.sdkKey ?? ''),
sdkSecret: String(params.sdkSecret ?? ''),
sdkSecret: existed?.sdkSecret ?? '',
deviceSn: String(params.deviceSn ?? ''),
deviceName: params.deviceName ? String(params.deviceName) : undefined,
deviceModel: params.deviceModel ? String(params.deviceModel) : undefined,
deviceCategoryId: String(params.deviceCategoryId ?? ''),
deviceCategoryName: params.deviceCategoryName ? String(params.deviceCategoryName) : undefined,
activateTime: String(params.activateTime ?? ''),
expireTime: String(params.expireTime ?? ''),
status: (params.status as ArcStatus) ?? 'inactive',
@@ -128,6 +144,12 @@ const updateArc = (ctx: MockContext): ApiResponse<null> => {
if (params.deviceSn !== undefined) target.deviceSn = String(params.deviceSn)
target.deviceName = params.deviceName !== undefined ? (params.deviceName ? String(params.deviceName) : undefined) : target.deviceName
target.deviceModel = params.deviceModel !== undefined ? (params.deviceModel ? String(params.deviceModel) : undefined) : target.deviceModel
if (params.deviceCategoryId !== undefined) {
target.deviceCategoryId = String(params.deviceCategoryId)
if (params.deviceCategoryName !== undefined) {
target.deviceCategoryName = params.deviceCategoryName ? String(params.deviceCategoryName) : undefined
}
}
if (params.activateTime !== undefined) target.activateTime = String(params.activateTime)
if (params.expireTime !== undefined) target.expireTime = String(params.expireTime)
if (params.status !== undefined) target.status = params.status as ArcStatus
@@ -143,10 +165,18 @@ const deleteArc = (ctx: MockContext): ApiResponse<null> => {
return ok('删除成功')
}
/** 异步导出:立即返回任务已接收 */
const exportArc = (): ApiResponse<null> => ok('导出任务已提交,可在"查看导出任务"中查看进度')
/** 批量导入:原型阶段直接返回成功 */
const importArc = (): ApiResponse<null> => ok('批量导入成功,共新增 0 条,更新 0 条')
registerMocks([
{ method: 'POST', url: '/admin/device/third-auth/arcsoft/page', handler: listArc },
{ method: 'POST', url: '/admin/device/third-auth/arcsoft/info', handler: infoArc },
{ method: 'POST', url: '/admin/device/third-auth/arcsoft/add', handler: addArc },
{ method: 'POST', url: '/admin/device/third-auth/arcsoft/update', handler: updateArc },
{ method: 'POST', url: '/admin/device/third-auth/arcsoft/delete', handler: deleteArc },
{ method: 'POST', url: '/admin/device/third-auth/arcsoft/export', handler: exportArc },
{ method: 'POST', url: '/admin/device/third-auth/arcsoft/import', handler: importArc },
])
@@ -0,0 +1,259 @@
import { registerMocks } from '@axios/mockBus'
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
/**
* SIM 卡管理 mock 数据
* - 1 条记录 = 1 张 SIM 卡(ICCID 唯一)
* - 状态:未激活 unactivated / 正常 normal / 已欠费 arrears / 已停用 stopped
* - 绑定设备 SN:空表示未绑定,可走"绑定设备"操作;非空可走"解绑设备"
* - 设备分类别 deviceCategory:货柜 / 体检机 / 打卡机 等
*/
type SimStatus = 'unactivated' | 'normal' | 'arrears' | 'stopped'
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'broadnet'
interface SimRecord {
id: string
iccid: string
phoneNo: string
carrier: Carrier
packageType: string
status: SimStatus
boundDeviceSn: string
boundDeviceName?: string
/** 设备分类别 */
deviceCategory?: string
activateTime: string
expireTime: string
remark?: string
createTime: string
}
interface SimParams {
pageNum?: number
pageSize?: number
column?: string
order?: 'ascend' | 'descend' | null
keyword?: string
carrier?: Carrier
status?: SimStatus
id?: string
simId?: string
deviceSn?: string
[key: string]: unknown
}
const getParams = (params: RequestParameter): SimParams => (params ?? {}) as SimParams
const includes = (value: unknown, keyword: unknown): boolean => {
if (!keyword) return true
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
}
const paginate = <T extends SimRecord>(list: T[], params: SimParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const simList: SimRecord[] = [
{ id: 'sim-001', iccid: '8986011224567890123', phoneNo: '13800138001', carrier: 'mobile', packageType: '物联30G/月', status: 'normal', boundDeviceSn: 'CABINET-SN-001', boundDeviceName: '总部一食堂货柜', deviceCategory: '智能货柜', activateTime: '2026-04-10', expireTime: '2027-04-10', remark: '总部主入口人脸识别货柜', createTime: '2026-04-10 09:18:30' },
{ id: 'sim-002', iccid: '8986011224567890124', phoneNo: '13800138002', carrier: 'mobile', packageType: '物联30G/月', status: 'normal', boundDeviceSn: 'CABINET-SN-002', boundDeviceName: '总部二食堂货柜', deviceCategory: '智能货柜', activateTime: '2026-04-12', expireTime: '2027-04-12', createTime: '2026-04-12 10:24:48' },
{ id: 'sim-003', iccid: '8986011724567890125', phoneNo: '16600138003', carrier: 'unicom', packageType: '物联50G/月', status: 'arrears', boundDeviceSn: 'CABINET-SN-OILF-01', boundDeviceName: '采油一厂食堂货柜', deviceCategory: '智能货柜', activateTime: '2026-04-15', expireTime: '2026-07-15', remark: '已欠费,待续费', createTime: '2026-04-15 14:08:11' },
{ id: 'sim-004', iccid: '8986011924567890126', phoneNo: '18900138004', carrier: 'telecom', packageType: '物联30G/月', status: 'normal', boundDeviceSn: 'CABINET-SN-REF-01', boundDeviceName: '炼化员工餐厅货柜', deviceCategory: '智能货柜', activateTime: '2026-04-18', expireTime: '2027-04-18', createTime: '2026-04-18 16:45:09' },
{ id: 'sim-005', iccid: '8986011824567890127', phoneNo: '13300138005', carrier: 'telecom', packageType: '语音100分钟+10G', status: 'stopped', boundDeviceSn: '', deviceCategory: '', activateTime: '2026-04-22', expireTime: '2027-04-22', remark: '设备下线,已停用保号', createTime: '2026-04-22 11:33:24' },
{ id: 'sim-006', iccid: '8986011924567890128', phoneNo: '18900138006', carrier: 'telecom', packageType: '物联30G/月', status: 'normal', boundDeviceSn: 'CABINET-SN-HP-01', boundDeviceName: '健康园区体检站终端', deviceCategory: '体检终端', activateTime: '2026-04-25', expireTime: '2027-04-25', createTime: '2026-04-25 09:12:46' },
{ id: 'sim-007', iccid: '8986011724567890129', phoneNo: '16600138007', carrier: 'unicom', packageType: '物联10G/月', status: 'unactivated', boundDeviceSn: '', deviceCategory: '', activateTime: '', expireTime: '2027-08-31', remark: '新购入待分配', createTime: '2026-05-02 08:55:32' },
{ id: 'sim-008', iccid: '8986040524567890130', phoneNo: '17000138008', carrier: 'broadnet', packageType: '物联20G/月', status: 'normal', boundDeviceSn: 'CABINET-SN-EDU-01', boundDeviceName: '江南大学档口机', deviceCategory: '档口机', activateTime: '2026-05-05', expireTime: '2027-05-05', createTime: '2026-05-05 13:20:09' },
]
/** 关键词搜索:ICCID / 手机号 / 设备 SN */
const matchKeyword = (item: SimRecord, keyword?: string): boolean => {
if (!keyword) return true
return includes(item.iccid, keyword) || includes(item.phoneNo, keyword) || includes(item.boundDeviceSn, keyword)
}
const listSim = (ctx: MockContext): ApiResponse<SimRecord[]> => {
const params = getParams(ctx.params)
const filtered = simList.filter((item) => {
if (!matchKeyword(item, params.keyword)) return false
if (params.carrier && item.carrier !== params.carrier) return false
if (params.status && item.status !== params.status) return false
return true
})
if (params.column === 'createTime' && params.order) {
filtered.sort((a, b) => {
const cmp = a.createTime.localeCompare(b.createTime)
return params.order === 'ascend' ? cmp : -cmp
})
}
return paginate(filtered, params)
}
const infoSim = (ctx: MockContext): ApiResponse<SimRecord | null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
return ok('查询成功', simList.find((item) => item.id === id) ?? null)
}
const addSim = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const now = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
const createTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
const record: SimRecord = {
id: `sim-${Date.now().toString(36)}`,
iccid: String(params.iccid ?? ''),
phoneNo: String(params.phoneNo ?? ''),
carrier: (params.carrier as Carrier) ?? 'mobile',
packageType: String(params.packageType ?? ''),
status: (params.status as SimStatus) ?? 'unactivated',
boundDeviceSn: '',
deviceCategory: '',
activateTime: String(params.activateTime ?? ''),
expireTime: String(params.expireTime ?? ''),
remark: params.remark ? String(params.remark) : undefined,
createTime,
}
simList.unshift(record)
return ok('新增成功')
}
const updateSim = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const target = simList.find((item) => item.id === id)
if (!target) return ok('记录不存在', null)
if (params.iccid !== undefined) target.iccid = String(params.iccid)
if (params.phoneNo !== undefined) target.phoneNo = String(params.phoneNo)
if (params.carrier !== undefined) target.carrier = params.carrier as Carrier
if (params.packageType !== undefined) target.packageType = String(params.packageType)
if (params.activateTime !== undefined) target.activateTime = String(params.activateTime)
if (params.expireTime !== undefined) target.expireTime = String(params.expireTime)
if (params.status !== undefined) target.status = params.status as SimStatus
target.remark = params.remark !== undefined ? (params.remark ? String(params.remark) : undefined) : target.remark
return ok('更新成功')
}
const deleteSim = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const id = String(params.id ?? '')
const idx = simList.findIndex((item) => item.id === id)
if (idx >= 0) simList.splice(idx, 1)
return ok('删除成功')
}
/** 绑定设备:写入 boundDeviceSn / boundDeviceName / deviceCategory */
const bindSim = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const simId = String(params.simId ?? '')
const target = simList.find((item) => item.id === simId)
if (!target) return ok('SIM 卡不存在', null)
if (target.boundDeviceSn) return ok('该 SIM 卡已绑定设备,请先解绑', null)
const deviceSn = String(params.deviceSn ?? '')
if (!deviceSn) return ok('请选择设备', null)
target.boundDeviceSn = deviceSn
target.boundDeviceName = params.deviceName ? String(params.deviceName) : deviceSn
if (params.deviceCategory) target.deviceCategory = String(params.deviceCategory)
return ok('绑定成功')
}
/** 解绑设备:清空 boundDeviceSn / boundDeviceName / deviceCategory */
const unbindSim = (ctx: MockContext): ApiResponse<null> => {
const params = getParams(ctx.params)
const simId = String(params.simId ?? '')
const target = simList.find((item) => item.id === simId)
if (!target) return ok('SIM 卡不存在', null)
if (!target.boundDeviceSn) return ok('该 SIM 卡未绑定设备', null)
target.boundDeviceSn = ''
target.boundDeviceName = undefined
target.deviceCategory = ''
return ok('解绑成功')
}
/** 异步导出:立即返回任务已接收 */
const exportSim = (): ApiResponse<null> => ok('导出任务已提交,可在"查看导出任务"中查看进度')
/** 批量导入:原型阶段直接返回成功 */
const importSim = (): ApiResponse<null> => ok('批量导入成功,共新增 0 条,更新 0 条')
/** 可绑定设备下拉(从已存在但未绑定的设备池里返回若干条) */
const deviceOptionsSim = (): ApiResponse<{ label: string; value: string }[]> => {
const pool: { label: string; value: string }[] = [
{ label: 'CABINET-SN-003(东区食堂货柜)', value: 'CABINET-SN-003' },
{ label: 'CABINET-SN-004(健身房东区体测机)', value: 'CABINET-SN-004' },
{ label: 'CABINET-SN-005(健身房体测机)', value: 'CABINET-SN-005' },
{ label: 'CABINET-SN-006(仓库备用货柜)', value: 'CABINET-SN-006' },
{ label: 'CABINET-SN-007(采油二厂食堂货柜)', value: 'CABINET-SN-007' },
]
const used = new Set(simList.filter((item) => item.boundDeviceSn).map((item) => item.boundDeviceSn))
return ok('查询成功', pool.filter((item) => !used.has(item.value)))
}
/** 导出任务列表已迁移到公共 handlerhandlers/device-center/export-task.ts
* - 统一接口:POST /admin/device/export-tasks/list { moduleKey: 'sim' }
* - 各业务模块通过 moduleKey 区分,mock 数据集中维护
*/
/** 绑定记录状态 */
type BindRecordStatus = 'bound' | 'unbound'
/** SIM 卡历史绑定记录(1 条 = 1 次绑定/解绑事件) */
interface SimBindRecord {
id: string
/** 所属 SIM 卡 ID */
simId: string
/** 绑定时间 */
bindTime: string
/** 解绑时间(status='unbound' 时必有) */
unbindTime?: string
/** 设备编码 */
deviceSn: string
/** 设备名称 */
deviceName: string
/** 设备分类别 */
deviceCategory: string
/** 操作人 */
operator: string
/** 状态:已绑定 / 已解绑 */
status: BindRecordStatus
}
/** 绑定记录池(按 simId 过滤后返回,单卡最多 2 条,总量 8 条) */
const simBindRecords: SimBindRecord[] = [
{ id: 'br-001', simId: 'sim-001', bindTime: '2026-04-10 09:18:30', deviceSn: 'CABINET-SN-001', deviceName: '总部一食堂货柜', deviceCategory: '智能货柜', operator: '李晓', status: 'bound' },
{ id: 'br-002', simId: 'sim-001', bindTime: '2026-03-15 10:00:00', unbindTime: '2026-04-09 18:00:00', deviceSn: 'CABINET-SN-OLD-01', deviceName: '总部旧货柜(已下线)', deviceCategory: '智能货柜', operator: '李晓', status: 'unbound' },
{ id: 'br-003', simId: 'sim-002', bindTime: '2026-04-12 10:24:48', deviceSn: 'CABINET-SN-002', deviceName: '总部二食堂货柜', deviceCategory: '智能货柜', operator: '李晓', status: 'bound' },
{ id: 'br-004', simId: 'sim-003', bindTime: '2026-04-15 14:08:11', deviceSn: 'CABINET-SN-OILF-01', deviceName: '采油一厂食堂货柜', deviceCategory: '智能货柜', operator: '王磊', status: 'bound' },
{ id: 'br-005', simId: 'sim-004', bindTime: '2026-04-18 16:45:09', deviceSn: 'CABINET-SN-REF-01', deviceName: '炼化员工餐厅货柜', deviceCategory: '智能货柜', operator: '王磊', status: 'bound' },
{ id: 'br-006', simId: 'sim-005', bindTime: '2026-04-22 11:33:24', unbindTime: '2026-06-10 09:50:12', deviceSn: 'CABINET-SN-EDU-02', deviceName: '江南大学实训机', deviceCategory: '档口机', operator: '赵敏', status: 'unbound' },
{ id: 'br-007', simId: 'sim-006', bindTime: '2026-04-25 09:12:46', deviceSn: 'CABINET-SN-HP-01', deviceName: '健康园区体检站终端', deviceCategory: '体检终端', operator: '赵敏', status: 'bound' },
{ id: 'br-008', simId: 'sim-008', bindTime: '2026-05-05 13:20:09', deviceSn: 'CABINET-SN-EDU-01', deviceName: '江南大学档口机', deviceCategory: '档口机', operator: '李晓', status: 'bound' },
]
/** 查询某张 SIM 卡的绑定记录(按绑定时间倒序) */
const listBindRecords = (ctx: MockContext): ApiResponse<SimBindRecord[]> => {
const params = getParams(ctx.params)
const simId = String(params.simId ?? '')
const filtered = simBindRecords
.filter((item) => item.simId === simId)
.sort((a, b) => b.bindTime.localeCompare(a.bindTime))
return ok('查询成功', filtered)
}
registerMocks([
{ method: 'POST', url: '/admin/device/third-auth/sim/page', handler: listSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/info', handler: infoSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/add', handler: addSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/update', handler: updateSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/delete', handler: deleteSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/bind', handler: bindSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/unbind', handler: unbindSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/bind-records', handler: listBindRecords },
{ method: 'POST', url: '/admin/device/third-auth/sim/export', handler: exportSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/import', handler: importSim },
{ method: 'POST', url: '/admin/device/third-auth/sim/device-options', handler: deviceOptionsSim },
])
@@ -25,7 +25,7 @@ const handleOverview = (_ctx: MockContext): ApiResponse<unknown> => {
{ key: 'monthAdd', label: '本月新增', value: 8, unit: '台', hint: '较上月 +33%', trend: 'up', icon: '🚀', color: 'purple' },
{ key: 'todayData', label: '今日数据量', value: 18642, unit: '条', hint: '较昨日 +12%', trend: 'up', icon: '📈', color: 'blue' },
{ key: 'todayDist', label: '今日分发量', value: 18260, unit: '条', hint: '较昨日 +10%', trend: 'up', icon: '🔄', color: 'green' },
{ key: 'tenant', label: '下游户数', value: 24, unit: '家', hint: '本月新增 2 家', trend: 'up', icon: '🏢', color: 'orange' },
{ key: 'tenant', label: '下游户数', value: 24, unit: '家', hint: '本月新增 2 家', trend: 'up', icon: '🏢', color: 'orange' },
{ key: 'successRate', label: '分发成功率', value: 98, unit: '%', hint: '较昨日 +0.5%', trend: 'up', icon: '✅', color: 'green' },
],
/** 设备资产结构(按顶级分类) */
+4
View File
@@ -46,6 +46,8 @@ import './device-center/asset/instock'
import './device-center/customer/supplier'
// 设备中心 - 库存管理 / 仓库区域(树形 CRUD,末级状态由入库出库联动)
import './device-center/stock/warehouse'
// 设备中心 - 公共导出任务(按 moduleKey 区分:sim / stock-inbound / stock-outbound / stock-instock
import './device-center/export-task'
// 设备中心 - 库存管理 / 库存盘点(应盘清单 + 实盘结果 + 审核修正台账)
import './device-center/stock/inventory'
// 设备中心 - 设备运维 / 工单管理(含流转)
@@ -74,6 +76,8 @@ import './device-center/distribute/deadletter'
import './device-center/third-auth/arcsoft'
// 设备中心 - 第三方授权 / 高德地图应用(API Key + 安全密钥)
import './device-center/third-auth/amap'
// 设备中心 - 第三方授权 / SIM 管理(CRUD + 绑定/解绑设备 + 导入/导出)
import './device-center/third-auth/sim'
// 实施运维小程序 - 工作台 + 告警(今日待办 / 本周统计 / 告警列表 + 详情 + 处置)
import './miniprogram/ops/workbench'
// 实施运维小程序 - 库存作业(收货入库 / 装机出库 / 库存盘点 / 库存查询)
+3
View File
@@ -153,6 +153,9 @@ export const restfulRequest = <T = unknown>(
export type {
ApiResponse,
HttpMethod,
MockContext,
MockHandler,
MockRoute,
RequestHeaders,
RequestParameter,
ServiceType,
+117
View File
@@ -0,0 +1,117 @@
<template>
<a-tree-select
:value="modelValue"
:tree-data="treeData"
:field-names="FIELD_NAMES"
:placeholder="placeholder"
:allow-clear="allowClear"
:show-search="showSearch"
:tree-default-expand-all="treeDefaultExpandAll"
:style="style"
:filter-tree-node="filterTreeNode"
@change="onChange"
/>
</template>
<script setup lang="ts">
/**
* 设备类别末级分类选择器
*
* 数据源:POST /admin/device/category/tree(与设备分类页 / 统计页同源)
*
* 用法:
* - 列表筛选场景(允许任意层级,选顶级命中整棵子树):<DeviceCategoryPicker v-model="search.categoryId" />
* - 单据录入场景(只能选末级):<DeviceCategoryPicker v-model="form.categoryId" leaf-only />
*
* 与 statistics 页设备类别筛选实现保持一致:a-tree-select + field-names { label:'name', value:'id', children:'children' }
*/
import { onMounted, ref } from 'vue'
import type { CSSProperties } from 'vue'
import { postRequest } from '@axios'
import type { ApiResponse } from '@axios'
interface CategoryNode {
id: string
name: string
children?: CategoryNode[]
}
const FIELD_NAMES = { label: 'name', value: 'id', children: 'children' }
const props = withDefaults(defineProps<{
/** 选中值(v-model */
modelValue?: string
/** 占位提示 */
placeholder?: string
/** 是否允许清空 */
allowClear?: boolean
/** 是否支持搜索 */
showSearch?: boolean
/** 默认展开全部 */
treeDefaultExpandAll?: boolean
/** 是否限制只能选末级节点(无 children) */
leafOnly?: boolean
/** 透传样式 */
style?: CSSProperties
}>(), {
placeholder: '全部类别',
allowClear: true,
showSearch: true,
treeDefaultExpandAll: true,
leafOnly: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | undefined): void
(e: 'change', value: string | undefined): void
}>()
const treeData = ref<CategoryNode[]>([])
/** 树节点过滤函数(a-tree-select :filter-tree-node */
const filterTreeNode = (input: string, node: { name?: string }): boolean =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())
/** 递归收集末级节点 id 集合 */
const collectLeafIds = (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.id)
})
}
walk(nodes)
return result
}
const onChange = (value: string | undefined): void => {
// leaf-only 模式下拦截非末级选择
if (value && props.leafOnly) {
const leafIds = collectLeafIds(treeData.value)
if (!leafIds.has(value)) {
// 非末级节点被选中,拒绝(保留原值)
return
}
}
emit('update:modelValue', value)
emit('change', value)
}
/** 加载设备分类全树 */
const loadTree = (): void => {
postRequest('axiosRequest', '/admin/device/category/tree', { keyword: '' })
.then((res: ApiResponse<CategoryNode[]>) => {
if (res.code === '00000') {
treeData.value = res.data ?? []
}
})
.catch((err: unknown) => {
console.error('设备分类树加载失败:', err)
})
}
onMounted(() => {
loadTree()
})
</script>
+168
View File
@@ -0,0 +1,168 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:destroy-on-close="true"
>
<a-spin :spinning="pageInfo.spin">
<div class="export-task-toolbar">
<a-button @click="loadList">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<a-table
:columns="columns"
:data-source="list"
row-key="id"
:pagination="false"
size="small"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-if="column.dataIndex === 'action'">
<a-button
v-if="record.status === 'success'"
type="link"
size="small"
@click="handleDownload(record as ExportTaskItem)"
>下载文件</a-button>
<span v-else></span>
</template>
</template>
</a-table>
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 公共导出任务抽屉
*
* 数据源:POST /admin/device/export-tasks/list { moduleKey }
*
* 用法:
* <ExportTaskDrawer ref="exportTaskRef" module-key="stock-inbound" />
* const exportTaskRef = ref<InstanceType<typeof ExportTaskDrawer> | null>(null)
* exportTaskRef.value?.openDrawer()
*
* 各业务模块只需提供 moduleKeymock 数据由 handlers/device-center/export-task.ts 统一维护。
*/
import { onMounted, reactive, ref } from 'vue'
import { ReloadOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType } from 'ant-design-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { postRequest } from '@axios'
import type { ApiResponse } from '@axios'
/** 导出任务状态 */
export type ExportTaskStatus = 'pending' | 'processing' | 'success' | 'failed'
/** 导出任务列表项 */
export interface ExportTaskItem {
id: string
taskName: string
status: ExportTaskStatus
submitter: string
submitTime: string
finishTime: string
rowCount: number
fileSize: string
failReason?: string
}
const props = defineProps<{
/** 模块标识(如 'sim' / 'stock-inbound' / 'stock-outbound' / 'stock-instock' */
moduleKey: string
}>()
const { message } = useAntdStaticMethods()
const pageInfo = reactive({
visible: false,
title: '查看导出任务',
width: 900,
spin: false,
})
const list = ref<ExportTaskItem[]>([])
const columns: TableColumnsType = [
{ title: '任务名称', dataIndex: 'taskName', align: 'left', width: 260, ellipsis: true },
{ title: '状态', dataIndex: 'status', align: 'center', width: 100 },
{ title: '提交人', dataIndex: 'submitter', align: 'center', width: 100 },
{ title: '提交时间', dataIndex: 'submitTime', align: 'center', width: 170 },
{ title: '完成时间', dataIndex: 'finishTime', align: 'center', width: 170 },
{ title: '行数', dataIndex: 'rowCount', align: 'right', width: 80 },
{ title: '文件大小', dataIndex: 'fileSize', align: 'right', width: 100 },
{ title: '失败原因', dataIndex: 'failReason', align: 'left', ellipsis: true, customRender: ({ value }: { value?: string }) => value ?? '—' },
{ title: '操作', dataIndex: 'action', align: 'center', width: 120, fixed: 'right' },
]
const STATUS_TEXT: Record<ExportTaskStatus, string> = {
pending: '排队中',
processing: '处理中',
success: '成功',
failed: '失败',
}
const STATUS_COLOR: Record<ExportTaskStatus, string> = {
pending: 'default',
processing: 'blue',
success: 'green',
failed: 'red',
}
const statusText = (value: ExportTaskStatus): string => STATUS_TEXT[value] ?? '—'
const statusColor = (value: ExportTaskStatus): string => STATUS_COLOR[value] ?? 'default'
const handleDownload = (record: ExportTaskItem): void => {
void message.success(`文件「${record.taskName}.xlsx」下载已开始(原型阶段占位)`)
}
/** 拉取导出任务列表(按 moduleKey 过滤) */
const loadList = (): void => {
pageInfo.spin = true
postRequest('axiosRequest', '/admin/device/export-tasks/list', { moduleKey: props.moduleKey })
.then((res: ApiResponse<ExportTaskItem[]>) => {
if (res.code === '00000') {
list.value = res.data ?? []
}
})
.catch((err: unknown) => {
console.error('导出任务列表查询失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const openDrawer = (): void => {
pageInfo.visible = true
list.value = []
loadList()
}
const closeDrawer = (): void => {
pageInfo.visible = false
}
defineExpose({ openDrawer, closeDrawer })
// 注:onMounted 时不主动加载,等 openDrawer 触发时再加载
onMounted(() => {
// 占位:组件挂载后等待外部调用 openDrawer
})
</script>
<style scoped lang="less">
.export-task-toolbar {
margin-bottom: 12px;
display: flex;
justify-content: flex-end;
}
</style>
+5 -9
View File
@@ -6,13 +6,13 @@
<script setup lang="ts">
/**
* 实施运维小程序 / 详情页固定底部操作栏
* 实施运维小程序 / 详情页底部操作栏
*
* 设计目的:抽取告警详情 / 工单详情 / 巡检详情 / 设备场景菜单 4 个详情页
* 中重复的"position: fixed + bottom: 0 + safe-area-inset-bottom + 顶部 1px 边框"
* 中重复的"内容流末尾的操作区 + 顶部 1px 边框 + flex 横向排列"
* 样式,避免每页都复制粘贴一大段 less。
*
* 用法:
* 用法:放在详情页内容流末尾(不再 fixed,跟随内容滚动):
* <OpsBottomActions>
* <van-button block type="primary" @click="onClose">关单</van-button>
* <van-button block type="warning" @click="onRepair">维修上报</van-button>
@@ -23,16 +23,12 @@
<style scoped lang="less">
.ops-bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 8px;
padding: 12px 16px env(safe-area-inset-bottom);
padding: 12px 16px;
margin-top: 12px;
background: #fff;
border-top: 1px solid var(--border);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
z-index: 10;
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<template>
<a-tree-select
:value="modelValue"
:tree-data="treeData"
:field-names="FIELD_NAMES"
:placeholder="placeholder"
:allow-clear="allowClear"
:show-search="showSearch"
:tree-default-expand-all="treeDefaultExpandAll"
:style="style"
:filter-tree-node="filterTreeNode"
@change="onChange"
/>
</template>
<script setup lang="ts">
/**
* 仓库区域末级选择器
*
* 数据源:POST /admin/device/warehouse/tree(与仓库区域页同源)
*
* 用法:
* - 列表筛选场景(允许任意层级):<WarehouseAreaPicker v-model="search.warehouseAreaId" />
* - 单据录入场景(只能选末级区域):<WarehouseAreaPicker v-model="form.warehouseAreaId" leaf-only />
*/
import { onMounted, ref } from 'vue'
import type { CSSProperties } from 'vue'
import { postRequest } from '@axios'
import type { ApiResponse } from '@axios'
interface WarehouseNode {
id: string
name: string
children?: WarehouseNode[]
}
const FIELD_NAMES = { label: 'name', value: 'id', children: 'children' }
const props = withDefaults(defineProps<{
/** 选中值(v-model */
modelValue?: string
/** 占位提示 */
placeholder?: string
/** 是否允许清空 */
allowClear?: boolean
/** 是否支持搜索 */
showSearch?: boolean
/** 默认展开全部 */
treeDefaultExpandAll?: boolean
/** 是否限制只能选末级节点(无 children) */
leafOnly?: boolean
/** 透传样式 */
style?: CSSProperties
}>(), {
placeholder: '全部区域',
allowClear: true,
showSearch: true,
treeDefaultExpandAll: true,
leafOnly: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string | undefined): void
(e: 'change', value: string | undefined): void
}>()
const treeData = ref<WarehouseNode[]>([])
const filterTreeNode = (input: string, node: { name?: string }): boolean =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())
/** 递归收集末级节点 id 集合 */
const collectLeafIds = (nodes: WarehouseNode[]): Set<string> => {
const result = new Set<string>()
const walk = (list: WarehouseNode[]): void => {
list.forEach((node) => {
if (node.children?.length) walk(node.children)
else result.add(node.id)
})
}
walk(nodes)
return result
}
const onChange = (value: string | undefined): void => {
// leaf-only 模式下拦截非末级选择
if (value && props.leafOnly) {
const leafIds = collectLeafIds(treeData.value)
if (!leafIds.has(value)) {
return
}
}
emit('update:modelValue', value)
emit('change', value)
}
/** 加载仓库区域全树 */
const loadTree = (): void => {
postRequest('axiosRequest', '/admin/device/warehouse/tree', { keyword: '' })
.then((res: ApiResponse<WarehouseNode[]>) => {
if (res.code === '00000') {
treeData.value = res.data ?? []
}
})
.catch((err: unknown) => {
console.error('仓库区域树加载失败:', err)
})
}
onMounted(() => {
loadTree()
})
</script>
+8
View File
@@ -151,6 +151,14 @@ const onSwitchMp = ({ key }: { key: string }): void => {
display: flex;
flex-direction: column;
position: relative;
/**
* 创建定位包含块:让 phone-shell 内所有 position:fixed 后代以 phone-shell 为参照,
* 而非浏览器视口。原因:本原型把 phone-shell(375×812) 居中放在桌面视口(如 1920×1080)里渲染,
* 默认 fixed 元素会跑到桌面视口边缘(如 OpsBottomActions 的 bottom:0 会贴视口底部,
* 而非 phone-shell 底部),导致按钮栏跑到手机壳外。
* 真机 375 视口下 phone-shell 等同于视口,表现不变。
*/
transform: translateZ(0);
}
.phone-shell__status {
+8 -1
View File
@@ -535,11 +535,18 @@ export const adminSections: NavSection[] = [
},
{
key: 'admin-device-third-auth-amap',
label: '高德地图应用',
label: '高德地图授权',
path: 'third-auth/amap',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/third-auth/amap/amap.vue'),
},
{
key: 'admin-device-third-auth-sim',
label: 'SIM卡管理',
path: 'third-auth/sim',
status: 'ready',
component: () => import('@pages/admin-portal/device-center/third-auth/sim/sim.vue'),
},
],
},
],
@@ -69,6 +69,14 @@
{{ selectedNode.dataReport ? '是' : '否' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="是否支持人脸识别">
<a-tag :color="selectedNode.faceRecognition ? 'green' : 'default'">
{{ selectedNode.faceRecognition ? '是' : '否' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="selectedNode.isIot" label="配网方式">
{{ networkModeLabel(selectedNode.networkMode) }}
</a-descriptions-item>
<!-- 叶子节点专属字段仅当无子节点时显示 -->
<template v-if="!selectedNode.children?.length">
<a-descriptions-item label="厂商">
@@ -103,6 +111,7 @@ const {
selectedNode,
addOrEditRef,
manufacturerLabel,
networkModeLabel,
levelLabel,
searchQuery,
onSelect,
@@ -11,8 +11,11 @@
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '110px', minWidth: '110px' } }"
:label-col="{ style: { width: '120px', minWidth: '120px' } }"
>
<!-- 基础信息区 -->
<a-divider orientation="left" plain>基础信息</a-divider>
<!-- 层级只读展示 -->
<a-form-item label="层级">
<a-tag :color="form.level === 1 ? 'blue' : 'cyan'">
@@ -22,7 +25,7 @@
<!-- 父级分类新增下级时展示顶级分类不展示 -->
<a-form-item v-if="form.level > 1" label="父级分类">
<span>{{ form.parentName ?? '—' }}</span>
<span class="add-or-edit__readonly">{{ form.parentName ?? '—' }}</span>
</a-form-item>
<a-row :gutter="16">
@@ -46,18 +49,24 @@
</a-col>
</a-row>
<a-form-item label="排序" name="sortOrder">
<a-input-number
v-model:value="form.sortOrder"
:min="1"
:max="9999"
style="width: 100%"
placeholder="同级排序"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="排序" name="sortOrder">
<a-input-number
v-model:value="form.sortOrder"
:min="1"
:max="9999"
style="width: 100%"
placeholder="同级排序"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 业务字段 level >= 2 节点显示 -->
<!-- 业务属性区 level >= 2 节点显示 -->
<template v-if="showBusinessFields">
<a-divider orientation="left" plain>业务属性</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="是否 IOT 设备">
@@ -66,7 +75,7 @@
checked-children=""
un-checked-children=""
/>
<span class="add-or-edit__hint">标记该分类下设备是否为物联网设备</span>
<div class="add-or-edit__hint">标记该分类下设备是否为物联网设备</div>
</a-form-item>
</a-col>
<a-col :span="12">
@@ -76,23 +85,51 @@
checked-children=""
un-checked-children=""
/>
<span class="add-or-edit__hint">设备实例继承此配置</span>
<div class="add-or-edit__hint">设备实例继承此配置</div>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="启用数据上报">
<a-switch
v-model:checked="form.dataReport"
checked-children=""
un-checked-children=""
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="启用数据上报">
<a-switch
v-model:checked="form.dataReport"
checked-children=""
un-checked-children=""
/>
<div class="add-or-edit__hint">仅控制开关上报地址由设备实例侧配置</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="支持人脸识别">
<a-switch
v-model:checked="form.faceRecognition"
checked-children=""
un-checked-children=""
/>
<div class="add-or-edit__hint">如食堂人脸支付门禁识别等场景</div>
</a-form-item>
</a-col>
</a-row>
<a-form-item v-if="form.isIot" label="配网方式" name="networkMode">
<a-select
v-model:value="form.networkMode"
allow-clear
show-search
:options="networkModeOptions"
:filter-option="filterOption"
placeholder="请选择配网方式(可选)"
/>
<span class="add-or-edit__hint">仅控制开关状态上报地址由设备实例侧配置</span>
<div class="add-or-edit__hint">标记该分类下设备的主流入网方式</div>
</a-form-item>
</template>
<!-- 叶子节点专属厂商 / 型号 -->
<!-- 设备规格区仅叶子节点显示 -->
<template v-if="showLeafFields">
<a-divider orientation="left" plain>设备规格</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="厂商" name="manufacturer">
@@ -120,6 +157,8 @@
</a-row>
</template>
<a-divider orientation="left" plain>其他</a-divider>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
@@ -154,10 +193,17 @@
* - 确定按钮 :disabled + :loading 都用 pageInfo.spin
* - defineExpose 只暴露 openModal
*
* 布局分区(a-divider):
* - 基础信息:层级 / 父级分类 / 名称 + 编码 / 排序
* - 业务属性(level >= 2):IOT 设备 + 数据分发 / 数据上报 + 人脸识别 / 配网方式(仅 IOT)
* - 设备规格(叶子节点):厂商 + 型号
* - 其他:备注
*
* 业务字段(对齐类别详情):
* - 层级 / 父级分类 / 编码:只读展示
* - 名称 / 排序 / 备注:所有节点通用
* - 是否 IOT / 数据分发 / 数据上报:仅 level >= 2 节点显示
* - IOT / 数据分发 / 数据上报 / 人脸识别 / 配网方式:仅 level >= 2 节点显示
* - 配网方式:仅 IOT 设备时显示(非 IOT 设备无入网场景)
* - 厂商 / 型号:仅叶子节点显示(新增视为叶子;编辑看是否有子)
*/
@@ -175,6 +221,7 @@ const {
formRef,
form,
rules,
networkModeOptions,
manufacturerOptions,
showBusinessFields,
showLeafFields,
@@ -197,9 +244,10 @@ defineExpose({ openModal })
<style scoped lang="less">
.add-or-edit__hint {
margin-left: 12px;
margin-top: 4px;
color: var(--text-secondary, #8c8c8c);
font-size: 12px;
line-height: 1.5;
}
.add-or-edit__readonly {
@@ -21,6 +21,7 @@ import { computed, ref } from 'vue'
import { dictItems } from '@api'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { add, edit, info } from '../api'
import { NETWORK_MODE_OPTIONS } from '@pages/admin-portal/device-center/asset/category/types'
import type { CategoryNode } from '@pages/admin-portal/device-center/asset/category/types'
import type { Form, ModalType, OpenContext, PageInfo } from '../types'
@@ -36,6 +37,8 @@ const createInitForm = (): Form => ({
isIot: false,
dataTransit: false,
dataReport: false,
faceRecognition: false,
networkMode: undefined,
manufacturer: undefined,
model: undefined,
hasChildren: false,
@@ -49,13 +52,16 @@ export const usePage = (emit: (e: 'load') => void) => {
visible: false,
type: 'add',
title: '',
width: 640,
width: 720,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
/** 配网方式下拉选项(复用 types 中的单一来源常量) */
const networkModeOptions = NETWORK_MODE_OPTIONS
/** 厂商字典选项(首次打开弹框时懒加载,后续复用) */
const manufacturerOptions = ref<Array<{ label: string; value: number }>>([])
@@ -120,10 +126,11 @@ export const usePage = (emit: (e: 'load') => void) => {
form.value.parentId = ctx.parentId
form.value.parentName = ctx.parentName
form.value.level = ctx.level ?? 2
/** 新增下级默认关闭个开关,由用户显式配置 */
/** 新增下级默认关闭个开关,由用户显式配置 */
form.value.isIot = false
form.value.dataTransit = false
form.value.dataReport = false
form.value.faceRecognition = false
} else {
form.value.level = ctx?.level ?? 1
}
@@ -178,10 +185,16 @@ export const usePage = (emit: (e: 'load') => void) => {
delete params.isIot
delete params.dataTransit
delete params.dataReport
delete params.faceRecognition
delete params.networkMode
delete params.manufacturer
delete params.model
return params
}
/** 非 IOT 设备不提交配网方式(仅 IOT 设备有入网场景) */
if (!raw.isIot) {
delete params.networkMode
}
/** 非叶子节点不提交厂商/型号 */
if (!isLeaf.value) {
delete params.manufacturer
@@ -236,6 +249,7 @@ export const usePage = (emit: (e: 'load') => void) => {
formRef,
form,
rules,
networkModeOptions,
manufacturerOptions,
showBusinessFields,
showLeafFields,
@@ -8,6 +8,8 @@
* - code 由系统按 D01A01 规则自动生成,弹框内只读展示
*/
import type { NetworkMode } from '@pages/admin-portal/device-center/asset/category/types'
export type ModalType = 'add' | 'edit'
/** 弹框状态 */
@@ -41,6 +43,10 @@ export interface Form {
dataTransit: boolean
/** 是否启用数据上报(仅 level >= 2 节点有效) */
dataReport: boolean
/** 是否支持人脸识别(仅 level >= 2 节点有效) */
faceRecognition: boolean
/** 配网方式(仅 level >= 2 节点有效;可选) */
networkMode?: NetworkMode | undefined
/** 厂商(字典 device_manufacturer 的 value;仅叶子节点有效) */
manufacturer?: number | undefined
/** 型号(仅叶子节点有效) */
@@ -3,6 +3,7 @@ import { dictItems } from '@api'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { remove, tree } from '../api'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import { networkModeLabel } from '../types'
import type { CategoryNode, SearchForm } from '../types'
import { createSearchKey } from './useSearch'
import { useTable } from './useTable'
@@ -212,6 +213,7 @@ export const usePage = () => {
selectedNode,
addOrEditRef,
manufacturerLabel,
networkModeLabel,
levelLabel,
searchQuery,
resetQuery,
@@ -3,6 +3,22 @@ import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/type
/** 分类层级:1=顶级分类 / 2=二级分类 / ... 支持 N 级 */
export type CategoryLevel = number
/** 配网方式(仅 level >= 2 节点配置;标记该分类下设备的主流入网方式) */
export type NetworkMode = 'wifi' | 'bluetooth' | 'sim' | 'ethernet' | 'other_iot'
/** 配网方式下拉选项(内置主流配网方式,无需字典维护) */
export const NETWORK_MODE_OPTIONS: Array<{ label: string; value: NetworkMode }> = [
{ label: 'WIFI', value: 'wifi' },
{ label: '蓝牙', value: 'bluetooth' },
{ label: 'SIM卡', value: 'sim' },
{ label: '网口直连', value: 'ethernet' },
{ label: '其他IOT协议', value: 'other_iot' },
]
/** 配网方式 value → label 映射(详情面板展示用,未配置返回 '—' */
export const networkModeLabel = (value?: NetworkMode | null): string =>
NETWORK_MODE_OPTIONS.find((m) => m.value === value)?.label ?? '—'
/** 设备分类树节点(支持任意层级嵌套) */
export interface CategoryNode extends BaseRecord {
/** 节点名称 */
@@ -23,6 +39,10 @@ export interface CategoryNode extends BaseRecord {
dataTransit?: boolean
/** 是否启用数据上报(仅 level >= 2 节点配置;仅控制开关,上报地址由设备实例侧配置) */
dataReport?: boolean
/** 是否支持人脸识别(仅 level >= 2 节点配置;如食堂人脸支付、门禁识别等场景) */
faceRecognition?: boolean
/** 配网方式(仅 level >= 2 节点配置;可选,标记该分类下设备的主流入网方式) */
networkMode?: NetworkMode
/** 厂商(字典 device_manufacturer 的 value;仅叶子节点配置) */
manufacturer?: number
/** 型号(仅叶子节点配置;与厂商配套) */
@@ -52,6 +72,8 @@ export interface CategoryPayload {
isIot?: boolean
dataTransit?: boolean
dataReport?: boolean
faceRecognition?: boolean
networkMode?: NetworkMode
/** 叶子节点专属字段 */
manufacturer?: number
model?: string
@@ -1,9 +1,9 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DeviceCategoryTab, DeviceItem, SearchForm } from '../types'
import type { CustomerOption, DeviceCategoryOption, DeviceItem, SearchForm, SiteOption } from '../types'
/** 查询设备列表(按顶级分类 Tab 过滤) */
export const list = (params: SearchForm & { category: DeviceCategoryTab }): Promise<ApiResponse<DeviceItem[]>> =>
export const list = (params: SearchForm & { category: string }): Promise<ApiResponse<DeviceItem[]>> =>
postRequest('axiosRequest', '/admin/device/manage/list', params)
/** 删除设备(归档,不物理删除) */
@@ -11,5 +11,21 @@ export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/delete', { id })
/** 异步导出设备列表(按当前搜索条件) */
export const exportList = (params: SearchForm & { category: DeviceCategoryTab }): Promise<ApiResponse<null>> =>
export const exportList = (params: SearchForm & { category: string }): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/export', params)
/** 批量导入设备(原型阶段占位) */
export const importList = (): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/manage/import', {})
/** 设备类别下拉(按顶级分类 Tab 过滤,附带 networkMode 供前端决定 SIM 卡字段显隐) */
export const categoryOptions = (category: string): Promise<ApiResponse<DeviceCategoryOption[]>> =>
postRequest('axiosRequest', '/admin/device/manage/category/options', { category })
/** 所属客户下拉(可搜索) */
export const customerOptions = (keyword?: string): Promise<ApiResponse<CustomerOption[]>> =>
postRequest('axiosRequest', '/admin/device/manage/customer/options', { keyword })
/** 所属场所下拉(按 customerId 联动过滤,可搜索) */
export const siteOptions = (customerId: string, keyword?: string): Promise<ApiResponse<SiteOption[]>> =>
postRequest('axiosRequest', '/admin/device/manage/site/options', { customerId, keyword })
@@ -9,103 +9,49 @@
@close="closeDrawer"
>
<a-spin :spinning="pageInfo.spin">
<a-tabs v-model:active-key="activeTab" type="line">
<!-- Tab 1 基础档案 -->
<a-tab-pane key="archive" tab="基础档案">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions-item label="设备编码">{{ detail.deviceCode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属型号">{{ detail.modelName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="顶级分类">{{ categoryLabel(detail.category) }}</a-descriptions-item>
<a-descriptions-item label="投用状态">
<a-tag :color="deployStatusColor(detail.deployStatus)">{{ deployStatusText(detail.deployStatus) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="运行状态">
<a-tag :color="runStatusColor(detail.runStatus)">{{ runStatusText(detail.runStatus) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="软件版本">{{ detail.softwareVersion ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="是否 IOT">
<a-tag :color="detail.isIot ? 'green' : 'default'">{{ detail.isIot ? '是' : '' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="所属租户">{{ detail.tenantName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="布点场所">{{ detail.siteName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="入库时间">{{ detail.onlineTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="投用时间">{{ detail.deployTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="最近心跳">{{ detail.lastHeartbeat ?? '—' }}</a-descriptions-item>
<a-empty v-if="!detail" description="暂无档案数据" />
<template v-else>
<!-- 基础信息对齐列表字段 -->
<a-descriptions :column="2" bordered size="small" title="基础信息">
<a-descriptions-item label="设备编码">{{ detail.deviceCode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备类别">{{ detail.deviceCategory ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="顶级分类">{{ categoryLabel(detail.category) }}</a-descriptions-item>
<a-descriptions-item label="设备名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备序列号" :span="2">{{ detail.serialNumber ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="投用状态">
<a-tag :color="deployStatusColor(detail.deployStatus)">{{ deployStatusText(detail.deployStatus) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="软件版本">{{ detail.softwareVersion ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属客户">{{ detail.customerName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属场所">{{ detail.siteName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="入库时间">{{ detail.onlineTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="投用时间">{{ detail.deployTime ?? '' }}</a-descriptions-item>
</a-descriptions>
<!-- 智养餐饮设备差异字段 -->
<template v-if="detail.category === 'catering'">
<a-descriptions-item label="所属轨道">{{ detail.trackNo ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="摆放方式">{{ detail.placement ?? '' }}</a-descriptions-item>
<a-descriptions-item label="绑定大屏">{{ detail.boundScreen ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所在档口">{{ detail.stall ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="客户端 IP" :span="2">{{ detail.clientIp ?? '' }}</a-descriptions-item>
</template>
<!-- 类别详情是否 IOT / 数据分发 / 数据上报 / 配网方式 / 厂商 / 型号 -->
<a-descriptions :column="2" bordered size="small" title="类别详情" class="info-drawer__section">
<a-descriptions-item label="是否 IOT">
<a-tag :color="detail.isIot ? 'green' : 'default'">{{ detail.isIot ? '是' : '' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="启用数据分发">
<a-tag :color="detail.dataTransit ? 'green' : 'default'">{{ detail.dataTransit ? '是' : '' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="启用数据上报">
<a-tag :color="detail.dataReport ? 'green' : 'default'">{{ detail.dataReport ? '是' : '否' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="配网方式">{{ networkModeText(detail.networkMode) }}</a-descriptions-item>
<a-descriptions-item label="厂商">{{ detail.manufacturer ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="型号">{{ detail.model ?? '—' }}</a-descriptions-item>
</a-descriptions>
<!-- 体重测量设备差异字段 -->
<template v-if="detail.category === 'body'">
<a-descriptions-item label="测量指标" :span="2">{{ detail.measureIndicators ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="量程">{{ detail.range ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="精度等级">{{ detail.accuracy ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="计量单位">{{ detail.unit ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="测量方式">{{ detail.measureMethod ?? '—' }}</a-descriptions-item>
</template>
<!-- 智能穿戴设备差异字段 -->
<template v-if="detail.category === 'wearable'">
<a-descriptions-item label="腕带编号">{{ detail.bandNo ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="SIM 卡号">{{ detail.simCard ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="绑定用户">{{ detail.boundUser ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="电池电量">{{ detail.battery ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="通讯协议" :span="2">{{ detail.protocol ?? '—' }}</a-descriptions-item>
</template>
<!-- 健身器材设备差异字段 -->
<template v-if="detail.category === 'fitness'">
<a-descriptions-item label="器材类型">{{ detail.equipmentType ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="阻力调节">{{ detail.resistanceMode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="训练数据采集" :span="2">{{ detail.trainDataType ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备位置" :span="2">{{ detail.location ?? '—' }}</a-descriptions-item>
</template>
<!-- 健康监测设备差异字段 -->
<template v-if="detail.category === 'monitor'">
<a-descriptions-item label="监测指标" :span="2">{{ detail.monitorIndicators ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="采样频率">{{ detail.sampleFrequency ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="测量方式">{{ detail.measureMethod ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="报告生成" :span="2">{{ detail.reportMode ?? '—' }}</a-descriptions-item>
</template>
</a-descriptions>
<a-empty v-else description="暂无档案数据" />
</a-tab-pane>
<!-- Tab 2 运行日志 -->
<a-tab-pane key="runLog" tab="运行日志">
<a-empty v-if="!detail?.runLogs?.length" description="暂无运行日志" />
<a-timeline v-else>
<a-timeline-item v-for="log in detail.runLogs" :key="log.id" :color="logTimelineColor(log.eventType)">
<p class="info-drawer__log-time">{{ log.occurTime ?? '—' }}</p>
<p class="info-drawer__log-type">
<a-tag :color="logTimelineColor(log.eventType)">{{ log.eventType }}</a-tag>
{{ log.content ?? '—' }}
</p>
</a-timeline-item>
</a-timeline>
</a-tab-pane>
<!-- Tab 3 维修履历 -->
<a-tab-pane key="repair" tab="维修履历">
<a-empty v-if="!detail?.repairs?.length" description="暂无维修履历" />
<a-table
v-else
:columns="repairColumns"
:data-source="detail?.repairs ?? []"
:pagination="false"
size="small"
row-key="id"
/>
</a-tab-pane>
</a-tabs>
<!-- 授权与 SIM -->
<a-descriptions :column="1" bordered size="small" title="授权与 SIM" class="info-drawer__section">
<a-descriptions-item label="虹软授权码">{{ detail.arcLicense ?? '—' }}</a-descriptions-item>
<a-descriptions-item v-if="detail.networkMode === 'sim'" label="绑定 SIM 卡">
{{ detail.simIccid ?? '' }}
</a-descriptions-item>
</a-descriptions>
</template>
</a-spin>
<template #footer>
@@ -121,51 +67,30 @@
* drawer-spec.md 强制项已遵循:
* - destroy-on-close 避免数据残留
* - 父组件通过 ref.openDrawer(record) 调用
* - emit('ok') 与 Modal 的 emit('load') 区分(本抽屉只读,无需 emit)
* - 只读详情,无 emit('ok')
*
* 业务字段对齐 §3.6.2 设备监测详情页(本期 P0 仅聚合基础档案 + 运行日志 + 维修履历)
* - 实施记录 / 自检记录 / 巡检记录 / 更新记录 由"设备运维"模块产生,后置二期补齐
* - 已删除"数据中转"字段,新增"投用状态",固件版本→软件版本
* 业务字段:
* - 基础信息:对齐列表字段
* - 类别详情:是否 IOT / 数据分发 / 数据上报 / 配网方式 / 厂商 / 型号
* - 虹软授权码
* - 绑定 SIM 卡:仅当类别 networkMode === 'sim' 时展示
* - 运行日志 / 维修履历 已移除(由设备运维菜单承载)
*/
import type { TableColumnsType } from 'ant-design-vue'
import type { DeployStatus, RunStatus } from '../../../../types'
import type { DeployStatus } from '../../../../types'
import { usePage } from './init/usePage'
const {
pageInfo,
detail,
activeTab,
tabList: _tabList,
categoryLabel,
networkModeText,
openDrawer,
closeDrawer,
} = usePage()
defineExpose({ openDrawer })
/** 运行状态 → Tag 颜色 */
const runStatusColor = (status: RunStatus | undefined): string => {
const map: Record<RunStatus, string> = {
online: 'success',
offline: 'default',
repairing: 'warning',
scrapped: 'error',
}
return status ? (map[status] ?? 'default') : 'default'
}
/** 运行状态 → 中文 */
const runStatusText = (status: RunStatus | undefined): string => {
const map: Record<RunStatus, string> = {
online: '在用',
offline: '离线',
repairing: '维修中',
scrapped: '报废',
}
return status ? (map[status] ?? '—') : '—'
}
/** 投用状态 → Tag 颜色 */
const deployStatusColor = (status: DeployStatus | undefined): string => {
const map: Record<DeployStatus, string> = {
@@ -183,36 +108,10 @@ const deployStatusText = (status: DeployStatus | undefined): string => {
}
return status ? (map[status] ?? '—') : '—'
}
/** 日志事件类型 → 时间线颜色 */
const logTimelineColor = (eventType: string | undefined): string => {
if (!eventType) return 'blue'
if (eventType.includes('异常') || eventType.includes('故障')) return 'red'
if (eventType === '关机' || eventType === '离线') return 'gray'
return 'blue'
}
/** 维修履历列定义 */
const repairColumns: TableColumnsType = [
{ title: '关联工单', dataIndex: 'workOrderNo', align: 'center', width: 140, ellipsis: true },
{ title: '故障类型', dataIndex: 'faultType', align: 'left', width: 120, ellipsis: true },
{ title: '维修方式', dataIndex: 'repairMethod', align: 'left', width: 120, ellipsis: true },
{ title: '维修人', dataIndex: 'repairer', align: 'center', width: 100, ellipsis: true },
{ title: '工时(h)', dataIndex: 'workHours', align: 'center', width: 90 },
{ title: '完成时间', dataIndex: 'finishTime', align: 'center', width: 170, ellipsis: true },
]
</script>
<style scoped lang="less">
.info-drawer__log-time {
margin: 0 0 4px;
color: #8c8c8c;
font-size: 12px;
}
.info-drawer__log-type {
margin: 0;
color: #262626;
font-size: 13px;
.info-drawer__section {
margin-top: 16px;
}
</style>
@@ -1,9 +1,9 @@
/**
* 设备管理 - 详情抽屉 - 主逻辑
*
* 严格遵循 drawer-spec.mdB 类型:详情抽屉)
* - openDrawer 接收 { id },拉取详情后展示
* - 抽屉内 Tab 切换:基础档案 / 运行日志 / 维修履历
* 严格遵循 drawer-spec.mdB 类型:详情抽屉,单页详情
* - openDrawer 接收 { id, name },拉取详情后展示
* - 不再分 Tab运行日志 / 维修履历 移至设备运维菜单)
*/
import { ref } from 'vue'
@@ -11,13 +11,6 @@ import type { DeviceCategoryTab } from '../../../../types'
import { getInfo } from '../api'
import type { DetailInfo, PageInfo } from '../types'
/** Tab 列表 */
export const TAB_LIST: { key: string; label: string }[] = [
{ key: 'archive', label: '基础档案' },
{ key: 'runLog', label: '运行日志' },
{ key: 'repair', label: '维修履历' },
]
/** 顶级分类 → 中文标签 */
const TAB_LABEL_MAP: Record<DeviceCategoryTab, string> = {
catering: '智养餐饮设备',
@@ -27,16 +20,24 @@ const TAB_LABEL_MAP: Record<DeviceCategoryTab, string> = {
monitor: '健康监测设备',
}
/** 配网方式 → 中文 */
const NETWORK_MODE_TEXT: Record<string, string> = {
wifi: 'Wi-Fi',
bluetooth: '蓝牙',
sim: 'SIM 卡',
ethernet: '以太网',
other_iot: '其他 IOT',
}
export const usePage = () => {
const pageInfo = reactive<PageInfo>({
visible: false,
title: '设备详情',
spin: false,
width: 900,
width: 720,
})
const detail = ref<DetailInfo | null>(null)
const activeTab = ref<string>('archive')
const loadData = (id: string): void => {
pageInfo.spin = true
@@ -57,7 +58,6 @@ export const usePage = () => {
/** 父组件调用入口 */
const openDrawer = (record: { id: string; name?: string }): void => {
detail.value = null
activeTab.value = 'archive'
pageInfo.visible = true
pageInfo.title = record.name ? `设备详情 · ${record.name}` : '设备详情'
loadData(record.id)
@@ -73,12 +73,17 @@ export const usePage = () => {
return TAB_LABEL_MAP[cat] ?? '—'
}
/** 配网方式中文 */
const networkModeText = (mode: string | undefined): string => {
if (!mode) return '—'
return NETWORK_MODE_TEXT[mode] ?? '—'
}
return {
pageInfo,
detail,
activeTab,
tabList: TAB_LIST,
categoryLabel,
networkModeText,
openDrawer,
closeDrawer,
}
@@ -3,10 +3,11 @@
*
* 严格遵循 drawer-spec.mdB 类型:详情抽屉)
* - PageInfo 控制抽屉状态
* - DetailInfo 承载完整档案 + 运行日志 + 维修履历
* - DetailInfo 对齐列表字段 + 类别详情 + 虹软授权码 + 绑定 SIM 卡
* - 去掉运行日志 / 维修履历(移至设备运维菜单)
*/
import type { DeviceCategoryTab, DeployStatus, RunStatus } from '../../../../types'
import type { DeployStatus, DeviceCategoryTab, NetworkMode } from '../../../../types'
/** 抽屉状态 */
export interface PageInfo {
@@ -16,55 +17,56 @@ export interface PageInfo {
width: number
}
/** 运行日志条目 */
export interface RunLogItem {
id: string
/** 事件类型:开机/关机/心跳/关键事件 */
eventType: string
/** 事件内容 */
content: string
/** 发生时间 */
occurTime: string
}
/** 维修记录条目 */
export interface RepairItem {
id: string
/** 关联工单号 */
workOrderNo: string
/** 故障类型 */
faultType: string
/** 维修方式 */
repairMethod: string
/** 维修人 */
repairer: string
/** 工时(小时) */
workHours: number
/** 完成时间 */
finishTime: string
}
/** 设备完整档案(含通用 + 5 Tab 差异字段) */
/**
* 设备完整档案
* - 基础信息:对齐列表字段
* - 类别详情:isIot / dataTransit / dataReport / networkMode / manufacturer / model
* - 虹软授权码 + 绑定 SIM 卡(仅 networkMode=sim 时有意义)
*/
export interface DetailInfo {
id: string
/** 设备编码 */
deviceCode: string
/** 设备名称 */
name: string
modelName: string
/** 设备序列号 */
serialNumber: string
/** 设备类别(二级分类名称) */
deviceCategory: string
/** 所属顶级分类 */
category: DeviceCategoryTab
tenantName: string
/** 所属客户 ID */
customerId: string
/** 所属客户名称 */
customerName: string
/** 所属场所 ID */
siteId: string
/** 所属场所名称 */
siteName: string
/** 投用状态 */
deployStatus: DeployStatus
runStatus: RunStatus
/** 软件版本 */
softwareVersion: string
/** 入网时间 */
onlineTime: string
/** 投用时间 */
deployTime: string
lastHeartbeat: string
isIot: boolean
/** 各 Tab 差异字段(动态) */
[key: string]: string | boolean | undefined
/** 虹软授权码 */
arcLicense: string
/** 绑定 SIM 卡 ICCID */
simIccid: string
/** 运行日志(近 10 条) */
runLogs?: RunLogItem[]
/** 维修履历(近 5 条) */
repairs?: RepairItem[]
// ===== 类别详情(冗余在设备记录里,由后端按 deviceCategory 回填) =====
/** 是否 IOT 设备 */
isIot: boolean
/** 是否启用数据分发 */
dataTransit: boolean
/** 是否启用数据上报 */
dataReport: boolean
/** 配网方式 */
networkMode: NetworkMode
/** 厂商 */
manufacturer: string
/** 型号 */
model: string
}
@@ -13,7 +13,7 @@
:rules="rules"
:label-col="LABEL_COL"
>
<!-- 通用字段区块 -->
<!-- 基础信息 -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备编码" name="deviceCode">
@@ -27,7 +27,24 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备名称" name="name">
<a-form-item label="设备类别" name="deviceCategory">
<a-select
v-model:value="form.deviceCategory"
:options="formOptions.deviceCategory"
show-search
allow-clear
placeholder="请选择设备类别"
style="width: 100%"
:filter-option="filterOption"
@change="onCategoryChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备名称">
<a-input
v-model:value="form.name"
v-no-space
@@ -37,61 +54,47 @@
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备序列号">
<a-input
v-model:value="form.serialNumber"
v-only-alphanumeric-special
allow-clear
:maxlength="40"
placeholder="如厂商出厂 SN"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属型号" name="modelName">
<a-form-item label="所属客户">
<a-select
v-model:value="form.modelName"
:options="formOptions.modelName"
v-model:value="form.customerId"
:options="formOptions.customer"
show-search
allow-clear
placeholder="请选择二级型号"
placeholder="留空代表在库未投用"
style="width: 100%"
:filter-option="filterOption"
@search="refreshCustomerOptions"
@change="onCustomerChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="运行状态" name="runStatus">
<a-form-item label="所属场所">
<a-select
v-model:value="form.runStatus"
:options="formOptions.runStatus"
placeholder="请选择运行状态"
v-model:value="form.siteId"
:options="formOptions.site"
:disabled="!form.customerId"
show-search
allow-clear
:placeholder="form.customerId ? '请选择场所' : '请先选择客户'"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!--
所属租户 / 布点场所 非必填
- 二者均为空 投用状态自动为"在库"
- 任一有值 投用状态自动为"投用"
- 投用状态以只读 Tag 形式展示在右侧
-->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属租户" name="tenantName">
<a-input
v-model:value="form.tenantName"
v-no-space
allow-clear
:maxlength="40"
placeholder="留空代表在库未投用"
@change="onDeployFieldChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="布点场所" name="siteName">
<a-input
v-model:value="form.siteName"
v-no-space
allow-clear
:maxlength="40"
placeholder="留空代表在库未投用"
@change="onDeployFieldChange"
:filter-option="filterOption"
@change="onSiteChange"
/>
</a-form-item>
</a-col>
@@ -114,142 +117,32 @@
<a-tag :color="form.deployStatus === 'deployed' ? 'success' : 'default'">
{{ deployStatusText }}
</a-tag>
<span class="deploy-tip"> / 场所自动判定</span>
<span class="deploy-tip"> / 场所自动判定</span>
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'catering'" orientation="left">智养餐饮设备差异字段</a-divider>
<a-row v-if="activeTab === 'catering'" :gutter="16">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属轨道">
<a-input v-model:value="form.trackNo" v-no-space allow-clear :maxlength="20" placeholder="如 T-01" />
<a-form-item label="虹软授权码">
<a-input
v-model:value="form.arcLicense"
v-no-space
allow-clear
:maxlength="60"
placeholder="虹软 SDK 授权码,无可留空"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="摆放方式">
<a-select v-model:value="form.placement" :options="formOptions.placement" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="绑定大屏">
<a-input v-model:value="form.boundScreen" v-no-space allow-clear :maxlength="30" placeholder="关联设备编码" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所在档口">
<a-input v-model:value="form.stall" v-no-space allow-clear :maxlength="30" placeholder="如 主食档" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="客户端 IP">
<a-input v-model:value="form.clientIp" v-no-space allow-clear :maxlength="30" placeholder="如 192.168.10.11" />
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'body'" orientation="left">体重测量设备差异字段</a-divider>
<a-row v-if="activeTab === 'body'" :gutter="16">
<a-col :span="12">
<a-form-item label="测量指标">
<a-input v-model:value="form.measureIndicators" v-no-space allow-clear :maxlength="60" placeholder="如 体重 / 体脂 / BMI" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="量程">
<a-input v-model:value="form.range" v-no-space allow-clear :maxlength="20" placeholder="如 0-200kg" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="精度等级">
<a-input v-model:value="form.accuracy" v-no-space allow-clear :maxlength="20" placeholder="如 ±0.1kg" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="计量单位">
<a-input v-model:value="form.unit" v-no-space allow-clear :maxlength="10" placeholder="如 kg" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="测量方式">
<a-select v-model:value="form.measureMethod" :options="formOptions.measureMethod" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'wearable'" orientation="left">智能穿戴设备差异字段</a-divider>
<a-row v-if="activeTab === 'wearable'" :gutter="16">
<a-col :span="12">
<a-form-item label="腕带编号">
<a-input v-model:value="form.bandNo" v-no-space allow-clear :maxlength="30" placeholder="如 BN-0001" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="SIM 卡号">
<a-input v-model:value="form.simCard" v-only-alphanumeric allow-clear :maxlength="30" placeholder="ICC-ID" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="绑定用户">
<a-input v-model:value="form.boundUser" v-no-space allow-clear :maxlength="30" placeholder="用户 ID + 姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="电池电量">
<a-input v-model:value="form.battery" v-no-space allow-clear :maxlength="10" placeholder="如 85%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="通讯协议">
<a-select v-model:value="form.protocol" :options="formOptions.protocol" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'fitness'" orientation="left">健身器材设备差异字段</a-divider>
<a-row v-if="activeTab === 'fitness'" :gutter="16">
<a-col :span="12">
<a-form-item label="器材类型">
<a-select v-model:value="form.equipmentType" :options="formOptions.equipmentType" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="阻力调节方式">
<a-select v-model:value="form.resistanceMode" :options="formOptions.resistanceMode" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="训练数据采集">
<a-input v-model:value="form.trainDataType" v-no-space allow-clear :maxlength="60" placeholder="如 时长 / 卡路里 / 距离" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备位置">
<a-input v-model:value="form.location" v-no-space allow-clear :maxlength="40" placeholder="场所内具体位置" />
</a-form-item>
</a-col>
</a-row>
<a-divider v-if="activeTab === 'monitor'" orientation="left">健康监测设备差异字段</a-divider>
<a-row v-if="activeTab === 'monitor'" :gutter="16">
<a-col :span="12">
<a-form-item label="监测指标">
<a-input v-model:value="form.monitorIndicators" v-no-space allow-clear :maxlength="80" placeholder="如 血糖 / 血压 / 血氧" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="采样频率">
<a-input v-model:value="form.sampleFrequency" v-no-space allow-clear :maxlength="20" placeholder="如 每5分钟 / 按需" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="测量方式">
<a-select v-model:value="form.measureMethod" :options="formOptions.measureMethod" allow-clear placeholder="请选择" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="报告生成">
<a-select v-model:value="form.reportMode" :options="formOptions.reportMode" allow-clear placeholder="请选择" style="width: 100%" />
<a-col v-if="showSimIccid" :span="12">
<a-form-item label="绑定 SIM 卡">
<a-input
v-model:value="form.simIccid"
v-only-alphanumeric
allow-clear
:maxlength="32"
placeholder="SIM 卡 ICCID"
/>
</a-form-item>
</a-col>
</a-row>
@@ -273,13 +166,14 @@
* 设备管理 - 新增/编辑 弹框
*
* modal-spec.md 强制项已遵循。
* 业务字段调整
* - 通用字段:编码 / 名称 / 号 / 运行状态 / 软件版本 / 投用状态
* - 所属租户 / 布点场所 非必填:留空 → 在库;任一有值 → 投用
* - 投用状态以只读 Tag 展示,由 onDeployFieldChange 联动计算
* - 按 activeTab 条件渲染 5 类差异字段区块
* 业务字段:
* - 通用字段:编码 / 类别 / 名称 / 序列号 / 客户 / 场所 / 软件版本 / 投用状态 / 虹软授权码
* - 绑定 SIM 卡:仅当所选类别 networkMode === 'sim' 时展示
* - 所属客户 / 场所 均为可搜索下拉,场所按客户联动过滤
* - 5 个 Tab 共用同一表单,无差异字段区块
*/
import { filterOption } from '@utils/antDesign/select'
import { usePage } from './init/usePage'
import { LABEL_COL } from './types'
@@ -292,13 +186,16 @@ const {
formRef,
form,
formOptions,
activeTab,
deployStatusText,
showSimIccid,
rules,
openModal,
closeModal,
submit,
onDeployFieldChange,
onCustomerChange,
onSiteChange,
onCategoryChange,
refreshCustomerOptions,
} = usePage(emit)
defineExpose({ openModal })
@@ -8,130 +8,53 @@
* - submit 开头立即 pageInfo.spin = true
* - 用 params.id 判断新增/编辑
*
* 设备管理特殊点:
* - 5 个 Tab 共用一张表单,差异字段activeTab 条件渲染
* - activeTab 在新增时由 OpenContext 传入;编辑时由详情回填
* - 下拉选项(型号、摆放方式等)按 activeTab 切换数据源
* - 所属租户 / 布点场所 非必填:留空 → 投用状态自动为在库;填值 → 投用状态自动为投用
* 业务要点:
* - 5 个 Tab 共用同一表单(无差异字段),activeTab 仅控制设备类别下拉数据源
* - 所属客户 / 所属场所 均为可搜索下拉,场所按客户联动过滤
* - 投用状态由客户 / 场所自动判定(任一有值 → deployed)
* - 绑定 SIM 卡字段仅当所选类别 networkMode === 'sim' 时展示
* - 类别元数据(isIot/dataTransit/dataReport/networkMode/manufacturer/model
* 由后端按所选 deviceCategory 在 add/update 时回填到设备记录,前端表单不直接编辑
*/
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { computed } from 'vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { categoryOptions, customerOptions, siteOptions } from '../../../../api'
import { add, edit, info } from '../api'
import type { DeviceCategoryTab, DeployStatus } from '../../../../types'
import type { DeviceCategoryOption, DeviceCategoryTab, DeployStatus, NetworkMode } from '../../../../types'
import type { Form, FormOptions, ModalType, OpenContext, PageInfo } from '../types'
/** 各 Tab 的二级型号下拉数据(与设备分类预置型号对齐) */
const MODEL_OPTIONS: Record<DeviceCategoryTab, { label: string; value: string }[]> = {
catering: [
{ label: '吐盘机', value: '吐盘机' },
{ label: '智能货柜', value: '智能货柜' },
{ label: '营养秤', value: '营养秤' },
{ label: '配比秤', value: '配比秤' },
{ label: '结算台', value: '结算台' },
{ label: '入库秤', value: '入库秤' },
{ label: '带鱼屏', value: '带鱼屏' },
{ label: '波菲炉', value: '波菲炉' },
],
body: [
{ label: '一体机', value: '一体机' },
{ label: '蓝牙秤', value: '蓝牙秤' },
{ label: '体脂仪', value: '体脂仪' },
],
wearable: [
{ label: '华为-watch5', value: '华为-watch5' },
{ label: '华为-watch4', value: '华为-watch4' },
{ label: '罗杰夫-LJF01', value: '罗杰夫-LJF01' },
],
fitness: [
{ label: '跑步机', value: '跑步机' },
{ label: '椭圆机', value: '椭圆机' },
{ label: '健身车', value: '健身车' },
],
monitor: [
{ label: '血糖监测仪', value: '血糖监测仪' },
{ label: '高血压治疗仪', value: '高血压治疗仪' },
{ label: '智能健康一体机', value: '智能健康一体机' },
],
}
const RUN_STATUS_OPTIONS: { label: string; value: Form['runStatus'] }[] = [
{ label: '在用', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '维修中', value: 'repairing' },
{ label: '报废', value: 'scrapped' },
]
const PLACEMENT_OPTIONS = [
{ label: '落地', value: '落地' },
{ label: '壁挂', value: '壁挂' },
{ label: '嵌入式', value: '嵌入式' },
{ label: '台面', value: '台面' },
]
const MEASURE_METHOD_OPTIONS = [
{ label: '站姿', value: '站姿' },
{ label: '坐姿', value: '坐姿' },
{ label: '手持', value: '手持' },
{ label: '采血', value: '采血' },
{ label: '袖带', value: '袖带' },
{ label: '光电', value: '光电' },
]
const PROTOCOL_OPTIONS = [
{ label: '4G', value: '4G' },
{ label: '5G', value: '5G' },
{ label: 'BLE', value: 'BLE' },
{ label: 'Wi-Fi', value: 'Wi-Fi' },
]
const EQUIPMENT_TYPE_OPTIONS = [
{ label: '有氧', value: '有氧' },
{ label: '力量', value: '力量' },
]
const RESISTANCE_MODE_OPTIONS = [
{ label: '机械', value: '机械' },
{ label: '电磁', value: '电磁' },
{ label: '液压', value: '液压' },
]
const REPORT_MODE_OPTIONS = [
{ label: '本地', value: '本地' },
{ label: '云端', value: '云端' },
]
/** 初始表单工厂 */
const createInitForm = (): Form => ({
id: undefined,
deviceCode: '',
name: '',
modelName: '',
serialNumber: '',
deviceCategory: '',
category: 'catering',
tenantName: '',
customerId: '',
customerName: '',
siteId: '',
siteName: '',
deployStatus: 'in_stock',
runStatus: 'online',
softwareVersion: '',
deployTime: '',
onlineTime: '',
arcLicense: '',
simIccid: '',
})
/** 初始下拉选项工厂 */
const createInitOptions = (): FormOptions => ({
modelName: [],
runStatus: RUN_STATUS_OPTIONS,
placement: PLACEMENT_OPTIONS,
measureMethod: MEASURE_METHOD_OPTIONS,
protocol: PROTOCOL_OPTIONS,
equipmentType: EQUIPMENT_TYPE_OPTIONS,
resistanceMode: RESISTANCE_MODE_OPTIONS,
reportMode: REPORT_MODE_OPTIONS,
deviceCategory: [],
customer: [],
site: [],
})
/** 依据户 / 场所判定投用状态 */
const computeDeployStatus = (tenantName: string, siteName: string): DeployStatus => {
if (!tenantName && !siteName) return 'in_stock'
/** 依据户 / 场所判定投用状态 */
const computeDeployStatus = (customerId: string, siteId: string): DeployStatus => {
if (!customerId && !siteId) return 'in_stock'
return 'deployed'
}
@@ -150,28 +73,66 @@ export const usePage = (emit: (e: 'load') => void) => {
const form = ref<Form>(createInitForm())
const formOptions = ref<FormOptions>(createInitOptions())
/** 当前 Tab(控制差异字段区块显隐 */
/** 当前 Tab(控制设备类别下拉数据源 */
const activeTab = ref<DeviceCategoryTab>('catering')
/** 投用状态实时联动(只读展示) */
const deployStatusText = computed(() => (form.value.deployStatus === 'deployed' ? '投用' : '在库'))
/** 租户或场所变化时重新计算投用状态 */
/** 当前所选类别对应的 networkMode(决定 SIM 卡字段显隐) */
const currentNetworkMode = computed<NetworkMode | undefined>(() => {
const matched = formOptions.value.deviceCategory.find((item) => item.value === form.value.deviceCategory)
return matched?.networkMode
})
/** 是否展示绑定 SIM 卡输入 */
const showSimIccid = computed(() => currentNetworkMode.value === 'sim')
/** 客户 / 场所变化时重新计算投用状态 */
const onDeployFieldChange = (): void => {
form.value.deployStatus = computeDeployStatus(form.value.tenantName, form.value.siteName)
form.value.deployStatus = computeDeployStatus(form.value.customerId, form.value.siteId)
}
/** 客户变更:清空场所并刷新场所下拉 */
const onCustomerChange = (): void => {
form.value.siteId = ''
form.value.siteName = ''
onDeployFieldChange()
if (form.value.customerId) {
siteOptions(form.value.customerId)
.then((res) => {
if (res.code === '00000') {
formOptions.value.site = (res.data ?? []).map((item) => ({ label: item.name, value: item.id }))
}
})
.catch((err: unknown) => {
console.error('场所下拉加载失败:', err)
})
} else {
formOptions.value.site = []
}
}
/** 场所变化时重新计算投用状态 */
const onSiteChange = (): void => {
onDeployFieldChange()
}
/** 设备类别变化时:若新类别 networkMode !== 'sim',清空已填的 simIccid */
const onCategoryChange = (): void => {
if (!showSimIccid.value) form.value.simIccid = ''
}
const rules: Record<string, Rule[]> = {
deviceCode: [{ required: true, message: '请输入设备编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
modelName: [{ required: true, message: '请选择所属型号', trigger: 'change' }],
runStatus: [{ required: true, message: '请选择运行状态', trigger: 'change' }],
// 所属租户 / 布点场所 非必填:留空代表在库未投用
deviceCategory: [{ required: true, message: '请选择设备类别', trigger: 'change' }],
// 设备名称 / 序列号 / 客户 / 场所 / 软件版本 / 虹软授权码 / SIM 卡 均非必填
}
const resetForm = (): void => {
formRef.value?.clearValidate()
form.value = createInitForm()
formOptions.value = createInitOptions()
}
const closeModal = (): void => {
@@ -179,9 +140,52 @@ export const usePage = (emit: (e: 'load') => void) => {
pageInfo.visible = false
}
/** 按 activeTab 切换型号下拉 */
const refreshModelOptions = (tab: DeviceCategoryTab): void => {
formOptions.value.modelName = MODEL_OPTIONS[tab] ?? []
/** 按 activeTab 切换设备类别下拉 */
const refreshCategoryOptions = (tab: DeviceCategoryTab): void => {
categoryOptions(tab)
.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,
}))
}
})
.catch((err: unknown) => {
console.error('设备类别下拉加载失败:', err)
})
}
/** 加载客户下拉(可搜索) */
const refreshCustomerOptions = (keyword?: string): void => {
customerOptions(keyword)
.then((res) => {
if (res.code === '00000') {
formOptions.value.customer = (res.data ?? []).map((item) => ({ label: item.name, value: item.id }))
}
})
.catch((err: unknown) => {
console.error('客户下拉加载失败:', err)
})
}
/** 按 customerId 加载场所下拉 */
const refreshSiteOptions = (customerId: string): void => {
if (!customerId) {
formOptions.value.site = []
return
}
siteOptions(customerId)
.then((res) => {
if (res.code === '00000') {
formOptions.value.site = (res.data ?? []).map((item) => ({ label: item.name, value: item.id }))
}
})
.catch((err: unknown) => {
console.error('场所下拉加载失败:', err)
})
}
const infoRequest = (id: string) => info(id)
@@ -199,7 +203,6 @@ export const usePage = (emit: (e: 'load') => void) => {
if (type === 'add') {
form.value.category = tab
}
refreshModelOptions(tab)
const tabLabelMap: Record<DeviceCategoryTab, string> = {
catering: '智养餐饮设备',
@@ -212,6 +215,10 @@ export const usePage = (emit: (e: 'load') => void) => {
? `新增设备(${tabLabelMap[tab]}`
: '编辑设备'
/** 并发加载下拉数据 */
refreshCategoryOptions(tab)
refreshCustomerOptions()
const requests: Promise<unknown>[] = []
if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id))
@@ -222,9 +229,11 @@ 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
refreshModelOptions(data.category)
refreshCategoryOptions(data.category)
/** 客户已选定,按 customerId 加载场所下拉(便于编辑回显) */
if (data.customerId) refreshSiteOptions(data.customerId)
}
}
})
@@ -274,8 +283,13 @@ export const usePage = (emit: (e: 'load') => void) => {
formRef.value
?.validate()
.then(() => {
/** 提交前重新计算投用状态,确保与户 / 场所一致 */
form.value.deployStatus = computeDeployStatus(form.value.tenantName, form.value.siteName)
/** 提交前重新计算投用状态,确保与户 / 场所一致 */
form.value.deployStatus = computeDeployStatus(form.value.customerId, form.value.siteId)
/** 按 customerId / siteId 回填 customerName / siteName */
const customer = formOptions.value.customer.find((item) => item.value === form.value.customerId)
form.value.customerName = customer?.label ?? ''
const site = formOptions.value.site.find((item) => item.value === form.value.siteId)
form.value.siteName = site?.label ?? ''
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
if (params.id) editRequest(params)
else addRequest(params)
@@ -293,10 +307,15 @@ export const usePage = (emit: (e: 'load') => void) => {
formOptions,
activeTab,
deployStatusText,
showSimIccid,
rules,
openModal,
closeModal,
submit,
onDeployFieldChange,
onCustomerChange,
onSiteChange,
onCategoryChange,
refreshCustomerOptions,
}
}
@@ -2,13 +2,14 @@
* 设备管理 - 新增/编辑 弹框 - 类型定义
*
* 严格遵循 modal-spec.md
* - 5 个 Tab 共用同一表单差异字段以扩展属性承载
* - activeTab 由 openModal 上下文传入,控制差异字段区块的显隐
* - 所属户 / 布点场所 非必填:留空代表在库未投用,由 usePage 联动计算 deployStatus
* - 5 个 Tab 共用同一表单(无差异字段
* - activeTab 由 openModal 上下文传入,仅用于切换设备类别下拉数据源
* - 所属户 / 所属场所 非必填:留空代表在库未投用,由 usePage 联动计算 deployStatus
* - 绑定 SIM 卡字段仅当所选类别 networkMode === 'sim' 时展示
*/
import type { SelectProps } from 'ant-design-vue'
import type { DeviceCategoryTab, DeployStatus, RunStatus } from '../../../../types'
import type { DeviceCategoryTab, DeployStatus, NetworkMode } from '../../../../types'
export type ModalType = 'add' | 'edit'
@@ -21,98 +22,54 @@ export interface PageInfo {
spin: boolean
}
/** 表单数据(通用字段 + 5 Tab 差异字段,差异字段按 activeTab 条件渲染 */
/** 表单数据(5 Tab 共用,无差异字段) */
export interface Form {
/** id 统一 string,新增时 undefined */
id?: string
/** 设备编码 */
deviceCode: string
/** 设备名称 */
/** 设备名称(非必填) */
name: string
/** 所属二级型号 */
modelName: string
/** 所属顶级分类 */
/** 设备序列号(非必填) */
serialNumber: string
/** 设备类别(二级分类名称) */
deviceCategory: string
/** 所属顶级分类(由后端按 deviceCategory 推导,前端提交时无需传) */
category: DeviceCategoryTab
/** 所属租户(非必填,留空代表在库未投用 */
tenantName: string
/** 所属布点场所(非必填,留空代表在库未投用 */
/** 所属客户 ID(非必填 */
customerId: string
/** 所属客户名称(提交时由 usePage 按 customerId 回填 */
customerName: string
/** 所属场所 ID(非必填) */
siteId: string
/** 所属场所名称(提交时由 usePage 按 siteId 回填) */
siteName: string
/** 投用状态(依据户 / 场所自动判定,提交时由 usePage 注入 */
/** 投用状态(依据户 / 场所自动判定) */
deployStatus: DeployStatus
/** 运行状态 */
runStatus: RunStatus
/** 软件版本 */
softwareVersion: string
/** 投用时间(投用后才存在,编辑回填) */
deployTime: string
// ===== 智养餐饮设备差异字段 =====
/** 所属轨道 */
trackNo?: string
/** 摆放方式 */
placement?: string
/** 绑定大屏 */
boundScreen?: string
/** 所在档口 */
stall?: string
/** 客户端 IP */
clientIp?: string
// ===== 体重测量设备差异字段 =====
/** 测量指标 */
measureIndicators?: string
/** 量程 */
range?: string
/** 精度等级 */
accuracy?: string
/** 计量单位 */
unit?: string
/** 测量方式 */
measureMethod?: string
// ===== 智能穿戴设备差异字段 =====
/** 腕带编号 */
bandNo?: string
/** SIM 卡号 */
simCard?: string
/** 绑定用户 */
boundUser?: string
/** 电池电量 */
battery?: string
/** 通讯协议 */
protocol?: string
// ===== 健身器材设备差异字段 =====
/** 器材类型 */
equipmentType?: string
/** 阻力调节方式 */
resistanceMode?: string
/** 训练数据采集类型 */
trainDataType?: string
/** 设备位置 */
location?: string
// ===== 健康监测设备差异字段 =====
/** 监测指标列表 */
monitorIndicators?: string
/** 采样频率 */
sampleFrequency?: string
/** 报告生成方式 */
reportMode?: string
/** 入网时间(编辑回填,新增时由后端写入) */
onlineTime: string
/** 虹软授权码(非必填) */
arcLicense: string
/** 绑定 SIM 卡 ICCID(仅类别 networkMode=sim 时有意义,非必填) */
simIccid: string
}
/** 下拉选项 */
export interface FormOptions {
modelName: SelectProps['options']
runStatus: SelectProps['options']
placement: SelectProps['options']
measureMethod: SelectProps['options']
protocol: SelectProps['options']
equipmentType: SelectProps['options']
resistanceMode: SelectProps['options']
reportMode: SelectProps['options']
/** 设备类别(按 activeTab 切换) */
deviceCategory: { label: string; value: string; networkMode: NetworkMode }[]
/** 所属客户 */
customer: { label: string; value: string }[]
/** 所属场所(按 customerId 联动) */
site: { label: string; value: string }[]
}
export type SelectOptionsType = SelectProps['options']
/** 打开弹框时传入的上下文 */
export interface OpenContext {
id?: string
@@ -1,40 +1,46 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 { exportList, list, remove } from '../api'
import { categoryOptions, 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 { DeviceCategoryTab, DeviceItem, RunStatus, SearchForm } from '../types'
import { DEPLOY_STATUS_OPTIONS, RUN_STATUS_OPTIONS, createSearchKey } from './useSearch'
import type { CustomerOption, DeviceCategoryOption, DeviceCategoryTab, DeviceItem, SearchForm, SiteOption } from '../types'
import { DEPLOY_STATUS_OPTIONS, createSearchKey } from './useSearch'
import { useTable } from './useTable'
/** 5 个 Tab 元信息(与设备分类预置值对齐) */
export const TAB_LIST: { key: DeviceCategoryTab; label: string; isIot: boolean }[] = [
{ key: 'catering', label: '智养餐饮设备', isIot: true },
{ key: 'body', label: '体重测量设备', isIot: true },
{ key: 'wearable', label: '智能穿戴设备', isIot: true },
{ key: 'fitness', label: '健身器材设备', isIot: true },
{ key: 'monitor', label: '健康监测设备', isIot: true },
export const TAB_LIST: { key: DeviceCategoryTab; label: string }[] = [
{ key: 'catering', label: '智养餐饮设备' },
{ key: 'body', label: '体重测量设备' },
{ key: 'wearable', label: '智能穿戴设备' },
{ key: 'fitness', label: '健身器材设备' },
{ key: 'monitor', label: '健康监测设备' },
]
/**
* 设备管理页主编排:
* - 顶部 5 Tab 切换顶级分类,每个 Tab 一张设备实例列表
* - 列定义按 Tab 动态拼装(通用列 + 差异列 + 操作列)
* - 顶部 5 Tab 切换顶级分类,5 Tab 字段完全一致
* - 所属客户 / 所属场所 均为可搜索下拉,且场所按客户联动过滤
* - 新增/编辑/详情 走独立子组件(modal-spec / drawer-spec
* - 删除二次确认
* - 导出为异步任务,配套"查看导出任务"入口
* - 导出/批量导入 为异步任务
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const route = useRoute()
const router = useRouter()
const { buildColumns } = useTable()
const activeTab = ref<DeviceCategoryTab>('catering')
const search = reactive<SearchForm>(createSearchKey())
/** 是否按入库单号反查(由路由 query 注入,跨 Tab 聚合) */
const inboundCodeFilter = ref<boolean>(false)
const table = reactive<TableState<DeviceItem>>({
columns: [],
dataSource: [],
@@ -42,12 +48,19 @@ export const usePage = () => {
pagination: createPaginationConfig() as TablePaginationConfig,
})
/** 根据 activeTab 动态生成列定义 */
const tableColumns = computed(() => buildColumns(activeTab.value))
/** 列定义(5 Tab 一致,无需按 tab 切换) */
const tableColumns = computed(() => buildColumns())
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
/** 设备类别下拉(按当前 Tab 联动刷新) */
const deviceCategoryOptions = ref<DeviceCategoryOption[]>([])
/** 所属客户下拉(可搜索) */
const customerOptionList = ref<CustomerOption[]>([])
/** 所属场所下拉(按 customerId 联动过滤) */
const siteOptionList = ref<SiteOption[]>([])
/** 新增/编辑子组件 ref(必须在 usePage.ts 中定义) */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
/** 详情抽屉子组件 ref */
@@ -58,12 +71,15 @@ export const usePage = () => {
table.columns = tableColumns.value
}
/** 拉取当前 Tab 的设备列表 */
/** 拉取当前 Tab 的设备列表(按 inboundCode 反查时跨 Tab 聚合) */
const listRequest = (): void => {
pageLoading.value = true
syncColumns()
selectedRowKeys.value = []
list({ ...search, category: activeTab.value })
const params = search.inboundCode
? { ...search, category: activeTab.value, inboundCode: search.inboundCode }
: { ...search, category: activeTab.value }
list(params)
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
@@ -78,28 +94,79 @@ export const usePage = () => {
})
}
/** 拉取设备类别下拉(按当前 Tab) */
const refreshCategoryOptions = (): void => {
categoryOptions(activeTab.value)
.then((res) => {
if (res.code === '00000') deviceCategoryOptions.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('设备类别下拉加载失败:', err)
})
}
/** 拉取所属客户下拉 */
const refreshCustomerOptions = (keyword?: string): void => {
customerOptions(keyword)
.then((res) => {
if (res.code === '00000') customerOptionList.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('所属客户下拉加载失败:', err)
})
}
/** 拉取所属场所下拉(按 customerId 联动) */
const refreshSiteOptions = (customerId: string, keyword?: string): void => {
if (!customerId) {
siteOptionList.value = []
return
}
siteOptions(customerId, keyword)
.then((res) => {
if (res.code === '00000') siteOptionList.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('所属场所下拉加载失败:', err)
})
}
/** 搜索 */
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
/** 重置 */
/** 重置(同时清掉入库单反查 query,恢复 Tab 维度查询) */
const resetQuery = (): void => {
Object.assign(search, createSearchKey())
inboundCodeFilter.value = false
if (route.query.inboundCode) {
void router.replace({ path: route.path, query: {} })
}
siteOptionList.value = []
table.pagination.current = 1
listRequest()
}
/** 切换 Tab(接收 a-radio-group change 事件或字符串 key */
/** 切换 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()
}
/** 筛选区所属客户变更:清空场所并刷新场所下拉 */
const onCustomerChange = (): void => {
search.siteId = undefined
if (search.customerId) refreshSiteOptions(search.customerId)
else siteOptionList.value = []
}
/** 分页/排序变更 */
const dataSourceChange = (pagination: TablePaginationConfig): void => {
table.pagination.current = pagination.current ?? 1
@@ -162,6 +229,20 @@ export const usePage = () => {
void message.info('当前共有 3 个导出任务正在处理中(原型阶段提示)')
}
/** 批量导入设备(原型阶段占位) */
const importListData = (): void => {
importList()
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg || '导入任务已提交,请稍后刷新列表查看结果')
listRequest()
}
})
.catch((err: unknown) => {
console.error('设备管理批量导入失败:', err)
})
}
/** 统一操作入口 */
const handleAction = (type: string, record?: DeviceItem): void => {
if (type === 'add') {
@@ -190,6 +271,14 @@ export const usePage = () => {
}
onMounted(() => {
refreshCategoryOptions()
refreshCustomerOptions()
const q = route.query.inboundCode
if (typeof q === 'string' && q) {
/** 由入库单"查看台账"跳转:按 inboundCode 跨 Tab 反查 */
search.inboundCode = q
inboundCodeFilter.value = true
}
listRequest()
})
@@ -203,17 +292,24 @@ export const usePage = () => {
addOrEditRef,
infoDrawerRef,
tabList: TAB_LIST,
runStatusOptions: RUN_STATUS_OPTIONS,
deployStatusOptions: DEPLOY_STATUS_OPTIONS,
deviceCategoryOptions,
customerOptionList,
siteOptionList,
inboundCodeFilter,
searchQuery,
resetQuery,
switchTab,
onCustomerChange,
refreshCustomerOptions,
refreshSiteOptions,
dataSourceChange,
handleAction,
exportListData,
viewExportTask,
importListData,
listRequest,
}
}
export type { RunStatus }
export type { DeviceItem, SearchForm }
@@ -1,4 +1,4 @@
import type { DeployStatus, RunStatus, SearchForm } from '../types'
import type { DeployStatus, SearchForm } from '../types'
/** 投用状态下拉选项 */
export const DEPLOY_STATUS_OPTIONS: { label: string; value: DeployStatus }[] = [
@@ -6,19 +6,12 @@ export const DEPLOY_STATUS_OPTIONS: { label: string; value: DeployStatus }[] = [
{ label: '投用', value: 'deployed' },
]
/** 运行状态下拉选项 */
export const RUN_STATUS_OPTIONS: { label: string; value: RunStatus }[] = [
{ label: '在用', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '维修中', value: 'repairing' },
{ label: '报废', value: 'scrapped' },
]
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
runStatus: undefined,
deployStatus: undefined,
tenantName: undefined,
siteName: undefined,
deviceCategory: undefined,
customerId: undefined,
siteId: undefined,
inboundCode: undefined,
})
@@ -1,23 +1,7 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import type { DeviceCategoryTab, DeployStatus, DeviceItem, RunStatus } from '../types'
/** 运行状态 → Tag 颜色映射 */
const runStatusColor: Record<RunStatus, string> = {
online: 'success',
offline: 'default',
repairing: 'warning',
scrapped: 'error',
}
/** 运行状态 → 中文 */
const runStatusText: Record<RunStatus, string> = {
online: '在用',
offline: '离线',
repairing: '维修中',
scrapped: '报废',
}
import type { DeployStatus, DeviceItem } from '../types'
/** 投用状态 → Tag 颜色映射 */
const deployStatusColor: Record<DeployStatus, string> = {
@@ -31,12 +15,18 @@ const deployStatusText: Record<DeployStatus, string> = {
deployed: '投用',
}
/** 通用列(所有 Tab 共享,操作列在末尾追加) */
/**
* 通用列定义(5 个 Tab 共享,无差异化字段)
* - 不展示:最近心跳、客户端 IP、运行状态(移至设备检测菜单)
* - 列顺序:序号 / 设备编码 / 设备类别 / 设备名称 / 设备序列号 / 投用状态 /
* 所属客户 / 所属场所 / 软件版本 / 入库时间 / 投用时间 / 操作
*/
const buildCommonColumns = (): TableColumnsType => [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '设备名称', dataIndex: 'name', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '所属型号', dataIndex: 'modelName', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 160, fixed: 'left', resizable: true, ellipsis: true },
{ title: '设备类别', dataIndex: 'deviceCategory', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '设备名称', dataIndex: 'name', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '设备序列号', dataIndex: 'serialNumber', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{
title: '投用状态',
dataIndex: 'deployStatus',
@@ -45,20 +35,12 @@ const buildCommonColumns = (): TableColumnsType => [
customRender: ({ value }: { value: DeployStatus }) =>
h(Tag, { color: deployStatusColor[value] ?? 'default' }, () => deployStatusText[value] ?? '—'),
},
{
title: '运行状态',
dataIndex: 'runStatus',
align: 'center',
width: 100,
customRender: ({ value }: { value: RunStatus }) =>
h(Tag, { color: runStatusColor[value] ?? 'default' }, () => runStatusText[value] ?? '—'),
},
{ title: '所属租户', dataIndex: 'tenantName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '所属布点场所', dataIndex: 'siteName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '所属客户', dataIndex: 'customerName', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '所属场所', dataIndex: 'siteName', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '软件版本', dataIndex: 'softwareVersion', align: 'center', width: 110, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '入库单号', dataIndex: 'inboundCode', align: 'left', width: 170, resizable: true, ellipsis: true, customRender: ({ value }: { value?: string }) => value ?? '—' },
{ title: '入库时间', dataIndex: 'onlineTime', align: 'center', width: 170, sorter: true, resizable: true },
{ title: '投用时间', dataIndex: 'deployTime', align: 'center', width: 170, resizable: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '最近心跳', dataIndex: 'lastHeartbeat', align: 'center', width: 170, resizable: true },
]
/** 操作列(固定在最右侧) */
@@ -66,69 +48,12 @@ const buildActionColumn = (): TableColumnsType => [
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 160 },
]
/** 智养餐饮设备差异列 */
const cateringExtra: TableColumnsType = [
{ title: '所属轨道', dataIndex: 'trackNo', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '摆放方式', dataIndex: 'placement', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '绑定大屏', dataIndex: 'boundScreen', align: 'center', width: 120, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '所在档口', dataIndex: 'stall', align: 'left', width: 120, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '客户端 IP', dataIndex: 'clientIp', align: 'center', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 体重测量设备差异列 */
const bodyExtra: TableColumnsType = [
{ title: '测量指标', dataIndex: 'measureIndicators', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '量程', dataIndex: 'range', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '精度等级', dataIndex: 'accuracy', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '计量单位', dataIndex: 'unit', align: 'center', width: 90, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '测量方式', dataIndex: 'measureMethod', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 智能穿戴设备差异列 */
const wearableExtra: TableColumnsType = [
{ title: '腕带编号', dataIndex: 'bandNo', align: 'center', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: 'SIM 卡号', dataIndex: 'simCard', align: 'center', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '绑定用户', dataIndex: 'boundUser', align: 'left', width: 120, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '电池电量', dataIndex: 'battery', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '通讯协议', dataIndex: 'protocol', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 健身器材设备差异列 */
const fitnessExtra: TableColumnsType = [
{ title: '器材类型', dataIndex: 'equipmentType', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '阻力调节方式', dataIndex: 'resistanceMode', align: 'center', width: 120, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '训练数据采集', dataIndex: 'trainDataType', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '设备位置', dataIndex: 'location', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 健康监测设备差异列 */
const monitorExtra: TableColumnsType = [
{ title: '监测指标', dataIndex: 'monitorIndicators', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '采样频率', dataIndex: 'sampleFrequency', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '测量方式', dataIndex: 'measureMethod', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '报告生成', dataIndex: 'reportMode', align: 'center', width: 100, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
]
/** 按 Tab 拼装列定义(通用列前段 + 差异列 + 通用列后段 + 操作列) */
const buildColumns = (tab: DeviceCategoryTab): TableColumnsType => {
const common = buildCommonColumns()
/** 入库时间/投用时间/最近心跳 提前在差异列之后展示,操作列固定在末尾 */
const tailColumns = common.slice(-3)
const headColumns = common.slice(0, -3)
const extraMap: Record<DeviceCategoryTab, TableColumnsType> = {
catering: cateringExtra,
body: bodyExtra,
wearable: wearableExtra,
fitness: fitnessExtra,
monitor: monitorExtra,
}
return [...headColumns, ...extraMap[tab], ...tailColumns, ...buildActionColumn()]
}
/** 按 Tab 拼装列定义(当前 5 Tab 列完全一致,保留 tab 入参为后续差异化预留) */
const buildColumns = (): TableColumnsType => [...buildCommonColumns(), ...buildActionColumn()]
export const useTable = () => {
return {
buildColumns,
runStatusText,
deployStatusText,
}
}
@@ -14,6 +14,15 @@
</div>
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item v-if="inboundCodeFilter" label="入库单号">
<a-input
v-model:value="search.inboundCode"
v-no-space
allow-clear
placeholder="按入库单号跨类别反查"
style="width: 220px"
/>
</a-form-item>
<a-form-item label="关键字">
<a-input
v-model:value="search.keyword"
@@ -23,6 +32,18 @@
style="width: 200px"
/>
</a-form-item>
<a-form-item label="设备类别">
<a-select
v-model:value="search.deviceCategory"
:options="deviceCategorySelectOptions"
:field-names="{ label: 'name', value: 'name' }"
show-search
allow-clear
placeholder="全部"
style="width: 180px"
:filter-option="filterOption"
/>
</a-form-item>
<a-form-item label="投用状态">
<a-select
v-model:value="search.deployStatus"
@@ -32,20 +53,32 @@
:options="deployStatusOptions"
/>
</a-form-item>
<a-form-item label="运行状态">
<a-form-item label="所属客户">
<a-select
v-model:value="search.runStatus"
v-model:value="search.customerId"
:options="customerOptionList"
:field-names="{ label: 'name', value: 'id' }"
show-search
allow-clear
placeholder="全部"
style="width: 140px"
:options="runStatusOptions"
placeholder="请选择客户"
style="width: 200px"
:filter-option="filterOption"
@search="onCustomerSearch"
@change="onCustomerChange"
/>
</a-form-item>
<a-form-item label="所属租户">
<a-input v-model:value="search.tenantName" v-no-space allow-clear placeholder="请输入租户名称" style="width: 180px" />
</a-form-item>
<a-form-item label="所属场所">
<a-input v-model:value="search.siteName" v-no-space allow-clear placeholder="请输入场所名称" style="width: 180px" />
<a-select
v-model:value="search.siteId"
:options="siteOptionList"
:field-names="{ label: 'name', value: 'id' }"
:disabled="!search.customerId"
show-search
allow-clear
:placeholder="search.customerId ? '请选择场所' : '请先选择客户'"
style="width: 200px"
:filter-option="filterOption"
/>
</a-form-item>
</FilterBar>
@@ -55,6 +88,10 @@
<template #icon><PlusOutlined /></template>
新增设备
</a-button>
<a-button @click="importListData">
<template #icon><UploadOutlined /></template>
批量导入
</a-button>
<a-button @click="exportListData">
<template #icon><DownloadOutlined /></template>
导出列表数据
@@ -81,9 +118,10 @@
</template>
<script setup lang="ts">
import { DownloadOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { DownloadOutlined, PlusOutlined, UnorderedListOutlined, UploadOutlined } from '@ant-design/icons-vue'
import FilterBar from '@components/FilterBar.vue'
import TableCard from '@components/TableCard.vue'
import { filterOption } from '@utils/antDesign/select'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { usePage } from './init/usePage'
@@ -98,17 +136,33 @@ const {
addOrEditRef,
infoDrawerRef,
tabList,
runStatusOptions,
deployStatusOptions,
deviceCategoryOptions,
customerOptionList,
siteOptionList,
inboundCodeFilter,
searchQuery,
resetQuery,
switchTab,
onCustomerChange,
refreshCustomerOptions,
dataSourceChange,
handleAction,
exportListData,
viewExportTask,
importListData,
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)
}
</script>
<style scoped lang="less">
@@ -8,57 +8,102 @@ export type DeviceCategoryTab =
| 'fitness'
| 'monitor'
/** 运行状态 */
export type RunStatus = 'online' | 'offline' | 'repairing' | 'scrapped'
/** 投用状态:在库 / 投用 */
export type DeployStatus = 'in_stock' | 'deployed'
/** 设备实例(5 个 Tab 通用结构 + 差异字段以扩展属性承载 */
/** 配网方式(与设备分类节点 networkMode 对齐 */
export type NetworkMode = 'wifi' | 'bluetooth' | 'sim' | 'ethernet' | 'other_iot'
/**
* 设备实例(5 个 Tab 通用结构)
* - 不再承载各 Tab 差异化字段,所有分类共用同一字段集
* - 类别元数据(isIot / dataTransit / dataReport / networkMode / manufacturer / model
* 由后端按所选 deviceCategory 冗余回填到设备记录,详情只读展示
*/
export interface DeviceItem extends BaseRecord {
/** 设备编码 */
deviceCode: string
/** 设备名称 */
/** 设备名称(非必填) */
name: string
/** 所属二级型号 */
modelName: string
/** 所属顶级分类 */
/** 设备序列号(非必填) */
serialNumber: string
/** 设备类别(二级分类名称,对应设备分类树叶子节点 name) */
deviceCategory: string
/** 所属顶级分类(Tab 维度,由后端按 deviceCategory 推导) */
category: DeviceCategoryTab
/** 所属租户名称(为空代表在库未投用) */
tenantName: string
/** 所属布点场所(为空代表在库未投用 */
/** 所属客户 ID(为空代表在库未投用) */
customerId: string
/** 所属客户名称(list 接口回填,便于直接展示 */
customerName: string
/** 所属场所 ID(为空代表在库未投用) */
siteId: string
/** 所属场所名称 */
siteName: string
/** 投用状态:在库 / 投用(依据户与场所自动判定) */
/** 投用状态:在库 / 投用(依据户与场所自动判定) */
deployStatus: DeployStatus
/** 运行状态 */
runStatus: RunStatus
/** 软件版本 */
softwareVersion: string
/** 入网时间(即入库时间) */
onlineTime: string
/** 投用时间(投用后才存在) */
deployTime: string
/** 最近心跳时间 */
lastHeartbeat: string
/** 是否 IOT 设备(从分类继承,只读 */
/** 虹软授权码(非必填) */
arcLicense: string
/** 绑定 SIM 卡 ICCID(仅类别 networkMode=sim 时有意义,非必填 */
simIccid: string
// ===== 类别元数据(由后端按 deviceCategory 冗余回填,详情只读展示) =====
/** 是否 IOT 设备 */
isIot: boolean
/** 各 Tab 差异字段(动态) */
[key: string]: string | boolean | undefined
/** 是否启用数据分发 */
dataTransit: boolean
/** 是否启用数据上报 */
dataReport: boolean
/** 配网方式 */
networkMode: NetworkMode
/** 厂商(字典 device_manufacturer 的 label */
manufacturer: string
/** 型号 */
model: string
}
/** 搜索表单 */
export interface SearchForm extends BaseSearchForm {
keyword?: string
runStatus?: RunStatus
deployStatus?: DeployStatus
tenantName?: string
siteName?: string
deviceCategory?: string
customerId?: string
siteId?: string
/** 入库单号(按入库单反查设备台账,由路由 query 注入) */
inboundCode?: string
}
/** Tab 元信息 */
export interface TabMeta {
key: DeviceCategoryTab
label: string
/** 是否 IOT 设备(与设备分类预置值对齐) */
isIot: boolean
}
/** 客户下拉选项 */
export interface CustomerOption {
id: string
name: string
}
/** 场所下拉选项 */
export interface SiteOption {
id: string
name: string
/** 所属客户 ID(用于按客户过滤) */
customerId: string
}
/** 设备类别下拉选项(按顶级分类 Tab 分组) */
export interface DeviceCategoryOption {
/** 二级分类名称(作为 value) */
name: string
/** 顶级分类 Tab */
category: DeviceCategoryTab
/** 配网方式(用于决定是否展示 SIM 卡输入) */
networkMode: NetworkMode
}
@@ -1,6 +1,14 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DeviceStatItem, SearchForm, StatisticsOverview } from '../types'
import type { CategoryNode } from '../../category/types'
import type {
CustomerOption,
DeviceStatItem,
SearchForm,
SiteOption,
SoftwareVersionOption,
StatisticsOverview,
} from '../types'
/** 查询顶部统计聚合数据(4 个核心指标卡) */
export const fetchOverview = (params: SearchForm): Promise<ApiResponse<StatisticsOverview>> =>
@@ -13,3 +21,19 @@ export const list = (params: SearchForm): Promise<ApiResponse<DeviceStatItem[]>>
/** 异步导出统计列表数据 */
export const exportList = (params: SearchForm): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/statistics/export', params)
/** 设备分类全树(复用设备分类页接口,供筛选区树选择器加载) */
export const categoryTree = (keyword?: string): Promise<ApiResponse<CategoryNode[]>> =>
postRequest('axiosRequest', '/admin/device/category/tree', { keyword })
/** 所属客户下拉(可搜索,与设备管理页同源) */
export const customerOptions = (keyword?: string): Promise<ApiResponse<CustomerOption[]>> =>
postRequest('axiosRequest', '/admin/device/statistics/customer/options', { keyword })
/** 所属场所下拉(按 customerId 联动 + 关键字搜索) */
export const siteOptions = (customerId: string, keyword?: string): Promise<ApiResponse<SiteOption[]>> =>
postRequest('axiosRequest', '/admin/device/statistics/site/options', { customerId, keyword })
/** 软件版本下拉(来自数据集 unique 值,支持关键字搜索) */
export const softwareVersionOptions = (keyword?: string): Promise<ApiResponse<SoftwareVersionOption[]>> =>
postRequest('axiosRequest', '/admin/device/statistics/software-version/options', { keyword })
@@ -3,10 +3,25 @@ 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 { exportList, fetchOverview, list } from '../api'
import type { DeviceStatItem, SearchForm, StatisticsOverview } from '../types'
import type { CategoryNode } from '../../category/types'
import {
categoryTree as fetchCategoryTree,
customerOptions as fetchCustomerOptions,
exportList,
fetchOverview,
list,
siteOptions as fetchSiteOptions,
softwareVersionOptions as fetchSoftwareVersionOptions,
} from '../api'
import type {
CustomerOption,
DeviceStatItem,
SearchForm,
SiteOption,
SoftwareVersionOption,
StatisticsOverview,
} from '../types'
import {
CATEGORY_OPTIONS,
DEPLOY_STATUS_OPTIONS,
RUN_STATUS_OPTIONS,
YES_NO_OPTIONS,
@@ -14,11 +29,14 @@ import {
} from './useSearch'
import { buildSummaryRecord, tableColumns } from './useTable'
/** a-tree-select 字段映射(设备分类树节点) */
export const CATEGORY_TREE_FIELD_NAMES = { label: 'name', value: 'id', children: 'children' }
/**
* 设备统计页主编排:
* - 顶部 4 个核心指标卡(总数 / 投用 / 在库 / 异常)
* - 多维搜索条件(关键字 / 类别 / 投用 / 运行 / IOT / 分发 / 上报 / 租户 / 场所 / 软件版本 / 时间范围)
* - 列表展示扁平设备记录 + 首行统计行(合计)
* - 多维搜索条件(类别 / 投用 / 运行 / IOT / 户 / 场所 / 软件版本 / 时间范围)
* - 列表展示扁平设备记录 + 首行统计行(各列数字合计)
* - 异步导出 + 查看导出任务
*/
export const usePage = () => {
@@ -42,6 +60,15 @@ export const usePage = () => {
pagination: createPaginationConfig() as TablePaginationConfig,
})
/** 设备分类树(用于筛选区 a-tree-select */
const categoryTreeData = ref<CategoryNode[]>([])
/** 所属客户下拉(可搜索) */
const customerOptionList = ref<CustomerOption[]>([])
/** 所属场所下拉(按 customerId 联动过滤) */
const siteOptionList = ref<SiteOption[]>([])
/** 软件版本下拉(来自数据集 unique 值) */
const softwareVersionOptionList = ref<SoftwareVersionOption[]>([])
/** 构造请求参数(时间范围拍平) */
const buildParams = (): SearchForm => ({
...search,
@@ -84,6 +111,54 @@ export const usePage = () => {
})
}
/** 拉取设备分类树(复用设备分类页接口) */
const refreshCategoryTree = (keyword?: string): void => {
fetchCategoryTree(keyword)
.then((res) => {
if (res.code === '00000') categoryTreeData.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('设备分类树加载失败:', err)
})
}
/** 拉取所属客户下拉 */
const refreshCustomerOptions = (keyword?: string): void => {
fetchCustomerOptions(keyword)
.then((res) => {
if (res.code === '00000') customerOptionList.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('所属客户下拉加载失败:', err)
})
}
/** 拉取所属场所下拉(按 customerId 联动) */
const refreshSiteOptions = (customerId: string, keyword?: string): void => {
if (!customerId) {
siteOptionList.value = []
return
}
fetchSiteOptions(customerId, keyword)
.then((res) => {
if (res.code === '00000') siteOptionList.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('所属场所下拉加载失败:', err)
})
}
/** 拉取软件版本下拉 */
const refreshSoftwareVersionOptions = (keyword?: string): void => {
fetchSoftwareVersionOptions(keyword)
.then((res) => {
if (res.code === '00000') softwareVersionOptionList.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('软件版本下拉加载失败:', err)
})
}
/** 同时拉取列表 + 概览 */
const fetchAll = (): void => {
listRequest()
@@ -99,10 +174,18 @@ export const usePage = () => {
Object.assign(search, createSearchKey())
onlineRange.value = undefined
deployRange.value = undefined
siteOptionList.value = []
table.pagination.current = 1
fetchAll()
}
/** 筛选区所属客户变更:清空场所并刷新场所下拉 */
const onCustomerChange = (): void => {
search.siteId = undefined
if (search.customerId) refreshSiteOptions(search.customerId)
else siteOptionList.value = []
}
/** 分页/排序变更 */
const dataSourceChange = (pagination: TablePaginationConfig): void => {
table.pagination.current = pagination.current ?? 1
@@ -128,6 +211,9 @@ export const usePage = () => {
}
onMounted(() => {
refreshCategoryTree()
refreshCustomerOptions()
refreshSoftwareVersionOptions()
fetchAll()
})
@@ -138,10 +224,19 @@ export const usePage = () => {
deployRange,
table,
overview,
categoryOptions: CATEGORY_OPTIONS,
categoryTreeData,
categoryTreeFieldNames: CATEGORY_TREE_FIELD_NAMES,
deployStatusOptions: DEPLOY_STATUS_OPTIONS,
runStatusOptions: RUN_STATUS_OPTIONS,
yesNoOptions: YES_NO_OPTIONS,
customerOptionList,
siteOptionList,
softwareVersionOptionList,
refreshCategoryTree,
refreshCustomerOptions,
refreshSiteOptions,
refreshSoftwareVersionOptions,
onCustomerChange,
searchQuery,
resetQuery,
dataSourceChange,
@@ -1,14 +1,5 @@
import type { DeviceCategoryTab, DeployStatus, RunStatus } from '../../manage/types'
import type { SearchForm } from '../types'
/** 设备类别下拉(5 类) */
export const CATEGORY_OPTIONS: { label: string; value: DeviceCategoryTab }[] = [
{ label: '智养餐饮设备', value: 'catering' },
{ label: '体重测量设备', value: 'body' },
{ label: '智能穿戴设备', value: 'wearable' },
{ label: '健身器材设备', value: 'fitness' },
{ label: '健康监测设备', value: 'monitor' },
]
import type { DeployStatus } from '../../manage/types'
import type { RunStatus, SearchForm } from '../types'
/** 投用状态下拉 */
export const DEPLOY_STATUS_OPTIONS: { label: string; value: DeployStatus }[] = [
@@ -24,7 +15,7 @@ export const RUN_STATUS_OPTIONS: { label: string; value: RunStatus }[] = [
{ label: '报废', value: 'scrapped' },
]
/** 是/否下拉(IOT 设备 / 启用数据分发 / 启用数据上报 共用) */
/** 是/否下拉(IOT 设备共用) */
export const YES_NO_OPTIONS: { label: string; value: boolean }[] = [
{ label: '是', value: true },
{ label: '否', value: false },
@@ -32,15 +23,12 @@ export const YES_NO_OPTIONS: { label: string; value: boolean }[] = [
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
category: undefined,
categoryId: undefined,
deployStatus: undefined,
runStatus: undefined,
isIot: undefined,
dataDispatch: undefined,
dataReport: undefined,
tenantName: undefined,
siteName: undefined,
customerId: undefined,
siteId: undefined,
softwareVersion: undefined,
onlineStart: undefined,
onlineEnd: undefined,
@@ -5,11 +5,11 @@ import type { DeviceStatItem } from '../types'
/**
* 统计行 customRender 入参类型
* - summary 行的字段值会被替换为合计文本/数字,故 value/record 都放宽
* - summary 行的字段值会被替换为合计数字,故 value/record 都放宽
*/
type RenderCtx = { value: unknown; record: DeviceStatItem; index: number }
/** 列定义(按用户给定顺序) */
/** 列定义(按用户给定顺序,已移除「启用数据分发 / 启用数据上报 / 操作」三列 */
export const tableColumns: TableColumnsType = [
{
title: '序号',
@@ -21,9 +21,36 @@ export const tableColumns: TableColumnsType = [
customRender: ({ index, record }: RenderCtx) =>
record.__isSummary ? '合计' : index,
},
{ title: '设备类别', dataIndex: 'categoryLabel', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '设备名称', dataIndex: 'name', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 180, resizable: true, ellipsis: true },
{
title: '设备类别',
dataIndex: 'categoryLabel',
align: 'left',
width: 140,
resizable: true,
ellipsis: true,
customRender: ({ value, record }: RenderCtx) =>
record.__isSummary ? (value as number) : (value as string) ?? '—',
},
{
title: '设备名称',
dataIndex: 'name',
align: 'left',
width: 160,
resizable: true,
ellipsis: true,
customRender: ({ value, record }: RenderCtx) =>
record.__isSummary ? (value as number) : (value as string) ?? '—',
},
{
title: '设备编码',
dataIndex: 'deviceCode',
align: 'left',
width: 180,
resizable: true,
ellipsis: true,
customRender: ({ value, record }: RenderCtx) =>
record.__isSummary ? (value as number) : (value as string) ?? '—',
},
{
title: 'IOT 设备',
dataIndex: 'isIot',
@@ -39,35 +66,46 @@ export const tableColumns: TableColumnsType = [
width: 100,
customRender: ({ value, record }: RenderCtx) =>
record.__isSummary
? (value as string)
? (value as number)
: h(Tag, { color: value === 'deployed' ? 'success' : 'default' }, () => (value === 'deployed' ? '投用' : '在库')),
},
{
title: '启用数据分发',
dataIndex: 'dataDispatch',
align: 'center',
width: 120,
title: '所属客户',
dataIndex: 'customerName',
align: 'left',
width: 140,
resizable: true,
ellipsis: true,
customRender: ({ value, record }: RenderCtx) =>
record.__isSummary ? (value as number) : h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已启用' : '未启用')),
record.__isSummary ? (value as number) : (value as string) ?? '—',
},
{
title: '启用数据上报',
dataIndex: 'dataReport',
align: 'center',
width: 120,
title: '布点场所',
dataIndex: 'siteName',
align: 'left',
width: 140,
resizable: true,
ellipsis: true,
customRender: ({ value, record }: RenderCtx) =>
record.__isSummary ? (value as number) : h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已启用' : '未启用')),
record.__isSummary ? (value as number) : (value as string) ?? '—',
},
{
title: '软件版本',
dataIndex: 'softwareVersion',
align: 'center',
width: 110,
resizable: true,
ellipsis: true,
customRender: ({ value, record }: RenderCtx) =>
record.__isSummary ? (value as number) : (value as string) ?? '—',
},
{ title: '所属租户', dataIndex: 'tenantName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '布点场所', dataIndex: 'siteName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '软件版本', dataIndex: 'softwareVersion', align: 'center', width: 110, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{
title: '运行状态',
dataIndex: 'runStatus',
align: 'center',
width: 100,
customRender: ({ value, record }: RenderCtx) => {
if (record.__isSummary) return value as string
if (record.__isSummary) return value as number
const colorMap: Record<string, string> = {
online: 'success',
offline: 'default',
@@ -85,35 +123,36 @@ export const tableColumns: TableColumnsType = [
},
{ title: '入库时间', dataIndex: 'onlineTime', align: 'center', width: 170, sorter: true, resizable: true },
{ title: '投用时间', dataIndex: 'deployTime', align: 'center', width: 170, resizable: true, customRender: ({ value }: { value: string }) => value ?? '—' },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 100 },
]
/**
* 计算统计行字段值(合计)
* 字段 key 与列 dataIndex 对齐,summary 行的 cell 会直接消费这些值
* - 除两个时间列不统计(customRender 不命中 summary 分支,默认返回 —)
* - 其余列:枚举类显示「主指标数量」(投用数 / 在用数),唯一值类显示去重数量,名称/编码显示总数
*/
export const buildSummaryRow = (rows: DeviceStatItem[]): Record<string, number | string> => {
const total = rows.length
const deployed = rows.filter((r) => r.deployStatus === 'deployed').length
const inStock = rows.filter((r) => r.deployStatus === 'in_stock').length
const unique = (selector: (r: DeviceStatItem) => string): number =>
new Set(rows.map(selector).filter(Boolean)).size
const iot = rows.filter((r) => r.isIot).length
const dispatch = rows.filter((r) => r.dataDispatch).length
const report = rows.filter((r) => r.dataReport).length
const deployed = rows.filter((r) => r.deployStatus === 'deployed').length
const online = rows.filter((r) => r.runStatus === 'online').length
const offline = rows.filter((r) => r.runStatus === 'offline').length
return {
categoryLabel: unique((r) => r.categoryLabel),
name: total,
deviceCode: total,
isIot: iot,
name: `${total}`,
deployStatus: `${deployed} / ${inStock}`,
dataDispatch: dispatch,
dataReport: report,
runStatus: `${online} / ${offline}`,
deployStatus: deployed,
customerName: unique((r) => r.customerName),
siteName: unique((r) => r.siteName),
softwareVersion: unique((r) => r.softwareVersion),
runStatus: online,
}
}
/**
* 把合计字段包装成可插入 dataSource 的统计行 record
* - 带 __isSummary 标记,列 customRender 据此走简化渲染(直接返回文本/数字,不走 Tag
* - 带 __isSummary 标记,列 customRender 据此走简化渲染(直接返回数字,不走 Tag)
* - 字段值类型与 DeviceStatItem 不完全一致(如 isIot 是 number),用类型断言绕过
*/
export const buildSummaryRecord = (rows: DeviceStatItem[]): DeviceStatItem =>
@@ -124,5 +163,10 @@ export const buildSummaryRecord = (rows: DeviceStatItem[]): DeviceStatItem =>
category: 'catering' as DeviceStatItem['category'],
name: '',
deviceCode: '',
customerId: '',
customerName: '',
siteId: '',
siteName: '',
softwareVersion: '',
...buildSummaryRow(rows),
}) as unknown as DeviceStatItem
@@ -14,22 +14,18 @@
<!-- 多维搜索 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<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
v-model:value="search.category"
:options="categoryOptions"
<a-tree-select
v-model:value="search.categoryId"
:tree-data="categoryTreeData"
:field-names="categoryTreeFieldNames"
show-search
allow-clear
tree-default-expand-all
placeholder="全部类别"
style="width: 160px"
style="width: 220px"
:filter-tree-node="(input: string, node: { name?: string }) =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())"
/>
</a-form-item>
<a-form-item label="投用状态">
@@ -59,32 +55,44 @@
style="width: 120px"
/>
</a-form-item>
<a-form-item label="启用数据分发">
<a-form-item label="所属客户">
<a-select
v-model:value="search.dataDispatch"
:options="yesNoOptions"
v-model:value="search.customerId"
:options="customerOptionList"
:field-names="{ label: 'name', value: 'id' }"
show-search
allow-clear
placeholder="全部"
style="width: 140px"
placeholder="请选择客户"
style="width: 200px"
:filter-option="filterOption"
@search="onCustomerSearch"
@change="onCustomerChange"
/>
</a-form-item>
<a-form-item label="启用数据上报">
<a-select
v-model:value="search.dataReport"
:options="yesNoOptions"
allow-clear
placeholder="全部"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="所属租户">
<a-input v-model:value="search.tenantName" v-no-space allow-clear placeholder="请输入租户名称" style="width: 180px" />
</a-form-item>
<a-form-item label="布点场所">
<a-input v-model:value="search.siteName" v-no-space allow-clear placeholder="请输入场所名称" style="width: 180px" />
<a-select
v-model:value="search.siteId"
:options="siteOptionList"
:field-names="{ label: 'name', value: 'id' }"
:disabled="!search.customerId"
show-search
allow-clear
:placeholder="search.customerId ? '请选择场所' : '请先选择客户'"
style="width: 200px"
:filter-option="filterOption"
/>
</a-form-item>
<a-form-item label="软件版本">
<a-input v-model:value="search.softwareVersion" v-no-space allow-clear placeholder="如 V1.0.0" style="width: 140px" />
<a-select
v-model:value="search.softwareVersion"
:options="softwareVersionOptionList"
:field-names="{ label: 'label', value: 'value' }"
show-search
allow-clear
placeholder="全部版本"
style="width: 160px"
:filter-option="filterOption"
/>
</a-form-item>
<a-form-item label="入库时间">
<a-range-picker
@@ -120,20 +128,13 @@
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<!-- 合计行操作列留空 -->
<template v-if="record.__isSummary">—</template>
<a-button v-else type="link" size="small" @click="viewDetail(record)">查看详情</a-button>
</template>
</template>
</TableCard>
</div>
</template>
<script setup lang="ts">
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { filterOption } from '@utils/antDesign/select'
import StatCard from '@components/StatCard.vue'
import FilterBar from '@components/FilterBar.vue'
import TableCard from '@components/TableCard.vue'
@@ -147,10 +148,16 @@ const {
deployRange,
table,
overview,
categoryOptions,
categoryTreeData,
categoryTreeFieldNames,
deployStatusOptions,
runStatusOptions,
yesNoOptions,
customerOptionList,
siteOptionList,
softwareVersionOptionList,
refreshCustomerOptions,
onCustomerChange,
searchQuery,
resetQuery,
dataSourceChange,
@@ -158,11 +165,9 @@ const {
viewExportTask,
} = usePage()
const { message } = useAntdStaticMethods()
/** 查看详情(原型阶段占位) */
const viewDetail = (record: DeviceStatItem): void => {
void message.info(`查看设备详情:${record.name}(原型阶段提示)`)
/** 客户下拉搜索回调 */
const onCustomerSearch = (kw: string): void => {
refreshCustomerOptions(kw)
}
</script>
@@ -181,6 +186,13 @@ const viewDetail = (record: DeviceStatItem): void => {
flex-shrink: 0;
}
// 搜索区垂直间距加大,避免上下两行间距过近
:deep(.filter-bar__form) {
.ant-form-item {
margin-bottom: 16px;
}
}
// 合计行视觉强化:粗体 + 浅蓝底,与数据行区分
:deep(.ant-table-row.__isSummary td) {
background: #fafafa !important;
@@ -1,4 +1,7 @@
import type { DeviceCategoryTab, DeployStatus, RunStatus } from '../../manage/types'
import type { DeviceCategoryTab, DeployStatus } from '../../manage/types'
/** 运行状态(statistics 模块本地定义,manage/types 未导出) */
export type RunStatus = 'online' | 'offline' | 'repairing' | 'scrapped'
/** 顶部统计卡数据 */
export interface StatCardData {
@@ -13,10 +16,12 @@ export interface StatCardData {
/** 统计页列表行 = 设备扁平记录 */
export interface DeviceStatItem {
id: string
/** 设备类别(顶级分类中文) */
/** 设备类别(顶级分类中文,用于列展示与合计行去重统计 */
categoryLabel: string
/** 顶级分类 key */
/** 顶级分类 key(数据集内部标记,不直接用于筛选) */
category: DeviceCategoryTab
/** 设备分类树节点 ID(叶子节点,用于按类别筛选命中) */
categoryId: string
/** 设备名称 */
name: string
/** 设备编码 */
@@ -25,13 +30,13 @@ export interface DeviceStatItem {
isIot: boolean
/** 投用状态 */
deployStatus: DeployStatus
/** 启用数据分发 */
dataDispatch: boolean
/** 启用数据上报 */
dataReport: boolean
/** 所属租户 */
tenantName: string
/** 布点场所 */
/** 所属客户 ID(为空代表在库未投用) */
customerId: string
/** 所属客户名称(list 接口回填,便于直接展示) */
customerName: string
/** 所属场所 ID */
siteId: string
/** 所属场所名称 */
siteName: string
/** 软件版本 */
softwareVersion: string
@@ -52,15 +57,13 @@ export interface StatisticsOverview {
/** 搜索表单(多维检测条件) */
export interface SearchForm {
keyword?: string
category?: DeviceCategoryTab
/** 设备分类树节点 ID(命中该节点及其子树) */
categoryId?: string
deployStatus?: DeployStatus
runStatus?: RunStatus
isIot?: boolean
dataDispatch?: boolean
dataReport?: boolean
tenantName?: string
siteName?: string
customerId?: string
siteId?: string
softwareVersion?: string
/** 入库时间范围 */
onlineStart?: string
@@ -69,3 +72,25 @@ export interface SearchForm {
deployStart?: string
deployEnd?: string
}
/** 客户下拉选项(与 manage 页一致) */
export interface CustomerOption {
id: string
name: string
}
/** 场所下拉选项(与 manage 页一致) */
export interface SiteOption {
id: string
name: string
/** 所属客户 ID(用于按客户过滤) */
customerId: string
}
/** 软件版本下拉选项(来自数据集 unique 值) */
export interface SoftwareVersionOption {
/** 版本号作为 value */
value: string
/** 版本号作为 label */
label: string
}
@@ -46,6 +46,7 @@
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
<SiteBrief ref="siteBriefRef" />
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
@@ -53,6 +54,7 @@
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import SiteBrief from './component/modal/siteBrief/siteBrief.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { usePage } from './init/usePage'
import { CUSTOMER_TYPE_OPTIONS } from './types'
@@ -62,6 +64,7 @@ const {
search,
table,
addOrEditRef,
siteBriefRef,
infoDrawerRef,
searchQuery,
resetQuery,
@@ -6,7 +6,7 @@
:destroy-on-close="true"
>
<a-spin :spinning="pageInfo.spin">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions v-if="detail" title="基本信息" :column="2" bordered size="small">
<a-descriptions-item label="客户编码">{{ detail.code ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="客户名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="客户类型">
@@ -25,6 +25,14 @@
<a-descriptions-item label="地址" :span="2">{{ detail.address ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detail.remark ?? '—' }}</a-descriptions-item>
</a-descriptions>
<a-descriptions v-if="detail" title="数据分发配置" :column="1" bordered size="small" style="margin-top: 16px">
<a-descriptions-item label="数据分发地址">{{ detail.dispatchUrl ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="分发模式">实时</a-descriptions-item>
<a-descriptions-item label="鉴权账号">{{ detail.authAccount ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="鉴权秘钥">{{ detail.authSecret ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="鉴权接口地址">{{ detail.authApiUrl ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-drawer>
</template>
@@ -43,4 +43,12 @@ export interface DetailInfo {
remark?: string
/** 创建时间 */
createTime: string
/** 数据分发地址 */
dispatchUrl?: string
/** 鉴权账号 */
authAccount?: string
/** 鉴权秘钥 */
authSecret?: string
/** 鉴权接口地址 */
authApiUrl?: string
}
@@ -0,0 +1,12 @@
/**
* 客户档案 - 关联场所简表 - 接口层
* - 复用布点场所列表接口,按 customerId 过滤
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { SiteBriefItem } from '../types'
/** 查询客户关联的布点场所简表 */
export const listByCustomer = (customerId: string): Promise<ApiResponse<SiteBriefItem[]>> =>
postRequest('axiosRequest', '/admin/device/site/list', { customerId, pageSize: 200 })
@@ -0,0 +1,65 @@
/**
* 客户档案 - 关联场所简表 - 主逻辑
*/
import type { TablePaginationConfig } from 'ant-design-vue'
import { createPaginationConfig } from '@utils/antDesign/table'
import { listByCustomer } from '../api'
import type { OpenContext, PageInfo, SiteBriefItem } from '../types'
export const usePage = () => {
const pageInfo = reactive<PageInfo>({
visible: false,
title: '关联场所',
width: 920,
spin: false,
})
const dataSource = ref<SiteBriefItem[]>([])
const pagination = reactive<TablePaginationConfig>(createPaginationConfig())
const closeModal = (): void => {
pageInfo.visible = false
dataSource.value = []
pagination.current = 1
pagination.total = 0
}
const openModal = (ctx: OpenContext): void => {
pageInfo.visible = true
pageInfo.spin = true
pageInfo.title = ctx.customerName ? `${ctx.customerName} - 关联场所` : '关联场所'
dataSource.value = []
pagination.current = 1
pagination.total = 0
listByCustomer(ctx.customerId)
.then((res) => {
if (res.code === '00000') {
dataSource.value = res.data ?? []
pagination.total = dataSource.value.length
}
})
.catch((err: unknown) => {
console.error('关联场所简表查询失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
/** 分页变更(客户端分页,仅同步状态) */
const onPageChange = (pag: TablePaginationConfig): void => {
pagination.current = pag.current ?? 1
pagination.pageSize = pag.pageSize ?? 30
}
return {
pageInfo,
dataSource,
pagination,
openModal,
closeModal,
onPageChange,
}
}
@@ -0,0 +1,56 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:footer="null"
:destroy-on-close="true"
centered
>
<a-spin :spinning="pageInfo.spin">
<a-table
:data-source="dataSource"
:columns="columns"
:pagination="pagination"
row-key="id"
size="small"
:scroll="{ y: 360 }"
@change="onPageChange"
>
<template #bodyCell="{ column, value, record }">
<template v-if="column.dataIndex === 'siteType'">
{{ siteTypeText(value) }}
</template>
<template v-else-if="column.dataIndex === 'status'">
<a-tag :color="record.status ? 'green' : 'default'">{{ record.status ? '启用' : '停用' }}</a-tag>
</template>
</template>
</a-table>
</a-spin>
</a-modal>
</template>
<script setup lang="ts">
/**
* 客户档案 - 关联场所简表 弹框
* - 点击客户列表「关联场所」数字时弹出
* - 字段节选自布点场所列表:场所名称 / 编号 / 自定义标识 / 场所类型 / 状态 / 挂载设备数
* - 弹窗居中 + 客户端分页
*/
import type { TableColumnsType } from 'ant-design-vue'
import { usePage } from './init/usePage'
import { siteTypeText } from '../../../../site/init/useTable'
const columns: TableColumnsType = [
{ title: '场所名称', dataIndex: 'name', align: 'left', width: 160, ellipsis: true },
{ title: '场所编号', dataIndex: 'code', align: 'left', width: 140, ellipsis: true },
{ title: '自定义标识', dataIndex: 'customFlag', align: 'center', width: 140, ellipsis: true },
{ title: '场所类型', dataIndex: 'siteType', align: 'center', width: 120 },
{ title: '挂载设备', dataIndex: 'deviceCount', align: 'center', width: 100 },
{ title: '状态', dataIndex: 'status', align: 'center', width: 100 },
]
const { pageInfo, dataSource, pagination, openModal, closeModal, onPageChange } = usePage()
defineExpose({ openModal, closeModal })
</script>
@@ -0,0 +1,32 @@
/** 弹框页面状态 */
export interface PageInfo {
visible: boolean
title: string
width: number
spin: boolean
}
/** 打开弹框上下文 */
export interface OpenContext {
/** 客户 ID */
customerId: string
/** 客户名称(用于标题) */
customerName?: string
}
/** 布点场所简表项(节选自布点场所列表) */
export interface SiteBriefItem {
id: string
/** 场所名称 */
name: string
/** 场所编号 */
code: string
/** 自定义标识 */
customFlag: string
/** 场所类型 */
siteType: string
/** 启用状态 */
status: boolean
/** 挂载设备数 */
deviceCount: number
}
@@ -4,16 +4,17 @@ import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import SiteBrief from '../component/modal/siteBrief/siteBrief.vue'
import InfoDrawer from '../component/drawer/info/info.vue'
import { list, remove } from '../api'
import type { ArchiveItem, SearchForm } from '../types'
import { createSearchKey } from './useSearch'
import { tableColumns } from './useTable'
import { setSiteCountClickHandler, tableColumns } from './useTable'
/**
* 客户档案页主编排:
* - 标准 CRUD + 详情抽屉
* - appCount / siteCount 为只读聚合字段,不在弹框维护
* - 关联场所数字可点击 → 弹出 siteBrief 简表
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
@@ -32,9 +33,19 @@ export const usePage = () => {
/** 新增/编辑弹框 ref */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
/** 关联场所简表弹框 ref */
const siteBriefRef = ref<InstanceType<typeof SiteBrief> | null>(null)
/** 详情抽屉 ref */
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
/** 点击关联场所数字 → 打开简表弹框 */
const openSiteBrief = (record: ArchiveItem): void => {
siteBriefRef.value?.openModal({ customerId: record.id, customerName: record.name })
}
// 注入列回调(tableColumns 模块级常量,通过 setter 注入)
setSiteCountClickHandler(openSiteBrief)
const listRequest = (): void => {
pageLoading.value = true
list(search)
@@ -141,6 +152,7 @@ export const usePage = () => {
selectedRowKeys,
rowSelection,
addOrEditRef,
siteBriefRef,
infoDrawerRef,
searchQuery,
resetQuery,
@@ -1,6 +1,6 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import { Button, Tag } from 'ant-design-vue'
import type { ArchiveItem, CustomerType } from '../types'
import { CUSTOMER_TYPE_OPTIONS, customerTypeText } from '../types'
@@ -13,7 +13,7 @@ const typeColor: Record<CustomerType, string> = {
other: 'default',
}
/** 客户档案列定义 */
/** 客户档案列定义(关联场所列可点击,由 usePage 提供 onSiteClick 回调) */
export const tableColumns: TableColumnsType = [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '客户编码', dataIndex: 'code', align: 'left', width: 130, resizable: true, ellipsis: true },
@@ -29,8 +29,23 @@ export const tableColumns: TableColumnsType = [
{ title: '联系人', dataIndex: 'contactName', align: 'left', width: 110, resizable: true, ellipsis: true },
{ title: '联系电话', dataIndex: 'contactPhone', align: 'center', width: 140, resizable: true, ellipsis: true },
{ title: '地址', dataIndex: 'address', align: 'left', width: 240, resizable: true, ellipsis: true },
{ title: '授权应用', dataIndex: 'appCount', align: 'center', width: 90 },
{ title: '关联场所', dataIndex: 'siteCount', align: 'center', width: 90 },
{
title: '关联场所',
dataIndex: 'siteCount',
align: 'center',
width: 100,
customRender: ({ value, record }: { value: number; record: ArchiveItem }) =>
h(
Button,
{
type: 'link',
size: 'small',
disabled: !value,
onClick: () => onSiteCountClick?.(record),
},
() => String(value ?? 0),
),
},
{
title: '状态',
dataIndex: 'status',
@@ -39,10 +54,17 @@ export const tableColumns: TableColumnsType = [
customRender: ({ value }: { value: boolean }) =>
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '启用' : '停用')),
},
{ title: '到期时间', dataIndex: 'expireTime', align: 'center', width: 120, resizable: true },
{ title: '创建时间', dataIndex: 'createTime', align: 'center', width: 170, sorter: true, resizable: true },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 },
]
/** 关联场所点击回调(由 usePage 注入) */
let onSiteCountClick: ((record: ArchiveItem) => void) | null = null
/** 注入关联场所点击回调(供 usePage 调用) */
export const setSiteCountClickHandler = (handler: (record: ArchiveItem) => void): void => {
onSiteCountClick = handler
}
/** 客户类型下拉选项(供搜索/弹框复用) */
export const CUSTOMER_TYPE_OPTIONS_EXPORT = CUSTOMER_TYPE_OPTIONS
@@ -22,6 +22,6 @@ export const update = (payload: SitePayload): Promise<ApiResponse<null>> =>
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/site/delete', { id })
/** 查询租户下拉(只读消费,与 tenantList 同源 */
export const tenantOptions = (): Promise<ApiResponse<{ id: string; name: string }[]>> =>
postRequest('axiosRequest', '/admin/tenant/page', { pageNum: 1, pageSize: 200 })
/** 查询客户档案下拉(复用 archive 列表接口,用于 customerId → customerName 反查 */
export const customerOptions = (): Promise<ApiResponse<{ id: string; name: string }[]>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/page', { pageNum: 1, pageSize: 200 })
@@ -6,25 +6,18 @@
:destroy-on-close="true"
>
<a-spin :spinning="pageInfo.spin">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions v-if="detail" title="基本信息" :column="2" bordered size="small">
<a-descriptions-item label="场所名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="场所编号">{{ detail.code ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属户">{{ detail.tenantName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属户">{{ detail.customerName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="场所类型">{{ siteTypeText(detail.siteType) }}</a-descriptions-item>
<a-descriptions-item label="自定义标识" :span="2">{{ detail.customFlag ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="联系人">{{ detail.contactName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ detail.contactPhone ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="部门">{{ detail.department ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="挂载设备数">{{ detail.deviceCount ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="客户端心跳">
<a-tag :color="detail.clientHeartbeat === 'online' ? 'success' : 'default'">
{{ detail.clientHeartbeat === 'online' ? '在线' : '离线' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="MQ 心跳">
<a-tag :color="detail.mqHeartbeat === 'online' ? 'success' : 'default'">
{{ detail.mqHeartbeat === 'online' ? '在线' : '离线' }}
</a-tag>
<a-descriptions-item label="前置服务器">
<a-tag :color="detail.hasFrontendServer ? 'blue' : 'default'">{{ detail.hasFrontendServer ? '有' : '无' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="启用状态">
<a-tag :color="detail.status ? 'green' : 'default'">{{ detail.status ? '启用' : '停用' }}</a-tag>
@@ -32,6 +25,24 @@
<a-descriptions-item label="创建时间">{{ detail.createTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="地址" :span="2">{{ detail.address ?? '—' }}</a-descriptions-item>
</a-descriptions>
<a-descriptions
v-if="detail && detail.hasFrontendServer"
title="数据上报配置"
:column="1"
bordered
size="small"
style="margin-top: 16px"
>
<a-descriptions-item label="前置服务器状态">
<a-tag :color="detail.frontendServerStatus === 'online' ? 'success' : 'default'">
{{ detail.frontendServerStatus === 'online' ? '在线' : '离线' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="最后心跳时间">{{ detail.lastHeartbeatTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="MQ 通讯地址">{{ detail.mqAddress ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="业务服务请求地址">{{ detail.businessServiceUrl ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-drawer>
</template>
@@ -40,6 +51,7 @@
/**
* 布点场所 - 详情抽屉
* - 只读展示,所有字段 ?? '—'
* - hasFrontendServer=true 时额外展示「数据上报配置」分组
*/
import { usePage } from './init/usePage'
import { siteTypeText } from '../../../init/useTable'
@@ -19,10 +19,10 @@ export interface DetailInfo {
name: string
/** 场所编号 */
code: string
/** 所属户 ID */
tenantId: string
/** 所属户名称(联表回填) */
tenantName: string
/** 所属户 ID */
customerId: string
/** 所属户名称(联表回填) */
customerName: string
/** 部门 */
department?: string
/** 地址 */
@@ -35,10 +35,16 @@ export interface DetailInfo {
siteType: string
/** 自定义标识 */
customFlag: string
/** 客户端心跳状态 */
clientHeartbeat: 'online' | 'offline'
/** 消息队列心跳状态 */
mqHeartbeat: 'online' | 'offline'
/** 是否部署前置服务器 */
hasFrontendServer: boolean
/** 前置服务器状态 */
frontendServerStatus?: 'online' | 'offline'
/** 最后心跳时间 */
lastHeartbeatTime?: string
/** MQ 通讯地址 */
mqAddress?: string
/** 业务服务请求地址 */
businessServiceUrl?: string
/** 挂载设备数 */
deviceCount: number
/** 启用状态 */
@@ -40,14 +40,14 @@
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属户" name="tenantId">
<a-form-item label="所属户" name="customerId">
<a-select
v-model:value="form.tenantId"
:options="tenantOptionList"
v-model:value="form.customerId"
:options="customerOptionList"
:field-names="{ label: 'name', value: 'id' }"
show-search
allow-clear
placeholder="请选择户"
placeholder="请选择户"
:filter-option="filterOption"
/>
</a-form-item>
@@ -140,7 +140,7 @@
<script setup lang="ts">
/**
* 布点场所 - 新增/编辑 弹框
* - 字段:场所名称 / 编号 / 所属户 / 场所类型 / 自定义标识(必填)/ 联系人 / 电话 / 部门 / 启用状态 / 地址
* - 字段:场所名称 / 编号 / 所属户 / 场所类型 / 自定义标识(必填)/ 联系人 / 电话 / 部门 / 启用状态 / 地址
*/
import { filterOption } from '@utils/antDesign/select'
@@ -148,7 +148,7 @@ import { usePage } from './init/usePage'
import { SITE_TYPE_OPTIONS } from '../../../init/useTable'
const props = defineProps<{
tenantOptionList: { id: string; name: string }[]
customerOptionList: { id: string; name: string }[]
}>()
const emit = defineEmits<{
@@ -12,7 +12,7 @@ const createInitForm = (): Form => ({
id: undefined,
name: '',
code: '',
tenantId: undefined,
customerId: undefined,
department: '',
address: '',
contactName: '',
@@ -39,7 +39,7 @@ export const usePage = (emit: (e: 'load') => void) => {
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '请输入场所名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入场所编号', trigger: 'blur' }],
tenantId: [{ required: true, message: '请选择所属户', trigger: 'change' }],
customerId: [{ required: true, message: '请选择所属户', trigger: 'change' }],
siteType: [{ required: true, message: '请选择场所类型', trigger: 'change' }],
customFlag: [{ required: true, message: '请输入自定义标识(用于数据分发寻址、同名场所区分)', trigger: 'blur' }],
}
@@ -25,8 +25,8 @@ export interface Form {
name: string
/** 场所编号 */
code: string
/** 所属户 ID */
tenantId: string | undefined
/** 所属户 ID */
customerId: string | undefined
/** 部门 */
department: string
/** 地址 */
@@ -5,16 +5,16 @@ import { createPaginationConfig } from '@utils/antDesign/table'
import type { TableState } from '@utils/antDesign/table'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from '../component/drawer/info/info.vue'
import { list, remove, tenantOptions } from '../api'
import { customerOptions, list, remove } from '../api'
import type { SearchForm, SiteItem } from '../types'
import { createSearchKey } from './useSearch'
import { tableColumns } from './useTable'
/**
* 布点场所页主编排:
* - 标准 CRUD 列表,与租户 N:1
* - 标准 CRUD 列表,与客户档案 N:1
* - 必填"自定义标识"字段
* - 客户端/MQ 心跳只读展示
* - 前置服务器列为只读展示
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
@@ -31,8 +31,8 @@ export const usePage = () => {
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
/** 租户下拉选项(用于搜索/弹框) */
const tenantOptionList = ref<{ id: string; name: string }[]>([])
/** 客户档案下拉选项(用于搜索/弹框) */
const customerOptionList = ref<{ id: string; name: string }[]>([])
/** 新增/编辑弹框 ref */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
@@ -79,16 +79,16 @@ export const usePage = () => {
},
}
/** 拉取租户下拉 */
const initTenantOptions = (): void => {
tenantOptions()
/** 拉取客户档案下拉 */
const initCustomerOptions = (): void => {
customerOptions()
.then((res) => {
if (res.code === '00000') {
tenantOptionList.value = (res.data ?? []).map((item) => ({ id: item.id, name: item.name }))
customerOptionList.value = (res.data ?? []).map((item) => ({ id: item.id, name: item.name }))
}
})
.catch((err: unknown) => {
console.error('租户下拉请求失败:', err)
console.error('客户档案下拉请求失败:', err)
})
}
@@ -148,7 +148,7 @@ export const usePage = () => {
}
onMounted(() => {
initTenantOptions()
initCustomerOptions()
listRequest()
})
@@ -158,7 +158,7 @@ export const usePage = () => {
table,
selectedRowKeys,
rowSelection,
tenantOptionList,
customerOptionList,
addOrEditRef,
infoDrawerRef,
searchQuery,
@@ -3,7 +3,7 @@ import type { SearchForm } from '../types'
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
tenantId: undefined,
customerId: undefined,
siteType: undefined,
status: undefined,
})
@@ -1,41 +1,23 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import type { HeartbeatStatus } from '../types'
/** 心跳状态 → Tag 颜色 */
const heartbeatColor: Record<HeartbeatStatus, string> = {
online: 'success',
offline: 'default',
}
const heartbeatText = (status: HeartbeatStatus): string =>
status === 'online' ? '在线' : '离线'
/** 布点场所列定义 */
export const tableColumns: TableColumnsType = [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '场所名称', dataIndex: 'name', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '场所编号', dataIndex: 'code', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '所属户', dataIndex: 'tenantName', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '所属户', dataIndex: 'customerName', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '场所类型', dataIndex: 'siteType', align: 'center', width: 110, resizable: true, ellipsis: true },
{ title: '自定义标识', dataIndex: 'customFlag', align: 'center', width: 140, resizable: true, ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', align: 'left', width: 110, resizable: true, ellipsis: true },
{
title: '客户端心跳',
dataIndex: 'clientHeartbeat',
title: '前置服务器',
dataIndex: 'hasFrontendServer',
align: 'center',
width: 110,
customRender: ({ value }: { value: HeartbeatStatus }) =>
h(Tag, { color: heartbeatColor[value] }, () => heartbeatText(value)),
},
{
title: 'MQ 心跳',
dataIndex: 'mqHeartbeat',
align: 'center',
width: 100,
customRender: ({ value }: { value: HeartbeatStatus }) =>
h(Tag, { color: heartbeatColor[value] }, () => heartbeatText(value)),
customRender: ({ value }: { value: boolean }) =>
h(Tag, { color: value ? 'blue' : 'default' }, () => (value ? '有' : '无')),
},
{ title: '挂载设备数', dataIndex: 'deviceCount', align: 'center', width: 100 },
{
@@ -10,14 +10,14 @@
style="width: 220px"
/>
</a-form-item>
<a-form-item label="所属户">
<a-form-item label="所属户">
<a-select
v-model:value="search.tenantId"
v-model:value="search.customerId"
allow-clear
show-search
placeholder="请选择户"
placeholder="请选择户"
style="width: 200px"
:options="tenantOptionList"
:options="customerOptionList"
:field-names="{ label: 'name', value: 'id' }"
:filter-option="filterOption"
/>
@@ -63,7 +63,7 @@
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" :tenant-option-list="tenantOptionList" @load="listRequest" />
<AddOrEdit ref="addOrEditRef" :customer-option-list="customerOptionList" @load="listRequest" />
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
@@ -85,7 +85,7 @@ const {
pageLoading,
search,
table,
tenantOptionList,
customerOptionList,
addOrEditRef,
infoDrawerRef,
searchQuery,
@@ -1,7 +1,7 @@
import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types'
/** 心跳状态 */
export type HeartbeatStatus = 'online' | 'offline'
/** 前置服务器状态 */
export type FrontendServerStatus = 'online' | 'offline'
/** 布点场所档案 */
export interface SiteItem extends BaseRecord {
@@ -9,10 +9,10 @@ export interface SiteItem extends BaseRecord {
name: string
/** 场所编号 */
code: string
/** 所属户 ID */
tenantId: string
/** 所属户名称(联表回填) */
tenantName: string
/** 所属户 ID */
customerId: string
/** 所属户名称(联表回填) */
customerName: string
/** 部门 */
department?: string
/** 地址 */
@@ -25,10 +25,16 @@ export interface SiteItem extends BaseRecord {
siteType: string
/** 自定义标识(必填,运营自定义,用于数据中转寻址、同名场所区分、下游业务系统附加编码等) */
customFlag: string
/** 客户端心跳状态 */
clientHeartbeat: HeartbeatStatus
/** 消息队列心跳状态 */
mqHeartbeat: HeartbeatStatus
/** 是否部署前置服务器 */
hasFrontendServer: boolean
/** 前置服务器状态(hasFrontendServer=true 时有意义) */
frontendServerStatus?: FrontendServerStatus
/** 最后心跳时间 */
lastHeartbeatTime?: string
/** MQ 通讯地址 */
mqAddress?: string
/** 业务服务请求地址 */
businessServiceUrl?: string
/** 已挂载设备数 */
deviceCount: number
/** 启用状态 */
@@ -40,7 +46,7 @@ export interface SiteItem extends BaseRecord {
/** 搜索表单 */
export interface SearchForm extends BaseSearchForm {
keyword?: string
tenantId?: string
customerId?: string
siteType?: string
status?: boolean
}
@@ -50,7 +56,7 @@ export interface SitePayload {
id?: string
name: string
code: string
tenantId: string
customerId: string
department?: string
address?: string
contactName?: string
@@ -22,6 +22,10 @@ export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inbound/delete', { id })
/** 审核入库单(completed → audited,生成设备台账 N 条) */
export const audit = (id: string): Promise<ApiResponse<{ pushed: number }>> =>
postRequest('axiosRequest', '/admin/device/stock/inbound/audit', { id })
/** 导出 */
export const exportList = (params: ListParams): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inbound/export', params)
@@ -20,41 +20,23 @@
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备编码" name="deviceCode">
<a-input
v-model:value="form.deviceCode"
v-only-alphanumeric
allow-clear
:maxlength="40"
placeholder="如 CAB-A1-0003"
/>
<a-form-item label="入库类型" name="inboundType">
<a-select v-model:value="form.inboundType" :options="INBOUND_TYPE_OPTIONS" placeholder="请选择" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备名称" name="deviceName">
<a-input
v-model:value="form.deviceName"
v-no-space
allow-clear
:maxlength="60"
placeholder="请输入设备名称"
<a-form-item label="设备类别" name="categoryId">
<DeviceCategoryPicker
v-model="form.categoryId"
leaf-only
placeholder="请选择末级分类"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="二级型号" name="modelName">
<a-input
v-model:value="form.modelName"
v-no-space
allow-clear
:maxlength="60"
placeholder="如 智能货柜"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="批次号" name="batchNo">
<a-input
@@ -62,29 +44,25 @@
v-only-alphanumeric-special
allow-clear
:maxlength="40"
placeholder="如 B2026-001"
placeholder="如 B2026-001(回库可空)"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="供应商" name="supplierName">
<a-input
v-model:value="form.supplierName"
v-no-space
<a-form-item label="供应商" name="supplierId">
<a-select
v-model:value="form.supplierId"
:options="supplierOptionList"
:field-names="{ label: 'name', value: 'id' }"
show-search
allow-clear
:maxlength="60"
placeholder="请输入供应商名称"
placeholder="请选择供应商"
style="width: 100%"
:filter-option="filterOption"
@search="onSupplierSearch"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="入库类型" name="inboundType">
<a-select v-model:value="form.inboundType" :options="INBOUND_TYPE_OPTIONS" placeholder="请选择" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
@@ -95,7 +73,8 @@
:min="1"
:max="9999"
style="width: 100%"
placeholder="入库数量"
placeholder="自动同步设备明细条数"
disabled
/>
</a-form-item>
</a-col>
@@ -112,16 +91,81 @@
</a-col>
</a-row>
<a-form-item label="仓储位置" name="storageLocation">
<a-input
v-model:value="form.storageLocation"
v-no-space
allow-clear
:maxlength="60"
placeholder="如 A区-01"
<a-form-item label="仓库区域" name="warehouseAreaId">
<WarehouseAreaPicker
v-model="form.warehouseAreaId"
leaf-only
placeholder="请选择末级仓库区域"
style="width: 100%"
/>
</a-form-item>
<!-- 设备明细子表每行一台设备deviceCode + SN审核后生成台账 -->
<a-form-item label="设备明细" name="items" :label-col="{ style: { width: '110px' } }">
<div class="inbound-items">
<div class="inbound-items__toolbar">
<a-space :size="8">
<a-button size="small" @click="openBatchInput">
<template #icon><FormOutlined /></template>
批量录入
</a-button>
<a-button size="small" @click="triggerFileImport">
<template #icon><UploadOutlined /></template>
文件导入
</a-button>
<a-button size="small" type="dashed" @click="addRow">
<template #icon><PlusOutlined /></template>
增行
</a-button>
</a-space>
<span class="inbound-items__count"> {{ form.items.length }} </span>
<input
ref="fileInputRef"
type="file"
accept=".txt,.csv,text/plain"
style="display: none"
@change="handleFileChange"
/>
</div>
<a-table
:columns="itemColumns"
:data-source="form.items"
:pagination="false"
row-key="deviceCode"
size="small"
:scroll="{ y: 240 }"
class="inbound-items__table"
>
<template #bodyCell="{ column, index }">
<template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.dataIndex === 'deviceCode'">
<a-input
v-model:value="form.items[index].deviceCode"
v-no-space
allow-clear
:maxlength="60"
placeholder="设备编码"
size="small"
/>
</template>
<template v-else-if="column.dataIndex === 'serialNumber'">
<a-input
v-model:value="form.items[index].serialNumber"
v-no-space
allow-clear
:maxlength="60"
placeholder="SN(可选)"
size="small"
/>
</template>
<template v-else-if="column.dataIndex === 'operation'">
<a-button type="link" size="small" danger @click="removeRow(index)">删除</a-button>
</template>
</template>
</a-table>
</div>
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
@@ -134,6 +178,31 @@
</a-form>
</a-spin>
<!-- 批量录入弹窗 -->
<a-modal
v-model:open="batchInputRef.visible"
title="批量录入设备明细"
:width="640"
:keyboard="false"
:mask-closable="false"
>
<a-alert
type="info"
show-icon
message="每行一条设备编码,支持逗号或制表符分隔 SN。例:DEV2026-001,SN-A001"
style="margin-bottom: 12px"
/>
<a-textarea
v-model:value="batchInputRef.text"
:rows="10"
placeholder="DEV2026-001,SN-A001&#10;DEV2026-002,SN-A002&#10;DEV2026-003"
/>
<template #footer>
<a-button @click="cancelBatchInput">取消</a-button>
<a-button type="primary" @click="confirmBatchInput">追加</a-button>
</template>
</a-modal>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
@@ -149,9 +218,14 @@
<script setup lang="ts">
/**
* 入库管理 - 新增/编辑 弹框
* - 字段:入库单号(编辑只读)/ 设备编码 / 设备名称 / 二级型号 / 批次号 / 供应商 / 数量 / 采购价 / 入库类型 / 仓储位置 / 备注
* - 字段:入库类型 / 设备类别(末级分类)/ 批次号 / 供应商(下拉,数据来源于供应商管理)/ 数量(自动同步)/ 采购价 / 仓库区域(末级)/ 设备明细 / 备注
* - 设备明细:每行一条 deviceCode + SN,支持批量录入 / 文件导入 / 增删行
* - 数量字段只读,自动同步为 items.length
*/
import type { TableColumnsType } from 'ant-design-vue'
import { FormOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons-vue'
import DeviceCategoryPicker from '@components/DeviceCategoryPicker.vue'
import WarehouseAreaPicker from '@components/WarehouseAreaPicker.vue'
import { usePage } from './init/usePage'
import { INBOUND_TYPE_OPTIONS } from '../../../types'
@@ -164,10 +238,57 @@ const {
formRef,
form,
rules,
supplierOptionList,
refreshSupplierOptions,
filterOption,
batchInputRef,
fileInputRef,
openModal,
closeModal,
submit,
addRow,
removeRow,
openBatchInput,
cancelBatchInput,
confirmBatchInput,
triggerFileImport,
handleFileChange,
} = usePage(emit)
/** 供应商下拉搜索回调 */
const onSupplierSearch = (kw: string): void => {
refreshSupplierOptions(kw)
}
/** 设备明细子表列定义 */
const itemColumns: TableColumnsType = [
{ title: '序号', dataIndex: 'index', align: 'center', width: 60 },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left' },
{ title: '序列号(SN)', dataIndex: 'serialNumber', align: 'left', width: 200 },
{ title: '操作', dataIndex: 'operation', align: 'center', width: 80 },
]
defineExpose({ openModal })
</script>
<style scoped lang="less">
.inbound-items {
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
&__count {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
&__table {
:deep(.ant-table-cell) {
padding: 4px 8px !important;
}
}
}
</style>
@@ -4,7 +4,7 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { Form } from '../types'
import type { Form, SupplierOption } from '../types'
/** 详情查询(编辑回填) */
export const info = (id: string): Promise<ApiResponse<Form>> =>
@@ -17,3 +17,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/stock/inbound/update', params)
/** 供应商下拉(数据来源于供应商管理,可搜索) */
export const supplierOptions = (keyword?: string): Promise<ApiResponse<SupplierOption[]>> =>
postRequest('axiosRequest', '/admin/device/customer/supplier/options', { keyword })
@@ -1,29 +1,46 @@
/**
* 入库管理 - 新增/编辑 弹框 - 主逻辑
* - 设备明细子表:每行一台设备(deviceCode 必填、serialNumber 可选)
* - 批量录入:多行文本粘贴,按换行分隔,每行解析为 deviceCode[,serialNumber]
* - 文件导入:.txt/.csv 文本文件,按行解析同批量录入
* - 数量字段自动同步为 items.length,不允许手动改
*/
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { add, edit, info } from '../api'
import type { Form, ModalType, OpenContext, PageInfo } from '../types'
import { filterOption } from '@utils/antDesign/select'
import { add, edit, info, supplierOptions } from '../api'
import type { Form, ModalType, OpenContext, PageInfo, SupplierOption } from '../types'
import type { InboundDetailItem } from '../../../types'
const createInitForm = (): Form => ({
id: undefined,
inboundCode: '',
deviceCode: '',
deviceName: '',
modelName: '',
inboundType: 'purchase',
categoryId: undefined,
batchNo: '',
supplierId: '',
supplierName: '',
quantity: 1,
purchasePrice: 0,
inboundType: 'purchase',
warehouseAreaId: undefined,
status: 'completed',
storageLocation: '',
remark: '',
items: [],
})
/** 解析批量录入文本:每行一条,支持逗号 / 制表符分隔 deviceCode 与 serialNumber */
const parseBatchInput = (text: string): InboundDetailItem[] => {
const lines = text.split(/[\r\n]+/).map((line) => line.trim()).filter(Boolean)
return lines.map((line) => {
const [deviceCode, ...rest] = line.split(/[,\t]/).map((s) => s.trim())
return {
deviceCode: deviceCode ?? '',
serialNumber: rest.filter(Boolean).join(',') || '',
}
}).filter((item) => item.deviceCode)
}
export const usePage = (emit: (e: 'load') => void) => {
const { message } = useAntdStaticMethods()
@@ -38,15 +55,26 @@ export const usePage = (emit: (e: 'load') => void) => {
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
/** 供应商下拉列表(数据来源于供应商管理) */
const supplierOptionList = ref<SupplierOption[]>([])
const refreshSupplierOptions = (keyword?: string): void => {
supplierOptions(keyword)
.then((res) => {
if (res.code === '00000') supplierOptionList.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('供应商下拉加载失败:', err)
})
}
const rules: Record<string, Rule[]> = {
deviceCode: [{ required: true, message: '请输入设备编码', trigger: 'blur' }],
deviceName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
modelName: [{ required: true, message: '请输入二级型号', trigger: 'blur' }],
batchNo: [{ required: true, message: '请输入批次号', trigger: 'blur' }],
supplierName: [{ required: true, message: '请输入供应商', trigger: 'blur' }],
inboundType: [{ required: true, message: '请选择入库类型', trigger: 'change' }],
categoryId: [{ required: true, message: '请选择设备类别', trigger: 'change' }],
supplierId: [{ required: true, message: '请选择供应商', trigger: 'change' }],
quantity: [{ required: true, type: 'number', message: '请输入数量', trigger: 'blur' }],
purchasePrice: [{ required: true, type: 'number', message: '请输入采购价', trigger: 'blur' }],
inboundType: [{ required: true, message: '请选择入库类型', trigger: 'change' }],
warehouseAreaId: [{ required: true, message: '请选择仓库区域', trigger: 'change' }],
}
const resetForm = (): void => {
@@ -59,6 +87,92 @@ export const usePage = (emit: (e: 'load') => void) => {
pageInfo.visible = false
}
/** 同步数量字段 = items.length */
const syncQuantity = (): void => {
form.value.quantity = Math.max(1, form.value.items.length)
}
/** 新增空行 */
const addRow = (): void => {
form.value.items.push({ deviceCode: '', serialNumber: '' })
syncQuantity()
}
/** 删除指定行 */
const removeRow = (index: number): void => {
form.value.items.splice(index, 1)
syncQuantity()
}
/** 批量录入(多行文本),追加解析后的条目 */
const batchInputRef = ref<{ visible: boolean; text: string }>({ visible: false, text: '' })
const openBatchInput = (): void => {
batchInputRef.value.visible = true
batchInputRef.value.text = ''
}
const cancelBatchInput = (): void => {
batchInputRef.value.visible = false
batchInputRef.value.text = ''
}
const confirmBatchInput = (): void => {
const parsed = parseBatchInput(batchInputRef.value.text)
if (!parsed.length) {
void message.warning('未解析出有效设备编码,请按"每行一条"格式录入')
return
}
const existing = new Set(form.value.items.map((item) => item.deviceCode))
let appended = 0
for (const item of parsed) {
if (existing.has(item.deviceCode)) continue
form.value.items.push(item)
appended += 1
}
syncQuantity()
cancelBatchInput()
void message.success(`已追加 ${appended} 条明细${appended < parsed.length ? `(去重 ${parsed.length - appended} 条)` : ''}`)
}
/** 文件导入:读取文本文件后按批量录入解析 */
const fileInputRef = ref<HTMLInputElement | null>(null)
const triggerFileImport = (): void => {
fileInputRef.value?.click()
}
const handleFileChange = (e: Event): void => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const text = String(reader.result ?? '')
const parsed = parseBatchInput(text)
if (!parsed.length) {
void message.warning('文件未解析出有效设备编码')
target.value = ''
return
}
const existing = new Set(form.value.items.map((item) => item.deviceCode))
let appended = 0
for (const item of parsed) {
if (existing.has(item.deviceCode)) continue
form.value.items.push(item)
appended += 1
}
syncQuantity()
void message.success(`已导入 ${appended} 条明细${appended < parsed.length ? `(去重 ${parsed.length - appended} 条)` : ''}`)
target.value = ''
}
reader.onerror = () => {
void message.error('文件读取失败')
target.value = ''
}
reader.readAsText(file)
}
const infoRequest = (id: string) => info(id)
const openModal = (type: ModalType, ctx?: OpenContext): void => {
@@ -66,6 +180,7 @@ export const usePage = (emit: (e: 'load') => void) => {
pageInfo.spin = true
pageInfo.type = type
resetForm()
refreshSupplierOptions()
pageInfo.title = type === 'add' ? '新增入库单' : '编辑入库单'
@@ -129,6 +244,9 @@ export const usePage = (emit: (e: 'load') => void) => {
?.validate()
.then(() => {
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
// 按 supplierId 从下拉列表反查 supplierName 同步(保留双字段以兼容既有列表展示与 mock 数据)
const supplier = supplierOptionList.value.find((item) => item.id === form.value.supplierId)
params.supplierName = supplier?.name ?? ''
if (params.id) editRequest(params)
else addRequest(params)
})
@@ -143,8 +261,20 @@ export const usePage = (emit: (e: 'load') => void) => {
formRef,
form,
rules,
supplierOptionList,
refreshSupplierOptions,
filterOption,
batchInputRef,
fileInputRef,
openModal,
closeModal,
submit,
addRow,
removeRow,
openBatchInput,
cancelBatchInput,
confirmBatchInput,
triggerFileImport,
handleFileChange,
}
}
@@ -1,5 +1,5 @@
import type { FormInstance } from 'ant-design-vue/es/form'
import type { InboundStatus, InboundType } from '../../../types'
import type { InboundDetailItem, InboundStatus, InboundType } from '../../../types'
/** 弹框类型 */
export type ModalType = 'add' | 'edit'
@@ -24,13 +24,11 @@ export interface Form {
id: string | undefined
/** 入库单号(编辑时只读) */
inboundCode: string
/** 设备编码 */
deviceCode: string
/** 设备名称 */
deviceName: string
/** 二级型号 */
modelName: string
/** 批次号 */
/** 入库类型 */
inboundType: InboundType
/** 设备类别 ID(末级分类) */
categoryId: string | undefined
/** 批次号(可选,回库场景可空) */
batchNo: string
/** 供应商 ID */
supplierId: string
@@ -40,14 +38,14 @@ export interface Form {
quantity: number
/** 采购价(单价) */
purchasePrice: number
/** 入库类型 */
inboundType: InboundType
/** 仓库区域 ID(末级节点) */
warehouseAreaId: string | undefined
/** 状态 */
status: InboundStatus
/** 仓储位置 */
storageLocation: string
/** 备注 */
remark: string
/** 设备明细(每行一台设备,审核后生成台账) */
items: InboundDetailItem[]
}
/** 暴露给父组件的 ref 类型 */
@@ -55,3 +53,9 @@ export type FormRef = ReturnType<typeof usePageRef>
/** 仅用于类型推导的占位函数 */
declare function usePageRef(): FormInstance | undefined
/** 供应商下拉选项(数据来源于供应商管理,可搜索) */
export interface SupplierOption {
id: string
name: string
}
@@ -2,7 +2,13 @@
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="单号 / 设备编码 / 名称" style="width: 240px" />
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="入库单号" style="width: 200px" />
</a-form-item>
<a-form-item label="设备类别">
<DeviceCategoryPicker v-model="search.categoryId" placeholder="全部类别" style="width: 200px" />
</a-form-item>
<a-form-item label="仓库区域">
<WarehouseAreaPicker v-model="search.warehouseAreaId" placeholder="全部区域" style="width: 200px" />
</a-form-item>
<a-form-item label="入库类型">
<a-select
@@ -34,22 +40,57 @@
<template #icon><DownloadOutlined /></template>
导出
</a-button>
<a-button @click="viewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
<a-button
v-if="record.status === 'completed'"
type="link"
size="small"
@click="handleAction('audit', record)"
>审核</a-button>
<a-button
v-else
type="link"
size="small"
@click="handleAction('edit', record)"
>编辑</a-button>
<a-divider v-if="record.status === 'audited'" type="vertical" />
<a-button
v-if="record.status === 'audited'"
type="link"
size="small"
@click="handleAction('ledger', record)"
>查看台账</a-button>
<a-divider
v-if="record.status !== 'audited' && record.status !== 'cancelled'"
type="vertical"
/>
<a-button
v-if="record.status !== 'audited' && record.status !== 'cancelled'"
type="link"
size="small"
danger
@click="handleAction('delete', record)"
>删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
<ExportTaskDrawer ref="exportTaskRef" module-key="stock-inbound" />
</div>
</template>
<script setup lang="ts">
import { DownloadOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { DownloadOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import DeviceCategoryPicker from '@components/DeviceCategoryPicker.vue'
import WarehouseAreaPicker from '@components/WarehouseAreaPicker.vue'
import ExportTaskDrawer from '@components/ExportTaskDrawer.vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import { INBOUND_STATUS_OPTIONS, INBOUND_TYPE_OPTIONS } from './types'
import { usePage } from './init/usePage'
@@ -59,12 +100,14 @@ const {
search,
table,
addOrEditRef,
exportTaskRef,
dataSourceChange,
searchQuery,
resetQuery,
handleAction,
listRequest,
exportRecord,
viewExportTask,
} = usePage()
</script>
@@ -1,11 +1,13 @@
import { useRouter } from 'vue-router'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { useAdminTable } from '@pages/admin-portal/shared/useAdminTable'
import { useAdminSearch } from '@pages/admin-portal/shared/useAdminSearch'
import { exportList, list, remove } from '../api'
import { audit, exportList, list, remove } from '../api'
import { tableColumns } from './useTable'
import { createSearchKey } from './useSearch'
import type { ListItem, SearchForm } from '../types'
import type AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import type ExportTaskDrawer from '@components/ExportTaskDrawer.vue'
/**
* 入库管理页主编排:
@@ -14,10 +16,12 @@ import type AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const router = useRouter()
const { search, initOptions, resetSearch } = useAdminSearch(createSearchKey)
const pageLoading = ref<boolean>(false)
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
const exportTaskRef = ref<InstanceType<typeof ExportTaskDrawer> | null>(null)
const buildParams = () => ({
...search,
@@ -105,10 +109,44 @@ export const usePage = () => {
})
}
const viewExportTask = (): void => {
exportTaskRef.value?.openDrawer()
}
/** 审核入库单:completed → audited,触发设备台账生成 */
const auditRecord = (record: ListItem): void => {
Modal.confirm({
title: '确认审核?',
content: `入库单「${record.inboundCode}」审核后将生成 ${record.quantity} 条设备台账,且不可撤销。`,
okText: '确认审核',
cancelText: '取消',
onOk: () => audit(record.id)
.then((res) => {
if (res.code === '00000') {
void message.success(res.msg || '审核成功')
listRequest()
}
})
.catch((err: unknown) => {
console.error('入库单审核失败:', err)
}),
})
}
/** 查看设备台账:跳转到设备管理列表,按 inboundCode 过滤 */
const viewDeviceLedger = (record: ListItem): void => {
void router.push({
path: '/admin-portal/device-center/asset/manage',
query: { inboundCode: record.inboundCode },
})
}
const handleAction = (type: string, record?: ListItem): void => {
if (type === 'add') openAddModal()
else if (type === 'edit' && record) openEditModal(record)
else if (type === 'delete' && record) deleteRecord(record)
else if (type === 'audit' && record) auditRecord(record)
else if (type === 'ledger' && record) viewDeviceLedger(record)
}
const loadData = (): void => {
@@ -127,11 +165,15 @@ export const usePage = () => {
selectedRowKeys,
rowSelection,
addOrEditRef,
exportTaskRef,
dataSourceChange,
searchQuery,
resetQuery,
handleAction,
listRequest,
exportRecord,
viewExportTask,
auditRecord,
viewDeviceLedger,
}
}
@@ -4,4 +4,7 @@ export const createSearchKey = (): SearchForm => ({
keyword: undefined,
supplierId: undefined,
status: undefined,
inboundType: undefined,
categoryId: undefined,
warehouseAreaId: undefined,
})
@@ -18,10 +18,8 @@ const typeText = (type: string): string =>
export const tableColumns: TableColumnsType<ListItem> = [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '入库单号', dataIndex: 'inboundCode', align: 'left', width: 170, fixed: 'left', resizable: true, ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 150, resizable: true, ellipsis: true },
{ title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '二级型号', dataIndex: 'modelName', align: 'left', width: 130, resizable: true, ellipsis: true },
{ title: '批次号', dataIndex: 'batchNo', align: 'left', width: 130, resizable: true, ellipsis: true },
{ title: '设备类别', dataIndex: 'categoryName', align: 'left', width: 150, resizable: true, ellipsis: true },
{ title: '批次号', dataIndex: 'batchNo', align: 'left', width: 130, resizable: true, ellipsis: true, customRender: ({ value }: { value?: string }) => value ?? '—' },
{ title: '供应商', dataIndex: 'supplierName', align: 'left', width: 180, resizable: true, ellipsis: true },
{ title: '数量', dataIndex: 'quantity', align: 'center', width: 80 },
{ title: '采购价(元)', dataIndex: 'purchasePrice', align: 'right', width: 110, customRender: ({ value }: { value: number }) => value?.toFixed(2) ?? '—' },
@@ -33,6 +31,7 @@ export const tableColumns: TableColumnsType<ListItem> = [
customRender: ({ value }: { value: InboundType }) =>
h(Tag, { color: INBOUND_TYPE_COLOR[value] ?? 'default' }, () => typeText(value)),
},
{ title: '仓库区域', dataIndex: 'warehouseAreaName', align: 'left', width: 140, resizable: true, ellipsis: true, customRender: ({ value }: { value?: string }) => value ?? '—' },
{
title: '状态',
dataIndex: 'status',
@@ -43,5 +42,5 @@ export const tableColumns: TableColumnsType<ListItem> = [
},
{ title: '入库时间', dataIndex: 'inboundTime', align: 'center', width: 170, sorter: true, resizable: true },
{ title: '入库人', dataIndex: 'operator', align: 'center', width: 110, resizable: true, ellipsis: true },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 240 },
]
@@ -1,13 +1,24 @@
import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types'
/** 入库单状态 */
export type InboundStatus = 'pending' | 'completed' | 'cancelled'
/**
* 入库单状态:pending 待入库 / completed 已入库(待审核)/ audited 已审核(已生成台账)/ cancelled 已取消
* 注:completed 表示实物已到并扫码登记 SN,但尚未进入资产台账;audited 才是终态(正式生成设备台账)
*/
export type InboundStatus = 'pending' | 'completed' | 'audited' | 'cancelled'
/** 入库类型 */
/** 入库类型:采购入库 / 回库 / 调拨入库(回库通过入库单类型实现,不再单独入口) */
export type InboundType = 'purchase' | 'return' | 'transfer'
/** 入库设备明细:审核后按行生成设备台账 */
export interface InboundDetailItem {
/** 设备编码(必填,唯一) */
deviceCode: string
/** 序列号 SN(可选) */
serialNumber: string
}
export interface SearchForm extends BaseSearchForm {
/** 入库单号 / 设备编码 */
/** 入库单号 */
keyword?: string
/** 供应商 ID */
supplierId?: string
@@ -15,19 +26,21 @@ export interface SearchForm extends BaseSearchForm {
status?: string
/** 入库类型 */
inboundType?: string
/** 设备类别(末级分类 ID) */
categoryId?: string
/** 仓库区域(末级节点 ID) */
warehouseAreaId?: string
}
export interface ListItem extends BaseRecord {
/** 入库单号 */
inboundCode: string
/** 设备编码 */
deviceCode: string
/** 设备名称 */
deviceName: string
/** 二级型号 */
modelName: string
/** 批次号 */
batchNo: string
/** 设备类别 ID(末级分类) */
categoryId: string
/** 设备类别名称(末级分类名,接口回填) */
categoryName: string
/** 批次号(可选,回库场景可空) */
batchNo?: string
/** 供应商 ID */
supplierId: string
/** 供应商名称 */
@@ -44,38 +57,52 @@ export interface ListItem extends BaseRecord {
inboundType: InboundType
/** 状态 */
status: InboundStatus
/** 仓储位置 */
storageLocation?: string
/** 仓库区域 ID(末级节点) */
warehouseAreaId?: string
/** 仓库区域名称(接口回填) */
warehouseAreaName?: string
/** 备注 */
remark?: string
/** 设备明细(已入库待审核时录入,审核后生成台账) */
items?: InboundDetailItem[]
}
export interface ListParams extends BaseListParams, SearchForm {}
export interface InboundPayload {
id?: string
deviceCode: string
deviceName: string
modelName: string
batchNo: string
supplierId: string
supplierName: string
quantity: number
purchasePrice: number
/** 入库类型 */
inboundType: InboundType
storageLocation?: string
/** 设备类别 ID(末级分类) */
categoryId: string
/** 批次号(可选) */
batchNo?: string
/** 供应商 ID */
supplierId: string
/** 供应商名称 */
supplierName: string
/** 数量 */
quantity: number
/** 采购价(单价) */
purchasePrice: number
/** 仓库区域 ID(末级节点) */
warehouseAreaId?: string
/** 备注 */
remark?: string
/** 设备明细(每行一台设备,审核后生成台账) */
items?: InboundDetailItem[]
}
export const INBOUND_STATUS_OPTIONS = [
{ value: 'pending', label: '待入库' },
{ value: 'completed', label: '已入库' },
{ value: 'completed', label: '已入库/待审核' },
{ value: 'audited', label: '已审核' },
{ value: 'cancelled', label: '已取消' },
]
export const INBOUND_TYPE_OPTIONS = [
{ value: 'purchase', label: '采购入库' },
{ value: 'return', label: '回库入库' },
{ value: 'return', label: '回库' },
{ value: 'transfer', label: '调拨入库' },
]
@@ -87,6 +114,7 @@ export const INBOUND_TYPE_COLOR: Record<InboundType, string> = {
export const INBOUND_STATUS_COLOR: Record<InboundStatus, string> = {
pending: 'warning',
completed: 'success',
completed: 'processing',
audited: 'success',
cancelled: 'default',
}
@@ -1,6 +1,6 @@
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { ListItem, ListParams, ReturnPayload } from '../types'
import type { ListItem, ListParams } from '../types'
export const list = (params: ListParams): Promise<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/admin/device/stock/instock/page', params)
@@ -9,9 +9,5 @@ export const list = (params: ListParams): Promise<ApiResponse<ListItem[]>> =>
export const info = (id: string): Promise<ApiResponse<ListItem>> =>
postRequest('axiosRequest', '/admin/device/stock/instock/info', { id })
/** 回库登记 */
export const returnGoods = (payload: ReturnPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/instock/return', payload)
export const exportList = (params: ListParams): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/instock/export', params)
@@ -10,24 +10,19 @@
>
<a-spin :spinning="pageInfo.spin">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions-item label="型号编码">{{ detail.modelCode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="二级型号">{{ detail.modelName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备类型">{{ detail.typeName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备类别">{{ detail.categoryName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="批次号">{{ detail.batchNo ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="供应商" :span="2">{{ detail.supplierName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="采购价(元)">{{ detail.purchasePrice ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="入库时间">{{ detail.inboundTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="仓储位置" :span="2">{{ detail.storageLocation ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="仓库区域" :span="2">{{ detail.warehouseAreaName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="库存数量">{{ detail.stockQty ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="在途数量">{{ detail.inTransitQty ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="锁定数量">{{ detail.lockedQty ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="可用数量">{{ detail.availableQty ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="已出库数量">{{ detail.shippedQty ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="库存状态">
<a-tag :color="STOCK_STATUS_COLOR[detail.stockStatus] ?? 'default'">
{{ statusText(detail.stockStatus) }}
<a-descriptions-item label="库存健康度">
<a-tag :color="STOCK_HEALTH_COLOR[detail.healthLevel] ?? 'default'">
{{ healthText(detail.healthLevel) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ detail.updateTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="更新时间" :span="2">{{ detail.updateTime ?? '—' }}</a-descriptions-item>
</a-descriptions>
<a-empty v-else description="暂无库存数据" />
</a-spin>
@@ -41,11 +36,11 @@
<script setup lang="ts">
/**
* 在库库存 - 详情抽屉(只读)
* - 字段:型号编码 / 二级型号 / 设备类 / 批次号 / 供应商 / 采购价 / 入库时间 / 仓储位置 / 库存数量 / 在途数量 / 可用数量 / 已出库数量 / 库存状态 / 更新时间
* - 字段:设备类 / 批次号 / 供应商 / 仓库区域 / 库存数量 / 锁定数量 / 可用数量 / 库存健康度 / 更新时间
*/
import { usePage } from './init/usePage'
import { STOCK_STATUS_COLOR, STOCK_STATUS_OPTIONS } from '../../../types'
import { STOCK_HEALTH_COLOR, STOCK_HEALTH_OPTIONS } from '../../../types'
const {
pageInfo,
@@ -54,8 +49,8 @@ const {
closeDrawer,
} = usePage()
const statusText = (status: string): string =>
STOCK_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—'
const healthText = (level: string): string =>
STOCK_HEALTH_OPTIONS.find((item) => item.value === level)?.label ?? '—'
defineExpose({ openDrawer })
</script>
@@ -1,10 +0,0 @@
/**
* 在库库存 - 回库登记 弹框 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
/** 回库登记 */
export const returnGoods = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/instock/return', params)
@@ -1,92 +0,0 @@
/**
* 在库库存 - 回库登记 弹框 - 主逻辑
*/
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { returnGoods } from '../api'
import type { Form, OpenContext, PageInfo } from '../types'
const createInitForm = (): Form => ({
id: undefined,
returnQty: 1,
storageLocation: '',
remark: '',
})
export const usePage = (emit: (e: 'load') => void) => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
title: '回库登记',
width: 520,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
const rules: Record<string, Rule[]> = {
returnQty: [{ required: true, type: 'number', message: '请输入回库数量', trigger: 'blur' }],
storageLocation: [{ required: true, message: '请输入仓储位置', trigger: 'blur' }],
}
const resetForm = (): void => {
formRef.value?.clearValidate()
form.value = createInitForm()
}
const closeModal = (): void => {
resetForm()
pageInfo.visible = false
}
const openModal = (ctx: OpenContext): void => {
pageInfo.visible = true
pageInfo.spin = false
resetForm()
form.value.id = ctx.id
form.value.storageLocation = ctx.storageLocation ?? ''
pageInfo.title = ctx.modelName ? `回库登记 · ${ctx.modelName}` : '回库登记'
}
const successRequest = (msg: string): void => {
void message.success(msg)
emit('load')
closeModal()
}
const submit = (): void => {
pageInfo.spin = true
formRef.value
?.validate()
.then(() => {
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
returnGoods(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('回库登记失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
})
.catch((err: unknown) => {
pageInfo.spin = false
console.error('表单验证失败:', err)
})
}
return {
pageInfo,
formRef,
form,
rules,
openModal,
closeModal,
submit,
}
}
@@ -6,11 +6,12 @@ import { tableColumns } from './useTable'
import { createSearchKey } from './useSearch'
import type { ListItem, SearchForm } from '../types'
import type InfoDrawer from '../component/drawer/info/info.vue'
import type ReturnGoodsModal from '../component/modal/returnGoods/returnGoods.vue'
import type ExportTaskDrawer from '@components/ExportTaskDrawer.vue'
/**
* 在库库存页主编排:
* - 列表(只读查询) + 详情抽屉 + 回库登记 + 导出
* - 列表(只读查询) + 详情抽屉 + 导出
* - 回库通过入库单类型=回库实现,本页不再单独提供回库登记入口
*/
export const usePage = () => {
const { message } = useAntdStaticMethods()
@@ -18,7 +19,7 @@ export const usePage = () => {
const pageLoading = ref<boolean>(false)
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
const returnGoodsRef = ref<InstanceType<typeof ReturnGoodsModal> | null>(null)
const exportTaskRef = ref<InstanceType<typeof ExportTaskDrawer> | null>(null)
const buildParams = () => ({
...search,
@@ -67,15 +68,7 @@ export const usePage = () => {
}
const openInfoDrawer = (record: ListItem): void => {
infoDrawerRef.value?.openDrawer({ id: record.id, title: record.modelName })
}
const openReturnModal = (record: ListItem): void => {
returnGoodsRef.value?.openModal({
id: record.id,
modelName: record.modelName,
storageLocation: record.storageLocation,
})
infoDrawerRef.value?.openDrawer({ id: record.id, title: record.categoryName })
}
const exportRecord = (): void => {
@@ -88,9 +81,12 @@ export const usePage = () => {
})
}
const viewExportTask = (): void => {
exportTaskRef.value?.openDrawer()
}
const handleAction = (type: string, record?: ListItem): void => {
if (type === 'info' && record) openInfoDrawer(record)
else if (type === 'return' && record) openReturnModal(record)
}
const loadData = (): void => {
@@ -109,12 +105,13 @@ export const usePage = () => {
selectedRowKeys,
rowSelection,
infoDrawerRef,
returnGoodsRef,
exportTaskRef,
dataSourceChange,
searchQuery,
resetQuery,
handleAction,
listRequest,
exportRecord,
viewExportTask,
}
}
@@ -2,6 +2,7 @@ import type { SearchForm } from '../types'
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
deviceType: undefined,
stockStatus: undefined,
categoryId: undefined,
warehouseAreaId: undefined,
healthLevel: undefined,
})
@@ -1,34 +1,29 @@
import type { TableColumnsType } from 'ant-design-vue'
import { h } from 'vue'
import { Tag } from 'ant-design-vue'
import type { ListItem, StockStatus } from '../types'
import { STOCK_STATUS_COLOR, STOCK_STATUS_OPTIONS } from '../types'
import type { ListItem, StockHealthLevel } from '../types'
import { STOCK_HEALTH_COLOR, STOCK_HEALTH_OPTIONS } from '../types'
const statusText = (status: string): string =>
STOCK_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—'
const healthText = (level: string): string =>
STOCK_HEALTH_OPTIONS.find((item) => item.value === level)?.label ?? '—'
export const tableColumns: TableColumnsType<ListItem> = [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '型号编码', dataIndex: 'modelCode', align: 'left', width: 150, fixed: 'left', resizable: true, ellipsis: true },
{ title: '二级型号', dataIndex: 'modelName', align: 'left', width: 150, resizable: true, ellipsis: true },
{ title: '设备类型', dataIndex: 'typeName', align: 'center', width: 110, resizable: true, ellipsis: true },
{ title: '批次号', dataIndex: 'batchNo', align: 'left', width: 130, resizable: true, ellipsis: true },
{ title: '供应商', dataIndex: 'supplierName', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '采购价(元)', dataIndex: 'purchasePrice', align: 'right', width: 110, sorter: true },
{ title: '入库时间', dataIndex: 'inboundTime', align: 'center', width: 170, sorter: true, resizable: true },
{ title: '仓储位置', dataIndex: 'storageLocation', align: 'left', width: 130, resizable: true, ellipsis: true },
{ title: '设备类别', dataIndex: 'categoryName', align: 'left', width: 160, fixed: 'left', resizable: true, ellipsis: true },
{ title: '批次号', dataIndex: 'batchNo', align: 'left', width: 140, resizable: true, ellipsis: true },
{ title: '供应商', dataIndex: 'supplierName', align: 'left', width: 170, resizable: true, ellipsis: true },
{ title: '仓库区域', dataIndex: 'warehouseAreaName', align: 'left', width: 170, resizable: true, ellipsis: true },
{ title: '库存数量', dataIndex: 'stockQty', align: 'center', width: 100, sorter: true },
{ title: '在途数量', dataIndex: 'inTransitQty', align: 'center', width: 100 },
{ title: '已出库数量', dataIndex: 'shippedQty', align: 'center', width: 110 },
{ title: '锁定数量', dataIndex: 'lockedQty', align: 'center', width: 100 },
{ title: '可用数量', dataIndex: 'availableQty', align: 'center', width: 100, sorter: true },
{
title: '库存状态',
dataIndex: 'stockStatus',
title: '库存健康度',
dataIndex: 'healthLevel',
align: 'center',
width: 100,
customRender: ({ value }: { value: StockStatus }) =>
h(Tag, { color: STOCK_STATUS_COLOR[value] ?? 'default' }, () => statusText(value)),
width: 110,
customRender: ({ value }: { value: StockHealthLevel }) =>
h(Tag, { color: STOCK_HEALTH_COLOR[value] ?? 'default' }, () => healthText(value)),
},
{ title: '更新时间', dataIndex: 'updateTime', align: 'center', width: 170, resizable: true },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 },
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 120 },
]
@@ -1,26 +1,23 @@
<template>
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="设备类">
<a-select
v-model:value="search.deviceType"
:options="DEVICE_TYPE_OPTIONS"
allow-clear
placeholder="全部类型"
style="width: 160px"
/>
<a-form-item label="设备类">
<DeviceCategoryPicker v-model="search.categoryId" placeholder="全部类别" style="width: 200px" />
</a-form-item>
<a-form-item label="库存状态">
<a-form-item label="仓库区域">
<WarehouseAreaPicker v-model="search.warehouseAreaId" placeholder="全部区域" style="width: 200px" />
</a-form-item>
<a-form-item label="库存健康度">
<a-select
v-model:value="search.stockStatus"
:options="STOCK_STATUS_OPTIONS"
v-model:value="search.healthLevel"
:options="STOCK_HEALTH_OPTIONS"
allow-clear
placeholder="全部状态"
placeholder="全部健康度"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="关键字">
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="型号编码 / 名称 / 批次号" style="width: 240px" />
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="批次号 / 供应商" style="width: 220px" />
</a-form-item>
</FilterBar>
@@ -30,26 +27,30 @@
<template #icon><DownloadOutlined /></template>
导出
</a-button>
<a-button @click="viewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('info', record)">查看详情</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('return', record)">回库登记</a-button>
</template>
</template>
</TableCard>
<InfoDrawer ref="infoDrawerRef" />
<ReturnGoodsModal ref="returnGoodsRef" @load="listRequest" />
<ExportTaskDrawer ref="exportTaskRef" module-key="stock-instock" />
</div>
</template>
<script setup lang="ts">
import { DownloadOutlined } from '@ant-design/icons-vue'
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import DeviceCategoryPicker from '@components/DeviceCategoryPicker.vue'
import WarehouseAreaPicker from '@components/WarehouseAreaPicker.vue'
import ExportTaskDrawer from '@components/ExportTaskDrawer.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import ReturnGoodsModal from './component/modal/returnGoods/returnGoods.vue'
import { DEVICE_TYPE_OPTIONS, STOCK_STATUS_OPTIONS } from './types'
import { STOCK_HEALTH_OPTIONS } from './types'
import { usePage } from './init/usePage'
const {
@@ -57,13 +58,14 @@ const {
search,
table,
infoDrawerRef,
returnGoodsRef,
exportTaskRef,
dataSourceChange,
searchQuery,
resetQuery,
handleAction,
listRequest,
exportRecord,
viewExportTask,
} = usePage()
</script>
@@ -1,83 +1,56 @@
import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types'
/** 库存状态:在库 / 在途 / 已出库 / 已回库 */
export type StockStatus = 'instock' | 'inbound' | 'shipped' | 'returned'
/** 库存健康度:充足 / 断货预警 / 暂无库存(由后端按可用数量与阈值规则计算) */
export type StockHealthLevel = 'sufficient' | 'warning' | 'out_of_stock'
export interface SearchForm extends BaseSearchForm {
/** 批次号 / 供应商 关键字 */
keyword?: string
deviceType?: string
stockStatus?: string
/** 设备类别(末级分类 ID) */
categoryId?: string
/** 仓库区域(末级节点 ID) */
warehouseAreaId?: string
/** 库存健康度 */
healthLevel?: string
}
export interface ListItem extends BaseRecord {
/** 型号编码 */
modelCode: string
/** 二级型号 */
modelName: string
/** 设备类型 */
deviceType: string
/** 设备类型中文名 */
typeName: string
/** 设备类别 ID(末级分类) */
categoryId: string
/** 设备类别名称(末级分类名,接口回填) */
categoryName: string
/** 批次号 */
batchNo: string
/** 供应商 */
/** 供应商 ID */
supplierId: string
/** 供应商名称 */
supplierName: string
/** 采购价(单价 */
purchasePrice: number
/** 入库时间 */
inboundTime: string
/** 仓储位置 */
storageLocation: string
/** 库存状态 */
stockStatus: StockStatus
/** 库存数量 */
/** 仓库区域 ID(末级节点 */
warehouseAreaId?: string
/** 仓库区域名称(接口回填) */
warehouseAreaName?: string
/** 库存数量(在库台账数量) */
stockQty: number
/** 在途数量 */
inTransitQty: number
/** 可用数量 */
/** 锁定数量(待出库单已占未发) */
lockedQty: number
/** 可用数量(=库存数量 - 锁定数量,由后端计算) */
availableQty: number
/** 已出库数量 */
shippedQty: number
/** 库存健康度 */
healthLevel: StockHealthLevel
/** 最近更新时间 */
updateTime: string
}
export interface ListParams extends BaseListParams, SearchForm {}
/** 回库登记 payload */
export interface ReturnPayload {
id: string
/** 回库数量 */
returnQty: number
/** 入库后仓储位置 */
storageLocation: string
/** 备注 */
remark?: string
}
export const DEVICE_TYPE_OPTIONS = [
{ value: 'screen', label: '大屏' },
{ value: 'cabinet', label: '餐盘柜' },
{ value: 'shelf', label: '货架' },
{ value: 'scale-inbound', label: '入库秤' },
{ value: 'scale-ratio', label: '配比秤' },
{ value: 'scale-weight', label: '体重秤' },
{ value: 'scale-fat', label: '体脂秤' },
{ value: 'watch', label: '手表' },
{ value: 'fitness', label: '健身器材' },
{ value: 'checkup', label: '体检机' },
export const STOCK_HEALTH_OPTIONS = [
{ value: 'sufficient', label: '充足' },
{ value: 'warning', label: '断货预警' },
{ value: 'out_of_stock', label: '暂无库存' },
]
export const STOCK_STATUS_OPTIONS = [
{ value: 'instock', label: '在库' },
{ value: 'inbound', label: '在途' },
{ value: 'shipped', label: '已出库' },
{ value: 'returned', label: '已回库' },
]
export const STOCK_STATUS_COLOR: Record<StockStatus, string> = {
instock: 'success',
inbound: 'processing',
shipped: 'default',
returned: 'warning',
export const STOCK_HEALTH_COLOR: Record<StockHealthLevel, string> = {
sufficient: 'success',
warning: 'warning',
out_of_stock: 'default',
}
@@ -10,7 +10,7 @@ export const list = (params: ListParams): Promise<ApiResponse<ListItem[]>> =>
export const info = (id: string): Promise<ApiResponse<ListItem>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/info', { id })
/** 新增盘点单(选仓库 + 类型,系统自动从在库库存生成应盘清单) */
/** 新增盘点单(选仓库区域 + 类型,系统自动从在库库存生成应盘清单) */
export const add = (params: InventoryPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/add', params)
@@ -21,7 +21,3 @@ export const audit = (id: string): Promise<ApiResponse<null>> =>
/** 删除盘点单(仅草稿状态可删) */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/delete', { id })
/** 查询仓库区域下拉数据(用于新增弹框选仓库) */
export const warehouseOptions = (): Promise<ApiResponse<Array<{ id: string; name: string }>>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/warehouse-options', {})
@@ -16,7 +16,7 @@
<a-descriptions-item label="状态">
<a-tag :color="INVENTORY_STATUS_COLOR[detail.status]">{{ statusLabel(detail.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="盘点仓库">{{ detail.warehouseName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="仓库区域" :span="2">{{ detail.warehouseAreaName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="盘点类型">
<a-tag :color="INVENTORY_TYPE_COLOR[detail.inventoryType]">{{ typeLabel(detail.inventoryType) }}</a-tag>
</a-descriptions-item>
@@ -30,10 +30,10 @@
<!-- 差异汇总卡 -->
<a-row :gutter="12" style="margin-top: 16px">
<a-col :span="6">
<a-statistic title="应盘数量" :value="detail.expectedCount" />
<a-statistic title="应盘条目" :value="detail.expectedCount" />
</a-col>
<a-col :span="6">
<a-statistic title="实盘数量" :value="detail.actualCount" />
<a-statistic title="实盘条目" :value="detail.actualCount" />
</a-col>
<a-col :span="6">
<a-statistic title="盘盈" :value="detail.surplusCount" :value-style="{ color: '#fa8c16' }" />
@@ -49,7 +49,7 @@
:columns="itemColumns"
:data-source="detail.items ?? []"
:pagination="false"
row-key="deviceCode"
row-key="categoryId"
size="small"
:scroll="{ y: 360 }"
>
@@ -73,7 +73,7 @@
<script setup lang="ts">
/**
* 库存盘点 - 详情抽屉
* - 展示盘点单基本信息 + 差异汇总 + 应盘清单(带实盘结果 tag)
* - 展示盘点单基本信息 + 差异汇总 + 应盘清单(按"末级分类 + 批次"聚合,带实盘结果 tag
* - 仅查看,无操作(审核动作在主页操作列)
*/
import {
@@ -23,23 +23,22 @@ export const usePage = () => {
/** 当前详情数据 */
const detail = ref<ListItem | null>(null)
/** 应盘清单表格列(在 .vue 中通过 computed 引入避免循环依赖 */
/** 应盘清单表格列(按"末级分类 + 批次"聚合 */
const itemColumns = [
{ title: '序号', dataIndex: 'index', align: 'center', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 150, ellipsis: true },
{ title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 140, ellipsis: true },
{ title: '二级型号', dataIndex: 'modelName', align: 'left', width: 130, ellipsis: true },
{ title: '批次号', dataIndex: 'batchNo', align: 'left', width: 130, ellipsis: true },
{ title: '应盘', dataIndex: 'expectedQuantity', align: 'center', width: 80 },
{ title: '实盘', dataIndex: 'actualQuantity', align: 'center', width: 80 },
{ title: '设备类别', dataIndex: 'categoryName', align: 'left', width: 180, ellipsis: true },
{ title: '批次号', dataIndex: 'batchNo', align: 'left', width: 150, ellipsis: true },
{ title: '应盘', dataIndex: 'expectedQuantity', align: 'center', width: 90 },
{ title: '实盘', dataIndex: 'actualQuantity', align: 'center', width: 90 },
{ title: '差异', dataIndex: 'diff', align: 'center', width: 90, customRender: ({ record }: { record: { actualQuantity: number; expectedQuantity: number } }) => record.actualQuantity - record.expectedQuantity },
{ title: '结果', dataIndex: 'result', align: 'center', width: 100 },
]
const openDrawer = (id: string): void => {
const openDrawer = (ctx: OpenContext): void => {
pageInfo.visible = true
pageInfo.spin = true
detail.value = null
info(id)
info(ctx.id)
.then((res) => {
if (res.code === '00000' && res.data) {
detail.value = res.data
@@ -13,24 +13,41 @@
:rules="rules"
:label-col="{ style: { width: '100px', minWidth: '100px' } }"
>
<a-form-item label="盘点仓库" name="warehouseId">
<a-select
v-model:value="form.warehouseId"
:options="warehouseOptionList"
placeholder="请选择仓库"
show-search
:filter-option="filterOption"
@change="onWarehouseChange"
<a-form-item label="仓库区域" name="warehouseAreaId">
<WarehouseAreaPicker
v-model="form.warehouseAreaId"
placeholder="请选择仓库区域(任意层级,含其下所有子区域)"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="盘点类型" name="inventoryType">
<a-radio-group v-model:value="form.inventoryType">
<a-radio value="full">全量盘点应盘该仓库所有在库设备</a-radio>
<a-radio value="partial">部分盘点仅应盘指定型号</a-radio>
<a-radio value="full">全量盘点应盘该区域所有在库品类</a-radio>
<a-radio value="partial">部分盘点仅应盘指定品类</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
v-if="form.inventoryType === 'partial'"
label="应盘品类"
name="categoryIds"
required
>
<a-tree-select
v-model:value="form.categoryIds"
:tree-data="treeData"
:field-names="{ label: 'name', value: 'id', children: 'children' }"
multiple
show-search
allow-clear
tree-default-expand-all
:filter-tree-node="filterTreeNode"
placeholder="请选择应盘品类(可多选,支持大类或末级分类)"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
@@ -44,7 +61,9 @@
<a-alert
type="info"
show-icon
message="提交后系统按仓库当前在库台账生成应盘清单,盘点单进入草稿状态;小程序扫码完成实盘后变已完成;后台审核后修正库存台账。"
:message="form.inventoryType === 'partial'
? '提交后系统按所选品类 + 该区域当前在库台账生成应盘清单(按『设备类别末级分类 + 批次』聚合),盘点单进入草稿状态;小程序扫码完成实盘后变已完成;后台审核后修正库存台账。'
: '提交后系统按该区域当前在库台账生成应盘清单(按『设备类别末级分类 + 批次』聚合),盘点单进入草稿状态;小程序扫码完成实盘后变已完成;后台审核后修正库存台账。'"
style="margin-top: 8px"
/>
</a-form>
@@ -65,10 +84,10 @@
<script setup lang="ts">
/**
* 库存盘点 - 新增盘点单弹框
* - 字段:仓库 / 类型(全量 | 部分)/ 备注
* - 提交后由系统按在库库存生成应盘清单
* - 字段:仓库区域(末级)/ 类型(全量 | 部分)/ 备注
* - 提交后由系统按在库库存生成应盘清单(按"末级分类 + 批次"聚合)
*/
import { filterOption } from '@utils/antDesign/select'
import WarehouseAreaPicker from '@components/WarehouseAreaPicker.vue'
import { usePage } from './init/usePage'
const emit = defineEmits<{
@@ -80,10 +99,10 @@ const {
formRef,
form,
rules,
warehouseOptionList,
treeData,
filterTreeNode,
openModal,
closeModal,
onWarehouseChange,
submit,
} = usePage(emit)
@@ -1,21 +1,12 @@
/**
* 库存盘点 - 新增弹框 - 接口层
* - 复用主页 page 接口
* - 新增 add / 仓库下拉 warehouseOptions 接口
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { InventoryPayload } from '../../../types'
import type { Form, WarehouseOption } from '../types'
/** 新增盘点单 */
export const add = (params: InventoryPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/add', params)
/** 查询仓库下拉数据 */
export const warehouseOptions = (): Promise<ApiResponse<WarehouseOption[]>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/warehouse-options', {})
/** 仅用于类型导入兼容(Form 在 usePage 中用作 reactive 类型) */
export type { Form }
@@ -3,21 +3,31 @@
*
* 严格遵循 modal-spec.md
* - createInitForm 工厂函数
* - openModal 拉仓库下拉
* - submit 开头立即 pageInfo.spin = true
* - 仓库区域用 WarehouseAreaPickerleaf-only)公共组件,无需单独拉下拉
* - 部分盘点(partial)时显示应盘品类多选,数据源 /admin/device/category/tree
*/
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { ref } from 'vue'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { add, warehouseOptions } from '../api'
import type { Form, PageInfo, WarehouseOption } from '../types'
import { postRequest } from '@axios'
import type { ApiResponse } from '@axios'
import { add } from '../api'
import type { Form, PageInfo } from '../types'
import type { InventoryPayload } from '../../../types'
interface CategoryNode {
id: string
name: string
children?: CategoryNode[]
}
/** 初始表单工厂 */
const createInitForm = (): Form => ({
warehouseId: undefined,
warehouseName: '',
warehouseAreaId: undefined,
inventoryType: 'full',
categoryIds: [],
remark: '',
})
@@ -34,14 +44,30 @@ export const usePage = (emit: (e: 'load') => void) => {
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
/** 仓库下拉选项 */
const warehouseOptionList = ref<WarehouseOption[]>([])
/** 设备分类树数据(懒加载,openModal 时按需拉取一次) */
const treeData = ref<CategoryNode[]>([])
const rules: Record<string, Rule[]> = {
warehouseId: [{ required: true, message: '请选择盘点仓库', trigger: 'change' }],
warehouseAreaId: [{ required: true, message: '请选择盘点仓库区域', trigger: 'change' }],
inventoryType: [{ required: true, message: '请选择盘点类型', trigger: 'change' }],
}
/** 树节点过滤函数(a-tree-select :filter-tree-node */
const filterTreeNode = (input: string, node: { name?: string }): boolean =>
(node.name ?? '').toLowerCase().includes(input.toLowerCase())
/** 加载设备分类树(已加载则跳过) */
const loadCategoryTree = (): void => {
if (treeData.value.length) return
postRequest('axiosRequest', '/admin/device/category/tree', { keyword: '' })
.then((res: ApiResponse<CategoryNode[]>) => {
if (res.code === '00000') treeData.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('设备分类树加载失败:', err)
})
}
const resetForm = (): void => {
formRef.value?.clearValidate()
form.value = createInitForm()
@@ -52,30 +78,12 @@ export const usePage = (emit: (e: 'load') => void) => {
pageInfo.visible = false
}
/** 拉取仓库下拉(仅首次打开拉一次) */
const loadWarehouseOptions = (): void => {
if (warehouseOptionList.value.length > 0) return
warehouseOptions()
.then((res) => {
if (res.code === '00000') warehouseOptionList.value = res.data ?? []
})
.catch((err: unknown) => {
console.error('仓库下拉请求失败:', err)
})
}
/** 打开弹框 */
const openModal = (): void => {
pageInfo.visible = true
pageInfo.spin = false
resetForm()
loadWarehouseOptions()
}
/** 选中仓库时同步名称(提交时附带) */
const onWarehouseChange = (value: string): void => {
const matched = warehouseOptionList.value.find((item) => item.id === value)
form.value.warehouseName = matched?.name ?? ''
loadCategoryTree()
}
const successRequest = (msg: string): void => {
@@ -102,12 +110,20 @@ export const usePage = (emit: (e: 'load') => void) => {
formRef.value
?.validate()
.then(() => {
// 部分盘点必须选择至少一个应盘品类
if (form.value.inventoryType === 'partial' && !form.value.categoryIds.length) {
pageInfo.spin = false
void message.warning('请选择至少一个应盘品类')
return
}
const params: InventoryPayload = {
warehouseId: form.value.warehouseId as string,
warehouseName: form.value.warehouseName,
warehouseAreaId: form.value.warehouseAreaId as string,
inventoryType: form.value.inventoryType,
remark: form.value.remark || undefined,
}
if (form.value.inventoryType === 'partial') {
params.categoryIds = form.value.categoryIds
}
addRequest(params)
})
.catch((err: unknown) => {
@@ -121,10 +137,10 @@ export const usePage = (emit: (e: 'load') => void) => {
formRef,
form,
rules,
warehouseOptionList,
treeData,
filterTreeNode,
openModal,
closeModal,
onWarehouseChange,
submit,
}
}
@@ -1,4 +1,3 @@
import type { FormInstance } from 'ant-design-vue/es/form'
import type { InventoryType } from '../../../types'
/** 弹框页面状态 */
@@ -11,24 +10,12 @@ export interface PageInfo {
/** 表单字段 */
export interface Form {
/** 仓库 ID */
warehouseId: string | undefined
/** 仓库名称(提交时附带,便于后端记录) */
warehouseName: string
/** 仓库区域 ID(末级节点) */
warehouseAreaId: string | undefined
/** 盘点类型 */
inventoryType: InventoryType
/** 应盘品类 ID 列表(仅 inventoryType='partial' 时使用) */
categoryIds: string[]
/** 备注 */
remark: string
}
/** 仓库下拉选项 */
export interface WarehouseOption {
id: string
name: string
}
/** 暴露给父组件的 ref 类型 */
export type FormRef = ReturnType<typeof usePageRef>
/** 仅用于类型推导的占位函数 */
declare function usePageRef(): FormInstance | undefined
@@ -75,7 +75,7 @@ export const usePage = () => {
/** 查看详情 */
const openInfoDrawer = (record: ListItem): void => {
infoDrawerRef.value?.openDrawer(record.id)
infoDrawerRef.value?.openDrawer({ id: record.id })
}
/** 审核盘点单(仅 completed 状态可审核) */
@@ -2,7 +2,7 @@ import type { SearchForm } from '../types'
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
warehouseId: undefined,
warehouseAreaId: undefined,
status: undefined,
inventoryType: undefined,
})
@@ -18,7 +18,7 @@ const typeText = (type: string): string =>
export const tableColumns: TableColumnsType<ListItem> = [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '盘点单号', dataIndex: 'inventoryCode', align: 'left', width: 170, fixed: 'left', resizable: true, ellipsis: true },
{ title: '盘点仓库', dataIndex: 'warehouseName', align: 'left', width: 160, resizable: true, ellipsis: true },
{ title: '仓库区域', dataIndex: 'warehouseAreaName', align: 'left', width: 180, resizable: true, ellipsis: true },
{
title: '盘点类型',
dataIndex: 'inventoryType',
@@ -1,8 +1,8 @@
<template>
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="盘点单号" style="width: 200px" />
<a-form-item label="仓库区域">
<WarehouseAreaPicker v-model="search.warehouseAreaId" placeholder="全部区域" style="width: 200px" />
</a-form-item>
<a-form-item label="盘点类型">
<a-select
@@ -22,6 +22,9 @@
style="width: 140px"
/>
</a-form-item>
<a-form-item label="关键字">
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="盘点单号" style="width: 200px" />
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
@@ -34,14 +37,14 @@
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('info', record)">查看详情</a-button>
<a-divider type="vertical" />
<a-divider v-if="record.status === 'completed'" type="vertical" />
<a-button
v-if="record.status === 'completed'"
type="link"
size="small"
@click="handleAction('audit', record)"
>审核</a-button>
<a-divider v-if="record.status === 'completed'" type="vertical" />
<a-divider v-if="record.status === 'draft'" type="vertical" />
<a-button
v-if="record.status === 'draft'"
type="link"
@@ -60,6 +63,7 @@
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import WarehouseAreaPicker from '@components/WarehouseAreaPicker.vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { INVENTORY_STATUS_OPTIONS, INVENTORY_TYPE_OPTIONS } from './types'
@@ -6,45 +6,43 @@ export type InventoryStatus = 'draft' | 'completed' | 'audited'
/** 盘点类型:full 全量盘点 / partial 部分盘点 */
export type InventoryType = 'full' | 'partial'
/** 应盘设备条目的实盘结果 */
/** 应盘条目的实盘结果(按"设备类别末级分类 + 批次"为粒度) */
export type InventoryItemResult = 'matched' | 'surplus' | 'loss' | 'unchecked'
export interface SearchForm extends BaseSearchForm {
/** 盘点单号关键字 */
keyword?: string
/** 仓库 ID */
warehouseId?: string
/** 仓库区域(末级节点 ID */
warehouseAreaId?: string
/** 盘点状态 */
status?: string
/** 盘点类型 */
inventoryType?: string
}
/** 应盘清单条目(详情抽屉展示 */
/** 应盘清单条目(按"设备类别末级分类 + 批次"聚合,不再按单台设备 */
export interface InventoryItem {
/** 设备编码 */
deviceCode: string
/** 设备名称 */
deviceName: string
/** 二级型号 */
modelName: string
/** 批次号 */
/** 设备类别 ID(末级分类) */
categoryId: string
/** 设备类别名称(末级分类名) */
categoryName: string
/** 批次号(同一类别多批次时按批次维度展开) */
batchNo: string
/** 应盘数量(固定 1,每台设备一条) */
/** 应盘数量 */
expectedQuantity: number
/** 实盘数量(0=盘亏,1=一致,>1=盘盈) */
/** 实盘数量 */
actualQuantity: number
/** 实盘结果(前端按 actualQuantity 计算,后端只返回数量) */
/** 实盘结果(前端按 expected/actual 计算,后端只返回数量) */
result: InventoryItemResult
}
export interface ListItem extends BaseRecord {
/** 盘点单号 */
inventoryCode: string
/** 仓库 ID */
warehouseId: string
/** 仓库名称 */
warehouseName: string
/** 仓库区域 ID(末级节点) */
warehouseAreaId: string
/** 仓库区域名称(接口回填) */
warehouseAreaName: string
/** 盘点类型 */
inventoryType: InventoryType
/** 盘点人 */
@@ -53,13 +51,13 @@ export interface ListItem extends BaseRecord {
inventoryTime: string
/** 状态 */
status: InventoryStatus
/** 应盘数量 */
/** 应盘数量(条目数) */
expectedCount: number
/** 实盘数量 */
/** 实盘数量(条目数) */
actualCount: number
/** 盘盈数 */
/** 盘盈数(条目数) */
surplusCount: number
/** 盘亏数 */
/** 盘亏数(条目数) */
lossCount: number
/** 审核人(audited 状态有) */
auditor?: string
@@ -75,9 +73,13 @@ export interface ListParams extends BaseListParams, SearchForm {}
/** 新增盘点单提交载荷 */
export interface InventoryPayload {
warehouseId: string
warehouseName: string
/** 仓库区域 ID(末级节点) */
warehouseAreaId: string
/** 盘点类型 */
inventoryType: InventoryType
/** 应盘品类 ID 列表(仅 inventoryType='partial' 时必填,full 时忽略) */
categoryIds?: string[]
/** 备注 */
remark?: string
}

Some files were not shown because too many files have changed in this diff Show More