Files
rui-docs/superpowers/specs/2026-06-08-store-new-fields-design.md
T

15 KiB
Raw Blame History

门店管理新增字段设计规范

工单: 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 种字段类型:inputtextareaselectradiocheckboxnumbertree-selectdatedatetime。每个字段支持 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.vueuseApiFormfields 数组:

{
  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 弹窗宽度调整

StoreFormDialogApiFormDialog 追加 width prop 为 720px(原默认 600px),因为新增字段较多,需要更宽的弹窗空间。

5.3 只读展示字段(2 个,通过 custom-fields 插槽)

roomCountfreeRoomCount 不加入 fields 数组,而是在 ApiFormDialog#custom-fields 插槽中以 el-form-item + 纯文本方式渲染。仅在编辑模式(form.id 存在)时显示:

<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 默认值扩展

useApiForminitial 对象中追加新字段默认值:

initial: {
  // ...现有字段
  storeType: 'STANDARD',
  amenities: [],
  longitude: undefined,
  latitude: undefined,
  serviceFeeRate: undefined,
  openingDate: '',
  legalPerson: '',
},

5.5 数据转换逻辑

提交时(onSubmit 回调内)

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) 内)

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 列:

{
  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

<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" 格式)

<template #column-roomInfo="{ row }">
  <span>{{ row.freeRoomCount ?? '-' }}/{{ row.roomCount ?? '-' }}</span>
</template>

设施标签列(多个小 Tag

<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 为组件内的工具函数:

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 字段,在查询区增加门店类型下拉:

const queryParams = ref({
  storeName: '',
  status: undefined as number | undefined,
  storeType: undefined as string | undefined,
})

查询区模板中,状态筛选后追加:

<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 静态检查

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-numberprecision: 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 插槽只读展示,仅在编辑模式显示。
  • storeTypeselect 字段,3 个固定选项(FLAGSHIP/STANDARD/COMMUNITY),列表用彩色 Tag 展示,增加筛选条件。
  • longitude / latitudenumber 字段,精度 6 位小数,范围限制经度 [-180, 180]、纬度 [-90, 90]。
  • openingDatedate 字段,valueFormat 为 YYYY-MM-DD
  • legalPersoninput 字段,无特殊处理。
  • 列表新增:3 列(门店类型 Tag、包间 X/Y、设施多 Tag)+ 1 个筛选条件。
  • 弹窗宽度:从默认 600px 增至 720px。
  • 文件变更:仅修改 2 个文件(StoreFormDialog.vue、Index.vue)。

设计评审状态: 待评审 下一步: 用户评审通过后,编写实施计划(Plan)