Compare commits
15
Commits
f9772b99cb
...
main
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 仅当本次提交涉及 admin 源代码或配置时,运行 admin 构建(含 vue-tsc 类型检查)
|
||||||
|
# 通过检查暂存区是否有 zhican/admin/ 下的相关文件来决定
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CHANGED=$(git diff --cached --name-only --diff-filter=ACMR \
|
||||||
|
| grep -E '^zhican/admin/(src/|.*\.(json|ts|html))' || true)
|
||||||
|
|
||||||
|
if [ -z "$CHANGED" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ pre-commit: 检测到 admin 变更,运行 pnpm run build..."
|
||||||
|
cd "$(git rev-parse --show-toplevel)/zhican/admin"
|
||||||
|
|
||||||
|
if ! command -v pnpm >/dev/null 2>&1; then
|
||||||
|
echo "pre-commit: 未找到 pnpm,跳过构建检查(请手动运行 pnpm run build 验证)" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
pnpm run build
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# ============ OS / 系统垃圾 ============
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# ============ 编辑器 / IDE ============
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ============ 工具本地数据 ============
|
||||||
|
# Claude Code 个人设置(共享设置可放 .claude/settings.json)
|
||||||
|
.claude/settings.local.json
|
||||||
|
# Serena MCP 工具数据
|
||||||
|
.serena/
|
||||||
|
|
||||||
|
# ============ 临时/备份 ============
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# VCS 触发器(只触发 main 分支)在 TeamCity UI / 项目 Kotlin DSL 中配置,
|
||||||
|
# YAML 格式仅描述 jobs 与 steps 结构。
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
BuildAndDeploy:
|
||||||
|
name: 构建并部署 Admin
|
||||||
|
steps:
|
||||||
|
- type: script
|
||||||
|
name: Docker Build
|
||||||
|
script-content: |
|
||||||
|
docker build \
|
||||||
|
-f deploy/Dockerfile \
|
||||||
|
-t zhican-admin:latest \
|
||||||
|
.
|
||||||
|
- type: script
|
||||||
|
name: Compose Up
|
||||||
|
script-content: |
|
||||||
|
docker compose -f deploy/compose.yaml up -d
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 仓库结构
|
||||||
|
|
||||||
|
这是一个**多端单仓**项目,git 根目录下包含:
|
||||||
|
|
||||||
|
- `tcm-diagnosis.html` — 独立的中医诊断单页 HTML(与下面项目无依赖关系)
|
||||||
|
- `zhican/` — 智能餐养系统主项目,使用 npm workspaces 管理
|
||||||
|
- `admin/` — Vue 3 + Vite + TypeScript 后台管理端(workspace 成员)
|
||||||
|
- `miniprogram/` — 原生微信小程序(无构建步骤,由微信开发者工具直接打开 `project.config.json`)
|
||||||
|
|
||||||
|
注意:执行 npm 脚本前必须先 `cd zhican/`,根目录没有 `package.json`。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
**包管理器统一使用 pnpm**。`package-lock.json` 已被 `.gitignore`,禁止提交 npm/yarn 锁文件。仓库追踪的锁文件是 `zhican/admin/pnpm-lock.yaml`。
|
||||||
|
|
||||||
|
进入 `zhican/admin/` 目录执行(推荐):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # 安装依赖
|
||||||
|
pnpm run dev # 启动开发服务器(vite,端口 3000)
|
||||||
|
pnpm run build # 类型检查 + 构建(vue-tsc --noEmit && vite build),产物在 dist/
|
||||||
|
pnpm run preview # 本地预览构建产物
|
||||||
|
```
|
||||||
|
|
||||||
|
也可在 `zhican/` 根目录用 workspace 命令 `pnpm run admin:dev` / `admin:build`。
|
||||||
|
|
||||||
|
> 由于 `zhican/package.json` 用的是 npm workspaces 字段而非 `pnpm-workspace.yaml`,跨 workspace 命令的稳定性不如直接进 admin 子目录。一般操作建议进 `zhican/admin/`。
|
||||||
|
|
||||||
|
小程序端无构建步骤,使用微信开发者工具导入 `zhican/miniprogram/` 目录调试。
|
||||||
|
|
||||||
|
## 部署形态(H5 构建能力)
|
||||||
|
|
||||||
|
- **admin**:标准 Vite 项目,`npm run admin:build` 输出 `admin/dist/`(`index.html` + 静态 chunk),可直接部署到任意静态服务器/Nginx/对象存储
|
||||||
|
- **miniprogram**:**原生微信小程序,无法直接构建为 H5**。它使用 `.wxml` / `.wxss` / `wx.*` API 和 `app.json` tabBar,运行时绑定在微信宿主上。要得到浏览器可访问的 H5 版本必须迁移到 uni-app / Taro 等跨端框架重写,不是"加个构建脚本"就能完成
|
||||||
|
- **`tcm-diagnosis.html`**:单文件 HTML,无构建,直接打开或托管即可
|
||||||
|
|
||||||
|
## admin 后台架构
|
||||||
|
|
||||||
|
技术栈:Vue 3 (`<script setup>`) + Vite 6 + TypeScript + Pinia + vue-router 4 + Ant Design Vue 4。
|
||||||
|
|
||||||
|
### 自动导入与按需组件
|
||||||
|
|
||||||
|
`vite.config.ts` 配置了两个 unplugin:
|
||||||
|
|
||||||
|
- `unplugin-auto-import`:自动注入 `vue` / `vue-router` / `pinia` 的 API(`ref`、`useRouter`、`defineStore` 等无需手动 import),类型声明写入 `src/auto-imports.d.ts`
|
||||||
|
- `unplugin-vue-components` + `AntDesignVueResolver`:按需注册 ant-design-vue 组件,类型声明写入 `src/components.d.ts`(`importStyle: false`,依赖 antd 自带样式)
|
||||||
|
|
||||||
|
新增第三方库的全局 API 时需要在 `vite.config.ts` 的 `imports` 数组里登记,否则 TS 会报未定义。
|
||||||
|
|
||||||
|
### 路径与代理
|
||||||
|
|
||||||
|
- 别名 `@` → `src`(在 `vite.config.ts` 和 `tsconfig.json` 同步配置)
|
||||||
|
- dev server 代理 `/api` → `http://localhost:8080`,所以前端请求都写相对路径
|
||||||
|
- **vendor 拆分**:`build.rollupOptions.output.manualChunks` 仅把 vue 全家桶(vue/vue-router/pinia)单独打包成 `vue-vendor`,其余依赖(尤其是 ant-design-vue)维持按路由代码分割。**不要把 ant-design-vue 写进 manualChunks**——它体积约 1MB,强行单 chunk 会拖累首屏;按路由懒加载更优
|
||||||
|
|
||||||
|
### 路由组织(关键)
|
||||||
|
|
||||||
|
`src/router/index.ts` 采用**扁平注册**:所有业务页平铺在 `modules` 数组里,按注释分为 5 大业务域:
|
||||||
|
|
||||||
|
- 数据概览(dashboard / business-flow)
|
||||||
|
- 绿色种采(land-env、planting-monitor、harvest-storage、supplier-mgmt、procurement-mgmt)
|
||||||
|
- 卫生供应(hygiene-test、raw-storage、clean-process、clean-storage、clean-internal、clean-external)
|
||||||
|
- 健康生产(food-safety、recipe-plan、clean-order、clean-cabinet、clean-assemble、cook-by-order、serving-monitor、surplus-disposal、production-stats)
|
||||||
|
- 营养用餐(nutrition-test、food-composition、dish-inventory、algorithm-standard、recipe-mgmt、nutrition-order、user-mgmt、meal-line、pickup-mgmt、consumption-mgmt、nutrition-mgmt、ingredient-benefit、terminal-mgmt、model-mgmt、flavor-innovation)
|
||||||
|
- 系统管理(org-structure、employee-mgmt、role-mgmt、canteen-mgmt)
|
||||||
|
|
||||||
|
所有业务页都是 `MainLayout.vue` 的子路由,路径无前缀(`/dashboard` 而非 `/green-planting/land-env`)。每条路由通过 `meta.menuKey` 与侧边栏菜单联动,`meta.title` 用于 `document.title`。
|
||||||
|
|
||||||
|
**新增页面流程**:
|
||||||
|
1. 在 `src/views/<业务域>/<功能>/index.vue` 创建组件
|
||||||
|
2. 在 `src/router/index.ts` 的 `modules` 数组中追加一行(保持按业务域注释分组)
|
||||||
|
3. 同步在 `MainLayout.vue` 的菜单结构里加入对应 `menuKey`
|
||||||
|
|
||||||
|
### 路由守卫与登录
|
||||||
|
|
||||||
|
`router.beforeEach` 检查 `localStorage.token`,未登录访问非 login 页则重定向到 `/login`。token 由 `src/stores/user.ts` 管理。
|
||||||
|
|
||||||
|
### 数据来源现状(重要)
|
||||||
|
|
||||||
|
**当前项目是纯交互原型,没有任何真实后端调用**。各 view 全部使用 `src/mock/data.ts` 的静态数据驱动界面。原本预留的 `src/utils/request.ts`(axios 封装)和 `src/api/index.ts`(业务 API 聚合)已经清除——它们当时未被任何 view 引用,属于无主死代码。
|
||||||
|
|
||||||
|
登录流程也是原型:`router.beforeEach` 只检查 `localStorage.token` 是否存在,没有真正的鉴权请求。
|
||||||
|
|
||||||
|
**未来对接后端时**:建议沿用以下契约(这是被删除的脚手架原本约定的,可作为参考):
|
||||||
|
- 统一 axios 封装放 `src/utils/request.ts`,baseURL `/api`(配合 vite `server.proxy` 走 `localhost:8080`),自动注入 `Bearer token`
|
||||||
|
- 响应拦截器统一解包后端格式 `{ code, msg, data }`:`code === 0` 时直接返回 `data`,否则 `message.error(msg)` 并 reject;HTTP 401 清 token + 跳 `/login`
|
||||||
|
- API 按业务域聚合到 `src/api/<域>.ts`,view 里 `import { xxxApi } from '@/api/...'`,**不要**在 view 里直接 import `request`,也**不要**再 `.data.data` 解包(拦截器已经解一次)
|
||||||
|
|
||||||
|
### antd-vue 4.x 类型陷阱(踩坑警示)
|
||||||
|
|
||||||
|
`vue-tsc` 在严格模式下与 antd-vue 4.x 有几个高频冲突,新增页面时请提前避开:
|
||||||
|
|
||||||
|
1. **`a-table` 的 `columns` 必须显式注解**。`fixed: 'left' | 'right'` 字面量字段不加类型会被推为宽 `string` 类型,匹配不上 antd 的 `FixedType`。规范写法:
|
||||||
|
```ts
|
||||||
|
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||||
|
const columns: ColumnsType<any> = [ /* ... */ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`a-tree` 的 `tree-data` 即使配了 `field-names` 映射,静态类型也仍然要求 `DataNode` 形状(必须有 `key`)**。这是 antd-vue 类型限制,运行时 fieldNames 是有效的。已采用的解法:在绑定处加 `as any` 断言(`:tree-data="(myData as any)"`),不要去污染数据结构
|
||||||
|
|
||||||
|
3. **`a-tree` 的 `@select` 回调签名**是 `(keys: Key[], info) => void`,其中 `Key = string | number`。若声明 `(keys: string[]) => void` 会编译失败,需要 `Array<string | number>` 并按需 `String(keys[0])`
|
||||||
|
|
||||||
|
4. **`a-range-picker` / `a-date-picker` 的 `v-model` 不要用 `null` 初值**。antd 类型是 `[Dayjs, Dayjs] | undefined` 而非 `| null`,写 `ref<...>(null)` 会编译失败。规范写法:`const dateRange = ref<[Dayjs, Dayjs]>()`,表单字段用 `entryDate: undefined as Dayjs | undefined`
|
||||||
|
|
||||||
|
5. **`a-progress` 的 `format` 回调参数是 `percent?: number`**,必须接受 `undefined`。写 `(p: number) => ...` 会失败,应写 `(p?: number) => (p ?? 0) + '...'`
|
||||||
|
|
||||||
|
### 构建质量门禁
|
||||||
|
|
||||||
|
- **`vue-tsc --noEmit` 是构建管线的硬性步骤**(`pnpm run build` 会在 `vite build` 之前先跑),这意味着任何类型错误都会阻止构建。新增/修改代码后请本地跑一遍构建确认
|
||||||
|
- **git 钩子**:仓库根目录配置了 `.githooks/pre-commit`,当本次暂存的文件涉及 `zhican/admin/` 时自动运行 `pnpm run build`。**首次 clone 仓库后需要手动启用一次**:
|
||||||
|
```bash
|
||||||
|
cd <repo-root> && git config core.hooksPath .githooks
|
||||||
|
```
|
||||||
|
钩子若检测不到 `pnpm` 会跳过(不阻塞提交),但会在 stderr 提醒。Windows 用户需用 Git Bash 或确保 `bash` 可用
|
||||||
|
|
||||||
|
### 自动生成的类型声明(不要提交)
|
||||||
|
|
||||||
|
`src/auto-imports.d.ts` 和 `src/components.d.ts` 由 unplugin 在 dev/build 时**重新生成**,已加入 `.gitignore`。它们会随业务代码变化频繁更新,提交进 git 会反复制造无意义 diff 和合并冲突。如果发现这两个文件被意外提交,使用 `git rm --cached` 移除追踪。
|
||||||
|
|
||||||
|
## miniprogram 小程序
|
||||||
|
|
||||||
|
原生微信小程序结构(无构建链)。`app.json` 定义 4 个 tabBar 页面:吃 (`eat`) / 问 (`ask`) / 做 (`cook`) / 我 (`profile`),对应 `pages/<name>/index.{js,wxml,wxss,json}`。
|
||||||
|
|
||||||
|
小程序端目前也是纯交互原型,无任何真实接口调用。原本预留的 `utils/request.js` 同样属于无主死代码已清除。
|
||||||
|
|
||||||
|
## 业务上下文
|
||||||
|
|
||||||
|
「智能餐养系统」覆盖从种植采购到用餐消费的全链路:绿色种采 → 卫生供应(净菜加工) → 健康生产(按单烹制) → 营养用餐(点餐、取餐、营养分析)。理解某个页面的职责时优先按这条业务流向上下游追溯。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 构建上下文为仓库根目录
|
||||||
|
# docker build -f deploy/Dockerfile -t zhican-admin:latest .
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
# Stage 1: 构建阶段
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 配置阿里 npm 镜像,加速依赖安装
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 安装 pnpm 并配置镜像
|
||||||
|
RUN npm install -g pnpm && pnpm config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 优先复制依赖描述文件,利用 Docker 层缓存
|
||||||
|
COPY zhican/admin/package.json zhican/admin/pnpm-lock.yaml zhican/admin/pnpm-workspace.yaml ./
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 复制源码并构建
|
||||||
|
COPY zhican/admin/ .
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
# Stage 2: 成品阶段
|
||||||
|
# ────────────────────────────────────────────
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
|
||||||
|
# 移除默认站点配置
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 注入自定义 nginx 配置
|
||||||
|
COPY deploy/nginx.conf /etc/nginx/conf.d/zhican-admin.conf
|
||||||
|
|
||||||
|
# 拷贝构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
admin:
|
||||||
|
image: zhican-admin:latest
|
||||||
|
ports:
|
||||||
|
- "12801:80"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Vue Router history 模式:所有未匹配的路径回落到 index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vite 构建产物带 hash,可安全设置长期缓存
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# 基础安全头
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
}
|
||||||
+32
-2
@@ -1,7 +1,37 @@
|
|||||||
|
# ============ 依赖 ============
|
||||||
node_modules
|
node_modules
|
||||||
|
# 统一使用 pnpm,禁止意外提交 npm/yarn 锁文件
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# ============ 构建产物 ============
|
||||||
dist
|
dist
|
||||||
.DS_Store
|
dist-ssr
|
||||||
*.local
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# ============ 测试/覆盖率 ============
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# ============ 日志 ============
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# ============ 环境变量 ============
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ============ 系统垃圾 ============
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# ============ unplugin 自动生成的类型声明 ============
|
||||||
|
# 这些文件由 unplugin-auto-import / unplugin-vue-components 在 dev/build 时重新生成
|
||||||
|
# 提交进 git 会反复制造无意义 diff,且容易合并冲突
|
||||||
|
admin/src/auto-imports.d.ts
|
||||||
|
admin/src/components.d.ts
|
||||||
|
|||||||
@@ -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 + 统计接口)
|
||||||
Generated
+2056
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
core-js: true
|
||||||
|
esbuild: true
|
||||||
|
vue-demi: true
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- core-js
|
||||||
|
- esbuild
|
||||||
|
- vue-demi
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import request from '@/utils/request'
|
|
||||||
|
|
||||||
// 绿色种采 - 土地环境
|
|
||||||
export const landEnvApi = {
|
|
||||||
list: (params?: any) => request.get('/green-planting/land-env', { params }),
|
|
||||||
detail: (id: string) => request.get(`/green-planting/land-env/${id}`),
|
|
||||||
create: (data: any) => request.post('/green-planting/land-env', data),
|
|
||||||
update: (id: string, data: any) => request.put(`/green-planting/land-env/${id}`, data),
|
|
||||||
remove: (id: string) => request.delete(`/green-planting/land-env/${id}`),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 卫生供应 - 卫生检测
|
|
||||||
export const hygieneTestApi = {
|
|
||||||
list: (params?: any) => request.get('/hygiene-supply/hygiene-test', { params }),
|
|
||||||
detail: (id: string) => request.get(`/hygiene-supply/hygiene-test/${id}`),
|
|
||||||
create: (data: any) => request.post('/hygiene-supply/hygiene-test', data),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 健康生产 - 食安监控
|
|
||||||
export const foodSafetyApi = {
|
|
||||||
list: (params?: any) => request.get('/health-production/food-safety', { params }),
|
|
||||||
detail: (id: string) => request.get(`/health-production/food-safety/${id}`),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 营养用餐 - 用户管理
|
|
||||||
export const userMgmtApi = {
|
|
||||||
list: (params?: any) => request.get('/nutrition-dining/users', { params }),
|
|
||||||
detail: (id: string) => request.get(`/nutrition-dining/users/${id}`),
|
|
||||||
create: (data: any) => request.post('/nutrition-dining/users', data),
|
|
||||||
update: (id: string, data: any) => request.put(`/nutrition-dining/users/${id}`, data),
|
|
||||||
remove: (id: string) => request.delete(`/nutrition-dining/users/${id}`),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录
|
|
||||||
export const authApi = {
|
|
||||||
login: (data: { username: string; password: string }) => request.post('/auth/login', data),
|
|
||||||
logout: () => request.post('/auth/logout'),
|
|
||||||
profile: () => request.get('/auth/profile'),
|
|
||||||
}
|
|
||||||
Vendored
-75
@@ -1,75 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
// Generated by unplugin-vue-components
|
|
||||||
// Read more: https://github.com/vuejs/core/pull/3399
|
|
||||||
export {}
|
|
||||||
|
|
||||||
/* prettier-ignore */
|
|
||||||
declare module 'vue' {
|
|
||||||
export interface GlobalComponents {
|
|
||||||
AAlert: typeof import('ant-design-vue/es')['Alert']
|
|
||||||
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
|
||||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
|
||||||
AButton: typeof import('ant-design-vue/es')['Button']
|
|
||||||
ACard: typeof import('ant-design-vue/es')['Card']
|
|
||||||
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
|
||||||
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
|
|
||||||
ACol: typeof import('ant-design-vue/es')['Col']
|
|
||||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
|
||||||
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
|
|
||||||
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
|
||||||
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
|
||||||
ADivider: typeof import('ant-design-vue/es')['Divider']
|
|
||||||
ADrawer: typeof import('ant-design-vue/es')['Drawer']
|
|
||||||
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
|
||||||
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
|
||||||
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']
|
|
||||||
ALayout: typeof import('ant-design-vue/es')['Layout']
|
|
||||||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
|
||||||
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
|
||||||
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
|
|
||||||
AList: typeof import('ant-design-vue/es')['List']
|
|
||||||
AListItem: typeof import('ant-design-vue/es')['ListItem']
|
|
||||||
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
|
|
||||||
AMenu: typeof import('ant-design-vue/es')['Menu']
|
|
||||||
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
|
||||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
|
||||||
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
|
||||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
|
||||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
|
||||||
ARadio: typeof import('ant-design-vue/es')['Radio']
|
|
||||||
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
|
|
||||||
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
|
|
||||||
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
|
|
||||||
ARate: typeof import('ant-design-vue/es')['Rate']
|
|
||||||
ARow: typeof import('ant-design-vue/es')['Row']
|
|
||||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
|
||||||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
|
||||||
ASlider: typeof import('ant-design-vue/es')['Slider']
|
|
||||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
|
||||||
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
|
||||||
AStep: typeof import('ant-design-vue/es')['Step']
|
|
||||||
ASteps: typeof import('ant-design-vue/es')['Steps']
|
|
||||||
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
|
|
||||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
|
||||||
ATable: typeof import('ant-design-vue/es')['Table']
|
|
||||||
ATabPane: typeof import('ant-design-vue/es')['TabPane']
|
|
||||||
ATabs: typeof import('ant-design-vue/es')['Tabs']
|
|
||||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
|
||||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
|
||||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
|
||||||
ATree: typeof import('ant-design-vue/es')['Tree']
|
|
||||||
ChartPanel: typeof import('./components/common/ChartPanel.vue')['default']
|
|
||||||
RelatedModules: typeof import('./components/common/RelatedModules.vue')['default']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
|
||||||
StatCard: typeof import('./components/common/StatCard.vue')['default']
|
|
||||||
StatusDot: typeof import('./components/common/StatusDot.vue')['default']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,11 @@
|
|||||||
mode="inline"
|
mode="inline"
|
||||||
>
|
>
|
||||||
<a-menu-item key="dashboard">
|
<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>
|
||||||
|
|
||||||
<a-menu-item key="business-flow">
|
<a-menu-item key="business-flow">
|
||||||
@@ -27,7 +31,7 @@
|
|||||||
<a-menu-item key="planting-monitor">种养监控</a-menu-item>
|
<a-menu-item key="planting-monitor">种养监控</a-menu-item>
|
||||||
<a-menu-item key="harvest-storage">收成入库</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="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>
|
||||||
|
|
||||||
<a-sub-menu key="hygiene-supply">
|
<a-sub-menu key="hygiene-supply">
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
<span>卫生供应</span>
|
<span>卫生供应</span>
|
||||||
</template>
|
</template>
|
||||||
<a-menu-item key="hygiene-test">卫生检测</a-menu-item>
|
<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-process">净菜加工</a-menu-item>
|
||||||
<a-menu-item key="clean-storage">净菜入库</a-menu-item>
|
<a-menu-item key="clean-storage">净菜入库</a-menu-item>
|
||||||
<a-menu-item key="clean-internal">净菜内供</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="pickup-mgmt">取餐管理</a-menu-item>
|
||||||
<a-menu-item key="consumption-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="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="terminal-mgmt">终端管理</a-menu-item>
|
||||||
<a-menu-item key="model-mgmt">模型管理</a-menu-item>
|
<a-menu-item key="model-mgmt">模型管理</a-menu-item>
|
||||||
<a-menu-item key="flavor-innovation">味养创新</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="employee-mgmt">员工管理</a-menu-item>
|
||||||
<a-menu-item key="role-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="canteen-mgmt">食堂管理</a-menu-item>
|
||||||
|
<a-menu-item key="dict-config">字典配置</a-menu-item>
|
||||||
</a-sub-menu>
|
</a-sub-menu>
|
||||||
</a-menu>
|
</a-menu>
|
||||||
</a-layout-sider>
|
</a-layout-sider>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
// ==================== 通用工具 ====================
|
// ==================== 通用工具 ====================
|
||||||
export const randomId = () => Math.random().toString(36).slice(2, 10)
|
export const randomId = () => Math.random().toString(36).slice(2, 10)
|
||||||
export const randomNum = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
|
export const randomNum = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
@@ -42,6 +40,7 @@ const materialNames = ['有机大米', '土鸡蛋', '新鲜猪肉', '活鱼', '
|
|||||||
export function genLandEnvData(n = 12) {
|
export function genLandEnvData(n = 12) {
|
||||||
return Array.from({ length: n }, (_, i) => ({
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
|
code: 'LD' + String(i + 1).padStart(3, '0'),
|
||||||
name: pick(landNames),
|
name: pick(landNames),
|
||||||
area: randomFloat(5, 200, 0) + '亩',
|
area: randomFloat(5, 200, 0) + '亩',
|
||||||
soilType: pick(soilTypes),
|
soilType: pick(soilTypes),
|
||||||
@@ -52,6 +51,8 @@ export function genLandEnvData(n = 12) {
|
|||||||
status: pick(['normal', 'warning', 'error']),
|
status: pick(['normal', 'warning', 'error']),
|
||||||
lastCheck: randomDate(7),
|
lastCheck: randomDate(7),
|
||||||
crop: pick(cropTypes),
|
crop: pick(cropTypes),
|
||||||
|
sensorStatus: pick(['online', 'online', 'online', 'offline', 'fault']),
|
||||||
|
lastSync: randomDate(1) + ' ' + randomTime(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ export function genPlantingMonitorData(n = 10) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function genHarvestData(n = 15) {
|
export function genHarvestData(n = 15) {
|
||||||
|
const qualityLevels = ['优等', '良好', '一般', '不合格']
|
||||||
return Array.from({ length: n }, () => ({
|
return Array.from({ length: n }, () => ({
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
batchNo: 'HV' + randomDate(30).replace(/-/g, '') + randomNum(100, 999),
|
batchNo: 'HV' + randomDate(30).replace(/-/g, '') + randomNum(100, 999),
|
||||||
@@ -81,7 +83,7 @@ export function genHarvestData(n = 15) {
|
|||||||
land: pick(landNames),
|
land: pick(landNames),
|
||||||
harvestDate: randomDate(30),
|
harvestDate: randomDate(30),
|
||||||
quantity: randomFloat(100, 5000, 0) + 'kg',
|
quantity: randomFloat(100, 5000, 0) + 'kg',
|
||||||
quality: pick(['A', 'B', 'C']),
|
quality: pick(qualityLevels),
|
||||||
status: pick(['stored', 'pending', 'processing']),
|
status: pick(['stored', 'pending', 'processing']),
|
||||||
inspector: pick(['张三', '李四', '王五']),
|
inspector: pick(['张三', '李四', '王五']),
|
||||||
warehouse: pick(['1号仓', '2号仓', '3号冷库']),
|
warehouse: pick(['1号仓', '2号仓', '3号冷库']),
|
||||||
@@ -121,7 +123,7 @@ export function genProcurementData(n = 12) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 卫生供应 ====================
|
// ==================== 卫生供应 ====================
|
||||||
const testItems = ['农药残留', '重金属', '微生物', '添加剂', '兽药残留']
|
const testItems = ['农药残留', '重金属', '化肥残留']
|
||||||
const vegetableNames = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜', '土豆', '胡萝卜', '豆角', '西兰花']
|
const vegetableNames = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜', '土豆', '胡萝卜', '豆角', '西兰花']
|
||||||
const meatNames = ['猪里脊', '鸡胸肉', '牛腱子', '羊腿肉', '鱼片', '虾仁']
|
const meatNames = ['猪里脊', '鸡胸肉', '牛腱子', '羊腿肉', '鱼片', '虾仁']
|
||||||
|
|
||||||
@@ -343,7 +345,7 @@ export function genSurplusData(n = 8) {
|
|||||||
mealType: pick(mealTypes),
|
mealType: pick(mealTypes),
|
||||||
dish: pick(dishNames),
|
dish: pick(dishNames),
|
||||||
surplusQty: randomFloat(1, 30, 0) + 'kg',
|
surplusQty: randomFloat(1, 30, 0) + 'kg',
|
||||||
disposal: pick(['员工餐', '捐赠', '堆肥', '废弃']),
|
disposal: pick(['打折销售', '员工餐', '捐赠', '堆肥', '废弃']),
|
||||||
handler: pick(['处置员A', '处置员B']),
|
handler: pick(['处置员A', '处置员B']),
|
||||||
remark: '',
|
remark: '',
|
||||||
}))
|
}))
|
||||||
@@ -372,8 +374,6 @@ export function genProductionStatsData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 营养用餐 ====================
|
// ==================== 营养用餐 ====================
|
||||||
const nutrientNames = ['蛋白质', '脂肪', '碳水化合物', '膳食纤维', '维生素A', '维生素C', '钙', '铁', '锌']
|
|
||||||
|
|
||||||
export function genNutritionTestData(n = 12) {
|
export function genNutritionTestData(n = 12) {
|
||||||
return Array.from({ length: n }, () => ({
|
return Array.from({ length: n }, () => ({
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
@@ -498,6 +498,110 @@ 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: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ==================== 称重终端 ====================
|
||||||
|
// 菜品基础库:用于称重终端的品名网格选择。emoji 仅作占位图标
|
||||||
|
export const vegetableLibrary = [
|
||||||
|
{ id: 'v01', name: '白菜', emoji: '🥬' },
|
||||||
|
{ id: 'v02', name: '萝卜', emoji: '🥕' },
|
||||||
|
{ id: 'v03', name: '番茄', emoji: '🍅' },
|
||||||
|
{ id: 'v04', name: '黄瓜', emoji: '🥒' },
|
||||||
|
{ id: 'v05', name: '茄子', emoji: '🍆' },
|
||||||
|
{ id: 'v06', name: '青椒', emoji: '🫑' },
|
||||||
|
{ id: 'v07', name: '芹菜', emoji: '🌿' },
|
||||||
|
{ id: 'v08', name: '菠菜', emoji: '🥬' },
|
||||||
|
{ id: 'v09', name: '土豆', emoji: '🥔' },
|
||||||
|
{ id: 'v10', name: '胡萝卜', emoji: '🥕' },
|
||||||
|
{ id: 'v11', name: '豆角', emoji: '🫛' },
|
||||||
|
{ id: 'v12', name: '西兰花', emoji: '🥦' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 净菜规格(形状)枚举
|
||||||
|
export const specOptions = [
|
||||||
|
{ code: 'whole', label: '整' },
|
||||||
|
{ code: 'dice', label: '丁' },
|
||||||
|
{ code: 'shred', label: '丝' },
|
||||||
|
{ code: 'slice', label: '片' },
|
||||||
|
{ code: 'segment', label: '段' },
|
||||||
|
{ code: 'chunk', label: '块' },
|
||||||
|
]
|
||||||
|
|
||||||
// ==================== Dashboard ====================
|
// ==================== Dashboard ====================
|
||||||
export function genDashboardData() {
|
export function genDashboardData() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RouteRecordRaw } from 'vue-router'
|
|||||||
const modules: RouteRecordRaw[] = [
|
const modules: RouteRecordRaw[] = [
|
||||||
// ========== 数据概览 ==========
|
// ========== 数据概览 ==========
|
||||||
{ path: 'dashboard', name: 'dashboard', meta: { title: '数据概览', menuKey: 'dashboard' }, component: () => import('@/views/dashboard/index.vue') },
|
{ 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') },
|
{ 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: '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: '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: '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[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
@@ -62,6 +64,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/login/index.vue'),
|
component: () => import('@/views/login/index.vue'),
|
||||||
},
|
},
|
||||||
|
// 称重终端:触摸屏现场入口,不挂 MainLayout(无侧边栏/无顶栏,全屏),免登录
|
||||||
|
{
|
||||||
|
path: '/weighing-terminal',
|
||||||
|
name: 'weighing-terminal',
|
||||||
|
meta: { title: '称重终端', public: true },
|
||||||
|
component: () => import('@/views/weighing-terminal/index.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('@/layouts/MainLayout.vue'),
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
@@ -77,7 +86,9 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (to.name !== 'login' && !token) {
|
// 公开路由(如登录页、称重终端)免鉴权
|
||||||
|
const isPublic = to.name === 'login' || to.meta.public === true
|
||||||
|
if (!isPublic && !token) {
|
||||||
next({ name: 'login' })
|
next({ name: 'login' })
|
||||||
} else {
|
} else {
|
||||||
document.title = `${(to.meta.title as string) || ''} - 智能餐养`
|
document.title = `${(to.meta.title as string) || ''} - 智能餐养`
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// 毛菜入库记录
|
||||||
|
export interface RawVeggieRecord {
|
||||||
|
id: string
|
||||||
|
time: string // YYYY-MM-DD HH:mm:ss
|
||||||
|
name: string // 品名
|
||||||
|
weight: number // 重量(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 净菜入库记录
|
||||||
|
export interface CleanVeggieRecord {
|
||||||
|
id: string
|
||||||
|
time: string
|
||||||
|
name: string
|
||||||
|
spec: string // 规格(形状):整/丁/丝/片/段/块
|
||||||
|
weight: number // 净菜重量(g)
|
||||||
|
operator: string | null // 加工人(预留人脸识别,当前为 null)
|
||||||
|
yieldRate: number | null // 出净率(%);无毛菜参照时为 null
|
||||||
|
refRawWeight: number | null // 参照的毛菜重量(g);用于在记录里追溯出净率算法
|
||||||
|
}
|
||||||
|
|
||||||
|
const genId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||||
|
|
||||||
|
const nowStr = () => {
|
||||||
|
const d = new Date()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 称重终端 store:负责毛菜/净菜两类入库流水的增删查
|
||||||
|
* 设计要点:
|
||||||
|
* - 数据仅存活于本次会话内存(刷新即清空),原型阶段足够
|
||||||
|
* - 出净率自动算 = 当前净菜重量 / 最近一条同品名毛菜重量 * 100%
|
||||||
|
* - 加工人字段保留 null 占位,留给后期人脸识别填充
|
||||||
|
*/
|
||||||
|
export const useWeighingTerminalStore = defineStore('weighingTerminal', () => {
|
||||||
|
const rawVeggieRecords = ref<RawVeggieRecord[]>([])
|
||||||
|
const cleanVeggieRecords = ref<CleanVeggieRecord[]>([])
|
||||||
|
|
||||||
|
/** 新增一条毛菜入库记录 */
|
||||||
|
function addRawRecord(name: string, weight: number): RawVeggieRecord {
|
||||||
|
const record: RawVeggieRecord = {
|
||||||
|
id: genId(),
|
||||||
|
time: nowStr(),
|
||||||
|
name,
|
||||||
|
weight,
|
||||||
|
}
|
||||||
|
rawVeggieRecords.value.unshift(record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取最近一条同品名毛菜记录的重量(g);找不到返回 null */
|
||||||
|
function getLatestRawWeight(name: string): number | null {
|
||||||
|
const hit = rawVeggieRecords.value.find(r => r.name === name)
|
||||||
|
return hit ? hit.weight : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取最近一条同品名毛菜记录(含时间,用于界面展示参照批次) */
|
||||||
|
function getLatestRawRecord(name: string): RawVeggieRecord | null {
|
||||||
|
return rawVeggieRecords.value.find(r => r.name === name) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增一条净菜入库记录;出净率根据最近一条同品名毛菜自动计算 */
|
||||||
|
function addCleanRecord(payload: {
|
||||||
|
name: string
|
||||||
|
spec: string
|
||||||
|
weight: number
|
||||||
|
operator?: string | null
|
||||||
|
}): CleanVeggieRecord {
|
||||||
|
const refRaw = getLatestRawWeight(payload.name)
|
||||||
|
const yieldRate = refRaw && refRaw > 0
|
||||||
|
? +((payload.weight / refRaw) * 100).toFixed(1)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const record: CleanVeggieRecord = {
|
||||||
|
id: genId(),
|
||||||
|
time: nowStr(),
|
||||||
|
name: payload.name,
|
||||||
|
spec: payload.spec,
|
||||||
|
weight: payload.weight,
|
||||||
|
operator: payload.operator ?? null,
|
||||||
|
yieldRate,
|
||||||
|
refRawWeight: refRaw,
|
||||||
|
}
|
||||||
|
cleanVeggieRecords.value.unshift(record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawVeggieRecords,
|
||||||
|
cleanVeggieRecords,
|
||||||
|
addRawRecord,
|
||||||
|
getLatestRawWeight,
|
||||||
|
getLatestRawRecord,
|
||||||
|
addCleanRecord,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
|
|
||||||
const request: AxiosInstance = axios.create({
|
|
||||||
baseURL: '/api',
|
|
||||||
timeout: 15000,
|
|
||||||
})
|
|
||||||
|
|
||||||
request.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
(error) => Promise.reject(error)
|
|
||||||
)
|
|
||||||
|
|
||||||
request.interceptors.response.use(
|
|
||||||
(response: AxiosResponse) => {
|
|
||||||
const { code, msg, data } = response.data
|
|
||||||
if (code === 0) {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
message.error(msg || '请求失败')
|
|
||||||
return Promise.reject(new Error(msg))
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
window.location.href = '/login'
|
|
||||||
}
|
|
||||||
message.error(error.message || '网络错误')
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export default request
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<a-radio-button value="chain">供应链主线</a-radio-button>
|
<a-radio-button value="chain">供应链主线</a-radio-button>
|
||||||
<a-radio-button value="matrix">关联矩阵</a-radio-button>
|
<a-radio-button value="matrix">关联矩阵</a-radio-button>
|
||||||
<a-radio-button value="detail">模块详情</a-radio-button>
|
<a-radio-button value="detail">模块详情</a-radio-button>
|
||||||
|
<a-radio-button value="loop">业务闭环</a-radio-button>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
<span class="node-dot" :style="{ background: stage.color }"></span>
|
<span class="node-dot" :style="{ background: stage.color }"></span>
|
||||||
<span class="node-label">{{ node.label }}</span>
|
<span class="node-label">{{ node.label }}</span>
|
||||||
<span class="node-badge" v-if="node.badge">{{ node.badge }}</span>
|
<span class="node-badge" v-if="node.badge">{{ node.badge }}</span>
|
||||||
|
<a-tag v-if="node.dataSource" :color="dataSourceColor(node.dataSource)" size="small" style="font-size:10px;margin-left:4px">{{ node.dataSource }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stage-arrow" v-if="si < supplyChain.length - 1">→</div>
|
<div class="stage-arrow" v-if="si < supplyChain.length - 1">→</div>
|
||||||
@@ -221,6 +223,29 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== 业务闭环视图 ==================== -->
|
||||||
|
<div v-if="viewMode === 'loop'" class="loop-view">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="8" v-for="loop in businessLoops" :key="loop.title">
|
||||||
|
<a-card :title="loop.title" :bordered="false" size="small" :head-style="{ background: loop.color, color: '#fff' }">
|
||||||
|
<p style="color:#666;margin-bottom:12px">{{ loop.desc }}</p>
|
||||||
|
<a-steps direction="vertical" size="small" :current="loop.steps.length - 1">
|
||||||
|
<a-step v-for="(step, i) in loop.steps" :key="i" :title="step.name">
|
||||||
|
<template #description>
|
||||||
|
<div style="font-size:12px;color:#999">{{ step.detail }}</div>
|
||||||
|
<a-tag :color="dataSourceColor(step.source)" size="small" style="margin-top:4px;font-size:10px">{{ step.source }}</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-step>
|
||||||
|
</a-steps>
|
||||||
|
<a-divider style="margin:12px 0" />
|
||||||
|
<div style="text-align:center">
|
||||||
|
<a-tag v-for="page in loop.pages" :key="page" color="blue" style="margin:2px;cursor:pointer" @click="goTo(page)">{{ page }}</a-tag>
|
||||||
|
</div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -229,7 +254,7 @@ import { ref, computed } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const viewMode = ref<'chain' | 'matrix' | 'detail'>('chain')
|
const viewMode = ref<'chain' | 'matrix' | 'detail' | 'loop'>('chain')
|
||||||
const activeStage = ref('green')
|
const activeStage = ref('green')
|
||||||
const hoveredNode = ref('')
|
const hoveredNode = ref('')
|
||||||
const hoverCell = ref('')
|
const hoverCell = ref('')
|
||||||
@@ -247,49 +272,49 @@ const supplyChain = [
|
|||||||
{
|
{
|
||||||
id: 'green', name: '绿色种采', icon: '🌱', color: '#52c41a',
|
id: 'green', name: '绿色种采', icon: '🌱', color: '#52c41a',
|
||||||
nodes: [
|
nodes: [
|
||||||
{ key: 'land-env', label: '土地环境' },
|
{ key: 'land-env', label: '土地环境', dataSource: '传感器' },
|
||||||
{ key: 'planting-monitor', label: '种养监控' },
|
{ key: 'planting-monitor', label: '种养监控', dataSource: '传感器' },
|
||||||
{ key: 'harvest-storage', label: '收成入库', badge: '出' },
|
{ key: 'harvest-storage', label: '收成入库', badge: '出', dataSource: '智能秤' },
|
||||||
{ key: 'supplier-mgmt', label: '供商管理' },
|
{ key: 'supplier-mgmt', label: '供商管理', dataSource: '人工录入' },
|
||||||
{ key: 'procurement-mgmt', label: '采供管理', badge: '出' },
|
{ key: 'procurement-mgmt', label: '采购管理', badge: '出', dataSource: '系统生成' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hygiene', name: '卫生供应', icon: '🧪', color: '#1890ff',
|
id: 'hygiene', name: '卫生供应', icon: '🧪', color: '#1890ff',
|
||||||
nodes: [
|
nodes: [
|
||||||
{ key: 'hygiene-test', label: '卫生检测', badge: '验' },
|
{ key: 'hygiene-test', label: '卫生检测', badge: '验', dataSource: '设备检测' },
|
||||||
{ key: 'raw-storage', label: '毛菜入库', badge: '入' },
|
{ key: 'raw-storage', label: '毛菜管理', badge: '入', dataSource: '智能秤' },
|
||||||
{ key: 'clean-process', label: '净菜加工' },
|
{ key: 'clean-process', label: '净菜加工', dataSource: '智能秤' },
|
||||||
{ key: 'clean-storage', label: '净菜入库' },
|
{ key: 'clean-storage', label: '净菜入库', dataSource: '智能秤' },
|
||||||
{ key: 'clean-internal', label: '净菜内供', badge: '出' },
|
{ key: 'clean-internal', label: '净菜内供', badge: '出', dataSource: '系统计算' },
|
||||||
{ key: 'clean-external', label: '净菜外售', badge: '出' },
|
{ key: 'clean-external', label: '净菜外售', badge: '出', dataSource: '人工录入' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'health', name: '健康生产', icon: '👨🍳', color: '#faad14',
|
id: 'health', name: '健康生产', icon: '👨🍳', color: '#faad14',
|
||||||
nodes: [
|
nodes: [
|
||||||
{ key: 'food-safety', label: '食安监控', badge: '验' },
|
{ key: 'food-safety', label: '食安监控', badge: '验', dataSource: 'AI视频' },
|
||||||
{ key: 'recipe-plan', label: '食谱规划' },
|
{ key: 'recipe-plan', label: '食谱规划', dataSource: '系统计算' },
|
||||||
{ key: 'clean-order', label: '净菜订单', badge: '入' },
|
{ key: 'clean-order', label: '净菜订单', badge: '入', dataSource: '系统生成' },
|
||||||
{ key: 'clean-cabinet', label: '净菜进柜' },
|
{ key: 'clean-cabinet', label: '净菜进柜', dataSource: '智能秤' },
|
||||||
{ key: 'clean-assemble', label: '净菜组配' },
|
{ key: 'clean-assemble', label: '净菜组配', dataSource: '人工录入' },
|
||||||
{ key: 'cook-by-order', label: '按单烹制' },
|
{ key: 'cook-by-order', label: '按单烹制', dataSource: '智能秤' },
|
||||||
{ key: 'serving-monitor', label: '上餐监控', badge: '出' },
|
{ key: 'serving-monitor', label: '上餐监控', badge: '出', dataSource: '传感器' },
|
||||||
{ key: 'surplus-disposal', label: '余量处置' },
|
{ key: 'surplus-disposal', label: '余量处置', dataSource: '智能秤' },
|
||||||
{ key: 'production-stats', label: '生产统计' },
|
{ key: 'production-stats', label: '生产统计', dataSource: '系统计算' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nutrition', name: '营养用餐', icon: '🍽️', color: '#722ed1',
|
id: 'nutrition', name: '营养用餐', icon: '🍽️', color: '#722ed1',
|
||||||
nodes: [
|
nodes: [
|
||||||
{ key: 'nutrition-test', label: '营养检验', badge: '验' },
|
{ key: 'nutrition-test', label: '营养检验', badge: '验', dataSource: '设备检测' },
|
||||||
{ key: 'food-composition', label: '食物成分' },
|
{ key: 'food-composition', label: '食物成分', dataSource: '人工录入' },
|
||||||
{ key: 'dish-inventory', label: '餐品总库' },
|
{ key: 'dish-inventory', label: '餐品总库', dataSource: '人工录入' },
|
||||||
{ key: 'recipe-mgmt', label: '食谱管理' },
|
{ key: 'recipe-mgmt', label: '食谱管理', dataSource: '人工录入' },
|
||||||
{ key: 'nutrition-order', label: '营养点餐' },
|
{ key: 'nutrition-order', label: '营养点餐', dataSource: '系统计算' },
|
||||||
{ key: 'pickup-mgmt', label: '取餐管理' },
|
{ key: 'pickup-mgmt', label: '取餐管理', dataSource: '硬件自动' },
|
||||||
{ key: 'consumption-mgmt', label: '消费管理' },
|
{ key: 'consumption-mgmt', label: '消费管理', dataSource: '系统计算' },
|
||||||
{ key: 'nutrition-mgmt', label: '营养管理' },
|
{ key: 'nutrition-mgmt', label: '营养管理', dataSource: '系统计算' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -519,6 +544,53 @@ const moduleDetails: Record<string, any> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedModuleData = computed(() => moduleDetails[selectedModule.value])
|
const selectedModuleData = computed(() => moduleDetails[selectedModule.value])
|
||||||
|
|
||||||
|
function dataSourceColor(source: string) {
|
||||||
|
const map: Record<string, string> = { '硬件自动': 'blue', '设备检测': 'purple', '人工录入': 'orange', '系统计算': 'cyan', '系统生成': 'cyan', 'AI视频': 'red', '智能秤': 'blue', '传感器': 'blue' }
|
||||||
|
return map[source] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessLoops = [
|
||||||
|
{
|
||||||
|
title: '闭环1:食谱驱动反向采购',
|
||||||
|
color: '#1890ff',
|
||||||
|
desc: '基于食谱规划自动反算原料需求,生成采购订单,校验到货量,确保数据一致',
|
||||||
|
steps: [
|
||||||
|
{ name: '食谱规划', detail: '编排周/日食谱,系统反算净菜/毛菜需求', source: '系统计算' },
|
||||||
|
{ name: '生成采购订单', detail: '自动生成净菜采购订单推送加工端', source: '系统生成' },
|
||||||
|
{ name: '净菜进柜校验', detail: '智能秤校验订单量 vs 实收量', source: '智能秤' },
|
||||||
|
{ name: '库存动态表', detail: '送多少、用多少、产多少数据一致', source: '系统计算' },
|
||||||
|
],
|
||||||
|
pages: ['recipe-plan', 'clean-order', 'clean-cabinet', 'clean-assemble'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '闭环2:按单烹饪与熟重计量',
|
||||||
|
color: '#52c41a',
|
||||||
|
desc: '从生重到熟重全链路追踪,结合AI视频解析工艺动作',
|
||||||
|
steps: [
|
||||||
|
{ name: '生重称重', detail: '锅具上秤即采,自动记录生重', source: '智能秤' },
|
||||||
|
{ name: '调料时间点', detail: '采集放盐/油/花椒时刻', source: '传感器' },
|
||||||
|
{ name: '工艺动作识别', detail: '翻炒/颠勺/正反转等动作识别', source: 'AI视频' },
|
||||||
|
{ name: '熟重称重', detail: '烹饪完成自动称重', source: '智能秤' },
|
||||||
|
{ name: '上餐+温度衰减', detail: '记录上餐时间,监控温度衰减', source: '传感器' },
|
||||||
|
{ name: '开餐/加餐流转', detail: '效期预警,加餐自动触发', source: '系统计算' },
|
||||||
|
],
|
||||||
|
pages: ['cook-by-order', 'serving-monitor'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '闭环3:餐线调度与取餐治理',
|
||||||
|
color: '#722ed1',
|
||||||
|
desc: '以秤编号为单位,实时统计取餐量,智能补餐和双模识别',
|
||||||
|
steps: [
|
||||||
|
{ name: '秤绑定菜品序列', detail: '记录全天菜品更换序列', source: '智能秤' },
|
||||||
|
{ name: '取餐量实时统计', detail: '各时段取餐量自动汇总', source: '智能秤' },
|
||||||
|
{ name: '补餐阈值触发', detail: '存量<1/5自动触发补餐提醒', source: '系统计算' },
|
||||||
|
{ name: '时间过滤', detail: '可配置禁加餐时间', source: '系统计算' },
|
||||||
|
{ name: '双模取餐识别', detail: '人脸识别+RFID夹子绑定', source: '硬件自动' },
|
||||||
|
],
|
||||||
|
pages: ['meal-line', 'pickup-mgmt', 'consumption-mgmt'],
|
||||||
|
},
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<a-page-header title="数据概览" subtitle="智能餐养运营总览">
|
<a-page-header title="运营大屏" subtitle="智能餐养运营总览">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-space>
|
<a-space>
|
||||||
|
<a-radio-group v-model:value="timeRange" size="small" button-style="solid">
|
||||||
|
<a-radio-button value="today">今日</a-radio-button>
|
||||||
|
<a-radio-button value="week">本周</a-radio-button>
|
||||||
|
<a-radio-button value="month">本月</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
<a-select v-model:value="filterCanteen" style="width: 140px">
|
<a-select v-model:value="filterCanteen" style="width: 140px">
|
||||||
<a-select-option value="">全部食堂</a-select-option>
|
<a-select-option value="">全部食堂</a-select-option>
|
||||||
<a-select-option v-for="c in d.canteenStats" :key="c.name" :value="c.name">{{ c.name }}</a-select-option>
|
<a-select-option v-for="c in d.canteenStats" :key="c.name" :value="c.name">{{ c.name }}</a-select-option>
|
||||||
@@ -12,6 +17,28 @@
|
|||||||
</template>
|
</template>
|
||||||
</a-page-header>
|
</a-page-header>
|
||||||
|
|
||||||
|
<!-- ============ 快捷入口 ============ -->
|
||||||
|
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card hoverable :bordered="false" class="shortcut-card" @click="$router.push({ name: 'trace-overview' })">
|
||||||
|
<div class="shortcut-icon" style="background:#1890ff">溯</div>
|
||||||
|
<div><div class="shortcut-title">溯源总览</div><div class="shortcut-desc">按用户就餐反向追踪食材来源</div></div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card hoverable :bordered="false" class="shortcut-card" @click="$router.push({ name: 'business-flow' })">
|
||||||
|
<div class="shortcut-icon" style="background:#52c41a">流</div>
|
||||||
|
<div><div class="shortcut-title">业务流程图</div><div class="shortcut-desc">全链路业务关联与闭环视图</div></div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card hoverable :bordered="false" class="shortcut-card" @click="$router.push({ name: 'food-safety' })">
|
||||||
|
<div class="shortcut-icon" style="background:#f5222d">安</div>
|
||||||
|
<div><div class="shortcut-title">食安监控</div><div class="shortcut-desc">视频监控、环境安全、餐品留样</div></div>
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
<!-- ============ 第一行:核心 KPI ============ -->
|
<!-- ============ 第一行:核心 KPI ============ -->
|
||||||
<a-row :gutter="[16, 16]">
|
<a-row :gutter="[16, 16]">
|
||||||
<a-col :span="4" v-for="kpi in kpiCards" :key="kpi.title">
|
<a-col :span="4" v-for="kpi in kpiCards" :key="kpi.title">
|
||||||
@@ -108,6 +135,10 @@
|
|||||||
<span class="supply-label">库存天数</span>
|
<span class="supply-label">库存天数</span>
|
||||||
<span class="supply-val" :style="{ color: d.supplyChain.stockDays < 2 ? '#f5222d' : '#333' }">{{ d.supplyChain.stockDays }}天</span>
|
<span class="supply-val" :style="{ color: d.supplyChain.stockDays < 2 ? '#f5222d' : '#333' }">{{ d.supplyChain.stockDays }}天</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="supply-item">
|
||||||
|
<span class="supply-label">设备在线率</span>
|
||||||
|
<span class="supply-val" style="color:#52c41a">{{ deviceOnlineRate }}%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -167,6 +198,7 @@
|
|||||||
{{ d.nutritionOverview.structureOk ? '营养结构合理' : '营养结构偏离' }}
|
{{ d.nutritionOverview.structureOk ? '营养结构合理' : '营养结构偏离' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<span class="verdict-alert">{{ d.nutritionOverview.alertCount }} 条营养预警</span>
|
<span class="verdict-alert">{{ d.nutritionOverview.alertCount }} 条营养预警</span>
|
||||||
|
<a-tag color="red" style="margin-left:8px">{{ warningUserCount }}人需关注</a-tag>
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
@@ -237,6 +269,13 @@
|
|||||||
<template v-if="column.key === 'module'">
|
<template v-if="column.key === 'module'">
|
||||||
<a-tag>{{ record.module }}</a-tag>
|
<a-tag>{{ record.module }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button v-if="!record.handled" type="link" size="small" @click="handleAlert(record)">处理</a-button>
|
||||||
|
<a-tag v-else color="green">已处理</a-tag>
|
||||||
|
<a-button v-if="!record.handled" type="link" size="small" @click="ignoreAlert(record)">忽略</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
@@ -245,15 +284,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive } from 'vue'
|
import { ref, computed, reactive } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { message } from 'ant-design-vue'
|
||||||
import ChartPanel from '@/components/common/ChartPanel.vue'
|
import ChartPanel from '@/components/common/ChartPanel.vue'
|
||||||
import { genDashboardData } from '@/mock/data'
|
import { genDashboardData, randomNum } from '@/mock/data'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const d = reactive(genDashboardData())
|
const d = reactive(genDashboardData())
|
||||||
const filterCanteen = ref('')
|
const filterCanteen = ref('')
|
||||||
const trendMetric = ref<'orders' | 'revenue'>('orders')
|
const trendMetric = ref<'orders' | 'revenue'>('orders')
|
||||||
const alertFilter = ref('')
|
const alertFilter = ref('')
|
||||||
|
const timeRange = ref('today')
|
||||||
|
|
||||||
|
const deviceOnlineRate = computed(() => randomNum(88, 98))
|
||||||
|
const warningUserCount = computed(() => randomNum(3, 12))
|
||||||
|
|
||||||
function refreshData() {
|
function refreshData() {
|
||||||
Object.assign(d, genDashboardData())
|
Object.assign(d, genDashboardData())
|
||||||
@@ -304,15 +346,37 @@ const alertColumns = [
|
|||||||
{ title: '模块', key: 'module', width: 100 },
|
{ title: '模块', key: 'module', width: 100 },
|
||||||
{ title: '内容', dataIndex: 'message', ellipsis: true },
|
{ title: '内容', dataIndex: 'message', ellipsis: true },
|
||||||
{ title: '时间', dataIndex: 'time', width: 150 },
|
{ title: '时间', dataIndex: 'time', width: 150 },
|
||||||
|
{ title: '操作', key: 'action', width: 140 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const filteredAlerts = computed(() =>
|
const filteredAlerts = computed(() =>
|
||||||
alertFilter.value ? d.alerts.filter(a => a.type === alertFilter.value) : d.alerts
|
alertFilter.value ? d.alerts.filter(a => a.type === alertFilter.value) : d.alerts
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function handleAlert(record: any) {
|
||||||
|
record.handled = true
|
||||||
|
message.success('告警已处理')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ignoreAlert(record: any) {
|
||||||
|
record.handled = true
|
||||||
|
message.info('告警已忽略')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.dashboard {
|
.dashboard {
|
||||||
|
.shortcut-card {
|
||||||
|
:deep(.ant-card-body) { display: flex; align-items: center; gap: 16px; padding: 16px; }
|
||||||
|
}
|
||||||
|
.shortcut-icon {
|
||||||
|
width: 48px; height: 48px; border-radius: 12px; color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px; font-weight: 700; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.shortcut-title { font-size: 15px; font-weight: 600; }
|
||||||
|
.shortcut-desc { font-size: 12px; color: #999; margin-top: 2px; }
|
||||||
|
|
||||||
// KPI
|
// KPI
|
||||||
.kpi-card {
|
.kpi-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div class="trace-overview">
|
||||||
|
<a-page-header title="溯源总览" sub-title="按日期、用户、餐次反向追踪就餐餐品的完整链路" />
|
||||||
|
|
||||||
|
<a-card :bordered="false" style="margin-bottom: 16px">
|
||||||
|
<a-space wrap size="middle">
|
||||||
|
<a-date-picker v-model:value="queryDate" placeholder="选择日期" style="width: 160px" />
|
||||||
|
<a-select v-model:value="queryUser" placeholder="选择用户" style="width: 180px" allow-clear>
|
||||||
|
<a-select-option v-for="user in users" :key="user" :value="user">{{ user }}</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-select v-model:value="queryMeal" placeholder="选择餐次" style="width: 140px" allow-clear>
|
||||||
|
<a-select-option value="早餐">早餐</a-select-option>
|
||||||
|
<a-select-option value="午餐">午餐</a-select-option>
|
||||||
|
<a-select-option value="晚餐">晚餐</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
<a-button type="primary" @click="handleSearch">溯源查询</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card v-if="mealRecords.length" title="用户就餐餐品" :bordered="false" style="margin-bottom: 16px">
|
||||||
|
<a-table
|
||||||
|
:columns="mealColumns"
|
||||||
|
:data-source="mealRecords"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-button type="link" size="small" @click="selectMeal(record)">查看溯源</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card v-if="selectedMeal" title="餐品溯源详情" :bordered="false">
|
||||||
|
<a-descriptions bordered :column="2" size="small" style="margin-bottom: 16px">
|
||||||
|
<a-descriptions-item label="用户">{{ selectedMeal.user }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="就餐日期">{{ selectedMeal.date }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="餐次">{{ selectedMeal.mealType }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="取餐时间">{{ selectedMeal.pickupTime }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="餐品名称">{{ selectedMeal.dish }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="取餐重量/份数">{{ selectedMeal.quantity }}</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
|
||||||
|
<a-timeline>
|
||||||
|
<a-timeline-item color="green">
|
||||||
|
<template #label>{{ traceDetail?.serveInfo?.serveTime }}</template>
|
||||||
|
<div class="trace-title">上餐信息</div>
|
||||||
|
<div>餐线:{{ traceDetail?.serveInfo?.mealLine }}</div>
|
||||||
|
<div>上餐位置:{{ traceDetail?.serveInfo?.location }}</div>
|
||||||
|
<div>上餐温度:{{ traceDetail?.serveInfo?.temperature }}</div>
|
||||||
|
</a-timeline-item>
|
||||||
|
|
||||||
|
<a-timeline-item color="blue">
|
||||||
|
<template #label>{{ traceDetail?.cookInfo?.cookTime }}</template>
|
||||||
|
<div class="trace-title">烹饪信息</div>
|
||||||
|
<div>厨师:{{ traceDetail?.cookInfo?.chef }}</div>
|
||||||
|
<div>烹制时间:{{ traceDetail?.cookInfo?.cookTime }}</div>
|
||||||
|
<div>生重/熟重:{{ traceDetail?.cookInfo?.rawWeight }} / {{ traceDetail?.cookInfo?.cookedWeight }}</div>
|
||||||
|
<div>加工工艺:{{ traceDetail?.cookInfo?.process }}</div>
|
||||||
|
<div>开餐时间:{{ traceDetail?.cookInfo?.openTime }}</div>
|
||||||
|
</a-timeline-item>
|
||||||
|
|
||||||
|
<a-timeline-item color="orange">
|
||||||
|
<template #label>{{ traceDetail?.ingredientInfo?.processTime }}</template>
|
||||||
|
<div class="trace-title">食材批次信息</div>
|
||||||
|
<div v-for="item in traceDetail?.ingredientInfo?.items || []" :key="item.name" class="ingredient-item">
|
||||||
|
<div><strong>{{ item.name }}</strong></div>
|
||||||
|
<div>净菜批次:{{ item.cleanBatch }}</div>
|
||||||
|
<div>毛菜批次:{{ item.rawBatch }}</div>
|
||||||
|
<div>损耗率:{{ item.lossRate }}</div>
|
||||||
|
</div>
|
||||||
|
</a-timeline-item>
|
||||||
|
|
||||||
|
<a-timeline-item color="red">
|
||||||
|
<template #label>{{ traceDetail?.sourceInfo?.harvestTime }}</template>
|
||||||
|
<div class="trace-title">产地来源信息</div>
|
||||||
|
<div>食材产地:{{ traceDetail?.sourceInfo?.origin }}</div>
|
||||||
|
<div>地块/基地:{{ traceDetail?.sourceInfo?.land }}</div>
|
||||||
|
<div>供应商:{{ traceDetail?.sourceInfo?.supplier }}</div>
|
||||||
|
<div>检测结果:<a-tag color="green">{{ traceDetail?.sourceInfo?.testResult }}</a-tag></div>
|
||||||
|
</a-timeline-item>
|
||||||
|
</a-timeline>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-empty v-if="searched && !mealRecords.length" description="未找到该用户当日就餐记录" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import dayjs, { Dayjs } from 'dayjs'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { pick, randomDate, randomTime, randomNum } from '@/mock/data'
|
||||||
|
|
||||||
|
const users = ['张伟', '李芳', '王娜', '刘敏', '陈强', '杨磊', '黄艳', '赵勇']
|
||||||
|
const dishes = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝']
|
||||||
|
const mealLines = ['1号中餐线', '2号中餐线', '3号西餐线']
|
||||||
|
const origins = ['A区稻田', 'B区菜地', 'C区果园', 'D区养殖场']
|
||||||
|
const suppliers = ['集团种养公司', '集团净菜公司', '绿源农业', '丰禾食品']
|
||||||
|
const lands = ['A01地块', 'B02地块', 'C03基地', 'D04养殖区']
|
||||||
|
const ingredients = ['白菜', '番茄', '猪里脊', '鸡胸肉', '青椒', '土豆']
|
||||||
|
|
||||||
|
const queryDate = ref<Dayjs | undefined>(dayjs())
|
||||||
|
const queryUser = ref<string>()
|
||||||
|
const queryMeal = ref<string>()
|
||||||
|
const searched = ref(false)
|
||||||
|
|
||||||
|
const mealRecords = ref<any[]>([])
|
||||||
|
const selectedMeal = ref<any>(null)
|
||||||
|
const traceDetail = ref<any>(null)
|
||||||
|
|
||||||
|
const mealColumns = [
|
||||||
|
{ title: '用户', dataIndex: 'user', key: 'user' },
|
||||||
|
{ title: '日期', dataIndex: 'date', key: 'date' },
|
||||||
|
{ title: '餐次', dataIndex: 'mealType', key: 'mealType' },
|
||||||
|
{ title: '餐品', dataIndex: 'dish', key: 'dish' },
|
||||||
|
{ title: '取餐时间', dataIndex: 'pickupTime', key: 'pickupTime' },
|
||||||
|
{ title: '取餐重量/份数', dataIndex: 'quantity', key: 'quantity' },
|
||||||
|
{ title: '操作', key: 'action', width: 100 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function genMealRecords() {
|
||||||
|
const date = queryDate.value?.format('YYYY-MM-DD') || randomDate(3)
|
||||||
|
const user = queryUser.value || pick(users)
|
||||||
|
const mealType = queryMeal.value || pick(['早餐', '午餐', '晚餐'])
|
||||||
|
const count = randomNum(2, 4)
|
||||||
|
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: `${user}-${i}`,
|
||||||
|
user,
|
||||||
|
date,
|
||||||
|
mealType,
|
||||||
|
dish: pick(dishes),
|
||||||
|
pickupTime: `${date} ${randomTime()}`,
|
||||||
|
quantity: `${randomNum(1, 2)}份 / ${randomNum(180, 350)}g`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function genTraceDetail(meal: any) {
|
||||||
|
const pickedIngredients = Array.from({ length: randomNum(2, 3) }, () => pick(ingredients))
|
||||||
|
return {
|
||||||
|
serveInfo: {
|
||||||
|
serveTime: meal.pickupTime,
|
||||||
|
mealLine: pick(mealLines),
|
||||||
|
location: pick(['A窗口', 'B窗口', 'C窗口']),
|
||||||
|
temperature: `${randomNum(65, 78)}℃`,
|
||||||
|
},
|
||||||
|
cookInfo: {
|
||||||
|
cookTime: `${meal.date} ${randomTime()}`,
|
||||||
|
chef: pick(['厨师A', '厨师B', '厨师C', '厨师D']),
|
||||||
|
rawWeight: `${randomNum(20, 40)}kg`,
|
||||||
|
cookedWeight: `${randomNum(15, 30)}kg`,
|
||||||
|
process: pick(['炒', '炖', '煮', '烤']),
|
||||||
|
openTime: `${meal.date} ${randomTime()}`,
|
||||||
|
},
|
||||||
|
ingredientInfo: {
|
||||||
|
processTime: `${meal.date} ${randomTime()}`,
|
||||||
|
items: pickedIngredients.map((name, idx) => ({
|
||||||
|
name,
|
||||||
|
cleanBatch: `CS${meal.date.replace(/-/g, '')}${idx + 1}`,
|
||||||
|
rawBatch: `RS${meal.date.replace(/-/g, '')}${idx + 1}`,
|
||||||
|
lossRate: `${randomNum(5, 18)}%`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
sourceInfo: {
|
||||||
|
harvestTime: `${randomDate(20)} ${randomTime()}`,
|
||||||
|
origin: pick(origins),
|
||||||
|
land: pick(lands),
|
||||||
|
supplier: pick(suppliers),
|
||||||
|
testResult: '合格',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
if (!queryDate.value || !queryUser.value) {
|
||||||
|
message.warning('请选择日期和用户')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searched.value = true
|
||||||
|
mealRecords.value = genMealRecords()
|
||||||
|
selectedMeal.value = null
|
||||||
|
traceDetail.value = null
|
||||||
|
message.success('查询完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMeal(record: any) {
|
||||||
|
selectedMeal.value = record
|
||||||
|
traceDetail.value = genTraceDetail(record)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trace-overview {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.trace-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.ingredient-item {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
<StatCard title="总数量" :value="totalQuantity" suffix="kg" color="#52c41a" />
|
<StatCard title="总数量" :value="totalQuantity" suffix="kg" color="#52c41a" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="A级数量" :value="qualityCounts.A" color="#52c41a" />
|
<StatCard title="优等数量" :value="qualityCounts['优等']" color="#52c41a" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<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-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
@@ -51,8 +51,8 @@
|
|||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.dataIndex === 'quality'">
|
<template v-if="column.dataIndex === 'quality'">
|
||||||
<a-tag :color="record.quality === 'A' ? 'green' : record.quality === 'B' ? 'blue' : 'orange'">
|
<a-tag :color="record.quality === '优等' ? 'green' : record.quality === '良好' ? 'blue' : record.quality === '一般' ? 'orange' : 'red'">
|
||||||
{{ record.quality }}级
|
{{ record.quality }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'status'">
|
<template v-if="column.dataIndex === 'status'">
|
||||||
@@ -82,9 +82,10 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="质量等级">
|
<a-form-item label="质量等级">
|
||||||
<a-radio-group v-model:value="form.quality">
|
<a-radio-group v-model:value="form.quality">
|
||||||
<a-radio value="A">A级</a-radio>
|
<a-radio value="优等">优等</a-radio>
|
||||||
<a-radio value="B">B级</a-radio>
|
<a-radio value="良好">良好</a-radio>
|
||||||
<a-radio value="C">C级</a-radio>
|
<a-radio value="一般">一般</a-radio>
|
||||||
|
<a-radio value="不合格">不合格</a-radio>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="备注">
|
<a-form-item label="备注">
|
||||||
@@ -111,7 +112,7 @@ const relatedLinks = [
|
|||||||
|
|
||||||
const data = ref(genHarvestData(15))
|
const data = ref(genHarvestData(15))
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
const dateRange = ref<[Dayjs, Dayjs]>()
|
||||||
const statusFilter = ref<string | undefined>(undefined)
|
const statusFilter = ref<string | undefined>(undefined)
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const currentRecord = ref<any>(null)
|
const currentRecord = ref<any>(null)
|
||||||
@@ -119,7 +120,7 @@ const currentRecord = ref<any>(null)
|
|||||||
const form = ref({
|
const form = ref({
|
||||||
warehouse: undefined as string | undefined,
|
warehouse: undefined as string | undefined,
|
||||||
inspector: '',
|
inspector: '',
|
||||||
quality: 'A',
|
quality: '优等',
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -128,9 +129,9 @@ const totalQuantity = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const qualityCounts = 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 => {
|
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
|
return counts
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
<template v-if="column.key === 'status'">
|
<template v-if="column.key === 'status'">
|
||||||
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
|
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
|
||||||
</template>
|
</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'">
|
<template v-else-if="column.key === 'soilPH'">
|
||||||
<span :style="{ color: record.soilPH < 6 || record.soilPH > 7.5 ? '#f5222d' : '#52c41a' }">
|
<span :style="{ color: record.soilPH < 6 || record.soilPH > 7.5 ? '#f5222d' : '#52c41a' }">
|
||||||
{{ record.soilPH }}
|
{{ record.soilPH }}
|
||||||
@@ -83,6 +87,7 @@
|
|||||||
>
|
>
|
||||||
<template v-if="currentRecord">
|
<template v-if="currentRecord">
|
||||||
<a-descriptions :column="1" bordered size="small">
|
<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.name }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="地块面积">{{ currentRecord.area }}</a-descriptions-item>
|
<a-descriptions-item label="地块面积">{{ currentRecord.area }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="土壤类型">{{ currentRecord.soilType }}</a-descriptions-item>
|
<a-descriptions-item label="土壤类型">{{ currentRecord.soilType }}</a-descriptions-item>
|
||||||
@@ -97,6 +102,11 @@
|
|||||||
</a-tag>
|
</a-tag>
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
<a-descriptions-item label="上次检测时间">{{ currentRecord.lastCheck }}</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-descriptions>
|
||||||
|
|
||||||
<a-divider>近 7 天温度趋势</a-divider>
|
<a-divider>近 7 天温度趋势</a-divider>
|
||||||
@@ -123,16 +133,17 @@ const drawerOpen = ref(false)
|
|||||||
const currentRecord = ref<any>(null)
|
const currentRecord = ref<any>(null)
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
{ title: '地块编号', dataIndex: 'code', key: 'code', width: 100 },
|
||||||
{ title: '地块名称', dataIndex: 'name', key: 'name', width: 110 },
|
{ title: '地块名称', dataIndex: 'name', key: 'name', width: 110 },
|
||||||
{ title: '面积', dataIndex: 'area', key: 'area', width: 80 },
|
{ title: '面积', dataIndex: 'area', key: 'area', width: 80 },
|
||||||
{ title: '土壤类型', dataIndex: 'soilType', key: 'soilType', width: 90 },
|
{ title: '土壤类型', dataIndex: 'soilType', key: 'soilType', width: 90 },
|
||||||
{ title: 'pH值', dataIndex: 'soilPH', key: 'soilPH', width: 80 },
|
{ title: 'pH值', dataIndex: 'soilPH', key: 'soilPH', width: 80 },
|
||||||
{ title: '湿度', dataIndex: 'humidity', key: 'humidity', width: 80 },
|
{ title: '湿度', dataIndex: 'humidity', key: 'humidity', width: 80 },
|
||||||
{ title: '温度', dataIndex: 'temperature', key: 'temperature', width: 80 },
|
{ title: '温度', dataIndex: 'temperature', key: 'temperature', width: 80 },
|
||||||
{ title: '有机质', dataIndex: 'organicMatter', key: 'organicMatter', width: 90 },
|
|
||||||
{ title: '种植作物', dataIndex: 'crop', key: 'crop', width: 100 },
|
{ title: '种植作物', dataIndex: 'crop', key: 'crop', width: 100 },
|
||||||
|
{ title: '传感器', key: 'sensorStatus', width: 90 },
|
||||||
{ title: '状态', key: 'status', width: 90 },
|
{ title: '状态', key: 'status', width: 90 },
|
||||||
{ title: '上次检测', dataIndex: 'lastCheck', key: 'lastCheck', width: 120 },
|
{ title: '最近同步', dataIndex: 'lastSync', key: 'lastSync', width: 140 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const filteredData = computed(() => {
|
const filteredData = computed(() => {
|
||||||
|
|||||||
@@ -1,99 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="采供管理" />
|
<a-page-header title="采购管理" subtitle="采购订单与到货验收" />
|
||||||
<RelatedModules :links="relatedLinks" />
|
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="总订单数" :value="data.length" color="#1890ff" />
|
<StatCard title="总订单数" :value="orderData.length" color="#1890ff" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="总金额" :value="totalAmount" suffix="元" color="#52c41a" />
|
<StatCard title="总金额" :value="totalAmount" suffix="元" color="#52c41a" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="待处理" :value="pendingCount" color="#faad14" />
|
<StatCard title="待验收" :value="pendingVerifyCount" color="#faad14" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="本月订单" :value="thisMonthCount" color="#722ed1" />
|
<StatCard title="本月到货" :value="thisMonthArrived" color="#722ed1" />
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<!-- 工具栏 -->
|
<a-card>
|
||||||
<a-card style="margin-bottom: 16px">
|
<a-tabs v-model:activeKey="activeTab">
|
||||||
<a-row :gutter="16" align="middle">
|
<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-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>
|
||||||
<a-col :span="4">
|
<a-col :span="4">
|
||||||
<a-select v-model:value="statusFilter" placeholder="状态筛选" allow-clear style="width: 100%">
|
<a-select v-model:value="orderStatusFilter" placeholder="状态筛选" allow-clear style="width:100%">
|
||||||
<a-select-option value="pending">待处理</a-select-option>
|
<a-select-option value="pending">待确认</a-select-option>
|
||||||
<a-select-option value="processing">进行中</a-select-option>
|
<a-select-option value="confirmed">已确认</a-select-option>
|
||||||
<a-select-option value="done">已完成</a-select-option>
|
<a-select-option value="shipped">已发货</a-select-option>
|
||||||
<a-select-option value="approved">已审核</a-select-option>
|
<a-select-option value="arrived">已到货</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="4">
|
<a-col :span="4">
|
||||||
<a-button type="primary" @click="showModal">新增采购</a-button>
|
<a-button type="primary" @click="showOrderModal">新增采购</a-button>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 数据表格 -->
|
<!-- 到货验收工具栏 -->
|
||||||
<a-card>
|
<a-row v-if="activeTab === 'verify'" :gutter="16" align="middle" style="margin-bottom:16px">
|
||||||
<a-table
|
<a-col :span="6">
|
||||||
:columns="columns"
|
<a-input v-model:value="verifySearch" placeholder="搜索订单号/品种" allow-clear />
|
||||||
:data-source="filteredData"
|
</a-col>
|
||||||
:pagination="{ pageSize: 10 }"
|
<a-col :span="4">
|
||||||
row-key="id"
|
<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 #bodyCell="{ column, record }">
|
||||||
<template v-if="column.dataIndex === 'status'">
|
<template v-if="column.key === 'status'">
|
||||||
<a-tag :color="statusMap[record.status]?.color">
|
<a-tag :color="orderStatusColor(record.status)">{{ orderStatusText(record.status) }}</a-tag>
|
||||||
{{ statusMap[record.status]?.text }}
|
</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>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<a-popconfirm
|
<a-space v-if="record.verifyStatus === 'pending'">
|
||||||
title="确认审批通过?"
|
<a-button type="link" size="small" @click="verifyPass(record)">合格</a-button>
|
||||||
@confirm="handleApprove(record)"
|
<a-button type="link" size="small" danger @click="verifyFail(record)">不合格</a-button>
|
||||||
ok-text="确认"
|
</a-space>
|
||||||
cancel-text="取消"
|
|
||||||
>
|
|
||||||
<a-button type="link" size="small" :disabled="record.status === 'approved'">审批</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 新增采购弹窗 -->
|
<!-- 新增采购弹窗 -->
|
||||||
<a-modal v-model:open="modalVisible" title="新增采购" @ok="handleSubmit" @cancel="resetForm">
|
<a-modal v-model:open="orderModalVisible" title="新增采购订单" @ok="submitOrder" @cancel="orderModalVisible = false">
|
||||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
<a-form :label-col="{ span: 6 }">
|
||||||
<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-form-item label="供应商">
|
<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-option v-for="s in suppliers" :key="s" :value="s">{{ s }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="数量">
|
<a-form-item label="品种">
|
||||||
<a-input-group compact>
|
<a-select v-model:value="orderForm.material" placeholder="请选择品种">
|
||||||
<a-input-number v-model:value="form.quantity" :min="1" style="width: 70%" placeholder="数量" />
|
<a-select-option v-for="m in materials" :key="m" :value="m">{{ m }}</a-select-option>
|
||||||
<a-input v-model:value="form.unit" style="width: 30%" placeholder="单位" />
|
</a-select>
|
||||||
</a-input-group>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="单价(元)">
|
<a-form-item label="数量(kg)">
|
||||||
<a-input-number v-model:value="form.unitPrice" :min="0" :precision="2" style="width: 100%" placeholder="请输入单价" />
|
<a-input-number v-model:value="orderForm.quantity" :min="1" style="width:100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="到货日期">
|
<a-form-item label="单价(元/kg)">
|
||||||
<a-date-picker v-model:value="form.deliveryDate" style="width: 100%" />
|
<a-input-number v-model:value="orderForm.unitPrice" :min="0" :precision="2" style="width:100%" />
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="备注">
|
|
||||||
<a-textarea v-model:value="form.remark" :rows="3" placeholder="请输入备注" />
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
@@ -103,101 +122,139 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import type { Dayjs } from 'dayjs'
|
import { randomId, randomNum, randomFloat, randomDate, pick } from '@/mock/data'
|
||||||
import { genProcurementData, statusMap, randomId } from '@/mock/data'
|
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
import RelatedModules from '@/components/common/RelatedModules.vue'
|
|
||||||
|
|
||||||
const relatedLinks = [
|
const suppliers = ['集团种养公司', '集团净菜公司', '绿源农业', '丰禾食品', '天然牧场', '鲜美水产']
|
||||||
{ key: 'supplier-mgmt', label: '供商管理', color: 'green', arrow: '←' },
|
const materials = ['有机大米', '土鸡蛋', '新鲜猪肉', '活鱼', '时令蔬菜', '有机牛奶', '菜籽油', '食用盐']
|
||||||
{ key: 'raw-storage', label: '毛菜入库', color: 'blue', arrow: '→' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const data = ref(genProcurementData(12))
|
interface Order {
|
||||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
id: string; orderNo: string; supplier: string; material: string
|
||||||
const statusFilter = ref<string | undefined>(undefined)
|
quantity: number; unitPrice: number; amount: number
|
||||||
const modalVisible = ref(false)
|
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 = ['有机大米', '土鸡蛋', '新鲜猪肉', '活鱼', '时令蔬菜', '有机牛奶', '散养鸡', '绿色面粉', '菜籽油', '食用盐']
|
function genOrders(n = 15): Order[] {
|
||||||
const suppliers = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有��之家', '大地农庄', '春风蔬果', '碧水渔业']
|
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({
|
function genVerifyRecords(orders: Order[]): VerifyRecord[] {
|
||||||
material: undefined as string | undefined,
|
return orders.filter(o => o.status === 'arrived').map(o => {
|
||||||
supplier: undefined as string | undefined,
|
const actual = o.quantity - randomNum(-5, 10)
|
||||||
quantity: 100,
|
return {
|
||||||
unit: 'kg',
|
id: randomId(), orderNo: o.orderNo, material: o.material,
|
||||||
unitPrice: 0,
|
orderQty: o.quantity, actualQty: actual, diffQty: actual - o.quantity,
|
||||||
deliveryDate: null as Dayjs | null,
|
verifyPerson: pick(['验收员A', '验收员B', '验收员C']),
|
||||||
remark: '',
|
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(() => {
|
const filteredOrders = computed(() => {
|
||||||
return data.value.reduce((sum, item) => sum + parseFloat(item.totalPrice.replace('¥', '')), 0)
|
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
|
||||||
const pendingCount = computed(() => data.value.filter(item => item.status === 'pending').length)
|
return ms && mf
|
||||||
|
|
||||||
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 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: 'orderNo', key: 'orderNo' },
|
||||||
{ title: '物资名称', dataIndex: 'material', key: 'material' },
|
|
||||||
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' },
|
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' },
|
||||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity' },
|
{ title: '品种', dataIndex: 'material', key: 'material' },
|
||||||
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice' },
|
{ title: '数量(kg)', dataIndex: 'quantity', key: 'quantity' },
|
||||||
{ title: '总价', dataIndex: 'totalPrice', key: 'totalPrice' },
|
{ 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: 'orderDate', key: 'orderDate', sorter: (a: any, b: any) => a.orderDate.localeCompare(b.orderDate) },
|
||||||
{ title: '到货日期', dataIndex: 'deliveryDate', key: 'deliveryDate' },
|
{ title: '状态', key: 'status', width: 90 },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
{ title: '操作', key: 'action', width: 100 },
|
||||||
{ title: '采购员', dataIndex: 'purchaser', key: 'purchaser' },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function showModal() {
|
const verifyColumns = [
|
||||||
resetForm()
|
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo' },
|
||||||
modalVisible.value = true
|
{ 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() {
|
function orderStatusColor(s: string) { return s === 'pending' ? 'blue' : s === 'confirmed' ? 'cyan' : s === 'shipped' ? 'orange' : 'green' }
|
||||||
form.value = { material: undefined, supplier: undefined, quantity: 100, unit: 'kg', unitPrice: 0, deliveryDate: null, remark: '' }
|
function orderStatusText(s: string) { return s === 'pending' ? '待确认' : s === 'confirmed' ? '已确认' : s === 'shipped' ? '已发货' : '已到货' }
|
||||||
}
|
|
||||||
|
|
||||||
function handleApprove(record: any) {
|
function confirmOrder(r: any) { r.status = 'confirmed'; message.success('已确认') }
|
||||||
record.status = 'approved'
|
function shipOrder(r: any) { r.status = 'shipped'; message.success('已标记发货') }
|
||||||
message.success('审批通过')
|
function arriveOrder(r: any) {
|
||||||
}
|
r.status = 'arrived'
|
||||||
|
const actual = r.quantity - randomNum(-5, 10)
|
||||||
function handleSubmit() {
|
verifyData.value.unshift({
|
||||||
const total = form.value.quantity * form.value.unitPrice
|
id: randomId(), orderNo: r.orderNo, material: r.material,
|
||||||
data.value.unshift({
|
orderQty: r.quantity, actualQty: actual, diffQty: actual - r.quantity,
|
||||||
id: randomId(),
|
verifyPerson: '', verifyTime: new Date().toISOString().slice(0, 10), verifyStatus: 'pending',
|
||||||
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',
|
|
||||||
})
|
})
|
||||||
message.success('新增采购成功')
|
message.success('已到货,请前往到货验收')
|
||||||
modalVisible.value = false
|
}
|
||||||
resetForm()
|
|
||||||
|
function verifyPass(r: any) { r.verifyStatus = 'passed'; message.success('验收合格') }
|
||||||
|
function verifyFail(r: any) { 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>
|
</script>
|
||||||
|
|||||||
@@ -1,97 +1,150 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="供商管理" />
|
<a-page-header title="供商管理" subtitle="自有供应商与外部供应商统一管理" />
|
||||||
|
|
||||||
<!-- 工具栏 -->
|
<!-- 统计卡片 -->
|
||||||
<a-card style="margin-bottom: 16px">
|
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||||
<a-row :gutter="16" align="middle">
|
<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-col :span="6">
|
||||||
<a-input v-model:value="searchText" placeholder="搜索供应商名称" allow-clear />
|
<a-input v-model:value="searchText" placeholder="搜索供应商名称" allow-clear />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="4">
|
<a-col :span="4">
|
||||||
<a-select v-model:value="categoryFilter" placeholder="类别筛选" allow-clear style="width: 100%">
|
<a-select v-model:value="qualFilter" placeholder="资质状态" allow-clear style="width: 100%">
|
||||||
<a-select-option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</a-select-option>
|
<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-select>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="4">
|
<a-col :span="4">
|
||||||
<a-button type="primary" @click="handleAdd">新增供应商</a-button>
|
<a-button type="primary" @click="handleAdd">新增供应商</a-button>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 数据表格 -->
|
<!-- 数据表格 -->
|
||||||
<a-card>
|
|
||||||
<a-table
|
<a-table
|
||||||
:columns="columns"
|
:columns="currentColumns"
|
||||||
:data-source="filteredData"
|
:data-source="filteredData"
|
||||||
:pagination="{ pageSize: 10 }"
|
:pagination="{ pageSize: 10 }"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.dataIndex === 'category'">
|
<template v-if="column.key === 'supplierType'">
|
||||||
<a-tag color="blue">{{ record.category }}</a-tag>
|
<a-tag color="blue">{{ record.supplierType }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'address'">
|
<template v-if="column.key === 'qualification'">
|
||||||
<a-tooltip :title="record.address">
|
<a-tag :color="qualColor(record.qualification)">{{ qualText(record.qualification) }}</a-tag>
|
||||||
<span class="ellipsis-text">{{ record.address }}</span>
|
|
||||||
</a-tooltip>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'qualification'">
|
<template v-if="column.key === 'licenseExpiry'">
|
||||||
<a-tag :color="statusMap[record.qualification]?.color">
|
<span :style="{ color: isExpiring(record.licenseExpiry) ? '#faad14' : '' }">
|
||||||
{{ statusMap[record.qualification]?.text }}
|
{{ record.licenseExpiry }}
|
||||||
</a-tag>
|
<a-tag v-if="isExpiring(record.licenseExpiry)" color="warning" style="margin-left:4px">即将到期</a-tag>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'rating'">
|
<template v-if="column.key === 'creditScore'">
|
||||||
<a-rate :value="record.rating" disabled allow-half :style="{ fontSize: '14px' }" />
|
<a-rate :value="record.creditScore / 20" disabled allow-half :style="{ fontSize: '14px' }" />
|
||||||
|
<span style="margin-left:4px;color:#999">{{ record.creditScore }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'action'">
|
<template v-if="column.key === 'action'">
|
||||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
<a-space>
|
||||||
<a-button type="link" size="small" @click="handleDetail(record)">详情</a-button>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 新增/编辑弹窗 -->
|
<!-- 新增/编辑弹窗 -->
|
||||||
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑供应商' : '新增供应商'" @ok="handleSubmit" @cancel="resetForm">
|
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑供应商' : '新增供应商'" @ok="handleSubmit" @cancel="modalVisible = false" width="600px">
|
||||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
<a-form :label-col="{ span: 6 }">
|
||||||
<a-form-item label="供应商名称">
|
<a-form-item label="供应商名称">
|
||||||
<a-input v-model:value="form.name" placeholder="请输入名称" />
|
<a-input v-model:value="form.name" placeholder="请输入名称" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="类别">
|
<a-form-item label="供应商归属">
|
||||||
<a-select v-model:value="form.category" placeholder="请选择类别">
|
<a-radio-group v-model:value="form.ownership">
|
||||||
<a-select-option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</a-select-option>
|
<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-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="联系人">
|
||||||
|
<a-input v-model:value="form.contact" placeholder="请输入联系人" />
|
||||||
|
</a-form-item>
|
||||||
<a-form-item label="联系电话">
|
<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>
|
||||||
<a-form-item label="地址">
|
<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-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 详情抽屉 -->
|
<!-- 详情抽屉 -->
|
||||||
<a-drawer v-model:open="drawerVisible" title="供应商详情" :width="480">
|
<a-drawer v-model:open="drawerVisible" title="供应商详情" :width="520">
|
||||||
<a-descriptions v-if="currentRecord" bordered :column="1">
|
<a-descriptions v-if="currentRecord" bordered :column="1" size="small">
|
||||||
<a-descriptions-item label="供应商名称">{{ currentRecord.name }}</a-descriptions-item>
|
<a-descriptions-item label="供应商名称">{{ currentRecord.name }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="类别">
|
<a-descriptions-item label="归属">
|
||||||
<a-tag color="blue">{{ currentRecord.category }}</a-tag>
|
<a-tag :color="currentRecord.ownership === 'own' ? 'green' : 'purple'">
|
||||||
</a-descriptions-item>
|
{{ currentRecord.ownership === 'own' ? '自有供应商' : '外部供应商' }}
|
||||||
<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-tag>
|
</a-tag>
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
<a-descriptions-item label="评分">
|
<a-descriptions-item v-if="currentRecord.ownership === 'own'" label="类型">
|
||||||
<a-rate :value="currentRecord.rating" disabled allow-half />
|
<a-tag color="blue">{{ currentRecord.supplierType }}</a-tag>
|
||||||
</a-descriptions-item>
|
</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.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-descriptions>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,49 +153,110 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { message } from 'ant-design-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, 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 searchText = ref('')
|
||||||
const categoryFilter = ref<string | undefined>(undefined)
|
const qualFilter = ref<string>()
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const drawerVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const currentRecord = ref<any>(null)
|
const currentRecord = ref<Supplier | null>(null)
|
||||||
const categories = ['蔬菜', '肉类', '水产', '粮油', '蛋奶', '水果']
|
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '', ownership: 'own' as 'own' | 'external', supplierType: undefined as string | undefined,
|
||||||
category: undefined as string | undefined,
|
contact: '', phone: '', address: '',
|
||||||
contact: '',
|
licenseName: '', licenseExpiry: undefined as Dayjs | undefined, creditScore: 80,
|
||||||
address: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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(() => {
|
const filteredData = computed(() => {
|
||||||
return data.value.filter(item => {
|
const base = activeTab.value === 'own' ? ownData.value : externalData.value
|
||||||
const matchSearch = !searchText.value || item.name.includes(searchText.value)
|
return base.filter(s => {
|
||||||
const matchCategory = !categoryFilter.value || item.category === categoryFilter.value
|
const matchSearch = !searchText.value || s.name.includes(searchText.value)
|
||||||
return matchSearch && matchCategory
|
const matchQual = !qualFilter.value || s.qualification === qualFilter.value
|
||||||
|
return matchSearch && matchQual
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns = [
|
const commonCols = [
|
||||||
{ title: '供应商名称', dataIndex: 'name', key: 'name' },
|
{ title: '供应商名称', dataIndex: 'name', key: 'name' },
|
||||||
{ title: '类别', dataIndex: 'category', key: 'category' },
|
{ title: '联系人', dataIndex: 'contactPerson', key: 'contactPerson' },
|
||||||
{ title: '联系电话', dataIndex: 'contact', key: 'contact' },
|
{ title: '联系电话', dataIndex: 'phone', key: 'phone' },
|
||||||
{ title: '地址', dataIndex: 'address', key: 'address', ellipsis: true },
|
{ title: '资质证照', dataIndex: 'licenseName', key: 'licenseName' },
|
||||||
{ title: '资质状态', dataIndex: 'qualification', key: 'qualification' },
|
{ title: '证照有效期', dataIndex: 'licenseExpiry', key: 'licenseExpiry' },
|
||||||
{ title: '评分', dataIndex: 'rating', key: 'rating' },
|
{ title: '资质状态', key: 'qualification', width: 90 },
|
||||||
{ title: '合作年限', dataIndex: 'cooperationYears', key: 'cooperationYears' },
|
{ title: '诚信评分', key: 'creditScore', width: 180 },
|
||||||
{ title: '累计订单', dataIndex: 'totalOrders', key: 'totalOrders' },
|
{ title: '交易总额', dataIndex: 'totalAmount', key: 'totalAmount', customRender: ({ text }: any) => '¥' + (text || 0).toLocaleString() },
|
||||||
{ title: '上次供货日期', dataIndex: 'lastDelivery', key: 'lastDelivery' },
|
{ title: '操作', key: 'action', width: 120 },
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 140 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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() {
|
function handleAdd() {
|
||||||
isEdit.value = false
|
isEdit.value = false
|
||||||
currentRecord.value = null
|
currentRecord.value = null
|
||||||
resetForm()
|
form.value = { name: '', ownership: activeTab.value, supplierType: undefined, contact: '', phone: '', address: '', licenseName: '', licenseExpiry: undefined, creditScore: 80 }
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,10 +264,10 @@ function handleEdit(record: any) {
|
|||||||
isEdit.value = true
|
isEdit.value = true
|
||||||
currentRecord.value = record
|
currentRecord.value = record
|
||||||
form.value = {
|
form.value = {
|
||||||
name: record.name,
|
name: record.name, ownership: record.ownership, supplierType: record.supplierType,
|
||||||
category: record.category,
|
contact: record.contactPerson, phone: record.phone, address: record.address,
|
||||||
contact: record.contact,
|
licenseName: record.licenseName, licenseExpiry: record.licenseExpiry ? dayjs(record.licenseExpiry) : undefined,
|
||||||
address: record.address,
|
creditScore: record.creditScore,
|
||||||
}
|
}
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -163,45 +277,35 @@ function handleDetail(record: any) {
|
|||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
form.value = { name: '', category: undefined, contact: '', address: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
|
if (!form.value.name) { message.warning('请输入供应商名称'); return }
|
||||||
if (isEdit.value && currentRecord.value) {
|
if (isEdit.value && currentRecord.value) {
|
||||||
Object.assign(currentRecord.value, {
|
Object.assign(currentRecord.value, {
|
||||||
name: form.value.name,
|
name: form.value.name, ownership: form.value.ownership,
|
||||||
category: form.value.category,
|
supplierType: form.value.ownership === 'own' ? form.value.supplierType : '外部供应商',
|
||||||
contact: form.value.contact,
|
contactPerson: form.value.contact, phone: form.value.phone, address: form.value.address,
|
||||||
address: form.value.address,
|
licenseName: form.value.licenseName,
|
||||||
|
licenseExpiry: form.value.licenseExpiry?.format('YYYY-MM-DD') || '',
|
||||||
|
creditScore: form.value.creditScore,
|
||||||
})
|
})
|
||||||
message.success('编辑成功')
|
message.success('编辑成功')
|
||||||
} else {
|
} else {
|
||||||
data.value.unshift({
|
allData.value.unshift({
|
||||||
id: randomId(),
|
id: randomId(), name: form.value.name, ownership: form.value.ownership,
|
||||||
name: form.value.name || '新供应商',
|
supplierType: form.value.ownership === 'own' ? (form.value.supplierType || '种养公司') : '外部供应商',
|
||||||
category: form.value.category || '蔬菜',
|
contactPerson: form.value.contact, phone: form.value.phone, address: form.value.address,
|
||||||
contact: form.value.contact,
|
licenseName: form.value.licenseName,
|
||||||
address: form.value.address,
|
licenseExpiry: form.value.licenseExpiry?.format('YYYY-MM-DD') || '',
|
||||||
qualification: 'reviewing',
|
qualification: 'reviewing', creditScore: form.value.creditScore,
|
||||||
rating: 0,
|
totalAmount: 0, totalOrders: 0,
|
||||||
cooperationYears: 0,
|
cooperationStart: dayjs().format('YYYY-MM-DD'), lastTransaction: '',
|
||||||
totalOrders: 0,
|
|
||||||
lastDelivery: new Date().toISOString().slice(0, 10),
|
|
||||||
})
|
})
|
||||||
message.success('新增成功')
|
message.success('新增成功')
|
||||||
}
|
}
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
resetForm()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ellipsis-text {
|
.page-container { padding: 0; }
|
||||||
display: inline-block;
|
|
||||||
max-width: 150px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
v-if="record.status !== 'done'"
|
v-if="record.status !== 'done'"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@click="confirmAssemble(record)"
|
@click="confirmAssemble(record as AssembleItem)"
|
||||||
>确认组配</a-button>
|
>确认组配</a-button>
|
||||||
<span v-else style="color: #999">已完成</span>
|
<span v-else style="color: #999">已完成</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { statusMap, randomId, randomNum, randomFloat, pick, pickMulti, randomTime } from '@/mock/data'
|
import { statusMap, randomId, randomNum, randomFloat, pickMulti, randomTime } from '@/mock/data'
|
||||||
|
|
||||||
const vegs = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜']
|
const vegs = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜']
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,14 @@
|
|||||||
<p><strong>厨师:</strong>{{ item.chef }}</p>
|
<p><strong>厨师:</strong>{{ item.chef }}</p>
|
||||||
<p><strong>温度:</strong>{{ item.temperature }}</p>
|
<p><strong>温度:</strong>{{ item.temperature }}</p>
|
||||||
<p><strong>餐次:</strong>{{ item.mealType }}</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-steps :current="stepIndex(item.status)" size="small" style="margin-top: 12px">
|
||||||
<a-step title="待烹制" />
|
<a-step title="待烹制" />
|
||||||
<a-step title="烹制中" />
|
<a-step title="烹制中" />
|
||||||
@@ -79,7 +87,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { genCookByOrderData, statusMap, canteenNames } from '@/mock/data'
|
import { message } from 'ant-design-vue'
|
||||||
|
import { genCookByOrderData, canteenNames, randomNum } from '@/mock/data'
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
import RelatedModules from '@/components/common/RelatedModules.vue'
|
import RelatedModules from '@/components/common/RelatedModules.vue'
|
||||||
|
|
||||||
@@ -89,7 +98,11 @@ const relatedLinks = [
|
|||||||
{ key: 'serving-monitor', label: '上餐监控', color: 'blue', arrow: '→' },
|
{ 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 statusFilter = ref<string | undefined>(undefined)
|
||||||
const mealFilter = ref<string | undefined>(undefined)
|
const mealFilter = ref<string | undefined>(undefined)
|
||||||
const filterCanteen = ref<string | undefined>(undefined)
|
const filterCanteen = ref<string | undefined>(undefined)
|
||||||
@@ -124,6 +137,21 @@ function stepIndex(status: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshData() {
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,123 +1,184 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="食安监控" subtitle="实时食品安全检查与监控" />
|
<a-page-header title="食安监控" subtitle="食品安全视频监控、环境安全与餐品留样管理" />
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="今日检查" :value="tableData.length" suffix="次" color="#1890ff" />
|
<StatCard title="摄像头在线" :value="onlineCameras" suffix="台" color="#52c41a" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="合格率" :value="passRate" suffix="%" color="#52c41a" />
|
<StatCard title="今日违规" :value="todayViolations" suffix="次" color="#f5222d" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="发现问题" :value="issueCount" suffix="项" color="#f5222d" />
|
<StatCard title="环境告警" :value="envAlerts" suffix="项" color="#faad14" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<StatCard title="平均评分" :value="avgScore" suffix="分" color="#faad14" />
|
<StatCard title="留样记录" :value="sampleData.length" suffix="条" color="#1890ff" />
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<!-- 工具栏 -->
|
<a-card>
|
||||||
<a-card :bordered="false">
|
<a-tabs v-model:activeKey="activeTab">
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
<a-tab-pane key="video" tab="食安视频" />
|
||||||
<a-col :span="5">
|
<a-tab-pane key="env" tab="环境安全" />
|
||||||
<a-select v-model:value="filterCanteen" placeholder="所属食堂" allowClear style="width: 100%">
|
<a-tab-pane key="sample" tab="餐品留样" />
|
||||||
<a-select-option v-for="c in canteenNames" :key="c" :value="c">{{ c }}</a-select-option>
|
</a-tabs>
|
||||||
</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-table
|
<!-- 食安视频 -->
|
||||||
:columns="columns"
|
<a-table v-if="activeTab === 'video'" :columns="videoCols" :data-source="videoData"
|
||||||
:data-source="filteredData"
|
:pagination="{ pageSize: 10 }" row-key="id" size="middle">
|
||||||
:pagination="{ pageSize: 8, showTotal: (t: number) => `共 ${t} 条` }"
|
|
||||||
row-key="id"
|
|
||||||
size="middle"
|
|
||||||
>
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.dataIndex === 'result'">
|
<template v-if="column.key === 'online'">
|
||||||
<a-tag :color="statusMap[record.result]?.color">{{ statusMap[record.result]?.text }}</a-tag>
|
<a-badge :status="record.online ? 'success' : 'error'" :text="record.online ? '在线' : '离线'" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.dataIndex === 'score'">
|
<template v-if="column.key === 'violation'">
|
||||||
<a-progress
|
<a-tag v-if="record.violation" color="red">{{ record.violationType }}</a-tag>
|
||||||
:percent="record.score"
|
<span v-else style="color:#999">无</span>
|
||||||
:stroke-color="record.score > 90 ? '#52c41a' : record.score > 70 ? '#faad14' : '#f5222d'"
|
</template>
|
||||||
:size="'small'"
|
<template v-if="column.key === 'handleStatus'">
|
||||||
/>
|
<a-tag :color="record.handled ? 'green' : 'orange'">{{ record.handled ? '已处理' : '待处理' }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</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>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
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 StatCard from '@/components/common/StatCard.vue'
|
||||||
import { genFoodSafetyData, statusMap, canteenNames } from '@/mock/data'
|
|
||||||
|
|
||||||
const tableData = ref(genFoodSafetyData(15))
|
const activeTab = ref('video')
|
||||||
const filterCanteen = ref<string | undefined>(undefined)
|
|
||||||
const filterArea = ref<string | undefined>(undefined)
|
|
||||||
const filterResult = ref<string | undefined>(undefined)
|
|
||||||
|
|
||||||
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(() =>
|
const onlineCameras = computed(() => videoData.value.filter(v => v.online).length)
|
||||||
tableData.value.filter(d =>
|
const todayViolations = computed(() => videoData.value.filter(v => v.violation).length)
|
||||||
(!filterCanteen.value || d.canteen === filterCanteen.value) &&
|
|
||||||
(!filterArea.value || d.area === filterArea.value) &&
|
|
||||||
(!filterResult.value || d.result === filterResult.value)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const passRate = computed(() => {
|
const videoCols = [
|
||||||
const passed = tableData.value.filter(d => d.result === 'passed').length
|
{ title: '摄像头编号', dataIndex: 'cameraId', width: 120 },
|
||||||
return tableData.value.length ? +((passed / tableData.value.length) * 100).toFixed(1) : 0
|
{ title: '位置', dataIndex: 'location', width: 100 },
|
||||||
})
|
{ title: '在线状态', key: 'online', width: 90 },
|
||||||
|
{ title: '违规事件', key: 'violation', width: 100 },
|
||||||
const issueCount = computed(() => tableData.value.filter(d => d.issues !== '无').length)
|
{ title: '违规时间', dataIndex: 'violationTime', width: 150 },
|
||||||
|
{ title: '处理状态', key: 'handleStatus', width: 90 },
|
||||||
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 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped>
|
||||||
.page-container {
|
.page-container { padding: 0; }
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,76 +1,142 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="生产统计" />
|
<a-page-header title="生产统计" subtitle="生产报表与成本利润分析" />
|
||||||
|
|
||||||
<!-- 顶部统计 -->
|
<a-card>
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
<a-tabs v-model:activeKey="activeTab">
|
||||||
<a-col :span="6">
|
<a-tab-pane key="report" tab="生产报表" />
|
||||||
<StatCard title="今日菜品数" :value="statsData.today.totalDishes" color="#1890ff" />
|
<a-tab-pane key="cost" tab="成本利润" />
|
||||||
</a-col>
|
</a-tabs>
|
||||||
<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">
|
<div v-if="activeTab === 'report'">
|
||||||
<a-col :span="12">
|
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||||
<ChartPanel title="本周出餐趋势" type="bar" :data="servingsChartData" :height="260" />
|
<a-col :span="6">
|
||||||
</a-col>
|
<StatCard title="今日菜品数" :value="statsData.today.totalDishes" color="#1890ff" />
|
||||||
<a-col :span="12">
|
</a-col>
|
||||||
<ChartPanel title="本周浪费趋势" type="line" :data="wasteChartData" :height="260" />
|
<a-col :span="6">
|
||||||
</a-col>
|
<StatCard title="今日出餐量" :value="statsData.today.totalServings" suffix="份" color="#52c41a" />
|
||||||
</a-row>
|
</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="热门菜品排行">
|
<div v-if="activeTab === 'cost'">
|
||||||
<a-table
|
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||||
:columns="topDishColumns"
|
<a-col :span="6">
|
||||||
:data-source="statsData.topDishes"
|
<StatCard title="原材料成本" :value="costData.materialCost" suffix="元" color="#1890ff" />
|
||||||
:pagination="{ pageSize: 10 }"
|
</a-col>
|
||||||
row-key="name"
|
<a-col :span="6">
|
||||||
>
|
<StatCard title="人工成本" :value="costData.laborCost" suffix="元" color="#722ed1" />
|
||||||
<template #bodyCell="{ column, record }">
|
</a-col>
|
||||||
<template v-if="column.dataIndex === 'satisfaction'">
|
<a-col :span="6">
|
||||||
<a-rate :value="record.satisfaction" disabled allow-half :style="{ fontSize: '14px' }" />
|
<StatCard title="水电气成本" :value="costData.utilityCost" suffix="元" color="#faad14" />
|
||||||
</template>
|
</a-col>
|
||||||
</template>
|
<a-col :span="6">
|
||||||
</a-table>
|
<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>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import { genProductionStatsData } from '@/mock/data'
|
import { genProductionStatsData, randomNum } from '@/mock/data'
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
import ChartPanel from '@/components/common/ChartPanel.vue'
|
import ChartPanel from '@/components/common/ChartPanel.vue'
|
||||||
|
|
||||||
|
const activeTab = ref('report')
|
||||||
const statsData = reactive(genProductionStatsData())
|
const statsData = reactive(genProductionStatsData())
|
||||||
|
|
||||||
const servingsChartData = computed(() =>
|
const servingsChartData = computed(() => statsData.weeklyTrend.map(i => ({ label: i.day, value: i.servings })))
|
||||||
statsData.weeklyTrend.map(item => ({
|
const wasteChartData = computed(() => statsData.weeklyTrend.map(i => ({ label: i.day, value: i.waste })))
|
||||||
label: item.day,
|
|
||||||
value: item.servings,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const wasteChartData = computed(() =>
|
|
||||||
statsData.weeklyTrend.map(item => ({
|
|
||||||
label: item.day,
|
|
||||||
value: item.waste,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const topDishColumns = [
|
const topDishColumns = [
|
||||||
{ title: '菜品', dataIndex: 'name', key: 'name' },
|
{ title: '菜品', dataIndex: 'name' },
|
||||||
{ title: '出餐量', dataIndex: 'servings', key: 'servings', sorter: (a: any, b: any) => a.servings - b.servings },
|
{ title: '出餐量', dataIndex: 'servings', sorter: (a: any, b: any) => a.servings - b.servings },
|
||||||
{ title: '满意度', dataIndex: 'satisfaction', key: 'satisfaction' },
|
{ 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>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<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-card :bordered="false">
|
||||||
<a-tabs v-model:activeKey="activeDay">
|
<a-tabs v-model:activeKey="activeDay">
|
||||||
@@ -46,11 +50,26 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
</a-checkbox-group>
|
</a-checkbox-group>
|
||||||
</a-modal>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import { genRecipePlanData } from '@/mock/data'
|
||||||
|
|
||||||
const weekData = ref(genRecipePlanData())
|
const weekData = ref(genRecipePlanData())
|
||||||
@@ -59,6 +78,42 @@ const editModalVisible = ref(false)
|
|||||||
const editTarget = reactive({ day: '', meal: '' })
|
const editTarget = reactive({ day: '', meal: '' })
|
||||||
const selectedDishes = ref<string[]>([])
|
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 = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '麻婆豆腐', '蒜蓉西兰花', '酸辣汤', '蛋炒饭', '紫菜蛋花汤', '水煮鱼', '回锅肉', '红烧茄子', '醋溜白菜', '干煸豆角', '凉拌黄瓜', '蒸鲈鱼', '炖牛腩', '小炒肉']
|
const allDishes = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '麻婆豆腐', '蒜蓉西兰花', '酸辣汤', '蛋炒饭', '紫菜蛋花汤', '水煮鱼', '回锅肉', '红烧茄子', '醋溜白菜', '干煸豆角', '凉拌黄瓜', '蒸鲈鱼', '炖牛腩', '小炒肉']
|
||||||
|
|
||||||
function mealColor(meal: string) {
|
function mealColor(meal: string) {
|
||||||
|
|||||||
@@ -44,7 +44,14 @@
|
|||||||
<a-descriptions-item label="餐次">{{ item.mealType }}</a-descriptions-item>
|
<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.servingStart.split(' ')[1] }} - {{ item.servingEnd.split(' ')[1] }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="已出餐">{{ item.totalServed }}份</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-item label="值班员" :span="2">{{ item.monitor }}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
|
|
||||||
@@ -74,7 +81,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
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 StatCard from '@/components/common/StatCard.vue'
|
||||||
import RelatedModules from '@/components/common/RelatedModules.vue'
|
import RelatedModules from '@/components/common/RelatedModules.vue'
|
||||||
|
|
||||||
@@ -84,7 +91,12 @@ const relatedLinks = [
|
|||||||
{ key: 'consumption-mgmt', label: '消费管理', color: 'blue', arrow: '→' },
|
{ 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 filterCanteen = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
const filteredData = computed(() =>
|
const filteredData = computed(() =>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="4">
|
<a-col :span="4">
|
||||||
<a-select v-model:value="disposalFilter" placeholder="处置方式" allow-clear style="width: 100%">
|
<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>
|
<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 :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||||
<a-form-item label="处置方式">
|
<a-form-item label="处置方式">
|
||||||
<a-select v-model:value="editDisposal" style="width: 100%">
|
<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>
|
<a-select-option value="捐赠">捐赠</a-select-option>
|
||||||
<a-select-option value="堆肥">堆肥</a-select-option>
|
<a-select-option value="堆肥">堆肥</a-select-option>
|
||||||
@@ -74,18 +76,19 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
import { genSurplusData, statusMap } from '@/mock/data'
|
import { genSurplusData } from '@/mock/data'
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
import ChartPanel from '@/components/common/ChartPanel.vue'
|
import ChartPanel from '@/components/common/ChartPanel.vue'
|
||||||
|
|
||||||
const data = ref(genSurplusData(8))
|
const data = ref(genSurplusData(8))
|
||||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
const dateRange = ref<[Dayjs, Dayjs]>()
|
||||||
const disposalFilter = ref<string | undefined>(undefined)
|
const disposalFilter = ref<string | undefined>(undefined)
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const currentRecord = ref<any>(null)
|
const currentRecord = ref<any>(null)
|
||||||
const editDisposal = ref('')
|
const editDisposal = ref('')
|
||||||
|
|
||||||
const disposalColorMap: Record<string, string> = {
|
const disposalColorMap: Record<string, string> = {
|
||||||
|
'打折销售': 'cyan',
|
||||||
'员工餐': 'blue',
|
'员工餐': 'blue',
|
||||||
'捐赠': 'green',
|
'捐赠': 'green',
|
||||||
'堆肥': 'orange',
|
'堆肥': 'orange',
|
||||||
|
|||||||
@@ -27,8 +27,13 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-card>
|
<a-card>
|
||||||
<!-- 工具栏 -->
|
<a-tabs v-model:activeKey="activeTab">
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
|
<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-col :span="4">
|
||||||
<a-select v-model:value="lineFilter" style="width: 100%" placeholder="加工线">
|
<a-select v-model:value="lineFilter" style="width: 100%" placeholder="加工线">
|
||||||
<a-select-option value="">全部加工线</a-select-option>
|
<a-select-option value="">全部加工线</a-select-option>
|
||||||
@@ -50,6 +55,7 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
|
v-if="activeTab === 'record'"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data-source="filteredData"
|
:data-source="filteredData"
|
||||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
||||||
@@ -67,6 +73,23 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</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>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -75,6 +98,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { genCleanProcessData, statusMap } from '@/mock/data'
|
import { genCleanProcessData, statusMap } from '@/mock/data'
|
||||||
|
|
||||||
|
const activeTab = ref('record')
|
||||||
const tableData = ref(genCleanProcessData(25))
|
const tableData = ref(genCleanProcessData(25))
|
||||||
const lineFilter = ref('')
|
const lineFilter = ref('')
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
@@ -118,6 +142,32 @@ const columns = [
|
|||||||
{ title: '操作员', dataIndex: 'operator', width: 90 },
|
{ title: '操作员', dataIndex: 'operator', width: 90 },
|
||||||
{ title: '状态', dataIndex: 'status', 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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -5,81 +5,126 @@
|
|||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card>
|
<a-card><a-statistic title="检测总数" :value="tableData.length" suffix="批次" /></a-card>
|
||||||
<a-statistic title="检测总数" :value="tableData.length" suffix="批次" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card>
|
<a-card><a-statistic title="合格率" :value="passRate" suffix="%" :value-style="{ color: '#52c41a' }" /></a-card>
|
||||||
<a-statistic title="合格率" :value="passRate" suffix="%" :value-style="{ color: '#52c41a' }" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card>
|
<a-card><a-statistic title="不合格数" :value="failedCount" :value-style="{ color: '#ff4d4f' }" /></a-card>
|
||||||
<a-statistic title="不合格数" :value="failedCount" :value-style="{ color: '#ff4d4f' }" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card>
|
<a-card><a-statistic title="检测报告" :value="reportData.length" suffix="份" /></a-card>
|
||||||
<a-statistic title="今日检测" :value="todayCount" suffix="批次" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-card>
|
<a-card>
|
||||||
<!-- 工具栏 -->
|
<a-tabs v-model:activeKey="activeTab">
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
|
<a-tab-pane key="record" tab="检测记录" />
|
||||||
<a-col :span="6">
|
<a-tab-pane key="report" tab="检测报告" />
|
||||||
<a-range-picker v-model:value="dateRange" style="width: 100%" />
|
</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>
|
||||||
<a-col :span="4">
|
<a-col :span="4">
|
||||||
<a-select v-model:value="resultFilter" style="width: 100%" placeholder="检测结果">
|
<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-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="passed">合格</a-select-option>
|
||||||
<a-select-option value="failed">不合格</a-select-option>
|
<a-select-option value="failed">不合格</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="5">
|
<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-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-table
|
<!-- 检测记录表格 -->
|
||||||
:columns="columns"
|
<a-table v-if="activeTab === 'record'" :columns="recordColumns" :data-source="filteredRecords"
|
||||||
:data-source="filteredData"
|
:pagination="{ pageSize: 10, showTotal: (t: number) => `共 ${t} 条` }" row-key="id" size="middle">
|
||||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
|
||||||
row-key="id"
|
|
||||||
size="middle"
|
|
||||||
>
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.dataIndex === 'result'">
|
<template v-if="column.key === 'result'">
|
||||||
<a-tag :color="statusMap[record.result]?.color">{{ statusMap[record.result]?.text }}</a-tag>
|
<a-tag :color="record.result === 'passed' ? 'green' : 'red'">{{ record.result === 'passed' ? '合格' : '不合格' }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="column.key === 'action'">
|
<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>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 报告详情抽屉 -->
|
<!-- 检测记录详情抽屉 -->
|
||||||
<a-drawer
|
<a-drawer v-model:open="detailVisible" title="检测记录详情" :width="520">
|
||||||
v-model:open="drawerVisible"
|
|
||||||
title="检测报告详情"
|
|
||||||
:width="520"
|
|
||||||
>
|
|
||||||
<a-descriptions v-if="currentRecord" :column="1" bordered size="small">
|
<a-descriptions v-if="currentRecord" :column="1" bordered size="small">
|
||||||
<a-descriptions-item label="批次号">{{ currentRecord.batchNo }}</a-descriptions-item>
|
<a-descriptions-item label="批次号">{{ currentRecord.batchNo }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="食材名称">{{ currentRecord.material }}</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="检测日期">{{ currentRecord.testDate }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="检测结果">
|
<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>
|
||||||
<a-descriptions-item label="检测值">{{ currentRecord.value }}</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.standard }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="检测员">{{ currentRecord.inspector }}</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-descriptions>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,68 +133,86 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { Dayjs } from 'dayjs'
|
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 tableData = ref(genHygieneTestData(30))
|
||||||
|
|
||||||
|
// 检测记录
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const resultFilter = ref('')
|
const testItemFilter = ref<string>()
|
||||||
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
|
const resultFilter = ref<string>()
|
||||||
const drawerVisible = ref(false)
|
const dateRange = ref<[Dayjs, Dayjs]>()
|
||||||
|
const detailVisible = ref(false)
|
||||||
const currentRecord = ref<any>(null)
|
const currentRecord = ref<any>(null)
|
||||||
|
|
||||||
const passRate = computed(() => {
|
const passRate = computed(() => {
|
||||||
if (!tableData.value.length) return 0
|
if (!tableData.value.length) return 0
|
||||||
const passed = tableData.value.filter(r => r.result === 'passed').length
|
return +((tableData.value.filter(r => r.result === 'passed').length / tableData.value.length) * 100).toFixed(1)
|
||||||
return +((passed / tableData.value.length) * 100).toFixed(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const failedCount = computed(() => tableData.value.filter(r => r.result === 'failed').length)
|
const failedCount = computed(() => tableData.value.filter(r => r.result === 'failed').length)
|
||||||
|
|
||||||
const todayCount = computed(() => {
|
const filteredRecords = computed(() => {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
return tableData.value.filter(r => r.testDate === today).length
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredData = computed(() => {
|
|
||||||
return tableData.value.filter(item => {
|
return tableData.value.filter(item => {
|
||||||
if (searchText.value) {
|
const ms = !searchText.value || item.material.includes(searchText.value) || item.batchNo.includes(searchText.value)
|
||||||
const kw = searchText.value.toLowerCase()
|
const mt = !testItemFilter.value || item.testItem === testItemFilter.value
|
||||||
if (!item.material.toLowerCase().includes(kw) && !item.batchNo.toLowerCase().includes(kw)) return false
|
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'))
|
||||||
if (resultFilter.value && item.result !== resultFilter.value) return false
|
return ms && mt && mr && md
|
||||||
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 columns = [
|
const recordColumns = [
|
||||||
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
|
{ title: '批次号', dataIndex: 'batchNo', width: 160 },
|
||||||
{ title: '食材名', dataIndex: 'material', width: 100 },
|
{ title: '食材', dataIndex: 'material', width: 90 },
|
||||||
{ title: '检测项目', dataIndex: 'testItem', width: 100 },
|
{ title: '检测项', dataIndex: 'testItem', width: 90 },
|
||||||
{ title: '检测日期', dataIndex: 'testDate', width: 110 },
|
{ title: '农残值', dataIndex: 'value', width: 90 },
|
||||||
{ title: '结果', dataIndex: 'result', width: 80 },
|
{ title: '标准值', dataIndex: 'standard', width: 90 },
|
||||||
{ title: '检测值', dataIndex: 'value', width: 100 },
|
{ title: '结果', key: 'result', width: 80 },
|
||||||
{ title: '标准值', dataIndex: 'standard', width: 100 },
|
{ title: '检测员', dataIndex: 'inspector', width: 80 },
|
||||||
{ title: '检测员', dataIndex: 'inspector', width: 90 },
|
{ title: '检测时间', dataIndex: 'testDate', width: 110 },
|
||||||
{ title: '报告编号', dataIndex: 'report', width: 120 },
|
{ title: '操作', key: 'action', width: 80 },
|
||||||
{ title: '操作', key: 'action', width: 100, fixed: 'right' as const },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function showReport(record: any) {
|
function showDetail(record: any) { currentRecord.value = record; detailVisible.value = true }
|
||||||
currentRecord.value = record
|
|
||||||
drawerVisible.value = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
// 检测报告
|
||||||
.page-container {
|
interface Report { id: string; reportNo: string; batchNo: string; testItems: string[]; fileName: string; uploadMethod: 'device' | 'ocr'; uploadTime: string }
|
||||||
:deep(.ant-statistic-title) {
|
|
||||||
font-size: 13px;
|
const reportData = ref<Report[]>(
|
||||||
color: #666;
|
Array.from({ length: 15 }, () => ({
|
||||||
}
|
id: randomId(),
|
||||||
}
|
reportNo: 'RPT' + randomNum(10000, 99999),
|
||||||
</style>
|
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: any) { currentReport.value = record; reportDetailVisible.value = true }
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="毛菜入库" subtitle="原始食材入库管理与登记" />
|
<a-page-header title="毛菜管理" subtitle="毛菜入库与库存台账" />
|
||||||
<RelatedModules :links="relatedLinks" />
|
<RelatedModules :links="relatedLinks" />
|
||||||
|
|
||||||
<a-card>
|
<a-card>
|
||||||
<!-- 工具栏 -->
|
<a-tabs v-model:activeKey="activeTab">
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px" align="middle">
|
<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-col :span="6">
|
||||||
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号/供应商" allow-clear />
|
<a-input-search v-model:value="searchText" placeholder="搜索食材/批次号/供应商" allow-clear />
|
||||||
</a-col>
|
</a-col>
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
|
v-if="activeTab === 'storage'"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data-source="filteredData"
|
:data-source="filteredData"
|
||||||
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
:pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` }"
|
||||||
@@ -38,6 +44,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
|
<!-- 毛菜台账 -->
|
||||||
|
<a-table
|
||||||
|
v-if="activeTab === 'ledger'"
|
||||||
|
:columns="ledgerColumns"
|
||||||
|
:data-source="ledgerData"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="material"
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 新增入库 Modal -->
|
<!-- 新增入库 Modal -->
|
||||||
@@ -91,10 +107,41 @@ const relatedLinks = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const tableData = ref(genRawStorageData(28))
|
const tableData = ref(genRawStorageData(28))
|
||||||
|
const activeTab = ref('storage')
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
const modalVisible = ref(false)
|
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 materialOptions = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '猪里脊', '鸡胸肉']
|
||||||
const supplierOptions = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有机之家']
|
const supplierOptions = ['绿源农业', '丰禾食品', '天然牧场', '鲜美水产', '有机之家']
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
<div class="terminal-link">
|
||||||
|
<router-link to="/weighing-terminal">📦 打开称重终端 →</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,4 +73,18 @@ async function handleLogin() {
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #888;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover { color: #4c8bf5; }
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="算法标准" sub-title="营养标准参数配置" />
|
<a-page-header title="算法标准" sub-title="营养标准参数与营养方案配置" />
|
||||||
<a-card>
|
<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-tabs v-model:activeKey="activeTab">
|
||||||
<a-tab-pane v-for="g in groups" :key="g.key" :tab="g.label">
|
<a-tab-pane v-for="g in groups" :key="g.key" :tab="g.label">
|
||||||
<a-form layout="vertical" :model="config[g.key]">
|
<a-form layout="vertical" :model="config[g.key]">
|
||||||
@@ -69,6 +78,47 @@
|
|||||||
<a-button type="primary" @click="save">保存配置</a-button>
|
<a-button type="primary" @click="save">保存配置</a-button>
|
||||||
<a-button @click="reset">重置</a-button>
|
<a-button @click="reset">重置</a-button>
|
||||||
</div>
|
</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>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -77,7 +127,48 @@
|
|||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
|
|
||||||
|
const topTab = ref('standard')
|
||||||
|
const planTab = ref('personal')
|
||||||
const activeTab = ref('male')
|
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 = [
|
const groups = [
|
||||||
{ key: 'male', label: '成人男性' },
|
{ key: 'male', label: '成人男性' },
|
||||||
{ key: 'female', 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-option v-for="c in categories" :key="c" :value="c">{{ c }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
<a-button type="primary" @click="message.success('打开新增表单')">+ 新增餐品</a-button>
|
<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>
|
||||||
<div class="dish-grid">
|
<div class="dish-grid">
|
||||||
<a-card v-for="d in filtered" :key="d.id" hoverable class="dish-card">
|
<a-card v-for="d in filtered" :key="d.id" hoverable class="dish-card">
|
||||||
@@ -53,6 +57,27 @@ const filtered = computed(() =>
|
|||||||
return m && c
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<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-row :gutter="16" class="stats">
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card><a-statistic title="本月采购总额" :value="156800" prefix="¥" /></a-card>
|
<a-card><a-statistic title="本月采购总额" :value="156800" prefix="¥" /></a-card>
|
||||||
@@ -19,13 +26,40 @@
|
|||||||
<a-card style="margin-top: 16px">
|
<a-card style="margin-top: 16px">
|
||||||
<a-table :columns="columns" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id" />
|
<a-table :columns="columns" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id" />
|
||||||
</a-card>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import ChartPanel from '@/components/common/ChartPanel.vue'
|
import ChartPanel from '@/components/common/ChartPanel.vue'
|
||||||
|
import { randomNum, randomFloat } from '@/mock/data'
|
||||||
|
|
||||||
|
const benefitTab = ref('ingredient')
|
||||||
const materials = ['猪肉', '鸡肉', '大米', '青菜', '番茄', '土豆', '鸡蛋', '面粉']
|
const materials = ['猪肉', '鸡肉', '大米', '青菜', '番茄', '土豆', '鸡蛋', '面粉']
|
||||||
|
|
||||||
const list = ref(materials.map((name, i) => {
|
const list = ref(materials.map((name, i) => {
|
||||||
@@ -56,6 +90,34 @@ const columns = [
|
|||||||
{ title: '损耗率', dataIndex: 'lossRate' },
|
{ title: '损耗率', dataIndex: 'lossRate' },
|
||||||
{ title: '成本/kg', dataIndex: 'costPerKg' },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -21,24 +21,37 @@
|
|||||||
<a-tag :color="statusMap[line.status]?.color">{{ statusMap[line.status]?.text }}</a-tag>
|
<a-tag :color="statusMap[line.status]?.color">{{ statusMap[line.status]?.text }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<a-row :gutter="12" class="stats">
|
<a-row :gutter="12" class="stats">
|
||||||
<a-col :span="12">
|
<a-col :span="8">
|
||||||
<a-statistic title="今日菜品" :value="line.todayDishes" />
|
<a-statistic title="今日菜品" :value="line.todayDishes" />
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="8">
|
||||||
<a-statistic title="今日服务" :value="line.todayServed" suffix="人" />
|
<a-statistic title="今日服务" :value="line.todayServed" suffix="人" />
|
||||||
</a-col>
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-statistic title="取餐速率" :value="line.pickupRate" suffix="人/分" :value-style="{ fontSize: '16px' }" />
|
||||||
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-descriptions :column="1" size="small" class="desc">
|
<a-descriptions :column="1" size="small" class="desc">
|
||||||
<a-descriptions-item label="所属食堂">{{ line.canteen }}</a-descriptions-item>
|
<a-descriptions-item label="所属食堂">{{ line.canteen }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="餐线容量">{{ line.capacity }}</a-descriptions-item>
|
<a-descriptions-item label="当前存量">
|
||||||
|
<a-progress :percent="line.stockPercent" size="small"
|
||||||
|
:stroke-color="line.stockPercent < 20 ? '#f5222d' : line.stockPercent < 50 ? '#faad14' : '#52c41a'" />
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="补餐状态">
|
||||||
|
<a-tag :color="line.stockPercent < 20 ? 'red' : 'green'">
|
||||||
|
{{ line.stockPercent < 20 ? '需补餐' : '正常' }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="line.addMealDisabled" color="default">禁加餐</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
<a-descriptions-item label="负责人">{{ line.manager }}</a-descriptions-item>
|
<a-descriptions-item label="负责人">{{ line.manager }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="设备数">{{ line.equipment }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="评分">
|
<a-descriptions-item label="评分">
|
||||||
<a-rate :value="line.rating" disabled allow-half style="font-size: 14px" />
|
<a-rate :value="line.rating" disabled allow-half style="font-size: 14px" />
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
|
<a-alert v-if="line.stockPercent < 20" type="warning" show-icon
|
||||||
|
:message="`存量不足1/5,建议立即补餐`" style="margin-bottom:8px" />
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a-button size="small" @click="message.info('编辑 ' + line.name)">编辑</a-button>
|
<a-button size="small" @click="message.info('查看监控视频 - ' + line.name)">监控视频</a-button>
|
||||||
<a-button size="small" type="primary" @click="message.info('查看详情')">查看详情</a-button>
|
<a-button size="small" type="primary" @click="message.info('查看详情')">查看详情</a-button>
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
@@ -49,9 +62,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { genMealLineData, statusMap, canteenNames } from '@/mock/data'
|
import { genMealLineData, statusMap, canteenNames, randomNum } from '@/mock/data'
|
||||||
|
|
||||||
const list = ref(genMealLineData())
|
const list = ref(genMealLineData().map((l: any) => ({
|
||||||
|
...l,
|
||||||
|
pickupRate: randomNum(2, 12),
|
||||||
|
stockPercent: randomNum(5, 95),
|
||||||
|
addMealDisabled: Math.random() > 0.7,
|
||||||
|
})))
|
||||||
const filterCanteen = ref<string | undefined>(undefined)
|
const filterCanteen = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
const filteredList = computed(() =>
|
const filteredList = computed(() =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<a-table :columns="columns" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id">
|
<a-table :columns="columns" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id">
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'type'">
|
<template v-if="column.key === 'type'">
|
||||||
<a-tag :color="typeColor[record.type]">{{ record.type }}</a-tag>
|
<a-tag :color="typeColor[record.level]">{{ record.level }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'accuracy'">
|
<template v-else-if="column.key === 'accuracy'">
|
||||||
<a-progress :percent="record.accuracy" size="small" />
|
<a-progress :percent="record.accuracy" size="small" />
|
||||||
@@ -29,25 +29,27 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
|
|
||||||
const typeColor: Record<string, string> = { '推荐': 'blue', '营养分析': 'green', '预测': 'purple' }
|
const typeColor: Record<string, string> = { '大模型': 'red', '专用模型': 'blue', '小模型': 'green' }
|
||||||
|
|
||||||
const list = ref([
|
const list = ref([
|
||||||
{ id: 1, name: '智能点餐推荐', version: 'v2.1.3', type: '推荐', accuracy: 92, status: '在线', updateTime: '2026-04-01' },
|
{ id: 1, name: '智能点餐推荐', level: '大模型', version: 'v2.1.3', trainParams: 'batch=64, lr=0.001, epochs=50', apiEndpoint: '/api/model/recommend', accuracy: 92, status: '在线', updateTime: '2026-04-01' },
|
||||||
{ id: 2, name: '营养评分模型', version: 'v3.0.1', type: '营养分析', accuracy: 88, status: '在线', updateTime: '2026-03-28' },
|
{ id: 2, name: '营养评分模型', level: '专用模型', version: 'v3.0.1', trainParams: 'batch=32, lr=0.005, epochs=100', apiEndpoint: '/api/model/nutrition-score', accuracy: 88, status: '在线', updateTime: '2026-03-28' },
|
||||||
{ id: 3, name: '消费预测模型', version: 'v1.4.0', type: '预测', accuracy: 85, status: '在线', updateTime: '2026-03-25' },
|
{ id: 3, name: '消费预测模型', level: '专用模型', version: 'v1.4.0', trainParams: 'batch=128, lr=0.002, epochs=80', apiEndpoint: '/api/model/consumption-predict', accuracy: 85, status: '在线', updateTime: '2026-03-25' },
|
||||||
{ id: 4, name: '食材损耗预测', version: 'v1.2.0', type: '预测', accuracy: 79, status: '离线', updateTime: '2026-03-15' },
|
{ id: 4, name: '食材损耗预测', level: '小模型', version: 'v1.2.0', trainParams: 'batch=16, lr=0.01, epochs=30', apiEndpoint: '/api/model/loss-predict', accuracy: 79, status: '离线', updateTime: '2026-03-15' },
|
||||||
{ id: 5, name: '过敏原识别', version: 'v2.0.0', type: '营养分析', accuracy: 95, status: '在线', updateTime: '2026-04-03' },
|
{ id: 5, name: '过敏原识别', level: '专用模型', version: 'v2.0.0', trainParams: 'batch=64, lr=0.003, epochs=120', apiEndpoint: '/api/model/allergen-detect', accuracy: 95, status: '在线', updateTime: '2026-04-03' },
|
||||||
{ id: 6, name: '个性化菜谱', version: 'v1.8.2', type: '推荐', accuracy: 87, status: '离线', updateTime: '2026-03-20' },
|
{ id: 6, name: '个性化菜谱', level: '大模型', version: 'v1.8.2', trainParams: 'batch=64, lr=0.001, epochs=60', apiEndpoint: '/api/model/personal-recipe', accuracy: 87, status: '离线', updateTime: '2026-03-20' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '模型名称', dataIndex: 'name' },
|
{ title: '模型名称', dataIndex: 'name' },
|
||||||
{ title: '版本', dataIndex: 'version' },
|
{ title: '级别', key: 'type', width: 90 },
|
||||||
{ title: '类型', key: 'type' },
|
{ title: '版本', dataIndex: 'version', width: 80 },
|
||||||
{ title: '准确率', key: 'accuracy', width: 200 },
|
{ title: '训练参数', dataIndex: 'trainParams', ellipsis: true },
|
||||||
{ title: '状态', key: 'status' },
|
{ title: 'API接口', dataIndex: 'apiEndpoint', ellipsis: true },
|
||||||
{ title: '更新时间', dataIndex: 'updateTime' },
|
{ title: '准确率', key: 'accuracy', width: 160 },
|
||||||
{ title: '操作', key: 'actions' },
|
{ title: '状态', key: 'status', width: 80 },
|
||||||
|
{ title: '更新时间', dataIndex: 'updateTime', width: 110 },
|
||||||
|
{ title: '操作', key: 'actions', width: 120 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const toggle = (r: any) => {
|
const toggle = (r: any) => {
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="nutrition-mgmt">
|
<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-card :bordered="false" class="filter-bar">
|
||||||
<a-space size="middle" wrap>
|
<a-space size="middle" wrap>
|
||||||
@@ -123,7 +132,7 @@
|
|||||||
<a-descriptions :column="2" bordered size="small">
|
<a-descriptions :column="2" bordered size="small">
|
||||||
<a-descriptions-item label="姓名">{{ personProfile.name }}</a-descriptions-item>
|
<a-descriptions-item label="姓名">{{ personProfile.name }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="部门">{{ personProfile.dept }}</a-descriptions-item>
|
<a-descriptions-item label="部门">{{ personProfile.dept }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="日均热量">{{ personProfile.avgCalories }} kcal</a-descriptions-item>
|
<a-descriptions-item label="日均热量">{{ personProfile.calories }} kcal</a-descriptions-item>
|
||||||
<a-descriptions-item label="营养评分">
|
<a-descriptions-item label="营养评分">
|
||||||
<a-progress :percent="personProfile.score" :size="20"
|
<a-progress :percent="personProfile.score" :size="20"
|
||||||
:stroke-color="personProfile.score >= 80 ? '#52c41a' : personProfile.score >= 60 ? '#faad14' : '#f5222d'" />
|
:stroke-color="personProfile.score >= 80 ? '#52c41a' : personProfile.score >= 60 ? '#faad14' : '#f5222d'" />
|
||||||
@@ -179,7 +188,7 @@
|
|||||||
:percent="record.score"
|
:percent="record.score"
|
||||||
:size="20"
|
:size="20"
|
||||||
:stroke-color="record.score >= 80 ? '#52c41a' : record.score >= 60 ? '#faad14' : '#f5222d'"
|
:stroke-color="record.score >= 80 ? '#52c41a' : record.score >= 60 ? '#faad14' : '#f5222d'"
|
||||||
:format="(p: number) => p + ''"
|
:format="(p?: number) => (p ?? 0) + ''"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'verdict'">
|
<template v-else-if="column.key === 'verdict'">
|
||||||
@@ -247,16 +256,52 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</a-drawer>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import ChartPanel from '@/components/common/ChartPanel.vue'
|
import ChartPanel from '@/components/common/ChartPanel.vue'
|
||||||
import { randomNum, randomFloat, pick } from '@/mock/data'
|
import { randomNum, randomFloat, pick } from '@/mock/data'
|
||||||
|
|
||||||
// ============ 筛选状态 ============
|
// ============ 筛选状态 ============
|
||||||
const analysisMode = ref<'meal' | 'daily' | 'weekly' | 'person'>('meal')
|
const analysisMode = ref<'meal' | 'daily' | 'weekly' | 'person'>('meal')
|
||||||
|
const topTab = ref('nutrition')
|
||||||
const filterMeal = ref('')
|
const filterMeal = ref('')
|
||||||
const filterDept = ref('')
|
const filterDept = ref('')
|
||||||
const filterPerson = ref('')
|
const filterPerson = ref('')
|
||||||
@@ -267,19 +312,6 @@ const drawerPerson = ref<any>(null)
|
|||||||
const personList = ['张伟', '李芳', '王娜', '刘敏', '陈强', '杨磊', '黄艳', '赵勇', '周洋', '吴军']
|
const personList = ['张伟', '李芳', '王娜', '刘敏', '陈强', '杨磊', '黄艳', '赵勇', '周洋', '吴军']
|
||||||
const deptList = ['研发部', '市场部', '财务部', '行政部']
|
const deptList = ['研发部', '市场部', '财务部', '行政部']
|
||||||
|
|
||||||
// ============ 营养标准 ============
|
|
||||||
const standards = {
|
|
||||||
calories: { min: 1800, max: 2200, recommend: 2000 },
|
|
||||||
protein: { min: 55, max: 85, recommend: 70, unit: 'g' },
|
|
||||||
fat: { min: 45, max: 75, recommend: 60, unit: 'g' },
|
|
||||||
carbs: { min: 250, max: 340, recommend: 300, unit: 'g' },
|
|
||||||
fiber: { min: 20, max: 35, recommend: 25, unit: 'g' },
|
|
||||||
calcium: { min: 700, max: 1000, recommend: 800, unit: 'mg' },
|
|
||||||
iron: { min: 10, max: 20, recommend: 15, unit: 'mg' },
|
|
||||||
vitC: { min: 80, max: 150, recommend: 100, unit: 'mg' },
|
|
||||||
sodium: { min: 1000, max: 2300, recommend: 1500, unit: 'mg' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Mock 就餐数据 ============
|
// ============ Mock 就餐数据 ============
|
||||||
function genPersonNutrition() {
|
function genPersonNutrition() {
|
||||||
return personList.map(name => {
|
return personList.map(name => {
|
||||||
@@ -457,7 +489,7 @@ const mealBalance = computed(() => {
|
|||||||
// ============ 个人营养画像 ============
|
// ============ 个人营养画像 ============
|
||||||
const personProfile = computed(() => {
|
const personProfile = computed(() => {
|
||||||
const p = allPersonData.value.find(d => d.name === filterPerson.value)
|
const p = allPersonData.value.find(d => d.name === filterPerson.value)
|
||||||
return p || { name: '', dept: '', avgCalories: 0, score: 0, consecutiveDays: 0, bmi: 0 }
|
return p || { name: '', dept: '', calories: 0, score: 0, consecutiveDays: 0, bmi: 0 }
|
||||||
})
|
})
|
||||||
|
|
||||||
const personNutrientData = computed(() => {
|
const personNutrientData = computed(() => {
|
||||||
@@ -575,6 +607,49 @@ function viewPersonDetail(record: any) {
|
|||||||
drawerPerson.value = record
|
drawerPerson.value = record
|
||||||
drawerVisible.value = true
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="营养点餐" sub-title="基于营养目标的智能点餐订单" />
|
<a-page-header title="营养点餐" sub-title="个人/团体点餐与小程序订单" />
|
||||||
<RelatedModules :links="relatedLinks" />
|
<RelatedModules :links="relatedLinks" />
|
||||||
<a-card>
|
<a-card>
|
||||||
<a-table
|
<a-tabs v-model:activeKey="activeTab">
|
||||||
:columns="columns"
|
<a-tab-pane key="personal" tab="个人点餐" />
|
||||||
:data-source="list"
|
<a-tab-pane key="group" tab="团体点餐" />
|
||||||
:pagination="{ pageSize: 10 }"
|
<a-tab-pane key="cooked" tab="熟餐订单" />
|
||||||
row-key="id"
|
<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 }">
|
<template #expandedRowRender="{ record }">
|
||||||
<a-list size="small" :data-source="record.dishes">
|
<a-list size="small" :data-source="record.dishes">
|
||||||
<template #renderItem="{ item }">
|
<template #renderItem="{ item }">
|
||||||
@@ -21,15 +24,47 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'score'">
|
<template v-if="column.key === 'score'">
|
||||||
<a-progress
|
<a-progress type="circle" :percent="record.nutritionScore" :size="56" :stroke-color="scoreColor(record.nutritionScore)" />
|
||||||
type="circle"
|
|
||||||
:percent="record.nutritionScore"
|
|
||||||
:width="56"
|
|
||||||
:stroke-color="scoreColor(record.nutritionScore)"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'match'">
|
||||||
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
@@ -39,7 +74,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
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'
|
import RelatedModules from '@/components/common/RelatedModules.vue'
|
||||||
|
|
||||||
const relatedLinks = [
|
const relatedLinks = [
|
||||||
@@ -49,20 +84,102 @@ const relatedLinks = [
|
|||||||
{ key: 'pickup-mgmt', label: '取餐管理', color: 'blue', arrow: '→' },
|
{ 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 = [
|
const personalCols = [
|
||||||
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo' },
|
{ title: '订单号', dataIndex: 'orderNo' },
|
||||||
{ title: '用户', dataIndex: 'user', key: 'user' },
|
{ title: '用户', dataIndex: 'user' },
|
||||||
{ title: '餐次', dataIndex: 'mealType', key: 'mealType' },
|
{ title: '餐次', dataIndex: 'mealType' },
|
||||||
{ title: '目标热量', dataIndex: 'targetCalories', key: 'targetCalories' },
|
{ title: '目标热量', dataIndex: 'targetCalories' },
|
||||||
{ title: '实际热量', dataIndex: 'totalCalories', key: 'totalCalories' },
|
{ title: '营养匹配度', key: 'match', width: 140 },
|
||||||
|
{ title: '禁忌过滤', key: 'taboo', width: 90 },
|
||||||
{ title: '营养评分', key: 'score', width: 100 },
|
{ title: '营养评分', key: 'score', width: 100 },
|
||||||
{ title: '日期', dataIndex: 'date', key: 'date' },
|
{ title: '日期', dataIndex: 'date' },
|
||||||
{ title: '状态', key: 'status' },
|
]
|
||||||
|
|
||||||
|
// 团体点餐
|
||||||
|
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 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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="取餐管理" sub-title="实时取餐订单监控">
|
<a-page-header title="取餐管理" sub-title="实时取餐监控与AI取餐结构分析">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-button type="primary" @click="refresh">刷新</a-button>
|
<a-button type="primary" @click="refresh">刷新</a-button>
|
||||||
</template>
|
</template>
|
||||||
</a-page-header>
|
</a-page-header>
|
||||||
|
|
||||||
|
<!-- AI取餐结构分析 -->
|
||||||
|
<a-row :gutter="16" style="margin-bottom:16px">
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-card title="取餐结构分布" size="small">
|
||||||
|
<ChartPanel type="pie" :data="structureData" :height="200" :colors="['#1890ff', '#52c41a', '#faad14', '#722ed1']" />
|
||||||
|
</a-card>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="16">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="6"><a-card><a-statistic title="今日取餐" :value="orders.length" suffix="人次" /></a-card></a-col>
|
||||||
|
<a-col :span="6"><a-card><a-statistic title="主食占比" :value="structurePct.staple" suffix="%" :value-style="{ color: '#1890ff' }" /></a-card></a-col>
|
||||||
|
<a-col :span="6"><a-card><a-statistic title="荤菜占比" :value="structurePct.meat" suffix="%" :value-style="{ color: '#52c41a' }" /></a-card></a-col>
|
||||||
|
<a-col :span="6"><a-card><a-statistic title="素菜占比" :value="structurePct.veg" suffix="%" :value-style="{ color: '#faad14' }" /></a-card></a-col>
|
||||||
|
</a-row>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
<a-card>
|
<a-card>
|
||||||
<a-table :columns="columns" :data-source="orders" :pagination="{ pageSize: 10 }" row-key="id">
|
<a-table :columns="columns" :data-source="orders" :pagination="{ pageSize: 10 }" row-key="id">
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
@@ -21,9 +39,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { randomId, randomNum, randomTime, randomDate, pick, pickMulti } from '@/mock/data'
|
import { randomId, randomNum, randomTime, randomDate, pick, pickMulti } from '@/mock/data'
|
||||||
|
import ChartPanel from '@/components/common/ChartPanel.vue'
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
'待取餐': 'blue',
|
'待取餐': 'blue',
|
||||||
@@ -66,6 +85,22 @@ const refresh = () => {
|
|||||||
orders.value = gen()
|
orders.value = gen()
|
||||||
message.success('已刷新')
|
message.success('已刷新')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const structureData = computed(() => [
|
||||||
|
{ label: '主食', value: randomNum(200, 400) },
|
||||||
|
{ label: '荤菜', value: randomNum(300, 600) },
|
||||||
|
{ label: '素菜', value: randomNum(250, 500) },
|
||||||
|
{ label: '汤品', value: randomNum(100, 300) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const structurePct = computed(() => {
|
||||||
|
const total = structureData.value.reduce((s, d) => s + d.value, 0) || 1
|
||||||
|
return {
|
||||||
|
staple: Math.round(structureData.value[0].value / total * 100),
|
||||||
|
meat: Math.round(structureData.value[1].value / total * 100),
|
||||||
|
veg: Math.round(structureData.value[2].value / total * 100),
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -8,12 +8,17 @@
|
|||||||
<div class="dev-name">{{ d.name }}</div>
|
<div class="dev-name">{{ d.name }}</div>
|
||||||
<div class="dev-type">{{ d.type }}</div>
|
<div class="dev-type">{{ d.type }}</div>
|
||||||
</div>
|
</div>
|
||||||
<a-badge :status="d.status === 'online' ? 'success' : 'error'" :text="d.status === 'online' ? '在线' : '离线'" />
|
<a-badge :status="d.status === 'online' ? 'success' : d.status === 'fault' ? 'error' : 'default'" :text="d.status === 'online' ? '在线' : d.status === 'fault' ? '故障' : '离线'" />
|
||||||
</div>
|
</div>
|
||||||
<a-descriptions :column="1" size="small" class="desc">
|
<a-descriptions :column="1" size="small" class="desc">
|
||||||
|
<a-descriptions-item label="编号">{{ d.scaleId }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="位置">{{ d.location }}</a-descriptions-item>
|
<a-descriptions-item label="位置">{{ d.location }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="心跳">{{ d.heartbeat }}</a-descriptions-item>
|
<a-descriptions-item label="故障代码">
|
||||||
<a-descriptions-item label="版本">{{ d.version }}</a-descriptions-item>
|
<span :style="{ color: d.faultCode !== '-' ? '#f5222d' : '' }">{{ d.faultCode }}</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="心跳周期">{{ d.heartbeat }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="启用时长">{{ d.uptime }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="最近上报">{{ d.lastReport }}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a-button size="small" @click="message.info('重启 ' + d.name)">重启</a-button>
|
<a-button size="small" @click="message.info('重启 ' + d.name)">重启</a-button>
|
||||||
@@ -29,14 +34,14 @@ import { ref } from 'vue'
|
|||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
|
|
||||||
const devices = ref([
|
const devices = ref([
|
||||||
{ id: 1, name: '点餐机-01', type: '点餐机', location: '一楼大厅A', status: 'online', heartbeat: '2026-04-07 10:23', version: 'v2.3.1' },
|
{ id: 1, scaleId: 'CH00001', name: '智能秤-01', type: '智能秤', location: '1号中餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '128天', lastReport: '2026-04-23 10:23' },
|
||||||
{ id: 2, name: '点餐机-02', type: '点餐机', location: '一楼大厅B', status: 'online', heartbeat: '2026-04-07 10:24', version: 'v2.3.1' },
|
{ id: 2, scaleId: 'CH00002', name: '智能秤-02', type: '智能秤', location: '2号中餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '95天', lastReport: '2026-04-23 10:24' },
|
||||||
{ id: 3, name: '取餐柜-01', type: '取餐柜', location: '一楼出餐口', status: 'online', heartbeat: '2026-04-07 10:24', version: 'v1.5.0' },
|
{ id: 3, scaleId: 'CH00003', name: '智能秤-03', type: '智能秤', location: '3号西餐线', status: 'offline', faultCode: 'E102', heartbeat: '-', uptime: '62天', lastReport: '2026-04-22 18:30' },
|
||||||
{ id: 4, name: '取餐柜-02', type: '取餐柜', location: '二楼出餐口', status: 'offline', heartbeat: '2026-04-06 18:30', version: 'v1.5.0' },
|
{ id: 4, scaleId: 'CH00004', name: '智能秤-04', type: '智能秤', location: 'VIP餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '210天', lastReport: '2026-04-23 10:25' },
|
||||||
{ id: 5, name: '显示屏-01', type: '显示屏', location: '一楼大厅', status: 'online', heartbeat: '2026-04-07 10:25', version: 'v3.0.2' },
|
{ id: 5, scaleId: 'CH00005', name: '智能秤-05', type: '智能秤', location: '面点窗口', status: 'fault', faultCode: 'E205', heartbeat: '-', uptime: '45天', lastReport: '2026-04-22 08:10' },
|
||||||
{ id: 6, name: '显示屏-02', type: '显示屏', location: '二楼大厅', status: 'online', heartbeat: '2026-04-07 10:25', version: 'v3.0.2' },
|
{ id: 6, scaleId: 'CH00006', name: '智能秤-06', type: '智能秤', location: '小吃窗口', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '180天', lastReport: '2026-04-23 10:25' },
|
||||||
{ id: 7, name: '点餐机-03', type: '点餐机', location: '二楼大厅A', status: 'offline', heartbeat: '2026-04-06 22:10', version: 'v2.3.0' },
|
{ id: 7, scaleId: 'TM00001', name: '点餐机-01', type: '点餐机', location: '一楼大厅A', status: 'online', faultCode: '-', heartbeat: '60s', uptime: '300天', lastReport: '2026-04-23 10:23' },
|
||||||
{ id: 8, name: '取餐柜-03', type: '取餐柜', location: '三楼出餐口', status: 'online', heartbeat: '2026-04-07 10:23', version: 'v1.5.0' },
|
{ id: 8, scaleId: 'TM00002', name: '取餐柜-01', type: '取餐柜', location: '一楼出餐口', status: 'online', faultCode: '-', heartbeat: '60s', uptime: '250天', lastReport: '2026-04-23 10:24' },
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,136 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<a-page-header title="用户管理" sub-title="就餐用户档案" />
|
<a-page-header title="用户管理" sub-title="就餐用户五维档案与个性化推荐食谱" />
|
||||||
<a-card>
|
<a-card>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<a-input-search v-model:value="search" placeholder="搜索姓名/电话" style="width: 240px" allow-clear />
|
<a-input-search v-model:value="search" placeholder="搜索姓名/电话" style="width: 240px" allow-clear />
|
||||||
<a-select v-model:value="deptFilter" style="width: 140px" placeholder="部门" allow-clear>
|
<a-select v-model:value="deptFilter" style="width: 140px" placeholder="部门" allow-clear>
|
||||||
<a-select-option v-for="d in departments" :key="d" :value="d">{{ d }}</a-select-option>
|
<a-select-option v-for="d in departments" :key="d" :value="d">{{ d }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
<a-button type="primary" @click="modalOpen = true">+ 新增用户</a-button>
|
<a-button type="primary" @click="openAddModal">+ 新增用户</a-button>
|
||||||
</div>
|
</div>
|
||||||
<a-table :columns="columns" :data-source="filtered" :pagination="{ pageSize: 10 }" row-key="id">
|
<a-table :columns="columns" :data-source="filtered" :pagination="{ pageSize: 10 }" row-key="id" size="middle">
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'isMember'">
|
<template v-if="column.key === 'status'">
|
||||||
<a-tag :color="record.isMember ? 'gold' : 'default'">{{ record.isMember ? '会员' : '普通' }}</a-tag>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="column.key === 'status'">
|
|
||||||
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
|
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'allergens'">
|
<template v-if="column.key === 'taboo'">
|
||||||
<a-tag v-for="a in record.allergens" :key="a" color="red">{{ a }}</a-tag>
|
<a-tag v-for="t in record.tabooList" :key="t" color="red" style="margin:1px">{{ t }}</a-tag>
|
||||||
|
<span v-if="!record.tabooList?.length" style="color:#999">无</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" size="small" @click="openDetail(record)">详情</a-button>
|
||||||
|
<a-button type="link" size="small" @click="openRecipe(record)">推荐食谱</a-button>
|
||||||
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<a-modal v-model:open="modalOpen" title="新增用户" @ok="addUser">
|
<!-- 五维信息详情抽屉 -->
|
||||||
<a-form :model="form" layout="vertical">
|
<a-drawer v-model:open="detailVisible" title="用户五维档案" :width="620">
|
||||||
|
<template v-if="currentUser">
|
||||||
|
<a-tabs v-model:activeKey="detailTab">
|
||||||
|
<a-tab-pane key="basic" tab="静态基础">
|
||||||
|
<a-descriptions bordered :column="2" size="small">
|
||||||
|
<a-descriptions-item label="姓名">{{ currentUser.name }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="部门">{{ currentUser.department }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="年龄">{{ currentUser.age }}岁</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="性别">{{ currentUser.gender }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="身高">{{ currentUser.height }}cm</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="体重">{{ currentUser.weight }}kg</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="BMI">{{ currentUser.bmi }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="活动水平">{{ currentUser.activityLevel }}</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="health" tab="健康档案">
|
||||||
|
<a-descriptions bordered :column="1" size="small">
|
||||||
|
<a-descriptions-item label="疾病史">
|
||||||
|
<a-tag v-for="d in currentUser.diseases" :key="d" color="orange" style="margin:2px">{{ d }}</a-tag>
|
||||||
|
<span v-if="!currentUser.diseases?.length" style="color:#999">无</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="体检报告">{{ currentUser.examReport || '暂无' }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="最近体检日期">{{ currentUser.lastExamDate }}</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="taste" tab="口味偏好">
|
||||||
|
<a-descriptions bordered :column="2" size="small">
|
||||||
|
<a-descriptions-item label="口味偏好">
|
||||||
|
<a-tag v-for="t in currentUser.tastePrefer" :key="t" color="blue" style="margin:2px">{{ t }}</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="地域菜系">
|
||||||
|
<a-tag v-for="c in currentUser.cuisinePrefer" :key="c" color="purple" style="margin:2px">{{ c }}</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="taboo" tab="禁忌关联">
|
||||||
|
<a-descriptions bordered :column="1" size="small">
|
||||||
|
<a-descriptions-item label="饮食禁忌">
|
||||||
|
<div v-for="item in currentUser.tabooRelations" :key="item.disease" style="margin-bottom:6px">
|
||||||
|
<a-tag color="red">{{ item.disease }}</a-tag>
|
||||||
|
<span style="margin:0 6px">→</span>
|
||||||
|
<a-tag v-for="f in item.forbidden" :key="f" color="orange">禁{{ f }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<span v-if="!currentUser.tabooRelations?.length" style="color:#999">无禁忌</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="过敏原">
|
||||||
|
<a-tag v-for="a in currentUser.allergens" :key="a" color="red" style="margin:2px">{{ a }}</a-tag>
|
||||||
|
<span v-if="!currentUser.allergens?.length" style="color:#999">无</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="dynamic" tab="动态生理">
|
||||||
|
<a-descriptions bordered :column="2" size="small">
|
||||||
|
<a-descriptions-item label="日均运动量">{{ currentUser.dailyExercise }}步</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="体重趋势">{{ currentUser.weightTrend }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="体脂率">{{ currentUser.bodyFatRate }}%</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="数据来源">{{ currentUser.dataSource }}</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</template>
|
||||||
|
</a-drawer>
|
||||||
|
|
||||||
|
<!-- 推荐食谱弹窗 -->
|
||||||
|
<a-modal v-model:open="recipeVisible" :title="`${recipeUser?.name} - 个人推荐食谱`" width="700px" @ok="confirmRecipe">
|
||||||
|
<template v-if="recipeUser">
|
||||||
|
<a-alert type="info" show-icon style="margin-bottom:12px"
|
||||||
|
:message="`基于五维信息自动生成:BMI ${recipeUser.bmi} / 活动水平 ${recipeUser.activityLevel} / 每日目标 ${recipeUser.targetCalories}kcal`" />
|
||||||
|
|
||||||
|
<div v-for="meal in recommendedRecipe" :key="meal.mealType" style="margin-bottom:16px">
|
||||||
|
<div style="font-weight:600;margin-bottom:8px">{{ meal.mealType }}</div>
|
||||||
|
<a-table :columns="recipeCols" :data-source="meal.dishes" :pagination="false" size="small" row-key="name">
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'tabooFlag'">
|
||||||
|
<a-tag v-if="record.tabooFlag" color="red">已过滤</a-tag>
|
||||||
|
<a-tag v-else color="green">安全</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-if="column.key === 'adjust'">
|
||||||
|
<a-button type="link" size="small" danger @click="removeDish(meal, record)">移除</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider />
|
||||||
|
<a-descriptions :column="3" size="small">
|
||||||
|
<a-descriptions-item label="总热量">{{ totalCalories }}kcal</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="目标热量">{{ recipeUser.targetCalories }}kcal</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="匹配度">
|
||||||
|
<a-tag :color="matchRate > 90 ? 'green' : matchRate > 75 ? 'orange' : 'red'">{{ matchRate }}%</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 新增用户弹窗 -->
|
||||||
|
<a-modal v-model:open="addModalOpen" title="新增用户" @ok="addUser" width="600px">
|
||||||
|
<a-form :model="form" :label-col="{ span: 6 }">
|
||||||
<a-form-item label="姓名"><a-input v-model:value="form.name" /></a-form-item>
|
<a-form-item label="姓名"><a-input v-model:value="form.name" /></a-form-item>
|
||||||
<a-form-item label="电话"><a-input v-model:value="form.phone" /></a-form-item>
|
<a-form-item label="电话"><a-input v-model:value="form.phone" /></a-form-item>
|
||||||
<a-form-item label="部门">
|
<a-form-item label="部门">
|
||||||
@@ -33,6 +138,9 @@
|
|||||||
<a-select-option v-for="d in departments" :key="d" :value="d">{{ d }}</a-select-option>
|
<a-select-option v-for="d in departments" :key="d" :value="d">{{ d }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="年龄"><a-input-number v-model:value="form.age" :min="1" :max="120" style="width:100%" /></a-form-item>
|
||||||
|
<a-form-item label="身高(cm)"><a-input-number v-model:value="form.height" :min="50" :max="250" style="width:100%" /></a-form-item>
|
||||||
|
<a-form-item label="体重(kg)"><a-input-number v-model:value="form.weight" :min="20" :max="300" style="width:100%" /></a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,27 +149,76 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { genUserMgmtData, statusMap } from '@/mock/data'
|
import { genUserMgmtData, statusMap, randomNum, randomFloat, pick } from '@/mock/data'
|
||||||
|
|
||||||
const list = ref(genUserMgmtData(20))
|
|
||||||
const search = ref('')
|
|
||||||
const deptFilter = ref<string>()
|
|
||||||
const modalOpen = ref(false)
|
|
||||||
const form = reactive({ name: '', phone: '', department: '' })
|
|
||||||
|
|
||||||
const departments = ['研发部', '市场部', '财务部', '人事部', '行政部', '销售部']
|
const departments = ['研发部', '市场部', '财务部', '人事部', '行政部', '销售部']
|
||||||
|
const tasteOptions = ['酸', '甜', '苦', '辣', '咸', '清淡']
|
||||||
|
const cuisineOptions = ['川菜', '粤菜', '鲁菜', '苏菜', '湘菜', '西餐']
|
||||||
|
const diseaseOptions = ['糖尿病', '高血压', '高血脂', '痛风', '冠心病']
|
||||||
|
const activityLevels = ['久坐', '轻度活动', '中度活动', '重度活动']
|
||||||
|
const dishPool = ['红烧肉', '清炒时蔬', '番茄鸡蛋', '糖醋里脊', '宫保鸡丁', '鱼香肉丝', '蒜蓉西兰花', '蛋炒饭', '紫菜蛋花汤', '水煮鱼', '凉拌黄瓜', '蒸鲈鱼']
|
||||||
|
|
||||||
|
// 扩展用户数据,增加五维信息
|
||||||
|
const list = ref(genUserMgmtData(20).map((u: any) => {
|
||||||
|
const height = randomNum(155, 185)
|
||||||
|
const weight = randomNum(45, 95)
|
||||||
|
const bmi = +(weight / (height / 100) ** 2).toFixed(1)
|
||||||
|
const diseases = diseaseOptions.filter(() => Math.random() > 0.8)
|
||||||
|
const tabooMap: Record<string, string[]> = {
|
||||||
|
'糖尿病': ['高糖', '甜食'], '高血压': ['高钠', '腌制品'], '高血脂': ['高脂', '油炸'],
|
||||||
|
'痛风': ['高嘌呤', '海鲜'], '冠心病': ['高脂', '高胆固醇'],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...u,
|
||||||
|
age: randomNum(20, 65),
|
||||||
|
gender: pick(['男', '女']),
|
||||||
|
height, weight, bmi,
|
||||||
|
activityLevel: pick(activityLevels),
|
||||||
|
diseases,
|
||||||
|
examReport: pick(['2026年度体检报告', '2025年度体检报告', '']),
|
||||||
|
lastExamDate: `2026-0${randomNum(1, 3)}-${randomNum(10, 28)}`,
|
||||||
|
tastePrefer: tasteOptions.filter(() => Math.random() > 0.6),
|
||||||
|
cuisinePrefer: cuisineOptions.filter(() => Math.random() > 0.7),
|
||||||
|
tabooList: diseases.flatMap(d => tabooMap[d]?.slice(0, 1) || []),
|
||||||
|
tabooRelations: diseases.map(d => ({ disease: d, forbidden: tabooMap[d] || [] })),
|
||||||
|
dailyExercise: randomNum(2000, 15000),
|
||||||
|
weightTrend: pick(['稳定', '上升', '下降']),
|
||||||
|
bodyFatRate: randomFloat(12, 35, 1),
|
||||||
|
dataSource: pick(['体脂秤+智能手表', '小程序填报', '体检系统同步']),
|
||||||
|
targetCalories: bmi > 24 ? randomNum(1600, 1900) : randomNum(1900, 2300),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const deptFilter = ref<string>()
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailTab = ref('basic')
|
||||||
|
const currentUser = ref<any>(null)
|
||||||
|
const addModalOpen = ref(false)
|
||||||
|
const form = reactive({ name: '', phone: '', department: '', age: 30, height: 170, weight: 65 })
|
||||||
|
|
||||||
|
// 推荐食谱
|
||||||
|
const recipeVisible = ref(false)
|
||||||
|
const recipeUser = ref<any>(null)
|
||||||
|
const recommendedRecipe = ref<any[]>([])
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '姓名', dataIndex: 'name', key: 'name' },
|
{ title: '姓名', dataIndex: 'name', width: 80 },
|
||||||
{ title: '电话', dataIndex: 'phone', key: 'phone' },
|
{ title: '部门', dataIndex: 'department', width: 80 },
|
||||||
{ title: '部门', dataIndex: 'department', key: 'department' },
|
{ title: '年龄', dataIndex: 'age', width: 60 },
|
||||||
{ title: '餐卡号', dataIndex: 'mealCard', key: 'mealCard' },
|
{ title: 'BMI', dataIndex: 'bmi', width: 70 },
|
||||||
{ title: '余额', dataIndex: 'balance', key: 'balance' },
|
{ title: '活动水平', dataIndex: 'activityLevel', width: 90 },
|
||||||
{ title: '会员', key: 'isMember' },
|
{ title: '禁忌', key: 'taboo', width: 120 },
|
||||||
{ title: '累计订单', dataIndex: 'totalOrders', key: 'totalOrders' },
|
{ title: '状态', key: 'status', width: 70 },
|
||||||
{ title: '最近点餐', dataIndex: 'lastOrder', key: 'lastOrder' },
|
{ title: '操作', key: 'action', width: 160 },
|
||||||
{ title: '状态', key: 'status' },
|
]
|
||||||
{ title: '过敏原', key: 'allergens' },
|
|
||||||
|
const recipeCols = [
|
||||||
|
{ title: '菜品', dataIndex: 'name', width: 120 },
|
||||||
|
{ title: '热量(kcal)', dataIndex: 'calories', width: 100 },
|
||||||
|
{ title: '口味匹配', dataIndex: 'tasteMatch', width: 90 },
|
||||||
|
{ title: '禁忌过滤', key: 'tabooFlag', width: 90 },
|
||||||
|
{ title: '操作', key: 'adjust', width: 80 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const filtered = computed(() =>
|
const filtered = computed(() =>
|
||||||
@@ -72,9 +229,62 @@ const filtered = computed(() =>
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const addUser = () => {
|
const totalCalories = computed(() =>
|
||||||
|
recommendedRecipe.value.reduce((s, m) => s + m.dishes.reduce((ss: number, d: any) => ss + d.calories, 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const matchRate = computed(() => {
|
||||||
|
if (!recipeUser.value) return 0
|
||||||
|
const target = recipeUser.value.targetCalories
|
||||||
|
return Math.max(0, Math.min(100, Math.round(100 - Math.abs(totalCalories.value - target) / target * 100)))
|
||||||
|
})
|
||||||
|
|
||||||
|
function openDetail(record: any) {
|
||||||
|
currentUser.value = record
|
||||||
|
detailTab.value = 'basic'
|
||||||
|
detailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecipe(record: any) {
|
||||||
|
recipeUser.value = record
|
||||||
|
recommendedRecipe.value = genRecipe(record)
|
||||||
|
recipeVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function genRecipe(user: any) {
|
||||||
|
const meals = ['早餐', '午餐', '晚餐']
|
||||||
|
return meals.map(mealType => ({
|
||||||
|
mealType,
|
||||||
|
dishes: Array.from({ length: mealType === '早餐' ? 2 : 3 }, () => {
|
||||||
|
const name = pick(dishPool)
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
calories: randomNum(mealType === '早餐' ? 120 : 180, mealType === '早餐' ? 300 : 450),
|
||||||
|
tasteMatch: pick(['匹配', '匹配', '一般']),
|
||||||
|
tabooFlag: user.tabooList?.length ? Math.random() > 0.8 : false,
|
||||||
|
}
|
||||||
|
}).filter((d: any) => !d.tabooFlag),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDish(meal: any, dish: any) {
|
||||||
|
meal.dishes = meal.dishes.filter((d: any) => d.name !== dish.name)
|
||||||
|
message.success('已移除')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRecipe() {
|
||||||
|
message.success(`${recipeUser.value.name} 的推荐食谱已确认`)
|
||||||
|
recipeVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
Object.assign(form, { name: '', phone: '', department: '', age: 30, height: 170, weight: 65 })
|
||||||
|
addModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUser() {
|
||||||
message.success('用户已添加')
|
message.success('用户已添加')
|
||||||
modalOpen.value = false
|
addModalOpen.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -150,10 +150,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive } from 'vue'
|
import { ref, computed, reactive } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
|
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
import { canteenList } from '@/mock/data'
|
import { canteenList } from '@/mock/data'
|
||||||
|
|
||||||
const columns = [
|
const columns: ColumnsType<any> = [
|
||||||
{ title: '食堂名称', dataIndex: 'name', key: 'name', width: 110 },
|
{ title: '食堂名称', dataIndex: 'name', key: 'name', width: 110 },
|
||||||
{ title: '别名', dataIndex: 'alias', key: 'alias', width: 120 },
|
{ title: '别名', dataIndex: 'alias', key: 'alias', width: 120 },
|
||||||
{ title: '地址', dataIndex: 'address', key: 'address', width: 100 },
|
{ title: '地址', dataIndex: 'address', key: 'address', width: 100 },
|
||||||
|
|||||||
@@ -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 'enabled' | 'disabled', 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?: any) {
|
||||||
|
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 'enabled' | 'disabled', 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?: any) {
|
||||||
|
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: any) {
|
||||||
|
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>
|
||||||
@@ -145,10 +145,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, reactive } from 'vue'
|
import { ref, computed, reactive } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
|
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||||
import StatCard from '@/components/common/StatCard.vue'
|
import StatCard from '@/components/common/StatCard.vue'
|
||||||
|
|
||||||
const columns = [
|
const columns: ColumnsType<any> = [
|
||||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
||||||
{ title: '性别', dataIndex: 'gender', key: 'gender', width: 60 },
|
{ title: '性别', dataIndex: 'gender', key: 'gender', width: 60 },
|
||||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120 },
|
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120 },
|
||||||
@@ -183,13 +184,20 @@ const drawerVisible = ref(false)
|
|||||||
const editingId = ref('')
|
const editingId = ref('')
|
||||||
const viewingEmployee = ref<any>(null)
|
const viewingEmployee = ref<any>(null)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive<{
|
||||||
|
name: string
|
||||||
|
gender: string
|
||||||
|
phone: string
|
||||||
|
department: string
|
||||||
|
position: string
|
||||||
|
entryDate: Dayjs | undefined
|
||||||
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
gender: '男',
|
gender: '男',
|
||||||
phone: '',
|
phone: '',
|
||||||
department: '',
|
department: '',
|
||||||
position: '',
|
position: '',
|
||||||
entryDate: null,
|
entryDate: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeEmployees = computed(() => allEmployees.value.filter(e => e.status === '在职'))
|
const activeEmployees = computed(() => allEmployees.value.filter(e => e.status === '在职'))
|
||||||
@@ -227,7 +235,7 @@ function showAddModal() {
|
|||||||
form.phone = ''
|
form.phone = ''
|
||||||
form.department = ''
|
form.department = ''
|
||||||
form.position = ''
|
form.position = ''
|
||||||
form.entryDate = null
|
form.entryDate = undefined
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +278,7 @@ function saveEmployee() {
|
|||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
department: form.department,
|
department: form.department,
|
||||||
position: form.position,
|
position: form.position,
|
||||||
entryDate: form.entryDate?.format('YYYY-MM-DD'),
|
entryDate: form.entryDate?.format('YYYY-MM-DD') ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -281,7 +289,7 @@ function saveEmployee() {
|
|||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
department: form.department,
|
department: form.department,
|
||||||
position: form.position,
|
position: form.position,
|
||||||
entryDate: form.entryDate?.format('YYYY-MM-DD'),
|
entryDate: form.entryDate?.format('YYYY-MM-DD') ?? '',
|
||||||
status: '试用期',
|
status: '试用期',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
/>
|
/>
|
||||||
<a-tree
|
<a-tree
|
||||||
v-model:selectedKeys="selectedOrgKeys"
|
v-model:selectedKeys="selectedOrgKeys"
|
||||||
:tree-data="orgTreeData"
|
:tree-data="(orgTreeData as any)"
|
||||||
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
||||||
@select="onOrgSelect"
|
@select="onOrgSelect"
|
||||||
>
|
>
|
||||||
@@ -212,9 +212,9 @@ const selectedOrgEmployees = computed(() => {
|
|||||||
return mockEmployees[selectedOrg.value.id] || []
|
return mockEmployees[selectedOrg.value.id] || []
|
||||||
})
|
})
|
||||||
|
|
||||||
function onOrgSelect(keys: string[]) {
|
function onOrgSelect(keys: Array<string | number>) {
|
||||||
if (keys.length === 0) return
|
if (keys.length === 0) return
|
||||||
const key = keys[0]
|
const key = String(keys[0])
|
||||||
const findOrg = (data: any[]): any => {
|
const findOrg = (data: any[]): any => {
|
||||||
for (const item of data) {
|
for (const item of data) {
|
||||||
if (item.id === key) return item
|
if (item.id === key) return item
|
||||||
@@ -236,11 +236,11 @@ function showAddOrgModal() {
|
|||||||
orgModalVisible.value = true
|
orgModalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function editOrg(id: string) {
|
function editOrg(_id: string) {
|
||||||
message.info('编辑功能开发中')
|
message.info('编辑功能开发中')
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteOrg(id: string) {
|
function deleteOrg(_id: string) {
|
||||||
message.success('部门已删除')
|
message.success('部门已删除')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<a-tree
|
<a-tree
|
||||||
v-model:checkedKeys="selectedPermissions"
|
v-model:checkedKeys="selectedPermissions"
|
||||||
:tree-data="permissionTree"
|
:tree-data="(permissionTree as any)"
|
||||||
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
||||||
checkable
|
checkable
|
||||||
@check="onPermissionChange"
|
@check="onPermissionChange"
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<a-form-item label="权限">
|
<a-form-item label="权限">
|
||||||
<a-tree
|
<a-tree
|
||||||
v-model:checkedKeys="roleForm.permissions"
|
v-model:checkedKeys="roleForm.permissions"
|
||||||
:tree-data="permissionTree"
|
:tree-data="(permissionTree as any)"
|
||||||
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
||||||
checkable
|
checkable
|
||||||
/>
|
/>
|
||||||
@@ -82,8 +82,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
|
import type { ColumnsType } from 'ant-design-vue/es/table'
|
||||||
|
|
||||||
const roleColumns = [
|
const roleColumns: ColumnsType<any> = [
|
||||||
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 120 },
|
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 120 },
|
||||||
{ title: '描述', dataIndex: 'description', key: 'description', width: 150, ellipsis: true },
|
{ title: '描述', dataIndex: 'description', key: 'description', width: 150, ellipsis: true },
|
||||||
{ title: '用户数', dataIndex: 'userCount', key: 'userCount', width: 60 },
|
{ title: '用户数', dataIndex: 'userCount', key: 'userCount', width: 60 },
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
<template>
|
||||||
|
<div class="terminal" :class="`step-${step}`">
|
||||||
|
<!-- 顶部状态条 -->
|
||||||
|
<header class="bar">
|
||||||
|
<span class="bar-title">智能称重终端</span>
|
||||||
|
<span class="bar-time">{{ clock }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 入口:A/B 二选一 -->
|
||||||
|
<section v-if="step === 'entry'" class="entry">
|
||||||
|
<button class="big-btn a" @click="enterA">
|
||||||
|
<div class="big-btn-icon">🥬</div>
|
||||||
|
<div class="big-btn-title">A</div>
|
||||||
|
<div class="big-btn-sub">毛菜加工称重</div>
|
||||||
|
</button>
|
||||||
|
<button class="big-btn b" @click="enterB">
|
||||||
|
<div class="big-btn-icon">📦</div>
|
||||||
|
<div class="big-btn-title">B</div>
|
||||||
|
<div class="big-btn-sub">净菜进柜称重</div>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- A1: 毛菜识别 -->
|
||||||
|
<section v-else-if="step === 'a-recognize'" class="recognize">
|
||||||
|
<div v-if="recognizing" class="scanning">
|
||||||
|
<div class="scanning-frame">
|
||||||
|
<div class="scanning-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="scanning-text">摄像头识别中…</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<h2 class="recognize-title">请确认或选择品名</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<button
|
||||||
|
v-for="v in vegetableLibrary"
|
||||||
|
:key="v.id"
|
||||||
|
class="cell"
|
||||||
|
:class="{ active: pickedName === v.name }"
|
||||||
|
@click="pickedName = v.name"
|
||||||
|
>
|
||||||
|
<div class="cell-icon">{{ v.emoji }}</div>
|
||||||
|
<div class="cell-name">{{ v.name }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" @click="goBack">返回</button>
|
||||||
|
<button class="btn primary" :disabled="!pickedName" @click="enterAWeigh">
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- A2: 毛菜称重 -->
|
||||||
|
<section v-else-if="step === 'a-weigh'" class="weigh">
|
||||||
|
<div class="weigh-name">{{ pickedName }}</div>
|
||||||
|
<div class="scale">
|
||||||
|
<span class="digits">{{ displayWeight }}</span>
|
||||||
|
<span class="unit">g</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-status">
|
||||||
|
<span :class="{ stable: stable }">{{ stable ? '● 已稳定' : '○ 称重中…' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" @click="goBack">返回</button>
|
||||||
|
<button class="btn warn" @click="toggleStable">
|
||||||
|
{{ stable ? '继续称重' : '稳定/去皮' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn primary"
|
||||||
|
:disabled="!stable || displayWeight <= 0"
|
||||||
|
@click="confirmA"
|
||||||
|
>
|
||||||
|
开始加工
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- B1: 净菜识别 -->
|
||||||
|
<section v-else-if="step === 'b-recognize'" class="recognize">
|
||||||
|
<div v-if="recognizing" class="scanning">
|
||||||
|
<div class="scanning-frame">
|
||||||
|
<div class="scanning-line"></div>
|
||||||
|
</div>
|
||||||
|
<div class="scanning-text">摄像头识别中…</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<h2 class="recognize-title">请确认或选择净菜品名</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<button
|
||||||
|
v-for="v in vegetableLibrary"
|
||||||
|
:key="v.id"
|
||||||
|
class="cell"
|
||||||
|
:class="{ active: pickedName === v.name }"
|
||||||
|
@click="pickedName = v.name"
|
||||||
|
>
|
||||||
|
<div class="cell-icon">{{ v.emoji }}</div>
|
||||||
|
<div class="cell-name">{{ v.name }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" @click="goBack">返回</button>
|
||||||
|
<button class="btn primary" :disabled="!pickedName" @click="enterBSpec">
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- B2: 规格(形状)选择 -->
|
||||||
|
<section v-else-if="step === 'b-spec'" class="recognize">
|
||||||
|
<h2 class="recognize-title">{{ pickedName }} — 请选择规格(形状)</h2>
|
||||||
|
<div class="grid spec-grid">
|
||||||
|
<button
|
||||||
|
v-for="s in specOptions"
|
||||||
|
:key="s.code"
|
||||||
|
class="cell spec-cell"
|
||||||
|
:class="{ active: pickedSpec === s.code }"
|
||||||
|
@click="pickedSpec = s.code"
|
||||||
|
>
|
||||||
|
<div class="spec-label">{{ s.label }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" @click="goBack">返回</button>
|
||||||
|
<button class="btn primary" :disabled="!pickedSpec" @click="enterBWeigh">
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- B3: 净菜称重(含出净率) -->
|
||||||
|
<section v-else-if="step === 'b-weigh'" class="weigh">
|
||||||
|
<div class="ref-bar" :class="{ warn: !refRawRecord }">
|
||||||
|
<template v-if="refRawRecord">
|
||||||
|
参照毛菜批次:{{ refRawRecord.name }} {{ refRawRecord.weight }}g · {{ refRawRecord.time }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
⚠ 未找到 {{ pickedName }} 的毛菜参照,出净率无法计算
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="weigh-name">
|
||||||
|
{{ pickedName }}
|
||||||
|
<span class="spec-tag">{{ specLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale">
|
||||||
|
<span class="digits">{{ displayWeight }}</span>
|
||||||
|
<span class="unit">g</span>
|
||||||
|
</div>
|
||||||
|
<div class="yield-rate">
|
||||||
|
出净率:<span :class="{ ok: yieldRateValue !== null, na: yieldRateValue === null }">{{ yieldRateText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-status">
|
||||||
|
<span :class="{ stable: stable }">{{ stable ? '● 已稳定' : '○ 称重中…' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" @click="goBack">返回</button>
|
||||||
|
<button class="btn warn" @click="toggleStable">
|
||||||
|
{{ stable ? '继续称重' : '稳定/去皮' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn primary"
|
||||||
|
:disabled="!stable || displayWeight <= 0"
|
||||||
|
@click="confirmB"
|
||||||
|
>
|
||||||
|
确认进柜
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 成功提示 -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="showSuccess" class="toast">
|
||||||
|
<div class="toast-icon">✓</div>
|
||||||
|
<div class="toast-text">{{ successText }}</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { vegetableLibrary, specOptions } from '@/mock/data'
|
||||||
|
import { useWeighingTerminalStore } from '@/stores/weighingTerminal'
|
||||||
|
import type { RawVeggieRecord } from '@/stores/weighingTerminal'
|
||||||
|
|
||||||
|
// 终端流程状态机
|
||||||
|
type Step = 'entry' | 'a-recognize' | 'a-weigh' | 'b-recognize' | 'b-spec' | 'b-weigh'
|
||||||
|
const step = ref<Step>('entry')
|
||||||
|
|
||||||
|
const store = useWeighingTerminalStore()
|
||||||
|
|
||||||
|
// ---------- 顶部时钟 ----------
|
||||||
|
const clock = ref('')
|
||||||
|
let clockTimer: number | null = null
|
||||||
|
function tickClock() {
|
||||||
|
const d = new Date()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
clock.value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 共享:识别屏 / 称重屏状态 ----------
|
||||||
|
const recognizing = ref(false)
|
||||||
|
const pickedName = ref<string>('')
|
||||||
|
let recognizeTimer: number | null = null
|
||||||
|
|
||||||
|
const baseWeight = ref(0)
|
||||||
|
const displayWeight = ref(0)
|
||||||
|
const stable = ref(false)
|
||||||
|
let weighTimer: number | null = null
|
||||||
|
|
||||||
|
function startWeighLoop() {
|
||||||
|
stopWeighLoop()
|
||||||
|
weighTimer = window.setInterval(() => {
|
||||||
|
if (stable.value) return
|
||||||
|
const jitter = Math.round((Math.random() - 0.5) * 6) // [-3, +3]
|
||||||
|
displayWeight.value = Math.max(0, baseWeight.value + jitter)
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWeighLoop() {
|
||||||
|
if (weighTimer !== null) {
|
||||||
|
window.clearInterval(weighTimer)
|
||||||
|
weighTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStable() {
|
||||||
|
if (!stable.value) {
|
||||||
|
displayWeight.value = baseWeight.value
|
||||||
|
stable.value = true
|
||||||
|
} else {
|
||||||
|
stable.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- A 路径 ----------
|
||||||
|
function enterA() {
|
||||||
|
step.value = 'a-recognize'
|
||||||
|
pickedName.value = ''
|
||||||
|
recognizing.value = true
|
||||||
|
recognizeTimer = window.setTimeout(() => {
|
||||||
|
const hit = vegetableLibrary[Math.floor(Math.random() * vegetableLibrary.length)]
|
||||||
|
pickedName.value = hit.name
|
||||||
|
recognizing.value = false
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterAWeigh() {
|
||||||
|
if (!pickedName.value) return
|
||||||
|
step.value = 'a-weigh'
|
||||||
|
// 中心重量随机模拟一筐毛菜:800g ~ 5000g
|
||||||
|
baseWeight.value = Math.round(800 + Math.random() * 4200)
|
||||||
|
displayWeight.value = baseWeight.value
|
||||||
|
stable.value = false
|
||||||
|
startWeighLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmA() {
|
||||||
|
const rec = store.addRawRecord(pickedName.value, displayWeight.value)
|
||||||
|
successText.value = `已入库:${rec.name} ${rec.weight}g`
|
||||||
|
flashSuccess()
|
||||||
|
resetToEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- B 路径 ----------
|
||||||
|
const pickedSpec = ref<string>('') // 规格 code
|
||||||
|
const refRawRecord = ref<RawVeggieRecord | null>(null) // 参照毛菜批次
|
||||||
|
|
||||||
|
const specLabel = computed(() => {
|
||||||
|
return specOptions.find(s => s.code === pickedSpec.value)?.label ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 实时出净率:跟随 displayWeight 跳动,找不到参照时为 null
|
||||||
|
const yieldRateValue = computed<number | null>(() => {
|
||||||
|
if (!refRawRecord.value || refRawRecord.value.weight <= 0) return null
|
||||||
|
return +((displayWeight.value / refRawRecord.value.weight) * 100).toFixed(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const yieldRateText = computed(() => {
|
||||||
|
return yieldRateValue.value === null ? '— —' : `${yieldRateValue.value}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
function enterB() {
|
||||||
|
step.value = 'b-recognize'
|
||||||
|
pickedName.value = ''
|
||||||
|
pickedSpec.value = ''
|
||||||
|
recognizing.value = true
|
||||||
|
recognizeTimer = window.setTimeout(() => {
|
||||||
|
const hit = vegetableLibrary[Math.floor(Math.random() * vegetableLibrary.length)]
|
||||||
|
pickedName.value = hit.name
|
||||||
|
recognizing.value = false
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterBSpec() {
|
||||||
|
if (!pickedName.value) return
|
||||||
|
step.value = 'b-spec'
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterBWeigh() {
|
||||||
|
if (!pickedSpec.value) return
|
||||||
|
step.value = 'b-weigh'
|
||||||
|
// 取最近一条同品名毛菜作为出净率参照
|
||||||
|
refRawRecord.value = store.getLatestRawRecord(pickedName.value)
|
||||||
|
// 模拟净菜中心重量:
|
||||||
|
// 有参照时按 70%~90% 出净率倒推,让屏幕上算出来的出净率落在合理区间
|
||||||
|
// 无参照时随机一个 500~3500g 让流程继续可走
|
||||||
|
if (refRawRecord.value) {
|
||||||
|
const yieldRatio = 0.7 + Math.random() * 0.2 // [0.7, 0.9]
|
||||||
|
baseWeight.value = Math.round(refRawRecord.value.weight * yieldRatio)
|
||||||
|
} else {
|
||||||
|
baseWeight.value = Math.round(500 + Math.random() * 3000)
|
||||||
|
}
|
||||||
|
displayWeight.value = baseWeight.value
|
||||||
|
stable.value = false
|
||||||
|
startWeighLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmB() {
|
||||||
|
const rec = store.addCleanRecord({
|
||||||
|
name: pickedName.value,
|
||||||
|
spec: specLabel.value,
|
||||||
|
weight: displayWeight.value,
|
||||||
|
operator: null, // 加工人留空,待后期人脸识别
|
||||||
|
})
|
||||||
|
const rateText = rec.yieldRate === null ? '出净率—' : `出净率${rec.yieldRate}%`
|
||||||
|
successText.value = `已入库:${rec.name}(${rec.spec}) ${rec.weight}g · ${rateText}`
|
||||||
|
flashSuccess()
|
||||||
|
resetToEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 入库提示 ----------
|
||||||
|
const showSuccess = ref(false)
|
||||||
|
const successText = ref('')
|
||||||
|
let successTimer: number | null = null
|
||||||
|
|
||||||
|
function flashSuccess() {
|
||||||
|
showSuccess.value = true
|
||||||
|
if (successTimer !== null) window.clearTimeout(successTimer)
|
||||||
|
successTimer = window.setTimeout(() => {
|
||||||
|
showSuccess.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 流程复位 ----------
|
||||||
|
function resetToEntry() {
|
||||||
|
stopWeighLoop()
|
||||||
|
if (recognizeTimer !== null) {
|
||||||
|
window.clearTimeout(recognizeTimer)
|
||||||
|
recognizeTimer = null
|
||||||
|
}
|
||||||
|
step.value = 'entry'
|
||||||
|
pickedName.value = ''
|
||||||
|
pickedSpec.value = ''
|
||||||
|
refRawRecord.value = null
|
||||||
|
recognizing.value = false
|
||||||
|
stable.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
resetToEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 生命周期 ----------
|
||||||
|
onMounted(() => {
|
||||||
|
tickClock()
|
||||||
|
clockTimer = window.setInterval(tickClock, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (clockTimer !== null) window.clearInterval(clockTimer)
|
||||||
|
stopWeighLoop()
|
||||||
|
if (recognizeTimer !== null) window.clearTimeout(recognizeTimer)
|
||||||
|
if (successTimer !== null) window.clearTimeout(successTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.terminal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse at top, #1b2a4e 0%, #0a0f1f 70%);
|
||||||
|
color: #e6eefc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
flex: 0 0 56px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 32px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
.bar-title { font-weight: 600; letter-spacing: 2px; }
|
||||||
|
.bar-time { font-family: 'Courier New', monospace; opacity: 0.85; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 入口 ---------- */
|
||||||
|
.entry {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
padding: 64px;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-btn {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 600px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 48px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
.big-btn-icon { font-size: 120px; line-height: 1; }
|
||||||
|
.big-btn-title { font-size: 96px; font-weight: 700; letter-spacing: 6px; }
|
||||||
|
.big-btn-sub { font-size: 32px; opacity: 0.92; }
|
||||||
|
|
||||||
|
&.a { background: linear-gradient(135deg, #36b37e 0%, #1f7a4d 100%); }
|
||||||
|
&.b { background: linear-gradient(135deg, #4c8bf5 0%, #2752c4 100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 通用按钮 ---------- */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-width: 200px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: none;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.15s;
|
||||||
|
|
||||||
|
&:active:not(:disabled) { transform: scale(0.97); }
|
||||||
|
&:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
&.primary { background: linear-gradient(135deg, #36b37e, #1f7a4d); }
|
||||||
|
&.warn { background: linear-gradient(135deg, #f5a623, #c97a05); }
|
||||||
|
&.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- A1 识别屏 ---------- */
|
||||||
|
.recognize {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 32px 64px 0;
|
||||||
|
}
|
||||||
|
.recognize-title { font-size: 32px; font-weight: 600; margin: 0 0 24px; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
grid-auto-rows: minmax(140px, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #e6eefc;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:active { transform: scale(0.97); }
|
||||||
|
&.active {
|
||||||
|
background: rgba(54, 179, 126, 0.25);
|
||||||
|
border-color: #36b37e;
|
||||||
|
box-shadow: 0 0 0 4px rgba(54, 179, 126, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-icon { font-size: 48px; }
|
||||||
|
.cell-name { font-size: 24px; font-weight: 500; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 摄像头扫描动画 */
|
||||||
|
.scanning {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.scanning-frame {
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
border: 3px solid rgba(54, 179, 126, 0.6);
|
||||||
|
border-radius: 24px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 40px rgba(54, 179, 126, 0.35) inset;
|
||||||
|
}
|
||||||
|
.scanning-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, transparent, #36b37e, transparent);
|
||||||
|
animation: scanline 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes scanline {
|
||||||
|
0% { top: 0%; }
|
||||||
|
50% { top: 100%; }
|
||||||
|
100% { top: 0%; }
|
||||||
|
}
|
||||||
|
.scanning-text { font-size: 28px; letter-spacing: 4px; opacity: 0.85; }
|
||||||
|
|
||||||
|
/* ---------- A2 称重屏 ---------- */
|
||||||
|
.weigh {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.weigh-name {
|
||||||
|
font-size: 56px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
.scale {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px 64px;
|
||||||
|
box-shadow: inset 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
.digits {
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 180px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #36ffae;
|
||||||
|
text-shadow: 0 0 24px rgba(54, 255, 174, 0.5);
|
||||||
|
min-width: 6ch;
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
.unit { font-size: 48px; color: #36ffae; opacity: 0.85; }
|
||||||
|
}
|
||||||
|
.scale-status {
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0.8;
|
||||||
|
.stable { color: #36ffae; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 成功提示 ---------- */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(54, 179, 126, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
padding: 32px 56px;
|
||||||
|
border-radius: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&.info { background: rgba(76, 139, 245, 0.95); }
|
||||||
|
|
||||||
|
.toast-icon { font-size: 80px; line-height: 1; }
|
||||||
|
.toast-text { font-size: 28px; font-weight: 600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
/* ---------- B 路径专用 ---------- */
|
||||||
|
/* 参照毛菜批次条 */
|
||||||
|
.ref-bar {
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 12px 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(54, 179, 126, 0.18);
|
||||||
|
border: 1px solid rgba(54, 179, 126, 0.4);
|
||||||
|
color: #b9f5d6;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
background: rgba(245, 166, 35, 0.18);
|
||||||
|
border-color: rgba(245, 166, 35, 0.5);
|
||||||
|
color: #ffd591;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品名旁的规格小标签 */
|
||||||
|
.spec-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 16px;
|
||||||
|
padding: 4px 18px;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(76, 139, 245, 0.25);
|
||||||
|
border: 2px solid rgba(76, 139, 245, 0.6);
|
||||||
|
color: #b9d2ff;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 出净率显示 */
|
||||||
|
.yield-rate {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: #36ffae;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 56px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
.na {
|
||||||
|
color: #ffd591;
|
||||||
|
font-size: 40px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 规格选择网格(列数更少,方块更大) */
|
||||||
|
.spec-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-auto-rows: minmax(200px, 1fr);
|
||||||
|
}
|
||||||
|
.spec-cell {
|
||||||
|
.spec-label {
|
||||||
|
font-size: 96px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 12px;
|
||||||
|
color: #e6eefc;
|
||||||
|
}
|
||||||
|
&.active .spec-label { color: #36ffae; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|||||||
@@ -35,4 +35,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// 拆分 vendor chunk
|
||||||
|
// 只提取所有路由都用到的"foundation"依赖,让 ant-design-vue 维持按路由代码分割
|
||||||
|
// —— antd 体积巨大(~1MB),强行打成单 chunk 反而拖累首屏;按路由 lazy 加载更优
|
||||||
|
manualChunks: {
|
||||||
|
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 微信开发者工具个人设置(每个开发者机器各异)
|
||||||
|
project.private.config.json
|
||||||
|
|
||||||
|
# 小程序 npm 构建产物
|
||||||
|
miniprogram_npm/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# 系统垃圾
|
||||||
|
.DS_Store
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
const app = getApp()
|
|
||||||
|
|
||||||
const request = (options) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
wx.request({
|
|
||||||
url: app.globalData.baseUrl + options.url,
|
|
||||||
method: options.method || 'GET',
|
|
||||||
data: options.data || {},
|
|
||||||
header: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${app.globalData.token}`,
|
|
||||||
...options.header,
|
|
||||||
},
|
|
||||||
success(res) {
|
|
||||||
if (res.data.code === 0) {
|
|
||||||
resolve(res.data.data)
|
|
||||||
} else if (res.data.code === 401) {
|
|
||||||
wx.navigateTo({ url: '/pages/profile/index' })
|
|
||||||
reject(new Error('未登录'))
|
|
||||||
} else {
|
|
||||||
wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' })
|
|
||||||
reject(new Error(res.data.msg))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail(err) {
|
|
||||||
wx.showToast({ title: '网络错误', icon: 'none' })
|
|
||||||
reject(err)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { request }
|
|
||||||
Generated
-3237
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user