394 lines
15 KiB
Markdown
394 lines
15 KiB
Markdown
# 门店管理新增字段设计规范
|
||
|
||
**工单**: rui/rui-frontend#6 — 门店管理适配后端新增字段
|
||
**日期**: 2026-06-08
|
||
**关联 Issue**: rui/rui-cashier#6(后端门店表新增字段)
|
||
**优先级**: P2
|
||
|
||
---
|
||
|
||
## 1. 目标(Goal)
|
||
|
||
在 admin-ui 门店管理模块的列表页和表单弹窗中,适配后端门店表新增的 9 个字段(storeType、amenities、longitude、latitude、roomCount、freeRoomCount、serviceFeeRate、openingDate、legalPerson)。表单弹窗新增 7 个可编辑字段和 2 个只读展示字段,列表页新增 3 列(门店类型标签、包间信息、设施标签)和 1 个筛选条件(门店类型下拉)。所有变更在现有 `useApiForm` + `ApiFormDialog` + `RuiTable` 框架内完成,不引入新依赖。
|
||
|
||
## 2. 非目标(Non-Goals)
|
||
|
||
明确不在本期范围内的事项:
|
||
|
||
- **不修改后端代码或 API 接口定义**:后端已提供字段,前端仅适配展示和交互。
|
||
- **不做地图组件集成**:经纬度字段使用纯数字输入框,不嵌入高德/百度地图选点组件。
|
||
- **不修改 storeService.ts**:现有 BaseService 的 CRUD 方法已覆盖需求,无需扩展。
|
||
- **不新增独立详情页**:沿用当前「列表 + 表单弹窗」模式,只读字段在编辑弹窗中展示。
|
||
- **不做包间数量编辑**:roomCount 和 freeRoomCount 由后端计算,前端仅只读展示。
|
||
- **不引入新 npm 依赖**:所有 UI 组件使用现有 Element Plus + useApiForm 字段类型体系。
|
||
|
||
## 3. 背景与上下文(Context)
|
||
|
||
### 3.1 现有门店模块结构
|
||
|
||
- **列表页** `admin-ui/src/views/cashier/store/Index.vue`:使用 `RuiTable` 组件,现有 7 列(门店名称、门店编码、联系人、联系电话、地址、营业时间、状态),2 个筛选条件(门店名称、状态),支持增删改查和状态切换。
|
||
- **表单弹窗** `admin-ui/src/views/cashier/store/StoreFormDialog.vue`:使用 `useApiForm` composable + `ApiFormDialog` 组件,现有 7 个字段(storeName、storeCode、address、contactName、contactPhone、businessHours、status),通过 `v-model:visible` / `row` prop 控制显示和数据回填。
|
||
- **服务层** `admin-ui/src/service/cashier/storeService.ts`:继承 `BaseService('/cashier/admin/store')`,13 行代码,自动拥有 page/add/update/remove/changeStatus 能力。
|
||
|
||
### 3.2 useApiForm 字段类型支持
|
||
|
||
`useApiForm` 支持 9 种字段类型:`input`、`textarea`、`select`、`radio`、`checkbox`、`number`、`tree-select`、`date`、`datetime`。每个字段支持 `disabled` 属性(布尔值或返回布尔值的函数),可用于按编辑/新增模式动态禁用。
|
||
|
||
`ApiFormDialog` 在所有标准字段之后渲染 `<slot name="custom-fields" :form="formData" />`,用于放置无法用标准字段类型表达的 UI 内容。
|
||
|
||
### 3.3 后端新增字段一览
|
||
|
||
| 字段 | camelCase 键 | Java 类型 | 后端存储格式 |
|
||
|------|-------------|----------|-------------|
|
||
| 门店类型 | storeType | String | 纯字符串:`FLAGSHIP` / `STANDARD` / `COMMUNITY` |
|
||
| 设施标签 | amenities | String | JSON 数组字符串:`"[\"免费停车\",\"免费WiFi\"]"` |
|
||
| 经度 | longitude | BigDecimal | 小数 |
|
||
| 纬度 | latitude | BigDecimal | 小数 |
|
||
| 包间总数 | roomCount | Integer | 整数(后端计算) |
|
||
| 空闲包间数 | freeRoomCount | Integer | 整数(后端计算) |
|
||
| 平台服务费率 | serviceFeeRate | BigDecimal | 小数,如 `0.05` 表示 5% |
|
||
| 开业日期 | openingDate | LocalDate | `yyyy-MM-dd` 字符串 |
|
||
| 法人姓名 | legalPerson | String | 纯文本 |
|
||
|
||
## 4. 关键设计决策
|
||
|
||
| # | 决策项 | 选定方案 | 理由 |
|
||
|---|--------|---------|------|
|
||
| 1 | 整体方案 | 在现有 useApiForm + ApiFormDialog 框架内扩展 | 复用现有基础设施,保持与其他模块一致的开发模式 |
|
||
| 2 | amenities 表现层 | 使用 `checkbox` 字段类型,options 固定 6 项 | useApiForm 原生支持 checkbox,无需自定义渲染 |
|
||
| 3 | amenities 数据转换 | 提交时 `JSON.stringify`,编辑回填时 `JSON.parse` | 后端存 JSON 字符串,前端表单使用 `string[]` |
|
||
| 4 | serviceFeeRate 展示 | 前端用百分比数值(5 表示 5%),提交时除以 100,编辑回填时乘以 100 | 用户体验直观,避免手动输入 0.05 这样的小数 |
|
||
| 5 | roomCount / freeRoomCount | 使用 `#custom-fields` 插槽渲染只读文本 | 这两个字段仅编辑模式下只读展示,不需要 useApiForm 字段配置 |
|
||
| 6 | 门店类型枚举 | 前端硬编码 3 个选项 | 后端接口稳定,选项固定,无需动态加载 |
|
||
| 7 | 经纬度输入 | `number` 类型字段,精度限制 6 位小数 | 经纬度通常保留 6 位即可满足定位需求 |
|
||
|
||
## 5. 字段配置详情
|
||
|
||
### 5.1 useApiForm 字段新增(7 个可编辑字段)
|
||
|
||
以下字段追加到 `StoreFormDialog.vue` 中 `useApiForm` 的 `fields` 数组:
|
||
|
||
```ts
|
||
{
|
||
key: 'storeType',
|
||
label: '门店类型',
|
||
type: 'select',
|
||
required: true,
|
||
options: [
|
||
{ label: '旗舰店', value: 'FLAGSHIP' },
|
||
{ label: '标准店', value: 'STANDARD' },
|
||
{ label: '社区店', value: 'COMMUNITY' },
|
||
],
|
||
},
|
||
{
|
||
key: 'amenities',
|
||
label: '设施标签',
|
||
type: 'checkbox',
|
||
options: [
|
||
{ label: '免费停车', value: '免费停车' },
|
||
{ label: '免费WiFi', value: '免费WiFi' },
|
||
{ label: '充电桩', value: '充电桩' },
|
||
{ label: '24小时营业', value: '24小时营业' },
|
||
{ label: '包厢', value: '包厢' },
|
||
{ label: '吸烟区', value: '吸烟区' },
|
||
],
|
||
},
|
||
{
|
||
key: 'longitude',
|
||
label: '经度',
|
||
type: 'number',
|
||
props: { min: -180, max: 180, precision: 6, step: 0.000001 },
|
||
},
|
||
{
|
||
key: 'latitude',
|
||
label: '纬度',
|
||
type: 'number',
|
||
props: { min: -90, max: 90, precision: 6, step: 0.000001 },
|
||
},
|
||
{
|
||
key: 'serviceFeeRate',
|
||
label: '平台服务费率(%)',
|
||
type: 'number',
|
||
props: { min: 0, max: 100, precision: 2, step: 0.01 },
|
||
},
|
||
{
|
||
key: 'openingDate',
|
||
label: '开业日期',
|
||
type: 'date',
|
||
props: { valueFormat: 'YYYY-MM-DD' },
|
||
},
|
||
{
|
||
key: 'legalPerson',
|
||
label: '法人姓名',
|
||
type: 'input',
|
||
},
|
||
```
|
||
|
||
### 5.2 弹窗宽度调整
|
||
|
||
`StoreFormDialog` 中 `ApiFormDialog` 追加 `width` prop 为 `720px`(原默认 600px),因为新增字段较多,需要更宽的弹窗空间。
|
||
|
||
### 5.3 只读展示字段(2 个,通过 custom-fields 插槽)
|
||
|
||
`roomCount` 和 `freeRoomCount` 不加入 `fields` 数组,而是在 `ApiFormDialog` 的 `#custom-fields` 插槽中以 `el-form-item` + 纯文本方式渲染。仅在编辑模式(`form.id` 存在)时显示:
|
||
|
||
```vue
|
||
<template #custom-fields="{ form }">
|
||
<template v-if="form.id">
|
||
<el-form-item label="包间总数">
|
||
<span>{{ form.roomCount ?? '-' }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="空闲包间数">
|
||
<span>{{ form.freeRoomCount ?? '-' }}</span>
|
||
</el-form-item>
|
||
</template>
|
||
</template>
|
||
```
|
||
|
||
### 5.4 initial 默认值扩展
|
||
|
||
在 `useApiForm` 的 `initial` 对象中追加新字段默认值:
|
||
|
||
```ts
|
||
initial: {
|
||
// ...现有字段
|
||
storeType: 'STANDARD',
|
||
amenities: [],
|
||
longitude: undefined,
|
||
latitude: undefined,
|
||
serviceFeeRate: undefined,
|
||
openingDate: '',
|
||
legalPerson: '',
|
||
},
|
||
```
|
||
|
||
### 5.5 数据转换逻辑
|
||
|
||
#### 提交时(`onSubmit` 回调内)
|
||
|
||
```ts
|
||
onSubmit: async (rawData) => {
|
||
const data = { ...rawData }
|
||
|
||
// amenities: string[] → JSON 字符串
|
||
if (Array.isArray(data.amenities)) {
|
||
data.amenities = JSON.stringify(data.amenities)
|
||
}
|
||
|
||
// serviceFeeRate: 百分比 → 小数
|
||
if (data.serviceFeeRate != null && data.serviceFeeRate !== '') {
|
||
data.serviceFeeRate = Number(data.serviceFeeRate) / 100
|
||
}
|
||
|
||
// ...现有 add/update 逻辑
|
||
},
|
||
```
|
||
|
||
#### 编辑回填时(`watch(visible)` 内)
|
||
|
||
```ts
|
||
watch(() => props.visible, (val) => {
|
||
if (val && props.row) {
|
||
const rowData = { ...props.row }
|
||
|
||
// amenities: JSON 字符串 → string[]
|
||
if (typeof rowData.amenities === 'string' && rowData.amenities) {
|
||
try {
|
||
rowData.amenities = JSON.parse(rowData.amenities)
|
||
} catch {
|
||
rowData.amenities = []
|
||
}
|
||
} else if (!Array.isArray(rowData.amenities)) {
|
||
rowData.amenities = []
|
||
}
|
||
|
||
// serviceFeeRate: 小数 → 百分比
|
||
if (rowData.serviceFeeRate != null) {
|
||
rowData.serviceFeeRate = Number(rowData.serviceFeeRate) * 100
|
||
}
|
||
|
||
form.value = rowData
|
||
}
|
||
})
|
||
```
|
||
|
||
## 6. 列表页变更(Index.vue)
|
||
|
||
### 6.1 新增列配置
|
||
|
||
在 `columns` 数组中,`address` 列之后追加以下 3 列:
|
||
|
||
```ts
|
||
{
|
||
prop: 'storeType',
|
||
label: '门店类型',
|
||
width: 100,
|
||
align: 'center',
|
||
slot: true,
|
||
},
|
||
{
|
||
prop: 'roomInfo',
|
||
label: '包间',
|
||
width: 120,
|
||
align: 'center',
|
||
slot: true,
|
||
},
|
||
{
|
||
prop: 'amenities',
|
||
label: '设施标签',
|
||
minWidth: 200,
|
||
slot: true,
|
||
},
|
||
```
|
||
|
||
### 6.2 新增列 Slot 模板
|
||
|
||
#### 门店类型列(彩色 Tag)
|
||
|
||
```vue
|
||
<template #column-storeType="{ row }">
|
||
<el-tag
|
||
:type="row.storeType === 'FLAGSHIP' ? 'danger' : row.storeType === 'STANDARD' ? '' : 'info'"
|
||
size="small"
|
||
>
|
||
{{ { FLAGSHIP: '旗舰店', STANDARD: '标准店', COMMUNITY: '社区店' }[row.storeType] || '-' }}
|
||
</el-tag>
|
||
</template>
|
||
```
|
||
|
||
#### 包间信息列("空闲X/总数Y" 格式)
|
||
|
||
```vue
|
||
<template #column-roomInfo="{ row }">
|
||
<span>{{ row.freeRoomCount ?? '-' }}/{{ row.roomCount ?? '-' }}</span>
|
||
</template>
|
||
```
|
||
|
||
#### 设施标签列(多个小 Tag)
|
||
|
||
```vue
|
||
<template #column-amenities="{ row }">
|
||
<template v-if="parseAmenities(row.amenities).length">
|
||
<el-tag
|
||
v-for="tag in parseAmenities(row.amenities)"
|
||
:key="tag"
|
||
size="small"
|
||
type="info"
|
||
class="mr-1 mb-1"
|
||
>
|
||
{{ tag }}
|
||
</el-tag>
|
||
</template>
|
||
<span v-else>-</span>
|
||
</template>
|
||
```
|
||
|
||
其中 `parseAmenities` 为组件内的工具函数:
|
||
|
||
```ts
|
||
function parseAmenities(val: any): string[] {
|
||
if (Array.isArray(val)) return val
|
||
if (typeof val === 'string' && val) {
|
||
try { return JSON.parse(val) } catch { return [] }
|
||
}
|
||
return []
|
||
}
|
||
```
|
||
|
||
### 6.3 新增筛选条件
|
||
|
||
在 `queryParams` 中增加 `storeType` 字段,在查询区增加门店类型下拉:
|
||
|
||
```ts
|
||
const queryParams = ref({
|
||
storeName: '',
|
||
status: undefined as number | undefined,
|
||
storeType: undefined as string | undefined,
|
||
})
|
||
```
|
||
|
||
查询区模板中,状态筛选后追加:
|
||
|
||
```vue
|
||
<el-form-item label="门店类型">
|
||
<el-select v-model="queryParams.storeType" placeholder="请选择门店类型" clearable>
|
||
<el-option label="旗舰店" value="FLAGSHIP" />
|
||
<el-option label="标准店" value="STANDARD" />
|
||
<el-option label="社区店" value="COMMUNITY" />
|
||
</el-select>
|
||
</el-form-item>
|
||
```
|
||
|
||
`handleReset` 方法中补充 `storeType: undefined` 重置。
|
||
|
||
## 7. 涉及文件清单(Files To Change)
|
||
|
||
| # | 文件 | 操作 | 变更说明 |
|
||
|---|------|------|---------|
|
||
| 1 | `admin-ui/src/views/cashier/store/StoreFormDialog.vue` | 修改 | 新增 7 个 useApiForm 字段、custom-fields 插槽渲染 roomCount/freeRoomCount、数据转换逻辑、弹窗宽度调整为 720px |
|
||
| 2 | `admin-ui/src/views/cashier/store/Index.vue` | 修改 | 新增 3 列配置(storeType/roomInfo/amenities)及对应 slot 模板、新增 storeType 筛选条件、queryParams 扩展、parseAmenities 工具函数、handleReset 补充 |
|
||
|
||
**变更统计**:修改 2 个文件。**不新建文件,不修改服务层和路由**。
|
||
|
||
## 8. 测试策略
|
||
|
||
### 8.1 静态检查
|
||
|
||
```bash
|
||
pnpm --filter admin-ui type-check # 0 errors
|
||
pnpm --filter admin-ui lint # 0 errors
|
||
```
|
||
|
||
### 8.2 功能验证(手动)
|
||
|
||
| # | 验证场景 | 预期结果 |
|
||
|---|---------|---------|
|
||
| 1 | 列表加载 | 新增 3 列正确展示:门店类型(Tag)、包间(X/Y 格式)、设施标签(多个小 Tag) |
|
||
| 2 | 门店类型筛选 | 下拉选择后点击查询,列表按类型过滤;重置后筛选条件清空 |
|
||
| 3 | 无数据的门店 | storeType 为空显示 `-`,amenities 为空显示 `-`,包间无数据显示 `-/--` |
|
||
| 4 | 新增门店 | 弹窗宽度 720px,7 个新字段正确渲染,roomCount/freeRoomCount 不显示(新增模式) |
|
||
| 5 | 新增提交 | amenities 提交为 JSON 字符串,serviceFeeRate 提交为小数(5→0.05) |
|
||
| 6 | 编辑门店 | 弹窗回填所有字段:amenities 从 JSON 字符串解析为勾选状态,serviceFeeRate 从小数转为百分比(0.05→5) |
|
||
| 7 | 编辑模式只读字段 | roomCount 和 freeRoomCount 以纯文本显示,不可编辑 |
|
||
| 8 | 新增模式无只读字段 | 新增门店时 roomCount 和 freeRoomCount 区域不显示 |
|
||
| 9 | 门店类型必填校验 | storeType 为空时提交,表单校验不通过 |
|
||
|
||
### 8.3 验证清单(提交前必过)
|
||
|
||
- [ ] `pnpm --filter admin-ui type-check` 通过
|
||
- [ ] `pnpm --filter admin-ui lint` 通过
|
||
- [ ] 列表新增 3 列正确展示
|
||
- [ ] 门店类型筛选功能正常
|
||
- [ ] 新增门店:7 个新字段可正常填写和提交
|
||
- [ ] 编辑门店:所有新字段正确回填,只读字段不可编辑
|
||
- [ ] amenities 数据双向转换正确(JSON 字符串 ↔ 数组)
|
||
- [ ] serviceFeeRate 数据双向转换正确(小数 ↔ 百分比)
|
||
|
||
## 9. 风险与缓解(Risks And Mitigations)
|
||
|
||
| # | 风险 | 缓解措施 |
|
||
|---|------|---------|
|
||
| 1 | **amenities JSON 解析失败**:后端返回格式异常或 null 时,前端 JSON.parse 抛错导致表单崩溃 | 在编辑回填时用 try-catch 包裹 JSON.parse,解析失败降级为空数组 `[]`。在列表 parseAmenities 工具函数中同样做防御性解析。 |
|
||
| 2 | **serviceFeeRate 精度问题**:BigDecimal 除以 100 或乘以 100 可能产生浮点精度误差 | 使用 `Number()` 转换后通过 `el-input-number` 的 `precision: 2` 限制小数位数,避免显示多余精度。后端使用 BigDecimal 不会丢失精度。 |
|
||
| 3 | **后端字段尚未部署**:前端先于后端部署时,新增列显示空值 | 列表和表单均对 undefined/null 做降级处理(显示 `-`),不影响现有功能。前端部署无阻断风险。 |
|
||
| 4 | **弹窗字段过多导致布局拥挤**:原有 7 个字段 + 新增 9 个字段共 16 个表单项 | 弹窗宽度从 600px 增至 720px;新增模式不显示 roomCount/freeRoomCount,实际展示 14 项。后续如仍拥挤可考虑分组或 tabs 布局。 |
|
||
|
||
## 10. 决策摘要(Decision Summary)
|
||
|
||
- **架构**:在现有 `useApiForm` + `ApiFormDialog` + `RuiTable` 框架内扩展,不新建文件、不引入新依赖。
|
||
- **amenities**:使用 `checkbox` 字段类型,options 固定 6 项。提交时 `JSON.stringify`,编辑回填时 `JSON.parse`,均做防御性处理。
|
||
- **serviceFeeRate**:前端以百分比输入/显示,提交时 `÷100`,回填时 `×100`。
|
||
- **roomCount / freeRoomCount**:通过 `#custom-fields` 插槽只读展示,仅在编辑模式显示。
|
||
- **storeType**:`select` 字段,3 个固定选项(FLAGSHIP/STANDARD/COMMUNITY),列表用彩色 Tag 展示,增加筛选条件。
|
||
- **longitude / latitude**:`number` 字段,精度 6 位小数,范围限制经度 [-180, 180]、纬度 [-90, 90]。
|
||
- **openingDate**:`date` 字段,valueFormat 为 `YYYY-MM-DD`。
|
||
- **legalPerson**:`input` 字段,无特殊处理。
|
||
- **列表新增**:3 列(门店类型 Tag、包间 X/Y、设施多 Tag)+ 1 个筛选条件。
|
||
- **弹窗宽度**:从默认 600px 增至 720px。
|
||
- **文件变更**:仅修改 2 个文件(StoreFormDialog.vue、Index.vue)。
|
||
|
||
---
|
||
|
||
**设计评审状态**: 待评审
|
||
**下一步**: 用户评审通过后,编写实施计划(Plan)
|