From c64072f125c610c3529e83a25b9d42b5c69598ad Mon Sep 17 00:00:00 2001 From: fengpu <18691763612@163.com> Date: Fri, 5 Jun 2026 15:54:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(nutrition):=20=E5=B9=B3=E7=A7=BB=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E4=B8=8E=E5=BC=82=E5=B8=B8=E4=BA=8B=E4=BB=B6=206=20?= =?UTF-8?q?=E4=B8=AA=E9=A1=B5=E9=9D=A2=E5=B9=B6=E5=AE=8C=E5=96=84=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=BA=A4=E4=BA=92=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 档案应用版块: - 新增 arcCanteen(4 StatCard + Tabs:运营/成本)、arcAlgo(CRUD + 附件预览/下载) - 重写 arcUnit 为 Tabs 页(二级/三级单位 + 用餐人数 router 下钻) - arcEmployee 重构为列表/详情 v-if 切换,字段与旧 h5 完全对齐 - 详情子组件 arcEmployeeDetail 落到 arcEmployee/component/page/ 异常事件版块: - 新增 anoDirty(系统脏数据)、anoResult(结果异常 + 详情抽屉) 通用: - nav.ts 接入 6 个真实 component;6 个新 mock handler 注册到 mockBus - global.less 全局操作列 nowrap 保护,避免按钮换行 - 算法模型附件列改为"图标+文字"按钮(预览/下载) --- src/axios/handlers/index.ts | 12 ++ src/axios/handlers/nutrition/algo.ts | 132 +++++++++++++ src/axios/handlers/nutrition/anoDirty.ts | 77 ++++++++ src/axios/handlers/nutrition/anoResult.ts | 135 +++++++++++++ src/axios/handlers/nutrition/canteen.ts | 151 +++++++++++++++ src/axios/handlers/nutrition/employee.ts | 96 +++------- .../handlers/nutrition/employeeDetail.ts | 129 +++++++++++++ src/axios/handlers/nutrition/unit.ts | 117 +++++++++++ src/meta/nav.ts | 16 +- .../nutrition/ano/anoDirty/anoDirty.vue | 97 ++++++++++ .../nutrition/ano/anoDirty/api/index.ts | 13 ++ .../nutrition/ano/anoDirty/init/usePage.ts | 89 +++++++++ .../nutrition/ano/anoDirty/init/useSearch.ts | 73 +++++++ .../nutrition/ano/anoDirty/init/useTable.ts | 138 +++++++++++++ .../nutrition/ano/anoDirty/types/index.ts | 59 ++++++ .../nutrition/ano/anoResult/anoResult.vue | 115 +++++++++++ .../nutrition/ano/anoResult/api/index.ts | 15 ++ .../component/drawer/info/api/index.ts | 6 +- .../anoResult/component/drawer/info/info.vue | 124 ++++++++++++ .../component/drawer/info/init/usePage.ts | 19 +- .../component/drawer/info/types/index.ts | 20 ++ .../nutrition/ano/anoResult/init/usePage.ts | 102 ++++++++++ .../nutrition/ano/anoResult/init/useSearch.ts | 55 ++++++ .../nutrition/ano/anoResult/init/useTable.ts | 131 +++++++++++++ .../nutrition/ano/anoResult/types/index.ts | 68 +++++++ .../tenant-portal/nutrition/arc/ArcUnit.vue | 7 - .../nutrition/arc/arcAlgo/api/index.ts | 26 +++ .../nutrition/arc/arcAlgo/arcAlgo.vue | 133 +++++++++++++ .../component/modal/addOrEdit/addOrEdit.vue | 143 ++++++++++++++ .../component/modal/addOrEdit/api/index.ts | 16 ++ .../component/modal/addOrEdit/init/usePage.ts | 168 ++++++++++++++++ .../component/modal/addOrEdit/types/index.ts | 36 ++++ .../nutrition/arc/arcAlgo/init/usePage.ts | 140 ++++++++++++++ .../nutrition/arc/arcAlgo/init/useSearch.ts | 43 +++++ .../nutrition/arc/arcAlgo/init/useTable.ts | 75 ++++++++ .../nutrition/arc/arcAlgo/types/index.ts | 39 ++++ .../nutrition/arc/arcCanteen/api/index.ts | 11 ++ .../nutrition/arc/arcCanteen/arcCanteen.vue | 87 +++++++++ .../tabs/canteenOperation/api/index.ts | 10 + .../canteenOperation/canteenOperation.vue | 98 ++++++++++ .../tabs/canteenOperation/init/usePage.ts | 73 +++++++ .../tabs/canteenOperation/init/useSearch.ts | 69 +++++++ .../tabs/canteenOperation/init/useTable.ts | 91 +++++++++ .../tabs/canteenOperation/types/index.ts | 40 ++++ .../component/tabs/canteenProfit/api/index.ts | 10 + .../tabs/canteenProfit/canteenProfit.vue | 100 ++++++++++ .../tabs/canteenProfit/init/usePage.ts | 73 +++++++ .../tabs/canteenProfit/init/useSearch.ts | 69 +++++++ .../tabs/canteenProfit/init/useTable.ts | 138 +++++++++++++ .../tabs/canteenProfit/types/index.ts | 38 ++++ .../nutrition/arc/arcCanteen/init/useStats.ts | 44 +++++ .../nutrition/arc/arcCanteen/types/index.ts | 15 ++ .../nutrition/arc/arcEmployee/arcEmployee.vue | 170 ++++++++-------- .../component/drawer/info/info.vue | 61 ------ .../component/drawer/info/types/index.ts | 30 --- .../page/arcEmployeeDetail/api/index.ts | 13 ++ .../arcEmployeeDetail/arcEmployeeDetail.vue | 181 ++++++++++++++++++ .../page/arcEmployeeDetail/init/usePage.ts | 108 +++++++++++ .../page/arcEmployeeDetail/init/useSearch.ts | 42 ++++ .../page/arcEmployeeDetail/init/useTable.ts | 108 +++++++++++ .../page/arcEmployeeDetail/types/index.ts | 56 ++++++ .../nutrition/arc/arcEmployee/init/usePage.ts | 68 ++++--- .../arc/arcEmployee/init/useSearch.ts | 31 ++- .../arc/arcEmployee/init/useTable.ts | 71 +++---- .../nutrition/arc/arcEmployee/types/index.ts | 20 +- .../nutrition/arc/arcUnit/arcUnit.vue | 60 ++++++ .../component/tabs/level2Unit/api/index.ts | 11 ++ .../component/tabs/level2Unit/init/usePage.ts | 74 +++++++ .../tabs/level2Unit/init/useSearch.ts | 54 ++++++ .../tabs/level2Unit/init/useTable.ts | 115 +++++++++++ .../component/tabs/level2Unit/level2Unit.vue | 94 +++++++++ .../component/tabs/level2Unit/types/index.ts | 41 ++++ .../component/tabs/level3Unit/api/index.ts | 10 + .../component/tabs/level3Unit/init/usePage.ts | 73 +++++++ .../tabs/level3Unit/init/useSearch.ts | 52 +++++ .../tabs/level3Unit/init/useTable.ts | 119 ++++++++++++ .../component/tabs/level3Unit/level3Unit.vue | 89 +++++++++ .../component/tabs/level3Unit/types/index.ts | 31 +++ src/styles/global.less | 17 ++ 79 files changed, 5211 insertions(+), 369 deletions(-) create mode 100644 src/axios/handlers/nutrition/algo.ts create mode 100644 src/axios/handlers/nutrition/anoDirty.ts create mode 100644 src/axios/handlers/nutrition/anoResult.ts create mode 100644 src/axios/handlers/nutrition/canteen.ts create mode 100644 src/axios/handlers/nutrition/employeeDetail.ts create mode 100644 src/axios/handlers/nutrition/unit.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoDirty/anoDirty.vue create mode 100644 src/pages/tenant-portal/nutrition/ano/anoDirty/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoDirty/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoDirty/init/useSearch.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoDirty/init/useTable.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoDirty/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoResult/anoResult.vue create mode 100644 src/pages/tenant-portal/nutrition/ano/anoResult/api/index.ts rename src/pages/tenant-portal/nutrition/{arc/arcEmployee => ano/anoResult}/component/drawer/info/api/index.ts (57%) create mode 100644 src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/info.vue rename src/pages/tenant-portal/nutrition/{arc/arcEmployee => ano/anoResult}/component/drawer/info/init/usePage.ts (62%) create mode 100644 src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoResult/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoResult/init/useSearch.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoResult/init/useTable.ts create mode 100644 src/pages/tenant-portal/nutrition/ano/anoResult/types/index.ts delete mode 100644 src/pages/tenant-portal/nutrition/arc/ArcUnit.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/arcAlgo.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/addOrEdit.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/init/useSearch.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/init/useTable.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcAlgo/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/arcCanteen.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/canteenOperation.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/useSearch.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/useTable.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/canteenProfit.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/useSearch.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/useTable.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/init/useStats.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcCanteen/types/index.ts delete mode 100644 src/pages/tenant-portal/nutrition/arc/arcEmployee/component/drawer/info/info.vue delete mode 100644 src/pages/tenant-portal/nutrition/arc/arcEmployee/component/drawer/info/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcEmployee/component/page/arcEmployeeDetail/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcEmployee/component/page/arcEmployeeDetail/arcEmployeeDetail.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcEmployee/component/page/arcEmployeeDetail/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcEmployee/component/page/arcEmployeeDetail/init/useSearch.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcEmployee/component/page/arcEmployeeDetail/init/useTable.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcEmployee/component/page/arcEmployeeDetail/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/arcUnit.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level2Unit/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level2Unit/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level2Unit/init/useSearch.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level2Unit/init/useTable.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level2Unit/level2Unit.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level2Unit/types/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level3Unit/api/index.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level3Unit/init/usePage.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level3Unit/init/useSearch.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level3Unit/init/useTable.ts create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level3Unit/level3Unit.vue create mode 100644 src/pages/tenant-portal/nutrition/arc/arcUnit/component/tabs/level3Unit/types/index.ts diff --git a/src/axios/handlers/index.ts b/src/axios/handlers/index.ts index 58c121c..c4cf676 100644 --- a/src/axios/handlers/index.ts +++ b/src/axios/handlers/index.ts @@ -12,3 +12,15 @@ // 营养管理 - 员工营养数据 import './nutrition/employee' +// 营养管理 - 员工用餐记录详情 +import './nutrition/employeeDetail' +// 营养管理 - 单位报表数据(二级 + 三级) +import './nutrition/unit' +// 营养管理 - 场所报表数据(4 卡 stats + 运营 + 成本) +import './nutrition/canteen' +// 营养管理 - 算法模型(CRUD) +import './nutrition/algo' +// 营养管理 - 异常事件 / 系统脏数据 +import './nutrition/anoDirty' +// 营养管理 - 异常事件 / 结果异常(含详情) +import './nutrition/anoResult' diff --git a/src/axios/handlers/nutrition/algo.ts b/src/axios/handlers/nutrition/algo.ts new file mode 100644 index 0000000..579e419 --- /dev/null +++ b/src/axios/handlers/nutrition/algo.ts @@ -0,0 +1,132 @@ +/** + * mock handler - 营养管理 / 算法模型 + * + * 对应 api:src/pages/tenant-portal/nutrition/arc/arcAlgo/api/index.ts + * + * 提供完整 CRUD:page / info / add / update / delete + * dataset:5 条(≤ 8 条上限) + */ + +import { registerMocks } from '@/axios/mockBus' +import type { ApiResponse, MockContext } from '@/axios/types' + +interface AlgoRow { + id: string + name: string + description: string + organization: string + author: string + year: number + uploadDate: string + attachment: string +} + +const buildDataset = (): AlgoRow[] => [ + { id: 'A001', name: '营养摄入计算模型说明书', description: '基于用户体征数据和膳食记录,计算每日营养素摄入量', organization: '营养研究院', author: '张博士', year: 2026, uploadDate: '2026-02-15', attachment: 'algo-intake-model-v2.pdf' }, + { id: 'A002', name: '个性化推荐引擎技术文档', description: '根据员工营养档案和饮食偏好,智能推荐每餐菜品组合', organization: 'AI算法中心', author: '李工', year: 2026, uploadDate: '2026-01-20', attachment: 'recommend-engine-tech.docx' }, + { id: 'A003', name: '慢病风险评估模型白皮书', description: '综合分析长期饮食数据与体检指标,评估慢性病风险等级', organization: '健康管理部', author: '王主任', year: 2025, uploadDate: '2025-12-01', attachment: 'risk-assessment-whitepaper.pdf' }, + { id: 'A004', name: '食材营养成分识别算法', description: '通过图像识别和数据库匹配,自动获取食材营养成分信息', organization: 'AI算法中心', author: '赵工', year: 2025, uploadDate: '2025-10-10', attachment: 'food-recognition-algo.pdf' }, + { id: 'A005', name: '膳食平衡评分模型规范', description: '基于中国居民膳食指南,对每日膳食结构进行综合评分', organization: '营养研究院', author: '刘博士', year: 2025, uploadDate: '2025-09-05', attachment: 'balance-score-spec-v1.pdf' }, +] + +/** 缓存数据集 */ +let dataset: AlgoRow[] = buildDataset() + +const filterRows = (rows: AlgoRow[], params: Record): AlgoRow[] => { + const name = params.name as string | undefined + const year = params.year as number | undefined + return rows.filter((r) => { + if (name && !r.name.includes(name)) return false + if (year && r.year !== year) return false + return true + }) +} + +const sortRows = (rows: AlgoRow[], column?: string, order?: string | null): AlgoRow[] => { + if (!column || !order) return rows + const sorted = [...rows] + sorted.sort((a, b) => { + const av = (a as unknown as Record)[column] + const bv = (b as unknown as Record)[column] + if (av === bv) return 0 + if (av === undefined || av === null) return 1 + if (bv === undefined || bv === null) return -1 + const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av) > String(bv) ? 1 : -1 + return order === 'ascend' ? cmp : -cmp + }) + return sorted +} + +registerMocks([ + // 分页查询 + { + url: '/nutrition/arc/algo/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + const filtered = filterRows(dataset, params) + const sorted = sortRows(filtered, params.column as string | undefined, params.order as string | null | undefined) + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + return { code: '00000', msg: '查询成功', data, total: filtered.length } + }, + }, + + // 详情 + { + url: '/nutrition/arc/algo/info', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const id = (ctx.params as { id?: string } | undefined)?.id + const row = dataset.find((r) => r.id === id) ?? null + return { code: '00000', msg: '查询成功', data: row } + }, + }, + + // 新增 + { + url: '/nutrition/arc/algo/add', + method: 'POST', + handler: (ctx: MockContext): ApiResponse<{ id: string }> => { + const id = `A${String(dataset.length + 1).padStart(3, '0')}` + const params = (ctx.params ?? {}) as Partial + dataset.unshift({ + id, + name: params.name ?? '新模型', + description: params.description ?? '', + organization: params.organization ?? '—', + author: params.author ?? '—', + year: params.year ?? 2026, + uploadDate: new Date().toISOString().slice(0, 10), + attachment: params.attachment ?? '', + }) + return { code: '00000', msg: '新增成功', data: { id } } + }, + }, + + // 编辑 + { + url: '/nutrition/arc/algo/update', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Partial & { id?: string } + const idx = dataset.findIndex((r) => r.id === params.id) + if (idx >= 0) dataset[idx] = { ...dataset[idx], ...params } as AlgoRow + return { code: '00000', msg: '编辑成功', data: null } + }, + }, + + // 删除 + { + url: '/nutrition/arc/algo/delete', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const id = (ctx.params as { id?: string } | undefined)?.id + const idx = dataset.findIndex((r) => r.id === id) + if (idx >= 0) dataset.splice(idx, 1) + return { code: '00000', msg: '删除成功', data: null } + }, + }, +]) diff --git a/src/axios/handlers/nutrition/anoDirty.ts b/src/axios/handlers/nutrition/anoDirty.ts new file mode 100644 index 0000000..50b0fde --- /dev/null +++ b/src/axios/handlers/nutrition/anoDirty.ts @@ -0,0 +1,77 @@ +/** + * mock handler - 营养管理 / 系统脏数据 + * + * 对应 api:src/pages/tenant-portal/nutrition/ano/anoDirty/api/index.ts + * + * dataset 8 条(覆盖 7 个模块 + 6 种异常类型) + */ + +import { registerMocks } from '@/axios/mockBus' +import type { ApiResponse, MockContext } from '@/axios/types' + +interface DirtyRow { + id: string + module: 'recipe' | 'order' | 'user' | 'analysis' | 'material' | 'production' | 'supply' + dirtyType: 'missing' | 'format' | 'logic' | 'duplicate' | 'overflow' | 'orphan' + description: string + detectTime: string + tableName: string + fieldName: string + bizNo: string + abnormalValue: string + generateTime: string +} + +const dataset: DirtyRow[] = [ + { id: 'D001', module: 'recipe', dirtyType: 'missing', description: '热量值字段为空,无法参与营养计算', detectTime: '2026-05-14 09:23:00', tableName: 't_recipe_nutrition', fieldName: 'calorie', bizNo: 'RCP-20260514-0087', abnormalValue: 'NULL', generateTime: '2026-05-14 08:10:00' }, + { id: 'D002', module: 'order', dirtyType: 'format', description: '订餐日期格式非法,无法解析', detectTime: '2026-05-14 09:15:00', tableName: 't_order_meal', fieldName: 'order_date', bizNo: 'ORD-20260514-0342', abnormalValue: '2026/13/45', generateTime: '2026-05-14 07:55:00' }, + { id: 'D003', module: 'user', dirtyType: 'logic', description: '员工年龄为负数,BMI 值超出合理范围', detectTime: '2026-05-14 08:50:00', tableName: 't_employee_health', fieldName: 'age / bmi', bizNo: 'EMP-00156', abnormalValue: 'age=-5, bmi=999', generateTime: '2026-05-13 18:30:00' }, + { id: 'D004', module: 'analysis', dirtyType: 'duplicate', description: '同一用户同日存在 3 条重复营养分析记录', detectTime: '2026-05-13 17:30:00', tableName: 't_nutri_analysis', fieldName: 'user_id', bizNo: 'NA-20260513-0218', abnormalValue: '3 条重复', generateTime: '2026-05-13 12:00:00' }, + { id: 'D005', module: 'material', dirtyType: 'overflow', description: '库存数量为负值,疑似出库数据异常', detectTime: '2026-05-13 16:45:00', tableName: 't_material_stock', fieldName: 'stock_qty', bizNo: 'MAT-00523', abnormalValue: '-500 kg', generateTime: '2026-05-13 15:20:00' }, + { id: 'D006', module: 'production', dirtyType: 'format', description: '生产批次号含非法字符,无法关联溯源', detectTime: '2026-05-13 15:10:00', tableName: 't_prod_batch', fieldName: 'batch_no', bizNo: 'PRD-20260513-0091', abnormalValue: 'B@TCH#001!', generateTime: '2026-05-13 14:00:00' }, + { id: 'D007', module: 'supply', dirtyType: 'missing', description: '供应商资质证书编号为空', detectTime: '2026-05-13 14:20:00', tableName: 't_supplier_cert', fieldName: 'cert_no', bizNo: 'SUP-00078', abnormalValue: 'NULL', generateTime: '2026-05-13 10:30:00' }, + { id: 'D008', module: 'recipe', dirtyType: 'orphan', description: '食谱引用的食材 ID 在食材库中不存在', detectTime: '2026-05-12 17:30:00', tableName: 't_recipe_item', fieldName: 'material_id', bizNo: 'RCP-20260512-0045', abnormalValue: 'MAT-99999', generateTime: '2026-05-12 16:00:00' }, +] + +const filterRows = (rows: DirtyRow[], params: Record): DirtyRow[] => { + const moduleVal = params.module as DirtyRow['module'] | undefined + const dirtyType = params.dirtyType as DirtyRow['dirtyType'] | undefined + // year 暂不参与过滤(mock 数据均为 2026 年) + return rows.filter((r) => { + if (moduleVal && r.module !== moduleVal) return false + if (dirtyType && r.dirtyType !== dirtyType) return false + return true + }) +} + +const sortRows = (rows: DirtyRow[], column?: string, order?: string | null): DirtyRow[] => { + if (!column || !order) return rows + const sorted = [...rows] + sorted.sort((a, b) => { + const av = (a as unknown as Record)[column] + const bv = (b as unknown as Record)[column] + if (av === bv) return 0 + if (av === undefined || av === null) return 1 + if (bv === undefined || bv === null) return -1 + const cmp = String(av) > String(bv) ? 1 : -1 + return order === 'ascend' ? cmp : -cmp + }) + return sorted +} + +registerMocks([ + { + url: '/nutrition/ano/dirty/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + const filtered = filterRows(dataset, params) + const sorted = sortRows(filtered, params.column as string | undefined, params.order as string | null | undefined) + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + return { code: '00000', msg: '查询成功', data, total: filtered.length } + }, + }, +]) diff --git a/src/axios/handlers/nutrition/anoResult.ts b/src/axios/handlers/nutrition/anoResult.ts new file mode 100644 index 0000000..7c69bac --- /dev/null +++ b/src/axios/handlers/nutrition/anoResult.ts @@ -0,0 +1,135 @@ +/** + * mock handler - 营养管理 / 结果异常 + * + * 对应 api:src/pages/tenant-portal/nutrition/ano/anoResult/api/index.ts + * + * 提供:分页 / 详情(含算法输入输出 + 堆栈 + 建议) + * dataset 8 条 + */ + +import { registerMocks } from '@/axios/mockBus' +import type { ApiResponse, MockContext } from '@/axios/types' + +type ResultType = 'nutriValue' | 'emptyRecom' | 'nutriRange' | 'timeout' | 'modelOutput' +type Scene = 'nutriCalc' | 'dishRecom' | 'smartRecom' +type Severity = 'high' | 'middle' | 'low' + +interface ResultRow { + id: string + errCode: string + resultType: ResultType + scene: Scene + description: string + severity: Severity + algoVersion: string + foundTime: string +} + +interface ResultDetail extends ResultRow { + inputParams: string + outputResult: string + errorStack: string + suggestion: string +} + +const baseDataset: ResultRow[] = [ + { id: 'R001', errCode: 'ERR-2026-001', resultType: 'nutriValue', scene: 'nutriCalc', description: '员工EMP0234午餐热量计算结果为0kcal', severity: 'high', algoVersion: 'v2.3.1', foundTime: '2026-05-14 12:30:00' }, + { id: 'R002', errCode: 'ERR-2026-002', resultType: 'emptyRecom', scene: 'dishRecom', description: '员工EMP0456个性化推荐返回空列表', severity: 'middle', algoVersion: 'v1.8.0', foundTime: '2026-05-14 11:45:00' }, + { id: 'R003', errCode: 'ERR-2026-003', resultType: 'nutriRange', scene: 'nutriCalc', description: '某菜品钠含量计算结果为99999mg', severity: 'high', algoVersion: 'v2.3.1', foundTime: '2026-05-14 10:20:00' }, + { id: 'R004', errCode: 'ERR-2026-004', resultType: 'timeout', scene: 'nutriCalc', description: '批量营养分析任务执行超时(>30s)', severity: 'middle', algoVersion: 'v2.3.1', foundTime: '2026-05-13 16:40:00' }, + { id: 'R005', errCode: 'ERR-2026-005', resultType: 'emptyRecom', scene: 'dishRecom', description: '糖尿病患者推荐食谱未返回低GI菜品', severity: 'high', algoVersion: 'v1.8.0', foundTime: '2026-05-13 14:15:00' }, + { id: 'R006', errCode: 'ERR-2026-006', resultType: 'modelOutput', scene: 'smartRecom', description: 'LLM返回JSON格式解析失败', severity: 'middle', algoVersion: 'v3.0.2', foundTime: '2026-05-13 11:30:00' }, + { id: 'R007', errCode: 'ERR-2026-007', resultType: 'nutriValue', scene: 'nutriCalc', description: '维生素A摄入量计算结果为负值', severity: 'high', algoVersion: 'v2.3.1', foundTime: '2026-05-12 15:50:00' }, + { id: 'R008', errCode: 'ERR-2026-008', resultType: 'nutriRange', scene: 'nutriCalc', description: '单餐蛋白质含量超出日推荐量300%', severity: 'middle', algoVersion: 'v2.3.1', foundTime: '2026-05-12 12:10:00' }, +] + +/** 详情 dataset:基于基础行追加 inputParams / outputResult / errorStack / suggestion */ +const detailDataset: ResultDetail[] = baseDataset.map((r) => ({ + ...r, + inputParams: JSON.stringify( + { + employeeNo: r.errCode.includes('001') ? 'EMP0234' : 'EMP0456', + mealType: 'lunch', + date: r.foundTime.slice(0, 10), + algoVersion: r.algoVersion, + }, + null, + 2, + ), + outputResult: JSON.stringify( + { + calorie: r.resultType === 'nutriValue' ? 0 : -1, + protein: r.resultType === 'nutriRange' ? 999 : 25.4, + sodium: r.resultType === 'nutriRange' ? 99999 : 1200, + timestamp: r.foundTime, + }, + null, + 2, + ), + errorStack: + r.resultType === 'timeout' + ? `TimeoutError: algo execution exceeded 30000ms\n at NutritionCalculator.calc (nutrition.js:127)\n at async BatchProcessor.process (batch.js:84)\n at async Worker.run (worker.js:56)` + : `AssertionError: result value out of expected range\n at validate (validator.js:42)\n at NutritionCalculator.postProcess (nutrition.js:198)\n at NutritionCalculator.calc (nutrition.js:135)\n at Worker.run (worker.js:56)`, + suggestion: + r.severity === 'high' + ? '建议立即排查算法输入数据完整性,确认体征/膳食记录是否缺失;如复现需回滚算法版本。' + : r.resultType === 'timeout' + ? '建议拆分批处理粒度,单批次控制在 100 条以内;或升级到 v2.4 算法分支(已优化并行)。' + : '建议补充对应字段单位校验,并在前端提示用户复核输入。', +})) + +const filterRows = (rows: ResultRow[], params: Record): ResultRow[] => { + const resultType = params.resultType as ResultType | undefined + const startDate = params.startDate as string | undefined + const endDate = params.endDate as string | undefined + return rows.filter((r) => { + if (resultType && r.resultType !== resultType) return false + if (startDate && r.foundTime.slice(0, 10) < startDate) return false + if (endDate && r.foundTime.slice(0, 10) > endDate) return false + return true + }) +} + +const sortRows = (rows: ResultRow[], column?: string, order?: string | null): ResultRow[] => { + if (!column || !order) return rows + const sorted = [...rows] + sorted.sort((a, b) => { + const av = (a as unknown as Record)[column] + const bv = (b as unknown as Record)[column] + if (av === bv) return 0 + if (av === undefined || av === null) return 1 + if (bv === undefined || bv === null) return -1 + const cmp = String(av) > String(bv) ? 1 : -1 + return order === 'ascend' ? cmp : -cmp + }) + return sorted +} + +registerMocks([ + // 分页 + { + url: '/nutrition/ano/result/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + const filtered = filterRows(baseDataset, params) + const sorted = sortRows(filtered, params.column as string | undefined, params.order as string | null | undefined) + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + return { code: '00000', msg: '查询成功', data, total: filtered.length } + }, + }, + + // 详情 + { + url: '/nutrition/ano/result/info', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const id = (ctx.params as { id?: string } | undefined)?.id + const row = detailDataset.find((r) => r.id === id) ?? null + return { code: '00000', msg: '查询成功', data: row } + }, + }, +]) diff --git a/src/axios/handlers/nutrition/canteen.ts b/src/axios/handlers/nutrition/canteen.ts new file mode 100644 index 0000000..1022e93 --- /dev/null +++ b/src/axios/handlers/nutrition/canteen.ts @@ -0,0 +1,151 @@ +/** + * mock handler - 营养管理 / 场所报表数据 + * + * 对应 api: + * - /nutrition/arc/canteen/stats 主页面 4 卡数据 + * - /nutrition/arc/canteen/operation/page 运营总览分页 + * - /nutrition/arc/canteen/profit/page 成本效益分页 + * + * dataset:运营 8 条 / 成本 8 条(≤ 8 条上限) + */ + +import { registerMocks } from '@/axios/mockBus' +import type { ApiResponse, MockContext } from '@/axios/types' + +/** 运营总览行 */ +interface OperationRow { + id: string + canteen: string + unit: string + lines: number + openDays: number + openMeals: number + dailyAvg: number + yearMeals: number + perCapitaFreq: number + lineRate: number + termRate: number +} + +/** 成本效益行 */ +interface ProfitRow { + id: string + canteen: string + unit: string + revenue: number + cost: number + profit: number + perCapitaCost: number + materialRate: number + grossRate: number + surplusRate: number + score: number +} + +/** 运营总览 dataset(与旧 arc-canteen.html tab0 完全对齐) */ +const operationDataset: OperationRow[] = [ + { id: 'C001', canteen: '总部一食堂', unit: 'CQ能源总部', lines: 4, openDays: 138, openMeals: 414, dailyAvg: 680, yearMeals: 93840, perCapitaFreq: 2.8, lineRate: 92, termRate: 100 }, + { id: 'C002', canteen: '总部二食堂', unit: 'CQ能源总部', lines: 3, openDays: 138, openMeals: 414, dailyAvg: 520, yearMeals: 71760, perCapitaFreq: 2.6, lineRate: 88, termRate: 100 }, + { id: 'C003', canteen: '采油一厂食堂', unit: '采油一厂', lines: 5, openDays: 140, openMeals: 420, dailyAvg: 860, yearMeals: 120400, perCapitaFreq: 2.9, lineRate: 95, termRate: 80 }, + { id: 'C004', canteen: '采油一厂前线食堂', unit: '采油一厂', lines: 2, openDays: 140, openMeals: 280, dailyAvg: 210, yearMeals: 29400, perCapitaFreq: 2.5, lineRate: 74, termRate: 50 }, + { id: 'C005', canteen: '采油二厂食堂', unit: '采油二厂', lines: 4, openDays: 138, openMeals: 414, dailyAvg: 720, yearMeals: 99360, perCapitaFreq: 2.7, lineRate: 90, termRate: 75 }, + { id: 'C006', canteen: '采气一厂食堂', unit: '采气一厂', lines: 3, openDays: 136, openMeals: 408, dailyAvg: 480, yearMeals: 65280, perCapitaFreq: 2.6, lineRate: 86, termRate: 67 }, + { id: 'C007', canteen: '采气一厂驻地食堂', unit: '采气一厂', lines: 2, openDays: 136, openMeals: 272, dailyAvg: 190, yearMeals: 25840, perCapitaFreq: 2.4, lineRate: 72, termRate: 50 }, + { id: 'C008', canteen: '勘探院食堂', unit: '勘探开发研究院', lines: 2, openDays: 135, openMeals: 405, dailyAvg: 200, yearMeals: 27000, perCapitaFreq: 2.7, lineRate: 84, termRate: 100 }, +] + +/** 成本效益 dataset(与旧 arc-canteen.html tab1 完全对齐) */ +const profitDataset: ProfitRow[] = [ + { id: 'C001', canteen: '总部一食堂', unit: 'CQ能源总部', revenue: 2814720, cost: 2290380, profit: 524340, perCapitaCost: 30, materialRate: 42.5, grossRate: 18.6, surplusRate: 96, score: 92 }, + { id: 'C002', canteen: '总部二食堂', unit: 'CQ能源总部', revenue: 2009280, cost: 1671720, profit: 337560, perCapitaCost: 28, materialRate: 44.2, grossRate: 16.8, surplusRate: 94, score: 89 }, + { id: 'C003', canteen: '采油一厂食堂', unit: '采油一厂', revenue: 3371200, cost: 2792640, profit: 578560, perCapitaCost: 28, materialRate: 43.8, grossRate: 17.2, surplusRate: 93, score: 90 }, + { id: 'C004', canteen: '采油一厂前线食堂', unit: '采油一厂', revenue: 735000, cost: 643860, profit: 91140, perCapitaCost: 25, materialRate: 48.6, grossRate: 12.4, surplusRate: 82, score: 74 }, + { id: 'C005', canteen: '采油二厂食堂', unit: '采油二厂', revenue: 2782080, cost: 2309130, profit: 472950, perCapitaCost: 28, materialRate: 44.0, grossRate: 17.0, surplusRate: 91, score: 87 }, + { id: 'C006', canteen: '采气一厂食堂', unit: '采气一厂', revenue: 1827840, cost: 1537390, profit: 290450, perCapitaCost: 28, materialRate: 45.1, grossRate: 15.9, surplusRate: 89, score: 85 }, + { id: 'C007', canteen: '采气一厂驻地食堂', unit: '采气一厂', revenue: 620160, cost: 546960, profit: 73200, perCapitaCost: 24, materialRate: 49.2, grossRate: 11.8, surplusRate: 78, score: 71 }, + { id: 'C008', canteen: '勘探院食堂', unit: '勘探开发研究院', revenue: 837000, cost: 676260, profit: 160740, perCapitaCost: 31, materialRate: 41.8, grossRate: 19.2, surplusRate: 95, score: 88 }, +] + +const sortRows = >(rows: T[], column?: string, order?: string | null): T[] => { + if (!column || !order) return rows + const sorted = [...rows] + sorted.sort((a, b) => { + const av = a[column] + const bv = b[column] + if (av === bv) return 0 + if (av === undefined || av === null) return 1 + if (bv === undefined || bv === null) return -1 + const cmp = typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av) > String(bv) ? 1 : -1 + return order === 'ascend' ? cmp : -cmp + }) + return sorted +} + +const filterByCanteenUnit = ( + rows: T[], + params: Record, +): T[] => { + const canteen = params.canteen as string | undefined + const unit = params.unit as string | undefined + return rows.filter((r) => { + if (canteen && r.canteen !== canteen) return false + if (unit && r.unit !== unit) return false + return true + }) +} + +registerMocks([ + // 4 卡 stats + { + url: '/nutrition/arc/canteen/stats', + method: 'POST', + handler: (): ApiResponse<{ total: number; dailyAvg: number; rate: string; safety: string }> => ({ + code: '00000', + msg: '查询成功', + data: { + total: 12, + dailyAvg: 3860, + rate: '87.2%', + safety: '99.6%', + }, + }), + }, + + // 运营总览分页 + { + url: '/nutrition/arc/canteen/operation/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + + const filtered = filterByCanteenUnit(operationDataset, params) + const sorted = sortRows(filtered as unknown as Record[], params.column as string | undefined, params.order as string | null | undefined) as unknown as OperationRow[] + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + + return { code: '00000', msg: '查询成功', data, total: filtered.length } + }, + }, + + // 成本效益分页 + { + url: '/nutrition/arc/canteen/profit/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + + const filtered = filterByCanteenUnit(profitDataset, params) + const sorted = sortRows(filtered as unknown as Record[], params.column as string | undefined, params.order as string | null | undefined) as unknown as ProfitRow[] + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + + return { code: '00000', msg: '查询成功', data, total: filtered.length } + }, + }, +]) diff --git a/src/axios/handlers/nutrition/employee.ts b/src/axios/handlers/nutrition/employee.ts index 6c7b6aa..dba702a 100644 --- a/src/axios/handlers/nutrition/employee.ts +++ b/src/axios/handlers/nutrition/employee.ts @@ -3,15 +3,17 @@ * * 对应 api:src/pages/tenant-portal/nutrition/arc/arcEmployee/api/index.ts * - * 业务侧调用形如: - * postRequest('axiosRequest', '/nutrition/employee/page', params) - * 经 mockBus 路由到本文件的 handler 返回 mock 数据。 + * 字段与页面 types ListItem 对齐: + * - 删除了 status / createTime(业务上员工营养表不展示这两个字段) + * - 与旧 h5 原型 arc-employee.html 完全一致 + * + * mock dataset ≤ 8 条(原型展示规范) */ import { registerMocks } from '@/axios/mockBus' import type { ApiResponse, MockContext } from '@/axios/types' -/** 员工营养行类型(与页面 types 中 ListItem 对齐) */ +/** 员工营养行 */ interface EmployeeRow { id: string name: string @@ -27,37 +29,34 @@ interface EmployeeRow { fat: number carb: number rate: number - status: 0 | 1 - createTime: string } -/** 全量数据集(≤ 8 条 - 遵循原型 mock 规范:每个列表 mock 不超过 8 条) */ +/** 全量数据集(8 条) */ const buildDataset = (): EmployeeRow[] => [ - { id: 'EMP0001', name: '张伟', gender: '男', age: 32, empNo: 'EMP0001', unit: 'CQ能源总部', dept: '研发部', days: 132, meals: 386, calorie: '2,156', protein: 78.3, fat: 72.1, carb: 285.4, rate: 92, status: 1, createTime: '2026-05-10 09:12:00' }, - { id: 'EMP0002', name: '李娜', gender: '女', age: 28, empNo: 'EMP0002', unit: 'CQ能源总部', dept: '市场部', days: 128, meals: 372, calorie: '1,842', protein: 65.2, fat: 58.7, carb: 241.3, rate: 89, status: 1, createTime: '2026-05-09 08:30:00' }, - { id: 'EMP0003', name: '王磊', gender: '男', age: 35, empNo: 'EMP0003', unit: '采油一厂', dept: '运营部', days: 125, meals: 358, calorie: '2,380', protein: 85.6, fat: 89.2, carb: 312.1, rate: 76, status: 1, createTime: '2026-05-08 17:45:00' }, - { id: 'EMP0004', name: '赵敏', gender: '女', age: 30, empNo: 'EMP0004', unit: '采油二厂', dept: '财务部', days: 131, meals: 390, calorie: '1,720', protein: 58.4, fat: 52.3, carb: 228.6, rate: 95, status: 1, createTime: '2026-05-07 10:20:00' }, - { id: 'EMP0005', name: '陈刚', gender: '男', age: 41, empNo: 'EMP0005', unit: 'CQ能源总部', dept: '人事部', days: 120, meals: 345, calorie: '2,050', protein: 72.1, fat: 68.4, carb: 270.2, rate: 78, status: 1, createTime: '2026-05-06 14:00:00' }, - { id: 'EMP0006', name: '刘洋', gender: '男', age: 26, empNo: 'EMP0006', unit: '炼化分公司', dept: '生产部', days: 110, meals: 318, calorie: '2,210', protein: 80.5, fat: 74.6, carb: 295.0, rate: 85, status: 1, createTime: '2026-05-05 16:30:00' }, - { id: 'EMP0007', name: '杨芳', gender: '女', age: 33, empNo: 'EMP0007', unit: '采油一厂', dept: '安全环保部', days: 130, meals: 380, calorie: '1,950', protein: 70.2, fat: 63.5, carb: 258.3, rate: 91, status: 1, createTime: '2026-05-04 09:00:00' }, - { id: 'EMP0010', name: '吴军', gender: '男', age: 45, empNo: 'EMP0010', unit: '采油二厂', dept: '运营部', days: 122, meals: 352, calorie: '2,180', protein: 76.8, fat: 70.3, carb: 280.5, rate: 81, status: 1, createTime: '2026-05-01 13:25:00' }, + { id: 'EMP0001', name: '张伟', gender: '男', age: 32, empNo: 'EMP0001', unit: 'CQ能源总部', dept: '研发部', days: 132, meals: 386, calorie: '2,156', protein: 78.3, fat: 72.1, carb: 285.4, rate: 92 }, + { id: 'EMP0002', name: '李娜', gender: '女', age: 28, empNo: 'EMP0002', unit: 'CQ能源总部', dept: '市场部', days: 128, meals: 372, calorie: '1,842', protein: 65.2, fat: 58.7, carb: 241.3, rate: 89 }, + { id: 'EMP0003', name: '王磊', gender: '男', age: 35, empNo: 'EMP0003', unit: '采油一厂', dept: '运营部', days: 125, meals: 358, calorie: '2,380', protein: 85.6, fat: 89.2, carb: 312.1, rate: 76 }, + { id: 'EMP0004', name: '赵敏', gender: '女', age: 30, empNo: 'EMP0004', unit: '采油二厂', dept: '财务部', days: 131, meals: 390, calorie: '1,720', protein: 58.4, fat: 52.3, carb: 228.6, rate: 95 }, + { id: 'EMP0005', name: '陈刚', gender: '男', age: 41, empNo: 'EMP0005', unit: 'CQ能源总部', dept: '人事部', days: 120, meals: 345, calorie: '2,050', protein: 72.1, fat: 68.4, carb: 270.2, rate: 78 }, + { id: 'EMP0006', name: '刘洋', gender: '男', age: 26, empNo: 'EMP0006', unit: '炼化分公司', dept: '生产部', days: 110, meals: 318, calorie: '2,210', protein: 80.5, fat: 74.6, carb: 295.0, rate: 85 }, + { id: 'EMP0007', name: '杨芳', gender: '女', age: 33, empNo: 'EMP0007', unit: '采油一厂', dept: '安全环保部', days: 130, meals: 380, calorie: '1,950', protein: 70.2, fat: 63.5, carb: 258.3, rate: 91 }, + { id: 'EMP0010', name: '吴军', gender: '男', age: 45, empNo: 'EMP0010', unit: '采油二厂', dept: '运营部', days: 122, meals: 352, calorie: '2,180', protein: 76.8, fat: 70.3, carb: 280.5, rate: 81 }, ] -/** 缓存数据集(mock handler 内部使用,使用 let 允许 add/delete 修改) */ -let dataset: EmployeeRow[] = buildDataset() +/** 缓存数据集 */ +const dataset: EmployeeRow[] = buildDataset() /** 按搜索条件过滤 */ const filterRows = (rows: EmployeeRow[], params: Record): EmployeeRow[] => { const keyword = (params.keyword ?? params.name) as string | undefined const unit = params.unit as string | undefined const dept = params.dept as string | undefined - const status = params.status as 0 | 1 | undefined + // year 不参与过滤(mock 不区分年份) return rows.filter((r) => { if (keyword && !(r.name.includes(keyword) || r.empNo.includes(keyword))) return false if (unit && r.unit !== unit) return false if (dept && r.dept !== dept) return false - if (status !== undefined && r.status !== status) return false return true }) } @@ -72,7 +71,9 @@ const sortRows = (rows: EmployeeRow[], column?: string, order?: string | null): if (av === bv) return 0 if (av === undefined || av === null) return 1 if (bv === undefined || bv === null) return -1 - const cmp = String(av) > String(bv) ? 1 : -1 + const cmp = typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av) > String(bv) ? 1 : -1 return order === 'ascend' ? cmp : -cmp }) return sorted @@ -104,7 +105,7 @@ registerMocks([ }, }, - // 详情 + // 详情(按 id 取单行) { url: '/nutrition/employee/info', method: 'POST', @@ -119,59 +120,6 @@ registerMocks([ }, }, - // 新增 - { - url: '/nutrition/employee/add', - method: 'POST', - handler: (ctx: MockContext): ApiResponse<{ id: string }> => { - const id = `EMP${String(dataset.length + 1).padStart(4, '0')}` - const params = (ctx.params ?? {}) as Partial - dataset.unshift({ - id, - name: params.name ?? '新员工', - gender: params.gender ?? '男', - age: params.age ?? 25, - empNo: id, - unit: params.unit ?? 'CQ能源总部', - dept: params.dept ?? '研发部', - days: 0, - meals: 0, - calorie: '0', - protein: 0, - fat: 0, - carb: 0, - rate: 0, - status: params.status ?? 1, - createTime: new Date().toISOString().replace('T', ' ').slice(0, 19), - }) - return { code: '00000', msg: '新增成功', data: { id } } - }, - }, - - // 编辑 - { - url: '/nutrition/employee/update', - method: 'POST', - handler: (ctx: MockContext): ApiResponse => { - const params = (ctx.params ?? {}) as Partial & { id?: string } - const idx = dataset.findIndex((r) => r.id === params.id) - if (idx >= 0) dataset[idx] = { ...dataset[idx], ...params } as EmployeeRow - return { code: '00000', msg: '编辑成功', data: null } - }, - }, - - // 删除 - { - url: '/nutrition/employee/delete', - method: 'POST', - handler: (ctx: MockContext): ApiResponse => { - const id = (ctx.params as { id?: string } | undefined)?.id - const idx = dataset.findIndex((r) => r.id === id) - if (idx >= 0) dataset.splice(idx, 1) - return { code: '00000', msg: '删除成功', data: null } - }, - }, - // 统计卡数据 { url: '/nutrition/employee/stats', diff --git a/src/axios/handlers/nutrition/employeeDetail.ts b/src/axios/handlers/nutrition/employeeDetail.ts new file mode 100644 index 0000000..eef642b --- /dev/null +++ b/src/axios/handlers/nutrition/employeeDetail.ts @@ -0,0 +1,129 @@ +/** + * mock handler - 营养管理 / 员工用餐记录详情 + * + * 对应 api:src/pages/tenant-portal/nutrition/arc/arcEmployeeDetail/api/index.ts + * + * 提供: + * - /nutrition/employee/detail/info 员工基本信息 + * - /nutrition/employee/detail/page 用餐记录分页(mock dataset ≤ 8 条) + */ + +import { registerMocks } from '@/axios/mockBus' +import type { ApiResponse, MockContext } from '@/axios/types' + +/** 员工基本信息行(与页面 types EmployeeInfo 对齐) */ +interface EmployeeInfoRow { + empNo: string + name: string + gender: '男' | '女' + age: number + unit: string + dept: string + year: number + totalMeals: number + rate: number +} + +/** 用餐记录行(与页面 types ListItem 对齐) */ +interface MealRow { + id: string + empNo: string + date: string + meal: 'breakfast' | 'lunch' | 'dinner' + dineType: '堂食' | '外带' + dishes: string + calorie: number + protein: number + fat: number + carb: number + rateStatus: 'pass' | 'high' | 'over' +} + +/** 员工信息字典(按工号取) */ +const EMPLOYEE_MAP: Record = { + EMP0001: { empNo: 'EMP0001', name: '张伟', gender: '男', age: 32, unit: 'CQ能源总部', dept: '研发部', year: 2026, totalMeals: 386, rate: 92 }, + EMP0002: { empNo: 'EMP0002', name: '李娜', gender: '女', age: 28, unit: 'CQ能源总部', dept: '市场部', year: 2026, totalMeals: 372, rate: 89 }, + EMP0003: { empNo: 'EMP0003', name: '王磊', gender: '男', age: 35, unit: '采油一厂', dept: '运营部', year: 2026, totalMeals: 358, rate: 76 }, + EMP0004: { empNo: 'EMP0004', name: '赵敏', gender: '女', age: 30, unit: '采油二厂', dept: '财务部', year: 2026, totalMeals: 390, rate: 95 }, + EMP0005: { empNo: 'EMP0005', name: '陈刚', gender: '男', age: 41, unit: 'CQ能源总部', dept: '人事部', year: 2026, totalMeals: 345, rate: 78 }, + EMP0006: { empNo: 'EMP0006', name: '刘洋', gender: '男', age: 26, unit: '炼化分公司', dept: '生产部', year: 2026, totalMeals: 318, rate: 85 }, + EMP0007: { empNo: 'EMP0007', name: '杨芳', gender: '女', age: 33, unit: '采油一厂', dept: '安全环保部', year: 2026, totalMeals: 380, rate: 91 }, + EMP0010: { empNo: 'EMP0010', name: '吴军', gender: '男', age: 45, unit: '采油二厂', dept: '运营部', year: 2026, totalMeals: 352, rate: 81 }, +} + +/** 用餐记录数据集(8 条,覆盖 3 个餐次 + 3 种达标状态 + 堂食/外带) */ +const buildMealDataset = (empNo: string): MealRow[] => [ + { id: `${empNo}-001`, empNo, date: '2026-05-13', meal: 'lunch', dineType: '堂食', dishes: '宫保鸡丁、米饭、紫菜蛋花汤', calorie: 685, protein: 28.4, fat: 22.1, carb: 89.3, rateStatus: 'pass' }, + { id: `${empNo}-002`, empNo, date: '2026-05-13', meal: 'breakfast', dineType: '堂食', dishes: '豆浆、油条、茶叶蛋', calorie: 420, protein: 15.2, fat: 18.6, carb: 48.7, rateStatus: 'pass' }, + { id: `${empNo}-003`, empNo, date: '2026-05-12', meal: 'dinner', dineType: '外带', dishes: '红烧牛肉面', calorie: 580, protein: 24.8, fat: 16.3, carb: 78.2, rateStatus: 'pass' }, + { id: `${empNo}-004`, empNo, date: '2026-05-12', meal: 'lunch', dineType: '堂食', dishes: '糖醋排骨、清炒时蔬、米饭', calorie: 720, protein: 30.1, fat: 28.5, carb: 85.6, rateStatus: 'high' }, + { id: `${empNo}-005`, empNo, date: '2026-05-12', meal: 'breakfast', dineType: '堂食', dishes: '小米粥、馒头、咸菜', calorie: 310, protein: 8.6, fat: 4.2, carb: 62.1, rateStatus: 'pass' }, + { id: `${empNo}-006`, empNo, date: '2026-05-11', meal: 'lunch', dineType: '堂食', dishes: '鱼香肉丝、番茄炒蛋、米饭', calorie: 695, protein: 26.3, fat: 24.7, carb: 88.4, rateStatus: 'pass' }, + { id: `${empNo}-007`, empNo, date: '2026-05-11', meal: 'breakfast', dineType: '外带', dishes: '牛奶、三明治', calorie: 385, protein: 16.8, fat: 14.2, carb: 42.5, rateStatus: 'pass' }, + { id: `${empNo}-008`, empNo, date: '2026-05-10', meal: 'lunch', dineType: '堂食', dishes: '麻辣香锅、米饭', calorie: 860, protein: 32.4, fat: 42.1, carb: 82.3, rateStatus: 'over' }, +] + +/** 按搜索条件过滤 */ +const filterRows = (rows: MealRow[], params: Record): MealRow[] => { + const startDate = params.startDate as string | undefined + const endDate = params.endDate as string | undefined + const meal = params.meal as MealRow['meal'] | undefined + + return rows.filter((r) => { + if (startDate && r.date < startDate) return false + if (endDate && r.date > endDate) return false + if (meal && r.meal !== meal) return false + return true + }) +} + +/** 排序 */ +const sortRows = (rows: MealRow[], column?: string, order?: string | null): MealRow[] => { + if (!column || !order) return rows + const sorted = [...rows] + sorted.sort((a, b) => { + const av = (a as unknown as Record)[column] + const bv = (b as unknown as Record)[column] + if (av === bv) return 0 + if (av === undefined || av === null) return 1 + if (bv === undefined || bv === null) return -1 + const cmp = String(av) > String(bv) ? 1 : -1 + return order === 'ascend' ? cmp : -cmp + }) + return sorted +} + +registerMocks([ + // 员工基本信息 + { + url: '/nutrition/employee/detail/info', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const empNo = (ctx.params as { empNo?: string } | undefined)?.empNo ?? '' + const row = EMPLOYEE_MAP[empNo] ?? null + return { code: '00000', msg: '查询成功', data: row } + }, + }, + + // 用餐记录分页 + { + url: '/nutrition/employee/detail/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const empNo = (params.empNo as string | undefined) ?? 'EMP0001' + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + const column = params.column as string | undefined + const order = params.order as string | null | undefined + + const dataset = buildMealDataset(empNo) + const filtered = filterRows(dataset, params) + const sorted = sortRows(filtered, column, order) + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + + return { code: '00000', msg: '查询成功', data, total: filtered.length } + }, + }, +]) diff --git a/src/axios/handlers/nutrition/unit.ts b/src/axios/handlers/nutrition/unit.ts new file mode 100644 index 0000000..1a8cd7a --- /dev/null +++ b/src/axios/handlers/nutrition/unit.ts @@ -0,0 +1,117 @@ +/** + * mock handler - 营养管理 / 单位报表数据(二级 + 三级) + * + * 对应 api: + * - /nutrition/arc/unit/level2/page + * - /nutrition/arc/unit/level3/page + * + * 数据集:二级 5 条 / 三级 5 条(≤ 8 条上限) + */ + +import { registerMocks } from '@/axios/mockBus' +import type { ApiResponse, MockContext } from '@/axios/types' + +/** 二级单位行 */ +interface Level2Row { + id: string + unit: string + totalHc: number + diningHc: number + totalMeals: number + perCapitaMeals: number + calorie: string + score: number + rate: number + coverage: string +} + +/** 三级单位行(部门级别) */ +interface Level3Row { + id: string + dept: string + parentUnit: string + totalHc: number + diningHc: number + totalMeals: number + perCapitaMeals: number + calorie: string + score: number + rate: number + coverage: string +} + +/** 二级单位 dataset */ +const level2Dataset: Level2Row[] = [ + { id: 'U001', unit: '采油一厂', totalHc: 420, diningHc: 398, totalMeals: 52680, perCapitaMeals: 132, calorie: '2,180', score: 82, rate: 91, coverage: '94.8%' }, + { id: 'U002', unit: '采油二厂', totalHc: 380, diningHc: 356, totalMeals: 46920, perCapitaMeals: 132, calorie: '2,210', score: 79, rate: 88, coverage: '93.7%' }, + { id: 'U003', unit: '采气一厂', totalHc: 260, diningHc: 245, totalMeals: 31850, perCapitaMeals: 130, calorie: '2,150', score: 72, rate: 79, coverage: '94.2%' }, + { id: 'U004', unit: '能源总部机关', totalHc: 150, diningHc: 148, totalMeals: 19240, perCapitaMeals: 130, calorie: '1,980', score: 86, rate: 93, coverage: '98.7%' }, + { id: 'U005', unit: '勘探开发研究院', totalHc: 70, diningHc: 65, totalMeals: 8450, perCapitaMeals: 130, calorie: '2,050', score: 78, rate: 86, coverage: '92.9%' }, +] + +/** 三级单位(部门)dataset */ +const level3Dataset: Level3Row[] = [ + { id: 'D001', dept: '研发部', parentUnit: '采油一厂', totalHc: 85, diningHc: 82, totalMeals: 10824, perCapitaMeals: 132, calorie: '2,156', score: 84, rate: 92, coverage: '96.5%' }, + { id: 'D002', dept: '运营部', parentUnit: '采油一厂', totalHc: 120, diningHc: 112, totalMeals: 14560, perCapitaMeals: 130, calorie: '2,380', score: 71, rate: 76, coverage: '93.3%' }, + { id: 'D003', dept: '市场部', parentUnit: '采油一厂', totalHc: 95, diningHc: 90, totalMeals: 11700, perCapitaMeals: 130, calorie: '1,842', score: 80, rate: 89, coverage: '94.7%' }, + { id: 'D004', dept: '财务部', parentUnit: '采油二厂', totalHc: 60, diningHc: 58, totalMeals: 7540, perCapitaMeals: 130, calorie: '1,720', score: 87, rate: 95, coverage: '96.7%' }, + { id: 'D005', dept: '人事部', parentUnit: '采油二厂', totalHc: 45, diningHc: 42, totalMeals: 5460, perCapitaMeals: 130, calorie: '2,050', score: 73, rate: 78, coverage: '93.3%' }, +] + +/** 通用排序 */ +const sortRows = >(rows: T[], column?: string, order?: string | null): T[] => { + if (!column || !order) return rows + const sorted = [...rows] + sorted.sort((a, b) => { + const av = a[column] + const bv = b[column] + if (av === bv) return 0 + if (av === undefined || av === null) return 1 + if (bv === undefined || bv === null) return -1 + const cmp = typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av) > String(bv) ? 1 : -1 + return order === 'ascend' ? cmp : -cmp + }) + return sorted +} + +registerMocks([ + // 二级单位分页 + { + url: '/nutrition/arc/unit/level2/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + const unit = params.unit as string | undefined + + const filtered = level2Dataset.filter((r) => !unit || r.unit === unit) + const sorted = sortRows(filtered as unknown as Record[], params.column as string | undefined, params.order as string | null | undefined) as unknown as Level2Row[] + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + + return { code: '00000', msg: '查询成功', data, total: filtered.length } + }, + }, + + // 三级单位分页 + { + url: '/nutrition/arc/unit/level3/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + const parentUnit = params.parentUnit as string | undefined + + const filtered = level3Dataset.filter((r) => !parentUnit || r.parentUnit === parentUnit) + const sorted = sortRows(filtered as unknown as Record[], params.column as string | undefined, params.order as string | null | undefined) as unknown as Level3Row[] + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + + return { code: '00000', msg: '查询成功', data, total: filtered.length } + }, + }, +]) diff --git a/src/meta/nav.ts b/src/meta/nav.ts index c0ef8ca..afddc29 100644 --- a/src/meta/nav.ts +++ b/src/meta/nav.ts @@ -200,10 +200,12 @@ const nutritionApp: NavApp = { children: [ { key: 'nutrition-arc-employee', label: '员工营养数据', path: 'employee', status: 'ready', component: () => import('@pages/tenant-portal/nutrition/arc/arcEmployee/arcEmployee.vue') }, - { key: 'nutrition-arc-unit', label: '单位报表数据', path: 'unit', status: 'draft', - component: () => import('@pages/tenant-portal/nutrition/arc/ArcUnit.vue') }, - placeholder('nutrition-arc-canteen', '场所报表数据', 'canteen'), - placeholder('nutrition-arc-algo', '算法模型', 'algo'), + { key: 'nutrition-arc-unit', label: '单位报表数据', path: 'unit', status: 'ready', + component: () => import('@pages/tenant-portal/nutrition/arc/arcUnit/arcUnit.vue') }, + { key: 'nutrition-arc-canteen', label: '场所报表数据', path: 'canteen', status: 'ready', + component: () => import('@pages/tenant-portal/nutrition/arc/arcCanteen/arcCanteen.vue') }, + { key: 'nutrition-arc-algo', label: '算法模型', path: 'algo', status: 'ready', + component: () => import('@pages/tenant-portal/nutrition/arc/arcAlgo/arcAlgo.vue') }, ], }, { @@ -303,8 +305,10 @@ const nutritionApp: NavApp = { label: '异常事件', icon: 'WarningOutlined', children: [ - placeholder('nutrition-ano-dirty', '系统脏数据', 'dirty'), - placeholder('nutrition-ano-result', '结果异常', 'result'), + { key: 'nutrition-ano-dirty', label: '系统脏数据', path: 'dirty', status: 'ready', + component: () => import('@pages/tenant-portal/nutrition/ano/anoDirty/anoDirty.vue') }, + { key: 'nutrition-ano-result', label: '结果异常', path: 'result', status: 'ready', + component: () => import('@pages/tenant-portal/nutrition/ano/anoResult/anoResult.vue') }, ], }, ], diff --git a/src/pages/tenant-portal/nutrition/ano/anoDirty/anoDirty.vue b/src/pages/tenant-portal/nutrition/ano/anoDirty/anoDirty.vue new file mode 100644 index 0000000..8d50784 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoDirty/anoDirty.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/pages/tenant-portal/nutrition/ano/anoDirty/api/index.ts b/src/pages/tenant-portal/nutrition/ano/anoDirty/api/index.ts new file mode 100644 index 0000000..083ba98 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoDirty/api/index.ts @@ -0,0 +1,13 @@ +/** + * 异常事件 / 系统脏数据 - 接口层 + * - 只读查询 + * - 导出 / 查看导出任务为原型 message 提示 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +/** 分页查询 */ +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/nutrition/ano/dirty/page', params) diff --git a/src/pages/tenant-portal/nutrition/ano/anoDirty/init/usePage.ts b/src/pages/tenant-portal/nutrition/ano/anoDirty/init/usePage.ts new file mode 100644 index 0000000..4150b7e --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoDirty/init/usePage.ts @@ -0,0 +1,89 @@ +/** + * 异常事件 / 系统脏数据 - 页面主逻辑 + * + * 业务定位:只读查询(无操作列)+ 顶部导出/查看导出任务按钮 + */ + +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { list } from '../api' +import { useSearch } from './useSearch' +import { useTable } from './useTable' +import type { ListItem } from '../types' + +export const usePage = () => { + const { message } = useAntdStaticMethods() + const { search, options, initOptions, resetSearch } = useSearch() + + const pageLoading = ref(false) + + 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 ?? []) as ListItem[] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('脏数据列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + listRequest() + } + + /** 导出 - 原型 message 提示 */ + const onExport = (): void => { + void message.info('导出任务已提交,可在"查看导出任务"中查看进度') + } + + /** 查看导出任务 */ + const onViewExportTask = (): void => { + void message.info('当前共有 2 个导出任务正在处理中(原型阶段提示)') + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + options, + table, + dataSourceChange, + resizeColumn, + searchQuery, + resetQuery, + onExport, + onViewExportTask, + listRequest, + } +} diff --git a/src/pages/tenant-portal/nutrition/ano/anoDirty/init/useSearch.ts b/src/pages/tenant-portal/nutrition/ano/anoDirty/init/useSearch.ts new file mode 100644 index 0000000..e706caf --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoDirty/init/useSearch.ts @@ -0,0 +1,73 @@ +/** + * 异常事件 / 系统脏数据 - 搜索逻辑 + */ + +import type { DirtyType, ModuleType, SearchForm } from '../types' + +const DEFAULT_YEAR = 2026 + +const createSearchKey = (): SearchForm => ({ + module: undefined, + dirtyType: undefined, + year: DEFAULT_YEAR, +}) + +const createOptions = () => ({ + module: [] as { label: string; value: ModuleType }[], + dirtyType: [] as { label: string; value: DirtyType }[], + year: [] as { label: string; value: number }[], +}) + +/** 业务模块字典(与旧 ano-dirty.html 完全一致) */ +const MODULE_LIST: { label: string; value: ModuleType }[] = [ + { label: '食谱管理', value: 'recipe' }, + { label: '订餐管理', value: 'order' }, + { label: '用户档案', value: 'user' }, + { label: '营养分析', value: 'analysis' }, + { label: '食材库存', value: 'material' }, + { label: '健康生产', value: 'production' }, + { label: '卫生供应', value: 'supply' }, +] + +/** 异常类型字典 */ +const DIRTY_TYPE_LIST: { label: string; value: DirtyType }[] = [ + { label: '数据缺失', value: 'missing' }, + { label: '格式错误', value: 'format' }, + { label: '逻辑矛盾', value: 'logic' }, + { label: '重复数据', value: 'duplicate' }, + { label: '范围越界', value: 'overflow' }, + { label: '关联缺失', value: 'orphan' }, +] + +const YEAR_LIST = [ + { label: '2026年', value: 2026 }, + { label: '2025年', value: 2025 }, + { label: '2024年', value: 2024 }, +] + +export const useSearch = () => { + const search = reactive(createSearchKey()) + const options = reactive(createOptions()) + + const initOptions = (): void => { + Promise.all([ + Promise.resolve(MODULE_LIST), + Promise.resolve(DIRTY_TYPE_LIST), + Promise.resolve(YEAR_LIST), + ]) + .then(([moduleList, dirtyList, yearList]) => { + options.module = moduleList + options.dirtyType = dirtyList + options.year = yearList + }) + .catch((err: unknown) => { + console.error('初始化下拉选项失败:', err) + }) + } + + const resetSearch = (): void => { + Object.assign(search, createSearchKey()) + } + + return { search, options, initOptions, resetSearch } +} diff --git a/src/pages/tenant-portal/nutrition/ano/anoDirty/init/useTable.ts b/src/pages/tenant-portal/nutrition/ano/anoDirty/init/useTable.ts new file mode 100644 index 0000000..74a087d --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoDirty/init/useTable.ts @@ -0,0 +1,138 @@ +/** + * 异常事件 / 系统脏数据 - 表格逻辑 + * + * 9 列:业务模块 / 异常类型(标签色) / 异常描述 / 异常检测时间 / + * 表名 / 字段名 / 编号 / 异常值(红色高亮) / 数据产生时间 + * + * 异常类型的色彩规则对齐旧 ano-dirty.html: + * - 数据缺失/逻辑矛盾/范围越界 → 红 + * - 格式错误/关联缺失 → 橙 + * - 重复数据 → 蓝 + */ + +import { h } from 'vue' +import type { TableColumnsType } from 'ant-design-vue' +import { + type TableSort, + type TableState, + createDataSourceChange, + createPaginationConfig, + createResetTable, + resizeColumn, +} from '@utils/antDesign/table' +import type { DirtyType, ListItem, ModuleType } from '../types' + +const INIT_SORT: TableSort = { + field: 'detectTime', + order: 'descend', +} + +/** 业务模块标签 */ +const MODULE_LABEL: Record = { + recipe: '食谱管理', + order: '订餐管理', + user: '用户档案', + analysis: '营养分析', + material: '食材库存', + production: '健康生产', + supply: '卫生供应', +} + +/** 异常类型 → 文案 + 配色 */ +const DIRTY_TAG: Record = { + missing: { label: '数据缺失', color: '#f5222d', bg: '#fff1f0' }, + format: { label: '格式错误', color: '#faad14', bg: '#fff7e6' }, + logic: { label: '逻辑矛盾', color: '#f5222d', bg: '#fff1f0' }, + duplicate: { label: '重复数据', color: '#1890ff', bg: '#e6f7ff' }, + overflow: { label: '范围越界', color: '#f5222d', bg: '#fff1f0' }, + orphan: { label: '关联缺失', color: '#faad14', bg: '#fff7e6' }, +} + +const tableColumns: TableColumnsType = [ + { + title: '业务模块', + dataIndex: 'module', + align: 'center', + fixed: 'left', + width: 110, + customRender: ({ text }: { text: ModuleType }) => MODULE_LABEL[text] ?? '—', + }, + { + title: '异常类型', + dataIndex: 'dirtyType', + align: 'center', + width: 110, + customRender: ({ text }: { text: DirtyType }) => { + const meta = DIRTY_TAG[text] + if (!meta) return '—' + return h( + 'span', + { + style: { + color: meta.color, + background: meta.bg, + padding: '2px 8px', + borderRadius: '10px', + fontSize: '12px', + }, + }, + meta.label, + ) + }, + }, + { + title: '异常描述', + dataIndex: 'description', + align: 'left', + width: 320, + resizable: true, + ellipsis: true, + }, + { + title: '异常检测时间', + dataIndex: 'detectTime', + align: 'center', + width: 170, + sorter: true, + }, + { title: '表名', dataIndex: 'tableName', align: 'left', width: 160, resizable: true, ellipsis: true }, + { title: '字段名', dataIndex: 'fieldName', align: 'left', width: 140, resizable: true, ellipsis: true }, + { title: '编号', dataIndex: 'bizNo', align: 'left', width: 170, resizable: true, ellipsis: true }, + { + title: '异常值', + dataIndex: 'abnormalValue', + align: 'left', + width: 180, + resizable: true, + ellipsis: true, + customRender: ({ text }: { text: string }) => + h( + 'span', + { style: { color: '#f5222d', fontWeight: 500 } }, + text ?? '—', + ), + }, + { + title: '数据产生时间', + dataIndex: 'generateTime', + align: 'center', + width: 170, + sorter: true, + }, +] + +export const useTable = (listRequest: () => void) => { + const createInitState = (): TableState => ({ + columns: tableColumns, + dataSource: [], + sort: { ...INIT_SORT }, + pagination: createPaginationConfig(), + }) + + const table = reactive(createInitState()) as TableState + + const resetTable = createResetTable(table, createInitState) + const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest) + + return { table, dataSourceChange, resetTable, resizeColumn } +} diff --git a/src/pages/tenant-portal/nutrition/ano/anoDirty/types/index.ts b/src/pages/tenant-portal/nutrition/ano/anoDirty/types/index.ts new file mode 100644 index 0000000..18687bd --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoDirty/types/index.ts @@ -0,0 +1,59 @@ +/** + * 异常事件 / 系统脏数据 - 类型集中定义 + */ + +/** 异常类型枚举 */ +export type DirtyType = + | 'missing' // 数据缺失 + | 'format' // 格式错误 + | 'logic' // 逻辑矛盾 + | 'duplicate' // 重复数据 + | 'overflow' // 范围越界 + | 'orphan' // 关联缺失 + +/** 业务模块枚举 */ +export type ModuleType = + | 'recipe' // 食谱管理 + | 'order' // 订餐管理 + | 'user' // 用户档案 + | 'analysis' // 营养分析 + | 'material' // 食材库存 + | 'production' // 健康生产 + | 'supply' // 卫生供应 + +/** 列表行:脏数据记录 */ +export interface ListItem { + id: string + /** 业务模块 */ + module: ModuleType + /** 异常类型 */ + dirtyType: DirtyType + /** 异常描述 */ + description: string + /** 异常检测时间 YYYY-MM-DD HH:mm:ss */ + detectTime: string + /** 表名(数据库物理表名) */ + tableName: string + /** 字段名 */ + fieldName: string + /** 异常数据编号(业务编号) */ + bizNo: string + /** 异常值(字符串展示) */ + abnormalValue: string + /** 数据产生时间 YYYY-MM-DD HH:mm:ss */ + generateTime: string +} + +/** 搜索表单 */ +export interface SearchForm { + module?: ModuleType + dirtyType?: DirtyType + year?: number +} + +export interface ListParams extends SearchForm { + pageNum: number + pageSize: number + order?: 'ascend' | 'descend' | null + column?: string +} diff --git a/src/pages/tenant-portal/nutrition/ano/anoResult/anoResult.vue b/src/pages/tenant-portal/nutrition/ano/anoResult/anoResult.vue new file mode 100644 index 0000000..490b948 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/anoResult.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/pages/tenant-portal/nutrition/ano/anoResult/api/index.ts b/src/pages/tenant-portal/nutrition/ano/anoResult/api/index.ts new file mode 100644 index 0000000..506c28d --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/api/index.ts @@ -0,0 +1,15 @@ +/** + * 异常事件 / 结果异常 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { DetailInfo, ListItem, ListParams } from '../types' + +/** 分页查询 */ +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/nutrition/ano/result/page', params) + +/** 详情(含堆栈与算法输入输出) */ +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/nutrition/ano/result/info', { id }) diff --git a/src/pages/tenant-portal/nutrition/arc/arcEmployee/component/drawer/info/api/index.ts b/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/api/index.ts similarity index 57% rename from src/pages/tenant-portal/nutrition/arc/arcEmployee/component/drawer/info/api/index.ts rename to src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/api/index.ts index 8a47bed..a442424 100644 --- a/src/pages/tenant-portal/nutrition/arc/arcEmployee/component/drawer/info/api/index.ts +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/api/index.ts @@ -1,11 +1,11 @@ /** - * 员工营养 - 详情抽屉 - 接口层 + * 结果异常 - 详情抽屉 - 接口层 + * - 详情请求 reuse 主页面 api */ import type { ApiResponse } from '@axios' import { postRequest } from '@axios' import type { DetailInfo } from '../types' -/** 详情查询 */ export const getInfo = (id: string): Promise> => - postRequest('axiosRequest', '/nutrition/employee/info', { id }) + postRequest('axiosRequest', '/nutrition/ano/result/info', { id }) diff --git a/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/info.vue b/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/info.vue new file mode 100644 index 0000000..5587b11 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/info.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/pages/tenant-portal/nutrition/arc/arcEmployee/component/drawer/info/init/usePage.ts b/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/init/usePage.ts similarity index 62% rename from src/pages/tenant-portal/nutrition/arc/arcEmployee/component/drawer/info/init/usePage.ts rename to src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/init/usePage.ts index e4d329b..36e2c88 100644 --- a/src/pages/tenant-portal/nutrition/arc/arcEmployee/component/drawer/info/init/usePage.ts +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/init/usePage.ts @@ -1,26 +1,25 @@ /** - * 员工营养 - 详情抽屉 - 主逻辑 + * 结果异常 - 详情抽屉 - 主逻辑 * - * 对齐 vue 研发项目 drawer-spec.md(详情型): + * 严格遵循 drawer-spec.md 详情型: * - 仅暴露 openDrawer / closeDrawer - * - pageInfo.spin 管理 loading + * - pageInfo.spin 控制 loading * - 接口 .then().catch().finally() 链式 */ import { getInfo } from '../api' -import type { DetailInfo, PageInfo } from '../types' +import type { DetailInfo, OpenParam, PageInfo } from '../types' export const usePage = () => { const pageInfo = reactive({ visible: false, - title: '员工营养详情', + title: '结果异常详情', spin: false, - width: 720, + width: 800, }) const detail = ref(null) - /** 拉取详情 */ const loadData = (id: string): void => { pageInfo.spin = true getInfo(id) @@ -28,7 +27,7 @@ export const usePage = () => { if (res.code === '00000') detail.value = res.data }) .catch((err: unknown) => { - console.error('获取员工营养详情失败:', err) + console.error('获取结果异常详情失败:', err) }) .finally(() => { pageInfo.spin = false @@ -36,10 +35,10 @@ export const usePage = () => { } /** 父组件调用入口 */ - const openDrawer = (record: { id: string; name?: string }): void => { + const openDrawer = (record: OpenParam): void => { detail.value = null pageInfo.visible = true - pageInfo.title = record.name ? `${record.name} - 营养详情` : '员工营养详情' + pageInfo.title = record.errCode ? `${record.errCode} - 异常详情` : '结果异常详情' loadData(record.id) } diff --git a/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/types/index.ts b/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/types/index.ts new file mode 100644 index 0000000..7c84b0c --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/component/drawer/info/types/index.ts @@ -0,0 +1,20 @@ +/** + * 结果异常 - 详情抽屉 - 类型定义 + */ + +import type { DetailInfo } from '../../../types' + +export interface PageInfo { + visible: boolean + title: string + spin: boolean + width: number +} + +/** 抽屉打开参数 */ +export interface OpenParam { + id: string + errCode?: string +} + +export type { DetailInfo } diff --git a/src/pages/tenant-portal/nutrition/ano/anoResult/init/usePage.ts b/src/pages/tenant-portal/nutrition/ano/anoResult/init/usePage.ts new file mode 100644 index 0000000..a9c8345 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/init/usePage.ts @@ -0,0 +1,102 @@ +/** + * 异常事件 / 结果异常 - 页面主逻辑 + * + * 业务定位:只读查询页 + 详情抽屉 + * - 顶部 toolbar:导出 / 查看导出任务 + * - 操作列:详情 → 打开详情抽屉 + */ + +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { list } from '../api' +import { useSearch } from './useSearch' +import { useTable } from './useTable' +import InfoDrawer from '../component/drawer/info/info.vue' +import type { ListItem } from '../types' + +export const usePage = () => { + const { message } = useAntdStaticMethods() + const { search, options, initOptions, resetSearch } = useSearch() + + const pageLoading = ref(false) + + /** 详情抽屉 ref - 必须在 usePage.ts 中定义 */ + const infoDrawerRef = ref | null>(null) + + const buildParams = () => ({ + ...search, + pageNum: table.pagination.current ?? 1, + pageSize: table.pagination.pageSize ?? 30, + order: table.sort.order, + column: table.sort.field, + }) + + const listRequest = (): void => { + pageLoading.value = true + list(buildParams()) + .then((res) => { + if (res.code === '00000') { + table.dataSource = (res.data ?? []) as ListItem[] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('结果异常列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + listRequest() + } + + /** 打开详情抽屉(命名 open + 组件名) */ + const openInfoDrawer = (record: ListItem): void => { + infoDrawerRef.value?.openDrawer({ id: record.id, errCode: record.errCode }) + } + + /** 导出 */ + const onExport = (): void => { + void message.info('导出任务已提交,可在"查看导出任务"中查看进度') + } + + /** 查看导出任务 */ + const onViewExportTask = (): void => { + void message.info('当前共有 2 个导出任务正在处理中(原型阶段提示)') + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + infoDrawerRef, + pageLoading, + search, + options, + table, + dataSourceChange, + resizeColumn, + searchQuery, + resetQuery, + openInfoDrawer, + onExport, + onViewExportTask, + listRequest, + } +} diff --git a/src/pages/tenant-portal/nutrition/ano/anoResult/init/useSearch.ts b/src/pages/tenant-portal/nutrition/ano/anoResult/init/useSearch.ts new file mode 100644 index 0000000..6ec343b --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/init/useSearch.ts @@ -0,0 +1,55 @@ +/** + * 异常事件 / 结果异常 - 搜索逻辑 + */ + +import type { ResultType, SearchForm } from '../types' + +const DEFAULT_YEAR = 2026 + +const createSearchKey = (): SearchForm => ({ + resultType: undefined, + startDate: undefined, + endDate: undefined, + year: DEFAULT_YEAR, +}) + +const createOptions = () => ({ + resultType: [] as { label: string; value: ResultType }[], + year: [] as { label: string; value: number }[], +}) + +const RESULT_TYPE_LIST: { label: string; value: ResultType }[] = [ + { label: '营养值异常', value: 'nutriValue' }, + { label: '推荐结果为空', value: 'emptyRecom' }, + { label: '营养值超范围', value: 'nutriRange' }, + { label: '算法超时', value: 'timeout' }, + { label: '模型输出异常', value: 'modelOutput' }, +] + +const YEAR_LIST = [ + { label: '2026年', value: 2026 }, + { label: '2025年', value: 2025 }, + { label: '2024年', value: 2024 }, +] + +export const useSearch = () => { + const search = reactive(createSearchKey()) + const options = reactive(createOptions()) + + const initOptions = (): void => { + Promise.all([Promise.resolve(RESULT_TYPE_LIST), Promise.resolve(YEAR_LIST)]) + .then(([typeList, yearList]) => { + options.resultType = typeList + options.year = yearList + }) + .catch((err: unknown) => { + console.error('初始化下拉选项失败:', err) + }) + } + + const resetSearch = (): void => { + Object.assign(search, createSearchKey()) + } + + return { search, options, initOptions, resetSearch } +} diff --git a/src/pages/tenant-portal/nutrition/ano/anoResult/init/useTable.ts b/src/pages/tenant-portal/nutrition/ano/anoResult/init/useTable.ts new file mode 100644 index 0000000..fd689e2 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/init/useTable.ts @@ -0,0 +1,131 @@ +/** + * 异常事件 / 结果异常 - 表格逻辑 + * + * 8 列:异常ID / 异常类型(色彩) / 触发场景 / 异常描述 / + * 严重程度(色彩) / 算法版本 / 发现时间 / 操作 + */ + +import { h } from 'vue' +import type { TableColumnsType } from 'ant-design-vue' +import { + type TableSort, + type TableState, + createDataSourceChange, + createPaginationConfig, + createResetTable, + resizeColumn, +} from '@utils/antDesign/table' +import type { ListItem, ResultType, Scene, Severity } from '../types' + +const INIT_SORT: TableSort = { + field: 'foundTime', + order: 'descend', +} + +const RESULT_TAG: Record = { + nutriValue: { label: '营养值异常', color: '#f5222d', bg: '#fff1f0' }, + emptyRecom: { label: '推荐结果为空', color: '#faad14', bg: '#fff7e6' }, + nutriRange: { label: '营养值超范围', color: '#f5222d', bg: '#fff1f0' }, + timeout: { label: '算法超时', color: '#f5222d', bg: '#fff1f0' }, + modelOutput: { label: '模型输出异常', color: '#1890ff', bg: '#e6f7ff' }, +} + +const SCENE_LABEL: Record = { + nutriCalc: '营养计算', + dishRecom: '菜品推荐', + smartRecom: '智能推荐', +} + +const SEVERITY_TAG: Record = { + high: { label: '高', color: '#f5222d', bg: '#fff1f0' }, + middle: { label: '中', color: '#faad14', bg: '#fff7e6' }, + low: { label: '低', color: '#52c41a', bg: '#f6ffed' }, +} + +/** 标签 render */ +const renderTag = (meta: { label: string; color: string; bg: string }) => + h( + 'span', + { + style: { + color: meta.color, + background: meta.bg, + padding: '2px 8px', + borderRadius: '10px', + fontSize: '12px', + }, + }, + meta.label, + ) + +const tableColumns: TableColumnsType = [ + { + title: '异常ID', + dataIndex: 'errCode', + align: 'left', + fixed: 'left', + width: 150, + resizable: true, + ellipsis: true, + }, + { + title: '异常类型', + dataIndex: 'resultType', + align: 'center', + width: 130, + customRender: ({ text }: { text: ResultType }) => { + const meta = RESULT_TAG[text] + return meta ? renderTag(meta) : '—' + }, + }, + { + title: '触发场景', + dataIndex: 'scene', + align: 'center', + width: 110, + customRender: ({ text }: { text: Scene }) => SCENE_LABEL[text] ?? '—', + }, + { + title: '异常描述', + dataIndex: 'description', + align: 'left', + width: 360, + resizable: true, + ellipsis: true, + }, + { + title: '严重程度', + dataIndex: 'severity', + align: 'center', + width: 100, + customRender: ({ text }: { text: Severity }) => { + const meta = SEVERITY_TAG[text] + return meta ? renderTag(meta) : '—' + }, + }, + { title: '算法版本', dataIndex: 'algoVersion', align: 'center', width: 110 }, + { title: '发现时间', dataIndex: 'foundTime', align: 'center', width: 170, sorter: true }, + { + title: '操作', + dataIndex: 'action', + align: 'center', + fixed: 'right', + width: 90, + }, +] + +export const useTable = (listRequest: () => void) => { + const createInitState = (): TableState => ({ + columns: tableColumns, + dataSource: [], + sort: { ...INIT_SORT }, + pagination: createPaginationConfig(), + }) + + const table = reactive(createInitState()) as TableState + + const resetTable = createResetTable(table, createInitState) + const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest) + + return { table, dataSourceChange, resetTable, resizeColumn } +} diff --git a/src/pages/tenant-portal/nutrition/ano/anoResult/types/index.ts b/src/pages/tenant-portal/nutrition/ano/anoResult/types/index.ts new file mode 100644 index 0000000..cf389a4 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/ano/anoResult/types/index.ts @@ -0,0 +1,68 @@ +/** + * 异常事件 / 结果异常 - 类型集中定义 + */ + +/** 异常类型 */ +export type ResultType = + | 'nutriValue' // 营养值异常 + | 'emptyRecom' // 推荐结果为空 + | 'nutriRange' // 营养值超范围 + | 'timeout' // 算法超时 + | 'modelOutput' // 模型输出异常 + +/** 触发场景 */ +export type Scene = + | 'nutriCalc' // 营养计算 + | 'dishRecom' // 菜品推荐 + | 'smartRecom' // 智能推荐 + +/** 严重程度 */ +export type Severity = 'high' | 'middle' | 'low' + +/** 列表行:结果异常记录 */ +export interface ListItem { + id: string + /** 异常 ID(业务编号,形如 ERR-2026-001) */ + errCode: string + /** 异常类型 */ + resultType: ResultType + /** 触发场景 */ + scene: Scene + /** 异常描述 */ + description: string + /** 严重程度 */ + severity: Severity + /** 算法版本 */ + algoVersion: string + /** 发现时间 YYYY-MM-DD HH:mm:ss */ + foundTime: string +} + +/** 详情:含完整堆栈/输入输出 */ +export interface DetailInfo extends ListItem { + /** 算法输入参数(JSON 字符串) */ + inputParams: string + /** 算法输出结果(JSON 字符串) */ + outputResult: string + /** 异常堆栈(多行文本) */ + errorStack: string + /** 处理建议 */ + suggestion: string +} + +/** 搜索表单 */ +export interface SearchForm { + resultType?: ResultType + /** 时间范围 - 开始 */ + startDate?: string + /** 时间范围 - 结束 */ + endDate?: string + year?: number +} + +export interface ListParams extends SearchForm { + pageNum: number + pageSize: number + order?: 'ascend' | 'descend' | null + column?: string +} diff --git a/src/pages/tenant-portal/nutrition/arc/ArcUnit.vue b/src/pages/tenant-portal/nutrition/arc/ArcUnit.vue deleted file mode 100644 index b713bc1..0000000 --- a/src/pages/tenant-portal/nutrition/arc/ArcUnit.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/api/index.ts b/src/pages/tenant-portal/nutrition/arc/arcAlgo/api/index.ts new file mode 100644 index 0000000..0fe865c --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/api/index.ts @@ -0,0 +1,26 @@ +/** + * 算法模型 - 接口层 + * + * 业务定位:CRUD 列表 + * - list / info / add / edit / remove + * - 预览 / 下载在原型阶段用 message 模拟 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/nutrition/arc/algo/page', params) + +export const info = (id: string): Promise> => + postRequest('axiosRequest', '/nutrition/arc/algo/info', { id }) + +export const add = (params: Record): Promise => + postRequest('axiosRequest', '/nutrition/arc/algo/add', params) + +export const edit = (params: Record): Promise => + postRequest('axiosRequest', '/nutrition/arc/algo/update', params) + +export const remove = (id: string): Promise => + postRequest('axiosRequest', '/nutrition/arc/algo/delete', { id }) diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/arcAlgo.vue b/src/pages/tenant-portal/nutrition/arc/arcAlgo/arcAlgo.vue new file mode 100644 index 0000000..fd2103e --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/arcAlgo.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/addOrEdit.vue b/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/addOrEdit.vue new file mode 100644 index 0000000..8aa892a --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/addOrEdit.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/api/index.ts b/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/api/index.ts new file mode 100644 index 0000000..31808b6 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/api/index.ts @@ -0,0 +1,16 @@ +/** + * 算法模型 - 新增/编辑 弹框 - 接口层 + * - 复用主页面 api 即可(结构同构),此处为对齐目录规范单独透出 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' + +export const info = (id: string): Promise => + postRequest('axiosRequest', '/nutrition/arc/algo/info', { id }) + +export const add = (params: Record): Promise => + postRequest('axiosRequest', '/nutrition/arc/algo/add', params) + +export const edit = (params: Record): Promise => + postRequest('axiosRequest', '/nutrition/arc/algo/update', params) diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/init/usePage.ts b/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/init/usePage.ts new file mode 100644 index 0000000..a70d146 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/init/usePage.ts @@ -0,0 +1,168 @@ +/** + * 算法模型 - 新增/编辑 弹框 - 主逻辑 + * + * 严格遵循 modal-spec.md: + * - createInitForm / createInitOptions 工厂函数 + * - openModal 用 Promise.all 并发拉取详情 + 下拉 + * - addRequest 必须 delete params.id + * - submit 开头立即 pageInfo.spin = true + * - successRequest 接收 res.msg + * - 用 params.id 判断新增/编辑(禁用 type 文本判断) + */ + +import type { FormInstance, Rule } from 'ant-design-vue/es/form' +import { filterOption } from '@utils/antDesign/select' +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { add, edit, info } from '../api' +import type { Form, FormOptions, ModalType, PageInfo } from '../types' + +/** 初始表单工厂 */ +const createInitForm = (): Form => ({ + id: undefined, + name: '', + description: '', + organization: '', + author: '', + year: undefined, + attachment: '', +}) + +/** 初始下拉选项工厂 */ +const createInitOptions = (): FormOptions => ({ + year: [ + { label: '2026', value: 2026 }, + { label: '2025', value: 2025 }, + { label: '2024', value: 2024 }, + ], +}) + +export const usePage = (emit: (e: 'load') => void) => { + const { message } = useAntdStaticMethods() + + const pageInfo = reactive({ + visible: false, + type: 'add', + title: '', + width: 680, + spin: false, + }) + + const formRef = ref() + const form = ref
(createInitForm()) + const formOptions = ref(createInitOptions()) + + const rules: Record = { + name: [{ required: true, message: '请输入文档名称', trigger: 'blur' }], + organization: [{ required: true, message: '请输入制作机构', trigger: 'blur' }], + author: [{ required: true, message: '请输入制作人', trigger: 'blur' }], + year: [{ required: true, type: 'number', 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, record?: Form): void => { + pageInfo.visible = true + pageInfo.spin = true + pageInfo.type = type + resetForm() + + pageInfo.title = type === 'add' ? '新增算法模型' : '编辑算法模型' + + const requests: Promise[] = [] + if (type === 'edit' && record?.id) requests.push(infoRequest(record.id)) + + Promise.all(requests) + .then((results) => { + if (type === 'edit' && record?.id) { + const infoRes = results[0] as { code: string; data: Form } + if (infoRes?.code === '00000') { + form.value = { ...createInitForm(), ...infoRes.data } + } + } + }) + .catch((err: unknown) => { + console.error('打开弹框初始化失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const successRequest = (msg: string): void => { + void message.success(msg) + emit('load') + closeModal() + } + + const addRequest = (params: Record): void => { + delete params.id + add(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('新增失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const editRequest = (params: Record): void => { + edit(params) + .then((res) => { + if (res.code === '00000') successRequest(res.msg) + }) + .catch((err: unknown) => { + console.error('编辑失败:', err) + }) + .finally(() => { + pageInfo.spin = false + }) + } + + const submit = (): void => { + pageInfo.spin = true + formRef.value + ?.validate() + .then(() => { + const params = JSON.parse(JSON.stringify(form.value)) as Record + if (params.id) editRequest(params) + else addRequest(params) + }) + .catch((err: unknown) => { + pageInfo.spin = false + console.error('表单验证失败:', err) + }) + } + + /** 附件上传 beforeUpload - 原型阶段仅记录文件名 */ + const beforeUpload = (file: File): boolean => { + form.value.attachment = file.name + return false + } + + return { + pageInfo, + formRef, + form, + formOptions, + filterOption, + rules, + openModal, + closeModal, + submit, + beforeUpload, + } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/types/index.ts b/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/types/index.ts new file mode 100644 index 0000000..c31b3a3 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/component/modal/addOrEdit/types/index.ts @@ -0,0 +1,36 @@ +/** + * 算法模型 - 新增/编辑 弹框 - 类型定义 + * + * 严格遵循 modal-spec.md + */ + +import type { SelectProps } from 'ant-design-vue' + +export type ModalType = 'add' | 'edit' + +/** 弹框状态 */ +export interface PageInfo { + visible: boolean + type: ModalType + title: string + width: number + spin: boolean +} + +/** 表单数据 */ +export interface Form { + /** id 统一 string,新增时 undefined */ + id?: string + name: string + description?: string + organization: string + author: string + year: number | undefined + /** 附件名(原型阶段仅展示) */ + attachment?: string +} + +/** 下拉选项 */ +export interface FormOptions { + year: SelectProps['options'] +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/usePage.ts b/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/usePage.ts new file mode 100644 index 0000000..e57baff --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/usePage.ts @@ -0,0 +1,140 @@ +/** + * 算法模型 - 页面主逻辑 + * + * 业务定位:标准 CRUD 列表 + 附件预览/下载 + * - 顶部 toolbar:新增 / 导出 / 查看导出任务 + * - 操作列:预览 | 下载 | 编辑 | 删除(4 个按钮,正好达到 "可拆出更多" 的临界,业务侧选择全部保留) + */ + +import { useAntdStaticMethods } from '@utils/antDesign/popUp' +import { list, remove } from '../api' +import { useSearch } from './useSearch' +import { useTable } from './useTable' +import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue' +import type { ListItem } from '../types' + +export const usePage = () => { + const { Modal, message } = useAntdStaticMethods() + const { search, options, initOptions, resetSearch } = useSearch() + + const pageLoading = ref(false) + + /** 子组件 ref - 必须在 usePage.ts 中定义 */ + const addOrEditRef = ref | null>(null) + + /** 构造请求参数 */ + const buildParams = () => ({ + ...search, + pageNum: table.pagination.current ?? 1, + pageSize: table.pagination.pageSize ?? 30, + order: table.sort.order, + column: table.sort.field, + }) + + /** 列表请求 */ + const listRequest = (): void => { + pageLoading.value = true + list(buildParams()) + .then((res) => { + if (res.code === '00000') { + table.dataSource = (res.data ?? []) as ListItem[] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('算法模型列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + listRequest() + } + + /** 打开新增/编辑弹框(命名 open + 组件名) */ + const openAddOrEdit = (type: 'add' | 'edit', record?: ListItem): void => { + addOrEditRef.value?.openModal(type, record) + } + + /** 删除二次确认(强制 Modal.confirm + okType: 'danger') */ + const removeRecord = (record: ListItem): 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) + }) + }, + }) + } + + /** 预览附件 - 原型阶段用 message 模拟 */ + const previewAttachment = (record: ListItem): void => { + void message.info(`预览附件:${record.attachment ?? '无附件'}(原型阶段提示)`) + } + + /** 下载附件 - 原型阶段用 message 模拟 */ + const downloadAttachment = (record: ListItem): void => { + void message.info(`已发起下载:${record.attachment ?? '无附件'}(原型阶段提示)`) + } + + /** 导出列表 - 原型阶段 */ + const onExport = (): void => { + void message.info('导出任务已提交,可在"查看导出任务"中查看进度') + } + + /** 查看导出任务 - 原型阶段 */ + const onViewExportTask = (): void => { + void message.info('当前共有 3 个导出任务正在处理中(原型阶段提示)') + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + addOrEditRef, + pageLoading, + search, + options, + table, + dataSourceChange, + resizeColumn, + searchQuery, + resetQuery, + openAddOrEdit, + removeRecord, + previewAttachment, + downloadAttachment, + onExport, + onViewExportTask, + listRequest, + } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/useSearch.ts b/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/useSearch.ts new file mode 100644 index 0000000..eab4513 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/useSearch.ts @@ -0,0 +1,43 @@ +/** + * 算法模型 - 搜索逻辑 + */ + +import type { SearchForm } from '../types' + +const DEFAULT_YEAR = 2026 + +const createSearchKey = (): SearchForm => ({ + name: undefined, + year: DEFAULT_YEAR, +}) + +const createOptions = () => ({ + year: [] as { label: string; value: number }[], +}) + +const YEAR_LIST = [ + { label: '2026年', value: 2026 }, + { label: '2025年', value: 2025 }, + { label: '2024年', value: 2024 }, +] + +export const useSearch = () => { + const search = reactive(createSearchKey()) + const options = reactive(createOptions()) + + const initOptions = (): void => { + Promise.all([Promise.resolve(YEAR_LIST)]) + .then(([yearList]) => { + options.year = yearList + }) + .catch((err: unknown) => { + console.error('初始化下拉选项失败:', err) + }) + } + + const resetSearch = (): void => { + Object.assign(search, createSearchKey()) + } + + return { search, options, initOptions, resetSearch } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/useTable.ts b/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/useTable.ts new file mode 100644 index 0000000..45069aa --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/init/useTable.ts @@ -0,0 +1,75 @@ +/** + * 算法模型 - 表格逻辑 + * + * 列定义 8 列:文档名称 / 文档描述 / 制作机构 / 制作人 / 制作年份 / + * 上传日期 / 附件 / 操作 + */ + +import type { TableColumnsType } from 'ant-design-vue' +import { + type TableSort, + type TableState, + createDataSourceChange, + createPaginationConfig, + createResetTable, + resizeColumn, +} from '@utils/antDesign/table' +import type { ListItem } from '../types' + +const INIT_SORT: TableSort = { + field: 'uploadDate', + order: 'descend', +} + +const tableColumns: TableColumnsType = [ + { + title: '文档名称', + dataIndex: 'name', + align: 'left', + fixed: 'left', + width: 240, + resizable: true, + ellipsis: true, + }, + { + title: '文档描述', + dataIndex: 'description', + align: 'left', + width: 360, + resizable: true, + ellipsis: true, + }, + { title: '制作机构', dataIndex: 'organization', align: 'center', width: 140, resizable: true }, + { title: '制作人', dataIndex: 'author', align: 'center', width: 100 }, + { title: '制作年份', dataIndex: 'year', align: 'center', width: 100, sorter: true }, + { title: '上传日期', dataIndex: 'uploadDate', align: 'center', width: 130, sorter: true }, + { + title: '附件', + dataIndex: 'attachment', + align: 'center', + width: 180, + }, + { + title: '操作', + dataIndex: 'action', + align: 'center', + fixed: 'right', + width: 130, + }, +] + +export const useTable = (listRequest: () => void) => { + const createInitState = (): TableState => ({ + columns: tableColumns, + dataSource: [], + sort: { ...INIT_SORT }, + pagination: createPaginationConfig(), + }) + + const table = reactive(createInitState()) as TableState + + const resetTable = createResetTable(table, createInitState) + const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest) + + return { table, dataSourceChange, resetTable, resizeColumn } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcAlgo/types/index.ts b/src/pages/tenant-portal/nutrition/arc/arcAlgo/types/index.ts new file mode 100644 index 0000000..a581204 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcAlgo/types/index.ts @@ -0,0 +1,39 @@ +/** + * 算法模型 - 类型集中定义 + */ + +/** 列表行(算法/模型文档) */ +export interface ListItem { + /** 主键 */ + id: string + /** 文档名称 */ + name: string + /** 文档描述 */ + description: string + /** 制作机构 */ + organization: string + /** 制作人 */ + author: string + /** 制作年份 */ + year: number + /** 上传日期 YYYY-MM-DD */ + uploadDate: string + /** 附件名称(原型阶段仅展示) */ + attachment: string +} + +/** 搜索表单 */ +export interface SearchForm { + /** 文档名称模糊搜索 */ + name?: string + /** 制作年份 */ + year?: number +} + +/** 分页参数 */ +export interface ListParams extends SearchForm { + pageNum: number + pageSize: number + order?: 'ascend' | 'descend' | null + column?: string +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/api/index.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/api/index.ts new file mode 100644 index 0000000..2a89952 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/api/index.ts @@ -0,0 +1,11 @@ +/** + * 场所报表 - 主页面 stats 接口(跨 Tab 共用) + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { CanteenStats } from '../types' + +/** 4 卡 stats 拉取 */ +export const stats = (): Promise> => + postRequest('axiosRequest', '/nutrition/arc/canteen/stats', {}) diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/arcCanteen.vue b/src/pages/tenant-portal/nutrition/arc/arcCanteen/arcCanteen.vue new file mode 100644 index 0000000..f811ee0 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/arcCanteen.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/api/index.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/api/index.ts new file mode 100644 index 0000000..bfea337 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/api/index.ts @@ -0,0 +1,10 @@ +/** + * 场所报表 / 运营总览 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/nutrition/arc/canteen/operation/page', params) diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/canteenOperation.vue b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/canteenOperation.vue new file mode 100644 index 0000000..00b8a06 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/canteenOperation.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/usePage.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/usePage.ts new file mode 100644 index 0000000..4b33c4a --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/usePage.ts @@ -0,0 +1,73 @@ +/** + * 场所报表 / 运营总览 - 页面主逻辑 + */ + +import { list } from '../api' +import { useSearch } from './useSearch' +import { useTable } from './useTable' +import type { ListItem } from '../types' + +export const usePage = () => { + const { search, options, initOptions, resetSearch } = useSearch() + + const pageLoading = ref(false) + + 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 ?? []) as ListItem[] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('场所运营列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + listRequest() + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + options, + table, + dataSourceChange, + resizeColumn, + searchQuery, + resetQuery, + listRequest, + } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/useSearch.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/useSearch.ts new file mode 100644 index 0000000..e0b6555 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/useSearch.ts @@ -0,0 +1,69 @@ +/** + * 场所报表 / 运营总览 - 搜索逻辑 + */ + +import type { SearchForm } from '../types' + +const DEFAULT_YEAR = 2026 + +const createSearchKey = (): SearchForm => ({ + canteen: undefined, + unit: undefined, + year: DEFAULT_YEAR, +}) + +const createOptions = () => ({ + canteen: [] as { label: string; value: string }[], + unit: [] as { label: string; value: string }[], + year: [] as { label: string; value: number }[], +}) + +const CANTEEN_LIST = [ + { label: '总部一食堂', value: '总部一食堂' }, + { label: '总部二食堂', value: '总部二食堂' }, + { label: '采油一厂食堂', value: '采油一厂食堂' }, + { label: '采油二厂食堂', value: '采油二厂食堂' }, + { label: '采气一厂食堂', value: '采气一厂食堂' }, + { label: '勘探院食堂', value: '勘探院食堂' }, +] + +const UNIT_LIST = [ + { label: 'CQ能源总部', value: 'CQ能源总部' }, + { label: '采油一厂', value: '采油一厂' }, + { label: '采油二厂', value: '采油二厂' }, + { label: '采气一厂', value: '采气一厂' }, + { label: '勘探开发研究院', value: '勘探开发研究院' }, +] + +const YEAR_LIST = [ + { label: '2026年', value: 2026 }, + { label: '2025年', value: 2025 }, + { label: '2024年', value: 2024 }, +] + +export const useSearch = () => { + const search = reactive(createSearchKey()) + const options = reactive(createOptions()) + + const initOptions = (): void => { + Promise.all([ + Promise.resolve(CANTEEN_LIST), + Promise.resolve(UNIT_LIST), + Promise.resolve(YEAR_LIST), + ]) + .then(([canteenList, unitList, yearList]) => { + options.canteen = canteenList + options.unit = unitList + options.year = yearList + }) + .catch((err: unknown) => { + console.error('初始化下拉选项失败:', err) + }) + } + + const resetSearch = (): void => { + Object.assign(search, createSearchKey()) + } + + return { search, options, initOptions, resetSearch } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/useTable.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/useTable.ts new file mode 100644 index 0000000..df8c4a6 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/init/useTable.ts @@ -0,0 +1,91 @@ +/** + * 场所报表 / 运营总览 - 表格逻辑 + * + * 10 列,与旧 arc-canteen.html tab0 完全对齐 + * 餐线利用率、终端覆盖率用色带强化 + */ + +import { h } from 'vue' +import type { TableColumnsType } from 'ant-design-vue' +import { + type TableSort, + type TableState, + createDataSourceChange, + createPaginationConfig, + createResetTable, + resizeColumn, +} from '@utils/antDesign/table' +import type { ListItem } from '../types' + +const INIT_SORT: TableSort = { + field: 'dailyAvg', + order: 'descend', +} + +const rateBadge = (text: number) => { + const color = text >= 85 ? '#52c41a' : text >= 70 ? '#faad14' : '#f5222d' + const bg = text >= 85 ? '#f6ffed' : text >= 70 ? '#fff7e6' : '#fff1f0' + return h( + 'span', + { + style: { + color, + background: bg, + padding: '2px 8px', + borderRadius: '10px', + fontSize: '12px', + }, + }, + `${text}%`, + ) +} + +const tableColumns: TableColumnsType = [ + { + title: '场所名称', + dataIndex: 'canteen', + align: 'center', + fixed: 'left', + width: 170, + resizable: true, + ellipsis: true, + }, + { title: '所属单位', dataIndex: 'unit', align: 'center', width: 140, resizable: true, ellipsis: true }, + { title: '餐线数量', dataIndex: 'lines', align: 'center', width: 90, sorter: true }, + { title: '开餐天数', dataIndex: 'openDays', align: 'center', width: 90 }, + { title: '开餐次数', dataIndex: 'openMeals', align: 'center', width: 90 }, + { title: '日均服务人次', dataIndex: 'dailyAvg', align: 'center', width: 130, sorter: true }, + { title: '年度用餐总次数', dataIndex: 'yearMeals', align: 'center', width: 130, sorter: true }, + { title: '人均用餐频次', dataIndex: 'perCapitaFreq', align: 'center', width: 120 }, + { + title: '餐线利用率', + dataIndex: 'lineRate', + align: 'center', + width: 110, + sorter: true, + customRender: ({ text }: { text: number }) => rateBadge(text), + }, + { + title: '终端覆盖率', + dataIndex: 'termRate', + align: 'center', + width: 110, + customRender: ({ text }: { text: number }) => `${text}%`, + }, +] + +export const useTable = (listRequest: () => void) => { + const createInitState = (): TableState => ({ + columns: tableColumns, + dataSource: [], + sort: { ...INIT_SORT }, + pagination: createPaginationConfig(), + }) + + const table = reactive(createInitState()) as TableState + + const resetTable = createResetTable(table, createInitState) + const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest) + + return { table, dataSourceChange, resetTable, resizeColumn } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/types/index.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/types/index.ts new file mode 100644 index 0000000..6e7ba0e --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenOperation/types/index.ts @@ -0,0 +1,40 @@ +/** + * 场所报表 / 运营总览 - 类型定义 + */ + +export interface ListItem { + id: string + /** 场所名称 */ + canteen: string + /** 所属单位 */ + unit: string + /** 餐线数量 */ + lines: number + /** 开餐天数 */ + openDays: number + /** 开餐次数 */ + openMeals: number + /** 日均服务人次 */ + dailyAvg: number + /** 年度用餐总次数 */ + yearMeals: number + /** 人均用餐频次 */ + perCapitaFreq: number + /** 餐线利用率 0-100 */ + lineRate: number + /** 终端覆盖率 0-100 */ + termRate: number +} + +export interface SearchForm { + canteen?: string + unit?: string + year?: number +} + +export interface ListParams extends SearchForm { + pageNum: number + pageSize: number + order?: 'ascend' | 'descend' | null + column?: string +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/api/index.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/api/index.ts new file mode 100644 index 0000000..4be195b --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/api/index.ts @@ -0,0 +1,10 @@ +/** + * 场所报表 / 成本效益 - 接口层 + */ + +import type { ApiResponse } from '@axios' +import { postRequest } from '@axios' +import type { ListItem, ListParams } from '../types' + +export const list = (params: ListParams): Promise> => + postRequest('axiosRequest', '/nutrition/arc/canteen/profit/page', params) diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/canteenProfit.vue b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/canteenProfit.vue new file mode 100644 index 0000000..9dbda1f --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/canteenProfit.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/usePage.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/usePage.ts new file mode 100644 index 0000000..aace1f0 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/usePage.ts @@ -0,0 +1,73 @@ +/** + * 场所报表 / 成本效益 - 页面主逻辑 + */ + +import { list } from '../api' +import { useSearch } from './useSearch' +import { useTable } from './useTable' +import type { ListItem } from '../types' + +export const usePage = () => { + const { search, options, initOptions, resetSearch } = useSearch() + + const pageLoading = ref(false) + + 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 ?? []) as ListItem[] + table.pagination.total = res.total ?? 0 + } + }) + .catch((err: unknown) => { + console.error('场所成本列表请求失败:', err) + }) + .finally(() => { + pageLoading.value = false + }) + } + + const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest) + + const searchQuery = (): void => { + table.pagination.current = 1 + listRequest() + } + + const resetQuery = (): void => { + resetSearch() + resetTable() + listRequest() + } + + const loadData = (): void => { + initOptions() + listRequest() + } + + onMounted(() => { + loadData() + }) + + return { + pageLoading, + search, + options, + table, + dataSourceChange, + resizeColumn, + searchQuery, + resetQuery, + listRequest, + } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/useSearch.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/useSearch.ts new file mode 100644 index 0000000..f6c877c --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/useSearch.ts @@ -0,0 +1,69 @@ +/** + * 场所报表 / 成本效益 - 搜索逻辑(与运营 Tab 同结构) + */ + +import type { SearchForm } from '../types' + +const DEFAULT_YEAR = 2026 + +const createSearchKey = (): SearchForm => ({ + canteen: undefined, + unit: undefined, + year: DEFAULT_YEAR, +}) + +const createOptions = () => ({ + canteen: [] as { label: string; value: string }[], + unit: [] as { label: string; value: string }[], + year: [] as { label: string; value: number }[], +}) + +const CANTEEN_LIST = [ + { label: '总部一食堂', value: '总部一食堂' }, + { label: '总部二食堂', value: '总部二食堂' }, + { label: '采油一厂食堂', value: '采油一厂食堂' }, + { label: '采油二厂食堂', value: '采油二厂食堂' }, + { label: '采气一厂食堂', value: '采气一厂食堂' }, + { label: '勘探院食堂', value: '勘探院食堂' }, +] + +const UNIT_LIST = [ + { label: 'CQ能源总部', value: 'CQ能源总部' }, + { label: '采油一厂', value: '采油一厂' }, + { label: '采油二厂', value: '采油二厂' }, + { label: '采气一厂', value: '采气一厂' }, + { label: '勘探开发研究院', value: '勘探开发研究院' }, +] + +const YEAR_LIST = [ + { label: '2026年', value: 2026 }, + { label: '2025年', value: 2025 }, + { label: '2024年', value: 2024 }, +] + +export const useSearch = () => { + const search = reactive(createSearchKey()) + const options = reactive(createOptions()) + + const initOptions = (): void => { + Promise.all([ + Promise.resolve(CANTEEN_LIST), + Promise.resolve(UNIT_LIST), + Promise.resolve(YEAR_LIST), + ]) + .then(([canteenList, unitList, yearList]) => { + options.canteen = canteenList + options.unit = unitList + options.year = yearList + }) + .catch((err: unknown) => { + console.error('初始化下拉选项失败:', err) + }) + } + + const resetSearch = (): void => { + Object.assign(search, createSearchKey()) + } + + return { search, options, initOptions, resetSearch } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/useTable.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/useTable.ts new file mode 100644 index 0000000..c8c6f59 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/init/useTable.ts @@ -0,0 +1,138 @@ +/** + * 场所报表 / 成本效益 - 表格逻辑 + * + * 10 列含金额,金额按 toLocaleString 千分位展示 + */ + +import { h } from 'vue' +import type { TableColumnsType } from 'ant-design-vue' +import { + type TableSort, + type TableState, + createDataSourceChange, + createPaginationConfig, + createResetTable, + resizeColumn, +} from '@utils/antDesign/table' +import type { ListItem } from '../types' + +const INIT_SORT: TableSort = { + field: 'revenue', + order: 'descend', +} + +const money = (text: number): string => (text == null ? '—' : text.toLocaleString()) + +const rateBadge = (text: number) => { + const color = text >= 85 ? '#52c41a' : text >= 70 ? '#faad14' : '#f5222d' + const bg = text >= 85 ? '#f6ffed' : text >= 70 ? '#fff7e6' : '#fff1f0' + return h( + 'span', + { + style: { + color, + background: bg, + padding: '2px 8px', + borderRadius: '10px', + fontSize: '12px', + }, + }, + `${text}%`, + ) +} + +const tableColumns: TableColumnsType = [ + { + title: '场所名称', + dataIndex: 'canteen', + align: 'center', + fixed: 'left', + width: 170, + resizable: true, + ellipsis: true, + }, + { title: '所属单位', dataIndex: 'unit', align: 'center', width: 140, resizable: true, ellipsis: true }, + { + title: '年度营收(元)', + dataIndex: 'revenue', + align: 'right', + width: 130, + sorter: true, + customRender: ({ text }: { text: number }) => money(text), + }, + { + title: '成本总计(元)', + dataIndex: 'cost', + align: 'right', + width: 130, + customRender: ({ text }: { text: number }) => money(text), + }, + { + title: '营收利润(元)', + dataIndex: 'profit', + align: 'right', + width: 130, + sorter: true, + customRender: ({ text }: { text: number }) => money(text), + }, + { title: '人均消费(元)', dataIndex: 'perCapitaCost', align: 'center', width: 120 }, + { + title: '食材成本率', + dataIndex: 'materialRate', + align: 'center', + width: 110, + customRender: ({ text }: { text: number }) => `${text}%`, + }, + { + title: '毛利率', + dataIndex: 'grossRate', + align: 'center', + width: 100, + customRender: ({ text }: { text: number }) => `${text}%`, + }, + { + title: '余量处置率', + dataIndex: 'surplusRate', + align: 'center', + width: 110, + customRender: ({ text }: { text: number }) => rateBadge(text), + }, + { + title: '综合评分', + dataIndex: 'score', + align: 'center', + width: 110, + sorter: true, + customRender: ({ text }: { text: number }) => + h( + 'span', + { + style: { + color: '#1890ff', + background: '#e6f7ff', + padding: '2px 8px', + borderRadius: '10px', + fontSize: '12px', + fontWeight: 600, + }, + }, + String(text), + ), + }, +] + +export const useTable = (listRequest: () => void) => { + const createInitState = (): TableState => ({ + columns: tableColumns, + dataSource: [], + sort: { ...INIT_SORT }, + pagination: createPaginationConfig(), + }) + + const table = reactive(createInitState()) as TableState + + const resetTable = createResetTable(table, createInitState) + const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest) + + return { table, dataSourceChange, resetTable, resizeColumn } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/types/index.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/types/index.ts new file mode 100644 index 0000000..12bbb25 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/component/tabs/canteenProfit/types/index.ts @@ -0,0 +1,38 @@ +/** + * 场所报表 / 成本效益 - 类型定义 + */ + +export interface ListItem { + id: string + canteen: string + unit: string + /** 年度营收(元) */ + revenue: number + /** 成本总计(元) */ + cost: number + /** 营收利润(元) */ + profit: number + /** 人均消费(元) */ + perCapitaCost: number + /** 食材成本率 0-100 */ + materialRate: number + /** 毛利率 0-100 */ + grossRate: number + /** 余量处置率 0-100 */ + surplusRate: number + /** 综合评分 0-100 */ + score: number +} + +export interface SearchForm { + canteen?: string + unit?: string + year?: number +} + +export interface ListParams extends SearchForm { + pageNum: number + pageSize: number + order?: 'ascend' | 'descend' | null + column?: string +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/init/useStats.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/init/useStats.ts new file mode 100644 index 0000000..8f18fb4 --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/init/useStats.ts @@ -0,0 +1,44 @@ +/** + * 场所报表 - 主页面 stats hook + * + * 设计目的:把 stats 数据装载抽离到 init/ 层,让 .vue 保持薄 + * - 4 个 StatCard 跨 Tabs 共用(运营/成本两个 Tab 都基于场所) + */ + +import { stats } from '../api' +import type { CanteenStats } from '../types' + +interface StatCardItem { + icon: string + color: 'blue' | 'green' | 'orange' | 'red' + value: string | number + label: string +} + +export const useStats = () => { + const data = ref(null) + + const statsRequest = (): void => { + stats() + .then((res) => { + if (res.code === '00000') data.value = res.data + }) + .catch((err: unknown) => { + console.error('场所 stats 请求失败:', err) + }) + } + + /** 4 个 StatCard 数据(基于 data 动态计算) */ + const statCards = computed(() => [ + { icon: 'ShopOutlined', color: 'blue', value: data.value?.total ?? '—', label: '场所总数' }, + { icon: 'TeamOutlined', color: 'green', value: data.value?.dailyAvg ?? '—', label: '日均服务总人次' }, + { icon: 'BarChartOutlined', color: 'orange', value: data.value?.rate ?? '—', label: '综合营养达标率' }, + { icon: 'SafetyCertificateOutlined', color: 'red', value: data.value?.safety ?? '—', label: '食安检测合格率' }, + ]) + + onMounted(() => { + statsRequest() + }) + + return { statCards, statsRequest } +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcCanteen/types/index.ts b/src/pages/tenant-portal/nutrition/arc/arcCanteen/types/index.ts new file mode 100644 index 0000000..637ecec --- /dev/null +++ b/src/pages/tenant-portal/nutrition/arc/arcCanteen/types/index.ts @@ -0,0 +1,15 @@ +/** + * 场所报表 - 主页面级 stats 类型 + */ + +/** 4 个统计卡数据 */ +export interface CanteenStats { + /** 场所总数 */ + total: number + /** 日均服务总人次 */ + dailyAvg: number + /** 综合营养达标率(带 % 字符串) */ + rate: string + /** 食安检测合格率(带 % 字符串) */ + safety: string +} diff --git a/src/pages/tenant-portal/nutrition/arc/arcEmployee/arcEmployee.vue b/src/pages/tenant-portal/nutrition/arc/arcEmployee/arcEmployee.vue index fbf69a8..3cf127f 100644 --- a/src/pages/tenant-portal/nutrition/arc/arcEmployee/arcEmployee.vue +++ b/src/pages/tenant-portal/nutrition/arc/arcEmployee/arcEmployee.vue @@ -1,105 +1,106 @@