Compare commits

..
14 Commits
Author SHA1 Message Date
SkyJourneyandClaude Sonnet 4.6 35c4efd543 fix: Dockerfile 补充复制 pnpm-workspace.yaml
pnpm v11 install 前需要读取该文件中的构建脚本白名单,
否则 ERR_PNPM_IGNORED_BUILDS 依然会触发。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:50:32 +08:00
SkyJourneyandClaude Sonnet 4.6 4ea5a7d104 fix: 提交 pnpm-workspace.yaml,完成 pnpm v11 构建脚本授权
pnpm v11 approve-builds 将授权写入 pnpm-workspace.yaml(allowBuilds +
onlyBuiltDependencies),替代 v10 的 package.json["pnpm"] 字段。
CI 使用 pnpm v11 安装依赖时需要此文件。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:49:49 +08:00
SkyJourneyandClaude Sonnet 4.6 c132df918f fix: 通过 lockfile settings 授权构建脚本,修复 pnpm v11 ERR_PNPM_IGNORED_BUILDS
在 pnpm-lock.yaml settings.onlyBuiltDependencies 中声明允许运行构建脚本的包:
@parcel/watcher / core-js / esbuild / vue-demi
这是 pnpm approve-builds 的标准写入位置,--frozen-lockfile 会直接读取。
同时还原 Dockerfile 中的临时 workaround。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:42:54 +08:00
SkyJourneyandClaude Sonnet 4.6 77d857b18a fix: 改用 pnpm.json 配置构建脚本白名单(兼容 pnpm v11)
pnpm v11 不再读取 package.json 中的 pnpm 字段,
改为在 Dockerfile 中生成 pnpm.json 以授权构建脚本运行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:38:02 +08:00
SkyJourneyandClaude Sonnet 4.6 bcd128b9de fix: 修复 CI 中 pnpm v9+ ERR_PNPM_IGNORED_BUILDS 构建失败
在 Dockerfile 构建阶段注入 onlyBuiltDependencies 白名单,
授权 @parcel/watcher / core-js / esbuild / vue-demi 运行安装脚本。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:35:24 +08:00
yangmi 34ab9b5a1b 新增称重终端:A 毛菜加工 / B 净菜进柜 双路径
- 新增 /weighing-terminal 触摸屏路由(免登录、不挂 MainLayout)
- 新增 weighingTerminal pinia store 管理毛菜/净菜入库流水
- 终端流程:摄像头识别动画 + 网格选品 + 仿数码管称重 + B 路径出净率自动计算
- 登录页底部追加"打开称重终端"入口
- 顺手修复 trace 页 a-date-picker 类型陷阱(Dayjs|null → Dayjs|undefined)
2026-05-27 14:45:12 +08:00
yangmiandClaude Opus 4.7 38edfa2cb1 完善运营大屏、业务流程图、溯源总览及营养用餐模块
运营大屏:新增时间范围选择器、快捷入口(溯源/流程图/食安)、设备在线率、
营养预警人数、告警处理/忽略操作。
业务流程图:新增"业务闭环"视图(三大闭环流程可视化),供应链主线节点增加
数据来源标注(传感器/智能秤/设备检测/人工录入/系统计算/AI视频)。
溯源总览:改为按日期+用户+餐次反向溯源,展示就餐餐品→烹饪→食材→产地链路。
用户管理:扩展五维信息(静态基础/健康档案/口味偏好/禁忌关联/动态生理),
新增基于五维信息自动生成推荐食谱功能。
餐线管理:补充加餐提示、实时取餐数据、监控视频入口。
取餐管理:新增AI取餐结构分析饼图。
终端管理:补充秤编号、故障代码、启用时长、最近上报。
模型管理:补充级别(大/专用/小)、训练参数、API接口。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:51:46 +08:00
SkyJourneyandClaude Sonnet 4.6 1e423a0b7d 移除被意外追踪的 components.d.ts(unplugin 自动生成文件)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:01:13 +08:00
SkyJourneyandClaude Sonnet 4.6 919b4d496a 合并本地优化内容至远端新版本(CI/CD + pnpm + 构建修复)
- 以远端新版本为基底,保留采购管理双 Tab、供应商管理、字典配置等新功能
- 叠加本地优化:删除死代码 api/index.ts 和 utils/request.js
- 修复编译错误:antd-vue 4.x null→undefined(dateRange/licenseExpiry)、
  bodyCell slot 回调改 any、mock/data.ts Array.from 缺失 index 参数、
  移除未使用 import(statusMap/randomFloat/pick)、status as const 字面量类型

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:59:02 +08:00
SkyJourneyandClaude Sonnet 4.6 4c9723b1df 合并 pipeline 为单 job 双 step
build 与 deploy 顺序执行且无并行需要,
合并为一个 job 的两个 step 更简洁

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 17:45:02 +08:00
SkyJourneyandClaude Sonnet 4.6 e5e5b841a4 修正 TeamCity pipeline.yaml 语法
使用正确的 TeamCity Pipelines YAML 格式:
- type: script + script-content 替换错误的 script 字段
- dependencies 替换 GitHub Actions 风格的 needs
- 移除无效的 on/push 触发块,改为注释说明
  (VCS 触发器需在 TeamCity UI 或 Kotlin DSL 中配置)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 17:44:18 +08:00
SkyJourneyandClaude Sonnet 4.6 a6afbcffeb 添加 CI/CD 构建配置与部署文件
- deploy/Dockerfile:多阶段构建,node:24-alpine(阿里镜像)构建
  dist,nginx:alpine 作为成品镜像;构建上下文为仓库根目录
- deploy/nginx.conf:Vue Router history 模式 SPA 配置,含 gzip
  压缩和 /assets/ 长期缓存
- deploy/compose.yaml:以 zhican-admin:latest 启动服务,对外暴露
  12801 端口
