feat: 设备中心客户/库存菜单重组 + 小程序库存作业落地
- 后台:供应商从库存管理迁入客户管理;新增库存盘点页(应盘清单 + 审核流程 + 差异汇总);同步文档与 nav.ts - 小程序:工作台首页替换"快捷入口"为"库存作业"区(收货入库 / 装机出库 / 库存盘点 / 库存查询),新增 4 个 subPage + 配套 mock;扫码场景菜单追加 3 个库存场景,支持从扫码进入自动带入 deviceCode - 其它存量补齐:客户档案、仓库区域、第三方授权(虹软 / 高德)页面与 mock
This commit is contained in:
@@ -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 },
|
||||
])
|
||||
+5
-5
@@ -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 },
|
||||
])
|
||||
@@ -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)
|
||||
|
||||
@@ -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 子应用打散重组
|
||||
- 设备资产统一管理,数据分发独立成域
|
||||
- 原系统设置域取消,迁入设备运维域
|
||||
|
||||
@@ -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
@@ -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>
|
||||
+11
@@ -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>
|
||||
+48
@@ -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,
|
||||
}
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
+160
@@ -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>
|
||||
+20
@@ -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)
|
||||
+169
@@ -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,
|
||||
}
|
||||
}
|
||||
+51
@@ -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 ?? '—'
|
||||
+2
-2
@@ -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 })
|
||||
+3
-3
@@ -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', {})
|
||||
+12
@@ -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>
|
||||
+71
@@ -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,
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/** 抽屉页面状态 */
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
/** 打开抽屉上下文 */
|
||||
export interface OpenContext {
|
||||
/** 盘点单 ID */
|
||||
id: string
|
||||
}
|
||||
+91
@@ -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>
|
||||
+21
@@ -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 }
|
||||
+130
@@ -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,
|
||||
}
|
||||
}
|
||||
+34
@@ -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 })
|
||||
+152
@@ -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>
|
||||
+15
@@ -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)
|
||||
+207
@@ -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,
|
||||
}
|
||||
}
|
||||
+37
@@ -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 展示选中"区域详情" + 操作按钮
|
||||
* - 新增/编辑走子组件 AddOrEdit(modal-spec 4 文件结构)
|
||||
* - 末级节点(无 children)显示状态:空闲 / 占用 / 维护中
|
||||
* - 入库时区域必须选末级(由入库页面保证,本页只维护结构与初始状态)
|
||||
* - code 由系统按 W01A01 规则自动生成
|
||||
*/
|
||||
export const usePage = () => {
|
||||
const { message, Modal } = useAntdStaticMethods()
|
||||
const {
|
||||
treeData,
|
||||
selectedKeys,
|
||||
expandedKeys,
|
||||
pageLoading,
|
||||
fieldNames,
|
||||
collectExpandableKeys,
|
||||
resetTree,
|
||||
} = useTable()
|
||||
|
||||
const search = reactive<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 })
|
||||
+11
@@ -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>
|
||||
+48
@@ -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,
|
||||
}
|
||||
}
|
||||
+32
@@ -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
|
||||
}
|
||||
+108
@@ -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>
|
||||
+20
@@ -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)
|
||||
+145
@@ -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,
|
||||
}
|
||||
}
|
||||
+42
@@ -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,
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
+11
@@ -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 })
|
||||
+43
@@ -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>
|
||||
+48
@@ -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,
|
||||
}
|
||||
}
|
||||
+44
@@ -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
|
||||
}
|
||||
+183
@@ -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
Reference in New Issue
Block a user