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

上一篇:第10篇 · 下一篇:第12篇

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

面向读者:已经理解 PC-1 的会话隔离与 PC-2 的版本化写入,希望学习真实订单工作流、路由状态、可见轮询和异步故障恢复的开发者。

本篇依据 PC-3 提交 9ba3db9docs/PC3_CONTRACT.md 编写。代码展示实际关键实现,省略 import、组件模板或外围方法的片段不是独立完整工程。

本阶段交付订单中心、订单详情、接单与配送操作,以及工作台待办闭环。通知中心和 WebSocket 在下一阶段接入;资金、报表和运维页面仍未交付。文章引用阶段验收结果,没有重新运行测试或验证 Vditor 渲染。

1. 订单页为什么比普通管理表格更容易写错

PC-2 的商品编辑通常从一份草稿开始,最后提交一次资料修改。

订单页面的情况不同:员工正在看详情时,顾客可能取消,另一位员工可能接单,支付或退款结果也可能到达。

订单是一份持续变化的业务事实,而且某些变化需要外部支付渠道确认。

于是,几个看起来合理的页面实现都会出问题:

  • 点“接单”就先把状态改成待配送。
  • 取消接口返回 200,就显示退款成功。
  • 详情一直自动刷新,确认框提交时使用最新 version。
  • 订单显示已取消,就永远停止核对退款状态。
  • 工作台只查今天的待接单,跨日未处理订单从入口消失。
  • 列表没返回电话,就逐行请求详情,把地址资料全部拉到浏览器。

PC-3 围绕这些实际竞争场景组织页面,而不是先做一张有按钮的表格,再逐个补异常。

2. 页面权限与业务动作先建立对应关系

订单作业允许 ADMIN 和 STAFF,顾客档案仍只对 ADMIN 开放。

入口 能力 权限
/orders 组合筛选、服务端分页、进入详情 ADMIN / STAFF
/orders/:id 刷新可恢复的订单详情 ADMIN / STAFF
详情操作区 接单、拒单、取消、配送、完成 ADMIN / STAFF,服务端再次检查
工作台待办 跳转对应状态订单、查看待接前五条 ADMIN / STAFF
顾客详情关联订单 按顾客 UUID 进入订单列表 ADMIN

本阶段没有管理员创建订单、模拟支付、任意退款金额、打印、分配配送员或自由文本取消原因。

paymentId 与 refundId 可以展示和复制,但不会链接到还未交付的资金页面。

权限决定谁可以发起尝试,订单状态与资源版本决定该次尝试是否还能成立。两个维度都必须检查。

3. orders 模块怎样与工作台协作

模块内部主要结构:

admin/src/modules/orders/
├── api/orders.ts
├── model/
│   ├── order-filter.ts
│   ├── order-state.ts
│   └── use-order-detail.ts
├── pages/OrdersPage.vue
├── ui/
│   ├── OrderDetailPanel.vue
│   └── AwaitingOrders.vue
└── index.ts

API 适配负责请求契约;筛选模型负责把输入变成有效查询;状态模型描述可见动作和显示文案;详情组合函数负责读取、确认、写入与取消的生命周期。

工作台通过 orders/index.ts 使用 AwaitingOrders,不直接进入订单模块内部调用一组零散方法。

flowchart LR
  WORK[workspace 工作台] --> PUBLIC[orders 公开入口]
  PUBLIC --> LIST[AwaitingOrders]
  PAGE[OrdersPage] --> FILTER[筛选与路由模型]
  PAGE --> DETAIL[详情生命周期]
  DETAIL --> API[订单 API]
  LIST --> API
  API --> HTTP[统一认证客户端]

前端没有复制后端聚合来执行付款或退款规则。这里的状态表服务于界面可见性,服务端领域规则仍然是最终依据。

4. 列表查询的含义,需要精确到“是哪一个手机号”

订单列表使用 GET /api/v1/management/orders,支持状态、完整订单 UUID、顾客 UUID、收货手机号、创建时刻和分页。

所有条件在数据库中按 AND 组合。默认不限制日期,普通列表每页二十条,可切换五十条。

条件 实际含义
orderId 完整订单 UUID 精确匹配
customerId 下单顾客的内部标识
phone 成交时收货快照中的电话
from/to 订单创建时刻的左闭右开区间
status 当前订单状态

顾客账号手机号、地址簿当前手机号与历史订单收货电话可能不同。