- .teamcity/pipeline.yaml:main 分支触发,build/deploy 独立 step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 17:36:10 +08:00
SkyJourneyandClaude Opus 4.6 c8a18c9ec3 切换到 pnpm 工作流,补齐 .gitignore 与 vendor 拆分
- 根目录、zhican/、miniprogram/ 三层 .gitignore 就位
- 删除 package-lock.json,仓库统一追踪 pnpm-lock.yaml
- vite.config.ts 拆出 vue-vendor(vue/vue-router/pinia);
  ant-design-vue 体积~1MB,继续按路由 code-split,不入 manualChunks
- 新增根 CLAUDE.md 记录仓库结构、常用命令、antd 类型陷阱、
  构建门禁、vendor 拆分策略等

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:25:26 +08:00
SkyJourneyandClaude Opus 4.6 077495a401 修复 admin 构建门禁与类型错误,清理死代码
- tsconfig.node.json 启用 composite,让 vue-tsc --noEmit 真正生效
- 修复 24 处 antd-vue 4.x 类型错误(Table columns、Tree data、
  RangePicker null、Progress format 回调等)
- 删除未被引用的 axios 封装与 api 聚合(admin/utils/request.ts、
  admin/api/index.ts、miniprogram/utils/request.js)
- 新增 .githooks/pre-commit:admin 源码变更时自动 pnpm run build

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:24:29 +08:00
47 changed files with 3976 additions and 3660 deletions
+22
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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
+132
View File
@@ -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)` 并 rejectHTTP 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` 同样属于无主死代码已清除。
## 业务上下文
「智能餐养系统」覆盖从种植采购到用餐消费的全链路:绿色种采 → 卫生供应(净菜加工) → 健康生产(按单烹制) → 营养用餐(点餐、取餐、营养分析)。理解某个页面的职责时优先按这条业务流向上下游追溯。
+42
View File
@@ -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;"]
+6
View File
@@ -0,0 +1,6 @@
services:
admin:
image: zhican-admin:latest
ports:
- "12801:80"
restart: unless-stopped
+34
View File
@@ -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
View File
@@ -1,7 +1,37 @@
# ============ 依赖 ============
node_modules
# 统一使用 pnpm,禁止意外提交 npm/yarn 锁文件
package-lock.json
yarn.lock
# ============ 构建产物 ============
dist
.DS_Store
*.local
dist-ssr
*.tsbuildinfo
# ============ 测试/覆盖率 ============
coverage
.nyc_output
# ============ 日志 ============
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# ============ 环境变量 ============
.env
.env.*
!.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
+2056
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
allowBuilds:
'@parcel/watcher': true
core-js: true
esbuild: true
vue-demi: true
onlyBuiltDependencies:
- '@parcel/watcher'
- core-js
- esbuild
- vue-demi
-53
View File
@@ -1,53 +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 dictApi = {
// 字典类型
typeList: (params?: any) => request.get('/system/dict-type', { params }),
typeCreate: (data: any) => request.post('/system/dict-type', data),
typeUpdate: (id: string, data: any) => request.put(`/system/dict-type/${id}`, data),
typeRemove: (id: string) => request.delete(`/system/dict-type/${id}`),
// 字典项
itemList: (typeCode: string, params?: any) => request.get(`/system/dict-item/${typeCode}`, { params }),
itemCreate: (data: any) => request.post('/system/dict-item', data),
itemUpdate: (id: string, data: any) => request.put(`/system/dict-item/${id}`, data),
itemRemove: (id: string) => request.delete(`/system/dict-item/${id}`),
}
// 登录
export const authApi = {
login: (data: { username: string; password: string }) => request.post('/auth/login', data),
logout: () => request.post('/auth/logout'),
profile: () => request.get('/auth/profile'),
}
-75
View File
@@ -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']
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']
AUpload: typeof import('ant-design-vue/es')['Upload']
ChartPanel: typeof import('./components/common/ChartPanel.vue')['default']
RelatedModules: typeof import('./components/common/RelatedModules.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StatCard: typeof import('./components/common/StatCard.vue')['default']
StatusDot: typeof import('./components/common/StatusDot.vue')['default']
}
}
+27 -4
View File
@@ -1,5 +1,3 @@
import { ref } from 'vue'
// ==================== 通用工具 ====================
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
@@ -376,8 +374,6 @@ export function genProductionStatsData() {
}
// ==================== 营养用餐 ====================
const nutrientNames = ['蛋白质', '脂肪', '碳水化合物', '膳食纤维', '维生素A', '维生素C', '钙', '铁', '锌']
export function genNutritionTestData(n = 12) {
return Array.from({ length: n }, () => ({
id: randomId(),
@@ -579,6 +575,33 @@ export const dictItems: DictItem[] = [
{ 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 ====================
export function genDashboardData() {
return {
+10 -1
View File
@@ -64,6 +64,13 @@ const routes: RouteRecordRaw[] = [
name: 'login',
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: '/',
component: () => import('@/layouts/MainLayout.vue'),
@@ -79,7 +86,9 @@ const router = createRouter({
router.beforeEach((to, _from, next) => {
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' })
} else {
document.title = `${(to.meta.title as string) || ''} - 智能餐养`
+100
View File
@@ -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,
}
})
-40
View File
@@ -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
+101 -29
View File
@@ -8,6 +8,7 @@
<a-radio-button value="chain">供应链主线</a-radio-button>
<a-radio-button value="matrix">关联矩阵</a-radio-button>
<a-radio-button value="detail">模块详情</a-radio-button>
<a-radio-button value="loop">业务闭环</a-radio-button>
</a-radio-group>
</a-card>
@@ -39,6 +40,7 @@
<span class="node-dot" :style="{ background: stage.color }"></span>
<span class="node-label">{{ node.label }}</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 class="stage-arrow" v-if="si < supplyChain.length - 1"></div>
@@ -221,6 +223,29 @@
</a-col>
</a-row>
</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>
</template>
@@ -229,7 +254,7 @@ import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const viewMode = ref<'chain' | 'matrix' | 'detail'>('chain')
const viewMode = ref<'chain' | 'matrix' | 'detail' | 'loop'>('chain')
const activeStage = ref('green')
const hoveredNode = ref('')
const hoverCell = ref('')
@@ -247,49 +272,49 @@ const supplyChain = [
{
id: 'green', name: '绿色种采', icon: '🌱', color: '#52c41a',
nodes: [
{ key: 'land-env', label: '土地环境' },
{ key: 'planting-monitor', label: '种养监控' },
{ key: 'harvest-storage', label: '收成入库', badge: '出' },
{ key: 'supplier-mgmt', label: '供商管理' },
{ key: 'procurement-mgmt', label: '采管理', badge: '出' },
{ key: 'land-env', label: '土地环境', dataSource: '传感器' },
{ key: 'planting-monitor', label: '种养监控', dataSource: '传感器' },
{ key: 'harvest-storage', label: '收成入库', badge: '出', dataSource: '智能秤' },
{ key: 'supplier-mgmt', label: '供商管理', dataSource: '人工录入' },
{ key: 'procurement-mgmt', label: '采管理', badge: '出', dataSource: '系统生成' },
],
},
{
id: 'hygiene', name: '卫生供应', icon: '🧪', color: '#1890ff',
nodes: [
{ key: 'hygiene-test', label: '卫生检测', badge: '验' },
{ key: 'raw-storage', label: '毛菜入库', badge: '入' },
{ key: 'clean-process', label: '净菜加工' },
{ key: 'clean-storage', label: '净菜入库' },
{ key: 'clean-internal', label: '净菜内供', badge: '出' },
{ key: 'clean-external', label: '净菜外售', badge: '出' },
{ key: 'hygiene-test', label: '卫生检测', badge: '验', dataSource: '设备检测' },
{ key: 'raw-storage', label: '毛菜管理', badge: '入', dataSource: '智能秤' },
{ key: 'clean-process', label: '净菜加工', dataSource: '智能秤' },
{ key: 'clean-storage', label: '净菜入库', dataSource: '智能秤' },
{ key: 'clean-internal', label: '净菜内供', badge: '出', dataSource: '系统计算' },
{ key: 'clean-external', label: '净菜外售', badge: '出', dataSource: '人工录入' },
],
},
{
id: 'health', name: '健康生产', icon: '👨‍🍳', color: '#faad14',
nodes: [
{ key: 'food-safety', label: '食安监控', badge: '验' },
{ key: 'recipe-plan', label: '食谱规划' },
{ key: 'clean-order', label: '净菜订单', badge: '入' },
{ key: 'clean-cabinet', label: '净菜进柜' },
{ key: 'clean-assemble', label: '净菜组配' },
{ key: 'cook-by-order', label: '按单烹制' },
{ key: 'serving-monitor', label: '上餐监控', badge: '出' },
{ key: 'surplus-disposal', label: '余量处置' },
{ key: 'production-stats', label: '生产统计' },
{ key: 'food-safety', label: '食安监控', badge: '验', dataSource: 'AI视频' },
{ key: 'recipe-plan', label: '食谱规划', dataSource: '系统计算' },
{ key: 'clean-order', label: '净菜订单', badge: '入', dataSource: '系统生成' },
{ key: 'clean-cabinet', label: '净菜进柜', dataSource: '智能秤' },
{ key: 'clean-assemble', label: '净菜组配', dataSource: '人工录入' },
{ key: 'cook-by-order', label: '按单烹制', dataSource: '智能秤' },
{ key: 'serving-monitor', label: '上餐监控', badge: '出', dataSource: '传感器' },
{ key: 'surplus-disposal', label: '余量处置', dataSource: '智能秤' },
{ key: 'production-stats', label: '生产统计', dataSource: '系统计算' },
],
},
{
id: 'nutrition', name: '营养用餐', icon: '🍽️', color: '#722ed1',
nodes: [
{ key: 'nutrition-test', label: '营养检验', badge: '验' },
{ key: 'food-composition', label: '食物成分' },
{ key: 'dish-inventory', label: '餐品总库' },
{ key: 'recipe-mgmt', label: '食谱管理' },
{ key: 'nutrition-order', label: '营养点餐' },
{ key: 'pickup-mgmt', label: '取餐管理' },
{ key: 'consumption-mgmt', label: '消费管理' },
{ key: 'nutrition-mgmt', label: '营养管理' },
{ key: 'nutrition-test', label: '营养检验', badge: '验', dataSource: '设备检测' },
{ key: 'food-composition', label: '食物成分', dataSource: '人工录入' },
{ key: 'dish-inventory', label: '餐品总库', dataSource: '人工录入' },
{ key: 'recipe-mgmt', label: '食谱管理', dataSource: '人工录入' },
{ key: 'nutrition-order', label: '营养点餐', dataSource: '系统计算' },
{ key: 'pickup-mgmt', label: '取餐管理', dataSource: '硬件自动' },
{ key: 'consumption-mgmt', label: '消费管理', dataSource: '系统计算' },
{ key: 'nutrition-mgmt', label: '营养管理', dataSource: '系统计算' },
],
},
]
@@ -519,6 +544,53 @@ const moduleDetails: Record<string, any> = {
}
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>
<style scoped lang="scss">
+68 -4
View File
@@ -1,8 +1,13 @@
<template>
<div class="dashboard">
<a-page-header title="数据概览" subtitle="智能餐养运营总览">
<a-page-header title="运营大屏" subtitle="智能餐养运营总览">
<template #extra>
<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-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>
@@ -12,6 +17,28 @@
</template>
</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 ============ -->
<a-row :gutter="[16, 16]">
<a-col :span="4" v-for="kpi in kpiCards" :key="kpi.title">
@@ -108,6 +135,10 @@
<span class="supply-label">库存天数</span>
<span class="supply-val" :style="{ color: d.supplyChain.stockDays < 2 ? '#f5222d' : '#333' }">{{ d.supplyChain.stockDays }}</span>
</div>
<div class="supply-item">
<span class="supply-label">设备在线率</span>
<span class="supply-val" style="color:#52c41a">{{ deviceOnlineRate }}%</span>
</div>
</div>
</a-card>
</a-col>
@@ -167,6 +198,7 @@
{{ d.nutritionOverview.structureOk ? '营养结构合理' : '营养结构偏离' }}
</a-tag>
<span class="verdict-alert">{{ d.nutritionOverview.alertCount }} 条营养预警</span>
<a-tag color="red" style="margin-left:8px">{{ warningUserCount }}人需关注</a-tag>
</div>
</a-col>
</a-row>
@@ -237,6 +269,13 @@
<template v-if="column.key === 'module'">
<a-tag>{{ record.module }}</a-tag>
</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>
</a-table>
</a-card>
@@ -245,15 +284,18 @@
<script setup lang="ts">
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 { genDashboardData } from '@/mock/data'
import { genDashboardData, randomNum } from '@/mock/data'
const router = useRouter()
const d = reactive(genDashboardData())
const filterCanteen = ref('')
const trendMetric = ref<'orders' | 'revenue'>('orders')
const alertFilter = ref('')
const timeRange = ref('today')
const deviceOnlineRate = computed(() => randomNum(88, 98))
const warningUserCount = computed(() => randomNum(3, 12))
function refreshData() {
Object.assign(d, genDashboardData())
@@ -304,15 +346,37 @@ const alertColumns = [
{ title: '模块', key: 'module', width: 100 },
{ title: '内容', dataIndex: 'message', ellipsis: true },
{ title: '时间', dataIndex: 'time', width: 150 },
{ title: '操作', key: 'action', width: 140 },
]
const filteredAlerts = computed(() =>
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>
<style scoped lang="scss">
.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-card {
text-align: center;
+182 -55
View File
@@ -1,82 +1,209 @@
<template>
<div class="trace-overview">
<a-page-header title="溯源总览" sub-title="输入批次号追踪从农田到餐桌的完整链路" />
<a-page-header title="溯源总览" sub-title="按日期用户餐次反向追踪就餐餐品的完整链路" />
<a-card :bordered="false" style="margin-bottom:16px">
<a-input-search
v-model:value="batchNo"
placeholder="请输入批次号(如 HV20260415123"
enter-button="溯源查询"
size="large"
style="max-width:600px"
@search="handleSearch"
/>
<a-card :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="traceResult" :bordered="false">
<a-steps :current="traceResult.steps.length - 1" direction="vertical" size="small">
<a-step v-for="(step, i) in traceResult.steps" :key="i" :title="step.stage" :description="step.time">
<template #icon>
<span :style="{ color: stageColors[i] }">{{ i + 1 }}</span>
<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 #subTitle>
<a-tag :color="stageColors[i]">{{ step.module }}</a-tag>
</template>
</a-step>
</a-steps>
</a-table>
</a-card>
<a-divider />
<a-descriptions title="溯源详情" bordered :column="2" size="small">
<a-descriptions-item label="批次号">{{ traceResult.batchNo }}</a-descriptions-item>
<a-descriptions-item label="品种">{{ traceResult.material }}</a-descriptions-item>
<a-descriptions-item label="产地">{{ traceResult.origin }}</a-descriptions-item>
<a-descriptions-item label="供应商">{{ traceResult.supplier }}</a-descriptions-item>
<a-descriptions-item label="检测结果">
<a-tag color="green">合格</a-tag>
</a-descriptions-item>
<a-descriptions-item label="加工损耗率">{{ traceResult.lossRate }}</a-descriptions-item>
<a-descriptions-item label="最终菜品">{{ traceResult.dish }}</a-descriptions-item>
<a-descriptions-item label="上餐时间">{{ traceResult.serveTime }}</a-descriptions-item>
<a-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 && !traceResult" description="未找到该批次的溯源信息" />
<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 { randomDate, randomTime, pick } from '@/mock/data'
import { pick, randomDate, randomTime, randomNum } from '@/mock/data'
const batchNo = ref('')
const traceResult = ref<any>(null)
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 stageColors = ['#52c41a', '#1890ff', '#faad14', '#722ed1', '#f5222d']
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() {
searched.value = true
if (!batchNo.value) { message.warning('请输入批次号'); traceResult.value = null; return }
traceResult.value = {
batchNo: batchNo.value,
material: pick(['白菜', '番茄', '猪里脊', '鸡胸肉', '土豆']),
origin: pick(['A区稻田', 'B区菜地', 'C区果园']),
supplier: pick(['集团种养公司', '绿源农业', '丰禾食品']),
lossRate: '12.5%',
dish: pick(['红烧肉', '清炒时蔬', '番茄鸡蛋']),
serveTime: randomDate(3) + ' 11:30',
steps: [
{ stage: '种养采收', module: '绿色种养', time: randomDate(30) + ' ' + randomTime() + ' — 收成入库,质量等级:优等' },
{ stage: '卫生检测', module: '卫生供应', time: randomDate(25) + ' ' + randomTime() + ' — 农残/重金属/化肥残留检测合格' },
{ stage: '净菜加工', module: '卫生供应', time: randomDate(20) + ' ' + randomTime() + ' — 毛菜45kg → 净菜39.4kg,损耗率12.5%' },
{ stage: '烹饪生产', module: '健康生产', time: randomDate(3) + ' ' + randomTime() + ' — 按单烹制,生重2.5kg → 熟重2.1kg' },
{ stage: '上餐用餐', module: '营养用餐', time: randomDate(3) + ' 11:30 — 1号中餐线上餐,温度72℃' },
],
if (!queryDate.value || !queryUser.value) {
message.warning('请选择日期和用户')
return
}
message.success('溯源查询完成')
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-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>
@@ -112,7 +112,7 @@ const relatedLinks = [
const data = ref(genHarvestData(15))
const searchText = ref('')
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const dateRange = ref<[Dayjs, Dayjs]>()
const statusFilter = ref<string | undefined>(undefined)
const modalVisible = ref(false)
const currentRecord = ref<any>(null)
@@ -224,9 +224,9 @@ const verifyColumns = [
function orderStatusColor(s: string) { return s === 'pending' ? 'blue' : s === 'confirmed' ? 'cyan' : s === 'shipped' ? 'orange' : 'green' }
function orderStatusText(s: string) { return s === 'pending' ? '待确认' : s === 'confirmed' ? '已确认' : s === 'shipped' ? '已发货' : '已到货' }
function confirmOrder(r: Order) { r.status = 'confirmed'; message.success('已确认') }
function shipOrder(r: Order) { r.status = 'shipped'; message.success('已标记发货') }
function arriveOrder(r: Order) {
function confirmOrder(r: any) { r.status = 'confirmed'; message.success('已确认') }
function shipOrder(r: any) { r.status = 'shipped'; message.success('已标记发货') }
function arriveOrder(r: any) {
r.status = 'arrived'
const actual = r.quantity - randomNum(-5, 10)
verifyData.value.unshift({
@@ -237,8 +237,8 @@ function arriveOrder(r: Order) {
message.success('已到货,请前往到货验收')
}
function verifyPass(r: VerifyRecord) { r.verifyStatus = 'passed'; message.success('验收合格') }
function verifyFail(r: VerifyRecord) { r.verifyStatus = 'failed'; message.error('验收不合格') }
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 }
@@ -155,7 +155,7 @@ import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { randomId, randomNum, randomFloat, randomDate, pick } from '@/mock/data'
import { randomId, randomNum, randomDate, pick } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
interface Supplier {
@@ -205,7 +205,7 @@ const currentRecord = ref<Supplier | null>(null)
const form = ref({
name: '', ownership: 'own' as 'own' | 'external', supplierType: undefined as string | undefined,
contact: '', phone: '', address: '',
licenseName: '', licenseExpiry: null as Dayjs | null, creditScore: 80,
licenseName: '', licenseExpiry: undefined as Dayjs | undefined, creditScore: 80,
})
const ownData = computed(() => allData.value.filter(s => s.ownership === 'own'))
@@ -256,23 +256,23 @@ function qualText(q: string) {
function handleAdd() {
isEdit.value = false
currentRecord.value = null
form.value = { name: '', ownership: activeTab.value, supplierType: undefined, contact: '', phone: '', address: '', licenseName: '', licenseExpiry: null, creditScore: 80 }
form.value = { name: '', ownership: activeTab.value, supplierType: undefined, contact: '', phone: '', address: '', licenseName: '', licenseExpiry: undefined, creditScore: 80 }
modalVisible.value = true
}
function handleEdit(record: Supplier) {
function handleEdit(record: any) {
isEdit.value = true
currentRecord.value = record
form.value = {
name: record.name, ownership: record.ownership, supplierType: record.supplierType,
contact: record.contactPerson, phone: record.phone, address: record.address,
licenseName: record.licenseName, licenseExpiry: record.licenseExpiry ? dayjs(record.licenseExpiry) : null,
licenseName: record.licenseName, licenseExpiry: record.licenseExpiry ? dayjs(record.licenseExpiry) : undefined,
creditScore: record.creditScore,
}
modalVisible.value = true
}
function handleDetail(record: Supplier) {
function handleDetail(record: any) {
currentRecord.value = record
drawerVisible.value = true
}
@@ -32,7 +32,7 @@
v-if="record.status !== 'done'"
type="primary"
size="small"
@click="confirmAssemble(record)"
@click="confirmAssemble(record as AssembleItem)"
>确认组配</a-button>
<span v-else style="color: #999">已完成</span>
</template>
@@ -58,7 +58,7 @@
<script setup lang="ts">
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 = ['白菜', '萝卜', '番茄', '黄瓜', '茄子', '青椒', '芹菜', '菠菜']
@@ -88,7 +88,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genCookByOrderData, statusMap, canteenNames, randomNum } from '@/mock/data'
import { genCookByOrderData, canteenNames, randomNum } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import RelatedModules from '@/components/common/RelatedModules.vue'
@@ -89,7 +89,7 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { genProductionStatsData, randomNum, randomFloat } from '@/mock/data'
import { genProductionStatsData, randomNum } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
@@ -76,12 +76,12 @@
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import { genSurplusData, statusMap } from '@/mock/data'
import { genSurplusData } from '@/mock/data'
import StatCard from '@/components/common/StatCard.vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
const data = ref(genSurplusData(8))
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const dateRange = ref<[Dayjs, Dayjs]>()
const disposalFilter = ref<string | undefined>(undefined)
const modalVisible = ref(false)
const currentRecord = ref<any>(null)
@@ -142,7 +142,7 @@ const tableData = ref(genHygieneTestData(30))
const searchText = ref('')
const testItemFilter = ref<string>()
const resultFilter = ref<string>()
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const dateRange = ref<[Dayjs, Dayjs]>()
const detailVisible = ref(false)
const currentRecord = ref<any>(null)
@@ -214,5 +214,5 @@ const reportColumns = [
{ title: '操作', key: 'action', width: 80 },
]
function showReportDetail(record: Report) { currentReport.value = record; reportDetailVisible.value = true }
function showReportDetail(record: any) { currentReport.value = record; reportDetailVisible.value = true }
</script>
+17
View File
@@ -15,6 +15,9 @@
</a-button>
</a-form-item>
</a-form>
<div class="terminal-link">
<router-link to="/weighing-terminal">📦 打开称重终端 </router-link>
</div>
</div>
</div>
</template>
@@ -70,4 +73,18 @@ async function handleLogin() {
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>
@@ -57,7 +57,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
import { randomNum, randomFloat, pick } from '@/mock/data'
import { randomNum, randomFloat } from '@/mock/data'
const benefitTab = ref('ingredient')
const materials = ['猪肉', '鸡肉', '大米', '青菜', '番茄', '土豆', '鸡蛋', '面粉']
@@ -21,24 +21,37 @@
<a-tag :color="statusMap[line.status]?.color">{{ statusMap[line.status]?.text }}</a-tag>
</div>
<a-row :gutter="12" class="stats">
<a-col :span="12">
<a-col :span="8">
<a-statistic title="今日菜品" :value="line.todayDishes" />
</a-col>
<a-col :span="12">
<a-col :span="8">
<a-statistic title="今日服务" :value="line.todayServed" suffix="人" />
</a-col>
<a-col :span="8">
<a-statistic title="取餐速率" :value="line.pickupRate" suffix="人/分" :value-style="{ fontSize: '16px' }" />
</a-col>
</a-row>
<a-descriptions :column="1" size="small" class="desc">
<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.equipment }}</a-descriptions-item>
<a-descriptions-item label="评分">
<a-rate :value="line.rating" disabled allow-half style="font-size: 14px" />
</a-descriptions-item>
</a-descriptions>
<a-alert v-if="line.stockPercent < 20" type="warning" show-icon
:message="`存量不足1/5,建议立即补餐`" style="margin-bottom:8px" />
<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>
</div>
</a-card>
@@ -49,9 +62,14 @@
<script setup lang="ts">
import { ref, computed } from '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 filteredList = computed(() =>
@@ -5,7 +5,7 @@
<a-table :columns="columns" :data-source="list" :pagination="{ pageSize: 10 }" row-key="id">
<template #bodyCell="{ column, record }">
<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 v-else-if="column.key === 'accuracy'">
<a-progress :percent="record.accuracy" size="small" />
@@ -29,25 +29,27 @@
import { ref } from '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([
{ id: 1, name: '智能点餐推荐', version: 'v2.1.3', type: '推荐', accuracy: 92, status: '在线', updateTime: '2026-04-01' },
{ id: 2, name: '营养评分模型', version: 'v3.0.1', type: '营养分析', accuracy: 88, status: '在线', updateTime: '2026-03-28' },
{ id: 3, name: '消费预测模型', version: 'v1.4.0', type: '预测', accuracy: 85, status: '在线', updateTime: '2026-03-25' },
{ id: 4, name: '食材损耗预测', version: 'v1.2.0', type: '预测', accuracy: 79, status: '离线', updateTime: '2026-03-15' },
{ id: 5, name: '过敏原识别', version: 'v2.0.0', type: '营养分析', accuracy: 95, status: '在线', updateTime: '2026-04-03' },
{ id: 6, name: '个性化菜谱', version: 'v1.8.2', type: '推荐', accuracy: 87, status: '离线', updateTime: '2026-03-20' },
{ 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: '营养评分模型', 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: '消费预测模型', 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: '食材损耗预测', 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: '过敏原识别', 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: '个性化菜谱', 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 = [
{ title: '模型名称', dataIndex: 'name' },
{ title: '版本', dataIndex: 'version' },
{ title: '类型', key: 'type' },
{ title: '准确率', key: 'accuracy', width: 200 },
{ title: '状态', key: 'status' },
{ title: '更新时间', dataIndex: 'updateTime' },
{ title: '操作', key: 'actions' },
{ title: '级别', key: 'type', width: 90 },
{ title: '版本', dataIndex: 'version', width: 80 },
{ title: '训练参数', dataIndex: 'trainParams', ellipsis: true },
{ title: 'API接口', dataIndex: 'apiEndpoint', ellipsis: true },
{ title: '准确率', key: 'accuracy', width: 160 },
{ title: '状态', key: 'status', width: 80 },
{ title: '更新时间', dataIndex: 'updateTime', width: 110 },
{ title: '操作', key: 'actions', width: 120 },
]
const toggle = (r: any) => {
@@ -132,7 +132,7 @@
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ personProfile.name }}</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-progress :percent="personProfile.score" :size="20"
:stroke-color="personProfile.score >= 80 ? '#52c41a' : personProfile.score >= 60 ? '#faad14' : '#f5222d'" />
@@ -188,7 +188,7 @@
:percent="record.score"
:size="20"
:stroke-color="record.score >= 80 ? '#52c41a' : record.score >= 60 ? '#faad14' : '#f5222d'"
:format="(p: number) => p + ''"
:format="(p?: number) => (p ?? 0) + ''"
/>
</template>
<template v-else-if="column.key === 'verdict'">
@@ -295,7 +295,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { ref, computed } from 'vue'
import ChartPanel from '@/components/common/ChartPanel.vue'
import { randomNum, randomFloat, pick } from '@/mock/data'
@@ -312,19 +312,6 @@ const drawerPerson = ref<any>(null)
const personList = ['张伟', '李芳', '王娜', '刘敏', '陈强', '杨磊', '黄艳', '赵勇', '周洋', '吴军']
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 就餐数据 ============
function genPersonNutrition() {
return personList.map(name => {
@@ -502,7 +489,7 @@ const mealBalance = computed(() => {
// ============ 个人营养画像 ============
const personProfile = computed(() => {
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(() => {
@@ -1,10 +1,28 @@
<template>
<div class="page-container">
<a-page-header title="取餐管理" sub-title="实时取餐订单监控">
<a-page-header title="取餐管理" sub-title="实时取餐监控与AI取餐结构分析">
<template #extra>
<a-button type="primary" @click="refresh">刷新</a-button>
</template>
</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-table :columns="columns" :data-source="orders" :pagination="{ pageSize: 10 }" row-key="id">
<template #bodyCell="{ column, record }">
@@ -21,9 +39,10 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { randomId, randomNum, randomTime, randomDate, pick, pickMulti } from '@/mock/data'
import ChartPanel from '@/components/common/ChartPanel.vue'
const statusColor: Record<string, string> = {
'待取餐': 'blue',
@@ -66,6 +85,22 @@ const refresh = () => {
orders.value = gen()
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>
<style scoped lang="scss">
@@ -8,12 +8,17 @@
<div class="dev-name">{{ d.name }}</div>
<div class="dev-type">{{ d.type }}</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>
<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.heartbeat }}</a-descriptions-item>
<a-descriptions-item label="版本">{{ d.version }}</a-descriptions-item>
<a-descriptions-item label="故障代码">
<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>
<div class="actions">
<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'
const devices = ref([
{ id: 1, name: '点餐机-01', type: '点餐机', location: '一楼大厅A', status: 'online', heartbeat: '2026-04-07 10:23', version: 'v2.3.1' },
{ id: 2, name: '点餐机-02', type: '点餐机', location: '一楼大厅B', status: 'online', heartbeat: '2026-04-07 10:24', version: 'v2.3.1' },
{ id: 3, name: '取餐柜-01', type: '取餐柜', location: '一楼出餐口', status: 'online', heartbeat: '2026-04-07 10:24', version: 'v1.5.0' },
{ id: 4, name: '取餐柜-02', type: '取餐柜', location: '二楼出餐口', status: 'offline', heartbeat: '2026-04-06 18:30', version: 'v1.5.0' },
{ id: 5, name: '显示屏-01', type: '显示屏', location: '一楼大厅', status: 'online', heartbeat: '2026-04-07 10:25', version: 'v3.0.2' },
{ id: 6, name: '显示屏-02', type: '显示屏', location: '二楼大厅', status: 'online', heartbeat: '2026-04-07 10:25', version: 'v3.0.2' },
{ id: 7, name: '点餐机-03', type: '点餐机', location: '楼大厅A', status: 'offline', heartbeat: '2026-04-06 22:10', version: 'v2.3.0' },
{ id: 8, name: '取餐柜-03', type: '取餐柜', location: '楼出餐口', status: 'online', heartbeat: '2026-04-07 10:23', version: 'v1.5.0' },
{ id: 1, scaleId: 'CH00001', name: '智能秤-01', type: '智能秤', location: '1号中餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '128天', lastReport: '2026-04-23 10:23' },
{ id: 2, scaleId: 'CH00002', name: '智能秤-02', type: '智能秤', location: '2号中餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '95天', lastReport: '2026-04-23 10:24' },
{ id: 3, scaleId: 'CH00003', name: '智能秤-03', type: '智能秤', location: '3号西餐线', status: 'offline', faultCode: 'E102', heartbeat: '-', uptime: '62天', lastReport: '2026-04-22 18:30' },
{ id: 4, scaleId: 'CH00004', name: '智能秤-04', type: '智能秤', location: 'VIP餐线', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '210天', lastReport: '2026-04-23 10:25' },
{ id: 5, scaleId: 'CH00005', name: '智能秤-05', type: '智能秤', location: '面点窗口', status: 'fault', faultCode: 'E205', heartbeat: '-', uptime: '45天', lastReport: '2026-04-22 08:10' },
{ id: 6, scaleId: 'CH00006', name: '智能秤-06', type: '智能秤', location: '小吃窗口', status: 'online', faultCode: '-', heartbeat: '30s', uptime: '180天', lastReport: '2026-04-23 10:25' },
{ id: 7, scaleId: 'TM00001', name: '点餐机-01', type: '点餐机', location: '楼大厅A', status: 'online', faultCode: '-', heartbeat: '60s', uptime: '300天', lastReport: '2026-04-23 10:23' },
{ id: 8, scaleId: 'TM00002', name: '取餐柜-01', type: '取餐柜', location: '楼出餐口', status: 'online', faultCode: '-', heartbeat: '60s', uptime: '250天', lastReport: '2026-04-23 10:24' },
])
</script>
@@ -1,31 +1,136 @@
<template>
<div class="page-container">
<a-page-header title="用户管理" sub-title="就餐用户档案" />
<a-page-header title="用户管理" sub-title="就餐用户五维档案与个性化推荐食谱" />
<a-card>
<div class="toolbar">
<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-option v-for="d in departments" :key="d" :value="d">{{ d }}</a-select-option>
</a-select>
<a-button type="primary" @click="modalOpen = true">+ 新增用户</a-button>
<a-button type="primary" @click="openAddModal">+ 新增用户</a-button>
</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 v-if="column.key === 'isMember'">
<a-tag :color="record.isMember ? 'gold' : 'default'">{{ record.isMember ? '会员' : '普通' }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<template v-if="column.key === 'status'">
<a-tag :color="statusMap[record.status]?.color">{{ statusMap[record.status]?.text }}</a-tag>
</template>
<template v-else-if="column.key === 'allergens'">
<a-tag v-for="a in record.allergens" :key="a" color="red">{{ a }}</a-tag>
<template v-if="column.key === 'taboo'">
<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>
</a-table>
</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.phone" /></a-form-item>
<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>
</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-modal>
</div>
@@ -41,27 +149,76 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import { genUserMgmtData, statusMap } 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: '' })
import { genUserMgmtData, statusMap, randomNum, randomFloat, pick } from '@/mock/data'
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 = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '电话', dataIndex: 'phone', key: 'phone' },
{ title: '部门', dataIndex: 'department', key: 'department' },
{ title: '餐卡号', dataIndex: 'mealCard', key: 'mealCard' },
{ title: '余额', dataIndex: 'balance', key: 'balance' },
{ title: '会员', key: 'isMember' },
{ title: '累计订单', dataIndex: 'totalOrders', key: 'totalOrders' },
{ title: '最近点餐', dataIndex: 'lastOrder', key: 'lastOrder' },
{ title: '状态', key: 'status' },
{ title: '过敏原', key: 'allergens' },
{ title: '姓名', dataIndex: 'name', width: 80 },
{ title: '部门', dataIndex: 'department', width: 80 },
{ title: '年龄', dataIndex: 'age', width: 60 },
{ title: 'BMI', dataIndex: 'bmi', width: 70 },
{ title: '活动水平', dataIndex: 'activityLevel', width: 90 },
{ title: '禁忌', key: 'taboo', width: 120 },
{ title: '状态', key: 'status', width: 70 },
{ title: '操作', key: 'action', width: 160 },
]
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(() =>
@@ -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('用户已添加')
modalOpen.value = false
addModalOpen.value = false
}
</script>
@@ -150,10 +150,11 @@
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { message } from 'ant-design-vue'
import type { ColumnsType } from 'ant-design-vue/es/table'
import StatCard from '@/components/common/StatCard.vue'
import { canteenList } from '@/mock/data'
const columns = [
const columns: ColumnsType<any> = [
{ title: '食堂名称', dataIndex: 'name', key: 'name', width: 110 },
{ title: '别名', dataIndex: 'alias', key: 'alias', width: 120 },
{ title: '地址', dataIndex: 'address', key: 'address', width: 100 },
@@ -149,7 +149,7 @@ const typeSearch = ref('')
const selectedType = ref<DictType | null>(null)
const typeModalVisible = ref(false)
const editingType = ref<DictType | null>(null)
const typeForm = ref({ name: '', code: '', status: 'enabled' as const, remark: '' })
const typeForm = ref({ name: '', code: '', status: 'enabled' as 'enabled' | 'disabled', remark: '' })
const filteredTypes = computed(() => {
const kw = typeSearch.value.toLowerCase()
@@ -168,7 +168,7 @@ function selectType(record: DictType) {
selectedType.value = record
}
function openTypeModal(record?: DictType) {
function openTypeModal(record?: any) {
editingType.value = record || null
typeForm.value = record
? { name: record.name, code: record.code, status: record.status, remark: record.remark }
@@ -202,7 +202,7 @@ function removeType(id: string) {
const items = ref<DictItem[]>([...initItems])
const itemModalVisible = ref(false)
const editingItem = ref<DictItem | null>(null)
const itemForm = ref({ label: '', value: '', sort: 0, status: 'enabled' as const, remark: '' })
const itemForm = ref({ label: '', value: '', sort: 0, status: 'enabled' as 'enabled' | 'disabled', remark: '' })
const currentItems = computed(() => {
if (!selectedType.value) return []
@@ -219,7 +219,7 @@ const itemColumns = [
{ title: '操作', key: 'action', width: 120 },
]
function openItemModal(record?: DictItem) {
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 }
@@ -247,7 +247,7 @@ function removeItem(id: string) {
message.success('删除成功')
}
function toggleItemStatus(record: DictItem) {
function toggleItemStatus(record: any) {
record.status = record.status === 'enabled' ? 'disabled' : 'enabled'
message.success(`${record.status === 'enabled' ? '启用' : '停用'}`)
}
@@ -145,10 +145,11 @@
<script setup lang="ts">
import { ref, computed, reactive } from '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'
const columns = [
const columns: ColumnsType<any> = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '性别', dataIndex: 'gender', key: 'gender', width: 60 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120 },
@@ -183,13 +184,20 @@ const drawerVisible = ref(false)
const editingId = ref('')
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: '',
gender: '男',
phone: '',
department: '',
position: '',
entryDate: null,
entryDate: undefined,
})
const activeEmployees = computed(() => allEmployees.value.filter(e => e.status === '在职'))
@@ -227,7 +235,7 @@ function showAddModal() {
form.phone = ''
form.department = ''
form.position = ''
form.entryDate = null
form.entryDate = undefined
modalVisible.value = true
}
@@ -270,7 +278,7 @@ function saveEmployee() {
phone: form.phone,
department: form.department,
position: form.position,
entryDate: form.entryDate?.format('YYYY-MM-DD'),
entryDate: form.entryDate?.format('YYYY-MM-DD') ?? '',
}
}
} else {
@@ -281,7 +289,7 @@ function saveEmployee() {
phone: form.phone,
department: form.department,
position: form.position,
entryDate: form.entryDate?.format('YYYY-MM-DD'),
entryDate: form.entryDate?.format('YYYY-MM-DD') ?? '',
status: '试用期',
})
}
@@ -15,7 +15,7 @@
/>
<a-tree
v-model:selectedKeys="selectedOrgKeys"
:tree-data="orgTreeData"
:tree-data="(orgTreeData as any)"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
@select="onOrgSelect"
>
@@ -212,9 +212,9 @@ const selectedOrgEmployees = computed(() => {
return mockEmployees[selectedOrg.value.id] || []
})
function onOrgSelect(keys: string[]) {
function onOrgSelect(keys: Array<string | number>) {
if (keys.length === 0) return
const key = keys[0]
const key = String(keys[0])
const findOrg = (data: any[]): any => {
for (const item of data) {
if (item.id === key) return item
@@ -236,11 +236,11 @@ function showAddOrgModal() {
orgModalVisible.value = true
}
function editOrg(id: string) {
function editOrg(_id: string) {
message.info('编辑功能开发中')
}
function deleteOrg(id: string) {
function deleteOrg(_id: string) {
message.success('部门已删除')
}
@@ -46,7 +46,7 @@
</p>
<a-tree
v-model:checkedKeys="selectedPermissions"
:tree-data="permissionTree"
:tree-data="(permissionTree as any)"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
checkable
@check="onPermissionChange"
@@ -69,7 +69,7 @@
<a-form-item label="权限">
<a-tree
v-model:checkedKeys="roleForm.permissions"
:tree-data="permissionTree"
:tree-data="(permissionTree as any)"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
checkable
/>
@@ -82,8 +82,9 @@
<script setup lang="ts">
import { ref, reactive } from '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: 'description', key: 'description', width: 150, ellipsis: true },
{ 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 -2
View File
@@ -1,14 +1,13 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
+12
View File
@@ -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'],
},
},
},
},
})
+9
View File
@@ -0,0 +1,9 @@
# 微信开发者工具个人设置(每个开发者机器各异)
project.private.config.json
# 小程序 npm 构建产物
miniprogram_npm/
node_modules/
# 系统垃圾
.DS_Store
-33
View File
@@ -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 }
-3237
View File
File diff suppressed because it is too large Load Diff