diff --git a/.claude/skills/new-page.md b/.claude/skills/new-page.md index 705baab..85d96a7 100644 --- a/.claude/skills/new-page.md +++ b/.claude/skills/new-page.md @@ -1,17 +1,31 @@ --- name: new-page -description: 在原型工程中新建一个标准列表页 / 详情页 / 弹窗,按 init 三件套规范生成完整骨架(vue + types + api + 三个 init 文件),并自动注册到 src/meta/nav.ts。当用户说"新增 xxx 页面"、"加一个 xxx 列表"或"new-page" 时触发。 +description: 在原型工程中新建一个标准列表页 / 详情页 / 弹窗,按 vue 研发项目规范生成完整骨架(vue + types + api + 三个 init 文件 + mock handler),并自动注册到 src/meta/nav.ts。当用户说"新增 xxx 页面"、"加一个 xxx 列表"或"new-page" 时触发。 --- -# Skill: 新建原型页面脚手架 +# Skill: 新建原型页面脚手架(对齐 vue 研发项目) + +## 总体原则 + +本 skill 生成的页面骨架必须与生产 vue 项目(D:\Work\platform-vue-tenant)的代码规范 1:1 对齐,研发接手时几乎无需改动。 + +**强制规范来源**: +- `src/docs/list-page-spec.md`(列表页规范) +- `src/docs/modal-spec.md`(弹框规范) +- `src/docs/drawer-spec.md`(抽屉规范) +- `src/docs/detail-page-spec.md`(详情页规范) +- `src/docs/tabs-page-spec.md`(Tabs 页规范) +- `src/docs/cross-project-mapping.md`(原型 ↔ 研发对照表) + +**标准参考样板**:`src/pages/tenant-portal/nutrition/arc/arcEmployee/` ## 图标规范(强制) -所有图标必须取自 `@ant-design/icons-vue`(4.x),**禁止使用 emoji / 自定义 svg / 第三方 icon** 作为菜单或按钮图标。 +所有图标必须取自 `@ant-design/icons-vue`(4.x),**禁止使用 emoji / 自定义 svg / 第三方 icon**。 -> 应用图标(NavApp.icon)可继续使用 emoji,仅用于"应用网格首页"与左上角应用切换器。 +> 应用图标(NavApp.icon)允许 emoji,仅用于"应用网格首页"与左上角应用切换器。 -**菜单图标**(在 nav.ts 中以字符串形式声明组件名,渲染时 layouts 用 ``): +**菜单图标**: | 层级 | 字段 | 是否必填 | 兜底 | |------|------|---------|------| @@ -19,23 +33,14 @@ description: 在原型工程中新建一个标准列表页 / 详情页 / 弹窗 | 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): +**操作按钮图标**(100% 遵循统一映射): | 操作 | 图标 | |------|------| | 查询 / 搜索 | SearchOutlined | | 重置 | RedoOutlined | -| 新增 / 添加 | PlusOutlined | -| 编辑 / 修改 | EditOutlined | +| 新增 | PlusOutlined | +| 编辑 | EditOutlined | | 删除 | DeleteOutlined | | 导入 | UploadOutlined | | 导出 | DownloadOutlined | @@ -43,37 +48,37 @@ description: 在原型工程中新建一个标准列表页 / 详情页 / 弹窗 | 查看任务/列表 | 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 (三级页面) + └─ NavApp(独立可部署应用) + └─ NavSection(应用内一级菜单,顶部 tab) + └─ NavGroup(二级分组,可选)| NavLeaf(二级页面) + └─ NavLeaf(三级页面) ``` URL:`///
/` -## 输入参数(向用户依次提问) +## 输入参数(向用户依次询问) -1. **页面类型**:`list`(列表页)| `detail`(详情页)| `modal`(弹窗) +1. **页面类型**:`list`(列表页)| `detail`(详情页)| `modal`(弹窗)| `tabs`(Tabs 页) 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 建议 `-
-<页面简称>` 风格 +3. **所属 NavApp** key:如 `nutrition`、`monitor` +4. **所属 NavSection** key:如 `nutrition-arc`、`nutrition-farm` +5. **如有二级分组**:NavGroup key 与 label +6. **页面 key**:唯一 + 小驼峰,如 `arcUnit`;建议 `-
-<页面简称>` 风格 7. **页面中文名**:如 `单位营养报表` 8. **路由 path 片段**:短横线小写,如 `arc-unit` -9. **图标**(可选):emoji,未指定时 Sidebar 自动用默认图标 +9. **菜单图标**:必须是 `@ant-design/icons-vue` 的 `XxxOutlined` 名称 +10. **页面功能**(重要 - 决定生成哪些按钮/组件): + - **列表页**:必问 `["search"|"detail"|"add"|"edit"|"delete"|"import"|"export"|"batch-delete"|"audit"|...]` 至少包含一项 + - 默认只生成 `search`(查询)+ `detail`(查看详情,挂 InfoDrawer) + - 用户未明确指定 `add/edit/delete` 时**禁止**生成 addOrEdit 弹框 + - 用户未明确指定 `export` 时**禁止**生成导出按钮 + - 用户未明确指定 `import` 时**禁止**生成导入按钮 ## 执行流程 @@ -82,134 +87,157 @@ URL:`///
/` - 检查 `src/pages///
/<页面key>/` 目录不存在 - 检查 `src/meta/nav.ts` 对应 NavSection 的 children 内不存在同名 leaf -如已存在,告知用户并中止。 +### Step 2:生成骨架(以列表页 list 为例) -### Step 2:以 arcEmployee 为蓝本生成骨架 - -参考路径:`src/pages/tenant-portal/nutrition/arc/arcEmployee/` - -生成文件清单(list 类型): +**文件清单(6 文件 + 可选 component/modal/addOrEdit/ 4 文件)**: ``` src/pages///
/<页面key>/ -├── <页面key>.vue # 薄模板层(仅渲染,不写逻辑) -├── types/index.ts # 接口类型集中 -├── api/index.ts # 接口契约占位 -└── init/ - ├── usePage.ts # 页面级 state + 行为(无 PageHeader / 无 pageInfo) - ├── useSearch.ts # 搜索字段 + 选项 + 查询/重置 - └── useTable.ts # 列 + 数据 + 分页(mock 数据写这里) +├── <页面key>.vue # 模板层(薄) +├── api/index.ts # postRequest 调用 +├── types/index.ts # 类型集中定义 +├── init/ +│ ├── usePage.ts # 页面主逻辑 +│ ├── useTable.ts # TableState 表格状态 +│ └── useSearch.ts # reactive + Object.assign 重置 +└── component/modal/addOrEdit/ # 可选:新增/编辑弹框 + ├── addOrEdit.vue + ├── init/usePage.ts + ├── api/index.ts + └── types/index.ts ``` -模板差异: +### Step 3:填充内容(强制按 list-page-spec.md 模板) -| 页面类型 | 包含文件 | -|---------|---------| -| `list` | 全部 6 个文件 | -| `detail` | 5 个文件(去掉 useSearch + useTable,新增 useDetail.ts) | -| `modal` | 4 个文件(vue + usePage + types;api 视需要) | +**types/index.ts**: +- `SearchForm`(字段全 optional) +- `ListItem`(id 统一 string 类型) +- `ListParams`(继承 SearchForm + pageNum/pageSize/order/column) -### Step 3:填充内容(遵循新规范) +**api/index.ts**: +- import `postRequest from '@axios'` +- 接口路径 `//<页面key>/page`、`/info`、`/add`、`/update`、`/delete` +- 每个方法返回 `Promise>` -**列表页 vue 模板**: +**init/useSearch.ts**: +- `createSearchKey()` 工厂函数 +- `createOptions()` 工厂函数 +- `search = reactive(createSearchKey())` +- `resetSearch` 用 `Object.assign(search, createSearchKey())` +- `initOptions` 用 `Promise.all` 并行加载下拉 -```vue - +### Step 4:生成 mock handler + +在 `src/axios/handlers//<页面key>.ts` 创建 mock,注册到 `src/axios/handlers/index.ts`: + +```typescript +import { registerMocks } from '@/axios/mockBus' + +registerMocks([ + { url: '//<页面key>/page', method: 'POST', handler: (ctx) => { ... } }, + { url: '//<页面key>/info', method: 'POST', handler: (ctx) => { ... } }, + { url: '//<页面key>/add', method: 'POST', handler: (ctx) => { ... } }, + { url: '//<页面key>/update', method: 'POST', handler: (ctx) => { ... } }, + { url: '//<页面key>/delete', method: 'POST', handler: (ctx) => { ... } }, +]) ``` -**重要规范**: +并在 `src/axios/handlers/index.ts` 末尾追加: -- ❌ **禁止**渲染 `PageHeader`(标题、面包屑由顶部菜单/历史栏表达) -- ❌ **禁止**给 `TableCard` 传 `title`(菜单已经表达了所在位置) -- ✅ **必须**通过 `TableCard #toolbar` 放置主操作按钮(导出、新增等),位置在表格卡片左上方 -- ✅ **可选**使用 `TableCard #extra` 放置次要操作(视图切换、批量操作等) -- ✅ **可选**统计卡片放在 `FilterBar` 之上 -- ✅ 替换实体名占位:`Employee` → 用户输入的页面 key 推导 -- ✅ API 接口路径占位:`POST /api//<页面key>/page` +```typescript +import './/<页面key>' +``` -### Step 4:注册到 nav.ts +### Step 5:注册到 nav.ts 读取 `src/meta/nav.ts`,找到目标 NavApp → NavSection 对象: -- 若指定了 `分组 key`:找到该 NavGroup(若不存在则新建),在其 children 末尾追加 NavLeaf +- 若指定分组:找到 NavGroup(若不存在则新建),children 末尾追加 NavLeaf - 若未指定分组:直接在 NavSection.children 末尾追加 NavLeaf -- NavLeaf 模板: ```ts { key: '', label: '<中文名>', path: '<路由 path 片段>', - icon: '', // 可选,省略时 Sidebar 用默认图标 - status: 'draft', // 新页面默认 draft + icon: '', + status: 'draft', component: () => import('@pages///
/<页面key>/<页面key>.vue'), } ``` -### Step 5:验证 +### Step 6:验证 -- 打印生成清单,告知用户在浏览器访问的路由路径:`///
/` -- 若 dev 已启动,提示用户直接刷新即可看到新菜单与页面 +- 打印生成清单 +- 打印访问 URL:`///
/` +- 打印 mock 路径列表 -## 输出物 +## 强制规范(与 vue 研发项目对齐) -1. 6 个新文件(list 类型) -2. 修改后的 `src/meta/nav.ts` -3. 终端打印的访问 URL +| 项 | 要求 | +|---|------| +| 请求风格 | `.then().catch().finally()`,禁 `async/await` | +| 函数关键字 | 仅 `const x = () =>`,禁 `function` | +| 表格状态 | `TableState` 统一对象 | +| 搜索状态 | `reactive + 工厂函数 + Object.assign` | +| 子组件 ref | 必须在父 `usePage.ts` 定义 | +| 子组件打开方法 | `open + 组件名` | +| 子组件 emit | Modal 用 `load`,Drawer 用 `ok` | +| 删除二次确认 | `Modal.confirm` + `okType: 'danger'` | +| 弹窗静态方法 | `useAntdStaticMethods()` | +| 空值展示 | `{{ x ?? '—' }}` | +| 时间格式 | 时间 `YYYY-MM-DD HH:mm:ss`,日期 `YYYY-MM-DD` | +| ID 类型 | `string \| undefined` | +| mock 数据 | 必须放 `src/axios/handlers/`,禁写 useTable.ts | ## 禁止事项 -- 禁止跨页面修改其他模块的代码 -- 禁止跨应用 import(不同 NavApp 之间模拟独立部署) -- 禁止省略 `init/` 拆分;即使只有一个搜索字段或一列表格,也必须独立文件 -- 禁止把 mock 数据写在 .vue 的 setup 里,必须放 useTable.ts -- 禁止修改 `src/components/` 下的公共组件,原型阶段所有定制都在页面内 -- 禁止渲染 PageHeader 标题 / TableCard title(新规范) -- 禁止把导出/新增按钮放在页面顶部独立位置(必须放 TableCard #toolbar) +- ❌ 禁止跨页面修改其它模块代码 +- ❌ 禁止跨应用 import +- ❌ 禁止省略 `init/` 三件套,即使只有一个字段也必须独立 +- ❌ 禁止把 mock 数据写在 useTable.ts,必须放 `src/axios/handlers/` +- ❌ **禁止单个 mock dataset 超过 10 条**(原型展示而非性能压测) +- ❌ 禁止渲染 PageHeader / TableCard title +- ❌ 禁止把主操作按钮放页面顶部独立位置(必须放 TableCard #toolbar) +- ❌ 禁止使用 emoji / 自定义 svg 作为菜单或按钮图标 +- ❌ **禁止给操作列的 `` 加图标** +- ❌ **禁止脚手架默认生成"新增/编辑/删除"按钮**,必须根据用户指定的页面功能裁剪 +- ❌ 禁止 `async/await` +- ❌ 禁止 `function` 关键字 +- ❌ 禁止直接 `import { message } from 'ant-design-vue'`,必须 `useAntdStaticMethods()` +- ❌ 禁止在 `.vue` 文件定义子组件 ref +- ❌ 禁止 `.vue` 文件写业务逻辑 ## 注意事项 - 所有注释使用简体中文 -- TypeScript 类型必须明确声明,不允许 `any` +- TypeScript 类型必须明确,禁止 `any` - 字段命名小驼峰;CSS 类名 BEM -- 生成后再次提醒用户:原型阶段 mock 数据写在 useTable.ts,工程化时只需替换为 api 调用即可 +- 生成后提醒用户:mock 数据在 `src/axios/handlers/`,研发接手时仅需替换 `src/axios/` 为真实 axios 即可 diff --git a/.claude/tmp/verify-arc-employee.png b/.claude/tmp/verify-arc-employee.png deleted file mode 100644 index ff98764..0000000 Binary files a/.claude/tmp/verify-arc-employee.png and /dev/null differ diff --git a/.claude/tmp/verify-page-history-v2.png b/.claude/tmp/verify-page-history-v2.png deleted file mode 100644 index 16eef1b..0000000 Binary files a/.claude/tmp/verify-page-history-v2.png and /dev/null differ diff --git a/CLAUDE.md b/CLAUDE.md index 6cf0780..4462ee0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ yx-platform-prototype/ │ └── user_profile.md 用户画像 │ └── src/ - ├── main.ts 应用入口:创建 Vue 实例、挂载 router + ├── main.ts 应用入口:创建 Vue 实例、挂载 router、注册指令、初始化 antd 静态方法、加载 mock handlers ├── App.vue 根组件,仅 ├── style.css 全局设计令牌(CSS 变量)+ reset │ @@ -113,6 +113,34 @@ yx-platform-prototype/ │ - admin: adminApps;tenant: tenantApps(含 nutrition / monitor 完整三层菜单) │ - status + audience 字段控制可见性 │ + ├── axios/ 🆕 假 axios 层(与 vue 研发项目签名 1:1,研发接手时整体替换) + │ ├── index.ts getRequest / postRequest / putRequest / deleteRequest / restfulRequest / uploadRequest / downloadRequest + │ ├── mockBus.ts mock 总线(registerMock / dispatchMock / 200~500ms 模拟延迟) + │ ├── types.ts ApiResponse / ServiceType / MockHandler 等 + │ └── handlers/ mock handler 注册(按 <模块>/<页面>.ts 组织) + │ ├── index.ts 统一 import 触发副作用 + │ └── nutrition/employee.ts + │ + ├── api/ + │ └── index.ts 🆕 跨模块公共接口(字典 / 部门树 / 组织树) + │ + ├── utils/ 🆕 业务工具(与 vue 研发项目 @utils 1:1) + │ └── antDesign/ + │ ├── table.ts TableState / TableSort / createPaginationConfig / createDataSourceChange / createResetTable / resizeColumn / createRowSelection + │ ├── popUp.ts useAntdStaticMethods / initializeAntdStaticMethods + │ └── select.ts filterOption + │ + ├── directives/ 🆕 自定义指令(与 vue 研发项目 @directives 1:1) + │ ├── index.ts registerDirectives 全局注册 + │ ├── noSpace.ts v-no-space + │ ├── onlyNumber.ts v-only-number + │ ├── onlyAlphanumeric.ts v-only-alphanumeric + │ └── onlyAlphanumericSpecial.ts v-only-alphanumeric-special + │ + ├── assets/ 🆕 静态资源 + 公共样式 + │ └── styles/ + │ └── listPage.less 列表页统一布局(listPageForm / listPageRow / listPageTable / page-header / page-footer) + │ ├── composables/ 复用逻辑 │ └── useVisibility.ts 可见性核心:角色、过滤、覆写存取 │ @@ -128,8 +156,8 @@ yx-platform-prototype/ │ ├── components/ 公共业务组件(被自动注册到 AntDV 风格) │ ├── StatCard.vue 统计卡(4 色配色) - │ ├── FilterBar.vue 配置驱动搜索栏(5 种字段类型) - │ ├── TableCard.vue 表格卡(无 title;#toolbar 左上、#extra 右上、a-table 全部透传) + │ ├── FilterBar.vue 搜索栏(default slot 裸 a-form-item / 兼容 fields 配置驱动) + │ ├── TableCard.vue 表格卡(接 :table 对象 TableState;#toolbar 左上、#extra 右上) │ ├── PageHeader.vue 页头(保留供详情页用,列表页不应使用) │ ├── PlaceholderPage.vue 后台占位页(接 title/name) │ └── AppPlaceholder.vue 移动端占位页(接 title/name) @@ -153,7 +181,7 @@ yx-platform-prototype/ │ │ │ └── AppGrid.vue 🌟 租户应用网格首页(卡片入口) │ │ ├── nutrition/ 营养管理应用 │ │ │ ├── arc/ 档案(一级菜单) - │ │ │ │ ├── arcEmployee/ ⭐ 标准列表页样板(六文件) + │ │ │ │ ├── arcEmployee/ ⭐ 新规范标准列表页样板(TableState + reactive 搜索 + mock handler + addOrEdit 弹框) │ │ │ │ └── ArcUnit.vue │ │ │ └── farm/ 绿色种采(一级菜单) │ │ │ └── FarmLand.vue @@ -169,8 +197,16 @@ yx-platform-prototype/ │ ├── arc-employee.md │ └── screenshots/<页面>/<状态>.jpg 截图(≤60KB) │ - ├── docs/ 业务文档 - │ └── business-architecture.md 🌟 业务架构(AI 必读) + ├── docs/ 业务文档 + 代码规范(AI 必读) + │ ├── business-architecture.md 🌟 业务架构 + │ ├── cross-project-mapping.md 🆕 原型 ↔ vue 研发项目对照表 + │ ├── list-page-spec.md 🆕 列表页规范 + │ ├── modal-spec.md 🆕 弹框规范 + │ ├── drawer-spec.md 🆕 抽屉规范 + │ ├── detail-page-spec.md 🆕 详情页规范 + │ ├── tabs-page-spec.md 🆕 Tabs 页规范 + │ ├── utils-table.md 🆕 表格工具文档 + │ └── utils-popup.md 🆕 弹窗工具文档 │ └── types/ 全局类型声明(自动生成 + 手写 shims) ├── auto-imports.d.ts unplugin-auto-import 生成 @@ -182,15 +218,20 @@ yx-platform-prototype/ ## 路径别名 -| 别名 | 指向 | -|------|------| -| `@` | `src/` | -| `@meta` | `src/meta/` | -| `@layouts` | `src/layouts/` | -| `@components` | `src/components/` | -| `@composables` | `src/composables/` | -| `@pages` | `src/pages/` | -| `@router` | `src/router/` | +| 别名 | 指向 | 与 vue 研发项目对齐 | +|------|------|-------------------| +| `@` | `src/` | ✅ | +| `@meta` | `src/meta/` | 原型特有(菜单数据) | +| `@layouts` | `src/layouts/` | ✅ | +| `@components` | `src/components/` | ✅ | +| `@composables` | `src/composables/` | 原型特有 | +| `@pages` | `src/pages/` | 研发项目用 `@views` | +| `@router` | `src/router/` | ✅ | +| `@utils` | `src/utils/` | ✅ 1:1 | +| `@axios` | `src/axios/` | ✅ 1:1 | +| `@assets` | `src/assets/` | ✅ 1:1 | +| `@directives` | `src/directives/` | ✅ 1:1 | +| `@api` | `src/api/` | ✅ 1:1 | --- @@ -212,25 +253,65 @@ yx-platform-prototype/ 3. **URL 规范**:`///
/`,例:`/tenant-portal/nutrition/nutrition-farm/monitor/crop` 4. **列表页 UI 规范(重要)**: - **不渲染 PageHeader**(菜单 + 顶部历史栏已经表达所在位置) - - **TableCard 不显示 title** - - **主操作按钮**(导出/新增/批量等)放在 `TableCard #toolbar` 左上方 + - **TableCard 接 `:table` 对象**(推荐)或拆分 props(兼容);不显示 title + - **FilterBar 用 default slot** 写裸 a-form-item(推荐),或 `:fields` 配置驱动(兼容) + - **顶部 toolbar 按钮**(导出/新增/导入/批量删除等)放 `TableCard #toolbar` 左上方,必须带 antd icon + - **操作列 `` 一律不加图标**(对齐 vue 研发项目);多个按钮之间用 `` 分隔 - **次要操作**放在 `TableCard #extra` 右上方 - **统计卡**放在 `FilterBar` 上方(可选) + - **顶栏高度**:固定 64px(对齐 vue 研发项目 `` 默认高度) + - **CRUD 不是列表页标配**:是否有"新增/编辑/删除/导出"按由业务决定;只读查询页操作列可以只有"查看详情" 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 +6. **端隔离**:4 端不允许互相 import;共享仅通过 `@components` / `@layouts` / `@meta` / `@composables` +7. **应用隔离**:同一 portal 下不同应用之间也不应相互 import(对齐 vue 项目"子应用独立部署");共享只能走 nav.ts 的公共抽象 +8. **列表页固定 6 文件结构**:`<页面>.vue + types + api + init/(usePage + useSearch + useTable)`,子组件按需追加 `component/modal/<名>/` 或 `component/drawer/<名>/` +9. **Mock 数据**:必须写在 `src/axios/handlers/<模块>/<页面>.ts`,通过 `registerMock(s)` 注册;**禁止写在 useTable.ts**;**单个 mock dataset 不得超过 10 条**(原型展示而非性能压测) +10. **国际化**:根组件 `App.vue` 必须用 `` 包裹,分页等组件显示为"30 条/页"等中文格式 +11. **HTTP 调用**:业务层走 `src/axios` 的 `postRequest/getRequest/restfulRequest`(签名与 vue 研发项目 1:1),不直接 import axios +12. **弹窗静态方法**:必须 `useAntdStaticMethods()` 获取 `message/Modal/notification`,禁直接 `import { message } from 'ant-design-vue'` +13. **请求风格**:强制 `.then().catch().finally()` 链式,**禁用 `async/await`** +14. **函数声明**:全部用箭头函数 `const x = () => {}`,**禁用 `function` 关键字** +15. **表格状态**:`useTable` 必须返回 `TableState` 统一对象,`table.dataSource` 存数据,**禁单独 `dataList` ref** +16. **搜索状态**:`useSearch` 必须用 `reactive + createSearchKey()` 工厂函数 + `Object.assign(search, createSearchKey())` 重置 +17. **子组件 ref**:必须在父 `usePage.ts` 定义(禁在 `.vue` 定义);打开方法命名 `open + 组件名` +18. **子组件 emit**:Modal 用 `load`,Drawer 用 `ok` +19. **删除二次确认**:必须 `Modal.confirm` + `okType: 'danger'`;批量按钮 `:disabled="!selectedRowKeys.length"` +20. **空值展示**:统一 `{{ x ?? '—' }}`(禁空串/null) +20. **时间格式**:时间 `YYYY-MM-DD HH:mm:ss`,日期 `YYYY-MM-DD` +21. **ID 类型**:统一 `string | undefined`(禁 number) +22. **公共组件优先**:StatCard / FilterBar / TableCard / PlaceholderPage +23. **TS 严格**:禁止 any +24. **中文注释** +25. **可见性**:每个 NavLeaf 有 `status`(draft/review/ready)+ `audience`(designer/all),dev 角色看不到 draft 或 designer-only 的页面 +26. **PRD 截图体积**:Web ≤ 60KB、小程序 ≤ 60KB、硬件 ≤ 80KB;优先 puppeteer,失真时降级 chrome-devtools MCP +27. **代码规范文档**:详见 `src/docs/`(list-page-spec / modal-spec / drawer-spec / detail-page-spec / tabs-page-spec / cross-project-mapping / utils-table / utils-popup) + +--- + +## 与 vue 研发项目的对齐策略 + +本项目最高目标:**研发接手时改动最小**。详见 `src/docs/cross-project-mapping.md`。 + +| 对齐项 | 原型现状 | 研发项目 | +|--------|---------|---------| +| HTTP 签名 | 假 axios + mockBus + handlers | 真 axios + 拦截器 | +| 业务代码(api/usePage/useTable/useSearch) | 100% 与研发一致 | 100% 与原型一致 | +| 公共组件 | TableCard / FilterBar(支持 :table / default slot) | 裸 a-table / a-form | +| Mock 数据 | `src/axios/handlers/` | 删除即可 | +| 路径别名 | 与研发项目 1:1 | 同左 | + +迁移步骤(研发接手时): +1. 替换 `src/axios/` 为真实 axios 封装(保留方法签名) +2. 删除 `src/axios/handlers/` +3. 删除原型独有页面(gate / console / prd / overview)和 composables(useVisibility) +4. 把 `` 拆为 `` + `` +5. 把 `` 拆为 `` + `` +6. 业务逻辑 100% 不改 --- @@ -262,19 +343,66 @@ yx-platform-prototype/ ## 禁止事项 +### 文件 / 跨模块 - 禁止跨页面修改其他模块的代码 -- 禁止在 .vue 的 setup 里直接写表格 columns 数组、mock 数据数组 - 禁止跨端引用页面(admin ↛ tenant ↛ miniprogram ↛ hardware) - 禁止跨应用引用页面(不同 NavApp 之间不能互相 import,模拟独立部署) -- 禁止修改 `src/components/` 的公共组件(除非走 PR 评审) +- 禁止把 Vant 组件用在后台页面(admin/tenant),反之亦然 +- 禁止在 `src/api/index.ts` 写业务私有接口(仅放跨模块公共接口) + +### 列表页结构 - 禁止省略 init 三件套(即使只有 1 列表格也要独立 useTable.ts) - 禁止把业务逻辑写在模板(所有逻辑通过 usePage 等 hook 暴露) -- 禁止使用 `any` 类型 - 禁止在列表页渲染 PageHeader(含面包屑/标题/副标题) -- 禁止给 TableCard 传 title(菜单已表达所在位置) +- 禁止给 TableCard 显式传 title(菜单已表达所在位置) - 禁止把主操作按钮放在页面顶部独立位置(必须放 TableCard #toolbar) + +### Mock / 接口 +- 禁止把 mock 数据写在 `useTable.ts`,必须放 `src/axios/handlers/<模块>/<页面>.ts` +- 禁止单个 mock dataset 超过 **8 条**(原型用于展示而非性能压测) +- 禁止在业务代码直接 `import axios`,必须走 `src/axios` 的 postRequest / getRequest +- 禁止在 `api/index.ts` 写业务判断(如 `if res.code === '00000'`),只做 HTTP 调用 + +### 代码风格 +- 禁止使用 `async/await`,请求统一 `.then().catch().finally()` 链式 +- 禁止使用 `function` 关键字,所有函数用箭头函数 `const x = () => {}` +- 禁止直接 `import { message } from 'ant-design-vue'`,必须 `useAntdStaticMethods()` +- 禁止使用 `any` 类型 + +### Hook 拆分 +- 禁止在 `useTable.ts` 调接口,列表请求只在 `usePage.ts` 发起 +- 禁止在 `usePage.ts` 维护独立 `dataList` ref,数据统一存 `table.dataSource` +- 禁止在 `usePage.ts` 写表格列定义(应在 useTable.ts)或下拉请求(应在 useSearch.ts) +- 禁止 `useSearch` 用 `Object.keys` 遍历清空,必须 `Object.assign(search, createSearchKey())` +- 禁止 `searchQuery` 调 `resetTable()`(会清空排序),必须 `table.pagination.current = 1` 后 `listRequest()` +- 禁止在父 `.vue` 中定义子组件 ref,必须在父 `usePage.ts` 中定义 +- 禁止子组件打开方法命名无意义(如 `open / show / visible`),必须 `open + 组件名` + +### Modal / Drawer +- 禁止在弹框/抽屉的 `.vue` 写业务逻辑 +- 禁止 a-modal 不加 `:keyboard="false" :mask-closable="false"`(防止编辑数据丢失) +- 禁止省略 `finally` 中的 `pageInfo.spin = false` +- 禁止用 `pageInfo.title / type` 文本做新增/编辑判断,必须用 `params.id` +- 禁止 addRequest 中保留 id 字段(必须 `delete params.id`) +- 禁止省略 a-drawer(含列表型)的 `destroy-on-close` + +### 数据展示 +- 禁止删除/批量操作不经二次确认(必须 `Modal.confirm` + `okType:'danger'`) +- 禁止批量操作按钮在未选中数据时可点击(必须 `:disabled="!selectedRowKeys.length"`) +- 禁止空值字段展示为空字符串或 `null`(统一 `?? '—'`) +- 禁止时间/日期格式不统一(时间 `YYYY-MM-DD HH:mm:ss`,日期 `YYYY-MM-DD`) + +### 图标 / UI - 禁止使用 emoji / 自定义 svg 作为菜单或按钮图标(必须用 @ant-design/icons-vue) -- 禁止把 Vant 组件用在后台页面(admin/tenant),反之亦然 +- **禁止给操作列的 `` 加任何图标**(对齐 vue 研发项目,操作列纯文字) +- **禁止 AI 脚手架默认生成"新增/编辑/删除"按钮**(CRUD 不是列表页标配,必须由用户明确指定页面功能) +- 禁止操作列多个按钮之间不加 `` 分隔 +- 禁止在列定义的 `customRender` 中写 JSX,统一用 `h()` 函数 +- 禁止 `a-input-number` 不设 `style="width: 100%"` +- 禁止顶栏高度偏离 64px(必须与 vue 研发项目 `` 默认高度一致) +- 禁止 App.vue 漏写 ``(否则分页等组件显示英文) + +### 原型独有 - 禁止把截图存到 PRD 目录之外(如 public/) - 禁止生成 > 60KB 的单张 Web/小程序截图 - 禁止在 Web 后台调用 `/design-overview` skill(仅支持小程序/硬件) diff --git a/src/App.vue b/src/App.vue index 15565d2..8858787 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,16 @@ diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..83ef8de --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,36 @@ +/** + * 全局公共接口 + * + * 设计目的:与 vue 研发项目 @api 对齐。 + * 这里只放跨模块复用的公共接口(字典、部门树、组织树等),业务私有接口必须放各页面模块的 api/index.ts。 + * + * 对齐参考:platform-vue-tenant/docs/public-api.md + */ + +import type { ApiResponse, RequestParameter } from '@axios' +import { postRequest } from '@axios' + +/** + * 按字典编码获取字典项列表 + * @param code 字典编码 + */ +export const dictItems = (code: string): Promise>> => + postRequest('axiosRequest', '/sys/dict/items', { code }) + +/** + * 获取部门树(完整树形结构) + */ +export const departTree = (params?: RequestParameter): Promise> => + postRequest('axiosRequest', '/sys/depart/tree', params) + +/** + * 按层级查询组织树 + */ +export const departLevelTree = (params?: RequestParameter): Promise> => + postRequest('axiosRequest', '/sys/depart/levelTree', params) + +/** + * 返回扁平化组织列表 + */ +export const departListLevel = (params?: RequestParameter): Promise> => + postRequest('axiosRequest', '/sys/depart/listLevel', params) diff --git a/src/assets/styles/listPage.less b/src/assets/styles/listPage.less new file mode 100644 index 0000000..49a893c --- /dev/null +++ b/src/assets/styles/listPage.less @@ -0,0 +1,54 @@ +/** + * 列表页统一布局样式 + * + * 对齐 vue 研发项目 @assets/styles/listPage.less。 + * 业务侧用法: + * + * + * 提供三个标准结构类名: + * - .listPageForm 搜索区 + * - .listPageRow 操作按钮行 + * - .listPageTable 表格区 + * + * 同时提供 .page-header / .page-footer 用于详情页顶/底返回按钮 + */ + +.listPageForm { + margin-bottom: 12px; + + .ant-form-item { + margin-bottom: 12px; + } +} + +.listPageRow { + margin-bottom: 12px; + min-height: 32px; + display: flex; + align-items: center; +} + +.listPageTable { + flex: 1; + min-height: 0; + + > .ant-col { + height: 100%; + } + + .ant-table-wrapper { + height: 100%; + } +} + +.page-header { + margin-bottom: 12px; +} + +.page-footer { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #f0f0f0; +} diff --git a/src/axios/handlers/index.ts b/src/axios/handlers/index.ts new file mode 100644 index 0000000..58c121c --- /dev/null +++ b/src/axios/handlers/index.ts @@ -0,0 +1,14 @@ +/** + * mock handlers 注册入口 + * + * 设计目的: + * - 各页面的 mock handler 通过 import 副作用方式注册到 mockBus + * - main.ts 仅需 import '@/axios/handlers' 即可触发全部注册 + * + * 新增 mock 时: + * 1. 在 handlers/<模块>/<页面>.ts 中写 handler 并调用 registerMock + * 2. 在本文件 import './<模块>/<页面>' + */ + +// 营养管理 - 员工营养数据 +import './nutrition/employee' diff --git a/src/axios/handlers/nutrition/employee.ts b/src/axios/handlers/nutrition/employee.ts new file mode 100644 index 0000000..6c7b6aa --- /dev/null +++ b/src/axios/handlers/nutrition/employee.ts @@ -0,0 +1,190 @@ +/** + * mock handler - 营养管理 / 员工营养数据 + * + * 对应 api:src/pages/tenant-portal/nutrition/arc/arcEmployee/api/index.ts + * + * 业务侧调用形如: + * postRequest('axiosRequest', '/nutrition/employee/page', params) + * 经 mockBus 路由到本文件的 handler 返回 mock 数据。 + */ + +import { registerMocks } from '@/axios/mockBus' +import type { ApiResponse, MockContext } from '@/axios/types' + +/** 员工营养行类型(与页面 types 中 ListItem 对齐) */ +interface EmployeeRow { + id: string + name: string + gender: '男' | '女' + age: number + empNo: string + unit: string + dept: string + days: number + meals: number + calorie: string + protein: number + fat: number + carb: number + rate: number + status: 0 | 1 + createTime: string +} + +/** 全量数据集(≤ 8 条 - 遵循原型 mock 规范:每个列表 mock 不超过 8 条) */ +const buildDataset = (): EmployeeRow[] => [ + { id: 'EMP0001', name: '张伟', gender: '男', age: 32, empNo: 'EMP0001', unit: 'CQ能源总部', dept: '研发部', days: 132, meals: 386, calorie: '2,156', protein: 78.3, fat: 72.1, carb: 285.4, rate: 92, status: 1, createTime: '2026-05-10 09:12:00' }, + { id: 'EMP0002', name: '李娜', gender: '女', age: 28, empNo: 'EMP0002', unit: 'CQ能源总部', dept: '市场部', days: 128, meals: 372, calorie: '1,842', protein: 65.2, fat: 58.7, carb: 241.3, rate: 89, status: 1, createTime: '2026-05-09 08:30:00' }, + { id: 'EMP0003', name: '王磊', gender: '男', age: 35, empNo: 'EMP0003', unit: '采油一厂', dept: '运营部', days: 125, meals: 358, calorie: '2,380', protein: 85.6, fat: 89.2, carb: 312.1, rate: 76, status: 1, createTime: '2026-05-08 17:45:00' }, + { id: 'EMP0004', name: '赵敏', gender: '女', age: 30, empNo: 'EMP0004', unit: '采油二厂', dept: '财务部', days: 131, meals: 390, calorie: '1,720', protein: 58.4, fat: 52.3, carb: 228.6, rate: 95, status: 1, createTime: '2026-05-07 10:20:00' }, + { id: 'EMP0005', name: '陈刚', gender: '男', age: 41, empNo: 'EMP0005', unit: 'CQ能源总部', dept: '人事部', days: 120, meals: 345, calorie: '2,050', protein: 72.1, fat: 68.4, carb: 270.2, rate: 78, status: 1, createTime: '2026-05-06 14:00:00' }, + { id: 'EMP0006', name: '刘洋', gender: '男', age: 26, empNo: 'EMP0006', unit: '炼化分公司', dept: '生产部', days: 110, meals: 318, calorie: '2,210', protein: 80.5, fat: 74.6, carb: 295.0, rate: 85, status: 1, createTime: '2026-05-05 16:30:00' }, + { id: 'EMP0007', name: '杨芳', gender: '女', age: 33, empNo: 'EMP0007', unit: '采油一厂', dept: '安全环保部', days: 130, meals: 380, calorie: '1,950', protein: 70.2, fat: 63.5, carb: 258.3, rate: 91, status: 1, createTime: '2026-05-04 09:00:00' }, + { id: 'EMP0010', name: '吴军', gender: '男', age: 45, empNo: 'EMP0010', unit: '采油二厂', dept: '运营部', days: 122, meals: 352, calorie: '2,180', protein: 76.8, fat: 70.3, carb: 280.5, rate: 81, status: 1, createTime: '2026-05-01 13:25:00' }, +] + +/** 缓存数据集(mock handler 内部使用,使用 let 允许 add/delete 修改) */ +let dataset: EmployeeRow[] = buildDataset() + +/** 按搜索条件过滤 */ +const filterRows = (rows: EmployeeRow[], params: Record): EmployeeRow[] => { + const keyword = (params.keyword ?? params.name) as string | undefined + const unit = params.unit as string | undefined + const dept = params.dept as string | undefined + const status = params.status as 0 | 1 | undefined + + return rows.filter((r) => { + if (keyword && !(r.name.includes(keyword) || r.empNo.includes(keyword))) return false + if (unit && r.unit !== unit) return false + if (dept && r.dept !== dept) return false + if (status !== undefined && r.status !== status) return false + return true + }) +} + +/** 按排序字段排序 */ +const sortRows = (rows: EmployeeRow[], column?: string, order?: string | null): EmployeeRow[] => { + if (!column || !order) return rows + const sorted = [...rows] + sorted.sort((a, b) => { + const av = (a as unknown as Record)[column] + const bv = (b as unknown as Record)[column] + if (av === bv) return 0 + if (av === undefined || av === null) return 1 + if (bv === undefined || bv === null) return -1 + const cmp = String(av) > String(bv) ? 1 : -1 + return order === 'ascend' ? cmp : -cmp + }) + return sorted +} + +registerMocks([ + // 分页查询 + { + url: '/nutrition/employee/page', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Record + const pageNum = (params.pageNum as number | undefined) ?? 1 + const pageSize = (params.pageSize as number | undefined) ?? 30 + const column = params.column as string | undefined + const order = params.order as string | null | undefined + + const filtered = filterRows(dataset, params) + const sorted = sortRows(filtered, column, order) + const start = (pageNum - 1) * pageSize + const data = sorted.slice(start, start + pageSize) + + return { + code: '00000', + msg: '查询成功', + data, + total: filtered.length, + } + }, + }, + + // 详情 + { + url: '/nutrition/employee/info', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const id = (ctx.params as { id?: string } | undefined)?.id + const row = dataset.find((r) => r.id === id) ?? null + return { + code: '00000', + msg: '查询成功', + data: row, + } + }, + }, + + // 新增 + { + url: '/nutrition/employee/add', + method: 'POST', + handler: (ctx: MockContext): ApiResponse<{ id: string }> => { + const id = `EMP${String(dataset.length + 1).padStart(4, '0')}` + const params = (ctx.params ?? {}) as Partial + dataset.unshift({ + id, + name: params.name ?? '新员工', + gender: params.gender ?? '男', + age: params.age ?? 25, + empNo: id, + unit: params.unit ?? 'CQ能源总部', + dept: params.dept ?? '研发部', + days: 0, + meals: 0, + calorie: '0', + protein: 0, + fat: 0, + carb: 0, + rate: 0, + status: params.status ?? 1, + createTime: new Date().toISOString().replace('T', ' ').slice(0, 19), + }) + return { code: '00000', msg: '新增成功', data: { id } } + }, + }, + + // 编辑 + { + url: '/nutrition/employee/update', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const params = (ctx.params ?? {}) as Partial & { id?: string } + const idx = dataset.findIndex((r) => r.id === params.id) + if (idx >= 0) dataset[idx] = { ...dataset[idx], ...params } as EmployeeRow + return { code: '00000', msg: '编辑成功', data: null } + }, + }, + + // 删除 + { + url: '/nutrition/employee/delete', + method: 'POST', + handler: (ctx: MockContext): ApiResponse => { + const id = (ctx.params as { id?: string } | undefined)?.id + const idx = dataset.findIndex((r) => r.id === id) + if (idx >= 0) dataset.splice(idx, 1) + return { code: '00000', msg: '删除成功', data: null } + }, + }, + + // 统计卡数据 + { + url: '/nutrition/employee/stats', + method: 'POST', + handler: (): ApiResponse<{ total: number; days: number; rate: string; coverage: string }> => ({ + code: '00000', + msg: '查询成功', + data: { + total: dataset.length, + days: 132, + rate: '86.5%', + coverage: '96.2%', + }, + }), + }, +]) diff --git a/src/axios/index.ts b/src/axios/index.ts new file mode 100644 index 0000000..a58eb48 --- /dev/null +++ b/src/axios/index.ts @@ -0,0 +1,162 @@ +/** + * 假 axios 层 - 请求方法入口 + * + * 设计目的: + * - 暴露与 vue 研发项目 100% 一致的请求方法签名 + * - 原型阶段所有请求走 mockBus,返回预置 mock 数据 + * - 研发接手时,把本文件整体替换为真实 axios 封装即可 + * + * 对齐参考:platform-vue-tenant/docs/http-request.md + * + * 用法(与 vue 项目相同): + * import { postRequest } from '@axios' + * postRequest('axiosRequest', '/api/xxx/list', params) + * .then(res => { ... }) + * .catch(err => console.error(err)) + * .finally(() => { ... }) + */ + +import { dispatchMock } from './mockBus' +import type { + ApiResponse, + HttpMethod, + RequestHeaders, + RequestParameter, + ServiceType, +} from './types' + +/** + * GET 请求 + * @param type 服务类型 + * @param url 请求路径 + * @param parameter query 参数 + * @param signal AbortController.signal(原型阶段不生效,保留签名兼容) + */ +export const getRequest = ( + type: ServiceType, + url: string, + parameter?: RequestParameter, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + signal?: AbortSignal, +): Promise> => dispatchMock(type, url, 'GET', parameter) + +/** + * POST 请求(最常用) + * @param type 服务类型 + * @param url 请求路径 + * @param parameter body 参数 + * @param headers 自定义请求头 + * @param signal AbortController.signal(原型阶段不生效) + */ +export const postRequest = ( + type: ServiceType, + url: string, + parameter?: RequestParameter, + headers?: RequestHeaders, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + signal?: AbortSignal, +): Promise> => + dispatchMock(type, url, 'POST', parameter, headers) + +/** + * PUT 请求 + */ +export const putRequest = ( + type: ServiceType, + url: string, + parameter?: RequestParameter, + headers?: RequestHeaders, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + signal?: AbortSignal, +): Promise> => + dispatchMock(type, url, 'PUT', parameter, headers) + +/** + * DELETE 请求 + */ +export const deleteRequest = ( + type: ServiceType, + url: string, + parameter?: RequestParameter, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + signal?: AbortSignal, +): Promise> => + dispatchMock(type, url, 'DELETE', parameter) + +/** + * 文件上传请求(原型阶段直接返回 mock 成功响应) + * @param type 服务类型(通常为 axiosUpload) + * @param url 上传路径 + * @param parameter FormData 对象 + * @param signal AbortController.signal(原型阶段不生效) + */ +export const uploadRequest = ( + type: ServiceType, + url: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + parameter: FormData, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + signal?: AbortSignal, +): Promise> => + dispatchMock(type, url, 'POST', undefined, { + 'Content-Type': 'multipart/form-data', + }) + +/** + * 文件下载请求 - 原型阶段返回空 Blob + * @param type 服务类型(通常为 axiosDownload) + * @param url 下载路径 + * @param parameter 业务参数 + * @param headers 自定义请求头 + * @param signal AbortController.signal(原型阶段不生效) + */ +export const downloadRequest = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type: ServiceType, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + url: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + parameter?: RequestParameter, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + headers?: RequestHeaders, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + signal?: AbortSignal, +): Promise => + // 原型阶段下载返回空 Blob,避免真实触发文件下载 + new Promise((resolve) => { + setTimeout(() => { + resolve(new Blob(['[原型阶段] 文件下载占位'], { type: 'text/plain' })) + }, 300) + }) + +/** + * 自定义 method 的 RESTful 请求 + * @param type 服务类型 + * @param url 请求路径 + * @param parameter 请求参数 + * @param method HTTP method,缺省 GET + * @param headers 自定义请求头 + * @param signal AbortController.signal(原型阶段不生效) + */ +export const restfulRequest = ( + type: ServiceType, + url: string, + parameter?: RequestParameter, + method: HttpMethod = 'GET', + headers?: RequestHeaders, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + signal?: AbortSignal, +): Promise> => + dispatchMock(type, url, method, parameter, headers) + +// 透出类型,方便业务直接 import +export type { + ApiResponse, + HttpMethod, + RequestHeaders, + RequestParameter, + ServiceType, +} from './types' + +// 透出注册函数,便于 mock handler 编写 +export { registerMock, registerMocks } from './mockBus' diff --git a/src/axios/mockBus.ts b/src/axios/mockBus.ts new file mode 100644 index 0000000..56a6cb9 --- /dev/null +++ b/src/axios/mockBus.ts @@ -0,0 +1,116 @@ +/** + * 假 axios 层 - mock 总线 + * + * 设计目的: + * - 集中注册各页面的 mock handler + * - 按 service + method + url 路由到对应 handler + * - 模拟网络延迟(默认 200-500ms 随机),让原型体验贴近真实网络 + * - 未匹配到 handler 时,返回友好的"接口未实现"占位响应 + * + * 使用方式: + * - 业务侧通过 src/axios/handlers/<模块>/<页面>.ts 调用 registerMock(...) 注册 + * - 业务侧 import 'src/axios/handlers' 在应用入口触发一次注册(main.ts 已挂载) + */ + +import type { + ApiResponse, + HttpMethod, + MockContext, + MockHandler, + MockRoute, + RequestHeaders, + RequestParameter, + ServiceType, +} from './types' + +/** 已注册的 mock 路由表 */ +const routes: MockRoute[] = [] + +/** + * 注册 mock handler + * @param route mock 路由项(service/method/url/handler/delay) + */ +export const registerMock = (route: MockRoute): void => { + routes.push(route) +} + +/** + * 批量注册 mock handler + */ +export const registerMocks = (list: MockRoute[]): void => { + list.forEach(registerMock) +} + +/** + * 判断 url 是否命中 + */ +const isMatch = (pattern: string | RegExp, url: string): boolean => { + if (typeof pattern === 'string') return pattern === url + return pattern.test(url) +} + +/** + * 查找匹配的 handler + * 匹配顺序:service 一致 → method 一致 → url 命中 + */ +const findHandler = ( + service: ServiceType, + method: HttpMethod, + url: string, +): MockRoute | undefined => + routes.find( + (r) => + (r.service ?? 'axiosRequest') === service && + (r.method ?? 'POST') === method && + isMatch(r.url, url), + ) + +/** + * 生成 200-500ms 之间的随机延迟 + */ +const randomDelay = (): number => 200 + Math.floor(Math.random() * 300) + +/** + * 模拟延迟 + */ +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * 调度 mock 请求 + * @param service 服务类型 + * @param url 请求路径 + * @param method HTTP method + * @param params 请求参数 + * @param headers 请求头 + * @returns ApiResponse + */ +export const dispatchMock = ( + service: ServiceType, + url: string, + method: HttpMethod, + params: RequestParameter, + headers: RequestHeaders = {}, +): Promise> => { + const route = findHandler(service, method, url) + const ctx: MockContext = { service, url, method, params, headers } + const delay = route?.delay ?? randomDelay() + + // 未匹配到 handler:返回友好占位响应(避免开发期间报红) + if (!route) { + return sleep(delay).then(() => ({ + code: 'MOCK_NOT_FOUND', + msg: `[原型阶段] 未注册 mock handler:${method} ${url}(service=${service})`, + data: null as unknown as T, + })) + } + + return sleep(delay) + .then(() => Promise.resolve(route.handler(ctx))) + .then((res) => res as ApiResponse) +} + +/** + * 调试用:列出已注册的 mock 路由(仅开发期使用) + */ +export const listMockRoutes = (): MockRoute[] => routes.slice() diff --git a/src/axios/types.ts b/src/axios/types.ts new file mode 100644 index 0000000..da49943 --- /dev/null +++ b/src/axios/types.ts @@ -0,0 +1,90 @@ +/** + * 假 axios 层 - 类型定义 + * + * 设计目的:让原型阶段的 api/index.ts 与 vue 研发项目 1:1 兼容。 + * 研发接手时只需把 src/axios/ 替换成真实 axios 封装,业务代码零改动。 + * + * 对齐参考:platform-vue-tenant/docs/http-request.md + */ + +/** + * 服务类型 - 用于指定请求走哪个服务地址 + * - axiosRequest:业务接口(最常用) + * - axiosUpload:文件上传服务 + * - axiosDownload:文件下载服务 + */ +export type ServiceType = 'axiosRequest' | 'axiosUpload' | 'axiosDownload' + +/** + * HTTP method(与 axios Method 对齐) + */ +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + +/** + * 通用请求参数 - 与 vue 项目 RequestParameter 等价 + * 原型阶段不强校验,接受任意业务对象 / FormData / undefined + */ +export type RequestParameter = object | undefined + +/** + * 自定义请求头(兼容 axios AxiosRequestHeaders 的极简版) + */ +export type RequestHeaders = Record + +/** + * 统一响应契约(与 vue 项目 ApiResponse 完全一致) + * - code === '00000' 表示成功 + * - msg:后端消息 + * - data:业务数据 + * - total:分页接口的总条数 + */ +export interface ApiResponse { + /** 响应码,'00000' 表示成功 */ + code: string + /** 响应消息 */ + msg: string + /** 业务数据 */ + data: T + /** 分页接口的总条数(非分页接口可省略) */ + total?: number +} + +/** + * mock handler 的上下文 - 透传给 handler 函数 + */ +export interface MockContext { + /** 服务类型 */ + service: ServiceType + /** 请求路径(不含域名) */ + url: string + /** HTTP method */ + method: HttpMethod + /** 请求参数(GET 走 query,其它走 body) */ + params: RequestParameter + /** 请求头 */ + headers: RequestHeaders +} + +/** + * mock handler 签名 - 用户写 mock 数据时实现这个函数 + * 返回 ApiResponse 或 Promise + */ +export type MockHandler = ( + ctx: MockContext, +) => ApiResponse | Promise> + +/** + * handler 注册项 - 支持 url 精确匹配或正则匹配 + */ +export interface MockRoute { + /** 服务类型,缺省 axiosRequest */ + service?: ServiceType + /** HTTP method,缺省 'POST' */ + method?: HttpMethod + /** url 匹配规则:字符串精确匹配或正则匹配 */ + url: string | RegExp + /** mock 处理函数 */ + handler: MockHandler + /** 模拟延迟(毫秒),缺省 200-500 随机 */ + delay?: number +} diff --git a/src/components/FilterBar.vue b/src/components/FilterBar.vue index c7dd742..777ff99 100644 --- a/src/components/FilterBar.vue +++ b/src/components/FilterBar.vue @@ -1,63 +1,17 @@