feat: 设备中心客户/库存菜单重组 + 小程序库存作业落地

- 后台:供应商从库存管理迁入客户管理;新增库存盘点页(应盘清单 + 审核流程 + 差异汇总);同步文档与 nav.ts
- 小程序:工作台首页替换"快捷入口"为"库存作业"区(收货入库 / 装机出库 / 库存盘点 / 库存查询),新增 4 个 subPage + 配套 mock;扫码场景菜单追加 3 个库存场景,支持从扫码进入自动带入 deviceCode
- 其它存量补齐:客户档案、仓库区域、第三方授权(虹软 / 高德)页面与 mock
This commit is contained in:
fengpu
2026-07-07 20:20:49 +08:00
parent 011111655f
commit 7d8c126e26
114 changed files with 7627 additions and 269 deletions
@@ -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 = <T extends ArchiveRecord>(list: T[], params: ArchiveParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const 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<ArchiveRecord[]> => {
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<ArchiveRecord | null> => {
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<null> => {
const params = getParams(ctx.params)
const now = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
const createTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
const record: 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<null> => {
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<null> => {
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 },
])
@@ -115,9 +115,9 @@ const updateRecord = (params: PageParams): ApiResponse<null> => {
}
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)) },
])
@@ -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 = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const paginate = <T extends InventoryRecord>(list: T[], params: PageParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
/** 生成应盘清单(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<InventoryRecord[]> => {
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<InventoryRecord | null> => {
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<Array<{ id: string; name: string }>> => {
const options = [
{ id: 'wh-floor1', name: '一楼设备仓库' },
{ id: 'wh-floor2', name: '二楼备品仓库' },
{ id: 'wh-floor1-large', name: '大型设备区' },
{ id: 'wh-floor1-small', name: '小型设备区' },
{ id: 'wh-floor2-check', name: '待检区' },
{ id: 'wh-floor1-large-a', name: 'A号货架' },
]
return ok('查询成功', options)
}
/** 生成盘点单号 */
const genInventoryCode = (): string => {
const d = new Date()
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<null> => {
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<null> => {
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<null> => {
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() },
])
@@ -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 = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
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<WarehouseNode[]> => {
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<null> => {
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<null> => {
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<WarehouseNode> => {
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<null> => {
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 },
])
@@ -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 = <T extends AmapRecord>(list: T[], params: AmapParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const 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<AmapRecord[]> => {
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<AmapRecord | null> => {
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<null> => {
const params = getParams(ctx.params)
const now = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
const createTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
const record: 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<null> => {
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<null> => {
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 },
])
@@ -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 = <T extends ArcRecord>(list: T[], params: ArcParams): ApiResponse<T[]> => {
const pageNum = Number(params.pageNum ?? 1)
const pageSize = Number(params.pageSize ?? 30)
const start = (pageNum - 1) * pageSize
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
}
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const 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<ArcRecord[]> => {
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<ArcRecord | null> => {
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<null> => {
const params = getParams(ctx.params)
const now = new Date()
const pad = (n: number): string => String(n).padStart(2, '0')
const createTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
const record: 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<null> => {
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<null> => {
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 },
])
+16 -4
View File
@@ -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
+271
View File
@@ -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 = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
const getParams = <T = Record<string, unknown>>(params: RequestParameter): T => (params ?? {}) as T
const 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<WarehouseOption[]> => ok('查询成功', warehouses),
},
// 客户下拉
{
url: '/mp/ops/stock/customer-options',
method: 'POST',
handler: (): ApiResponse<CustomerOption[]> => ok('查询成功', customers),
},
// 设备详情(收货入库 / 装机出库扫码时查询)
{
url: '/mp/ops/stock/device-detail',
method: 'POST',
handler: (ctx): ApiResponse<DeviceDetail | null> => {
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<null> => {
const body = getParams<InboundSubmitBody>(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<null> => {
const body = getParams<OutboundSubmitBody>(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<InventoryDetail | null> => {
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<null> => {
const body = getParams<InventorySubmitBody>(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<StockItem[]> => {
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)
},
},
])
@@ -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 子应用打散重组
- 设备资产统一管理,数据分发独立成域
- 原系统设置域取消,迁入设备运维域
+15
View File
@@ -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;
}
</style>
+71 -15
View File
@@ -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',
},
],
},
{
@@ -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<ApiResponse<ArchiveItem[]>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/page', params)
/** 查询客户档案详情 */
export const info = (id: string): Promise<ApiResponse<ArchiveItem>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/info', { id })
/** 新增客户档案 */
export const add = (payload: ArchivePayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/add', payload)
/** 更新客户档案 */
export const update = (payload: ArchivePayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/update', payload)
/** 删除客户档案 */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/delete', { id })
@@ -0,0 +1,82 @@
<template>
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="编码 / 名称 / 联系人"
style="width: 220px"
/>
</a-form-item>
<a-form-item label="客户类型">
<a-select
v-model:value="search.type"
:options="CUSTOMER_TYPE_OPTIONS"
allow-clear
placeholder="全部类型"
style="width: 160px"
/>
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="search.status" allow-clear placeholder="全部状态" style="width: 140px">
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">停用</a-select-option>
</a-select>
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" @click="handleAction('add')">
<template #icon><PlusOutlined /></template>
新增客户
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { usePage } from './init/usePage'
import { CUSTOMER_TYPE_OPTIONS } from './types'
const {
pageLoading,
search,
table,
addOrEditRef,
infoDrawerRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
listRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -0,0 +1,11 @@
/**
* 客户档案 - 详情抽屉 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo } from '../types'
/** 详情查询 */
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/info', { id })
@@ -0,0 +1,56 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:destroy-on-close="true"
>
<a-spin :spinning="pageInfo.spin">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions-item label="客户编码">{{ detail.code ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="客户名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="客户类型">
<a-tag :color="typeColor">{{ customerTypeText(detail.type) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="启用状态">
<a-tag :color="detail.status ? 'green' : 'default'">{{ detail.status ? '启用' : '停用' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="联系人">{{ detail.contactName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ detail.contactPhone ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="联系邮箱">{{ detail.email ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="到期时间">{{ detail.expireTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="授权应用数">{{ detail.appCount ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="关联场所数">{{ detail.siteCount ?? 0 }}</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ detail.createTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="地址" :span="2">{{ detail.address ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detail.remark ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 客户档案 - 详情抽屉
* - 只读展示,所有字段 ?? '—'
*/
import { computed } from 'vue'
import { usePage } from './init/usePage'
import { customerTypeText } from '../../../types'
import type { CustomerType } from '../../../types'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
/** 客户类型 → Tag 颜色(与列表保持一致) */
const typeColorMap: Record<CustomerType, string> = {
enterprise: 'blue',
gov: 'purple',
edu: 'cyan',
medical: 'green',
other: 'default',
}
const typeColor = computed(() => (detail.value ? typeColorMap[detail.value.type] ?? 'default' : 'default'))
defineExpose({ openDrawer, closeDrawer })
</script>
@@ -0,0 +1,48 @@
/**
* 客户档案 - 详情抽屉 - 主逻辑
*/
import { info } from '../api'
import type { DetailInfo, OpenContext, PageInfo } from '../types'
export const usePage = () => {
const pageInfo = reactive<PageInfo>({
visible: false,
title: '客户详情',
width: 720,
spin: false,
})
const detail = ref<DetailInfo | null>(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,
}
}
@@ -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
}
@@ -0,0 +1,160 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '110px', minWidth: '110px' } }"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="客户编码" name="code">
<a-input
v-model:value="form.code"
v-only-alphanumeric
allow-clear
:maxlength="40"
placeholder="如 CQ-001"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="客户类型" name="type">
<a-select v-model:value="form.type" :options="CUSTOMER_TYPE_OPTIONS" placeholder="请选择" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="客户名称" name="name">
<a-input
v-model:value="form.name"
v-no-space
allow-clear
:maxlength="60"
placeholder="请输入客户名称"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="联系人" name="contactName">
<a-input
v-model:value="form.contactName"
v-no-space
allow-clear
:maxlength="20"
placeholder="联系人姓名"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="contactPhone">
<a-input
v-model:value="form.contactPhone"
v-only-number
allow-clear
:maxlength="20"
placeholder="手机或座机号"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="联系邮箱" name="email">
<a-input
v-model:value="form.email"
allow-clear
:maxlength="60"
placeholder="可选"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="到期时间" name="expireTime">
<a-date-picker
v-model:value="form.expireTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择日期"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="启用状态" name="status">
<a-switch v-model:checked="form.status" checked-children="启用" un-checked-children="停用" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="地址" name="address">
<a-input
v-model:value="form.address"
v-no-space
allow-clear
:maxlength="120"
placeholder="详细地址"
/>
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
:rows="3"
:maxlength="200"
show-count
placeholder="可选,客户补充说明"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 客户档案 - 新增/编辑 弹框
* - 字段:编码 / 名称 / 类型 / 联系人 / 电话 / 邮箱 / 到期时间 / 启用状态 / 地址 / 备注
* - 联系电话校验:手机或座机
*/
import { usePage } from './init/usePage'
import { CUSTOMER_TYPE_OPTIONS } from '../../../types'
const emit = defineEmits<{
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
rules,
openModal,
closeModal,
submit,
} = usePage(emit)
defineExpose({ openModal })
</script>
@@ -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<ApiResponse<Form>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/info', { id })
/** 新增 */
export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/add', params)
/** 编辑 */
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/customer/archive/update', params)
@@ -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<PageInfo>({
visible: false,
type: 'add',
title: '',
width: 760,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
/** 联系电话校验:允许座机 / 手机 */
const phoneValidator = (_rule: unknown, value: string): Promise<void> => {
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<void> => {
if (!value) return Promise.resolve()
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)
? Promise.resolve()
: Promise.reject(new Error('邮箱格式不正确'))
}
const rules: Record<string, Rule[]> = {
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<unknown>[] = []
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<string, unknown>): 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<string, unknown>): 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<string, unknown>
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,
}
}
@@ -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<typeof usePageRef>
/** 仅用于类型推导的占位函数 */
declare function usePageRef(): FormInstance | undefined
@@ -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<SearchForm>(createSearchKey())
const table = reactive<TableState<ArchiveItem>>({
columns: tableColumns,
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
/** 新增/编辑弹框 ref */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
/** 详情抽屉 ref */
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | 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,
}
}
@@ -0,0 +1,8 @@
import type { SearchForm } from '../types'
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
type: undefined,
status: undefined,
})
@@ -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<CustomerType, string> = {
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
@@ -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 ?? '—'
@@ -3,7 +3,7 @@ import { postRequest } from '@axios'
import type { ListItem, ListParams } from '../types'
export const list = (params: ListParams): Promise<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/admin/device/stock/supplier/page', params)
postRequest('axiosRequest', '/admin/device/customer/supplier/page', params)
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/supplier/delete', { id })
postRequest('axiosRequest', '/admin/device/customer/supplier/delete', { id })
@@ -10,12 +10,12 @@ import type { Form } from '../types'
/** 详情查询(编辑回填) */
export const info = (id: string): Promise<ApiResponse<Form>> =>
postRequest('axiosRequest', '/admin/device/stock/supplier/info', { id })
postRequest('axiosRequest', '/admin/device/customer/supplier/info', { id })
/** 新增 */
export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/supplier/add', params)
postRequest('axiosRequest', '/admin/device/customer/supplier/add', params)
/** 编辑 */
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/supplier/update', params)
postRequest('axiosRequest', '/admin/device/customer/supplier/update', params)
@@ -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<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/page', params)
/** 盘点单详情(含应盘清单 items) */
export const info = (id: string): Promise<ApiResponse<ListItem>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/info', { id })
/** 新增盘点单(选仓库 + 类型,系统自动从在库库存生成应盘清单) */
export const add = (params: InventoryPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/add', params)
/** 审核盘点单(审核后库存台账以实盘数据为准) */
export const audit = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/audit', { id })
/** 删除盘点单(仅草稿状态可删) */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/delete', { id })
/** 查询仓库区域下拉数据(用于新增弹框选仓库) */
export const warehouseOptions = (): Promise<ApiResponse<Array<{ id: string; name: string }>>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/warehouse-options', {})
@@ -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<ApiResponse<ListItem>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/info', { id })
@@ -0,0 +1,106 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
destroy-on-close
>
<a-spin :spinning="pageInfo.spin">
<a-empty v-if="!detail" description="暂无数据" />
<template v-else>
<!-- 基本信息 -->
<a-descriptions :column="2" bordered size="small" title="基本信息">
<a-descriptions-item label="盘点单号">{{ detail.inventoryCode }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="INVENTORY_STATUS_COLOR[detail.status]">{{ statusLabel(detail.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="盘点仓库">{{ detail.warehouseName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="盘点类型">
<a-tag :color="INVENTORY_TYPE_COLOR[detail.inventoryType]">{{ typeLabel(detail.inventoryType) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="盘点人">{{ detail.operator ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="盘点时间">{{ detail.inventoryTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="审核人">{{ detail.auditor ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="审核时间">{{ detail.auditTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detail.remark ?? '—' }}</a-descriptions-item>
</a-descriptions>
<!-- 差异汇总卡 -->
<a-row :gutter="12" style="margin-top: 16px">
<a-col :span="6">
<a-statistic title="应盘数量" :value="detail.expectedCount" />
</a-col>
<a-col :span="6">
<a-statistic title="实盘数量" :value="detail.actualCount" />
</a-col>
<a-col :span="6">
<a-statistic title="盘盈" :value="detail.surplusCount" :value-style="{ color: '#fa8c16' }" />
</a-col>
<a-col :span="6">
<a-statistic title="盘亏" :value="detail.lossCount" :value-style="{ color: '#ff4d4f' }" />
</a-col>
</a-row>
<!-- 应盘清单 -->
<a-divider orientation="left" style="margin-top: 24px">应盘清单{{ detail.items?.length ?? 0 }} </a-divider>
<a-table
:columns="itemColumns"
:data-source="detail.items ?? []"
:pagination="false"
row-key="deviceCode"
size="small"
:scroll="{ y: 360 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'result'">
<a-tag :color="INVENTORY_ITEM_RESULT_COLOR[record.result]">
{{ INVENTORY_ITEM_RESULT_LABEL[record.result] }}
</a-tag>
</template>
</template>
</a-table>
</template>
</a-spin>
<template #footer>
<a-button @click="closeDrawer">关闭</a-button>
</template>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 库存盘点 - 详情抽屉
* - 展示盘点单基本信息 + 差异汇总 + 应盘清单(带实盘结果 tag)
* - 仅查看,无操作(审核动作在主页操作列)
*/
import {
INVENTORY_ITEM_RESULT_COLOR,
INVENTORY_ITEM_RESULT_LABEL,
INVENTORY_STATUS_COLOR,
INVENTORY_STATUS_OPTIONS,
INVENTORY_TYPE_COLOR,
INVENTORY_TYPE_OPTIONS,
} from '../../../types'
import { usePage } from './init/usePage'
const {
pageInfo,
detail,
itemColumns,
openDrawer,
closeDrawer,
} = usePage()
/** 状态 → 中文标签 */
const statusLabel = (status: string): string =>
INVENTORY_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? '—'
/** 类型 → 中文标签 */
const typeLabel = (type: string): string =>
INVENTORY_TYPE_OPTIONS.find((item) => item.value === type)?.label ?? '—'
defineExpose({ openDrawer })
</script>
@@ -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<PageInfo>({
visible: false,
title: '盘点单详情',
width: 900,
spin: false,
})
/** 当前详情数据 */
const detail = ref<ListItem | null>(null)
/** 应盘清单表格列(在 .vue 中通过 computed 引入避免循环依赖) */
const itemColumns = [
{ title: '序号', dataIndex: 'index', align: 'center', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 150, ellipsis: true },
{ title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 140, ellipsis: true },
{ title: '二级型号', dataIndex: 'modelName', align: 'left', width: 130, ellipsis: true },
{ title: '批次号', dataIndex: 'batchNo', align: 'left', width: 130, ellipsis: true },
{ title: '应盘', dataIndex: 'expectedQuantity', align: 'center', width: 80 },
{ title: '实盘', dataIndex: 'actualQuantity', align: 'center', width: 80 },
{ title: '结果', dataIndex: '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,
}
}
@@ -0,0 +1,13 @@
/** 抽屉页面状态 */
export interface PageInfo {
visible: boolean
title: string
width: number
spin: boolean
}
/** 打开抽屉上下文 */
export interface OpenContext {
/** 盘点单 ID */
id: string
}
@@ -0,0 +1,91 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '100px', minWidth: '100px' } }"
>
<a-form-item label="盘点仓库" name="warehouseId">
<a-select
v-model:value="form.warehouseId"
:options="warehouseOptionList"
placeholder="请选择仓库"
show-search
:filter-option="filterOption"
@change="onWarehouseChange"
/>
</a-form-item>
<a-form-item label="盘点类型" name="inventoryType">
<a-radio-group v-model:value="form.inventoryType">
<a-radio value="full">全量盘点应盘该仓库所有在库设备</a-radio>
<a-radio value="partial">部分盘点仅应盘指定型号</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
:rows="3"
:maxlength="200"
show-count
placeholder="可选,盘点任务说明"
/>
</a-form-item>
<a-alert
type="info"
show-icon
message="提交后系统按仓库当前在库台账生成应盘清单,盘点单进入草稿状态;小程序扫码完成实盘后变已完成;后台审核后修正库存台账。"
style="margin-top: 8px"
/>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 库存盘点 - 新增盘点单弹框
* - 字段:仓库 / 类型(全量 | 部分)/ 备注
* - 提交后由系统按在库库存生成应盘清单
*/
import { filterOption } from '@utils/antDesign/select'
import { usePage } from './init/usePage'
const emit = defineEmits<{
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
rules,
warehouseOptionList,
openModal,
closeModal,
onWarehouseChange,
submit,
} = usePage(emit)
defineExpose({ openModal })
</script>
@@ -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<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/add', params)
/** 查询仓库下拉数据 */
export const warehouseOptions = (): Promise<ApiResponse<WarehouseOption[]>> =>
postRequest('axiosRequest', '/admin/device/stock/inventory/warehouse-options', {})
/** 仅用于类型导入兼容(Form 在 usePage 中用作 reactive 类型) */
export type { Form }
@@ -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<PageInfo>({
visible: false,
title: '新增盘点单',
width: 560,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
/** 仓库下拉选项 */
const warehouseOptionList = ref<WarehouseOption[]>([])
const rules: Record<string, Rule[]> = {
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,
}
}
@@ -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<typeof usePageRef>
/** 仅用于类型推导的占位函数 */
declare function usePageRef(): FormInstance | undefined
@@ -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<boolean>(false)
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | 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<ListItem>(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,
}
}
@@ -0,0 +1,8 @@
import type { SearchForm } from '../types'
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
warehouseId: undefined,
status: undefined,
inventoryType: undefined,
})
@@ -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<ListItem> = [
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
{ title: '盘点单号', dataIndex: 'inventoryCode', align: 'left', width: 170, fixed: 'left', resizable: true, ellipsis: true },
{ title: '盘点仓库', dataIndex: 'warehouseName', align: 'left', width: 160, resizable: true, ellipsis: true },
{
title: '盘点类型',
dataIndex: '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 },
]
@@ -0,0 +1,90 @@
<template>
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="盘点单号" style="width: 200px" />
</a-form-item>
<a-form-item label="盘点类型">
<a-select
v-model:value="search.inventoryType"
:options="INVENTORY_TYPE_OPTIONS"
allow-clear
placeholder="全部类型"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="search.status"
:options="INVENTORY_STATUS_OPTIONS"
allow-clear
placeholder="全部状态"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" @click="handleAction('add')">
<template #icon><PlusOutlined /></template>
新增盘点
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('info', record)">查看详情</a-button>
<a-divider type="vertical" />
<a-button
v-if="record.status === 'completed'"
type="link"
size="small"
@click="handleAction('audit', record)"
>审核</a-button>
<a-divider v-if="record.status === 'completed'" type="vertical" />
<a-button
v-if="record.status === 'draft'"
type="link"
size="small"
danger
@click="handleAction('delete', record)"
>删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { INVENTORY_STATUS_OPTIONS, INVENTORY_TYPE_OPTIONS } from './types'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
table,
addOrEditRef,
infoDrawerRef,
dataSourceChange,
searchQuery,
resetQuery,
handleAction,
listRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -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<InventoryStatus, string> = {
draft: 'default',
completed: 'warning',
audited: 'success',
}
export const INVENTORY_TYPE_COLOR: Record<InventoryType, string> = {
full: 'blue',
partial: 'orange',
}
/** 单条实盘结果 → a-tag color */
export const INVENTORY_ITEM_RESULT_COLOR: Record<InventoryItemResult, string> = {
matched: 'success',
surplus: 'gold',
loss: 'red',
unchecked: 'default',
}
/** 单条实盘结果 → 中文标签 */
export const INVENTORY_ITEM_RESULT_LABEL: Record<InventoryItemResult, string> = {
matched: '一致',
surplus: '盘盈',
loss: '盘亏',
unchecked: '未盘',
}
@@ -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<ApiResponse<WarehouseNode[]>> =>
postRequest('axiosRequest', '/admin/device/warehouse/tree', params)
/** 查询节点详情 */
export const info = (id: string): Promise<ApiResponse<WarehouseNode>> =>
postRequest('axiosRequest', '/admin/device/warehouse/info', { id })
/** 新增节点 */
export const add = (payload: WarehousePayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/warehouse/add', payload)
/** 更新节点 */
export const update = (payload: WarehousePayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/warehouse/update', payload)
/** 删除节点(含子节点级联删除) */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/warehouse/delete', { id })
@@ -0,0 +1,152 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '110px', minWidth: '110px' } }"
>
<!-- 层级只读展示 -->
<a-form-item label="层级">
<a-tag :color="form.level === 1 ? 'blue' : 'cyan'">
{{ levelText }}
</a-tag>
</a-form-item>
<!-- 父级区域新增下级时展示顶级仓库不展示 -->
<a-form-item v-if="form.level > 1" label="父级区域">
<span>{{ form.parentName ?? '—' }}</span>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="名称" name="name">
<a-input
v-model:value="form.name"
v-no-space
allow-clear
:maxlength="30"
placeholder="请输入节点名称"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="编码">
<!-- code 由系统按 W01A01 规则自动生成前端只读展示 -->
<span v-if="form.code" class="add-or-edit__readonly">{{ form.code }}</span>
<span v-else class="add-or-edit__hint">保存后由系统按 W01A01 规则自动生成</span>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="排序" name="sortOrder">
<a-input-number
v-model:value="form.sortOrder"
:min="1"
:max="9999"
style="width: 100%"
placeholder="同级排序"
/>
</a-form-item>
<!-- 末级节点专属状态 -->
<a-form-item v-if="isLeaf" label="状态">
<a-select
v-model:value="form.status"
:options="WAREHOUSE_STATUS_OPTIONS"
placeholder="请选择状态"
/>
<span class="add-or-edit__hint">入库时区域必须选末级状态由入库出库业务联动</span>
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
:rows="3"
:maxlength="200"
show-count
placeholder="可选,区域补充说明"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 仓库区域 - 新增/编辑 弹框
*
* modal-spec.md 强制项已遵循:
* - a-modal :keyboard="false" :mask-closable="false"
* - a-spin 包裹整个 a-form
* - 确定按钮 :disabled + :loading 都用 pageInfo.spin
* - defineExpose 只暴露 openModal
*
* 业务字段(对齐区域详情):
* - 层级 / 父级区域 / 编码:只读展示
* - 名称 / 排序 / 备注:所有节点通用
* - 状态(idle/occupied/maintenance):仅末级节点显示(新增视为叶子;编辑看是否有子)
*/
import { computed } from 'vue'
import { WAREHOUSE_STATUS_OPTIONS } from '../../../types'
import { usePage } from './init/usePage'
const emit = defineEmits<{
/** 操作成功后通知父组件刷新树 */
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
rules,
isLeaf,
openModal,
closeModal,
submit,
} = usePage(emit)
/** 层级中文文案:1=顶级仓库,其余按"第 N 级区域" */
const levelText = computed<string>(() => {
const lv = form.value.level
if (lv === 1) return '顶级仓库'
const cn = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
const text = lv <= 10 ? cn[lv] : String(lv)
return `${text}级区域`
})
defineExpose({ openModal })
</script>
<style scoped lang="less">
.add-or-edit__hint {
margin-left: 12px;
color: var(--text-secondary, #8c8c8c);
font-size: 12px;
}
.add-or-edit__readonly {
font-weight: 500;
color: var(--text-primary, #262626);
}
</style>
@@ -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<ApiResponse<WarehouseNode>> =>
postRequest('axiosRequest', '/admin/device/warehouse/info', { id })
/** 新增节点 */
export const add = (payload: WarehousePayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/warehouse/add', payload)
/** 更新节点 */
export const edit = (payload: WarehousePayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/warehouse/update', payload)
@@ -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<PageInfo>({
visible: false,
type: 'add',
title: '',
width: 640,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
sortOrder: [{ required: true, type: 'number', message: '请输入排序号', trigger: 'blur' }],
}
/** 是否为叶子节点:新增视为叶子;编辑看是否有子 */
const isLeaf = computed<boolean>(() => {
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<unknown>[] = []
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,
}
}
@@ -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
}
@@ -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 展示选中"区域详情" + 操作按钮
* - 新增/编辑走子组件 AddOrEditmodal-spec 4 文件结构)
* - 末级节点(无 children)显示状态:空闲 / 占用 / 维护中
* - 入库时区域必须选末级(由入库页面保证,本页只维护结构与初始状态)
* - code 由系统按 W01A01 规则自动生成
*/
export const usePage = () => {
const { message, Modal } = useAntdStaticMethods()
const {
treeData,
selectedKeys,
expandedKeys,
pageLoading,
fieldNames,
collectExpandableKeys,
resetTree,
} = useTable()
const search = reactive<SearchForm>(createSearchKey())
/** 当前选中的节点对象(基于 selectedKeys 计算) */
const selectedNode = ref<WarehouseNode | null>(null)
/** 新增/编辑子组件 ref(必须在 usePage.ts 中定义) */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | 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,
}
}
@@ -0,0 +1,6 @@
import type { SearchForm } from '../types'
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
keyword: undefined,
})
@@ -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<WarehouseNode[]>([])
const selectedKeys = ref<string[]>([])
const expandedKeys = ref<string[]>([])
const pageLoading = ref<boolean>(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,
}
}
@@ -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<WarehouseStatus, string> = {
idle: 'green',
occupied: 'red',
maintenance: 'orange',
}
/** 状态 → 中文标签映射 */
export const WAREHOUSE_STATUS_LABEL: Record<WarehouseStatus, string> = {
idle: '空闲',
occupied: '占用',
maintenance: '维护中',
}
@@ -0,0 +1,162 @@
<template>
<div class="admin-list-page warehouse-page">
<a-row :gutter="12" class="warehouse-page__body">
<a-col :span="10">
<a-card class="warehouse-page__tree" :bordered="false">
<div class="warehouse-page__tree-toolbar">
<a-input-search
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="按名称 / 编码过滤"
style="flex: 1"
@search="searchQuery"
/>
<a-button type="primary" @click="openAddModal()">
<template #icon><PlusOutlined /></template>
新增仓库
</a-button>
</div>
<a-spin :spinning="pageLoading">
<a-empty v-if="!treeData.length" description="暂无仓库区域数据" />
<a-tree
v-else
:tree-data="treeData"
:field-names="fieldNames"
:selected-keys="selectedKeys"
:expanded-keys="expandedKeys"
show-line
block-node
@select="onSelect"
@expand="onExpand"
>
<template #title="{ name, status, children }">
<span>{{ name }}</span>
<!-- 末级节点显示状态空闲 / 占用 / 维护中 -->
<a-tag
v-if="!children?.length && status"
:color="statusColor(status)"
class="warehouse-page__tag"
>
{{ statusLabel(status) }}
</a-tag>
</template>
</a-tree>
</a-spin>
</a-card>
</a-col>
<a-col :span="14">
<a-card class="warehouse-page__detail" :bordered="false">
<template #title>区域详情</template>
<template #extra>
<a-button type="link" size="small" :disabled="!selectedNode" @click="openAddModal(selectedNode ?? undefined)">新增下级区域</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" :disabled="!selectedNode" @click="openEditModal">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger :disabled="!selectedNode" @click="deleteNode">删除</a-button>
</template>
<a-empty v-if="!selectedNode" description="请选择左侧节点查看区域详情" />
<a-descriptions v-else :column="2" bordered size="small">
<a-descriptions-item label="层级">{{ levelLabel(selectedNode.level) }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ selectedNode.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="编码">{{ selectedNode.code ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="排序">{{ selectedNode.sortOrder ?? '—' }}</a-descriptions-item>
<a-descriptions-item v-if="selectedNode.level > 1" label="父级名称">
{{ selectedNode.parentName ?? '' }}
</a-descriptions-item>
<!-- 末级节点显示状态 -->
<a-descriptions-item v-if="!selectedNode.children?.length" label="状态">
<a-tag :color="statusColor(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ selectedNode.remark ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
<AddOrEdit ref="addOrEditRef" @load="treeRequest" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import {
WAREHOUSE_STATUS_COLOR,
WAREHOUSE_STATUS_LABEL,
type WarehouseStatus,
} from './types'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
treeData,
selectedKeys,
expandedKeys,
fieldNames,
selectedNode,
addOrEditRef,
levelLabel,
searchQuery,
onSelect,
onExpand,
openAddModal,
openEditModal,
deleteNode,
treeRequest,
} = usePage()
/** 状态 → a-tag color */
const statusColor = (status?: WarehouseStatus): string =>
status ? WAREHOUSE_STATUS_COLOR[status] : 'default'
/** 状态 → 中文标签 */
const statusLabel = (status?: WarehouseStatus): string =>
status ? WAREHOUSE_STATUS_LABEL[status] : '—'
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
}
.warehouse-page {
&__body {
flex: 1;
min-height: 0;
margin: 0 !important;
}
&__tree,
&__detail {
height: 100%;
overflow: auto;
:deep(.ant-card-body) {
padding: 16px 20px;
}
}
&__tree-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
&__tag {
margin-left: 6px;
transform: scale(0.85);
transform-origin: left center;
}
}
</style>
@@ -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<ApiResponse<TenantItem[]>> =>
postRequest('axiosRequest', '/admin/tenant/page', params)
@@ -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<SearchForm>(createSearchKey())
const table = reactive<TableState<TenantItem>>({
columns: tableColumns,
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
const pageLoading = ref<boolean>(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,
}
}
@@ -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 },
]
@@ -1,54 +0,0 @@
<template>
<div class="admin-list-page tenant-page">
<a-alert
class="tenant-page__hint"
type="info"
show-icon
message="本页为只读视图,租户档案的维护请前往「运营中心 → 租户管理」。"
/>
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="租户名称">
<a-input v-model:value="search.name" v-no-space allow-clear placeholder="请输入租户名称" style="width: 200px" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="search.status" allow-clear placeholder="全部" style="width: 140px">
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">停用</a-select-option>
</a-select>
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange" />
</div>
</template>
<script setup lang="ts">
import FilterBar from '@components/FilterBar.vue'
import TableCard from '@components/TableCard.vue'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
table,
searchQuery,
resetQuery,
dataSourceChange,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
gap: 12px;
}
.tenant-page__hint {
flex-shrink: 0;
}
</style>
@@ -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
}
@@ -0,0 +1,72 @@
<template>
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="应用名称 / API Key / 用途"
style="width: 240px"
/>
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="search.status" allow-clear placeholder="全部状态" style="width: 140px">
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">停用</a-select-option>
</a-select>
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" @click="handleAction('add')">
<template #icon><PlusOutlined /></template>
新增应用
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
table,
addOrEditRef,
infoDrawerRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
listRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -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<ApiResponse<AmapItem[]>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/page', params)
/** 查询高德应用详情 */
export const info = (id: string): Promise<ApiResponse<AmapItem>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/info', { id })
/** 新增高德应用 */
export const add = (payload: AmapPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/add', payload)
/** 更新高德应用 */
export const update = (payload: AmapPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/update', payload)
/** 删除高德应用 */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/delete', { id })
@@ -0,0 +1,11 @@
/**
* 高德应用 - 详情抽屉 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo } from '../types'
/** 详情查询 */
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/info', { id })
@@ -0,0 +1,34 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:destroy-on-close="true"
>
<a-spin :spinning="pageInfo.spin">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions-item label="应用名称" :span="2">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="API Key" :span="2">{{ detail.apiKey ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="安全密钥" :span="2">{{ detail.securityCode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="启用状态">
<a-tag :color="detail.status ? 'green' : 'default'">{{ detail.status ? '启用' : '停用' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detail.createTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="用途说明" :span="2">{{ detail.usage ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detail.remark ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 高德应用 - 详情抽屉
* - 只读展示,所有字段 ?? '—'
*/
import { usePage } from './init/usePage'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
defineExpose({ openDrawer, closeDrawer })
</script>
@@ -0,0 +1,48 @@
/**
* 高德应用 - 详情抽屉 - 主逻辑
*/
import { info } from '../api'
import type { DetailInfo, OpenContext, PageInfo } from '../types'
export const usePage = () => {
const pageInfo = reactive<PageInfo>({
visible: false,
title: '应用详情',
width: 640,
spin: false,
})
const detail = ref<DetailInfo | null>(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,
}
}
@@ -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
}
@@ -0,0 +1,108 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '110px', minWidth: '110px' } }"
>
<a-form-item label="应用名称" name="name">
<a-input
v-model:value="form.name"
v-no-space
allow-clear
:maxlength="60"
placeholder="如 园区地图导航服务"
/>
</a-form-item>
<a-form-item label="API Key" name="apiKey">
<a-input
v-model:value="form.apiKey"
v-only-alphanumeric-special
allow-clear
:maxlength="80"
placeholder="高德开放平台分配的 Key"
/>
</a-form-item>
<a-form-item label="安全密钥" name="securityCode">
<a-input
v-model:value="form.securityCode"
v-only-alphanumeric-special
allow-clear
:maxlength="80"
placeholder="可选,2021 年底后新增的应用需配置"
/>
</a-form-item>
<a-form-item label="用途说明" name="usage">
<a-textarea
v-model:value="form.usage"
:rows="2"
:maxlength="120"
show-count
placeholder="可选,描述此 Key 用于什么业务场景"
/>
</a-form-item>
<a-form-item label="启用状态" name="status">
<a-switch v-model:checked="form.status" checked-children="启用" un-checked-children="停用" />
</a-form-item>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
:rows="3"
:maxlength="200"
show-count
placeholder="可选,应用补充说明"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 高德应用 - 新增/编辑 弹框
* - 字段:应用名称 / API Key / 安全密钥 / 用途说明 / 启用状态 / 备注
* - API Key 与安全密钥允许连字符,使用 v-only-alphanumeric-special
*/
import { usePage } from './init/usePage'
const emit = defineEmits<{
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
rules,
openModal,
closeModal,
submit,
} = usePage(emit)
defineExpose({ openModal })
</script>
@@ -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<ApiResponse<Form>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/info', { id })
/** 新增 */
export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/add', params)
/** 编辑 */
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/third-auth/amap/update', params)
@@ -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<PageInfo>({
visible: false,
type: 'add',
title: '',
width: 640,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
const rules: Record<string, Rule[]> = {
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<unknown>[] = []
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<string, unknown>): 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<string, unknown>): 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<string, unknown>
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,
}
}
@@ -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<typeof usePageRef>
/** 仅用于类型推导的占位函数 */
declare function usePageRef(): FormInstance | undefined
@@ -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<SearchForm>(createSearchKey())
const table = reactive<TableState<AmapItem>>({
columns: tableColumns,
dataSource: [],
sort: { field: '', order: null },
pagination: createPaginationConfig() as TablePaginationConfig,
})
const pageLoading = ref<boolean>(false)
const selectedRowKeys = ref<(string | number)[]>([])
/** 新增/编辑弹框 ref */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
/** 详情抽屉 ref */
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | 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,
}
}
@@ -2,6 +2,6 @@ import type { SearchForm } from '../types'
/** 搜索表单工厂:统一重置入口 */
export const createSearchKey = (): SearchForm => ({
name: undefined,
keyword: undefined,
status: undefined,
})
@@ -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 }
@@ -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
}
@@ -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<ApiResponse<ArcItem[]>> =>
postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/page', params)
/** 查询虹软授权详情 */
export const info = (id: string): Promise<ApiResponse<ArcItem>> =>
postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/info', { id })
/** 新增虹软授权 */
export const add = (payload: ArcPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/add', payload)
/** 更新虹软授权 */
export const update = (payload: ArcPayload): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/update', payload)
/** 删除虹软授权 */
export const remove = (id: string): Promise<ApiResponse<null>> =>
postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/delete', { id })
@@ -0,0 +1,76 @@
<template>
<div class="admin-list-page">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="关键字">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="激活码 / 设备 SN / 设备名称"
style="width: 240px"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="search.status"
:options="STATUS_OPTIONS"
allow-clear
placeholder="全部状态"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
<template #toolbar>
<a-button type="primary" @click="handleAction('add')">
<template #icon><PlusOutlined /></template>
新增授权
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
</template>
</template>
</TableCard>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import InfoDrawer from './component/drawer/info/info.vue'
import { usePage } from './init/usePage'
import { STATUS_OPTIONS } from './init/useTable'
const {
pageLoading,
search,
table,
addOrEditRef,
infoDrawerRef,
searchQuery,
resetQuery,
dataSourceChange,
handleAction,
listRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.admin-list-page {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -0,0 +1,11 @@
/**
* 虹软授权 - 详情抽屉 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo } from '../types'
/** 详情查询 */
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/admin/device/third-auth/arcsoft/info', { id })
@@ -0,0 +1,43 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:destroy-on-close="true"
>
<a-spin :spinning="pageInfo.spin">
<a-descriptions v-if="detail" :column="2" bordered size="small">
<a-descriptions-item label="激活码" :span="2">{{ detail.activeKey ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备 SN">{{ detail.deviceSn ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备名称">{{ detail.deviceName ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="设备型号">{{ detail.deviceModel ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="授权状态">
<a-tag :color="statusColor">{{ arcStatusText(detail.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="AppID" :span="2">{{ detail.appId ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="SDK Key" :span="2">{{ detail.sdkKey ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="SDK Secret" :span="2">{{ detail.sdkSecret ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="激活时间">{{ detail.activateTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="到期时间">{{ detail.expireTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ detail.createTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detail.remark ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 虹软授权 - 详情抽屉
* - 只读展示,所有字段 ?? '—'
*/
import { computed } from 'vue'
import { usePage } from './init/usePage'
import { arcStatusColor, arcStatusText } from '../../../types'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
const statusColor = computed(() => (detail.value ? arcStatusColor(detail.value.status) : 'default'))
defineExpose({ openDrawer, closeDrawer })
</script>
@@ -0,0 +1,48 @@
/**
* 虹软授权 - 详情抽屉 - 主逻辑
*/
import { info } from '../api'
import type { DetailInfo, OpenContext, PageInfo } from '../types'
export const usePage = () => {
const pageInfo = reactive<PageInfo>({
visible: false,
title: '授权详情',
width: 720,
spin: false,
})
const detail = ref<DetailInfo | null>(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,
}
}
@@ -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
}
@@ -0,0 +1,183 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '110px', minWidth: '110px' } }"
>
<a-form-item label="激活码" name="activeKey">
<a-input
v-model:value="form.activeKey"
v-only-alphanumeric-special
allow-clear
:maxlength="80"
placeholder="虹软平台分配的激活码(与设备硬件 1:1 绑定)"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="AppID" name="appId">
<a-input
v-model:value="form.appId"
v-no-space
allow-clear
:maxlength="60"
placeholder="应用 AppID"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备 SN" name="deviceSn">
<a-input
v-model:value="form.deviceSn"
v-only-alphanumeric-special
allow-clear
:maxlength="40"
placeholder="绑定设备序列号"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="SDK Key" name="sdkKey">
<a-input
v-model:value="form.sdkKey"
v-no-space
allow-clear
:maxlength="80"
placeholder="SDK Key"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="SDK Secret" name="sdkSecret">
<a-input
v-model:value="form.sdkSecret"
v-no-space
allow-clear
:maxlength="80"
placeholder="SDK Secret"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备名称" name="deviceName">
<a-input
v-model:value="form.deviceName"
v-no-space
allow-clear
:maxlength="60"
placeholder="可选,便于识别"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备型号" name="deviceModel">
<a-input
v-model:value="form.deviceModel"
v-no-space
allow-clear
:maxlength="40"
placeholder="可选"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="激活时间" name="activateTime">
<a-date-picker
v-model:value="form.activateTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="可选"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="到期时间" name="expireTime">
<a-date-picker
v-model:value="form.expireTime"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="请选择日期"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="授权状态" name="status">
<a-select v-model:value="form.status" :options="statusOptions" placeholder="请选择" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注">
<a-textarea
v-model:value="form.remark"
:rows="3"
:maxlength="200"
show-count
placeholder="可选,授权补充说明"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 虹软授权 - 新增/编辑 弹框
* - 字段:激活码 / AppID / SDK Key / SDK Secret / 设备 SN / 设备名称 / 设备型号 / 激活时间 / 到期时间 / 状态 / 备注
* - 激活码与设备 SN 使用 v-only-alphanumeric-special 允许连字符
*/
import { usePage } from './init/usePage'
import { ARC_STATUS_OPTIONS } from '../../../types'
const emit = defineEmits<{
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
rules,
openModal,
closeModal,
submit,
} = usePage(emit)
const statusOptions = ARC_STATUS_OPTIONS
defineExpose({ openModal })
</script>

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