feat: 对齐 vue 研发项目基础设施并落地列表页规范样板
- 新增 axios/utils/directives/api/assets 五大基础设施目录(与生产 vue 项目 1:1) - 重构 FilterBar / TableCard 支持 :table 对象与 default slot - 新增列表页/弹框/抽屉/详情页/Tabs 等代码规范文档 - arcEmployee 作为新规范标准列表页样板重构 - 清理 .claude/tmp 临时截图
This commit is contained in:
+152
-124
@@ -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 子应用对齐):
|
||||
## 项目背景
|
||||
|
||||
```
|
||||
Portal(admin-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 + types;api 视需要) |
|
||||
**types/index.ts**:
|
||||
- `SearchForm`(字段全 optional)
|
||||
- `ListItem`(id 统一 string 类型)
|
||||
- `ListParams`(继承 SearchForm + pageNum/pageSize/order/column)
|
||||
|
||||
### Step 3:填充内容(遵循新规范)
|
||||
**api/index.ts**:
|
||||
- import `postRequest from '@axios'`
|
||||
- 接口路径 `/<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 按钮才加 icon(PlusOutlined / 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 |
@@ -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: adminApps;tenant: tenantApps(含 nutrition / monitor 完整三层菜单)
|
||||
│ - status + audience 字段控制可见性
|
||||
│
|
||||
├── axios/ 🆕 假 axios 层(与 vue 研发项目签名 1:1,研发接手时整体替换)
|
||||
│ ├── index.ts getRequest / postRequest / putRequest / deleteRequest / restfulRequest / uploadRequest / downloadRequest
|
||||
│ ├── mockBus.ts mock 总线(registerMock / dispatchMock / 200~500ms 模拟延迟)
|
||||
│ ├── types.ts ApiResponse / ServiceType / MockHandler 等
|
||||
│ └── handlers/ mock handler 注册(按 <模块>/<页面>.ts 组织)
|
||||
│ ├── index.ts 统一 import 触发副作用
|
||||
│ └── nutrition/employee.ts
|
||||
│
|
||||
├── api/
|
||||
│ └── index.ts 🆕 跨模块公共接口(字典 / 部门树 / 组织树)
|
||||
│
|
||||
├── utils/ 🆕 业务工具(与 vue 研发项目 @utils 1:1)
|
||||
│ └── antDesign/
|
||||
│ ├── table.ts TableState / TableSort / createPaginationConfig / createDataSourceChange / createResetTable / resizeColumn / createRowSelection
|
||||
│ ├── popUp.ts useAntdStaticMethods / initializeAntdStaticMethods
|
||||
│ └── select.ts filterOption
|
||||
│
|
||||
├── directives/ 🆕 自定义指令(与 vue 研发项目 @directives 1:1)
|
||||
│ ├── index.ts registerDirectives 全局注册
|
||||
│ ├── noSpace.ts v-no-space
|
||||
│ ├── onlyNumber.ts v-only-number
|
||||
│ ├── onlyAlphanumeric.ts v-only-alphanumeric
|
||||
│ └── onlyAlphanumericSpecial.ts v-only-alphanumeric-special
|
||||
│
|
||||
├── assets/ 🆕 静态资源 + 公共样式
|
||||
│ └── styles/
|
||||
│ └── listPage.less 列表页统一布局(listPageForm / listPageRow / listPageTable / page-header / page-footer)
|
||||
│
|
||||
├── composables/ 复用逻辑
|
||||
│ └── useVisibility.ts 可见性核心:角色、过滤、覆写存取
|
||||
│
|
||||
@@ -128,8 +156,8 @@ yx-platform-prototype/
|
||||
│
|
||||
├── components/ 公共业务组件(被自动注册到 AntDV 风格)
|
||||
│ ├── StatCard.vue 统计卡(4 色配色)
|
||||
│ ├── FilterBar.vue 配置驱动搜索栏(5 种字段类型)
|
||||
│ ├── TableCard.vue 表格卡(无 title;#toolbar 左上、#extra 右上、a-table 全部透传)
|
||||
│ ├── FilterBar.vue 搜索栏(default slot 裸 a-form-item / 兼容 fields 配置驱动)
|
||||
│ ├── TableCard.vue 表格卡(接 :table 对象 TableState;#toolbar 左上、#extra 右上)
|
||||
│ ├── PageHeader.vue 页头(保留供详情页用,列表页不应使用)
|
||||
│ ├── PlaceholderPage.vue 后台占位页(接 title/name)
|
||||
│ └── AppPlaceholder.vue 移动端占位页(接 title/name)
|
||||
@@ -153,7 +181,7 @@ yx-platform-prototype/
|
||||
│ │ │ └── AppGrid.vue 🌟 租户应用网格首页(卡片入口)
|
||||
│ │ ├── nutrition/ 营养管理应用
|
||||
│ │ │ ├── arc/ 档案(一级菜单)
|
||||
│ │ │ │ ├── arcEmployee/ ⭐ 标准列表页样板(六文件)
|
||||
│ │ │ │ ├── arcEmployee/ ⭐ 新规范标准列表页样板(TableState + reactive 搜索 + mock handler + addOrEdit 弹框)
|
||||
│ │ │ │ └── ArcUnit.vue
|
||||
│ │ │ └── farm/ 绿色种采(一级菜单)
|
||||
│ │ │ └── FarmLand.vue
|
||||
@@ -169,8 +197,16 @@ yx-platform-prototype/
|
||||
│ ├── arc-employee.md
|
||||
│ └── screenshots/<页面>/<状态>.jpg 截图(≤60KB)
|
||||
│
|
||||
├── docs/ 业务文档
|
||||
│ └── business-architecture.md 🌟 业务架构(AI 必读)
|
||||
├── docs/ 业务文档 + 代码规范(AI 必读)
|
||||
│ ├── business-architecture.md 🌟 业务架构
|
||||
│ ├── cross-project-mapping.md 🆕 原型 ↔ vue 研发项目对照表
|
||||
│ ├── list-page-spec.md 🆕 列表页规范
|
||||
│ ├── modal-spec.md 🆕 弹框规范
|
||||
│ ├── drawer-spec.md 🆕 抽屉规范
|
||||
│ ├── detail-page-spec.md 🆕 详情页规范
|
||||
│ ├── tabs-page-spec.md 🆕 Tabs 页规范
|
||||
│ ├── utils-table.md 🆕 表格工具文档
|
||||
│ └── utils-popup.md 🆕 弹窗工具文档
|
||||
│
|
||||
└── types/ 全局类型声明(自动生成 + 手写 shims)
|
||||
├── auto-imports.d.ts unplugin-auto-import 生成
|
||||
@@ -182,15 +218,20 @@ yx-platform-prototype/
|
||||
|
||||
## 路径别名
|
||||
|
||||
| 别名 | 指向 |
|
||||
|------|------|
|
||||
| `@` | `src/` |
|
||||
| `@meta` | `src/meta/` |
|
||||
| `@layouts` | `src/layouts/` |
|
||||
| `@components` | `src/components/` |
|
||||
| `@composables` | `src/composables/` |
|
||||
| `@pages` | `src/pages/` |
|
||||
| `@router` | `src/router/` |
|
||||
| 别名 | 指向 | 与 vue 研发项目对齐 |
|
||||
|------|------|-------------------|
|
||||
| `@` | `src/` | ✅ |
|
||||
| `@meta` | `src/meta/` | 原型特有(菜单数据) |
|
||||
| `@layouts` | `src/layouts/` | ✅ |
|
||||
| `@components` | `src/components/` | ✅ |
|
||||
| `@composables` | `src/composables/` | 原型特有 |
|
||||
| `@pages` | `src/pages/` | 研发项目用 `@views` |
|
||||
| `@router` | `src/router/` | ✅ |
|
||||
| `@utils` | `src/utils/` | ✅ 1:1 |
|
||||
| `@axios` | `src/axios/` | ✅ 1:1 |
|
||||
| `@assets` | `src/assets/` | ✅ 1:1 |
|
||||
| `@directives` | `src/directives/` | ✅ 1:1 |
|
||||
| `@api` | `src/api/` | ✅ 1:1 |
|
||||
|
||||
---
|
||||
|
||||
@@ -212,25 +253,65 @@ yx-platform-prototype/
|
||||
3. **URL 规范**:`/<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)和 composables(useVisibility)
|
||||
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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* mock handler - 营养管理 / 员工营养数据
|
||||
*
|
||||
* 对应 api:src/pages/tenant-portal/nutrition/arc/arcEmployee/api/index.ts
|
||||
*
|
||||
* 业务侧调用形如:
|
||||
* postRequest('axiosRequest', '/nutrition/employee/page', params)
|
||||
* 经 mockBus 路由到本文件的 handler 返回 mock 数据。
|
||||
*/
|
||||
|
||||
import { registerMocks } from '@/axios/mockBus'
|
||||
import type { ApiResponse, MockContext } from '@/axios/types'
|
||||
|
||||
/** 员工营养行类型(与页面 types 中 ListItem 对齐) */
|
||||
interface EmployeeRow {
|
||||
id: string
|
||||
name: string
|
||||
gender: '男' | '女'
|
||||
age: number
|
||||
empNo: string
|
||||
unit: string
|
||||
dept: string
|
||||
days: number
|
||||
meals: number
|
||||
calorie: string
|
||||
protein: number
|
||||
fat: number
|
||||
carb: number
|
||||
rate: number
|
||||
status: 0 | 1
|
||||
createTime: string
|
||||
}
|
||||
|
||||
/** 全量数据集(≤ 8 条 - 遵循原型 mock 规范:每个列表 mock 不超过 8 条) */
|
||||
const buildDataset = (): EmployeeRow[] => [
|
||||
{ id: 'EMP0001', name: '张伟', gender: '男', age: 32, empNo: 'EMP0001', unit: 'CQ能源总部', dept: '研发部', days: 132, meals: 386, calorie: '2,156', protein: 78.3, fat: 72.1, carb: 285.4, rate: 92, status: 1, createTime: '2026-05-10 09:12:00' },
|
||||
{ id: 'EMP0002', name: '李娜', gender: '女', age: 28, empNo: 'EMP0002', unit: 'CQ能源总部', dept: '市场部', days: 128, meals: 372, calorie: '1,842', protein: 65.2, fat: 58.7, carb: 241.3, rate: 89, status: 1, createTime: '2026-05-09 08:30:00' },
|
||||
{ id: 'EMP0003', name: '王磊', gender: '男', age: 35, empNo: 'EMP0003', unit: '采油一厂', dept: '运营部', days: 125, meals: 358, calorie: '2,380', protein: 85.6, fat: 89.2, carb: 312.1, rate: 76, status: 1, createTime: '2026-05-08 17:45:00' },
|
||||
{ id: 'EMP0004', name: '赵敏', gender: '女', age: 30, empNo: 'EMP0004', unit: '采油二厂', dept: '财务部', days: 131, meals: 390, calorie: '1,720', protein: 58.4, fat: 52.3, carb: 228.6, rate: 95, status: 1, createTime: '2026-05-07 10:20:00' },
|
||||
{ id: 'EMP0005', name: '陈刚', gender: '男', age: 41, empNo: 'EMP0005', unit: 'CQ能源总部', dept: '人事部', days: 120, meals: 345, calorie: '2,050', protein: 72.1, fat: 68.4, carb: 270.2, rate: 78, status: 1, createTime: '2026-05-06 14:00:00' },
|
||||
{ id: 'EMP0006', name: '刘洋', gender: '男', age: 26, empNo: 'EMP0006', unit: '炼化分公司', dept: '生产部', days: 110, meals: 318, calorie: '2,210', protein: 80.5, fat: 74.6, carb: 295.0, rate: 85, status: 1, createTime: '2026-05-05 16:30:00' },
|
||||
{ id: 'EMP0007', name: '杨芳', gender: '女', age: 33, empNo: 'EMP0007', unit: '采油一厂', dept: '安全环保部', days: 130, meals: 380, calorie: '1,950', protein: 70.2, fat: 63.5, carb: 258.3, rate: 91, status: 1, createTime: '2026-05-04 09:00:00' },
|
||||
{ id: 'EMP0010', name: '吴军', gender: '男', age: 45, empNo: 'EMP0010', unit: '采油二厂', dept: '运营部', days: 122, meals: 352, calorie: '2,180', protein: 76.8, fat: 70.3, carb: 280.5, rate: 81, status: 1, createTime: '2026-05-01 13:25:00' },
|
||||
]
|
||||
|
||||
/** 缓存数据集(mock handler 内部使用,使用 let 允许 add/delete 修改) */
|
||||
let dataset: EmployeeRow[] = buildDataset()
|
||||
|
||||
/** 按搜索条件过滤 */
|
||||
const filterRows = (rows: EmployeeRow[], params: Record<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%',
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 icon(SearchOutlined / 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>
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
<template>
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<!-- 卡片头:左侧 actions(slot) + 可选标题 + 右侧 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 关闭分页 */
|
||||
/** 用法 A:TableState 统一对象(推荐) */
|
||||
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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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-table,columns/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 等,研发已具备)
|
||||
@@ -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`
|
||||
- ❌ 禁止省略顶/底两处返回按钮
|
||||
@@ -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
|
||||
@@ -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-item,TableCard 接 `: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" />` 分隔
|
||||
@@ -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%"`
|
||||
@@ -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()`
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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(搜索条件 + 分页)
|
||||
* @响应 EmployeeListResponse(records + total)
|
||||
* 员工营养数据 - 接口层
|
||||
*
|
||||
* 原型阶段说明:实际数据见 init/useTable.ts 的 dataList
|
||||
* 业务定位:只读查询页
|
||||
* - 仅需 list / info / stats 三个接口
|
||||
* - 无 add / edit / delete(CRUD 不是列表页标配)
|
||||
*
|
||||
* 对齐 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(不含分页字段)
|
||||
* @响应 Blob(xlsx 二进制流)
|
||||
*/
|
||||
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>
|
||||
+51
@@ -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 }
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user