init: 智养餐饮平台 V3 原型工程初始化
This commit is contained in:
@@ -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 不一致,优先采用代码并在文档末尾"差异说明"中列出
|
||||
- 生成完毕提醒用户:本文档基于原型源代码,若原型迭代请重新生成
|
||||
@@ -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 实际查找引用,禁止凭空判断
|
||||
- 不要给出主观的"代码风格建议"(如变量命名口味);只对**有客观依据的规范**给意见
|
||||
@@ -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` — 个性化回复时
|
||||
@@ -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 4(375×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 明确"优先使用 puppeteer,puppeteer 截图失真或者尺寸不对时再用 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'"` 渲染该按钮。研发模式下不可见。
|
||||
@@ -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. 截图工具的优先级与降级策略
|
||||
|
||||
**规则**:截图优先 puppeteer;puppeteer 失真或尺寸不对时再用 chrome-devtools MCP。
|
||||
|
||||
**Why**:用户 2026-06-04 明确说"优先使用 puppeteer,puppeteer 截图失真或者尺寸不对时再用 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`)
|
||||
- 超限:降 quality(85→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"。
|
||||
@@ -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 4(Hash 模式) |
|
||||
| 状态管理 | 不引入 Pinia |
|
||||
| 样式 | LESS + CSS 变量(设计令牌) |
|
||||
| 自动导入 | unplugin-auto-import + unplugin-vue-components |
|
||||
| Markdown | markdown-it(PRD 渲染) |
|
||||
| 截图 | 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 + audience,dev 看不到 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/
|
||||
```
|
||||
@@ -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 ~670KB(AntDV 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+ 页面后做)
|
||||
@@ -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 的工程判断**,但需要在关键决策点(选型、阶段拆分、目录结构)有清晰对齐。
|
||||
@@ -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),否则边距错位
|
||||
- 总览页是"静态截图浏览器",不会调起业务页面 — 截图缺失就是缺失,必须先截
|
||||
@@ -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 |
|
||||
|------|------|---------|-------------|
|
||||
| 姓名/工号 | 文本框 | - | 请输入姓名或工号 |
|
||||
| 所属单位 | 下拉 | unitOptions(3 个值) | 全部 |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
## 六、操作流程
|
||||
|
||||
> 自动从模板的 @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`(自定义渲染列),在"表格列展示规则"中说明渲染规则,不要凭空推断字段
|
||||
@@ -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` | 表格 columns(dataIndex / 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`,禁止跨端引用
|
||||
@@ -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 子应用对齐):
|
||||
|
||||
```
|
||||
Portal(admin-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 + types;api 视需要) |
|
||||
|
||||
### 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 调用即可
|
||||
@@ -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,不调用任何工具子进程)
|
||||
@@ -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 |
@@ -0,0 +1,5 @@
|
||||
# 门禁页密码(提交到 git;生产分发时可在部署脚本里替换)
|
||||
# 设计师密码:可看见全部内容(含未完成草稿)
|
||||
VITE_GATE_PASSWORD_DESIGNER=design2026
|
||||
# 研发密码:只能看见 status≠draft 且 audience≠designer 的内容
|
||||
VITE_GATE_PASSWORD_DEV=dev2026
|
||||
+35
@@ -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
|
||||
@@ -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 4(375×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 4(Hash 模式) |
|
||||
| 状态管理 | 不引入 Pinia |
|
||||
| 样式 | LESS + CSS 变量(设计令牌) |
|
||||
| 自动导入 | unplugin-auto-import + unplugin-vue-components |
|
||||
| Markdown 渲染 | markdown-it(PRD 查看) |
|
||||
| 截图 | 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: adminApps;tenant: 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 移动端底部 Tab(Vant 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
@@ -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>
|
||||
Generated
+3005
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 应用根:只承载路由出口,所有布局在各 Layout 内部实现 */
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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 icon(SearchOutlined / 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<!-- 卡片头:左侧 actions(slot) + 可选标题 + 右侧 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>
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 可见性控制
|
||||
* - 当前角色:'designer'(产品/设计,全部可见) | 'dev'(研发,过滤 draft / designer-only)
|
||||
* - 角色来自门禁页登录态(localStorage: gateway.role);默认 'designer'
|
||||
* - 控制台可对单页面覆写 status / audience(localStorage: 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)
|
||||
}
|
||||
|
||||
/** 过滤小程序 tabs:tab 的 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,
|
||||
}
|
||||
}
|
||||
@@ -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` 自动汇总)。
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.key,change 事件回传完整 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>
|
||||
@@ -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 /> 刷新当前页面
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeOthers">
|
||||
<CloseOutlined /> 关闭其他菜单
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeAll">
|
||||
<DeleteOutlined /> 关闭所有菜单
|
||||
</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>
|
||||
@@ -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" />  刷新菜单
|
||||
</a-menu-item>
|
||||
<a-menu-item key="fullscreen">
|
||||
<component :is="ExpandOutlined" />  打开全屏
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout">
|
||||
<component :is="LogoutOutlined" />  退出登录
|
||||
</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:原型首页用;compact:portal/小程序/硬件内用 */
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* 菜单数据单一来源
|
||||
* - 同时供 router/routes.ts(生成路由)与 layouts/*(渲染菜单)消费
|
||||
* - 新增页面只改这一处,路由 + 菜单同步生效
|
||||
*
|
||||
* 四个终端:
|
||||
* - admin-portal 综合管理后台(AntDV 后台 UI)
|
||||
* - tenant-portal 租户运营后台(AntDV 后台 UI;下挂 17 个独立应用)
|
||||
* - miniprogram C 端微信小程序(Vant,375×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-monitor(menu_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 类
|
||||
* - 每类独立 viewport(width × 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>
|
||||
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="vc">
|
||||
<header class="vc__header">
|
||||
<h1 class="vc__title">设计完成度控制台</h1>
|
||||
<p class="vc__sub">控制各页面在「研发模式」下的可见性。所有变更存储在 localStorage(visibility.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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<PlaceholderPage
|
||||
title="结算台 - 首页"
|
||||
name="hardware/cashier/home"
|
||||
>
|
||||
硬件占位。本设备的 viewport、朝向、缩放参数在 nav.ts 的 hardwareDevices 中独立声明。
|
||||
</PlaceholderPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 结算台 首页占位 */
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<PlaceholderPage
|
||||
title="炒菜机器人 - 首页"
|
||||
name="hardware/cooking-robot/home"
|
||||
>
|
||||
硬件占位。本设备的 viewport、朝向、缩放参数在 nav.ts 的 hardwareDevices 中独立声明。
|
||||
</PlaceholderPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 炒菜机器人 首页占位 */
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<PlaceholderPage
|
||||
title="吐盘机 - 首页"
|
||||
name="hardware/disc-machine/home"
|
||||
>
|
||||
硬件占位。本设备的 viewport、朝向、缩放参数在 nav.ts 的 hardwareDevices 中独立声明。
|
||||
</PlaceholderPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 吐盘机 首页占位 */
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<PlaceholderPage
|
||||
title="配比秤 - 首页"
|
||||
name="hardware/ratio-scale/home"
|
||||
>
|
||||
硬件占位。本设备的 viewport、朝向、缩放参数在 nav.ts 的 hardwareDevices 中独立声明。
|
||||
</PlaceholderPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 配比秤 首页占位 */
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<PlaceholderPage
|
||||
title="带鱼屏 - 首页"
|
||||
name="hardware/ribbon-screen/home"
|
||||
>
|
||||
硬件占位。本设备的 viewport、朝向、缩放参数在 nav.ts 的 hardwareDevices 中独立声明。
|
||||
</PlaceholderPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 带鱼屏 首页占位 */
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<PlaceholderPage
|
||||
title="智能货柜 - 首页"
|
||||
name="hardware/smart-cabinet/home"
|
||||
>
|
||||
硬件占位。本设备的 viewport、朝向、缩放参数在 nav.ts 的 hardwareDevices 中独立声明。
|
||||
</PlaceholderPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 智能货柜 首页占位 */
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<PlaceholderPage
|
||||
title="档口机 - 首页"
|
||||
name="hardware/stall-machine/home"
|
||||
>
|
||||
硬件占位。本设备的 viewport、朝向、缩放参数在 nav.ts 的 hardwareDevices 中独立声明。
|
||||
</PlaceholderPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 档口机 首页占位 */
|
||||
</script>
|
||||
@@ -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
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<AppPlaceholder
|
||||
title="小程序 - 问"
|
||||
name="miniprogram/ask"
|
||||
>
|
||||
占位:本 Tab 下属页面后续根据 PRD 拓展。
|
||||
</AppPlaceholder>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 小程序 问 Tab 首页占位 */
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<AppPlaceholder
|
||||
title="小程序 - 吃"
|
||||
name="miniprogram/eat"
|
||||
>
|
||||
占位:本 Tab 下属页面后续根据 PRD 拓展。
|
||||
</AppPlaceholder>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 小程序 吃 Tab 首页占位 */
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<AppPlaceholder
|
||||
title="小程序 - 做"
|
||||
name="miniprogram/make"
|
||||
>
|
||||
占位:本 Tab 下属页面后续根据 PRD 拓展。
|
||||
</AppPlaceholder>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 小程序 做 Tab 首页占位 */
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<AppPlaceholder
|
||||
title="小程序 - 我"
|
||||
name="miniprogram/me"
|
||||
>
|
||||
占位:本 Tab 下属页面后续根据 PRD 拓展。
|
||||
</AppPlaceholder>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/** 小程序 我 Tab 首页占位 */
|
||||
</script>
|
||||
@@ -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/<页面>/<状态>.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">×</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 / hardware;Web 后台用 PRD 替代
|
||||
*/
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
miniprogramTabs,
|
||||
hardwareDevices,
|
||||
} from '@meta/nav'
|
||||
|
||||
type Portal = 'miniprogram' | 'hardware'
|
||||
|
||||
interface ImageItem {
|
||||
/** 文件 URL(vite 处理后) */
|
||||
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 下所有截图为 URL,eager 一次性加载(图片是 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(搜索条件 + 分页)
|
||||
* @响应 EmployeeListResponse(records + 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(不含分页字段)
|
||||
* @响应 Blob(xlsx 二进制流)
|
||||
*/
|
||||
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×900,1x 缩放
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 三、统计区
|
||||
|
||||
页面顶部展示 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) |
|
||||
@@ -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 个(吃 / 问 / 做 / 我),当前选中项高亮
|
||||
|
||||
- 顶部导航栏显示页面标题,左侧返回按钮(一级页面无返回),右侧可放置更多操作入口
|
||||
|
||||
- 适配主流手机分辨率(iOS:iPhone 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(系统每秒处理查询次数) | HPS:10000 次/秒<br>TPS:3000 笔/秒<br>QPS:5000 次/秒 |
|
||||
| 并发用户数 | 指在同一时刻内,登录系统并进行业务操作的用户数量 | 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. 主要适配内核:Blink(Chromium)、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 及以上,支持 2K(2560×1440)。<br>2. 显示器尺寸:13in–34in。 | 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×812(iPhone X)。 | 在不同机型上需进行界面兼容性测试,输出兼容性报告。 |
|
||||
| 硬件终端屏 | 嵌入式应用 / Web 套壳 | Android 10+(操作类设备)或 Windows / Linux + Chromium(展示类设备) | 操作类:Android 10+;展示类:Chromium 100+ | 各设备视口在 nav.ts 中独立声明,详见每台设备 PRD | 现场环境(戴手套触控、油污、强光)需做实地测试 |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+7
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"include": ["vite.config.ts"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -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 Vue(Web 端)与 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(),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user