如果用户替家人点餐,用当前顾客手机号检索收货电话,就会漏掉这份订单。页面标签因此明确写“收货手机号”。

列表只显示摘要已有字段,不逐行补查姓名、电话、菜品与支付信息。进入某一订单详情时再读取对应快照。

5. 非法筛选必须阻止查询,不能悄悄变成全部订单

如果用户输入了八位短 UUID,页面不能把它当成空值,然后发出一个无条件列表请求。

这样虽然“有数据了”,却完全背离用户输入,还会扩大本次读取范围。

实际筛选编译先做校验:

if (value.status && !Object.hasOwn(statuses, value.status))
  throw new ApiProblem(400, '订单状态不合法')
if ([value.orderId, value.customerId].some((id) => id && !uuidPattern.test(id)))
  throw new ApiProblem(400, '请输入完整有效的订单或顾客 UUID')
if (value.phone && !/^\+?[1-9][0-9]{6,14}$/.test(value.phone))
  throw new ApiProblem(400, '请输入完整收货手机号')

这是 compileFilter 的片段,value 是去除输入首尾空白后的对象。

日期还要检查实际日历值,起日不能晚于结束日期;路由分页检查非负整数和允许的 size,非法值显示错误。

列表查询显式依赖这个错误状态:

enabled: computed(() => !filterError.value),

界面可以把 UUID 缩略成前八位与后四位,但复制和请求始终使用完整值。显示便利不应改变资源身份。

6. 草稿筛选、已应用筛选与路由,是三份不同职责的状态

用户输入到一半,不代表已经决定应用新条件。

页面使用 draft 保存正在填写的输入,applied 保存上次明确查询的条件,URL 保存可恢复的非电话条件。

flowchart LR
  INPUT[筛选输入 draft] -->|点击查询并校验| APPLIED[applied]
  APPLIED --> REQUEST[真实 API 查询]
  APPLIED --> URL[URL 中的状态 UUID 日期 分页]
  URL -->|刷新或后退| RESTORE[恢复可序列化条件]
  RESTORE --> APPLIED

自动刷新只使用 applied,不把用户刚输入一半的电话号码发送出去,也不覆盖正在填写的 draft。

手机号保留在当前页面内存,不写入 URL、查询键或持久化缓存。刷新页面后,它不会从 URL 自动恢复。

实际查询键:

const query = useQuery({
  queryKey: computed(() => [
    'orders-list', scope, filterRevision.value, page.value, size.value,
  ]),
  enabled: computed(() => !filterError.value),
  queryFn: ({ signal }) =>
    ordersApi.list(compileFilter(applied.value), page.value, size.value, signal),
  gcTime: 0,
})

scope 隔离本次页面实例,filterRevision 代表应用筛选的变化;公共前缀 orders-list 又允许订单写入后统一使相关查询失效。

这没有把 UUID 变成不敏感数据,也没有让手机号从网络请求里消失。它表达的是页面对 URL 和缓存键的具体保留策略。

7. 日期边界和工作台待办不能混为一谈

订单筛选沿用上海日历日期转 UTC 时刻的规则:起日零点包含,结束日期次日零点排除。

转换结束日期的实际代码:

to: value.toDate
  ? dayjs
      .tz(dayjs(value.toDate).add(1, 'day').format('YYYY-MM-DD'), 'Asia/Shanghai')
      .toISOString()
  : undefined,

这是返回筛选对象的片段。未选择日期时,from/to 都不传,不暗中补“今天”。

工作台有今日摘要,也有全店当前待办。这两个概念不同:

工作台信息 时间口径
今日经营摘要 服务端经营日期对应的统计
当前待接单、待配送、配送中 当前还需要处理的订单,跨所有日期
待接订单前五条 权威订单列表中的当前 PAID 订单

前一天留下的待办不应该因为午夜已过而从工作入口消失。

8. 详情放进路由,才有真正的刷新与后退语义

如果详情只存在于 selectedOrder 变量,刷新页面就会丢失选择,也无法分享准确的站内目标。

PC-3 使用 /orders/:id 表达当前详情,query 保存列表筛选与分页。

打开详情的实际代码:

async function open(id: string) {
  if (!busy.value)
    await router.push({
      path: `/orders/${id}`,
      query: filterQuery(applied.value, page.value, size.value),
    })
}

关闭详情时回到 /orders,继续携带这些列表参数。浏览器后退也可以恢复先前的非电话筛选。

