diff --git a/src/axios/handlers/device-center/customer/archive.ts b/src/axios/handlers/device-center/customer/archive.ts new file mode 100644 index 0000000..e9c5cce --- /dev/null +++ b/src/axios/handlers/device-center/customer/archive.ts @@ -0,0 +1,155 @@ +import { registerMocks } from '@axios/mockBus' +import type { ApiResponse, MockContext, RequestParameter } from '@axios' + +/** + * 客户档案 mock 数据 + * - 设备中心本地实体,与运营中心 tenantList 解耦 + * - appCount / siteCount 由后端按关联表聚合返回,前端只读展示 + * - status 控制启停,停用客户不参与新设备绑定 + */ + +type CustomerType = 'enterprise' | 'gov' | 'edu' | 'medical' | 'other' + +interface ArchiveRecord { + id: string + code: string + name: string + type: CustomerType + contactName: string + contactPhone: string + email?: string + address?: string + expireTime: string + appCount: number + siteCount: number + status: boolean + remark?: string + createTime: string +} + +interface ArchiveParams { + pageNum?: number + pageSize?: number + column?: string + order?: 'ascend' | 'descend' | null + keyword?: string + type?: CustomerType + status?: boolean + [key: string]: unknown +} + +const getParams = (params: RequestParameter): ArchiveParams => (params ?? {}) as ArchiveParams + +const includes = (value: unknown, keyword: unknown): boolean => { + if (!keyword) return true + return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase()) +} + +const paginate = (list: T[], params: ArchiveParams): ApiResponse => { + 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 = (msg: string, data: T = null as T): ApiResponse => ({ 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' }, +] + +/** 关键词搜索:编码 / 名称 / 联系人 */ +const matchKeyword = (item: ArchiveRecord, keyword?: string): boolean => { + if (!keyword) return true + return includes(item.code, keyword) || includes(item.name, keyword) || includes(item.contactName, keyword) +} + +const listArchive = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const filtered = archiveList.filter((item) => { + if (!matchKeyword(item, params.keyword)) return false + if (params.type && item.type !== params.type) return false + if (typeof params.status === 'boolean' && 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 infoArchive = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const target = archiveList.find((item) => item.id === id) + return ok('查询成功', target ?? null) +} + +const addArchive = (ctx: MockContext): ApiResponse => { + 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: ArchiveRecord = { + id: `cust-${Date.now().toString(36)}`, + code: String(params.code ?? ''), + name: String(params.name ?? ''), + type: (params.type as CustomerType) ?? 'other', + contactName: String(params.contactName ?? ''), + contactPhone: String(params.contactPhone ?? ''), + email: params.email ? String(params.email) : undefined, + address: params.address ? String(params.address) : undefined, + expireTime: String(params.expireTime ?? ''), + appCount: 0, + siteCount: 0, + status: typeof params.status === 'boolean' ? params.status : true, + remark: params.remark ? String(params.remark) : undefined, + createTime, + } + archiveList.unshift(record) + return ok('新增成功') +} + +const updateArchive = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const target = archiveList.find((item) => item.id === id) + if (!target) return ok('客户不存在', null) + if (params.code !== undefined) target.code = String(params.code) + if (params.name !== undefined) target.name = String(params.name) + if (params.type !== undefined) target.type = params.type as CustomerType + if (params.contactName !== undefined) target.contactName = String(params.contactName) + if (params.contactPhone !== undefined) target.contactPhone = String(params.contactPhone) + target.email = params.email !== undefined ? (params.email ? String(params.email) : undefined) : target.email + target.address = params.address !== undefined ? (params.address ? String(params.address) : undefined) : target.address + if (params.expireTime !== undefined) target.expireTime = String(params.expireTime) + if (typeof params.status === 'boolean') target.status = params.status + target.remark = params.remark !== undefined ? (params.remark ? String(params.remark) : undefined) : target.remark + return ok('更新成功') +} + +const deleteArchive = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const idx = archiveList.findIndex((item) => item.id === id) + if (idx >= 0) archiveList.splice(idx, 1) + return ok('删除成功') +} + +registerMocks([ + { method: 'POST', url: '/admin/device/customer/archive/page', handler: listArchive }, + { method: 'POST', url: '/admin/device/customer/archive/info', handler: infoArchive }, + { method: 'POST', url: '/admin/device/customer/archive/add', handler: addArchive }, + { method: 'POST', url: '/admin/device/customer/archive/update', handler: updateArchive }, + { method: 'POST', url: '/admin/device/customer/archive/delete', handler: deleteArchive }, +]) diff --git a/src/axios/handlers/device-center/tenantSite/site.ts b/src/axios/handlers/device-center/customer/site.ts similarity index 100% rename from src/axios/handlers/device-center/tenantSite/site.ts rename to src/axios/handlers/device-center/customer/site.ts diff --git a/src/axios/handlers/device-center/asset/supplier.ts b/src/axios/handlers/device-center/customer/supplier.ts similarity index 92% rename from src/axios/handlers/device-center/asset/supplier.ts rename to src/axios/handlers/device-center/customer/supplier.ts index 3c2d125..c41fc05 100644 --- a/src/axios/handlers/device-center/asset/supplier.ts +++ b/src/axios/handlers/device-center/customer/supplier.ts @@ -115,9 +115,9 @@ const updateRecord = (params: PageParams): ApiResponse => { } registerMocks([ - { method: 'POST', url: '/admin/device/stock/supplier/page', handler: listPage }, - { method: 'POST', url: '/admin/device/stock/supplier/info', handler: (ctx) => infoById(getParams(ctx.params)) }, - { method: 'POST', url: '/admin/device/stock/supplier/add', handler: (ctx) => addRecord(getParams(ctx.params)) }, - { method: 'POST', url: '/admin/device/stock/supplier/update', handler: (ctx) => updateRecord(getParams(ctx.params)) }, - { method: 'POST', url: '/admin/device/stock/supplier/delete', handler: (ctx) => deleteById(getParams(ctx.params)) }, + { 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)) }, ]) diff --git a/src/axios/handlers/device-center/stock/inventory.ts b/src/axios/handlers/device-center/stock/inventory.ts new file mode 100644 index 0000000..4f98a1d --- /dev/null +++ b/src/axios/handlers/device-center/stock/inventory.ts @@ -0,0 +1,239 @@ +import { registerMocks } from '@axios/mockBus' +import type { ApiResponse, MockContext, RequestParameter } from '@axios' + +interface PageParams { + pageNum?: number + pageSize?: number + column?: string + order?: 'ascend' | 'descend' | null + keyword?: string + status?: string + inventoryType?: string + warehouseId?: string + [key: string]: unknown +} + +interface InventoryItem { + deviceCode: string + deviceName: string + modelName: string + batchNo: string + expectedQuantity: number + actualQuantity: number + result: 'matched' | 'surplus' | 'loss' | 'unchecked' +} + +interface InventoryRecord { + id: string + inventoryCode: string + warehouseId: string + warehouseName: string + inventoryType: 'full' | 'partial' + operator: string + inventoryTime: string + status: 'draft' | 'completed' | 'audited' + expectedCount: number + actualCount: number + surplusCount: number + lossCount: number + auditor?: string + auditTime?: string + remark?: string + items?: InventoryItem[] +} + +const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams +const includes = (value: unknown, keyword: unknown): boolean => { + if (!keyword) return true + return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase()) +} +const ok = (msg: string, data: T = null as T): ApiResponse => ({ code: '00000', msg, data }) +const paginate = (list: T[], params: PageParams): ApiResponse => { + 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 } +} + +/** 生成应盘清单(mock:固定模板,每条记录复用) */ +const buildItems = (surplus: number, loss: number, unchecked: number, total: number): InventoryItem[] => { + const items: InventoryItem[] = [] + for (let i = 0; i < total; i++) { + let actual = 1 + let result: InventoryItem['result'] = 'matched' + if (i < surplus) { + actual = 2 + result = 'surplus' + } else if (i < surplus + loss) { + actual = 0 + 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], + batchNo: `B2026-${String((i % 8) + 1).padStart(3, '0')}`, + expectedQuantity: 1, + actualQuantity: actual, + result, + }) + } + return items +} + +const inventoryList: InventoryRecord[] = [ + { + id: 'inv-001', inventoryCode: 'PD-20260601-001', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库', + 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), + }, + { + id: 'inv-002', inventoryCode: 'PD-20260610-002', warehouseId: 'wh-floor2', warehouseName: '二楼备品仓库', + 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), + }, + { + id: 'inv-003', inventoryCode: 'PD-20260620-003', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库', + 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), + }, + { + id: 'inv-004', inventoryCode: 'PD-20260625-004', warehouseId: 'wh-floor1-large', warehouseName: '大型设备区', + 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: '待检区', + 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: '小型设备区', + 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号货架', + 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: '二楼备品仓库', + 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), + }, +] + +const listPage = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + 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.keyword && !includes(item.inventoryCode, params.keyword)) return false + return true + }) + if (params.column === 'inventoryTime' && params.order) { + filtered.sort((a, b) => { + const cmp = a.inventoryTime.localeCompare(b.inventoryTime) + return params.order === 'ascend' ? cmp : -cmp + }) + } + return paginate(filtered, params) +} + +/** 详情查询(含应盘清单 items) */ +const infoById = (params: PageParams): ApiResponse => { + const id = String(params.id ?? '') + const record = inventoryList.find((item) => item.id === id) + if (!record) return ok('盘点单不存在') + /** 返回深拷贝避免 mock 数据被外部修改 */ + return ok('查询成功', JSON.parse(JSON.stringify(record))) +} + +/** 仓库下拉(mock 简化:固定 6 个常用仓库) */ +const warehouseOptions = (): ApiResponse> => { + 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() + const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}` + const seq = String(inventoryList.length + 1).padStart(3, '0') + return `PD-${ymd}-${seq}` +} + +/** 新增(系统按仓库当前在库台账生成应盘清单,mock 用 8 条固定模板) */ +const addRecord = (params: PageParams): ApiResponse => { + const expectedCount = 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', + operator: '当前用户', + inventoryTime: new Date().toISOString().replace('T', ' ').slice(0, 19), + status: 'draft', + expectedCount, + actualCount: 0, + surplusCount: 0, + lossCount: 0, + remark: params.remark ? String(params.remark) : undefined, + items: buildItems(0, 0, expectedCount, expectedCount), + } + inventoryList.unshift(record) + return ok('新增成功,已按仓库在库台账生成应盘清单') +} + +/** 审核(已审核 → 库存台账以实盘数据为准) */ +const auditRecord = (params: PageParams): ApiResponse => { + const id = String(params.id ?? '') + const record = inventoryList.find((item) => item.id === id) + if (!record) return ok('盘点单不存在') + if (record.status !== 'completed') return ok('仅已完成状态的盘点单可审核') + record.status = 'audited' + record.auditor = '当前用户' + record.auditTime = new Date().toISOString().replace('T', ' ').slice(0, 19) + return ok('审核完成,库存台账已按实盘数据修正') +} + +/** 删除(仅 draft 可删) */ +const deleteById = (params: PageParams): ApiResponse => { + const id = String(params.id ?? '') + const idx = inventoryList.findIndex((item) => item.id === id) + if (idx >= 0) { + if (inventoryList[idx].status !== 'draft') return ok('仅草稿状态可删除') + inventoryList.splice(idx, 1) + } + return ok('删除成功') +} + +registerMocks([ + { method: 'POST', url: '/admin/device/stock/inventory/page', handler: listPage }, + { method: 'POST', url: '/admin/device/stock/inventory/info', handler: (ctx) => infoById(getParams(ctx.params)) }, + { 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() }, +]) diff --git a/src/axios/handlers/device-center/stock/warehouse.ts b/src/axios/handlers/device-center/stock/warehouse.ts new file mode 100644 index 0000000..d073c13 --- /dev/null +++ b/src/axios/handlers/device-center/stock/warehouse.ts @@ -0,0 +1,272 @@ +import { registerMocks } from '@axios/mockBus' +import type { ApiResponse, MockContext } from '@axios' + +interface WarehouseNode { + id: string + name: string + /** 编码:系统按 W01 / W01A01 / W01A01A01 规则自动生成 */ + code: string + /** 层级:1=顶级仓库 */ + level: number + parentId?: string + /** 父级名称(仅 info 回填时注入) */ + parentName?: string + sortOrder: number + /** 末级状态:仅叶子节点(无 children)有效 */ + status?: 'idle' | 'occupied' | 'maintenance' + remark?: string + children?: WarehouseNode[] + createTime?: string +} + +interface WarehousePayload { + id?: string + name: string + /** code 由系统自动生成,新增时不传 */ + code?: string + level: number + parentId?: string + parentName?: string + sortOrder: number + status?: 'idle' | 'occupied' | 'maintenance' + remark?: string +} + +const ok = (msg: string, data: T = null as T): ApiResponse => ({ + code: '00000', + msg, + data, +}) + +const now = (): string => new Date().toISOString().replace('T', ' ').slice(0, 19) + +/** 顶级仓库预置 ID 前缀(与分类管理不同,仓库可任意新增/删除) */ +const TOP = { + floor1: 'wh-floor1', + floor2: 'wh-floor2', +} as const + +/** + * 仓库区域树 mock 数据 + * + * 编码规则: + * - 顶级:W01、W02 … + * - 二级:W01A01、W01A02 …(父编码 + A + 同级序号) + * - 三级及更深:W01A01A01 … + * + * 字段规则: + * - 任意节点通用:name / code / level / parentId / sortOrder / remark + * - 仅末级节点(无 children):携带 status + * - 入库时区域必须选末级 + */ +const warehouseTree: WarehouseNode[] = [ + { + id: TOP.floor1, name: '一楼设备仓库', code: 'W01', level: 1, sortOrder: 1, + remark: '主仓库,存放日常设备', createTime: '2026-05-10 09:20:00', + children: [ + { + id: 'wh-floor1-large', name: '大型设备区', code: 'W01A01', level: 2, parentId: TOP.floor1, sortOrder: 1, + createTime: '2026-05-11 10:00:00', + children: [ + { + 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-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-small', name: '小型设备区', code: 'W01A02', level: 2, parentId: TOP.floor1, sortOrder: 2, + createTime: '2026-05-11 11:00:00', + children: [ + { + 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: TOP.floor2, name: '二楼备品仓库', code: 'W02', level: 1, sortOrder: 2, + remark: '备品与待检品仓库', createTime: '2026-05-10 09:25:00', + children: [ + { + 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' }, + ], + }, + ], + }, +] + +/** 按关键字过滤树(保留命中节点的祖先链) */ +const filterTree = (nodes: WarehouseNode[], keyword: string): WarehouseNode[] => { + const lower = keyword.toLowerCase() + const walk = (list: WarehouseNode[]): WarehouseNode[] => { + const result: WarehouseNode[] = [] + list.forEach((node) => { + const matched = + node.name.toLowerCase().includes(lower) || + node.code.toLowerCase().includes(lower) + const children = node.children ? walk(node.children) : [] + if (matched || children.length > 0) { + result.push({ ...node, children: children.length > 0 ? children : undefined }) + } + }) + return result + } + return walk(nodes) +} + +/** 深拷贝树(避免 mock 数据被修改污染) */ +const cloneTree = (nodes: WarehouseNode[]): WarehouseNode[] => + nodes.map((node) => ({ ...node, children: node.children ? cloneTree(node.children) : undefined })) + +/** 递归查找并移除节点 */ +const removeNode = (nodes: WarehouseNode[], id: string): boolean => { + const idx = nodes.findIndex((n) => n.id === id) + if (idx >= 0) { + nodes.splice(idx, 1) + return true + } + for (const node of nodes) { + if (node.children && removeNode(node.children, id)) return true + } + return false +} + +/** 递归查找节点 */ +const findNode = (nodes: WarehouseNode[], id: string): WarehouseNode | null => { + for (const node of nodes) { + if (node.id === id) return node + if (node.children) { + const found = findNode(node.children, id) + if (found) return found + } + } + 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)}` + +/** + * 按规则生成节点编码 + * - 顶级:W + 两位序号(按现有顶级数 +1) + * - 子级:parent.code + 'A' + 两位序号(按父级下现有子节点数 +1) + */ +const genCode = (tree: WarehouseNode[], parentId?: string): string => { + const pad = (n: number): string => n.toString().padStart(2, '0') + if (!parentId) { + return `W${pad(tree.length + 1)}` + } + const parent = findNode(tree, parentId) + if (!parent) return `W${pad(tree.length + 1)}A01` + const siblingCount = parent.children?.length ?? 0 + return `${parent.code}A${pad(siblingCount + 1)}` +} + +const handleTree = (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as { keyword?: string } + const data = cloneTree(warehouseTree) + const result = params.keyword ? filterTree(data, params.keyword) : data + return ok('查询成功', result) +} + +const handleAdd = (ctx: MockContext): ApiResponse => { + const payload = (ctx.params ?? {}) as WarehousePayload + if (!payload.name) { + return ok('名称不能为空') + } + /** code 由系统按 W01A01 规则生成 */ + const newCode = genCode(warehouseTree, payload.parentId) + const newNode: WarehouseNode = { + id: genId('wh'), + name: payload.name, + code: newCode, + level: payload.level, + parentId: payload.parentId, + sortOrder: payload.sortOrder, + remark: payload.remark, + createTime: now(), + } + /** 末级节点(新建必然无子)携带状态,默认空闲 */ + newNode.status = payload.status ?? 'idle' + + if (payload.parentId) { + const parent = findNode(warehouseTree, payload.parentId) + if (parent) { + parent.children = parent.children ?? [] + parent.children.push(newNode) + } + } else { + warehouseTree.push(newNode) + } + return ok(`新增成功,编码:${newCode}`) +} + +const handleUpdate = (ctx: MockContext): ApiResponse => { + const payload = (ctx.params ?? {}) as WarehousePayload + if (!payload.id) return ok('ID 不能为空') + const node = findNode(warehouseTree, payload.id) + if (!node) return ok('节点不存在') + node.name = payload.name + node.sortOrder = payload.sortOrder + node.remark = payload.remark + /** 末级节点才更新状态 */ + if (isLeafNode(node)) { + node.status = payload.status ?? 'idle' + } + return ok('更新成功') +} + +const handleInfo = (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as { id?: string } + if (!params.id) return ok('ID 不能为空') + const node = findNode(warehouseTree, params.id) + if (!node) return ok('节点不存在') + /** 附带父级名称便于弹框回填展示 */ + const result: WarehouseNode = { ...node } + if (node.parentId) { + const parent = findNode(warehouseTree, node.parentId) + result.parentName = parent?.name + } + return ok('查询成功', result) +} + +const handleDelete = (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as { id?: string } + if (!params.id) return ok('ID 不能为空') + /** 仓库区域任意节点均可删除(含顶级仓库),子节点级联删除 */ + const removed = removeNode(warehouseTree, params.id) + return removed ? ok('删除成功') : ok('节点不存在') +} + +registerMocks([ + { method: 'POST', url: '/admin/device/warehouse/tree', handler: handleTree }, + { method: 'POST', url: '/admin/device/warehouse/info', handler: handleInfo }, + { method: 'POST', url: '/admin/device/warehouse/add', handler: handleAdd }, + { method: 'POST', url: '/admin/device/warehouse/update', handler: handleUpdate }, + { method: 'POST', url: '/admin/device/warehouse/delete', handler: handleDelete }, +]) diff --git a/src/axios/handlers/device-center/third-auth/amap.ts b/src/axios/handlers/device-center/third-auth/amap.ts new file mode 100644 index 0000000..89b1bde --- /dev/null +++ b/src/axios/handlers/device-center/third-auth/amap.ts @@ -0,0 +1,135 @@ +import { registerMocks } from '@axios/mockBus' +import type { ApiResponse, MockContext, RequestParameter } from '@axios' + +/** + * 高德地图应用 mock 数据 + * - 1 条记录 = 1 个高德开放平台应用(含 API Key + 安全密钥) + * - key 不区分类型(Web服务 / JS API / 小程序等共用同一条记录) + * - status 为启用/停用开关,停用后前端调用将拒绝加载地图 + */ + +interface AmapRecord { + id: string + name: string + apiKey: string + securityCode?: string + usage?: string + status: boolean + remark?: string + createTime: string +} + +interface AmapParams { + pageNum?: number + pageSize?: number + column?: string + order?: 'ascend' | 'descend' | null + keyword?: string + status?: boolean + [key: string]: unknown +} + +const getParams = (params: RequestParameter): AmapParams => (params ?? {}) as AmapParams + +const includes = (value: unknown, keyword: unknown): boolean => { + if (!keyword) return true + return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase()) +} + +const paginate = (list: T[], params: AmapParams): ApiResponse => { + 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 = (msg: string, data: T = null as T): ApiResponse => ({ 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' }, +] + +/** 关键词搜索:应用名称 / 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) +} + +const listAmap = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const filtered = amapList.filter((item) => { + if (!matchKeyword(item, params.keyword)) return false + if (typeof params.status === 'boolean' && 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 infoAmap = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const target = amapList.find((item) => item.id === id) + return ok('查询成功', target ?? null) +} + +const addAmap = (ctx: MockContext): ApiResponse => { + 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: AmapRecord = { + id: `amap-${Date.now().toString(36)}`, + name: String(params.name ?? ''), + apiKey: String(params.apiKey ?? ''), + securityCode: params.securityCode ? String(params.securityCode) : undefined, + usage: params.usage ? String(params.usage) : undefined, + status: typeof params.status === 'boolean' ? params.status : true, + remark: params.remark ? String(params.remark) : undefined, + createTime, + } + amapList.unshift(record) + return ok('新增成功') +} + +const updateAmap = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const target = amapList.find((item) => item.id === id) + if (!target) return ok('记录不存在', 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.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 + return ok('更新成功') +} + +const deleteAmap = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const idx = amapList.findIndex((item) => item.id === id) + if (idx >= 0) amapList.splice(idx, 1) + return ok('删除成功') +} + +registerMocks([ + { method: 'POST', url: '/admin/device/third-auth/amap/page', handler: listAmap }, + { method: 'POST', url: '/admin/device/third-auth/amap/info', handler: infoAmap }, + { method: 'POST', url: '/admin/device/third-auth/amap/add', handler: addAmap }, + { method: 'POST', url: '/admin/device/third-auth/amap/update', handler: updateAmap }, + { method: 'POST', url: '/admin/device/third-auth/amap/delete', handler: deleteAmap }, +]) diff --git a/src/axios/handlers/device-center/third-auth/arcsoft.ts b/src/axios/handlers/device-center/third-auth/arcsoft.ts new file mode 100644 index 0000000..6ce0901 --- /dev/null +++ b/src/axios/handlers/device-center/third-auth/arcsoft.ts @@ -0,0 +1,152 @@ +import { registerMocks } from '@axios/mockBus' +import type { ApiResponse, MockContext, RequestParameter } from '@axios' + +/** + * 虹软人脸授权 mock 数据 + * - 1 条记录 = 1 台设备的激活码(每台设备硬件绑定一个 activeKey) + * - appId / sdkKey / sdkSecret 为虹软开发者平台应用凭证(同租户共享,但激活后不可换绑设备) + * - 状态:未激活 inactive / 已激活 active / 已过期 expired / 已停用 disabled + */ + +type ArcStatus = 'inactive' | 'active' | 'expired' | 'disabled' + +interface ArcRecord { + id: string + activeKey: string + appId: string + sdkKey: string + sdkSecret: string + deviceSn: string + deviceName?: string + deviceModel?: string + activateTime: string + expireTime: string + status: ArcStatus + remark?: string + createTime: string +} + +interface ArcParams { + pageNum?: number + pageSize?: number + column?: string + order?: 'ascend' | 'descend' | null + keyword?: string + status?: ArcStatus + [key: string]: unknown +} + +const getParams = (params: RequestParameter): ArcParams => (params ?? {}) as ArcParams + +const includes = (value: unknown, keyword: unknown): boolean => { + if (!keyword) return true + return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase()) +} + +const paginate = (list: T[], params: ArcParams): ApiResponse => { + 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 = (msg: string, data: T = null as T): ApiResponse => ({ 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' }, +] + +/** 关键词搜索:激活码 / 设备 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 => { + const params = getParams(ctx.params) + const filtered = arcList.filter((item) => { + if (!matchKeyword(item, params.keyword)) 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 infoArc = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const target = arcList.find((item) => item.id === id) + return ok('查询成功', target ?? null) +} + +const addArc = (ctx: MockContext): ApiResponse => { + 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: ArcRecord = { + id: `arc-${Date.now().toString(36)}`, + activeKey: String(params.activeKey ?? ''), + appId: String(params.appId ?? ''), + sdkKey: String(params.sdkKey ?? ''), + sdkSecret: String(params.sdkSecret ?? ''), + deviceSn: String(params.deviceSn ?? ''), + deviceName: params.deviceName ? String(params.deviceName) : undefined, + deviceModel: params.deviceModel ? String(params.deviceModel) : undefined, + activateTime: String(params.activateTime ?? ''), + expireTime: String(params.expireTime ?? ''), + status: (params.status as ArcStatus) ?? 'inactive', + remark: params.remark ? String(params.remark) : undefined, + createTime, + } + arcList.unshift(record) + return ok('新增成功') +} + +const updateArc = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const target = arcList.find((item) => item.id === id) + if (!target) return ok('记录不存在', null) + if (params.activeKey !== undefined) target.activeKey = String(params.activeKey) + if (params.appId !== undefined) target.appId = String(params.appId) + if (params.sdkKey !== undefined) target.sdkKey = String(params.sdkKey) + if (params.sdkSecret !== undefined) target.sdkSecret = String(params.sdkSecret) + 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.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 + target.remark = params.remark !== undefined ? (params.remark ? String(params.remark) : undefined) : target.remark + return ok('更新成功') +} + +const deleteArc = (ctx: MockContext): ApiResponse => { + const params = getParams(ctx.params) + const id = String(params.id ?? '') + const idx = arcList.findIndex((item) => item.id === id) + if (idx >= 0) arcList.splice(idx, 1) + return ok('删除成功') +} + +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 }, +]) diff --git a/src/axios/handlers/index.ts b/src/axios/handlers/index.ts index 0042043..d6bbc39 100644 --- a/src/axios/handlers/index.ts +++ b/src/axios/handlers/index.ts @@ -42,8 +42,12 @@ import './device-center/asset/inbound' import './device-center/asset/outbound' // 设备中心 - 库存管理 / 在库库存(只读) import './device-center/asset/instock' -// 设备中心 - 库存管理 / 供应商管理 -import './device-center/asset/supplier' +// 设备中心 - 客户管理 / 供应商管理(从库存管理迁入) +import './device-center/customer/supplier' +// 设备中心 - 库存管理 / 仓库区域(树形 CRUD,末级状态由入库出库联动) +import './device-center/stock/warehouse' +// 设备中心 - 库存管理 / 库存盘点(应盘清单 + 实盘结果 + 审核修正台账) +import './device-center/stock/inventory' // 设备中心 - 设备运维 / 工单管理(含流转) import './device-center/asset/workOrder' // 设备中心 - 设备运维 / 设备初始化(出库装机前激活 / 烧录证书 / 固件基线) @@ -56,16 +60,24 @@ import './device-center/ops/version' import './device-center/ops/alarm' // 设备中心 - 设备运维 / 系统配置(5 分组 + 变更历史) import './device-center/ops/config' -// 设备中心 - 租户与场所 / 布点场所(CRUD,含心跳状态展示) -import './device-center/tenantSite/site' +// 设备中心 - 客户管理 / 客户档案(CRUD + 详情抽屉) +import './device-center/customer/archive' +// 设备中心 - 客户管理 / 布点场所(CRUD,含心跳状态展示) +import './device-center/customer/site' // 设备中心 - 数据分发 / 分发配置(下游系统 + 订阅映射,双 Tab CRUD) import './device-center/distribute/config' // 设备中心 - 数据分发 / 分发记录(含重投、批量重投、导出) import './device-center/distribute/record' // 设备中心 - 数据分发 / 失败重投(死信队列,含手动重投、永久丢弃) import './device-center/distribute/deadletter' +// 设备中心 - 第三方授权 / 虹软人脸授权(按设备绑定激活码) +import './device-center/third-auth/arcsoft' +// 设备中心 - 第三方授权 / 高德地图应用(API Key + 安全密钥) +import './device-center/third-auth/amap' // 实施运维小程序 - 工作台 + 告警(今日待办 / 本周统计 / 告警列表 + 详情 + 处置) import './miniprogram/ops/workbench' +// 实施运维小程序 - 库存作业(收货入库 / 装机出库 / 库存盘点 / 库存查询) +import './miniprogram/ops/stock' // 实施运维小程序 - 工单(列表 3 段 + 详情 + 接单/关单/维修上报) import './miniprogram/ops/workOrder' // 实施运维小程序 - 扫码(识别 / 历史 / 初始化 / 实施记录 / 维修上报 / OTA / 设备监测 5 Tab) diff --git a/src/axios/handlers/miniprogram/ops/stock.ts b/src/axios/handlers/miniprogram/ops/stock.ts new file mode 100644 index 0000000..ad09ff6 --- /dev/null +++ b/src/axios/handlers/miniprogram/ops/stock.ts @@ -0,0 +1,271 @@ +/** + * 实施运维小程序 / 库存作业 mock + * - 收货入库:仓库下拉 + 设备详情 + 提交 + * - 装机出库:客户下拉 + 提交 + * - 库存盘点:盘点单详情 + 提交(带 items[].result) + * - 库存查询:在库设备列表(只读) + * + * 数据风格对齐后台设备中心 / 库存管理 4 个页面,但字段精简适配手机端。 + */ + +import { registerMocks } from '@axios/mockBus' +import type { ApiResponse, RequestParameter } from '@axios' + +interface WarehouseOption { id: string; name: string } +interface CustomerOption { id: string; name: string } + +interface DeviceDetail { + deviceCode: string + deviceName: string + modelName: string + batchNo: string + activated?: boolean +} + +interface StockItem { + id: string + deviceCode: string + deviceName: string + modelName: string + batchNo: string + warehouseId: string + warehouseName: string + quantity: number + unit: string + status: 'instock' | 'checkout' | 'frozen' + inboundTime: string +} + +interface InventoryItem { + deviceCode: string + deviceName: string + modelName: string + batchNo: string + expectedQuantity: number + actualQuantity: number + result: 'matched' | 'surplus' | 'loss' | 'unchecked' +} + +interface InventoryDetail { + id: string + inventoryCode: string + warehouseId: string + warehouseName: string + inventoryType: 'full' | 'partial' + inventoryTime: string + expectedCount: number + actualCount: number + surplusCount: number + lossCount: number + items: InventoryItem[] +} + +interface InboundSubmitBody { + warehouseId: string + inboundType: 'purchase' | 'return' | 'transfer' + remark?: string + deviceCodes: string[] +} + +interface OutboundSubmitBody { + warehouseId: string + outboundType: 'install' | 'repair' | 'scrap' | 'transfer' + customerId?: string + siteName?: string + remark?: string + deviceCodes: string[] +} + +interface InventorySubmitBody { + id: string + items: Array<{ deviceCode: string; actualQuantity: number; result: 'matched' | 'surplus' | 'loss' | 'unchecked' }> +} + +const ok = (msg: string, data: T = null as T): ApiResponse => ({ code: '00000', msg, data }) +const getParams = >(params: RequestParameter): T => (params ?? {}) as T + +const warehouses: WarehouseOption[] = [ + { 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号货架' }, +] + +const customers: CustomerOption[] = [ + { id: 'cus-001', name: 'CQ 能源集团' }, + { id: 'cus-002', name: '北京健康管理机构' }, + { id: 'cus-003', name: '上海张江食堂' }, + { id: 'cus-004', name: '杭州西湖社区中心' }, + { id: 'cus-005', name: '广州天河体检站' }, +] + +const devicePool: DeviceDetail[] = [ + { deviceCode: 'DEV-0001', deviceName: '1号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-001', activated: true }, + { deviceCode: 'DEV-0002', deviceName: '蓝牙秤 A', modelName: 'CS-蓝牙秤 v3', batchNo: 'B2026-002', activated: false }, + { deviceCode: 'DEV-0003', deviceName: '体脂仪 1', modelName: 'BF-体脂仪 X1', batchNo: 'B2026-003', activated: true }, + { deviceCode: 'DEV-0004', deviceName: '入库秤 2', modelName: 'IS-入库秤 K2', batchNo: 'B2026-004', activated: false }, + { deviceCode: 'DEV-0005', deviceName: '2号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-005', activated: true }, +] + +const stockList: StockItem[] = [ + { id: 'stk-001', deviceCode: 'DEV-0001', deviceName: '1号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-001', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库', quantity: 1, unit: '台', status: 'instock', inboundTime: '2026-06-01 10:15:00' }, + { id: 'stk-002', deviceCode: 'DEV-0002', deviceName: '蓝牙秤 A', modelName: 'CS-蓝牙秤 v3', batchNo: 'B2026-002', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库', quantity: 5, unit: '台', status: 'instock', inboundTime: '2026-06-05 11:00:00' }, + { id: 'stk-003', deviceCode: 'DEV-0003', deviceName: '体脂仪 1', modelName: 'BF-体脂仪 X1', batchNo: 'B2026-003', warehouseId: 'wh-floor2', warehouseName: '二楼备品仓库', quantity: 3, unit: '台', status: 'instock', inboundTime: '2026-06-10 09:30:00' }, + { id: 'stk-004', deviceCode: 'DEV-0004', deviceName: '入库秤 2', modelName: 'IS-入库秤 K2', batchNo: 'B2026-004', warehouseId: 'wh-floor1-large', warehouseName: '大型设备区', quantity: 2, unit: '台', status: 'frozen', inboundTime: '2026-06-12 14:00:00' }, + { id: 'stk-005', deviceCode: 'DEV-0005', deviceName: '2号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-005', warehouseId: 'wh-floor1-small', warehouseName: '小型设备区', quantity: 1, unit: '台', status: 'instock', inboundTime: '2026-06-15 16:30:00' }, + { id: 'stk-006', deviceCode: 'DEV-0006', deviceName: '体脂仪 2', modelName: 'BF-体脂仪 X1', batchNo: 'B2026-006', warehouseId: 'wh-floor2-check', warehouseName: '待检区', quantity: 1, unit: '台', status: 'instock', inboundTime: '2026-06-20 10:00:00' }, + { id: 'stk-007', deviceCode: 'DEV-0007', deviceName: '蓝牙秤 B', modelName: 'CS-蓝牙秤 v3', batchNo: 'B2026-007', warehouseId: 'wh-floor1-large-a', warehouseName: 'A号货架', quantity: 1, unit: '台', status: 'checkout', inboundTime: '2026-06-25 09:00:00' }, + { id: 'stk-008', deviceCode: 'DEV-0008', deviceName: '3号净菜柜', modelName: '智养净菜柜 V2', batchNo: 'B2026-008', warehouseId: 'wh-floor1', warehouseName: '一楼设备仓库', quantity: 1, unit: '台', status: 'instock', inboundTime: '2026-06-28 13:45:00' }, +] + +const buildInventoryItems = (total: number, unchecked: number): InventoryItem[] => { + const items: InventoryItem[] = [] + for (let i = 0; i < total; i++) { + items.push({ + deviceCode: `DEV-${String(i + 1).padStart(4, '0')}`, + deviceName: `设备${i + 1}`, + modelName: ['智养净菜柜', '蓝牙秤', '体脂仪', '入库秤'][i % 4], + batchNo: `B2026-${String((i % 8) + 1).padStart(3, '0')}`, + expectedQuantity: 1, + actualQuantity: i < total - unchecked ? 1 : 0, + result: i < total - unchecked ? 'matched' : 'unchecked', + }) + } + return items +} + +const inventoryRecords: InventoryDetail[] = [ + { + id: 'inv-006', inventoryCode: 'PD-20260701-006', + warehouseId: 'wh-floor1-small', warehouseName: '小型设备区', + inventoryType: 'partial', inventoryTime: '2026-07-01 14:20:00', + expectedCount: 10, actualCount: 0, surplusCount: 0, lossCount: 0, + items: buildInventoryItems(10, 10), + }, + { + id: 'inv-007', inventoryCode: 'PD-20260703-007', + warehouseId: 'wh-floor1-large-a', warehouseName: 'A号货架', + inventoryType: 'full', inventoryTime: '2026-07-03 10:00:00', + expectedCount: 8, actualCount: 3, surplusCount: 0, lossCount: 0, + items: buildInventoryItems(8, 5), + }, + { + id: 'inv-008', inventoryCode: 'PD-20260705-008', + warehouseId: 'wh-floor2', warehouseName: '二楼备品仓库', + inventoryType: 'full', inventoryTime: '2026-07-05 16:30:00', + expectedCount: 5, actualCount: 0, surplusCount: 0, lossCount: 0, + items: buildInventoryItems(5, 5), + }, +] + +registerMocks([ + // 仓库下拉 + { + url: '/mp/ops/stock/warehouse-options', + method: 'POST', + handler: (): ApiResponse => ok('查询成功', warehouses), + }, + // 客户下拉 + { + url: '/mp/ops/stock/customer-options', + method: 'POST', + handler: (): ApiResponse => ok('查询成功', customers), + }, + // 设备详情(收货入库 / 装机出库扫码时查询) + { + url: '/mp/ops/stock/device-detail', + method: 'POST', + handler: (ctx): ApiResponse => { + const { deviceCode } = getParams<{ deviceCode?: string }>(ctx.params) + const found = devicePool.find((d) => d.deviceCode === deviceCode) + if (!found) { + return ok('设备不存在,请检查编码或先在后台录入台账', { + deviceCode: deviceCode ?? '', + deviceName: '未知设备', + modelName: '—', + batchNo: '—', + }) + } + return ok('查询成功', found) + }, + }, + // 收货入库提交 + { + url: '/mp/ops/stock/inbound/submit', + method: 'POST', + handler: (ctx): ApiResponse => { + const body = getParams(ctx.params) + if (!body.warehouseId) return ok('请选择入库仓库', null) + if (!body.deviceCodes?.length) return ok('请扫码添加设备', null) + return ok(`入库成功,新增 ${body.deviceCodes.length} 台到台账`, null) + }, + }, + // 装机出库提交 + { + url: '/mp/ops/stock/outbound/submit', + method: 'POST', + handler: (ctx): ApiResponse => { + const body = getParams(ctx.params) + if (!body.warehouseId) return ok('请选择出库仓库', null) + if (!body.deviceCodes?.length) return ok('请扫码添加设备', null) + if (body.outboundType === 'install' && !body.customerId) return ok('装机出库请选择客户', null) + return ok(`出库成功,扣减 ${body.deviceCodes.length} 台库存`, null) + }, + }, + // 盘点单详情 + { + url: '/mp/ops/stock/inventory/detail', + method: 'POST', + handler: (ctx): ApiResponse => { + const { id } = getParams<{ id?: string }>(ctx.params) + const found = inventoryRecords.find((r) => r.id === id) + if (!found) return ok('盘点单不存在', null) + return ok('查询成功', JSON.parse(JSON.stringify(found))) + }, + }, + // 盘点单提交(更新实际结果 + 状态变 completed) + { + url: '/mp/ops/stock/inventory/submit', + method: 'POST', + handler: (ctx): ApiResponse => { + const body = getParams(ctx.params) + const found = inventoryRecords.find((r) => r.id === body.id) + if (!found) return ok('盘点单不存在', null) + body.items.forEach((submit) => { + const item = found.items.find((x) => x.deviceCode === submit.deviceCode) + if (item) { + item.result = submit.result + item.actualQuantity = submit.actualQuantity + } + }) + found.actualCount = body.items.filter((x) => x.result === 'matched' || x.result === 'surplus').length + found.surplusCount = body.items.filter((x) => x.result === 'surplus').length + found.lossCount = body.items.filter((x) => x.result === 'loss').length + return ok('盘点已提交,等待后台审核修正台账', null) + }, + }, + // 库存查询列表 + { + url: '/mp/ops/stock/list', + method: 'POST', + handler: (ctx): ApiResponse => { + const params = getParams<{ warehouseId?: string; status?: string; keyword?: string }>(ctx.params) + const filtered = stockList.filter((x) => { + if (params.warehouseId && x.warehouseId !== params.warehouseId) return false + if (params.status && x.status !== params.status) return false + if (params.keyword) { + const kw = params.keyword.toLowerCase() + const hit = + x.deviceCode.toLowerCase().includes(kw) || + x.deviceName.toLowerCase().includes(kw) || + x.batchNo.toLowerCase().includes(kw) + if (!hit) return false + } + return true + }) + return ok('查询成功', filtered) + }, + }, +]) diff --git a/src/docs/device/设备管理服务器功能现状.md b/src/docs/device/设备管理服务器功能现状.md index a89ff3f..92a503c 100644 --- a/src/docs/device/设备管理服务器功能现状.md +++ b/src/docs/device/设备管理服务器功能现状.md @@ -26,18 +26,18 @@ - 体重管理类设备(体重秤、体脂秤、手表、健身器材、体检一体机)的业务数据**过本平台** - 是否过平台由"数据分发开关"控制(详见第五章) -### 1.2 租户与场所两层关系 +### 1.2 客户与场所两层关系 -本系统的下游订阅关系采用**租户 + 布点场所**两层结构(菜单层面命名为"租户与场所"): +本系统的下游订阅关系采用**客户 + 布点场所**两层结构(菜单层面命名为"客户管理"): -- **租户(顶层)**:签约使用平台的机构(如 XX 餐饮集团、XX 健康管理机构),即综合管理后台运营中心已存在的"租户"概念 -- **布点场所(下属)**:租户下属的具体经营点,与租户为 N:1 关系 - - 餐饮类租户 → 食堂、餐厅 - - 健康管理类租户 → 体检站、体重测量点 -- 一个租户可挂多个布点场所;设备挂在布点场所下 +- **客户(顶层)**:签约使用平台的机构(如 XX 餐饮集团、XX 健康管理机构),即综合管理后台运营中心已存在的"租户"概念——设备中心改称为"客户",与运营中心"租户"等价 +- **布点场所(下属)**:客户下属的具体经营点,与客户为 N:1 关系 + - 餐饮类客户 → 食堂、餐厅 + - 健康管理类客户 → 体检站、体重测量点 +- 一个客户可挂多个布点场所;设备挂在布点场所下 - 每个布点场所档案必须含一个 **自定义标识** 字段(运营自定义,用于数据分发寻址、同名场所区分、下游业务系统附加编码等场景) -> 设计意图:不把"食堂"作为独立概念硬编码,而是用通用的"布点场所"承载各类场所类型,便于未来扩展(如健身房、社区站等)。"租户管理"复用运营中心已有的租户列表(只读),不在设备中心重复维护租户档案。 +> 设计意图:不把"食堂"作为独立概念硬编码,而是用通用的"布点场所"承载各类场所类型,便于未来扩展(如健身房、社区站等)。"客户档案"复用运营中心已有的租户列表(只读),不在设备中心重复维护客户档案。 ### 1.3 历史遗留问题 @@ -68,15 +68,17 @@ │ ├─ 设备管理 ← 顶部 5 Tab(按顶级分类切换),每类设备字段差异承载 │ └─ 设备统计 ← 多维度统计报表(按分类 / 场所 / 租户 / 在线状态 / 数据分发) │ -├─ 3. 库存管理 ← 原"设备库存"更名,独立二级菜单 +├─ 3. 库存管理 ← 原"设备库存"更名,独立二级菜单(5 个子菜单) +│ ├─ 仓库区域 ← 仓库档案(多仓库场景:中央仓 / 现场仓 / 备品仓) │ ├─ 在库库存 ← 原"库存明细"更名,当前在库台账 │ ├─ 入库管理 ← 采购到货登记 / 入库单 │ ├─ 出库管理 ← 出库登记 + 出库单融合(不再单列"出库单"菜单) -│ └─ 供应商管理 ← 供应商档案 +│ └─ 库存盘点 ← 盘点单管理(生成 / 执行 / 审核),自动比对盘盈盘亏 │ -├─ 4. 租户与场所 ← 改名(原"客户与场所") -│ ├─ 租户管理 ← 只读复用运营中心 tenantList,无新增/编辑/删除等操作按钮 -│ └─ 布点场所 ← N:1 租户;档案含"自定义标识"字段 +├─ 4. 客户管理 ← 原"租户与场所"更名;客户档案 + 布点场所 + 供应商档案 +│ ├─ 客户档案 ← 只读复用运营中心 tenantList,无新增/编辑/删除等操作按钮 +│ ├─ 布点场所 ← N:1 客户;档案含"自定义标识"字段 +│ └─ 供应商管理 ← 供应商档案(从库存管理迁入) │ ├─ 5. 设备运维 ← 主动运维 + 被动维修 + 系统配置合一,承接原"系统设置"域 │ ├─ 设备初始化 ← 出库装机前的激活 / 预配置 / 入网 @@ -94,11 +96,11 @@ > **菜单调整说明(2026-07-02 修订)**: > 1. **拍平 4 级嵌套**:原"设备资产 → 设备库存 → 5 个子菜单""设备资产 → 故障与维修 → 2 个子菜单""数据中转 → 下游分发 → 3 个子菜单""系统设置 → 设备版本管理 → 2 个子菜单"均为 4 级,本期全部拍平到 3 级。 -> 2. **设备库存更名 + 菜单精简**:原"设备库存"更名为"库存管理",从 5 个子菜单精简为 4 个——"出库单"与"出库管理"融合(出库单作为出库管理内的视图/详情,不再单列菜单);"库存明细"更名为"在库库存"以突出"在库状态"视角。 +> 2. **设备库存更名 + 菜单重组**:原"设备库存"更名为"库存管理",5 个二级菜单——"出库单"与"出库管理"融合(出库单作为出库管理内的视图/详情,不再单列菜单);"库存明细"更名为"在库库存"以突出"在库状态"视角;新增"仓库区域"承载多仓库档案;新增"库存盘点"承载盘点单管理;供应商管理迁出到客户管理域(详见第 6 条)。 > 3. **设备运维扩域 + 承接系统设置**:原"系统设置"一级域取消,其 4 个二级菜单全部迁入"设备运维"域——版本管理直接迁入;更新记录迁入"设备监测"详情页 Tab;系统配置直接迁入;业务日志并入"告警管理"作为日志视图。运维域共 6 个二级菜单(设备初始化 / 设备监测 / 工单管理 / 版本管理 / 告警管理 / 系统配置)。 > 4. **设备监测聚合多视角**:原"实施记录 / 自检记录 / 巡检记录 / 运行日志 / 维修记录"5 个独立二级菜单收敛为"设备监测"1 个二级菜单——主列表页统一展示设备运行状态总览,详情页以 Tab 形式聚合 5 个视角 + 更新记录共 6 个 Tab。 > 5. **数据中转更名 + 简化为接口回调**:原"数据中转"更名为"数据分发",本期仅支持"接口回调"模式(平台收到设备上报数据后按规则 HTTP 回调下游)。原 6 个子菜单(采集设备/采集监控/订阅关系/投递记录/死信重投/数据归档)精简为 3 个——"下游系统 + 订阅关系"融合为"分发配置";"投递记录"更名为"分发记录";"死信重投"更名为"失败重投";"采集设备/采集监控"并入设备资产/设备监测(不再重复建表);"数据归档"后置 P1。 -> 6. **租户与场所更名**:原"客户与场所"改为"租户与场所","客户管理"改为"租户管理"且只读复用运营中心 tenantList。 +> 6. **客户管理扩域 + 更名**:原"客户与场所"先改为"租户与场所",本期再次改为"客户管理"(与运营中心"租户"概念区分);"租户管理"改为"客户档案"且只读复用运营中心 tenantList;供应商管理从库存管理迁入(属于"商业合作伙伴"档案而非"仓储资产")。 > 7. **设备资产瘦身**:仅保留"设备分类 / 设备管理 / 设备统计"3 个二级菜单;原"设备配置 / 大屏内容管理"**暂未归属,后续讨论**其归宿。 > 8. **设备租售本期不做**:原"设备租售"域本期不建设,相关章节标注"本期不做"保留设计思路供未来参考。 @@ -115,11 +117,13 @@ | 入库 | 库存管理 → 入库管理 | | 出库 + 出库单(融合) | 库存管理 → 出库管理(出库单作为内嵌视图) | | 库存明细 | 库存管理 → 在库库存 | -| 供应商 | 库存管理 → 供应商管理 | +| 仓库档案 | 库存管理 → 仓库区域 | +| 盘点单 | 库存管理 → 库存盘点 | +| 供应商 | 客户管理 → 供应商管理(从库存管理迁入) | | 大屏设备列表 | 暂未归属,后续讨论(原"大屏内容管理") | | 虹软 SDK 授权 | 暂未归属,后续讨论(原"设备配置") | -| 客户列表 | 租户与场所 → 租户管理(只读复用运营中心 tenantList) | -| 食堂列表 / 同步 | 租户与场所 → 布点场所 | +| 客户列表 | 客户管理 → 客户档案(只读复用运营中心 tenantList) | +| 食堂列表 / 同步 | 客户管理 → 布点场所 | | 小型 / 大型体重秤 | 设备资产 → 设备管理("体重测量设备" Tab);数据分发按分类订阅 | | InBody 体脂秤 | 设备资产 → 设备管理("体重测量设备" Tab);数据分发按分类订阅 | | 顿米健身器材 | 设备资产 → 设备管理("健身器材设备" Tab);数据分发按分类订阅 | @@ -144,8 +148,8 @@ 4. **设备管理按顶级分类 Tab 切换**:5 个顶级分类作为顶部 Tab 呈现,每类设备字段差异在各自 Tab 内承载,避免一张表硬塞所有字段 5. **数据分发标识上提到分类层**:是否 IOT 设备、是否启用数据分发,统一在"设备分类"层配置;设备管理不再为单台设备单独配置数据分发开关(详见第五章) 6. **数据分发独立成域**,与资产台账解耦;本期仅支持"接口回调"一种分发模式,下游注册回调 URL + 鉴权密钥,平台按"分发配置"规则 HTTP 回调 -7. **租户与场所分层**,场所类型可扩展(不绑定"食堂"概念);租户档案只读复用运营中心,不在设备中心重复维护 -8. **库存管理独立二级**:库存与资产台账关注角色不同(仓储 vs 资产管理),独立二级菜单后查询不互相污染;出库单与出库管理融合,避免重复 +7. **客户管理分层 + 扩域**:客户与布点场所两层结构,场所类型可扩展(不绑定"食堂"概念);客户档案只读复用运营中心,不在设备中心重复维护;本期供应商管理从库存管理迁入,与客户档案、布点场所共同构成"业务伙伴"层(供应商属于商业合作伙伴而非仓储资产) +8. **库存管理独立二级 + 扩域**:库存与资产台账关注角色不同(仓储 vs 资产管理),独立二级菜单后查询不互相污染;出库单与出库管理融合,避免重复;本期扩域加入"仓库区域"承载多仓库档案、加入"库存盘点"承载盘点单管理;供应商管理迁出到客户管理域(详见第 7 条) 9. **设备运维扩域承接系统设置**:运维域既管主动运维(监测/巡检/自检/运行日志)也管被动维修(工单/维修记录),同时承接原系统设置的版本/告警/系统配置,统一由运维班组负责,避免故障工单与系统设置在两个域间跳转 10. **设备监测多视角聚合**:原"实施记录 / 自检记录 / 巡检记录 / 运行日志 / 维修记录"5 个独立二级菜单收敛为"设备监测"1 个二级菜单——主列表页看设备运行状态总览,详情页 Tab 聚合多视角,避免同一台设备在 5 个菜单间来回跳转 11. **告警统一处置**:运行异常、自检异常、巡检异常、SLA 超时、OTA 失败等告警统一进"告警管理"处置;业务日志作为告警管理下的日志视图,避免业务日志单独建菜单 @@ -215,8 +219,8 @@ | 设备编码 | 全局唯一,设备入网时分配 | | 设备名称 | 自定义名称 | | 所属分类 | 二级型号(继承自当前 Tab) | -| 所属租户 | 关联租户与场所 → 租户管理 | -| 所属布点场所 | 关联租户与场所 → 布点场所 | +| 所属客户 | 关联客户管理 → 客户档案(即综合管理后台运营中心的"租户") | +| 所属布点场所 | 关联客户管理 → 布点场所 | | 运行状态 | 在用 / 离线 / 维修中 / 报废 | | 固件版本 | 当前固件版本号 | | 入网时间 | YYYY-MM-DD HH:mm:ss | @@ -259,7 +263,13 @@ ### 3.3 库存管理 -原"设备库存"更名,独立二级菜单。从原 5 个子菜单精简为 4 个:将"出库单"融合到"出库管理"内作为视图/详情,"库存明细"更名为"在库库存"以突出"在库状态"视角。 +原"设备库存"更名,独立二级菜单。本期 **5 个二级菜单**:仓库区域 / 在库库存 / 入库管理 / 出库管理 / 库存盘点。供应商管理迁出到客户管理域(详见 §3.4)。 + +**仓库区域**: +- 仓库档案(多仓库场景:中央仓 / 现场仓 / 备品仓) +- 字段:仓库名称、仓库编码、仓库类型(中央仓 / 现场仓 / 备品仓)、地址、负责人、容量上限、备注 +- 操作:新增 / 编辑 / 删除(删除前置校验:仓库下无在库设备)/ 查看详情 +- 与"在库库存"关系:仓库区域是父级档案,在库库存按仓库区域聚合展示 **在库库存**(原"库存明细"): - 当前在库台账(按型号 / 批次 / 供应商聚合) @@ -280,29 +290,39 @@ - 操作:新增出库单、查看详情(含设备清单)、撤回、导出 - 与"设备运维 → 设备初始化"衔接:出库后设备进入初始化阶段,激活完成后转为设备资产实例 -**供应商管理**: -- 供应商档案(名称、联系人、合作起止日期、供货品类、资质附件) -- 操作:新增 / 编辑 / 删除 / 查看详情 -- 便于采购溯源与品类筛选 +**库存盘点**: +- 盘点单管理(创建盘点任务 → 扫码盘点 → 自动比对 → 生成差异 → 审核) +- 字段:盘点单号、盘点仓库、盘点类型(全量盘点 / 部分盘点)、盘点人、盘点时间、状态(草稿 / 已完成 / 已审核)、应盘数量、实盘数量、盘盈数、盘亏数、差异说明 +- 操作:新增盘点单(选仓库 → 系统列出应盘清单 → 扫码录入实盘 → 自动比对)→ 查看 → 审核(审核后库存台账以实盘数据为准) +- 与"在库库存"关系:盘点是校验在库台账准确性的工具;审核完成的盘点单会触发库存台账修正(盘盈新增在库记录,盘亏标记库存状态异常并触发工单核查) +- 与"小程序库存作业"关系:盘点单在后台创建并审核;现场扫码盘点动作由小程序"库存盘点"场景承担(详见小程序端设计文档) > 与"设备资产 → 设备管理"的边界:设备管理管的是**已入网的单台设备实例**(含场所归属、运行状态),库存管理管的是**未入网或已回库的设备**(含批次、仓储位置、采购价)。出库动作会把在库库存的一条记录流转到设备初始化,初始化完成后转为设备资产实例。 -### 3.4 租户与场所 +### 3.4 客户管理 -**租户管理**: +> 原菜单名"租户与场所"已更名为"客户管理"。**"客户"= 平台下游订阅方**(与综合管理后台运营中心的"租户"概念等价,仅在设备中心改称为客户);菜单层用"客户档案 + 布点场所 + 供应商档案"三层并列,承载所有"业务伙伴"档案。 + +**客户档案**: - **只读复用**综合管理后台运营中心的 `operation-center/tenantManagement/tenantList` 页面 -- 列表展示租户基本信息(名称、联系人、地址、应用授权、状态) -- **不在设备中心重复维护租户档案**;新增/编辑/删除/授权应用/重置密码等操作按钮**全部隐藏** -- 设备中心只读消费租户数据,用于设备归属、布点场所关联 +- 列表展示客户基本信息(名称、联系人、地址、应用授权、状态) +- **不在设备中心重复维护客户档案**;新增/编辑/删除/授权应用/重置密码等操作按钮**全部隐藏** +- 设备中心只读消费客户数据,用于设备归属、布点场所关联 **布点场所**: -- 场所档案(名称、编号、所属租户、部门、地址、联系人、场所类型) +- 场所档案(名称、编号、所属客户、部门、地址、联系人、场所类型) - **自定义标识**(必填字段):运营自定义,用于数据分发寻址、同名场所区分、下游业务系统附加编码等场景 - 客户端心跳监控(监控在线状态) - 消息队列心跳监控 -- 按租户同步场所信息 +- 按客户同步场所信息 - 设备挂载关系(场所下挂哪些设备) -- 与租户为 N:1 关系(一个租户可挂多个场所) +- 与客户为 N:1 关系(一个客户可挂多个场所) + +**供应商管理**(从库存管理迁入): +- 供应商档案(名称、联系人、合作起止日期、供货品类、资质附件) +- 操作:新增 / 编辑 / 删除 / 查看详情 +- 便于采购溯源与品类筛选 +- **迁入理由**:供应商属于"商业合作伙伴"档案而非"仓储资产",与客户档案、布点场所同属"业务伙伴"层;放在客户管理域更内聚,库存管理专注"仓储动作"(入库 / 出库 / 在库 / 盘点) ### 3.5 设备租售(本期不做) @@ -618,7 +638,7 @@ ### 7.2 重构方向 #### (1)菜单按业务能力域重组(本次重点) -- 6 大业务域:工作台 / 设备资产 / 库存管理 / 租户与场所 / 设备运维 / 数据分发 +- 6 大业务域:工作台 / 设备资产 / 库存管理 / 客户管理 / 设备运维 / 数据分发 - 食堂数字化、智慧餐厅、体重管理 3 子应用打散重组 - 设备资产统一管理,数据分发独立成域 - 原系统设置域取消,迁入设备运维域 diff --git a/src/layouts/MiniprogramLayout.vue b/src/layouts/MiniprogramLayout.vue index 7e77037..b8cf76d 100644 --- a/src/layouts/MiniprogramLayout.vue +++ b/src/layouts/MiniprogramLayout.vue @@ -191,4 +191,19 @@ const onSwitchMp = ({ key }: { key: string }): void => { background: #fff; flex-shrink: 0; } + +/** + * 修复 Vant van-dropdown-item 在桌面浏览器中撑满视口导致的样式错乱 + * - Vant 默认 .van-dropdown-item { position: fixed; left:0; right:0; } 撑满整个浏览器视口 + * - 真机 375 视口下无问题;但本原型把 phone-shell(375×812) 居中放在桌面视口(如 1920)里渲染, + * 导致弹层被拉到 1920px 宽,内部菜单项横向散开 → "样式错乱" + * - 这里把它约束到与 phone-shell 等宽并居中,对齐 bar + * - top 仍由 Vant 按 bar 的 viewport 坐标设置,保持不变 + */ +:deep(.van-dropdown-item) { + left: 50% !important; + right: auto !important; + transform: translateX(-50%) !important; + width: 375px !important; +} diff --git a/src/meta/nav.ts b/src/meta/nav.ts index f7843c7..a3891e0 100644 --- a/src/meta/nav.ts +++ b/src/meta/nav.ts @@ -369,12 +369,19 @@ export const adminSections: NavSection[] = [ }, ], }, - // 3. 库存管理(原"设备库存"更名,4 个二级菜单) + // 3. 库存管理(原"设备库存"更名,5 个二级菜单) { key: 'admin-device-stock', label: '库存管理', icon: 'InboxOutlined', children: [ + { + key: 'admin-device-stock-warehouse', + label: '仓库区域', + path: 'stock/warehouse', + status: 'ready', + component: () => import('@pages/admin-portal/device-center/stock/warehouse/warehouse.vue'), + }, { key: 'admin-device-stock-instock', label: '在库库存', @@ -397,33 +404,40 @@ export const adminSections: NavSection[] = [ component: () => import('@pages/admin-portal/device-center/stock/outbound/outbound.vue'), }, { - key: 'admin-device-stock-supplier', - label: '供应商管理', - path: 'stock/supplier', + key: 'admin-device-stock-inventory', + label: '库存盘点', + path: 'stock/inventory', status: 'ready', - component: () => import('@pages/admin-portal/device-center/stock/supplier/supplier.vue'), + component: () => import('@pages/admin-portal/device-center/stock/inventory/inventory.vue'), }, ], }, - // 4. 租户与场所(更名:原"客户与场所") + // 4. 客户管理(原"租户与场所",客户档案 + 布点场所 + 供应商管理) { - key: 'admin-device-tenant-site', - label: '租户与场所', + key: 'admin-device-customer', + label: '客户管理', icon: 'TeamOutlined', children: [ { - key: 'admin-device-tenant-site-tenant', - label: '租户管理', - path: 'tenant-site/tenant', + key: 'admin-device-customer-archive', + label: '客户档案', + path: 'customer/archive', status: 'ready', - component: () => import('@pages/admin-portal/device-center/tenantSite/tenant/tenant.vue'), + component: () => import('@pages/admin-portal/device-center/customer/archive/archive.vue'), }, { - key: 'admin-device-tenant-site-site', + key: 'admin-device-customer-site', label: '布点场所', - path: 'tenant-site/site', + path: 'customer/site', status: 'ready', - component: () => import('@pages/admin-portal/device-center/tenantSite/site/site.vue'), + component: () => import('@pages/admin-portal/device-center/customer/site/site.vue'), + }, + { + key: 'admin-device-customer-supplier', + label: '供应商管理', + path: 'customer/supplier', + status: 'ready', + component: () => import('@pages/admin-portal/device-center/customer/supplier/supplier.vue'), }, ], }, @@ -506,6 +520,28 @@ export const adminSections: NavSection[] = [ }, ], }, + // 7. 第三方授权(虹软人脸授权按设备绑定 + 高德地图应用 Key 管理) + { + key: 'admin-device-third-auth', + label: '第三方授权', + icon: 'SafetyCertificateOutlined', + children: [ + { + key: 'admin-device-third-auth-arcsoft', + label: '虹软人脸授权', + path: 'third-auth/arcsoft', + status: 'ready', + component: () => import('@pages/admin-portal/device-center/third-auth/arcsoft/arcsoft.vue'), + }, + { + key: 'admin-device-third-auth-amap', + label: '高德地图应用', + path: 'third-auth/amap', + status: 'ready', + component: () => import('@pages/admin-portal/device-center/third-auth/amap/amap.vue'), + }, + ], + }, ], }, ] @@ -962,6 +998,26 @@ export const miniprograms: NavMiniprogram[] = [ component: () => import('@pages/miniprogram/ops/workbench/alarmDetail.vue'), status: 'draft', }, + { + key: 'mp-ops-stock-inbound', label: '收货入库', path: 'stock-inbound', + component: () => import('@pages/miniprogram/ops/workbench/stockInbound.vue'), + status: 'draft', + }, + { + key: 'mp-ops-stock-outbound', label: '装机出库', path: 'stock-outbound', + component: () => import('@pages/miniprogram/ops/workbench/stockOutbound.vue'), + status: 'draft', + }, + { + key: 'mp-ops-stock-inventory', label: '库存盘点', path: 'stock-inventory', + component: () => import('@pages/miniprogram/ops/workbench/stockInventory.vue'), + status: 'draft', + }, + { + key: 'mp-ops-stock-list', label: '库存查询', path: 'stock-list', + component: () => import('@pages/miniprogram/ops/workbench/stockList.vue'), + status: 'draft', + }, ], }, { diff --git a/src/pages/admin-portal/device-center/customer/archive/api/index.ts b/src/pages/admin-portal/device-center/customer/archive/api/index.ts new file mode 100644 index 0000000..16bff41 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/api/index.ts @@ -0,0 +1,23 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ArchiveItem, ArchivePayload, SearchForm } from '../types' + +/** 查询客户档案列表 */ +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/page', params) + +/** 查询客户档案详情 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/info', { id }) + +/** 新增客户档案 */ +export const add = (payload: ArchivePayload): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/add', payload) + +/** 更新客户档案 */ +export const update = (payload: ArchivePayload): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/update', payload) + +/** 删除客户档案 */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/delete', { id }) diff --git a/src/pages/admin-portal/device-center/customer/archive/archive.vue b/src/pages/admin-portal/device-center/customer/archive/archive.vue new file mode 100644 index 0000000..0b67bfe --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/archive.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/api/index.ts new file mode 100644 index 0000000..e73a4c0 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/api/index.ts @@ -0,0 +1,11 @@ +/** + * 客户档案 - 详情抽屉 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { DetailInfo } from '../types' + +/** 详情查询 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/info', { id }) diff --git a/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/info.vue new file mode 100644 index 0000000..5d1af90 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/info.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..56b6e42 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/init/usePage.ts @@ -0,0 +1,48 @@ +/** + * 客户档案 - 详情抽屉 - 主逻辑 + */ + +import { info } from '../api' +import type { DetailInfo, OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '客户详情', + width: 720, + spin: false, + }) + + const detail = ref(null) + + const closeDrawer = (): void => { + pageInfo.visible = false + detail.value = null + } + + const openDrawer = (ctx: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + detail.value = null + + info(ctx.id) + .then((res) => { + if (res.code === '00000' && res.data) { + detail.value = res.data + } + }) + .catch((err: unknown) => { + console.error('客户详情查询失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + return { + pageInfo, + detail, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/types/index.ts new file mode 100644 index 0000000..834245a --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/component/drawer/info/types/index.ts @@ -0,0 +1,46 @@ +import type { CustomerType } from '../../../types' + +/** 抽屉页面状态 */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +/** 打开抽屉上下文 */ +export interface OpenContext { + /** 必传 */ + id: string +} + +/** 详情数据(与列表项 ArchiveItem 同构) */ +export interface DetailInfo { + id: string + /** 客户编码 */ + code: string + /** 客户名称 */ + name: string + /** 客户类型 */ + type: CustomerType + /** 联系人 */ + contactName: string + /** 联系电话 */ + contactPhone: string + /** 联系邮箱 */ + email?: string + /** 地址 */ + address?: string + /** 到期时间 */ + expireTime: string + /** 授权应用数 */ + appCount?: number + /** 关联场所数 */ + siteCount?: number + /** 启用状态 */ + status: boolean + /** 备注 */ + remark?: string + /** 创建时间 */ + createTime: string +} diff --git a/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..248f228 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,160 @@ + + + diff --git a/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..b20b556 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,20 @@ +/** + * 客户档案 - 新增/编辑 弹框 - 接口层 + * - 复用主页的 info / add / update + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +/** 详情查询(编辑回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/customer/archive/update', params) diff --git a/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..f8a1e56 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,169 @@ +/** + * 客户档案 - 新增/编辑 弹框 - 主逻辑 + * + * 严格遵循 modal-spec.md: + * - createInitForm 工厂函数 + * - openModal 用 Promise.all 拉取详情 + * - addRequest 必须 delete params.id + * - submit 开头立即 pageInfo.spin = true + * - 用 params.id 判断新增/编辑 + */ + +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' + +/** 初始表单工厂 */ +const createInitForm = (): Form => ({ + id: undefined, + code: '', + name: '', + type: 'enterprise', + contactName: '', + contactPhone: '', + email: '', + address: '', + expireTime: '', + status: true, + remark: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 760, + spin: false, + }) + + const formRef = ref() + const form = ref
(createInitForm()) + + /** 联系电话校验:允许座机 / 手机 */ + const phoneValidator = (_rule: unknown, value: string): Promise => { + if (!value) return Promise.reject(new Error('请输入联系电话')) + const okFlag = /^1[3-9]\d{9}$|^\d{3,4}-?\d{7,8}$/.test(value) + return okFlag ? Promise.resolve() : Promise.reject(new Error('请输入正确的手机或座机号')) + } + + /** 邮箱校验(可选,填写时必须合法) */ + const emailValidator = (_rule: unknown, value: string): Promise => { + if (!value) return Promise.resolve() + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value) + ? Promise.resolve() + : Promise.reject(new Error('邮箱格式不正确')) + } + + const rules: Record = { + code: [{ required: true, message: '请输入客户编码', trigger: 'blur' }], + name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }], + type: [{ required: true, message: '请选择客户类型', trigger: 'change' }], + contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], + contactPhone: [{ required: true, validator: phoneValidator, trigger: 'blur' }], + email: [{ validator: emailValidator, trigger: 'blur' }], + expireTime: [{ required: true, message: '请选择到期时间', trigger: 'change' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const infoRequest = (id: string) => info(id) + + /** 打开弹框 */ + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + pageInfo.title = type === 'add' ? '新增客户' : '编辑客户' + + const requests: Promise[] = [] + if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id)) + + Promise.all(requests) + .then((results) => { + if (type === 'edit' && ctx?.id) { + const infoRes = results[0] as { code: string; data: Form } + if (infoRes?.code === '00000' && infoRes.data) { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .catch((err: unknown) => { + console.error('打开弹框初始化失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const successRequest = (msg: string): void => { + void message.success(msg) + emit('load') + closeModal() + } + + const addRequest = (params: Record): void => { + delete params.id + add(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('新增失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const editRequest = (params: Record): void => { + edit(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('编辑失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const submit = (): void => { + pageInfo.spin = true + formRef.value + ?.validate() + .then(() => { + const params = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..fa3c36a --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,51 @@ +import type { FormInstance } from 'ant-design-vue/es/form' +import type { CustomerType } from '../../../types' + +/** 弹框类型 */ +export type ModalType = 'add' | 'edit' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 打开弹框上下文 */ +export interface OpenContext { + /** 编辑模式必传 */ + id?: string +} + +/** 表单字段(对齐 ArchivePayload) */ +export interface Form { + id: string | undefined + /** 客户编码 */ + code: string + /** 客户名称 */ + name: string + /** 客户类型 */ + type: CustomerType + /** 联系人 */ + contactName: string + /** 联系电话 */ + contactPhone: string + /** 联系邮箱 */ + email: string + /** 地址 */ + address: string + /** 到期时间 */ + expireTime: string + /** 启用状态 */ + status: boolean + /** 备注 */ + remark: string +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/customer/archive/init/usePage.ts b/src/pages/admin-portal/device-center/customer/archive/init/usePage.ts new file mode 100644 index 0000000..0d35486 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/init/usePage.ts @@ -0,0 +1,151 @@ +import { onMounted, reactive, ref } from 'vue' +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 AddOrEdit from '../component/modal/addOrEdit/addOrEdit.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' + +/** + * 客户档案页主编排: + * - 标准 CRUD + 详情抽屉 + * - appCount / siteCount 为只读聚合字段,不在弹框维护 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + /** 新增/编辑弹框 ref */ + const addOrEditRef = ref | null>(null) + /** 详情抽屉 ref */ + const infoDrawerRef = ref | null>(null) + + const listRequest = (): void => { + pageLoading.value = true + list(search) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = table.dataSource.length + } + }) + .catch((err: unknown) => { + console.error('客户档案列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + Object.assign(search, createSearchKey()) + table.pagination.current = 1 + listRequest() + } + + const dataSourceChange = (pagination: TablePaginationConfig): void => { + table.pagination.current = pagination.current ?? 1 + table.pagination.pageSize = pagination.pageSize ?? 30 + } + + const rowSelection = { + selectedRowKeys, + onChange: (keys: (string | number)[]): void => { + selectedRowKeys.value = keys + }, + } + + /** 打开新增弹框 */ + const openAddModal = (): void => { + addOrEditRef.value?.openModal('add') + } + + /** 打开编辑弹框 */ + const openEditModal = (record: ArchiveItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + /** 打开详情抽屉 */ + const openInfoDrawer = (record: ArchiveItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id }) + } + + const deleteRecord = (record: ArchiveItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `客户「${record.name}」删除后不可恢复,关联场所与设备将失去归属,请谨慎操作。`, + okType: 'danger', + okText: '确认删除', + cancelText: '取消', + onOk: () => { + remove(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('客户档案删除失败:', err) + }) + }, + }) + } + + const handleAction = (type: string, record?: ArchiveItem): void => { + if (type === 'add') { + openAddModal() + return + } + if (type === 'edit' && record) { + openEditModal(record) + return + } + if (type === 'detail' && record) { + openInfoDrawer(record) + return + } + if (type === 'delete' && record) { + deleteRecord(record) + } + } + + onMounted(() => { + listRequest() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + addOrEditRef, + infoDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/customer/archive/init/useSearch.ts b/src/pages/admin-portal/device-center/customer/archive/init/useSearch.ts new file mode 100644 index 0000000..1d762a9 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/init/useSearch.ts @@ -0,0 +1,8 @@ +import type { SearchForm } from '../types' + +/** 搜索表单工厂:统一重置入口 */ +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + type: undefined, + status: undefined, +}) diff --git a/src/pages/admin-portal/device-center/customer/archive/init/useTable.ts b/src/pages/admin-portal/device-center/customer/archive/init/useTable.ts new file mode 100644 index 0000000..ed2b83e --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/init/useTable.ts @@ -0,0 +1,48 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { ArchiveItem, CustomerType } from '../types' +import { CUSTOMER_TYPE_OPTIONS, customerTypeText } from '../types' + +/** 客户类型 → Tag 颜色 */ +const typeColor: Record = { + enterprise: 'blue', + gov: 'purple', + edu: 'cyan', + medical: 'green', + other: 'default', +} + +/** 客户档案列定义 */ +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 }, + { title: '客户名称', dataIndex: 'name', align: 'left', width: 200, resizable: true, ellipsis: true }, + { + title: '客户类型', + dataIndex: 'type', + align: 'center', + width: 110, + customRender: ({ value }: { value: CustomerType }) => + h(Tag, { color: typeColor[value] ?? 'default' }, () => customerTypeText(value)), + }, + { 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: 'status', + align: 'center', + width: 90, + 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 }, +] + +/** 客户类型下拉选项(供搜索/弹框复用) */ +export const CUSTOMER_TYPE_OPTIONS_EXPORT = CUSTOMER_TYPE_OPTIONS diff --git a/src/pages/admin-portal/device-center/customer/archive/types/index.ts b/src/pages/admin-portal/device-center/customer/archive/types/index.ts new file mode 100644 index 0000000..6718031 --- /dev/null +++ b/src/pages/admin-portal/device-center/customer/archive/types/index.ts @@ -0,0 +1,69 @@ +import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 客户类型枚举 */ +export type CustomerType = 'enterprise' | 'gov' | 'edu' | 'medical' | 'other' + +/** 客户档案列表项 */ +export interface ArchiveItem extends BaseRecord { + /** 客户编码 */ + code: string + /** 客户名称 */ + name: string + /** 客户类型 */ + type: CustomerType + /** 联系人 */ + contactName: string + /** 联系电话 */ + contactPhone: string + /** 联系邮箱 */ + email?: string + /** 地址 */ + address?: string + /** 到期时间 */ + expireTime: string + /** 授权应用数(只读聚合) */ + appCount?: number + /** 关联场所数(只读聚合) */ + siteCount?: number + /** 启用状态 */ + status: boolean + /** 备注 */ + remark?: string + /** 创建时间 */ + createTime: string +} + +/** 搜索表单 */ +export interface SearchForm extends BaseSearchForm { + keyword?: string + type?: CustomerType + status?: boolean +} + +/** 新增/编辑提交载荷 */ +export interface ArchivePayload { + id?: string + code: string + name: string + type: CustomerType + contactName: string + contactPhone: string + email?: string + address?: string + expireTime: string + status?: boolean + remark?: string +} + +/** 客户类型下拉选项 */ +export const CUSTOMER_TYPE_OPTIONS = [ + { label: '企业客户', value: 'enterprise' }, + { label: '政府机关', value: 'gov' }, + { label: '教育机构', value: 'edu' }, + { label: '医疗机构', value: 'medical' }, + { label: '其他', value: 'other' }, +] + +/** 客户类型 → 中文 */ +export const customerTypeText = (value: string): string => + CUSTOMER_TYPE_OPTIONS.find((item) => item.value === value)?.label ?? value ?? '—' diff --git a/src/pages/admin-portal/device-center/tenantSite/site/api/index.ts b/src/pages/admin-portal/device-center/customer/site/api/index.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/api/index.ts rename to src/pages/admin-portal/device-center/customer/site/api/index.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/customer/site/component/drawer/info/api/index.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/api/index.ts rename to src/pages/admin-portal/device-center/customer/site/component/drawer/info/api/index.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/customer/site/component/drawer/info/info.vue similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/info.vue rename to src/pages/admin-portal/device-center/customer/site/component/drawer/info/info.vue diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/customer/site/component/drawer/info/init/usePage.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/init/usePage.ts rename to src/pages/admin-portal/device-center/customer/site/component/drawer/info/init/usePage.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/customer/site/component/drawer/info/types/index.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/component/drawer/info/types/index.ts rename to src/pages/admin-portal/device-center/customer/site/component/drawer/info/types/index.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/customer/site/component/modal/addOrEdit/addOrEdit.vue similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/addOrEdit.vue rename to src/pages/admin-portal/device-center/customer/site/component/modal/addOrEdit/addOrEdit.vue diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/customer/site/component/modal/addOrEdit/api/index.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/api/index.ts rename to src/pages/admin-portal/device-center/customer/site/component/modal/addOrEdit/api/index.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/customer/site/component/modal/addOrEdit/init/usePage.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/init/usePage.ts rename to src/pages/admin-portal/device-center/customer/site/component/modal/addOrEdit/init/usePage.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/customer/site/component/modal/addOrEdit/types/index.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/component/modal/addOrEdit/types/index.ts rename to src/pages/admin-portal/device-center/customer/site/component/modal/addOrEdit/types/index.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/init/usePage.ts b/src/pages/admin-portal/device-center/customer/site/init/usePage.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/init/usePage.ts rename to src/pages/admin-portal/device-center/customer/site/init/usePage.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/init/useSearch.ts b/src/pages/admin-portal/device-center/customer/site/init/useSearch.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/init/useSearch.ts rename to src/pages/admin-portal/device-center/customer/site/init/useSearch.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/init/useTable.ts b/src/pages/admin-portal/device-center/customer/site/init/useTable.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/init/useTable.ts rename to src/pages/admin-portal/device-center/customer/site/init/useTable.ts diff --git a/src/pages/admin-portal/device-center/tenantSite/site/site.vue b/src/pages/admin-portal/device-center/customer/site/site.vue similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/site.vue rename to src/pages/admin-portal/device-center/customer/site/site.vue diff --git a/src/pages/admin-portal/device-center/tenantSite/site/types/index.ts b/src/pages/admin-portal/device-center/customer/site/types/index.ts similarity index 100% rename from src/pages/admin-portal/device-center/tenantSite/site/types/index.ts rename to src/pages/admin-portal/device-center/customer/site/types/index.ts diff --git a/src/pages/admin-portal/device-center/stock/supplier/api/index.ts b/src/pages/admin-portal/device-center/customer/supplier/api/index.ts similarity index 63% rename from src/pages/admin-portal/device-center/stock/supplier/api/index.ts rename to src/pages/admin-portal/device-center/customer/supplier/api/index.ts index c813b08..eb450ff 100644 --- a/src/pages/admin-portal/device-center/stock/supplier/api/index.ts +++ b/src/pages/admin-portal/device-center/customer/supplier/api/index.ts @@ -3,7 +3,7 @@ import { postRequest } from '@axios' import type { ListItem, ListParams } from '../types' export const list = (params: ListParams): Promise> => - postRequest('axiosRequest', '/admin/device/stock/supplier/page', params) + postRequest('axiosRequest', '/admin/device/customer/supplier/page', params) export const remove = (id: string): Promise> => - postRequest('axiosRequest', '/admin/device/stock/supplier/delete', { id }) + postRequest('axiosRequest', '/admin/device/customer/supplier/delete', { id }) diff --git a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/addOrEdit.vue similarity index 100% rename from src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/addOrEdit.vue rename to src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/addOrEdit.vue diff --git a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/api/index.ts similarity index 70% rename from src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/api/index.ts rename to src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/api/index.ts index cbfe3aa..095c2b2 100644 --- a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/api/index.ts +++ b/src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/api/index.ts @@ -10,12 +10,12 @@ import type { Form } from '../types' /** 详情查询(编辑回填) */ export const info = (id: string): Promise> => - postRequest('axiosRequest', '/admin/device/stock/supplier/info', { id }) + postRequest('axiosRequest', '/admin/device/customer/supplier/info', { id }) /** 新增 */ export const add = (params: Record): Promise> => - postRequest('axiosRequest', '/admin/device/stock/supplier/add', params) + postRequest('axiosRequest', '/admin/device/customer/supplier/add', params) /** 编辑 */ export const edit = (params: Record): Promise> => - postRequest('axiosRequest', '/admin/device/stock/supplier/update', params) + postRequest('axiosRequest', '/admin/device/customer/supplier/update', params) diff --git a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/init/usePage.ts similarity index 100% rename from src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/init/usePage.ts rename to src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/init/usePage.ts diff --git a/src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/types/index.ts similarity index 100% rename from src/pages/admin-portal/device-center/stock/supplier/component/modal/addOrEdit/types/index.ts rename to src/pages/admin-portal/device-center/customer/supplier/component/modal/addOrEdit/types/index.ts diff --git a/src/pages/admin-portal/device-center/stock/supplier/init/usePage.ts b/src/pages/admin-portal/device-center/customer/supplier/init/usePage.ts similarity index 100% rename from src/pages/admin-portal/device-center/stock/supplier/init/usePage.ts rename to src/pages/admin-portal/device-center/customer/supplier/init/usePage.ts diff --git a/src/pages/admin-portal/device-center/stock/supplier/init/useSearch.ts b/src/pages/admin-portal/device-center/customer/supplier/init/useSearch.ts similarity index 100% rename from src/pages/admin-portal/device-center/stock/supplier/init/useSearch.ts rename to src/pages/admin-portal/device-center/customer/supplier/init/useSearch.ts diff --git a/src/pages/admin-portal/device-center/stock/supplier/init/useTable.ts b/src/pages/admin-portal/device-center/customer/supplier/init/useTable.ts similarity index 100% rename from src/pages/admin-portal/device-center/stock/supplier/init/useTable.ts rename to src/pages/admin-portal/device-center/customer/supplier/init/useTable.ts diff --git a/src/pages/admin-portal/device-center/stock/supplier/supplier.vue b/src/pages/admin-portal/device-center/customer/supplier/supplier.vue similarity index 100% rename from src/pages/admin-portal/device-center/stock/supplier/supplier.vue rename to src/pages/admin-portal/device-center/customer/supplier/supplier.vue diff --git a/src/pages/admin-portal/device-center/stock/supplier/types/index.ts b/src/pages/admin-portal/device-center/customer/supplier/types/index.ts similarity index 100% rename from src/pages/admin-portal/device-center/stock/supplier/types/index.ts rename to src/pages/admin-portal/device-center/customer/supplier/types/index.ts diff --git a/src/pages/admin-portal/device-center/stock/inventory/api/index.ts b/src/pages/admin-portal/device-center/stock/inventory/api/index.ts new file mode 100644 index 0000000..b148d15 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/api/index.ts @@ -0,0 +1,27 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { InventoryPayload, ListItem, ListParams } from '../types' + +/** 分页查询盘点单 */ +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inventory/page', params) + +/** 盘点单详情(含应盘清单 items) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inventory/info', { id }) + +/** 新增盘点单(选仓库 + 类型,系统自动从在库库存生成应盘清单) */ +export const add = (params: InventoryPayload): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inventory/add', params) + +/** 审核盘点单(审核后库存台账以实盘数据为准) */ +export const audit = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inventory/audit', { id }) + +/** 删除盘点单(仅草稿状态可删) */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inventory/delete', { id }) + +/** 查询仓库区域下拉数据(用于新增弹框选仓库) */ +export const warehouseOptions = (): Promise>> => + postRequest('axiosRequest', '/admin/device/stock/inventory/warehouse-options', {}) diff --git a/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/api/index.ts new file mode 100644 index 0000000..fd3709a --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/api/index.ts @@ -0,0 +1,12 @@ +/** + * 库存盘点 - 详情抽屉 - 接口层 + * - 复用主页 page 接口 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem } from '../../../types' + +/** 盘点单详情(含应盘清单 items) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inventory/info', { id }) diff --git a/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/info.vue new file mode 100644 index 0000000..e2d2973 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/info.vue @@ -0,0 +1,106 @@ + + + diff --git a/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..41209be --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/init/usePage.ts @@ -0,0 +1,71 @@ +/** + * 库存盘点 - 详情抽屉 - 主逻辑 + * + * 严格遵循 drawer-spec.md: + * - openDrawer 用 Promise 拉取详情 + * - destroy-on-close + spin 控制 + */ +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { info } from '../api' +import type { ListItem } from '../../../types' +import type { OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + title: '盘点单详情', + width: 900, + spin: false, + }) + + /** 当前详情数据 */ + const detail = ref(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: 'result', align: 'center', width: 100 }, + ] + + const openDrawer = (id: string): void => { + pageInfo.visible = true + pageInfo.spin = true + detail.value = null + info(id) + .then((res) => { + if (res.code === '00000' && res.data) { + detail.value = res.data + pageInfo.title = `盘点单详情 · ${res.data.inventoryCode}` + } else { + void message.error(res.msg || '详情查询失败') + } + }) + .catch((err: unknown) => { + console.error('盘点单详情查询失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const closeDrawer = (): void => { + pageInfo.visible = false + detail.value = null + } + + return { + pageInfo, + detail, + itemColumns, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/types/index.ts new file mode 100644 index 0000000..5edfd2e --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/component/drawer/info/types/index.ts @@ -0,0 +1,13 @@ +/** 抽屉页面状态 */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +/** 打开抽屉上下文 */ +export interface OpenContext { + /** 盘点单 ID */ + id: string +} diff --git a/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..7c091a0 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..e87a2ea --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,21 @@ +/** + * 库存盘点 - 新增弹框 - 接口层 + * - 复用主页 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> => + postRequest('axiosRequest', '/admin/device/stock/inventory/add', params) + +/** 查询仓库下拉数据 */ +export const warehouseOptions = (): Promise> => + postRequest('axiosRequest', '/admin/device/stock/inventory/warehouse-options', {}) + +/** 仅用于类型导入兼容(Form 在 usePage 中用作 reactive 类型) */ +export type { Form } diff --git a/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..db40a2d --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,130 @@ +/** + * 库存盘点 - 新增弹框 - 主逻辑 + * + * 严格遵循 modal-spec.md: + * - createInitForm 工厂函数 + * - openModal 拉仓库下拉 + * - submit 开头立即 pageInfo.spin = true + */ + +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, warehouseOptions } from '../api' +import type { Form, PageInfo, WarehouseOption } from '../types' +import type { InventoryPayload } from '../../../types' + +/** 初始表单工厂 */ +const createInitForm = (): Form => ({ + warehouseId: undefined, + warehouseName: '', + inventoryType: 'full', + remark: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + title: '新增盘点单', + width: 560, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + /** 仓库下拉选项 */ + const warehouseOptionList = ref([]) + + const rules: Record = { + warehouseId: [{ required: true, message: '请选择盘点仓库', trigger: 'change' }], + inventoryType: [{ required: true, message: '请选择盘点类型', trigger: 'change' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + 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 ?? '' + } + + const successRequest = (msg: string): void => { + void message.success(msg) + emit('load') + closeModal() + } + + const addRequest = (params: InventoryPayload): void => { + add(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('新增盘点单失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const submit = (): void => { + pageInfo.spin = true + formRef.value + ?.validate() + .then(() => { + const params: InventoryPayload = { + warehouseId: form.value.warehouseId as string, + warehouseName: form.value.warehouseName, + inventoryType: form.value.inventoryType, + remark: form.value.remark || undefined, + } + addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + warehouseOptionList, + openModal, + closeModal, + onWarehouseChange, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..952f433 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,34 @@ +import type { FormInstance } from 'ant-design-vue/es/form' +import type { InventoryType } from '../../../types' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +/** 表单字段 */ +export interface Form { + /** 仓库 ID */ + warehouseId: string | undefined + /** 仓库名称(提交时附带,便于后端记录) */ + warehouseName: string + /** 盘点类型 */ + inventoryType: InventoryType + /** 备注 */ + remark: string +} + +/** 仓库下拉选项 */ +export interface WarehouseOption { + id: string + name: string +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/stock/inventory/init/usePage.ts b/src/pages/admin-portal/device-center/stock/inventory/init/usePage.ts new file mode 100644 index 0000000..2817b4c --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/init/usePage.ts @@ -0,0 +1,166 @@ +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { useAdminTable } from '@pages/admin-portal/shared/useAdminTable' +import { useAdminSearch } from '@pages/admin-portal/shared/useAdminSearch' +import { audit, 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 InfoDrawer from '../component/drawer/info/info.vue' + +/** + * 库存盘点页主编排: + * - 列表 + 新增(选仓库+类型)+ 查看详情(抽屉)+ 审核 + 删除(仅草稿可删) + * - 状态流转:草稿 → 已完成(小程序扫码提交后)→ 已审核(后台审核后修正库存台账) + * - 实盘扫码动作在小程序端做,后台只读查看实盘数据并审核 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + const { search, initOptions, resetSearch } = useAdminSearch(createSearchKey) + + const pageLoading = ref(false) + const addOrEditRef = ref | null>(null) + const infoDrawerRef = ref | null>(null) + + const buildParams = () => ({ + ...search, + pageNum: table.pagination.current ?? 1, + pageSize: table.pagination.pageSize ?? 30, + order: table.sort.order, + column: table.sort.field, + }) + + const listRequest = (): void => { + pageLoading.value = true + list(buildParams()) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('盘点单列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { + table, + dataSourceChange, + resetTable, + selectedRowKeys, + rowSelection, + clearSelection, + } = useAdminTable(tableColumns, listRequest, false) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + clearSelection?.() + listRequest() + } + + /** 新增盘点单 */ + const openAddModal = (): void => { + addOrEditRef.value?.openModal() + } + + /** 查看详情 */ + const openInfoDrawer = (record: ListItem): void => { + infoDrawerRef.value?.openDrawer(record.id) + } + + /** 审核盘点单(仅 completed 状态可审核) */ + const auditRecord = (record: ListItem): void => { + if (record.status !== 'completed') { + void message.warning('仅"已完成"状态的盘点单可审核') + return + } + Modal.confirm({ + title: '确认审核?', + content: `盘点单「${record.inventoryCode}」审核后,库存台账将以实盘数据为准:盘盈 ${record.surplusCount} 条新增在库,盘亏 ${record.lossCount} 条标记异常并触发工单核查。`, + okType: 'danger', + okText: '确认审核', + cancelText: '取消', + onOk: () => { + audit(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('盘点单审核失败:', err) + }) + }, + }) + } + + /** 删除(仅 draft 可删) */ + const deleteRecord = (record: ListItem): void => { + if (record.status !== 'draft') { + void message.warning('仅"草稿"状态的盘点单可删除') + return + } + Modal.confirm({ + title: '确认删除?', + content: `盘点单「${record.inventoryCode}」删除后不可恢复。`, + okType: 'danger', + okText: '确认删除', + cancelText: '取消', + onOk: () => { + remove(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('盘点单删除失败:', err) + }) + }, + }) + } + + /** 操作列分发 */ + const handleAction = (type: string, record?: ListItem): void => { + if (type === 'add') openAddModal() + else if (type === 'info' && record) openInfoDrawer(record) + else if (type === 'audit' && record) auditRecord(record) + else if (type === 'delete' && record) deleteRecord(record) + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + addOrEditRef, + infoDrawerRef, + dataSourceChange, + searchQuery, + resetQuery, + handleAction, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/stock/inventory/init/useSearch.ts b/src/pages/admin-portal/device-center/stock/inventory/init/useSearch.ts new file mode 100644 index 0000000..8857111 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/init/useSearch.ts @@ -0,0 +1,8 @@ +import type { SearchForm } from '../types' + +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + warehouseId: undefined, + status: undefined, + inventoryType: undefined, +}) diff --git a/src/pages/admin-portal/device-center/stock/inventory/init/useTable.ts b/src/pages/admin-portal/device-center/stock/inventory/init/useTable.ts new file mode 100644 index 0000000..97694b7 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/init/useTable.ts @@ -0,0 +1,57 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { InventoryStatus, InventoryType, ListItem } from '../types' +import { + INVENTORY_STATUS_COLOR, + INVENTORY_STATUS_OPTIONS, + INVENTORY_TYPE_COLOR, + INVENTORY_TYPE_OPTIONS, +} from '../types' + +const statusText = (status: string): string => + INVENTORY_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—' + +const typeText = (type: string): string => + INVENTORY_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? '—' + +export const tableColumns: TableColumnsType = [ + { 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: 'inventoryType', + align: 'center', + width: 110, + customRender: ({ value }: { value: InventoryType }) => + h(Tag, { color: INVENTORY_TYPE_COLOR[value] ?? 'default' }, () => typeText(value)), + }, + { title: '盘点人', dataIndex: 'operator', align: 'center', width: 100, resizable: true, ellipsis: true }, + { title: '盘点时间', dataIndex: 'inventoryTime', align: 'center', width: 170, sorter: true, resizable: true }, + { title: '应盘', dataIndex: 'expectedCount', align: 'center', width: 80 }, + { title: '实盘', dataIndex: 'actualCount', align: 'center', width: 80 }, + { + title: '盘盈', + dataIndex: 'surplusCount', + align: 'center', + width: 80, + customRender: ({ value }: { value: number }) => (value > 0 ? h(Tag, { color: 'gold' }, () => `+${value}`) : '0'), + }, + { + title: '盘亏', + dataIndex: 'lossCount', + align: 'center', + width: 80, + customRender: ({ value }: { value: number }) => (value > 0 ? h(Tag, { color: 'red' }, () => `-${value}`) : '0'), + }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 110, + customRender: ({ value }: { value: InventoryStatus }) => + h(Tag, { color: INVENTORY_STATUS_COLOR[value] ?? 'default' }, () => statusText(value)), + }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 220 }, +] diff --git a/src/pages/admin-portal/device-center/stock/inventory/inventory.vue b/src/pages/admin-portal/device-center/stock/inventory/inventory.vue new file mode 100644 index 0000000..c18788c --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/inventory.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/stock/inventory/types/index.ts b/src/pages/admin-portal/device-center/stock/inventory/types/index.ts new file mode 100644 index 0000000..53859b4 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/inventory/types/index.ts @@ -0,0 +1,120 @@ +import type { BaseListParams, BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 盘点单状态:draft 草稿 / completed 已完成(待审核)/ audited 已审核 */ +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 + /** 盘点状态 */ + status?: string + /** 盘点类型 */ + inventoryType?: string +} + +/** 应盘清单条目(详情抽屉展示) */ +export interface InventoryItem { + /** 设备编码 */ + deviceCode: string + /** 设备名称 */ + deviceName: string + /** 二级型号 */ + modelName: string + /** 批次号 */ + batchNo: string + /** 应盘数量(固定 1,每台设备一条) */ + expectedQuantity: number + /** 实盘数量(0=盘亏,1=一致,>1=盘盈) */ + actualQuantity: number + /** 实盘结果(前端按 actualQuantity 计算,后端只返回数量) */ + result: InventoryItemResult +} + +export interface ListItem extends BaseRecord { + /** 盘点单号 */ + inventoryCode: string + /** 仓库 ID */ + warehouseId: string + /** 仓库名称 */ + warehouseName: string + /** 盘点类型 */ + inventoryType: InventoryType + /** 盘点人 */ + operator: string + /** 盘点时间 */ + inventoryTime: string + /** 状态 */ + status: InventoryStatus + /** 应盘数量 */ + expectedCount: number + /** 实盘数量 */ + actualCount: number + /** 盘盈数 */ + surplusCount: number + /** 盘亏数 */ + lossCount: number + /** 审核人(audited 状态有) */ + auditor?: string + /** 审核时间(audited 状态有) */ + auditTime?: string + /** 差异说明 */ + remark?: string + /** 应盘清单(详情接口返回,列表接口不返回) */ + items?: InventoryItem[] +} + +export interface ListParams extends BaseListParams, SearchForm {} + +/** 新增盘点单提交载荷 */ +export interface InventoryPayload { + warehouseId: string + warehouseName: string + inventoryType: InventoryType + remark?: string +} + +export const INVENTORY_STATUS_OPTIONS: Array<{ label: string; value: InventoryStatus }> = [ + { label: '草稿', value: 'draft' }, + { label: '已完成', value: 'completed' }, + { label: '已审核', value: 'audited' }, +] + +export const INVENTORY_TYPE_OPTIONS: Array<{ label: string; value: InventoryType }> = [ + { label: '全量盘点', value: 'full' }, + { label: '部分盘点', value: 'partial' }, +] + +export const INVENTORY_STATUS_COLOR: Record = { + draft: 'default', + completed: 'warning', + audited: 'success', +} + +export const INVENTORY_TYPE_COLOR: Record = { + full: 'blue', + partial: 'orange', +} + +/** 单条实盘结果 → a-tag color */ +export const INVENTORY_ITEM_RESULT_COLOR: Record = { + matched: 'success', + surplus: 'gold', + loss: 'red', + unchecked: 'default', +} + +/** 单条实盘结果 → 中文标签 */ +export const INVENTORY_ITEM_RESULT_LABEL: Record = { + matched: '一致', + surplus: '盘盈', + loss: '盘亏', + unchecked: '未盘', +} diff --git a/src/pages/admin-portal/device-center/stock/warehouse/api/index.ts b/src/pages/admin-portal/device-center/stock/warehouse/api/index.ts new file mode 100644 index 0000000..0f1c15c --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/api/index.ts @@ -0,0 +1,23 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { SearchForm, WarehouseNode, WarehousePayload } from '../types' + +/** 查询仓库区域全树(按关键字过滤) */ +export const tree = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/device/warehouse/tree', params) + +/** 查询节点详情 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/warehouse/info', { id }) + +/** 新增节点 */ +export const add = (payload: WarehousePayload): Promise> => + postRequest('axiosRequest', '/admin/device/warehouse/add', payload) + +/** 更新节点 */ +export const update = (payload: WarehousePayload): Promise> => + postRequest('axiosRequest', '/admin/device/warehouse/update', payload) + +/** 删除节点(含子节点级联删除) */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/warehouse/delete', { id }) diff --git a/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..13fde85 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..14a2e15 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,15 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { WarehouseNode, WarehousePayload } from '../../../types' + +/** 查询节点详情 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/warehouse/info', { id }) + +/** 新增节点 */ +export const add = (payload: WarehousePayload): Promise> => + postRequest('axiosRequest', '/admin/device/warehouse/add', payload) + +/** 更新节点 */ +export const edit = (payload: WarehousePayload): Promise> => + postRequest('axiosRequest', '/admin/device/warehouse/update', payload) diff --git a/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..4c1164c --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,207 @@ +/** + * 仓库区域 - 新增/编辑 弹框 - 主逻辑 + * + * 严格遵循 modal-spec.md: + * - createInitForm 工厂函数 + * - openModal 用 Promise.all 并发拉取详情 + * - addRequest 必须 delete params.id + * - submit 开头立即 pageInfo.spin = true + * - successRequest 接收 res.msg + * - 用 params.id 判断新增/编辑(禁用 type 文本判断) + * + * 仓库区域特殊点: + * - 树形结构,任意节点均可继续新增下级区域 + * - code 由系统按 W01A01 规则自动生成,弹框内只读展示 + * - 末级状态(idle/occupied/maintenance)仅在叶子节点显示 + * - 新增视为叶子;编辑时按"无 children"判断 + */ + +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { computed, ref } from 'vue' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, edit, info } from '../api' +import type { WarehouseNode, WarehousePayload } from '@pages/admin-portal/device-center/stock/warehouse/types' +import type { Form, ModalType, OpenContext, PageInfo } from '../types' + +/** 初始表单工厂 */ +const createInitForm = (): Form => ({ + id: undefined, + name: '', + code: '', + level: 1, + parentId: undefined, + parentName: undefined, + sortOrder: 99, + status: 'idle', + remark: '', + hasChildren: false, +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 640, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + const rules: Record = { + name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }], + sortOrder: [{ required: true, type: 'number', message: '请输入排序号', trigger: 'blur' }], + } + + /** 是否为叶子节点:新增视为叶子;编辑看是否有子 */ + const isLeaf = computed(() => { + if (pageInfo.type === 'add') return true + return !form.value.hasChildren + }) + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const infoRequest = (id: string) => info(id) + + /** + * 打开弹框 + * - ctx.id 存在:编辑模式,拉取详情回填 + * - ctx.parentId 存在:新增下级区域,预置父级上下文(level = parent.level + 1) + * - ctx 为空:新增顶级仓库(level=1) + */ + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + /** 预置父级上下文(新增下级区域时) */ + if (ctx?.parentId) { + form.value.parentId = ctx.parentId + form.value.parentName = ctx.parentName + form.value.level = ctx.level ?? 2 + /** 新增下级默认 idle,由用户显式配置或后续入库联动 */ + form.value.status = 'idle' + } else { + form.value.level = ctx?.level ?? 1 + } + + pageInfo.title = type === 'add' + ? (form.value.level === 1 ? '新增顶级仓库' : `新增下级区域${ctx?.parentName ? `(父级:${ctx.parentName})` : ''}`) + : '编辑仓库区域' + + const requests: Promise[] = [] + if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id)) + + Promise.all(requests) + .then((results) => { + if (type === 'edit' && ctx?.id) { + const infoRes = results[0] as { code: string; data: WarehouseNode } + if (infoRes?.code === '00000' && infoRes.data) { + /** 剥离 children,转为 hasChildren 标志位 */ + const { children, ...rest } = infoRes.data + form.value = { + ...createInitForm(), + ...rest, + hasChildren: !!(children?.length), + } + } + } + }) + .catch((err: unknown) => { + console.error('打开弹框初始化失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const successRequest = (msg: string): void => { + void message.success(msg) + emit('load') + closeModal() + } + + /** 提交前清洗:根据叶子状态剥离 status;剥离 code(系统生成) */ + const buildPayload = (raw: Form): WarehousePayload => { + const params: WarehousePayload = { + id: raw.id, + name: raw.name, + level: raw.level, + parentId: raw.parentId, + parentName: raw.parentName, + sortOrder: raw.sortOrder, + remark: raw.remark, + } + /** 叶子节点保留 status;非叶子节点不提交 */ + if (isLeaf.value) { + params.status = raw.status ?? 'idle' + } + /** id 仅编辑保留 */ + if (!params.id) delete params.id + return params + } + + const addRequest = (params: WarehousePayload): void => { + add(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('新增失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const editRequest = (params: WarehousePayload): void => { + edit(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('编辑失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const submit = (): void => { + pageInfo.spin = true + formRef.value + ?.validate() + .then(() => { + const params = buildPayload(form.value) + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + isLeaf, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..b14fd6c --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,37 @@ +import type { WarehouseStatus } from '../../../types' + +export type ModalType = 'add' | 'edit' + +/** 打开弹框上下文:编辑传 id,新增下级传父级信息 */ +export interface OpenContext { + id?: string + parentId?: string + parentName?: string + level?: number +} + +/** 弹框 PageInfo */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 表单 Form */ +export interface Form { + id?: string + name: string + /** code 编辑时回填,新增时为空(保存后由系统生成) */ + code?: string + level: number + parentId?: string + parentName?: string + sortOrder: number + /** 末级状态:仅叶子节点有效 */ + status?: WarehouseStatus + remark?: string + /** 前端展示用:是否有子节点(编辑时回填) */ + hasChildren: boolean +} diff --git a/src/pages/admin-portal/device-center/stock/warehouse/init/usePage.ts b/src/pages/admin-portal/device-center/stock/warehouse/init/usePage.ts new file mode 100644 index 0000000..74d1cf3 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/init/usePage.ts @@ -0,0 +1,199 @@ +import { onMounted, reactive, ref } from 'vue' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { remove, tree } from '../api' +import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue' +import type { SearchForm, WarehouseNode } from '../types' +import { createSearchKey } from './useSearch' +import { useTable } from './useTable' + +/** + * 仓库区域页主编排: + * - 左侧 a-tree 展示仓库空间层级(业务上支持任意层级嵌套,不强制深度) + * - 右侧 a-card 展示选中"区域详情" + 操作按钮 + * - 新增/编辑走子组件 AddOrEdit(modal-spec 4 文件结构) + * - 末级节点(无 children)显示状态:空闲 / 占用 / 维护中 + * - 入库时区域必须选末级(由入库页面保证,本页只维护结构与初始状态) + * - code 由系统按 W01A01 规则自动生成 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + const { + treeData, + selectedKeys, + expandedKeys, + pageLoading, + fieldNames, + collectExpandableKeys, + resetTree, + } = useTable() + + const search = reactive(createSearchKey()) + + /** 当前选中的节点对象(基于 selectedKeys 计算) */ + const selectedNode = ref(null) + + /** 新增/编辑子组件 ref(必须在 usePage.ts 中定义) */ + const addOrEditRef = ref | null>(null) + + /** 递归查找节点 */ + const findNode = (nodes: WarehouseNode[], id: string): WarehouseNode | null => { + for (const node of nodes) { + if (node.id === id) return node + if (node.children) { + const found = findNode(node.children, id) + if (found) return found + } + } + return null + } + + /** 拉取仓库区域全树 */ + const treeRequest = (): void => { + pageLoading.value = true + tree({ keyword: search.keyword }) + .then((res) => { + if (res.code === '00000') { + const data = res.data ?? [] + treeData.value = data + if (expandedKeys.value.length === 0) { + expandedKeys.value = collectExpandableKeys(data) + } + if (selectedKeys.value.length > 0) { + /** 已有选中:在最新树中回填节点对象 */ + const matched = findNode(data, selectedKeys.value[0]) + selectedNode.value = matched + if (!matched) { + /** 原选中节点已不存在:回退到首个顶级仓库 */ + selectedKeys.value = data.length ? [data[0].id] : [] + selectedNode.value = data.length ? data[0] : null + } + } else if (data.length > 0) { + /** 默认选中首个顶级仓库,便于直接展示详情 */ + selectedKeys.value = [data[0].id] + selectedNode.value = data[0] + } else { + selectedNode.value = null + } + } + }) + .catch((err: unknown) => { + console.error('仓库区域树请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + /** 搜索:过滤树 */ + const searchQuery = (): void => { + treeRequest() + } + + /** 重置:清空搜索 + 重新拉树 */ + const resetQuery = (): void => { + Object.assign(search, createSearchKey()) + resetTree() + treeRequest() + } + + /** 选中树节点 */ + const onSelect = (keys: (string | number)[]): void => { + const id = keys[0]?.toString() + selectedKeys.value = id ? [id] : [] + selectedNode.value = id ? findNode(treeData.value, id) : null + } + + /** 展开节点 */ + const onExpand = (keys: (string | number)[]): void => { + expandedKeys.value = keys.map((k) => k.toString()) + } + + /** 层级中文标签:1=顶级仓库,2=二级区域,依此类推 */ + const levelLabel = (level: number): string => { + if (level === 1) return '顶级仓库' + const cn = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'] + const text = level <= 10 ? cn[level] : String(level) + return `第${text}级区域` + } + + /** 打开新增弹框(任意层级节点的下级区域) */ + const openAddModal = (parent?: WarehouseNode): void => { + if (parent) { + /** 新增下级区域:level = 父级 + 1,支持任意层级嵌套 */ + addOrEditRef.value?.openModal('add', { + parentId: parent.id, + parentName: parent.name, + level: parent.level + 1, + }) + } else { + /** 新增顶级仓库(保留入口供程序化调用) */ + addOrEditRef.value?.openModal('add', { level: 1 }) + } + } + + /** 打开编辑弹框 */ + const openEditModal = (): void => { + if (!selectedNode.value) { + void message.warning('请先选择一个节点') + return + } + addOrEditRef.value?.openModal('edit', { id: selectedNode.value.id }) + } + + /** 删除节点(二次确认 + 级联提示) */ + const deleteNode = (): void => { + if (!selectedNode.value) { + void message.warning('请先选择一个节点') + return + } + const node = selectedNode.value + const hasChildren = node.children && node.children.length > 0 + Modal.confirm({ + title: '确认删除?', + content: hasChildren + ? `该节点下有 ${node.children?.length} 个子节点,删除后子节点将一并删除,请谨慎操作。` + : '删除后数据不可恢复,请谨慎操作。', + okType: 'danger', + okText: '确认删除', + cancelText: '取消', + onOk: () => { + remove(node.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + selectedKeys.value = [] + selectedNode.value = null + treeRequest() + } + }) + .catch((err: unknown) => { + console.error('仓库区域删除失败:', err) + }) + }, + }) + } + + onMounted(() => { + treeRequest() + }) + + return { + pageLoading, + search, + treeData, + selectedKeys, + expandedKeys, + fieldNames, + selectedNode, + addOrEditRef, + levelLabel, + searchQuery, + resetQuery, + onSelect, + onExpand, + openAddModal, + openEditModal, + deleteNode, + treeRequest, + } +} diff --git a/src/pages/admin-portal/device-center/stock/warehouse/init/useSearch.ts b/src/pages/admin-portal/device-center/stock/warehouse/init/useSearch.ts new file mode 100644 index 0000000..4ab6fac --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/init/useSearch.ts @@ -0,0 +1,6 @@ +import type { SearchForm } from '../types' + +/** 搜索表单工厂:统一重置入口 */ +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, +}) diff --git a/src/pages/admin-portal/device-center/stock/warehouse/init/useTable.ts b/src/pages/admin-portal/device-center/stock/warehouse/init/useTable.ts new file mode 100644 index 0000000..44755c9 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/init/useTable.ts @@ -0,0 +1,72 @@ +import { ref } from 'vue' +import type { WarehouseNode } from '../types' + +/** a-tree 字段映射(与 WarehouseNode 字段对齐) */ +export const treeFieldNames = { + title: 'name', + key: 'id', + children: 'children', +} as const + +/** + * 树形状态:管理树数据、选中、展开 + * - 全部用 ref,模板中自动解包,usePage 中用 .value 访问 + */ +export const useTable = () => { + const treeData = ref([]) + const selectedKeys = ref([]) + const expandedKeys = ref([]) + const pageLoading = ref(false) + + /** 按关键字过滤树(保留命中节点的祖先链) */ + const filterTree = (nodes: WarehouseNode[], keyword: string): WarehouseNode[] => { + const lower = keyword.toLowerCase() + const walk = (list: WarehouseNode[]): WarehouseNode[] => { + const result: WarehouseNode[] = [] + list.forEach((node) => { + const matched = + node.name.toLowerCase().includes(lower) || + node.code.toLowerCase().includes(lower) + const children = node.children ? walk(node.children) : [] + if (matched || children.length > 0) { + result.push({ ...node, children: children.length > 0 ? children : undefined }) + } + }) + return result + } + return walk(nodes) + } + + /** 收集所有非叶子节点 key(用于默认展开顶级仓库) */ + const collectExpandableKeys = (nodes: WarehouseNode[]): string[] => { + const keys: string[] = [] + const walk = (list: WarehouseNode[]): void => { + list.forEach((node) => { + if (node.children && node.children.length > 0) { + keys.push(node.id) + walk(node.children) + } + }) + } + walk(nodes) + return keys + } + + /** 重置树状态 */ + const resetTree = (): void => { + treeData.value = [] + selectedKeys.value = [] + expandedKeys.value = [] + } + + return { + treeData, + selectedKeys, + expandedKeys, + pageLoading, + fieldNames: treeFieldNames, + filterTree, + collectExpandableKeys, + resetTree, + } +} diff --git a/src/pages/admin-portal/device-center/stock/warehouse/types/index.ts b/src/pages/admin-portal/device-center/stock/warehouse/types/index.ts new file mode 100644 index 0000000..265f00f --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/types/index.ts @@ -0,0 +1,67 @@ +import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 仓库区域末级状态:idle 空闲 / occupied 占用 / maintenance 维护中 */ +export type WarehouseStatus = 'idle' | 'occupied' | 'maintenance' + +/** 仓库区域树节点(支持任意层级嵌套,业务上不强制层级深度) */ +export interface WarehouseNode extends BaseRecord { + /** 节点名称 */ + name: string + /** 节点编码(系统按 W01 / W01A01 / W01A01A01 规则自动生成) */ + code: string + /** 层级:1=顶级仓库,2=二级区域,依此类推 */ + level: number + /** 父节点 ID(顶级仓库为空) */ + parentId?: string + /** 父级名称(仅 info 回填时注入,树节点本身不存储) */ + parentName?: string + /** 排序号 */ + sortOrder: number + /** 末级状态:仅叶子节点(无 children)有效;入库出库时由业务联动 */ + status?: WarehouseStatus + /** 备注 */ + remark?: string + /** 子节点 */ + children?: WarehouseNode[] +} + +/** 搜索表单(按名称/编码过滤树) */ +export interface SearchForm extends BaseSearchForm { + keyword?: string +} + +/** 新增/编辑提交载荷 */ +export interface WarehousePayload { + id?: string + name: string + /** code 由系统自动生成,提交时不传;编辑时仅用于回显 */ + code?: string + level: number + parentId?: string + parentName?: string + sortOrder: number + /** 叶子节点专属:末级状态 */ + status?: WarehouseStatus + remark?: string +} + +/** 末级状态选项(弹框 / 筛选用) */ +export const WAREHOUSE_STATUS_OPTIONS: Array<{ label: string; value: WarehouseStatus }> = [ + { label: '空闲', value: 'idle' }, + { label: '占用', value: 'occupied' }, + { label: '维护中', value: 'maintenance' }, +] + +/** 状态 → a-tag color 映射 */ +export const WAREHOUSE_STATUS_COLOR: Record = { + idle: 'green', + occupied: 'red', + maintenance: 'orange', +} + +/** 状态 → 中文标签映射 */ +export const WAREHOUSE_STATUS_LABEL: Record = { + idle: '空闲', + occupied: '占用', + maintenance: '维护中', +} diff --git a/src/pages/admin-portal/device-center/stock/warehouse/warehouse.vue b/src/pages/admin-portal/device-center/stock/warehouse/warehouse.vue new file mode 100644 index 0000000..10761b1 --- /dev/null +++ b/src/pages/admin-portal/device-center/stock/warehouse/warehouse.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/api/index.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/api/index.ts deleted file mode 100644 index 60b475f..0000000 --- a/src/pages/admin-portal/device-center/tenantSite/tenant/api/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ApiResponse } from '@axios' -import { postRequest } from '@axios' -import type { SearchForm, TenantItem } from '../types' - -/** 查询租户列表(与运营中心 tenantList 共用后端接口,设备中心只读消费) */ -export const list = (params: SearchForm): Promise> => - postRequest('axiosRequest', '/admin/tenant/page', params) diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/init/usePage.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/init/usePage.ts deleted file mode 100644 index 0c4607b..0000000 --- a/src/pages/admin-portal/device-center/tenantSite/tenant/init/usePage.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { onMounted, reactive, ref } from 'vue' -import type { TablePaginationConfig } from 'ant-design-vue' -import { createPaginationConfig } from '@utils/antDesign/table' -import type { TableState } from '@utils/antDesign/table' -import { list } from '../api' -import type { SearchForm, TenantItem } from '../types' -import { createSearchKey } from './useSearch' -import { tableColumns } from './useTable' - -/** - * 租户管理页主编排: - * - 只读复用运营中心 tenantList 数据源 - * - 不提供新增/编辑/删除等操作按钮,仅展示 - * - 操作列省略 - */ -export const usePage = () => { - const search = reactive(createSearchKey()) - - const table = reactive>({ - columns: tableColumns, - dataSource: [], - sort: { field: '', order: null }, - pagination: createPaginationConfig() as TablePaginationConfig, - }) - - const pageLoading = ref(false) - - const listRequest = (): void => { - pageLoading.value = true - list(search) - .then((res) => { - if (res.code === '00000') { - table.dataSource = res.data ?? [] - table.pagination.total = table.dataSource.length - } - }) - .catch((err: unknown) => { - console.error('租户列表请求失败:', err) - }) - .finally(() => { - pageLoading.value = false - }) - } - - const searchQuery = (): void => { - table.pagination.current = 1 - listRequest() - } - - const resetQuery = (): void => { - Object.assign(search, createSearchKey()) - table.pagination.current = 1 - listRequest() - } - - const dataSourceChange = (pagination: TablePaginationConfig): void => { - table.pagination.current = pagination.current ?? 1 - table.pagination.pageSize = pagination.pageSize ?? 30 - } - - onMounted(() => { - listRequest() - }) - - return { - pageLoading, - search, - table, - searchQuery, - resetQuery, - dataSourceChange, - } -} diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/init/useTable.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/init/useTable.ts deleted file mode 100644 index 7607f92..0000000 --- a/src/pages/admin-portal/device-center/tenantSite/tenant/init/useTable.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { TableColumnsType } from 'ant-design-vue' -import { h } from 'vue' -import { Tag } from 'ant-design-vue' - -/** 租户只读列表列定义(无操作列) */ -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: 180, resizable: true, ellipsis: true }, - { title: '租户编码', dataIndex: 'code', align: 'left', width: 140, resizable: true, ellipsis: true }, - { title: '联系人', dataIndex: 'contactName', align: 'left', width: 120, resizable: true, ellipsis: true }, - { title: '联系电话', dataIndex: 'contactPhone', align: 'center', width: 130, resizable: true, ellipsis: true }, - { title: '关联场所数', dataIndex: 'siteCount', align: 'center', width: 110 }, - { title: '授权应用数', dataIndex: 'appCount', align: 'center', width: 110 }, - { - title: '状态', - dataIndex: 'status', - align: 'center', - width: 100, - customRender: ({ value }: { value: boolean }) => - h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '启用' : '停用')), - }, - { title: '到期时间', dataIndex: 'expireTime', align: 'center', width: 130, resizable: true }, - { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 170, sorter: true, resizable: true }, -] diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/tenant.vue b/src/pages/admin-portal/device-center/tenantSite/tenant/tenant.vue deleted file mode 100644 index b677eac..0000000 --- a/src/pages/admin-portal/device-center/tenantSite/tenant/tenant.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/types/index.ts b/src/pages/admin-portal/device-center/tenantSite/tenant/types/index.ts deleted file mode 100644 index 9091169..0000000 --- a/src/pages/admin-portal/device-center/tenantSite/tenant/types/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' - -/** 租户档案(只读视图,与运营中心 tenantList 数据同源) */ -export interface TenantItem extends BaseRecord { - /** 租户名称 */ - name: string - /** 租户编码 */ - code: string - /** 联系人 */ - contactName?: string - /** 联系电话 */ - contactPhone?: string - /** 地址 */ - address?: string - /** 授权应用数 */ - appCount?: number - /** 关联布点场所数 */ - siteCount?: number - /** 到期时间 */ - expireTime: string - /** 启用状态 */ - status: boolean - /** 创建时间 */ - createTime: string -} - -/** 搜索表单 */ -export interface SearchForm extends BaseSearchForm { - name?: string - status?: boolean -} diff --git a/src/pages/admin-portal/device-center/third-auth/amap/amap.vue b/src/pages/admin-portal/device-center/third-auth/amap/amap.vue new file mode 100644 index 0000000..331ff7d --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/amap.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/third-auth/amap/api/index.ts b/src/pages/admin-portal/device-center/third-auth/amap/api/index.ts new file mode 100644 index 0000000..0360757 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/api/index.ts @@ -0,0 +1,23 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { AmapItem, AmapPayload, SearchForm } from '../types' + +/** 查询高德应用列表 */ +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/page', params) + +/** 查询高德应用详情 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/info', { id }) + +/** 新增高德应用 */ +export const add = (payload: AmapPayload): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/add', payload) + +/** 更新高德应用 */ +export const update = (payload: AmapPayload): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/update', payload) + +/** 删除高德应用 */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/delete', { id }) diff --git a/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/api/index.ts new file mode 100644 index 0000000..17d9866 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/api/index.ts @@ -0,0 +1,11 @@ +/** + * 高德应用 - 详情抽屉 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { DetailInfo } from '../types' + +/** 详情查询 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/info', { id }) diff --git a/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/info.vue new file mode 100644 index 0000000..7adbde0 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/info.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..872bc71 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/init/usePage.ts @@ -0,0 +1,48 @@ +/** + * 高德应用 - 详情抽屉 - 主逻辑 + */ + +import { info } from '../api' +import type { DetailInfo, OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '应用详情', + width: 640, + spin: false, + }) + + const detail = ref(null) + + const closeDrawer = (): void => { + pageInfo.visible = false + detail.value = null + } + + const openDrawer = (ctx: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + detail.value = null + + info(ctx.id) + .then((res) => { + if (res.code === '00000' && res.data) { + detail.value = res.data + } + }) + .catch((err: unknown) => { + console.error('高德应用详情查询失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + return { + pageInfo, + detail, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/types/index.ts new file mode 100644 index 0000000..0723e37 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/component/drawer/info/types/index.ts @@ -0,0 +1,32 @@ +/** 抽屉页面状态 */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +/** 打开抽屉上下文 */ +export interface OpenContext { + /** 必传 */ + id: string +} + +/** 详情数据(与列表项 AmapItem 同构) */ +export interface DetailInfo { + id: string + /** 应用名称 */ + name: string + /** API Key */ + apiKey: string + /** 安全密钥 */ + securityCode?: string + /** 用途说明 */ + usage?: string + /** 启用状态 */ + status: boolean + /** 备注 */ + remark?: string + /** 创建时间 */ + createTime: string +} diff --git a/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..f322fb1 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..706c1e3 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,20 @@ +/** + * 高德应用 - 新增/编辑 弹框 - 接口层 + * - 复用主页的 info / add / update + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +/** 详情查询(编辑回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/amap/update', params) diff --git a/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..c589a99 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,145 @@ +/** + * 高德应用 - 新增/编辑 弹框 - 主逻辑 + * + * 严格遵循 modal-spec.md: + * - createInitForm 工厂函数 + * - openModal 用 Promise.all 拉取详情 + * - addRequest 必须 delete params.id + * - submit 开头立即 pageInfo.spin = true + * - 用 params.id 判断新增/编辑 + */ + +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' + +/** 初始表单工厂 */ +const createInitForm = (): Form => ({ + id: undefined, + name: '', + apiKey: '', + securityCode: '', + usage: '', + status: true, + remark: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 640, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + const rules: Record = { + name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }], + apiKey: [{ required: true, message: '请输入 API Key', trigger: 'blur' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const infoRequest = (id: string) => info(id) + + /** 打开弹框 */ + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + pageInfo.title = type === 'add' ? '新增应用' : '编辑应用' + + const requests: Promise[] = [] + if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id)) + + Promise.all(requests) + .then((results) => { + if (type === 'edit' && ctx?.id) { + const infoRes = results[0] as { code: string; data: Form } + if (infoRes?.code === '00000' && infoRes.data) { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .catch((err: unknown) => { + console.error('打开弹框初始化失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const successRequest = (msg: string): void => { + void message.success(msg) + emit('load') + closeModal() + } + + const addRequest = (params: Record): void => { + delete params.id + add(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('新增失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const editRequest = (params: Record): void => { + edit(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('编辑失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const submit = (): void => { + pageInfo.spin = true + formRef.value + ?.validate() + .then(() => { + const params = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..c2345bf --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,42 @@ +import type { FormInstance } from 'ant-design-vue/es/form' + +/** 弹框类型 */ +export type ModalType = 'add' | 'edit' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 打开弹框上下文 */ +export interface OpenContext { + /** 编辑模式必传 */ + id?: string +} + +/** 表单字段(对齐 AmapPayload) */ +export interface Form { + id: string | undefined + /** 应用名称 */ + name: string + /** API Key */ + apiKey: string + /** 安全密钥 */ + securityCode: string + /** 用途说明 */ + usage: string + /** 启用状态 */ + status: boolean + /** 备注 */ + remark: string +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/third-auth/amap/init/usePage.ts b/src/pages/admin-portal/device-center/third-auth/amap/init/usePage.ts new file mode 100644 index 0000000..598aa4c --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/init/usePage.ts @@ -0,0 +1,151 @@ +import { onMounted, reactive, ref } from 'vue' +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 AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue' +import InfoDrawer from '../component/drawer/info/info.vue' +import { list, remove } from '../api' +import type { AmapItem, SearchForm } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +/** + * 高德地图应用页主编排: + * - 标准 CRUD + 详情抽屉 + * - key 不区分类型,统一以应用名称 + API Key 维护 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + /** 新增/编辑弹框 ref */ + const addOrEditRef = ref | null>(null) + /** 详情抽屉 ref */ + const infoDrawerRef = ref | null>(null) + + const listRequest = (): void => { + pageLoading.value = true + list(search) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = table.dataSource.length + } + }) + .catch((err: unknown) => { + console.error('高德应用列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + Object.assign(search, createSearchKey()) + table.pagination.current = 1 + listRequest() + } + + const dataSourceChange = (pagination: TablePaginationConfig): void => { + table.pagination.current = pagination.current ?? 1 + table.pagination.pageSize = pagination.pageSize ?? 30 + } + + const rowSelection = { + selectedRowKeys, + onChange: (keys: (string | number)[]): void => { + selectedRowKeys.value = keys + }, + } + + /** 打开新增弹框 */ + const openAddModal = (): void => { + addOrEditRef.value?.openModal('add') + } + + /** 打开编辑弹框 */ + const openEditModal = (record: AmapItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + /** 打开详情抽屉 */ + const openInfoDrawer = (record: AmapItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id }) + } + + const deleteRecord = (record: AmapItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `应用「${record.name}」删除后不可恢复,依赖此 Key 的地图服务将不可用,请谨慎操作。`, + okType: 'danger', + okText: '确认删除', + cancelText: '取消', + onOk: () => { + remove(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('高德应用删除失败:', err) + }) + }, + }) + } + + const handleAction = (type: string, record?: AmapItem): void => { + if (type === 'add') { + openAddModal() + return + } + if (type === 'edit' && record) { + openEditModal(record) + return + } + if (type === 'detail' && record) { + openInfoDrawer(record) + return + } + if (type === 'delete' && record) { + deleteRecord(record) + } + } + + onMounted(() => { + listRequest() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + addOrEditRef, + infoDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/tenantSite/tenant/init/useSearch.ts b/src/pages/admin-portal/device-center/third-auth/amap/init/useSearch.ts similarity index 88% rename from src/pages/admin-portal/device-center/tenantSite/tenant/init/useSearch.ts rename to src/pages/admin-portal/device-center/third-auth/amap/init/useSearch.ts index ecb36e5..7f0ffc2 100644 --- a/src/pages/admin-portal/device-center/tenantSite/tenant/init/useSearch.ts +++ b/src/pages/admin-portal/device-center/third-auth/amap/init/useSearch.ts @@ -2,6 +2,6 @@ import type { SearchForm } from '../types' /** 搜索表单工厂:统一重置入口 */ export const createSearchKey = (): SearchForm => ({ - name: undefined, + keyword: undefined, status: undefined, }) diff --git a/src/pages/admin-portal/device-center/third-auth/amap/init/useTable.ts b/src/pages/admin-portal/device-center/third-auth/amap/init/useTable.ts new file mode 100644 index 0000000..99170fb --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/init/useTable.ts @@ -0,0 +1,25 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { AmapItem } from '../types' + +/** 高德地图应用列定义 */ +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: 200, resizable: true, ellipsis: true }, + { title: 'API Key', dataIndex: 'apiKey', align: 'left', width: 300, resizable: true, ellipsis: true }, + { title: '安全密钥', dataIndex: 'securityCode', align: 'left', width: 200, resizable: true, ellipsis: true }, + { title: '用途说明', dataIndex: 'usage', align: 'left', width: 260, resizable: true, ellipsis: true }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 90, + customRender: ({ value }: { value: boolean }) => + h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '启用' : '停用')), + }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 170, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] + +export type { AmapItem } diff --git a/src/pages/admin-portal/device-center/third-auth/amap/types/index.ts b/src/pages/admin-portal/device-center/third-auth/amap/types/index.ts new file mode 100644 index 0000000..d7dcc12 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/amap/types/index.ts @@ -0,0 +1,36 @@ +import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 高德地图应用列表项(1 条 = 1 个应用凭证) */ +export interface AmapItem extends BaseRecord { + /** 应用名称 */ + name: string + /** 高德 API Key */ + apiKey: string + /** 安全密钥(高德 2021 年底新增) */ + securityCode?: string + /** 用途说明 */ + usage?: string + /** 启用状态 */ + status: boolean + /** 备注 */ + remark?: string + /** 创建时间 */ + createTime: string +} + +/** 搜索表单 */ +export interface SearchForm extends BaseSearchForm { + keyword?: string + status?: boolean +} + +/** 新增/编辑提交载荷 */ +export interface AmapPayload { + id?: string + name: string + apiKey: string + securityCode?: string + usage?: string + status?: boolean + remark?: string +} diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/api/index.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/api/index.ts new file mode 100644 index 0000000..67e651d --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/api/index.ts @@ -0,0 +1,23 @@ +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ArcItem, ArcPayload, SearchForm } from '../types' + +/** 查询虹软授权列表 */ +export const list = (params: SearchForm): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/page', params) + +/** 查询虹软授权详情 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/info', { id }) + +/** 新增虹软授权 */ +export const add = (payload: ArcPayload): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/add', payload) + +/** 更新虹软授权 */ +export const update = (payload: ArcPayload): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/update', payload) + +/** 删除虹软授权 */ +export const remove = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/delete', { id }) diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/arcsoft.vue b/src/pages/admin-portal/device-center/third-auth/arcsoft/arcsoft.vue new file mode 100644 index 0000000..a100074 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/arcsoft.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/api/index.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/api/index.ts new file mode 100644 index 0000000..f59a4fe --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/api/index.ts @@ -0,0 +1,11 @@ +/** + * 虹软授权 - 详情抽屉 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { DetailInfo } from '../types' + +/** 详情查询 */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/info', { id }) diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/info.vue b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/info.vue new file mode 100644 index 0000000..291df2e --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/info.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/init/usePage.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/init/usePage.ts new file mode 100644 index 0000000..48e7b1e --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/init/usePage.ts @@ -0,0 +1,48 @@ +/** + * 虹软授权 - 详情抽屉 - 主逻辑 + */ + +import { info } from '../api' +import type { DetailInfo, OpenContext, PageInfo } from '../types' + +export const usePage = () => { + const pageInfo = reactive({ + visible: false, + title: '授权详情', + width: 720, + spin: false, + }) + + const detail = ref(null) + + const closeDrawer = (): void => { + pageInfo.visible = false + detail.value = null + } + + const openDrawer = (ctx: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + detail.value = null + + info(ctx.id) + .then((res) => { + if (res.code === '00000' && res.data) { + detail.value = res.data + } + }) + .catch((err: unknown) => { + console.error('虹软授权详情查询失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + return { + pageInfo, + detail, + openDrawer, + closeDrawer, + } +} diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/types/index.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/types/index.ts new file mode 100644 index 0000000..8a4ff9e --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/drawer/info/types/index.ts @@ -0,0 +1,44 @@ +import type { ArcStatus } from '../../../types' + +/** 抽屉页面状态 */ +export interface PageInfo { + visible: boolean + title: string + width: number + spin: boolean +} + +/** 打开抽屉上下文 */ +export interface OpenContext { + /** 必传 */ + id: string +} + +/** 详情数据(与列表项 ArcItem 同构) */ +export interface DetailInfo { + id: string + /** 激活码 */ + activeKey: string + /** AppID */ + appId: string + /** SDK Key */ + sdkKey: string + /** SDK Secret */ + sdkSecret: string + /** 设备 SN */ + deviceSn: string + /** 设备名称 */ + deviceName?: string + /** 设备型号 */ + deviceModel?: string + /** 激活时间 */ + activateTime: string + /** 到期时间 */ + expireTime: string + /** 授权状态 */ + status: ArcStatus + /** 备注 */ + remark?: string + /** 创建时间 */ + createTime: string +} diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/addOrEdit.vue b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..3becd5f --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,183 @@ + + + diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/api/index.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..69310d8 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,20 @@ +/** + * 虹软授权 - 新增/编辑 弹框 - 接口层 + * - 复用主页的 info / add / update + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { Form } from '../types' + +/** 详情查询(编辑回填) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/info', { id }) + +/** 新增 */ +export const add = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/add', params) + +/** 编辑 */ +export const edit = (params: Record): Promise> => + postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/update', params) diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/init/usePage.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..fb67a42 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,155 @@ +/** + * 虹软授权 - 新增/编辑 弹框 - 主逻辑 + * + * 严格遵循 modal-spec.md: + * - createInitForm 工厂函数 + * - openModal 用 Promise.all 拉取详情 + * - addRequest 必须 delete params.id + * - submit 开头立即 pageInfo.spin = true + * - 用 params.id 判断新增/编辑 + */ + +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' + +/** 初始表单工厂 */ +const createInitForm = (): Form => ({ + id: undefined, + activeKey: '', + appId: '', + sdkKey: '', + sdkSecret: '', + deviceSn: '', + deviceName: '', + deviceModel: '', + activateTime: '', + expireTime: '', + status: 'inactive', + remark: '', +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 820, + spin: false, + }) + + const formRef = ref() + const form = ref(createInitForm()) + + const rules: Record = { + activeKey: [{ required: true, message: '请输入激活码', trigger: 'blur' }], + appId: [{ required: true, message: '请输入 AppID', trigger: 'blur' }], + sdkKey: [{ required: true, message: '请输入 SDK Key', trigger: 'blur' }], + sdkSecret: [{ required: true, message: '请输入 SDK Secret', trigger: 'blur' }], + deviceSn: [{ required: true, message: '请输入设备 SN', trigger: 'blur' }], + expireTime: [{ required: true, message: '请选择到期时间', trigger: 'change' }], + status: [{ required: true, message: '请选择状态', trigger: 'change' }], + } + + const resetForm = (): void => { + formRef.value?.clearValidate() + form.value = createInitForm() + } + + const closeModal = (): void => { + resetForm() + pageInfo.visible = false + } + + const infoRequest = (id: string) => info(id) + + /** 打开弹框 */ + const openModal = (type: ModalType, ctx?: OpenContext): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + pageInfo.title = type === 'add' ? '新增授权' : '编辑授权' + + const requests: Promise[] = [] + if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id)) + + Promise.all(requests) + .then((results) => { + if (type === 'edit' && ctx?.id) { + const infoRes = results[0] as { code: string; data: Form } + if (infoRes?.code === '00000' && infoRes.data) { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .catch((err: unknown) => { + console.error('打开弹框初始化失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const successRequest = (msg: string): void => { + void message.success(msg) + emit('load') + closeModal() + } + + const addRequest = (params: Record): void => { + delete params.id + add(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('新增失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const editRequest = (params: Record): void => { + edit(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('编辑失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const submit = (): void => { + pageInfo.spin = true + formRef.value + ?.validate() + .then(() => { + const params = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + return { + pageInfo, + formRef, + form, + rules, + openModal, + closeModal, + submit, + } +} diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/types/index.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..f048d3d --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,53 @@ +import type { FormInstance } from 'ant-design-vue/es/form' +import type { ArcStatus } from '../../../types' + +/** 弹框类型 */ +export type ModalType = 'add' | 'edit' + +/** 弹框页面状态 */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 打开弹框上下文 */ +export interface OpenContext { + /** 编辑模式必传 */ + id?: string +} + +/** 表单字段(对齐 ArcPayload) */ +export interface Form { + id: string | undefined + /** 激活码 */ + activeKey: string + /** AppID */ + appId: string + /** SDK Key */ + sdkKey: string + /** SDK Secret */ + sdkSecret: string + /** 设备 SN */ + deviceSn: string + /** 设备名称 */ + deviceName: string + /** 设备型号 */ + deviceModel: string + /** 激活时间 */ + activateTime: string + /** 到期时间 */ + expireTime: string + /** 授权状态 */ + status: ArcStatus + /** 备注 */ + remark: string +} + +/** 暴露给父组件的 ref 类型 */ +export type FormRef = ReturnType + +/** 仅用于类型推导的占位函数 */ +declare function usePageRef(): FormInstance | undefined diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/init/usePage.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/init/usePage.ts new file mode 100644 index 0000000..c7626b9 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/init/usePage.ts @@ -0,0 +1,151 @@ +import { onMounted, reactive, ref } from 'vue' +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 AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue' +import InfoDrawer from '../component/drawer/info/info.vue' +import { list, remove } from '../api' +import type { ArcItem, SearchForm } from '../types' +import { createSearchKey } from './useSearch' +import { tableColumns } from './useTable' + +/** + * 虹软人脸授权页主编排: + * - 标准 CRUD + 详情抽屉 + * - 每条记录 = 一台设备的激活码,不可重复绑定 + */ +export const usePage = () => { + const { message, Modal } = useAntdStaticMethods() + + const search = reactive(createSearchKey()) + + const table = reactive>({ + columns: tableColumns, + dataSource: [], + sort: { field: '', order: null }, + pagination: createPaginationConfig() as TablePaginationConfig, + }) + + const pageLoading = ref(false) + const selectedRowKeys = ref<(string | number)[]>([]) + + /** 新增/编辑弹框 ref */ + const addOrEditRef = ref | null>(null) + /** 详情抽屉 ref */ + const infoDrawerRef = ref | null>(null) + + const listRequest = (): void => { + pageLoading.value = true + list(search) + .then((res) => { + if (res.code === '00000') { + table.dataSource = res.data ?? [] + table.pagination.total = table.dataSource.length + } + }) + .catch((err: unknown) => { + console.error('虹软授权列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + Object.assign(search, createSearchKey()) + table.pagination.current = 1 + listRequest() + } + + const dataSourceChange = (pagination: TablePaginationConfig): void => { + table.pagination.current = pagination.current ?? 1 + table.pagination.pageSize = pagination.pageSize ?? 30 + } + + const rowSelection = { + selectedRowKeys, + onChange: (keys: (string | number)[]): void => { + selectedRowKeys.value = keys + }, + } + + /** 打开新增弹框 */ + const openAddModal = (): void => { + addOrEditRef.value?.openModal('add') + } + + /** 打开编辑弹框 */ + const openEditModal = (record: ArcItem): void => { + addOrEditRef.value?.openModal('edit', { id: record.id }) + } + + /** 打开详情抽屉 */ + const openInfoDrawer = (record: ArcItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id }) + } + + const deleteRecord = (record: ArcItem): void => { + Modal.confirm({ + title: '确认删除?', + content: `激活码「${record.activeKey}」删除后不可恢复,绑定设备「${record.deviceSn ?? '—'}」将失去授权,请谨慎操作。`, + okType: 'danger', + okText: '确认删除', + cancelText: '取消', + onOk: () => { + remove(record.id) + .then((res) => { + if (res.code === '00000') { + void message.success(res.msg) + listRequest() + } + }) + .catch((err: unknown) => { + console.error('虹软授权删除失败:', err) + }) + }, + }) + } + + const handleAction = (type: string, record?: ArcItem): void => { + if (type === 'add') { + openAddModal() + return + } + if (type === 'edit' && record) { + openEditModal(record) + return + } + if (type === 'detail' && record) { + openInfoDrawer(record) + return + } + if (type === 'delete' && record) { + deleteRecord(record) + } + } + + onMounted(() => { + listRequest() + }) + + return { + pageLoading, + search, + table, + selectedRowKeys, + rowSelection, + addOrEditRef, + infoDrawerRef, + searchQuery, + resetQuery, + dataSourceChange, + handleAction, + listRequest, + } +} diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/init/useSearch.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/init/useSearch.ts new file mode 100644 index 0000000..7f0ffc2 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/init/useSearch.ts @@ -0,0 +1,7 @@ +import type { SearchForm } from '../types' + +/** 搜索表单工厂:统一重置入口 */ +export const createSearchKey = (): SearchForm => ({ + keyword: undefined, + status: undefined, +}) diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/init/useTable.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/init/useTable.ts new file mode 100644 index 0000000..b93b2bf --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/init/useTable.ts @@ -0,0 +1,37 @@ +import type { TableColumnsType } from 'ant-design-vue' +import { h } from 'vue' +import { Tag } from 'ant-design-vue' +import type { ArcItem, ArcStatus } from '../types' +import { arcStatusColor, arcStatusText } from '../types' + +/** 虹软授权列定义 */ +export const tableColumns: TableColumnsType = [ + { title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 }, + { title: '激活码', dataIndex: 'activeKey', align: 'left', width: 240, resizable: true, ellipsis: true }, + { title: '设备 SN', dataIndex: 'deviceSn', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 180, resizable: true, ellipsis: true }, + { title: '设备型号', dataIndex: 'deviceModel', align: 'center', width: 120, resizable: true, ellipsis: true }, + { title: 'AppID', dataIndex: 'appId', align: 'left', width: 200, resizable: true, ellipsis: true }, + { title: '激活时间', dataIndex: 'activateTime', align: 'center', width: 120, resizable: true }, + { title: '到期时间', dataIndex: 'expireTime', align: 'center', width: 120, resizable: true }, + { + title: '状态', + dataIndex: 'status', + align: 'center', + width: 100, + customRender: ({ value }: { value: ArcStatus }) => + h(Tag, { color: arcStatusColor(value) }, () => arcStatusText(value)), + }, + { title: '创建时间', dataIndex: 'createTime', align: 'center', width: 170, sorter: true, resizable: true }, + { title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 }, +] + +/** 状态过滤选项(用于搜索栏) */ +export const STATUS_OPTIONS = [ + { label: '未激活', value: 'inactive' }, + { label: '已激活', value: 'active' }, + { label: '已过期', value: 'expired' }, + { label: '已停用', value: 'disabled' }, +] + +export type { ArcItem } diff --git a/src/pages/admin-portal/device-center/third-auth/arcsoft/types/index.ts b/src/pages/admin-portal/device-center/third-auth/arcsoft/types/index.ts new file mode 100644 index 0000000..ad9fdd1 --- /dev/null +++ b/src/pages/admin-portal/device-center/third-auth/arcsoft/types/index.ts @@ -0,0 +1,77 @@ +import type { BaseRecord, BaseSearchForm } from '@pages/admin-portal/shared/types' + +/** 虹软授权状态枚举 */ +export type ArcStatus = 'inactive' | 'active' | 'expired' | 'disabled' + +/** 虹软人脸授权列表项(每条 = 一台设备的激活码) */ +export interface ArcItem extends BaseRecord { + /** 激活码(与设备硬件 1:1 绑定) */ + activeKey: string + /** 虹软应用 AppID(同租户共享) */ + appId: string + /** SDK Key */ + sdkKey: string + /** SDK Secret */ + sdkSecret: string + /** 绑定设备 SN */ + deviceSn: string + /** 设备名称 */ + deviceName?: string + /** 设备型号 */ + deviceModel?: string + /** 激活时间 */ + activateTime: string + /** 到期时间 */ + expireTime: string + /** 授权状态 */ + status: ArcStatus + /** 备注 */ + remark?: string + /** 创建时间 */ + createTime: string +} + +/** 搜索表单 */ +export interface SearchForm extends BaseSearchForm { + keyword?: string + status?: ArcStatus +} + +/** 新增/编辑提交载荷 */ +export interface ArcPayload { + id?: string + activeKey: string + appId: string + sdkKey: string + sdkSecret: string + deviceSn: string + deviceName?: string + deviceModel?: string + activateTime?: string + expireTime: string + status: ArcStatus + remark?: string +} + +/** 授权状态下拉选项 */ +export const ARC_STATUS_OPTIONS = [ + { label: '未激活', value: 'inactive' }, + { label: '已激活', value: 'active' }, + { label: '已过期', value: 'expired' }, + { label: '已停用', value: 'disabled' }, +] + +/** 状态 → 中文 */ +export const arcStatusText = (value: string): string => + ARC_STATUS_OPTIONS.find((item) => item.value === value)?.label ?? value ?? '—' + +/** 状态 → Tag 颜色 */ +export const arcStatusColor = (value: ArcStatus): string => { + const map: Record = { + inactive: 'default', + active: 'green', + expired: 'orange', + disabled: 'red', + } + return map[value] ?? 'default' +} diff --git a/src/pages/miniprogram/ops/scan/sceneMenu.vue b/src/pages/miniprogram/ops/scan/sceneMenu.vue index 18f492a..e401bde 100644 --- a/src/pages/miniprogram/ops/scan/sceneMenu.vue +++ b/src/pages/miniprogram/ops/scan/sceneMenu.vue @@ -57,7 +57,7 @@ /** * 实施运维小程序 / 扫码场景操作菜单 * - 设备摘要卡(编码 / 型号 / 状态 / 待处理工单红点) - * - 6 项操作按设备状态过滤显示 + * - 9 项操作按设备状态过滤显示(6 项运维 + 3 项库存作业) */ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' @@ -76,6 +76,8 @@ interface SceneAction { icon: string path: (deviceId: string) => string statuses: DeviceStatus[] + /** 库存相关场景独立于设备生命周期,可与任意状态并存 */ + isStock?: boolean } const ALL_ACTIONS: SceneAction[] = [ @@ -109,6 +111,25 @@ const ALL_ACTIONS: SceneAction[] = [ icon: 'eye-o', path: (id) => `/miniprogram/ops/monitor/${id}`, statuses: ['activated', 'inUse', 'scrapped'], }, + // 库存作业 3 个场景(扫码进入时自动带入 deviceCode) + { + key: 'stock-inbound', label: '扫码入库', desc: '将本设备登记到收货入库清单', + icon: 'after-sale', path: (code) => `/miniprogram/ops/stock-inbound?deviceCode=${code}`, + statuses: ['inactive', 'activated', 'inUse', 'scrapped'], + isStock: true, + }, + { + key: 'stock-outbound', label: '扫码出库', desc: '将本设备加入装机出库清单', + icon: 'logistics', path: (code) => `/miniprogram/ops/stock-outbound?deviceCode=${code}`, + statuses: ['inactive', 'activated', 'inUse', 'scrapped'], + isStock: true, + }, + { + key: 'stock-inventory', label: '扫码盘点', desc: '在当前盘点单中标记本设备实盘结果', + icon: 'balance-list-o', path: (code) => `/miniprogram/ops/stock-inventory?deviceCode=${code}&id=inv-006`, + statuses: ['inactive', 'activated', 'inUse', 'scrapped'], + isStock: true, + }, ] const availableActions = computed(() => { diff --git a/src/pages/miniprogram/ops/workbench/Home.vue b/src/pages/miniprogram/ops/workbench/Home.vue index 1369036..a3418b0 100644 --- a/src/pages/miniprogram/ops/workbench/Home.vue +++ b/src/pages/miniprogram/ops/workbench/Home.vue @@ -87,14 +87,15 @@ - +
-
快捷入口
+
库存作业
@@ -110,13 +111,13 @@ * 实施运维小程序 / 工作台首页 * - 5 张待办卡(含告警卡,告警作为重点跨整行展示) * - 3 张本周统计卡 - * - 4 个快捷入口 + * - 4 个库存作业入口(收货入库 / 装机出库 / 库存盘点 / 库存查询) */ import { onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { getTodoStats, getWeekStats } from './api' import { formatDate, weekDayText } from '@utils/datetime' -import type { QuickEntry, TodoStats, WeekStats } from './types' +import type { StockEntry, TodoStats, WeekStats } from './types' const router = useRouter() @@ -128,11 +129,12 @@ const todayText = (() => { return `${formatDate(d)} 周${weekDayText(d).replace('周', '')}` })() -const quickEntries: QuickEntry[] = [ - { key: 'scan', label: '扫码', icon: 'scan-o', path: '/miniprogram/ops/scan' }, - { key: 'message', label: '消息中心', icon: 'chat-o', path: '/miniprogram/ops/message-center' }, - { key: 'inspect', label: '巡检', icon: 'todo-list-o', path: '/miniprogram/ops/inspect' }, - { key: 'me', label: '个人统计', icon: 'user-o', path: '/miniprogram/ops/me' }, +/** 库存作业入口(替代原快捷入口,对齐现场扫码作业场景) */ +const stockEntries: StockEntry[] = [ + { key: 'inbound', label: '收货入库', icon: 'after-sale', tone: 'green', path: '/miniprogram/ops/stock-inbound' }, + { key: 'outbound', label: '装机出库', icon: 'logistics', tone: 'orange', path: '/miniprogram/ops/stock-outbound' }, + { key: 'inventory', label: '库存盘点', icon: 'balance-list-o', tone: 'blue', path: '/miniprogram/ops/stock-inventory' }, + { key: 'list', label: '库存查询', icon: 'search', tone: 'gray', path: '/miniprogram/ops/stock-list' }, ] const goPath = (path: string): void => { @@ -410,6 +412,7 @@ onMounted(() => { text-align: center; box-shadow: var(--shadow-sm); transition: transform 0.15s ease; + border-top: 2px solid transparent; &:active { transform: scale(0.96); @@ -417,7 +420,6 @@ onMounted(() => { &__icon { font-size: 24px; - color: var(--primary); } &__label { @@ -425,5 +427,22 @@ onMounted(() => { font-size: var(--font-xs); color: var(--text-primary); } + + &--green { + border-top-color: var(--success); + .quick-entry__icon { color: var(--success); } + } + &--orange { + border-top-color: #fa8c16; + .quick-entry__icon { color: #fa8c16; } + } + &--blue { + border-top-color: var(--primary); + .quick-entry__icon { color: var(--primary); } + } + &--gray { + border-top-color: #8c8c8c; + .quick-entry__icon { color: #595959; } + } } diff --git a/src/pages/miniprogram/ops/workbench/stockInbound.vue b/src/pages/miniprogram/ops/workbench/stockInbound.vue new file mode 100644 index 0000000..1453306 --- /dev/null +++ b/src/pages/miniprogram/ops/workbench/stockInbound.vue @@ -0,0 +1,450 @@ + + + + + diff --git a/src/pages/miniprogram/ops/workbench/stockInventory.vue b/src/pages/miniprogram/ops/workbench/stockInventory.vue new file mode 100644 index 0000000..9b87a21 --- /dev/null +++ b/src/pages/miniprogram/ops/workbench/stockInventory.vue @@ -0,0 +1,470 @@ + + + + + diff --git a/src/pages/miniprogram/ops/workbench/stockList.vue b/src/pages/miniprogram/ops/workbench/stockList.vue new file mode 100644 index 0000000..c84b544 --- /dev/null +++ b/src/pages/miniprogram/ops/workbench/stockList.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/src/pages/miniprogram/ops/workbench/stockOutbound.vue b/src/pages/miniprogram/ops/workbench/stockOutbound.vue new file mode 100644 index 0000000..ed3e897 --- /dev/null +++ b/src/pages/miniprogram/ops/workbench/stockOutbound.vue @@ -0,0 +1,500 @@ + + + + + diff --git a/src/pages/miniprogram/ops/workbench/types.ts b/src/pages/miniprogram/ops/workbench/types.ts index 34eb700..9d9aa89 100644 --- a/src/pages/miniprogram/ops/workbench/types.ts +++ b/src/pages/miniprogram/ops/workbench/types.ts @@ -23,11 +23,13 @@ export interface WeekStats { monthInstallCount: number } -/** 快捷入口项 */ -export interface QuickEntry { +/** 库存作业入口项(工作台 4 个库存操作) */ +export interface StockEntry { key: string label: string icon: string + /** 配色:green=收货入库 / orange=装机出库 / blue=库存盘点 / gray=库存查询 */ + tone: 'green' | 'orange' | 'blue' | 'gray' path: string }