系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第9篇 · 下一篇:第11篇

系列:Han Menu 外卖系统实践 · PC-2

面向读者:已经理解 PC-1 的会话与请求基础,希望通过真实管理页面学习 Vue 表单、服务端分页、编辑草稿、图片上传和并发写入的开发者。

本篇依据 PC-2 提交 32e8a35docs/PC2_CONTRACT.md 编写。代码展示实际关键实现;省略 import、外围组件或辅助方法的片段不是独立完整文件。

本阶段交付员工、顾客、分类、菜品、套餐和门店管理。订单作业、顾客关联订单跳转、实时通知与资金报表仍属于后续阶段。本文引用阶段验收记录,没有因撰写博客重新运行测试,也不验证 Vditor 渲染。

1. 登录之后,第一批业务页面为什么仍然不只是增删改查

PC-1 已经解决了“谁在操作”和“请求属于哪一轮会话”。进入经营资料页面后,还需要回答另一组问题:

  • 管理员正在修改菜品,后台刷新能不能覆盖输入?
  • 两个人同时编辑门店,后保存的人是否可以直接覆盖前一个人?
  • 图片上传成功,是不是意味着商品也已经保存?
  • 停用员工以后,旧令牌会不会在重新启用时恢复?
  • 当前只取到二十条商品,能不能在这二十条里搜索后宣称“全店没有这个菜”?
  • 套餐引用的菜品改了口味,页面还能直接提交旧组合吗?

这些问题都出现在看起来普通的表单、开关和表格里。

PC-2 的核心,是把后端已有的业务约束表达成用户能理解的操作过程,同时保留服务端的最终判断。

页面需要帮助用户提交正确命令,也需要在结果不确定时停止继续写入,而不是把每次点击都包装成一次绿色成功提示。

2. 先确定页面能力与权限,再选择组件

本阶段的页面范围如下:

页面 入口 关键能力
员工管理 /settings/employees 姓名筛选、分页、新建普通员工、编辑、启停用
顾客管理 /customers/customers/:id 组合筛选、档案详情、启停用
分类管理 /catalog/categories 名称、排序、启停用、无引用删除
菜品管理 /catalog/dishes 分类分页、上下架、删除、进入编辑
套餐管理 /catalog/meals 分类分页、上下架、组成菜品及固定规格
商品编辑 /catalog/{dishes,meals}/new/:id/edit 独立草稿、图片、规格和保存
门店设置 /settings/shop 基础资料、开店与打烊

这些页面只对 ADMIN 开放。普通员工菜单隐藏,直接访问 URL 仍由前端守卫拒绝;真正的 API 授权继续由后端执行。

员工履约订单属于后续 PC-3,不能因为本阶段经营页面都是管理员专用,就把未来订单操作也一律锁给管理员。

当前没有库存、营销、多店、管理员代改顾客地址或密码等能力。组件库能画出一个输入框,不意味着业务已经允许修改这个字段。

3. 模块怎样拆,才能复用能力而不制造万能表单

新增业务目录的主要结构:

admin/src/modules/
├── employees/
│   ├── api/employees.ts
│   ├── pages/EmployeesPage.vue
│   └── index.ts
├── customers/
│   ├── api/customers.ts
│   ├── pages/CustomersPage.vue
│   └── index.ts
├── catalog/
│   ├── api/catalog.ts
│   ├── model/product-draft.ts
│   ├── pages/
│   ├── ui/DishPicker.vue
│   ├── ui/ProductImage.vue
│   └── index.ts
└── shop/
    ├── api/shop.ts
    ├── pages/ShopPage.vue
    └── index.ts

shared 中新增的是已经出现真实重复的能力:写命令反馈、草稿离开保护、分页和资源响应读取。