宽度至少 1366 时,详情与列表并排;较窄桌面使用抽屉。两种布局使用同一份详情生命周期,不维护两套互相漂移的接单逻辑。

确认或提交期间,普通详情切换与路由离开被 busy 保护。会话失效仍允许安全退出。

9. 历史订单为什么不能展示当前商品和地址

顾客下单之后,商品可能改名、调价、下架,收货地址也可能被删除。

订单详情显示的是成交快照:商品名称、规格、单价、数量、小计、套餐组成、总金额和收货信息。

flowchart TD
  CATALOG[当前目录] -->|结算时生成快照| ORDER[订单快照]
  ADDRESS[结算时收货地址] -->|复制收货事实| ORDER
  ORDER --> DETAIL[管理端详情]
  EDIT[后续商品或地址修改] --> CATALOG

图中详情的数据来源只有订单快照。后续修改不会沿着实时目录查询反向覆盖历史订单。

付款前金额显示为“订单金额”;只有 paidAt 已存在时才显示“实付金额”。页面不自行拆出接口没有提供的运费、优惠或包装费。

订单电话默认掩码,切换或关闭详情后恢复掩码。完整资料只在当前详情明确展开。

10. 时间线要来自服务端时刻,不能靠状态补造

实际时间线函数:

export function timeline(order: OrderDetail) {
  return [
    { label: '已下单', time: order.createdAt },
    { label: '支付成功', time: order.lifecycle?.paidAt },
    { label: '商家接单', time: order.lifecycle?.acceptedAt },
    { label: '开始配送', time: order.lifecycle?.deliveredAt },
    { label: '订单完成', time: order.lifecycle?.completedAt },
    { label: '订单取消', time: order.cancelledAt },
  ].filter((value) => !!value.time)
}

这里的 deliveredAt 按当前后端契约表达开始配送,完成对应 completedAt,不能只凭英文名称自行换成送达时间。

过滤掉没有值的节点,意味着“服务器未提供这个时刻”,不意味着事件一定从未发生。尤其不能用浏览器当前时间补一个退款成功节点。

催单次数、最近催单时刻、固定取消原因与独立退款状态,也都依据服务端详情展示。

11. 动作表应该完整,也应该对未知状态关闭写入口

本阶段状态与动作对应关系:

状态 页面文字 合法动作
UNPAID 待付款 只读
PAID 待接单 接单、拒单并退款、取消并退款
ACCEPTED 待配送 开始配送、取消并退款
DELIVERING 配送中 确认完成
COMPLETED 已完成 只读
CANCELLING 取消处理中 读取进度
REFUNDING 退款处理中 读取进度
CANCELLED 已取消 只读,继续展示独立退款状态

实际函数很短:

export function availableActions(status?: string): OrderAction[] {
  switch (status) {
    case 'PAID':
      return ['acceptance', 'rejection', 'cancellation']
    case 'ACCEPTED':
      return ['delivery', 'cancellation']
    case 'DELIVERING':
      return ['completion']
    default:
      return []
  }
}

未知状态不会获得默认接单按钮。后端新增状态时,旧前端可以显示未知并保持只读,而不是猜测一个最相近的操作。

商家不能取消未付款订单,也不能在开始配送后继续通过本阶段商家取消动作处理它。

这一表只决定前端何时提供入口。用户直接调用 API 时,后端仍会检查身份、版本和聚合状态。

12. 确认框必须冻结用户实际确认的版本

假设员工看到订单版本 4,点击接单打开确认框。另一位员工此时已经取消订单,轮询读到了版本 5。

如果确认框点击确定时从响应式变量里随手取最新 version,就可能把“我确认刚才那份订单”变成“我确认后来自动变化的订单”。

实际代码在打开确认框之前保存快照标识:

const snapshot = revision(order.value!),
  epoch = generation,
  session = sessionBridge.snapshot().generation
confirming.value = true

确认内容展示完整订单编号与业务影响。确认和提交期间停止详情轮询,禁止对这份订单发起第二个写命令。

确认返回后再校验归属:

confirming.value = false
if (!confirmed || disposed || generation !== epoch || !sessionBridge.isCurrent(session))
  return

然后使用被冻结的 id/version:

const updated = await ordersApi.act(snapshot.id, action, snapshot.version)
sequenceDiagram
  participant A as 员工A页面
  participant B as 员工B
  participant S as 订单服务
  A->>S: GET 订单
  S-->>A: PAID,version=4
  A->>A: 打开确认框,冻结版本4
  B->>S: 按版本4接单
  S-->>B: ACCEPTED,新版本
  A->>S: 仍按版本4提交接单
  S-->>A: 409
  A->>A: 保留详情,阻止重复写入
  A->>S: 用户显式重新读取
  S-->>A: 返回真实状态与可重新判断的版本

页面暂停轮询不会锁住服务器。并发写入仍然可能发生,version 正是用于发现这种变化。

13. 命令只发送意图与版本,不发送想象中的最终状态

订单动作 API 的构造片段:

const options = { params: { path: { id } }, body: { version } }
switch (action) {
  case 'acceptance':
    return resource(await api.POST('/api/v1/management/orders/{id}/acceptance', options))
  case 'rejection':
    return resource(await api.POST('/api/v1/management/orders/{id}/rejection', options))
  case 'cancellation':
    return resource(await api.POST('/api/v1/management/orders/{id}/cancellation', options))
  case 'delivery':
    return resource(await api.POST('/api/v1/management/orders/{id}/delivery', options))
  case 'completion':
    return resource(await api.POST('/api/v1/management/orders/{id}/completion', options))
}

这是 ordersApi.act 方法内部实现。请求只有 {version},动作由资源路径表达。

不提交 {status: 'COMPLETED'} 这种任意状态修改,也不附带前端决定的退款金额或支付成功标识。

成功后使用返回的新详情,再使订单列表和工作台查询失效。即使订单已经离开当前筛选,也保留更新后的详情,让员工确认操作结果。

例如在“待接单”列表接单成功后,该行可能从列表消失,但右侧详情应继续显示“待配送”,而不是突然关闭造成“到底接上没有”的疑问。

14. 拒单与取消为什么只能先显示“已提交”

订单服务已经受理取消,不代表外部渠道退款已经完成。

实际成功提示依据响应状态选择:

message.success(
  updated.status === 'REFUNDING' || updated.status === 'CANCELLING'
    ? '已提交,等待服务端确认'
    : `${actions[action].label}成功`,
)

退款状态独立显示 NONE、PENDING、SUCCEEDED,对应无需退款、退款处理中、已退款。

可以用两个维度理解详情:

OrderView = (OrderStatus, RefundStatus)

例如 (CANCELLED, PENDING) 并不必然矛盾:订单已经取消,迟到付款仍可能需要继续退款。

前端不能因为订单终态就隐藏正在处理的退款,更不能把拒单按钮点下去的时刻当作钱已退回顾客账户的时刻。

支付、退款的渠道请求和结果验签仍由 P5 后端处理;本阶段前端没有直接连接支付宝,也没有模拟支付成功入口。

15. 写入冲突和响应丢失,都会把详情置于保护状态

订单操作失败后,详情生命周期保留当前内容并设置 blocked。

与 PC-2 按错误类别计算 needsReload 相比,这里的订单写操作失败处理更保守:失败后要求显式重新读取,重新确定当前动作。

自动详情读取入口首先检查:

if (busy.value || !id.value || (blocked.value && !explicit)) return

只有显式读取成功,才解除保护:

order.value = value
if (explicit) {
  writeError.value = null
  blocked.value = false
}

这里不能让十五秒轮询偷偷清除 blocked。否则用户刚看到“结果未知”,按钮稍后又自动恢复,容易误以为原操作确定失败。

stateDiagram-v2
  [*] --> Reading
  Reading --> Ready: 详情完整
  Ready --> Confirming: 用户选择动作
  Confirming --> Ready: 取消确认
  Confirming --> Writing: 确认且上下文未变
  Writing --> Ready: 返回有效新详情
  Writing --> Blocked: 冲突或写入失败
  Blocked --> Reading: 用户显式重新读取

这张图是页面命令生命周期,不是订单领域状态机。

16. 详情请求需要两层异步归属保护

PC-1 的会话代数防止旧账号响应污染新账号。但同一个员工快速打开 A、B 两个订单,还需要页面自身的请求代数。

读取核心片段:

controller?.abort()
controller = new AbortController()
const epoch = ++generation
loading.value = true
loadError.value = null
try {
  const value = await ordersApi.detail(id.value, controller.signal)
  if (epoch !== generation || disposed) return
  if (value.id !== id.value || !Number.isSafeInteger(value.version))
    throw new ApiProblem(502, '订单详情或版本不完整,请重新读取')
  order.value = value
  // 此处省略显式重读成功后清除写入保护的分支。
}

