完善智能餐养管理系统功能模块
按全链路溯源主线(绿色种养→卫生供应→健康生产→营养用餐)完善管理后台所有业务模块, 新增字典配置、溯源总览、营养方案、指标/慢病预警、餐品效益等功能, 拆分多个页面为 Tab 结构(采购管理、卫生检测、毛菜管理、净菜加工、生产统计、营养点餐、 餐材效益、营养管理等),补充硬件数据接入字段(传感器状态、效期预警、AI视频分析等)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
# 智能餐养管理系统 — 功能完善规划文档(v5)
|
||||
|
||||
## 核心理念:全链路溯源
|
||||
|
||||
```
|
||||
农田 ──→ 检测 ──→ 加工 ──→ 厨房 ──→ 餐桌
|
||||
绿色种养 卫生供应 卫生供应 健康生产 营养用餐
|
||||
(种从哪来) (检了什么) (怎么加工) (怎么做的) (谁吃了什么)
|
||||
```
|
||||
|
||||
以"溯源"为灵魂,贯穿绿色种养→卫生供应→健康生产→营养用餐四大阶段,形成从农田到餐桌的完整数据闭环。每条数据都可沿此链路正向追踪或反向溯源。
|
||||
|
||||
---
|
||||
|
||||
## 一、项目现状
|
||||
|
||||
- 技术栈:Vue 3 + TypeScript + Ant Design Vue + Vite
|
||||
- 已实现页面:8个(Dashboard、登录、土地环境、食安监控、食谱管理、用户管理、组织机构、业务流程图)
|
||||
- 占位页面:34个(仅有框架,无实质内容)
|
||||
- API:仅覆盖 4 个业务模块,大部分缺失
|
||||
- Mock 数据:有基础框架,需按模块扩展
|
||||
|
||||
---
|
||||
|
||||
## 二、数据采集架构
|
||||
|
||||
本系统数据来源分为三类,页面设计需区别对待:
|
||||
|
||||
| 采集方式 | 说明 | 页面设计要点 |
|
||||
|---|---|---|
|
||||
| 硬件自动采集 | 物联网传感器、智能秤、气体传感器、环境传感器等设备自动上报 | 数据只读展示 + 设备状态监控 + 异常告警 + 数据刷新机制 |
|
||||
| 专业设备检测 | 营养质谱仪、电子鼻/舌、质构仪、OCR识别等 | 设备直连/报告上传两种模式 + 检测结果结构化解析 |
|
||||
| 人工录入/系统对接 | 人工补录、小程序填报、体检系统API对接 | 表单录入 + 批量导入 + 外部系统数据同步 |
|
||||
|
||||
---
|
||||
|
||||
## 三、三级菜单结构与数据项
|
||||
|
||||
### 一级:数据概览
|
||||
|
||||
| 二级 | 三级页面 | 核心展示数据项 |
|
||||
|---|---|---|
|
||||
| — | 运营大屏 | KPI卡片、食堂运营数据、供应链状态、订单趋势、告警 |
|
||||
| — | 溯源总览 | 全链路溯源可视化(输入批次号→展示从种养到用餐的完整链路) |
|
||||
| — | 业务流程图 | 四大阶段流程关系图、模块关联矩阵 |
|
||||
|
||||
---
|
||||
|
||||
### 一级:绿色种养
|
||||
|
||||
说明:土地环境、种养监控、收成入库直接作为二级菜单,不再归入"种养基地"三级分组。
|
||||
|
||||
| 二级 | 三级页面 | 核心展示数据项 | 数据来源 |
|
||||
|---|---|---|---|
|
||||
| 土地环境 | — | 地块编号、面积、土壤pH、水质指标、温湿度、传感器在线状态 | 物联网传感器 |
|
||||
| 种养监控 | — | 批次号、地块、品种、播种日期、生长周期、当前状态、预计收成日期 | 传感器+人工 |
|
||||
| 收成入库 | — | 批次号、品种、收成时间、入库重量(kg)、质量等级(优/良/一般/不合格)、入库人、库位 | 智能秤+人工 |
|
||||
| 供商管理 | 自有供应商 | 名称、类型(种养公司/净菜公司/餐饮公司)、资质证照、有效期、诚信评分、交易总额 | 人工录入 |
|
||||
| | 外部供应商 | 名称、资质证照、有效期、合作起始日、交易总额、最近交易、诚信评分 | 人工录入 |
|
||||
| 采购管理 | 采购订单 | 订单号、供应商、品种、数量、单价、金额、下单时间、状态(待确认/已发货/已到货) | 系统生成+API对接 |
|
||||
| | 到货验收 | 订单号、品种、实收重量、差异量、验收人、验收时间、是否合格 | 智能秤+人工确认 |
|
||||
|
||||
---
|
||||
|
||||
### 一级:卫生供应
|
||||
|
||||
说明:净菜入库、净菜内供、净菜外售直接作为二级菜单,不再归入"净菜流通"三级分组。
|
||||
|
||||
| 二级 | 三级页面 | 核心展示数据项 | 数据来源 |
|
||||
|---|---|---|---|
|
||||
| 卫生检测 | 检测记录 | 批次号、品种、农残值、重金属值、化肥残留值、检测员、检测时间、结果(合格/不合格) | 设备直连 |
|
||||
| | 检测报告 | 报告编号、关联批次、检测项明细、报告文件、上传方式(设备直连/OCR识别)、上传时间 | 设备/OCR |
|
||||
| 毛菜管理 | 毛菜入库 | 批次号、品种、毛菜/净菜类型、称重重量(kg)、入库时间、关联检测报告、入库人 | 智能秤自动称重 |
|
||||
| | 毛菜台账 | 品种、当前库存、入库总量、出库总量、最近入库时间、库龄 | 系统汇总 |
|
||||
| 净菜加工 | 加工记录 | 加工批次、品种、毛菜重量(kg)、净菜重量(kg)、损耗率(%)、加工起止时间、操作员 | 双秤自动计算 |
|
||||
| | 损耗统计 | 品种、平均损耗率、最高/最低损耗率、趋势图、异常标记 | 系统汇总 |
|
||||
| 净菜入库 | — | 批次号、品种、净菜重量(kg)、入库时间、来源(自加工/外采)、库位 | 智能秤 |
|
||||
| 净菜内供 | — | 出库单号、品种、数量(kg)、目标食堂、出库时间、领用人 | 系统记录 |
|
||||
| 净菜外售 | — | 销售单号、客户、品种、数量(kg)、单价、金额、出库时间 | 系统+人工 |
|
||||
|
||||
---
|
||||
|
||||
### 一级:健康生产
|
||||
|
||||
说明:健康生产模块采用扁平化结构,核心生产流程节点直接作为二级菜单;食安监控为二级菜单,其下设三级页面。
|
||||
|
||||
| 二级菜单 | 三级页面 | 核心展示数据项 | 数据来源 |
|
||||
|---|---|---|---|
|
||||
| 食安监控 | 食安视频 | 摄像头编号、位置、在线状态、违规事件(未戴帽/未戴口罩)、违规截图、时间、处理状态 | AI视频分析 |
|
||||
| | 环境安全 | 天然气浓度(ppm)、报警状态、消防设施状态、各区域温度(℃)、湿度(%)、温湿度趋势图、超阈值告警、最近检查时间、告警历史 | 气体/消防/环境温湿度传感器 |
|
||||
| | 餐品留样 | 留样菜品名、留样量(g)、留样时间、到期时间(48h)、留样柜编号、留样状态(留样中/已到期/已销毁)、留样照片 | 人工录入 |
|
||||
| 食谱规划 | — | 日期、餐次(早/中/晚)、菜品列表、营养评分、反算原料需求(可选净菜需求或毛菜需求,毛菜需求=净菜需求÷(1-损耗率))、采购订单状态 | 人工编排+系统计算 |
|
||||
| 净菜订单 | — | 订单号、来源食谱、品种、需求量(kg)、供应商、状态、到货时间 | 系统自动生成 |
|
||||
| 净菜进柜 | — | 柜号、品种、订单量(kg)、实收量(kg)、差异量、校验结果、当前库存 | 智能秤校验 |
|
||||
| 净菜组配 | — | 食谱菜品、所需食材清单、配比量(g)、实际领用量(g)、组配完成状态 | 系统+人工确认 |
|
||||
| 按单烹制 | — | 菜品名、生重(kg)、熟重(kg)、生熟比、调料时间轴(盐/油/花椒时刻)、工艺动作(翻炒/颠勺)、烹饪时长、厨师;开餐/加餐自动化流转(上架记录"开餐"时间,加餐需求量基于消耗量自动触发);零售后效期自动预警(临期倒计时、过期自动锁定下架) | 智能秤+AI视频 |
|
||||
| 上餐监控 | — | 菜品名、上餐时间、初始温度(℃)、当前温度、温度衰减曲线、上餐位置、剩余量(kg)、出品时间、保质时限、剩余有效期、过期预警状态 | 温度传感器 |
|
||||
| 余量处置 | — | 菜品名、剩余量(kg)、处置方式(打折销售/回收/废弃)、打折比例、打折销售量、处置时间、处置人 | 智能秤+人工 |
|
||||
| 生产统计 | 生产报表 | 日期范围、总产量、总损耗率、菜品产量排行、厨师效率排行、异常事件数、取餐结构分析(主食/荤菜/素菜/汤品分布饼图、各品类消耗趋势) | 系统汇总 |
|
||||
| | 成本利润 | 原材料成本(系统自动计算:采购单价×用量,沿溯源链路自动关联)、人工成本(人工录入:厨师工时×时薪)、水电气成本(人工录入/抄表)、总成本、营收(来自消费管理)、毛利润、毛利率(%)、按日/周/月维度、趋势图、成本结构饼图 | 原材料=自动计算;人工/水电气=人工录入;营收=系统汇总 |
|
||||
|
||||
> **原材料成本自动计算逻辑:** `原材料成本 = Σ(食材采购单价 × 实际使用量)`,采购单价自动关联「采购订单」,实际使用量自动关联「净菜组配」领用记录 + 「加工记录」损耗。
|
||||
|
||||
---
|
||||
|
||||
### 一级:营养用餐
|
||||
|
||||
说明:营养用餐采用扁平化结构,大部分功能直接作为二级菜单;仅"营养点餐"下设三级(个人点餐/团体点餐)。
|
||||
|
||||
| 二级 | 三级页面 | 核心展示数据项 | 数据来源 |
|
||||
|---|---|---|---|
|
||||
| 营养检验 | — | 菜品名、油(g)、盐(g)、糖(g)、蛋白质(g)、碳水(g)、脂肪(g)、矿物质(mg)、维生素(mg)、检测设备、检测时间 | 营养质谱仪等专业设备 |
|
||||
| 食物成分 | — | 食材名、每100g:热量(kcal)、蛋白质、脂肪、碳水、膳食纤维、维生素、矿物质、数据来源(国标/检测) | 国标导入+检测补充 |
|
||||
| 餐品总库 | — | 菜品名、主材(≤12)、辅材(≤4)、配比、工艺(炒/炖/煮/烤)、菜系、荤素、冷热、人群标签(低脂/高钙/低钠)、图片;支持批量导入(Excel/CSV模板:下载模板→填写→上传→校验反馈) | 人工录入+批量导入 |
|
||||
| 算法标准 | 算法标准 | 人群(年龄/性别/孕期/老年)、每日推荐:热量(kcal)、蛋白质(g)、脂肪(g)、碳水(g)、维生素矿物质、来源(膳食指南版本)、动态算法接口配置;食物交换份标准化:每份90大卡为1份,系统自动将菜品拆解为主食份数/菜品份数/饮品份数/水果份数,界面显示每份热量占比 | 国标导入+API配置 |
|
||||
| | 营养方案 | 两类方案并行:(1) 个性化方案——起始模板按人群(孕妇/少儿/老人/儿童/普通成人),基于BMI模型+体质健康问卷综合评估自动生成,支持用户人工干预+系统自动推荐结合,无营养师场景下系统可独立输出;(2) 大众方案——面向工厂/院校团体(含正餐/加班餐/夜宵),院校权限可按学段折算至营养方案 | 算法生成+人工调整 |
|
||||
| 食谱管理 | — | (已实现) 补充营养均衡校验、与餐品总库联动 | 人工+系统校验 |
|
||||
| 营养点餐 | 个人点餐 | 用户、个人营养画像、推荐菜品列表、营养匹配度(%)、禁忌过滤标记、已选菜品、本餐营养预估;分级管控:根据营养方案设定每餐各品类推荐份数,用户可手动调整(系统提示偏离度),界面显示每份热量占比、人群推荐占比对比 | 算法推荐+用户画像 |
|
||||
| | 团体点餐 | 团体名称(部门/班级)、人数、人群特征分布、团体营养方案、菜品组合推荐、份量自动计算、预算控制、食物交换份展示、按团体人群结构自动分配各品类份数 | 算法推荐+团体画像 |
|
||||
| | 熟餐订单 | (小程序端下单) 订单号、用户、餐次(早/中/晚)、菜品明细(成品菜)、份数、金额、下单时间、配送/自取方式、订单状态(待支付/已支付/制作中/待取餐/已完成/已取消)、取餐时间 | 小程序下单 |
|
||||
| | 净菜订单 | (小程序端下单) 订单号、用户、净菜品种、规格(kg)、数量、金额、下单时间、配送/自取方式、订单状态(待支付/已支付/备货中/待取货/已完成/已取消)、取货时间 | 小程序下单 |
|
||||
| 用户管理 | — | (已实现) 扩展五维信息:(1) 静态基础(年龄/身高/体重/活动水平);(2) 健康档案(疾病史/体检报告);(3) 口味偏好(酸甜苦辣/地域菜系);(4) 禁忌关联(糖尿病禁高糖等);(5) 动态生理数据(运动量/体重趋势/体脂率);基于五维信息自动生成个人推荐食谱(综合营养需求、口味偏好、禁忌过滤、热量目标),支持手动调整后确认 | 体脂秤/智能手表+小程序填报+体检系统API+算法生成 |
|
||||
| 餐线管理 | — | 秤编号(CH00025)、当前菜品、更换序列(早→午→晚)、当前存量(kg)、补餐状态(正常/需补餐/禁加餐)、各时段取餐量;补餐阈值(存量<1/5);禁加餐时间(可配置,如13:30后);加餐提示(存量低于阈值自动推送提醒);实时取餐数据(当前取餐人数/取餐速率);餐线监控视频(关联摄像头实时画面、回放) | 智能秤上报+摄像头 |
|
||||
| 取餐管理 | — | 用户、识别方式(人脸/RFID)、菜品、取餐重量(g)、取餐时间、秤编号、异常标记;AI取餐数据分析:自动统计每人取餐结构(主食/荤菜/素菜/汤品占比),饼图展示 | 人脸+RFID设备 |
|
||||
| 消费管理 | — | 用户、日期、餐次、菜品明细、金额、支付方式、月度统计 | 系统记录 |
|
||||
| 营养管理 | 营养管理 | 用户、日期范围、每日摄入(热量/蛋白质/脂肪/碳水) vs 推荐量对比、健康趋势图、异常提醒 | 系统汇总 |
|
||||
| | 指标预警 | 用户、预警指标(BMI/体脂率/血糖/血压等)、当前值、正常范围、偏离度、预警等级(关注/警告/严重)、触发时间、处理状态、建议措施 | 设备采集+体检数据+系统计算 |
|
||||
| | 慢病预警 | 用户、慢病类型(糖尿病/高血压/高血脂/痛风等)、风险评分、关联指标趋势、饮食风险项(如高糖/高钠摄入频次)、预警等级、干预建议(推荐食谱调整)、跟踪记录 | 系统算法评估+健康档案 |
|
||||
| 餐材效益 | — | 食材名、采购成本、使用量、损耗量、产出菜品数、单位成本、效益评分 | 系统汇总 |
|
||||
| 终端管理 | — | 秤编号、在线状态、故障代码、心跳周期、启用时长、最近上报时间 | 设备固件上报 |
|
||||
| 模型管理 | — | 模型名、级别(大/专用/小)、版本、训练参数、API接口、准确率 | 人工配置+系统采集 |
|
||||
| 风味创新 | — | 新菜品名、研发人、配方、电子鼻/电子舌评分、试吃评价、状态(研发中/试吃/上线/下线) | 设备检测+人工 |
|
||||
|
||||
---
|
||||
|
||||
### 一级:系统管理
|
||||
|
||||
| 二级 | 核心展示数据项 | 数据来源 |
|
||||
|---|---|---|
|
||||
| 组织机构 | (已实现) 树形结构、部门详情 | 人工录入 |
|
||||
| 员工管理 | 姓名、工号、部门、岗位、联系方式、状态 | 人工录入 |
|
||||
| 角色管理 | 角色名、权限范围、关联员工数 | 人工配置 |
|
||||
| 食堂管理 | 食堂名、地址、容纳人数、餐线数、运营状态 | 人工录入 |
|
||||
| 字典配置 | 字典类型(质量等级/处置方式/加工工艺/菜系/人群标签等)、字典编码、字典名称、排序、状态(启用/停用)、备注 | 人工配置 |
|
||||
|
||||
---
|
||||
|
||||
## 四、硬件数据接入页面设计规范
|
||||
|
||||
所有涉及硬件采集的页面统一增加以下设计元素:
|
||||
|
||||
| 设计元素 | 说明 |
|
||||
|---|---|
|
||||
| 设备状态面板 | 展示关联设备的在线/离线/故障状态 |
|
||||
| 数据同步指示 | 显示最近一次数据同步时间,标记"实时"或"延迟" |
|
||||
| 异常告警区域 | 设备离线、数据中断、数值超阈值等异常自动高亮告警 |
|
||||
| 数据只读标记 | 硬件自动采集字段标记为只读,与人工可编辑字段区分 |
|
||||
| 手动补录入口 | 设备故障时提供人工补录通道,补录数据标记来源为"手动" |
|
||||
| 数据来源标签 | 每条数据标注来源(自动采集/设备检测/人工录入/系统计算) |
|
||||
|
||||
---
|
||||
|
||||
## 五、三大业务闭环
|
||||
|
||||
### 闭环1:食谱驱动反向采购
|
||||
|
||||
```
|
||||
食谱规划(周/日) → 系统自动反算原料需求(净菜需求 或 毛菜需求,毛菜=净菜÷(1-损耗率))
|
||||
→ 生成采购订单 → 推送加工端 → 净菜进柜(智能秤校验订单量 vs 实收量)
|
||||
→ 厨房净菜柜库存动态表 → "送多少、用多少、产多少"数据一致
|
||||
```
|
||||
|
||||
涉及页面:食谱规划、净菜订单、净菜进柜、净菜组配
|
||||
|
||||
### 闭环2:按单烹饪与熟重计量
|
||||
|
||||
```
|
||||
生重(智能秤自动称) → 调料时间点(传感器采集) → 工艺动作(AI视频解析)
|
||||
→ 熟重(智能秤自动称) → 上餐时间(系统记录) → 温度衰减(温度传感器)
|
||||
→ 开餐/加餐自动流转 → 效期预警
|
||||
```
|
||||
|
||||
涉及页面:按单烹制、上餐监控
|
||||
|
||||
### 闭环3:餐线智能调度与取餐治理
|
||||
|
||||
```
|
||||
秤编号绑定菜品序列(设备自动上报) → 各时段取餐量(智能秤实时统计)
|
||||
→ 补餐阈值触发(存量<1/5, 系统自动判断) → 时间过滤(可配置禁加餐时间,如13:30后)
|
||||
→ 人脸+RFID双模取餐识别(硬件自动采集) → AI取餐结构分析(饼图)
|
||||
```
|
||||
|
||||
涉及页面:餐线管理、取餐管理、消费管理
|
||||
|
||||
---
|
||||
|
||||
## 六、实施顺序(8步)
|
||||
|
||||
| 步骤 | 模块 | 页面数 | 具体内容 |
|
||||
|---|---|---|---|
|
||||
| Step 1 | 系统管理 | 2新建+2已有 | 员工管理、角色管理、食堂管理、字典配置(基础数据先行,后续模块依赖字典) |
|
||||
| Step 2 | 绿色种养 | 4新建+1补充 | 种养监控、收成入库、自有/外部供应商、采购订单、到货验收、土地环境补充 |
|
||||
| Step 3 | 卫生供应 | 9新建 | 检测记录、检测报告、毛菜入库、毛菜台账、加工记录、损耗统计、净菜入库、净菜内供、净菜外售 |
|
||||
| Step 4 | 健康生产-食安监控 | 3新建 | 食安视频、环境安全、餐品留样 |
|
||||
| Step 5 | 健康生产-生产流程+闭环1&2 | 8新建+2三级 | 食谱规划、净菜订单、净菜进柜、净菜组配、按单烹制、上餐监控、余量处置、生产报表、成本利润 |
|
||||
| Step 6 | 营养用餐-数据体系 | 6新建+1补充 | 营养检验、食物成分、餐品总库(含导入)、算法标准、营养方案、食谱管理补充、风味创新 |
|
||||
| Step 7 | 营养用餐-用餐服务+闭环3 | 12新建+1扩展 | 个人/团体点餐、熟餐/净菜订单、用户管理扩展、餐线管理、取餐管理、消费管理、营养管理、指标预警、慢病预警、餐材效益、终端管理、模型管理 |
|
||||
| Step 8 | 数据概览 | 1新建+2补充 | 溯源总览(新建)、运营大屏升级、业务流程图升级 |
|
||||
|
||||
执行逻辑:Step 1 字典配置是所有模块的基础依赖 → Step 2-3 按溯源链路从上游开始 → Step 4-5 健康生产拆两步 → Step 6-7 营养用餐拆两步 → Step 8 数据概览最后做(需所有模块数据支撑)。
|
||||
|
||||
每步内部按「路由+菜单 → Mock数据 → API定义 → 页面实现」顺序逐页推进。
|
||||
|
||||
---
|
||||
|
||||
## 七、每个页面的标准实施内容
|
||||
|
||||
1. 顶部统计卡片(关键指标)
|
||||
2. 筛选工具栏(搜索、时间范围、状态筛选)
|
||||
3. 数据表格(分页、排序、导出)
|
||||
4. 新增/编辑弹窗或抽屉(含表单校验)
|
||||
5. 详情查看(抽屉或子页面)
|
||||
6. Mock 数据扩展(覆盖该页面所有字段)
|
||||
7. API 接口定义(CRUD + 统计接口)
|
||||
@@ -31,6 +31,20 @@ export const userMgmtApi = {
|
||||
remove: (id: string) => request.delete(`/nutrition-dining/users/${id}`),
|
||||
}
|
||||
|
||||
// 系统管理 - 字典配置
|
||||
export const dictApi = {
|
||||
// 字典类型
|
||||
typeList: (params?: any) => request.get('/system/dict-type', { params }),
|
||||
typeCreate: (data: any) => request.post('/system/dict-type', data),
|
||||
typeUpdate: (id: string, data: any) => request.put(`/system/dict-type/${id}`, data),
|
||||
typeRemove: (id: string) => request.delete(`/system/dict-type/${id}`),
|
||||
// 字典项
|
||||
itemList: (typeCode: string, params?: any) => request.get(`/system/dict-item/${typeCode}`, { params }),
|
||||
itemCreate: (data: any) => request.post('/system/dict-item', data),
|
||||
itemUpdate: (id: string, data: any) => request.put(`/system/dict-item/${id}`, data),
|
||||
itemRemove: (id: string) => request.delete(`/system/dict-item/${id}`),
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const authApi = {
|
||||
login: (data: { username: string; password: string }) => request.post('/auth/login', data),
|
||||
|
||||
Vendored
+1
-1
@@ -26,7 +26,6 @@ declare module 'vue' {
|
||||
AForm: typeof import('ant-design-vue/es')['Form']
|
||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputGroup: typeof import('ant-design-vue/es')['InputGroup']
|
||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
|
||||
@@ -65,6 +64,7 @@ declare module 'vue' {
|
||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
ATree: typeof import('ant-design-vue/es')['Tree']
|
||||
AUpload: typeof import('ant-design-vue/es')['Upload']
|
||||
ChartPanel: typeof import('./components/common/ChartPanel.vue')['default']
|
||||
RelatedModules: typeof import('./components/common/RelatedModules.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
mode="inline"
|
||||
>
|
||||
<a-menu-item key="dashboard">
|
||||
<span>数据概览</span>
|
||||
<span>运营大屏</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item key="trace-overview">
|
||||
<span>溯源总览</span>
|
||||
</a-menu-item>
|
||||
|
||||
<a-menu-item key="business-flow">
|
||||
@@ -27,7 +31,7 @@
|
||||
<a-menu-item key="planting-monitor">种养监控</a-menu-item>
|
||||
<a-menu-item key="harvest-storage">收成入库</a-menu-item>
|
||||
<a-menu-item key="supplier-mgmt">供商管理</a-menu-item>
|
||||
<a-menu-item key="procurement-mgmt">采供管理</a-menu-item>
|
||||
<a-menu-item key="procurement-mgmt">采购管理</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<a-sub-menu key="hygiene-supply">
|
||||
@@ -35,7 +39,7 @@
|
||||
<span>卫生供应</span>
|
||||
</template>
|
||||
<a-menu-item key="hygiene-test">卫生检测</a-menu-item>
|
||||
<a-menu-item key="raw-storage">毛菜入库</a-menu-item>
|
||||
<a-menu-item key="raw-storage">毛菜管理</a-menu-item>
|
||||
<a-menu-item key="clean-process">净菜加工</a-menu-item>
|
||||
<a-menu-item key="clean-storage">净菜入库</a-menu-item>
|
||||
<a-menu-item key="clean-internal">净菜内供</a-menu-item>
|
||||
@@ -72,7 +76,7 @@
|
||||
<a-menu-item key="pickup-mgmt">取餐管理</a-menu-item>
|
||||
<a-menu-item key="consumption-mgmt">消费管理</a-menu-item>
|
||||
<a-menu-item key="nutrition-mgmt">营养管理</a-menu-item>
|
||||
<a-menu-item key="ingredient-benefit">食材效益</a-menu-item>
|
||||
<a-menu-item key="ingredient-benefit">餐材效益</a-menu-item>
|
||||
<a-menu-item key="terminal-mgmt">终端管理</a-menu-item>
|
||||
<a-menu-item key="model-mgmt">模型管理</a-menu-item>
|
||||
<a-menu-item key="flavor-innovation">味养创新</a-menu-item>
|
||||
@@ -86,6 +90,7 @@
|
||||
<a-menu-item key="employee-mgmt">员工管理</a-menu-item>
|
||||
<a-menu-item key="role-mgmt">角色管理</a-menu-item>
|
||||
<a-menu-item key="canteen-mgmt">食堂管理</a-menu-item>
|
||||
<a-menu-item key="dict-config">字典配置</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
@@ -42,6 +42,7 @@ const materialNames = ['有机大米', '土鸡蛋', '新鲜猪肉', '活鱼', '
|
||||
export function genLandEnvData(n = 12) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: randomId(),
|
||||
code: 'LD' + String(i + 1).padStart(3, '0'),
|
||||
name: pick(landNames),
|
||||
area: randomFloat(5, 200, 0) + '亩',
|
||||
soilType: pick(soilTypes),
|
||||
@@ -52,6 +53,8 @@ export function genLandEnvData(n = 12) {
|
||||
status: pick(['normal', 'warning', 'error']),
|
||||
lastCheck: randomDate(7),
|
||||
crop: pick(cropTypes),
|
||||
sensorStatus: pick(['online', 'online', 'online', 'offline', 'fault']),
|
||||
lastSync: randomDate(1) + ' ' + randomTime(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -74,6 +77,7 @@ export function genPlantingMonitorData(n = 10) {
|
||||
}
|
||||
|
||||
export function genHarvestData(n = 15) {
|
||||
const qualityLevels = ['优等', '良好', '一般', '不合格']
|
||||
return Array.from({ length: n }, () => ({
|
||||
id: randomId(),
|
||||
batchNo: 'HV' + randomDate(30).replace(/-/g, '') + randomNum(100, 999),
|
||||
@@ -81,7 +85,7 @@ export function genHarvestData(n = 15) {
|
||||
land: pick(landNames),
|
||||
harvestDate: randomDate(30),
|
||||
quantity: randomFloat(100, 5000, 0) + 'kg',
|
||||
quality: pick(['A', 'B', 'C']),
|
||||
quality: pick(qualityLevels),
|
||||
status: pick(['stored', 'pending', 'processing']),
|
||||
inspector: pick(['张三', '李四', '王五']),
|
||||
warehouse: pick(['1号仓', '2号仓', '3号冷库']),
|
||||
@@ -121,7 +125,7 @@ export function genProcurementData(n = 12) {
|
||||
}
|
||||
|
||||
// ==================== 卫生供应 ====================
|
||||
const testItems = ['农药残留', '重金属', '微生物', '添加剂', '兽药残留']
|
||||
const testItems = ['农药残留', '重金属', '化肥残留']
|
||||
const vegetableNames = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜', '土豆', '胡萝卜', '豆角', '西兰花']
|
||||
const meatNames = ['猪里脊', '鸡胸肉', '牛腱子', '羊腿肉', '鱼片', '虾仁']
|
||||
|
||||
@@ -343,7 +347,7 @@ export function genSurplusData(n = 8) {
|
||||
mealType: pick(mealTypes),
|
||||
dish: pick(dishNames),
|
||||
surplusQty: randomFloat(1, 30, 0) + 'kg',
|
||||
disposal: pick(['员工餐', '捐赠', '堆肥', '废弃']),
|
||||
disposal: pick(['打折销售', '员工餐', '捐赠', '堆肥', '废弃']),
|
||||
handler: pick(['处置员A', '处置员B']),
|
||||
remark: '',
|
||||
}))
|
||||
@@ -498,6 +502,83 @@ export function genMealLineData() {
|
||||
}))
|
||||
}
|
||||
|
||||
// ==================== 系统管理 - 字典配置 ====================
|
||||
export interface DictType {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
status: 'enabled' | 'disabled'
|
||||
remark: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
export interface DictItem {
|
||||
id: string
|
||||
typeCode: string
|
||||
label: string
|
||||
value: string
|
||||
sort: number
|
||||
status: 'enabled' | 'disabled'
|
||||
remark: string
|
||||
}
|
||||
|
||||
// 预置字典类型
|
||||
export const dictTypes: DictType[] = [
|
||||
{ id: 'd1', name: '质量等级', code: 'quality_level', status: 'enabled', remark: '收成入库质量等级', createTime: '2025-01-01' },
|
||||
{ id: 'd2', name: '处置方式', code: 'disposal_method', status: 'enabled', remark: '余量处置方式', createTime: '2025-01-01' },
|
||||
{ id: 'd3', name: '加工工艺', code: 'process_method', status: 'enabled', remark: '菜品加工工艺', createTime: '2025-01-01' },
|
||||
{ id: 'd4', name: '菜系', code: 'cuisine_type', status: 'enabled', remark: '菜品菜系分类', createTime: '2025-01-01' },
|
||||
{ id: 'd5', name: '人群标签', code: 'crowd_tag', status: 'enabled', remark: '适用人群标签', createTime: '2025-01-01' },
|
||||
{ id: 'd6', name: '检测项类型', code: 'test_type', status: 'enabled', remark: '卫生检测项分类', createTime: '2025-01-01' },
|
||||
{ id: 'd7', name: '供应商类型', code: 'supplier_type', status: 'enabled', remark: '供应商分类', createTime: '2025-01-01' },
|
||||
{ id: 'd8', name: '订单状态', code: 'order_status', status: 'enabled', remark: '通用订单状态', createTime: '2025-01-01' },
|
||||
]
|
||||
|
||||
// 预置字典项
|
||||
export const dictItems: DictItem[] = [
|
||||
// 质量等级
|
||||
{ id: 'di01', typeCode: 'quality_level', label: '优等', value: 'excellent', sort: 1, status: 'enabled', remark: '' },
|
||||
{ id: 'di02', typeCode: 'quality_level', label: '良好', value: 'good', sort: 2, status: 'enabled', remark: '' },
|
||||
{ id: 'di03', typeCode: 'quality_level', label: '一般', value: 'normal', sort: 3, status: 'enabled', remark: '' },
|
||||
{ id: 'di04', typeCode: 'quality_level', label: '不合格', value: 'unqualified', sort: 4, status: 'enabled', remark: '' },
|
||||
// 处置方式
|
||||
{ id: 'di05', typeCode: 'disposal_method', label: '打折销售', value: 'discount', sort: 1, status: 'enabled', remark: '' },
|
||||
{ id: 'di06', typeCode: 'disposal_method', label: '回收', value: 'recycle', sort: 2, status: 'enabled', remark: '' },
|
||||
{ id: 'di07', typeCode: 'disposal_method', label: '废弃', value: 'discard', sort: 3, status: 'enabled', remark: '' },
|
||||
// 加工工艺
|
||||
{ id: 'di08', typeCode: 'process_method', label: '炒', value: 'stir_fry', sort: 1, status: 'enabled', remark: '' },
|
||||
{ id: 'di09', typeCode: 'process_method', label: '炖', value: 'stew', sort: 2, status: 'enabled', remark: '' },
|
||||
{ id: 'di10', typeCode: 'process_method', label: '煮', value: 'boil', sort: 3, status: 'enabled', remark: '' },
|
||||
{ id: 'di11', typeCode: 'process_method', label: '烤', value: 'roast', sort: 4, status: 'enabled', remark: '' },
|
||||
// 菜系
|
||||
{ id: 'di12', typeCode: 'cuisine_type', label: '川菜', value: 'sichuan', sort: 1, status: 'enabled', remark: '' },
|
||||
{ id: 'di13', typeCode: 'cuisine_type', label: '粤菜', value: 'cantonese', sort: 2, status: 'enabled', remark: '' },
|
||||
{ id: 'di14', typeCode: 'cuisine_type', label: '鲁菜', value: 'shandong', sort: 3, status: 'enabled', remark: '' },
|
||||
{ id: 'di15', typeCode: 'cuisine_type', label: '苏菜', value: 'jiangsu', sort: 4, status: 'enabled', remark: '' },
|
||||
{ id: 'di16', typeCode: 'cuisine_type', label: '湘菜', value: 'hunan', sort: 5, status: 'enabled', remark: '' },
|
||||
// 人群标签
|
||||
{ id: 'di17', typeCode: 'crowd_tag', label: '低脂', value: 'low_fat', sort: 1, status: 'enabled', remark: '' },
|
||||
{ id: 'di18', typeCode: 'crowd_tag', label: '高钙', value: 'high_calcium', sort: 2, status: 'enabled', remark: '' },
|
||||
{ id: 'di19', typeCode: 'crowd_tag', label: '低钠', value: 'low_sodium', sort: 3, status: 'enabled', remark: '' },
|
||||
{ id: 'di20', typeCode: 'crowd_tag', label: '高蛋白', value: 'high_protein', sort: 4, status: 'enabled', remark: '' },
|
||||
{ id: 'di21', typeCode: 'crowd_tag', label: '低糖', value: 'low_sugar', sort: 5, status: 'enabled', remark: '' },
|
||||
// 检测项类型
|
||||
{ id: 'di22', typeCode: 'test_type', label: '农药残留', value: 'pesticide', sort: 1, status: 'enabled', remark: '' },
|
||||
{ id: 'di23', typeCode: 'test_type', label: '重金属', value: 'heavy_metal', sort: 2, status: 'enabled', remark: '' },
|
||||
{ id: 'di24', typeCode: 'test_type', label: '化肥残留', value: 'fertilizer', sort: 3, status: 'enabled', remark: '' },
|
||||
// 供应商类型
|
||||
{ id: 'di25', typeCode: 'supplier_type', label: '种养公司', value: 'farming', sort: 1, status: 'enabled', remark: '' },
|
||||
{ id: 'di26', typeCode: 'supplier_type', label: '净菜公司', value: 'clean_veg', sort: 2, status: 'enabled', remark: '' },
|
||||
{ id: 'di27', typeCode: 'supplier_type', label: '餐饮公司', value: 'catering', sort: 3, status: 'enabled', remark: '' },
|
||||
{ id: 'di28', typeCode: 'supplier_type', label: '外部供应商', value: 'external', sort: 4, status: 'enabled', remark: '' },
|
||||
// 订单状态
|
||||
{ id: 'di29', typeCode: 'order_status', label: '待确认', value: 'pending', sort: 1, status: 'enabled', remark: '' },
|
||||
{ id: 'di30', typeCode: 'order_status', label: '已确认', value: 'confirmed', sort: 2, status: 'enabled', remark: '' },
|
||||
{ id: 'di31', typeCode: 'order_status', label: '已发货', value: 'shipped', sort: 3, status: 'enabled', remark: '' },
|
||||
{ id: 'di32', typeCode: 'order_status', label: '已到货', value: 'arrived', sort: 4, status: 'enabled', remark: '' },
|
||||
{ id: 'di33', typeCode: 'order_status', label: '已取消', value: 'cancelled', sort: 5, status: 'enabled', remark: '' },
|
||||
]
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
export function genDashboardData() {
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
const modules: RouteRecordRaw[] = [
|
||||
// ========== 数据概览 ==========
|
||||
{ path: 'dashboard', name: 'dashboard', meta: { title: '数据概览', menuKey: 'dashboard' }, component: () => import('@/views/dashboard/index.vue') },
|
||||
{ path: 'trace-overview', name: 'trace-overview', meta: { title: '溯源总览', menuKey: 'trace-overview' }, component: () => import('@/views/dashboard/trace/index.vue') },
|
||||
{ path: 'business-flow', name: 'business-flow', meta: { title: '业务流程', menuKey: 'business-flow' }, component: () => import('@/views/business-flow/index.vue') },
|
||||
|
||||
// ========== 绿色种采 ==========
|
||||
@@ -54,6 +55,7 @@ const modules: RouteRecordRaw[] = [
|
||||
{ path: 'employee-mgmt', name: 'employee-mgmt', meta: { title: '员工管理', menuKey: 'employee-mgmt' }, component: () => import('@/views/system/employee-mgmt/index.vue') },
|
||||
{ path: 'role-mgmt', name: 'role-mgmt', meta: { title: '角色管理', menuKey: 'role-mgmt' }, component: () => import('@/views/system/role-mgmt/index.vue') },
|
||||
{ path: 'canteen-mgmt', name: 'canteen-mgmt', meta: { title: '食堂管理', menuKey: 'canteen-mgmt' }, component: () => import('@/views/system/canteen-mgmt/index.vue') },
|
||||
{ path: 'dict-config', name: 'dict-config', meta: { title: '字典配置', menuKey: 'dict-config' }, component: () => import('@/views/system/dict-config/index.vue') },
|
||||
]
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="trace-overview">
|
||||
<a-page-header title="溯源总览" sub-title="输入批次号,追踪从农田到餐桌的完整链路" />
|
||||
|
||||
<a-card :bordered="false" style="margin-bottom:16px">
|
||||
<a-input-search
|
||||
v-model:value="batchNo"
|
||||
placeholder="请输入批次号(如 HV20260415123)"
|
||||
enter-button="溯源查询"
|
||||
size="large"
|
||||
style="max-width:600px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="traceResult" :bordered="false">
|
||||
<a-steps :current="traceResult.steps.length - 1" direction="vertical" size="small">
|
||||
<a-step v-for="(step, i) in traceResult.steps" :key="i" :title="step.stage" :description="step.time">
|
||||
<template #icon>
|
||||
<span :style="{ color: stageColors[i] }">{{ i + 1 }}</span>
|
||||
</template>
|
||||
<template #subTitle>
|
||||
<a-tag :color="stageColors[i]">{{ step.module }}</a-tag>
|
||||
</template>
|
||||
</a-step>
|
||||
</a-steps>
|
||||
|
||||
<a-divider />
|
||||
<a-descriptions title="溯源详情" bordered :column="2" size="small">
|
||||
<a-descriptions-item label="批次号">{{ traceResult.batchNo }}</a-descriptions-item>
|
||||
<a-descriptions-item label="品种">{{ traceResult.material }}</a-descriptions-item>
|
||||
<a-descriptions-item label="产地">{{ traceResult.origin }}</a-descriptions-item>
|
||||
<a-descriptions-item label="供应商">{{ traceResult.supplier }}</a-descriptions-item>
|
||||
<a-descriptions-item label="检测结果">
|
||||
<a-tag color="green">合格</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="加工损耗率">{{ traceResult.lossRate }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最终菜品">{{ traceResult.dish }}</a-descriptions-item>
|
||||
<a-descriptions-item label="上餐时间">{{ traceResult.serveTime }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-empty v-if="searched && !traceResult" description="未找到该批次的溯源信息" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { randomDate, randomTime, pick } from '@/mock/data'
|
||||
|
||||
const batchNo = ref('')
|
||||
const traceResult = ref<any>(null)
|
||||
const searched = ref(false)
|
||||
const stageColors = ['#52c41a', '#1890ff', '#faad14', '#722ed1', '#f5222d']
|
||||
|
||||
function handleSearch() {
|
||||
searched.value = true
|
||||
if (!batchNo.value) { message.warning('请输入批次号'); traceResult.value = null; return }
|
||||
traceResult.value = {
|
||||
batchNo: batchNo.value,
|
||||
material: pick(['白菜', '番茄', '猪里脊', '鸡胸肉', '土豆']),
|
||||
origin: pick(['A区稻田', 'B区菜地', 'C区果园']),
|
||||
supplier: pick(['集团种养公司', '绿源农业', '丰禾食品']),
|
||||
lossRate: '12.5%',
|
||||
dish: pick(['红烧肉', '清炒时蔬', '番茄鸡蛋']),
|
||||
serveTime: randomDate(3) + ' 11:30',
|
||||
steps: [
|
||||
{ stage: '种养采收', module: '绿色种养', time: randomDate(30) + ' ' + randomTime() + ' — 收成入库,质量等级:优等' },
|
||||
{ stage: '卫生检测', module: '卫生供应', time: randomDate(25) + ' ' + randomTime() + ' — 农残/重金属/化肥残留检测合格' },
|
||||
{ stage: '净菜加工', module: '卫生供应', time: randomDate(20) + ' ' + randomTime() + ' — 毛菜45kg → 净菜39.4kg,损耗率12.5%' },
|
||||
{ stage: '烹饪生产', module: '健康生产', time: randomDate(3) + ' ' + randomTime() + ' — 按单烹制,生重2.5kg → 熟重2.1kg' },
|
||||
{ stage: '上餐用餐', module: '营养用餐', time: randomDate(3) + ' 11:30 — 1号中餐线上餐,温度72℃' },
|
||||
],
|
||||
}
|
||||
message.success('溯源查询完成')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trace-overview { padding: 0; }
|
||||
</style>
|
||||
@@ -12,10 +12,10 @@
|
||||
<StatCard title="总数量" :value="totalQuantity" suffix="kg" color="#52c41a" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="A级数量" :value="qualityCounts.A" color="#52c41a" />
|
||||
<StatCard title="优等数量" :value="qualityCounts['优等']" color="#52c41a" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="B/C级数量" :value="qualityCounts.B + qualityCounts.C" color="#faad14" />
|
||||
<StatCard title="良好/一般" :value="qualityCounts['良好'] + qualityCounts['一般']" color="#faad14" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'quality'">
|
||||
<a-tag :color="record.quality === 'A' ? 'green' : record.quality === 'B' ? 'blue' : 'orange'">
|
||||
{{ record.quality }}级
|
||||
<a-tag :color="record.quality === '优等' ? 'green' : record.quality === '良好' ? 'blue' : record.quality === '一般' ? 'orange' : 'red'">
|
||||
{{ record.quality }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
@@ -82,9 +82,10 @@
|
||||
</a-form-item>
|
||||
<a-form-item label="质量等级">
|
||||
<a-radio-group v-model:value="form.quality">
|
||||
<a-radio value="A">A级</a-radio>
|
||||
<a-radio value="B">B级</a-radio>
|
||||
<a-radio value="C">C级</a-radio>
|
||||
<a-radio value="优等">优等</a-radio>
|
||||
<a-radio value="良好">良好</a-radio>
|
||||
<a-radio value="一般">一般</a-radio>
|
||||
<a-radio value="不合格">不合格</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
@@ -119,7 +120,7 @@ const currentRecord = ref<any>(null)
|
||||
const form = ref({
|
||||
warehouse: undefined as string | undefined,
|
||||
inspector: '',
|
||||
quality: 'A',
|
||||
quality: '优等',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
@@ -128,9 +129,9 @@ const totalQuantity = computed(() => {
|
||||
})
|
||||
|
||||
const qualityCounts = computed(() => {
|
||||
const counts = { A: 0, B: 0, C: 0 }
|
||||
const counts: Record<string, number> = { '优等': 0, '良好': 0, '一般': 0, '不合格': 0 }
|
||||
data.value.forEach(item => {
|
||||
if (item.quality in counts) counts[item.quality as keyof typeof counts]++
|
||||
if (item.quality in counts) counts[item.quality]++
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'sensorStatus'">
|
||||
<a-badge :status="record.sensorStatus === 'online' ? 'success' : record.sensorStatus === 'offline' ? 'default' : 'error'"
|
||||
:text="record.sensorStatus === 'online' ? '在线' : record.sensorStatus === 'offline' ? '离线' : '故障'" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'soilPH'">
|
||||
<span :style="{ color: record.soilPH < 6 || record.soilPH > 7.5 ? '#f5222d' : '#52c41a' }">
|
||||
{{ record.soilPH }}
|
||||
@@ -83,6 +87,7 @@
|
||||
>
|
||||
<template v-if="currentRecord">
|
||||
<a-descriptions :column="1" bordered size="small">
|
||||
<a-descriptions-item label="地块编号">{{ currentRecord.code }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地块名称">{{ currentRecord.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地块面积">{{ currentRecord.area }}</a-descriptions-item>
|
||||
<a-descriptions-item label="土壤类型">{{ currentRecord.soilType }}</a-descriptions-item>
|
||||
@@ -97,6 +102,11 @@
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="上次检测时间">{{ currentRecord.lastCheck }}</a-descriptions-item>
|
||||
<a-descriptions-item label="传感器状态">
|
||||
<a-badge :status="currentRecord.sensorStatus === 'online' ? 'success' : currentRecord.sensorStatus === 'offline' ? 'default' : 'error'"
|
||||
:text="currentRecord.sensorStatus === 'online' ? '在线' : currentRecord.sensorStatus === 'offline' ? '离线' : '故障'" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最近数据同步">{{ currentRecord.lastSync }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider>近 7 天温度趋势</a-divider>
|
||||
@@ -123,16 +133,17 @@ const drawerOpen = ref(false)
|
||||
const currentRecord = ref<any>(null)
|
||||
|
||||
const columns = [
|
||||
{ title: '地块编号', dataIndex: 'code', key: 'code', width: 100 },
|
||||
{ title: '地块名称', dataIndex: 'name', key: 'name', width: 110 },
|
||||
{ title: '面积', dataIndex: 'area', key: 'area', width: 80 },
|
||||
{ title: '土壤类型', dataIndex: 'soilType', key: 'soilType', width: 90 },
|
||||
{ title: 'pH值', dataIndex: 'soilPH', key: 'soilPH', width: 80 },
|
||||
{ title: '湿度', dataIndex: 'humidity', key: 'humidity', width: 80 },
|
||||
{ title: '温度', dataIndex: 'temperature', key: 'temperature', width: 80 },
|
||||
{ title: '有机质', dataIndex: 'organicMatter', key: 'organicMatter', width: 90 },
|
||||
{ title: '种植作物', dataIndex: 'crop', key: 'crop', width: 100 },
|
||||
{ title: '传感器', key: 'sensorStatus', width: 90 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '上次检测', dataIndex: 'lastCheck', key: 'lastCheck', width: 120 },
|
||||
{ title: '最近同步', dataIndex: 'lastSync', key: 'lastSync', width: 140 },
|
||||
]
|
||||
|
||||
const filteredData = computed(() => {
|
||||
|
||||
@@ -1,99 +1,118 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="采供管理" />
|
||||
<RelatedModules :links="relatedLinks" />
|
||||
<a-page-header title="采购管理" subtitle="采购订单与到货验收" />
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<StatCard title="总订单数" :value="data.length" color="#1890ff" />
|
||||
<StatCard title="总订单数" :value="orderData.length" color="#1890ff" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="总金额" :value="totalAmount" suffix="元" color="#52c41a" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="待处理" :value="pendingCount" color="#faad14" />
|
||||
<StatCard title="待验收" :value="pendingVerifyCount" color="#faad14" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="本月订单" :value="thisMonthCount" color="#722ed1" />
|
||||
<StatCard title="本月到货" :value="thisMonthArrived" color="#722ed1" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<a-card style="margin-bottom: 16px">
|
||||
<a-row :gutter="16" align="middle">
|
||||
<a-card>
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="order" tab="采购订单" />
|
||||
<a-tab-pane key="verify" tab="到货验收" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 采购订单工具栏 -->
|
||||
<a-row v-if="activeTab === 'order'" :gutter="16" align="middle" style="margin-bottom:16px">
|
||||
<a-col :span="6">
|
||||
<a-range-picker v-model:value="dateRange" style="width: 100%" />
|
||||
<a-input v-model:value="orderSearch" placeholder="搜索订单号/物资" allow-clear />
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="statusFilter" placeholder="状态筛选" allow-clear style="width: 100%">
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
<a-select-option value="processing">进行中</a-select-option>
|
||||
<a-select-option value="done">已完成</a-select-option>
|
||||
<a-select-option value="approved">已审核</a-select-option>
|
||||
<a-select v-model:value="orderStatusFilter" placeholder="状态筛选" allow-clear style="width:100%">
|
||||
<a-select-option value="pending">待确认</a-select-option>
|
||||
<a-select-option value="confirmed">已确认</a-select-option>
|
||||
<a-select-option value="shipped">已发货</a-select-option>
|
||||
<a-select-option value="arrived">已到货</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-button type="primary" @click="showModal">新增采购</a-button>
|
||||
<a-button type="primary" @click="showOrderModal">新增采购</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-card>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredData"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<!-- 到货验收工具栏 -->
|
||||
<a-row v-if="activeTab === 'verify'" :gutter="16" align="middle" style="margin-bottom:16px">
|
||||
<a-col :span="6">
|
||||
<a-input v-model:value="verifySearch" placeholder="搜索订单号/品种" allow-clear />
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="verifyStatusFilter" placeholder="验收状态" allow-clear style="width:100%">
|
||||
<a-select-option value="pending">待验收</a-select-option>
|
||||
<a-select-option value="passed">合格</a-select-option>
|
||||
<a-select-option value="failed">不合格</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 采购订单表格 -->
|
||||
<a-table v-if="activeTab === 'order'" :columns="orderColumns" :data-source="filteredOrders" :pagination="{ pageSize: 10 }" row-key="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="statusMap[record.status]?.color">
|
||||
{{ statusMap[record.status]?.text }}
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="orderStatusColor(record.status)">{{ orderStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button v-if="record.status === 'pending'" type="link" size="small" @click="confirmOrder(record)">确认</a-button>
|
||||
<a-button v-if="record.status === 'confirmed'" type="link" size="small" @click="shipOrder(record)">发货</a-button>
|
||||
<a-button v-if="record.status === 'shipped'" type="link" size="small" @click="arriveOrder(record)">到货</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 到货验收表格 -->
|
||||
<a-table v-if="activeTab === 'verify'" :columns="verifyColumns" :data-source="filteredVerify" :pagination="{ pageSize: 10 }" row-key="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'diff'">
|
||||
<span :style="{ color: record.diffQty < 0 ? '#f5222d' : '#52c41a' }">
|
||||
{{ record.diffQty > 0 ? '+' : '' }}{{ record.diffQty }}kg
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'verifyStatus'">
|
||||
<a-tag :color="record.verifyStatus === 'passed' ? 'green' : record.verifyStatus === 'failed' ? 'red' : 'blue'">
|
||||
{{ record.verifyStatus === 'passed' ? '合格' : record.verifyStatus === 'failed' ? '不合格' : '待验收' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-popconfirm
|
||||
title="确认审批通过?"
|
||||
@confirm="handleApprove(record)"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" size="small" :disabled="record.status === 'approved'">审批</a-button>
|
||||
</a-popconfirm>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space v-if="record.verifyStatus === 'pending'">
|
||||
<a-button type="link" size="small" @click="verifyPass(record)">合格</a-button>
|
||||
<a-button type="link" size="small" danger @click="verifyFail(record)">不合格</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增采购弹窗 -->
|
||||
<a-modal v-model:open="modalVisible" title="新增采购" @ok="handleSubmit" @cancel="resetForm">
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="物资名称">
|
||||
<a-select v-model:value="form.material" placeholder="请选择物资">
|
||||
<a-select-option v-for="m in materials" :key="m" :value="m">{{ m }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-modal v-model:open="orderModalVisible" title="新增采购订单" @ok="submitOrder" @cancel="orderModalVisible = false">
|
||||
<a-form :label-col="{ span: 6 }">
|
||||
<a-form-item label="供应商">
|
||||
<a-select v-model:value="form.supplier" placeholder="请选择供应商">
|
||||
<a-select v-model:value="orderForm.supplier" placeholder="请选择供应商">
|
||||
<a-select-option v-for="s in suppliers" :key="s" :value="s">{{ s }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="数量">
|
||||
<a-input-group compact>
|
||||
<a-input-number v-model:value="form.quantity" :min="1" style="width: 70%" placeholder="数量" />
|
||||
<a-input v-model:value="form.unit" style="width: 30%" placeholder="单位" />
|
||||
</a-input-group>
|
||||
<a-form-item label="品种">
|
||||
<a-select v-model:value="orderForm.material" placeholder="请选择品种">
|
||||
<a-select-option v-for="m in materials" :key="m" :value="m">{{ m }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="单价(元)">
|
||||
<a-input-number v-model:value="form.unitPrice" :min="0" :precision="2" style="width: 100%" placeholder="请输入单价" />
|
||||
<a-form-item label="数量(kg)">
|
||||
<a-input-number v-model:value="orderForm.quantity" :min="1" style="width:100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="到货日期">
|
||||
<a-date-picker v-model:value="form.deliveryDate" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="form.remark" :rows="3" placeholder="请输入备注" />
|
||||
<a-form-item label="单价(元/kg)">
|
||||
<a-input-number v-model:value="orderForm.unitPrice" :min="0" :precision="2" style="width:100%" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -103,101 +122,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { genProcurementData, statusMap, randomId } from '@/mock/data'
|
||||
import { randomId, randomNum, randomFloat, randomDate, pick } from '@/mock/data'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import RelatedModules from '@/components/common/RelatedModules.vue'
|
||||
|
||||
const relatedLinks = [
|
||||
{ key: 'supplier-mgmt', label: '供商管理', color: 'green', arrow: '←' },
|
||||
{ key: 'raw-storage', label: '毛菜入库', color: 'blue', arrow: '→' },
|
||||
]
|
||||
const suppliers = ['集团种养公司', '集团净菜公司', '绿源农业', '丰禾食品', '天然牧场', '鲜美水产']
|
||||
const materials = ['有机大米', '土鸡蛋', '新鲜猪肉', '活鱼', '时令蔬菜', '有机牛奶', '菜籽油', '食用盐']
|
||||
|
||||
const data = ref(genProcurementData(12))
|
||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
||||
const statusFilter = ref<string | undefined>(undefined)
|
||||
const modalVisible = ref(false)
|
||||
interface Order {
|
||||
id: string; orderNo: string; supplier: string; material: string
|
||||
quantity: number; unitPrice: number; amount: number
|
||||
orderDate: string; status: 'pending' | 'confirmed' | 'shipped' | 'arrived'
|
||||
}
|
||||
interface VerifyRecord {
|
||||
id: string; orderNo: string; material: string; orderQty: number
|
||||
actualQty: number; diffQty: number; verifyPerson: string
|
||||
verifyTime: string; verifyStatus: 'pending' | 'passed' | 'failed'
|
||||
}
|
||||
|
||||
const materials = ['有机大米', '土鸡蛋', '新鲜猪肉', '活鱼', '时令蔬菜', '有机牛奶', '散养鸡', '绿色面粉', '菜籽油', '食用盐']
|
||||
const suppliers = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有��之家', '大地农庄', '春风蔬果', '碧水渔业']
|
||||
function genOrders(n = 15): Order[] {
|
||||
return Array.from({ length: n }, () => {
|
||||
const qty = randomNum(50, 2000)
|
||||
const price = randomFloat(2, 80)
|
||||
return {
|
||||
id: randomId(), orderNo: 'PO' + randomDate(30).replace(/-/g, '') + randomNum(100, 999),
|
||||
supplier: pick(suppliers), material: pick(materials),
|
||||
quantity: qty, unitPrice: price, amount: +(qty * price).toFixed(0),
|
||||
orderDate: randomDate(30), status: pick(['pending', 'confirmed', 'shipped', 'arrived']) as Order['status'],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
material: undefined as string | undefined,
|
||||
supplier: undefined as string | undefined,
|
||||
quantity: 100,
|
||||
unit: 'kg',
|
||||
unitPrice: 0,
|
||||
deliveryDate: null as Dayjs | null,
|
||||
remark: '',
|
||||
function genVerifyRecords(orders: Order[]): VerifyRecord[] {
|
||||
return orders.filter(o => o.status === 'arrived').map(o => {
|
||||
const actual = o.quantity - randomNum(-5, 10)
|
||||
return {
|
||||
id: randomId(), orderNo: o.orderNo, material: o.material,
|
||||
orderQty: o.quantity, actualQty: actual, diffQty: actual - o.quantity,
|
||||
verifyPerson: pick(['验收员A', '验收员B', '验收员C']),
|
||||
verifyTime: randomDate(7), verifyStatus: pick(['pending', 'passed', 'passed']) as VerifyRecord['verifyStatus'],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const orderData = ref<Order[]>(genOrders())
|
||||
const verifyData = ref<VerifyRecord[]>(genVerifyRecords(orderData.value))
|
||||
const activeTab = ref('order')
|
||||
const orderSearch = ref('')
|
||||
const orderStatusFilter = ref<string>()
|
||||
const verifySearch = ref('')
|
||||
const verifyStatusFilter = ref<string>()
|
||||
const orderModalVisible = ref(false)
|
||||
const orderForm = ref({ supplier: undefined as string | undefined, material: undefined as string | undefined, quantity: 100, unitPrice: 10 })
|
||||
|
||||
const totalAmount = computed(() => orderData.value.reduce((s, o) => s + o.amount, 0))
|
||||
const pendingVerifyCount = computed(() => verifyData.value.filter(v => v.verifyStatus === 'pending').length)
|
||||
const thisMonthArrived = computed(() => {
|
||||
const m = new Date().toISOString().slice(0, 7)
|
||||
return verifyData.value.filter(v => v.verifyTime.startsWith(m)).length
|
||||
})
|
||||
|
||||
const totalAmount = computed(() => {
|
||||
return data.value.reduce((sum, item) => sum + parseFloat(item.totalPrice.replace('¥', '')), 0)
|
||||
})
|
||||
|
||||
const pendingCount = computed(() => data.value.filter(item => item.status === 'pending').length)
|
||||
|
||||
const thisMonthCount = computed(() => {
|
||||
const now = new Date()
|
||||
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
return data.value.filter(item => item.orderDate.startsWith(month)).length
|
||||
})
|
||||
|
||||
const filteredData = computed(() => {
|
||||
return data.value.filter(item => {
|
||||
const matchStatus = !statusFilter.value || item.status === statusFilter.value
|
||||
const matchDate = !dateRange.value ||
|
||||
(item.orderDate >= dateRange.value[0].format('YYYY-MM-DD') &&
|
||||
item.orderDate <= dateRange.value[1].format('YYYY-MM-DD'))
|
||||
return matchStatus && matchDate
|
||||
const filteredOrders = computed(() => {
|
||||
return orderData.value.filter(o => {
|
||||
const ms = !orderSearch.value || o.orderNo.includes(orderSearch.value) || o.material.includes(orderSearch.value)
|
||||
const mf = !orderStatusFilter.value || o.status === orderStatusFilter.value
|
||||
return ms && mf
|
||||
})
|
||||
})
|
||||
|
||||
const columns = [
|
||||
const filteredVerify = computed(() => {
|
||||
return verifyData.value.filter(v => {
|
||||
const ms = !verifySearch.value || v.orderNo.includes(verifySearch.value) || v.material.includes(verifySearch.value)
|
||||
const mf = !verifyStatusFilter.value || v.verifyStatus === verifyStatusFilter.value
|
||||
return ms && mf
|
||||
})
|
||||
})
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo' },
|
||||
{ title: '物资名称', dataIndex: 'material', key: 'material' },
|
||||
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' },
|
||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity' },
|
||||
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice' },
|
||||
{ title: '总价', dataIndex: 'totalPrice', key: 'totalPrice' },
|
||||
{ title: '品种', dataIndex: 'material', key: 'material' },
|
||||
{ title: '数量(kg)', dataIndex: 'quantity', key: 'quantity' },
|
||||
{ title: '单价(元)', dataIndex: 'unitPrice', key: 'unitPrice' },
|
||||
{ title: '金额(元)', dataIndex: 'amount', key: 'amount' },
|
||||
{ title: '下单日期', dataIndex: 'orderDate', key: 'orderDate', sorter: (a: any, b: any) => a.orderDate.localeCompare(b.orderDate) },
|
||||
{ title: '到货日期', dataIndex: 'deliveryDate', key: 'deliveryDate' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '采购员', dataIndex: 'purchaser', key: 'purchaser' },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
]
|
||||
|
||||
function showModal() {
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
const verifyColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo' },
|
||||
{ title: '品种', dataIndex: 'material', key: 'material' },
|
||||
{ title: '订单量(kg)', dataIndex: 'orderQty', key: 'orderQty' },
|
||||
{ title: '实收量(kg)', dataIndex: 'actualQty', key: 'actualQty' },
|
||||
{ title: '差异量', key: 'diff', width: 100 },
|
||||
{ title: '验收人', dataIndex: 'verifyPerson', key: 'verifyPerson' },
|
||||
{ title: '验收时间', dataIndex: 'verifyTime', key: 'verifyTime' },
|
||||
{ title: '验收状态', key: 'verifyStatus', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 140 },
|
||||
]
|
||||
|
||||
function resetForm() {
|
||||
form.value = { material: undefined, supplier: undefined, quantity: 100, unit: 'kg', unitPrice: 0, deliveryDate: null, remark: '' }
|
||||
}
|
||||
function orderStatusColor(s: string) { return s === 'pending' ? 'blue' : s === 'confirmed' ? 'cyan' : s === 'shipped' ? 'orange' : 'green' }
|
||||
function orderStatusText(s: string) { return s === 'pending' ? '待确认' : s === 'confirmed' ? '已确认' : s === 'shipped' ? '已发货' : '已到货' }
|
||||
|
||||
function handleApprove(record: any) {
|
||||
record.status = 'approved'
|
||||
message.success('审批通过')
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const total = form.value.quantity * form.value.unitPrice
|
||||
data.value.unshift({
|
||||
id: randomId(),
|
||||
orderNo: 'PO' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + Math.floor(Math.random() * 900 + 100),
|
||||
material: form.value.material || '新物资',
|
||||
supplier: form.value.supplier || '未指定',
|
||||
quantity: form.value.quantity + form.value.unit,
|
||||
unitPrice: '¥' + form.value.unitPrice.toFixed(1),
|
||||
totalPrice: '¥' + total.toFixed(0),
|
||||
orderDate: new Date().toISOString().slice(0, 10),
|
||||
deliveryDate: form.value.deliveryDate?.format('YYYY-MM-DD') || '',
|
||||
status: 'pending',
|
||||
purchaser: '采购员A',
|
||||
function confirmOrder(r: Order) { r.status = 'confirmed'; message.success('已确认') }
|
||||
function shipOrder(r: Order) { r.status = 'shipped'; message.success('已标记发货') }
|
||||
function arriveOrder(r: Order) {
|
||||
r.status = 'arrived'
|
||||
const actual = r.quantity - randomNum(-5, 10)
|
||||
verifyData.value.unshift({
|
||||
id: randomId(), orderNo: r.orderNo, material: r.material,
|
||||
orderQty: r.quantity, actualQty: actual, diffQty: actual - r.quantity,
|
||||
verifyPerson: '', verifyTime: new Date().toISOString().slice(0, 10), verifyStatus: 'pending',
|
||||
})
|
||||
message.success('新增采购成功')
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
message.success('已到货,请前往到货验收')
|
||||
}
|
||||
|
||||
function verifyPass(r: VerifyRecord) { r.verifyStatus = 'passed'; message.success('验收合格') }
|
||||
function verifyFail(r: VerifyRecord) { r.verifyStatus = 'failed'; message.error('验收不合格') }
|
||||
|
||||
function showOrderModal() {
|
||||
orderForm.value = { supplier: undefined, material: undefined, quantity: 100, unitPrice: 10 }
|
||||
orderModalVisible.value = true
|
||||
}
|
||||
|
||||
function submitOrder() {
|
||||
if (!orderForm.value.supplier || !orderForm.value.material) { message.warning('请填写完整'); return }
|
||||
const amount = +(orderForm.value.quantity * orderForm.value.unitPrice).toFixed(0)
|
||||
orderData.value.unshift({
|
||||
id: randomId(), orderNo: 'PO' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + randomNum(100, 999),
|
||||
supplier: orderForm.value.supplier, material: orderForm.value.material,
|
||||
quantity: orderForm.value.quantity, unitPrice: orderForm.value.unitPrice, amount,
|
||||
orderDate: new Date().toISOString().slice(0, 10), status: 'pending',
|
||||
})
|
||||
message.success('新增成功')
|
||||
orderModalVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,97 +1,150 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="供商管理" />
|
||||
<a-page-header title="供商管理" subtitle="自有供应商与外部供应商统一管理" />
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<a-card style="margin-bottom: 16px">
|
||||
<a-row :gutter="16" align="middle">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<StatCard title="供应商总数" :value="allData.length" color="#1890ff" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="自有供应商" :value="ownData.length" color="#52c41a" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="外部供应商" :value="externalData.length" color="#722ed1" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="资质即将到期" :value="expiringCount" color="#faad14" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<a-card>
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="own" tab="自有供应商" />
|
||||
<a-tab-pane key="external" tab="外部供应商" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<a-row :gutter="16" align="middle" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<a-input v-model:value="searchText" placeholder="搜索供应商名称" allow-clear />
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="categoryFilter" placeholder="类别筛选" allow-clear style="width: 100%">
|
||||
<a-select-option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</a-select-option>
|
||||
<a-select v-model:value="qualFilter" placeholder="资质状态" allow-clear style="width: 100%">
|
||||
<a-select-option value="approved">已审核</a-select-option>
|
||||
<a-select-option value="reviewing">审核中</a-select-option>
|
||||
<a-select-option value="expired">已过期</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-button type="primary" @click="handleAdd">新增供应商</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-card>
|
||||
<!-- 数据表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:columns="currentColumns"
|
||||
:data-source="filteredData"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'category'">
|
||||
<a-tag color="blue">{{ record.category }}</a-tag>
|
||||
<template v-if="column.key === 'supplierType'">
|
||||
<a-tag color="blue">{{ record.supplierType }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'address'">
|
||||
<a-tooltip :title="record.address">
|
||||
<span class="ellipsis-text">{{ record.address }}</span>
|
||||
</a-tooltip>
|
||||
<template v-if="column.key === 'qualification'">
|
||||
<a-tag :color="qualColor(record.qualification)">{{ qualText(record.qualification) }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'qualification'">
|
||||
<a-tag :color="statusMap[record.qualification]?.color">
|
||||
{{ statusMap[record.qualification]?.text }}
|
||||
</a-tag>
|
||||
<template v-if="column.key === 'licenseExpiry'">
|
||||
<span :style="{ color: isExpiring(record.licenseExpiry) ? '#faad14' : '' }">
|
||||
{{ record.licenseExpiry }}
|
||||
<a-tag v-if="isExpiring(record.licenseExpiry)" color="warning" style="margin-left:4px">即将到期</a-tag>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'rating'">
|
||||
<a-rate :value="record.rating" disabled allow-half :style="{ fontSize: '14px' }" />
|
||||
<template v-if="column.key === 'creditScore'">
|
||||
<a-rate :value="record.creditScore / 20" disabled allow-half :style="{ fontSize: '14px' }" />
|
||||
<span style="margin-left:4px;color:#999">{{ record.creditScore }}</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handleDetail(record)">详情</a-button>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handleDetail(record)">详情</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑供应商' : '新增供应商'" @ok="handleSubmit" @cancel="resetForm">
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑供应商' : '新增供应商'" @ok="handleSubmit" @cancel="modalVisible = false" width="600px">
|
||||
<a-form :label-col="{ span: 6 }">
|
||||
<a-form-item label="供应商名称">
|
||||
<a-input v-model:value="form.name" placeholder="请输入名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="类别">
|
||||
<a-select v-model:value="form.category" placeholder="请选择类别">
|
||||
<a-select-option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</a-select-option>
|
||||
<a-form-item label="供应商归属">
|
||||
<a-radio-group v-model:value="form.ownership">
|
||||
<a-radio value="own">自有供应商</a-radio>
|
||||
<a-radio value="external">外部供应商</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.ownership === 'own'" label="供应商类型">
|
||||
<a-select v-model:value="form.supplierType" placeholder="请选择类型">
|
||||
<a-select-option value="种养公司">种养公司</a-select-option>
|
||||
<a-select-option value="净菜公司">净菜公司</a-select-option>
|
||||
<a-select-option value="餐饮公司">餐饮公司</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="联系人">
|
||||
<a-input v-model:value="form.contact" placeholder="请输入联系人" />
|
||||
</a-form-item>
|
||||
<a-form-item label="联系电话">
|
||||
<a-input v-model:value="form.contact" placeholder="请输入联系电话" />
|
||||
<a-input v-model:value="form.phone" placeholder="请输入联系电话" />
|
||||
</a-form-item>
|
||||
<a-form-item label="地址">
|
||||
<a-textarea v-model:value="form.address" :rows="3" placeholder="请输入地址" />
|
||||
<a-input v-model:value="form.address" placeholder="请输入地址" />
|
||||
</a-form-item>
|
||||
<a-form-item label="资质证照">
|
||||
<a-input v-model:value="form.licenseName" placeholder="证照名称" style="margin-bottom:8px" />
|
||||
<a-date-picker v-model:value="form.licenseExpiry" placeholder="到期日期" style="width:100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="诚信评分">
|
||||
<a-slider v-model:value="form.creditScore" :min="0" :max="100" :marks="{ 0: '0', 50: '50', 100: '100' }" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer v-model:open="drawerVisible" title="供应商详情" :width="480">
|
||||
<a-descriptions v-if="currentRecord" bordered :column="1">
|
||||
<a-drawer v-model:open="drawerVisible" title="供应商详情" :width="520">
|
||||
<a-descriptions v-if="currentRecord" bordered :column="1" size="small">
|
||||
<a-descriptions-item label="供应商名称">{{ currentRecord.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="类别">
|
||||
<a-tag color="blue">{{ currentRecord.category }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="联系电话">{{ currentRecord.contact }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地址">{{ currentRecord.address }}</a-descriptions-item>
|
||||
<a-descriptions-item label="资质状态">
|
||||
<a-tag :color="statusMap[currentRecord.qualification]?.color">
|
||||
{{ statusMap[currentRecord.qualification]?.text }}
|
||||
<a-descriptions-item label="归属">
|
||||
<a-tag :color="currentRecord.ownership === 'own' ? 'green' : 'purple'">
|
||||
{{ currentRecord.ownership === 'own' ? '自有供应商' : '外部供应商' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="评分">
|
||||
<a-rate :value="currentRecord.rating" disabled allow-half />
|
||||
<a-descriptions-item v-if="currentRecord.ownership === 'own'" label="类型">
|
||||
<a-tag color="blue">{{ currentRecord.supplierType }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="合作年限">{{ currentRecord.cooperationYears }}年</a-descriptions-item>
|
||||
<a-descriptions-item label="联系人">{{ currentRecord.contactPerson }}</a-descriptions-item>
|
||||
<a-descriptions-item label="联系电话">{{ currentRecord.phone }}</a-descriptions-item>
|
||||
<a-descriptions-item label="地址">{{ currentRecord.address }}</a-descriptions-item>
|
||||
<a-descriptions-item label="资质证照">{{ currentRecord.licenseName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="证照有效期">
|
||||
{{ currentRecord.licenseExpiry }}
|
||||
<a-tag v-if="isExpiring(currentRecord.licenseExpiry)" color="warning" style="margin-left:4px">即将到期</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="资质状态">
|
||||
<a-tag :color="qualColor(currentRecord.qualification)">{{ qualText(currentRecord.qualification) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="诚信评分">
|
||||
<a-rate :value="currentRecord.creditScore / 20" disabled allow-half />
|
||||
<span style="margin-left:8px">{{ currentRecord.creditScore }}分</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="交易总额">¥{{ currentRecord.totalAmount?.toLocaleString() }}</a-descriptions-item>
|
||||
<a-descriptions-item label="累计订单">{{ currentRecord.totalOrders }}单</a-descriptions-item>
|
||||
<a-descriptions-item label="上次供货日期">{{ currentRecord.lastDelivery }}</a-descriptions-item>
|
||||
<a-descriptions-item label="合作起始日">{{ currentRecord.cooperationStart }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最近交易">{{ currentRecord.lastTransaction }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-drawer>
|
||||
</div>
|
||||
@@ -100,108 +153,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { genSupplierData, statusMap, randomId } from '@/mock/data'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { randomId, randomNum, randomFloat, randomDate, pick } from '@/mock/data'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
|
||||
const data = ref(genSupplierData(8))
|
||||
interface Supplier {
|
||||
id: string; name: string; ownership: 'own' | 'external'; supplierType: string
|
||||
contactPerson: string; phone: string; address: string
|
||||
licenseName: string; licenseExpiry: string; qualification: string
|
||||
creditScore: number; totalAmount: number; totalOrders: number
|
||||
cooperationStart: string; lastTransaction: string
|
||||
}
|
||||
|
||||
function genSuppliers(): Supplier[] {
|
||||
const ownNames = ['集团种养公司', '集团净菜公司', '集团餐饮公司']
|
||||
const ownTypes = ['种养公司', '净菜公司', '餐饮公司']
|
||||
const extNames = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有机之家', '大地农庄']
|
||||
const own: Supplier[] = ownNames.map((name, i) => ({
|
||||
id: randomId(), name, ownership: 'own', supplierType: ownTypes[i],
|
||||
contactPerson: pick(['张经理', '李经理', '王经理']), phone: '1' + randomNum(30, 99) + String(randomNum(10000000, 99999999)),
|
||||
address: pick(['总部A栋', '总部B栋', '加工园区']) + randomNum(1, 5) + '层',
|
||||
licenseName: '食品经营许可证', licenseExpiry: '2026-' + String(randomNum(1, 12)).padStart(2, '0') + '-01',
|
||||
qualification: 'approved', creditScore: randomNum(80, 100),
|
||||
totalAmount: randomNum(100000, 2000000), totalOrders: randomNum(200, 1000),
|
||||
cooperationStart: '2020-01-01', lastTransaction: randomDate(7),
|
||||
}))
|
||||
const ext: Supplier[] = extNames.map(name => ({
|
||||
id: randomId(), name, ownership: 'external', supplierType: '外部供应商',
|
||||
contactPerson: pick(['赵总', '钱总', '孙总', '周总']), phone: '1' + randomNum(30, 99) + String(randomNum(10000000, 99999999)),
|
||||
address: pick(['上海市浦东新区', '江苏省南京市', '浙江省杭州市']) + '某路' + randomNum(1, 200) + '号',
|
||||
licenseName: pick(['食品经营许可证', '食品流通许可证']),
|
||||
licenseExpiry: '20' + pick(['25', '26', '27']) + '-' + String(randomNum(1, 12)).padStart(2, '0') + '-01',
|
||||
qualification: pick(['approved', 'approved', 'reviewing', 'expired']),
|
||||
creditScore: randomNum(50, 95), totalAmount: randomNum(50000, 1500000),
|
||||
totalOrders: randomNum(50, 500), cooperationStart: randomDate(365 * 3),
|
||||
lastTransaction: randomDate(30),
|
||||
}))
|
||||
return [...own, ...ext]
|
||||
}
|
||||
|
||||
const allData = ref<Supplier[]>(genSuppliers())
|
||||
const activeTab = ref<'own' | 'external'>('own')
|
||||
const searchText = ref('')
|
||||
const categoryFilter = ref<string | undefined>(undefined)
|
||||
const qualFilter = ref<string>()
|
||||
const modalVisible = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentRecord = ref<any>(null)
|
||||
const categories = ['蔬菜', '肉类', '水产', '粮油', '蛋奶', '水果']
|
||||
const currentRecord = ref<Supplier | null>(null)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
category: undefined as string | undefined,
|
||||
contact: '',
|
||||
address: '',
|
||||
name: '', ownership: 'own' as 'own' | 'external', supplierType: undefined as string | undefined,
|
||||
contact: '', phone: '', address: '',
|
||||
licenseName: '', licenseExpiry: null as Dayjs | null, creditScore: 80,
|
||||
})
|
||||
|
||||
const ownData = computed(() => allData.value.filter(s => s.ownership === 'own'))
|
||||
const externalData = computed(() => allData.value.filter(s => s.ownership === 'external'))
|
||||
const expiringCount = computed(() => allData.value.filter(s => isExpiring(s.licenseExpiry)).length)
|
||||
|
||||
const filteredData = computed(() => {
|
||||
return data.value.filter(item => {
|
||||
const matchSearch = !searchText.value || item.name.includes(searchText.value)
|
||||
const matchCategory = !categoryFilter.value || item.category === categoryFilter.value
|
||||
return matchSearch && matchCategory
|
||||
const base = activeTab.value === 'own' ? ownData.value : externalData.value
|
||||
return base.filter(s => {
|
||||
const matchSearch = !searchText.value || s.name.includes(searchText.value)
|
||||
const matchQual = !qualFilter.value || s.qualification === qualFilter.value
|
||||
return matchSearch && matchQual
|
||||
})
|
||||
})
|
||||
|
||||
const columns = [
|
||||
const commonCols = [
|
||||
{ title: '供应商名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类别', dataIndex: 'category', key: 'category' },
|
||||
{ title: '联系电话', dataIndex: 'contact', key: 'contact' },
|
||||
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
|
||||
{ title: '资质状态', dataIndex: 'qualification', key: 'qualification' },
|
||||
{ title: '评分', dataIndex: 'rating', key: 'rating' },
|
||||
{ title: '合作年限', dataIndex: 'cooperationYears', key: 'cooperationYears' },
|
||||
{ title: '累计订单', dataIndex: 'totalOrders', key: 'totalOrders' },
|
||||
{ title: '上次供货日期', dataIndex: 'lastDelivery', key: 'lastDelivery' },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 140 },
|
||||
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson' },
|
||||
{ title: '联系电话', dataIndex: 'phone', key: 'phone' },
|
||||
{ title: '资质证照', dataIndex: 'licenseName', key: 'licenseName' },
|
||||
{ title: '证照有效期', dataIndex: 'licenseExpiry', key: 'licenseExpiry' },
|
||||
{ title: '资质状态', key: 'qualification', width: 90 },
|
||||
{ title: '诚信评分', key: 'creditScore', width: 180 },
|
||||
{ title: '交易总额', dataIndex: 'totalAmount', key: 'totalAmount', customRender: ({ text }: any) => '¥' + (text || 0).toLocaleString() },
|
||||
{ title: '操作', key: 'action', width: 120 },
|
||||
]
|
||||
|
||||
const currentColumns = computed(() => {
|
||||
if (activeTab.value === 'own') {
|
||||
return [commonCols[0], { title: '类型', key: 'supplierType', width: 100 }, ...commonCols.slice(1)]
|
||||
}
|
||||
return [...commonCols]
|
||||
})
|
||||
|
||||
function isExpiring(dateStr: string) {
|
||||
if (!dateStr) return false
|
||||
const diff = dayjs(dateStr).diff(dayjs(), 'day')
|
||||
return diff >= 0 && diff <= 90
|
||||
}
|
||||
|
||||
function qualColor(q: string) {
|
||||
return q === 'approved' ? 'green' : q === 'reviewing' ? 'orange' : 'red'
|
||||
}
|
||||
function qualText(q: string) {
|
||||
return q === 'approved' ? '已审核' : q === 'reviewing' ? '审核中' : '已过期'
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
currentRecord.value = null
|
||||
resetForm()
|
||||
form.value = { name: '', ownership: activeTab.value, supplierType: undefined, contact: '', phone: '', address: '', licenseName: '', licenseExpiry: null, creditScore: 80 }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: any) {
|
||||
function handleEdit(record: Supplier) {
|
||||
isEdit.value = true
|
||||
currentRecord.value = record
|
||||
form.value = {
|
||||
name: record.name,
|
||||
category: record.category,
|
||||
contact: record.contact,
|
||||
address: record.address,
|
||||
name: record.name, ownership: record.ownership, supplierType: record.supplierType,
|
||||
contact: record.contactPerson, phone: record.phone, address: record.address,
|
||||
licenseName: record.licenseName, licenseExpiry: record.licenseExpiry ? dayjs(record.licenseExpiry) : null,
|
||||
creditScore: record.creditScore,
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleDetail(record: any) {
|
||||
function handleDetail(record: Supplier) {
|
||||
currentRecord.value = record
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { name: '', category: undefined, contact: '', address: '' }
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!form.value.name) { message.warning('请输入供应商名称'); return }
|
||||
if (isEdit.value && currentRecord.value) {
|
||||
Object.assign(currentRecord.value, {
|
||||
name: form.value.name,
|
||||
category: form.value.category,
|
||||
contact: form.value.contact,
|
||||
address: form.value.address,
|
||||
name: form.value.name, ownership: form.value.ownership,
|
||||
supplierType: form.value.ownership === 'own' ? form.value.supplierType : '外部供应商',
|
||||
contactPerson: form.value.contact, phone: form.value.phone, address: form.value.address,
|
||||
licenseName: form.value.licenseName,
|
||||
licenseExpiry: form.value.licenseExpiry?.format('YYYY-MM-DD') || '',
|
||||
creditScore: form.value.creditScore,
|
||||
})
|
||||
message.success('编辑成功')
|
||||
} else {
|
||||
data.value.unshift({
|
||||
id: randomId(),
|
||||
name: form.value.name || '新供应商',
|
||||
category: form.value.category || '蔬菜',
|
||||
contact: form.value.contact,
|
||||
address: form.value.address,
|
||||
qualification: 'reviewing',
|
||||
rating: 0,
|
||||
cooperationYears: 0,
|
||||
totalOrders: 0,
|
||||
lastDelivery: new Date().toISOString().slice(0, 10),
|
||||
allData.value.unshift({
|
||||
id: randomId(), name: form.value.name, ownership: form.value.ownership,
|
||||
supplierType: form.value.ownership === 'own' ? (form.value.supplierType || '种养公司') : '外部供应商',
|
||||
contactPerson: form.value.contact, phone: form.value.phone, address: form.value.address,
|
||||
licenseName: form.value.licenseName,
|
||||
licenseExpiry: form.value.licenseExpiry?.format('YYYY-MM-DD') || '',
|
||||
qualification: 'reviewing', creditScore: form.value.creditScore,
|
||||
totalAmount: 0, totalOrders: 0,
|
||||
cooperationStart: dayjs().format('YYYY-MM-DD'), lastTransaction: '',
|
||||
})
|
||||
message.success('新增成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ellipsis-text {
|
||||
display: inline-block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.page-container { padding: 0; }
|
||||
</style>
|
||||
|
||||
@@ -65,6 +65,14 @@
|
||||
<p><strong>厨师:</strong>{{ item.chef }}</p>
|
||||
<p><strong>温度:</strong>{{ item.temperature }}</p>
|
||||
<p><strong>餐次:</strong>{{ item.mealType }}</p>
|
||||
<p v-if="item.status === 'served'">
|
||||
<strong>开餐时间:</strong>{{ item.openTime }}
|
||||
<a-tag :color="item.shelfRemain > 30 ? 'green' : item.shelfRemain > 0 ? 'orange' : 'red'" style="margin-left:6px">
|
||||
{{ item.shelfRemain > 0 ? `剩余${item.shelfRemain}分钟` : '已过期下架' }}
|
||||
</a-tag>
|
||||
</p>
|
||||
<a-button v-if="item.status === 'done'" type="link" size="small" @click="openMeal(item)">开餐上架</a-button>
|
||||
<a-button v-if="item.status === 'served' && item.shelfRemain < 30 && item.shelfRemain > 0" type="link" size="small" @click="addMeal(item)">触发加餐</a-button>
|
||||
<a-steps :current="stepIndex(item.status)" size="small" style="margin-top: 12px">
|
||||
<a-step title="待烹制" />
|
||||
<a-step title="烹制中" />
|
||||
@@ -79,7 +87,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { genCookByOrderData, statusMap, canteenNames } from '@/mock/data'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { genCookByOrderData, statusMap, canteenNames, randomNum } from '@/mock/data'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import RelatedModules from '@/components/common/RelatedModules.vue'
|
||||
|
||||
@@ -89,7 +98,11 @@ const relatedLinks = [
|
||||
{ key: 'serving-monitor', label: '上餐监控', color: 'blue', arrow: '→' },
|
||||
]
|
||||
|
||||
const data = ref(genCookByOrderData(10))
|
||||
const data = ref(genCookByOrderData(10).map((d: any) => ({
|
||||
...d,
|
||||
openTime: d.status === 'served' ? new Date(Date.now() - randomNum(0, 120) * 60000).toISOString().slice(11, 16) : '',
|
||||
shelfRemain: d.status === 'served' ? randomNum(-30, 90) : 120,
|
||||
})))
|
||||
const statusFilter = ref<string | undefined>(undefined)
|
||||
const mealFilter = ref<string | undefined>(undefined)
|
||||
const filterCanteen = ref<string | undefined>(undefined)
|
||||
@@ -124,6 +137,21 @@ function stepIndex(status: string): number {
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
data.value = genCookByOrderData(10)
|
||||
data.value = genCookByOrderData(10).map((d: any) => ({
|
||||
...d,
|
||||
openTime: d.status === 'served' ? new Date(Date.now() - randomNum(0, 120) * 60000).toISOString().slice(11, 16) : '',
|
||||
shelfRemain: d.status === 'served' ? randomNum(-30, 90) : 120,
|
||||
}))
|
||||
}
|
||||
|
||||
function openMeal(item: any) {
|
||||
item.status = 'served'
|
||||
item.openTime = new Date().toTimeString().slice(0, 5)
|
||||
item.shelfRemain = 120
|
||||
message.success(`${item.dish} 已开餐上架,效期120分钟`)
|
||||
}
|
||||
|
||||
function addMeal(item: any) {
|
||||
message.success(`已触发 ${item.dish} 加餐,需求量基于消耗量自动计算`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,123 +1,184 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="食安监控" subtitle="实时食品安全检查与监控" />
|
||||
<a-page-header title="食安监控" subtitle="食品安全视频监控、环境安全与餐品留样管理" />
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<StatCard title="今日检查" :value="tableData.length" suffix="次" color="#1890ff" />
|
||||
<StatCard title="摄像头在线" :value="onlineCameras" suffix="台" color="#52c41a" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="合格率" :value="passRate" suffix="%" color="#52c41a" />
|
||||
<StatCard title="今日违规" :value="todayViolations" suffix="次" color="#f5222d" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="发现问题" :value="issueCount" suffix="项" color="#f5222d" />
|
||||
<StatCard title="环境告警" :value="envAlerts" suffix="项" color="#faad14" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="平均评分" :value="avgScore" suffix="分" color="#faad14" />
|
||||
<StatCard title="留样记录" :value="sampleData.length" suffix="条" color="#1890ff" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<a-card :bordered="false">
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="5">
|
||||
<a-select v-model:value="filterCanteen" placeholder="所属食堂" allowClear style="width: 100%">
|
||||
<a-select-option v-for="c in canteenNames" :key="c" :value="c">{{ c }}</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="5">
|
||||
<a-select v-model:value="filterArea" placeholder="区域筛选" allowClear style="width: 100%">
|
||||
<a-select-option v-for="a in areas" :key="a" :value="a">{{ a }}</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="5">
|
||||
<a-select v-model:value="filterResult" placeholder="结果筛选" allowClear style="width: 100%">
|
||||
<a-select-option value="passed">合格</a-select-option>
|
||||
<a-select-option value="warning">预警</a-select-option>
|
||||
<a-select-option value="failed">不合格</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-button type="primary" @click="refreshData">刷新数据</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-card>
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="video" tab="食安视频" />
|
||||
<a-tab-pane key="env" tab="环境安全" />
|
||||
<a-tab-pane key="sample" tab="餐品留样" />
|
||||
</a-tabs>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredData"
|
||||
:pagination="{ pageSize: 8, showTotal: (t: number) => `共 ${t} 条` }"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<!-- 食安视频 -->
|
||||
<a-table v-if="activeTab === 'video'" :columns="videoCols" :data-source="videoData"
|
||||
:pagination="{ pageSize: 10 }" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'result'">
|
||||
<a-tag :color="statusMap[record.result]?.color">{{ statusMap[record.result]?.text }}</a-tag>
|
||||
<template v-if="column.key === 'online'">
|
||||
<a-badge :status="record.online ? 'success' : 'error'" :text="record.online ? '在线' : '离线'" />
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'score'">
|
||||
<a-progress
|
||||
:percent="record.score"
|
||||
:stroke-color="record.score > 90 ? '#52c41a' : record.score > 70 ? '#faad14' : '#f5222d'"
|
||||
:size="'small'"
|
||||
/>
|
||||
<template v-if="column.key === 'violation'">
|
||||
<a-tag v-if="record.violation" color="red">{{ record.violationType }}</a-tag>
|
||||
<span v-else style="color:#999">无</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'handleStatus'">
|
||||
<a-tag :color="record.handled ? 'green' : 'orange'">{{ record.handled ? '已处理' : '待处理' }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 环境安全 -->
|
||||
<a-table v-if="activeTab === 'env'" :columns="envCols" :data-source="envData"
|
||||
:pagination="false" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'gasStatus'">
|
||||
<a-badge :status="record.gasAlarm ? 'error' : 'success'" :text="record.gasAlarm ? '报警' : '正常'" />
|
||||
</template>
|
||||
<template v-if="column.key === 'fireStatus'">
|
||||
<a-badge :status="record.fireOk ? 'success' : 'error'" :text="record.fireOk ? '正常' : '异常'" />
|
||||
</template>
|
||||
<template v-if="column.key === 'tempStatus'">
|
||||
<span :style="{ color: record.temperature > 28 || record.temperature < 5 ? '#f5222d' : '#52c41a' }">
|
||||
{{ record.temperature }}℃ / {{ record.humidity }}%
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 餐品留样 -->
|
||||
<div v-if="activeTab === 'sample'">
|
||||
<a-row :gutter="16" style="margin-bottom:16px" align="middle">
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="sampleStatusFilter" placeholder="留样状态" allow-clear style="width:100%">
|
||||
<a-select-option value="留样中">留样中</a-select-option>
|
||||
<a-select-option value="已到期">已到期</a-select-option>
|
||||
<a-select-option value="已销毁">已销毁</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-button type="primary" @click="addSample">新增留样</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-table :columns="sampleCols" :data-source="filteredSamples"
|
||||
:pagination="{ pageSize: 10 }" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === '留样中' ? 'blue' : record.status === '已到期' ? 'orange' : 'default'">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { randomId, randomNum, randomFloat, randomDate, randomTime, pick } from '@/mock/data'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import { genFoodSafetyData, statusMap, canteenNames } from '@/mock/data'
|
||||
|
||||
const tableData = ref(genFoodSafetyData(15))
|
||||
const filterCanteen = ref<string | undefined>(undefined)
|
||||
const filterArea = ref<string | undefined>(undefined)
|
||||
const filterResult = ref<string | undefined>(undefined)
|
||||
const activeTab = ref('video')
|
||||
|
||||
const areas = computed(() => [...new Set(tableData.value.map(d => d.area))])
|
||||
// ===== 食安视频 =====
|
||||
const violationTypes = ['未戴帽', '未戴口罩', '未穿工服', '操作台不洁']
|
||||
const videoData = ref(Array.from({ length: 20 }, (_, i) => {
|
||||
const hasViolation = Math.random() > 0.7
|
||||
return {
|
||||
id: randomId(), cameraId: 'CAM-' + String(i + 1).padStart(2, '0'),
|
||||
location: pick(['中餐A区', '中餐B区', '西餐区', '面点区', '凉菜区', '洗消间']),
|
||||
online: Math.random() > 0.15, violation: hasViolation,
|
||||
violationType: hasViolation ? pick(violationTypes) : '',
|
||||
violationTime: hasViolation ? randomDate(3) + ' ' + randomTime() : '',
|
||||
handled: hasViolation ? Math.random() > 0.5 : true,
|
||||
}
|
||||
}))
|
||||
|
||||
const filteredData = computed(() =>
|
||||
tableData.value.filter(d =>
|
||||
(!filterCanteen.value || d.canteen === filterCanteen.value) &&
|
||||
(!filterArea.value || d.area === filterArea.value) &&
|
||||
(!filterResult.value || d.result === filterResult.value)
|
||||
)
|
||||
)
|
||||
const onlineCameras = computed(() => videoData.value.filter(v => v.online).length)
|
||||
const todayViolations = computed(() => videoData.value.filter(v => v.violation).length)
|
||||
|
||||
const passRate = computed(() => {
|
||||
const passed = tableData.value.filter(d => d.result === 'passed').length
|
||||
return tableData.value.length ? +((passed / tableData.value.length) * 100).toFixed(1) : 0
|
||||
})
|
||||
|
||||
const issueCount = computed(() => tableData.value.filter(d => d.issues !== '无').length)
|
||||
|
||||
const avgScore = computed(() => {
|
||||
const sum = tableData.value.reduce((s, d) => s + d.score, 0)
|
||||
return tableData.value.length ? +(sum / tableData.value.length).toFixed(1) : 0
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '所属食堂', dataIndex: 'canteen', key: 'canteen', width: 100 },
|
||||
{ title: '区域', dataIndex: 'area', width: 100 },
|
||||
{ title: '检查类型', dataIndex: 'type', width: 100 },
|
||||
{ title: '检查时间', dataIndex: 'checkTime', width: 150 },
|
||||
{ title: '结果', dataIndex: 'result', width: 80 },
|
||||
{ title: '评分', dataIndex: 'score', width: 160 },
|
||||
{ title: '检查员', dataIndex: 'inspector', width: 90 },
|
||||
{ title: '问题描述', dataIndex: 'issues', ellipsis: true },
|
||||
{ title: '监控编号', dataIndex: 'camera', width: 110 },
|
||||
const videoCols = [
|
||||
{ title: '摄像头编号', dataIndex: 'cameraId', width: 120 },
|
||||
{ title: '位置', dataIndex: 'location', width: 100 },
|
||||
{ title: '在线状态', key: 'online', width: 90 },
|
||||
{ title: '违规事件', key: 'violation', width: 100 },
|
||||
{ title: '违规时间', dataIndex: 'violationTime', width: 150 },
|
||||
{ title: '处理状态', key: 'handleStatus', width: 90 },
|
||||
]
|
||||
|
||||
function refreshData() {
|
||||
tableData.value = genFoodSafetyData(15)
|
||||
// ===== 环境安全 =====
|
||||
const envData = ref(Array.from({ length: 8 }, () => ({
|
||||
id: randomId(), area: pick(['中餐A区', '中餐B区', '西餐区', '面点区', '凉菜区', '仓储区', '洗消间', '配餐间']),
|
||||
gasConcentration: randomFloat(0, 5, 2), gasAlarm: Math.random() > 0.9,
|
||||
fireOk: Math.random() > 0.1,
|
||||
temperature: randomFloat(5, 30, 1), humidity: randomFloat(30, 80, 0),
|
||||
lastCheck: randomDate(7) + ' ' + randomTime(),
|
||||
})))
|
||||
|
||||
const envAlerts = computed(() => envData.value.filter(e => e.gasAlarm || !e.fireOk || e.temperature > 28 || e.temperature < 5).length)
|
||||
|
||||
const envCols = [
|
||||
{ title: '区域', dataIndex: 'area', width: 100 },
|
||||
{ title: '天然气浓度(ppm)', dataIndex: 'gasConcentration', width: 140 },
|
||||
{ title: '天然气状态', key: 'gasStatus', width: 100 },
|
||||
{ title: '消防设施', key: 'fireStatus', width: 90 },
|
||||
{ title: '温度/湿度', key: 'tempStatus', width: 120 },
|
||||
{ title: '最近检查', dataIndex: 'lastCheck', width: 150 },
|
||||
]
|
||||
|
||||
// ===== 餐品留样 =====
|
||||
const dishNames = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '麻婆豆腐', '蒜蓉西兰花']
|
||||
const sampleData = ref(Array.from({ length: 15 }, () => ({
|
||||
id: randomId(), dishName: pick(dishNames),
|
||||
sampleWeight: randomNum(100, 250) + 'g',
|
||||
sampleTime: randomDate(7) + ' ' + randomTime(),
|
||||
expiryTime: randomDate(-2) + ' ' + randomTime(),
|
||||
cabinetNo: '留样柜' + randomNum(1, 3) + '-' + randomNum(1, 10),
|
||||
status: pick(['留样中', '留样中', '已到期', '已销毁']),
|
||||
})))
|
||||
|
||||
const sampleStatusFilter = ref<string>()
|
||||
const filteredSamples = computed(() => {
|
||||
if (!sampleStatusFilter.value) return sampleData.value
|
||||
return sampleData.value.filter(s => s.status === sampleStatusFilter.value)
|
||||
})
|
||||
|
||||
const sampleCols = [
|
||||
{ title: '留样菜品', dataIndex: 'dishName', width: 120 },
|
||||
{ title: '留样量', dataIndex: 'sampleWeight', width: 80 },
|
||||
{ title: '留样时间', dataIndex: 'sampleTime', width: 150 },
|
||||
{ title: '到期时间(48h)', dataIndex: 'expiryTime', width: 150 },
|
||||
{ title: '留样柜编号', dataIndex: 'cabinetNo', width: 120 },
|
||||
{ title: '留样状态', key: 'status', width: 90 },
|
||||
]
|
||||
|
||||
function addSample() {
|
||||
sampleData.value.unshift({
|
||||
id: randomId(), dishName: pick(dishNames),
|
||||
sampleWeight: '150g', sampleTime: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||
expiryTime: '', cabinetNo: '留样柜1-1', status: '留样中',
|
||||
})
|
||||
message.success('新增留样成功')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-container {
|
||||
padding: 0;
|
||||
}
|
||||
<style scoped>
|
||||
.page-container { padding: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,142 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="生产统计" />
|
||||
<a-page-header title="生产统计" subtitle="生产报表与成本利润分析" />
|
||||
|
||||
<!-- 顶部统计 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<StatCard title="今日菜品数" :value="statsData.today.totalDishes" color="#1890ff" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="今日出餐量" :value="statsData.today.totalServings" suffix="份" color="#52c41a" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="食材用量" :value="statsData.today.materialsUsed" color="#722ed1" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="浪费率" :value="statsData.today.wasteRate" color="#f5222d" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-card>
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="report" tab="生产报表" />
|
||||
<a-tab-pane key="cost" tab="成本利润" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 趋势图表 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="12">
|
||||
<ChartPanel title="本周出餐趋势" type="bar" :data="servingsChartData" :height="260" />
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<ChartPanel title="本周浪费趋势" type="line" :data="wasteChartData" :height="260" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
<!-- 生产报表 -->
|
||||
<div v-if="activeTab === 'report'">
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<StatCard title="今日菜品数" :value="statsData.today.totalDishes" color="#1890ff" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="今日出餐量" :value="statsData.today.totalServings" suffix="份" color="#52c41a" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="食材用量" :value="statsData.today.materialsUsed" color="#722ed1" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="浪费率" :value="statsData.today.wasteRate" color="#f5222d" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="12">
|
||||
<ChartPanel title="本周出餐趋势" type="bar" :data="servingsChartData" :height="260" />
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<ChartPanel title="本周浪费趋势" type="line" :data="wasteChartData" :height="260" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-card title="热门菜品排行" size="small">
|
||||
<a-table :columns="topDishColumns" :data-source="statsData.topDishes" :pagination="{ pageSize: 10 }" row-key="name" size="small">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'satisfaction'">
|
||||
<a-rate :value="record.satisfaction" disabled allow-half :style="{ fontSize: '14px' }" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 热门菜品排行 -->
|
||||
<a-card title="热门菜品排行">
|
||||
<a-table
|
||||
:columns="topDishColumns"
|
||||
:data-source="statsData.topDishes"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="name"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'satisfaction'">
|
||||
<a-rate :value="record.satisfaction" disabled allow-half :style="{ fontSize: '14px' }" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<!-- 成本利润 -->
|
||||
<div v-if="activeTab === 'cost'">
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<StatCard title="原材料成本" :value="costData.materialCost" suffix="元" color="#1890ff" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="人工成本" :value="costData.laborCost" suffix="元" color="#722ed1" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="水电气成本" :value="costData.utilityCost" suffix="元" color="#faad14" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard title="毛利率" :value="costData.profitRate" suffix="%" :color="costData.profitRate > 20 ? '#52c41a' : '#f5222d'" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="12">
|
||||
<ChartPanel title="成本结构" type="pie" :data="costPieData" :height="260" />
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<ChartPanel title="近7日利润趋势" type="line" :data="profitTrendData" :height="260" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card title="成本明细" size="small">
|
||||
<a-table :columns="costDetailCols" :data-source="costDetails" :pagination="false" row-key="date" size="small">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'profit'">
|
||||
<span :style="{ color: record.profit > 0 ? '#52c41a' : '#f5222d', fontWeight: 600 }">
|
||||
{{ record.profit > 0 ? '+' : '' }}¥{{ record.profit.toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
<a-alert style="margin-top:12px" type="info" show-icon
|
||||
message="原材料成本自动计算逻辑:原材料成本 = Σ(食材采购单价 × 实际使用量),采购单价自动关联采购订单,实际使用量自动关联净菜组配领用记录。" />
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed } from 'vue'
|
||||
import { genProductionStatsData } from '@/mock/data'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { genProductionStatsData, randomNum, randomFloat } from '@/mock/data'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import ChartPanel from '@/components/common/ChartPanel.vue'
|
||||
|
||||
const activeTab = ref('report')
|
||||
const statsData = reactive(genProductionStatsData())
|
||||
|
||||
const servingsChartData = computed(() =>
|
||||
statsData.weeklyTrend.map(item => ({
|
||||
label: item.day,
|
||||
value: item.servings,
|
||||
}))
|
||||
)
|
||||
|
||||
const wasteChartData = computed(() =>
|
||||
statsData.weeklyTrend.map(item => ({
|
||||
label: item.day,
|
||||
value: item.waste,
|
||||
}))
|
||||
)
|
||||
|
||||
const servingsChartData = computed(() => statsData.weeklyTrend.map(i => ({ label: i.day, value: i.servings })))
|
||||
const wasteChartData = computed(() => statsData.weeklyTrend.map(i => ({ label: i.day, value: i.waste })))
|
||||
const topDishColumns = [
|
||||
{ title: '菜品', dataIndex: 'name', key: 'name' },
|
||||
{ title: '出餐量', dataIndex: 'servings', key: 'servings', sorter: (a: any, b: any) => a.servings - b.servings },
|
||||
{ title: '满意度', dataIndex: 'satisfaction', key: 'satisfaction' },
|
||||
{ title: '菜品', dataIndex: 'name' },
|
||||
{ title: '出餐量', dataIndex: 'servings', sorter: (a: any, b: any) => a.servings - b.servings },
|
||||
{ title: '满意度', dataIndex: 'satisfaction' },
|
||||
]
|
||||
|
||||
// 成本利润
|
||||
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
const costDetails = days.map(day => {
|
||||
const material = randomNum(3000, 8000)
|
||||
const labor = randomNum(1500, 3000)
|
||||
const utility = randomNum(500, 1500)
|
||||
const revenue = randomNum(8000, 18000)
|
||||
const total = material + labor + utility
|
||||
return { date: day, materialCost: material, laborCost: labor, utilityCost: utility, totalCost: total, revenue, profit: revenue - total }
|
||||
})
|
||||
|
||||
const costData = reactive({
|
||||
materialCost: costDetails.reduce((s, d) => s + d.materialCost, 0),
|
||||
laborCost: costDetails.reduce((s, d) => s + d.laborCost, 0),
|
||||
utilityCost: costDetails.reduce((s, d) => s + d.utilityCost, 0),
|
||||
profitRate: +((costDetails.reduce((s, d) => s + d.profit, 0) / costDetails.reduce((s, d) => s + d.revenue, 0)) * 100).toFixed(1),
|
||||
})
|
||||
|
||||
const costPieData = computed(() => [
|
||||
{ label: '原材料', value: costData.materialCost },
|
||||
{ label: '人工', value: costData.laborCost },
|
||||
{ label: '水电气', value: costData.utilityCost },
|
||||
])
|
||||
|
||||
const profitTrendData = computed(() => costDetails.map(d => ({ label: d.date, value: d.profit })))
|
||||
|
||||
const costDetailCols = [
|
||||
{ title: '日期', dataIndex: 'date', width: 80 },
|
||||
{ title: '原材料(元)', dataIndex: 'materialCost', customRender: ({ text }: any) => '¥' + text.toLocaleString(), width: 110 },
|
||||
{ title: '人工(元)', dataIndex: 'laborCost', customRender: ({ text }: any) => '¥' + text.toLocaleString(), width: 100 },
|
||||
{ title: '水电气(元)', dataIndex: 'utilityCost', customRender: ({ text }: any) => '¥' + text.toLocaleString(), width: 100 },
|
||||
{ title: '总成本(元)', dataIndex: 'totalCost', customRender: ({ text }: any) => '¥' + text.toLocaleString(), width: 110 },
|
||||
{ title: '营收(元)', dataIndex: 'revenue', customRender: ({ text }: any) => '¥' + text.toLocaleString(), width: 100 },
|
||||
{ title: '利润', key: 'profit', width: 110 },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="食谱规划" subtitle="每周菜品与营养安排" />
|
||||
<a-page-header title="食谱规划" subtitle="每周菜品与营养安排,自动反算原料需求">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="calcMaterial">反算原料需求</a-button>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-tabs v-model:activeKey="activeDay">
|
||||
@@ -46,11 +50,26 @@
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-modal>
|
||||
<!-- 反算原料需求弹窗 -->
|
||||
<a-modal v-model:open="materialModalVisible" title="反算原料需求" @ok="materialModalVisible = false" width="700px">
|
||||
<a-radio-group v-model:value="calcMode" style="margin-bottom:16px">
|
||||
<a-radio-button value="net">反算净菜需求</a-radio-button>
|
||||
<a-radio-button value="raw">反算毛菜需求</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-alert v-if="calcMode === 'raw'" type="info" show-icon
|
||||
message="毛菜需求 = 净菜需求 ÷ (1 - 损耗率),按品种历史平均损耗率自动换算"
|
||||
style="margin-bottom:12px" />
|
||||
<a-table :columns="materialCols" :data-source="materialData" :pagination="false" size="small" row-key="material" />
|
||||
<div style="text-align:right;margin-top:16px">
|
||||
<a-button type="primary" @click="genOrder">生成采购订单</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { genRecipePlanData } from '@/mock/data'
|
||||
|
||||
const weekData = ref(genRecipePlanData())
|
||||
@@ -59,6 +78,42 @@ const editModalVisible = ref(false)
|
||||
const editTarget = reactive({ day: '', meal: '' })
|
||||
const selectedDishes = ref<string[]>([])
|
||||
|
||||
// 反算原料需求
|
||||
const materialModalVisible = ref(false)
|
||||
const calcMode = ref<'net' | 'raw'>('net')
|
||||
const materialRaw = ref([
|
||||
{ material: '白菜', dishCount: 12, netQty: 45, lossRate: 15 },
|
||||
{ material: '番茄', dishCount: 8, netQty: 20, lossRate: 8 },
|
||||
{ material: '猪里脊', dishCount: 6, netQty: 18, lossRate: 5 },
|
||||
{ material: '鸡胸肉', dishCount: 5, netQty: 15, lossRate: 6 },
|
||||
{ material: '黄瓜', dishCount: 7, netQty: 25, lossRate: 12 },
|
||||
{ material: '青椒', dishCount: 4, netQty: 12, lossRate: 10 },
|
||||
])
|
||||
|
||||
const materialData = computed(() => materialRaw.value.map(m => ({
|
||||
...m,
|
||||
rawQty: +(m.netQty / (1 - m.lossRate / 100)).toFixed(1),
|
||||
finalQty: calcMode.value === 'net' ? m.netQty + 'kg' : +(m.netQty / (1 - m.lossRate / 100)).toFixed(1) + 'kg',
|
||||
})))
|
||||
|
||||
const materialCols = [
|
||||
{ title: '品种', dataIndex: 'material', width: 100 },
|
||||
{ title: '涉及菜品数', dataIndex: 'dishCount', width: 100 },
|
||||
{ title: '净菜需求(kg)', dataIndex: 'netQty', width: 120 },
|
||||
{ title: '历史损耗率', dataIndex: 'lossRate', customRender: ({ text }: any) => text + '%', width: 110 },
|
||||
{ title: '毛菜需求(kg)', dataIndex: 'rawQty', width: 120 },
|
||||
{ title: '最终需求量', dataIndex: 'finalQty', width: 120 },
|
||||
]
|
||||
|
||||
function calcMaterial() {
|
||||
materialModalVisible.value = true
|
||||
}
|
||||
|
||||
function genOrder() {
|
||||
message.success(`已生成${calcMode.value === 'net' ? '净菜' : '毛菜'}采购订单,已推送至加工端`)
|
||||
materialModalVisible.value = false
|
||||
}
|
||||
|
||||
const allDishes = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '麻婆豆腐', '蒜蓉西兰花', '酸辣汤', '蛋炒饭', '紫菜蛋花汤', '水煮鱼', '回锅肉', '红烧茄子', '醋溜白菜', '干煸豆角', '凉拌黄瓜', '蒸鲈鱼', '炖牛腩', '小炒肉']
|
||||
|
||||
function mealColor(meal: string) {
|
||||
|
||||
@@ -44,7 +44,14 @@
|
||||
<a-descriptions-item label="餐次">{{ item.mealType }}</a-descriptions-item>
|
||||
<a-descriptions-item label="出餐时段">{{ item.servingStart.split(' ')[1] }} - {{ item.servingEnd.split(' ')[1] }}</a-descriptions-item>
|
||||
<a-descriptions-item label="已出餐">{{ item.totalServed }}份</a-descriptions-item>
|
||||
<a-descriptions-item label="温度">{{ item.temperature }}</a-descriptions-item>
|
||||
<a-descriptions-item label="当前温度">{{ item.temperature }}</a-descriptions-item>
|
||||
<a-descriptions-item label="出品时间">{{ item.produceTime }}</a-descriptions-item>
|
||||
<a-descriptions-item label="保质时限">{{ item.shelfLimit }}分钟</a-descriptions-item>
|
||||
<a-descriptions-item label="剩余有效期">
|
||||
<a-tag :color="item.shelfRemain > 30 ? 'green' : item.shelfRemain > 0 ? 'orange' : 'red'">
|
||||
{{ item.shelfRemain > 0 ? item.shelfRemain + '分钟' : '已过期' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="值班员" :span="2">{{ item.monitor }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
@@ -74,7 +81,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { genServingMonitorData, statusMap, canteenNames } from '@/mock/data'
|
||||
import { genServingMonitorData, statusMap, canteenNames, randomNum } from '@/mock/data'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import RelatedModules from '@/components/common/RelatedModules.vue'
|
||||
|
||||
@@ -84,7 +91,12 @@ const relatedLinks = [
|
||||
{ key: 'consumption-mgmt', label: '消费管理', color: 'blue', arrow: '→' },
|
||||
]
|
||||
|
||||
const data = ref(genServingMonitorData(8))
|
||||
const data = ref(genServingMonitorData(8).map((d: any) => ({
|
||||
...d,
|
||||
produceTime: d.servingStart.split(' ')[1],
|
||||
shelfLimit: 120,
|
||||
shelfRemain: randomNum(-20, 110),
|
||||
})))
|
||||
const filterCanteen = ref<string | undefined>(undefined)
|
||||
|
||||
const filteredData = computed(() =>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="disposalFilter" placeholder="处置方式" allow-clear style="width: 100%">
|
||||
<a-select-option value="打折销售">打折销售</a-select-option>
|
||||
<a-select-option value="员工餐">员工餐</a-select-option>
|
||||
<a-select-option value="捐赠">捐赠</a-select-option>
|
||||
<a-select-option value="堆肥">堆肥</a-select-option>
|
||||
@@ -59,6 +60,7 @@
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="处置方式">
|
||||
<a-select v-model:value="editDisposal" style="width: 100%">
|
||||
<a-select-option value="打折销售">打折销售</a-select-option>
|
||||
<a-select-option value="员工餐">员工餐</a-select-option>
|
||||
<a-select-option value="捐赠">捐赠</a-select-option>
|
||||
<a-select-option value="堆肥">堆肥</a-select-option>
|
||||
@@ -86,6 +88,7 @@ const currentRecord = ref<any>(null)
|
||||
const editDisposal = ref('')
|
||||
|
||||
const disposalColorMap: Record<string, string> = {
|
||||
'打折销售': 'cyan',
|
||||
'员工餐': 'blue',
|
||||
'捐赠': 'green',
|
||||
'堆肥': 'orange',
|
||||
|
||||
@@ -27,8 +27,13 @@
|
||||
</a-row>
|
||||
|
||||
<a-card>
|
||||
<!-- 工具栏 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="record" tab="加工记录" />
|
||||
<a-tab-pane key="loss" tab="损耗统计" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 加工记录工具栏 -->
|
||||
<a-row v-if="activeTab === 'record'" :gutter="16" style="margin-bottom: 16px" align="middle">
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="lineFilter" style="width: 100%" placeholder="加工线">
|
||||
<a-select-option value="">全部加工线</a-select-option>
|
||||
@@ -50,6 +55,7 @@
|
||||
</a-row>
|
||||
|
||||
<a-table
|
||||
v-if="activeTab === 'record'"
|
||||
:columns="columns"
|
||||
:data-source="filteredData"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
||||
@@ -67,6 +73,23 @@
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 损耗统计 -->
|
||||
<div v-if="activeTab === 'loss'">
|
||||
<a-table :columns="lossColumns" :data-source="lossStats" :pagination="false" row-key="material" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'avgLoss'">
|
||||
<span :style="{ color: record.avgLoss > 30 ? '#ff4d4f' : '#52c41a', fontWeight: 600 }">
|
||||
{{ record.avgLoss }}%
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'abnormal'">
|
||||
<a-tag v-if="record.maxLoss > 35" color="red">异常</a-tag>
|
||||
<a-tag v-else color="green">正常</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -75,6 +98,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { genCleanProcessData, statusMap } from '@/mock/data'
|
||||
|
||||
const activeTab = ref('record')
|
||||
const tableData = ref(genCleanProcessData(25))
|
||||
const lineFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
@@ -118,6 +142,32 @@ const columns = [
|
||||
{ title: '操作员', dataIndex: 'operator', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
]
|
||||
|
||||
// 损耗统计
|
||||
const lossStats = computed(() => {
|
||||
const map = new Map<string, number[]>()
|
||||
tableData.value.forEach(r => {
|
||||
const loss = parseFloat(r.lossRate)
|
||||
if (!map.has(r.material)) map.set(r.material, [])
|
||||
map.get(r.material)!.push(loss)
|
||||
})
|
||||
return Array.from(map.entries()).map(([material, losses]) => ({
|
||||
material,
|
||||
batchCount: losses.length,
|
||||
avgLoss: +(losses.reduce((a, b) => a + b, 0) / losses.length).toFixed(1),
|
||||
maxLoss: +Math.max(...losses).toFixed(1),
|
||||
minLoss: +Math.min(...losses).toFixed(1),
|
||||
}))
|
||||
})
|
||||
|
||||
const lossColumns = [
|
||||
{ title: '品种', dataIndex: 'material', width: 100 },
|
||||
{ title: '加工批次数', dataIndex: 'batchCount', width: 100 },
|
||||
{ title: '平均损耗率', key: 'avgLoss', width: 110 },
|
||||
{ title: '最高损耗率', customRender: ({ record }: any) => record.maxLoss + '%', width: 110 },
|
||||
{ title: '最低损耗率', customRender: ({ record }: any) => record.minLoss + '%', width: 110 },
|
||||
{ title: '异常标记', key: 'abnormal', width: 90 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -5,81 +5,126 @@
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="检测总数" :value="tableData.length" suffix="批次" />
|
||||
</a-card>
|
||||
<a-card><a-statistic title="检测总数" :value="tableData.length" suffix="批次" /></a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="合格率" :value="passRate" suffix="%" :value-style="{ color: '#52c41a' }" />
|
||||
</a-card>
|
||||
<a-card><a-statistic title="合格率" :value="passRate" suffix="%" :value-style="{ color: '#52c41a' }" /></a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="不合格数" :value="failedCount" :value-style="{ color: '#ff4d4f' }" />
|
||||
</a-card>
|
||||
<a-card><a-statistic title="不合格数" :value="failedCount" :value-style="{ color: '#ff4d4f' }" /></a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="今日检测" :value="todayCount" suffix="批次" />
|
||||
</a-card>
|
||||
<a-card><a-statistic title="检测报告" :value="reportData.length" suffix="份" /></a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card>
|
||||
<!-- 工具栏 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
|
||||
<a-col :span="6">
|
||||
<a-range-picker v-model:value="dateRange" style="width: 100%" />
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="record" tab="检测记录" />
|
||||
<a-tab-pane key="report" tab="检测报告" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 检测记录工具栏 -->
|
||||
<a-row v-if="activeTab === 'record'" :gutter="16" style="margin-bottom:16px" align="middle">
|
||||
<a-col :span="5">
|
||||
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号" allow-clear />
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="resultFilter" style="width: 100%" placeholder="检测结果">
|
||||
<a-select-option value="">全部</a-select-option>
|
||||
<a-select v-model:value="testItemFilter" placeholder="检测项" allow-clear style="width:100%">
|
||||
<a-select-option value="农药残留">农药残留</a-select-option>
|
||||
<a-select-option value="重金属">重金属</a-select-option>
|
||||
<a-select-option value="化肥残留">化肥残留</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="resultFilter" placeholder="检测结果" allow-clear style="width:100%">
|
||||
<a-select-option value="passed">合格</a-select-option>
|
||||
<a-select-option value="failed">不合格</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="5">
|
||||
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号" allow-clear />
|
||||
<a-range-picker v-model:value="dateRange" style="width:100%" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredData"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<!-- 检测记录表格 -->
|
||||
<a-table v-if="activeTab === 'record'" :columns="recordColumns" :data-source="filteredRecords"
|
||||
:pagination="{ pageSize: 10, showTotal: (t: number) => `共 ${t} 条` }" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'result'">
|
||||
<a-tag :color="statusMap[record.result]?.color">{{ statusMap[record.result]?.text }}</a-tag>
|
||||
<template v-if="column.key === 'result'">
|
||||
<a-tag :color="record.result === 'passed' ? 'green' : 'red'">{{ record.result === 'passed' ? '合格' : '不合格' }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="showReport(record)">查看报告</a-button>
|
||||
<a-button type="link" size="small" @click="showDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 检测报告工具栏 -->
|
||||
<a-row v-if="activeTab === 'report'" :gutter="16" style="margin-bottom:16px" align="middle">
|
||||
<a-col :span="5">
|
||||
<a-input-search v-model:value="reportSearch" placeholder="搜索报告编号/批次" allow-clear />
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-select v-model:value="uploadMethodFilter" placeholder="上传方式" allow-clear style="width:100%">
|
||||
<a-select-option value="device">设备直连</a-select-option>
|
||||
<a-select-option value="ocr">OCR识别</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 检测报告表格 -->
|
||||
<a-table v-if="activeTab === 'report'" :columns="reportColumns" :data-source="filteredReports"
|
||||
:pagination="{ pageSize: 10 }" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'uploadMethod'">
|
||||
<a-tag :color="record.uploadMethod === 'device' ? 'blue' : 'purple'">
|
||||
{{ record.uploadMethod === 'device' ? '设备直连' : 'OCR识别' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="showReportDetail(record)">查看</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 报告详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="drawerVisible"
|
||||
title="检测报告详情"
|
||||
:width="520"
|
||||
>
|
||||
<!-- 检测记录详情抽屉 -->
|
||||
<a-drawer v-model:open="detailVisible" title="检测记录详情" :width="520">
|
||||
<a-descriptions v-if="currentRecord" :column="1" bordered size="small">
|
||||
<a-descriptions-item label="批次号">{{ currentRecord.batchNo }}</a-descriptions-item>
|
||||
<a-descriptions-item label="食材名称">{{ currentRecord.material }}</a-descriptions-item>
|
||||
<a-descriptions-item label="检测项目">{{ currentRecord.testItem }}</a-descriptions-item>
|
||||
<a-descriptions-item label="检测项目">
|
||||
<a-tag color="blue">{{ currentRecord.testItem }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="检测日期">{{ currentRecord.testDate }}</a-descriptions-item>
|
||||
<a-descriptions-item label="检测结果">
|
||||
<a-tag :color="statusMap[currentRecord.result]?.color">{{ statusMap[currentRecord.result]?.text }}</a-tag>
|
||||
<a-tag :color="currentRecord.result === 'passed' ? 'green' : 'red'">
|
||||
{{ currentRecord.result === 'passed' ? '合格' : '不合格' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="检测值">{{ currentRecord.value }}</a-descriptions-item>
|
||||
<a-descriptions-item label="标准值">{{ currentRecord.standard }}</a-descriptions-item>
|
||||
<a-descriptions-item label="检测员">{{ currentRecord.inspector }}</a-descriptions-item>
|
||||
<a-descriptions-item label="报告编号">{{ currentRecord.report }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关联报告">{{ currentRecord.report }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 检测报告详情抽屉 -->
|
||||
<a-drawer v-model:open="reportDetailVisible" title="检测报告详情" :width="520">
|
||||
<a-descriptions v-if="currentReport" :column="1" bordered size="small">
|
||||
<a-descriptions-item label="报告编号">{{ currentReport.reportNo }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关联批次">{{ currentReport.batchNo }}</a-descriptions-item>
|
||||
<a-descriptions-item label="检测项明细">
|
||||
<a-tag v-for="item in currentReport.testItems" :key="item" color="blue" style="margin:2px">{{ item }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="上传方式">
|
||||
<a-tag :color="currentReport.uploadMethod === 'device' ? 'blue' : 'purple'">
|
||||
{{ currentReport.uploadMethod === 'device' ? '设备直连' : 'OCR识别' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="上传时间">{{ currentReport.uploadTime }}</a-descriptions-item>
|
||||
<a-descriptions-item label="报告文件">{{ currentReport.fileName }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-drawer>
|
||||
</div>
|
||||
@@ -88,68 +133,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { genHygieneTestData, statusMap } from '@/mock/data'
|
||||
import { genHygieneTestData, randomId, randomDate, randomTime, randomNum, pick } from '@/mock/data'
|
||||
|
||||
const activeTab = ref('record')
|
||||
const tableData = ref(genHygieneTestData(30))
|
||||
|
||||
// 检测记录
|
||||
const searchText = ref('')
|
||||
const resultFilter = ref('')
|
||||
const testItemFilter = ref<string>()
|
||||
const resultFilter = ref<string>()
|
||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
||||
const drawerVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<any>(null)
|
||||
|
||||
const passRate = computed(() => {
|
||||
if (!tableData.value.length) return 0
|
||||
const passed = tableData.value.filter(r => r.result === 'passed').length
|
||||
return +((passed / tableData.value.length) * 100).toFixed(1)
|
||||
return +((tableData.value.filter(r => r.result === 'passed').length / tableData.value.length) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
const failedCount = computed(() => tableData.value.filter(r => r.result === 'failed').length)
|
||||
|
||||
const todayCount = computed(() => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
return tableData.value.filter(r => r.testDate === today).length
|
||||
})
|
||||
|
||||
const filteredData = computed(() => {
|
||||
const filteredRecords = computed(() => {
|
||||
return tableData.value.filter(item => {
|
||||
if (searchText.value) {
|
||||
const kw = searchText.value.toLowerCase()
|
||||
if (!item.material.toLowerCase().includes(kw) && !item.batchNo.toLowerCase().includes(kw)) return false
|
||||
}
|
||||
if (resultFilter.value && item.result !== resultFilter.value) return false
|
||||
if (dateRange.value) {
|
||||
const [start, end] = dateRange.value
|
||||
const d = item.testDate
|
||||
if (d < start.format('YYYY-MM-DD') || d > end.format('YYYY-MM-DD')) return false
|
||||
}
|
||||
return true
|
||||
const ms = !searchText.value || item.material.includes(searchText.value) || item.batchNo.includes(searchText.value)
|
||||
const mt = !testItemFilter.value || item.testItem === testItemFilter.value
|
||||
const mr = !resultFilter.value || item.result === resultFilter.value
|
||||
const md = !dateRange.value || (item.testDate >= dateRange.value[0].format('YYYY-MM-DD') && item.testDate <= dateRange.value[1].format('YYYY-MM-DD'))
|
||||
return ms && mt && mr && md
|
||||
})
|
||||
})
|
||||
|
||||
const columns = [
|
||||
const recordColumns = [
|
||||
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
|
||||
{ title: '食材名', dataIndex: 'material', width: 100 },
|
||||
{ title: '检测项目', dataIndex: 'testItem', width: 100 },
|
||||
{ title: '检测日期', dataIndex: 'testDate', width: 110 },
|
||||
{ title: '结果', dataIndex: 'result', width: 80 },
|
||||
{ title: '检测值', dataIndex: 'value', width: 100 },
|
||||
{ title: '标准值', dataIndex: 'standard', width: 100 },
|
||||
{ title: '检测员', dataIndex: 'inspector', width: 90 },
|
||||
{ title: '报告编号', dataIndex: 'report', width: 120 },
|
||||
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
|
||||
{ title: '食材', dataIndex: 'material', width: 90 },
|
||||
{ title: '检测项', dataIndex: 'testItem', width: 90 },
|
||||
{ title: '农残值', dataIndex: 'value', width: 90 },
|
||||
{ title: '标准值', dataIndex: 'standard', width: 90 },
|
||||
{ title: '结果', key: 'result', width: 80 },
|
||||
{ title: '检测员', dataIndex: 'inspector', width: 80 },
|
||||
{ title: '检测时间', dataIndex: 'testDate', width: 110 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
]
|
||||
|
||||
function showReport(record: any) {
|
||||
currentRecord.value = record
|
||||
drawerVisible.value = true
|
||||
}
|
||||
</script>
|
||||
function showDetail(record: any) { currentRecord.value = record; detailVisible.value = true }
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-container {
|
||||
:deep(.ant-statistic-title) {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
// 检测报告
|
||||
interface Report { id: string; reportNo: string; batchNo: string; testItems: string[]; fileName: string; uploadMethod: 'device' | 'ocr'; uploadTime: string }
|
||||
|
||||
const reportData = ref<Report[]>(
|
||||
Array.from({ length: 15 }, () => ({
|
||||
id: randomId(),
|
||||
reportNo: 'RPT' + randomNum(10000, 99999),
|
||||
batchNo: 'HT' + randomDate(14).replace(/-/g, '') + randomNum(100, 999),
|
||||
testItems: ['农药残留', '重金属', '化肥残留'].filter(() => Math.random() > 0.3),
|
||||
fileName: '检测报告_' + randomNum(1000, 9999) + '.pdf',
|
||||
uploadMethod: pick(['device', 'ocr']) as 'device' | 'ocr',
|
||||
uploadTime: randomDate(14) + ' ' + randomTime(),
|
||||
}))
|
||||
)
|
||||
|
||||
const reportSearch = ref('')
|
||||
const uploadMethodFilter = ref<string>()
|
||||
const reportDetailVisible = ref(false)
|
||||
const currentReport = ref<Report | null>(null)
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
return reportData.value.filter(r => {
|
||||
const ms = !reportSearch.value || r.reportNo.includes(reportSearch.value) || r.batchNo.includes(reportSearch.value)
|
||||
const mu = !uploadMethodFilter.value || r.uploadMethod === uploadMethodFilter.value
|
||||
return ms && mu
|
||||
})
|
||||
})
|
||||
|
||||
const reportColumns = [
|
||||
{ title: '报告编号', dataIndex: 'reportNo', width: 140 },
|
||||
{ title: '关联批次', dataIndex: 'batchNo', width: 160 },
|
||||
{ title: '检测项数', customRender: ({ record }: any) => record.testItems.length + '项', width: 80 },
|
||||
{ title: '报告文件', dataIndex: 'fileName', width: 180 },
|
||||
{ title: '上传方式', key: 'uploadMethod', width: 100 },
|
||||
{ title: '上传时间', dataIndex: 'uploadTime', width: 150 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
]
|
||||
|
||||
function showReportDetail(record: Report) { currentReport.value = record; reportDetailVisible.value = true }
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="毛菜入库" subtitle="原始食材入库管理与登记" />
|
||||
<a-page-header title="毛菜管理" subtitle="毛菜入库与库存台账" />
|
||||
<RelatedModules :links="relatedLinks" />
|
||||
|
||||
<a-card>
|
||||
<!-- 工具栏 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="storage" tab="毛菜入库" />
|
||||
<a-tab-pane key="ledger" tab="毛菜台账" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 毛菜入库工具栏 -->
|
||||
<a-row v-if="activeTab === 'storage'" :gutter="16" style="margin-bottom: 16px" align="middle">
|
||||
<a-col :span="6">
|
||||
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号/供应商" allow-clear />
|
||||
</a-col>
|
||||
@@ -26,6 +31,7 @@
|
||||
</a-row>
|
||||
|
||||
<a-table
|
||||
v-if="activeTab === 'storage'"
|
||||
:columns="columns"
|
||||
:data-source="filteredData"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
||||
@@ -38,6 +44,16 @@
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 毛菜台账 -->
|
||||
<a-table
|
||||
v-if="activeTab === 'ledger'"
|
||||
:columns="ledgerColumns"
|
||||
:data-source="ledgerData"
|
||||
:pagination="false"
|
||||
row-key="material"
|
||||
size="middle"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增入库 Modal -->
|
||||
@@ -91,10 +107,41 @@ const relatedLinks = [
|
||||
]
|
||||
|
||||
const tableData = ref(genRawStorageData(28))
|
||||
const activeTab = ref('storage')
|
||||
const searchText = ref('')
|
||||
const statusFilter = ref('')
|
||||
const modalVisible = ref(false)
|
||||
|
||||
// 毛菜台账(按品种汇总)
|
||||
const ledgerData = computed(() => {
|
||||
const map = new Map<string, { material: string; currentStock: number; totalIn: number; totalOut: number; lastIn: string }>()
|
||||
tableData.value.forEach(r => {
|
||||
const qty = parseFloat(r.quantity)
|
||||
if (!map.has(r.material)) {
|
||||
map.set(r.material, { material: r.material, currentStock: 0, totalIn: 0, totalOut: 0, lastIn: '' })
|
||||
}
|
||||
const item = map.get(r.material)!
|
||||
item.totalIn += qty
|
||||
if (r.status === 'stored' || r.status === 'pending') item.currentStock += qty
|
||||
if (r.status === 'processing') item.totalOut += qty
|
||||
if (!item.lastIn || r.receiveDate > item.lastIn) item.lastIn = r.receiveDate
|
||||
})
|
||||
return Array.from(map.values()).map(d => ({
|
||||
...d,
|
||||
currentStock: d.currentStock.toFixed(0) + 'kg',
|
||||
totalIn: d.totalIn.toFixed(0) + 'kg',
|
||||
totalOut: d.totalOut.toFixed(0) + 'kg',
|
||||
}))
|
||||
})
|
||||
|
||||
const ledgerColumns = [
|
||||
{ title: '品种', dataIndex: 'material', width: 120 },
|
||||
{ title: '当前库存', dataIndex: 'currentStock', width: 120 },
|
||||
{ title: '入库总量', dataIndex: 'totalIn', width: 120 },
|
||||
{ title: '出库总量', dataIndex: 'totalOut', width: 120 },
|
||||
{ title: '最近入库', dataIndex: 'lastIn', width: 120 },
|
||||
]
|
||||
|
||||
const materialOptions = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '猪里脊', '鸡胸肉']
|
||||
const supplierOptions = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有机之家']
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="算法标准" sub-title="营养标准参数配置" />
|
||||
<a-page-header title="算法标准" sub-title="营养标准参数与营养方案配置" />
|
||||
<a-card>
|
||||
<a-tabs v-model:activeKey="topTab">
|
||||
<a-tab-pane key="standard" tab="算法标准" />
|
||||
<a-tab-pane key="plan" tab="营养方案" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 算法标准 -->
|
||||
<div v-if="topTab === 'standard'">
|
||||
<a-alert type="info" show-icon style="margin-bottom:16px"
|
||||
message="食物交换份标准:每份90大卡为1份,菜品自动拆解为主食/菜品/饮品/水果份数" />
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane v-for="g in groups" :key="g.key" :tab="g.label">
|
||||
<a-form layout="vertical" :model="config[g.key]">
|
||||
@@ -69,6 +78,47 @@
|
||||
<a-button type="primary" @click="save">保存配置</a-button>
|
||||
<a-button @click="reset">重置</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 营养方案 -->
|
||||
<div v-if="topTab === 'plan'">
|
||||
<a-tabs v-model:activeKey="planTab">
|
||||
<a-tab-pane key="personal" tab="个性化方案" />
|
||||
<a-tab-pane key="group" tab="大众方案" />
|
||||
</a-tabs>
|
||||
|
||||
<div v-if="planTab === 'personal'">
|
||||
<a-alert type="info" show-icon style="margin-bottom:16px"
|
||||
message="个性化方案:基于BMI模型+体质健康问卷综合评估自动生成,支持人工干预调整" />
|
||||
<a-table :columns="personalCols" :data-source="personalPlans" :pagination="false" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === '已发布' ? 'green' : 'orange'">{{ record.status }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small">编辑</a-button>
|
||||
<a-button type="link" size="small">详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<div v-if="planTab === 'group'">
|
||||
<a-alert type="info" show-icon style="margin-bottom:16px"
|
||||
message="大众方案:面向工厂/院校团体场景(含正餐/加班餐/夜宵),院校权限可按学段折算" />
|
||||
<a-table :columns="groupCols" :data-source="groupPlans" :pagination="false" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'scenarios'">
|
||||
<a-tag v-for="s in record.scenarios" :key="s" color="blue">{{ s }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small">编辑</a-button>
|
||||
<a-button type="link" size="small">详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,7 +127,48 @@
|
||||
import { ref, reactive } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const topTab = ref('standard')
|
||||
const planTab = ref('personal')
|
||||
const activeTab = ref('male')
|
||||
|
||||
// 个性化方案
|
||||
const personalPlans = [
|
||||
{ id: 'p1', name: '孕妇营养方案', crowd: '孕妇', bmiRange: '18.5-24.9', calories: '2200kcal', exchangeShare: '24份', status: '已发布', creator: '营养师A' },
|
||||
{ id: 'p2', name: '少儿营养方案', crowd: '少儿', bmiRange: '15-20', calories: '1600kcal', exchangeShare: '18份', status: '已发布', creator: '营养师B' },
|
||||
{ id: 'p3', name: '老人营养方案', crowd: '老人', bmiRange: '20-26.9', calories: '1800kcal', exchangeShare: '20份', status: '已发布', creator: '营养师A' },
|
||||
{ id: 'p4', name: '儿童营养方案', crowd: '儿童', bmiRange: '14-19', calories: '1400kcal', exchangeShare: '16份', status: '已发布', creator: '营养师B' },
|
||||
{ id: 'p5', name: '普通成人方案', crowd: '普通成人', bmiRange: '18.5-23.9', calories: '2000kcal', exchangeShare: '22份', status: '已发布', creator: '营养师A' },
|
||||
]
|
||||
|
||||
const personalCols = [
|
||||
{ title: '方案名', dataIndex: 'name', width: 140 },
|
||||
{ title: '适用人群', dataIndex: 'crowd', width: 100 },
|
||||
{ title: 'BMI范围', dataIndex: 'bmiRange', width: 110 },
|
||||
{ title: '每日热量', dataIndex: 'calories', width: 100 },
|
||||
{ title: '交换份总数', dataIndex: 'exchangeShare', width: 100 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '创建人', dataIndex: 'creator', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 120 },
|
||||
]
|
||||
|
||||
// 大众方案
|
||||
const groupPlans = [
|
||||
{ id: 'g1', name: '工厂标准方案', scenario: '工厂', scenarios: ['正餐', '加班餐', '夜宵'], avgCalories: '2300kcal', coverage: '85%', status: '已发布' },
|
||||
{ id: 'g2', name: '院校大学方案', scenario: '院校-大学', scenarios: ['正餐'], avgCalories: '2100kcal', coverage: '92%', status: '已发布' },
|
||||
{ id: 'g3', name: '院校中学方案', scenario: '院校-中学', scenarios: ['正餐'], avgCalories: '1900kcal', coverage: '88%', status: '已发布' },
|
||||
{ id: 'g4', name: '院校小学方案', scenario: '院校-小学', scenarios: ['正餐'], avgCalories: '1600kcal', coverage: '90%', status: '已发布' },
|
||||
]
|
||||
|
||||
const groupCols = [
|
||||
{ title: '方案名', dataIndex: 'name', width: 140 },
|
||||
{ title: '场景', dataIndex: 'scenario', width: 120 },
|
||||
{ title: '涵盖餐次', key: 'scenarios', width: 180 },
|
||||
{ title: '平均热量', dataIndex: 'avgCalories', width: 100 },
|
||||
{ title: '推荐覆盖率', dataIndex: 'coverage', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 120 },
|
||||
]
|
||||
|
||||
const groups = [
|
||||
{ key: 'male', label: '成人男性' },
|
||||
{ key: 'female', label: '成人女性' },
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
<a-select-option v-for="c in categories" :key="c" :value="c">{{ c }}</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="message.success('打开新增表单')">+ 新增餐品</a-button>
|
||||
<a-button @click="downloadTemplate">下载导入模板</a-button>
|
||||
<a-upload :before-upload="handleImport" :show-upload-list="false" accept=".xlsx,.csv">
|
||||
<a-button>批量导入</a-button>
|
||||
</a-upload>
|
||||
</div>
|
||||
<div class="dish-grid">
|
||||
<a-card v-for="d in filtered" :key="d.id" hoverable class="dish-card">
|
||||
@@ -53,6 +57,27 @@ const filtered = computed(() =>
|
||||
return m && c
|
||||
})
|
||||
)
|
||||
|
||||
function downloadTemplate() {
|
||||
const headers = '菜品名,主材,辅材,配比,加工工艺,菜系,荤素,冷热,人群标签\n'
|
||||
const example = '红烧肉,五花肉,葱姜蒜,500g:50g,炖,川菜,荤,热,低糖\n'
|
||||
const blob = new Blob(['' + headers + example], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '餐品总库导入模板.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
message.success('模板已下载')
|
||||
}
|
||||
|
||||
function handleImport(file: File) {
|
||||
message.loading({ content: `正在解析 ${file.name}...`, key: 'import' })
|
||||
setTimeout(() => {
|
||||
message.success({ content: `导入完成:成功 12 条,失败 0 条,重复 1 条`, key: 'import', duration: 3 })
|
||||
}, 1500)
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="食材效益" sub-title="食材成本与损耗分析" />
|
||||
<a-page-header title="餐材效益" sub-title="食材效益与餐品效益分析" />
|
||||
<a-card :bordered="false" style="margin-bottom:16px">
|
||||
<a-tabs v-model:activeKey="benefitTab">
|
||||
<a-tab-pane key="ingredient" tab="食材效益" />
|
||||
<a-tab-pane key="dish" tab="餐品效益" />
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
<div v-if="benefitTab === 'ingredient'">
|
||||
<a-row :gutter="16" class="stats">
|
||||
<a-col :span="6">
|
||||
<a-card><a-statistic title="本月采购总额" :value="156800" prefix="¥" /></a-card>
|
||||
@@ -19,13 +26,40 @@
|
||||
<a-card style="margin-top: 16px">
|
||||
<a-table :columns="columns" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 餐品效益 -->
|
||||
<div v-if="benefitTab === 'dish'">
|
||||
<a-row :gutter="16" class="stats">
|
||||
<a-col :span="6"><a-card><a-statistic title="餐品总数" :value="dishBenefitData.length" /></a-card></a-col>
|
||||
<a-col :span="6"><a-card><a-statistic title="平均毛利率" :value="avgDishProfit" suffix="%" :value-style="{ color: '#52c41a' }" /></a-card></a-col>
|
||||
<a-col :span="6"><a-card><a-statistic title="高效益餐品" :value="highProfitCount" /></a-card></a-col>
|
||||
<a-col :span="6"><a-card><a-statistic title="低效益餐品" :value="lowProfitCount" /></a-card></a-col>
|
||||
</a-row>
|
||||
<a-card style="margin-top:16px">
|
||||
<a-table :columns="dishCols" :data-source="dishBenefitData" :pagination="{ pageSize: 10 }" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'profitRate'">
|
||||
<span :style="{ color: record.profitRate > 30 ? '#52c41a' : record.profitRate > 15 ? '#faad14' : '#f5222d', fontWeight: 600 }">
|
||||
{{ record.profitRate }}%
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'rating'">
|
||||
<a-rate :value="record.rating" disabled allow-half :style="{ fontSize: '12px' }" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import ChartPanel from '@/components/common/ChartPanel.vue'
|
||||
import { randomNum, randomFloat, pick } from '@/mock/data'
|
||||
|
||||
const benefitTab = ref('ingredient')
|
||||
const materials = ['猪肉', '鸡肉', '大米', '青菜', '番茄', '土豆', '鸡蛋', '面粉']
|
||||
|
||||
const list = ref(materials.map((name, i) => {
|
||||
@@ -56,6 +90,34 @@ const columns = [
|
||||
{ title: '损耗率', dataIndex: 'lossRate' },
|
||||
{ title: '成本/kg', dataIndex: 'costPerKg' },
|
||||
]
|
||||
|
||||
// 餐品效益
|
||||
const dishNames = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '麻婆豆腐', '蒜蓉西兰花', '蛋炒饭', '水煮鱼']
|
||||
const dishBenefitData = dishNames.map((name, i) => {
|
||||
const cost = randomNum(5, 25)
|
||||
const price = randomNum(12, 45)
|
||||
const sales = randomNum(50, 500)
|
||||
return {
|
||||
id: i, name, materialCost: cost, sellingPrice: price,
|
||||
profitRate: +((price - cost) / price * 100).toFixed(1),
|
||||
dailySales: sales, dailyRevenue: price * sales,
|
||||
rating: randomFloat(3.0, 5.0),
|
||||
}
|
||||
})
|
||||
|
||||
const avgDishProfit = computed(() => +(dishBenefitData.reduce((s, d) => s + d.profitRate, 0) / dishBenefitData.length).toFixed(1))
|
||||
const highProfitCount = computed(() => dishBenefitData.filter(d => d.profitRate > 30).length)
|
||||
const lowProfitCount = computed(() => dishBenefitData.filter(d => d.profitRate < 15).length)
|
||||
|
||||
const dishCols = [
|
||||
{ title: '餐品', dataIndex: 'name', width: 120 },
|
||||
{ title: '原料成本(元)', dataIndex: 'materialCost', width: 110 },
|
||||
{ title: '售价(元)', dataIndex: 'sellingPrice', width: 90 },
|
||||
{ title: '毛利率', key: 'profitRate', width: 90 },
|
||||
{ title: '日均销量', dataIndex: 'dailySales', width: 90 },
|
||||
{ title: '日均营收(元)', dataIndex: 'dailyRevenue', customRender: ({ text }: any) => '¥' + text.toLocaleString(), width: 120 },
|
||||
{ title: '评分', key: 'rating', width: 130 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<template>
|
||||
<div class="nutrition-mgmt">
|
||||
<a-page-header title="营养管理" sub-title="就餐人员营养数据分析与监控" />
|
||||
<a-page-header title="营养管理" sub-title="营养数据分析、指标预警与慢病预警" />
|
||||
|
||||
<a-card :bordered="false" style="margin-bottom:16px">
|
||||
<a-tabs v-model:activeKey="topTab">
|
||||
<a-tab-pane key="nutrition" tab="营养管理" />
|
||||
<a-tab-pane key="indicator" tab="指标预警" />
|
||||
<a-tab-pane key="chronic" tab="慢病预警" />
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
|
||||
<div v-if="topTab === 'nutrition'">
|
||||
<!-- 筛选工具栏 -->
|
||||
<a-card :bordered="false" class="filter-bar">
|
||||
<a-space size="middle" wrap>
|
||||
@@ -247,6 +256,41 @@
|
||||
/>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
|
||||
<!-- 指标预警 -->
|
||||
<div v-if="topTab === 'indicator'">
|
||||
<a-table :columns="indicatorCols" :data-source="indicatorData" :pagination="{ pageSize: 10 }" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'deviation'">
|
||||
<span :style="{ color: Math.abs(record.deviation) > 20 ? '#f5222d' : '#faad14' }">{{ record.deviation > 0 ? '+' : '' }}{{ record.deviation }}%</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="record.level === '严重' ? 'red' : record.level === '警告' ? 'orange' : 'blue'">{{ record.level }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'handleStatus'">
|
||||
<a-tag :color="record.handled ? 'green' : 'orange'">{{ record.handled ? '已处理' : '待处理' }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 慢病预警 -->
|
||||
<div v-if="topTab === 'chronic'">
|
||||
<a-table :columns="chronicCols" :data-source="chronicData" :pagination="{ pageSize: 10 }" row-key="id" size="middle">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'riskScore'">
|
||||
<a-progress :percent="record.riskScore" size="small" :stroke-color="record.riskScore > 70 ? '#f5222d' : record.riskScore > 40 ? '#faad14' : '#52c41a'" />
|
||||
</template>
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="record.level === '高风险' ? 'red' : record.level === '中风险' ? 'orange' : 'green'">{{ record.level }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'riskItems'">
|
||||
<a-tag v-for="r in record.riskItems" :key="r" color="orange" style="margin:2px">{{ r }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -257,6 +301,7 @@ import { randomNum, randomFloat, pick } from '@/mock/data'
|
||||
|
||||
// ============ 筛选状态 ============
|
||||
const analysisMode = ref<'meal' | 'daily' | 'weekly' | 'person'>('meal')
|
||||
const topTab = ref('nutrition')
|
||||
const filterMeal = ref('')
|
||||
const filterDept = ref('')
|
||||
const filterPerson = ref('')
|
||||
@@ -575,6 +620,49 @@ function viewPersonDetail(record: any) {
|
||||
drawerPerson.value = record
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// ============ 指标预警 ============
|
||||
const indicatorData = personList.map(name => ({
|
||||
id: name + '_ind', user: name, indicator: pick(['BMI', '体脂率', '血糖', '血压']),
|
||||
currentValue: pick(['26.5', '32%', '6.8mmol/L', '145/95mmHg']),
|
||||
normalRange: pick(['18.5-23.9', '15-25%', '3.9-6.1mmol/L', '90-140/60-90mmHg']),
|
||||
deviation: randomNum(-30, 40), level: pick(['关注', '警告', '严重']),
|
||||
triggerTime: '2026-04-' + String(randomNum(15, 22)).padStart(2, '0') + ' ' + randomNum(8, 18) + ':00',
|
||||
handled: Math.random() > 0.4, suggestion: pick(['建议低脂饮食', '建议增加运动', '建议就医检查', '建议减少碳水摄入']),
|
||||
}))
|
||||
|
||||
const indicatorCols = [
|
||||
{ title: '用户', dataIndex: 'user', width: 80 },
|
||||
{ title: '预警指标', dataIndex: 'indicator', width: 90 },
|
||||
{ title: '当前值', dataIndex: 'currentValue', width: 120 },
|
||||
{ title: '正常范围', dataIndex: 'normalRange', width: 140 },
|
||||
{ title: '偏离度', key: 'deviation', width: 90 },
|
||||
{ title: '预警等级', key: 'level', width: 90 },
|
||||
{ title: '触发时间', dataIndex: 'triggerTime', width: 150 },
|
||||
{ title: '处理状态', key: 'handleStatus', width: 90 },
|
||||
{ title: '建议措施', dataIndex: 'suggestion', ellipsis: true },
|
||||
]
|
||||
|
||||
// ============ 慢病预警 ============
|
||||
const chronicData = personList.slice(0, 6).map(name => ({
|
||||
id: name + '_chr', user: name,
|
||||
diseaseType: pick(['糖尿病', '高血压', '高血脂', '痛风']),
|
||||
riskScore: randomNum(20, 90),
|
||||
level: pick(['高风险', '中风险', '低风险']),
|
||||
riskItems: ['高糖摄入', '高钠摄入', '高脂摄入', '低纤维'].filter(() => Math.random() > 0.5),
|
||||
suggestion: pick(['推荐低糖食谱', '推荐低钠食谱', '推荐低脂食谱', '推荐高纤维食谱']),
|
||||
trackRecord: randomNum(1, 8) + '次跟踪',
|
||||
}))
|
||||
|
||||
const chronicCols = [
|
||||
{ title: '用户', dataIndex: 'user', width: 80 },
|
||||
{ title: '慢病类型', dataIndex: 'diseaseType', width: 90 },
|
||||
{ title: '风险评分', key: 'riskScore', width: 150 },
|
||||
{ title: '预警等级', key: 'level', width: 90 },
|
||||
{ title: '饮食风险项', key: 'riskItems', width: 200 },
|
||||
{ title: '干预建议', dataIndex: 'suggestion', width: 140 },
|
||||
{ title: '跟踪记录', dataIndex: 'trackRecord', width: 100 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-page-header title="营养点餐" sub-title="基于营养目标的智能点餐订单" />
|
||||
<a-page-header title="营养点餐" sub-title="个人/团体点餐与小程序订单" />
|
||||
<RelatedModules :links="relatedLinks" />
|
||||
<a-card>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="personal" tab="个人点餐" />
|
||||
<a-tab-pane key="group" tab="团体点餐" />
|
||||
<a-tab-pane key="cooked" tab="熟餐订单" />
|
||||
<a-tab-pane key="clean" tab="净菜订单" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 个人点餐 -->
|
||||
<a-table v-if="activeTab === 'personal'" :columns="personalCols" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id">
|
||||
<template #expandedRowRender="{ record }">
|
||||
<a-list size="small" :data-source="record.dishes">
|
||||
<template #renderItem="{ item }">
|
||||
@@ -21,15 +24,47 @@
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'score'">
|
||||
<a-progress
|
||||
type="circle"
|
||||
:percent="record.nutritionScore"
|
||||
:width="56"
|
||||
:stroke-color="scoreColor(record.nutritionScore)"
|
||||
/>
|
||||
<a-progress type="circle" :percent="record.nutritionScore" :size="56" :stroke-color="scoreColor(record.nutritionScore)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
|
||||
<template v-else-if="column.key === 'match'">
|
||||
<a-progress :percent="record.matchRate" size="small" :stroke-color="record.matchRate > 80 ? '#52c41a' : '#faad14'" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'taboo'">
|
||||
<a-tag v-if="record.tabooFiltered" color="orange">已过滤</a-tag>
|
||||
<a-tag v-else color="green">无禁忌</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 团体点餐 -->
|
||||
<a-table v-if="activeTab === 'group'" :columns="groupCols" :data-source="groupOrders" :pagination="{ pageSize: 10 }" row-key="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'coverage'">
|
||||
<a-progress :percent="record.coverage" size="small" :stroke-color="record.coverage > 85 ? '#52c41a' : '#faad14'" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 熟餐订单 -->
|
||||
<a-table v-if="activeTab === 'cooked'" :columns="cookedCols" :data-source="cookedOrders" :pagination="{ pageSize: 10 }" row-key="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="orderStatusColor(record.status)">{{ record.status }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'method'">
|
||||
<a-tag :color="record.method === '配送' ? 'blue' : 'green'">{{ record.method }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 净菜订单 -->
|
||||
<a-table v-if="activeTab === 'clean'" :columns="cleanCols" :data-source="cleanOrders" :pagination="{ pageSize: 10 }" row-key="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="orderStatusColor(record.status)">{{ record.status }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'method'">
|
||||
<a-tag :color="record.method === '配送' ? 'blue' : 'green'">{{ record.method }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -39,7 +74,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { genNutritionOrderData, statusMap } from '@/mock/data'
|
||||
import { genNutritionOrderData, randomId, randomNum, randomFloat, randomDate, randomTime, pick } from '@/mock/data'
|
||||
import RelatedModules from '@/components/common/RelatedModules.vue'
|
||||
|
||||
const relatedLinks = [
|
||||
@@ -49,22 +84,104 @@ const relatedLinks = [
|
||||
{ key: 'pickup-mgmt', label: '取餐管理', color: 'blue', arrow: '→' },
|
||||
]
|
||||
|
||||
const list = ref(genNutritionOrderData(15))
|
||||
const activeTab = ref('personal')
|
||||
const list = ref(genNutritionOrderData(15).map((o: any) => ({
|
||||
...o,
|
||||
matchRate: randomNum(60, 98),
|
||||
tabooFiltered: Math.random() > 0.6,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo' },
|
||||
{ title: '用户', dataIndex: 'user', key: 'user' },
|
||||
{ title: '餐次', dataIndex: 'mealType', key: 'mealType' },
|
||||
{ title: '目标热量', dataIndex: 'targetCalories', key: 'targetCalories' },
|
||||
{ title: '实际热量', dataIndex: 'totalCalories', key: 'totalCalories' },
|
||||
const personalCols = [
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '用户', dataIndex: 'user' },
|
||||
{ title: '餐次', dataIndex: 'mealType' },
|
||||
{ title: '目标热量', dataIndex: 'targetCalories' },
|
||||
{ title: '营养匹配度', key: 'match', width: 140 },
|
||||
{ title: '禁忌过滤', key: 'taboo', width: 90 },
|
||||
{ title: '营养评分', key: 'score', width: 100 },
|
||||
{ title: '日期', dataIndex: 'date', key: 'date' },
|
||||
{ title: '状态', key: 'status' },
|
||||
{ title: '日期', dataIndex: 'date' },
|
||||
]
|
||||
|
||||
// 团体点餐
|
||||
const groupOrders = Array.from({ length: 8 }, () => ({
|
||||
id: randomId(),
|
||||
groupName: pick(['研发部', '市场部', '财务部', '人事部', '一年级三班', '二年级一班']),
|
||||
groupType: pick(['部门', '班级']),
|
||||
peopleCount: randomNum(15, 80),
|
||||
crowdProfile: pick(['年轻成人为主', '中年混合', '青少年']),
|
||||
budget: '¥' + randomNum(20, 50) + '/人',
|
||||
totalAmount: '¥' + randomNum(500, 4000),
|
||||
coverage: randomNum(70, 98),
|
||||
date: randomDate(7),
|
||||
}))
|
||||
const groupCols = [
|
||||
{ title: '团体名称', dataIndex: 'groupName', width: 120 },
|
||||
{ title: '类型', dataIndex: 'groupType', width: 80 },
|
||||
{ title: '人数', dataIndex: 'peopleCount', width: 70 },
|
||||
{ title: '人群特征', dataIndex: 'crowdProfile', width: 130 },
|
||||
{ title: '人均预算', dataIndex: 'budget', width: 100 },
|
||||
{ title: '总金额', dataIndex: 'totalAmount', width: 100 },
|
||||
{ title: '营养覆盖率', key: 'coverage', width: 140 },
|
||||
{ title: '日期', dataIndex: 'date', width: 110 },
|
||||
]
|
||||
|
||||
// 熟餐订单(小程序)
|
||||
const orderStatuses = ['待支付', '已支付', '制作中', '待取餐', '已完成', '已取消']
|
||||
const cookedOrders = Array.from({ length: 12 }, () => ({
|
||||
id: randomId(),
|
||||
orderNo: 'CK' + randomDate(7).replace(/-/g, '') + randomNum(100, 999),
|
||||
user: pick(['张伟', '李芳', '王娜', '刘敏', '陈强']),
|
||||
mealType: pick(['早餐', '午餐', '晚餐']),
|
||||
dishes: pick(['红烧肉+米饭', '宫保鸡丁套餐', '麻婆豆腐+面条']),
|
||||
count: randomNum(1, 3) + '份',
|
||||
amount: '¥' + randomNum(15, 60),
|
||||
method: pick(['配送', '自取']),
|
||||
status: pick(orderStatuses),
|
||||
orderTime: randomDate(7) + ' ' + randomTime(),
|
||||
}))
|
||||
const cookedCols = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{ title: '用户', dataIndex: 'user', width: 80 },
|
||||
{ title: '餐次', dataIndex: 'mealType', width: 70 },
|
||||
{ title: '菜品', dataIndex: 'dishes', width: 180 },
|
||||
{ title: '份数', dataIndex: 'count', width: 70 },
|
||||
{ title: '金额', dataIndex: 'amount', width: 80 },
|
||||
{ title: '方式', key: 'method', width: 70 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '下单时间', dataIndex: 'orderTime', width: 150 },
|
||||
]
|
||||
|
||||
// 净菜订单(小程序)
|
||||
const cleanStatuses = ['待支付', '已支付', '备货中', '待取货', '已完成', '已取消']
|
||||
const cleanOrders = Array.from({ length: 10 }, () => ({
|
||||
id: randomId(),
|
||||
orderNo: 'CL' + randomDate(7).replace(/-/g, '') + randomNum(100, 999),
|
||||
user: pick(['张伟', '李芳', '王娜', '刘敏', '陈强']),
|
||||
variety: pick(['净土豆', '净白菜', '净番茄', '净胡萝卜']),
|
||||
spec: randomFloat(0.5, 5, 1) + 'kg',
|
||||
amount: '¥' + randomNum(8, 80),
|
||||
method: pick(['配送', '自取']),
|
||||
status: pick(cleanStatuses),
|
||||
orderTime: randomDate(7) + ' ' + randomTime(),
|
||||
}))
|
||||
const cleanCols = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{ title: '用户', dataIndex: 'user', width: 80 },
|
||||
{ title: '净菜品种', dataIndex: 'variety', width: 100 },
|
||||
{ title: '规格', dataIndex: 'spec', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', width: 80 },
|
||||
{ title: '方式', key: 'method', width: 70 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '下单时间', dataIndex: 'orderTime', width: 150 },
|
||||
]
|
||||
|
||||
const scoreColor = (s: number) => s >= 90 ? '#52c41a' : s >= 80 ? '#1890ff' : s >= 70 ? '#faad14' : '#f5222d'
|
||||
const orderStatusColor = (s: string) => {
|
||||
const map: Record<string, string> = { '待支付': 'orange', '已支付': 'blue', '制作中': 'cyan', '备货中': 'cyan', '待取餐': 'purple', '待取货': 'purple', '已完成': 'green', '已取消': 'default' }
|
||||
return map[s] || 'default'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cal { color: #fa8c16; float: right; }
|
||||
</style>
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="dict-config">
|
||||
<a-row :gutter="16">
|
||||
<!-- 左侧:字典类型列表 -->
|
||||
<a-col :span="8">
|
||||
<a-card title="字典类型" size="small">
|
||||
<template #extra>
|
||||
<a-button type="link" size="small" @click="openTypeModal()">新增</a-button>
|
||||
</template>
|
||||
<a-input-search
|
||||
v-model:value="typeSearch"
|
||||
placeholder="搜索类型名称/编码"
|
||||
style="margin-bottom: 12px"
|
||||
allow-clear
|
||||
/>
|
||||
<a-table
|
||||
:columns="typeColumns"
|
||||
:data-source="filteredTypes"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
row-key="id"
|
||||
:row-class-name="(record: any) => record.id === selectedType?.id ? 'selected-row' : ''"
|
||||
:custom-row="(record: any) => ({ onClick: () => selectType(record) })"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- PLACEHOLDER_TYPE_TABLE -->
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'enabled' ? 'green' : 'red'">
|
||||
{{ record.status === 'enabled' ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click.stop="openTypeModal(record)">编辑</a-button>
|
||||
<a-popconfirm title="确定删除?" @confirm="removeType(record.id)">
|
||||
<a-button type="link" size="small" danger @click.stop>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧:字典项列表 -->
|
||||
<a-col :span="16">
|
||||
<a-card :title="selectedType ? `字典项 - ${selectedType.name}` : '字典项'" size="small">
|
||||
<template #extra>
|
||||
<a-button type="link" size="small" :disabled="!selectedType" @click="openItemModal()">新增</a-button>
|
||||
</template>
|
||||
<a-empty v-if="!selectedType" description="请选择左侧字典类型" />
|
||||
<a-table
|
||||
v-else
|
||||
:columns="itemColumns"
|
||||
:data-source="currentItems"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 'enabled'"
|
||||
checked-children="启用"
|
||||
un-checked-children="停用"
|
||||
@change="toggleItemStatus(record)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openItemModal(record)">编辑</a-button>
|
||||
<a-popconfirm title="确定删除?" @confirm="removeItem(record.id)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 字典类型弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="typeModalVisible"
|
||||
:title="editingType ? '编辑字典类型' : '新增字典类型'"
|
||||
@ok="saveType"
|
||||
@cancel="typeModalVisible = false"
|
||||
>
|
||||
<a-form :label-col="{ span: 5 }">
|
||||
<a-form-item label="类型名称">
|
||||
<a-input v-model:value="typeForm.name" placeholder="请输入类型名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="类型编码">
|
||||
<a-input v-model:value="typeForm.code" placeholder="请输入类型编码" :disabled="!!editingType" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="typeForm.status">
|
||||
<a-radio value="enabled">启用</a-radio>
|
||||
<a-radio value="disabled">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="typeForm.remark" :rows="2" placeholder="请输入备注" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 字典项弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="itemModalVisible"
|
||||
:title="editingItem ? '编辑字典项' : '新增字典项'"
|
||||
@ok="saveItem"
|
||||
@cancel="itemModalVisible = false"
|
||||
>
|
||||
<a-form :label-col="{ span: 5 }">
|
||||
<a-form-item label="字典标签">
|
||||
<a-input v-model:value="itemForm.label" placeholder="请输入字典标签" />
|
||||
</a-form-item>
|
||||
<a-form-item label="字典值">
|
||||
<a-input v-model:value="itemForm.value" placeholder="请输入字典值" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model:value="itemForm.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="itemForm.status">
|
||||
<a-radio value="enabled">启用</a-radio>
|
||||
<a-radio value="disabled">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="itemForm.remark" :rows="2" placeholder="请输入备注" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { dictTypes as initTypes, dictItems as initItems, randomId } from '@/mock/data'
|
||||
import type { DictType, DictItem } from '@/mock/data'
|
||||
|
||||
// 字典类型
|
||||
const types = ref<DictType[]>([...initTypes])
|
||||
const typeSearch = ref('')
|
||||
const selectedType = ref<DictType | null>(null)
|
||||
const typeModalVisible = ref(false)
|
||||
const editingType = ref<DictType | null>(null)
|
||||
const typeForm = ref({ name: '', code: '', status: 'enabled' as const, remark: '' })
|
||||
|
||||
const filteredTypes = computed(() => {
|
||||
const kw = typeSearch.value.toLowerCase()
|
||||
if (!kw) return types.value
|
||||
return types.value.filter(t => t.name.includes(kw) || t.code.includes(kw))
|
||||
})
|
||||
|
||||
const typeColumns = [
|
||||
{ title: '类型名称', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{ title: '编码', dataIndex: 'code', key: 'code', ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 70 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
]
|
||||
|
||||
function selectType(record: DictType) {
|
||||
selectedType.value = record
|
||||
}
|
||||
|
||||
function openTypeModal(record?: DictType) {
|
||||
editingType.value = record || null
|
||||
typeForm.value = record
|
||||
? { name: record.name, code: record.code, status: record.status, remark: record.remark }
|
||||
: { name: '', code: '', status: 'enabled', remark: '' }
|
||||
typeModalVisible.value = true
|
||||
}
|
||||
|
||||
function saveType() {
|
||||
if (!typeForm.value.name || !typeForm.value.code) {
|
||||
message.warning('请填写完整信息')
|
||||
return
|
||||
}
|
||||
if (editingType.value) {
|
||||
Object.assign(editingType.value, typeForm.value)
|
||||
message.success('修改成功')
|
||||
} else {
|
||||
types.value.push({ id: randomId(), ...typeForm.value, createTime: new Date().toISOString().slice(0, 10) })
|
||||
message.success('新增成功')
|
||||
}
|
||||
typeModalVisible.value = false
|
||||
}
|
||||
|
||||
function removeType(id: string) {
|
||||
types.value = types.value.filter(t => t.id !== id)
|
||||
if (selectedType.value?.id === id) selectedType.value = null
|
||||
items.value = items.value.filter(i => i.typeCode !== types.value.find(t => t.id === id)?.code)
|
||||
message.success('删除成功')
|
||||
}
|
||||
|
||||
// 字典项
|
||||
const items = ref<DictItem[]>([...initItems])
|
||||
const itemModalVisible = ref(false)
|
||||
const editingItem = ref<DictItem | null>(null)
|
||||
const itemForm = ref({ label: '', value: '', sort: 0, status: 'enabled' as const, remark: '' })
|
||||
|
||||
const currentItems = computed(() => {
|
||||
if (!selectedType.value) return []
|
||||
return items.value
|
||||
.filter(i => i.typeCode === selectedType.value!.code)
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
})
|
||||
|
||||
const itemColumns = [
|
||||
{ title: '字典标签', dataIndex: 'label', key: 'label' },
|
||||
{ title: '字典值', dataIndex: 'value', key: 'value' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 70 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '操作', key: 'action', width: 120 },
|
||||
]
|
||||
|
||||
function openItemModal(record?: DictItem) {
|
||||
editingItem.value = record || null
|
||||
itemForm.value = record
|
||||
? { label: record.label, value: record.value, sort: record.sort, status: record.status, remark: record.remark }
|
||||
: { label: '', value: '', sort: currentItems.value.length + 1, status: 'enabled', remark: '' }
|
||||
itemModalVisible.value = true
|
||||
}
|
||||
|
||||
function saveItem() {
|
||||
if (!itemForm.value.label || !itemForm.value.value) {
|
||||
message.warning('请填写完整信息')
|
||||
return
|
||||
}
|
||||
if (editingItem.value) {
|
||||
Object.assign(editingItem.value, itemForm.value)
|
||||
message.success('修改成功')
|
||||
} else {
|
||||
items.value.push({ id: randomId(), typeCode: selectedType.value!.code, ...itemForm.value })
|
||||
message.success('新增成功')
|
||||
}
|
||||
itemModalVisible.value = false
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
items.value = items.value.filter(i => i.id !== id)
|
||||
message.success('删除成功')
|
||||
}
|
||||
|
||||
function toggleItemStatus(record: DictItem) {
|
||||
record.status = record.status === 'enabled' ? 'disabled' : 'enabled'
|
||||
message.success(`已${record.status === 'enabled' ? '启用' : '停用'}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dict-config {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.selected-row) {
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
:deep(.ant-table-row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user