flowchart TD
  APP[app 路由与布局] --> ENTRY[各模块 index.ts]
  ENTRY --> PAGE[业务页面]
  PAGE --> API[本模块 API]
  PAGE --> MODEL[草稿与业务输入模型]
  PAGE --> SHARED[共享命令与草稿保护]
  API --> HTTP[统一认证客户端]
  HTTP --> SERVER[真实后端资源]

员工资料、套餐组成与门店营业状态没有被塞进同一个“传配置就生成所有 CRUD”的引擎。

共同的请求生命周期可以复用;不同的业务行为仍留在各自模块。这让“套餐必须补全固定口味”这样的规则有明确落点。

4. 同样是成功响应,200 与 204 的消费方式不同

商品、门店等接口可能返回更新后的资源,员工更新则返回 204。

如果所有 API 都写成 return result.data!,204 会得到不存在的正文,调用方却以为拿到了新员工快照。

共享响应适配分成两种:

export function resource<T>(result: {
  data?: T
  error?: unknown
  response: Response
}): NonNullable<T> {
  complete(result)
  if (result.data == null)
    throw new ApiProblem(502, '服务器返回的资源不完整', 'INVALID_RESOURCE')
  return result.data
}

export function complete(result: { error?: unknown; response: Response }): void {
  if (!result.response.ok)
    throw problemFromResponse(result.error, result.response)
}

complete 检查请求是否成功,不要求正文;resource 在这个基础上要求资源确实存在。

员工 API 更新的实际用法:

update: async (id: string, body: components['schemas']['UpdateEmployee']) =>
  complete(
    await api.PUT('/api/v1/employees/{id}', {
      params: { path: { id } },
      body,
    }),
  ),

这是 employeesApi 对象的方法摘录。页面收到成功后重新读取列表,不在本地拼一个未经服务端确认的 version。

resource 也不是完整 JSON schema 校验器。它检查状态码和正文存在性;关键字段还需要按用例单独检查。

5. 版本是用户所见资源的前置条件,不能缺省为零

有版本的资源写入前,共享方法提取 id 与 version:

export function revision(value: { id?: string; version?: number }): {
  id: string
  version: number
} {
  if (!value.id || !Number.isSafeInteger(value.version) || value.version! < 0)
    throw new ApiProblem(502, '资源标识或版本缺失,请重新读取', 'INVALID_RESOURCE')
  return { id: value.id, version: value.version! }
}

不能写成 version: value.version ?? 0。缺少版本意味着响应不足以支撑本次修改,不意味着资源刚刚创建。

同样,保存成功以后也不能习惯性执行 version++。是否发生实际变化、后端如何维护版本,应由服务器新快照或后续读取来证明。

可以把版本化命令理解为:

Command = (ResourceId, ExpectedVersion, IntendedChange)

这里的 ExpectedVersion 是资源并发前置条件,与 PC-1 的员工会话 generation 完全不同。

标识 回答的问题
资源 version 我修改的资源,还是自己看到的那个版本吗?
会话 generation 这个异步结果,还属于当前登录会话吗?
页面请求序号 这个详情响应,还属于当前打开的对象吗?

三个标识保护不同的竞争边界,不能互相替代。

6. useCommand 为什么在冲突或结果未知后阻止再次提交

写操作沿用 PC-1 的“不自动重试”,再增加页面可观察的提交状态。

核心状态:

const pending = ref(false)
const error = ref<unknown>(null)
const needsReload = computed(
  () =>
    error.value instanceof ApiProblem &&
    ([0, 409].includes(error.value.status) || error.value.status >= 500),
)

这里的 0 是项目用于表达网络失败的客户端状态值,不是服务器返回了 HTTP 0。

run 的实际控制流程:

async function run(action: () => Promise<void>, success = '已保存'): Promise<boolean> {
  if (pending.value || needsReload.value) return false
  pending.value = true
  error.value = null
  const epoch = sessionBridge.snapshot().generation
  try {
    await action()
    if (!sessionBridge.isCurrent(epoch)) return false
    message.success(success)
    return true
  } catch (failure) {
    if (
      sessionBridge.isCurrent(epoch) &&
      !(failure instanceof DOMException && failure.name === 'AbortError')
    )
      error.value = failure
    return false
  } finally {
    pending.value = false
  }
}