这是 try 主体摘录,完整方法还包含 catch/finally。取消信号尽快停止旧请求,generation 则拒绝无法及时取消的迟到结果。

需要检查响应 id 与当前路由 id 相同,也需要有效版本。不能只因为响应是一个对象就开放操作区。

失败情况 当前详情处理
暂时网络或服务故障 保留已知内容,显示错误,禁用写入
403 / 404 清除已经无权读取或不存在的详情
旧请求被取消 不显示为当前业务错误
切换订单后旧响应到达 忽略旧结果

保留旧内容不代表继续相信它可以用于修改。显示与可写是两个独立判断。

17. 十五秒轮询怎样避免重叠与后台空转

本阶段尚未连接通知流,列表、工作台和活动订单详情通过可见轮询核对状态。

共享 useVisiblePoll 使用 setTimeout 调度,同时维护 running:

async function tick() {
  if (disposed || running || document.visibilityState !== 'visible') return
  schedule()
  if (!enabled()) return
  running = true
  try {
    await task()
  } finally {
    running = false
    schedule()
  }
}

schedule 会清理旧计时器,在页面仍可见且没有卸载时安排下一轮,默认间隔十五秒。

这样不把一个很慢的查询叠成多个并发轮询。页面隐藏时暂停,恢复可见时立即核对;卸载时清理计时器和 visibilitychange 监听。

十五秒是客户端核对策略,不是订单状态同步延迟的严格上界,也不代表所有标签页都在后台持续请求。

终态停止轮询还要检查退款

实际函数:

export function shouldPoll(order: OrderDetail) {
  return (
    ['UNPAID', 'PAID', 'ACCEPTED', 'DELIVERING', 'CANCELLING', 'REFUNDING'].includes(
      order.status || '',
    ) || order.lifecycle?.refundStatus === 'PENDING'
  )
}

完成和取消终态默认停止详情轮询,但只要独立退款仍为 PENDING,就继续核对。

详情确认中、写入中、正在读取或冲突后未重读时,轮询也不会覆盖用户当前上下文。

18. 工作台投影与权威订单短暂不同,应该如实呈现

工作台摘要来自 reporting 投影,待接订单列表来自 ordering 的权威查询。

实际待接列表:

const query = useQuery({
  queryKey: ['orders-list', 'awaiting'],
  queryFn: ({ signal }) => ordersApi.list({ status: 'PAID' }, 0, 5, signal),
})

这里没有日期过滤,也没有根据统计数字在浏览器伪造五条订单。

投影还未消费最新事件时,摘要可能短暂落后。前端应展示真实更新时间和真实订单,不自行加减统计,让两个来源“看起来一致”。

订单列表也不能用前五条长度代表全店待办总量。

如果工作台投影暂不可用,仍提供直接进入订单中心的入口。查看经营摘要失败,不应该把员工履约路径一并封死。

19. 又一次 OpenAPI 同名覆盖,说明契约也需要测试

PC-3 发现订单摘要与报表汇总都使用 Summary 名称,生成文档时发生覆盖,导致 History.items 引用了报表字段。

前端如果据此生成类型,可能得到“订单列表项有营业额汇总,却没有订单摘要字段”的错误结构。

修复把订单应用 DTO 明确命名为 OrderSummary,并验证 History.items 的引用和字段集,再重新生成前端契约类型。

JSON 字段、URL、数据库与领域规则不变,没有新增迁移。

PC-2 的 StatusChange 和本阶段的 Summary 说明:检查 /v3/api-docs 能否返回 200 远远不够,还要验证关键资源真的指向正确 schema。

20. 用可控时序验证确认框,而不只验证按钮点击

一个有效的测试需要让确认框停在那里,再主动触发第二次点击和详情刷新。

实际测试片段:

it('确认期间不刷新版本,重复点击只发一次命令', async () => {
  let approve!: () => void
  mocks.confirm.mockImplementation((options) => (approve = options.onOk))
  const { model } = setup()
  await flushPromises()
  const command = model.act('acceptance')
  expect(model.busy.value).toBe(true)
  await model.act('acceptance')
  await model.load()
  expect(mocks.detail).toHaveBeenCalledTimes(1)
  approve()
  await command
  expect(mocks.act).toHaveBeenCalledExactlyOnceWith(first, 'acceptance', 4)
  expect(model.order.value?.status).toBe('ACCEPTED')
})

