feat: 对齐 vue 研发项目基础设施并落地列表页规范样板

- 新增 axios/utils/directives/api/assets 五大基础设施目录(与生产 vue 项目 1:1)
- 重构 FilterBar / TableCard 支持 :table 对象与 default slot
- 新增列表页/弹框/抽屉/详情页/Tabs 等代码规范文档
- arcEmployee 作为新规范标准列表页样板重构
- 清理 .claude/tmp 临时截图
This commit is contained in:
W10-0020\Administrator
2026-06-05 11:07:05 +08:00
parent 87fabc49c1
commit 785e25d231
45 changed files with 4080 additions and 531 deletions
+152 -124
View File
@@ -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 用 `<component :is="icon" />`
**菜单图标**
| 层级 | 字段 | 是否必填 | 兜底 |
|------|------|---------|------|
@@ -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 子应用对齐):
## 项目背景
```
Portaladmin-portal / tenant-portal / miniprogram / hardware
└─ NavApp (应用,独立可部署,如"营养管理"/"健康监测")
└─ NavSection (应用内一级菜单,顶部 tab,如"档案"/"绿色种采")
└─ NavGroup (二级分组,可选) | NavLeaf (二级页面)
└─ NavLeaf (三级页面)
└─ NavApp(独立可部署应用)
└─ NavSection应用内一级菜单,顶部 tab
└─ NavGroup二级分组,可选| NavLeaf二级页面
└─ NavLeaf三级页面
```
URL`/<portal>/<app>/<section>/<page>`
## 输入参数(向用户依次问)
## 输入参数(向用户依次问)
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 建议 `<app>-<section 简称>-<页面简称>` 风格
3. **所属 NavApp** key:如 `nutrition``monitor`
4. **所属 NavSection** key:如 `nutrition-arc``nutrition-farm`
5. **如有二级分组**NavGroup key 与 label
6. **页面 key**:唯一 + 小驼峰,如 `arcUnit`;建议 `<app>-<section 简称>-<页面简称>` 风格
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`/<portal>/<app>/<section>/<page>`
- 检查 `src/pages/<portal>/<app>/<section 短名>/<页面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/<portal>/<app>/<section 短名>/<页面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 + typesapi 视需要) |
**types/index.ts**
- `SearchForm`(字段全 optional
- `ListItem`id 统一 string 类型)
- `ListParams`(继承 SearchForm + pageNum/pageSize/order/column
### Step 3:填充内容(遵循新规范)
**api/index.ts**
- import `postRequest from '@axios'`
- 接口路径 `/<app>/<页面key>/page``/info``/add``/update``/delete`
- 每个方法返回 `Promise<ApiResponse<...>>`
**列表页 vue 模板**
**init/useSearch.ts**
- `createSearchKey()` 工厂函数
- `createOptions()` 工厂函数
- `search = reactive(createSearchKey())`
- `resetSearch``Object.assign(search, createSearchKey())`
- `initOptions``Promise.all` 并行加载下拉
```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>
**init/useTable.ts**
- 顶部定义 `INIT_SORT: TableSort`
- 顶部定义 `tableColumns: TableColumnsType`customRender 用 `h()`
- `createInitState()` 工厂返回 `TableState`
- 暴露 `{ table, dataSourceChange, resetTable, resizeColumn }`
<!-- 搜索栏 -->
<FilterBar
v-model="searchForm"
:fields="filterFields"
@search="handleSearch"
@reset="handleReset"
/>
**init/usePage.ts**
- import `useAntdStaticMethods from '@utils/antDesign/popUp'`
- 子组件 ref 必须在此定义:`ref<{ openModal: ... } | null>(null)`
- `buildParams()` 统一构造请求参数
- `listRequest()` 链式 `.then().catch().finally()`
- `searchQuery``table.pagination.current = 1``listRequest()`
- `resetQuery` 顺序 `resetSearch → resetTable → listRequest`
- 删除强制 `Modal.confirm` + `okType: 'danger'`
- `onMounted` 通过 `loadData()` 统一触发
<!-- 表格无标题主操作放 #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>
**<页面key>.vue**
- 顶部 statCards 可选
- FilterBar 用 default slot 写裸 a-form-item
- TableCard 接 `:table` 对象
- **toolbar slot 仅在用户指定了 add/import/export/batch 时才生成**;只读查询页 toolbar slot 整段省略
- 顶部 toolbar 按钮才加 iconPlusOutlined / UploadOutlined / DownloadOutlined / DeleteOutlined
- **操作列 `<a-button type="link">` 一律无图标**(与 vue 项目一致)
- 操作列多个按钮之间用 `<a-divider type="vertical" />` 分隔
- 操作列只读页只需 `查看详情`,挂 InfoDrawer
- `@import "@assets/styles/listPage.less"`
<template #bodyCell="{ column, record }">
<!-- 业务渲染 -->
</template>
</TableCard>
</div>
</template>
### Step 4:生成 mock handler
`src/axios/handlers/<app>/<页面key>.ts` 创建 mock,注册到 `src/axios/handlers/index.ts`
```typescript
import { registerMocks } from '@/axios/mockBus'
registerMocks([
{ url: '/<app>/<页面key>/page', method: 'POST', handler: (ctx) => { ... } },
{ url: '/<app>/<页面key>/info', method: 'POST', handler: (ctx) => { ... } },
{ url: '/<app>/<页面key>/add', method: 'POST', handler: (ctx) => { ... } },
{ url: '/<app>/<页面key>/update', method: 'POST', handler: (ctx) => { ... } },
{ url: '/<app>/<页面key>/delete', method: 'POST', handler: (ctx) => { ... } },
])
```
**重要规范**
并在 `src/axios/handlers/index.ts` 末尾追加
- ❌ **禁止**渲染 `PageHeader`(标题、面包屑由顶部菜单/历史栏表达)
- ❌ **禁止**给 `TableCard``title`(菜单已经表达了所在位置)
- ✅ **必须**通过 `TableCard #toolbar` 放置主操作按钮(导出、新增等),位置在表格卡片左上方
- ✅ **可选**使用 `TableCard #extra` 放置次要操作(视图切换、批量操作等)
- ✅ **可选**统计卡片放在 `FilterBar` 之上
- ✅ 替换实体名占位:`Employee` → 用户输入的页面 key 推导
- ✅ API 接口路径占位:`POST /api/<app>/<页面key>/page`
```typescript
import './<app>/<页面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: '<leaf key>',
label: '<中文名>',
path: '<路由 path 片段>',
icon: '<emoji>', // 可选,省略时 Sidebar 用默认图标
status: 'draft', // 新页面默认 draft
icon: '<XxxOutlined>',
status: 'draft',
component: () => import('@pages/<portal>/<app>/<section 短名>/<页面key>/<页面key>.vue'),
}
```
### Step 5:验证
### Step 6:验证
- 打印生成清单,告知用户在浏览器访问的路由路径:`/<portal>/<app>/<section key>/<path>`
- 若 dev 已启动,提示用户直接刷新即可看到新菜单与页面
- 打印生成清单
- 打印访问 URL`/<portal>/<app>/<section key>/<path>`
- 打印 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 作为菜单或按钮图标
-**禁止给操作列的 `<a-button type="link">` 加图标**
-**禁止脚手架默认生成"新增/编辑/删除"按钮**,必须根据用户指定的页面功能裁剪
- ❌ 禁止 `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 即可
Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

+159 -31
View File
@@ -98,7 +98,7 @@ yx-platform-prototype/
│ └── user_profile.md 用户画像
└── src/
├── main.ts 应用入口:创建 Vue 实例、挂载 router
├── main.ts 应用入口:创建 Vue 实例、挂载 router、注册指令、初始化 antd 静态方法、加载 mock handlers
├── App.vue 根组件,仅 <router-view />
├── style.css 全局设计令牌(CSS 变量)+ reset
@@ -113,6 +113,34 @@ yx-platform-prototype/
│ - admin: adminAppstenant: 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 规范**`/<portal>/<app>/<section>/<page>`,例:`/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
- **操作列 `<a-button type="link">` 一律不加图标**(对齐 vue 研发项目);多个按钮之间用 `<a-divider type="vertical" />` 分隔
- **次要操作**放在 `TableCard #extra` 右上方
- **统计卡**放在 `FilterBar` 上方(可选)
- **顶栏高度**:固定 64px(对齐 vue 研发项目 `<a-layout-header>` 默认高度)
- **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` 必须用 `<a-config-provider :locale="zhCN">` 包裹,分页等组件显示为"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)和 composablesuseVisibility
4.`<TableCard :table>` 拆为 `<a-row class="listPageTable">` + `<a-table>`
5.`<FilterBar v-model>` 拆为 `<a-row class="listPageForm">` + `<a-form>`
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),反之亦然
- **禁止给操作列的 `<a-button type="link">` 加任何图标**(对齐 vue 研发项目,操作列纯文字)
- **禁止 AI 脚手架默认生成"新增/编辑/删除"按钮**(CRUD 不是列表页标配,必须由用户明确指定页面功能)
- 禁止操作列多个按钮之间不加 `<a-divider type="vertical" />` 分隔
- 禁止在列定义的 `customRender` 中写 JSX,统一用 `h()` 函数
- 禁止 `a-input-number` 不设 `style="width: 100%"`
- 禁止顶栏高度偏离 64px(必须与 vue 研发项目 `<a-layout-header>` 默认高度一致)
- 禁止 App.vue 漏写 `<a-config-provider :locale="zhCN">`(否则分页等组件显示英文)
### 原型独有
- 禁止把截图存到 PRD 目录之外(如 public/
- 禁止生成 > 60KB 的单张 Web/小程序截图
- 禁止在 Web 后台调用 `/design-overview` skill(仅支持小程序/硬件)
+11 -2
View File
@@ -1,7 +1,16 @@
<template>
<router-view />
<!--
全局国际化注入 zh_CN locale
- a-pagination 显示 "30 条/页"而非默认 "30 / page"
- a-table / a-date-picker / a-empty 等组件中文化
- vue 研发项目 main 应用入口的 a-config-provider 保持一致
-->
<a-config-provider :locale="zhCN">
<router-view />
</a-config-provider>
</template>
<script setup lang="ts">
/** 应用根:只承载路由出口,所有布局在各 Layout 内部实现 */
/** 应用根:注入 zh_CN locale + 路由出口,所有布局在各 Layout 内部实现 */
import zhCN from 'ant-design-vue/es/locale/zh_CN'
</script>
+36
View File
@@ -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<ApiResponse<Array<{ label: string; value: number | string }>>> =>
postRequest('axiosRequest', '/sys/dict/items', { code })
/**
* 获取部门树(完整树形结构)
*/
export const departTree = (params?: RequestParameter): Promise<ApiResponse<unknown[]>> =>
postRequest('axiosRequest', '/sys/depart/tree', params)
/**
* 按层级查询组织树
*/
export const departLevelTree = (params?: RequestParameter): Promise<ApiResponse<unknown[]>> =>
postRequest('axiosRequest', '/sys/depart/levelTree', params)
/**
* 返回扁平化组织列表
*/
export const departListLevel = (params?: RequestParameter): Promise<ApiResponse<unknown[]>> =>
postRequest('axiosRequest', '/sys/depart/listLevel', params)
+54
View File
@@ -0,0 +1,54 @@
/**
* 列表页统一布局样式
*
* 对齐 vue 研发项目 @assets/styles/listPage.less。
* 业务侧用法:
* <style scoped lang="less">
* @import "@assets/styles/listPage.less";
* </style>
*
* 提供三个标准结构类名:
* - .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;
}
+14
View File
@@ -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'
+190
View File
@@ -0,0 +1,190 @@
/**
* mock handler - 营养管理 / 员工营养数据
*
* 对应 apisrc/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<string, unknown>): 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<string, unknown>)[column]
const bv = (b as unknown as Record<string, unknown>)[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<EmployeeRow[]> => {
const params = (ctx.params ?? {}) as Record<string, unknown>
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<EmployeeRow | null> => {
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<EmployeeRow>
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<null> => {
const params = (ctx.params ?? {}) as Partial<EmployeeRow> & { 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<null> => {
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%',
},
}),
},
])
+162
View File
@@ -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 = <T = unknown>(
type: ServiceType,
url: string,
parameter?: RequestParameter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
signal?: AbortSignal,
): Promise<ApiResponse<T>> => dispatchMock<T>(type, url, 'GET', parameter)
/**
* POST 请求(最常用)
* @param type 服务类型
* @param url 请求路径
* @param parameter body 参数
* @param headers 自定义请求头
* @param signal AbortController.signal(原型阶段不生效)
*/
export const postRequest = <T = unknown>(
type: ServiceType,
url: string,
parameter?: RequestParameter,
headers?: RequestHeaders,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
signal?: AbortSignal,
): Promise<ApiResponse<T>> =>
dispatchMock<T>(type, url, 'POST', parameter, headers)
/**
* PUT 请求
*/
export const putRequest = <T = unknown>(
type: ServiceType,
url: string,
parameter?: RequestParameter,
headers?: RequestHeaders,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
signal?: AbortSignal,
): Promise<ApiResponse<T>> =>
dispatchMock<T>(type, url, 'PUT', parameter, headers)
/**
* DELETE 请求
*/
export const deleteRequest = <T = unknown>(
type: ServiceType,
url: string,
parameter?: RequestParameter,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
signal?: AbortSignal,
): Promise<ApiResponse<T>> =>
dispatchMock<T>(type, url, 'DELETE', parameter)
/**
* 文件上传请求(原型阶段直接返回 mock 成功响应)
* @param type 服务类型(通常为 axiosUpload
* @param url 上传路径
* @param parameter FormData 对象
* @param signal AbortController.signal(原型阶段不生效)
*/
export const uploadRequest = <T = unknown>(
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<ApiResponse<T>> =>
dispatchMock<T>(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> =>
// 原型阶段下载返回空 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 = <T = unknown>(
type: ServiceType,
url: string,
parameter?: RequestParameter,
method: HttpMethod = 'GET',
headers?: RequestHeaders,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
signal?: AbortSignal,
): Promise<ApiResponse<T>> =>
dispatchMock<T>(type, url, method, parameter, headers)
// 透出类型,方便业务直接 import
export type {
ApiResponse,
HttpMethod,
RequestHeaders,
RequestParameter,
ServiceType,
} from './types'
// 透出注册函数,便于 mock handler 编写
export { registerMock, registerMocks } from './mockBus'
+116
View File
@@ -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<void> =>
new Promise((resolve) => setTimeout(resolve, ms))
/**
* 调度 mock 请求
* @param service 服务类型
* @param url 请求路径
* @param method HTTP method
* @param params 请求参数
* @param headers 请求头
* @returns ApiResponse
*/
export const dispatchMock = <T = unknown>(
service: ServiceType,
url: string,
method: HttpMethod,
params: RequestParameter,
headers: RequestHeaders = {},
): Promise<ApiResponse<T>> => {
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<T>)
}
/**
* 调试用:列出已注册的 mock 路由(仅开发期使用)
*/
export const listMockRoutes = (): MockRoute[] => routes.slice()
+90
View File
@@ -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<string, string>
/**
* 统一响应契约(与 vue 项目 ApiResponse 完全一致)
* - code === '00000' 表示成功
* - msg:后端消息
* - data:业务数据
* - total:分页接口的总条数
*/
export interface ApiResponse<T = unknown> {
/** 响应码,'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<ApiResponse>
*/
export type MockHandler<T = unknown> = (
ctx: MockContext,
) => ApiResponse<T> | Promise<ApiResponse<T>>
/**
* handler 注册项 - 支持 url 精确匹配或正则匹配
*/
export interface MockRoute {
/** 服务类型,缺省 axiosRequest */
service?: ServiceType
/** HTTP method,缺省 'POST' */
method?: HttpMethod
/** url 匹配规则:字符串精确匹配或正则匹配 */
url: string | RegExp
/** mock 处理函数 */
handler: MockHandler
/** 模拟延迟(毫秒),缺省 200-500 随机 */
delay?: number
}
+130 -83
View File
@@ -1,63 +1,17 @@
<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推荐default slot 写裸 a-form-item vue 研发项目模板一致 -->
<a-form
v-if="$slots.default"
:model="model"
layout="inline"
class="filter-bar__form"
@keyup.enter="emit('search', model)"
>
<slot />
<!-- 数字 -->
<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-group>
<a-button type="primary" @click="emit('search', model)">
<template #icon><SearchOutlined /></template>
查询
@@ -67,7 +21,75 @@
重置
</a-button>
<slot name="actions" />
</a-space>
</a-button-group>
</a-form-item>
</a-form>
<!-- 用法 B兼容旧页面fields 配置驱动 -->
<a-form
v-else
layout="inline"
:model="model"
class="filter-bar__form"
@keyup.enter="emit('search', 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 as Record<string, unknown>)[field.name] as string | undefined"
: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 as Record<string, unknown>)[field.name] as number | undefined"
: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 as Record<string, unknown>)[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 as Record<string, unknown>)[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 as Record<string, unknown>)[field.name]"
:style="{ width: field.width ?? '260px' }"
@update:value="(v: unknown) => onFieldChange(field.name, v)"
/>
</a-form-item>
<a-form-item class="filter-bar__actions">
<a-button-group>
<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-button-group>
</a-form-item>
</a-form>
</a-card>
@@ -75,33 +97,50 @@
<script setup lang="ts">
/**
* 通用搜索栏 - 配置驱动
* - v-model 绑定整张表单数据
* - fields 决定渲染哪些字段、什么类型
* - 默认查询/重置按钮已带统一 antd iconSearchOutlined / RedoOutlined
* 通用搜索栏v-model 双向绑定)
*
* 支持两种用法:
*
* 用法 A(推荐 / 与 vue 研发项目对齐):default slot 写裸 a-form-item
* <FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
* <a-form-item label="姓名">
* <a-input v-model:value="search.name" v-no-space allow-clear />
* </a-form-item>
* </FilterBar>
*
* 用法 B(兼容旧页面):fields 配置驱动渲染
* <FilterBar v-model="searchForm" :fields="filterFields" @search="..." @reset="..." />
*
* 设计说明:
* - 用法 A 与 vue 项目模板一字不差,研发接手时只需把 <FilterBar> 标签去掉即可
* - v-model 直接绑 reactive 对象,模板内字段 v-model:value="search.xxx"
* - 内置 a-button-group:查询(type=primary, icon=SearchOutlined/ 重置(icon=RedoOutlined
* - 默认监听回车 → emit('search')
* - 用法 B 的重置:把所有 field.name 字段置 undefined
* - 用法 A 的重置:由调用方在 @reset 回调里执行(如 Object.assign(search, createSearchKey())
*/
import { SearchOutlined, RedoOutlined } from '@ant-design/icons-vue'
/** 单个字段配置 */
/** 单个字段配置(用法 B 专用) */
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 */
const props = withDefaults(defineProps<{
/** v-model 绑定的搜索表单数据 */
modelValue: Record<string, unknown>
/** 字段配置 */
fields: FilterField[]
}>()
/** 用法 B:字段配置(用法 A 不传) */
fields?: FilterField[]
}>(), {
fields: () => [],
})
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, unknown>): void
@@ -112,36 +151,44 @@ const emit = defineEmits<{
/** 内部读取的引用 */
const model = computed(() => props.modelValue)
/** 单字段更新:拷贝旧值 + 替换单字段 → 抛出新对象 */
const onFieldChange = (name: string, value: unknown) => {
/** 单字段更新(用法 B 内部用) */
const onFieldChange = (name: string, value: unknown): void => {
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)
/** 重置(用法 A:仅触发 reset 事件,由调用方处理;用法 B:先清空字段再 emit reset */
const onResetClick = (): void => {
if (props.fields && props.fields.length > 0) {
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;
margin-bottom: 12px;
:deep(.ant-card-body) {
padding: 20px 24px 4px;
padding: 16px 20px 4px;
}
&__form {
.ant-form-item {
margin-bottom: 12px;
}
}
&__item {
margin-bottom: 16px;
margin-bottom: 12px;
}
&__actions {
margin-bottom: 16px;
margin-bottom: 12px;
}
}
</style>
+78 -39
View File
@@ -1,28 +1,27 @@
<template>
<a-card class="table-card" :bordered="false">
<!-- 卡片头左侧 actionsslot + 可选标题 + 右侧 extra -->
<!-- 卡片头左侧 toolbar slot + 右侧 extra slot都不存在时不渲染卡片头 -->
<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>
<slot name="toolbar" />
</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"
:columns="resolvedColumns"
:data-source="resolvedDataSource"
:pagination="resolvedPagination"
:scroll="scroll"
:row-key="rowKey"
:size="size"
:loading="loading"
v-bind="$attrs"
@change="onChange"
@resize-column="resizeColumn"
>
<!-- 透传 a-table 全部命名插槽 -->
<template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
@@ -33,28 +32,47 @@
</template>
<script setup lang="ts">
import type { TableColumnsType, TablePaginationConfig, TableProps } from 'ant-design-vue'
/**
* 表格卡片(项目标准列表页组件)
*
* 用法约定(新规范)
* - **不显示标题**(title 默认空),列表页不再展示重复的菜单名作为标题
* - **左上方操作区**:通过 `#toolbar` slot 放置主操作按钮(如"导出列表数据""查看导出任务"
* - **右上方扩展区**:通过 `#extra` slot 放置筛选/视图切换等次要操作
* - 表格主体 props/slots 全部透传 a-table
* 支持两种用法:
*
* 如果显式传入 title,将以标题文本渲染(兼容旧用法,但新页面不应使用
* 用法 A(推荐 / 与 vue 研发项目对齐):传 :table="table"TableState 对象
* <TableCard :table="table" :loading="pageLoading" @change="dataSourceChange">
* <template #toolbar><a-button ... /></template>
* </TableCard>
*
* 用法 B(兼容旧页面):分别传 :columns :data-source :pagination
* <TableCard :columns="columns" :data-source="dataList" :pagination="pagination" />
*
* 设计说明:
* - 卡片**不显示 title**(列表页不再展示重复的菜单名作为标题)
* - 主操作按钮通过 #toolbar slot 放置在卡片左上方
* - 次要操作通过 #extra slot 放置在卡片右上方
* - 表格 props / slots 全部透传 a-table
* - @change 事件优先走 props.table 上下文的回调(接 createDataSourceChange 返回值)
* - @resize-column 已内置 resizeColumn
*
* 研发接手改造路径:
* - 把 <TableCard :table="table"> 替换为 <a-table :columns="table.columns" :data-source="table.dataSource" ...>
* - 业务逻辑(useTable / usePage)完全无需改动
*/
import type {
TableColumnsType,
TablePaginationConfig,
TableProps,
} from 'ant-design-vue'
import type { TableState } from '@utils/antDesign/table'
import { resizeColumn } from '@utils/antDesign/table'
const props = withDefaults(defineProps<{
/** 卡片标题(新规范下不应使用,留空即可 */
title?: string
/** 表格列配置 */
columns: TableColumnsType
/** 表格数据源 */
dataSource: Record<string, unknown>[]
/** 分页配置:传 false 关闭分页 */
/** 用法 ATableState 统一对象(推荐 */
table?: TableState
/** 用法 B:表格列配置(兼容旧页面) */
columns?: TableColumnsType
/** 用法 B表格数据源 */
dataSource?: Record<string, unknown>[]
/** 用法 B分页配置false 关闭 */
pagination?: TablePaginationConfig | false
/** 横向 / 纵向滚动 */
scroll?: TableProps['scroll']
@@ -65,7 +83,9 @@ const props = withDefaults(defineProps<{
/** 加载态 */
loading?: boolean
}>(), {
title: '',
table: undefined,
columns: undefined,
dataSource: undefined,
pagination: undefined,
scroll: () => ({ x: 'max-content' }),
rowKey: 'id',
@@ -73,10 +93,41 @@ const props = withDefaults(defineProps<{
loading: false,
})
const emit = defineEmits<{
(e: 'change', pagination: TablePaginationConfig, filters: unknown, sorter: unknown): void
}>()
const slots = useSlots()
/** 是否渲染卡片头: toolbar slot 存在 或 显式传入 title 时 */
const showHeader = computed(() => Boolean(slots.toolbar) || Boolean(props.title))
/** 是否渲染卡片头:仅当存在 toolbar slot 时显示 */
const showHeader = computed(() => Boolean(slots.toolbar))
/** 解析 columns:优先 table.columns,其次 props.columns */
const resolvedColumns = computed<TableColumnsType>(() =>
props.table?.columns ?? props.columns ?? [],
)
/** 解析 dataSource:优先 table.dataSource,其次 props.dataSource */
const resolvedDataSource = computed<readonly Record<string, unknown>[]>(() =>
(props.table?.dataSource as Record<string, unknown>[] | undefined)
?? props.dataSource
?? [],
)
/** 解析 pagination:优先 table.pagination,其次 props.pagination */
const resolvedPagination = computed<TablePaginationConfig | false>(() => {
if (props.table) return props.table.pagination
return props.pagination ?? false
})
/** @change 事件透传(接 createDataSourceChange 返回值) */
const onChange = (
pagination: TablePaginationConfig,
filters: unknown,
sorter: unknown,
): void => {
emit('change', pagination, filters, sorter)
}
/** $attrs 透传剩余 props,关闭根节点继承避免污染 a-card */
defineOptions({ inheritAttrs: false })
@@ -104,17 +155,5 @@ defineOptions({ inheritAttrs: false })
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>
+25
View File
@@ -0,0 +1,25 @@
/**
* 自定义指令统一注册入口
*
* 在 main.ts 中调用 registerDirectives(app)
* 对齐 vue 研发项目,业务模板可直接使用:
* v-no-space
* v-only-number / v-only-number.integer / v-only-number.positive
* v-only-alphanumeric
* v-only-alphanumeric-special
*/
import type { App } from 'vue'
import { noSpace } from './noSpace'
import { onlyNumber } from './onlyNumber'
import { onlyAlphanumeric } from './onlyAlphanumeric'
import { onlyAlphanumericSpecial } from './onlyAlphanumericSpecial'
export const registerDirectives = (app: App): void => {
app.directive('no-space', noSpace)
app.directive('only-number', onlyNumber)
app.directive('only-alphanumeric', onlyAlphanumeric)
app.directive('only-alphanumeric-special', onlyAlphanumericSpecial)
}
export { noSpace, onlyNumber, onlyAlphanumeric, onlyAlphanumericSpecial }
+42
View File
@@ -0,0 +1,42 @@
/**
* v-no-space 自定义指令
*
* 设计目的:禁止输入前后空格 + 禁止中间连续空格压缩
* - 对齐 vue 研发项目 @directives/noSpace
* - 用于 <a-input v-no-space>、<a-textarea v-no-space>
*
* 行为:
* - input 事件时去除所有空白字符(包括全角空格)
* - 同步触发 input 事件,保证 v-model 取得净化后的值
*/
import type { Directive } from 'vue'
/** 去除字符串中的所有空白(含全角空格、Tab、换行) */
const stripSpaces = (val: string): string => val.replace(/[\s ]/g, '')
export const noSpace: Directive<HTMLElement> = {
mounted(el) {
const input = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
? el
: el.querySelector('input, textarea')) as HTMLInputElement | HTMLTextAreaElement | null
if (!input) return
const handler = (): void => {
const next = stripSpaces(input.value)
if (input.value !== next) {
input.value = next
input.dispatchEvent(new Event('input', { bubbles: true }))
}
}
input.addEventListener('input', handler)
;(el as unknown as { __noSpaceHandler__?: EventListener }).__noSpaceHandler__ = handler
},
unmounted(el) {
const input = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
? el
: el.querySelector('input, textarea')) as HTMLInputElement | HTMLTextAreaElement | null
const handler = (el as unknown as { __noSpaceHandler__?: EventListener }).__noSpaceHandler__
if (input && handler) input.removeEventListener('input', handler)
},
}
+37
View File
@@ -0,0 +1,37 @@
/**
* v-only-alphanumeric 自定义指令
*
* 设计目的:限制输入仅为字母与数字(大小写均可)
* - 对齐 vue 研发项目 @directives/onlyAlphanumeric
* - 用于工号、账号等场景
*/
import type { Directive } from 'vue'
const sanitize = (val: string): string => val.replace(/[^A-Za-z0-9]/g, '')
export const onlyAlphanumeric: Directive<HTMLElement> = {
mounted(el) {
const input = (el.tagName === 'INPUT'
? el
: el.querySelector('input')) as HTMLInputElement | null
if (!input) return
const handler = (): void => {
const next = sanitize(input.value)
if (input.value !== next) {
input.value = next
input.dispatchEvent(new Event('input', { bubbles: true }))
}
}
input.addEventListener('input', handler)
;(el as unknown as { __onlyAlnumHandler__?: EventListener }).__onlyAlnumHandler__ = handler
},
unmounted(el) {
const input = (el.tagName === 'INPUT'
? el
: el.querySelector('input')) as HTMLInputElement | null
const handler = (el as unknown as { __onlyAlnumHandler__?: EventListener }).__onlyAlnumHandler__
if (input && handler) input.removeEventListener('input', handler)
},
}
+40
View File
@@ -0,0 +1,40 @@
/**
* v-only-alphanumeric-special 自定义指令
*
* 设计目的:限制输入为字母、数字与常见特殊符号(密码场景)
* - 对齐 vue 研发项目 @directives/onlyAlphanumericSpecial
* - 允许字符:a-z A-Z 0-9 ~!@#$%^&*()_+`-={}:";'<>?,./
*/
import type { Directive } from 'vue'
// eslint-disable-next-line no-useless-escape
const ALLOWED = /[^A-Za-z0-9~!@#$%^&*()_+`\-={}:";'<>?,./]/g
const sanitize = (val: string): string => val.replace(ALLOWED, '')
export const onlyAlphanumericSpecial: Directive<HTMLElement> = {
mounted(el) {
const input = (el.tagName === 'INPUT'
? el
: el.querySelector('input')) as HTMLInputElement | null
if (!input) return
const handler = (): void => {
const next = sanitize(input.value)
if (input.value !== next) {
input.value = next
input.dispatchEvent(new Event('input', { bubbles: true }))
}
}
input.addEventListener('input', handler)
;(el as unknown as { __onlyAlnumSpecialHandler__?: EventListener }).__onlyAlnumSpecialHandler__ = handler
},
unmounted(el) {
const input = (el.tagName === 'INPUT'
? el
: el.querySelector('input')) as HTMLInputElement | null
const handler = (el as unknown as { __onlyAlnumSpecialHandler__?: EventListener }).__onlyAlnumSpecialHandler__
if (input && handler) input.removeEventListener('input', handler)
},
}
+65
View File
@@ -0,0 +1,65 @@
/**
* v-only-number 自定义指令
*
* 设计目的:限制输入仅为数字(含负号与小数点)
* - 对齐 vue 研发项目 @directives/onlyNumber
* - 用于 <a-input v-only-number>(注意:数字录入推荐 <a-input-number>
*
* 修饰符:
* - .integer 仅整数(默认允许小数)
* - .positive 仅正数(默认允许负号)
*/
import type { Directive, DirectiveBinding } from 'vue'
interface OnlyNumberModifiers {
integer?: boolean
positive?: boolean
}
const sanitize = (val: string, mods: OnlyNumberModifiers): string => {
let s = val
// 去除非数字、非小数点、非负号
s = s.replace(mods.integer ? /[^\d-]/g : /[^\d.\-]/g, '')
// 负号只能出现在最前
if (!mods.positive) {
s = s.replace(/(?!^)-/g, '')
} else {
s = s.replace(/-/g, '')
}
// 仅保留第一个小数点
if (!mods.integer) {
const firstDot = s.indexOf('.')
if (firstDot !== -1) {
s = s.slice(0, firstDot + 1) + s.slice(firstDot + 1).replace(/\./g, '')
}
}
return s
}
export const onlyNumber: Directive<HTMLElement, OnlyNumberModifiers | undefined> = {
mounted(el, binding: DirectiveBinding<OnlyNumberModifiers | undefined>) {
const input = (el.tagName === 'INPUT'
? el
: el.querySelector('input')) as HTMLInputElement | null
if (!input) return
const mods = (binding.modifiers ?? {}) as OnlyNumberModifiers
const handler = (): void => {
const next = sanitize(input.value, mods)
if (input.value !== next) {
input.value = next
input.dispatchEvent(new Event('input', { bubbles: true }))
}
}
input.addEventListener('input', handler)
;(el as unknown as { __onlyNumberHandler__?: EventListener }).__onlyNumberHandler__ = handler
},
unmounted(el) {
const input = (el.tagName === 'INPUT'
? el
: el.querySelector('input')) as HTMLInputElement | null
const handler = (el as unknown as { __onlyNumberHandler__?: EventListener }).__onlyNumberHandler__
if (input && handler) input.removeEventListener('input', handler)
},
}
+173
View File
@@ -0,0 +1,173 @@
# 原型 ↔ 研发项目 对照表
> **本文档是 AI 与研发的最高级查询表**。
>
> 原型项目(yx-platform-prototype)的最高目标是:**研发接手时改动最小**。本表列出"原型用什么、研发用什么、迁移时怎么换"。
## 一、目录结构对照
| 原型项目 | 研发项目 (platform-vue-tenant) | 说明 |
|---------|------------------------------|------|
| `src/pages/<portal>/<app>/<section>/<page>/` | `apps/sub<App>/src/views/dynamicPage/<module>/<page>/` | 业务页面位置 |
| `src/components/` | `apps/sub<App>/src/components/` | 公共组件(子应用级) |
| `src/utils/antDesign/table.ts` | `apps/sub<App>/src/utils/antDesign/table/` | 表格工具 |
| `src/utils/antDesign/popUp.ts` | `apps/sub<App>/src/utils/antDesign/popUp/` | 弹窗静态方法 |
| `src/utils/antDesign/select.ts` | `apps/sub<App>/src/utils/antDesign/select/` | 下拉过滤 |
| `src/axios/` | `apps/sub<App>/src/axios/` | HTTP 封装 |
| `src/api/index.ts` | `apps/sub<App>/src/api/index.ts` | 全局公共接口(字典/部门树) |
| `src/directives/` | `apps/sub<App>/src/directives/` | 自定义指令 |
| `src/assets/styles/listPage.less` | `apps/sub<App>/src/assets/styles/listPage.less` | 列表页公共样式 |
## 二、路径别名对照
| 别名 | 原型项目 | 研发项目 | 一致性 |
|------|---------|---------|--------|
| `@` | `src/` | `src/` | ✅ |
| `@api` | `src/api/` | `src/api/` | ✅ |
| `@axios` | `src/axios/` | `src/axios/` | ✅ |
| `@utils` | `src/utils/` | `src/utils/` | ✅ |
| `@assets` | `src/assets/` | `src/assets/` | ✅ |
| `@components` | `src/components/` | `src/components/` | ✅ |
| `@directives` | `src/directives/` | `src/directives/` | ✅ |
| `@meta` | `src/meta/`(导航数据) | — | 原型特有 |
| `@pages` | `src/pages/` | — | 原型特有(研发用 `@views` |
| `@layouts` | `src/layouts/` | `src/layouts/` | ✅ |
## 三、HTTP 请求层对照
### 业务侧调用方式(**完全一致,零改动**)
```typescript
import { postRequest } from '@axios'
postRequest('axiosRequest', '/nutrition/employee/page', params)
.then((res: any) => {
if (res.code === '00000') {
table.dataSource = res.data
table.pagination.total = res.total
}
})
.catch((err: unknown) => {
console.error('列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
```
### 内部实现差异
| 项目 | 实现 |
|------|------|
| 原型 | `src/axios/index.ts``mockBus` 路由 → mock handler 返回预置数据 + 200~500ms 延迟 |
| 研发 | `src/axios/index.ts` → axios 实例 + 拦截器 → 真实 HTTP |
### 迁移步骤
1. **删除** `src/axios/mockBus.ts``src/axios/handlers/`
2. **替换** `src/axios/index.ts` 为真实 axios 封装(保持 `getRequest/postRequest/...` 签名不变)
3. **业务代码(api/index.ts / usePage.ts)零改动**
## 四、工具函数对照
| 名称 | 用法 | 一致性 |
|------|------|--------|
| `createPaginationConfig` | `createPaginationConfig(total?: number): TablePaginationConfig` | ✅ 1:1 |
| `createDataSourceChange` | `createDataSourceChange(table, initSort, listRequest)` | ✅ 1:1 |
| `createResetTable` | `createResetTable(table, createInitState)` | ✅ 1:1 |
| `resizeColumn` | `<a-table @resize-column="resizeColumn">` | ✅ 1:1 |
| `createRowSelection` | `createRowSelection({ rowKey, type, ... })` | ✅ 1:1 |
| `useAntdStaticMethods` | `const { message, Modal, notification } = useAntdStaticMethods()` | ✅ 1:1 |
| `filterOption` | `<a-select :filter-option="filterOption">` | ✅ 1:1 |
## 五、类型定义对照
| 类型 | 字段 | 一致性 |
|------|------|--------|
| `TableSort` | `{ field: string; order: 'ascend' \| 'descend' \| null }` | ✅ 1:1 |
| `TableState<T>` | `{ columns; dataSource: T[]; sort: TableSort; pagination }` | ✅ 1:1 |
| `ApiResponse<T>` | `{ code: string; msg: string; data: T; total?: number }` | ✅ 1:1 |
| `ServiceType` | `'axiosRequest' \| 'axiosUpload' \| 'axiosDownload'` | ✅ 1:1 |
## 六、自定义指令对照
| 指令 | 行为 | 一致性 |
|------|------|--------|
| `v-no-space` | 禁止前后/中间空格 | ✅ 1:1 |
| `v-only-number` | 仅数字(含小数和负号) | ✅ 1:1 |
| `v-only-alphanumeric` | 仅字母数字 | ✅ 1:1 |
| `v-only-alphanumeric-special` | 字母+数字+常用特殊符号 | ✅ 1:1 |
## 七、公共组件对照
| 原型组件 | 研发对应写法 | 迁移路径 |
|---------|------------|---------|
| `<TableCard :table="table">` + `#toolbar` | `<a-row class="listPageRow">` + `<a-table>` | 把 TableCard 拆成 a-row + a-tablecolumns/dataSource/pagination 走 `table.xxx` |
| `<FilterBar v-model="search">` + 内部裸 a-form-item | `<a-row class="listPageForm">` + `<a-form>` + `<a-form-item>` | 把 FilterBar 拆成 a-row + a-form,查询/重置按钮直接写 a-button |
| `<StatCard>` | 项目按需,无强约束 | 保留或废弃 |
| `<PageHeader>` | 项目按需 | 保留 |
> **关键**:原型公共组件**优先支持"裸 slot"写法**,研发接手时把组件标签去掉即可。
## 八、列表页文件结构对照
```
列表页/
├── <页面名>.vue # 模板层(薄) ✅ 一致
├── api/index.ts # HTTP 调用 ✅ 一致
├── types/index.ts # 类型集中定义 ✅ 一致
├── init/
│ ├── usePage.ts # 页面主逻辑 ✅ 一致
│ ├── useTable.ts # 表格状态(TableState)✅ 一致
│ └── useSearch.ts # 搜索 + 下拉选项 ✅ 一致
└── component/
├── modal/<名>/ # 弹框子组件 ✅ 一致
├── drawer/<名>/ # 抽屉子组件 ✅ 一致
└── page/<名>Detail/ # 详情子页面 ✅ 一致
```
## 九、命名 / 风格强约束对照
| 项 | 约束 | 一致性 |
|---|------|--------|
| 请求风格 | `.then().catch().finally()`,禁 `async/await` | ✅ |
| 函数关键字 | 只用箭头函数 `const x = () => {}`,禁 `function` | ✅ |
| id 类型 | `string \| undefined` | ✅ |
| 搜索状态 | `reactive<SearchForm>(createSearchKey())` | ✅ |
| 搜索重置 | `Object.assign(search, createSearchKey())` | ✅ |
| 表格状态 | `reactive<TableState>(createInitState())` | ✅ |
| 表格数据 | `table.dataSource`(禁单独 `dataList` ref | ✅ |
| 分页参数 | `pageNum / pageSize` | ✅ |
| 打开子组件 | `open + 组件名`(如 `openAddOrEdit` | ✅ |
| 子组件 emit | Modal: `load`Drawer: `ok` | ✅ |
| 子组件 ref | 必须在父 `usePage.ts` 定义 | ✅ |
| 删除/批量 | `Modal.confirm` + `okType:'danger'` | ✅ |
| 空值 | `??` 兜底 `—` | ✅ |
| 时间格式 | `YYYY-MM-DD HH:mm:ss` / `YYYY-MM-DD` | ✅ |
| 列定义 | 写在 `useTable.ts`customRender 用 `h()` | ✅ |
| Mock 数据 | 写在 `src/axios/handlers/`,禁写在 useTable | ⚠️ 原型独有 |
## 十、原型独有 / 研发不需要的内容
| 内容 | 说明 | 迁移处理 |
|------|------|---------|
| 门禁页 `Gate.vue` | 设计/研发账号区分 | 删除 |
| 可见性控制台 `VisibilityConsole.vue` | 设计完成度调试 | 删除 |
| PRD 在线查看 `PrdViewer.vue` | markdown 渲染 | 删除 |
| 设计总览 `DesignOverview.vue` | 小程序/硬件平铺 | 删除 |
| `src/composables/useVisibility.ts` | 角色过滤 | 删除 |
| `src/meta/nav.ts` 中的 `status` / `audience` | 可见性字段 | 与权限菜单合并 |
| `src/axios/mockBus.ts` + `src/axios/handlers/` | Mock 数据 | 删除 |
| `src/prd/` | PRD markdown | 不进入研发仓库 |
| `src/docs/` | 规范文档 | 复用作为研发规范 |
## 十一、迁移 Checklist(研发接手时执行)
1. ☐ 复制 `src/pages/<portal>/<app>/<section>/<page>/` 到研发项目对应目录
2. ☐ 调整 import 路径:`@pages``@views`、其它别名保持
3. ☐ 替换 `src/axios/` 为真实 axios 封装(保持方法签名)
4. ☐ 删除 mock handlers
5. ☐ 删除原型独有页面与 composables
6. ☐ 删除 `src/meta/nav.ts` 的可见性字段(保留菜单结构)
7. ☐ 把 TableCard / FilterBar 拆为裸 `<a-table>` / `<a-form>`(业务逻辑零改动)
8. ☐ 检查 `package.json` 依赖差异(原型缺 Pinia / axios / dayjs 等,研发已具备)
+251
View File
@@ -0,0 +1,251 @@
# 详情页规范(二级页面)
> 对齐 platform-vue-tenant 的 `docs/detail-page-spec.md`。
## 一、适用场景
- 列表点击进入的子列表页(如字典项列表、记录明细)
- 编辑或展示某条数据完整信息的静态详情页
## 二、目录结构
```
component/page/xxxDetail/
├── xxxDetail.vue
├── init/
│ ├── usePage.ts
│ ├── useTable.ts # 子列表型
│ └── useSearch.ts # 子列表型且有搜索时
├── api/index.ts
└── types/index.ts
```
## 三、两种类型
### 类型一:子列表型
- 结构同主列表页(useTable + useSearch + usePage
- `parentId` 通过 `route.query.id as string` 获取
- 顶部 + 底部各放一个返回按钮,点击 `emit('back')`
### 类型二:纯详情型
-`usePage.ts`
-`<a-descriptions>` 展示
- 仅顶部 + 底部返回按钮
## 四、模板(子列表型)
```vue
<template>
<a-row class="page-header">
<a-col><a-button @click="goBack">返回</a-button></a-col>
</a-row>
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="名称">
<a-input v-model:value="search.name" v-no-space allow-clear placeholder="请输入名称" />
</a-form-item>
</FilterBar>
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<template #toolbar>
<a-button type="primary" @click="openAddOrEdit('add')">
<template #icon><PlusOutlined /></template>
新增
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="openAddOrEdit('edit', record)">编辑</a-button>
<a-button type="link" size="small" danger @click="removeRecord(record.id)">删除</a-button>
</template>
</template>
</TableCard>
<a-row class="page-footer">
<a-col><a-button @click="goBack">返回</a-button></a-col>
</a-row>
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import { usePage } from './init/usePage'
const emit = defineEmits<{
(e: 'back'): void
}>()
const {
addOrEditRef,
pageLoading,
search,
table,
dataSourceChange,
searchQuery,
resetQuery,
openAddOrEdit,
removeRecord,
goBack,
listRequest,
} = usePage(emit)
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
</style>
```
## 五、init/usePage.ts(子列表型)
```typescript
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { list, remove } from '../api'
import { useSearch } from './useSearch'
import { useTable } from './useTable'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import type { ListItem } from '../types'
export const usePage = (emit: (e: 'back') => void) => {
const route = useRoute()
const parentId = route.query.id as string
const { Modal, message } = useAntdStaticMethods()
const { search, options, initOptions, resetSearch } = useSearch()
const pageLoading = ref<boolean>(false)
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
const buildParams = () => ({
...search,
parentId,
pageNum: table.pagination.current,
pageSize: table.pagination.pageSize,
order: table.sort.order,
column: table.sort.field,
})
const listRequest = (): void => {
pageLoading.value = true
list(buildParams())
.then((res: { code: string; data: ListItem[]; total?: number }) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = res.total ?? 0
}
})
.catch((err: unknown) => console.error('列表请求失败:', err))
.finally(() => { pageLoading.value = false })
}
const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest)
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
const resetQuery = (): void => {
resetSearch()
resetTable()
listRequest()
}
const openAddOrEdit = (type: 'add' | 'edit', record?: ListItem): void => {
addOrEditRef.value?.openModal(type, record)
}
const removeRecord = (id: string): void => {
Modal.confirm({
title: '确认删除?',
content: '删除后不可恢复',
okType: 'danger',
onOk: () => {
remove(id)
.then((res: { code: string; msg: string }) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => console.error('删除失败:', err))
},
})
}
/** 返回上级 */
const goBack = (): void => {
emit('back')
}
onMounted(() => {
initOptions()
listRequest()
})
return {
addOrEditRef,
pageLoading,
search,
options,
table,
dataSourceChange,
resizeColumn,
searchQuery,
resetQuery,
openAddOrEdit,
removeRecord,
goBack,
listRequest,
}
}
```
## 六、纯详情型示例
```vue
<template>
<a-row class="page-header">
<a-col><a-button @click="goBack">返回</a-button></a-col>
</a-row>
<a-spin :spinning="pageLoading">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="状态">{{ detail.status === 1 ? '启用' : '禁用' }}</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ detail.createTime ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">{{ detail.description ?? '—' }}</a-descriptions-item>
</a-descriptions>
</a-spin>
<a-row class="page-footer">
<a-col><a-button @click="goBack">返回</a-button></a-col>
</a-row>
</template>
```
## 七、强制要求
- 必须有顶部 + 底部两个返回按钮
- `parentId` 通过 `route.query.id as string` 获取
- `goBack()` 通过 `emit('back')` 通知父组件切换视图(禁直接 `router.back()`
- 数据请求在 `onMounted` 触发,写在 `usePage.ts`
- 子组件 ref 在 `usePage.ts` 定义
## 八、禁止行为
- ❌ 禁止 `.vue` 中写业务逻辑
- ❌ 禁止 `async/await`
- ❌ 禁止 `router.back()`
- ❌ 禁止 `parentId` 硬编码
- ❌ 纯详情页禁用 `a-table`,必须 `a-descriptions`
- ❌ 禁止省略顶/底两处返回按钮
+156
View File
@@ -0,0 +1,156 @@
# 抽屉规范(Drawer
> 对齐 platform-vue-tenant 的 `docs/drawer-spec.md`。
## 一、目录结构
```
drawer/xxxDrawer/
├── xxxDrawer.vue
├── init/
│ ├── usePage.ts
│ ├── useTable.ts # 含列表时
│ └── useSearch.ts # 有搜索时
├── api/index.ts
└── types/index.ts
```
## 二、两种类型
### A 类型:含列表抽屉(结构同列表页)
- `useTable.ts` / `useSearch.ts` 必须独立拆分(与主列表页规则相同)
- `<a-drawer>` 必须加 `destroy-on-close`(避免关闭后数据残留)
- 父组件监听 `@ok` 刷新(与 Modal 的 `@load` 区分)
### B 类型:详情抽屉(仅展示,无列表)
-`usePage.ts` + `api/index.ts` + `types/index.ts`
-`<a-descriptions>` 展示字段
## 三、types/index.ts
```typescript
export interface PageInfo {
visible: boolean
title: string
spin: boolean
width: number
}
export interface DetailInfo {
id: string
name: string
status: 0 | 1
createTime: string
}
```
## 四、init/usePage.ts(详情抽屉版)
```typescript
import type { DetailInfo, PageInfo } from '../types'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { getInfo } from '../api'
export const usePage = () => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
title: '详情',
spin: false,
width: 800,
})
const detail = ref<DetailInfo | null>(null)
const loadData = (id: string): void => {
pageInfo.spin = true
getInfo(id)
.then((res: { code: string; data: DetailInfo }) => {
if (res.code === '00000') detail.value = res.data
})
.catch((err: unknown) => {
console.error('获取详情失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
/** 父组件调用入口 */
const openDrawer = (record: { id: string }): void => {
detail.value = null
pageInfo.visible = true
pageInfo.title = '详情'
loadData(record.id)
}
const closeDrawer = (): void => {
pageInfo.visible = false
}
return { pageInfo, detail, openDrawer, closeDrawer }
}
```
## 五、模板(详情抽屉)
```vue
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
destroy-on-close
@close="closeDrawer"
>
<a-spin :spinning="pageInfo.spin">
<template v-if="!detail">
<a-empty description="暂无数据" />
</template>
<template v-else>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="名称">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="状态">{{ detail.status === 1 ? '启用' : '禁用' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detail.createTime ?? '—' }}</a-descriptions-item>
</a-descriptions>
</template>
</a-spin>
<template #footer>
<a-button @click="closeDrawer">关闭</a-button>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { usePage } from './init/usePage'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
defineExpose({ openDrawer })
</script>
```
## 六、强制要求
| 项 | 要求 |
|---|------|
| 请求风格 | `.then().catch().finally()` |
| 弹窗静态方法 | `useAntdStaticMethods()` |
| 打开方法 | 组件暴露 `openDrawer`,父调用方法 `open + 组件名` |
| 关闭方法 | `closeDrawer()` |
| loading | `pageInfo.spin`,用 `<a-spin :spinning="pageInfo.spin">` 包裹 |
| `destroy-on-close` | 含列表的抽屉必须加 |
| 子组件 ref | 必须在父 `usePage.ts` 定义 |
| emit 事件名 | `'ok'`(区别于 Modal 的 `load` |
## 七、禁止行为
- ❌ 禁止在 `.vue` 写业务逻辑
- ❌ 禁止 `async/await`
- ❌ 禁止省略 `destroy-on-close`(含列表抽屉)
- ❌ 禁止用 `open / show / visible` 等无意义命名
- ❌ 禁止父 `.vue` 定义子组件 ref
+530
View File
@@ -0,0 +1,530 @@
# 列表页规范(主页面)
> 对齐 platform-vue-tenant 的 `docs/list-page-spec.md`,原型阶段强制执行。
>
> **标准参考样板**`src/pages/tenant-portal/nutrition/arc/arcEmployee/`
## 一、目录结构(强制 6 文件)
```
<页面名>/
├── <页面名>.vue # 模板层(无业务逻辑,仅渲染)
├── init/
│ ├── usePage.ts # 页面主逻辑(含子组件 ref 和打开方法)
│ ├── useTable.ts # 表格列配置(TableState
│ └── useSearch.ts # 搜索表单 + 下拉选项
├── component/ # 可选:弹框/抽屉/子详情页
│ ├── modal/addOrEdit/ # 新增编辑弹框(见 modal-spec.md
│ └── drawer/info/ # 详情抽屉(见 drawer-spec.md
├── api/index.ts # 接口层(postRequest 调用 / Promise 链)
└── types/index.ts # 类型层(Interface 集中定义)
```
## 二、types/index.ts
```typescript
/** 搜索表单类型 */
export interface SearchForm {
name?: string
status?: 0 | 1
}
/** 列表行数据 */
export interface ListItem {
/** 主键,统一 string 类型 */
id: string
name: string
status: 0 | 1
createTime: string
}
/** 列表分页请求参数 */
export interface ListParams extends SearchForm {
pageNum: number
pageSize: number
order?: 'ascend' | 'descend' | null
column?: string
}
```
## 三、api/index.ts
```typescript
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { ListParams } from '../types'
/** 列表分页查询 */
export const list = (params: ListParams): Promise<ApiResponse> =>
postRequest('axiosRequest', '/example/page', params)
/** 详情 */
export const info = (id: string): Promise<ApiResponse> =>
postRequest('axiosRequest', '/example/info', { id })
/** 新增 */
export const add = (params: Record<string, unknown>): Promise<ApiResponse> =>
postRequest('axiosRequest', '/example/add', params)
/** 编辑 */
export const edit = (params: Record<string, unknown>): Promise<ApiResponse> =>
postRequest('axiosRequest', '/example/update', params)
/** 删除 */
export const remove = (id: string): Promise<ApiResponse> =>
postRequest('axiosRequest', '/example/delete', { id })
```
## 四、init/useSearch.ts
> 强制用 `reactive` 包裹 + 工厂函数 `createSearchKey()` + `Object.assign` 重置
```typescript
import type { SearchForm } from '../types'
import { dictItems } from '@api'
/** 创建搜索初始值(工厂函数) */
const createSearchKey = (): SearchForm => ({
name: undefined,
status: undefined,
})
/** 创建下拉选项初始值(工厂函数) */
const createOptions = () => ({
status: [] as { label: string; value: number }[],
})
export const useSearch = () => {
const search = reactive<SearchForm>(createSearchKey())
const options = reactive(createOptions())
/** 加载所有下拉数据(usePage.loadData 中统一调用) */
const initOptions = (): void => {
Promise.all([dictItems('enable_status')])
.then(([statusList]) => {
options.status = (statusList?.data ?? []) as { label: string; value: number }[]
})
.catch((err: unknown) => {
console.error('初始化下拉选项失败:', err)
})
}
/** 重置搜索条件(保持响应式引用不变) */
const resetSearch = (): void => {
Object.assign(search, createSearchKey())
}
return { search, options, initOptions, resetSearch }
}
```
## 五、init/useTable.ts
> 强制用 `TableState` 统一对象 + `createInitState` 工厂函数
```typescript
import { h } from 'vue'
import type { TableColumnsType } from 'ant-design-vue'
import {
type TableSort,
type TableState,
createPaginationConfig,
createDataSourceChange,
createResetTable,
resizeColumn,
} from '@utils/antDesign/table'
import type { ListItem } from '../types'
/** 默认排序 */
const INIT_SORT: TableSort = {
field: 'createTime',
order: 'descend',
}
/** 表格列定义(customRender 用 h(),禁 JSX */
const tableColumns: TableColumnsType = [
{
title: '序号',
dataIndex: 'index',
align: 'center',
fixed: 'left',
width: 70,
customRender: ({ index }: { index: number }) => index + 1,
},
{
title: '名称',
dataIndex: 'name',
align: 'center',
width: 150,
resizable: true,
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
align: 'center',
width: 100,
customRender: ({ text }: { text: 0 | 1 }) => {
const color = text === 1 ? '#52c41a' : '#bfbfbf'
const label = text === 1 ? '启用' : '禁用'
return h('span', { style: { color } }, label)
},
},
{
title: '创建时间',
dataIndex: 'createTime',
align: 'center',
width: 180,
sorter: true,
resizable: true,
},
{
title: '操作',
dataIndex: 'action',
align: 'center',
fixed: 'right',
width: 160,
},
]
export const useTable = (listRequest: () => void) => {
const createInitState = (): TableState<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
const table = reactive<TableState<ListItem>>(createInitState())
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
```
## 六、init/usePage.ts
> 强制用 `buildParams()` + `.then().catch().finally()` + `Modal.confirm` 删除二次确认
```typescript
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { list, remove } from '../api'
import { useSearch } from './useSearch'
import { useTable } from './useTable'
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
import type { ListItem } from '../types'
export const usePage = () => {
const { Modal, message } = useAntdStaticMethods()
const { search, options, initOptions, resetSearch } = useSearch()
const pageLoading = ref<boolean>(false)
/** 子组件 ref 必须在 usePage.ts 中定义 */
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
/** 构造请求参数(搜索 + 排序 + 分页) */
const buildParams = () => ({
...search,
pageNum: table.pagination.current,
pageSize: table.pagination.pageSize,
order: table.sort.order,
column: table.sort.field,
})
/** 列表请求 */
const listRequest = (): void => {
pageLoading.value = true
list(buildParams())
.then((res: { code: string; data: ListItem[]; total?: number }) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = res.total ?? 0
}
})
.catch((err: unknown) => {
console.error('列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
/** useTable 必须在 listRequest 之后调用 */
const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest)
/** 查询(回第一页) */
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
/** 重置 */
const resetQuery = (): void => {
resetSearch()
resetTable()
listRequest()
}
/** 打开新增/编辑弹框 */
const openAddOrEdit = (type: 'add' | 'edit', record?: ListItem): void => {
addOrEditRef.value?.openModal(type, record)
}
/** 删除(二次确认强制) */
const removeRecord = (id: string): void => {
Modal.confirm({
title: '确认删除?',
content: '删除后数据不可恢复,请谨慎操作。',
okType: 'danger',
okText: '确认删除',
cancelText: '取消',
onOk: () => {
remove(id)
.then((res: { code: string; msg: string }) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => {
console.error('删除失败:', err)
})
},
})
}
/** 数据初始加载 */
const loadData = (): void => {
initOptions()
listRequest()
}
onMounted(() => {
loadData()
})
return {
addOrEditRef,
pageLoading,
search,
options,
table,
dataSourceChange,
resizeColumn,
searchQuery,
resetQuery,
openAddOrEdit,
removeRecord,
listRequest,
}
}
```
## 七、模板(<页面>.vue
> 用 FilterBar 的 default slot 写裸 a-form-itemTableCard 接 `:table` 对象。
```vue
<template>
<div class="example-page">
<!-- 搜索区 -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="名称">
<a-input
v-model:value="search.name"
v-no-space
allow-clear
placeholder="请输入名称"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="search.status"
:options="options.status"
:filter-option="filterOption"
show-search
allow-clear
placeholder="请选择状态"
style="width: 160px"
/>
</a-form-item>
</FilterBar>
<!-- 表格 -->
<TableCard
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<!--
toolbar slot - 顶部操作按钮按需添加不是标配
- 顶部按钮新增/导入/导出/批量删除等才加 icon
- 没有顶部操作时直接省略 toolbar slot
-->
<template #toolbar>
<a-button type="primary" @click="openAddOrEdit('add')">
<template #icon><PlusOutlined /></template>
新增
</a-button>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'action'">
<!--
操作列规范强制
- <a-button type="link"> 一律不加图标 #icon
- 多个操作之间用 <a-divider type="vertical" /> 分隔
- 操作过多> 3 时收起到"更多 ▾"下拉
- "删除"按钮加 danger 属性
- 只读查询页操作列可以只有"查看详情"一个按钮
-->
<a-button type="link" size="small" @click="openAddOrEdit('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="removeRecord(record.id)">删除</a-button>
</template>
</template>
</TableCard>
<!-- 新增/编辑弹框 CRUD 类页面需要 -->
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
</div>
</template>
<script setup lang="ts">
import { PlusOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
import { usePage } from './init/usePage'
const {
addOrEditRef,
pageLoading,
search,
options,
table,
dataSourceChange,
searchQuery,
resetQuery,
openAddOrEdit,
removeRecord,
listRequest,
} = usePage()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.example-page {
display: flex;
flex-direction: column;
height: 100%;
}
</style>
```
### 7.1 按钮图标规范(强制)
| 位置 | 是否加图标 | 说明 |
|------|-----------|------|
| **顶部 toolbar 主操作按钮**(新增/导入/导出/批量删除等) | ✅ 必须加 | 用 `@ant-design/icons-vue` 4.x 的对应图标 |
| **操作列 `<a-button type="link">`**(详情/编辑/删除/重置密码等) | ❌ **禁止加** | 与 vue 研发项目一致,文字直接显示 |
| **搜索栏 查询 / 重置** | ✅ 已在 FilterBar 内置 | SearchOutlined / RedoOutlined |
| **页面通用 a-button**(非 link | 视情况 | 主操作 + icon;次要可不加 |
### 7.2 操作列模板规范
```vue
<!-- 只读查询页如本页 -->
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="openInfoDrawer(record)">查看详情</a-button>
</template>
<!-- 标准 CRUD -->
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="openAddOrEdit('edit', record)">编辑</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" danger @click="removeRecord(record.id)">删除</a-button>
</template>
<!-- 多操作收起> 3 个时 -->
<template v-if="column.dataIndex === 'action'">
<a-button type="link" size="small" @click="action('info', record.id)">详情</a-button>
<a-divider type="vertical" />
<a-button type="link" size="small" @click="openResetPassword(record.id)">重置密码</a-button>
<a-divider type="vertical" />
<a-dropdown>
<a-button type="link" size="small">更多<DownOutlined /></a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="action('edit', record.id)">编辑</a-menu-item>
<a-menu-item @click="removeRecord(record.id)">删除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
```
### 7.3 CRUD 操作不是列表页标配(重要)
每个列表页的功能由业务决定,不能假定所有列表页都有"新增/编辑/删除"
| 业务类型 | 顶部 toolbar | 操作列 |
|---------|------------|--------|
| 只读查询页(如统计、监控、报表、外部数据展示) | 无 | 查看详情 |
| 标准管理页(如字典、用户、菜单) | 新增 | 编辑 / 删除 |
| 审核页 | 批量审核 | 审核 / 查看详情 |
| 任务执行页 | 新建任务 / 重试 | 详情 / 重试 / 取消 |
| 数据导入页 | 导入 / 查看导入任务 | 详情 / 重新上传 |
**禁止**:脚手架生成时**默认带上新增/编辑/删除**。必须由用户明确指定页面功能。
## 八、数据展示规范
| 数据类型 | 展示格式 |
|---------|---------|
| 时间(含时分秒) | `YYYY-MM-DD HH:mm:ss` |
| 日期 | `YYYY-MM-DD` |
| 金额 | 保留两位小数,如 `1,234.56` |
| 百分比 | 保留一位小数,如 `12.5%` |
| 浮点(无特殊说明) | 保留两位小数 |
| 空值 / 无数据 | 统一展示 `—`(禁空串 / null |
空值兜底:`{{ record.value ?? '—' }}`
## 九、强制要求汇总
- 逻辑必须拆分为 `usePage.ts` / `useTable.ts` / `useSearch.ts` 三层,无论简单与否
- 搜索 / 下拉选项在 `useSearch.ts``initOptions``Promise.all` 并行
- `useTable` 必须用 `TableState` 统一对象,列定义在 `useTable.ts`customRender 用 `h()`
- `searchQuery()``table.pagination.current = 1` 后调用 `listRequest()`
- `resetQuery()` 顺序:`resetSearch()``resetTable()``listRequest()`
- 子组件 ref 必须在 `usePage.ts` 定义,打开方法命名 `open + 组件名`
- `onMounted` 写在 `usePage.ts`,统一通过 `loadData()` 触发
- 删除 / 批量必须 `Modal.confirm` + `okType:'danger'`
- 批量按钮 `:disabled="!selectedRowKeys.length"`,文案带数量"批量删除(3)"
## 十、禁止行为
- ❌ 禁止在 `.vue` 文件的 `<script setup>` 中写业务逻辑
- ❌ 禁止使用 `async/await`,请求统一 `.then().catch().finally()`
- ❌ 禁止使用 `function` 关键字,所有函数用箭头函数
- ❌ 禁止从 `ant-design-vue` 直接 import `message/Modal/notification`,统一 `useAntdStaticMethods()`
- ❌ 禁止在父 `.vue` 中定义子组件 ref,必须在 `usePage.ts`
- ❌ 禁止在 `usePage.ts` 中写表格列定义或下拉请求
- ❌ 禁止在 `useTable.ts` 中调接口
- ❌ 禁止在 `usePage.ts` 维护独立 `dataList`,统一 `table.dataSource`
- ❌ 禁止 `searchQuery``resetTable()`(会清空排序)
- ❌ 禁止删除不经二次确认
- ❌ 禁止批量操作按钮在未选中数据时可点击
- ❌ 禁止空值展示为 `''``null`
- ❌ 禁止时间格式不统一
- ❌ 禁止在 `useTable.ts` 中写 mock 数据,mock 数据必须放 `src/axios/handlers/`
- ❌ 禁止单个 mock dataset 超过 **10 条**(原型展示需要而非性能压测)
- ❌ 禁止在列定义的 `customRender` 中写 JSX,统一用 `h()`
-**禁止操作列的 `<a-button type="link">` 加图标**(对齐 vue 研发项目)
-**禁止 AI 脚手架默认带"新增/编辑/删除"按钮**CRUD 必须由用户明确指定
- ❌ 禁止操作列多个按钮之间不加 `<a-divider type="vertical" />` 分隔
+311
View File
@@ -0,0 +1,311 @@
# 弹框规范(Modal
> 对齐 platform-vue-tenant 的 `docs/modal-spec.md`。
>
> **标准参考样板**`src/pages/tenant-portal/nutrition/arc/arcEmployee/component/modal/addOrEdit/`
## 一、目录结构(强制 4 文件)
```
modal/addOrEdit/
├── addOrEdit.vue
├── init/usePage.ts
├── api/index.ts
└── types/index.ts
```
## 二、types/index.ts
```typescript
import type { SelectProps, TreeSelectProps } from 'ant-design-vue'
/** 弹框类型,按业务扩展 */
export type ModalType = 'add' | 'edit'
/** 弹框状态信息 */
export interface PageInfo {
/** 显隐 */
visible: boolean
/** 类型 */
type: ModalType
/** 标题 */
title: string
/** 宽度(px */
width: number
/** 加载态 */
spin: boolean
}
/** 表单数据 */
export interface Form {
/** id 统一 string,新增时 undefined,编辑时回填 */
id?: string
name: string
status: number
}
/** 下拉选项类型(优先用 antd 官方类型) */
export interface FormOptions {
status: SelectProps['options']
parentTree: TreeSelectProps['treeData']
}
```
## 三、init/usePage.ts
```typescript
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
import { filterOption } from '@utils/antDesign/select'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { add, edit, info } from '../api'
import type { Form, FormOptions, ModalType, PageInfo } from '../types'
/** 创建初始表单(工厂函数) */
const createInitForm = (): Form => ({
id: undefined,
name: '',
status: 1,
})
/** 创建初始下拉选项(工厂函数) */
const createInitOptions = (): FormOptions => ({
status: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
parentTree: [],
})
export const usePage = (emit: (e: 'load') => void) => {
const { message } = useAntdStaticMethods()
const pageInfo = reactive<PageInfo>({
visible: false,
type: 'add',
title: '',
width: 600,
spin: false,
})
const formRef = ref<FormInstance>()
const form = ref<Form>(createInitForm())
const formOptions = ref<FormOptions>(createInitOptions())
const rules: Record<string, Rule[]> = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const resetForm = (): void => {
formRef.value?.clearValidate()
form.value = createInitForm()
}
const closeModal = (): void => {
resetForm()
pageInfo.visible = false
}
/** 详情请求 */
const infoRequest = (id: string) => info(id)
/** 打开弹框 */
const openModal = (type: ModalType, record?: Form): void => {
pageInfo.visible = true
pageInfo.spin = true
pageInfo.type = type
resetForm()
pageInfo.title = type === 'add' ? '新增' : '编辑'
const requests: Promise<unknown>[] = []
if (type === 'edit' && record?.id) requests.push(infoRequest(record.id))
Promise.all(requests)
.then((results) => {
if (type === 'edit' && record?.id) {
const infoRes = results[0] as { code: string; data: Form }
if (infoRes?.code === '00000') {
form.value = { ...createInitForm(), ...infoRes.data }
}
}
})
.catch((err: unknown) => {
console.error('打开弹框初始化失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const successRequest = (msg: string): void => {
void message.success(msg)
emit('load')
closeModal()
}
const addRequest = (params: Record<string, unknown>): void => {
delete params.id
add(params)
.then((res: { code: string; msg: string }) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('新增失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const editRequest = (params: Record<string, unknown>): void => {
edit(params)
.then((res: { code: string; msg: string }) => {
if (res.code === '00000') successRequest(res.msg)
})
.catch((err: unknown) => {
console.error('编辑失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
const submit = (): void => {
pageInfo.spin = true
formRef.value
?.validate()
.then(() => {
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
if (params.id) editRequest(params)
else addRequest(params)
})
.catch((err: unknown) => {
pageInfo.spin = false
console.error('表单验证失败:', err)
})
}
return {
pageInfo,
formRef,
form,
formOptions,
filterOption,
rules,
openModal,
closeModal,
submit,
}
}
```
## 四、模板(addOrEdit.vue
```vue
<template>
<a-modal
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
:keyboard="false"
:mask-closable="false"
>
<!-- a-spin 包裹整个表单禁止用 :confirm-loading -->
<a-spin :spinning="pageInfo.spin">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ style: { width: '100px', minWidth: '100px' } }"
>
<a-form-item label="名称" name="name">
<a-input
v-model:value="form.name"
v-no-space
allow-clear
:maxlength="30"
placeholder="请输入名称"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-select
v-model:value="form.status"
:options="formOptions.status"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择状态"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-button @click="closeModal">取消</a-button>
<a-button
type="primary"
:disabled="pageInfo.spin"
:loading="pageInfo.spin"
@click="submit"
>确定</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
import { usePage } from './init/usePage'
const emit = defineEmits<{
/** 操作成功后通知父组件刷新列表 */
(e: 'load'): void
}>()
const {
pageInfo,
formRef,
form,
formOptions,
filterOption,
rules,
openModal,
closeModal,
submit,
} = usePage(emit)
defineExpose({ openModal })
</script>
<style scoped lang="less"></style>
```
## 五、强制要求
| 项 | 要求 |
|---|------|
| `id` 类型 | `string \| undefined` |
| 请求风格 | `.then().catch().finally()`,禁 `async/await` |
| 并发请求 | `openModal` 中用 `Promise.all` 并发拉取下拉与详情 |
| `submit()` | 开头立即 `pageInfo.spin = true` |
| `successRequest(msg)` | 必须接收 `res.msg` 作为参数 |
| emit 事件名 | `'load'` |
| `addRequest` | 必须 `delete params.id` |
| `formOptions` | 用 `ref<FormOptions>` 包裹 |
| `rules` | 在 `usePage.ts` 中定义 |
| 确定按钮 | `:disabled="pageInfo.spin" :loading="pageInfo.spin"` |
| `a-spin` | 包裹整个 `a-form`,禁 `:confirm-loading` |
| `a-modal` | 必须 `:keyboard="false" :mask-closable="false"` |
| `defineExpose` | 只暴露 `openModal` |
| 子组件 ref | 必须在父 `usePage.ts` 定义 |
| 打开方法命名 | `open + 组件名` |
## 六、禁止行为
- ❌ 禁止在 `.vue` 文件写业务逻辑
- ❌ 禁止用 `pageInfo.title / type` 文本做新增/编辑判断,必须用 `params.id`
- ❌ 禁止在 `openModal` 直接 `form.value = record`,必须 `Promise.all + infoRequest`
- ❌ 禁止将 `formOptions` 定义为 `reactive`,必须用 `ref<FormOptions>`
- ❌ 禁止在 Modal 内直接调父组件刷新方法,必须 `emit('load')`
- ❌ 禁止省略 `finally` 中的 `pageInfo.spin = false`
- ❌ 禁止 a-input-number 不设 `style="width: 100%"`
+92
View File
@@ -0,0 +1,92 @@
# Tabs 页面规范
> 对齐 platform-vue-tenant 的 `docs/tabs-page-spec.md`。
## 一、目录结构
```
xxxPage/
├── xxxPage.vue # 主页面(仅 a-tabs 容器,无业务逻辑)
└── component/
└── tabs/
├── tabA/
│ ├── tabA.vue
│ ├── init/
│ │ ├── usePage.ts
│ │ ├── useTable.ts
│ │ └── useSearch.ts
│ ├── api/index.ts
│ ├── types/index.ts
│ └── component/ # 各 Tab 独立的 modal / drawer
└── tabB/
└── ...
```
## 二、主页面
```vue
<template>
<a-card class="listPageCard" :bordered="false">
<a-tabs class="aTabs">
<a-tab-pane key="tabA" tab="Tab A 标题">
<TabA />
</a-tab-pane>
<a-tab-pane key="tabB" tab="Tab B 标题">
<TabB />
</a-tab-pane>
</a-tabs>
</a-card>
</template>
<script setup lang="ts">
import TabA from './component/tabs/tabA/tabA.vue'
import TabB from './component/tabs/tabB/tabB.vue'
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.aTabs {
width: 100%;
height: 100%;
:deep(.ant-tabs-tab) {
padding-top: 0;
}
:deep(.ant-tabs-content) {
width: 100%;
height: 100%;
}
:deep(.ant-tabs-tabpane) {
height: 100%;
display: flex;
flex-direction: column;
}
}
</style>
```
## 三、Tab 子组件
每个 Tab 完全独立,结构与主列表页一致(见 `list-page-spec.md`)。
## 四、强制要求
- 主页面仅 a-tabs 容器,禁写任何业务逻辑
- 各 Tab 独立 6 文件结构,禁混合 `usePage.ts`
- `.aTabs` 样式必须包含:
- `width: 100%; height: 100%`
- `:deep(.ant-tabs-content)` 宽高 100%
- `:deep(.ant-tabs-tabpane)` flex 列布局
## 五、禁止行为
- ❌ 禁止主页面(`xxxPage.vue`)写业务逻辑
- ❌ 禁止多 Tab 逻辑混写一个 `usePage.ts`
- ❌ 禁止 Tab 子组件直接放主页面目录
- ❌ 禁止省略 `.aTabs` 高度样式
- ❌ 禁止 Tab `useSearch.ts``Object.keys` 清空,必须 `Object.assign(search, createSearchKey())`
- ❌ 禁止 `useTable.ts` 直接传 `pagination` 给工具,必须传完整 `TableState`
- ❌ 禁止 `listRequest` 散写参数转换,统一 `buildParams()`
+74
View File
@@ -0,0 +1,74 @@
# 弹窗静态方法
**路径**`@utils/antDesign/popUp`
## 用法
```typescript
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
const { message, Modal, notification } = useAntdStaticMethods()
```
## message
```typescript
message.success('操作成功')
message.error('操作失败')
message.warning('请先选择数据')
const hide = message.loading('提交中...')
hide()
```
## Modal.confirm
```typescript
Modal.confirm({
title: '确认删除?',
content: '删除后不可恢复,请谨慎操作',
okText: '确认',
cancelText: '取消',
okType: 'danger',
onOk: () => {
remove(id)
.then((res: { code: string; msg: string }) => {
if (res.code === '00000') {
void message.success(res.msg)
listRequest()
}
})
.catch((err: unknown) => console.error('删除失败:', err))
},
})
```
## Modal.warning / Modal.error
```typescript
Modal.warning({ title: '提示', content: '当前操作不可撤销' })
Modal.error({ title: '错误', content: '服务异常,请稍后重试' })
```
## notification
```typescript
notification.success({ message: '导出成功', description: '文件已开始下载' })
notification.error({ message: '导入失败', description: '请使用模板文件' })
```
## 初始化(main.ts 已挂载)
```typescript
import { message, Modal, notification } from 'ant-design-vue'
import { initializeAntdStaticMethods } from '@utils/antDesign/popUp'
initializeAntdStaticMethods(notification, Modal, message)
```
## 禁止行为
- ❌ 禁止直接 `import { message } from 'ant-design-vue'`,必须 `useAntdStaticMethods()`
- ❌ 禁止删除不经 `Modal.confirm` 二次确认
- ❌ 禁止 `Modal.confirm``onOk` 中省略错误处理
- ❌ 禁止业务代码重复调 `initializeAntdStaticMethods`
+111
View File
@@ -0,0 +1,111 @@
# 表格工具
**路径**`@utils/antDesign/table`
## 快速导入
```typescript
import {
type TableSort,
type TableState,
createPaginationConfig,
createDataSourceChange,
createResetTable,
resizeColumn,
createRowSelection,
} from '@utils/antDesign/table'
```
## 类型
```typescript
interface TableSort {
field: string
order: 'ascend' | 'descend' | null
}
interface TableState<T = unknown> {
columns: TableColumnsType
dataSource: T[]
sort: TableSort
pagination: TablePaginationConfig
}
```
## createPaginationConfig
```typescript
createPaginationConfig(total?: number): TablePaginationConfig
```
| 默认值 | 说明 |
|--------|------|
| `current = 1` | 第 1 页 |
| `pageSize = 30` | 每页 30 条 |
| `pageSizeOptions = ['30','50','100','300']` | 可选条数 |
| `showSizeChanger = true` | 显示页大小切换 |
| `showTotal = '共 N 条'` | 显示总条数 |
## createDataSourceChange
```typescript
createDataSourceChange(
table: TableState,
initSort: TableSort,
listRequest: () => void,
options?: { toggleSortOnSameField?: boolean },
): TableProps['onChange']
```
- 自动同步 `table.pagination``table.sort`
- 用户取消排序时还原 `initSort`
- 同字段点击切换升降序(`toggleSortOnSameField: true`,默认)
- 触发 `listRequest()`
## createResetTable
```typescript
createResetTable<T>(table: TableState<T>, createInitState: () => TableState<T>): () => void
```
- 还原分页(第 1 页 / 30 条 / total 清零)
- 还原排序到 initSort
- 清空 dataSource
- **不会**触发 listRequest(由调用方在 reset 后自行触发)
## resizeColumn
```typescript
resizeColumn(w: number, col: Record<string, unknown>): void
```
直接挂到 `<a-table @resize-column="resizeColumn">`
## createRowSelection
```typescript
createRowSelection<T>(options?: {
rowKey?: string
type?: 'checkbox' | 'radio'
fixed?: boolean
onChange?: (keys, rows) => void
onSelect?: (record, selected) => void
onSelectAll?: (selected, rows) => void
getCheckboxProps?: (record) => Record<string, unknown>
}): {
selectedRowKeys: Ref<(string | number)[]>
selectedRows: Ref<T[]>
rowSelection: ComputedRef<TableRowSelection>
clearSelection: () => void
}
```
挂到 `<a-table :row-selection="rowSelection">`
## 禁止行为
- ❌ 禁止 `.vue` 直接定义 `columns`,必须放 `useTable.ts`
- ❌ 禁止手动拼分页参数,必须 `createDataSourceChange`
- ❌ 禁止 `createPaginationConfig` 返回值再用 ref/reactive 包裹
- ❌ 禁止 `useTable.ts` 中发 HTTP,请求逻辑在 `usePage.ts`
- ❌ 禁止用 index 作为 `:key`,必须业务唯一 id
+12 -2
View File
@@ -157,10 +157,16 @@ const onSearch = (leaf: NavLeaf, section: NavSection) => {
<style scoped lang="less">
.portal-layout {
min-height: 100vh;
/*
* 高度固定 100vh + 外层 overflow hidden:对齐 vue 研发项目 <a-layout> 默认行为
* 任何内部内容超出时由子级(content / sider)内部滚动,整页不出现 body 滚动条
*/
height: 100vh;
overflow: hidden;
&__body {
height: calc(100vh - 56px);
/* 减去 TopBar 高度 64px(与 TopBar.vue 内 .top-bar height 保持同步) */
height: calc(100vh - 64px);
}
&__sider {
@@ -173,10 +179,12 @@ const onSearch = (leaf: NavLeaf, section: NavSection) => {
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
// 灰底 + 三面留白:让 PageHistory 与下方内容都"浮"起来,与 TopBar 视觉解耦
background: var(--background);
padding: 6px 12px 12px;
gap: 6px;
overflow: hidden;
}
&__content {
@@ -185,11 +193,13 @@ const onSearch = (leaf: NavLeaf, section: NavSection) => {
border-radius: 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
&__content--full {
padding: 0;
background: var(--background);
overflow-y: auto;
}
}
</style>
+6 -5
View File
@@ -177,7 +177,8 @@ const getPopupContainer = (trigger: HTMLElement) => trigger.parentElement ?? doc
display: flex;
flex-wrap: nowrap;
align-items: center;
height: 56px;
/* 高度 64px:对齐 vue 研发项目 <a-layout-header> 默认高度,所有项目保持一致 */
height: 64px;
padding: 0;
background: #fff;
color: #1a1a2e;
@@ -235,20 +236,20 @@ const getPopupContainer = (trigger: HTMLElement) => trigger.parentElement ?? doc
.top-bar__menu-inner {
background: #fff;
border-bottom: none;
line-height: 56px;
line-height: 64px;
}
:deep(.top-bar__menu-inner.ant-menu-light) {
background: #fff !important;
border-bottom: none !important;
line-height: 56px;
line-height: 64px;
}
:deep(.top-bar__menu-inner .ant-menu-item),
:deep(.top-bar__menu-inner .ant-menu-submenu-title) {
top: 0;
height: 56px;
line-height: 56px;
height: 64px;
line-height: 64px;
color: #595959;
}
+16
View File
@@ -5,6 +5,9 @@
* - 引入 AntDV reset 样式 + 全局令牌
* - 组件库通过 unplugin-vue-components 按需自动注册(见 vite.config.ts
* - antd icons-vue 全量注册到 app.component,让 nav.ts 中字符串名能动态渲染
* - 注册自定义指令(v-no-space / v-only-number / v-only-alphanumeric / v-only-alphanumeric-special
* - 初始化 antd 静态方法 useAntdStaticMethods()
* - 引入 mock handlers,触发 registerMock() 副作用
*/
import { createApp } from 'vue'
import App from './App.vue'
@@ -12,6 +15,13 @@ import router from '@router/index'
import 'ant-design-vue/dist/reset.css'
import './styles/global.less'
import * as Icons from '@ant-design/icons-vue'
import { message, Modal, notification } from 'ant-design-vue'
import { registerDirectives } from '@/directives'
import { initializeAntdStaticMethods } from '@utils/antDesign/popUp'
// 触发 mock handler 注册副作用(具体 handler 在 src/axios/handlers/ 各子目录)
import '@/axios/handlers'
const app = createApp(App)
@@ -22,5 +32,11 @@ for (const [name, comp] of Object.entries(Icons)) {
}
}
// 注册自定义指令
registerDirectives(app)
// 初始化 antd 静态方法(业务侧通过 useAntdStaticMethods() 调用)
initializeAntdStaticMethods(notification, Modal, message)
app.use(router)
app.mount('#app')
@@ -1,34 +1,29 @@
/**
* 员工营养数据 - 接口契约层(原型阶段)
* - 原型阶段不实际调用后端,mock 数据直接在 init/useTable.ts 中写死
* - 本文件保留接口签名,作为「未来对接后端时」的契约说明
* - 后端 / 测试 / 前端工程开发可从本文件读到「需要哪些接口、参数和返回结构」
* - 工程化迁移时,把签名实现替换为真实 HTTP 调用即可
*/
import type {
EmployeeListParams,
EmployeeListResponse,
} from '../types'
/**
* 员工营养数据 - 分页查询
* @接口路径 POST /nutrition/employee/page
* @请求参数 EmployeeListParams(搜索条件 + 分页)
* @响应 EmployeeListResponserecords + total
* 员工营养数据 - 接口
*
* 原型阶段说明:实际数据见 init/useTable.ts 的 dataList
* 业务定位:只读查询页
* - 仅需 list / info / stats 三个接口
* - 无 add / edit / deleteCRUD 不是列表页标配)
*
* 对齐 vue 研发项目规范:
* - 接口签名与生产代码 1:1 一致
* - 不写业务判断逻辑(如 if res.code === '00000'
* - 原型阶段所有请求走 src/axios → mockBus → handlers/nutrition/employee.ts
* - 研发接手时仅需删除 mock handler,业务代码零改动
*/
export const queryEmployeePage = (_params: EmployeeListParams): Promise<EmployeeListResponse> => {
return Promise.reject(new Error('[原型阶段] 接口未实现,数据来自 init/useTable.ts'))
}
/**
* 员工营养数据 - 导出列表
* @接口路径 POST /nutrition/employee/export
* @请求参数 EmployeeListParams(不含分页字段)
* @响应 Blobxlsx 二进制流)
*/
export const exportEmployeeList = (_params: EmployeeListParams): Promise<Blob> => {
return Promise.reject(new Error('[原型阶段] 接口未实现'))
}
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { ListItem, ListParams, NutritionStats } from '../types'
/** 列表分页查询 */
export const list = (params: ListParams): Promise<ApiResponse<ListItem[]>> =>
postRequest('axiosRequest', '/nutrition/employee/page', params)
/** 详情 */
export const info = (id: string): Promise<ApiResponse<ListItem>> =>
postRequest('axiosRequest', '/nutrition/employee/info', { id })
/** 统计卡数据 */
export const stats = (): Promise<ApiResponse<NutritionStats>> =>
postRequest('axiosRequest', '/nutrition/employee/stats', {})
@@ -2,92 +2,125 @@
<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 :span="6" v-for="s in statCards" :key="s.label">
<StatCard :color="s.color" :value="s.value" :label="s.label">
<template #icon>
<component :is="s.icon" />
</template>
</StatCard>
</a-col>
</a-row>
<!-- 搜索 -->
<FilterBar
v-model="searchForm"
:fields="filterFields"
@search="handleSearch"
@reset="handleReset"
/>
<!-- 搜索FilterBar default slot写裸 a-form-item -->
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
<a-form-item label="姓名/工号">
<a-input
v-model:value="search.keyword"
v-no-space
allow-clear
placeholder="请输入姓名或工号"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="所属单位">
<a-select
v-model:value="search.unit"
:options="options.unit"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择单位"
style="width: 180px"
/>
</a-form-item>
<a-form-item label="所属部门">
<a-select
v-model:value="search.dept"
:options="options.dept"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择部门"
style="width: 160px"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="search.status"
:options="options.status"
:filter-option="(input: string, opt: any) => filterOption(input, opt, 'label')"
show-search
allow-clear
placeholder="请选择状态"
style="width: 140px"
/>
</a-form-item>
</FilterBar>
<!-- 表格无标题左上方放主操作按钮 -->
<!-- 表格区(只读查询:无 toolbar 操作按钮) -->
<TableCard
:columns="columns"
:data-source="dataList"
:pagination="pagination"
:table="table"
:loading="pageLoading"
row-key="id"
@change="dataSourceChange"
>
<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 v-if="column.dataIndex === 'action'">
<!-- 操作列 link 按钮统一不加图标(对齐 vue 研发项目规范) -->
<a-button type="link" size="small" @click="openInfoDrawer(record)">查看详情</a-button>
</template>
</template>
</TableCard>
<!-- 详情抽屉 -->
<InfoDrawer ref="infoDrawerRef" />
</div>
</template>
<script setup lang="ts">
/**
* 员工营养数据 - 列表页(薄模板层)
* - 本文件只负责"渲染",所有逻辑由 init/ 三件套提供
* - 这是「标准列表页样板」,未来 new-page 脚手架按此结构复制
*
* 新规范要点:
* - 不渲染 PageHeader / 不显示页面标题(菜单已经表达了所在位置)
* - TableCard 不显示标题;主操作按钮通过 #toolbar slot 放在卡片左上方
* - 所有按钮均带统一 antd icon(导出 DownloadOutlined / 查看任务 UnorderedListOutlined
* 业务定位:只读查询页
* - 仅支持搜索、排序、分页、查看详情
* - 无新增/编辑/删除/导出按钮
*
* 对齐 vue 研发项目规范:
* - 不渲染 PageHeader(菜单/历史栏已表达所在位置)
* - FilterBar 用 default slot 写裸 a-form-item
* - TableCard 接 :table 对象(TableState
* - 操作列 <a-button type="link"> 不加任何图标
* - 所有业务逻辑通过 usePage() 暴露
*/
import { DownloadOutlined, UnorderedListOutlined } from '@ant-design/icons-vue'
import { filterOption } from '@utils/antDesign/select'
import InfoDrawer from './component/drawer/info/info.vue'
import { usePage } from './init/usePage'
import { useSearch } from './init/useSearch'
import { useTable } from './init/useTable'
/** 页面级:统计卡、详情跳转、导出 */
const {
stats,
viewDetail,
handleExport,
viewExportTasks,
infoDrawerRef,
pageLoading,
search,
options,
table,
statCards,
dataSourceChange,
searchQuery,
resetQuery,
openInfoDrawer,
} = usePage()
/** 搜索:表单、字段配置、查询/重置 */
const {
searchForm,
filterFields,
handleSearch,
handleReset,
} = useSearch()
/** 表格:列、数据、分页 */
const { columns, dataList, pagination } = useTable()
</script>
<style scoped lang="less">
@import "@assets/styles/listPage.less";
.arc-employee {
display: flex;
flex-direction: column;
height: 100%;
&__stats {
margin-bottom: 16px;
margin-bottom: 12px;
}
}
</style>
@@ -0,0 +1,11 @@
/**
* 员工营养 - 详情抽屉 - 接口层
*/
import type { ApiResponse } from '@axios'
import { postRequest } from '@axios'
import type { DetailInfo } from '../types'
/** 详情查询 */
export const getInfo = (id: string): Promise<ApiResponse<DetailInfo>> =>
postRequest('axiosRequest', '/nutrition/employee/info', { id })
@@ -0,0 +1,61 @@
<template>
<a-drawer
v-model:open="pageInfo.visible"
:title="pageInfo.title"
:width="pageInfo.width"
destroy-on-close
@close="closeDrawer"
>
<a-spin :spinning="pageInfo.spin">
<template v-if="!detail">
<a-empty description="暂无数据" />
</template>
<template v-else>
<a-descriptions :column="2" bordered size="middle">
<a-descriptions-item label="姓名">{{ detail.name ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="员工工号">{{ detail.empNo ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="性别">{{ detail.gender ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="年龄">{{ detail.age ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属单位">{{ detail.unit ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="所属部门">{{ detail.dept ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="用餐天数">{{ detail.days ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="用餐次数">{{ detail.meals ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="热量均值(kcal">{{ detail.calorie ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="蛋白质均值(g">{{ detail.protein ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="脂肪均值(g">{{ detail.fat ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="碳水均值(g">{{ detail.carb ?? '—' }}</a-descriptions-item>
<a-descriptions-item label="营养达标率" :span="2">
<span :style="{ color: detail.rate >= 85 ? '#52c41a' : detail.rate >= 70 ? '#faad14' : '#f5222d', fontWeight: 600 }">
{{ detail.rate }}%
</span>
</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ detail.createTime ?? '—' }}</a-descriptions-item>
</a-descriptions>
</template>
</a-spin>
<template #footer>
<a-button @click="closeDrawer">关闭</a-button>
</template>
</a-drawer>
</template>
<script setup lang="ts">
/**
* 员工营养 - 详情抽屉
*
* 对齐 vue 研发项目 drawer-spec.md(详情型):
* - a-drawer 加 destroy-on-close
* - a-spin 包裹内容
* - defineExpose 只暴露 openDrawer
* - 空值统一用 ?? '—' 兜底
*/
import { usePage } from './init/usePage'
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
defineExpose({ openDrawer })
</script>
<style scoped lang="less"></style>
@@ -0,0 +1,51 @@
/**
* 员工营养 - 详情抽屉 - 主逻辑
*
* 对齐 vue 研发项目 drawer-spec.md(详情型):
* - 仅暴露 openDrawer / closeDrawer
* - pageInfo.spin 管理 loading
* - 接口 .then().catch().finally() 链式
*/
import { getInfo } from '../api'
import type { DetailInfo, PageInfo } from '../types'
export const usePage = () => {
const pageInfo = reactive<PageInfo>({
visible: false,
title: '员工营养详情',
spin: false,
width: 720,
})
const detail = ref<DetailInfo | null>(null)
/** 拉取详情 */
const loadData = (id: string): void => {
pageInfo.spin = true
getInfo(id)
.then((res) => {
if (res.code === '00000') detail.value = res.data
})
.catch((err: unknown) => {
console.error('获取员工营养详情失败:', err)
})
.finally(() => {
pageInfo.spin = false
})
}
/** 父组件调用入口 */
const openDrawer = (record: { id: string; name?: string }): void => {
detail.value = null
pageInfo.visible = true
pageInfo.title = record.name ? `${record.name} - 营养详情` : '员工营养详情'
loadData(record.id)
}
const closeDrawer = (): void => {
pageInfo.visible = false
}
return { pageInfo, detail, openDrawer, closeDrawer }
}
@@ -0,0 +1,30 @@
/**
* 员工营养 - 详情抽屉 - 类型
*/
export interface PageInfo {
visible: boolean
title: string
spin: boolean
width: number
}
/** 详情数据(与列表行完全一致) */
export interface DetailInfo {
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
}
@@ -1,44 +1,133 @@
/**
* 员工营养数据 - 页面主逻辑
* - 页面级 state:统计卡数据
* - 页面级行为:详情跳转、导出、查看导出任务
* - 不耦合搜索 / 表格内部细节,与 useSearch / useTable 平级并列
*
* 新规范:列表页不再显示 PageHeader 标题;菜单/历史栏已表达所在位置。
* 业务定位:**只读查询页**
* - 仅支持搜索 / 排序 / 分页 / 查看详情
* - 无新增、编辑、删除、导出按钮(CRUD 不是列表页标配)
*
* 对齐 vue 研发项目规范:
* - 统一 buildParams() 构建请求参数(搜索 + 排序 + 分页)
* - 列表请求 .then().catch().finally() 链式
* - 子组件 ref 在 usePage.ts 定义,打开方法命名 open + 组件名
* - onMounted 通过 loadData() 统一触发
*/
import { message } from 'ant-design-vue'
import type { EmployeeNutritionRecord, NutritionStat } from '../types'
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
import { list, stats } from '../api'
import { useSearch } from './useSearch'
import { useTable } from './useTable'
import type { ListItem, NutritionStats } from '../types'
interface StatCardItem {
icon: string
color: 'blue' | 'green' | 'orange' | 'red'
value: string | number
label: string
}
export const usePage = () => {
const { message } = useAntdStaticMethods()
const { search, options, initOptions, resetSearch } = useSearch()
/** 顶部统计卡数据 */
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: '数据覆盖率' },
]
const pageLoading = ref<boolean>(false)
const statsData = ref<NutritionStats | null>(null)
/** 查看员工详情 - 工程化时跳转 detail 路由 */
const viewDetail = (record: EmployeeNutritionRecord) => {
message.info(`查看 ${record.name} 的详情(原型演示)`)
/** 详情抽屉 ref - 必须在 usePage.ts 中定义 */
const infoDrawerRef = ref<{ openDrawer: (record: { id: string; name?: string }) => void } | null>(null)
/** 构造请求参数 */
const buildParams = () => ({
...search,
pageNum: table.pagination.current ?? 1,
pageSize: table.pagination.pageSize ?? 30,
order: table.sort.order,
column: table.sort.field,
})
/** 列表请求 */
const listRequest = (): void => {
pageLoading.value = true
list(buildParams())
.then((res) => {
if (res.code === '00000') {
table.dataSource = res.data ?? []
table.pagination.total = res.total ?? 0
}
})
.catch((err: unknown) => {
console.error('员工营养列表请求失败:', err)
})
.finally(() => {
pageLoading.value = false
})
}
/** 导出列表 - 工程化时调用 exportEmployeeList */
const handleExport = () => {
message.success('已触发导出任务(原型演示)')
/** 统计卡数据请求 */
const statsRequest = (): void => {
stats()
.then((res) => {
if (res.code === '00000') statsData.value = res.data
})
.catch((err: unknown) => {
console.error('统计卡数据请求失败:', err)
})
}
/** 查看导出任务列表 */
const viewExportTasks = () => {
message.info('查看导出任务列表(原型演示)')
/** 顶部统计卡(基于 statsData 动态生成) */
const statCards = computed<StatCardItem[]>(() => [
{ icon: 'UserOutlined', color: 'blue', value: statsData.value?.total ?? '—', label: '监控员工数' },
{ icon: 'CalendarOutlined', color: 'green', value: statsData.value?.days ?? '—', label: '本年已监控天数' },
{ icon: 'BarChartOutlined', color: 'orange', value: statsData.value?.rate ?? '—', label: '营养达标率' },
{ icon: 'DatabaseOutlined', color: 'green', value: statsData.value?.coverage ?? '—', label: '数据覆盖率' },
])
/** useTable 必须在 listRequest 定义之后调用 */
const { table, dataSourceChange, resetTable, resizeColumn } = useTable(listRequest)
/** 查询:回第一页 */
const searchQuery = (): void => {
table.pagination.current = 1
listRequest()
}
/** 重置:先 resetSearch → resetTable → listRequest */
const resetQuery = (): void => {
resetSearch()
resetTable()
listRequest()
}
/** 打开详情抽屉 */
const openInfoDrawer = (record: ListItem): void => {
infoDrawerRef.value?.openDrawer({ id: record.id, name: record.name })
}
/** 统一数据加载入口 */
const loadData = (): void => {
initOptions()
statsRequest()
listRequest()
}
// 保留 message 引用以便后续扩展(如批量、刷新提示)
void message
onMounted(() => {
loadData()
})
return {
stats,
viewDetail,
handleExport,
viewExportTasks,
infoDrawerRef,
pageLoading,
search,
options,
table,
statCards,
dataSourceChange,
resizeColumn,
searchQuery,
resetQuery,
openInfoDrawer,
listRequest,
}
}
@@ -1,68 +1,81 @@
/**
* 员工营养数据 - 搜索逻辑
* - 暴露搜索表单、下拉选项、字段配置、查询/重置方法
* - 与 FilterBar 组件配合:fields 数组驱动渲染
*
* 对齐 vue 研发项目规范:
* - reactive + 工厂函数 createSearchKey()
* - resetSearch 用 Object.assign 重置(保持响应式引用)
* - 下拉选项用 createOptions 工厂函数
* - initOptions 用 Promise.all 并行
*/
import { message } from 'ant-design-vue'
import type { FilterField } from '@components/FilterBar.vue'
import type { EmployeeSearchForm } from '../types'
import type { SearchForm } from '../types'
/** 创建搜索初始值(工厂函数) */
const createSearchKey = (): SearchForm => ({
keyword: undefined,
unit: undefined,
dept: undefined,
status: undefined,
})
/** 创建下拉选项初始值(工厂函数) */
const createOptions = () => ({
unit: [] as { label: string; value: string }[],
dept: [] as { label: string; value: string }[],
status: [] as { label: string; value: number }[],
})
/** 单位静态数据(实际可走 dictItems / departTree */
const UNIT_LIST = [
{ label: 'CQ能源总部', value: 'CQ能源总部' },
{ label: '采油一厂', value: '采油一厂' },
{ label: '采油二厂', value: '采油二厂' },
{ label: '炼化分公司', value: '炼化分公司' },
]
const DEPT_LIST = [
{ label: '研发部', value: '研发部' },
{ label: '市场部', value: '市场部' },
{ label: '运营部', value: '运营部' },
{ label: '财务部', value: '财务部' },
{ label: '人事部', value: '人事部' },
{ label: '生产部', value: '生产部' },
{ label: '安全环保部', value: '安全环保部' },
]
const STATUS_LIST = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
]
export const useSearch = () => {
const search = reactive<SearchForm>(createSearchKey())
const options = reactive(createOptions())
/** 搜索表单初始值 */
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)}`)
/** 初始化下拉选项 - 用 Promise.all 并行加载 */
const initOptions = (): void => {
// 原型阶段直接同步赋值;实际研发可替换为接口调用:
// Promise.all([dictItems('unit'), dictItems('dept'), dictItems('status')])
// .then(([u, d, s]) => { ... })
Promise.all([
Promise.resolve(UNIT_LIST),
Promise.resolve(DEPT_LIST),
Promise.resolve(STATUS_LIST),
])
.then(([unitList, deptList, statusList]) => {
options.unit = unitList
options.dept = deptList
options.status = statusList
})
.catch((err: unknown) => {
console.error('初始化下拉选项失败:', err)
})
}
/** 重置 - FilterBar 内部已清空字段,这里仅做副作用 */
const handleReset = () => {
searchForm.value = initialForm()
message.info('已重置筛选条件')
/** 重置搜索条件(保持响应式引用) */
const resetSearch = (): void => {
Object.assign(search, createSearchKey())
}
return {
searchForm,
filterFields,
unitOptions, deptOptions, yearOptions,
handleSearch, handleReset,
}
return { search, options, initOptions, resetSearch }
}
@@ -1,53 +1,115 @@
/**
* 员工营养数据 - 表格逻辑
* - columns:与正式工程 a-table 列定义完全兼容(dataIndex/key/width/fixed
* - dataList:原型阶段 mock 数据,工程化时替换为 await queryEmployeePage()
* - pagination:标准 a-table pagination 配置
*
* 对齐 vue 研发项目规范:
* - 使用 TableState 统一对象(含 columns / dataSource / sort / pagination
* - 列定义在文件顶部,customRender 用 h() 函数
* - createInitState 工厂函数 + createResetTable 还原全部状态
* - INIT_SORT 默认按 createTime 降序
*/
import { h } from 'vue'
import type { TableColumnsType } from 'ant-design-vue'
import type { EmployeeNutritionRecord } from '../types'
import {
type TableSort,
type TableState,
createDataSourceChange,
createPaginationConfig,
createResetTable,
resizeColumn,
} from '@utils/antDesign/table'
import type { ListItem } from '../types'
export const useTable = () => {
/** 默认排序:创建时间倒序 */
const INIT_SORT: TableSort = {
field: 'createTime',
order: 'descend',
}
/** 表格列配置(标题、字段、宽度、列冻结) */
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' },
]
/** 表格列定义 */
const tableColumns: TableColumnsType = [
{
title: '序号',
dataIndex: 'index',
align: 'center',
fixed: 'left',
width: 70,
customRender: ({ index }: { index: number }) => index + 1,
},
{
title: '姓名',
dataIndex: 'name',
align: 'center',
fixed: 'left',
width: 90,
resizable: true,
ellipsis: true,
},
{ title: '性别', dataIndex: 'gender', align: 'center', width: 70 },
{ title: '年龄', dataIndex: 'age', align: 'center', width: 70 },
{ title: '员工工号', dataIndex: 'empNo', align: 'center', width: 120, resizable: true },
{ title: '所属单位', dataIndex: 'unit', align: 'center', width: 140, resizable: true, ellipsis: true },
{ title: '所属部门', dataIndex: 'dept', align: 'center', width: 110, resizable: true, ellipsis: true },
{ title: '用餐天数', dataIndex: 'days', align: 'center', width: 100 },
{ title: '用餐次数', dataIndex: 'meals', align: 'center', width: 100 },
{ title: '热量均值(kcal)', dataIndex: 'calorie', align: 'center', width: 130 },
{ title: '蛋白质均值(g)', dataIndex: 'protein', align: 'center', width: 130 },
{ title: '脂肪均值(g)', dataIndex: 'fat', align: 'center', width: 120 },
{ title: '碳水均值(g)', dataIndex: 'carb', align: 'center', width: 120 },
{
title: '营养达标率',
dataIndex: 'rate',
align: 'center',
width: 110,
customRender: ({ text }: { text: number }) => {
const color = text >= 85 ? '#52c41a' : text >= 70 ? '#faad14' : '#f5222d'
return h('span', { style: { color, fontWeight: 600 } }, `${text}%`)
},
},
{
title: '状态',
dataIndex: 'status',
align: 'center',
width: 80,
customRender: ({ text }: { text: 0 | 1 }) => {
const color = text === 1 ? '#52c41a' : '#bfbfbf'
const label = text === 1 ? '启用' : '禁用'
return h('span', { style: { color } }, label)
},
},
{
title: '创建时间',
dataIndex: 'createTime',
align: 'center',
width: 180,
sorter: true,
resizable: true,
},
{
title: '操作',
dataIndex: 'action',
align: 'center',
fixed: 'right',
width: 100,
},
]
/**
* 表格数据源(原型 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}`,
/**
* 员工营养表格 Hook
* @param listRequest 数据刷新回调
*/
export const useTable = (listRequest: () => void) => {
const createInitState = (): TableState<ListItem> => ({
columns: tableColumns,
dataSource: [],
sort: { ...INIT_SORT },
pagination: createPaginationConfig(),
})
return { columns, dataList, pagination }
const table = reactive(createInitState()) as TableState<ListItem>
const resetTable = createResetTable(table, createInitState)
const dataSourceChange = createDataSourceChange(table, INIT_SORT, listRequest)
return { table, dataSourceChange, resetTable, resizeColumn }
}
@@ -1,14 +1,15 @@
/**
* 员工营养数据 - 类型集中定义
* - 所有 Interface / Type 集中在此,禁止散落到各 use*.ts
* - 后端 DTO 字段如有变化,只改这一处
*
* 对齐 vue 研发项目规范:
* - id 统一 string 类型
* - SearchForm 字段都用 optional
* - 参数类型 ListParams 显式继承 SearchForm
*/
import type { StatColor } from '@components/StatCard.vue'
/** 员工营养监控行数据(对应后端 EmployeeNutritionDTO */
export interface EmployeeNutritionRecord {
/** 主键,对应 vue-table row-key */
/** 列表行数据(与后端 EmployeeNutritionDTO 字段对齐) */
export interface ListItem {
/** 主键,统一 string 类型 */
id: string
/** 姓名 */
name: string
@@ -18,9 +19,9 @@ export interface EmployeeNutritionRecord {
age: number
/** 员工工号 */
empNo: string
/** 所属单位(显示名称) */
/** 所属单位 */
unit: string
/** 所属部门(显示名称) */
/** 所属部门 */
dept: string
/** 用餐天数 */
days: number
@@ -36,40 +37,40 @@ export interface EmployeeNutritionRecord {
carb: number
/** 营养达标率(0-100 */
rate: number
/** 状态:1 启用 / 0 禁用 */
status: 0 | 1
/** 创建时间 */
createTime: string
}
/** 搜索表单类型 */
export interface EmployeeSearchForm {
/** 搜索表单 */
export interface SearchForm {
/** 姓名 / 工号关键词 */
keyword?: string
/** 所属单位value */
/** 所属单位 */
unit?: string
/** 所属部门value */
/** 所属部门 */
dept?: string
/** 年份 */
year?: number
/** 状态 */
status?: 0 | 1
}
/** 顶部统计卡数据 */
export interface NutritionStat {
icon: string
color: StatColor
value: string
label: string
}
/** 列表分页查询请求(与后端约定) */
export interface EmployeeListParams extends EmployeeSearchForm {
/** 当前页(从 1 开始) */
current: number
/** 每页条数 */
/** 列表分页请求参数 */
export interface ListParams extends SearchForm {
pageNum: number
pageSize: number
order?: 'ascend' | 'descend' | null
column?: string
}
/** 列表分页响应 */
export interface EmployeeListResponse {
/** 当前页数据 */
records: EmployeeNutritionRecord[]
/** 总条数 */
/** 统计卡数据 */
export interface NutritionStats {
/** 监控员工数 */
total: number
/** 本年已监控天数 */
days: number
/** 营养达标率 */
rate: string
/** 数据覆盖率 */
coverage: string
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Ant Design Vue 静态弹窗方法
*
* 设计目的:与 platform-vue-tenant 的 @utils/antDesign/popUp 1:1 兼容。
* 业务侧统一通过 useAntdStaticMethods() 获取 message / Modal / notification
* 避免 setup 外使用时的上下文丢失问题。
*
* 对齐参考:platform-vue-tenant/docs/utils-popup.md
*
* 用法:
* import { useAntdStaticMethods } from '@utils/antDesign/popUp'
* const { message, Modal, notification } = useAntdStaticMethods()
* message.success('操作成功')
* Modal.confirm({ title: '确认删除?', okType: 'danger', onOk: () => {...} })
* notification.success({ message: '导出成功', description: '请稍候' })
*
* 初始化(main.ts 中调用一次,已自动挂载):
* import { message, Modal, notification } from 'ant-design-vue'
* initializeAntdStaticMethods(notification, Modal, message)
*/
import { Modal as AModal, message as aMessage, notification as aNotification } from 'ant-design-vue'
type MessageApi = typeof aMessage
type ModalApi = typeof AModal
type NotificationApi = typeof aNotification
/** 内部容器:保存已初始化的静态方法引用 */
const container = {
message: aMessage as MessageApi,
Modal: AModal as ModalApi,
notification: aNotification as NotificationApi,
}
/**
* 初始化静态方法(在 main.ts 应用挂载后调用一次)
* - 原型阶段直接复用 ant-design-vue 的默认导出,仍保留此入口以贴合 vue 项目的写法
* @param notification ant-design-vue 的 notification
* @param Modal ant-design-vue 的 Modal
* @param message ant-design-vue 的 message
*/
export const initializeAntdStaticMethods = (
notification: NotificationApi,
Modal: ModalApi,
message: MessageApi,
): void => {
container.notification = notification
container.Modal = Modal
container.message = message
}
/**
* 在 setup / hooks 内统一获取 antd 静态方法
* @returns { message, Modal, notification }
*/
export const useAntdStaticMethods = (): {
message: MessageApi
Modal: ModalApi
notification: NotificationApi
} => ({
message: container.message,
Modal: container.Modal,
notification: container.notification,
})
+32
View File
@@ -0,0 +1,32 @@
/**
* Ant Design Vue 下拉框工具
*
* 设计目的:与 platform-vue-tenant 的 @utils/antDesign/select.filterOption 一致。
* 用于 <a-select show-search :filter-option="filterOption"> 的搜索过滤。
*
* 用法:
* import { filterOption } from '@utils/antDesign/select'
* <a-select show-search :filter-option="filterOption" :options="options" />
*
* 复杂场景(自定义匹配字段):
* :filter-option="(inputValue, option) => filterOption(inputValue, option, 'label')"
*/
/**
* a-select 的搜索过滤函数
* - 不区分大小写
* - 默认匹配 option.label,可指定其他字段
* @param inputValue 用户输入的关键词
* @param option 候选项(来自 :options 或 v-for 渲染的子项)
* @param field 参与匹配的字段名,默认 'label'
*/
export const filterOption = (
inputValue: string,
option: Record<string, unknown>,
field: string = 'label',
): boolean => {
if (!inputValue) return true
const target = option?.[field]
if (target === undefined || target === null) return false
return String(target).toLowerCase().includes(inputValue.toLowerCase())
}
+247
View File
@@ -0,0 +1,247 @@
/**
* Ant Design Vue 表格工具集
*
* 设计目的:与 platform-vue-tenant 项目的 @utils/antDesign/table 1:1 兼容。
* 业务层(useTable / usePage)写法在原型与研发项目之间可直接复用。
*
* 对齐参考:platform-vue-tenant/docs/utils-table.md
*
* 导出:
* - createPaginationConfig 创建分页配置
* - createDataSourceChange 分页/排序变更回调(自动同步 table.sort 与 table.pagination
* - createResetTable 重置表格到初始状态(含排序)
* - resizeColumn 列宽拖拽回调
* - createRowSelection 行选择
*
* 类型:
* - TableSort 排序状态
* - TableState 表格状态统一对象(columns + dataSource + sort + pagination
*/
import type {
TableColumnsType,
TablePaginationConfig,
TableProps,
} from 'ant-design-vue'
import type { Ref, ComputedRef } from 'vue'
// ============================================================================
// 类型定义
// ============================================================================
/**
* 排序状态
* - field:参与排序的字段名(对应后端排序字段)
* - order:升序 / 降序 / 无(用户取消排序)
*/
export interface TableSort {
/** 排序字段名 */
field: string
/** 排序方向:升序 / 降序 / 取消 */
order: 'ascend' | 'descend' | null
}
/**
* 表格状态统一对象
* - 业务侧用 reactive 包裹后作为 useTable 的核心状态
* - 模板侧通过 table.columns / table.dataSource / table.pagination 访问
*
* 注意:columns 类型故意采用宽松 unknown[],避免 antd TableColumnsType 在 reactive
* 泛型上下文中触发 TS2589 递归过深错误。业务侧给 columns 赋值时仍可写
* `const tableColumns: TableColumnsType = [...]` 维持类型校验。
*/
export interface TableState<T = unknown> {
/** 表格列定义(运行时为 TableColumnsType */
columns: unknown[]
/** 表格数据源 */
dataSource: T[]
/** 排序状态 */
sort: TableSort
/** 分页配置 */
pagination: TablePaginationConfig
}
/**
* createDataSourceChange 的额外选项
*/
export interface DataSourceChangeOptions {
/** 同一字段点击时是否循环切换排序方向(默认 true) */
toggleSortOnSameField?: boolean
}
/**
* createRowSelection 的入参
*/
export interface RowSelectionOptions<T = unknown> {
/** 行唯一标识字段名(默认 'id' */
rowKey?: keyof T | string
/** 选择类型(默认 checkbox */
type?: 'checkbox' | 'radio'
/** 是否固定选择列(默认 true) */
fixed?: boolean
/** 选中项变更回调 */
onChange?: (keys: (string | number)[], rows: T[]) => void
/** 单行勾选回调 */
onSelect?: (record: T, selected: boolean) => void
/** 全选回调 */
onSelectAll?: (selected: boolean, rows: T[]) => void
/** 自定义行复选框属性 */
getCheckboxProps?: (record: T) => Record<string, unknown>
}
/**
* createRowSelection 的返回值
*/
export interface RowSelectionReturn<T = unknown> {
/** 已选行的 key 数组 */
selectedRowKeys: Ref<(string | number)[]>
/** 已选行的完整数据数组 */
selectedRows: Ref<T[]>
/** 挂到 <a-table :row-selection> 的配置对象 */
rowSelection: ComputedRef<Record<string, unknown>>
/** 清空已选项 */
clearSelection: () => void
}
// ============================================================================
// 工具函数
// ============================================================================
/**
* 创建分页配置
* - 默认第 1 页、每页 30 条、可选 [30, 50, 100, 300]
* - 默认显示总条数 "共 N 条"
* @param total 初始总条数,默认 0
*/
export const createPaginationConfig = (total: number = 0): TablePaginationConfig => ({
current: 1,
pageSize: 30,
total,
pageSizeOptions: ['30', '50', '100', '300'],
showSizeChanger: true,
showQuickJumper: false,
showTotal: (t: number) => `${t}`,
})
/**
* 创建分页 / 排序变更回调
* - 自动同步 table.pagination 与 table.sort
* - 同字段点击切换排序方向(toggleSortOnSameField: true 时)
* - 用户取消排序时还原为 initSort
* @param table 表格状态(reactive 后传入)
* @param initSort 初始排序,用户取消排序时还原
* @param listRequest 数据刷新回调
* @param options 额外选项
*/
export const createDataSourceChange = (
table: TableState,
initSort: TableSort,
listRequest: () => void,
options: DataSourceChangeOptions = {},
): TableProps['onChange'] => {
const { toggleSortOnSameField = true } = options
return (pagination, _filters, sorter) => {
// 1. 同步分页
table.pagination.current = pagination.current ?? 1
table.pagination.pageSize = pagination.pageSize ?? 30
// 2. 同步排序
const incoming = Array.isArray(sorter) ? sorter[0] : sorter
if (incoming && incoming.field && incoming.order) {
const field = String(incoming.field)
if (toggleSortOnSameField && table.sort.field === field) {
table.sort.order = incoming.order as 'ascend' | 'descend'
} else {
table.sort.field = field
table.sort.order = incoming.order as 'ascend' | 'descend'
}
} else {
// 用户取消排序,还原初始排序
table.sort.field = initSort.field
table.sort.order = initSort.order
}
// 3. 触发请求
listRequest()
}
}
/**
* 创建重置表格函数
* - 调用后回到第一页、清空数据、还原初始排序
* - 注意:本函数不会触发 listRequest,需由调用方在 reset 后自行触发
* @param table 表格状态(reactive 后传入)
* @param createInitState 初始状态工厂函数
*/
export const createResetTable = <T = unknown>(
table: TableState<T>,
createInitState: () => TableState<T>,
): (() => void) => {
return () => {
const init = createInitState()
table.columns = init.columns
table.dataSource = init.dataSource
table.sort.field = init.sort.field
table.sort.order = init.sort.order
table.pagination.current = init.pagination.current ?? 1
table.pagination.pageSize = init.pagination.pageSize ?? 30
table.pagination.total = init.pagination.total ?? 0
}
}
/**
* 列宽拖拽回调 - 直接挂到 <a-table @resize-column="resizeColumn">
* - antd 4.x 列拖拽时传入 (width, column),直接修改 column.width
* @param w 新宽度(像素)
* @param col 目标列对象
*/
export const resizeColumn = (w: number, col: Record<string, unknown>): void => {
col.width = w
}
/**
* 创建行选择
* @param options 行选择配置
*/
export const createRowSelection = <T extends Record<string, unknown> = Record<string, unknown>>(
options: RowSelectionOptions<T> = {},
): RowSelectionReturn<T> => {
const {
type = 'checkbox',
fixed = true,
onChange,
onSelect,
onSelectAll,
getCheckboxProps,
} = options
const selectedRowKeys = ref<(string | number)[]>([])
const selectedRows = ref<T[]>([]) as Ref<T[]>
const clearSelection = (): void => {
selectedRowKeys.value = []
selectedRows.value = []
}
const rowSelection = computed<Record<string, unknown>>(() => ({
type,
fixed,
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: (string | number)[], rows: T[]) => {
selectedRowKeys.value = keys
selectedRows.value = rows
onChange?.(keys, rows)
},
onSelect,
onSelectAll,
getCheckboxProps,
}))
return {
selectedRowKeys,
selectedRows,
rowSelection,
clearSelection,
}
}
+8 -1
View File
@@ -17,7 +17,14 @@
"@components/*": ["src/components/*"],
"@composables/*": ["src/composables/*"],
"@pages/*": ["src/pages/*"],
"@router/*": ["src/router/*"]
"@router/*": ["src/router/*"],
"@utils/*": ["src/utils/*"],
"@axios/*": ["src/axios/*"],
"@axios": ["src/axios/index.ts"],
"@assets/*": ["src/assets/*"],
"@directives/*": ["src/directives/*"],
"@api/*": ["src/api/*"],
"@api": ["src/api/index.ts"]
},
"types": ["node"]
}
+6
View File
@@ -27,6 +27,12 @@ export default defineConfig({
'@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)),
// 与 vue 研发项目对齐:业务直接使用 @utils @axios @assets @directives @api
'@utils': fileURLToPath(new URL('./src/utils', import.meta.url)),
'@axios': fileURLToPath(new URL('./src/axios', import.meta.url)),
'@assets': fileURLToPath(new URL('./src/assets', import.meta.url)),
'@directives': fileURLToPath(new URL('./src/directives', import.meta.url)),
'@api': fileURLToPath(new URL('./src/api', import.meta.url)),
},
},