这个方法避免并发点击,也避免在换账号后继续弹出旧请求的成功消息。底层请求仍使用统一客户端的会话取消和代数校验。

为什么网络失败不能直接解释成保存失败

假设管理员修改门店电话:

sequenceDiagram
  participant UI as 管理员页面
  participant API as 后端
  participant DB as 数据库
  UI->>API: 修改电话,version=7
  API->>DB: 提交新电话和新版本
  DB-->>API: 已提交
  API--xUI: 响应在网络中丢失
  UI->>UI: 保留草稿,要求重新读取
  UI->>API: GET 门店当前状态
  API-->>UI: 返回真实资料与版本

浏览器没有收到成功,只能证明它不知道结果。

409 则说明当前命令的前提发生冲突。页面需要展示服务端原因,不能擅自取一个新版本,把旧草稿自动再提交一次。

对于这些情况,PC-2 保留输入、禁用重复提交,并提供显式重新读取。重新读取如果会覆盖草稿,还要让用户确认放弃。

400 一类可定位的输入问题可以由用户修改后再试,不需要把所有错误都当作同一种冲突。

7. 查询快照与编辑草稿,为什么必须深度分离

商品详情包含数组和嵌套对象:口味组、选项、套餐组成、固定选择。

下面这段错误示意只复制最外层:

const draft = { ...product }
// draft.flavors 内的对象仍可能与 product 共用引用。

用户改了“辣度”选项,查询缓存里的原商品也可能被一起改掉。界面尚未保存,其他组件却已经看到了“新商品”。

实际 productDraft 对可编辑结构逐层复制:

export function productDraft(value?: Product): ProductDraft {
  return {
    name: value?.name || '',
    categoryId: value?.categoryId || '',
    description: value?.description || '',
    price: value?.price?.toFixed(2) || '',
    imageId: value?.imageId,
    flavors: (value?.flavors || []).map((f) => ({
      name: f.name || '',
      options: [...(f.options || [])],
      required: !!f.required,
    })),
    components: (value?.components || []).map((c) => ({
      dishId: c.dishId || '',
      quantity: c.quantity || 1,
      selections: { ...c.selections },
    })),
  }
}

这个函数同时完成两件事:把响应变成适合编辑的形状,并切断可变嵌套数据的共享引用。

它没有复制整个服务端 DTO,因此 id、version、销售状态不会混进用户可编辑资料里。

页面用 JSON 字符串记录当前草稿基线,再比较是否修改:

const baseline = ref(JSON.stringify(productDraft()))
const draft = reactive(productDraft())
const dirty = computed(() => JSON.stringify(draft) !== baseline.value)

这适用于当前字段明确、内容有界且可序列化的草稿,不是一套适用于任意对象的通用差异算法。

8. 草稿保护不仅发生在“返回列表”按钮上

用户可以通过很多路径离开编辑状态:侧栏导航、浏览器后退、关闭抽屉、同一路由换商品参数、点击重新读取、关闭浏览器。

只在一个取消按钮上写确认框,会漏掉其他入口。

共享保护的判断顺序:

function confirmDiscard(): Promise<boolean> {
  if (!sessionBridge.snapshot().token) return Promise.resolve(true)
  if (pending.value) return Promise.resolve(false)
  if (!dirty.value) return Promise.resolve(true)
  return new Promise((resolve) =>
    modal.confirm({
      title: '放弃尚未保存的修改?',
      content: '重新读取或离开会清空当前草稿。',
      okText: '放弃修改',
      cancelText: '继续编辑',
      onOk: () => resolve(true),
      onCancel: () => resolve(false),
    }),
  )
}

路由离开和参数更新复用它:

onBeforeRouteLeave(confirmDiscard)
onBeforeRouteUpdate(confirmDiscard)