setup、first 和 mocks 由测试文件的装配代码提供。这里关注的是行为:确认期间没有新详情读取,最终命令仍带版本 4。

其余用例主动制造这些顺序:

  • A 订单先请求,B 订单先返回,最后才让 A 返回。
  • 409 后触发自动读取,确认它不能解除保护。
  • 确认框打开后改变订单 id,再点确认,不能发出原命令。
  • 退款受理返回 REFUNDING/PENDING,只显示等待确认。
  • 详情返回 404 后,再次尝试动作,不能继续写。

这些测试比“mock 请求立即成功,然后按钮变灰”更能覆盖真实竞争。

21. 真实浏览器测试里的付款事实,不等于新的渠道实付验收

本阶段真实浏览器测试继续运行后端 JAR 和独立 PostgreSQL 临时 schema。

为了稳定覆盖八种状态与履约竞争,测试脚本通过专用 _test 连接准备历史付款等事实,只允许访问运行器登记的随机 pc1_ schema。

这不是业务 HTTP 模拟支付接口,脚本也不进入生产构建。

测试能证明这些真实持久化状态下的查询、权限、版本竞争和履约行为;不能据此声称浏览器已经通过支付宝完成了新一轮沙箱实付。

渠道创建、签名、查询、通知和退款仍由 P5 既有测试覆盖。

浏览器场景 验证重点
普通员工完整履约 从工作台进入,接单、配送、完成并刷新恢复
拒单与取消 真实受理结果不显示伪退款成功
二十一条订单分页 服务端总量与跨页结果
组合筛选 完整 UUID、成交快照电话、日期、状态
两次版本竞争 陈旧命令被拒绝,重读后才恢复操作
响应丢失 服务端可能已执行,客户端不重复写
跨日待办 工作台入口不隐式限制今天
可见详情刷新 读到其他员工更新,不恢复失效动作

2026-09-20 阶段最终记录:155 项后端测试、40 项前端单元测试、25 项真实浏览器测试全部通过,无跳过

统一入口仍为 ./scripts/verify.sh,覆盖格式、Checkstyle、架构、类型、契约一致性和生产构建。设计记录见 admin/design-qa-pc3.md

该轮浏览器记录到一次 ResizeObserver 通知,业务与可视区域断言通过,没有过滤异常或降低门禁;远程 CI 尚未执行。文章不把这份阶段记录描述成刚刚重新运行的结果。

22. 订单可操作以后,下一步怎样接入实时提示

到 PC-3,员工已经能从当前待办进入具体订单,依据快照确认动作,并在并发和未知结果下恢复。

下一阶段要减少发现新订单和催单的等待时间,但 WebSocket 不会改变这里的规则:通知到达后仍要读取权威订单,旧通知不能恢复旧动作,收到提醒也不能自动接单。

读者练习

练习一:冻结版本。 确认框打开后,另一员工已经接单。为什么应该让旧版本请求冲突,而不是静默换成新版本后再执行?

练习二:取消与退款。 (CANCELLED, PENDING) 应该显示什么?什么条件下可以停止详情轮询?

练习三:结果未知。 接单已提交,但浏览器收不到响应。为什么自动轮询不能悄悄解除写入保护?用户明确重读后应该看到哪些变化?

练习四:快照电话。 顾客修改账号手机号和地址簿之后,用哪个电话仍能找到旧订单?详情应该重新查询地址簿吗?

练习五:跨日待办。 工作台“今日订单”与“当前待接单”各自有什么日期口径?为什么不能共用一个默认今天的过滤器?

练习六:通知来了。 假设收到一条五分钟前的来单通知,但该订单已经完成。通知应直接设置 PAID,还是打开详情重新读取?

下一篇进入 PC-4,区分实时唤醒、HTTP 连续补查与员工显式已读,并把这些资源纳入完整会话生命周期。

实现对照

  • 阶段契约:docs/PC3_CONTRACT.md
  • 筛选与状态:admin/src/modules/orders/model/order-filter.tsorder-state.ts
  • 详情生命周期:admin/src/modules/orders/model/use-order-detail.ts
  • 可见轮询:admin/src/shared/lib/use-visible-poll.ts
  • 工作台待接列表:admin/src/modules/orders/ui/AwaitingOrders.vue
  • 真实浏览器验收:admin/e2e/orders.spec.ts

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

上一篇:第10篇 · 下一篇:第12篇