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:
@@ -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'
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* mock handler - 营养管理 / 算法模型
|
||||
*
|
||||
* 对应 api:src/pages/tenant-portal/nutrition/arc/arcAlgo/api/index.ts
|
||||
*
|
||||
* 提供完整 CRUD:page / info / add / update / delete
|
||||
* dataset:5 条(≤ 8 条上限)
|
||||
*/
|
||||
|
||||
import { registerMocks } from '@/axios/mockBus'
|
||||
import type { ApiResponse, MockContext } from '@/axios/types'
|
||||
|
||||
interface AlgoRow {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
organization: string
|
||||
author: string
|
||||
year: number
|
||||
uploadDate: string
|
||||
attachment: string
|
||||
}
|
||||
|
||||
const buildDataset = (): AlgoRow[] => [
|
||||
{ id: 'A001', name: '营养摄入计算模型说明书', description: '基于用户体征数据和膳食记录,计算每日营养素摄入量', organization: '营养研究院', author: '张博士', year: 2026, uploadDate: '2026-02-15', attachment: 'algo-intake-model-v2.pdf' },
|
||||
{ id: 'A002', name: '个性化推荐引擎技术文档', description: '根据员工营养档案和饮食偏好,智能推荐每餐菜品组合', organization: 'AI算法中心', author: '李工', year: 2026, uploadDate: '2026-01-20', attachment: 'recommend-engine-tech.docx' },
|
||||
{ id: 'A003', name: '慢病风险评估模型白皮书', description: '综合分析长期饮食数据与体检指标,评估慢性病风险等级', organization: '健康管理部', author: '王主任', year: 2025, uploadDate: '2025-12-01', attachment: 'risk-assessment-whitepaper.pdf' },
|
||||
{ id: 'A004', name: '食材营养成分识别算法', description: '通过图像识别和数据库匹配,自动获取食材营养成分信息', organization: 'AI算法中心', author: '赵工', year: 2025, uploadDate: '2025-10-10', attachment: 'food-recognition-algo.pdf' },
|
||||
{ id: 'A005', name: '膳食平衡评分模型规范', description: '基于中国居民膳食指南,对每日膳食结构进行综合评分', organization: '营养研究院', author: '刘博士', year: 2025, uploadDate: '2025-09-05', attachment: 'balance-score-spec-v1.pdf' },
|
||||
]
|
||||
|
||||
/** 缓存数据集 */
|
||||
let dataset: AlgoRow[] = buildDataset()
|
||||
|
||||
const filterRows = (rows: AlgoRow[], params: Record<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 }
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* mock handler - 营养管理 / 系统脏数据
|
||||
*
|
||||
* 对应 api:src/pages/tenant-portal/nutrition/ano/anoDirty/api/index.ts
|
||||
*
|
||||
* dataset 8 条(覆盖 7 个模块 + 6 种异常类型)
|
||||
*/
|
||||
|
||||
import { registerMocks } from '@/axios/mockBus'
|
||||
import type { ApiResponse, MockContext } from '@/axios/types'
|
||||
|
||||
interface DirtyRow {
|
||||
id: string
|
||||
module: 'recipe' | 'order' | 'user' | 'analysis' | 'material' | 'production' | 'supply'
|
||||
dirtyType: 'missing' | 'format' | 'logic' | 'duplicate' | 'overflow' | 'orphan'
|
||||
description: string
|
||||
detectTime: string
|
||||
tableName: string
|
||||
fieldName: string
|
||||
bizNo: string
|
||||
abnormalValue: string
|
||||
generateTime: string
|
||||
}
|
||||
|
||||
const dataset: DirtyRow[] = [
|
||||
{ id: 'D001', module: 'recipe', dirtyType: 'missing', description: '热量值字段为空,无法参与营养计算', detectTime: '2026-05-14 09:23:00', tableName: 't_recipe_nutrition', fieldName: 'calorie', bizNo: 'RCP-20260514-0087', abnormalValue: 'NULL', generateTime: '2026-05-14 08:10:00' },
|
||||
{ id: 'D002', module: 'order', dirtyType: 'format', description: '订餐日期格式非法,无法解析', detectTime: '2026-05-14 09:15:00', tableName: 't_order_meal', fieldName: 'order_date', bizNo: 'ORD-20260514-0342', abnormalValue: '2026/13/45', generateTime: '2026-05-14 07:55:00' },
|
||||
{ id: 'D003', module: 'user', dirtyType: 'logic', description: '员工年龄为负数,BMI 值超出合理范围', detectTime: '2026-05-14 08:50:00', tableName: 't_employee_health', fieldName: 'age / bmi', bizNo: 'EMP-00156', abnormalValue: 'age=-5, bmi=999', generateTime: '2026-05-13 18:30:00' },
|
||||
{ id: 'D004', module: 'analysis', dirtyType: 'duplicate', description: '同一用户同日存在 3 条重复营养分析记录', detectTime: '2026-05-13 17:30:00', tableName: 't_nutri_analysis', fieldName: 'user_id', bizNo: 'NA-20260513-0218', abnormalValue: '3 条重复', generateTime: '2026-05-13 12:00:00' },
|
||||
{ id: 'D005', module: 'material', dirtyType: 'overflow', description: '库存数量为负值,疑似出库数据异常', detectTime: '2026-05-13 16:45:00', tableName: 't_material_stock', fieldName: 'stock_qty', bizNo: 'MAT-00523', abnormalValue: '-500 kg', generateTime: '2026-05-13 15:20:00' },
|
||||
{ id: 'D006', module: 'production', dirtyType: 'format', description: '生产批次号含非法字符,无法关联溯源', detectTime: '2026-05-13 15:10:00', tableName: 't_prod_batch', fieldName: 'batch_no', bizNo: 'PRD-20260513-0091', abnormalValue: 'B@TCH#001!', generateTime: '2026-05-13 14:00:00' },
|
||||
{ id: 'D007', module: 'supply', dirtyType: 'missing', description: '供应商资质证书编号为空', detectTime: '2026-05-13 14:20:00', tableName: 't_supplier_cert', fieldName: 'cert_no', bizNo: 'SUP-00078', abnormalValue: 'NULL', generateTime: '2026-05-13 10:30:00' },
|
||||
{ id: 'D008', module: 'recipe', dirtyType: 'orphan', description: '食谱引用的食材 ID 在食材库中不存在', detectTime: '2026-05-12 17:30:00', tableName: 't_recipe_item', fieldName: 'material_id', bizNo: 'RCP-20260512-0045', abnormalValue: 'MAT-99999', generateTime: '2026-05-12 16:00:00' },
|
||||
]
|
||||
|
||||
const filterRows = (rows: DirtyRow[], params: Record<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 }
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* mock handler - 营养管理 / 结果异常
|
||||
*
|
||||
* 对应 api:src/pages/tenant-portal/nutrition/ano/anoResult/api/index.ts
|
||||
*
|
||||
* 提供:分页 / 详情(含算法输入输出 + 堆栈 + 建议)
|
||||
* dataset 8 条
|
||||
*/
|
||||
|
||||
import { registerMocks } from '@/axios/mockBus'
|
||||
import type { ApiResponse, MockContext } from '@/axios/types'
|
||||
|
||||
type ResultType = 'nutriValue' | 'emptyRecom' | 'nutriRange' | 'timeout' | 'modelOutput'
|
||||
type Scene = 'nutriCalc' | 'dishRecom' | 'smartRecom'
|
||||
type Severity = 'high' | 'middle' | 'low'
|
||||
|
||||
interface ResultRow {
|
||||
id: string
|
||||
errCode: string
|
||||
resultType: ResultType
|
||||
scene: Scene
|
||||
description: string
|
||||
severity: Severity
|
||||
algoVersion: string
|
||||
foundTime: string
|
||||
}
|
||||
|
||||
interface ResultDetail extends ResultRow {
|
||||
inputParams: string
|
||||
outputResult: string
|
||||
errorStack: string
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
const baseDataset: ResultRow[] = [
|
||||
{ id: 'R001', errCode: 'ERR-2026-001', resultType: 'nutriValue', scene: 'nutriCalc', description: '员工EMP0234午餐热量计算结果为0kcal', severity: 'high', algoVersion: 'v2.3.1', foundTime: '2026-05-14 12:30:00' },
|
||||
{ id: 'R002', errCode: 'ERR-2026-002', resultType: 'emptyRecom', scene: 'dishRecom', description: '员工EMP0456个性化推荐返回空列表', severity: 'middle', algoVersion: 'v1.8.0', foundTime: '2026-05-14 11:45:00' },
|
||||
{ id: 'R003', errCode: 'ERR-2026-003', resultType: 'nutriRange', scene: 'nutriCalc', description: '某菜品钠含量计算结果为99999mg', severity: 'high', algoVersion: 'v2.3.1', foundTime: '2026-05-14 10:20:00' },
|
||||
{ id: 'R004', errCode: 'ERR-2026-004', resultType: 'timeout', scene: 'nutriCalc', description: '批量营养分析任务执行超时(>30s)', severity: 'middle', algoVersion: 'v2.3.1', foundTime: '2026-05-13 16:40:00' },
|
||||
{ id: 'R005', errCode: 'ERR-2026-005', resultType: 'emptyRecom', scene: 'dishRecom', description: '糖尿病患者推荐食谱未返回低GI菜品', severity: 'high', algoVersion: 'v1.8.0', foundTime: '2026-05-13 14:15:00' },
|
||||
{ id: 'R006', errCode: 'ERR-2026-006', resultType: 'modelOutput', scene: 'smartRecom', description: 'LLM返回JSON格式解析失败', severity: 'middle', algoVersion: 'v3.0.2', foundTime: '2026-05-13 11:30:00' },
|
||||
{ id: 'R007', errCode: 'ERR-2026-007', resultType: 'nutriValue', scene: 'nutriCalc', description: '维生素A摄入量计算结果为负值', severity: 'high', algoVersion: 'v2.3.1', foundTime: '2026-05-12 15:50:00' },
|
||||
{ id: 'R008', errCode: 'ERR-2026-008', resultType: 'nutriRange', scene: 'nutriCalc', description: '单餐蛋白质含量超出日推荐量300%', severity: 'middle', algoVersion: 'v2.3.1', foundTime: '2026-05-12 12:10:00' },
|
||||
]
|
||||
|
||||
/** 详情 dataset:基于基础行追加 inputParams / outputResult / errorStack / suggestion */
|
||||
const detailDataset: ResultDetail[] = baseDataset.map((r) => ({
|
||||
...r,
|
||||
inputParams: JSON.stringify(
|
||||
{
|
||||
employeeNo: r.errCode.includes('001') ? 'EMP0234' : 'EMP0456',
|
||||
mealType: 'lunch',
|
||||
date: r.foundTime.slice(0, 10),
|
||||
algoVersion: r.algoVersion,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
outputResult: JSON.stringify(
|
||||
{
|
||||
calorie: r.resultType === 'nutriValue' ? 0 : -1,
|
||||
protein: r.resultType === 'nutriRange' ? 999 : 25.4,
|
||||
sodium: r.resultType === 'nutriRange' ? 99999 : 1200,
|
||||
timestamp: r.foundTime,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
errorStack:
|
||||
r.resultType === 'timeout'
|
||||
? `TimeoutError: algo execution exceeded 30000ms\n at NutritionCalculator.calc (nutrition.js:127)\n at async BatchProcessor.process (batch.js:84)\n at async Worker.run (worker.js:56)`
|
||||
: `AssertionError: result value out of expected range\n at validate (validator.js:42)\n at NutritionCalculator.postProcess (nutrition.js:198)\n at NutritionCalculator.calc (nutrition.js:135)\n at Worker.run (worker.js:56)`,
|
||||
suggestion:
|
||||
r.severity === 'high'
|
||||
? '建议立即排查算法输入数据完整性,确认体征/膳食记录是否缺失;如复现需回滚算法版本。'
|
||||
: r.resultType === 'timeout'
|
||||
? '建议拆分批处理粒度,单批次控制在 100 条以内;或升级到 v2.4 算法分支(已优化并行)。'
|
||||
: '建议补充对应字段单位校验,并在前端提示用户复核输入。',
|
||||
}))
|
||||
|
||||
const filterRows = (rows: ResultRow[], params: Record<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 }
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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 }
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -3,15 +3,17 @@
|
||||
*
|
||||
* 对应 api:src/pages/tenant-portal/nutrition/arc/arcEmployee/api/index.ts
|
||||
*
|
||||
* 业务侧调用形如:
|
||||
* postRequest('axiosRequest', '/nutrition/employee/page', params)
|
||||
* 经 mockBus 路由到本文件的 handler 返回 mock 数据。
|
||||
* 字段与页面 types ListItem 对齐:
|
||||
* - 删除了 status / createTime(业务上员工营养表不展示这两个字段)
|
||||
* - 与旧 h5 原型 arc-employee.html 完全一致
|
||||
*
|
||||
* mock dataset ≤ 8 条(原型展示规范)
|
||||
*/
|
||||
|
||||
import { registerMocks } from '@/axios/mockBus'
|
||||
import type { ApiResponse, MockContext } from '@/axios/types'
|
||||
|
||||
/** 员工营养行类型(与页面 types 中 ListItem 对齐) */
|
||||
/** 员工营养行 */
|
||||
interface EmployeeRow {
|
||||
id: string
|
||||
name: string
|
||||
@@ -27,37 +29,34 @@ interface EmployeeRow {
|
||||
fat: number
|
||||
carb: number
|
||||
rate: number
|
||||
status: 0 | 1
|
||||
createTime: string
|
||||
}
|
||||
|
||||
/** 全量数据集(≤ 8 条 - 遵循原型 mock 规范:每个列表 mock 不超过 8 条) */
|
||||
/** 全量数据集(8 条) */
|
||||
const buildDataset = (): EmployeeRow[] => [
|
||||
{ id: 'EMP0001', name: '张伟', gender: '男', age: 32, empNo: 'EMP0001', unit: 'CQ能源总部', dept: '研发部', days: 132, meals: 386, calorie: '2,156', protein: 78.3, fat: 72.1, carb: 285.4, rate: 92, status: 1, createTime: '2026-05-10 09:12:00' },
|
||||
{ id: 'EMP0002', name: '李娜', gender: '女', age: 28, empNo: 'EMP0002', unit: 'CQ能源总部', dept: '市场部', days: 128, meals: 372, calorie: '1,842', protein: 65.2, fat: 58.7, carb: 241.3, rate: 89, status: 1, createTime: '2026-05-09 08:30:00' },
|
||||
{ id: 'EMP0003', name: '王磊', gender: '男', age: 35, empNo: 'EMP0003', unit: '采油一厂', dept: '运营部', days: 125, meals: 358, calorie: '2,380', protein: 85.6, fat: 89.2, carb: 312.1, rate: 76, status: 1, createTime: '2026-05-08 17:45:00' },
|
||||
{ id: 'EMP0004', name: '赵敏', gender: '女', age: 30, empNo: 'EMP0004', unit: '采油二厂', dept: '财务部', days: 131, meals: 390, calorie: '1,720', protein: 58.4, fat: 52.3, carb: 228.6, rate: 95, status: 1, createTime: '2026-05-07 10:20:00' },
|
||||
{ id: 'EMP0005', name: '陈刚', gender: '男', age: 41, empNo: 'EMP0005', unit: 'CQ能源总部', dept: '人事部', days: 120, meals: 345, calorie: '2,050', protein: 72.1, fat: 68.4, carb: 270.2, rate: 78, status: 1, createTime: '2026-05-06 14:00:00' },
|
||||
{ id: 'EMP0006', name: '刘洋', gender: '男', age: 26, empNo: 'EMP0006', unit: '炼化分公司', dept: '生产部', days: 110, meals: 318, calorie: '2,210', protein: 80.5, fat: 74.6, carb: 295.0, rate: 85, status: 1, createTime: '2026-05-05 16:30:00' },
|
||||
{ id: 'EMP0007', name: '杨芳', gender: '女', age: 33, empNo: 'EMP0007', unit: '采油一厂', dept: '安全环保部', days: 130, meals: 380, calorie: '1,950', protein: 70.2, fat: 63.5, carb: 258.3, rate: 91, status: 1, createTime: '2026-05-04 09:00:00' },
|
||||
{ id: 'EMP0010', name: '吴军', gender: '男', age: 45, empNo: 'EMP0010', unit: '采油二厂', dept: '运营部', days: 122, meals: 352, calorie: '2,180', protein: 76.8, fat: 70.3, carb: 280.5, rate: 81, status: 1, createTime: '2026-05-01 13:25:00' },
|
||||
{ id: 'EMP0001', name: '张伟', gender: '男', age: 32, empNo: 'EMP0001', unit: 'CQ能源总部', dept: '研发部', days: 132, meals: 386, calorie: '2,156', protein: 78.3, fat: 72.1, carb: 285.4, rate: 92 },
|
||||
{ id: 'EMP0002', name: '李娜', gender: '女', age: 28, empNo: 'EMP0002', unit: 'CQ能源总部', dept: '市场部', days: 128, meals: 372, calorie: '1,842', protein: 65.2, fat: 58.7, carb: 241.3, rate: 89 },
|
||||
{ id: 'EMP0003', name: '王磊', gender: '男', age: 35, empNo: 'EMP0003', unit: '采油一厂', dept: '运营部', days: 125, meals: 358, calorie: '2,380', protein: 85.6, fat: 89.2, carb: 312.1, rate: 76 },
|
||||
{ id: 'EMP0004', name: '赵敏', gender: '女', age: 30, empNo: 'EMP0004', unit: '采油二厂', dept: '财务部', days: 131, meals: 390, calorie: '1,720', protein: 58.4, fat: 52.3, carb: 228.6, rate: 95 },
|
||||
{ id: 'EMP0005', name: '陈刚', gender: '男', age: 41, empNo: 'EMP0005', unit: 'CQ能源总部', dept: '人事部', days: 120, meals: 345, calorie: '2,050', protein: 72.1, fat: 68.4, carb: 270.2, rate: 78 },
|
||||
{ id: 'EMP0006', name: '刘洋', gender: '男', age: 26, empNo: 'EMP0006', unit: '炼化分公司', dept: '生产部', days: 110, meals: 318, calorie: '2,210', protein: 80.5, fat: 74.6, carb: 295.0, rate: 85 },
|
||||
{ id: 'EMP0007', name: '杨芳', gender: '女', age: 33, empNo: 'EMP0007', unit: '采油一厂', dept: '安全环保部', days: 130, meals: 380, calorie: '1,950', protein: 70.2, fat: 63.5, carb: 258.3, rate: 91 },
|
||||
{ id: 'EMP0010', name: '吴军', gender: '男', age: 45, empNo: 'EMP0010', unit: '采油二厂', dept: '运营部', days: 122, meals: 352, calorie: '2,180', protein: 76.8, fat: 70.3, carb: 280.5, rate: 81 },
|
||||
]
|
||||
|
||||
/** 缓存数据集(mock handler 内部使用,使用 let 允许 add/delete 修改) */
|
||||
let dataset: EmployeeRow[] = buildDataset()
|
||||
/** 缓存数据集 */
|
||||
const dataset: EmployeeRow[] = buildDataset()
|
||||
|
||||
/** 按搜索条件过滤 */
|
||||
const filterRows = (rows: EmployeeRow[], params: Record<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 - 营养管理 / 员工用餐记录详情
|
||||
*
|
||||
* 对应 api:src/pages/tenant-portal/nutrition/arc/arcEmployeeDetail/api/index.ts
|
||||
*
|
||||
* 提供:
|
||||
* - /nutrition/employee/detail/info 员工基本信息
|
||||
* - /nutrition/employee/detail/page 用餐记录分页(mock dataset ≤ 8 条)
|
||||
*/
|
||||
|
||||
import { registerMocks } from '@/axios/mockBus'
|
||||
import type { ApiResponse, MockContext } from '@/axios/types'
|
||||
|
||||
/** 员工基本信息行(与页面 types EmployeeInfo 对齐) */
|
||||
interface EmployeeInfoRow {
|
||||
empNo: string
|
||||
name: string
|
||||
gender: '男' | '女'
|
||||
age: number
|
||||
unit: string
|
||||
dept: string
|
||||
year: number
|
||||
totalMeals: number
|
||||
rate: number
|
||||
}
|
||||
|
||||
/** 用餐记录行(与页面 types ListItem 对齐) */
|
||||
interface MealRow {
|
||||
id: string
|
||||
empNo: string
|
||||
date: string
|
||||
meal: 'breakfast' | 'lunch' | 'dinner'
|
||||
dineType: '堂食' | '外带'
|
||||
dishes: string
|
||||
calorie: number
|
||||
protein: number
|
||||
fat: number
|
||||
carb: number
|
||||
rateStatus: 'pass' | 'high' | 'over'
|
||||
}
|
||||
|
||||
/** 员工信息字典(按工号取) */
|
||||
const EMPLOYEE_MAP: Record<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 }
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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
@@ -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 })
|
||||
+3
-3
@@ -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>
|
||||
+9
-10
@@ -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>
|
||||
+143
@@ -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)
|
||||
+168
@@ -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,
|
||||
}
|
||||
}
|
||||
+36
@@ -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>
|
||||
+10
@@ -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)
|
||||
+98
@@ -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>
|
||||
+73
@@ -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,
|
||||
}
|
||||
}
|
||||
+69
@@ -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 }
|
||||
}
|
||||
+91
@@ -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 }
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
+10
@@ -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)
|
||||
+100
@@ -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>
|
||||
+73
@@ -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,
|
||||
}
|
||||
}
|
||||
+69
@@ -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 }
|
||||
}
|
||||
+138
@@ -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 }
|
||||
}
|
||||
+38
@@ -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>
|
||||
-30
@@ -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
|
||||
}
|
||||
+13
@@ -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)
|
||||
+181
@@ -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>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 员工用餐记录 - 页面主逻辑(子组件型)
|
||||
*
|
||||
* 业务定位:arcEmployee 的二级详情视图
|
||||
* - 父组件传入 empNo(props.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,
|
||||
}
|
||||
}
|
||||
+42
@@ -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 }
|
||||
}
|
||||
+108
@@ -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 }
|
||||
}
|
||||
+56
@@ -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)
|
||||
+74
@@ -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,
|
||||
}
|
||||
}
|
||||
+54
@@ -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 }
|
||||
}
|
||||
+115
@@ -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 }
|
||||
}
|
||||
+94
@@ -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>
|
||||
+41
@@ -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)
|
||||
+73
@@ -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,
|
||||
}
|
||||
}
|
||||
+52
@@ -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 }
|
||||
}
|
||||
+119
@@ -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 }
|
||||
}
|
||||
+89
@@ -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>
|
||||
+31
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user