浏览器关闭由 beforeunload 尽力提示,卸载时移除监听。它受浏览器行为限制,不是保证任何情况下都能拦住窗口关闭。

会话失效时先允许安全离开,不能用未保存表单把用户困在没有访问权限的后台。

商品上传把 uploading 与写入 pending 合并为 busy。上传尚未返回图片标识时,保存和普通离开操作都被禁止,避免保存一个尚未确定的关联。

9. 商品上架、下架与编辑应该保持显式

新增商品保存后默认下架。管理员确认资料,再从列表发起上架。

在售商品进入编辑页时只读,需要先明确下架,才能修改内容。

flowchart LR
  NEW[填写新商品] --> SAVE[保存为下架商品]
  SAVE --> REVIEW[核对商品资料]
  REVIEW --> ON[显式上架]
  ON --> OFF[显式下架]
  OFF --> EDIT[编辑独立草稿]
  EDIT --> SAVE

这张图展示页面操作顺序,不替代后端完整状态机和引用检查。

不能在用户点击“编辑”时偷偷下架,保存后又偷偷上架。三个动作各自有业务影响,也可能因套餐引用、分类状态或并发版本而失败。

前端不构造一个“强制忽略套餐引用”的选项,也不虚构后端没有提供的反向引用列表。服务端拒绝时,展示其明确原因。

10. 价格先作为十进制文本检查,再构造请求

如果输入框直接把任意文本变成 Number,1e2 会变成 100;如果先四舍五入,18.999 又可能悄悄变成 19。

实际输入校验:

export function priceValue(input: string): number {
  if (!/^(?:0|[1-9]\d{0,5})(?:\.\d{1,2})?$/.test(input) || Number(input) < 0.01)
    throw new Error('价格需为0.01—999999.99,最多两位小数')
  return Number(input)
}
输入 结果 原因
18.5 接受 合法十进制单价
0.01 接受 达到最小金额
999999.99 接受 未超最大金额
18.999 拒绝 超过两位小数
1e2 拒绝 不接受科学计数法文本
01.2 拒绝 不符合当前输入格式
0-1 拒绝 不满足单价范围

最后转为 number 是为了匹配当前 HTTP 契约,并不表示 JavaScript Number 获得了任意精度十进制能力。

这里处理的是商品单价输入,订单应付金额仍由后端计算。管理端不根据表单单价重算历史订单。

11. 口味与套餐组成,怎样把领域约束变成可定位的提示

菜品最多十组口味,每组一到二十个选项;每组单选,可以要求必选。

组名与选项先去首尾空白,再检查重复。否则“微辣”和“ 微辣 ”会看起来一样,却被当成两个选项。

套餐最多五十个不重复菜品,每项数量为一到九十九。套餐不能嵌套套餐。

更重要的是,套餐保存的是固定口味选择:

套餐:工作日午餐
  菜品:鸡腿饭 × 1
  固定口味:辣度 = 微辣

如果“辣度”必选而没有选择,页面不能把责任留给顾客下单时再处理。

实际验证的一部分:

const dish = dishes[c.dishId]
if (!dish || dish.kind !== 'DISH')
  throw new Error('请等待组成菜品读取完成或重新选择')
for (const f of dish.flavors || [])
  if (
    f.name &&
    ((f.required && !c.selections[f.name]) ||
      (c.selections[f.name] && !f.options?.includes(c.selections[f.name]!)))
  )
    throw new Error(`请补全“${dish.name}”的有效口味选择`)

这是 detailsFor 遍历套餐组成时的片段。后续还检查选择中是否存在已经失效的口味组名。

为什么编辑套餐还要读取组成菜品

已有套餐里有 dishId 和 selections,但必须取得当前菜品规格,才能知道这个选择是否仍然有效。

编辑页一次读取套餐,再对最多五十个组成菜品进行有界读取。任何必要读取失败,都不能把缺失规格当成“没有必选项”。

