init: 智养餐饮平台 V3 原型工程初始化

This commit is contained in:
W10-0020\Administrator
2026-06-04 19:43:15 +08:00
commit 87fabc49c1
99 changed files with 13000 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
---
name: cross-role-doc-builder
description: 为一个模块(如 nutrition / health-checkup)生成完整的跨岗位协作文档包 — 含模块概述、页面清单、PRD 摘要、字段总览、接口契约、操作流程、测试关注点。比单页面的 gen-fields skill 粒度更大,输出物用于"模块级"对外交付。当主 Claude 接到"给营养模块出一份给后端的文档"、"整理一份测试参考"、"build cross-role docs for xxx" 时使用。
tools: Read, Glob, Grep, Write
---
# Agent: 跨岗位协作文档构建员
## 定位
针对**一个完整业务模块**,整合多个页面的信息,输出一份给后端 / 测试 / 移动端开发使用的**模块级文档**。
不只是单页字段清单,而是站在模块视角,回答:
1. 这个模块对外提供哪些功能?
2. 涉及哪些实体?字段是什么?
3. 哪些接口需要后端实现?签名是什么?
4. 业务流程怎么走?状态怎么流转?
5. 测试需要关注哪些边界?
## 何时调用
- 用户说"给 nutrition 模块出一份完整文档"、"给后端发个模块级开发文档"、"做一份测试参考"
- 模块迭代节点(如 Sprint 末尾)需要对外同步进度
- 跨团队接口对齐前,主动产出文档减少口头沟通
## 输入参数
1. **模块路径**(必须):如 `web-admin/nutrition`(相对 `src/pages/`
2. **目标受众**(必须):`backend` | `qa` | `mobile` | `all`all 输出综合版)
3. **是否包含截图**(可选):默认否;若是,调用浏览器自动化截屏所有页面
4. **PRD 引用**(可选):如 `docs/nutrition-prd.md`,含 PRD 内容时优先采纳
## 执行流程
### Step 1:扫描模块
使用 Glob`src/pages/<模块路径>/**/*`
收集:
- 全部 .vue 文件(页面)
- 全部 types/index.ts(实体)
- 全部 api/index.ts(接口)
- 全部 init/use*.ts(行为)
### Step 2:对每个页面跑 gen-fields skill 的逻辑
复用 gen-fields 的字段提取算法,但**不写入单页 fields.md**,而是把数据汇聚到内存中。
### Step 3:归纳模块级信息
#### 3.1 实体归并
将所有页面 types/index.ts 中的主实体接口收集,按"实体名"去重。
若同名实体在不同页面有不同字段(例如列表页只展示部分字段,详情页字段更全),合并字段并标注**字段在哪些页面出现**。
#### 3.2 接口清单
汇总所有页面 api/index.ts 中的接口,按"接口路径"分组:
| 接口 | 调用方页面 | 用途 |
|------|-----------|------|
| POST /nutrition/employee/page | arcEmployee | 员工列表分页 |
| POST /nutrition/employee/export | arcEmployee | 员工列表导出 |
| ... | ... | ... |
#### 3.3 业务流程图
从模块的菜单结构 + 页面跳转关系 + usePage.ts 中的 router.push 调用,自动推导**用户操作路径**:
```mermaid
flowchart LR
A[档案-员工营养数据 列表] -->|查看详情| B[员工详情页]
A -->|导出| C[导出任务抽屉]
B -->|编辑| D[编辑员工弹窗]
```
#### 3.4 状态流转图
若发现页面中存在状态字段(如 status / orderState),生成状态机:
```mermaid
stateDiagram-v2
[*] --> 待审核
待审核 --> 已通过: 审核通过
待审核 --> 已驳回: 审核驳回
已驳回 --> 待审核: 重新提交
```
### Step 4:按受众组装文档
**Backend 视角文档** `docs/<模块>/backend.md`
```markdown
# <模块中文名> · 后端接口文档
## 一、模块概述
(一段话 + 实体关系图)
## 二、实体定义(ER 视角)
- 实体 1:员工营养记录 EmployeeNutritionRecord
- 字段表(含类型、约束)
- 字段在哪些接口出现
## 三、接口清单
- 按接口路径排序,每个接口含:路径、方法、请求、响应、错误码
## 四、ApiResponse 约定
(统一响应包装格式说明)
## 五、字典编码
(页面用到的所有下拉选项 value,建议作为后端字典编码)
```
**QA 视角文档** `docs/<模块>/qa.md`
```markdown
# <模块中文名> · 测试参考文档
## 一、模块功能清单
- 列出每个页面的功能点
## 二、字段校验规则
- 每个搜索 / 编辑字段的:必填、长度、格式、特殊值
## 三、状态流转
(状态机图 + 每个状态的允许操作)
## 四、关键操作流程
(含用户路径与期望结果)
## 五、边界用例
- 空数据态
- 大数据量(分页、滚动)
- 列冻结 + 横向滚动一致性
- 网络异常态(loading / error / empty
## 六、跨页面联动
- A 页面操作后,B 页面是否同步?
```
**Mobile 视角文档** `docs/<模块>/mobile.md`
```markdown
# <模块中文名> · 移动端开发参考
## 一、对应移动端页面映射
- Web 端 列表页 ↔ 移动端 哪些页面
## 二、字段精简建议
- Web 端 14 列 → 移动端建议保留哪 5-6 个核心字段
## 三、操作差异
- Web 端"鼠标 hover"→ 移动端"长按"
- Web 端"批量勾选"→ 移动端建议改为"多选模式切换"
## 四、共享 API
(与 Web 端共用后端接口的清单)
```
### Step 5:写入文件
落地路径:
```
src/docs/<模块路径>/
├── backend.md
├── qa.md
├── mobile.md
└── overview.md # 综合版(all 时生成)
```
写入前检查已有文件,若存在询问"是否覆盖 / 增量更新"。
## 输出物
- 1-4 个 markdown 文件(按受众)
- 终端打印文件路径与摘要
## 工具约束
`Read, Glob, Grep, Write` — 仅 Write 创建文档文件,**不可** Edit 原型代码。
禁止使用:`Edit, NotebookEdit`
## 注意事项
- 文档必须基于实际代码生成,禁止凭空构造接口或字段
- 状态流转图必须能从代码或 PRD 中找到依据
- 接口路径与 api/index.ts 中的 `@接口路径` 注释保持完全一致
- 字段类型必须使用 TypeScript 字面量(与 types/index.ts 一致),不自行翻译为 Java 类型
- 若代码与 PRD 不一致,优先采用代码并在文档末尾"差异说明"中列出
- 生成完毕提醒用户:本文档基于原型源代码,若原型迭代请重新生成
+165
View File
@@ -0,0 +1,165 @@
---
name: prototype-reviewer
description: 综合评审原型 PR / 一次性提交的变更。同时跑规范检查、菜单同步、字段一致性、PRD 对齐度,给出一份分级评审报告。当主 Claude 接到"评审这次原型变更"、"prototype review"、"看下这个 PR 改的怎么样" 等任务时使用。
tools: Read, Glob, Grep, Bash
---
# Agent: 原型综合评审员
## 定位
不是写代码的 Agent,是**只读评审**型 Agent。综合 `prototype-lint` / `sync-nav` / `gen-fields` 三个 skill 的能力,外加跨文件一致性与业务合理性判断,给出一份可直接用于 PR 评论的报告。
## 何时调用
主 Claude 在以下场景应优先 spawn 本 Agent,而不是自己评审:
1. 用户说"评审这次变更"、"review 一下"、"看看代码质量"
2. 用户即将提交 PR 或 git commit,希望先自检
3. 一次性新增 / 修改了 3 个以上原型页面,担心整体一致性
4. 想知道"这次改动有没有影响其他模块"
## 输入
- **评审范围**(必须):
- `--diff` 模式:基于 `git diff` 评审本次未提交变更(默认)
- `--branch <name>` 模式:评审某个分支相对 master 的全部变更
- `--paths <glob>` 模式:评审指定路径(如 `src/pages/web-admin/nutrition/**`
- **PRD 对照文件**(可选):如 `docs/nutrition-prd.md`,用于检查字段是否与 PRD 一致
## 评审维度
### 1. 规范符合度(参考 prototype-lint 全部检查项)
- 目录结构、模板层、init 文件、types、api、公共组件使用
### 2. 菜单一致性(参考 sync-nav
- 孤儿页面 / 死引用 / 命名不规范
### 3. 字段一致性
- types/index.ts 主实体字段 ↔ useTable.ts columns.dataIndex ↔ useSearch.ts filterFields.name 三者对齐
- api/index.ts 接口参数类型 ↔ types/index.ts Params 类型
### 4. 跨模块影响
- 本次变更是否动了 `src/components/`(公共组件)→ 列出受影响的页面
- 本次变更是否动了 `src/meta/nav.ts` → 列出菜单结构变化
- 本次变更是否动了 `src/layouts/` → 列出布局变化(影响所有页面)
### 5. PRD 对齐度(若提供 PRD
- 字段名 / 中文标题 / 必填规则 / 默认值 是否与 PRD 表述一致
- 操作按钮(导出、批量操作等)是否覆盖 PRD 描述的全部操作
### 6. 经验性提示
- 列宽合计是否过宽(建议横向滚动而非压缩列宽 < 60px)
- 是否有列没有声明 `width`(会自动等分,可能不符合 PRD
- 是否有 `fixed: 'right'` 操作列但宽度 < 90px(按钮易换行)
- 搜索字段超过 6 个时建议折叠(用户体验提示)
## 执行流程
### Step 1:确定变更范围
```bash
# 默认 diff 模式
git diff --name-only HEAD
# branch 模式
git diff --name-only master..HEAD
# paths 模式:直接 Glob
```
输出变更文件列表。
### Step 2:分类变更文件
| 文件类型 | 说明 |
|---------|------|
| 页面新增 | `src/pages/<新目录>/*` 全部新文件 |
| 页面修改 | 已有页面目录内的文件 |
| 公共变更 | `src/components/ | src/layouts/ | src/meta/nav.ts` |
| 配置变更 | `vite.config.ts / tsconfig.json / package.json` |
### Step 3:按文件类型分别评审
- **页面新增**:跑全部 lint 检查项 + 字段一致性 + PRD 对照
- **页面修改**:增量评审改动行,特别关注是否新增了反模式
- **公共变更**:分析爆炸半径,列出受影响的所有页面(Grep 引用关系)
- **配置变更**:单独 highlight,建议人工审核
### Step 4:汇总报告
```markdown
# 原型评审报告(PR / 当前未提交变更)
> 评审时间:YYYY-MM-DD HH:mm
> 变更范围:6 个文件(新增 2 个页面 + 改了 1 个公共组件 + 改了 nav.ts
## 📊 总览评分
| 维度 | 评分 | 说明 |
|------|------|------|
| 规范符合度 | 🟢 4/5 | 仅 1 处 any 类型 |
| 菜单一致性 | 🟢 5/5 | 无孤儿 / 死引用 |
| 字段一致性 | 🟡 3/5 | 1 处 columns 字段在 types 中未声明 |
| 跨模块影响 | 🔴 需关注 | 修改了 StatCard.vue,影响 8 个页面 |
| PRD 对齐 | 🟢 5/5 | 字段命名与 PRD 一致 |
## 🔴 阻塞项(必须修复后再合并)
1. **公共组件 StatCard.vue 改动**src/components/StatCard.vue
- 修改了 `color` prop 的可选值,移除了 `red`
- 影响:以下页面使用了 `color="red"` 会显示异常
- pages/web-admin/health-monitor/exception/exception.vue:23
- pages/web-admin/emergency-dispatch/serious-illness/serious-illness.vue:45
- **建议**:保留 `red`,或同步修改受影响页面
## 🟡 改进项(建议修复,不阻塞)
1. **字段不一致**arcUnit 的 columns 中 dataIndex='manager' 在 EmployeeUnitRecord 中未声明
2. **缺类型注解**farmHarvest/init/useTable.ts:8 的 columns 缺少 TableColumnsType 注解
...
## 🟢 提示(仅供参考)
1. 列宽合计 1640px,建议横向滚动
2. 搜索字段 8 个,建议增加"高级筛选"折叠按钮
## ✅ 已通过
- 所有新增页面均符合 init 三件套结构
- 所有页面均已注册到 nav.ts
- 类型定义集中在 types/index.ts,无散落
```
### Step 5:交付给主 Claude
不直接修改任何文件,只输出报告。主 Claude 收到后可选择:
- 把报告作为最终答复返回用户
- 询问用户"是否一键修复 N 个 warning"
- 主动调用 prototype-lint 自动修复白名单内的项
## 输出物
完整 Markdown 评审报告(建议主 Claude 直接呈现给用户)。
## 工具约束
只读:`Read / Glob / Grep / Bash (only for git diff)`
禁止使用:`Edit / Write / NotebookEdit`
理由:评审 Agent 不应直接改代码。若发现需修复的问题,由主 Claude 决定如何应对。
## 注意事项
- 评审 Agent 应**穷尽全部维度**,不要因为某个维度 OK 就跳过其他维度
- 报告要分级:阻塞 / 改进 / 提示 三档,便于决策
- 跨模块影响分析必须使用 Grep 实际查找引用,禁止凭空判断
- 不要给出主观的"代码风格建议"(如变量命名口味);只对**有客观依据的规范**给意见
+23
View File
@@ -0,0 +1,23 @@
# 记忆索引
> **单一基准**:此目录是唯一权威来源,其他位置的记忆以此为准同步。
> _Last synced: 2026-06-04 | 阶段 16 完成(智养餐饮平台V3 业务清单 + Home.vue 视觉重做)_
| 文件 | 描述 | 类型 | 更新日期 |
|------|------|------|----------|
| project_overview.md | 项目技术栈、四端架构、与正式工程关系 | project | 2026-06-04 |
| project_progress.md | 1-15 阶段完成情况、当前状态、下一步 | project | 2026-06-04 |
| decisions.md | 关键决策:Vue 工程化、菜单单源、init 三件套、四端架构、门禁、可见性、PRD、总览 | project | 2026-06-04 |
| feedback.md | 协作规范、用户偏好、已避免的反模式 | feedback | 2026-06-04 |
| user_profile.md | 用户画像(产品经理 + AI 协作) | user | 2026-06-04 |
## 必读优先级
1. `decisions.md` — 避免重新讨论已决事项
2. `feedback.md` — 避免重犯已纠正的错误
3. `project_progress.md` — 避免重复已完成工作
## 按需加载
- `project_overview.md` — 涉及架构 / 技术栈讨论时
- `user_profile.md` — 个性化回复时
+258
View File
@@ -0,0 +1,258 @@
---
name: 关键决策与架构约定
description: 记录本原型工程的核心决策,避免跨会话重新讨论
metadata:
type: project
last_updated: 2026-06-04
---
## 1. 采用 Vue 工程化原型(替代原 HTML 自包含方案)
**结论**:原型工程采用 Vue 3 + Vite + Ant Design Vue + Vant,是真正的 SPA 工程。
**Why**:用户 2026-06-03 明确撤销了墨刀约束,技术栈与正式工程 `platform-vue-tenant` 对齐,让 AI 还原代码近乎零创造性。
---
## 2. 四个终端架构
**结论**
| 终端 | key | UI 库 | 主要用户 |
|------|-----|-------|---------|
| 综合管理后台 | `admin-portal` | Ant Design Vue | 平台管理员 |
| 租户运营后台 | `tenant-portal` | Ant Design Vue | 租户运营 |
| C 端微信小程序 | `miniprogram` | Vant 4375×812 | 终端用户 |
| 硬件终端屏 | `hardware` | 自定义动态 viewport | 现场操作 |
**Why**:用户 2026-06-04 明确"新项目中有四个终端"。两个后台都用 AntDV 但业务边界不同(平台层 vs 业务层)。硬件含多种设备(净菜柜屏 / 大屏 / PAD),每种 viewport 不一致 → 由 `NavHardwareDevice.viewport` 字段独立声明,HardwareLayout 动态渲染。
---
## 3. 菜单数据单一来源(src/meta/nav.ts
**结论**`adminPortalModules / tenantPortalModules / miniprogramTabs / hardwareDevices` 四个数组是所有路由 + 菜单的唯一数据源。
**Why**:菜单变更只改一处,路由自动派生,所有 Layout 自动重渲。
---
## 4. 列表页固定为"init 三件套"6 文件结构
**结论**`<页面>.vue + types + api + init/(usePage + useSearch + useTable)`
**Why**:与正式工程目录结构 100% 同构,AI 还原成本接近零。
---
## 5. Mock 数据直接写在 useTable.ts
**结论**:原型 mock 数据写 `init/useTable.ts` 的 dataList 数组,**不引入 MockJS**。
---
## 6. 不引入 Pinia
**结论**:原型阶段不引入 Pinia 等全局状态管理。
---
## 7. 双密码门禁 + 7 天有效期(阶段 11)
**结论**
- 部署后访问需密码。两套密码:`designer``dev`(在 `.env` 中配置)
- 登录后写入 localStorage**7 天 TTL**,过期自动跳回门禁页
- 路由守卫强制:未登录 → `/gate`;已登录 → 放行
- `/gate` 页面**简洁纯白居中**风格
**Why**:用户 2026-06-04 明确"密码要有过期机制,7 天后重新登录"、"需要做两套密码"、"简洁纯白居中"。
---
## 8. NavLeaf 加 status + audience,可见性过滤(阶段 10
**结论**:每个 `NavLeaf` 可选字段:
- `status?: 'draft' | 'review' | 'ready'`(默认 ready
- `audience?: 'designer' | 'all'`(默认 all
研发角色(dev)看不到 `status='draft'``audience='designer'` 的页面。
设计师角色(designer)看到全部。
`useVisibility` composable 提供过滤函数;Sidebar / Home / Miniprogram / Hardware Layouts 都自动应用。
---
## 9. 设计完成度控制台(阶段 12,纯展示)
**结论**`/__console` 路由,仅 designer 可访问。
- 列出全部叶子页面 + status + audience + 当前对 dev 是否可见
- 支持单页面覆写(存 localStorage `visibility.overrides`
- **不修改 nav.ts 源代码**(用户 2026-06-04 选 A:纯展示控制台手动同步)
- 支持"清除单行覆写"和"重置全部覆写"
---
## 10. PRD 在线查看(阶段 13
**结论**
- 路由 `/__prd`,所有登录角色都可访问
- 自动收集 `src/prd/**/*.md` 作为目录树
- markdown-it 渲染,含表格 / 代码 / blockquote / 自动图片解析
- 截图存在 `src/prd/<端>/<模块>/screenshots/<页面>/<状态>.jpg`
---
## 11. 截图工具优先级与体积限制(阶段 14)
**结论**
| 类别 | 视口 | DPR | 体积上限 |
|------|------|-----|---------|
| Web 后台 | 1440 × 900 | 1 | ≤ 60KB |
| 小程序 | 375 × 812 | 2 | ≤ 60KB |
| 硬件 | 按设备 viewport | 1 | ≤ 80KB |
- **优先 puppeteer**;失真或尺寸不对时降级 chrome-devtools MCP
- 输出 jpeg q=85;超限时降 quality 或缩尺寸
**Why**:用户 2026-06-04 明确"优先使用 puppeteerpuppeteer 截图失真或者尺寸不对时再用 chrome-devtools MCP"。
---
## 12. 设计总览仅小程序 / 硬件(阶段 14 + 阶段 17 重构)
**结论**
- 路由 `/__overview` 是**截图浏览器**:把 PRD 目录中的所有截图(含弹窗、状态变体)平铺到一页,给 UI 设计岗评审
- 数据源:`src/prd/<端>/<模块>/screenshots/<页面>/<状态>.{jpg,jpeg,png}`,用 `import.meta.glob` 自动收集
- 命名解析:
- `default.*` → 默认状态(无标签)
- `modal-*` / `dialog-*` / `popup-*`**弹窗**(橙色 tag--modal
- `state-*` / `tab-*` / `empty` / `loading` / `error` / `expanded` / `collapsed`**状态变体**(蓝色 tag--state
- 视觉风格完全参考原 `platform-prototype/_templates/design-overview-template.html`:暗色背景 + sticky 顶部导航 + 横向滚动卡片 + 灯箱放大 + 模块锚点
- 仅小程序、硬件,**不做后台**(用 PRD 替代)
**Why**:用户 2026-06-04 明确"设计总览只做小程序和硬件";阶段 17 进一步澄清"使用 PRD 文档的截图平铺到一个页面中,截图要包含所有的页面、弹窗、同页面的不同形态"。
**How to apply**
- `/design-overview` skill 负责"截图 + 命名 + 落地到 PRD 目录"
- `/__overview` 页面负责"读取 + 分组 + 灯箱"
- 体积上限:小程序 ≤ 60KB、硬件 ≤ 80KB;优先 puppeteer 截图,失真时降级 chrome-devtools MCP
---
## 13. 业务架构文档(src/docs/business-architecture.md
**结论**:在项目根目录建一份业务架构文档,含四端定位、用户角色映射、各端模块清单、跨端协作关系。
**Why**:用户 2026-06-04 提到"还需要在项目目录下新建一个业务架构的描述文档,方便后期在生成原型时 AI 对目录规划和业务理解"。AI 在 new-page / gen-prd / cross-role-doc-builder 等任务前必读。
---
## 14. dev server 端口 5180
避开正式工程 7000-7009 端口。
---
## 15. 主题色 #1890FF
与正式工程、原 HTML 原型一致。
---
## 16. 工具路由命名约定(带 `__` 前缀)
**结论**:所有"非业务"工具路由用 `__` 前缀,便于人眼区分、防止与业务模块 key 冲突。
清单:
- `/gate` — 门禁页(特殊:唯一无 `__` 前缀且 `meta.public: true`
- `/__console` — 设计完成度控制台
- `/__prd` — PRD 查看
- `/__overview` — 设计总览
---
## 17. 公共组件清单(阶段三沉淀)
- `StatCard` — 统计卡(4 色配色)
- `FilterBar` — 配置驱动搜索栏
- `TableCard` — 表格卡(透传 a-table 全部 slots
- `PageHeader` — 页头
- `RoleBadge` — 顶部角色徽章(layouts/components 下)
---
## 18. 项目专属 skills / agents 清单
| 类型 | 名称 | 用途 |
|------|------|------|
| skill | new-page | 新建原型页面脚手架 |
| skill | gen-fields | 生成单页字段清单 |
| skill | gen-prd | 生成 PRD 文档 + 截图 |
| skill | design-overview | 设计总览(仅小程序/硬件) |
| skill | sync-nav | 菜单同步检查 |
| skill | prototype-lint | 代码规范 + 截图体积检查 |
| agent | prototype-reviewer | PR / 变更综合评审(只读) |
| agent | cross-role-doc-builder | 模块级跨岗位文档包 |
---
## 19. 项目名:智养餐饮平台V3(阶段 16)
**结论**:项目正式名称为"智养餐饮平台V3",原"健康CQ升级"是早期占位。所有 UI 文案与文档统一使用新名称。
**Why**:用户 2026-06-04 提供。
**How to apply**CLAUDE.md / Home 标题 / Gate 标题 / 记忆文件均使用"智养餐饮平台V3"。
---
## 20. 业务清单填充(阶段 16)
**结论**:用户给出四端粗粒度功能清单:
- **综合管理后台**:系统设置、运营中心、设备中心
- **租户运营后台**(18 模块):租户首页、控制台、系统管理、营养管理、健康体检、体重管理、运动管理、健康监测、健康评估、知识普及、健康数据、健康档案、心脑血管病预防、糖尿病预防、癌症预防、专家咨询、应急就医、一线医疗
- **微信小程序**(4 Tab):吃、问、做、我
- **硬件终端屏**(10 类设备):智能货柜、档口机、入库秤、营养秤、吐盘机、结算台、配比秤、带鱼屏、统计大屏、炒菜机器人
均以 `status: 'draft'` 占位接入 nav.ts;营养管理保留 arcEmployee 样板为 ready。
**How to apply**
- 每个模块的具体二级菜单由用户规划后用 `/new-page` skill 生成
- 业务架构文档 `src/docs/business-architecture.md` 已同步此清单
- 占位页文件实际存在(不是简单的通用占位组件),便于将来直接替换内容
---
## 21. Home.vue 视觉:参考原 platform-prototype/index.html(阶段 16
**结论**Home.vue 视觉效果**完全参考原 HTML 原型项目的 index.html**
- 顶部渐变 Header(紫蓝 → 蓝 → 浅蓝)
- Tab 嵌入 Header 底部(圆角胶囊式 + 玻璃态 + 计数徽章)
- 模块卡片网格:左名称 + 右状态徽章 + 底部 PRD 链接 + 页数徽章
- 卡片状态色:ready 绿色呼吸灯 / wip 黄色 / empty 灰色虚线圆形水印
- 右上角悬浮:📄 PRD / 🖼 总览 / 🎛 控制台 / 角色徽章
**Why**:用户 2026-06-04 明确"整个原型项目的引导页的布局我希望你完全参考之前的原型项目的 index 页面的布局重新设计"。
**How to apply**:禁止把首页改成 a-tabs 等 AntDV 通用风格;保持渐变 + 胶囊 Tab + 卡片视觉。
---
## 22. RoleBadge 控制台独立按钮(阶段 16)
**结论**:设计模式下,控制台入口从下拉菜单升级为顶部独立按钮 `🎛 控制台`(橙色高亮);下拉菜单仅保留"退出登录"。
**Why**:用户 2026-06-04 明确"设计模式下右上角比之前多个控制台"。
**How to apply**`RoleBadge.vue` 中通过 `v-if="role === 'designer'"` 渲染该按钮。研发模式下不可见。
+187
View File
@@ -0,0 +1,187 @@
---
name: 协作规范与用户纠正
description: 记录与用户协作的偏好、风格、已纠正的反模式
metadata:
type: feedback
last_updated: 2026-06-03
---
## 1. 简体中文优先
**规则**:所有与用户对话、所有代码注释一律使用简体中文。
**Why**:用户为中文母语,团队规范要求。
**How to apply**:永远不要用英文回复(除非涉及代码标识符);所有注释中文。
---
## 2. 分阶段执行 + 阶段汇报
**规则**:涉及多步骤任务时,先列阶段任务清单等用户确认,再分阶段执行,每个阶段完成后汇报、等待用户决定继续或验收。
**Why**:用户偏好掌握节奏;避免单次性输出过长内容难以验收。
**How to apply**
- 大任务用 TodoWrite 拆分跟踪
- 每个阶段完成后用"汇报 + 是否继续"的格式
- 用户说"全部跑完"等明确授权时,可以一口气完成,但仍要每阶段简短汇报
---
## 3. 设计先对齐,再写代码
**规则**:涉及抽象设计 / 架构调整 / 新模块时,先用文字 + Mermaid 图给出设计方案,等用户确认后再写代码。
**Why**:来自用户全局 CLAUDE.md 的强制要求。
**How to apply**
- 给方案优先用 Mermaid 流程图 / 类图 / 时序图
- 重大决策点用表格列出选项 + 推荐 + 权衡
- 决策项过多时用 ask_user_question 提问,不要让用户做长选择题
---
## 4. 代码风格
**规则**
- TypeScript 严格类型,禁止 `any`
- 注释只解释 WHY、不解释 WHAT
- 函数 / 变量名小驼峰;CSS 类名 BEM
- `<script setup lang="ts">` 是 .vue 文件的唯一形式
- 列表页强制 init 三件套拆分
**Why**:与正式工程对齐 + 团队规范。
**How to apply**:见 CLAUDE.md "关键约定" 章节。
---
## 5. 公共组件优先,不重复造轮子
**规则**:列表页必须使用 4 个公共组件(StatCard / FilterBar / TableCard / PageHeader);不允许自己写 div + CSS 模拟。
**Why**:阶段三沉淀。一致性 + 维护成本。
**How to apply**:发现公共组件不够用,**提议增强**而非在页面里 fork。
---
## 6. 用户对效率与还原度的极度敏感
**规则**:用户的核心诉求是"原型 → 正式工程"的转化效率与还原度。每次方案选择都要回答这一点。
**Why**:用户 2026-06-03 多次强调"AI 还原代码效率"、"半工程化"路线、"和 vue 开发相同的技术栈和 UI 组件库"。
**How to apply**
- 建议方案时明确指出还原度(如"95%+"、"复制粘贴即可"
- 设计样板时优先与正式工程目录结构同构
- 当面临"轻量 vs 工程化"的取舍,倾向工程化(用户已明确"还不如直接整一个工程化的 vue 项目")
---
## 7. 跨岗位协作物料独立沉淀
**规则**:原型代码主要给前端 + AI 用;跨岗位协作(后端 / 测试 / 移动端)通过单独的 `fields.md` 文档传递信息,而不是让别的岗位读原型代码。
**Why**:用户 2026-06-03 讨论得出的结论 — 不同岗位关注点不同,统一文档比"让他们读代码"更友好。
**How to apply**
-`/gen-fields` skill 为单页生成 fields.md
-`cross-role-doc-builder` agent 为模块生成 backend.md / qa.md / mobile.md
---
## 8. 不主动加文档 / 不主动写 README
**规则**:除非用户明确要求,不主动创建 README.md、CHANGELOG.md 等文档。
**Why**:来自全局 CLAUDE.md 默认行为约束。
**How to apply**:完成代码后口头说明 + 必要时更新 CLAUDE.md / memory,不创建零散文档。
---
## 9. 文件路径使用 Windows 绝对路径
**规则**:所有文件操作(Read / Write / Edit)使用完整 Windows 绝对路径,反斜杠或正斜杠都可(但保持一致)。
**Why**:用户全局 CLAUDE.md 中明确的工具兼容性要求。
**How to apply**
-`D:\Work\yx-platform-prototype\src\...`
-`D:/Work/yx-platform-prototype/src/...`
-`/c/Work/...``./src/...`
---
## 10. 不要急着进入"最优解"
**规则**:用户问"有没有更好的方式"时,先把每种方式的权衡说清楚,让用户基于自己情况决策,**不要立即推一个方案就开始写代码**。
**Why**:用户作为产品经理,更关心方案合理性而非快速产出。
**How to apply**:探索性问题("有没有什么思路")用 2-3 句话给方向 + 主要权衡 + 询问是否继续。
---
## 11. 阶段任务连续执行授权
**规则**:当用户说"全部跑完"、"先把后面的几个阶段都跑完"等明确授权时,可以一口气连续完成多阶段任务而不需每阶段都等用户回复。但**每阶段完成后仍要简短汇报**(一句话级别),并继续下一阶段。
**Why**:用户 2026-06-04 多次提"先把所有的阶段都跑完,自己检查验证",明示信任 AI 端到端执行。
**How to apply**
- 阶段开始:列出本阶段任务清单
- 阶段中:使用 TodoWrite 跟踪
- 阶段结束:简短汇报(不超 3 句)+ 立即进入下一阶段
- 全部完成后:做汇总总结 + 验收清单
---
## 12. 截图工具的优先级与降级策略
**规则**:截图优先 puppeteerpuppeteer 失真或尺寸不对时再用 chrome-devtools MCP。
**Why**:用户 2026-06-04 明确说"优先使用 puppeteerpuppeteer 截图失真或者尺寸不对时再用 chrome-devtools MCP,据我之前的观察 chrome-devtools MCP 截图效果可能会更好"。即 puppeteer 是默认选择,MCP 是质量备份方案。
**How to apply**
- 在 gen-prd / design-overview skill 文档中明确这一优先级
- 实际截图任务先用 puppeteer,输出后检查质量;不达标再降级 MCP
---
## 13. 截图体积控制(强制)
**规则**:截图体积严格控制:
- Web 后台 1440×900 @1x:≤ 60KB
- 小程序 375×812 @2x:≤ 60KB
- 硬件设备(按 viewport):≤ 80KB
- 拼图 PNG:≤ 500KB
**Why**:用户 2026-06-04 明确"一定要注意之前 prd 文档中的截图规范(控制尺寸和体积)"。原 platform-prototype 项目曾有此规范,迁移延续。
**How to apply**
- 截图后用 Bash check size`stat -c %s file`
- 超限:降 quality85→75);仍超用 sharp 二次压缩
- prototype-lint 会扫描整个 src/prd/**/screenshots 并报告超限文件
---
## 14. 业务架构文档作为 AI 必读
**规则**:所有 new-page / gen-prd / cross-role-doc-builder 等生成原型的任务,**必须先读 `src/docs/business-architecture.md`** 理解业务上下文,禁止跨端引用。
**Why**:用户 2026-06-04 明确"还需要在项目目录下新建一个业务架构的描述文档,方便后期在生成原型时 AI 对目录规划和业务理解"。
**How to apply**:每个 skill 描述里写明"必读 src/docs/business-architecture.md"。
+77
View File
@@ -0,0 +1,77 @@
---
name: 项目概览与技术栈
description: 本原型工程的技术栈、四端架构、与正式工程的关系
metadata:
type: project
last_updated: 2026-06-04
---
## 定位
`yx-platform-prototype` 是智养餐饮平台V3项目的**高保真交互原型工程**。
- **服务对象**:产品经理画原型 → AI 还原成正式 Vue 工程代码 + 跨岗位文档
- **核心 KPI**:原型 → 正式工程的"复制粘贴还原度"(目标 95%+)
## 四个终端
| 终端 | key | UI 库 | 业务定位 |
|------|-----|-------|---------|
| 综合管理后台 | admin-portal | AntDV | 平台层管理(跨租户) |
| 租户运营后台 | tenant-portal | AntDV | 单租户业务运营 |
| C 端微信小程序 | miniprogram | Vant 4 (375×812) | 终端用户自助 |
| 硬件终端屏 | hardware | 自定义动态 viewport | 现场操作 / 公共展示 |
## 技术栈
| 类别 | 选型 |
|------|------|
| 框架 | Vue 3.5 + TypeScript 5.9 |
| 构建 | Vite 8 |
| Web 端 UI | Ant Design Vue 4.2 |
| 移动端 UI | Vant 4 |
| 路由 | Vue Router 4Hash 模式) |
| 状态管理 | 不引入 Pinia |
| 样式 | LESS + CSS 变量(设计令牌) |
| 自动导入 | unplugin-auto-import + unplugin-vue-components |
| Markdown | markdown-itPRD 渲染) |
| 截图 | puppeteer(优先)/ chrome-devtools MCP(降级) |
## 关键架构特性
1. **菜单单源**`src/meta/nav.ts` 是路由 + 菜单的唯一数据源
2. **端隔离**:4 端不互相 import;共享通过 @components / @layouts / @meta / @composables
3. **六文件页面结构**:列表页固定 `<页面>.vue + types + api + init/(usePage + useSearch + useTable)`
4. **可见性模型**NavLeaf.status + audiencedev 看不到 draft / designer-only 页面
5. **门禁机制**:双密码 + 7 天 TTL,路由守卫强制
6. **工具路由**`/gate` / `/__console` / `/__prd` / `/__overview`
## 与正式工程的对照
正式工程 `D:\Work\platform-vue-tenant`Monorepo + Qiankun)。
| 维度 | 原型工程 | 正式工程 |
|------|---------|---------|
| 工程形态 | 单 SPA | Qiankun 微前端 + Monorepo |
| 状态管理 | 无 | Pinia + 持久化 |
| 接口 | 占位 | 真 HTTP + 拦截器 + 401/403 |
| 权限 | 角色门禁 + 可见性过滤 | 菜单权限 + 按钮权限 + 动态路由 |
| Mock 数据 | useTable.ts 写死 | 无 |
| 文件结构 | **完全一致** | **完全一致** |
| 技术栈 | **完全一致** | **完全一致** |
## 部署 / 分发
- 构建:`npm run build``dist/`
- 静态分发:dist/ 是纯静态文件,可部署到任何静态服务
- 门禁密码可在 `.env.production` 重新定义后构建
## 跨工程引用关系
```
yx-platform-prototype/
├─ src/pages/ 原型源
├─ src/prd/ PRD markdown + 截图
├─ src/docs/ 业务架构文档
└─ AI 还原 → platform-vue-tenant/
```
+75
View File
@@ -0,0 +1,75 @@
---
name: 阶段进度
description: 16 阶段重构完成情况、当前状态、下一步计划
metadata:
type: project
last_updated: 2026-06-04
---
## 总体进度
**16 阶段全部完成(2026-06-04**。原型工程从 0 到 1 搭建完毕 + 四端架构 + 门禁 + 控制台 + PRD + 总览 + 智养餐饮平台V3 业务清单与首页重做。可投入日常使用。
## 阶段一览
| 阶段 | 主题 | 状态 |
|------|------|------|
| 1 | 工程骨架(Vite + Vue 3 + TS | ✅ |
| 2 | 路由 + 菜单数据化 + 共享布局 | ✅ |
| 3 | 公共业务组件(StatCard / FilterBar / TableCard / PageHeader | ✅ |
| 4 | 标准列表页样板(arcEmployee 六文件结构) | ✅ |
| 5 | 导航首页(首版) | ✅ |
| 6 | 项目专属 4 skill + 2 agent(首版) | ✅ |
| 7 | CLAUDE.md + 记忆体系(首版) | ✅ |
| 8 | 四端架构重构:nav.ts + 3 个 Layout + 业务架构文档 | ✅ |
| 9 | Home.vue 改为四端 Tab | ✅ |
| 10 | NavLeaf 加 status/audience + useVisibility 过滤 | ✅ |
| 11 | 双密码门禁页 + 7 天过期 | ✅ |
| 12 | VisibilityConsole 设计完成度控制台 | ✅ |
| 13 | PRD 在线查看(PrdViewer.vue | ✅ |
| 14 | /gen-prd skill + /design-overview skill + DesignOverview.vue | ✅ |
| 15 | CLAUDE.md 详细目录说明 + lint 体积检查 + 记忆体系同步 | ✅ |
| 16 | 智养餐饮平台V3 业务清单填充 + Home.vue 视觉重做(参考原 index.html+ RoleBadge 控制台独立按钮 | ✅ |
| 17 | DesignOverview.vue 重做为"PRD 截图浏览器" + design-overview skill 重写(命名规范 / 弹窗与状态标签 / 灯箱) | ✅ |
## 当前状态
- **项目名**:智养餐饮平台V3(原"健康CQ升级"已替换为正式名称)
- 工程可启动:`npm run dev` → http://localhost:5180
- 门禁密码:`design2026` / `dev2026`
- **nav.ts 业务清单完成度**
- 综合管理后台:3 个一级模块(系统设置 / 运营中心 / 设备中心),各 1 个 draft 占位页
- 租户运营后台:18 个一级模块,营养管理已有样板(arcEmployee ready + 2 个 draft),其余 17 个均 draft 占位
- 微信小程序:4 个底部 Tab(吃 / 问 / 做 / 我),均 draft 占位
- 硬件终端屏:10 类设备(智能货柜 / 档口机 / 入库秤 / 营养秤 / 吐盘机 / 结算台 / 配比秤 / 带鱼屏 / 统计大屏 / 炒菜机器人),每类独立 viewport
- **首页视觉**:完全参考原 platform-prototype/index.html — 渐变 header + 嵌入式 Tab 胶囊 + 卡片网格(状态徽章/PRD/页数)+ ready 绿色呼吸灯 / wip 黄色 / empty 灰色虚线
- **右上角入口**:📄 PRD / 🖼 总览 / 🎛 控制台(仅 designer 可见独立按钮)/ 角色徽章下拉(退出登录)
## 已验证通过的端到端流程
1. 未登录 → 自动跳 `/gate`
2. 输入密码 → 设置角色 + 7 天 TTL → 跳到原目标
3. 顶部角色徽章显示"设计模式 · 7 天" + 下拉菜单(打开控制台 / 退出)
4. 四端 Tab 切换正常(admin / tenant / 小程序 / 硬件)
5. 控制台显示全部叶子页面,可单页面切换 status / audience
6. PRD 文档树自动加载,markdown-it 渲染正常
7. 设计总览 iframe 平铺渲染(小程序 2 个手机壳并排)
8. dev / build 全链路无报错
## 已知遗留事项
1. **生产 chunk 偏大**arcEmployee 单页 chunk ~670KBAntDV 4 ESM 未按需拆分)。大量页面后做 manualChunks 优化
2. **小程序 iframe 总览仍显示底部"返回首页"链接**:可后续增强 `?_ov=1` 模式隐藏布局外壳
3. **PRD 截图工具未自动集成**`gen-prd` skill 说明了 puppeteer 模板,但实际截图需要主 Claude 调用工具实现
4. **PortalLayout 没有 designerOnly 路由侧边栏隐藏样式**:从 dev 视角看 `/__console` 入口被守卫拦下(已用 `v-if="role === 'designer'"` 在 RoleBadge 处理),但若 dev 直接输 URL 会被 router 守卫重定向回 `/`
## 下一步建议
按优先级:
1. **填写 `src/docs/business-architecture.md`** — 用户填充四端模块清单,AI 在生成原型时会先读
2. **画一两个真实业务页面** — 试用 `/new-page` skill 验证体验
3. **跑一次 `/gen-prd`** — 验证给后端的物料质量与截图体积控制
4. **试用 `/__overview`** — 走通设计总览给 UI 岗评审的流程
5. **存量原 HTML 原型按需迁移** — 不强求一次性
6. **chunk 优化**(积累 20+ 页面后做)
+58
View File
@@ -0,0 +1,58 @@
---
name: 用户画像
description: 用户角色、协作风格、技能背景
metadata:
type: user
last_updated: 2026-06-03
---
## 角色
**产品经理**(软件公司)
## 工作流
- 通过 Claude Code 画原型 → AI 还原代码 → Vue 开发参考实现
- 经历过纯 HTML 原型阶段,已意识到"原型与正式工程脱节"的痛点
- 2026-06-03 主导了原型工程从 HTML 迁移到 Vue 工程化的决策
## 技术背景
- **熟练**:原型设计、PRD 编写、业务建模、需求拆解
- **了解**:基本前端概念(Vue / 组件 / 路由)、npm 命令
- **不熟练**TypeScript 深度类型、Vite 内部机制、复杂工程化配置
- **不需要**:手写代码(依靠 AI 完成)
**协作策略**:写代码这件事完全交给 AI;用户负责设计决策、验收、走通日常使用流程。文档要避免"写完即过期"的细节,应聚焦"为什么这么做、怎么用"。
## 沟通偏好
- 简体中文
- 简洁、有结构(表格 / 列表)
- 重要决策要列表对比 + 推荐 + 权衡
- 不喜欢长篇大论的解释;喜欢"先方案后实施"的节奏
- 不喜欢被推到"二选一陷阱";喜欢"建议 X,但你可以选 Y/Z"
## 关键工作场景
1. **画新原型**:会用 `/new-page` skill 快速生成骨架
2. **改原型字段**:直接改 useTable.ts 的 columns 与 dataList
3. **跨岗位同步**:用 `/gen-fields``cross-role-doc-builder` 生成文档分享
4. **PR 自检**:用 `prototype-reviewer` agent 做综合评审
5. **AI 还原代码**:把原型整个目录提供给前端开发的 AI
## 工作时间习惯
- 2026-06-03 16:00 左右说"快要下班了,明天再验收"
- 推测工作时间:常规上班时间(9-18 / 10-19
## 协作中的明确表达
用户在本次会话(2026-06-03)的重要表达:
- "我感觉搞成这种了还不如直接整一个工程化的 vue 项目"(否定了 sfc-loader 中间方案)
- "如果我直接将原型的绘制方式从之前的 HTML 迁移至 vue 框架,对于服务端(java)或者是测试岗,移动端有没有什么影响"(关注跨岗位)
- "技术选型上是否考虑今天提到的原型其实可以走半工程化路线(不是强制,需要你给出最优解)"(鼓励 AI 给最优解、不必拘泥用户表面提议)
- "你先全部把后面的几个阶段跑完吧,我快要下班了"(信任 AI 端到端完成执行)
**判断依据**:用户**信任 AI 的工程判断**,但需要在关键决策点(选型、阶段拆分、目录结构)有清晰对齐。
+200
View File
@@ -0,0 +1,200 @@
---
name: design-overview
description: 把一个端 / 模块所有页面 + 弹窗 + 状态变体截图平铺到一个页面,交付给 UI 设计岗评审。截图来自 PRD 目录 src/prd/**/screenshots,按"端 → 模块 → 页面 → 状态"4 层组织,弹窗与状态变体分别用橙色 / 蓝色标签区分。在线查看页 /__overview 自动渲染。当用户说"生成 xx 的设计总览"、"截一组截图给 UI"、"design-overview xx" 时触发。
---
# Skill: 页面设计总览
## 用途定位
**交付物**:给 UI 设计岗评审用的"一页式截图墙",包含一个模块(或一个端)下:
- 所有页面的默认状态
- 每个页面的全部弹窗(modal / dialog / popup
- 每个页面的状态变体(empty / loading / error / tab 切换 / 展开收起 …)
UI 岗一眼能看完整模块视觉,不用切换页面。
> 与 PRD 文档的区别:PRD 关注字段/接口/操作;设计总览只关注**截图视觉**。两者共享同一 `screenshots/` 目录。
## 触发场景
- 模块原型画完,需要给 UI 评审
- 调整了视觉风格,要重新出一份总览给 UI 复检
- 弹窗 / 状态变体多,散落在 PRD 各段不便整体看
## 输入参数
1. **端**(必须):`miniprogram``hardware`(**仅这两个端**;Web 后台用 PRD 查看代替,详见决策 22)
2. **模块**(可选):如 `eat`(小程序)或 `smart-cabinet`(硬件);不传 = 整个端
3. **截图状态清单**(必须):要包含的状态,至少有 `default`,其余按页面定(如 `search-active` / `modal-confirm` / `empty`
## 执行流程
### Step 1:读业务架构与现有 PRD
必读:
- `src/docs/business-architecture.md`(理解模块在端中的位置)
- `src/prd/<端>/<模块>/*.md`(已有页面清单与截图引用)
确认要补截图的页面集合。
### Step 2:截图(核心)
**目标路径统一**`src/prd/<端>/<模块>/screenshots/<页面 path>/<状态>.jpg`
例如:
```
src/prd/miniprogram/eat/screenshots/home/default.jpg
src/prd/miniprogram/eat/screenshots/home/modal-confirm.jpg
src/prd/miniprogram/eat/screenshots/home/empty.jpg
src/prd/miniprogram/eat/screenshots/order/default.jpg
src/prd/miniprogram/eat/screenshots/order/state-paid.jpg
```
#### 视口规范(必须严格执行)
| 端 | 视口 | DPR | 输出 | 体积上限 |
|----|------|-----|------|---------|
| miniprogram | 375 × 812 | 2 | jpeg q=85 | **≤ 60KB** |
| hardware(按设备) | 见 `nav.ts``viewport` 字段 | 1 | jpeg q=80 | **≤ 80KB** |
#### 工具优先级
1. **puppeteer**(首选)—— 输出质量稳定
2. **chrome-devtools MCP**(降级)—— puppeteer 失真或尺寸不对时使用
#### puppeteer 截图模板
```js
import puppeteer from 'puppeteer'
const browser = await puppeteer.launch({ headless: 'new' })
const page = await browser.newPage()
// 小程序:iPhone X 规格 @2x
await page.setViewport({ width: 375, height: 812, deviceScaleFactor: 2 })
await page.goto('http://localhost:5180/#/miniprogram/eat', { waitUntil: 'networkidle0' })
// 等待小程序壳渲染完毕
await page.waitForSelector('.phone-shell__content > *')
// 仅截"内容区"(不截整个手机壳)
const content = await page.$('.phone-shell__content')
await content.screenshot({
path: 'src/prd/miniprogram/eat/screenshots/home/default.jpg',
type: 'jpeg',
quality: 85,
})
// 触发弹窗状态再截
await page.click('[data-test="confirm-btn"]')
await page.waitForSelector('.modal-confirm')
await content.screenshot({
path: 'src/prd/miniprogram/eat/screenshots/home/modal-confirm.jpg',
type: 'jpeg',
quality: 85,
})
await browser.close()
```
#### chrome-devtools MCP 降级路径
仅当 puppeteer 输出尺寸不对 / 文字模糊时切换:
```
1. mcp__chrome-devtools__resize_page → width=375 height=812
2. mcp__chrome-devtools__navigate_page → URL
3. mcp__chrome-devtools__wait_for / click → 触发状态
4. mcp__chrome-devtools__take_screenshot → format=jpeg quality=85 filePath=...
```
#### 体积控制
每张截图保存后立即检查:
```bash
stat -c %s src/prd/.../<状态>.jpg
```
- 超 60KB(小程序)/ 80KB(硬件)→ 降 quality (85→75) 重截
- 仍超 → `npx sharp -i in.jpg -o out.jpg --quality 70`
- 仍超 → 检查是否长截图溢出(应只截视口)
### Step 3:命名规范(严格)
文件名 = **状态 key**,决定视觉分类标签:
| 文件名前缀 / 关键词 | 类型 | 总览页标签颜色 |
|--------------------|------|---------------|
| `default.jpg` / 模块入口 | 默认状态 | 无标签 |
| `modal-<名>.jpg` / `dialog-<名>.jpg` / `popup-<名>.jpg` | 弹窗 | **橙色 tag--modal** |
| `state-<名>.jpg` / `tab-<名>.jpg` / `empty.jpg` / `loading.jpg` / `error.jpg` / `expanded.jpg` / `collapsed.jpg` | 状态变体 | **蓝色 tag--state** |
| 其他自定义 | 其他变体 | 蓝色 tag--state |
总览页 `/__overview` 自动按命名解析 + 渲染标签,**不需要额外配置文件**。
### Step 4:在线查看
完成截图后,访问:
```
http://localhost:5180/#/__overview?portal=<端>&module=<模块>
```
如:
```
http://localhost:5180/#/__overview?portal=miniprogram&module=eat
```
页面布局参考原 platform-prototype/_templates/design-overview-template.html
- 暗色背景 `#1a1a2e`
- 顶部 sticky 导航 + 模块锚点
- 按"页面"分小组,每组横向滚动卡片
- 每张卡 260×564(小程序)/ 比例缩略(硬件)
- 弹窗橙标、状态变体蓝标
- 点击卡片灯箱放大
### Step 5:检查清单
交付前自检:
- [ ] 模块下每个页面**至少**有 `default.jpg`
- [ ] 重要弹窗都有截图,命名 `modal-<名>.jpg`
- [ ] 关键状态变体覆盖完整(空数据 / 加载 / 错误 / 主要 tab)
- [ ] 单图体积全部达标
- [ ] 总览页 `/__overview?portal=...&module=...` 渲染正常,无缺图占位
### Step 6:交付提示
打印:
- 截图清单(路径 + 体积)
- 总图数 / 弹窗数 / 状态变体数
- 在线查看 URL
## 输出物
- `src/prd/<端>/<模块>/screenshots/<页面 path>/<状态>.jpg` (多张)
- 终端打印检查清单
## 禁止事项
- **禁止给 Web 后台**admin / tenant)生成设计总览(用 PRD 替代)
- 禁止超过 60KB / 80KB 体积上限
- 禁止把截图存到 `src/prd/<端>/<模块>/screenshots/` 之外
- 禁止用截图脚本控制设备屏幕外尺寸(必须严格按 viewport)
- 禁止跨模块共享同一截图(每个模块独立 screenshots 目录)
## 注意事项
- 截图前确保 dev server 运行,且**已登录 designer 模式**(否则部分 draft 页被过滤)
- 弹窗截图时点击触发后用 `waitForSelector` 等真实弹窗 DOM 出现,避免拍到过渡态
- 硬件设备视口往往超大(1080×1920 / 1920×1080 / 3840×1080),puppeteer 必须设对应 viewport
- 截图前后**不要修改**布局组件(Layout / phone-shell),否则边距错位
- 总览页是"静态截图浏览器",不会调起业务页面 — 截图缺失就是缺失,必须先截
+180
View File
@@ -0,0 +1,180 @@
---
name: gen-fields
description: 扫描指定原型页面的 useTable.ts 与 useSearch.ts,反向提取字段语义,生成跨岗位协作用的 fields.md 字段清单(含表格列字段、搜索字段、接口契约、操作流程),供后端 / 测试 / 移动端开发参考。当用户说"生成字段清单"、"gen-fields xxx"、"给 xxx 出一份接口文档" 时触发。
---
# Skill: 反向生成跨岗位字段清单
## 触发场景
原型页面已经画完,需要把"字段语义"沉淀成结构化文档,给后端写接口、测试写用例、移动端开发参考。
## 输入参数
1. **目标页面路径**:相对 `src/pages/` 的完整路径,例如 `tenant-portal/nutrition/arc/arcEmployee`4 端 / NavApp / NavSection / 页面)
2. **是否覆盖已有 fields.md**:默认 `false`,发现冲突时询问用户
## 执行流程
### Step 1:定位页面文件
读取以下文件:
- `src/pages/<路径>/<页面key>.vue` — 模板层,提取页面标题、子组件用法
- `src/pages/<路径>/types/index.ts` — 类型定义,提取实体接口(如 EmployeeNutritionRecord
- `src/pages/<路径>/api/index.ts` — 接口契约
- `src/pages/<路径>/init/useTable.ts` — 表格列
- `src/pages/<路径>/init/useSearch.ts` — 搜索字段
- `src/pages/<路径>/init/usePage.ts` — 页面级行为
任一文件缺失则提示用户并继续(部分页面如详情页可能没有 useTable)。
### Step 2:提取字段语义
**从 `useTable.ts` 的 columns 数组提取**
| 提取项 | 来源 |
|--------|------|
| 字段名 | `dataIndex` |
| 中文标题 | `title` |
| 列宽 | `width` |
| 是否冻结 | `fixed: 'left'/'right'` |
| 自定义渲染 | `key` 字段(在模板 bodyCell 中找规则) |
**从 `useSearch.ts` 的 filterFields 数组提取**
| 提取项 | 来源 |
|--------|------|
| 字段名 | `name` |
| 标签 | `label` |
| 控件类型 | `type` |
| 必填规则 | (从 placeholder 启发,原型阶段大多无强制规则) |
| 下拉选项 | `options`(从 useSearch.ts 同文件 unitOptions 等取) |
**从 `types/index.ts` 提取**
- 主实体 interface 的全部字段类型(含 TS 类型)
- 与 columns 对齐补充字段说明
**从 `api/index.ts` 提取**
- 接口路径、请求方法
- 请求 / 响应类型
### Step 3:生成 fields.md
写入路径:`src/docs/<portal>/<app>/<section 短名>/<页面key>.md`(镜像 pages 目录结构)
> 注意:新规范下列表页不再有 PageHeader,因此生成文档时**不要**从 vue 模板里抓取标题——以 nav.ts 中的 leaf.label 为准。
文档结构:
```markdown
# <页面中文名>
> 自动生成于 YYYY-MM-DD,对应原型路径 `src/pages/<路径>`
## 一、页面概览
- **业务定位**:<从 PRD / 页面标题推断的一段说明>
- **路由路径**`/<端>/<模块>/<路由>`
- **页面类型**:列表页 / 详情页 / 弹窗
## 二、接口契约
| 用途 | 方法 | 路径 | 请求参数 | 响应 |
|------|------|------|---------|------|
| 分页查询 | POST | /nutrition/employee/page | EmployeeListParams | EmployeeListResponse |
| 导出 | POST | /nutrition/employee/export | EmployeeListParams | Blob |
## 三、实体字段(对照后端 DTO)
| 字段名 | TS 类型 | 中文 | 来源接口 | 必填 | 备注 |
|--------|---------|------|---------|------|------|
| id | string | 主键 | page | 是 | row-key |
| name | string | 姓名 | page | 是 | 冻结左侧 |
| ... | ... | ... | ... | ... | ... |
## 四、表格列展示规则
| 列 | 列宽 | 冻结 | 自定义渲染 |
|----|------|------|-----------|
| 姓名 | 80 | 左 | - |
| 营养达标率 | 110 | - | ≥85 绿色 / ≥70 黄 / <70 红 |
| 操作 | 100 | 右 | 「查看详情」链接 |
## 五、搜索字段
| 字段 | 控件 | 选项来源 | placeholder |
|------|------|---------|-------------|
| 姓名/工号 | 文本框 | - | 请输入姓名或工号 |
| 所属单位 | 下拉 | unitOptions3 个值) | 全部 |
| ... | ... | ... | ... |
## 六、操作流程
> 自动从模板的 @click 与 usePage.ts 暴露的方法提取
- **查看详情**:调用 `viewDetail(record)`,期望跳转到 detail 路由(原型用 message 占位)
- **导出列表**:调用 `handleExport()`,期望调用 `exportEmployeeList` 接口下载 Blob
- **查看导出任务**:调用 `viewExportTasks()`,期望打开抽屉展示历史任务
## 七、测试关注点(供测试同事参考)
- 搜索字段重置后能否清空所有筛选条件
- 营养达标率 tag 颜色阈值(85 / 70)
- 表格横向滚动时左侧"姓名"与右侧"操作"列冻结正确
- 分页:首页禁用上一页;尾页禁用下一页;每页条数切换后回到第一页
## 八、后端接口期望(供 Java 开发参考)
接口路径:`POST /nutrition/employee/page`
请求体(JSON):
\`\`\`json
{
"current": 1,
"pageSize": 30,
"keyword": "张",
"unit": "hq",
"dept": "rd",
"year": 2026
}
\`\`\`
响应体(JSON,符合 ApiResponse 包装):
\`\`\`json
{
"code": "00000",
"msg": "操作成功",
"data": {
"records": [ ... ],
"total": 1280
}
}
\`\`\`
```
### Step 4:交付提示
- 打印生成的 fields.md 路径
- 提示用户可以把这份文档**分享给后端、测试、移动端**作为接口契约依据
- 提醒:如果原型 columns / filterFields 之后有变更,再次执行本 skill 会重新生成(除非配置 `--no-overwrite`
## 输出物
`src/docs/<portal>/<app>/<section 短名>/<页面key>.md`
## 禁止事项
- 禁止主动修改原型源代码(即使发现命名不规范,仅在文档末尾"备注"中说明)
- 禁止生成不在 useTable / useSearch 中体现的字段(避免凭空构造)
- 禁止把 mock 数据复制到 fields.md(只放结构和规则)
## 注意事项
- 生成的字段类型必须与 types/index.ts 保持一致
- 接口路径采用 `api/index.ts` 注释里声明的路径
- 若 useTable 中某列用了 `key` 而非 `dataIndex`(自定义渲染列),在"表格列展示规则"中说明渲染规则,不要凭空推断字段
+125
View File
@@ -0,0 +1,125 @@
---
name: gen-prd
description: 为指定原型页面生成 / 更新 PRD 文档(src/prd/<端>/<模块>/<页面>.md),并截取关键状态截图存入 screenshots/。截图严格控制尺寸(Web 1440×900 1x、移动 375×812 2x)与体积(≤ 60KB)。当用户说"生成 xx 的 PRD"、"截一组截图"、"gen-prd xx" 时触发。
---
# Skill: 生成 PRD 文档 + 截图
## 触发场景
- 完成一个页面原型后,需要把字段语义、操作流程、接口契约沉淀为可分发的 markdown
- 页面有较大改动,PRD 需重新生成
- 给后端 / 测试 / 移动端开发提交"对外可读文档 + 关键截图"
## 输入参数
1. **目标页面路径**(必须):相对 `src/pages/`,如 `tenant-portal/nutrition/arcEmployee`
2. **状态清单**(可选):要截图的状态,默认 `default`;常见还有 `search-active` / `empty` / `loading` / `error` / `<弹窗名>`
3. **覆盖策略**(可选):默认增量更新(保留"页面概述"等手工编辑部分),传 `--overwrite` 全量重写
## 执行流程
### Step 1:扫描页面源
读取以下文件并提取信息:
| 文件 | 提取项 |
|------|--------|
| `<页面>.vue` | 页面标题、组件结构、@click 行为 |
| `init/usePage.ts` | 页头信息、统计卡数据、详情跳转 |
| `init/useSearch.ts` | 搜索字段配置、下拉选项 |
| `init/useTable.ts` | 表格 columnsdataIndex / title / width / fixed |
| `types/index.ts` | 主实体接口 / 搜索参数 / 响应类型 |
| `api/index.ts` | 接口路径、方法、参数、返回 |
对照 `src/docs/business-architecture.md` 理解模块业务上下文,避免凭空生成。
### Step 2:生成 / 合并 markdown
落地路径:`src/prd/<端>/<模块>/<页面 path>.md`(与 `pages/` 路径同构,文件名使用路由 path 短横线版)
模板结构(与 `arc-employee.md` 一致):
1. 页面概述(保留旧版手工编辑内容)
2. 页面截图(自动写入截图引用)
3. 统计区(自动从 usePage.ts stats 提取)
4. 搜索区(自动从 useSearch.ts filterFields 提取)
5. 表格字段(自动从 useTable.ts columns + types 实体对齐)
6. 操作(自动从模板 @click 与 usePage.ts 暴露的方法提取)
7. 接口契约(自动从 api/index.ts 注释提取)
8. 状态流转(如有 status 字段,输出 mermaid 状态机)
9. 测试关注点(自动 + 手工编辑保留)
10. 变更记录(追加新条目)
**保留手工编辑**:第 1、9 节若已存在内容,保留并在末尾追加自动生成部分。
### Step 3:截图
按状态清单逐张截图,存放路径:`src/prd/<端>/<模块>/screenshots/<页面 path>/<状态>.jpg`
**优先 puppeteer**;若 puppeteer 输出失真 / 尺寸不对,降级到 chrome-devtools MCP。
#### 通用截图规范(严格)
| 类别 | 视口 | DPR | 输出 | 体积上限 |
|------|------|-----|------|---------|
| Web 后台 | 1440 × 900 | 1 | jpeg q=85 | **≤ 60KB** |
| 小程序 | 375 × 812 | 2 | jpeg q=85 | **≤ 60KB** |
| 硬件 | 按设备 viewport | 1 | jpeg q=80 | **≤ 80KB**(大屏可放宽) |
#### puppeteer 代码模板(伪代码)
```js
import puppeteer from 'puppeteer'
const browser = await puppeteer.launch({ headless: 'new' })
const page = await browser.newPage()
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 })
await page.goto('http://localhost:5180/#/tenant-portal/nutrition/arc-employee', { waitUntil: 'networkidle0' })
// 触发状态(如 search-active 时执行 fill + click
await page.screenshot({ path: 'src/prd/tenant-portal/nutrition/screenshots/arc-employee/default.jpg', type: 'jpeg', quality: 85 })
await browser.close()
```
#### chrome-devtools MCP 降级路径(仅当 puppeteer 输出有问题时)
```
1. mcp__chrome-devtools__navigate_page → 目标 URL
2. mcp__chrome-devtools__resize_page → 设视口
3. (如需触发状态)mcp__chrome-devtools__fill / click
4. mcp__chrome-devtools__take_screenshot → format=jpeg quality=85 filePath=...
```
#### 体积控制
截图后立即检查文件体积:
- 若 > 60KB 且 quality > 70:降低 quality 重截
- 若 > 60KB 且尺寸过大:检查是否长截图溢出(应只截视口)
- 仍不达标:用 `sharp` 二次压缩 `npx sharp -i input.jpg -o output.jpg --quality 75`
### Step 4:交付提示
打印:
- 生成 / 更新的 md 路径
- 截图清单 + 单张体积
- PRD 在线查看 URL`http://localhost:5180/#/__prd?path=<端>/<模块>/<页面>.md`
## 输出物
- `src/prd/<端>/<模块>/<页面>.md`
- `src/prd/<端>/<模块>/screenshots/<页面>/*.jpg`
## 禁止事项
- 禁止把截图存到其他目录(如 `public/`
- 禁止生成超过 60KB 的单张 Web 截图(必须二次压缩或降 quality)
- 禁止把 mock 数据复制到 PRD(PRD 关注规则,不关注具体数据值)
- 禁止删除 PRD 中"页面概述"等手工编辑章节
## 注意事项
- 所有字段类型严格采用 types/index.ts 中的 TS 字面量,禁止自译为 Java 类型
- 接口路径必须与 api/index.ts 注释一致
- 写完后引导用户访问 `/__prd` 验证
- 业务架构上下文:必读 `src/docs/business-architecture.md`,禁止跨端引用
+215
View File
@@ -0,0 +1,215 @@
---
name: new-page
description: 在原型工程中新建一个标准列表页 / 详情页 / 弹窗,按 init 三件套规范生成完整骨架(vue + types + api + 三个 init 文件),并自动注册到 src/meta/nav.ts。当用户说"新增 xxx 页面"、"加一个 xxx 列表"或"new-page" 时触发。
---
# Skill: 新建原型页面脚手架
## 图标规范(强制)
所有图标必须取自 `@ant-design/icons-vue`4.x),**禁止使用 emoji / 自定义 svg / 第三方 icon** 作为菜单或按钮图标。
> 应用图标(NavApp.icon)可继续使用 emoji,仅用于"应用网格首页"与左上角应用切换器。
**菜单图标**(在 nav.ts 中以字符串形式声明组件名,渲染时 layouts 用 `<component :is="icon" />`):
| 层级 | 字段 | 是否必填 | 兜底 |
|------|------|---------|------|
| NavSection(一级菜单) | `icon: 'XxxOutlined'` | 必填 | AppstoreOutlined |
| NavGroup(二级分组) | `icon: 'XxxOutlined'` | 必填 | AppstoreOutlined |
| NavLeaf(叶子页面) | `icon: 'XxxOutlined'` | 可选 | 二级叶子 FileOutlined / 三级叶子 MinusOutlined |
常用菜单图标候选(写 nav.ts 时优先复用):
- 档案/记录:FolderOpenOutlined / FileTextOutlined / ProfileOutlined
- 监控/统计:MonitorOutlined / DashboardOutlined / BarChartOutlined / PieChartOutlined
- 设备/工具:DesktopOutlined / ToolOutlined / ApiOutlined
- 异常/告警:WarningOutlined / ExclamationCircleOutlined
- 配置/字典:SettingOutlined / DatabaseOutlined / BookOutlined
- 安全/检测:SafetyCertificateOutlined / FileSearchOutlined
- 环境/地理:EnvironmentOutlined / CloudOutlined / GlobalOutlined
**操作按钮图标**(所有页面按钮 100% 遵循,统一 antd icon):
| 操作 | 图标 |
|------|------|
| 查询 / 搜索 | SearchOutlined |
| 重置 | RedoOutlined |
| 新增 / 添加 | PlusOutlined |
| 编辑 / 修改 | EditOutlined |
| 删除 | DeleteOutlined |
| 导入 | UploadOutlined |
| 导出 | DownloadOutlined |
| 查看详情 | EyeOutlined |
| 查看任务/列表 | UnorderedListOutlined |
| 批量操作 | AppstoreOutlined |
| 刷新 | ReloadOutlined / SyncOutlined |
| 复制 | CopyOutlined |
| 返回 | RollbackOutlined |
| 上一步/下一步 | LeftOutlined / RightOutlined |
`FilterBar` 公共组件已内置"查询/重置"按钮(带图标),新页面无需重写。
## 项目背景(必读)
本项目 4 端架构 + 后台菜单四层模型(与生产 vue 项目 `D:\Work\platform-vue-tenant` qiankun 子应用对齐):
```
Portaladmin-portal / tenant-portal / miniprogram / hardware
└─ NavApp (应用,独立可部署,如"营养管理"/"健康监测")
└─ NavSection (应用内一级菜单,顶部 tab,如"档案"/"绿色种采")
└─ NavGroup (二级分组,可选) | NavLeaf (二级页面)
└─ NavLeaf (三级页面)
```
URL`/<portal>/<app>/<section>/<page>`
## 输入参数(向用户依次提问)
1. **页面类型**`list`(列表页)| `detail`(详情页)| `modal`(弹窗)
2. **所属 Portal**`admin-portal` | `tenant-portal` | `miniprogram` | `hardware`
3. **所属 NavApp** key:如 `nutrition``monitor``checkup`
4. **所属 NavSection** key:如 `nutrition-arc`(档案)、`nutrition-farm`(绿色种采)
5. **如有二级分组**NavGroup key 与 label(如 `nutrition-farm-monitor` / 种养监控)
6. **页面 key**:唯一 + 小驼峰,如 `arcUnit`nav.ts 中 leaf key 建议 `<app>-<section 简称>-<页面简称>` 风格
7. **页面中文名**:如 `单位营养报表`
8. **路由 path 片段**:短横线小写,如 `arc-unit`
9. **图标**(可选):emoji,未指定时 Sidebar 自动用默认图标
## 执行流程
### Step 1:参数校验
- 检查 `src/pages/<portal>/<app>/<section 短名>/<页面key>/` 目录不存在
- 检查 `src/meta/nav.ts` 对应 NavSection 的 children 内不存在同名 leaf
如已存在,告知用户并中止。
### Step 2:以 arcEmployee 为蓝本生成骨架
参考路径:`src/pages/tenant-portal/nutrition/arc/arcEmployee/`
生成文件清单(list 类型):
```
src/pages/<portal>/<app>/<section 短名>/<页面key>/
├── <页面key>.vue # 薄模板层(仅渲染,不写逻辑)
├── types/index.ts # 接口类型集中
├── api/index.ts # 接口契约占位
└── init/
├── usePage.ts # 页面级 state + 行为(无 PageHeader / 无 pageInfo
├── useSearch.ts # 搜索字段 + 选项 + 查询/重置
└── useTable.ts # 列 + 数据 + 分页(mock 数据写这里)
```
模板差异:
| 页面类型 | 包含文件 |
|---------|---------|
| `list` | 全部 6 个文件 |
| `detail` | 5 个文件(去掉 useSearch + useTable,新增 useDetail.ts |
| `modal` | 4 个文件(vue + usePage + typesapi 视需要) |
### Step 3:填充内容(遵循新规范)
**列表页 vue 模板**
```vue
<template>
<div class="<页面key kebab>">
<!-- 顶部统计卡可选 -->
<a-row :gutter="16" class="<页面key kebab>__stats">
<a-col :span="6" v-for="s in stats" :key="s.label">
<StatCard :icon="s.icon" :color="s.color" :value="s.value" :label="s.label" />
</a-col>
</a-row>
<!-- 搜索栏 -->
<FilterBar
v-model="searchForm"
:fields="filterFields"
@search="handleSearch"
@reset="handleReset"
/>
<!-- 表格无标题主操作放 #toolbar 左上方 -->
<TableCard
:columns="columns"
:data-source="dataList"
:pagination="pagination"
row-key="id"
>
<template #toolbar>
<a-button type="primary" @click="handleExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="viewExportTasks">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<!-- 业务渲染 -->
</template>
</TableCard>
</div>
</template>
```
**重要规范**
- ❌ **禁止**渲染 `PageHeader`(标题、面包屑由顶部菜单/历史栏表达)
- ❌ **禁止**给 `TableCard``title`(菜单已经表达了所在位置)
- ✅ **必须**通过 `TableCard #toolbar` 放置主操作按钮(导出、新增等),位置在表格卡片左上方
- ✅ **可选**使用 `TableCard #extra` 放置次要操作(视图切换、批量操作等)
- ✅ **可选**统计卡片放在 `FilterBar` 之上
- ✅ 替换实体名占位:`Employee` → 用户输入的页面 key 推导
- ✅ API 接口路径占位:`POST /api/<app>/<页面key>/page`
### Step 4:注册到 nav.ts
读取 `src/meta/nav.ts`,找到目标 NavApp → NavSection 对象:
- 若指定了 `分组 key`:找到该 NavGroup(若不存在则新建),在其 children 末尾追加 NavLeaf
- 若未指定分组:直接在 NavSection.children 末尾追加 NavLeaf
- NavLeaf 模板:
```ts
{
key: '<leaf key>',
label: '<中文名>',
path: '<路由 path 片段>',
icon: '<emoji>', // 可选,省略时 Sidebar 用默认图标
status: 'draft', // 新页面默认 draft
component: () => import('@pages/<portal>/<app>/<section 短名>/<页面key>/<页面key>.vue'),
}
```
### Step 5:验证
- 打印生成清单,告知用户在浏览器访问的路由路径:`/<portal>/<app>/<section key>/<path>`
- 若 dev 已启动,提示用户直接刷新即可看到新菜单与页面
## 输出物
1. 6 个新文件(list 类型)
2. 修改后的 `src/meta/nav.ts`
3. 终端打印的访问 URL
## 禁止事项
- 禁止跨页面修改其他模块的代码
- 禁止跨应用 import(不同 NavApp 之间模拟独立部署)
- 禁止省略 `init/` 拆分;即使只有一个搜索字段或一列表格,也必须独立文件
- 禁止把 mock 数据写在 .vue 的 setup 里,必须放 useTable.ts
- 禁止修改 `src/components/` 下的公共组件,原型阶段所有定制都在页面内
- 禁止渲染 PageHeader 标题 / TableCard title(新规范)
- 禁止把导出/新增按钮放在页面顶部独立位置(必须放 TableCard #toolbar
## 注意事项
- 所有注释使用简体中文
- TypeScript 类型必须明确声明,不允许 `any`
- 字段命名小驼峰;CSS 类名 BEM
- 生成后再次提醒用户:原型阶段 mock 数据写在 useTable.ts,工程化时只需替换为 api 调用即可
+158
View File
@@ -0,0 +1,158 @@
---
name: prototype-lint
description: 扫描原型工程的代码规范一致性。检查每个页面目录是否符合 init 三件套结构、columns 与 types 字段是否对齐、是否存在禁止的反模式(mock 写在 .vue / 业务逻辑写在模板 / 跨模块引用 / 缺类型)。当用户说"检查规范"、"prototype-lint"、"扫描代码风格" 时触发。
---
# Skill: 原型工程规范检查
## 触发场景
- PR 合并前自检
- 多人协作时确保一致性
- 周期性巡检(建议每周一次)
## 检查项
### A. 目录结构合规(关键)
`src/pages/**` 下的每个页面目录检查:
| 项 | 要求 | 严重度 |
|----|------|--------|
| A1 | 列表页必须含 `init/usePage.ts + init/useSearch.ts + init/useTable.ts` 三件套 | 🔴 错误 |
| A2 | 必须有 `types/index.ts` | 🔴 错误 |
| A3 | 必须有 `api/index.ts`(占位也算) | 🟡 警告 |
| A4 | 主 .vue 文件名必须与目录名一致(小驼峰) | 🔴 错误 |
| A5 | 禁止在页面目录直接放 `.css/.less` 文件,样式必须 `<style scoped>` | 🟡 警告 |
### B. 模板层 .vue 文件检查
对每个页面主 .vue 文件:
| 项 | 要求 | 严重度 |
|----|------|--------|
| B1 | `<script setup lang="ts">` 必须设置 `lang="ts"` | 🔴 错误 |
| B2 | setup 内不允许出现表格数据数组字面量(`const dataList = ref([{...},{...}])` | 🔴 错误 |
| B3 | setup 内不允许出现 `columns` 数组字面量 | 🔴 错误 |
| B4 | 不允许直接 `import` 其他模块 / 其他端的页面文件 | 🔴 错误 |
| B5 | `<style>` 必须带 `scoped`(除非顶层 Layout) | 🟡 警告 |
| B6 | 模板中 class 命名建议使用 BEM | 🟢 提示 |
### C. init/*.ts 检查
| 项 | 要求 | 严重度 |
|----|------|--------|
| C1 | 每个 use*.ts 必须用 `export const useXxx = () => { ... }` 命名规范 | 🔴 错误 |
| C2 | useTable.ts 的 `columns` 数组应使用 `TableColumnsType` 类型注解 | 🟡 警告 |
| C3 | useSearch.ts 的 `filterFields` 应使用 `FilterField[]` 类型注解 | 🟡 警告 |
| C4 | 禁止使用 `any` 类型 | 🟡 警告 |
### D. types/index.ts 与 columns 对齐
对照 useTable.ts 的 `columns` 和 types/index.ts 中的主实体接口字段:
| 项 | 要求 | 严重度 |
|----|------|--------|
| D1 | columns.dataIndex 中出现的字段,必须在主实体 interface 中声明 | 🔴 错误 |
| D2 | 主实体的必填字段(无 `?`),应该至少出现在 columns 或 useDetail 中 | 🟢 提示 |
### E. api/index.ts 检查
| 项 | 要求 | 严重度 |
|----|------|--------|
| E1 | 接口函数必须有 `@接口路径` 注释(供后端 / 测试参考) | 🟡 警告 |
| E2 | 接口参数应使用 types/index.ts 中的 Params 类型 | 🟡 警告 |
| E3 | 原型阶段允许返回 `Promise.reject` 占位,但必须含 `[原型阶段]` 标识 | 🟢 提示 |
### F. 公共组件使用规范
| 项 | 要求 | 严重度 |
|----|------|--------|
| F1 | 列表页若有顶部统计区,应使用 `StatCard` 而非自定义 div | 🟡 警告 |
| F2 | 列表页搜索区应使用 `FilterBar` + filterFields 配置驱动 | 🟡 警告 |
| F3 | 列表页表格应使用 `TableCard` 包装 | 🟡 警告 |
| F4 | 禁止修改 `src/components/` 下的公共组件(按需扩展走"提一个 PR 评审" | 🔴 错误 |
### G. 截图与文档体积巡检
| 项 | 要求 | 严重度 |
|----|------|--------|
| G1 | `src/prd/**/screenshots/**/*.jpg`Web 截图体积 ≤ 60KB | 🔴 错误 |
| G2 | `src/prd/**/screenshots/**/*.jpg`:小程序截图体积 ≤ 60KB | 🔴 错误 |
| G3 | `src/prd/**/screenshots/**/*.jpg`:硬件截图体积 ≤ 80KB | 🟡 警告 |
| G4 | `src/prd/**/_overview/**/*.png`:拼图体积 ≤ 500KB | 🟡 警告 |
| G5 | 单个 PRD `.md` 体积 ≤ 200KB(防止粘贴大段截图 base64) | 🟡 警告 |
| G6 | `dist/assets/*.js`:单 chunk 体积 ≤ 700KB(信息提示,可通过 manualChunks 优化) | 🟢 提示 |
实现:用 Bash `find ... -size +60k` 找出超限文件并列出。
## 执行流程
### Step 1:枚举页面目录
使用 Glob`src/pages/web-admin/**/*/``src/pages/employee-app/**/*/``src/pages/emergency-app/**/*/`
跳过:
- 占位页(`AppPlaceholder.vue` / `PlaceholderPage.vue`
- `home/`
### Step 2:逐目录跑全部检查项
对每个目录,串行执行 A → F 的检查,收集问题。
### Step 3:输出报告
```markdown
# 原型规范检查报告(YYYY-MM-DD
## 总览
- 检查页面数:12
- 🔴 错误:3
- 🟡 警告:8
- 🟢 提示:5
## 错误详情
### pages/web-admin/nutrition/arcUnit/
- [A1] 缺少 `init/useSearch.ts`
- [B2] arcUnit.vue 第 45 行:表格数据写在了 setup 里,应迁移到 init/useTable.ts
### pages/employee-app/profile/
- [B4] Profile.vue 第 12 行:import 了 web-admin 的组件,禁止跨端引用
## 警告详情
...
```
### Step 4:可选自动修复
询问用户:"以下 N 个警告可以自动修复(如 missing scoped、any 类型替换、缺类型注解),是否执行?"
仅对**白名单内的可逆改动**执行自动修复:
-`<style>``scoped`
-`columns``TableColumnsType` 类型注解
-`filterFields``FilterField[]` 类型注解
不在白名单内的(如 B2 数据搬迁),打印手工修复指引但不动代码。
## 输出物
- 终端打印的检查报告
- 可选:`.claude/memory/lint_report.md`(持久化最近一次报告)
## 禁止事项
- 禁止执行可能破坏页面渲染的"自动修复"
- 禁止跨页面/跨模块自动重构(仅同文件内的微调)
- 禁止重命名文件(命名问题只报告)
## 注意事项
- 列表页 vs 详情页 vs 弹窗的"必须文件"清单不同,按页面类型差异化检查
- 占位页(标题含 "Placeholder" 或文件名为 Placeholder*)不参与全部检查
- 检查耗时:100 个页面 < 5 秒(基于 Grep + Read,不调用任何工具子进程)
+109
View File
@@ -0,0 +1,109 @@
---
name: sync-nav
description: 检查 src/meta/nav.ts 与 src/pages/ 实际文件的一致性。扫描 pages 下所有 .vue 文件,对照 nav.ts 中的 component 懒加载路径,报告:在 pages 但未注册到 nav 的孤儿页面 / 在 nav 但 pages 不存在的死引用 / 路径与目录命名不一致的问题。当用户说"检查菜单同步"、"sync-nav"、"看看哪些页面没注册到菜单" 时触发。
---
# Skill: 菜单与页面文件一致性检查
## 触发场景
- 大量新增 / 删除原型页面后,不放心 nav.ts 是否同步
- PR 评审前的快速自检
- 出现"菜单点击 404 / 页面访问 404 / 菜单没显示新页面"等异常时排查
## 执行流程
### Step 1:扫描 src/pages 下所有 .vue 文件
使用 Glob`src/pages/**/*.vue`
排除:
- `src/pages/home/Home.vue`(导航首页,不参与菜单)
- 任何以 `_` 开头的目录(约定为草稿)
收集每个 .vue 的相对路径(相对 `src/pages/`)。
### Step 2:读取 src/meta/nav.ts 中的所有 component 路径
正则匹配:`import\('@pages/(.+?)\.vue'\)`
提取每条匹配的相对路径,建立"已注册"集合。
### Step 3:比对差异
**差异 A:孤儿页面(pages 中存在但 nav 未注册)**
```
src/pages/web-admin/nutrition/arcUnit/ArcUnit.vue
→ nav.ts 未引用,菜单不会显示,无法访问
```
**差异 B:死引用(nav 引用但 pages 不存在)**
```
nav.ts → import('@pages/web-admin/foo/Foo.vue')
→ 实际文件不存在,路由懒加载会失败(404 或 Vite 报错)
```
**差异 C:命名不规范**
检查每个 .vue 文件路径是否符合:
- 目录名小驼峰:`arcEmployee` ✅ / `ArcEmployee` ❌ / `arc-employee` ❌(路由 path 用短横线,目录不用)
- 文件名与目录名一致:`arcEmployee/arcEmployee.vue`
- 不允许多层冗余:`arcEmployee/index.vue`(除非阶段一约定的占位页)
### Step 4:输出诊断报告
```markdown
# 菜单同步检查报告(YYYY-MM-DD HH:mm
## 总览
- pages 下 .vue 文件总数:12
- nav.ts 已注册路径数:10
- 一致性:⚠️ 发现 3 处差异
## 差异 A:孤儿页面(2
| 文件 | 建议 |
|------|------|
| pages/web-admin/nutrition/arcUnit/arcUnit.vue | 建议在 nav.ts 的 nutrition.arc 分组追加 NavLeaf |
| pages/web-admin/nutrition/farmHarvest/farmHarvest.vue | 同上 |
## 差异 B:死引用(1
| nav 引用 | 建议 |
|---------|------|
| @pages/web-admin/foo/Foo.vue | 删除该 NavLeaf 或补齐文件 |
## 差异 C:命名不规范(0
## 建议执行命令
如需自动补齐 nav,可继续调用 new-page 重新生成(仅注册步骤)。
```
### Step 5:交互处理
询问用户:
- "是否要我自动为孤儿页面在 nav.ts 中追加 NavLeaf" → 默认放在该模块最后一个 NavGroup 末尾
- "是否要删除死引用?" → 默认不删,仅标注
## 输出物
终端打印诊断报告。若用户确认修复,直接更新 `src/meta/nav.ts`
## 禁止事项
- 禁止主动重命名文件 / 目录(命名问题只报告,让用户决定)
- 禁止删除 .vue 文件(即使是死引用对应的孤儿目录)
- 禁止跨模块移动文件
## 注意事项
- 占位组件(`PlaceholderPage.vue` / `AppPlaceholder.vue`)属于"通用占位",可以被多个 NavLeaf 共享引用,不算死引用
- 检测路径时区分大小写:因 Vite dev 严格区分,`ArcEmployee.vue``arcEmployee.vue` 视为不同文件
Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+5
View File
@@ -0,0 +1,5 @@
# 门禁页密码(提交到 git;生产分发时可在部署脚本里替换)
# 设计师密码:可看见全部内容(含未完成草稿)
VITE_GATE_PASSWORD_DESIGNER=design2026
# 研发密码:只能看见 status≠draft 且 audience≠designer 的内容
VITE_GATE_PASSWORD_DEV=dev2026
+35
View File
@@ -0,0 +1,35 @@
# 依赖
node_modules
.npm
# 构建产物
dist
dist-ssr
*.local
# 编辑器
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# 日志
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 类型自动生成产物(unplugin-auto-import / unplugin-vue-components 会在首次启动时生成)
src/types/auto-imports.d.ts
src/types/components.d.ts
# TS 增量构建缓存
*.tsbuildinfo
+280
View File
@@ -0,0 +1,280 @@
# yx-platform-prototype — 项目开发指南
## 项目定位
智养餐饮平台V3项目的**高保真交互原型工程**,采用 **Vue 3 + Vite + Ant Design Vue + Vant** 半工程化方案。
**核心目标**:让产品经理画的原型 = 开发要写的代码骨架,AI 还原代码时几乎零创造性工作。
---
## 四个终端
| 终端 | key | UI 库 | 主要用户 | 业务定位 |
|------|-----|-------|---------|---------|
| 综合管理后台 | `admin-portal` | Ant Design Vue | 平台管理员 | 平台级管理(系统设置/运营中心/设备中心 等独立应用) |
| 租户运营后台 | `tenant-portal` | Ant Design Vue | 租户运营人员 | **17 个独立可部署应用的统一入口**(营养管理/健康监测/... |
| C 端微信小程序 | `miniprogram` | Vant 4375×812) | 终端用户 | 用户自助查询、上报、咨询 |
| 硬件终端屏 | `hardware` | 自定义(动态 viewport) | 现场操作员 | 净菜柜屏 / 自助查询屏 / 大屏 等 |
> **重要架构说明**(对齐生产 vue 项目 `D:\Work\platform-vue-tenant` 的 qiankun 微前端形态):
> - `admin-portal` / `tenant-portal` 只是登录门户外壳。下挂多个**独立可部署的应用(NavApp)**,每个应用对应生产 vue 项目中的一个 `subXxx` 子应用。
> - 进入某个应用后,顶部显示**该应用内的一级菜单 tabs**NavSection),左侧显示**当前一级菜单下的二/三级菜单**NavGroup → NavLeaf)。
> - URL 结构:`/<portal>/<app>/<section>/<page>`,例:`/tenant-portal/nutrition/nutrition-farm/monitor/crop`
> - 应用首页(`/<portal>`):展示应用网格卡片(参考 vue 项目 main 的 `home.vue`)。
> 详细业务边界与各应用模块清单见 **`src/docs/business-architecture.md`**。AI 在生成原型前**必须先读这份文档**。
---
## 技术栈
| 类别 | 选型 |
|------|------|
| 框架 | Vue 3.5 + TypeScript 5.9 |
| 构建 | Vite 8 |
| Web 端 UI | Ant Design Vue 4.2 |
| 移动端 UI | Vant 4 |
| 路由 | Vue Router 4Hash 模式) |
| 状态管理 | 不引入 Pinia |
| 样式 | LESS + CSS 变量(设计令牌) |
| 自动导入 | unplugin-auto-import + unplugin-vue-components |
| Markdown 渲染 | markdown-itPRD 查看) |
| 截图 | puppeteer(优先)/ chrome-devtools MCP(降级) |
---
## 启动命令
```bash
npm install # 一次性安装
npm run dev # 启动(端口 5180
npm run build # 构建产物到 dist/
npm run preview # 预览构建产物
```
**门禁密码**`.env`):
- 设计:`design2026`(可见全部)
- 研发:`dev2026`(看不见 draft / designer-only 页面)
登录后 7 天有效;过期自动重新登录。
---
## 详细目录说明(重要)
```
yx-platform-prototype/
├── package.json npm 配置 + 依赖列表 + 脚本命令
├── vite.config.ts Vite 构建配置:端口 5180、路径别名、自动导入插件
├── tsconfig.json TS 项目根配置(references → tsconfig.app + node
├── tsconfig.app.json 前端代码的 TS 编译配置(vue/dom)
├── tsconfig.node.json Vite 配置自身的 TS 编译配置(node 环境)
├── index.html Vite 入口 HTML,挂载 #app
├── .env 环境变量(门禁密码;可提交 git)
├── public/ 静态资源(不参与构建处理,直接拷到 dist 根)
├── CLAUDE.md 本文件 —— 项目开发指南(AI 必读)
├── .claude/
│ ├── skills/ 项目专属技能(slash command 可触发)
│ │ ├── new-page.md 新建原型页骨架(init 三件套)
│ │ ├── gen-fields.md 单页字段清单(给后端/测试)
│ │ ├── gen-prd.md 生成 PRD 文档 + 截图(严格控制尺寸/体积)
│ │ ├── design-overview.md 设计总览:小程序/硬件版块平铺(含拼图)
│ │ ├── sync-nav.md 菜单与 pages/ 一致性检查
│ │ └── prototype-lint.md 代码规范检查
│ ├── agents/ 项目专属代理
│ │ ├── prototype-reviewer.md PR / 变更综合评审(只读)
│ │ └── cross-role-doc-builder.md 模块级跨岗位文档包
│ └── memory/ 会话间持久化记忆
│ ├── MEMORY.md 索引
│ ├── decisions.md 关键决策
│ ├── feedback.md 协作规范
│ ├── project_overview.md 技术栈与架构
│ ├── project_progress.md 阶段进度
│ └── user_profile.md 用户画像
└── src/
├── main.ts 应用入口:创建 Vue 实例、挂载 router
├── App.vue 根组件,仅 <router-view />
├── style.css 全局设计令牌(CSS 变量)+ reset
├── router/
│ ├── index.ts 创建 router 实例 + 守卫(门禁 + designerOnly
│ └── routes.ts 路由表:由 nav.ts 自动派生 + 4 个工具路由(gate/console/prd/overview
├── meta/
│ └── nav.ts 🌟 菜单数据单一来源
│ - 4 端类型定义(NavApp / NavSection / NavGroup / NavLeaf / NavMiniprogramTab / NavHardwareDevice
│ - 后台层级:NavApp → NavSection → (NavGroup | NavLeaf) → NavLeaf
│ - admin: adminAppstenant: tenantApps(含 nutrition / monitor 完整三层菜单)
│ - status + audience 字段控制可见性
├── composables/ 复用逻辑
│ └── useVisibility.ts 可见性核心:角色、过滤、覆写存取
├── layouts/ 共享布局(菜单只写一次)
│ ├── PortalLayout.vue 后台门户(admin/tenant 共用,按 route.meta.isAppGrid 切换"应用网格首页"/"应用内三层菜单"
│ ├── MiniprogramLayout.vue 小程序手机壳(375×812
│ ├── HardwareLayout.vue 硬件外壳(按 viewport 动态尺寸)
│ └── components/
│ ├── TopBar.vue 后台顶部(Logo + 应用名▾下拉切换 + 一级菜单 tabs + 返回首页 + 应用首页 + 用户区)
│ ├── Sidebar.vue 后台侧边栏(二/三级菜单,支持 SubMenu 嵌套)
│ ├── AppTabBar.vue 移动端底部 TabVant Tabbar
│ └── RoleBadge.vue 顶部角色徽章 + PRD/总览快捷入口 + 登出
├── components/ 公共业务组件(被自动注册到 AntDV 风格)
│ ├── StatCard.vue 统计卡(4 色配色)
│ ├── FilterBar.vue 配置驱动搜索栏(5 种字段类型)
│ ├── TableCard.vue 表格卡(无 title#toolbar 左上、#extra 右上、a-table 全部透传)
│ ├── PageHeader.vue 页头(保留供详情页用,列表页不应使用)
│ ├── PlaceholderPage.vue 后台占位页(接 title/name
│ └── AppPlaceholder.vue 移动端占位页(接 title/name
├── pages/ 原型页面(按端 + 应用 + 一级菜单 分目录)
│ ├── home/Home.vue 🏠 全局导航首页(四端 Tab + 卡片网格)
│ ├── gate/Gate.vue 🔒 门禁页(密码登录 → 设置角色 + 7 天 TTL)
│ ├── console/
│ │ └── VisibilityConsole.vue 🎛 设计完成度控制台(仅 designer)
│ ├── prd/
│ │ └── PrdViewer.vue 📄 PRD 在线查看(markdown-it 渲染 src/prd/**.md
│ ├── overview/
│ │ └── DesignOverview.vue 🖼 设计总览(小程序/硬件页面平铺)
│ │
│ ├── admin-portal/ 综合管理后台业务页面
│ │ ├── system-settings/Overview.vue
│ │ ├── operation-center/Overview.vue
│ │ └── device-center/Overview.vue
│ ├── tenant-portal/ 租户运营后台业务页面(按 <app>/<section>/<page> 组织)
│ │ ├── home/
│ │ │ └── AppGrid.vue 🌟 租户应用网格首页(卡片入口)
│ │ ├── nutrition/ 营养管理应用
│ │ │ ├── arc/ 档案(一级菜单)
│ │ │ │ ├── arcEmployee/ ⭐ 标准列表页样板(六文件)
│ │ │ │ └── ArcUnit.vue
│ │ │ └── farm/ 绿色种采(一级菜单)
│ │ │ └── FarmLand.vue
│ │ └── monitor/ 健康监测应用(菜单结构占位,页面待开发)
│ ├── miniprogram/ 小程序业务页面
│ │ ├── home/Home.vue
│ │ └── profile/Profile.vue
│ └── hardware/ 硬件业务页面
│ └── cabinet/Home.vue
├── prd/ PRD markdown 文档(自动由 /gen-prd 维护)
│ └── tenant-portal/nutrition/
│ ├── arc-employee.md
│ └── screenshots/<页面>/<状态>.jpg 截图(≤60KB
├── docs/ 业务文档
│ └── business-architecture.md 🌟 业务架构(AI 必读)
└── types/ 全局类型声明(自动生成 + 手写 shims)
├── auto-imports.d.ts unplugin-auto-import 生成
├── components.d.ts unplugin-vue-components 生成
└── shims-vue.d.ts .vue 文件类型声明
```
---
## 路径别名
| 别名 | 指向 |
|------|------|
| `@` | `src/` |
| `@meta` | `src/meta/` |
| `@layouts` | `src/layouts/` |
| `@components` | `src/components/` |
| `@composables` | `src/composables/` |
| `@pages` | `src/pages/` |
| `@router` | `src/router/` |
---
## 工具路由(带 `__` 前缀,不在 nav 菜单中)
| 路由 | 说明 |
|------|------|
| `/gate` | 门禁登录页 |
| `/__console` | 设计完成度控制台(仅 designer) |
| `/__prd` | PRD 文档在线查看 |
| `/__overview` | 设计总览(小程序 / 硬件) |
---
## 关键约定
1. **菜单单一来源**:所有菜单结构集中在 `src/meta/nav.ts`
2. **三层菜单结构**:每个应用(NavApp → 一级菜单(NavSection → 二/三级菜单(NavGroup → NavLeaf
3. **URL 规范**`/<portal>/<app>/<section>/<page>`,例:`/tenant-portal/nutrition/nutrition-farm/monitor/crop`
4. **列表页 UI 规范(重要)**
- **不渲染 PageHeader**(菜单 + 顶部历史栏已经表达所在位置)
- **TableCard 不显示 title**
- **主操作按钮**(导出/新增/批量等)放在 `TableCard #toolbar` 左上方
- **次要操作**放在 `TableCard #extra` 右上方
- **统计卡**放在 `FilterBar` 上方(可选)
5. **图标规范(强制)**
- 所有菜单图标 / 操作按钮图标必须取自 `@ant-design/icons-vue` 4.x**禁止使用 emoji / 自定义 svg**
- NavSection / NavGroup:必填 `icon: 'XxxOutlined'`
- NavLeaf:可选;layouts 渲染时未指定则二级用 FileOutlined / 三级用 MinusOutlined 兜底
- 操作按钮标准映射:查询=SearchOutlined / 重置=RedoOutlined / 新增=PlusOutlined / 编辑=EditOutlined / 删除=DeleteOutlined / 导入=UploadOutlined / 导出=DownloadOutlined / 查看详情=EyeOutlined / 查看任务=UnorderedListOutlined
- 应用图标(NavApp.icon)允许 emoji(仅在应用网格首页 + 左上角应用切换器显示)
5. **端隔离**:4 端不允许互相 import;共享仅通过 `@components` / `@layouts` / `@meta` / `@composables`
6. **应用隔离**:同一 portal 下不同应用之间也不应相互 import(对齐 vue 项目"子应用独立部署");共享只能走 nav.ts 的公共抽象
7. **列表页固定 6 文件结构**`<页面>.vue + types + api + init/(usePage + useSearch + useTable)`
8. **Mock 数据**:写在 `init/useTable.ts` 的 dataList
9. **公共组件优先**StatCard / FilterBar / TableCard / PlaceholderPage
10. **TS 严格**:禁止 any
11. **中文注释**
12. **可见性**:每个 NavLeaf 有 `status`draft/review/ready+ `audience`designer/all),dev 角色看不到 draft 或 designer-only 的页面
13. **PRD 截图体积**Web ≤ 60KB、小程序 ≤ 60KB、硬件 ≤ 80KB;优先 puppeteer,失真时降级 chrome-devtools MCP
---
## 项目专属 Skills / Agents
| 触发场景 | 调用 |
|---------|------|
| 新增原型页面 | `/new-page` skill |
| 生成 PRD(含截图) | `/gen-prd` skill |
| 生成单页字段清单 | `/gen-fields` skill |
| 设计总览(小程序/硬件) | `/design-overview` skill |
| 菜单同步检查 | `/sync-nav` skill |
| 代码规范检查 | `/prototype-lint` skill |
| PR 综合评审 | `prototype-reviewer` agent |
| 模块级跨岗位文档包 | `cross-role-doc-builder` agent |
详见 `.claude/skills/``.claude/agents/` 下的 frontmatter description。
---
## 记忆体系(会话启动必读)
每次新会话启动时,必须先读取记忆体系索引(`.claude/memory/MEMORY.md`),再按需加载关键记忆文件。
必读:`decisions.md` / `feedback.md` / `project_progress.md`
按需:`project_overview.md` / `user_profile.md`
---
## 禁止事项
- 禁止跨页面修改其他模块的代码
- 禁止在 .vue 的 setup 里直接写表格 columns 数组、mock 数据数组
- 禁止跨端引用页面(admin ↛ tenant ↛ miniprogram ↛ hardware
- 禁止跨应用引用页面(不同 NavApp 之间不能互相 import,模拟独立部署)
- 禁止修改 `src/components/` 的公共组件(除非走 PR 评审)
- 禁止省略 init 三件套(即使只有 1 列表格也要独立 useTable.ts
- 禁止把业务逻辑写在模板(所有逻辑通过 usePage 等 hook 暴露)
- 禁止使用 `any` 类型
- 禁止在列表页渲染 PageHeader(含面包屑/标题/副标题)
- 禁止给 TableCard 传 title(菜单已表达所在位置)
- 禁止把主操作按钮放在页面顶部独立位置(必须放 TableCard #toolbar
- 禁止使用 emoji / 自定义 svg 作为菜单或按钮图标(必须用 @ant-design/icons-vue
- 禁止把 Vant 组件用在后台页面(admin/tenant),反之亦然
- 禁止把截图存到 PRD 目录之外(如 public/
- 禁止生成 > 60KB 的单张 Web/小程序截图
- 禁止在 Web 后台调用 `/design-overview` skill(仅支持小程序/硬件)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智养餐饮平台V3 · 原型</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3005
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "yx-platform-prototype",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "智养餐饮平台V3 - 高保真交互原型(Vue 工程版)",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"ant-design-vue": "^4.2.6",
"dayjs": "^1.11.13",
"markdown-it": "^14.2.0",
"vant": "^4.9.14",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.10.5",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"less": "^4.2.1",
"typescript": "~5.6.3",
"unplugin-auto-import": "^0.19.0",
"unplugin-vue-components": "^0.28.0",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
},
"allowScripts": {
"esbuild@0.25.12": true
}
}
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
/** 应用根:只承载路由出口,所有布局在各 Layout 内部实现 */
</script>
+34
View File
@@ -0,0 +1,34 @@
<template>
<div class="app-placeholder">
<h3>{{ title }}</h3>
<div class="app-placeholder__meta">{{ name }}</div>
<p>占位 - 阶段四会替换为真实原型</p>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string; name: string }>()
</script>
<style scoped lang="less">
.app-placeholder {
padding: 24px 20px;
h3 {
margin: 0 0 8px;
color: var(--primary);
font-size: var(--font-lg);
}
&__meta {
color: var(--text-secondary);
font-size: var(--font-sm);
margin-bottom: 16px;
}
p {
color: var(--text-placeholder);
font-size: var(--font-sm);
}
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<template>
<a-card class="filter-bar" :bordered="false">
<a-form layout="inline" :model="model">
<a-form-item
v-for="field in fields"
:key="field.name"
:label="field.label"
class="filter-bar__item"
>
<!-- 文本输入 -->
<a-input
v-if="field.type === 'input'"
:value="model[field.name]"
:placeholder="field.placeholder ?? '请输入'"
allow-clear
:style="{ width: field.width ?? '200px' }"
@update:value="(v: string) => onFieldChange(field.name, v)"
/>
<!-- 数字 -->
<a-input-number
v-else-if="field.type === 'number'"
:value="model[field.name]"
:placeholder="field.placeholder ?? '请输入数字'"
:style="{ width: field.width ?? '160px' }"
@update:value="(v: number | string | null) => onFieldChange(field.name, v)"
/>
<!-- 下拉选择 -->
<a-select
v-else-if="field.type === 'select'"
:value="model[field.name]"
:placeholder="field.placeholder ?? '全部'"
:options="field.options ?? []"
:allow-clear="field.allowClear ?? true"
:style="{ width: field.width ?? '160px' }"
@update:value="(v: unknown) => onFieldChange(field.name, v)"
/>
<!-- 单日期 -->
<a-date-picker
v-else-if="field.type === 'date'"
:value="model[field.name]"
:placeholder="field.placeholder ?? '选择日期'"
:style="{ width: field.width ?? '180px' }"
@update:value="(v: unknown) => onFieldChange(field.name, v)"
/>
<!-- 日期区间 -->
<a-range-picker
v-else-if="field.type === 'dateRange'"
:value="model[field.name]"
:style="{ width: field.width ?? '260px' }"
@update:value="(v: unknown) => onFieldChange(field.name, v)"
/>
</a-form-item>
<!-- 操作区默认查询/重置按钮统一带 antd icon -->
<a-form-item class="filter-bar__actions">
<a-space>
<a-button type="primary" @click="emit('search', model)">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="onResetClick">
<template #icon><RedoOutlined /></template>
重置
</a-button>
<slot name="actions" />
</a-space>
</a-form-item>
</a-form>
</a-card>
</template>
<script setup lang="ts">
/**
* 通用搜索栏 - 配置驱动
* - v-model 绑定整张表单数据
* - fields 决定渲染哪些字段、什么类型
* - 默认查询/重置按钮已带统一 antd iconSearchOutlined / RedoOutlined
*/
import { SearchOutlined, RedoOutlined } from '@ant-design/icons-vue'
/** 单个字段配置 */
export interface FilterField {
name: string
label: string
type: 'input' | 'number' | 'select' | 'date' | 'dateRange'
placeholder?: string
/** 控件宽度(CSS 值) */
width?: string
/** select 选项 */
options?: Array<{ value: unknown; label: string }>
/** select 是否可清空(默认 true */
allowClear?: boolean
}
const props = defineProps<{
/** 表单数据(v-model */
modelValue: Record<string, unknown>
/** 字段配置 */
fields: FilterField[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, unknown>): void
(e: 'search', value: Record<string, unknown>): void
(e: 'reset'): void
}>()
/** 内部读取的引用 */
const model = computed(() => props.modelValue)
/** 单字段更新:拷贝旧值 + 替换单字段 → 抛出新对象 */
const onFieldChange = (name: string, value: unknown) => {
emit('update:modelValue', { ...props.modelValue, [name]: value })
}
/** 重置:把所有字段置为初始空值(input/select 置 undefined */
const onResetClick = () => {
const next: Record<string, unknown> = {}
props.fields.forEach((f) => {
next[f.name] = undefined
})
emit('update:modelValue', next)
emit('reset')
}
</script>
<style scoped lang="less">
.filter-bar {
margin-bottom: 16px;
:deep(.ant-card-body) {
padding: 20px 24px 4px;
}
&__item {
margin-bottom: 16px;
}
&__actions {
margin-bottom: 16px;
}
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<template>
<header class="page-header">
<!-- 面包屑 -->
<a-breadcrumb v-if="breadcrumb && breadcrumb.length" class="page-header__crumb">
<a-breadcrumb-item v-for="(item, idx) in breadcrumb" :key="idx">
{{ typeof item === 'string' ? item : item.label }}
</a-breadcrumb-item>
</a-breadcrumb>
<!-- 标题 + 副标题 + 右侧操作 -->
<div class="page-header__main">
<div class="page-header__title-zone">
<h2 class="page-header__title">{{ title }}</h2>
<div v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</div>
</div>
<div v-if="$slots.actions" class="page-header__actions">
<slot name="actions" />
</div>
</div>
</header>
</template>
<script setup lang="ts">
/**
* 页头
* - 面包屑(可选)+ 标题 + 副标题 + 右侧操作区
* - 与 PRD 中的页面结构对齐:面包屑反映导航位置,actions 放页面级按钮
*/
/** 面包屑项:字符串或对象 */
type CrumbItem = string | { label: string }
withDefaults(defineProps<{
/** 主标题 */
title: string
/** 副标题(可选) */
subtitle?: string
/** 面包屑数组 */
breadcrumb?: CrumbItem[]
}>(), {
subtitle: '',
breadcrumb: () => [],
})
</script>
<style scoped lang="less">
.page-header {
margin-bottom: 16px;
&__crumb {
margin-bottom: 12px;
font-size: var(--font-sm);
}
&__main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
&__title {
margin: 0;
font-size: var(--font-xxl);
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
}
&__subtitle {
margin-top: 4px;
font-size: var(--font-sm);
color: var(--text-secondary);
}
&__actions {
flex-shrink: 0;
}
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="placeholder-page">
<h2 class="placeholder-page__title">{{ title }}</h2>
<div class="placeholder-page__meta">
<span>路由{{ $route.fullPath }}</span>
<span>组件{{ name }}</span>
</div>
<div class="placeholder-page__hint">
<slot>占位页面 阶段四会替换为真实原型内容</slot>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string; name: string }>()
</script>
<style scoped lang="less">
.placeholder-page {
background: #fff;
border-radius: var(--radius-md);
padding: 32px;
box-shadow: var(--shadow-sm);
&__title {
margin: 0 0 12px;
color: var(--primary);
font-size: var(--font-xl);
}
&__meta {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--text-secondary);
font-size: var(--font-sm);
margin-bottom: 16px;
}
&__hint {
color: var(--text-placeholder);
font-size: var(--font-sm);
padding: 16px;
background: var(--background);
border-radius: var(--radius-sm);
}
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<template>
<a-card class="stat-card" :bordered="false">
<div class="stat-card__inner">
<div :class="['stat-card__icon', `stat-card__icon--${color}`]">
<slot name="icon">{{ icon }}</slot>
</div>
<div class="stat-card__info">
<div class="stat-card__value">{{ value }}</div>
<div class="stat-card__label">{{ label }}</div>
</div>
</div>
</a-card>
</template>
<script setup lang="ts">
/** 统计卡片配色 */
type StatColor = 'blue' | 'green' | 'orange' | 'red'
withDefaults(defineProps<{
/** 图标(emoji / 文本占位;如需 svg 用 slot:icon */
icon?: string
/** 配色 */
color?: StatColor
/** 数值 */
value: string | number
/** 文字说明 */
label: string
}>(), {
icon: '',
color: 'blue',
})
</script>
<style scoped lang="less">
.stat-card {
height: 100%;
:deep(.ant-card-body) {
padding: 20px 24px;
}
&__inner {
display: flex;
align-items: center;
gap: 16px;
}
&__icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
&--blue { background: #e6f7ff; color: var(--primary); }
&--green { background: #f6ffed; color: var(--success); }
&--orange { background: #fff7e6; color: var(--warning); }
&--red { background: #fff1f0; color: var(--danger); }
}
&__info {
min-width: 0;
flex: 1;
}
&__value {
font-size: var(--font-xl);
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
&__label {
font-size: var(--font-sm);
color: var(--text-secondary);
margin-top: 2px;
}
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<a-card class="table-card" :bordered="false">
<!-- 卡片头左侧 actionsslot + 可选标题 + 右侧 extra -->
<template v-if="showHeader" #title>
<div class="table-card__head">
<div v-if="$slots.toolbar" class="table-card__toolbar">
<slot name="toolbar" />
</div>
<div v-else-if="title" class="table-card__title">{{ title }}</div>
</div>
</template>
<template v-if="$slots.extra" #extra>
<slot name="extra" />
</template>
<!-- 表格主体 - 完全透传 a-table props slots -->
<a-table
:columns="columns"
:data-source="dataSource"
:pagination="pagination"
:scroll="scroll"
:row-key="rowKey"
:size="size"
:loading="loading"
v-bind="$attrs"
>
<!-- 透传 a-table 全部命名插槽 -->
<template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
<slot :name="name" v-bind="slotData ?? {}" />
</template>
</a-table>
</a-card>
</template>
<script setup lang="ts">
import type { TableColumnsType, TablePaginationConfig, TableProps } from 'ant-design-vue'
/**
* 表格卡片(项目标准列表页组件)
*
* 用法约定(新规范):
* - **不显示标题**(title 默认空),列表页不再展示重复的菜单名作为标题
* - **左上方操作区**:通过 `#toolbar` slot 放置主操作按钮(如"导出列表数据""查看导出任务"
* - **右上方扩展区**:通过 `#extra` slot 放置筛选/视图切换等次要操作
* - 表格主体 props/slots 全部透传 a-table
*
* 如果显式传入 title,将以标题文本渲染(兼容旧用法,但新页面不应使用)
*/
const props = withDefaults(defineProps<{
/** 卡片标题(新规范下不应使用,留空即可) */
title?: string
/** 表格列配置 */
columns: TableColumnsType
/** 表格数据源 */
dataSource: Record<string, unknown>[]
/** 分页配置:传 false 关闭分页 */
pagination?: TablePaginationConfig | false
/** 横向 / 纵向滚动 */
scroll?: TableProps['scroll']
/** 行 key */
rowKey?: string
/** 表格尺寸 */
size?: TableProps['size']
/** 加载态 */
loading?: boolean
}>(), {
title: '',
pagination: undefined,
scroll: () => ({ x: 'max-content' }),
rowKey: 'id',
size: 'middle',
loading: false,
})
const slots = useSlots()
/** 是否渲染卡片头:当 toolbar slot 存在 或 显式传入 title 时 */
const showHeader = computed(() => Boolean(slots.toolbar) || Boolean(props.title))
/** $attrs 透传剩余 props,关闭根节点继承避免污染 a-card */
defineOptions({ inheritAttrs: false })
</script>
<style scoped lang="less">
.table-card {
:deep(.ant-card-head) {
padding: 12px 16px;
min-height: auto;
border-bottom: 1px solid #f0f0f0;
}
:deep(.ant-card-head-title) {
padding: 0;
}
:deep(.ant-card-body) {
padding: 12px 16px;
}
&__head {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
}
&__toolbar {
display: flex;
align-items: center;
gap: 8px;
}
&__title {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
}
</style>
+113
View File
@@ -0,0 +1,113 @@
/**
* 页面访问历史(最近打开的菜单 Tabs)
* - 对齐生产 vue 项目 D:\Work\platform-vue-tenant\apps\subNutritionManagement\src\store\pageHistory
* - 实现方式:composable + localStorage(项目不引入 Pinia
* - 作用域:每个 portal 独立维护一份历史(key: pageHistory.<portalKey>
*/
import type { Router } from 'vue-router'
/** 单个历史 Tab */
export interface PageHistoryItem {
/** 路由完整路径(包含 query 仅基础路径) */
path: string
/** 菜单名(用于 Tab 标签) */
title: string
/** 所属应用 key */
app?: string
/** 所属一级菜单 key */
section?: string
/** 叶子 key */
leafKey?: string
}
const STORAGE_KEY_PREFIX = 'pageHistory.'
const readFromStorage = (portalKey: string): PageHistoryItem[] => {
try {
const raw = localStorage.getItem(STORAGE_KEY_PREFIX + portalKey)
return raw ? (JSON.parse(raw) as PageHistoryItem[]) : []
} catch {
return []
}
}
const writeToStorage = (portalKey: string, list: PageHistoryItem[]): void => {
localStorage.setItem(STORAGE_KEY_PREFIX + portalKey, JSON.stringify(list))
}
/** 各 portal 的响应式历史列表 */
const stores = new Map<string, ReturnType<typeof ref<PageHistoryItem[]>>>()
const getStore = (portalKey: string) => {
let s = stores.get(portalKey)
if (!s) {
s = ref<PageHistoryItem[]>(readFromStorage(portalKey))
stores.set(portalKey, s)
}
return s
}
/**
* 使用页面历史(无数量上限,对齐 vue 项目)
* @param portalKey 'admin-portal' | 'tenant-portal' 等
*/
export const usePageHistory = (portalKey: string) => {
const list = getStore(portalKey)
/** 新增页面(已存在则前移到末尾) */
const addPage = (item: PageHistoryItem): void => {
const arr = (list.value ?? []).filter(i => i.path !== item.path)
arr.push(item)
list.value = arr
writeToStorage(portalKey, arr)
}
/** 移除指定路径 */
const removePage = (path: string): string | null => {
const arr = list.value ?? []
const idx = arr.findIndex(i => i.path === path)
if (idx === -1) return null
arr.splice(idx, 1)
list.value = [...arr]
writeToStorage(portalKey, list.value)
// 返回下一个应跳转的路径(关闭当前页时用)
return list.value[list.value.length - 1]?.path ?? null
}
/** 关闭除指定路径外的所有 */
const closeOthers = (path: string): void => {
list.value = (list.value ?? []).filter(i => i.path === path)
writeToStorage(portalKey, list.value)
}
/** 清空 */
const clear = (): void => {
list.value = []
writeToStorage(portalKey, [])
}
return { list, addPage, removePage, closeOthers, clear }
}
/**
* 在 router.afterEach 钩子里自动追踪某个 portal 的访问
* - 仅当路由 meta.portal 匹配时记录
* - 忽略应用网格首页 / 重定向路由
*/
export const installPageHistoryTracker = (router: Router): void => {
router.afterEach((to) => {
const portal = to.meta?.portal as string | undefined
if (!portal) return
if (to.meta?.isAppGrid) return
if (!to.name) return
const { addPage } = usePageHistory(portal)
addPage({
path: to.path,
title: String(to.meta?.label ?? to.name ?? to.path),
app: to.meta?.app as string | undefined,
section: to.meta?.section as string | undefined,
leafKey: String(to.name),
})
})
}
+210
View File
@@ -0,0 +1,210 @@
/**
* 可见性控制
* - 当前角色:'designer'(产品/设计,全部可见) | 'dev'(研发,过滤 draft / designer-only
* - 角色来自门禁页登录态(localStorage: gateway.role);默认 'designer'
* - 控制台可对单页面覆写 status / audiencelocalStorage: visibility.overrides
* - 过滤规则:
* dev 不能看到 status='draft' 或 audience='designer' 的页面
* designer 始终全部可见
*
* 层级(与 nav.ts 对齐):
* NavApp -> NavSection -> (NavGroup | NavLeaf) -> NavLeaf
*/
import {
isNavGroup,
type NavLeaf,
type NavGroup,
type NavSection,
type NavApp,
type NavMiniprogramTab,
type NavHardwareDevice,
} from '@meta/nav'
/** 用户角色(来自门禁页) */
export type Role = 'designer' | 'dev'
/** 单页面可见性覆写记录 */
export interface VisibilityOverride {
status?: 'draft' | 'review' | 'ready'
audience?: 'designer' | 'all'
}
/** 控制台覆写集合(key = NavLeaf.key */
export type VisibilityOverrides = Record<string, VisibilityOverride>
const ROLE_KEY = 'gateway.role'
const ROLE_EXPIRE_KEY = 'gateway.expireAt'
const OVERRIDES_KEY = 'visibility.overrides'
/** 默认 7 天有效期(毫秒) */
export const ROLE_TTL_MS = 7 * 24 * 60 * 60 * 1000
// ============================== 角色读写 ==============================
/** 读取当前角色(含过期校验) */
export const readCurrentRole = (): Role | null => {
const role = localStorage.getItem(ROLE_KEY) as Role | null
const expireAt = Number(localStorage.getItem(ROLE_EXPIRE_KEY) ?? 0)
if (!role || !expireAt) return null
if (Date.now() > expireAt) {
clearRole()
return null
}
return role
}
/** 写入角色 + 7 天有效期 */
export const writeRole = (role: Role): void => {
localStorage.setItem(ROLE_KEY, role)
localStorage.setItem(ROLE_EXPIRE_KEY, String(Date.now() + ROLE_TTL_MS))
}
/** 清除角色(登出 / 过期) */
export const clearRole = (): void => {
localStorage.removeItem(ROLE_KEY)
localStorage.removeItem(ROLE_EXPIRE_KEY)
}
/** 读取过期时间戳(用于显示剩余时间) */
export const readRoleExpireAt = (): number | null => {
const ts = Number(localStorage.getItem(ROLE_EXPIRE_KEY) ?? 0)
return ts > 0 ? ts : null
}
// ============================== 覆写读写 ==============================
/** 读取控制台覆写集合 */
export const readOverrides = (): VisibilityOverrides => {
try {
const raw = localStorage.getItem(OVERRIDES_KEY)
return raw ? JSON.parse(raw) as VisibilityOverrides : {}
} catch {
return {}
}
}
/** 写入控制台覆写集合 */
export const writeOverrides = (overrides: VisibilityOverrides): void => {
localStorage.setItem(OVERRIDES_KEY, JSON.stringify(overrides))
}
/** 更新单个页面的覆写(partial 合并) */
export const setOverride = (leafKey: string, patch: VisibilityOverride): void => {
const all = readOverrides()
const next = { ...all[leafKey], ...patch }
if (next.status === undefined && next.audience === undefined) {
delete all[leafKey]
} else {
all[leafKey] = next
}
writeOverrides(all)
}
/** 清除所有覆写 */
export const clearOverrides = (): void => {
localStorage.removeItem(OVERRIDES_KEY)
}
// ============================== 过滤核心 ==============================
/** 合并 NavLeaf 默认值 + 控制台覆写 → 有效 status/audience */
const effective = (leaf: NavLeaf, overrides: VisibilityOverrides): Required<Pick<NavLeaf, 'status' | 'audience'>> => {
const ovr = overrides[leaf.key] ?? {}
return {
status: ovr.status ?? leaf.status ?? 'ready',
audience: ovr.audience ?? leaf.audience ?? 'all',
}
}
/** 判断单个叶子节点对当前角色是否可见 */
export const isLeafVisible = (leaf: NavLeaf, role: Role, overrides: VisibilityOverrides): boolean => {
if (role === 'designer') return true
const { status, audience } = effective(leaf, overrides)
if (audience === 'designer') return false
if (status === 'draft') return false
return true
}
/** 过滤分组 children,移除研发不可见的叶子;若分组空则丢弃 */
const filterGroup = (group: NavGroup, role: Role, overrides: VisibilityOverrides): NavGroup | null => {
const children = group.children.filter(l => isLeafVisible(l, role, overrides))
if (children.length === 0) return null
return { ...group, children }
}
/** 过滤应用一级菜单(NavSection)的 children;若 section 空则丢弃 */
export const filterSection = (section: NavSection, role: Role, overrides: VisibilityOverrides): NavSection | null => {
const children = section.children
.map((item): NavLeaf | NavGroup | null => {
if (isNavGroup(item)) return filterGroup(item, role, overrides)
return isLeafVisible(item, role, overrides) ? item : null
})
.filter((x): x is NavLeaf | NavGroup => x !== null)
if (children.length === 0) return null
return { ...section, children }
}
/** 过滤一个应用:过滤其 sections,sections 都空则该应用整个不可见 */
export const filterApp = (app: NavApp, role: Role, overrides: VisibilityOverrides): NavApp | null => {
const children = app.children
.map(s => filterSection(s, role, overrides))
.filter((s): s is NavSection => s !== null)
if (children.length === 0) return null
return { ...app, children }
}
/** 过滤应用列表 */
export const filterApps = (apps: NavApp[], role: Role, overrides: VisibilityOverrides): NavApp[] => {
return apps.map(a => filterApp(a, role, overrides)).filter((a): a is NavApp => a !== null)
}
/** 过滤小程序 tabstab 的 page 不可见则整个 tab 丢弃 */
export const filterMiniprogramTabs = (tabs: NavMiniprogramTab[], role: Role, overrides: VisibilityOverrides): NavMiniprogramTab[] => {
return tabs.filter(t => isLeafVisible(t.page, role, overrides))
}
/** 过滤硬件设备 */
export const filterHardwareDevices = (devices: NavHardwareDevice[], role: Role, overrides: VisibilityOverrides): NavHardwareDevice[] => {
return devices
.map(d => ({ ...d, children: d.children.filter(l => isLeafVisible(l, role, overrides)) }))
.filter(d => d.children.length > 0)
}
// ============================== Vue 集成 ==============================
/** 响应式角色 + 覆写(跨组件共享) */
const roleRef = ref<Role | null>(readCurrentRole())
const overridesRef = ref<VisibilityOverrides>(readOverrides())
/** 角色发生变化时通知(登录 / 登出) */
export const setRole = (role: Role | null): void => {
if (role === null) {
clearRole()
} else {
writeRole(role)
}
roleRef.value = role
}
/** 覆写发生变化时通知(控制台编辑) */
export const updateOverride = (leafKey: string, patch: VisibilityOverride): void => {
setOverride(leafKey, patch)
overridesRef.value = readOverrides()
}
export const resetOverrides = (): void => {
clearOverrides()
overridesRef.value = {}
}
/** Composable 入口 */
export const useVisibility = () => {
return {
role: roleRef,
overrides: overridesRef,
setRole,
updateOverride,
resetOverrides,
}
}
+119
View File
@@ -0,0 +1,119 @@
# 业务架构
> **作用**:这份文档供 AI 在生成原型时参考,确保理解业务上下文、不串端、不重复造功能。
> **维护规则**:用户在规划每个端的具体功能模块时填写本文档,AI 在 new-page / gen-prd / cross-role-doc-builder 等任务中**必须先读这份文档**。
> _Last updated: 2026-06-04 / 阶段 16(用户提供四端粗粒度功能清单)_
---
## 一、四个终端
### 1.1 综合管理后台 `admin-portal`
| 子端 | 一级模块 | 说明 |
|------|---------|------|
| 系统设置 | (待规划) | 平台层基础设置:组织、账号、权限、字典、参数 |
| 运营中心 | (待规划) | 平台运营视角:租户管理、应用配置、计费、统计 |
| 设备中心 | (待规划) | 跨租户硬件接入与运维:设备注册、固件版本、在线状态 |
> 综合后台是**跨租户**视角;与租户运营后台的边界:凡是只能在租户内部生效的业务(如某租户的食堂菜单),归属租户后台。
### 1.2 租户运营后台 `tenant-portal`
| 一级模块 | 说明 |
|---------|------|
| 租户首页 | 单租户运营视角的首页:核心 KPI、待办、快捷入口 |
| 控制台 | 租户运营的控制中心:组织架构、岗位、人员、流程 |
| 系统管理 | 租户级系统管理:账号、角色、字典、日志、消息模板 |
| 营养管理 | 食堂全链路:种植、采购、加工、菜品、食谱、订餐、出餐、质检 |
| 健康体检 | 体检计划、机构、项目、报告、统计 |
| 体重管理 | 体重档案、采集、目标、干预、提醒 |
| 运动管理 | 运动计划、器械、记录、统计、激励 |
| 健康监测 | 穿戴 / 体重秤 / 体脂仪 / 环境监测(室内/室外/水/花粉) |
| 健康评估 | 综合体检评估、风险分级、健康画像 |
| 知识普及 | 健康科普文章、视频、问答 |
| 健康数据 | 各类健康数据接入、清洗、查询 |
| 健康档案 | 个人 / 单位 健康档案聚合视图 |
| 心脑血管病预防 | 高危识别 / 干预方案 / 跟踪 |
| 糖尿病预防 | 同上(糖尿病专题) |
| 癌症预防 | 同上(癌症专题) |
| 专家咨询 | 视频问诊 + 小助手对话 + 评价 + 档案 |
| 应急就医 | 应急工单 / 大病就医 / 派单 / 救助 |
| 一线医疗 | 驻场医务工作站:日常诊疗 / 处置记录 / 转诊 |
### 1.3 C 端微信小程序 `miniprogram`
四个底部 Tab
| Tab | 含义 | 候选功能 |
|-----|------|---------|
| 吃 | 营养与订餐 | 营养首页、今日食谱、菜品详情、订餐、订单确认、购物车、就餐记录 |
| 问 | AI 助手 / 咨询 | AI 营养助手、AI 对话、历史对话、人工咨询入口 |
| 做 | 创作与社区 | 社区首页、照做食谱、创作食谱、排行、分享、热门、我的创作 |
| 我 | 个人中心 | 个人首页、健康档案、订单、收藏、余额、会员、消息、设置、扫码溯源 |
### 1.4 硬件终端屏 `hardware`
10 类设备,每类独立 viewport,互不影响:
| 设备 key | 名称 | 典型业务 | 备注 |
|---------|------|---------|------|
| smart-cabinet | 智能货柜 | 取菜 / 出柜 / 库存显示 | 通常竖屏 |
| stall-machine | 档口机 | 食堂档口出餐 / 称重 / 结算 | 横屏 |
| inbound-scale | 入库秤 | 毛菜入库称重 + 录入 | 横屏 |
| nutrition-scale | 营养秤 | 餐线上的营养数据采集 | 横屏 |
| disc-machine | 吐盘机 | 餐盘回收 | 竖屏 |
| cashier | 结算台 | 用户结算 / 扫码 | 横屏 |
| ratio-scale | 配比秤 | 配菜组合称重 | 横屏 |
| ribbon-screen | 带鱼屏 | 长条信息展示(食堂排队 / 菜单) | 超宽横屏 |
| stats-screen | 统计大屏 | 数据可视化大屏 | 1920×1080 / 4K |
| cooking-robot | 炒菜机器人 | 烹饪流程触控屏 | 横屏 |
> **每台设备的 viewport / 朝向 / 缩放**在 `src/meta/nav.ts` 的 `hardwareDevices` 数组中独立声明。新增设备 = 加一条记录,HardwareLayout 自动按此渲染外壳。
---
## 二、端隔离原则
- 4 端不允许互相 `import` 页面或私有组件
- 跨端共享只能通过:`@meta`(菜单数据)、`@components`(公共业务组件)、`@layouts`(共享布局)、`@composables`
- 同一业务(如"营养管理")会在多端各自有入口(tenant-portal 后台管 + miniprogram 吃 + hardware 智能货柜),但**各端的页面、字段范围、交互方式独立维护**
---
## 三、用户角色映射
> 用户后期完善。当前为占位推断:
```
平台管理员 → 综合管理后台(admin-portal
租户运营人员 → 租户运营后台(tenant-portal
员工 / 终端用户 → 微信小程序(miniprogram
食堂工作人员 → 硬件终端屏(智能货柜 / 档口机 / 结算台 / 配比秤 / 营养秤 / 入库秤 / 吐盘机 / 炒菜机器人)
公共空间 → 硬件终端屏(带鱼屏 / 统计大屏)
```
---
## 四、跨端协作关系
> 用 Mermaid 图描述跨端业务数据流向。建议在功能模块逐步落地后补充。
示例:
```mermaid
flowchart LR
mp[小程序 · 吃-订餐] -->|订单| t1[tenant-portal · 订餐订单]
t1 -->|食谱排期| t2[tenant-portal · 加工与出餐]
t2 -->|出餐指令| hw1[hardware · 档口机]
hw1 -->|出餐完成| hw2[hardware · 吐盘机]
hw2 -->|餐盘回收数据| t1
```
---
## 五、数据字典约定
跨多端复用的字典编码(如单位、部门、岗位、设备类型、菜品分类、营养元素等)由 `admin-portal` 定义;其他端通过 API 拉取使用。
具体字典清单见各模块 PRD(用 `/gen-prd` 自动汇总)。
+257
View File
@@ -0,0 +1,257 @@
<template>
<div class="hw-shell">
<!-- 顶部信息条设备类型 + viewport 尺寸 + 返回 -->
<header class="hw-shell__topbar">
<div class="hw-shell__brand">
<span class="hw-shell__logo">智养餐饮平台V3</span>
<span class="hw-shell__sub">原型 · 硬件终端屏</span>
</div>
<div class="hw-shell__device-info" v-if="currentDevice">
<a-tag color="processing">{{ currentDevice.label }}</a-tag>
<span class="hw-shell__viewport-text">
{{ currentDevice.viewport.width }} × {{ currentDevice.viewport.height }}
·
{{ currentDevice.viewport.orientation === 'portrait' ? '竖屏' : '横屏' }}
·
缩放 {{ (currentDevice.viewport.scale ?? 1) * 100 }}%
</span>
</div>
<div class="hw-shell__right">
<router-link to="/" class="hw-shell__home"> 返回原型首页</router-link>
</div>
</header>
<!-- 设备列表左侧 -->
<aside class="hw-shell__device-list">
<div class="hw-shell__device-list-title">硬件设备</div>
<a
v-for="device in visibleDevices"
:key="device.key"
class="hw-shell__device-item"
:class="{ 'hw-shell__device-item--active': device.key === currentDeviceKey }"
@click="onDeviceClick(device.key)"
>
<div class="hw-shell__device-item-name">{{ device.label }}</div>
<div class="hw-shell__device-item-meta">
{{ device.viewport.width }} × {{ device.viewport.height }}
</div>
</a>
</aside>
<!-- 中间 viewport 动态尺寸的设备外壳 -->
<main class="hw-shell__stage">
<div
v-if="currentDevice"
class="hw-device"
:style="deviceStyle"
>
<div class="hw-device__inner">
<router-view />
</div>
</div>
<div v-else class="hw-shell__empty">
请在左侧选择一台硬件设备
</div>
</main>
</div>
</template>
<script setup lang="ts">
/**
* 硬件终端屏布局
* - 左侧设备列表 / 中间设备外壳(按 viewport 动态调整尺寸)
* - 每台设备可独立设置 width × height、朝向、缩放
* - 设备列表按当前角色过滤
*/
import { hardwareDevices, type NavHardwareDevice } from '@meta/nav'
import { useVisibility, filterHardwareDevices } from '@composables/useVisibility'
const route = useRoute()
const router = useRouter()
const { role, overrides } = useVisibility()
/** 按角色过滤后的设备列表 */
const visibleDevices = computed(() => {
const effectiveRole = role.value ?? 'designer'
return filterHardwareDevices(hardwareDevices, effectiveRole, overrides.value)
})
/** 当前激活设备 key(来自 route.meta.device,由 routes.ts 注入) */
const currentDeviceKey = computed<string>(() =>
(route.meta.device as string) ?? visibleDevices.value[0]?.key ?? '',
)
const currentDevice = computed<NavHardwareDevice | undefined>(() =>
visibleDevices.value.find(d => d.key === currentDeviceKey.value),
)
/** 切换设备:跳到该设备第一个页面 */
const onDeviceClick = (deviceKey: string) => {
const device = visibleDevices.value.find(d => d.key === deviceKey)
if (!device) return
const firstPage = device.children[0]
if (!firstPage) return
void router.push(`/hardware/${device.key}/${firstPage.path}`)
}
/** 设备外壳 CSS:尺寸 + 朝向 + 缩放 */
const deviceStyle = computed(() => {
const d = currentDevice.value
if (!d) return {}
const scale = d.viewport.scale ?? 1
return {
width: `${d.viewport.width}px`,
height: `${d.viewport.height}px`,
transform: `scale(${scale})`,
transformOrigin: 'top center',
}
})
</script>
<style scoped lang="less">
.hw-shell {
min-height: 100vh;
display: grid;
grid-template-areas:
'topbar topbar'
'devices stage';
grid-template-rows: 56px 1fr;
grid-template-columns: 240px 1fr;
background: #0d1b2a;
color: #e0f0ff;
}
.hw-shell__topbar {
grid-area: topbar;
display: flex;
align-items: center;
padding: 0 24px;
background: #0a1628;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
gap: 24px;
}
.hw-shell__brand {
display: flex;
align-items: baseline;
gap: 8px;
}
.hw-shell__logo {
font-size: var(--font-lg);
font-weight: 600;
color: #fff;
}
.hw-shell__sub {
font-size: var(--font-xs);
color: rgba(255, 255, 255, 0.45);
}
.hw-shell__device-info {
display: flex;
align-items: center;
gap: 12px;
font-size: var(--font-sm);
color: rgba(255, 255, 255, 0.65);
}
.hw-shell__viewport-text {
font-family: 'Menlo', 'Consolas', monospace;
font-size: var(--font-xs);
}
.hw-shell__right {
margin-left: auto;
}
.hw-shell__home {
color: rgba(255, 255, 255, 0.65);
text-decoration: none;
font-size: var(--font-sm);
&:hover {
color: #fff;
}
}
.hw-shell__device-list {
grid-area: devices;
background: #0a1628;
border-right: 1px solid rgba(255, 255, 255, 0.05);
overflow-y: auto;
padding: 16px 0;
}
.hw-shell__device-list-title {
font-size: var(--font-xs);
color: rgba(255, 255, 255, 0.35);
padding: 0 24px 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.hw-shell__device-item {
display: block;
padding: 12px 24px;
text-decoration: none;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
border-left: 2px solid transparent;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.04);
}
&--active {
color: #fff;
background: rgba(22, 119, 255, 0.16);
border-left-color: var(--primary);
}
}
.hw-shell__device-item-name {
font-size: var(--font-sm);
font-weight: 500;
}
.hw-shell__device-item-meta {
font-size: var(--font-xs);
color: rgba(255, 255, 255, 0.35);
margin-top: 2px;
font-family: 'Menlo', 'Consolas', monospace;
}
.hw-shell__stage {
grid-area: stage;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 24px;
overflow: auto;
}
.hw-shell__empty {
color: rgba(255, 255, 255, 0.45);
font-size: var(--font-base);
padding-top: 80px;
}
.hw-device {
background: #fff;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
overflow: hidden;
flex-shrink: 0;
color: var(--text-primary);
}
.hw-device__inner {
width: 100%;
height: 100%;
overflow: auto;
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<template>
<!-- 外层桌面背景 + 居中的"手机壳"容器小程序 375×812 -->
<div class="mp-shell">
<div class="phone-shell">
<!-- 状态栏 -->
<div class="phone-shell__status">
<span>9:41</span>
<span class="phone-shell__title">小程序</span>
<span>100%</span>
</div>
<!-- 内容区可滚动 -->
<main class="phone-shell__content">
<router-view />
</main>
<!-- 底部 Tab -->
<AppTabBar :tabs="visibleTabs" :active-key="activeTabKey" @change="onTabChange" />
<!-- 底部安全区 -->
<div class="phone-shell__safe-area" />
</div>
<!-- 右上角返回首页 -->
<router-link to="/" class="mp-shell__home"> 返回原型首页</router-link>
</div>
</template>
<script setup lang="ts">
/**
* 小程序布局
* - 375×812 手机壳(iPhone X 逻辑分辨率,与小程序通用模拟器一致)
* - 底部 Vant Tabbar(按当前角色过滤)
*/
import { miniprogramTabs, type NavMiniprogramTab } from '@meta/nav'
import { useVisibility, filterMiniprogramTabs } from '@composables/useVisibility'
const route = useRoute()
const router = useRouter()
const { role, overrides } = useVisibility()
/** 按角色过滤后的 tabs */
const visibleTabs = computed(() => {
const effectiveRole = role.value ?? 'designer'
return filterMiniprogramTabs(miniprogramTabs, effectiveRole, overrides.value)
})
/** 当前激活 tab key */
const activeTabKey = computed<string>(() => (route.meta.tab as string) ?? visibleTabs.value[0]?.key ?? '')
/** Tab 切换 */
const onTabChange = (tab: NavMiniprogramTab) => {
void router.push(`/miniprogram/${tab.page.path}`)
}
</script>
<style scoped lang="less">
.mp-shell {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #e9edf2;
padding: 24px;
position: relative;
}
.mp-shell__home {
position: absolute;
top: 16px;
left: 24px;
color: #666;
text-decoration: none;
font-size: var(--font-sm);
&:hover {
color: var(--primary);
}
}
.phone-shell {
/** 小程序使用 iPhone X 逻辑分辨率 */
width: 375px;
height: 812px;
background: #fff;
border-radius: 44px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18);
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.phone-shell__status {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 24px;
background: #fff;
font-size: var(--font-sm);
color: var(--text-primary);
font-weight: 600;
flex-shrink: 0;
}
.phone-shell__title {
color: var(--text-secondary);
font-weight: 500;
}
.phone-shell__content {
flex: 1;
overflow-y: auto;
background: var(--background);
}
.phone-shell__safe-area {
height: 34px;
background: #fff;
flex-shrink: 0;
}
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<a-layout class="portal-layout">
<!-- 顶部Logo + 应用名 + 应用内一级菜单 + 用户区 -->
<TopBar
:apps="apps"
:current-app="isAppGrid ? undefined : currentApp"
:sections="isAppGrid ? [] : currentAppSections"
:current-section="currentSectionKey"
:show-app-grid="!isAppGrid"
:app-grid-path="portalPath"
:portal-title="portalTitle"
@section-change="onSectionChange"
@app-change="onAppChange"
@search="onSearch"
>
<template #extra>
<RoleBadge mode="compact" />
</template>
</TopBar>
<a-layout class="portal-layout__body">
<!-- 应用网格首页不显示侧边栏 / 不显示 PageHistory -->
<a-layout-content v-if="isAppGrid" class="portal-layout__content portal-layout__content--full">
<router-view />
</a-layout-content>
<!-- 进入具体应用侧边栏 + 主内容 PageHistory + 路由出口 -->
<template v-else>
<a-layout-sider class="portal-layout__sider" :width="200" theme="dark">
<Sidebar :section="currentSection" :active-key="currentLeafKey" @navigate="onLeafClick" />
</a-layout-sider>
<a-layout class="portal-layout__main">
<PageHistory :portal-key="portalKey" />
<a-layout-content class="portal-layout__content">
<router-view />
</a-layout-content>
</a-layout>
</template>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
/**
* 后台门户布局
* - 综合管理后台(admin)与租户运营后台(tenant)共用此 Layout
* - 通过 props.variant 切换菜单数据源、顶部副标题与颜色
* - 自动按当前角色 + 控制台覆写过滤菜单
*
* 层级(对齐 vue 项目):
* Portal -> NavApp (应用) -> NavSection (一级菜单) -> NavGroup/NavLeaf -> NavLeaf
*
* URL/<portal>/<app>/<section>/<page>
*/
import { getPortalApps, isNavGroup, type NavApp, type NavSection, type NavLeaf } from '@meta/nav'
import { useVisibility, filterApps } from '@composables/useVisibility'
const props = defineProps<{
variant: 'admin' | 'tenant'
}>()
const route = useRoute()
const router = useRouter()
const { role, overrides } = useVisibility()
/** 当前 portal 的 routerPath 前缀 */
const portalPath = computed(() => props.variant === 'admin' ? '/admin-portal' : '/tenant-portal')
/** 当前 portal key(用于 PageHistory 隔离) */
const portalKey = computed(() => props.variant === 'admin' ? 'admin-portal' : 'tenant-portal')
/** 当前 portal 的应用列表(按角色 + 覆写过滤) */
const apps = computed<NavApp[]>(() => {
const all = getPortalApps(props.variant === 'admin' ? 'admin-portal' : 'tenant-portal')
const effectiveRole = role.value ?? 'designer'
return filterApps(all, effectiveRole, overrides.value)
})
/** 是否处于应用网格首页(路由 meta.isAppGrid */
const isAppGrid = computed<boolean>(() => Boolean(route.meta.isAppGrid))
/** 顶部 portal 标题(用于应用网格首页 logo 区) */
const portalTitle = computed(() =>
props.variant === 'admin' ? '综合管理后台' : '租户运营后台',
)
/** 当前激活应用 key(从 route.meta 取,回退到第一个应用) */
const currentAppKey = computed<string>(() =>
(route.meta.app as string) ?? apps.value[0]?.key ?? '',
)
/** 当前激活应用对象 */
const currentApp = computed<NavApp | undefined>(() =>
apps.value.find(a => a.key === currentAppKey.value) ?? apps.value[0],
)
/** 当前应用下的一级菜单(sections) */
const currentAppSections = computed<NavSection[]>(() => currentApp.value?.children ?? [])
/** 当前激活一级菜单 key(从 route.meta 取) */
const currentSectionKey = computed<string>(() =>
(route.meta.section as string) ?? currentAppSections.value[0]?.key ?? '',
)
/** 当前激活一级菜单对象 */
const currentSection = computed<NavSection | undefined>(() =>
currentAppSections.value.find(s => s.key === currentSectionKey.value) ?? currentAppSections.value[0],
)
/** 当前激活叶子页面 key(即路由 name) */
const currentLeafKey = computed<string>(() => (route.name as string) ?? '')
/** 找一个 section 的第一个叶子 */
const firstLeafOfSection = (section: NavSection): NavLeaf | undefined => {
const first = section.children[0]
if (!first) return undefined
return isNavGroup(first) ? first.children[0] : first
}
/** 找一个 app 的第一个叶子(用于切换应用时跳转) */
const firstLeafOfApp = (app: NavApp): { section: NavSection; leaf: NavLeaf } | undefined => {
for (const s of app.children) {
const leaf = firstLeafOfSection(s)
if (leaf) return { section: s, leaf }
}
return undefined
}
/** 切换顶部一级菜单:跳到该 section 第一个页面 */
const onSectionChange = (sectionKey: string) => {
const section = currentAppSections.value.find(s => s.key === sectionKey)
if (!section) return
const leaf = firstLeafOfSection(section)
if (!leaf) return
void router.push(`${portalPath.value}/${currentAppKey.value}/${section.key}/${leaf.path}`)
}
/** 切换应用:跳到该应用第一个页面 */
const onAppChange = (appKey: string) => {
const app = apps.value.find(a => a.key === appKey)
if (!app) return
const entry = firstLeafOfApp(app)
if (!entry) return
void router.push(`${portalPath.value}/${app.key}/${entry.section.key}/${entry.leaf.path}`)
}
/** 点击侧边栏叶子:跳路由 */
const onLeafClick = (leaf: NavLeaf) => {
void router.push(`${portalPath.value}/${currentAppKey.value}/${currentSectionKey.value}/${leaf.path}`)
}
/** 顶部搜索菜单:直接跳到目标叶子 */
const onSearch = (leaf: NavLeaf, section: NavSection) => {
void router.push(`${portalPath.value}/${currentAppKey.value}/${section.key}/${leaf.path}`)
}
</script>
<style scoped lang="less">
.portal-layout {
min-height: 100vh;
&__body {
height: calc(100vh - 56px);
}
&__sider {
background: #001529;
overflow-y: auto;
}
&__main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
// 灰底 + 三面留白:让 PageHistory 与下方内容都"浮"起来,与 TopBar 视觉解耦
background: var(--background);
padding: 6px 12px 12px;
gap: 6px;
}
&__content {
padding: 16px;
background: #fff;
border-radius: 8px;
overflow-y: auto;
flex: 1;
}
&__content--full {
padding: 0;
background: var(--background);
}
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<template>
<van-tabbar
:model-value="activeKey"
:fixed="false"
active-color="#1890ff"
inactive-color="#999"
@change="onChange"
>
<van-tabbar-item v-for="tab in tabs" :key="tab.key" :name="tab.key" :icon="tab.icon">
{{ tab.label }}
</van-tabbar-item>
</van-tabbar>
</template>
<script setup lang="ts">
import type { NavMiniprogramTab } from '@meta/nav'
const props = defineProps<{
tabs: NavMiniprogramTab[]
activeKey: string
}>()
const emit = defineEmits<{ (e: 'change', tab: NavMiniprogramTab): void }>()
/** name 取了 tab.keychange 事件回传完整 NavMiniprogramTab */
const onChange = (key: string | number) => {
const hit = props.tabs.find(t => t.key === String(key))
if (hit) emit('change', hit)
}
</script>
<style scoped lang="less">
:deep(.van-tabbar) {
position: relative;
flex-shrink: 0;
}
</style>
+292
View File
@@ -0,0 +1,292 @@
<template>
<div class="page-history">
<!-- 左卡滚动控制 + Tag 列表圆角 8 0 0 8 -->
<a-card class="page-history__left" :bordered="false">
<a-button
type="text"
size="small"
class="page-history__scroll-btn"
:disabled="!canScrollLeft"
@click="scrollTags('left')"
>
<LeftOutlined />
</a-button>
<div ref="containerRef" class="page-history__tags">
<a-tag
v-for="item in list"
:key="item.path"
:closable="item.path !== currentPath"
:color="item.path === currentPath ? 'blue' : ''"
class="page-history__tag"
@click="goTo(item.path)"
@close="onClose(item.path)"
>
{{ item.title }}
</a-tag>
</div>
<a-button
type="text"
size="small"
class="page-history__scroll-btn"
:disabled="!canScrollRight"
@click="scrollTags('right')"
>
<RightOutlined />
</a-button>
</a-card>
<!-- 右卡菜单按钮圆角 0 8 8 0 -->
<a-card class="page-history__menu" :bordered="false">
<a-dropdown :trigger="['click']" placement="bottomRight" arrow>
<a-button type="text" size="small" class="page-history__menu-btn">
<MenuOutlined />
</a-button>
<template #overlay>
<a-menu @click="onMenuClick">
<a-menu-item key="refresh">
<RedoOutlined />&ensp;刷新当前页面
</a-menu-item>
<a-menu-item key="closeOthers">
<CloseOutlined />&ensp;关闭其他菜单
</a-menu-item>
<a-menu-item key="closeAll">
<DeleteOutlined />&ensp;关闭所有菜单
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-card>
</div>
</template>
<script setup lang="ts">
/**
* 页面历史(最近打开的菜单 Tabs)
* 严格对齐 vue 项目 D:\Work\platform-vue-tenant\apps\subXxx\src\layouts\pageHistory
*
* 视觉:
* - 两张白色圆角 a-card 拼接(左卡装滚动+Tag;右卡装菜单按钮)
* - 卡片之间留 2px 间隙
* - 整体浮在 PortalLayout__main 的灰底之上,与 TopBar 视觉独立
*
* 行为:
* - 无数量上限(vue 项目原样)
* - 点击 Tag 跳转;可关闭非当前页 Tag
* - 路由变化时自动滚动至激活 Tag
* - 左右箭头点击:滚动 ~两个 Tag 的距离
*/
import {
MenuOutlined, RedoOutlined, CloseOutlined, DeleteOutlined,
LeftOutlined, RightOutlined,
} from '@ant-design/icons-vue'
import type { MenuProps } from 'ant-design-vue'
import { usePageHistory } from '@composables/usePageHistory'
const props = defineProps<{ portalKey: string }>()
const router = useRouter()
const route = useRoute()
const containerRef = ref<HTMLDivElement | null>(null)
const canScrollLeft = ref(false)
const canScrollRight = ref(false)
const SCROLL_STEP_TAGS = 2
const { list, removePage, closeOthers, clear } = usePageHistory(props.portalKey)
const currentPath = computed(() => route.path)
// ============================== 跳转 / 关闭 ==============================
const goTo = (path: string) => {
if (path === currentPath.value) return
void router.push(path)
}
const onClose = (path: string) => {
const next = removePage(path)
if (path === currentPath.value) {
void router.push(next ?? '/')
}
}
const onMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'refresh': location.reload(); break
case 'closeOthers': closeOthers(currentPath.value); break
case 'closeAll': clear(); void router.push('/'); break
}
}
// ============================== 滚动控制 ==============================
const updateScrollState = (): void => {
const c = containerRef.value
if (!c) {
canScrollLeft.value = false
canScrollRight.value = false
return
}
canScrollLeft.value = c.scrollLeft > 0
const max = c.scrollWidth - c.clientWidth
canScrollRight.value = max > 0 && Math.abs(c.scrollLeft - max) > 1
}
const getEffectiveTagWidth = (): number => {
const c = containerRef.value
if (!c) return 0
const tag = c.querySelector('.ant-tag') as HTMLElement | null
if (!tag) return 0
const cs = window.getComputedStyle(tag)
const marginRight = parseFloat(cs.marginRight || '0')
return tag.offsetWidth + marginRight + 6 /* gap */
}
const scrollTags = (direction: 'left' | 'right'): void => {
const c = containerRef.value
if (!c) return
const tagW = getEffectiveTagWidth()
const step = tagW === 0 ? c.clientWidth / 2 : tagW * SCROLL_STEP_TAGS
let target = c.scrollLeft + (direction === 'right' ? step : -step)
const max = c.scrollWidth - c.clientWidth
target = Math.max(0, Math.min(max, target))
c.scrollTo({ left: target, behavior: 'smooth' })
setTimeout(updateScrollState, 300)
}
const scrollToCurrent = (): void => {
const c = containerRef.value
if (!c) return
const active = c.querySelector('.ant-tag-blue') as HTMLElement | null
if (!active) {
c.scrollTo({ left: 0, behavior: 'smooth' })
return
}
const cr = c.getBoundingClientRect()
const tr = active.getBoundingClientRect()
const visible = tr.left >= cr.left && tr.right <= cr.right
if (!visible) {
c.scrollTo({ left: active.offsetLeft - c.offsetLeft, behavior: 'smooth' })
}
}
const updateAndScroll = (): void => {
nextTick(() => {
scrollToCurrent()
updateScrollState()
})
}
watch(() => route.path, updateAndScroll, { immediate: true })
watch(list, updateAndScroll, { deep: true, flush: 'post' })
const onResize = (): void => {
nextTick(() => {
const c = containerRef.value
if (c) c.scrollLeft = c.scrollWidth - c.clientWidth
updateScrollState()
})
}
onMounted(() => {
onResize()
containerRef.value?.addEventListener('scroll', updateScrollState)
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
containerRef.value?.removeEventListener('scroll', updateScrollState)
window.removeEventListener('resize', onResize)
})
</script>
<style scoped lang="less">
.page-history {
display: flex;
align-items: stretch;
height: 40px;
gap: 2px;
flex-shrink: 0;
}
// 左卡:圆角 8 0 0 8,包含左右滚动按钮 + Tag 列表
.page-history__left {
flex: 1;
width: 0;
border-radius: 8px 0 0 8px;
overflow: hidden;
:deep(.ant-card-body) {
padding: 0 6px;
height: 100%;
display: flex;
align-items: center;
gap: 4px;
}
}
// 右卡:圆角 0 8 8 0,包含菜单按钮
.page-history__menu {
flex-shrink: 0;
border-radius: 0 8px 8px 0;
overflow: hidden;
:deep(.ant-card-body) {
padding: 0 6px;
height: 100%;
display: flex;
align-items: center;
}
}
// 滚动箭头:淡灰色,hover 变蓝
.page-history__scroll-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.35);
&:hover:not(:disabled) {
color: #1890ff;
}
&:disabled {
color: rgba(0, 0, 0, 0.15);
}
}
.page-history__tags {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
&::-webkit-scrollbar {
height: 0;
display: none;
}
}
.page-history__tag {
cursor: pointer;
user-select: none;
margin: 0;
flex-shrink: 0;
}
.page-history__menu-btn {
display: flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.65);
&:hover {
color: #1890ff;
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div class="role-badge-wrap">
<!-- full 模式原型首页用显示总览/控制台/角色徽章/登出 -->
<template v-if="mode === 'full'">
<a-button type="text" size="small" class="role-badge__link" @click="goOverview">🖼 页面总览</a-button>
<a-button
v-if="role === 'designer'"
type="text"
size="small"
class="role-badge__link role-badge__link--console"
@click="goConsole"
>
🎛 控制台
</a-button>
<a-dropdown :trigger="['click']" placement="bottomRight">
<a-tag :color="role === 'designer' ? 'magenta' : 'blue'" class="role-badge">
{{ role === 'designer' ? '设计模式' : '研发模式' }}
</a-tag>
<template #overlay>
<a-menu @click="onMenuClick">
<a-menu-item key="logout"><span>退出登录</span></a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<!-- compact 模式进入应用内时使用只保留下拉里"刷新菜单/打开全屏/退出登录" -->
<template v-else>
<a-dropdown :trigger="['click']" placement="bottomRight">
<span class="role-badge__avatar-trigger">
<component :is="DownOutlined" class="role-badge__arrow" />
</span>
<template #overlay>
<a-menu @click="onMenuClick">
<a-menu-item key="refresh">
<component :is="SyncOutlined" /> &ensp;刷新菜单
</a-menu-item>
<a-menu-item key="fullscreen">
<component :is="ExpandOutlined" /> &ensp;打开全屏
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<component :is="LogoutOutlined" /> &ensp;退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</div>
</template>
<script setup lang="ts">
/**
* 顶部角色标签 / 用户操作菜单
* - mode='full':原型导航首页用,显示页面总览/控制台/角色徽章/退出
* - mode='compact':进入具体应用后用,只保留下拉里的常用操作(对齐 vue 项目子应用 header 的用户下拉)
*/
import { useRouter } from 'vue-router'
import type { MenuProps } from 'ant-design-vue'
import { DownOutlined, SyncOutlined, ExpandOutlined, LogoutOutlined } from '@ant-design/icons-vue'
import { useVisibility } from '@composables/useVisibility'
withDefaults(defineProps<{
/** full:原型首页用;compactportal/小程序/硬件内用 */
mode?: 'full' | 'compact'
}>(), {
mode: 'full',
})
const router = useRouter()
const { role, setRole } = useVisibility()
const goOverview = () => router.push('/__overview')
const goConsole = () => router.push('/__console')
const onMenuClick: MenuProps['onClick'] = ({ key }) => {
switch (key) {
case 'logout':
setRole(null)
void router.replace('/gate')
break
case 'refresh':
location.reload()
break
case 'fullscreen':
if (document.fullscreenElement) {
void document.exitFullscreen()
} else {
void document.documentElement.requestFullscreen()
}
break
}
}
</script>
<style scoped lang="less">
.role-badge-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
}
.role-badge {
cursor: pointer;
margin: 0;
font-size: var(--font-xs);
user-select: none;
}
.role-badge__link {
color: rgba(255, 255, 255, 0.7);
padding: 0 8px;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
&--console {
color: #ffd591;
&:hover {
color: #fff;
background: rgba(250, 173, 20, 0.2);
}
}
}
.role-badge__avatar-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: rgba(255, 255, 255, 0.7);
&:hover {
color: #fff;
}
}
.role-badge__arrow {
font-size: 10px;
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<template>
<a-menu
v-if="section"
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
theme="dark"
mode="inline"
class="sidebar-menu"
@click="onMenuClick"
>
<template v-for="item in section.children" :key="item.key">
<!-- 二级分组含三级页面 -->
<a-sub-menu v-if="isNavGroup(item)" :key="`group-${item.key}`">
<template #title>
<component :is="item.icon ?? 'AppstoreOutlined'" />
<span>{{ item.label }}</span>
</template>
<a-menu-item v-for="leaf in item.children" :key="leaf.key">
<component :is="leaf.icon ?? 'MinusOutlined'" />
<span>{{ leaf.label }}</span>
</a-menu-item>
</a-sub-menu>
<!-- 直接挂在 section 下的二级叶子 -->
<a-menu-item v-else :key="item.key">
<component :is="item.icon ?? 'FileOutlined'" />
<span>{{ item.label }}</span>
</a-menu-item>
</template>
</a-menu>
</template>
<script setup lang="ts">
/**
* 侧边栏:渲染当前激活的一级菜单(NavSection)的 children
* - 图标统一使用 @ant-design/icons-vue(已在 main.ts 全量 app.component 注册)
* - 未显式指定图标时:分组用 AppstoreOutlined / 二级叶子用 FileOutlined / 三级叶子用 MinusOutlined
* - 对齐 vue 项目 D:\Work\platform-vue-tenant\apps\subNutritionManagement\src\layouts\menu
*/
import type { MenuProps } from 'ant-design-vue'
import { isNavGroup, type NavLeaf, type NavSection } from '@meta/nav'
const props = defineProps<{
section: NavSection | undefined
activeKey: string
}>()
const emit = defineEmits<{ (e: 'navigate', leaf: NavLeaf): void }>()
/** 当前包含激活叶子的分组 key(用于受控展开) */
const activeGroupKeys = computed<string[]>(() => {
if (!props.section) return []
const groups: string[] = []
for (const item of props.section.children) {
if (isNavGroup(item) && item.children.some(l => l.key === props.activeKey)) {
groups.push(`group-${item.key}`)
}
}
return groups
})
const openKeys = ref<string[]>([])
const selectedKeys = computed({
get: () => [props.activeKey],
set: () => { /* 受控 */ },
})
watch(
() => [props.section?.key, props.activeKey] as const,
() => {
if (activeGroupKeys.value.length) {
const merged = new Set([...openKeys.value, ...activeGroupKeys.value])
openKeys.value = Array.from(merged)
}
},
{ immediate: true },
)
const findLeafByKey = (key: string): NavLeaf | undefined => {
if (!props.section) return undefined
for (const item of props.section.children) {
if (isNavGroup(item)) {
const hit = item.children.find(l => l.key === key)
if (hit) return hit
} else if (item.key === key) {
return item
}
}
return undefined
}
const onMenuClick: MenuProps['onClick'] = (info) => {
const leaf = findLeafByKey(String(info.key))
if (leaf) emit('navigate', leaf)
}
</script>
<style scoped lang="less">
.sidebar-menu {
border-right: none;
height: 100%;
}
</style>
+329
View File
@@ -0,0 +1,329 @@
<template>
<header class="top-bar">
<!-- 左侧 Logo + 应用图标 + 应用名对齐 vue 项目 subXxx layouts/header -->
<div class="top-bar__logo">
<span class="top-bar__app-icon">{{ logoIcon }}</span>
<a-dropdown v-if="apps.length > 1 && currentApp" :trigger="['click']">
<span class="top-bar__app-name">
{{ currentApp.label }}
<DownOutlined class="top-bar__app-arrow" />
</span>
<template #overlay>
<a-menu @click="(info) => emit('appChange', String(info.key))">
<a-menu-item v-for="app in apps" :key="app.key">
<span v-if="app.icon" class="app-switch-icon">{{ app.icon }}</span>
<span>{{ app.label }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<span v-else class="top-bar__app-name">{{ currentApp?.label ?? portalTitle ?? '租户运营' }}</span>
</div>
<!-- 中部应用内一级菜单横向 a-menu, light 主题白底 -->
<div class="top-bar__menu">
<a-menu
v-if="sections.length > 0"
v-model:selectedKeys="selectedKeys"
theme="light"
mode="horizontal"
class="top-bar__menu-inner"
@click="onSectionClick"
>
<a-menu-item v-for="s in sections" :key="s.key">
<component :is="s.icon ?? 'AppstoreOutlined'" />
<span>{{ s.label }}</span>
</a-menu-item>
</a-menu>
</div>
<!-- 菜单搜索仅进入应用后显示对齐 vue 项目的 a-cascader -->
<div v-if="currentApp" class="top-bar__search">
<a-cascader
:options="searchOptions"
:value="searchValue"
placeholder="搜索菜单"
show-search
expand-trigger="hover"
:allow-clear="true"
:get-popup-container="getPopupContainer"
@change="onSearchChange"
/>
</div>
<!-- 右侧返回首页 + 用户区 + 扩展插槽 -->
<div class="top-bar__right">
<router-link to="/" class="top-bar__home">
<component :is="HomeOutlined" />
<span class="top-bar__home-text">返回原型首页</span>
</router-link>
<router-link v-if="showAppGrid" :to="appGridPath ?? '/'" class="top-bar__home">
<component :is="AppstoreOutlined" />
<span class="top-bar__home-text">应用首页</span>
</router-link>
<a-avatar class="top-bar__avatar"></a-avatar>
<span class="top-bar__user">系统管理员</span>
<slot name="extra" />
</div>
</header>
</template>
<script setup lang="ts">
import type { MenuProps, CascaderProps } from 'ant-design-vue'
import { DownOutlined, HomeOutlined, AppstoreOutlined } from '@ant-design/icons-vue'
import { isNavGroup, type NavApp, type NavLeaf, type NavSection, type NavGroup } from '@meta/nav'
/**
* 顶部栏:渲染当前激活应用的一级菜单 tabs
* 对齐 vue 项目 D:\Work\platform-vue-tenant\apps\subNutritionManagement\src\layouts\header\index.vue
*
* 视觉规范:
* - 左侧 Logo 区:深蓝 #001529,宽 220px,显示应用图标 + 应用名 ▾
* - 中部一级菜单:a-menu mode=horizontal theme=light(白底)
* - 菜单搜索:a-cascader(应用内所有菜单的两级联动)
* - 右侧:返回首页 + 应用首页 + 用户区
*/
const props = defineProps<{
/** 当前 portal 下所有应用(已按角色过滤) */
apps: NavApp[]
/** 当前激活应用(应用网格首页时为 undefined) */
currentApp?: NavApp
/** 当前激活应用内的一级菜单 sections */
sections: NavApp['children']
/** 当前激活一级菜单 key */
currentSection: string
/** 是否显示"应用首页"快捷入口 */
showAppGrid?: boolean
/** 应用网格首页路径 */
appGridPath?: string
/** Portal 标题(应用网格首页时显示) */
portalTitle?: string
}>()
const emit = defineEmits<{
(e: 'sectionChange', sectionKey: string): void
(e: 'appChange', appKey: string): void
(e: 'search', leaf: NavLeaf, section: NavSection): void
}>()
/** 左侧图标:进入应用后用 app.icon;应用网格首页用默认网格 */
const logoIcon = computed(() => props.currentApp?.icon ?? '🏢')
/** 当前选中项 */
const selectedKeys = computed({
get: () => [props.currentSection],
set: () => { /* 受控由父级 currentSection 决定 */ },
})
/** 点击一级菜单 */
const onSectionClick: MenuProps['onClick'] = (info) => {
emit('sectionChange', String(info.key))
}
// ============================== 菜单搜索 ==============================
type SearchOption = {
/** 用 NavSection.key 或 NavLeaf.key 作为唯一值 */
value: string
label: string
children?: SearchOption[]
/** 关联的叶子(仅叶子节点有) */
__leaf?: NavLeaf
/** 关联的一级菜单(用于搜索后跳转) */
__section?: NavSection
}
/** 把当前应用的菜单树转成 cascader 选项 */
const searchOptions = computed<SearchOption[]>(() => {
if (!props.currentApp) return []
return props.currentApp.children.map<SearchOption>((section) => ({
value: section.key,
label: section.label,
children: section.children.flatMap<SearchOption>((child) => {
if (isNavGroup(child)) {
// 分组:用 group.label 作为前缀显示
return child.children.map(leaf => ({
value: leaf.key,
label: `${(child as NavGroup).label} / ${leaf.label}`,
__leaf: leaf,
__section: section,
}))
}
return [{
value: child.key,
label: child.label,
__leaf: child,
__section: section,
}]
}),
}))
})
const searchValue = ref<string[]>([])
const onSearchChange: CascaderProps['onChange'] = (_value, selectedOptions) => {
const last = selectedOptions?.[selectedOptions.length - 1] as SearchOption | undefined
if (last?.__leaf && last.__section) {
emit('search', last.__leaf, last.__section)
}
searchValue.value = []
}
const getPopupContainer = (trigger: HTMLElement) => trigger.parentElement ?? document.body
</script>
<style scoped lang="less">
.top-bar {
display: flex;
flex-wrap: nowrap;
align-items: center;
height: 56px;
padding: 0;
background: #fff;
color: #1a1a2e;
flex-shrink: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
/* 左侧 Logo 区 — 深蓝背景固定 200px(与 vue 项目 logo 区一致) */
.top-bar__logo {
width: 200px;
min-width: 200px;
height: 100%;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
background: #001529;
box-sizing: border-box;
overflow: hidden;
}
.top-bar__app-icon {
font-size: 22px;
line-height: 1;
flex-shrink: 0;
}
.top-bar__app-name {
color: #fff;
font-size: 16px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.top-bar__app-arrow {
font-size: 10px;
color: rgba(255, 255, 255, 0.65);
}
/* 中部菜单 — 白底 a-menu horizontal */
.top-bar__menu {
flex: 1;
width: 0;
height: 100%;
overflow: hidden;
}
.top-bar__menu-inner {
background: #fff;
border-bottom: none;
line-height: 56px;
}
:deep(.top-bar__menu-inner.ant-menu-light) {
background: #fff !important;
border-bottom: none !important;
line-height: 56px;
}
:deep(.top-bar__menu-inner .ant-menu-item),
:deep(.top-bar__menu-inner .ant-menu-submenu-title) {
top: 0;
height: 56px;
line-height: 56px;
color: #595959;
}
:deep(.top-bar__menu-inner .ant-menu-item-selected) {
color: #1890ff;
font-weight: 600;
}
:deep(.top-bar__menu-inner .ant-menu-item:hover) {
color: #1890ff;
}
.top-bar__menu-icon {
margin-right: 6px;
font-size: 14px;
}
/* 菜单搜索 */
.top-bar__search {
display: flex;
align-items: center;
width: 200px;
margin-right: 16px;
@media screen and (max-width: 1100px) {
display: none;
}
}
/* 右侧 */
.top-bar__right {
display: flex;
align-items: center;
gap: 14px;
color: #595959;
font-size: 13px;
padding: 0 24px;
height: 100%;
}
.top-bar__home {
color: #595959;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
&:hover {
color: #1890ff;
}
}
.top-bar__home-text {
@media screen and (max-width: 900px) {
display: none;
}
}
.top-bar__avatar {
background-color: #1890ff;
flex-shrink: 0;
}
.top-bar__user {
white-space: nowrap;
color: #595959;
@media screen and (max-width: 720px) {
display: none;
}
}
.app-switch-icon {
display: inline-block;
margin-right: 6px;
}
</style>
+26
View File
@@ -0,0 +1,26 @@
/**
* 应用入口
* - 挂载 Vue 实例
* - 接入 vue-router
* - 引入 AntDV reset 样式 + 全局令牌
* - 组件库通过 unplugin-vue-components 按需自动注册(见 vite.config.ts
* - antd icons-vue 全量注册到 app.component,让 nav.ts 中字符串名能动态渲染
*/
import { createApp } from 'vue'
import App from './App.vue'
import router from '@router/index'
import 'ant-design-vue/dist/reset.css'
import './styles/global.less'
import * as Icons from '@ant-design/icons-vue'
const app = createApp(App)
// 全量注册 @ant-design/icons-vue(用于菜单/按钮通过字符串名动态引用)
for (const [name, comp] of Object.entries(Icons)) {
if (typeof comp === 'object' || typeof comp === 'function') {
app.component(name, comp as never)
}
}
app.use(router)
app.mount('#app')
+681
View File
@@ -0,0 +1,681 @@
/**
* 菜单数据单一来源
* - 同时供 router/routes.ts(生成路由)与 layouts/*(渲染菜单)消费
* - 新增页面只改这一处,路由 + 菜单同步生效
*
* 四个终端:
* - admin-portal 综合管理后台(AntDV 后台 UI)
* - tenant-portal 租户运营后台(AntDV 后台 UI;下挂 17 个独立应用)
* - miniprogram C 端微信小程序(Vant375×812;吃 / 问 / 做 / 我)
* - hardware 硬件终端屏(按设备类型动态 viewport;10 类设备)
*
* 后台菜单层级(对齐生产 vue 项目 D:\Work\platform-vue-tenant):
* Portal (终端)
* └─ NavApp (应用,对应 vue 项目的 subXxx 子应用,可独立部署)
* └─ NavSection (应用内一级菜单,对应顶部横向 tabs)
* └─ NavGroup (二级分组,可选) 或 NavLeaf (二级菜单/页面)
* └─ NavLeaf (三级页面)
*
* 图标规范:
* - NavApp.icon:应用图标,可自定义 emoji
* - NavSection / NavGroup / NavLeaf 的 icon**必须**使用 ant-design-vue 4.2 内置图标组件名
* (例:'FileTextOutlined' / 'AppstoreOutlined' / 'BarChartOutlined'
* - 渲染:layouts 用 `<component :is="icon" />` 动态渲染
*/
/** 终端类型 */
export type PortalKey = 'admin-portal' | 'tenant-portal' | 'miniprogram' | 'hardware'
/** 页面叶子节点(最小可路由单元) */
export interface NavLeaf {
/** 唯一 key,作为 vue-router name */
key: string
/** 菜单显示文字 */
label: string
/** 相对路径片段(不含 / 前缀,由父级拼接) */
path: string
/** 页面组件懒加载 */
component: () => Promise<unknown>
/** 可选图标(AntDV / Vant 图标名 / emoji */
icon?: string
/** 设计完成度,影响研发受众可见性 */
status?: 'draft' | 'review' | 'ready'
/** 受众限制,'designer' 仅设计可见;默认 'all' */
audience?: 'designer' | 'all'
}
/** 二级分组(侧边栏 SubMenu,里面装三级叶子) */
export interface NavGroup {
key: string
label: string
/** 可选图标 */
icon?: string
children: NavLeaf[]
}
/** 应用内一级菜单(顶部横向 tab,里面装二级菜单/分组) */
export interface NavSection {
/** 唯一 key */
key: string
/** tab 标题 */
label: string
/** 可选图标 */
icon?: string
/** 侧边栏内容:分组 或 叶子 */
children: (NavLeaf | NavGroup)[]
}
/**
* 应用(独立可部署单元,对应生产 vue 项目的 subXxx
* - 在租户首页以卡片方式展示
* - 点击卡片进入应用 → 顶部显示 sections 作为 tabs
*/
export interface NavApp {
/** 唯一 key(同时作为路由路径片段,如 nutrition) */
key: string
/** 应用名(首页卡片标题 / 顶部应用名) */
label: string
/** 应用图标(emoji 或图标名) */
icon?: string
/** 应用描述(首页卡片副标题) */
description?: string
/** 应用所属分组(首页分类展示,如"预防"/"救治"/"数据" */
category?: 'prevention' | 'rescue' | 'data' | 'system'
/** 应用内一级菜单(顶部横向 tabs) */
children: NavSection[]
}
/** 小程序底部 Tab */
export interface NavMiniprogramTab {
key: string
label: string
/** Vant 图标名 */
icon: string
/** Tab 默认显示的页面(首页) */
page: NavLeaf
/** Tab 内的子页面(如详情页),参与路由但不显示在底部 Tab */
subPages?: NavLeaf[]
}
/** 硬件设备的视口规格 */
export interface NavHardwareViewport {
/** 设备宽度(CSS px */
width: number
/** 设备高度(CSS px */
height: number
/** 朝向 */
orientation: 'portrait' | 'landscape'
/** 视口缩放(用于在桌面上预览大屏,0.5 = 缩到一半) */
scale?: number
}
/** 硬件设备类型(每种设备有独立 viewport 与页面集) */
export interface NavHardwareDevice {
key: string
label: string
viewport: NavHardwareViewport
/** 该设备包含的页面 */
children: NavLeaf[]
}
// =========================== 工具:占位 / 工厂 ===========================
/** PlaceholderPage 公共占位组件 */
const PlaceholderImport = () => import('@components/PlaceholderPage.vue')
/** 生成占位叶子节点(页面组件统一为 PlaceholderPage */
const placeholder = (
key: string,
label: string,
path: string,
status: NavLeaf['status'] = 'draft',
): NavLeaf => ({
key,
label,
path,
status,
component: PlaceholderImport,
})
/** 简易占位应用工厂:生成只有一个"<应用>首页"占位的应用 */
const makePlaceholderApp = (
key: string,
label: string,
icon?: string,
description?: string,
category?: NavApp['category'],
): NavApp => ({
key,
label,
icon,
description,
category,
children: [
{
key: `${key}-default`,
label,
children: [
placeholder(`${key}-overview`, `${label}首页`, 'overview'),
],
},
],
})
// =========================== 综合管理后台 ===========================
/** 综合管理后台应用列表(粗粒度占位) */
export const adminApps: NavApp[] = [
makePlaceholderApp('system-settings', '系统设置', '⚙️', '平台级用户、角色、权限、字典', 'system'),
makePlaceholderApp('operation-center', '运营中心', '📊', '租户管理、版本管理、计费', 'system'),
makePlaceholderApp('device-center', '设备中心', '🖥️', '硬件设备注册、监控、运维', 'system'),
]
// =========================== 租户运营后台 ===========================
/**
* 营养管理应用(nutrition
* 7 个一级菜单:溯源总览 / 档案 / 绿色种采 / 卫生供应 / 健康生产 / 营养用餐 / 异常事件
* 菜单结构迁移自 D:\Work\platform-prototype\web-admin\nutrition(老 html 原型)
*/
const nutritionApp: NavApp = {
key: 'nutrition',
label: '营养管理',
icon: '🥗',
description: '从田间到餐桌的全链路营养与配餐管理',
category: 'prevention',
children: [
{
key: 'nutrition-trace',
label: '溯源总览',
icon: 'NodeIndexOutlined',
children: [
placeholder('nutrition-trace-query', '溯源查询', 'query'),
placeholder('nutrition-trace-timeline', '链路可视化', 'timeline'),
],
},
{
key: 'nutrition-arc',
label: '档案',
icon: 'FolderOpenOutlined',
children: [
{ key: 'nutrition-arc-employee', label: '员工营养数据', path: 'employee', status: 'ready',
component: () => import('@pages/tenant-portal/nutrition/arc/arcEmployee/arcEmployee.vue') },
{ key: 'nutrition-arc-unit', label: '单位报表数据', path: 'unit', status: 'draft',
component: () => import('@pages/tenant-portal/nutrition/arc/ArcUnit.vue') },
placeholder('nutrition-arc-canteen', '场所报表数据', 'canteen'),
placeholder('nutrition-arc-algo', '算法模型', 'algo'),
],
},
{
key: 'nutrition-farm',
label: '绿色种采',
icon: 'EnvironmentOutlined',
children: [
{ key: 'nutrition-farm-land', label: '土地环境', path: 'land', status: 'draft',
component: () => import('@pages/tenant-portal/nutrition/farm/FarmLand.vue') },
{
key: 'nutrition-farm-monitor',
label: '种养监控',
icon: 'MonitorOutlined',
children: [
placeholder('nutrition-farm-monitor-crop', '作物种植监控', 'monitor/crop'),
placeholder('nutrition-farm-monitor-livestock', '畜禽养殖监控', 'monitor/livestock'),
],
},
placeholder('nutrition-farm-harvest', '收成入库', 'harvest'),
placeholder('nutrition-farm-supplier', '供商管理', 'supplier'),
placeholder('nutrition-farm-purchase', '采供管理', 'purchase'),
],
},
{
key: 'nutrition-sup',
label: '卫生供应',
icon: 'SafetyCertificateOutlined',
children: [
placeholder('nutrition-sup-hygiene', '卫生检测', 'hygiene-check'),
placeholder('nutrition-sup-warehouse', '仓储管理', 'warehouse'),
placeholder('nutrition-sup-in-raw', '毛菜入库', 'in-raw'),
placeholder('nutrition-sup-in-clean', '净菜加工', 'in-clean'),
placeholder('nutrition-sup-in-cleanin', '净菜入库', 'in-cleanin'),
placeholder('nutrition-sup-pkg-label', '净菜包装', 'pkg-label'),
placeholder('nutrition-sup-clean-internal', '净菜内供', 'clean-internal'),
placeholder('nutrition-sup-clean-external', '净菜外售', 'clean-external'),
],
},
{
key: 'nutrition-prod',
label: '健康生产',
icon: 'ExperimentOutlined',
children: [
placeholder('nutrition-prod-qc-daily', '食安监控', 'qc-daily'),
placeholder('nutrition-prod-recipe-plan', '食谱规划', 'recipe-plan'),
placeholder('nutrition-prod-clean-order', '净菜订单', 'clean-order'),
placeholder('nutrition-prod-cabinet', '净菜进柜', 'cabinet'),
placeholder('nutrition-prod-combo', '净菜组配', 'combo'),
placeholder('nutrition-prod-cook', '按单烹制', 'cook'),
placeholder('nutrition-prod-serve', '上餐监控', 'serve'),
placeholder('nutrition-prod-surplus', '余量处置', 'surplus'),
placeholder('nutrition-prod-stats', '生产统计', 'stats'),
],
},
{
key: 'nutrition-din',
label: '营养用餐',
icon: 'CoffeeOutlined',
children: [
placeholder('nutrition-din-canteen', '场所管理', 'canteen'),
placeholder('nutrition-din-dict', '字典管理', 'dict'),
placeholder('nutrition-din-qc-nutrition', '营养检验', 'qc-nutrition'),
placeholder('nutrition-din-ingredient', '食物成分', 'ingredient'),
placeholder('nutrition-din-dish-all', '餐品总库', 'dish-all'),
{
key: 'nutrition-din-algo',
label: '算法标准',
icon: 'CalculatorOutlined',
children: [
placeholder('nutrition-din-algo-population', '人群基础标准', 'algo/population'),
placeholder('nutrition-din-algo-vip', 'VIP定制标准', 'algo/vip'),
],
},
placeholder('nutrition-din-recipe', '食谱管理', 'recipe'),
placeholder('nutrition-din-order-meal', '营养点餐', 'order-meal'),
placeholder('nutrition-din-user', '用户管理', 'user'),
placeholder('nutrition-din-order-line', '餐线管理', 'order-line'),
placeholder('nutrition-din-order-pickup', '取餐管理', 'order-pickup'),
placeholder('nutrition-din-consume', '消费管理', 'consume'),
placeholder('nutrition-din-data-nutri', '营养管理', 'data-nutri'),
placeholder('nutrition-din-data-benefit', '餐材效益', 'data-benefit'),
placeholder('nutrition-din-terminal', '终端管理', 'terminal'),
{
key: 'nutrition-din-model',
label: '模型管理',
icon: 'RobotOutlined',
children: [
placeholder('nutrition-din-model-llm', '大模型管理', 'model/llm'),
placeholder('nutrition-din-model-small', '小模型管理', 'model/small'),
],
},
placeholder('nutrition-din-innovation', '味养创新', 'innovation'),
],
},
{
key: 'nutrition-ano',
label: '异常事件',
icon: 'WarningOutlined',
children: [
placeholder('nutrition-ano-dirty', '系统脏数据', 'dirty'),
placeholder('nutrition-ano-result', '结果异常', 'result'),
],
},
],
}
/**
* 健康监测应用(monitor
* 6 个一级菜单:档案 / 健康排查 / 穿戴监测 / 体重监测 / 环境监测 / 异常事件
* 菜单结构迁移自 D:\Work\platform-prototype\web-admin\health-monitormenu_parsed.txt
*/
const monitorApp: NavApp = {
key: 'monitor',
label: '健康监测',
icon: '⌚',
description: '人员排查、穿戴、体重、环境多维健康监测',
category: 'prevention',
children: [
{
key: 'monitor-archive',
label: '档案',
icon: 'FolderOpenOutlined',
children: [
placeholder('monitor-archive-personal', '个人监测数据', 'personal'),
placeholder('monitor-archive-unit', '单位报表数据', 'unit'),
placeholder('monitor-archive-algo', '算法模型', 'algo'),
],
},
{
key: 'monitor-checkup',
label: '健康排查',
icon: 'FileSearchOutlined',
children: [
{
key: 'monitor-checkup-survey',
label: '排查问卷',
icon: 'FormOutlined',
children: [
placeholder('monitor-checkup-survey-category', '问题分类', 'survey/category'),
placeholder('monitor-checkup-survey-score', '积分规则', 'survey/score'),
placeholder('monitor-checkup-survey-template', '模板信息', 'survey/template'),
placeholder('monitor-checkup-survey-question', '问题管理', 'survey/question'),
],
},
placeholder('monitor-checkup-maxim', '格言管理', 'maxim'),
{
key: 'monitor-checkup-record',
label: '排查记录',
icon: 'SolutionOutlined',
children: [
placeholder('monitor-checkup-record-user', '用户排查', 'record/user'),
placeholder('monitor-checkup-record-dept', '部门排查', 'record/dept'),
],
},
],
},
{
key: 'monitor-wearable',
label: '穿戴监测',
icon: 'HeartOutlined',
children: [
placeholder('monitor-wearable-tool', '工具管理', 'tool'),
placeholder('monitor-wearable-bind', '绑定记录', 'bind'),
placeholder('monitor-wearable-user-data', '用户数据', 'user-data'),
placeholder('monitor-wearable-exception', '异常事件', 'exception'),
placeholder('monitor-wearable-notify', '通知消息', 'notify'),
placeholder('monitor-wearable-dept-stat', '部门数据统计', 'dept-stat'),
placeholder('monitor-wearable-personal-stat', '个人数据统计', 'personal-stat'),
],
},
{
key: 'monitor-weight',
label: '体重监测',
icon: 'DashboardOutlined',
children: [
{
key: 'monitor-weight-scale',
label: '体重秤',
icon: 'ColumnHeightOutlined',
children: [
placeholder('monitor-weight-scale-device', '设备管理', 'scale/device'),
placeholder('monitor-weight-scale-monitor', '设备监测', 'scale/monitor'),
placeholder('monitor-weight-scale-stat', '设备统计', 'scale/stat'),
],
},
{
key: 'monitor-weight-bluetooth',
label: '蓝牙称',
icon: 'BulbOutlined',
children: [
placeholder('monitor-weight-bluetooth-device', '设备管理', 'bluetooth/device'),
placeholder('monitor-weight-bluetooth-monitor', '设备监测', 'bluetooth/monitor'),
placeholder('monitor-weight-bluetooth-stat', '设备统计', 'bluetooth/stat'),
],
},
{
key: 'monitor-weight-fat',
label: '体脂仪',
icon: 'RadarChartOutlined',
children: [
placeholder('monitor-weight-fat-device', '设备管理', 'fat/device'),
placeholder('monitor-weight-fat-monitor', '设备监测', 'fat/monitor'),
placeholder('monitor-weight-fat-stat', '设备统计', 'fat/stat'),
],
},
{
key: 'monitor-weight-sport',
label: '运动器械',
icon: 'ThunderboltOutlined',
children: [
placeholder('monitor-weight-sport-device', '设备管理', 'sport/device'),
placeholder('monitor-weight-sport-monitor', '设备监测', 'sport/monitor'),
placeholder('monitor-weight-sport-stat', '设备统计', 'sport/stat'),
],
},
placeholder('monitor-weight-employee', '员工数据', 'employee'),
],
},
{
key: 'monitor-env',
label: '环境监测',
icon: 'CloudOutlined',
children: [
{
key: 'monitor-env-indoor',
label: '室内空气',
icon: 'HomeOutlined',
children: [
placeholder('monitor-env-indoor-device-list', '设备列表', 'indoor/device-list'),
placeholder('monitor-env-indoor-device-category', '设备分类', 'indoor/device-category'),
placeholder('monitor-env-indoor-data-monitor', '数据监测', 'indoor/data-monitor'),
placeholder('monitor-env-indoor-exception-alarm', '异常报警', 'indoor/exception-alarm'),
placeholder('monitor-env-indoor-app-record', '应用记录', 'indoor/app-record'),
placeholder('monitor-env-indoor-history-data', '历史数据', 'indoor/history-data'),
],
},
{
key: 'monitor-env-outdoor',
label: '室外空气',
icon: 'GlobalOutlined',
children: [
placeholder('monitor-env-outdoor-device-list', '设备列表', 'outdoor/device-list'),
placeholder('monitor-env-outdoor-device-category', '设备分类', 'outdoor/device-category'),
placeholder('monitor-env-outdoor-data-monitor', '数据监测', 'outdoor/data-monitor'),
placeholder('monitor-env-outdoor-exception-alarm', '异常报警', 'outdoor/exception-alarm'),
placeholder('monitor-env-outdoor-app-record', '应用记录', 'outdoor/app-record'),
placeholder('monitor-env-outdoor-history-data', '历史数据', 'outdoor/history-data'),
],
},
{
key: 'monitor-env-water',
label: '水质监测',
icon: 'CloudDownloadOutlined',
children: [
placeholder('monitor-env-water-device-list', '设备列表', 'water/device-list'),
placeholder('monitor-env-water-device-category', '设备分类', 'water/device-category'),
placeholder('monitor-env-water-data-monitor', '数据监测', 'water/data-monitor'),
placeholder('monitor-env-water-exception-alarm', '异常报警', 'water/exception-alarm'),
placeholder('monitor-env-water-app-record', '应用记录', 'water/app-record'),
placeholder('monitor-env-water-history-data', '历史数据', 'water/history-data'),
],
},
{
key: 'monitor-env-pollen',
label: '花粉监测',
icon: 'BugOutlined',
children: [
placeholder('monitor-env-pollen-device-list', '设备列表', 'pollen/device-list'),
placeholder('monitor-env-pollen-device-category', '设备分类', 'pollen/device-category'),
placeholder('monitor-env-pollen-data-monitor', '数据监测', 'pollen/data-monitor'),
placeholder('monitor-env-pollen-exception-alarm', '异常报警', 'pollen/exception-alarm'),
placeholder('monitor-env-pollen-app-record', '应用记录', 'pollen/app-record'),
placeholder('monitor-env-pollen-history-data', '历史数据', 'pollen/history-data'),
placeholder('monitor-env-pollen-knowledge', '知识科普', 'pollen/knowledge'),
],
},
],
},
{
key: 'monitor-ano',
label: '异常事件',
icon: 'WarningOutlined',
children: [
placeholder('monitor-ano-dirty', '系统脏数据', 'dirty'),
placeholder('monitor-ano-result', '结果异常', 'result'),
],
},
],
}
/**
* 租户运营后台应用列表(17 个独立应用)
* - 营养管理 / 健康监测:完整菜单结构(来自老原型)
* - 其余 15 个:暂为占位应用(单一首页叶子)
*/
export const tenantApps: NavApp[] = [
makePlaceholderApp('console', '控制台', '🏠', '租户运营驾驶舱', 'system'),
makePlaceholderApp('system', '系统管理', '⚙️', '组织、用户、角色、权限', 'system'),
nutritionApp,
makePlaceholderApp('checkup', '健康体检', '🩺', '员工体检计划与结果跟踪', 'prevention'),
makePlaceholderApp('weight', '体重管理', '⚖️', '员工体重、体脂、BMI 数据管理', 'prevention'),
makePlaceholderApp('exercise', '运动管理', '🏃', '运动处方与数据追踪', 'prevention'),
monitorApp,
makePlaceholderApp('assessment', '健康评估', '📋', '健康风险评估与报告', 'prevention'),
makePlaceholderApp('knowledge', '知识普及', '📚', '健康知识库与科普推送', 'prevention'),
makePlaceholderApp('data', '健康数据', '📊', '统一健康数据中心与看板', 'data'),
makePlaceholderApp('record', '健康档案', '📁', '员工电子健康档案', 'data'),
makePlaceholderApp('cardiovascular', '心脑血管病预防', '❤️', '心脑血管病专项筛查与干预', 'prevention'),
makePlaceholderApp('diabetes', '糖尿病预防', '🩸', '糖尿病专项筛查与干预', 'prevention'),
makePlaceholderApp('cancer', '癌症预防', '🎗️', '癌症专项筛查与干预', 'prevention'),
makePlaceholderApp('consult', '专家咨询', '👨‍⚕️', '专家在线咨询与预约', 'rescue'),
makePlaceholderApp('emergency', '应急就医', '🚑', '应急就医调度与跟踪', 'rescue'),
makePlaceholderApp('frontline', '一线医疗', '🏥', '一线医疗站点与人员管理', 'rescue'),
]
// =========================== C 端微信小程序 ===========================
/** 小程序四个底部 Tab:吃 / 问 / 做 / 我 */
export const miniprogramTabs: NavMiniprogramTab[] = [
{
key: 'eat',
label: '吃',
icon: 'shop-o',
page: {
key: 'mp-eat-home', label: '吃', path: 'eat',
component: () => import('@pages/miniprogram/eat/Home.vue'),
status: 'draft',
},
},
{
key: 'ask',
label: '问',
icon: 'chat-o',
page: {
key: 'mp-ask-home', label: '问', path: 'ask',
component: () => import('@pages/miniprogram/ask/Home.vue'),
status: 'draft',
},
},
{
key: 'make',
label: '做',
icon: 'apps-o',
page: {
key: 'mp-make-home', label: '做', path: 'make',
component: () => import('@pages/miniprogram/make/Home.vue'),
status: 'draft',
},
},
{
key: 'me',
label: '我',
icon: 'user-o',
page: {
key: 'mp-me-home', label: '我', path: 'me',
component: () => import('@pages/miniprogram/me/Home.vue'),
status: 'draft',
},
},
]
// =========================== 硬件终端屏 ===========================
/**
* 硬件设备:10 类
* - 每类独立 viewportwidth × height × orientation × scale
* - 桌面预览时 scale 用于缩放外壳
* - 新增设备只需在数组中加一条记录
*/
export const hardwareDevices: NavHardwareDevice[] = [
{
key: 'smart-cabinet',
label: '智能货柜',
viewport: { width: 1080, height: 1920, orientation: 'portrait', scale: 0.4 },
children: [
{ key: 'cabinet-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/smart-cabinet/Home.vue'), status: 'draft' },
],
},
{
key: 'stall-machine',
label: '档口机',
viewport: { width: 1920, height: 1080, orientation: 'landscape', scale: 0.4 },
children: [
{ key: 'stall-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/stall-machine/Home.vue'), status: 'draft' },
],
},
{
key: 'inbound-scale',
label: '入库秤',
viewport: { width: 1280, height: 800, orientation: 'landscape', scale: 0.55 },
children: [
{ key: 'inbound-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/inbound-scale/Home.vue'), status: 'draft' },
],
},
{
key: 'nutrition-scale',
label: '营养秤',
viewport: { width: 1280, height: 800, orientation: 'landscape', scale: 0.55 },
children: [
{ key: 'nutrition-scale-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/nutrition-scale/Home.vue'), status: 'draft' },
],
},
{
key: 'disc-machine',
label: '吐盘机',
viewport: { width: 1080, height: 1920, orientation: 'portrait', scale: 0.4 },
children: [
{ key: 'disc-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/disc-machine/Home.vue'), status: 'draft' },
],
},
{
key: 'cashier',
label: '结算台',
viewport: { width: 1920, height: 1080, orientation: 'landscape', scale: 0.4 },
children: [
{ key: 'cashier-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/cashier/Home.vue'), status: 'draft' },
],
},
{
key: 'ratio-scale',
label: '配比秤',
viewport: { width: 1280, height: 800, orientation: 'landscape', scale: 0.55 },
children: [
{ key: 'ratio-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/ratio-scale/Home.vue'), status: 'draft' },
],
},
{
key: 'ribbon-screen',
label: '带鱼屏',
viewport: { width: 3840, height: 1080, orientation: 'landscape', scale: 0.2 },
children: [
{ key: 'ribbon-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/ribbon-screen/Home.vue'), status: 'draft' },
],
},
{
key: 'stats-screen',
label: '统计大屏',
viewport: { width: 1920, height: 1080, orientation: 'landscape', scale: 0.45 },
children: [
{ key: 'stats-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/stats-screen/Home.vue'), status: 'draft' },
],
},
{
key: 'cooking-robot',
label: '炒菜机器人',
viewport: { width: 1280, height: 800, orientation: 'landscape', scale: 0.55 },
children: [
{ key: 'cooking-home', label: '首页', path: 'home',
component: () => import('@pages/hardware/cooking-robot/Home.vue'), status: 'draft' },
],
},
]
// =========================== 工具:类型守卫 ===========================
/** 类型守卫:item 是否为分组(含 children 字段且 children[0] 不是 NavGroup */
export const isNavGroup = (item: NavLeaf | NavGroup): item is NavGroup => {
return (item as NavGroup).children !== undefined
}
// =========================== 工具:按 Portal 取数 ===========================
/** 按 portalKey 取得对应的应用数组(admin / tenant */
export const getPortalApps = (key: 'admin-portal' | 'tenant-portal'): NavApp[] => {
return key === 'admin-portal' ? adminApps : tenantApps
}
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="设备中心 - 首页"
name="admin-portal/device-center/overview"
>
综合管理后台占位设备中心 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 设备中心 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="运营中心 - 首页"
name="admin-portal/operation-center/overview"
>
综合管理后台占位运营中心 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 运营中心 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="系统设置 - 首页"
name="admin-portal/system-settings/overview"
>
综合管理后台占位系统设置 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 系统设置 首页占位 */
</script>
+285
View File
@@ -0,0 +1,285 @@
<template>
<div class="vc">
<header class="vc__header">
<h1 class="vc__title">设计完成度控制台</h1>
<p class="vc__sub">控制各页面在研发模式下的可见性所有变更存储在 localStoragevisibility.overrides不写入 nav.ts</p>
<div class="vc__toolbar">
<a-input-search
v-model:value="keyword"
placeholder="搜索 页面 key / label / 模块"
style="width: 320px"
allow-clear
/>
<a-radio-group v-model:value="filterPortal" button-style="solid">
<a-radio-button value="all">全部</a-radio-button>
<a-radio-button value="admin-portal">管理后台</a-radio-button>
<a-radio-button value="tenant-portal">租户后台</a-radio-button>
<a-radio-button value="miniprogram">小程序</a-radio-button>
<a-radio-button value="hardware">硬件</a-radio-button>
</a-radio-group>
<a-radio-group v-model:value="filterVisible" button-style="solid">
<a-radio-button value="all">所有</a-radio-button>
<a-radio-button value="visible">研发可见</a-radio-button>
<a-radio-button value="hidden">研发不可见</a-radio-button>
</a-radio-group>
<a-button danger @click="onResetAll">重置所有覆写</a-button>
<RouterLink to="/" class="vc__back"> 返回首页</RouterLink>
</div>
</header>
<main class="vc__main">
<a-table
:columns="columns"
:data-source="rows"
:pagination="{ pageSize: 50, showSizeChanger: true }"
row-key="key"
size="middle"
:scroll="{ x: 'max-content' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'portal'">
<a-tag :color="portalColor(record.portal)">{{ portalLabel(record.portal) }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-select
:value="effectiveStatus(record)"
:options="statusOptions"
size="small"
style="width: 96px"
@update:value="(v: 'draft' | 'review' | 'ready') => updateOverride(record.key, { status: v })"
/>
</template>
<template v-else-if="column.key === 'audience'">
<a-select
:value="effectiveAudience(record)"
:options="audienceOptions"
size="small"
style="width: 100px"
@update:value="(v: 'designer' | 'all') => updateOverride(record.key, { audience: v })"
/>
</template>
<template v-else-if="column.key === 'visibleToDev'">
<a-tag :color="record.visibleToDev ? 'success' : 'error'">
{{ record.visibleToDev ? '可见' : '隐藏' }}
</a-tag>
</template>
<template v-else-if="column.key === 'overridden'">
<a-tag v-if="record.overridden" color="warning">已覆写</a-tag>
<span v-else style="color: #ccc"></span>
</template>
<template v-else-if="column.key === 'action'">
<a-button
type="link"
size="small"
:disabled="!record.overridden"
@click="onClearRow(record.key)"
>
清除覆写
</a-button>
</template>
</template>
</a-table>
</main>
</div>
</template>
<script setup lang="ts">
/**
* 设计完成度控制台
* - 仅 designer 角色可访问(守卫在 routes.ts 中处理;非 designer 重定向首页)
* - 列表罗列全部叶子页面 + 控制 status / audience
* - 变更立即生效(localStorage + 响应式)
*/
import type { TableColumnsType } from 'ant-design-vue'
import {
adminApps,
tenantApps,
miniprogramTabs,
hardwareDevices,
isNavGroup,
type NavLeaf,
type PortalKey,
} from '@meta/nav'
import {
useVisibility,
isLeafVisible,
type VisibilityOverride,
} from '@composables/useVisibility'
interface Row {
key: string
label: string
portal: PortalKey
group: string
rawStatus?: 'draft' | 'review' | 'ready'
rawAudience?: 'designer' | 'all'
overridden: boolean
visibleToDev: boolean
}
const { overrides, updateOverride, resetOverrides } = useVisibility()
const keyword = ref('')
const filterPortal = ref<'all' | PortalKey>('all')
const filterVisible = ref<'all' | 'visible' | 'hidden'>('all')
/** 收集全部叶子页面 + 端 + 应用 / 一级菜单 / 分组 归属 */
const allLeaves = computed<Row[]>(() => {
const list: Row[] = []
const push = (leaf: NavLeaf, portal: PortalKey, group: string) => {
const ovr = overrides.value[leaf.key]
list.push({
key: leaf.key,
label: leaf.label,
portal,
group,
rawStatus: leaf.status,
rawAudience: leaf.audience,
overridden: Boolean(ovr),
visibleToDev: isLeafVisible(leaf, 'dev', overrides.value),
})
}
const pushApps = (apps: typeof adminApps, portal: PortalKey) => {
apps.forEach((app) => {
app.children.forEach((section) => {
section.children.forEach((item) => {
if (isNavGroup(item)) {
item.children.forEach(l =>
push(l, portal, `${app.label} / ${section.label} / ${item.label}`))
} else {
push(item, portal, `${app.label} / ${section.label}`)
}
})
})
})
}
pushApps(adminApps, 'admin-portal')
pushApps(tenantApps, 'tenant-portal')
miniprogramTabs.forEach((tab) => {
push(tab.page, 'miniprogram', tab.label)
tab.subPages?.forEach(l => push(l, 'miniprogram', `${tab.label} / 子页面`))
})
hardwareDevices.forEach((dev) => {
dev.children.forEach(l => push(l, 'hardware', dev.label))
})
return list
})
/** 应用搜索/筛选 */
const rows = computed<Row[]>(() => {
const kw = keyword.value.trim().toLowerCase()
return allLeaves.value.filter((r) => {
if (filterPortal.value !== 'all' && r.portal !== filterPortal.value) return false
if (filterVisible.value === 'visible' && !r.visibleToDev) return false
if (filterVisible.value === 'hidden' && r.visibleToDev) return false
if (!kw) return true
return r.key.toLowerCase().includes(kw)
|| r.label.toLowerCase().includes(kw)
|| r.group.toLowerCase().includes(kw)
})
})
/** 表格列 */
const columns: TableColumnsType = [
{ title: '端', key: 'portal', width: 110, fixed: 'left' },
{ title: '所属模块', dataIndex: 'group', width: 200 },
{ title: '页面 key', dataIndex: 'key', width: 180 },
{ title: '页面名称', dataIndex: 'label', width: 160 },
{ title: '完成度', key: 'status', width: 120 },
{ title: '受众', key: 'audience', width: 120 },
{ title: '研发视角', key: 'visibleToDev', width: 100 },
{ title: '覆写', key: 'overridden', width: 80 },
{ title: '操作', key: 'action', width: 110, fixed: 'right' },
]
const statusOptions = [
{ value: 'draft', label: 'draft' },
{ value: 'review', label: 'review' },
{ value: 'ready', label: 'ready' },
]
const audienceOptions = [
{ value: 'all', label: 'all' },
{ value: 'designer', label: 'designer' },
]
const portalLabel = (p: PortalKey) => ({
'admin-portal': '管理后台',
'tenant-portal': '租户后台',
'miniprogram': '小程序',
'hardware': '硬件',
})[p]
const portalColor = (p: PortalKey) => ({
'admin-portal': 'blue',
'tenant-portal': 'green',
'miniprogram': 'purple',
'hardware': 'orange',
})[p]
/** 取生效后的 status / audience(覆写优先,否则用默认) */
const effectiveStatus = (r: Row) =>
overrides.value[r.key]?.status ?? r.rawStatus ?? 'ready'
const effectiveAudience = (r: Row) =>
overrides.value[r.key]?.audience ?? r.rawAudience ?? 'all'
/** 清除单行覆写 */
const onClearRow = (key: string) => {
updateOverride(key, { status: undefined, audience: undefined } as VisibilityOverride)
}
/** 重置全部覆写 */
const onResetAll = () => {
resetOverrides()
}
</script>
<style scoped lang="less">
.vc {
min-height: 100vh;
background: var(--background);
padding: 24px 32px;
}
.vc__header {
margin-bottom: 16px;
}
.vc__title {
margin: 0;
font-size: var(--font-xxl);
color: var(--text-primary);
}
.vc__sub {
margin: 8px 0 16px;
color: var(--text-secondary);
font-size: var(--font-sm);
}
.vc__toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.vc__back {
margin-left: auto;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-sm);
&:hover { color: var(--primary); }
}
.vc__main {
background: #fff;
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-sm);
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<template>
<div class="gate">
<div class="gate__card">
<div class="gate__logo">智养餐饮平台V3 · 原型</div>
<div class="gate__title">访问授权</div>
<div class="gate__sub">请输入访问密码</div>
<a-form layout="vertical" class="gate__form" @submit.prevent="onSubmit">
<a-form-item :validate-status="error ? 'error' : ''" :help="error">
<a-input-password
v-model:value="password"
placeholder="输入密码"
size="large"
autofocus
@press-enter="onSubmit"
/>
</a-form-item>
<a-button type="primary" size="large" block :loading="loading" @click="onSubmit">
进入原型
</a-button>
</a-form>
<div class="gate__tip">
登录后 7 天内无需再次输入<br>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 门禁页
* - 两套密码:designer / dev
* - 登录成功写入 localStorage(角色 + 7 天过期)
* - 简洁纯白居中风格
*/
import { useRouter, useRoute } from 'vue-router'
import { useVisibility, type Role } from '@composables/useVisibility'
const router = useRouter()
const route = useRoute()
const { setRole } = useVisibility()
const password = ref('')
const error = ref('')
const loading = ref(false)
/** 校验密码 → 设置角色 → 跳转到 redirect 或首页 */
const onSubmit = () => {
error.value = ''
const value = password.value.trim()
if (!value) {
error.value = '请输入密码'
return
}
const designerPwd = import.meta.env.VITE_GATE_PASSWORD_DESIGNER
const devPwd = import.meta.env.VITE_GATE_PASSWORD_DEV
let role: Role | null = null
if (value === designerPwd) role = 'designer'
else if (value === devPwd) role = 'dev'
if (!role) {
error.value = '密码错误'
return
}
loading.value = true
setRole(role)
// 跳转到登录前要去的路径,默认首页
const redirect = (route.query.redirect as string) || '/'
void router.replace(redirect)
}
</script>
<style scoped lang="less">
.gate {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
padding: 24px;
}
.gate__card {
width: 360px;
padding: 40px 32px 32px;
background: #fff;
border-radius: var(--radius-lg);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.08);
text-align: center;
}
.gate__logo {
font-size: var(--font-base);
color: var(--text-secondary);
margin-bottom: 24px;
letter-spacing: 1px;
}
.gate__title {
font-size: var(--font-xxl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.gate__sub {
font-size: var(--font-sm);
color: var(--text-secondary);
margin-bottom: 32px;
}
.gate__form {
text-align: left;
}
.gate__tip {
margin-top: 24px;
font-size: var(--font-xs);
color: var(--text-placeholder);
line-height: 1.6;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="结算台 - 首页"
name="hardware/cashier/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 结算台 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="炒菜机器人 - 首页"
name="hardware/cooking-robot/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 炒菜机器人 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="吐盘机 - 首页"
name="hardware/disc-machine/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 吐盘机 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="入库秤 - 首页"
name="hardware/inbound-scale/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 入库秤 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="营养秤 - 首页"
name="hardware/nutrition-scale/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 营养秤 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="配比秤 - 首页"
name="hardware/ratio-scale/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 配比秤 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="带鱼屏 - 首页"
name="hardware/ribbon-screen/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 带鱼屏 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="智能货柜 - 首页"
name="hardware/smart-cabinet/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 智能货柜 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="档口机 - 首页"
name="hardware/stall-machine/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 档口机 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="统计大屏 - 首页"
name="hardware/stats-screen/home"
>
硬件占位本设备的 viewport朝向缩放参数在 nav.ts hardwareDevices 中独立声明
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 统计大屏 首页占位 */
</script>
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
<template>
<AppPlaceholder
title="小程序 - 问"
name="miniprogram/ask"
>
占位 Tab 下属页面后续根据 PRD 拓展
</AppPlaceholder>
</template>
<script setup lang="ts">
/** 小程序 问 Tab 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<AppPlaceholder
title="小程序 - 吃"
name="miniprogram/eat"
>
占位 Tab 下属页面后续根据 PRD 拓展
</AppPlaceholder>
</template>
<script setup lang="ts">
/** 小程序 吃 Tab 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<AppPlaceholder
title="小程序 - 做"
name="miniprogram/make"
>
占位 Tab 下属页面后续根据 PRD 拓展
</AppPlaceholder>
</template>
<script setup lang="ts">
/** 小程序 做 Tab 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<AppPlaceholder
title="小程序 - 我"
name="miniprogram/me"
>
占位 Tab 下属页面后续根据 PRD 拓展
</AppPlaceholder>
</template>
<script setup lang="ts">
/** 小程序 我 Tab 首页占位 */
</script>
+544
View File
@@ -0,0 +1,544 @@
<template>
<div class="ov">
<!-- 顶部 sticky 导航 -->
<header class="ov__nav">
<span class="ov__title">设计总览</span>
<a-radio-group v-model:value="portal" button-style="solid" size="small">
<a-radio-button value="miniprogram">小程序</a-radio-button>
<a-radio-button value="hardware">硬件</a-radio-button>
</a-radio-group>
<a-select
v-model:value="moduleKey"
:options="moduleOptions"
placeholder="选择模块"
style="width: 220px"
size="small"
allow-clear
/>
<div class="ov__nav-spacer" />
<!-- 模块内页面锚点含弹窗 / 状态变体计数 -->
<a
v-for="g in groups"
:key="g.pageKey"
class="ov__anchor"
:href="`#sec-${g.pageKey}`"
>
{{ g.pageLabel }}
<span class="ov__anchor-badge">{{ g.images.length }}</span>
</a>
<RouterLink to="/" class="ov__back"> 返回首页</RouterLink>
</header>
<!-- 主内容按页面分组 -->
<main class="ov__main">
<div v-if="groups.length === 0" class="ov__empty">
<div class="ov__empty-icon">📷</div>
<div class="ov__empty-title">该模块暂无截图</div>
<div class="ov__empty-tip">
请用 <code>/design-overview</code> skill 把截图存到
<code>src/prd/{{ portal }}/{{ moduleKey || '<模块>' }}/screenshots/&lt;页面&gt;/&lt;&gt;.jpg</code>
</div>
</div>
<section
v-for="g in groups"
:key="g.pageKey"
class="ov__section"
:id="`sec-${g.pageKey}`"
>
<div class="ov__section-header">
<div class="ov__section-title">{{ g.pageLabel }}</div>
<div class="ov__section-count">{{ g.images.length }} </div>
</div>
<div class="ov__row">
<div
v-for="img in g.images"
:key="img.src"
class="ov__card"
@click="openLightbox(img)"
>
<div
class="ov__card-img"
:class="[`ov__card-img--${portal}`, img.kind === 'modal' ? 'ov__card-img--modal' : '']"
>
<img :src="img.src" :alt="img.label" loading="lazy" />
</div>
<div class="ov__card-label">{{ img.label }}</div>
<div class="ov__card-tags">
<span v-if="img.kind === 'modal'" class="ov__tag ov__tag--modal">弹窗</span>
<span v-else-if="img.kind === 'state'" class="ov__tag ov__tag--state">{{ img.stateName }}</span>
</div>
</div>
</div>
</section>
</main>
<!-- 灯箱 -->
<transition name="fade">
<div v-if="lightbox" class="ov__lightbox" @click="lightbox = null">
<span class="ov__lightbox-close">&times;</span>
<img :src="lightbox.src" :alt="lightbox.label" />
<div class="ov__lightbox-name">{{ lightbox.fullPath }}</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
/**
* 设计总览(截图浏览器版)
* - 自动收集 src/prd/**\/screenshots/**\/*.{jpg,jpeg,png}
* - 按端 → 模块 → 页面 三层分组
* - 按文件名解析:default / modal-* / dialog-* / state-* / empty / loading / error / tab-* / expanded / collapsed 等
* - 暗色背景 + 横向滚动卡片 + 弹窗橙标 / 状态蓝标 + 灯箱放大
*
* 仅支持 miniprogram / hardwareWeb 后台用 PRD 替代
*/
import { useRoute, useRouter } from 'vue-router'
import {
miniprogramTabs,
hardwareDevices,
} from '@meta/nav'
type Portal = 'miniprogram' | 'hardware'
interface ImageItem {
/** 文件 URLvite 处理后) */
src: string
/** 截图文件路径(用于灯箱底部展示) */
fullPath: string
/** 截图类型 */
kind: 'default' | 'modal' | 'state'
/** 显示文字 */
label: string
/** 状态名(state 类专属) */
stateName?: string
}
interface PageGroup {
pageKey: string
pageLabel: string
images: ImageItem[]
}
const route = useRoute()
const router = useRouter()
// 端 / 模块(URL 同步)
const portal = ref<Portal>((route.query.portal as Portal) || 'miniprogram')
const moduleKey = ref<string>((route.query.module as string) || '')
watch([portal, moduleKey], ([p, m]) => {
void router.replace({ query: { ...route.query, portal: p, module: m || undefined } })
})
// 模块下拉选项
const moduleOptions = computed(() => {
if (portal.value === 'miniprogram') {
return miniprogramTabs.map(t => ({ value: t.key, label: t.label }))
}
return hardwareDevices.map(d => ({ value: d.key, label: d.label }))
})
// ---------- 收集截图 ----------
/** 收集 src/prd 下所有截图为 URLeager 一次性加载(图片是 vite asset,不会膨胀 JS */
const allShots = import.meta.glob('/src/prd/**/screenshots/**/*.{jpg,jpeg,png}', {
eager: true,
query: '?url',
import: 'default',
}) as Record<string, string>
/** 解析一个截图路径 → 元数据 */
const parseShotPath = (absPath: string) => {
// 路径形如:/src/prd/miniprogram/eat/screenshots/home/default.jpg
const rel = absPath.replace(/^\/src\/prd\//, '')
const segs = rel.split('/')
// segs: [端, 模块, 'screenshots', ...页面 path 段, 文件名]
if (segs.length < 5 || segs[2] !== 'screenshots') return null
const portalKey = segs[0] as Portal
const moduleKey = segs[1]
const pageSegs = segs.slice(3, -1)
const filename = segs[segs.length - 1]
const stateKey = filename.replace(/\.(jpg|jpeg|png)$/i, '')
return {
portalKey,
moduleKey,
pageKey: pageSegs.join('/'),
stateKey,
fullPath: rel,
}
}
/** 根据 state key 推断类型与显示文案 */
const classifyState = (stateKey: string): { kind: 'default' | 'modal' | 'state'; label: string; stateName?: string } => {
if (stateKey === 'default') return { kind: 'default', label: '默认' }
if (/^(modal|dialog|popup)-/i.test(stateKey)) {
const name = stateKey.replace(/^(modal|dialog|popup)-/i, '')
return { kind: 'modal', label: name, stateName: name }
}
if (/^(state|tab)-/i.test(stateKey)) {
const name = stateKey.replace(/^(state|tab)-/i, '')
return { kind: 'state', label: name, stateName: name }
}
// 常见状态裸名
const stateKeywords = ['empty', 'loading', 'error', 'expanded', 'collapsed', 'success']
if (stateKeywords.includes(stateKey.toLowerCase())) {
return { kind: 'state', label: stateKey, stateName: stateKey }
}
return { kind: 'state', label: stateKey, stateName: stateKey }
}
/** 当前端 + 模块下的分组数据 */
const groups = computed<PageGroup[]>(() => {
const buckets = new Map<string, PageGroup>()
Object.entries(allShots).forEach(([abs, url]) => {
const meta = parseShotPath(abs)
if (!meta) return
if (meta.portalKey !== portal.value) return
if (moduleKey.value && meta.moduleKey !== moduleKey.value) return
const groupKey = `${meta.moduleKey}/${meta.pageKey}`
let group = buckets.get(groupKey)
if (!group) {
group = { pageKey: groupKey, pageLabel: meta.pageKey, images: [] }
buckets.set(groupKey, group)
}
const cls = classifyState(meta.stateKey)
group.images.push({
src: url,
fullPath: meta.fullPath,
kind: cls.kind,
label: cls.label,
stateName: cls.stateName,
})
})
// 同组内:default 优先,其次弹窗,最后状态
buckets.forEach((g) => {
g.images.sort((a, b) => {
const order = { default: 0, modal: 1, state: 2 }
if (order[a.kind] !== order[b.kind]) return order[a.kind] - order[b.kind]
return a.label.localeCompare(b.label)
})
})
return Array.from(buckets.values()).sort((a, b) => a.pageKey.localeCompare(b.pageKey))
})
// ---------- 灯箱 ----------
const lightbox = ref<ImageItem | null>(null)
const openLightbox = (img: ImageItem) => {
lightbox.value = img
}
// ESC 关闭灯箱
const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') lightbox.value = null
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<style scoped lang="less">
.ov {
min-height: 100vh;
background: #1a1a2e;
color: #e0e0e0;
}
// ========== 顶部 sticky 导航 ==========
.ov__nav {
position: sticky;
top: 0;
z-index: 100;
background: #16213e;
border-bottom: 1px solid #0f3460;
padding: 14px 24px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.ov__title {
font-size: 16px;
font-weight: 600;
color: #4fc3f7;
margin-right: 12px;
white-space: nowrap;
}
.ov__nav-spacer {
flex: 1;
min-width: 0;
}
.ov__anchor {
font-size: 12px;
color: #aaa;
text-decoration: none;
padding: 4px 10px;
border-radius: 12px;
border: 1px solid transparent;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
&:hover {
background: #0f3460;
color: #4fc3f7;
border-color: #4fc3f7;
}
}
.ov__anchor-badge {
font-size: 10px;
padding: 0 6px;
border-radius: 8px;
background: #2a2a4a;
color: #888;
}
.ov__back {
font-size: 12px;
color: #888;
text-decoration: none;
margin-left: 8px;
&:hover { color: #4fc3f7; }
}
// ========== 主内容 ==========
.ov__main {
padding: 16px 24px 40px;
}
.ov__empty {
text-align: center;
padding: 100px 24px;
color: #888;
}
.ov__empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.ov__empty-title {
font-size: 16px;
color: #ccc;
margin-bottom: 12px;
}
.ov__empty-tip {
font-size: 13px;
line-height: 1.8;
code {
background: #0f3460;
color: #4fc3f7;
padding: 2px 8px;
border-radius: 4px;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
}
}
// ========== 分组 ==========
.ov__section {
padding: 16px 0 8px;
border-bottom: 1px solid #2a2a4a;
&:last-child { border-bottom: none; }
}
.ov__section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.ov__section-title {
font-size: 14px;
font-weight: 600;
color: #fff;
}
.ov__section-count {
font-size: 11px;
color: #888;
background: #2a2a4a;
padding: 2px 8px;
border-radius: 10px;
}
// ========== 横向滚动 + 卡片 ==========
.ov__row {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 16px;
scrollbar-width: thin;
scrollbar-color: #444 transparent;
&::-webkit-scrollbar { height: 6px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
}
.ov__card {
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s;
&:hover { transform: scale(1.02); }
}
.ov__card-img {
border-radius: 12px;
overflow: hidden;
border: 2px solid #2a2a4a;
background: #111;
transition: border-color 0.2s;
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
display: block;
}
&--miniprogram {
width: 240px;
height: 520px; /* 375 × 812 → 240 × 520 */
}
&--hardware {
width: 320px;
height: 200px; /* 横屏比例缩放 */
}
&--modal {
border-color: rgba(255, 169, 64, 0.4);
}
}
.ov__card:hover .ov__card-img {
border-color: #4fc3f7;
&--modal { border-color: #ffa940; }
}
.ov__card-label {
margin-top: 8px;
font-size: 12px;
color: #999;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.ov__card-tags {
display: flex;
justify-content: center;
gap: 4px;
margin-top: 4px;
flex-wrap: wrap;
}
.ov__tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
&--modal {
background: #4a2800;
color: #ffa940;
}
&--state {
background: #002a4a;
color: #4fc3f7;
}
}
// ========== 灯箱 ==========
.ov__lightbox {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.ov__lightbox img {
max-width: 90vw;
max-height: 92vh;
border-radius: 8px;
cursor: default;
}
.ov__lightbox-close {
position: absolute;
top: 16px;
right: 20px;
color: #fff;
font-size: 24px;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&:hover { background: rgba(255, 255, 255, 0.2); }
}
.ov__lightbox-name {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: #ccc;
font-size: 12px;
font-family: 'Menlo', 'Consolas', monospace;
background: rgba(0, 0, 0, 0.6);
padding: 6px 16px;
border-radius: 20px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+371
View File
@@ -0,0 +1,371 @@
<template>
<div class="prd">
<!-- 顶部 sticky 工具栏 -->
<div class="prd__topbar">
<button class="prd__back" @click="goBack"> 返回</button>
<span class="prd__title">{{ docTitle || '文档预览' }}</span>
<span class="prd__path">{{ currentPath }}</span>
</div>
<!-- 内容主区单栏 900px 居中 -->
<div class="prd__main">
<article
v-if="renderedHtml"
ref="articleRef"
class="prd__article markdown-body"
v-html="renderedHtml"
@click="onArticleClick"
/>
<div v-else class="prd__empty">
<div class="prd__empty-icon">📄</div>
<div class="prd__empty-text">未指定文档或文档不存在</div>
<div class="prd__empty-hint">请从首页产品需求文档Tab 选择 PRD 进入</div>
</div>
</div>
<!-- 图片放大遮罩 -->
<div v-if="lightboxSrc" class="lightbox" @click="lightboxSrc = ''">
<img :src="lightboxSrc" alt="放大预览" />
</div>
</div>
</template>
<script setup lang="ts">
/**
* PRD 在线查看(参考旧项目 _templates/md-viewer.html 重做)
* - 顶部 sticky 工具栏:返回 + 文档标题 + 路径
* - 内容单栏 900px 居中,无侧边树(入口已在首页 PRD Tab 分类列出)
* - 图片点击放大(Lightbox 遮罩)
* - markdown-it 渲染,截图路径通过 import.meta.glob 解析为 vite 资源 URL
*/
import MarkdownIt from 'markdown-it'
import { useRoute, useRouter } from 'vue-router'
// ---------------- 收集 PRD 与截图 ----------------
/** 收集 src/prd 下全部 md(按 raw 读取) */
const prdModules = import.meta.glob('/src/prd/**/*.md', {
query: '?raw',
import: 'default',
}) as Record<string, () => Promise<string>>
/** 收集截图为可访问 URL */
const screenshotUrls = import.meta.glob(
'/src/prd/**/screenshots/**/*.{jpg,jpeg,png,gif,webp}',
{ eager: true, query: '?url', import: 'default' },
) as Record<string, string>
// ---------------- markdown 渲染 ----------------
const md = new MarkdownIt({ html: false, linkify: true, breaks: false, typographer: true })
/** 截图 src 重写:把相对路径解析为 vite 处理后的 URL */
const rewriteImageSrc = (htmlSource: string, mdPath: string): string => {
const baseDir = mdPath.replace(/[^/]+$/, '')
return htmlSource.replace(/<img\s+([^>]*?)src="([^"]+)"([^>]*)>/g, (_m, pre, src, post) => {
if (/^(https?:|\/)/.test(src)) return `<img ${pre}src="${src}"${post}>`
const resolved = (baseDir + src).replace(/[^/]+\/\.\.\//g, '')
const url = screenshotUrls[resolved]
return url
? `<img ${pre}src="${url}"${post}>`
: `<img ${pre}src="${src}"${post} alt="缺图: ${src}" style="opacity:0.4">`
})
}
const renderedHtml = ref('')
const currentPath = ref('')
const docTitle = ref('')
const lightboxSrc = ref('')
const articleRef = ref<HTMLElement | null>(null)
const route = useRoute()
const router = useRouter()
/** 渲染指定 md path */
const loadPath = async (mdPath: string) => {
const loader = prdModules[mdPath]
if (!loader) {
renderedHtml.value = ''
currentPath.value = mdPath.replace(/^\/src\/prd\//, '')
return
}
const raw = await loader()
const html = md.render(raw)
renderedHtml.value = rewriteImageSrc(html, mdPath)
currentPath.value = mdPath.replace(/^\/src\/prd\//, '')
}
/** 返回:优先回退,否则回首页 */
const goBack = () => {
if (window.history.length > 1) router.back()
else void router.push('/')
}
/** 文章内点击:若点击的是 img 则打开 Lightbox */
const onArticleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target.tagName === 'IMG') {
const src = (target as HTMLImageElement).src
if (src) lightboxSrc.value = src
}
}
/** URL ?path=xxx 同步 */
watch(
() => route.query.path,
(qPath) => {
if (typeof qPath !== 'string') {
renderedHtml.value = ''
currentPath.value = ''
docTitle.value = ''
return
}
void loadPath(`/src/prd/${qPath}`)
},
{ immediate: true },
)
/** URL ?title=xxx 同步 */
watch(
() => route.query.title,
(qTitle) => {
docTitle.value = typeof qTitle === 'string' ? qTitle : ''
},
{ immediate: true },
)
/** ESC 关闭 Lightbox */
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') lightboxSrc.value = ''
}
onMounted(() => window.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
</script>
<style scoped lang="less">
.prd {
min-height: 100vh;
background: #f5f7fa;
color: #1a1a2e;
}
// ========== 顶部工具栏 ==========
.prd__topbar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e8ecf1;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.prd__back {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 13px;
color: #1677ff;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #e6f7ff;
}
}
.prd__title {
font-size: 15px;
font-weight: 600;
color: #1a1a2e;
}
.prd__path {
margin-left: auto;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
color: #999;
}
// ========== 内容主区 ==========
.prd__main {
max-width: 900px;
margin: 0 auto;
padding: 32px 40px 60px;
background: #fff;
min-height: calc(100vh - 50px);
}
.prd__article {
font-size: 14px;
line-height: 1.8;
color: #1a1a2e;
}
.prd__empty {
text-align: center;
padding: 120px 0 80px;
color: #999;
}
.prd__empty-icon {
font-size: 56px;
opacity: 0.4;
}
.prd__empty-text {
font-size: 15px;
margin-top: 16px;
color: #5a607f;
}
.prd__empty-hint {
font-size: 12px;
margin-top: 8px;
color: #b0b6c5;
}
// ========== Markdown 渲染(参考旧项目 md-viewer 排版) ==========
.markdown-body {
:deep(h1) {
font-size: 24px;
font-weight: 800;
margin: 0 0 16px;
padding-bottom: 10px;
border-bottom: 2px solid #e8ecf1;
}
:deep(h2) {
font-size: 20px;
font-weight: 700;
margin: 32px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e8ecf1;
}
:deep(h3) {
font-size: 17px;
font-weight: 600;
margin: 24px 0 8px;
}
:deep(h4) {
font-size: 15px;
font-weight: 600;
margin: 20px 0 6px;
}
:deep(p) {
font-size: 14px;
line-height: 1.8;
margin: 8px 0;
}
:deep(ul),
:deep(ol) {
padding-left: 20px;
margin: 8px 0;
font-size: 14px;
line-height: 1.8;
}
:deep(li) {
margin: 4px 0;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
:deep(th) {
background: #fafafa;
font-weight: 600;
text-align: left;
padding: 8px 12px;
border: 1px solid #e8ecf1;
}
:deep(td) {
padding: 8px 12px;
border: 1px solid #e8ecf1;
}
:deep(code) {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
}
:deep(pre) {
background: #f6f8fa;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin: 12px 0;
}
:deep(pre code) {
background: none;
padding: 0;
font-size: 13px;
line-height: 1.6;
}
:deep(blockquote) {
border-left: 4px solid #1677ff;
padding: 8px 16px;
margin: 12px 0;
background: #e6f7ff;
border-radius: 0 4px 4px 0;
}
:deep(blockquote p) {
color: #5a607f;
margin: 0;
}
:deep(strong) {
color: #1a1a2e;
}
:deep(hr) {
border: none;
border-top: 1px solid #e8ecf1;
margin: 24px 0;
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid #e8ecf1;
margin: 12px 0;
cursor: zoom-in;
transition: opacity 0.15s;
}
:deep(img:hover) {
opacity: 0.85;
}
:deep(a) {
color: #1677ff;
}
}
// ========== Lightbox 图片放大遮罩 ==========
.lightbox {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
img {
max-width: 92vw;
max-height: 92vh;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
}
</style>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="健康评估 - 首页"
name="tenant-portal/assessment/overview"
>
租户运营后台占位健康评估 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 健康评估 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="癌症预防 - 首页"
name="tenant-portal/cancer/overview"
>
租户运营后台占位癌症预防 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 癌症预防 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="心脑血管病预防 - 首页"
name="tenant-portal/cardiovascular/overview"
>
租户运营后台占位心脑血管病预防 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 心脑血管病预防 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="健康体检 - 首页"
name="tenant-portal/checkup/overview"
>
租户运营后台占位健康体检 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 健康体检 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="控制台 - 首页"
name="tenant-portal/console/overview"
>
租户运营后台占位控制台 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 控制台 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="专家咨询 - 首页"
name="tenant-portal/consult/overview"
>
租户运营后台占位专家咨询 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 专家咨询 首页占位 */
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="健康数据 - 首页"
name="tenant-portal/data/overview"
>
租户运营后台占位健康数据 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 健康数据 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="糖尿病预防 - 首页"
name="tenant-portal/diabetes/overview"
>
租户运营后台占位糖尿病预防 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 糖尿病预防 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="应急就医 - 首页"
name="tenant-portal/emergency/overview"
>
租户运营后台占位应急就医 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 应急就医 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="运动管理 - 首页"
name="tenant-portal/exercise/overview"
>
租户运营后台占位运动管理 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 运动管理 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="一线医疗 - 首页"
name="tenant-portal/frontline/overview"
>
租户运营后台占位一线医疗 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 一线医疗 首页占位 */
</script>
+232
View File
@@ -0,0 +1,232 @@
<template>
<div class="app-grid">
<div class="app-grid__header">
<div class="app-grid__welcome">
<div class="app-grid__title">您好欢迎使用智养餐饮平台</div>
<div class="app-grid__subtitle">请选择需要进入的应用</div>
</div>
</div>
<div class="app-grid__body">
<!-- 按分类展示应用卡片对齐 vue 项目 home.vue "预防/救治/数据"分类 -->
<template v-for="cat in categoryList" :key="cat.key">
<div v-if="appsByCategory[cat.key]?.length" class="app-grid__section">
<div class="app-grid__section-title">
<span class="app-grid__section-dot" :style="{ background: cat.color }"></span>
{{ cat.label }}
</div>
<a-row :gutter="[16, 16]">
<a-col
v-for="app in appsByCategory[cat.key]"
:key="app.key"
:xl="6"
:lg="8"
:md="12"
:sm="24"
:xs="24"
>
<div class="app-card" @click="enterApp(app)">
<div class="app-card__icon" :style="{ background: cat.iconBg, color: cat.color }">
<span>{{ app.icon ?? '📦' }}</span>
</div>
<div class="app-card__body">
<div class="app-card__title">{{ app.label }}</div>
<a-tooltip :title="app.description">
<div class="app-card__desc">{{ app.description ?? '—' }}</div>
</a-tooltip>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 应用网格首页(租户/管理后台共用)
* 对齐 vue 项目 D:\Work\platform-vue-tenant\apps\main\src\views\staticPage\home
*
* 视觉规范:
* - 浅灰背景,白色卡片,圆角 12px
* - 卡片 hover 上浮 + 阴影
* - 按 app.category 分组(prevention/rescue/data/system
*/
import { isNavGroup, type NavApp, type NavLeaf, type NavSection } from '@meta/nav'
import { useVisibility, filterApps } from '@composables/useVisibility'
import { getPortalApps } from '@meta/nav'
const route = useRoute()
const router = useRouter()
const { role, overrides } = useVisibility()
/** 当前 portal 根据路由前缀推断 */
const portalKey = computed<'admin-portal' | 'tenant-portal'>(() => {
return route.path.startsWith('/admin-portal') ? 'admin-portal' : 'tenant-portal'
})
const portalPath = computed(() => '/' + portalKey.value)
/** 当前 portal 的应用列表(按角色过滤) */
const apps = computed<NavApp[]>(() => {
const all = getPortalApps(portalKey.value)
const effectiveRole = role.value ?? 'designer'
return filterApps(all, effectiveRole, overrides.value)
})
/** 分类元数据(对齐 vue 项目 home 的预防/救治/数据) */
const categoryList = [
{ key: 'system' as const, label: '平台与运营', color: '#1890ff', iconBg: '#E6F7FF' },
{ key: 'prevention' as const, label: '健康预防', color: '#52C41A', iconBg: '#F6FFED' },
{ key: 'rescue' as const, label: '应急救治', color: '#FA541C', iconBg: '#FFF2E8' },
{ key: 'data' as const, label: '数据档案', color: '#722ED1', iconBg: '#F9F0FF' },
{ key: 'other' as const, label: '其他应用', color: '#8C8C8C', iconBg: '#F5F5F5' },
]
/** 按分类汇总 */
const appsByCategory = computed<Record<string, NavApp[]>>(() => {
const map: Record<string, NavApp[]> = {
system: [], prevention: [], rescue: [], data: [], other: [],
}
for (const app of apps.value) {
const cat = app.category ?? 'other'
map[cat]?.push(app)
}
return map
})
/** 找一个 app 的第一个叶子页面入口 */
const firstAppEntry = (app: NavApp): { section: NavSection; leaf: NavLeaf } | undefined => {
for (const section of app.children) {
const first = section.children[0]
if (!first) continue
const leaf = isNavGroup(first) ? first.children[0] : first
if (leaf) return { section, leaf }
}
return undefined
}
/** 点击卡片进入应用 */
const enterApp = (app: NavApp) => {
const entry = firstAppEntry(app)
if (!entry) return
void router.push(`${portalPath.value}/${app.key}/${entry.section.key}/${entry.leaf.path}`)
}
</script>
<style scoped lang="less">
.app-grid {
min-height: 100%;
padding: 24px 32px 40px;
background: linear-gradient(180deg, #f0f5ff 0%, #f5f7fa 220px);
}
.app-grid__header {
margin-bottom: 24px;
}
.app-grid__welcome {
padding: 24px 28px;
background: #fff;
border-radius: 12px;
box-shadow: 0 6px 12px 0 #E1E6ED;
border: 1px solid #e6f0ff;
}
.app-grid__title {
color: #101010;
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
}
.app-grid__subtitle {
color: #888;
font-size: 14px;
}
.app-grid__body {
display: flex;
flex-direction: column;
gap: 24px;
}
.app-grid__section {
background: #fff;
border-radius: 12px;
padding: 20px 24px 24px;
box-shadow: 0 6px 12px 0 #E1E6ED;
}
.app-grid__section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
color: #101010;
margin-bottom: 16px;
}
.app-grid__section-dot {
width: 4px;
height: 18px;
border-radius: 2px;
display: inline-block;
}
/* 应用卡片 — 对齐 vue 项目 prevention-list-item 样式 */
.app-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #1890ff;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.18);
}
}
.app-card__icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
border-radius: 10px;
flex-shrink: 0;
}
.app-card__body {
flex: 1;
min-width: 0;
}
.app-card__title {
font-size: 15px;
font-weight: 600;
color: #101010;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-card__desc {
font-size: 12px;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="知识普及 - 首页"
name="tenant-portal/knowledge/overview"
>
租户运营后台占位知识普及 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 知识普及 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="健康监测 - 首页"
name="tenant-portal/monitor/overview"
>
租户运营后台占位健康监测 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 健康监测 首页占位 */
</script>
@@ -0,0 +1,7 @@
<template>
<PlaceholderPage title="单位报表数据" name="web-admin/nutrition/ArcUnit" />
</template>
<script setup lang="ts">
// 占位
</script>
@@ -0,0 +1,34 @@
/**
* 员工营养数据 - 接口契约层(原型阶段)
* - 原型阶段不实际调用后端,mock 数据直接在 init/useTable.ts 中写死
* - 本文件保留接口签名,作为「未来对接后端时」的契约说明
* - 后端 / 测试 / 前端工程开发可从本文件读到「需要哪些接口、参数和返回结构」
* - 工程化迁移时,把签名实现替换为真实 HTTP 调用即可
*/
import type {
EmployeeListParams,
EmployeeListResponse,
} from '../types'
/**
* 员工营养数据 - 分页查询
* @接口路径 POST /nutrition/employee/page
* @请求参数 EmployeeListParams(搜索条件 + 分页)
* @响应 EmployeeListResponserecords + total
*
* 原型阶段说明:实际数据见 init/useTable.ts 的 dataList
*/
export const queryEmployeePage = (_params: EmployeeListParams): Promise<EmployeeListResponse> => {
return Promise.reject(new Error('[原型阶段] 接口未实现,数据来自 init/useTable.ts'))
}
/**
* 员工营养数据 - 导出列表
* @接口路径 POST /nutrition/employee/export
* @请求参数 EmployeeListParams(不含分页字段)
* @响应 Blobxlsx 二进制流)
*/
export const exportEmployeeList = (_params: EmployeeListParams): Promise<Blob> => {
return Promise.reject(new Error('[原型阶段] 接口未实现'))
}
@@ -0,0 +1,93 @@
<template>
<div class="arc-employee">
<!-- 顶部统计卡 -->
<a-row :gutter="16" class="arc-employee__stats">
<a-col :span="6" v-for="s in stats" :key="s.label">
<StatCard :icon="s.icon" :color="s.color" :value="s.value" :label="s.label" />
</a-col>
</a-row>
<!-- 搜索栏 -->
<FilterBar
v-model="searchForm"
:fields="filterFields"
@search="handleSearch"
@reset="handleReset"
/>
<!-- 表格无标题左上方放主操作按钮 -->
<TableCard
:columns="columns"
:data-source="dataList"
:pagination="pagination"
row-key="id"
>
<template #toolbar>
<a-button type="primary" @click="handleExport">
<template #icon><DownloadOutlined /></template>
导出列表数据
</a-button>
<a-button @click="viewExportTasks">
<template #icon><UnorderedListOutlined /></template>
查看导出任务
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'rate'">
<a-tag :color="record.rate >= 85 ? 'success' : record.rate >= 70 ? 'warning' : 'error'">
{{ record.rate }}%
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a @click="viewDetail(record)">查看详情</a>
</template>
</template>
</TableCard>
</div>
</template>
<script setup lang="ts">
/**
* 员工营养数据 - 列表页(薄模板层)
* - 本文件只负责"渲染",所有逻辑由 init/ 三件套提供
* - 这是「标准列表页样板」,未来 new-page 脚手架按此结构复制
*
* 新规范要点:
* - 不渲染 PageHeader / 不显示页面标题(菜单已经表达了所在位置)
* - TableCard 不显示标题;主操作按钮通过 #toolbar slot 放在卡片左上方
* - 所有按钮均带统一 antd icon(导出 DownloadOutlined / 查看任务 UnorderedListOutlined
*/
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { usePage } from './init/usePage'
import { useSearch } from './init/useSearch'
import { useTable } from './init/useTable'
/** 页面级:统计卡、详情跳转、导出 */
const {
stats,
viewDetail,
handleExport,
viewExportTasks,
} = usePage()
/** 搜索:表单、字段配置、查询/重置 */
const {
searchForm,
filterFields,
handleSearch,
handleReset,
} = useSearch()
/** 表格:列、数据、分页 */
const { columns, dataList, pagination } = useTable()
</script>
<style scoped lang="less">
.arc-employee {
&__stats {
margin-bottom: 16px;
}
}
</style>
@@ -0,0 +1,44 @@
/**
* 员工营养数据 - 页面主逻辑
* - 页面级 state:统计卡数据
* - 页面级行为:详情跳转、导出、查看导出任务
* - 不耦合搜索 / 表格内部细节,与 useSearch / useTable 平级并列
*
* 新规范:列表页不再显示 PageHeader 标题;菜单/历史栏已表达所在位置。
*/
import { message } from 'ant-design-vue'
import type { EmployeeNutritionRecord, NutritionStat } from '../types'
export const usePage = () => {
/** 顶部统计卡数据 */
const stats: NutritionStat[] = [
{ icon: '👤', color: 'blue', value: '1,280', label: '监控员工数' },
{ icon: '📅', color: 'green', value: '132', label: '本年已监控天数' },
{ icon: '📊', color: 'orange', value: '86.5%', label: '营养达标率' },
{ icon: '💾', color: 'green', value: '96.2%', label: '数据覆盖率' },
]
/** 查看员工详情 - 工程化时跳转 detail 路由 */
const viewDetail = (record: EmployeeNutritionRecord) => {
message.info(`查看 ${record.name} 的详情(原型演示)`)
}
/** 导出列表 - 工程化时调用 exportEmployeeList */
const handleExport = () => {
message.success('已触发导出任务(原型演示)')
}
/** 查看导出任务列表 */
const viewExportTasks = () => {
message.info('查看导出任务列表(原型演示)')
}
return {
stats,
viewDetail,
handleExport,
viewExportTasks,
}
}
@@ -0,0 +1,68 @@
/**
* 员工营养数据 - 搜索逻辑
* - 暴露搜索表单、下拉选项、字段配置、查询/重置方法
* - 与 FilterBar 组件配合:fields 数组驱动渲染
*/
import { message } from 'ant-design-vue'
import type { FilterField } from '@components/FilterBar.vue'
import type { EmployeeSearchForm } from '../types'
export const useSearch = () => {
/** 搜索表单初始值 */
const initialForm = (): EmployeeSearchForm => ({
keyword: '',
unit: undefined,
dept: undefined,
year: 2026,
})
/** 搜索表单数据(v-model 给 FilterBar */
const searchForm = ref<EmployeeSearchForm>(initialForm())
/** 所属单位下拉选项 */
const unitOptions = [
{ value: 'hq', label: 'CQ能源总部' },
{ value: 'oil1', label: '采油一厂' },
{ value: 'oil2', label: '采油二厂' },
]
/** 所属部门下拉选项 */
const deptOptions = [
{ value: 'rd', label: '研发部' },
{ value: 'mk', label: '市场部' },
{ value: 'op', label: '运营部' },
{ value: 'fin', label: '财务部' },
{ value: 'hr', label: '人事部' },
]
/** 年份下拉选项 */
const yearOptions = [2026, 2025, 2024].map(y => ({ value: y, label: `${y}` }))
/** FilterBar 字段配置(与 FilterField 类型严格对齐) */
const filterFields: FilterField[] = [
{ name: 'keyword', label: '姓名/工号', type: 'input', placeholder: '请输入姓名或工号' },
{ name: 'unit', label: '所属单位', type: 'select', options: unitOptions },
{ name: 'dept', label: '所属部门', type: 'select', options: deptOptions },
{ name: 'year', label: '年份', type: 'select', options: yearOptions, allowClear: false, width: '120px' },
]
/** 触发查询 - 原型阶段只演示 message 提示 */
const handleSearch = (form: EmployeeSearchForm) => {
message.success(`已触发查询:${JSON.stringify(form)}`)
}
/** 重置 - FilterBar 内部已清空字段,这里仅做副作用 */
const handleReset = () => {
searchForm.value = initialForm()
message.info('已重置筛选条件')
}
return {
searchForm,
filterFields,
unitOptions, deptOptions, yearOptions,
handleSearch, handleReset,
}
}
@@ -0,0 +1,53 @@
/**
* 员工营养数据 - 表格逻辑
* - columns:与正式工程 a-table 列定义完全兼容(dataIndex/key/width/fixed
* - dataList:原型阶段 mock 数据,工程化时替换为 await queryEmployeePage()
* - pagination:标准 a-table pagination 配置
*/
import type { TableColumnsType } from 'ant-design-vue'
import type { EmployeeNutritionRecord } from '../types'
export const useTable = () => {
/** 表格列配置(标题、字段、宽度、列冻结) */
const columns: TableColumnsType = [
{ title: '姓名', dataIndex: 'name', width: 80, fixed: 'left' },
{ title: '性别', dataIndex: 'gender', width: 60 },
{ title: '年龄', dataIndex: 'age', width: 60 },
{ title: '员工工号', dataIndex: 'empNo', width: 100 },
{ title: '所属单位', dataIndex: 'unit', width: 140 },
{ title: '所属部门', dataIndex: 'dept', width: 100 },
{ title: '用餐天数', dataIndex: 'days', width: 90 },
{ title: '用餐次数', dataIndex: 'meals', width: 90 },
{ title: '热量均值(kcal)', dataIndex: 'calorie', width: 130 },
{ title: '蛋白质均值(g)', dataIndex: 'protein', width: 130 },
{ title: '脂肪均值(g)', dataIndex: 'fat', width: 120 },
{ title: '碳水均值(g)', dataIndex: 'carb', width: 120 },
{ title: '营养达标率', key: 'rate', width: 110 },
{ title: '操作', key: 'action', width: 100, fixed: 'right' },
]
/**
* 表格数据源(原型 Mock)
* 工程化时替换为:const { records } = await queryEmployeePage(params)
*/
const dataList = ref<EmployeeNutritionRecord[]>([
{ id: 'EMP001', name: '张伟', gender: '男', age: 32, empNo: 'EMP001', unit: 'CQ能源总部', dept: '研发部', days: 132, meals: 386, calorie: '2,156', protein: 78.3, fat: 72.1, carb: 285.4, rate: 92 },
{ id: 'EMP002', name: '李娜', gender: '女', age: 28, empNo: 'EMP002', unit: 'CQ能源总部', dept: '市场部', days: 128, meals: 372, calorie: '1,842', protein: 65.2, fat: 58.7, carb: 241.3, rate: 89 },
{ id: 'EMP003', name: '王磊', gender: '男', age: 35, empNo: 'EMP003', unit: '采油一厂', dept: '运营部', days: 125, meals: 358, calorie: '2,380', protein: 85.6, fat: 89.2, carb: 312.1, rate: 76 },
{ id: 'EMP004', name: '赵敏', gender: '女', age: 30, empNo: 'EMP004', unit: '采油二厂', dept: '财务部', days: 131, meals: 390, calorie: '1,720', protein: 58.4, fat: 52.3, carb: 228.6, rate: 95 },
{ id: 'EMP005', name: '陈刚', gender: '男', age: 41, empNo: 'EMP005', unit: 'CQ能源总部', dept: '人事部', days: 120, meals: 345, calorie: '2,050', protein: 72.1, fat: 68.4, carb: 270.2, rate: 78 },
])
/** 分页配置 */
const pagination = reactive({
current: 1,
pageSize: 30,
total: 1280,
showSizeChanger: true,
showTotal: (total: number) => `${total}`,
})
return { columns, dataList, pagination }
}
@@ -0,0 +1,75 @@
/**
* 员工营养数据 - 类型集中定义
* - 所有 Interface / Type 集中在此,禁止散落到各 use*.ts
* - 后端 DTO 字段如有变化,只改这一处
*/
import type { StatColor } from '@components/StatCard.vue'
/** 员工营养监控行数据(对应后端 EmployeeNutritionDTO */
export interface EmployeeNutritionRecord {
/** 主键,对应 vue-table row-key */
id: string
/** 姓名 */
name: string
/** 性别 */
gender: '男' | '女'
/** 年龄 */
age: number
/** 员工工号 */
empNo: string
/** 所属单位(显示名称) */
unit: string
/** 所属部门(显示名称) */
dept: string
/** 用餐天数 */
days: number
/** 用餐次数 */
meals: number
/** 热量均值(kcal */
calorie: string
/** 蛋白质均值(g */
protein: number
/** 脂肪均值(g */
fat: number
/** 碳水化合物均值(g */
carb: number
/** 营养达标率(0-100 */
rate: number
}
/** 搜索表单类型 */
export interface EmployeeSearchForm {
/** 姓名 / 工号关键词 */
keyword?: string
/** 所属单位(value */
unit?: string
/** 所属部门(value */
dept?: string
/** 年份 */
year?: number
}
/** 顶部统计卡数据 */
export interface NutritionStat {
icon: string
color: StatColor
value: string
label: string
}
/** 列表分页查询请求(与后端约定) */
export interface EmployeeListParams extends EmployeeSearchForm {
/** 当前页(从 1 开始) */
current: number
/** 每页条数 */
pageSize: number
}
/** 列表分页响应 */
export interface EmployeeListResponse {
/** 当前页数据 */
records: EmployeeNutritionRecord[]
/** 总条数 */
total: number
}
@@ -0,0 +1,7 @@
<template>
<PlaceholderPage title="种植土地" name="web-admin/nutrition/FarmLand" />
</template>
<script setup lang="ts">
// 占位
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="健康档案 - 首页"
name="tenant-portal/record/overview"
>
租户运营后台占位健康档案 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 健康档案 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="系统管理 - 首页"
name="tenant-portal/system/overview"
>
租户运营后台占位系统管理 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 系统管理 首页占位 */
</script>
@@ -0,0 +1,12 @@
<template>
<PlaceholderPage
title="体重管理 - 首页"
name="tenant-portal/weight/overview"
>
租户运营后台占位体重管理 的二级菜单待规划后通过 /new-page 生成
</PlaceholderPage>
</template>
<script setup lang="ts">
/** 体重管理 首页占位 */
</script>
@@ -0,0 +1,153 @@
# 员工营养数据 · PRD
> **路径**`tenant-portal/nutrition/arc-employee`
> **页面 key**`arc-employee`
> **完成度**ready
> **本文档自动由 /gen-prd 生成 / 维护,手工修改时请同步更新 init/ 中的字段**
---
## 一、页面概述
员工营养数据列表页面,按年度展示企业全部员工的营养监控数据,支持按姓名/工号、所属单位、所属部门、年份多维筛选,结果导出为 xlsx。
页面定位:租户运营后台 > 营养管理 > 档案 > 员工营养数据。
---
## 二、页面截图
> 截图存放在 `src/prd/tenant-portal/nutrition/screenshots/arc-employee/`
> 命名规范:`<状态>.jpg`(如 `default.jpg`、`search-active.jpg`、`empty.jpg`
> 单图体积 ≤ 60KB,使用 puppeteer 或 chrome-devtools MCP 截取
> 视口:Web 端 1440×9001x 缩放
![默认状态](screenshots/arc-employee/default.jpg)
---
## 三、统计区
页面顶部展示 4 个统计卡:
| 指标 | 含义 | 示例值 |
|------|------|--------|
| 监控员工数 | 当前年度纳入营养监控的员工总数 | 1,280 |
| 本年已监控天数 | 截至当前的累计监控天数 | 132 |
| 营养达标率 | 监控员工中营养达标的员工占比 | 86.5% |
| 数据覆盖率 | 已采集营养数据的员工占比 | 96.2% |
---
## 四、搜索区
| 字段 | 控件 | 是否必填 | 默认值 | 选项来源 |
|------|------|---------|--------|---------|
| 姓名/工号 | 文本框 | 否 | 空 | - |
| 所属单位 | 下拉 | 否 | 全部 | 字典:CQ能源总部 / 采油一厂 / 采油二厂 |
| 所属部门 | 下拉 | 否 | 全部 | 字典:研发部 / 市场部 / 运营部 / 财务部 / 人事部 |
| 年份 | 下拉 | 是 | 当前年 | 2026 / 2025 / 2024 |
---
## 五、表格字段
| # | 列名 | 字段名 | 类型 | 列宽 | 备注 |
|---|------|--------|------|------|------|
| 1 | 姓名 | name | string | 80 | 左冻结 |
| 2 | 性别 | gender | '男' \| '女' | 60 | |
| 3 | 年龄 | age | number | 60 | |
| 4 | 员工工号 | empNo | string | 100 | 唯一 |
| 5 | 所属单位 | unit | string | 140 | |
| 6 | 所属部门 | dept | string | 100 | |
| 7 | 用餐天数 | days | number | 90 | |
| 8 | 用餐次数 | meals | number | 90 | |
| 9 | 热量均值(kcal) | calorie | string | 130 | 保留千分位 |
| 10 | 蛋白质均值(g) | protein | number | 130 | 1 位小数 |
| 11 | 脂肪均值(g) | fat | number | 120 | 1 位小数 |
| 12 | 碳水均值(g) | carb | number | 120 | 1 位小数 |
| 13 | 营养达标率 | rate | number(0-100) | 110 | tag 颜色:≥85 绿、≥70 黄、<70 红 |
| 14 | 操作 | - | - | 100 | 右冻结。链接「查看详情」跳转 |
---
## 六、操作
| 操作 | 触发位置 | 行为 |
|------|---------|------|
| 查询 | 搜索栏「查询」按钮 | 携搜索条件请求 page 接口 |
| 重置 | 搜索栏「重置」按钮 | 清空筛选 + 查询 |
| 查看详情 | 表格行「查看详情」 | 跳转员工详情页 |
| 导出列表数据 | 页头「导出列表数据」 | 调用 export 接口下载 xlsx |
| 查看导出任务 | 页头「查看导出任务」 | 打开导出任务列表抽屉 |
---
## 七、接口契约
### 7.1 分页查询
```
POST /nutrition/employee/page
```
请求:
```json
{
"current": 1,
"pageSize": 30,
"keyword": "张",
"unit": "hq",
"dept": "rd",
"year": 2026
}
```
响应(标准 ApiResponse 包装):
```json
{
"code": "00000",
"msg": "操作成功",
"data": {
"records": [ /* EmployeeNutritionRecord[] */ ],
"total": 1280
}
}
```
### 7.2 导出
```
POST /nutrition/employee/export
```
请求:同 7.1 但不含 current / pageSize 字段。
响应:`Blob`xlsx 二进制流)
---
## 八、状态流转
无状态字段(纯展示页面)。
---
## 九、测试关注点
- 搜索"重置"后清空所有筛选条件并自动查询
- 营养达标率颜色阈值:85 / 70
- 列冻结:左姓名、右操作;横向滚动正常
- 分页:首页/尾页禁用上下按钮;切页大小回到第一页
- 导出权限校验
- 空数据态文案:`暂无数据`
---
## 十、变更记录
| 日期 | 类型 | 摘要 |
|------|------|------|
| 2026-06-04 | init | 初始化(来自 /gen-prd skill |
+532
View File
@@ -0,0 +1,532 @@
# 智养餐饮平台V3 - 产品需求文档(PRD)
> **所属端**:智养餐饮平台V3 共性需求说明
> **模块路径**`yx-platform-prototype/`
> **最后更新**2026-06-04
---
# 一、文档概述
本文档为"智养餐饮平台V3"的产品需求规格说明书(以下简称PRD),旨在系统性地定义本次升级的业务目标、功能范围、用户角色及非功能性需求,为项目团队(包括产品、设计、研发、测试等角色)提供统一的需求基线。该文档适用于"智养餐饮平台V3"的全生命周期,涵盖需求分析、系统设计、原型设计、测试验证、交付验收各阶段。
### 文档目的
- 明确本次平台建设的核心目标与建设范围,确保各方对项目方向达成一致理解;
- 详细描述各功能模块的业务逻辑、交互流程及数据要求,作为设计与开发的直接依据;
- 规范验收标准与质量指标,为后续测试与上线交付提供参考。
### 读者对象
- 项目管理人员:用于把控项目范围、进度与风险;
- 产品与设计人员:作为功能设计与交互设计的输入依据;
- 研发人员:用于理解业务逻辑,进行技术方案设计与编码实现;
- 测试人员:用于编写测试用例,验证系统功能与非功能性指标;
- 客户方相关人员:用于确认需求内容,参与评审与验收。
### 文档内容
- 项目背景与目标:阐述"智养餐饮平台V3"的建设背景、升级动因及总体目标,明确本次项目的价值定位;
- 项目需求分析:拆解面向平台管理员、租户运营人员、终端用户(餐饮服务对象)、食堂工作人员等用户群体的用户画像分析,概述本次项目的具体交付成果,对核心业务板块做简要说明;
- 共性需求说明:针对交付产物(综合管理后台、租户运营后台、微信小程序、硬件终端屏)通用能力进行统一说明,包括用户权限管理、通用样式布局、操作一致性、数据字典等跨模块、跨端的公共需求;
- 功能需求详细设计:围绕系统管理、营养管理、健康体检、体重管理、健康监测、健康评估、健康档案、知识普及、专家咨询、应急就医、一线医疗等核心模块,逐一描述业务场景、功能流程、界面要求及业务规则;
- 非功能性需求:明确系统在性能、安全、可用性、兼容性、可扩展性等方面的质量要求与量化指标;
- 技术实现方案:阐述系统整体技术架构、技术选型、数据库设计、第三方系统对接(如智能货柜、营养秤、穿戴设备等 IoT 设备)、系统安全与可用性、系统部署与运维等方案。
# 二、项目背景与目标
## (一)项目背景
智养餐饮平台 V1/V2 以面向单一组织的食堂数字化管理为起点,覆盖了菜品、食谱、订餐、出餐等基础场景。随着多租户运营模式的推行、营养健康一体化诉求的增强、以及食堂全链路(绿色种采、加工、出餐、质检、回收)数据上云的实际需要,原有平台在多租户隔离、跨端协同、硬件终端接入、个性化营养干预等方面已无法完全满足业务诉求。
为此,启动"智养餐饮平台V3"项目,通过对原有平台的全面重构与升级,搭建一套"综合管理后台 + 租户运营后台 + C 端微信小程序 + 多类硬件终端屏"的四端一体协同体系,将餐饮业务与员工健康管理打通,覆盖从田间到餐桌、从菜品到健康档案的全链路。
## (二)项目目标
本项目立足于企事业单位食堂与员工健康管理的实际业务场景,围绕"多租户支撑、餐饮健康一体化、硬件协同、智能化升级"四大方向展开,具体目标如下:
* 多租户支撑:综合管理后台与租户运营后台清晰分层,平台级配置(账号、字典、租户、设备)与租户级运营(菜品、食谱、订餐、出餐、质检)解耦,支持快速复制开通新租户。
* 餐饮健康一体化:打通"营养管理 / 健康体检 / 体重管理 / 健康监测 / 健康评估 / 健康档案"等模块的数据流,从单纯的"吃得对"延伸到"吃得健康",把菜品营养元素与员工健康画像绑定,实现个性化食谱推荐与主动健康干预。
* 硬件协同:原型期覆盖 10 类硬件终端(智能货柜、档口机、入库秤、营养秤、吐盘机、结算台、配比秤、带鱼屏、统计大屏、炒菜机器人),与后台进行实时双向通信,将食堂前场与后场的人、菜、餐数据全部纳入闭环。
* 智能化升级:引入 AI 与数据中台能力,强化营养建议、风险识别、菜品创新、健康评估等核心场景的智能化程度,从"流程数字化"迈向"主动健康守护"。
## (三)交付标准
为确保项目成果符合预期,本次升级项目将依据以下标准进行验收与交付。所有交付内容需经客户方及我方项目主管部门双重确认。
### 性能与稳定性标准
* 并发处理能力:系统需支持高峰时段(如午餐高峰、新租户上线季、全员体检季)不低于 3000 用户同时在线,核心业务接口响应时间控制在 3 秒以内。
* 数据同步时效:硬件终端(营养秤、智能货柜、健康监测设备等)数据从设备端到平台端的同步延迟不超过 5 分钟,确保食堂出餐与健康监测的实时性。
* 系统可用性:系统在试运行期间可用性达到 99.6% 以上,无重大级生产事故。
### 功能完整性标准
* 模块全覆盖:完成租户运营后台 18 个核心模块(系统管理、营养管理、健康体检、体重管理、运动管理、健康监测、健康评估、知识普及、健康数据、健康档案、心脑血管病预防、糖尿病预防、癌症预防、专家咨询、应急就医、一线医疗、控制台、租户首页)、综合管理后台 3 个子端(系统设置、运营中心、设备中心)、C 端微信小程序四个 Tab(吃 / 问 / 做 / 我)以及 10 类硬件终端的设计与开发,所有功能均需通过预设的测试用例。
* 业务流程闭环:关键业务流程(如订餐 → 出餐 → 回收的全过程;菜品创新从灵感采集到上架的全链路;员工健康档案从体检数据归集到主动干预的闭环)需实现端到端的业务闭环,无逻辑断点。
* 权限准确率:平台管理员、租户运营人员、终端用户(员工)、食堂工作人员四类角色的数据隔离与操作权限准确率达到 100%。
### 智能化改造标准
* 主动健康能力:基于营养摄入、健康监测、体检报告数据,通过数据中台与 AI 赋能,实现对营养偏差、慢病风险、异常指标的自动识别与预警推送,预警准确率不低于 80%。
* 数据辅助决策:支持生成多维度的营养与健康分析报告(如食堂菜品营养画像、租户健康风险分布、高风险人群清单),为食谱排期、菜品创新、健康干预策略的制定提供数据支撑。
### 文档与技术资产交付
* 开发文档:提供完整的《系统详细设计说明书》《软件产品需求文档》《接口文档》(包含与智能货柜、营养秤、穿戴设备、环境监测 IoT 设备等第三方硬件的对接规范)。
* 部署运维文档:提供《部署手册》《服务器环境清单》《系统监控与运维手册》及《常见问题处理指南》,确保系统具备可持续运维能力。
* 测试报告:提供功能测试报告、性能测试报告及安全测试报告,明确各项测试结论。
# 三、项目需求分析
## (一)用户画像分析
| 用户角色 | 角色定义 | 核心使用场景 | 核心诉求 | 当前痛点 |
| --- | --- | --- | --- | --- |
| 平台管理员 | 智养餐饮平台运营方的后台管理人员 | 跨租户基础数据维护、租户开通与配置、字典管理、设备注册、平台级运营统计与监控 | 多租户清晰隔离、配置高效、数据可视化、设备状态可监控 | 原平台多租户能力薄弱;设备接入流程繁琐;缺乏跨租户统计视图 |
| 租户运营人员 | 单租户内部的食堂运营、营养师、健康管理岗位人员 | 菜品 / 食谱 / 订餐 / 出餐 / 质检管理、员工健康档案维护、营养与健康数据查询、专项干预活动发布 | 操作便捷、模块联动、批量处理能力强、数据可导出 | 模块割裂、批量操作能力弱、统计维度单一、数据导出受限 |
| 终端用户(员工) | 享受餐饮与健康服务的最终用户,通过微信小程序使用 | 浏览今日食谱、订餐取餐、查看就餐与营养记录、健康档案查看、AI 营养咨询、社区食谱创作 | 入口简洁、信息及时、营养可视化、互动感强 | 原版小程序功能单一、营养信息不直观、缺少互动与个性化推荐 |
| 食堂工作人员 | 食堂前场(出餐、结算)与后场(入库、加工、配菜、烹饪)的操作人员 | 通过硬件终端进行入库称重、配比、烹饪、出餐、结算、吐盘等环节的操作与记录 | 硬件操作直观、易学易用、与后台数据实时同步、避免重复录入 | 现场设备形态多样、操作不统一、数据上报延迟、与后台脱节 |
| 健康咨询专家 | 提供营养与健康咨询服务的专业医师、营养师、健康管理师 | 接收员工咨询、回复问题、查看咨询历史、辅助健康评估 | 咨询界面清晰、健康档案可调阅、辅助工具完善 | 消息通知不及时;档案调阅不便;缺乏常用回复模板 |
| 应急就医服务人员 | 负责应急就医调度与协调的人员 | 接收应急就医申请、核实信息、协调资源、跟踪处理进度、反馈结果 | 响应高效、信息完整、流程闭环、异常可预警 | 应急流程缺乏自动化提醒;处理进度跟踪困难;与一线医疗信息联动不足 |
| 一线医疗工作人员 | 驻场或一线医疗点的医护人员 | 现场就诊记录录入、慢病随访、体检报告解读、健康宣教 | 信息同步及时、操作简便、与上级机构协同顺畅 | 平台响应慢;就诊记录与平台数据同步困难,存在重复录入 |
## (二)项目交付成果分析
| 交付成果 | 终端定位 | 使用人群 | 核心功能要点 |
| --- | --- | --- | --- |
| 综合管理后台(admin-portal) | 跨租户的平台统一管理后台,支撑全平台基础配置、租户与设备运营 | 平台管理员 | 系统设置(账号、组织、权限、字典、参数)、运营中心(租户开通、应用配置、计费、统计)、设备中心(跨租户硬件注册、固件、在线状态) |
| 租户运营后台(tenant-portal) | 单租户视角的业务运营平台,覆盖营养、健康、咨询、应急等 18 个一级模块 | 租户运营人员、营养师、健康管理岗位 | 租户首页 / 控制台 / 系统管理 / 营养管理 / 健康体检 / 体重 / 运动 / 健康监测 / 健康评估 / 知识普及 / 健康数据 / 健康档案 / 三大慢病预防 / 专家咨询 / 应急就医 / 一线医疗 |
| C 端微信小程序(miniprogram) | 员工日常使用的轻量化服务入口 | 终端用户(员工) | 吃(营养首页、今日食谱、菜品详情、订餐、订单确认、购物车、就餐记录)、问(AI 营养助手、AI 对话、历史对话)、做(创作食谱、社区、排行、分享)、我(个人中心、健康档案、订单、收藏、余额、会员、消息、设置、扫码溯源) |
| 硬件终端屏(hardware) | 食堂前场 / 后场以及公共空间使用的多种专用终端 | 食堂工作人员、公共空间观看用户 | 智能货柜(取菜出柜)、档口机(出餐 / 结算)、入库秤(毛菜入库)、营养秤(餐线营养采集)、吐盘机(餐盘回收)、结算台(用户结算)、配比秤(配菜组合)、带鱼屏(信息展示)、统计大屏(数据可视化)、炒菜机器人(烹饪流程触控) |
## (三)核心业务分析
| 核心业务 | 业务概述 | 现状问题 | 升级方向 |
| --- | --- | --- | --- |
| 系统管理 | 用户管理、角色权限、组织架构、字典与参数、操作日志,保障平台安全稳定运行 | 权限颗粒度粗、日志不完善、字典分散维护 | RBAC 精细化权限、完整操作日志、平台级与租户级字典分层、统一参数中心 |
| 营养管理 | 食堂业务全链路(种植、采购、加工、菜品、食谱、订餐、出餐、质检),打通营养元素至个体摄入数据 | 流程节点多、跨节点数据不一致、营养元素分析弱、与健康数据未联动 | 全链路数据贯通、菜品营养建模、营养摄入可视化、与健康档案双向联动 |
| 健康体检 | 体检计划、机构、项目、报告、统计 | 预约流程繁琐、报告上传依赖人工、跨年趋势不可见 | 优化预约、自动报告归档、历年趋势可视化、数据自动入档 |
| 体重管理 | 体重档案、采集(蓝牙称 / 体脂仪)、目标、干预、提醒 | 仅记录、缺目标激励、智能建议弱 | 目标与进度追踪、趋势图表、AI 减重指导、与营养联动 |
| 运动管理 | 运动计划、器械、记录、目标、激励 | 数据来源单一、缺目标计划、与健康模块联动弱 | 多源数据接入、目标计划、个性化推荐、联动体重与营养 |
| 健康监测 | 穿戴 / 体重秤 / 体脂仪 / 环境监测(室内 / 室外 / 水 / 花粉)的设备接入与数据看板 | 设备同步延迟、仅展示无分析、缺异常告警 | 优化同步、趋势可视化、AI 异常识别、扩展设备兼容性 |
| 健康评估 | 综合体检、监测、营养、生活方式数据生成评估报告与风险等级 | 评估模型单一、报告样式陈旧、缺主动预测 | AI 多模型评估、可视化报告、自动预警 |
| 健康数据 | 各类健康数据接入、清洗、查询,作为数据中台底座 | 数据分散、维度单一、导出能力弱 | 统一数据中心、多维度看板、灵活报表导出 |
| 健康档案 | 个人 / 单位 健康档案聚合视图,串联体检、监测、咨询、营养、干预数据 | 数据未统一、历史留存不完整 | 多源整合、统一视图、灵活统计与导出 |
| 知识普及 | 健康与营养科普文章、视频、问答 | 更新慢、分类弱、形式单一 | 多形式内容、智能推荐、互动反馈 |
| 心脑血管病预防 | 高风险识别、预警推送、干预方案、专项科普 | 评估准确性差、评估与干预脱节 | 高风险自动识别、专项干预方案、科普专区 |
| 糖尿病预防 | 血糖记录、风险预警、饮食运动指导,服务高风险及前期人群 | 评估准确性差、监控难落实、建议针对性弱 | 血糖趋势分析、自动风险识别、个性化建议、专项干预 |
| 癌症预防 | 常见癌症科普、早期筛查提醒、风险评估与预防建议 | 内容覆盖不全、缺评估工具、提醒缺失 | 科普知识库、风险评估问卷、筛查提醒、结果入档 |
| 专家咨询 | 视频问诊、小助手对话、咨询档案、专家管理与评价 | 消息时效差、咨询助手智能化低 | 实时推送、智能助手辅助、档案一键调阅 |
| 应急就医 | 应急工单、大病就医、派单调度、救助跟踪 | 流程不闭环、人工干预多、跨端联动弱 | 完善闭环、就近资源匹配、超时升级提醒、跨端联动 |
| 一线医疗 | 驻场医务工作站:日常诊疗、处置记录、慢病随访、转诊 | 权限混乱、数据滞后、智能化低 | 实时维护、权限分层、AI 辅助诊疗 |
| 待办通知 | 待处理事项、系统消息、预警推送、活动提醒,保障流程推进 | 通知方式单一、缺强提醒、待办分散 | 统一待办中心、多渠道推送(小程序 / 短信 / 微信)、消息分类与状态管理、超时升级提醒 |
# 四、共性需求说明
本章节对平台四端(综合管理后台、租户运营后台、C 端微信小程序、硬件终端屏)的通用交互规范、数据展示规则、权限控制及跨端一致性要求进行统一定义,所有功能模块的设计与开发须遵循本章节规范,不得与之冲突。
## (一)后台 Web 端共性需求(综合管理后台 / 租户运营后台)
### 1.布局与框架
- 整体采用顶部导航 + 左侧导航栏 + 右侧内容区的经典后台布局,内容区支持自适应拉伸
- 无权限的菜单项、按钮直接隐藏
- 导入功能使用统一弹窗布局,支持模板下载、拖拽上传、文件预览
- 列表分页默认每页 10 条,支持切换每页条数(10 / 20 / 50 / 100
- 列表表格支持列宽拖动、根据每列内容合理规划列宽(如手机号、身份证号、性别、年龄、工号等长度固定的字段)、列显隐配置,配置状态本地持久化
- 超长文本单行截断显示"...",鼠标悬浮展示完整内容(Tooltip)
- 无数据、加载中、接口异常、空白页使用统一样式组件
- 详情页与编辑页排版结构与新增页保持一致
### 2.表单设计规范
- 必填字段在标签前标注红色星号(*),选填字段不做标注
- 字段标签统一左对齐,同一表单内标签宽度保持一致
- 校验时机:输入框失焦时触发单字段校验,提交时触发全量校验
- 只读态字段使用纯文本展示,不使用禁用输入框
- 下拉选项超过 10 条时,支持关键词搜索过滤
- 日期/时间选择统一使用系统日期组件,不允许纯手动输入
- 表单提交后按钮进入 loading 状态,防止重复提交
- 表单离开未保存时,弹出"是否放弃修改?"二次确认弹窗
- 常规校验:如电话、邮箱、身份证号必须加正则校验,姓名(格式+长度)校验,数值类型(如身高、体重、菜品克数、营养元素含量等)根据常规知识灵活加校验(如体重不能为负数,身高不能大于 300 cm,菜品份量不能为负等)
- 字段字数限制规范:所有表单输入字段须按下表设置最大字数限制,前端通过 `maxlength` 属性控制,后端同步校验
| 字段类型 | 最大字数 | 说明 |
|---------|-------|-------------------------------------------------------------------------------------|
| 姓名/人名 | 20 | 中文姓名+少数民族长名 |
| 手机号 | 11 | 固定位数,正则校验 |
| 座机号 | 12 | 正则校验 |
| 身份证号 | 18 | 固定位数,正则校验 |
| 标题/名称 | 50 | 如菜品名称、食谱标题、模块名称 |
| 短文本输入 | 100 | 如地址、单位名称、一句话备注 |
| 多行备注/描述 | 500 | 如简介、问题说明、菜品描述 |
| 搜索框 | 50 | 关键词搜索 |
| 验证码 | 6 | 短信/邮箱验证码 |
| 密码 | 12~30 | 必须包含至少一个小写字母;必须包含至少一个大写字母;必须包含至少一个数字;必须包含至少一个特殊符号(可根据需要修改特殊符号的定义);密码长度必须大于等于 12 |
| 邮箱 | 50 | 常规邮箱地址,正则校验 |
| URL链接 | 200 | 网址输入 |
- 多行文本框右下角实时显示字数计数提示,格式为"已输入 X / Y 字"
- 达到字数上限后禁止继续输入
- 提交校验时,必填字段为空提示"请输入XXX",超限提示"XXX不能超过N个字"
### 3.搜索与筛选规范
- 搜索框支持回车键触发搜索,与点击搜索按钮效果一致
- 多条件筛选区域默认展开,条件较多时可折叠收起
- 已选筛选条件以标签形式展示在筛选区下方,支持单独点击删除
- 筛选条件变更后需手动点击"查询"按钮触发,不自动触发(避免频繁请求)
- 提供"重置"按钮,一键清空所有筛选条件并恢复默认列表
- 列表页搜索/筛选状态不跨页面保留,刷新后恢复默认
### 4.交互与操作
- 删除、清空、重置、批量操作前必须弹出二次确认弹窗,明确说明操作影响
- 批量操作按钮在未选中数据时置灰禁用,选中后激活并显示已选数量
- 所有按钮做防连点控制,同一接口短时间内不可重复触发
- 接口请求期间按钮进入 loading 状态,内容区显示骨架屏或 loading 遮罩
- 导出规范:小数据量(≤1000 条)即时导出;大数据量(>1000 条)异步导出,生成任务后提示"请在导出任务中查看";按钮文案统一为"导出列表数据",导出成功后 Toast 提示
- 所有字段校验采用前端校验 + 后端校验双重保障
### 5.数据展示
- 时间展示统一格式:YYYY-MM-DD HH:mm:ss;日期类型统一格式:YYYY-MM-DD
- 金额类型保留两位小数;百分比保留一位小数;营养元素含量保留一位小数;克数保留整数
- 浮点型数据无特别说明时默认保留两位小数
- 手机号、固定电话、身份证号使用统一正则验证规则
- 表格中数字列右对齐,文本列左对齐
- 空值/无数据字段统一展示为"—",不展示空字符串或 null
### 6.文件处理规范
- 上传入口处明确标注支持的文件类型与大小上限
- 图片文件支持在线预览;PDF、Word、Excel 支持在线预览(不强制下载)
- 大文件上传(>5MB)显示上传进度条,支持取消上传
- 下载文件统一通过浏览器下载,文件命名格式:模块名_数据名_时间戳
- 批量下载超过 10 个文件时,自动打包为 ZIP 后下载
### 7.反馈与提示
- 操作成功/失败:使用顶部居中 Toast 提示,3 秒后自动消失;提示词保持格式统一,如"删除成功"、"保存成功"、"保存失败"
- 关键操作(删除、提交、发布):使用 Confirm 确认框,需用户主动确认
- 接口报错:统一提示"系统繁忙,请稍后重试",详细错误信息仅输出至控制台
- 网络断开:全局顶部展示"网络连接已断开"横幅提示,恢复后自动消失
- 操作日志:新增、修改、删除、导出等关键操作自动记录操作日志,支持审计追溯
### 8.权限控制
- 应用权限:根据登录用户角色与所属租户动态显示主页应用模块,无权限模块不可见
- 菜单权限:根据用户角色动态渲染菜单,无权限菜单项直接隐藏
- 按钮权限:页面内操作按钮根据权限控制显示/隐藏或启用/禁用
- 数据权限:所有业务数据默认按"租户 → 组织架构层级 → 登录用户管理范围"逐级隔离,禁止数据越权访问(如员工信息、菜品库、订餐记录、监测设备数据等);综合管理后台具备跨租户视图,但仍受平台级角色限制;需要特殊处理或不做数据权限控制的板块会在对应板块的 PRD 文档中备注说明
- 接口权限:核心业务接口必须加权限校验,租户运营人员 Token 不可访问平台级综合后台接口,C 端小程序 Token 不可访问后台管理接口
## (二)C 端微信小程序共性需求
### 1.布局与导航
- 整体采用单列布局,内容区上下滑动
- 底部 Tab 栏固定为 4 个(吃 / 问 / 做 / 我),当前选中项高亮
- 顶部导航栏显示页面标题,左侧返回按钮(一级页面无返回),右侧可放置更多操作入口
- 适配主流手机分辨率(iOSiPhone 12 及以上;Android:主流全面屏),支持安全区域适配
- 支持左滑返回上一页;列表支持下拉刷新、上拉加载更多
### 2.表单设计规范
- 表单采用单列纵向排列,标签置于输入框上方
- 必填字段标注红色星号(*
- 输入框获取焦点时键盘自动弹起,内容区自动上移,避免键盘遮挡输入框
- 点击空白区域收起键盘
- 选择类控件使用底部弹出 Picker 或 ActionSheet,不使用 PC 式下拉框
- 长表单建议分步骤展示,每步不超过 5 个字段,顶部显示步骤进度条
- 表单提交后按钮进入 loading 状态,防止重复提交
- 字段字数限制规范:与后台 Web 端保持一致
| 字段类型 | 最大字数 | 说明 |
|---------|-------|-------------------------------------------------------------------------------------|
| 姓名/人名 | 20 | 中文姓名+少数民族长名 |
| 手机号 | 11 | 固定位数,正则校验 |
| 座机号 | 12 | 正则校验 |
| 身份证号 | 18 | 固定位数,正则校验 |
| 标题/名称 | 50 | 如菜品名称、食谱标题、模块名称 |
| 短文本输入 | 100 | 如地址、单位名称、一句话备注 |
| 多行备注/描述 | 500 | 如简介、问题说明、菜品描述 |
| 搜索框 | 50 | 关键词搜索 |
| 验证码 | 6 | 短信/邮箱验证码 |
| 密码 | 12~30 | 必须包含至少一个小写字母;必须包含至少一个大写字母;必须包含至少一个数字;必须包含至少一个特殊符号;密码长度必须大于等于 12 |
| 邮箱 | 50 | 常规邮箱地址,正则校验 |
| URL链接 | 200 | 网址输入 |
- 多行文本框右下角实时显示字数计数提示,格式为"已输入 X / Y 字"
- 达到字数上限后禁止继续输入
### 3.交互与操作
- 可点击区域不小于 44×44pt,确保手指触控友好
- 主按钮使用圆角大按钮,宽度占满或居中,置于页面底部悬浮或跟随内容流
- 删除、清空等危险操作使用底部弹出 ActionSheet 或居中模态框,需二次确认
- 键盘弹起时底部悬浮按钮自动上移,避免被键盘遮挡
- 列表支持下拉刷新,刷新时顶部显示 loading 动画
### 4.数据展示
- 图表采用移动端适配的简化版本,支持手势缩放查看详情
- 营养元素采用环形 / 条形可视化展示,强调直观对比;菜品热量采用色阶卡片
- 无数据时显示统一占位图 + 提示文案,提供引导操作(如"先选个菜吧")
- 空值统一展示为"—"
### 5.图片与文件处理
- 图片上传支持拍照和相册选取两种方式,入口明确区分
- 上传前自动压缩,单张图片大小不超过 2MB
- 图片预览支持双指缩放、左右滑动切换多张图片
- 文件下载显示进度,完成后提示"下载完成,点击打开"
- 上传失败时提示具体原因(如"文件格式不支持"、"文件超出大小限制"
### 6.反馈与提示
- 操作反馈使用 Toast 轻提示,1.5~2 秒自动消失,不阻断操作
- 异步请求使用全屏或局部 Loading,避免用户重复点击
- 待办事项、未读消息、未取餐订单在入口处显示红点或数字角标
- 关键操作(如订餐提交、问卷提交)成功后,给予明确的成功状态页,而非仅 Toast
### 7.网络异常处理
- 请求超时时间统一设置为 10 秒,超时后提示"服务器繁忙,请稍后再试"并提供重试按钮
- 网络断开时,页面顶部展示"当前无网络连接"横幅,恢复后自动消失并刷新数据
- 弱网环境下优先展示缓存数据,并标注"网络不佳,数据可能存在延迟"
- 关键操作(如下单、应急就医发起)在网络恢复后自动重试,并通知用户操作结果
- 首屏关键数据优先加载,非关键内容懒加载;图片使用缩略图 + 懒加载策略
## (三)硬件终端屏共性需求
> 硬件终端的设备类型多样(横屏 / 竖屏 / 超宽屏 / 触控 / 数据可视化),不同设备业务差异较大。本节给出跨设备需统一遵守的共性规范,单台设备的特殊交互在对应模块 PRD 中详述。
### 1.视口与布局
- 每类设备的 viewport(宽 × 高 × 朝向 × 缩放)在 `src/meta/nav.ts > hardwareDevices` 中独立声明,HardwareLayout 自动按此渲染外壳
- 设备按用途分两类:操作类(智能货柜 / 档口机 / 入库秤 / 营养秤 / 吐盘机 / 结算台 / 配比秤 / 炒菜机器人)—— 需要触控可点击与表单录入;展示类(带鱼屏 / 统计大屏)—— 以数据可视化为主,不接受人工输入
- 操作类设备的主要可点击区域不小于 64×64 px,需适配现场戴手套、油污环境下的触控精度
- 展示类设备布局以模块化卡片 + 大数字 + 色块为主,重要 KPI 字号不低于 40 px
### 2.数据同步与离线
- 设备应支持离线缓存关键数据(如菜品库、价格表、用户配额),网络中断 60 分钟内核心功能(出餐 / 称重 / 结算)不受影响
- 网络恢复后自动同步本地缓存到平台端,并显示同步进度
- 设备状态(在线 / 离线 / 异常)实时上报至综合管理后台「设备中心」,运营方可远程查看
### 3.交互与反馈
- 关键操作(出餐确认、结算成功、入库完成)必须给出明显的视觉反馈(满屏色块 / 大字提示)+ 声音反馈
- 错误操作给出友好提示,避免使用"系统错误"等模糊文案,应明确告知用户下一步该做什么
- 设备故障时本机应能展示故障码与重启入口,并自动上报至设备中心
### 4.安全与隐私
- 现场设备只展示与本次操作相关的最小数据范围(如本人订单、本次称重的菜品),不展示其他用户敏感数据
- 操作日志上传至平台后清除本地缓存
- 公共展示类设备不展示个人敏感字段(手机号、身份证号),仅展示聚合统计数据
## (四)跨端通用规范
### 1.品牌与视觉一致性
- 综合管理后台、租户运营后台、微信小程序、硬件终端屏采用统一的品牌色、字体、圆角、阴影等视觉语言
- 全平台功能命名、按钮文案、提示文案保持一致(如统一使用"订餐"而不出现"点餐"、"预订"等变体)
- 时间显示格式统一:近 24 小时内显示"X 小时前",超过 24 小时显示具体日期
- 错误提示文案统一维护,避免不同模块出现不同表述
- 同一用户在不同端操作,数据实时同步,无延迟感知(如小程序下单后档口机立即看到订单)
### 2.数据安全与隐私保护
- 所有数据传输使用 HTTPS 加密,禁止明文传输敏感信息
- 敏感字段(手机号、身份证号、健康指标数据)在列表中默认脱敏,详情页按权限展示完整信息
- 会话超时时间设置为 30 分钟无操作后自动登出,跳转登录页并提示"登录已过期,请重新登录"
- 移动端禁止对健康敏感数据页面进行截图分享(系统级限制,如条件不允许则在界面层提示禁止截图)
- 用户登出后本地缓存的敏感数据立即清除
### 3.字典与枚举规范
- 平台级字典(单位类型、菜品分类、营养元素、设备类型、岗位等)由综合管理后台维护;租户级字典(本租户的菜品、食谱、用餐时段等)由租户运营后台维护,C 端小程序与硬件终端通过 API 拉取
- 前后端枚举值保持严格一致,接口文档中明确枚举定义
- 字典数据由后端统一下发,前端缓存使用;字典变更时主动通知前端刷新缓存
- 列表筛选、表单选项中的枚举值均从字典接口动态获取
### 4.无障碍规范
- 后台 Web 端正文字体最小不低于 12 px,C 端小程序正文不低于 14 px,硬件终端正文不低于 18 px
- 文字与背景色对比度不低于 4.5:1(WCAG AA 标准)
- 所有图标按钮需配备文字说明或 aria-label 属性
- 表单字段需关联 label 标签,支持屏幕阅读器识别
- 关键操作结果(成功/失败)需通过文字或图标明确告知,不能仅依赖颜色区分
# 五、非功能性需求
## (一)性能要求
### 业务指标
| 指标 | 定义 | 简称 | 本系统标准 |
| --- | --- | --- | --- |
| 请求响应时间 | 指用户从客户端发起一个请求开始,到客户端接收到从服务器端返回的响应结束,整个过程所耗费的时间 | Response Time: RT | 平均响应在 1s 以下;<br>统计查询接口 3s 以下 |
| 系统处理能力 | 指系统在利用硬件平台和软件平台进行信息处理的能力,通过每秒钟能够处理的交易数量来评价 | HPS(每秒点击次数)<br>TPS(系统每秒处理交易数)<br>QPS(系统每秒处理查询次数) | HPS10000 次/秒<br>TPS3000 笔/秒<br>QPS5000 次/秒 |
| 并发用户数 | 指在同一时刻内,登录系统并进行业务操作的用户数量 | Virtual User: VU | 正常负载下支持用户并发数 3000+ |
| 错误率 | 指系统在负载情况下,失败交易的概率 | Failure Ratio: FR | 错误率低于千分之六,即成功率高于 99.6% |
### 服务器资源指标
| 指标 | 定义 | 简称 | 本系统标准 |
| --- | --- | --- | --- |
| CPU | 中央处理器,是一台计算机的运算核心与控制核心,主要功能是解释计算机指令并处理软件中的数据 | CPU | CPU sys% 小于 30%CPU wait% 小于 5%CPU Load 小于 CPU 核数 |
| 内存 | 计算机中与 CPU 进行沟通的桥梁,所有程序的运行都在内存中进行 | Memory | 一般情况下不会利用到 SWAP 空间,自身内存可支撑项目运行 |
| 磁盘吞吐量 | 指在无磁盘故障的情况下单位时间内通过磁盘的数据量 | Disk Throughput | 分布式架构下,磁盘繁忙率应低于 70% |
| 网络吞吐量 | 指在无网络故障的情况下单位时间内通过的网络数据数量,单位 Byte/s | Network Throughput | 一般情况下不超过设备或链路最大传输能力的 70% |
### 前端应用指标
| 指标 | 二级指标 | 解释 | 本系统指标 |
| --- | --- | --- | --- |
| 页面展示 | 首次显示时间 | 在浏览器地址栏输入 URL 按回车到用户看到网页的第一个视觉标志为止 | 秒级别响应 |
| | OnLoad 事件时间 | 浏览器触发 onLoad 事件的时间 | 秒级别响应 |
| | 完全载入的时间 | 所有 onLoad JavaScript 处理程序执行完毕的时间 | 秒级别响应 |
| 页面数量 | 页面大小 | 整个页面大小 | 所有页面 Body 大小为 KB 级别 |
| | 请求数量 | 从网站下载资源时所有网络请求的总数,尽量少 | — |
| 网络所花时间 | DNS 时间 | DNS 查找时间 | 秒级别响应 |
| | 连接时间 | 浏览器与 Web 服务器建立 TCP/IP 连接的时间 | 秒级别响应 |
| | 服务器时间 | 服务器处理时间 | 秒级别响应 |
| | 传输时间 | 内容传输所用时间 | 秒级别响应 |
| | 等待时间 | 等待某个资源释放的时间 | 秒级处理 |
### 稳定性、可靠性指标
| 指标 | 解释 | 本系统标准 |
| --- | --- | --- |
| 稳定性指标 | 系统按最大容量的 80% 或标准压力(系统的预期日常压力)情况下运行,能够稳定运行的最短时间 | 提供 7×24 运行的系统稳定运行能力 |
| 集群 | 集群中某个节点出现故障时,系统是否有业务中断情况;新增/恢复节点是否需要重启;节点切换需要多长时间 | 故障节点自动从集群中剔除,业务不中断;节点恢复时自动加入集群,无感知切换,纳秒级切换 |
| 备份和恢复 | 备份是否成功及消耗时间;备份是否使用脚本自动化;恢复是否成功及消耗时间;恢复是否使用脚本自动化 | 采用全量 + 增量备份策略,小时为单位周期循环备份,天/周为单位周期循环全量备份,为数据提供基础底层保障 |
## (二)兼容性要求
| 应用名称 | 系统性质 | 操作系统/浏览器内核兼容性要求 | 操作系统版本/浏览器版本要求 | 终端适配要求 | 备注 |
| --- | --- | --- | --- | --- | --- |
| 综合管理后台 / 租户运营后台 | B/S 应用 | 1. 主要适配内核:BlinkChromium)、Gecko、WebKit。<br>2. 不再支持 IE 浏览器(Trident 内核已废弃)。<br>3. 适配 PC 端主流浏览器。 | Chromium 内核:Chrome 100+Edge 100+360 极速浏览器(Chromium 内核)13+;QQ 浏览器 14+;搜狗浏览器 12+Opera 85+。<br>Gecko 内核:Firefox 100+。<br>WebKit 内核:Safari 16+(仅适用于 macOS 用户)。<br>企业微信内置浏览器(Chromium 内核):跟随企业微信最新版本。 | 1. 显示器分辨率:最低 1366×768,推荐 1920×1080 及以上,支持 2K2560×1440)。<br>2. 显示器尺寸:13in34in。 | 1. 推荐使用 Chrome 浏览器以获得最佳体验。<br>2. 不再支持 IE 浏览器,访问时提示升级。<br>3. 兼容性测试以浏览器版本为主,需同时注明内核版本。<br>4. 终端适配以分辨率为准,显示器尺寸为参考值。 |
| C 端微信小程序 | 微信小程序 | 跟随微信官方基础库版本 | 微信客户端 8.0.30+;基础库 2.27+ | 1. 适配 iPhone 12 及以上、Android 主流全面屏(1080×2400 以上);<br>2. 支持安全区域适配;<br>3. 设计基准:375×812iPhone X)。 | 在不同机型上需进行界面兼容性测试,输出兼容性报告。 |
| 硬件终端屏 | 嵌入式应用 / Web 套壳 | Android 10+(操作类设备)或 Windows / Linux + Chromium(展示类设备) | 操作类:Android 10+;展示类:Chromium 100+ | 各设备视口在 nav.ts 中独立声明,详见每台设备 PRD | 现场环境(戴手套触控、油污、强光)需做实地测试 |
+36
View File
@@ -0,0 +1,36 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import routes from './routes'
import { readCurrentRole } from '@composables/useVisibility'
import { installPageHistoryTracker } from '@composables/usePageHistory'
/**
* 路由实例
* - 使用 hash 模式,便于部署到任何静态服务(含本地双击 dist/index.html
* - 全局守卫:未登录或登录已过期则跳转到 /gate
* - 自动追踪 portal 内访问历史(用于顶部"最近打开菜单"Tab 栏)
*/
const router = createRouter({
history: createWebHashHistory(),
routes,
scrollBehavior: () => ({ top: 0 }),
})
router.beforeEach((to) => {
// 公共路由(/gate)直接放行
if (to.meta.public) return true
const role = readCurrentRole()
// 未登录 → 重定向到 gate,登录后回到原目标
if (!role) {
return { path: '/gate', query: { redirect: to.fullPath } }
}
// designerOnly 路由:研发角色不可访问
if (to.meta.designerOnly && role !== 'designer') {
return { path: '/' }
}
return true
})
// 追踪 portal 访问历史
installPageHistoryTracker(router)
export default router
+222
View File
@@ -0,0 +1,222 @@
import type { RouteRecordRaw } from 'vue-router'
import {
adminApps,
tenantApps,
miniprogramTabs,
hardwareDevices,
isNavGroup,
type NavApp,
type NavSection,
type NavLeaf,
} from '@meta/nav'
/**
* 基于 nav.ts 自动生成路由配置
* - 四个 Portal 各挂一棵子树
* - 页面组件来自 NavLeaf.component(懒加载)
*
* 后台 URL 结构:/<portal>/<app>/<section>/<page>
* /<portal> → 应用网格(首页)
* /<portal>/<app> → 跳到该应用首个一级菜单的首页
*/
/** 将后台应用列表展平为路由叶子节点 */
const flattenPortalPages = (
apps: NavApp[],
portalKey: string,
): RouteRecordRaw[] => {
const routes: RouteRecordRaw[] = []
apps.forEach((app) => {
app.children.forEach((section: NavSection) => {
section.children.forEach((item) => {
const leaves: NavLeaf[] = isNavGroup(item) ? item.children : [item]
leaves.forEach((leaf) => {
const r: RouteRecordRaw = {
path: `${app.key}/${section.key}/${leaf.path}`,
name: leaf.key,
component: leaf.component as RouteRecordRaw['component'],
meta: {
portal: portalKey,
app: app.key,
section: section.key,
label: leaf.label,
},
} as RouteRecordRaw
routes.push(r)
})
})
})
})
return routes
}
/** 取后台第一个可访问的页面路径(用于 portal 根路径重定向) */
const firstPortalEntry = (apps: NavApp[]): string | null => {
for (const app of apps) {
for (const section of app.children) {
const first = section.children[0]
if (!first) continue
const leaf = isNavGroup(first) ? first.children[0] : first
if (leaf) return `${app.key}/${section.key}/${leaf.path}`
}
}
return null
}
/** 取一个应用的第一个可访问页面路径(用于 /<portal>/<app> 重定向) */
const firstAppEntry = (app: NavApp | undefined): string | null => {
if (!app) return null
for (const section of app.children) {
const first = section.children[0]
if (!first) continue
const leaf = isNavGroup(first) ? first.children[0] : first
if (leaf) return `${section.key}/${leaf.path}`
}
return null
}
/** 小程序路由:展平 tabs + 各 tab 的 subPages */
const buildMiniprogramRoutes = (): RouteRecordRaw[] => {
const routes: RouteRecordRaw[] = []
miniprogramTabs.forEach((tab) => {
routes.push({
path: tab.page.path,
name: tab.page.key,
component: tab.page.component as RouteRecordRaw['component'],
meta: { portal: 'miniprogram', tab: tab.key, label: tab.label },
} as RouteRecordRaw)
tab.subPages?.forEach((sub) => {
routes.push({
path: sub.path,
name: sub.key,
component: sub.component as RouteRecordRaw['component'],
meta: { portal: 'miniprogram', tab: tab.key, label: sub.label },
} as RouteRecordRaw)
})
})
return routes
}
/** 硬件路由:按设备类型嵌套,路径形如 /hardware/cabinet/home */
const buildHardwareRoutes = (): RouteRecordRaw[] => {
const routes: RouteRecordRaw[] = []
hardwareDevices.forEach((device) => {
device.children.forEach((leaf) => {
routes.push({
path: `${device.key}/${leaf.path}`,
name: leaf.key,
component: leaf.component as RouteRecordRaw['component'],
meta: { portal: 'hardware', device: device.key, label: leaf.label },
} as RouteRecordRaw)
})
})
return routes
}
/** 构建一个 portal 的所有子路由(首页 + 应用入口重定向 + 页面) */
const buildPortalChildren = (apps: NavApp[], portalKey: string): RouteRecordRaw[] => {
const children: RouteRecordRaw[] = []
// 应用网格首页
children.push({
path: '',
name: `${portalKey}-home`,
component: () => import('@pages/tenant-portal/home/AppGrid.vue'),
meta: { portal: portalKey, label: '应用首页', isAppGrid: true },
} as RouteRecordRaw)
// 每个应用 /<portal>/<app> → 跳到该应用第一个页面
apps.forEach((app) => {
const entry = firstAppEntry(app)
if (entry) {
children.push({
path: app.key,
redirect: `/${portalKey}/${app.key}/${entry}`,
})
}
})
// 所有具体页面
children.push(...flattenPortalPages(apps, portalKey))
return children
}
/** 全局路由表 */
const routes: RouteRecordRaw[] = [
{
path: '/gate',
name: 'gate',
component: () => import('@pages/gate/Gate.vue'),
meta: { label: '访问授权', public: true },
},
{
path: '/__console',
name: 'visibility-console',
component: () => import('@pages/console/VisibilityConsole.vue'),
meta: { label: '设计完成度控制台', designerOnly: true },
},
{
path: '/__prd',
name: 'prd-viewer',
component: () => import('@pages/prd/PrdViewer.vue'),
meta: { label: 'PRD 在线查看' },
},
{
path: '/__overview',
name: 'design-overview',
component: () => import('@pages/overview/DesignOverview.vue'),
meta: { label: '设计总览' },
},
{
path: '/',
name: 'home',
component: () => import('@pages/home/Home.vue'),
meta: { label: '原型导航首页' },
},
/** 综合管理后台 */
{
path: '/admin-portal',
component: () => import('@layouts/PortalLayout.vue'),
props: { variant: 'admin' },
children: buildPortalChildren(adminApps, 'admin-portal'),
},
/** 租户运营后台 */
{
path: '/tenant-portal',
component: () => import('@layouts/PortalLayout.vue'),
props: { variant: 'tenant' },
children: buildPortalChildren(tenantApps, 'tenant-portal'),
},
/** C 端微信小程序 */
{
path: '/miniprogram',
component: () => import('@layouts/MiniprogramLayout.vue'),
redirect: `/miniprogram/${miniprogramTabs[0]?.page.path ?? 'home'}`,
children: buildMiniprogramRoutes(),
},
/** 硬件终端屏 */
{
path: '/hardware',
component: () => import('@layouts/HardwareLayout.vue'),
redirect: () => {
const d = hardwareDevices[0]
const p = d?.children[0]
return d && p ? `/hardware/${d.key}/${p.path}` : '/'
},
children: buildHardwareRoutes(),
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
]
// firstPortalEntry 保留导出(控制台/总览可能用)
export { firstPortalEntry }
export default routes
+46
View File
@@ -0,0 +1,46 @@
/**
* 全局基础样式
* 设计令牌沿用现有原型规范,AntDV 主题色保持 #1890FF
*/
:root {
--primary: #1890ff;
--primary-light: #e6f7ff;
--success: #52c41a;
--warning: #faad14;
--danger: #ff4d4f;
--text-primary: #333;
--text-secondary: #666;
--text-placeholder: #999;
--border: #e8e8e8;
--background: #f5f7fa;
--font-xs: 11px;
--font-sm: 13px;
--font-base: 15px;
--font-lg: 17px;
--font-xl: 20px;
--font-xxl: 24px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.16);
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
background: var(--background);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: var(--font-base);
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/types/**/*.d.ts"
],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@meta/*": ["src/meta/*"],
"@layouts/*": ["src/layouts/*"],
"@components/*": ["src/components/*"],
"@composables/*": ["src/composables/*"],
"@pages/*": ["src/pages/*"],
"@router/*": ["src/router/*"]
},
"types": ["node"]
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"include": ["vite.config.ts"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"types": ["node"]
}
}
+53
View File
@@ -0,0 +1,53 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver, VantResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
/**
* Vite 构建配置
* - 端口固定 5180,避开正式工程 7000-7009
* - 自动导入 Vue / Vue Router 核心 API
* - 自动注册 Ant Design VueWeb 端)与 Vant(移动端)组件
*/
export default defineConfig({
server: {
port: 5180,
host: true,
open: false,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@meta': fileURLToPath(new URL('./src/meta', import.meta.url)),
'@layouts': fileURLToPath(new URL('./src/layouts', import.meta.url)),
'@components': fileURLToPath(new URL('./src/components', import.meta.url)),
'@composables': fileURLToPath(new URL('./src/composables', import.meta.url)),
'@pages': fileURLToPath(new URL('./src/pages', import.meta.url)),
'@router': fileURLToPath(new URL('./src/router', import.meta.url)),
},
},
plugins: [
vue(),
/** 自动导入 Vue/Vue Router API,无需手动 import */
AutoImport({
imports: ['vue', 'vue-router'],
dts: 'src/types/auto-imports.d.ts',
eslintrc: { enabled: false },
}),
/** 自动注册组件库 + 项目公共组件 */
Components({
dts: 'src/types/components.d.ts',
dirs: ['src/components', 'src/layouts/components'],
resolvers: [
AntDesignVueResolver({ importStyle: false }), // 样式由 reset.css 与组件运行时注入
VantResolver(),
],
}),
],
})