feat(nutrition): 平移档案与异常事件 6 个页面并完善列表交互规范

档案应用版块:
- 新增 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 保护,避免按钮换行
- 算法模型附件列改为"图标+文字"按钮(预览/下载)
This commit is contained in:
fengpu
2026-06-05 15:54:24 +08:00
parent 785e25d231
commit c64072f125
79 changed files with 5211 additions and 369 deletions
+12
View File
@@ -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'
+132
View File
@@ -0,0 +1,132 @@
/**
* mock handler - 营养管理 / 算法模型
*
* 对应 apisrc/pages/tenant-portal/nutrition/arc/arcAlgo/api/index.ts
*
* 提供完整 CRUDpage / info / add / update / delete
* dataset5 条(≤ 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<string, unknown>): 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<string, unknown>)[column]
const bv = (b as unknown as Record<string, unknown>)[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<AlgoRow[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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<AlgoRow | null> => {
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<AlgoRow>
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<null> => {
const params = (ctx.params ?? {}) as Partial<AlgoRow> & { 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<null> => {
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 }
},
},
])
+77
View File
@@ -0,0 +1,77 @@
/**
* mock handler - 营养管理 / 系统脏数据
*
* 对应 apisrc/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<string, unknown>): 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<string, unknown>)[column]
const bv = (b as unknown as Record<string, unknown>)[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<DirtyRow[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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 }
},
},
])
+135
View File
@@ -0,0 +1,135 @@
/**
* mock handler - 营养管理 / 结果异常
*
* 对应 apisrc/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<string, unknown>): 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<string, unknown>)[column]
const bv = (b as unknown as Record<string, unknown>)[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<ResultRow[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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<ResultDetail | null> => {
const id = (ctx.params as { id?: string } | undefined)?.id
const row = detailDataset.find((r) => r.id === id) ?? null
return { code: '00000', msg: '查询成功', data: row }
},
},
])
+151
View File
@@ -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 = <T extends Record<string, unknown>>(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 = <T extends { canteen: string; unit: string }>(
rows: T[],
params: Record<string, unknown>,
): 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<OperationRow[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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<string, unknown>[], 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<ProfitRow[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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<string, unknown>[], 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 }
},
},
])
+22 -74
View File
@@ -3,15 +3,17 @@
*
* 对应 apisrc/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<string, unknown>): 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<EmployeeRow>
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<null> => {
const params = (ctx.params ?? {}) as Partial<EmployeeRow> & { 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<null> => {
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',
@@ -0,0 +1,129 @@
/**
* mock handler - 营养管理 / 员工用餐记录详情
*
* 对应 apisrc/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<string, EmployeeInfoRow> = {
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<string, unknown>): 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<string, unknown>)[column]
const bv = (b as unknown as Record<string, unknown>)[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<EmployeeInfoRow | null> => {
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<MealRow[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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 }
},
},
])
+117
View File
@@ -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 = <T extends Record<string, unknown>>(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<Level2Row[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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<string, unknown>[], 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<Level3Row[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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<string, unknown>[], 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 }
},
},
])
+10 -6
View File
@@ -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') },
],
},
],
@@ -0,0 +1,97 @@
<template>
<div class="ano-dirty">
<!-- 搜索 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="业务模块">
<a-select
v-model:value="search.module"
:options="options.module"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择业务模块"
style="width: 160px"
/>
</a-form-item>
<a-form-item label="异常类型">
<a-select
v-model:value="search.dirtyType"
:options="options.dirtyType"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择异常类型"
style="width: 160px"
/>
</a-form-item>
<a-form-item label="年份">
<a-select
v-model:value="search.year"
:options="options.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择年份"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<!-- 表格(只读,无操作列) -->
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #toolbar>
<a-button type="primary" @click="onExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="onViewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
</TableCard>
</div>
</template>
<script setup lang="ts">
/**
* 异常事件 / 系统脏数据 - 主列表页
*
* 业务定位:只读查询页
* - 9 列固定,无操作列(仅查看用途)
* - 顶部导出/查看导出任务(message 模拟)
* - 异常类型用色彩标签强化辨识
* - 异常值列红色高亮
*/
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import { usePage } from './init/usePage'
const {
pageLoading,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
onExport,
onViewExportTask,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.ano-dirty {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -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<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/ano/dirty/page', params)
@@ -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<boolean>(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,
}
}
@@ -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<SearchForm>(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 }
}
@@ -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<ModuleType, string> = {
recipe: '食谱管理',
order: '订餐管理',
user: '用户档案',
analysis: '营养分析',
material: '食材库存',
production: '健康生产',
supply: '卫生供应',
}
/** 异常类型 → 文案 + 配色 */
const DIRTY_TAG: Record<DirtyType, { label: string; color: string; bg: string }> = {
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<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -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
}
@@ -0,0 +1,115 @@
<template>
<div class="ano-result">
<!-- 搜索 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="年份">
<a-select
v-model:value="search.year"
:options="options.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择年份"
style="width: 140px"
/>
</a-form-item>
<a-form-item label="异常类型">
<a-select
v-model:value="search.resultType"
:options="options.resultType"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择异常类型"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="时间范围">
<a-range-picker
v-model:value="dateRange"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
style="width: 280px"
@change="onDateRangeChange"
/>
</a-form-item>
</FilterBar>
<!-- 表格 + 操作列详情 -->
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #toolbar>
<a-button type="primary" @click="onExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="onViewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<!-- 操作列 link 不加图标 -->
<a-button type="link" size="small" @click="openInfoDrawer(record)">详情</a-button>
</template>
</template>
</TableCard>
<!-- 结果异常详情抽屉 -->
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
/**
* 异常事件 / 结果异常 - 主列表页
*
* 业务定位:只读查询 + 详情抽屉
* - 8 列展示
* - 异常类型/严重程度用色彩标签强化
* - 操作列:详情 → 详情抽屉(展示算法输入/输出/堆栈/建议)
*/
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import InfoDrawer from './component/drawer/info/info.vue'
import { usePage } from './init/usePage'
const {
infoDrawerRef,
pageLoading,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
openInfoDrawer,
onExport,
onViewExportTask,
} = usePage()
/** 日期范围 picker 中间变量(与 search.startDate / endDate 双向) */
const dateRange = ref<[string, string] | undefined>(undefined)
const onDateRangeChange = (val: [string, string] | null): void => {
search.startDate = val?.[0]
search.endDate = val?.[1]
}
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.ano-result {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -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<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/ano/result/page', params)
/** 详情(含堆栈与算法输入输出) */
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/nutrition/ano/result/info', { id })
@@ -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<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/nutrition/employee/info', { id })
postRequest('axiosRequest', '/nutrition/ano/result/info', { id })
@@ -0,0 +1,124 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
destroy-on-close
@close="closeDrawer"
>
<a-spin :spinning="pageInfo.spin">
<template v-if="!detail">
<a-empty description="暂无数据" />
</template>
<template v-else>
<!-- 基础信息 -->
<a-descriptions :column="2" bordered size="middle" title="基础信息">
<a-descriptions-item label="异常ID">{{ detail.errCode ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="算法版本">{{ detail.algoVersion ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="异常类型">{{ resultTypeLabel(detail.resultType) }}</a-descriptions-item>
<a-descriptions-item label="触发场景">{{ sceneLabel(detail.scene) }}</a-descriptions-item>
<a-descriptions-item label="严重程度">
<span :style="severityStyle(detail.severity)">{{ severityLabel(detail.severity) }}</span>
</a-descriptions-item>
<a-descriptions-item label="发现时间">{{ detail.foundTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="异常描述" :span="2">{{ detail.description ?? '—' }}</a-descriptions-item>
</a-descriptions>
<!-- 算法输入 -->
<a-divider orientation="left">算法输入参数</a-divider>
<pre class="ano-result-info__code">{{ detail.inputParams ?? '—' }}</pre>
<!-- 算法输出 -->
<a-divider orientation="left">算法输出结果</a-divider>
<pre class="ano-result-info__code">{{ detail.outputResult ?? '—' }}</pre>
<!-- 错误堆栈 -->
<a-divider orientation="left">错误堆栈</a-divider>
<pre class="ano-result-info__code ano-result-info__code--error">{{ detail.errorStack ?? '—' }}</pre>
<!-- 处理建议 -->
<a-divider orientation="left">处理建议</a-divider>
<a-alert :message="detail.suggestion ?? '—'" type="info" show-icon />
</template>
</a-spin>
<template #footer>
<a-button @click="closeDrawer">关闭</a-button>
</template>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 结果异常 - 详情抽屉
*
* drawer-spec 详情型:
* - destroy-on-close 已加
* - a-spin 包裹内容
* - defineExpose 只暴露 openDrawer
* - 空值 ?? '—' 兜底
* - 堆栈/JSON 用 <pre> 等宽展示
*/
import type { ResultType, Scene, Severity } from '../../../types'
import { usePage } from './init/usePage'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
defineExpose({ openDrawer })
/** 文案映射(与 useTable.ts 中常量保持一致) */
const resultTypeLabel = (t?: ResultType): string => {
const m: Record<ResultType, string> = {
nutriValue: '营养值异常',
emptyRecom: '推荐结果为空',
nutriRange: '营养值超范围',
timeout: '算法超时',
modelOutput: '模型输出异常',
}
return t ? m[t] ?? '—' : '—'
}
const sceneLabel = (s?: Scene): string => {
const m: Record<Scene, string> = {
nutriCalc: '营养计算',
dishRecom: '菜品推荐',
smartRecom: '智能推荐',
}
return s ? m[s] ?? '—' : '—'
}
const severityLabel = (s?: Severity): string => {
const m: Record<Severity, string> = { high: '高', middle: '中', low: '低' }
return s ? m[s] ?? '—' : '—'
}
const severityStyle = (s?: Severity): Record<string, string> => {
const colorMap: Record<Severity, string> = { high: '#f5222d', middle: '#faad14', low: '#52c41a' }
return s ? { color: colorMap[s], fontWeight: '600' } : { color: '#666' }
}
</script>
<style scoped lang="less">
.ano-result-info__code {
background: #f5f7fa;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 12px 16px;
font-family: 'Source Code Pro', Consolas, Monaco, 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
max-height: 240px;
overflow-y: auto;
&--error {
color: #f5222d;
background: #fff1f0;
border-color: #ffccc7;
}
}
</style>
@@ -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<PageInfo>({
visible: false,
title: '员工营养详情',
title: '结果异常详情',
spin: false,
width: 720,
width: 800,
})
const detail = ref<DetailInfo | null>(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)
}
@@ -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 }
@@ -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<boolean>(false)
/** 详情抽屉 ref - 必须在 usePage.ts 中定义 */
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
const buildParams = () => ({
...search,
pageNum: table.pagination.current ?? 1,
pageSize: table.pagination.pageSize ?? 30,
order: table.sort.order,
column: table.sort.field,
})
const listRequest = (): void => {
pageLoading.value = true
list(buildParams())
.then((res) => {
if (res.code === '00000') {
table.dataSource = (res.data ?? []) 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,
}
}
@@ -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<SearchForm>(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 }
}
@@ -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<ResultType, { label: string; color: string; bg: string }> = {
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<Scene, string> = {
nutriCalc: '营养计算',
dishRecom: '菜品推荐',
smartRecom: '智能推荐',
}
const SEVERITY_TAG: Record<Severity, { label: string; color: string; bg: string }> = {
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<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -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
}
@@ -1,7 +0,0 @@
<template>
<PlaceholderPage title="单位报表数据" name="web-admin/nutrition/ArcUnit" />
</template>
<script setup lang="ts">
// 占位
</script>
@@ -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<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/arc/algo/page', params)
export const info = (id: string): Promise<ApiResponse<ListItem>> =>
postRequest('axiosRequest', '/nutrition/arc/algo/info', { id })
export const add = (params: Record<string, unknown>): Promise<ApiResponse> =>
postRequest('axiosRequest', '/nutrition/arc/algo/add', params)
export const edit = (params: Record<string, unknown>): Promise<ApiResponse> =>
postRequest('axiosRequest', '/nutrition/arc/algo/update', params)
export const remove = (id: string): Promise<ApiResponse> =>
postRequest('axiosRequest', '/nutrition/arc/algo/delete', { id })
@@ -0,0 +1,133 @@
<template>
<div class="arc-algo">
<!-- 搜索 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="文档名称">
<a-input
v-model:value="search.name"
v-no-space
allow-clear
placeholder="请输入文档名称"
style="width: 220px"
/>
</a-form-item>
<a-form-item label="制作年份">
<a-select
v-model:value="search.year"
:options="options.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择制作年份"
style="width: 160px"
/>
</a-form-item>
</FilterBar>
<!-- 表格 + CRUD -->
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #toolbar>
<a-button type="primary" @click="openAddOrEdit('add')">
<template #icon><PlusOutlined /></template>
新增
</a-button>
<a-button @click="onExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="onViewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<!-- 附件列:预览 + 下载 两个图标 + 文字按钮 -->
<template v-if="column.dataIndex === 'attachment'">
<a-button
type="link"
size="small"
:disabled="!record.attachment"
@click="previewAttachment(record)"
>
<template #icon><EyeOutlined /></template>
预览
</a-button>
<a-button
type="link"
size="small"
:disabled="!record.attachment"
@click="downloadAttachment(record)"
>
<template #icon><DownloadOutlined /></template>
下载
</a-button>
</template>
<!-- 操作列:编辑 | 删除(去掉预览/下载,已移到附件列) -->
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="openAddOrEdit('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="removeRecord(record)">删除</a-button>
</template>
</template>
</TableCard>
<!-- 新增/编辑 弹框 -->
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
</div>
</template>
<script setup lang="ts">
/**
* 算法模型 - 主列表页
*
* 业务定位:标准 CRUD 列表
* - 顶部 toolbar:新增 / 导出 / 查看导出任务
* - 操作列:预览 | 下载 | 编辑 | 删除(4 按钮全保留)
*
* 规范:
* - 不渲染 PageHeader
* - 操作列 link 按钮不加图标,多个之间用 a-divider 分隔
* - 删除按钮 danger
* - 子组件 ref 在 usePage.ts 中定义
*/
import { DownloadOutlined, EyeOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import { usePage } from './init/usePage'
const {
addOrEditRef,
pageLoading,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
openAddOrEdit,
removeRecord,
previewAttachment,
downloadAttachment,
onExport,
onViewExportTask,
listRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.arc-algo {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -0,0 +1,143 @@
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '100px', minWidth: '100px' } }"
>
<a-form-item label="文档名称" name="name">
<a-input
v-model:value="form.name"
v-no-space
allow-clear
:maxlength="60"
placeholder="请输入文档名称"
/>
</a-form-item>
<a-form-item label="文档描述">
<a-textarea
v-model:value="form.description"
:rows="3"
:maxlength="200"
show-count
placeholder="请输入文档描述"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="制作机构" name="organization">
<a-input
v-model:value="form.organization"
v-no-space
allow-clear
:maxlength="60"
placeholder="请输入制作机构"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="制作人" name="author">
<a-input
v-model:value="form.author"
v-no-space
allow-clear
:maxlength="20"
placeholder="请输入制作人"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="制作年份" name="year">
<a-select
v-model:value="form.year"
:options="formOptions.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择制作年份"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="上传附件">
<a-upload
:before-upload="beforeUpload"
:max-count="1"
:show-upload-list="false"
>
<a-button>
<template #icon><UploadOutlined /></template>
选择文件
</a-button>
<span v-if="form.attachment" style="margin-left: 8px; color: #1890ff">
{{ form.attachment }}
</span>
</a-upload>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
/**
* 算法模型 - 新增/编辑 弹框
*
* modal-spec.md 强制项已遵循:
* - a-modal :keyboard="false" :mask-closable="false"
* - a-spin 包裹整个 a-form
* - 确定按钮 :disabled + :loading 都用 pageInfo.spin
* - defineExpose 只暴露 openModal
*/
import { UploadOutlined } from '@ant-design/icons-vue'
import { usePage } from './init/usePage'
const emit = defineEmits<{
/** 操作成功后通知父组件刷新列表 */
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
formOptions,
filterOption,
rules,
openModal,
closeModal,
submit,
beforeUpload,
} = usePage(emit)
defineExpose({ openModal })
</script>
<style scoped lang="less"></style>
@@ -0,0 +1,16 @@
/**
* 算法模型 - 新增/编辑 弹框 - 接口层
* - 复用主页面 api 即可(结构同构),此处为对齐目录规范单独透出
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
export const info = (id: string): Promise<ApiResponse> =>
postRequest('axiosRequest', '/nutrition/arc/algo/info', { id })
export const add = (params: Record<string, unknown>): Promise<ApiResponse> =>
postRequest('axiosRequest', '/nutrition/arc/algo/add', params)
export const edit = (params: Record<string, unknown>): Promise<ApiResponse> =>
postRequest('axiosRequest', '/nutrition/arc/algo/update', params)
@@ -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<PageInfo>({
visible: false,
type: 'add',
title: '',
width: 680,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
const formOptions = ref<FormOptions>(createInitOptions())
const rules: Record<string, Rule[]> = {
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<unknown>[] = []
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<string, unknown>): void => {
delete params.id
add(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('新增失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const editRequest = (params: Record<string, unknown>): void => {
edit(params)
.then((res) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('编辑失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const submit = (): void => {
pageInfo.spin = true
formRef.value
?.validate()
.then(() => {
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
if (params.id) editRequest(params)
else addRequest(params)
})
.catch((err: unknown) => {
pageInfo.spin = false
console.error('表单验证失败:', err)
})
}
/** 附件上传 beforeUpload - 原型阶段仅记录文件名 */
const beforeUpload = (file: File): boolean => {
form.value.attachment = file.name
return false
}
return {
pageInfo,
formRef,
form,
formOptions,
filterOption,
rules,
openModal,
closeModal,
submit,
beforeUpload,
}
}
@@ -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']
}
@@ -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<boolean>(false)
/** 子组件 ref - 必须在 usePage.ts 中定义 */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | 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,
}
}
@@ -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<SearchForm>(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 }
}
@@ -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<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -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
}
@@ -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<ApiResponse<CanteenStats>> =>
postRequest('axiosRequest', '/nutrition/arc/canteen/stats', {})
@@ -0,0 +1,87 @@
<template>
<div class="arc-canteen">
<!-- 顶部 4 个统计卡 Tab 共用 -->
<a-row :gutter="16" class="arc-canteen__stats">
<a-col :span="6" v-for="s in statCards" :key="s.label">
<StatCard :color="s.color" :value="s.value" :label="s.label">
<template #icon>
<component :is="s.icon" />
</template>
</StatCard>
</a-col>
</a-row>
<!-- Tabs 容器 -->
<a-card class="listPageCard" :bordered="false">
<a-tabs v-model:activeKey="activeKey" class="aTabs">
<a-tab-pane key="operation" tab="运营总览">
<CanteenOperation />
</a-tab-pane>
<a-tab-pane key="profit" tab="成本效益">
<CanteenProfit />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup lang="ts">
/**
* 场所报表数据 - 主页面(薄)
*
* 业务定位:
* - 顶部 4 个统计卡(跨 Tab 共用)
* - Tabs:运营总览 / 成本效益
*
* 主页面只承担:
* - useStats 数据装载(无业务判断)
* - 渲染 StatCard + Tabs 容器
* 各 Tab 内部完整 6 文件结构
*/
import CanteenOperation from './component/tabs/canteenOperation/canteenOperation.vue'
import CanteenProfit from './component/tabs/canteenProfit/canteenProfit.vue'
import { useStats } from './init/useStats'
const activeKey = ref<'operation' | 'profit'>('operation')
const { statCards } = useStats()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.arc-canteen {
display: flex;
flex-direction: column;
height: 100%;
&__stats {
margin-bottom: 12px;
}
.listPageCard {
flex: 1;
}
}
.aTabs {
width: 100%;
height: 100%;
:deep(.ant-tabs-tab) {
padding-top: 0;
}
:deep(.ant-tabs-content) {
width: 100%;
height: 100%;
}
:deep(.ant-tabs-tabpane) {
height: 100%;
display: flex;
flex-direction: column;
}
}
</style>
@@ -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<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/arc/canteen/operation/page', params)
@@ -0,0 +1,98 @@
<template>
<div class="canteen-operation">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="场所名称">
<a-select
v-model:value="search.canteen"
:options="options.canteen"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择场所"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="所属单位">
<a-select
v-model:value="search.unit"
:options="options.unit"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择单位"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="年份">
<a-select
v-model:value="search.year"
:options="options.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择年份"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #toolbar>
<a-button type="primary" @click="onExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="onViewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
</TableCard>
</div>
</template>
<script setup lang="ts">
/**
* 场所报表 / 运营总览 - Tab 子组件
*/
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { usePage } from './init/usePage'
const { message } = useAntdStaticMethods()
const {
pageLoading,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
} = usePage()
const onExport = (): void => {
void message.info('导出任务已提交,可在"查看导出任务"中查看进度')
}
const onViewExportTask = (): void => {
void message.info('当前共有 3 个导出任务正在处理中(原型阶段提示)')
}
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.canteen-operation {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -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<boolean>(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,
}
}
@@ -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<SearchForm>(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 }
}
@@ -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<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -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
}
@@ -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<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/arc/canteen/profit/page', params)
@@ -0,0 +1,100 @@
<template>
<div class="canteen-profit">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="场所名称">
<a-select
v-model:value="search.canteen"
:options="options.canteen"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择场所"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="所属单位">
<a-select
v-model:value="search.unit"
:options="options.unit"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择单位"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="年份">
<a-select
v-model:value="search.year"
:options="options.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择年份"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #toolbar>
<a-button type="primary" @click="onExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="onViewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
</TableCard>
</div>
</template>
<script setup lang="ts">
/**
* 场所报表 / 成本效益 - Tab 子组件
* - 金额字段千分位展示
* - 综合评分用蓝色徽章
*/
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { usePage } from './init/usePage'
const { message } = useAntdStaticMethods()
const {
pageLoading,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
} = usePage()
const onExport = (): void => {
void message.info('导出任务已提交,可在"查看导出任务"中查看进度')
}
const onViewExportTask = (): void => {
void message.info('当前共有 3 个导出任务正在处理中(原型阶段提示)')
}
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.canteen-profit {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -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<boolean>(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,
}
}
@@ -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<SearchForm>(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 }
}
@@ -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<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -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
}
@@ -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<CanteenStats | null>(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<StatCardItem[]>(() => [
{ 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 }
}
@@ -0,0 +1,15 @@
/**
* 场所报表 - 主页面级 stats 类型
*/
/** 4 个统计卡数据 */
export interface CanteenStats {
/** 场所总数 */
total: number
/** 日均服务总人次 */
dailyAvg: number
/** 综合营养达标率(带 % 字符串) */
rate: string
/** 食安检测合格率(带 % 字符串) */
safety: string
}
@@ -1,105 +1,106 @@
<template>
<div class="arc-employee">
<!-- 顶部统计卡 -->
<a-row :gutter="16" class="arc-employee__stats">
<a-col :span="6" v-for="s in statCards" :key="s.label">
<StatCard :color="s.color" :value="s.value" :label="s.label">
<template #icon>
<component :is="s.icon" />
<!-- 列表视图 -->
<template v-if="currentView === 'list'">
<!-- 顶部统计卡 -->
<a-row :gutter="16" class="arc-employee__stats">
<a-col :span="6" v-for="s in statCards" :key="s.label">
<StatCard :color="s.color" :value="s.value" :label="s.label">
<template #icon>
<component :is="s.icon" />
</template>
</StatCard>
</a-col>
</a-row>
<!-- 搜索区与旧 h5 原型对齐姓名/工号所属单位所属部门年份 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="姓名/工号">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="请输入姓名或工号"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="所属单位">
<a-select
v-model:value="search.unit"
:options="options.unit"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择单位"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="所属部门">
<a-select
v-model:value="search.dept"
:options="options.dept"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择部门"
style="width: 160px"
/>
</a-form-item>
<a-form-item label="年份">
<a-select
v-model:value="search.year"
:options="options.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择年份"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<!-- 表格(只读 + 查看详情 link 切换到 detail 视图) -->
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="viewDetail(record)">查看详情</a-button>
</template>
</StatCard>
</a-col>
</a-row>
<!-- 搜索区FilterBar default slot写裸 a-form-item -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="姓名/工号">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="请输入姓名或工号"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="所属单位">
<a-select
v-model:value="search.unit"
:options="options.unit"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择单位"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="所属部门">
<a-select
v-model:value="search.dept"
:options="options.dept"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择部门"
style="width: 160px"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="search.status"
:options="options.status"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择状态"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<!-- 表格区(只读查询:无 toolbar 操作按钮) -->
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<!-- 操作列 link 按钮统一不加图标(对齐 vue 研发项目规范) -->
<a-button type="link" size="small" @click="openInfoDrawer(record)">查看详情</a-button>
</template>
</template>
</TableCard>
</TableCard>
</template>
<!-- 详情抽屉 -->
<InfoDrawer ref="infoDrawerRef" />
<!-- 详情视图(子组件型,emit 'back' 切回列表) -->
<template v-else>
<ArcEmployeeDetail :emp-no="currentEmpNo" @back="backToList" />
</template>
</div>
</template>
<script setup lang="ts">
/**
* 员工营养数据 - 列表(薄模板层)
* 员工营养数据 - 列表 + 详情切换模式(薄模板层)
*
* 业务定位:只读查询页
* - 仅支持搜索、排序、分页、查看详情
* - 无新增/编辑/删除/导出按钮
* 业务定位:
* - 列表视图:搜索 / 排序 / 分页 / 查看详情
* - 详情视图:通过 currentView v-if 切换至子组件 ArcEmployeeDetail
* - 不打开抽屉、不路由跳转(按用户要求采用 v-if 切换模式)
*
* 对齐 vue 研发项目规范:
* - 不渲染 PageHeader(菜单/历史栏已表达所在位置)
* - FilterBar 用 default slot 写裸 a-form-item
* - TableCard 接 :table 对象(TableState
* - 操作列 <a-button type="link"> 不加任何图标
* - 所有业务逻辑通过 usePage() 暴露
* 字段与旧 h5 原型 arc-employee.html 完全对齐
*/
import { filterOption } from '@utils/antDesign/select'
import InfoDrawer from './component/drawer/info/info.vue'
import ArcEmployeeDetail from './component/page/arcEmployeeDetail/arcEmployeeDetail.vue'
import { usePage } from './init/usePage'
const {
infoDrawerRef,
pageLoading,
currentView,
currentEmpNo,
search,
options,
table,
@@ -107,7 +108,8 @@ const {
dataSourceChange,
searchQuery,
resetQuery,
openInfoDrawer,
viewDetail,
backToList,
} = usePage()
</script>
@@ -1,61 +0,0 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
destroy-on-close
@close="closeDrawer"
>
<a-spin :spinning="pageInfo.spin">
<template v-if="!detail">
<a-empty description="暂无数据" />
</template>
<template v-else>
<a-descriptions :column="2" bordered size="middle">
<a-descriptions-item label="姓名">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="员工工号">{{ detail.empNo ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="性别">{{ detail.gender ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="年龄">{{ detail.age ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属单位">{{ detail.unit ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属部门">{{ detail.dept ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="用餐天数">{{ detail.days ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="用餐次数">{{ detail.meals ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="热量均值(kcal">{{ detail.calorie ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="蛋白质均值(g">{{ detail.protein ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="脂肪均值(g">{{ detail.fat ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="碳水均值(g">{{ detail.carb ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="营养达标率" :span="2">
<span :style="{ color: detail.rate >= 85 ? '#52c41a' : detail.rate >= 70 ? '#faad14' : '#f5222d', fontWeight: 600 }">
{{ detail.rate }}%
</span>
</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ detail.createTime ?? '—' }}</a-descriptions-item>
</a-descriptions>
</template>
</a-spin>
<template #footer>
<a-button @click="closeDrawer">关闭</a-button>
</template>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 员工营养 - 详情抽屉
*
* 对齐 vue 研发项目 drawer-spec.md(详情型):
* - a-drawer 加 destroy-on-close
* - a-spin 包裹内容
* - defineExpose 只暴露 openDrawer
* - 空值统一用 ?? '—' 兜底
*/
import { usePage } from './init/usePage'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
defineExpose({ openDrawer })
</script>
<style scoped lang="less"></style>
@@ -1,30 +0,0 @@
/**
* 员工营养 - 详情抽屉 - 类型
*/
export interface PageInfo {
visible: boolean
title: string
spin: boolean
width: number
}
/** 详情数据(与列表行完全一致) */
export interface DetailInfo {
id: string
name: string
gender: '男' | '女'
age: number
empNo: string
unit: string
dept: string
days: number
meals: number
calorie: string
protein: number
fat: number
carb: number
rate: number
status: 0 | 1
createTime: string
}
@@ -0,0 +1,13 @@
/**
* 员工用餐记录 - 接口层(arcEmployee 子组件型详情页)
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { EmployeeInfo, ListItem, ListParams } from '../types'
export const employeeInfo = (empNo: string): Promise<ApiResponse<EmployeeInfo>> =>
postRequest('axiosRequest', '/nutrition/employee/detail/info', { empNo })
export const list = (params: ListParams): Promise<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/employee/detail/page', params)
@@ -0,0 +1,181 @@
<template>
<div class="arc-employee-detail">
<!-- 顶部返回按钮detail-page-spec 强制/底各一个 -->
<a-row class="page-header">
<a-col>
<a-button @click="goBack">
<template #icon><ArrowLeftOutlined /></template>
返回员工列表
</a-button>
<span class="arc-employee-detail__crumb">
<a-divider type="vertical" />
<a class="arc-employee-detail__link" @click="goBack">员工营养数据</a>
<span class="arc-employee-detail__sep">/</span>
<span>{{ employee?.name ?? '—' }}{{ empNo }}{{ employee?.year ?? '—' }}年用餐记录</span>
</span>
</a-col>
</a-row>
<!-- 员工信息卡 -->
<a-card :bordered="false" class="arc-employee-detail__info">
<a-spin :spinning="!employee && pageLoading">
<a-descriptions :column="{ xs: 1, sm: 2, md: 3, lg: 5 }" size="middle">
<a-descriptions-item label="姓名">{{ employee?.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="性别">{{ employee?.gender ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="年龄">{{ employee ? `${employee.age}` : '—' }}</a-descriptions-item>
<a-descriptions-item label="工号">{{ employee?.empNo ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="监控年份">{{ employee ? `${employee.year}` : '—' }}</a-descriptions-item>
<a-descriptions-item label="所属单位">{{ employee?.unit ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属部门">{{ employee?.dept ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="总用餐次数">{{ employee ? `${employee.totalMeals}` : '—' }}</a-descriptions-item>
<a-descriptions-item label="营养达标率">
<span :style="rateStyle">{{ employee ? `${employee.rate}%` : '—' }}</span>
</a-descriptions-item>
</a-descriptions>
</a-spin>
</a-card>
<!-- 搜索 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="日期范围">
<a-range-picker
v-model:value="dateRange"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
style="width: 280px"
@change="onDateRangeChange"
/>
</a-form-item>
<a-form-item label="餐次">
<a-select
v-model:value="search.meal"
:options="options.meal"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择餐次"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<!-- 表格 -->
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="showNutritionDetail(record)">营养明细</a-button>
</template>
</template>
</TableCard>
<!-- 底部返回按钮 -->
<a-row class="page-footer">
<a-col>
<a-button @click="goBack">
<template #icon><ArrowLeftOutlined /></template>
返回员工列表
</a-button>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
/**
* 员工用餐记录 - 详情子组件页(薄模板)
*
* 子组件型路径:arcEmployee/component/page/arcEmployeeDetail/
* - props.empNo 从父组件传入(替代旧的 route.query.id
* - emit('back') 通知父组件切回列表视图
*/
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import { usePage } from './init/usePage'
const props = defineProps<{
/** 员工工号(父组件 v-bind 传入) */
empNo: string
}>()
const emit = defineEmits<{
/** 返回父组件列表视图 */
(e: 'back'): void
}>()
const {
pageLoading,
employee,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
showNutritionDetail,
goBack,
} = usePage(props, emit)
/** 暴露 empNo 给模板(面包屑用) */
const empNo = computed(() => props.empNo)
/** 日期范围 picker 双向绑定的中间变量 */
const dateRange = ref<[string, string] | undefined>(undefined)
const onDateRangeChange = (val: [string, string] | null): void => {
search.startDate = val?.[0]
search.endDate = val?.[1]
}
/** 营养达标率展示色 */
const rateStyle = computed<Record<string, string | number>>(() => {
if (!employee.value) return { color: '#666' }
const r = employee.value.rate
const color = r >= 85 ? '#52c41a' : r >= 70 ? '#faad14' : '#f5222d'
return { color, fontWeight: 600 }
})
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.arc-employee-detail {
display: flex;
flex-direction: column;
height: 100%;
&__info {
margin-bottom: 12px;
}
&__crumb {
margin-left: 8px;
color: #8c8c8c;
font-size: 13px;
}
&__link {
color: #1890ff;
cursor: pointer;
&:hover { color: #40a9ff; }
}
&__sep {
margin: 0 6px;
color: #bfbfbf;
}
.page-header {
margin-bottom: 12px;
}
.page-footer {
margin-top: 16px;
}
}
</style>
@@ -0,0 +1,108 @@
/**
* 员工用餐记录 - 页面主逻辑(子组件型)
*
* 业务定位:arcEmployee 的二级详情视图
* - 父组件传入 empNoprops.empNo),不再从 route.query 取
* - 返回时 emit('back') 通知父组件切换回列表视图
* - 顶+底两个返回按钮(detail-page-spec 强制)
*/
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { employeeInfo, list } from '../api'
import { useSearch } from './useSearch'
import { useTable } from './useTable'
import type { EmployeeInfo, ListItem } from '../types'
export const usePage = (props: { empNo: string }, emit: (e: 'back') => void) => {
const { message } = useAntdStaticMethods()
const { search, options, initOptions, resetSearch } = useSearch()
const pageLoading = ref<boolean>(false)
const employee = ref<EmployeeInfo | null>(null)
const buildParams = () => ({
...search,
empNo: props.empNo,
pageNum: table.pagination.current ?? 1,
pageSize: table.pagination.pageSize ?? 30,
order: table.sort.order,
column: table.sort.field,
})
const listRequest = (): void => {
pageLoading.value = true
list(buildParams())
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = res.total ?? 0
}
})
.catch((err: unknown) => {
console.error('用餐记录列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
const infoRequest = (): void => {
if (!props.empNo) return
employeeInfo(props.empNo)
.then((res) => {
if (res.code === '00000') employee.value = res.data
})
.catch((err: unknown) => {
console.error('员工信息请求失败:', err)
})
}
const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest)
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
const resetQuery = (): void => {
resetSearch()
resetTable()
listRequest()
}
/** 营养明细 - 原型阶段用 message 提示 */
const showNutritionDetail = (record: ListItem): void => {
void message.info(`查看营养明细:${record.date} ${record.meal}(原型阶段暂以提示替代)`)
}
/** 返回上级(emit back 给父组件,由父组件切换 v-if 视图) */
const goBack = (): void => {
emit('back')
}
const loadData = (): void => {
initOptions()
infoRequest()
listRequest()
}
onMounted(() => {
loadData()
})
return {
pageLoading,
employee,
search,
options,
table,
dataSourceChange,
resizeColumn,
searchQuery,
resetQuery,
showNutritionDetail,
goBack,
listRequest,
}
}
@@ -0,0 +1,42 @@
/**
* 员工用餐记录 - 搜索逻辑
*/
import type { SearchForm } from '../types'
const createSearchKey = (): SearchForm => ({
startDate: undefined,
endDate: undefined,
meal: undefined,
})
const createOptions = () => ({
meal: [] as { label: string; value: 'breakfast' | 'lunch' | 'dinner' }[],
})
const MEAL_LIST = [
{ label: '早餐', value: 'breakfast' as const },
{ label: '午餐', value: 'lunch' as const },
{ label: '晚餐', value: 'dinner' as const },
]
export const useSearch = () => {
const search = reactive<SearchForm>(createSearchKey())
const options = reactive(createOptions())
const initOptions = (): void => {
Promise.all([Promise.resolve(MEAL_LIST)])
.then(([mealList]) => {
options.meal = mealList
})
.catch((err: unknown) => {
console.error('初始化下拉选项失败:', err)
})
}
const resetSearch = (): void => {
Object.assign(search, createSearchKey())
}
return { search, options, initOptions, resetSearch }
}
@@ -0,0 +1,108 @@
/**
* 员工用餐记录 - 表格逻辑
*/
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, MealType, RateStatus } from '../types'
const INIT_SORT: TableSort = {
field: 'date',
order: 'descend',
}
const MEAL_LABEL: Record<MealType, string> = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
}
const RATE_MAP: Record<RateStatus, { color: string; bg: string; label: string }> = {
pass: { color: '#52c41a', bg: '#f6ffed', label: '达标' },
high: { color: '#faad14', bg: '#fff7e6', label: '偏高' },
over: { color: '#f5222d', bg: '#fff1f0', label: '超标' },
}
const tableColumns: TableColumnsType = [
{
title: '日期',
dataIndex: 'date',
align: 'center',
fixed: 'left',
width: 120,
sorter: true,
},
{
title: '餐次',
dataIndex: 'meal',
align: 'center',
width: 90,
customRender: ({ text }: { text: MealType }) => MEAL_LABEL[text] ?? '—',
},
{ title: '取餐方式', dataIndex: 'dineType', align: 'center', width: 100 },
{
title: '菜品名称',
dataIndex: 'dishes',
align: 'left',
width: 280,
resizable: true,
ellipsis: true,
},
{ title: '热量(kcal)', dataIndex: 'calorie', align: 'center', width: 110 },
{ title: '蛋白质(g)', dataIndex: 'protein', align: 'center', width: 100 },
{ title: '脂肪(g)', dataIndex: 'fat', align: 'center', width: 100 },
{ title: '碳水(g)', dataIndex: 'carb', align: 'center', width: 100 },
{
title: '达标状态',
dataIndex: 'rateStatus',
align: 'center',
width: 100,
customRender: ({ text }: { text: RateStatus }) => {
const meta = RATE_MAP[text] ?? RATE_MAP.pass
return h(
'span',
{
style: {
color: meta.color,
background: meta.bg,
padding: '2px 8px',
borderRadius: '10px',
fontSize: '12px',
},
},
meta.label,
)
},
},
{
title: '操作',
dataIndex: 'action',
align: 'center',
fixed: 'right',
width: 100,
},
]
export const useTable = (listRequest: () => void) => {
const createInitState = (): TableState<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -0,0 +1,56 @@
/**
* 员工用餐记录 - 类型集中定义(arcEmployee 子组件型详情页)
*/
/** 餐次类型 */
export type MealType = 'breakfast' | 'lunch' | 'dinner'
/** 取餐方式 */
export type DineType = '堂食' | '外带'
/** 达标状态 */
export type RateStatus = 'pass' | 'high' | 'over'
/** 列表行(单次用餐记录) */
export interface ListItem {
id: string
empNo: string
date: string
meal: MealType
dineType: DineType
dishes: string
calorie: number
protein: number
fat: number
carb: number
rateStatus: RateStatus
}
/** 搜索表单 */
export interface SearchForm {
startDate?: string
endDate?: string
meal?: MealType
}
/** 分页请求参数 */
export interface ListParams extends SearchForm {
empNo: string
pageNum: number
pageSize: number
order?: 'ascend' | 'descend' | null
column?: string
}
/** 员工基本信息(顶部信息卡) */
export interface EmployeeInfo {
empNo: string
name: string
gender: '男' | '女'
age: number
unit: string
dept: string
year: number
totalMeals: number
rate: number
}
@@ -1,18 +1,16 @@
/**
* 员工营养数据 - 页面主逻辑
*
* 业务定位:**只读查询页**
* - 仅支持搜索 / 排序 / 分页 / 查看详情
* - 无新增、编辑、删除、导出按钮(CRUD 不是列表页标配
* 业务定位:列表 + 详情视图切换
* - currentView: 'list' | 'detail'
* - 点击"查看详情":切换到 detail 视图(不打开抽屉、不路由跳转
* - detail 子组件 emit('back'):切回 list 视图
*
* 对齐 vue 研发项目规范
* - 统一 buildParams() 构建请求参数(搜索 + 排序 + 分页)
* - 列表请求 .then().catch().finally() 链式
* - 子组件 ref 在 usePage.ts 定义,打开方法命名 open + 组件名
* - onMounted 通过 loadData() 统一触发
* vue 研发项目对齐
* - 子组件 ref / 切换状态 / 选中行 都在 usePage.ts 定义
* - 详情视图属于"父组件包子组件"模式,符合 detail-page-spec 子组件型
*/
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { list, stats } from '../api'
import { useSearch } from './useSearch'
import { useTable } from './useTable'
@@ -25,15 +23,28 @@ interface StatCardItem {
label: string
}
/** 视图类型 */
type ViewType = 'list' | 'detail'
export const usePage = () => {
const { message } = useAntdStaticMethods()
const route = useRoute()
const { search, options, initOptions, resetSearch } = useSearch()
const pageLoading = ref<boolean>(false)
const statsData = ref<NutritionStats | null>(null)
/** 详情抽屉 ref - 必须在 usePage.ts 中定义 */
const infoDrawerRef = ref<{ openDrawer: (record: { id: string; name?: string }) => void } | null>(null)
/** 当前视图(列表 / 详情) */
const currentView = ref<ViewType>('list')
/** 当前查看详情的员工工号(切换到 detail 时设置) */
const currentEmpNo = ref<string>('')
/** 从 URL query 回显筛选条件(支持 arcUnit 下钻 ?unit=xxx&dept=yyy */
const applyQueryToSearch = (): void => {
const q = route.query
if (typeof q.unit === 'string') search.unit = q.unit
if (typeof q.dept === 'string') search.dept = q.dept
}
/** 构造请求参数 */
const buildParams = () => ({
@@ -75,10 +86,10 @@ export const usePage = () => {
/** 顶部统计卡(基于 statsData 动态生成) */
const statCards = computed<StatCardItem[]>(() => [
{ icon: 'UserOutlined', color: 'blue', value: statsData.value?.total ?? '—', label: '监控员工数' },
{ icon: 'CalendarOutlined', color: 'green', value: statsData.value?.days ?? '—', label: '本年已监控天数' },
{ icon: 'BarChartOutlined', color: 'orange', value: statsData.value?.rate ?? '—', label: '营养达标率' },
{ icon: 'DatabaseOutlined', color: 'green', value: statsData.value?.coverage ?? '—', label: '数据覆盖率' },
{ icon: 'UserOutlined', color: 'blue', value: statsData.value?.total ?? '—', label: '监控员工数' },
{ icon: 'CalendarOutlined', color: 'green', value: statsData.value?.days ?? '—', label: '本年已监控天数' },
{ icon: 'BarChartOutlined', color: 'orange', value: statsData.value?.rate ?? '—', label: '营养达标率' },
{ icon: 'DatabaseOutlined', color: 'green', value: statsData.value?.coverage ?? '—', label: '数据覆盖率' },
])
/** useTable 必须在 listRequest 定义之后调用 */
@@ -90,35 +101,41 @@ export const usePage = () => {
listRequest()
}
/** 重置:先 resetSearch → resetTable → listRequest */
/** 重置 */
const resetQuery = (): void => {
resetSearch()
resetTable()
listRequest()
}
/** 打开详情抽屉 */
const openInfoDrawer = (record: ListItem): void => {
infoDrawerRef.value?.openDrawer({ id: record.id, name: record.name })
/** 查看详情 - 切换到 detail 视图(替代旧的抽屉模式) */
const viewDetail = (record: ListItem): void => {
currentEmpNo.value = record.empNo
currentView.value = 'detail'
}
/** 返回列表视图(detail 子组件 emit 'back' 时触发) */
const backToList = (): void => {
currentView.value = 'list'
currentEmpNo.value = ''
}
/** 统一数据加载入口 */
const loadData = (): void => {
initOptions()
applyQueryToSearch()
statsRequest()
listRequest()
}
// 保留 message 引用以便后续扩展(如批量、刷新提示)
void message
onMounted(() => {
loadData()
})
return {
infoDrawerRef,
pageLoading,
currentView,
currentEmpNo,
search,
options,
table,
@@ -127,7 +144,8 @@ export const usePage = () => {
resizeColumn,
searchQuery,
resetQuery,
openInfoDrawer,
viewDetail,
backToList,
listRequest,
}
}
@@ -1,31 +1,30 @@
/**
* 员工营养数据 - 搜索逻辑
*
* 对齐 vue 研发项目规范:
* - reactive + 工厂函数 createSearchKey()
* - resetSearch 用 Object.assign 重置(保持响应式引用)
* - 下拉选项用 createOptions 工厂函数
* - initOptions 用 Promise.all 并行
* 与旧 h5 原型对齐:姓名/工号、所属单位、所属部门、年份
* - 去掉了原型早期模板中的"状态"字段(业务上员工营养表不需要)
*/
import type { SearchForm } from '../types'
const DEFAULT_YEAR = 2026
/** 创建搜索初始值(工厂函数) */
const createSearchKey = (): SearchForm => ({
keyword: undefined,
unit: undefined,
dept: undefined,
status: undefined,
year: DEFAULT_YEAR,
})
/** 创建下拉选项初始值(工厂函数) */
const createOptions = () => ({
unit: [] as { label: string; value: string }[],
dept: [] as { label: string; value: string }[],
status: [] as { label: string; value: number }[],
year: [] as { label: string; value: number }[],
})
/** 单位静态数据(实际可走 dictItems / departTree */
/** 单位静态数据 */
const UNIT_LIST = [
{ label: 'CQ能源总部', value: 'CQ能源总部' },
{ label: '采油一厂', value: '采油一厂' },
@@ -43,9 +42,10 @@ const DEPT_LIST = [
{ label: '安全环保部', value: '安全环保部' },
]
const STATUS_LIST = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
const YEAR_LIST = [
{ label: '2026年', value: 2026 },
{ label: '2025年', value: 2025 },
{ label: '2024年', value: 2024 },
]
export const useSearch = () => {
@@ -54,18 +54,15 @@ export const useSearch = () => {
/** 初始化下拉选项 - 用 Promise.all 并行加载 */
const initOptions = (): void => {
// 原型阶段直接同步赋值;实际研发可替换为接口调用:
// Promise.all([dictItems('unit'), dictItems('dept'), dictItems('status')])
// .then(([u, d, s]) => { ... })
Promise.all([
Promise.resolve(UNIT_LIST),
Promise.resolve(DEPT_LIST),
Promise.resolve(STATUS_LIST),
Promise.resolve(YEAR_LIST),
])
.then(([unitList, deptList, statusList]) => {
.then(([unitList, deptList, yearList]) => {
options.unit = unitList
options.dept = deptList
options.status = statusList
options.year = yearList
})
.catch((err: unknown) => {
console.error('初始化下拉选项失败:', err)
@@ -1,11 +1,11 @@
/**
* 员工营养数据 - 表格逻辑
*
* 对齐 vue 研发项目规范
* - 使用 TableState 统一对象(含 columns / dataSource / sort / pagination
* - 列定义在文件顶部,customRender 用 h() 函数
* - createInitState 工厂函数 + createResetTable 还原全部状态
* - INIT_SORT 默认按 createTime 降序
* 列定义与旧 h5 原型 arc-employee.html 完全对齐
* - 序号 / 姓名 / 性别 / 年龄 / 员工工号 / 所属单位 / 所属部门 /
* 用餐天数 / 用餐次数 / 热量均值 / 蛋白质均值 / 脂肪均值 / 碳水均值 /
* 营养达标率 / 操作
* - 不展示"状态"和"创建时间"两个后端通用字段
*/
import { h } from 'vue'
@@ -20,9 +20,9 @@ import {
} from '@utils/antDesign/table'
import type { ListItem } from '../types'
/** 默认排序:创建时间倒序 */
/** 默认排序:营养达标率倒序(旧原型按列表先到先得,原型阶段加个稳定排序便于查看) */
const INIT_SORT: TableSort = {
field: 'createTime',
field: 'rate',
order: 'descend',
}
@@ -45,59 +45,50 @@ const tableColumns: TableColumnsType = [
resizable: true,
ellipsis: true,
},
{ title: '性别', dataIndex: 'gender', align: 'center', width: 70 },
{ title: '年龄', dataIndex: 'age', align: 'center', width: 70 },
{ title: '性别', dataIndex: 'gender', align: 'center', width: 70 },
{ title: '年龄', dataIndex: 'age', align: 'center', width: 70 },
{ title: '员工工号', dataIndex: 'empNo', align: 'center', width: 120, resizable: true },
{ title: '所属单位', dataIndex: 'unit', align: 'center', width: 140, resizable: true, ellipsis: true },
{ title: '所属部门', dataIndex: 'dept', align: 'center', width: 110, resizable: true, ellipsis: true },
{ title: '用餐天数', dataIndex: 'days', align: 'center', width: 100 },
{ title: '所属单位', dataIndex: 'unit', align: 'center', width: 140, resizable: true, ellipsis: true },
{ title: '所属部门', dataIndex: 'dept', align: 'center', width: 110, resizable: true, ellipsis: true },
{ title: '用餐天数', dataIndex: 'days', align: 'center', width: 100 },
{ title: '用餐次数', dataIndex: 'meals', align: 'center', width: 100 },
{ title: '热量均值(kcal)', dataIndex: 'calorie', align: 'center', width: 130 },
{ title: '蛋白质均值(g)', dataIndex: 'protein', align: 'center', width: 130 },
{ title: '脂肪均值(g)', dataIndex: 'fat', align: 'center', width: 120 },
{ title: '碳水均值(g)', dataIndex: 'carb', align: 'center', width: 120 },
{ title: '蛋白质均值(g)', dataIndex: 'protein', align: 'center', width: 130 },
{ title: '脂肪均值(g)', dataIndex: 'fat', align: 'center', width: 120 },
{ title: '碳水均值(g)', dataIndex: 'carb', align: 'center', width: 120 },
{
title: '营养达标率',
dataIndex: 'rate',
align: 'center',
width: 110,
sorter: true,
customRender: ({ text }: { text: number }) => {
const color = text >= 85 ? '#52c41a' : text >= 70 ? '#faad14' : '#f5222d'
return h('span', { style: { color, fontWeight: 600 } }, `${text}%`)
const bg = text >= 85 ? '#f6ffed' : text >= 70 ? '#fff7e6' : '#fff1f0'
return h(
'span',
{
style: {
color,
background: bg,
padding: '2px 8px',
borderRadius: '10px',
fontSize: '12px',
},
},
`${text}%`,
)
},
},
{
title: '状态',
dataIndex: 'status',
align: 'center',
width: 80,
customRender: ({ text }: { text: 0 | 1 }) => {
const color = text === 1 ? '#52c41a' : '#bfbfbf'
const label = text === 1 ? '启用' : '禁用'
return h('span', { style: { color } }, label)
},
},
{
title: '创建时间',
dataIndex: 'createTime',
align: 'center',
width: 180,
sorter: true,
resizable: true,
},
{
title: '操作',
dataIndex: 'action',
align: 'center',
fixed: 'right',
width: 100,
width: 110,
},
]
/**
* 员工营养表格 Hook
* @param listRequest 数据刷新回调
*/
export const useTable = (listRequest: () => void) => {
const createInitState = (): TableState<ListItem> => ({
columns: tableColumns,
@@ -1,13 +1,13 @@
/**
* 员工营养数据 - 类型集中定义
*
* 对齐 vue 研发项目规范
* - id 统一 string 类型
* - SearchForm 字段都用 optional
* - 参数类型 ListParams 显式继承 SearchForm
* 字段与旧 h5 原型 arc-employee.html 对齐
* - 姓名 / 性别 / 年龄 / 员工工号 / 所属单位 / 所属部门 /
* 用餐天数 / 用餐次数 / 热量均值 / 蛋白质均值 / 脂肪均值 / 碳水均值 / 营养达标率
* - 不展示后端通用字段(status / createTime
*/
/** 列表行数据(与后端 EmployeeNutritionDTO 字段对齐) */
/** 列表行数据 */
export interface ListItem {
/** 主键,统一 string 类型 */
id: string
@@ -37,13 +37,9 @@ export interface ListItem {
carb: number
/** 营养达标率(0-100 */
rate: number
/** 状态:1 启用 / 0 禁用 */
status: 0 | 1
/** 创建时间 */
createTime: string
}
/** 搜索表单 */
/** 搜索表单(与旧 h5 原型对齐:姓名/工号、所属单位、所属部门、年份) */
export interface SearchForm {
/** 姓名 / 工号关键词 */
keyword?: string
@@ -51,8 +47,8 @@ export interface SearchForm {
unit?: string
/** 所属部门 */
dept?: string
/** 状态 */
status?: 0 | 1
/** 年份 */
year?: number
}
/** 列表分页请求参数 */
@@ -0,0 +1,60 @@
<template>
<a-card class="listPageCard arc-unit" :bordered="false">
<a-tabs v-model:activeKey="activeKey" class="aTabs">
<a-tab-pane key="level2" tab="二级单位">
<Level2Unit />
</a-tab-pane>
<a-tab-pane key="level3" tab="三级单位">
<Level3Unit />
</a-tab-pane>
</a-tabs>
</a-card>
</template>
<script setup lang="ts">
/**
* 单位报表数据 - Tabs 容器(薄主页面)
*
* 业务定位:按单位层级查看营养监控汇总
* - Tab 1:二级单位(直接归属能源总部 / 各厂)
* - Tab 2:三级单位(部门级别)
*
* Tabs 页规范:
* - 主页面仅 a-tabs 容器,禁写任何业务逻辑
* - 每个 Tab 独立 6 文件结构
* - .aTabs 必须满足 width/height: 100% + 嵌套 deep 选择器
*/
import Level2Unit from './component/tabs/level2Unit/level2Unit.vue'
import Level3Unit from './component/tabs/level3Unit/level3Unit.vue'
const activeKey = ref<'level2' | 'level3'>('level2')
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.arc-unit {
height: 100%;
}
.aTabs {
width: 100%;
height: 100%;
:deep(.ant-tabs-tab) {
padding-top: 0;
}
:deep(.ant-tabs-content) {
width: 100%;
height: 100%;
}
:deep(.ant-tabs-tabpane) {
height: 100%;
display: flex;
flex-direction: column;
}
}
</style>
@@ -0,0 +1,11 @@
/**
* 单位报表 / 二级单位 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { ListItem, ListParams } from '../types'
/** 二级单位分页列表 */
export const list = (params: ListParams): Promise<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/arc/unit/level2/page', params)
@@ -0,0 +1,74 @@
/**
* 单位报表 / 二级单位 - 页面主逻辑
*/
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<boolean>(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,
}
}
@@ -0,0 +1,54 @@
/**
* 单位报表 / 二级单位 - 搜索逻辑
*/
import type { SearchForm } from '../types'
/** 默认年份取当前自然年 */
const DEFAULT_YEAR = 2026
const createSearchKey = (): SearchForm => ({
unit: undefined,
year: DEFAULT_YEAR,
})
const createOptions = () => ({
unit: [] as { label: string; value: string }[],
year: [] as { label: string; value: number }[],
})
const UNIT_LIST = [
{ label: '采油一厂', value: '采油一厂' },
{ 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<SearchForm>(createSearchKey())
const options = reactive(createOptions())
const initOptions = (): void => {
Promise.all([Promise.resolve(UNIT_LIST), Promise.resolve(YEAR_LIST)])
.then(([unitList, yearList]) => {
options.unit = unitList
options.year = yearList
})
.catch((err: unknown) => {
console.error('初始化下拉选项失败:', err)
})
}
const resetSearch = (): void => {
Object.assign(search, createSearchKey())
}
return { search, options, initOptions, resetSearch }
}
@@ -0,0 +1,115 @@
/**
* 单位报表 / 二级单位 - 表格逻辑
*
* 9 列,与旧 arc-unit.html 二级单位 tab 完全对齐
* 用餐人数 > 0 时点击下钻到员工营养数据页(按单位筛选)
* - 用 router.push 替代旧版 window.location.reload
* - = 0 时显示纯文本(不可点)
*/
import { h } from 'vue'
import { useRouter } from 'vue-router'
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: 'totalMeals',
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}%`,
)
}
export const useTable = (listRequest: () => void) => {
const router = useRouter()
/** 下钻:跳转到员工营养数据页并带入单位筛选 */
const drillToEmployee = (unit: string): void => {
router.push({
name: 'nutrition-arc-employee',
query: { unit },
})
}
const tableColumns: TableColumnsType = [
{
title: '单位名称',
dataIndex: 'unit',
align: 'center',
fixed: 'left',
width: 160,
resizable: true,
ellipsis: true,
},
{ title: '在册人数', dataIndex: 'totalHc', align: 'center', width: 110, sorter: true },
{
title: '用餐人数',
dataIndex: 'diningHc',
align: 'center',
width: 110,
customRender: ({ text, record }: { text: number; record: ListItem }) => {
// > 0 才可点击下钻;= 0 显示纯文本
if (!text || text <= 0) return text ?? '—'
return h(
'a',
{
style: { color: '#1890ff', cursor: 'pointer' },
onClick: () => drillToEmployee(record.unit),
},
text,
)
},
},
{ title: '用餐总次数', dataIndex: 'totalMeals', align: 'center', width: 120, sorter: true },
{ title: '人均年用餐数', dataIndex: 'perCapitaMeals', align: 'center', width: 130 },
{ title: '热量均值(kcal)', dataIndex: 'calorie', align: 'center', width: 130 },
{ title: '人均营养评分', dataIndex: 'score', align: 'center', width: 130, sorter: true },
{
title: '营养达标率',
dataIndex: 'rate',
align: 'center',
width: 120,
sorter: true,
customRender: ({ text }: { text: number }) => rateBadge(text),
},
{ title: '数据覆盖率', dataIndex: 'coverage', align: 'center', width: 110 },
]
const createInitState = (): TableState<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -0,0 +1,94 @@
<template>
<div class="level2-unit">
<!-- 搜索 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="单位名称">
<a-select
v-model:value="search.unit"
:options="options.unit"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择单位"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="年份">
<a-select
v-model:value="search.year"
:options="options.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择年份"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<!-- 表格 -->
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #toolbar>
<a-button type="primary" @click="onExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="onViewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
</TableCard>
</div>
</template>
<script setup lang="ts">
/**
* 单位报表 / 二级单位 - Tab 子组件
*
* 业务定位:只读查询页(带导出/查看导出任务两个 toolbar 按钮)
* - 用餐人数列点击可下钻到员工列表(按单位筛选)
*/
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { usePage } from './init/usePage'
const { message } = useAntdStaticMethods()
const {
pageLoading,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
} = usePage()
/** 导出 - 原型阶段用 message 模拟 */
const onExport = (): void => {
void message.info('导出任务已提交,可在"查看导出任务"中查看进度')
}
/** 查看导出任务 - 原型阶段用 message 模拟 */
const onViewExportTask = (): void => {
void message.info('当前共有 3 个导出任务正在处理中(原型阶段提示)')
}
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.level2-unit {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -0,0 +1,41 @@
/**
* 单位报表 / 二级单位 - 类型集中定义
*/
/** 列表行:二级单位营养汇总 */
export interface ListItem {
/** 主键 */
id: string
/** 单位名称 */
unit: string
/** 在册人数 */
totalHc: number
/** 用餐人数 */
diningHc: number
/** 用餐总次数 */
totalMeals: number
/** 人均年用餐数 */
perCapitaMeals: number
/** 热量均值(kcal */
calorie: string
/** 人均营养评分 */
score: number
/** 营养达标率 0-100 */
rate: number
/** 数据覆盖率 0-100 字符串 */
coverage: string
}
/** 搜索表单 */
export interface SearchForm {
unit?: string
year?: number
}
/** 分页参数 */
export interface ListParams extends SearchForm {
pageNum: number
pageSize: number
order?: 'ascend' | 'descend' | null
column?: string
}
@@ -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<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/arc/unit/level3/page', params)
@@ -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<boolean>(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,
}
}
@@ -0,0 +1,52 @@
/**
* 单位报表 / 三级单位 - 搜索逻辑
*/
import type { SearchForm } from '../types'
const DEFAULT_YEAR = 2026
const createSearchKey = (): SearchForm => ({
parentUnit: undefined,
year: DEFAULT_YEAR,
})
const createOptions = () => ({
parentUnit: [] as { label: string; value: string }[],
year: [] as { label: string; value: number }[],
})
const UNIT_LIST = [
{ label: '采油一厂', value: '采油一厂' },
{ label: '采油二厂', value: '采油二厂' },
{ label: '采气一厂', value: '采气一厂' },
{ label: 'CQ能源总部', value: 'CQ能源总部' },
]
const YEAR_LIST = [
{ label: '2026年', value: 2026 },
{ label: '2025年', value: 2025 },
{ label: '2024年', value: 2024 },
]
export const useSearch = () => {
const search = reactive<SearchForm>(createSearchKey())
const options = reactive(createOptions())
const initOptions = (): void => {
Promise.all([Promise.resolve(UNIT_LIST), Promise.resolve(YEAR_LIST)])
.then(([unitList, yearList]) => {
options.parentUnit = unitList
options.year = yearList
})
.catch((err: unknown) => {
console.error('初始化下拉选项失败:', err)
})
}
const resetSearch = (): void => {
Object.assign(search, createSearchKey())
}
return { search, options, initOptions, resetSearch }
}
@@ -0,0 +1,119 @@
/**
* 单位报表 / 三级单位 - 表格逻辑
*
* 10 列,与旧 arc-unit.html 三级单位 tab 完全对齐
* 用餐人数 > 0 时点击下钻到员工营养数据页(按单位 + 部门筛选)
*/
import { h } from 'vue'
import { useRouter } from 'vue-router'
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: 'totalMeals',
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}%`,
)
}
export const useTable = (listRequest: () => void) => {
const router = useRouter()
/** 下钻:跳转到员工营养数据页并带入单位 + 部门筛选 */
const drillToEmployee = (unit: string, dept: string): void => {
router.push({
name: 'nutrition-arc-employee',
query: { unit, dept },
})
}
const tableColumns: TableColumnsType = [
{
title: '部门名称',
dataIndex: 'dept',
align: 'center',
fixed: 'left',
width: 140,
resizable: true,
ellipsis: true,
},
{
title: '所属二级单位',
dataIndex: 'parentUnit',
align: 'center',
width: 140,
resizable: true,
ellipsis: true,
},
{ title: '在册人数', dataIndex: 'totalHc', align: 'center', width: 100, sorter: true },
{
title: '用餐人数',
dataIndex: 'diningHc',
align: 'center',
width: 100,
customRender: ({ text, record }: { text: number; record: ListItem }) => {
if (!text || text <= 0) return text ?? '—'
return h(
'a',
{
style: { color: '#1890ff', cursor: 'pointer' },
onClick: () => drillToEmployee(record.parentUnit, record.dept),
},
text,
)
},
},
{ title: '用餐总次数', dataIndex: 'totalMeals', align: 'center', width: 110, sorter: true },
{ title: '人均年用餐数', dataIndex: 'perCapitaMeals', align: 'center', width: 120 },
{ title: '热量均值(kcal)', dataIndex: 'calorie', align: 'center', width: 130 },
{ title: '人均营养评分', dataIndex: 'score', align: 'center', width: 120, sorter: true },
{
title: '营养达标率',
dataIndex: 'rate',
align: 'center',
width: 110,
sorter: true,
customRender: ({ text }: { text: number }) => rateBadge(text),
},
{ title: '数据覆盖率', dataIndex: 'coverage', align: 'center', width: 110 },
]
const createInitState = (): TableState<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -0,0 +1,89 @@
<template>
<div class="level3-unit">
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="所属二级单位">
<a-select
v-model:value="search.parentUnit"
:options="options.parentUnit"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择单位"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="年份">
<a-select
v-model:value="search.year"
:options="options.year"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择年份"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #toolbar>
<a-button type="primary" @click="onExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="onViewExportTask">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
</TableCard>
</div>
</template>
<script setup lang="ts">
/**
* 单位报表 / 三级单位 - Tab 子组件
* - 部门级别营养汇总
* - 用餐人数列点击下钻到员工列表(按单位+部门筛选)
*/
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { usePage } from './init/usePage'
const { message } = useAntdStaticMethods()
const {
pageLoading,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
} = usePage()
const onExport = (): void => {
void message.info('导出任务已提交,可在"查看导出任务"中查看进度')
}
const onViewExportTask = (): void => {
void message.info('当前共有 3 个导出任务正在处理中(原型阶段提示)')
}
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.level3-unit {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
@@ -0,0 +1,31 @@
/**
* 单位报表 / 三级单位(部门级别) - 类型集中定义
*/
export interface ListItem {
id: string
/** 部门名称 */
dept: string
/** 所属二级单位 */
parentUnit: string
totalHc: number
diningHc: number
totalMeals: number
perCapitaMeals: number
calorie: string
score: number
rate: number
coverage: string
}
export interface SearchForm {
parentUnit?: string
year?: number
}
export interface ListParams extends SearchForm {
pageNum: number
pageSize: number
order?: 'ascend' | 'descend' | null
column?: string
}
+17
View File
@@ -44,3 +44,20 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: var(--font-base);
}
/**
* 全局操作列单行约束(强制 nowrap)
* - 凡是 fixed: 'right' 的列(通常都是"操作"列),即使按钮再多也必须单行展示
* - 同时约束 .col-actions 自定义类(业务页可在 columns 上加 className: 'col-actions' 显式声明)
*/
.ant-table-cell-fix-right,
.ant-table-cell.col-actions {
white-space: nowrap !important;
}
/* 操作列内的 link 按钮:行内对齐,避免折行 */
.ant-table-cell-fix-right .ant-btn-link,
.ant-table-cell.col-actions .ant-btn-link {
padding-left: 4px;
padding-right: 4px;
}