列表页面没有逐行读取所有商品详情;这里的额外请求只用于当前套餐编辑的业务约束。

即便前端检查通过,保存时菜品仍可能被另一个管理员改变。服务端的目录修订锁、引用检查与领域规则继续负责最终一致性。

12. 图片上传成功和商品保存成功,是两次不同的结果

图片流程分成两个资源操作:

sequenceDiagram
  participant U as 编辑页
  participant C as 目录接口
  participant S as 对象存储
  U->>U: 检查格式、大小和像素
  U->>C: multipart 上传文件
  C->>S: 验证后存储图片
  C-->>U: 返回 imageId
  U->>U: 把 imageId 放入草稿
  U->>C: 保存商品 details 与 version
  C-->>U: 返回商品写入结果

上传只产生图片资源标识。商品保存成功之后,图片与商品的关联才成为商品事实。

页面预检 PNG/JPEG、五 MiB、边长不超过 4096、总像素不超过一千六百万。服务端仍然验证真实文件内容,不能相信扩展名或浏览器 MIME。

像素检查使用 createImageBitmap,读完尺寸调用 bitmap.close() 释放资源。提前提示能减少无效上传,却不能代替服务端解码和安全约束。

multipart 继续使用统一认证客户端

实际 API 方法:

upload: async (file: File) =>
  resource(
    await api.POST('/api/v1/catalog/images', {
      body: { file: '' },
      bodySerializer: () => {
        const data = new FormData()
        data.append('file', file)
        return data
      },
    }),
  ),

bodySerializer 产生真正的 FormData。不要手写一个缺少 boundary 的 Content-Type,也不要另建一套不处理会话失效的裸上传请求。

上传完成时,页面同时检查编辑请求代数和员工会话代数,只有结果仍属于当前编辑上下文,才把 imageId 写入草稿。

上传失败保留其他输入,用户可以重选图片。

预览 URL 与员工 Bearer 不能混用

商品保存 imageId,预览时通过接口申请短期签名 URL,再交给 img 加载。

这条资源地址不附加员工 Bearer。签名 URL 过期时显示“刷新图片”,重新申请地址,不能因此宣称原始图片已经丢失。

“移除图片”只清空当前商品草稿的 imageId,不删除共享存储对象。上传后放弃商品编辑,也不能被描述成前端已经自动回滚对象存储。

13. 服务端分页不能用当前页数组冒充全量数据

商品列表只使用后端支持的 kind、categoryId、page、size。

本阶段没有商品名称或销售状态搜索接口,因此页面不增加一个只筛选当前二十条的“全店搜索”。

分页组件把一基页码转换成后端零基页码:

<Pagination
  :current="page + 1"
  :page-size="size"
  :total="total"
  :page-size-options="[20, 50]"
  show-size-changer
  :disabled="disabled"
  @change="(page: number, size: number) => $emit('change', page - 1, size)"
/>

total 来自服务端 totalElements,不是当前 items.length。删除末页最后一条后回到上一页,避免用户停在失去内容的末页。

分类接口返回有界完整数组,所以分类页没有伪装服务端分页。接口是什么语义,界面就应该按什么语义呈现。

加载失败、首次加载和筛选无结果也分别表达。请求失败时显示“暂不可用”,不能用空数组把错误包装成“没有数据”。

14. 顾客筛选值为什么没有直接进入 queryKey

常见写法会把完整筛选对象放入查询键。这样虽然方便缓存,但姓名和手机号也会进入查询键、开发工具或其他缓存观察入口。

当前顾客页面保留临时作用域与筛选修订号:

const scope = `customers-${crypto.randomUUID()}`
const query = useQuery({
  queryKey: computed(() => [scope, filterRevision.value, page.value, size.value]),
  queryFn: ({ signal }) =>
    customersApi.list(filter.value, page.value, size.value, signal),
  gcTime: 0,
})

每次应用筛选时,更新内存中的 filter,并推进 filterRevision。员工姓名筛选采取同类方式。

姓名、电话不进入浏览器页面 URL,也不长期保留在离页后的列表缓存中。它们仍然会作为实际 API 查询参数发送到服务器;这个做法不等于数据已经加密或从所有日志链路消失。

顾客详情默认掩码手机号,用户主动展开后才显示完整号码。关闭或切换详情时恢复掩码。

列表不返回也不展示顾客地址簿与购物车,不为了拼一个更丰满的管理页扩大个人资料读取范围。

15. 顾客注册日期采用左闭右开的 UTC 区间

页面选择的是上海日历日期,接口接收的是 UTC 时刻范围。

例如选择 2026 年 9 月 20 日,转换结果是:

from = 2026-09-19T16:00:00.000Z
to   = 2026-09-20T16:00:00.000Z

from <= createdAt < to

结束边界取结束日期的次日零点,不拼接 23:59:59.999 来猜数据库时间精度。

[startDate\ 00{:}00,\ (endDate+1)\ 00{:}00)

这个区间先按 Asia/Shanghai 解释,再转换为 UTC。不能使用浏览器当前时区悄悄改变业务日边界。

P6 报表接口使用经营日期,包含首尾日期;顾客注册检索使用时刻区间。两者不应复用一个含义模糊的“日期转参数”函数。

16. 员工、顾客与门店的状态,不是同一种开关

资源 状态模型 页面约束
员工 ACTIVE / DISABLED 新建固定 STAFF,管理员不可停用
顾客 enabled 布尔值 管理启停用,不代改密码和资料
门店 OPEN / CLOSED 联系资料与营业状态分开提交

它们看起来都能画成 Switch,实际命令、字段和业务后果却不同。

员工新建表单包含用户名、姓名、可选电话与初始密码;编辑没有角色变更和密码重置入口。初始密码只留在当前表单,关闭与卸载时清空。

员工或顾客停用会使旧会话失效。重新启用以后必须重新登录,不能把“账号允许登录”解释为“旧令牌重新有效”。

门店资料保存与营业状态变更也保持分离:草稿未保存时,开店/打烊按钮不可用,先保存或放弃资料修改。

门店变更成功后,使 storefrontworkspace 查询失效,顶栏和工作台重新取得服务端状态。打烊不会被页面解释成取消已经提交的订单。

17. 前端类型报错,有时暴露的是后端文档模型错误

PC-2 实践中,三个控制器都定义了名为 StatusChange 的内部请求类型。

生成 OpenAPI 时,它们发生同名模型覆盖,员工、顾客状态请求被错误地指向门店状态结构。

实际业务请求 应有字段
员工状态变更 status 为 ACTIVE/DISABLED,携带 version
顾客状态变更 enabled 与 version
门店营业状态 status 为 OPEN/CLOSED,携带 version

如果前端用 as any 绕过去,接口也许暂时能调用,但整个类型契约已经失去可靠性。

本次修复明确命名为 EmployeeStatusChange、CustomerStatusChange、ShopStatusChange,并补充真实 OpenAPI 集成断言,再重新取得契约、生成 TypeScript 类型。

HTTP 路径、JSON 字段与业务规则没有变化,也没有数据库结构变化,不需要新增 Flyway 迁移。

生成器只能忠实传播它收到的 schema。自动生成类型不等于上游文档天然正确。

18. 测试怎样证明草稿与服务端事实没有混在一起

草稿测试直接修改嵌套数组和套餐选择,再验证来源对象未变。实际用例摘录:

it('草稿与已读聚合的规格及套餐选择隔离', () => {
  const source: Product = {
    flavors: [{ name: '辣度', options: ['微辣'], required: true }],
    components: [{ dishId: 'dish', quantity: 1, selections: { 辣度: '微辣' } }],
  }
  const draft = productDraft(source)
  draft.flavors[0]!.options[0] = '不辣'
  draft.components[0]!.selections.辣度 = '不辣'
  expect(source.flavors![0]!.options).toEqual(['微辣'])
  expect(source.components![0]!.selections).toEqual({ 辣度: '微辣' })
})

另外覆盖价格格式、去空白后的重复口味、套餐必选规格、图片关联与销售状态隔离。

写命令测试检查重复提交、409 和未知结果保护,而不只是验证某个按钮是否存在。

真实浏览器验收覆盖分类引用冲突、图片上传、菜品规格、套餐依赖、员工启停用、顾客会话撤销、门店版本竞争和窄屏操作。

其中顾客停用测试还要用旧顾客会话访问后端,确认真正撤销,而不是只看到后台表格的状态文字变了。

测试图片也需要明确清理范围

浏览器测试使用独立 _test 数据库与随机 schema。图片放入 *-test 桶,并限制为 han-menu-test/<随机schema>/ 前缀。

清理时先停测试应用,再删除本次前缀图片和临时 schema,不使用开发账号和开发商品做破坏性验收。

新增的 @aws-sdk/client-s3 仅用于测试资源清理,不进入浏览器业务产物。

19. 本阶段验收证明了什么,仍然没有证明什么

根目录统一入口:

./scripts/verify.sh

2026-09-20 的阶段记录为:155 项后端测试、28 项前端单元测试、15 项真实浏览器测试通过,无跳过

前端单元测试从 PC-1 的二十项增加到二十八项,浏览器测试从六项增加到十五项;这些数字是该阶段累计数量。

格式、Checkstyle、ESLint、模块边界、OpenAPI 类型一致性、类型检查与生产构建均通过。设计证据记录于 admin/design-qa-pc2.md

文章引用已有记录,没有重新跑上述门禁;阶段本地通过也不代表远程 CI 已经执行。

PC-2 交付的是可以依据真实业务规则管理经营资料的页面,不包含尚未接入的订单履约和实时通知。

20. 从资料编辑走向订单作业

这一阶段建立了几条后续可以复用的约束:草稿与快照分离,版本缺失拒绝提交,写入结果未知时先读取,页面切换后旧结果失效。

到了订单阶段,这些约束还要继续扩展:确认框打开以后版本不能悄悄变化,订单取消受理不能显示成退款完成,订单详情也不能跟随实时商品资料一起变化。

读者练习

练习一:保存超时。 门店资料实际已经提交,但响应丢失。页面为什么保留草稿并要求重读?直接重试和自行 version+1 分别有什么问题?

练习二:浅复制。 一个商品有两组口味和一组套餐选择。只复制最外层对象,哪几类修改仍然会污染来源快照?

练习三:图片孤立。 上传成功后用户放弃编辑。当前页面真正完成了哪项资源操作?为什么不能声称商品已保存或图片已自动删除?

练习四:查询键。 不把手机号放进 queryKey 以后,怎样确保修改手机号仍会触发新查询?这是否意味着手机号不再经过网络?

练习五:套餐规格变化。 编辑页读到了菜品旧规格,保存前另一管理员又改了它。前端校验和后端校验分别负责哪一段?

练习六:同名模型。 TypeScript 认为顾客状态必须传 OPEN/CLOSED。应该先强制断言,还是沿生成链路检查 OpenAPI?怎样防止问题再次出现?

下一篇进入 PC-3,把这些请求与编辑边界应用到接单、配送、取消和退款进度中。

实现对照

  • 阶段契约:docs/PC2_CONTRACT.md
  • 写入与草稿保护:admin/src/shared/model/use-command.tsuse-editor-guard.ts
  • 商品草稿与约束:admin/src/modules/catalog/model/product-draft.ts
  • 商品编辑与图片:admin/src/modules/catalog/pages/ProductEditorPage.vueui/ProductImage.vue
  • 真实浏览器验收:admin/e2e/management.spec.ts

系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第9篇 · 下一篇:第11篇