# 确认订单页（多商品）— 前端技术方案（C 端 · shop-app）

> **依据：** 《确认订单页（多商品）功能需求.md》v1.1、《确认订单页（多商品）技术方案.md》v1.1  
> **关联：** 《购物车前端技术方案》、《确认订单页（单商品）前端技术方案》v1.1（接口同源）  
> **范围：** 消费者 APP **`shop-app`** 购物车 **同店去结算** 确认页、支付结果交互；**不** 改后端、**不** 实现我的订单列表。  
> **实现状态：** 页面与 API 已落地（v1.0）；支付为 **Mock 弹窗** + `pay`/`cancel` 接口，待联调。

---

## 1. 技术栈与约定

| 项 | 说明 |
|----|------|
| 框架 | uni-app **Vue 3** + **uview-plus** |
| 请求 | `@/utils/request`；须会员 Token |
| 鉴权 | 进入页 **`ensureApiToken()`**；无 Token → 引导登录 |
| 规格 v1 | **统一规格**；`specText` 读购物车快照，展示用 `parseCartSpecText` |
| 数据源 | **`source=CART`**；preview/submit 带 **cartItemIds** + **items 多行** |
| 与单商品分工 | 单商品走 `checkout.vue`（`BUY_NOW`）；本页 **独立路由** `checkout-cart.vue` |
| 参考 | `subpackage/order/checkout.vue`（地址/金额/支付）、`pages/cart/index.vue`（去结算入口） |

---

## 2. 页面与路由

| 页面 | 路径 | 入口 |
|------|------|------|
| 确认订单（多商品） | `subpackage/order/checkout-cart` | 购物车 Tab · **去结算** |
| 支付结果（共用） | `subpackage/order/pay-result` | 支付成功/关闭后跳转 |

**Query / 入参：**

| 参数 | 必填 | 说明 |
|------|:----:|------|
| cartItemIds | ✓ | 逗号分隔的购物车行 ID，如 `12,34,56` |

> **兜底：** 同时写入 `uni.setStorageSync('cart_checkout_item_ids')`；query 缺失时从 storage 读取。

**路径常量：**

```javascript
// utils/pageRoute.js
export const PAGE_CHECKOUT_CART = '/subpackage/order/checkout-cart'
export const PAGE_PAY_RESULT = '/subpackage/order/pay-result'
```

**分包注册：** `pages.json` → `pkg-order` 增加 `checkout-cart`；`preloadRule` 为 `pages/cart/index` 预加载 `pkg-order`。

---

## 3. 文件清单

| 类型 | 路径 | 说明 |
|------|------|------|
| 确认页 | `shop-app/subpackage/order/checkout-cart.vue` | 地址 + 多商品 + 金额 + 行备注 + 提交 + Mock 支付 |
| 支付结果 | `shop-app/subpackage/order/pay-result.vue` | 与单商品共用 |
| 地址条 | `shop-app/components/order/AddressBar.vue` | 展示/点击切换（复用） |
| 多商品行 | `shop-app/components/order/CheckoutMultiGoodsRow.vue` | 规格列表 + 改量 + **行备注** |
| 导航 | `shop-app/utils/checkoutNav.js` | `goCartCheckout(cartItemIds)` |
| API | `shop-app/api/checkout.js` | preview / submit |
| API | `shop-app/api/cart.js` | `prepareCartCheckout`（进页前门禁） |
| API | `shop-app/api/order.js` | pay / cancel |
| 映射 | `shop-app/utils/checkoutDisplay.js` | `mapCheckoutPreviewCart`、`mapGoodsItem` |
| 常量 | `shop-app/constants/checkout.js` | `SOURCE_CART`、`CART_CHECKOUT_IDS_KEY`、备注上限 |

**已改上游：**

| 文件 | 改动 |
|------|------|
| `pages/cart/index.vue` | `onCheckout`：`prepareCartCheckout` → `goCartCheckout(ids)` |
| `pages.json` | 注册 `checkout-cart`；购物车预加载 `pkg-order` |
| `utils/pageRoute.js` | `PAGE_CHECKOUT_CART` |

---

## 4. 接口封装

### 4.1 进页前门禁 `api/cart.js`

| 方法 | HTTP | 路径 | 说明 |
|------|------|------|------|
| `prepareCartCheckout(cartItemIds)` | POST | `/api/cart/checkout/prepare` | 同店、四条件、库存等轻量校验 |

购物车 **去结算** 先调此接口，成功后再跳转确认页。

### 4.2 确认页 `api/checkout.js`

| 方法 | HTTP | 路径 | 说明 |
|------|------|------|------|
| `previewCheckout(data)` | POST | `/api/checkout/preview` | 进入/改量/换地址 |
| `submitCheckout(data)` | POST | `/api/checkout/submit` | 提交订单 |

**preview Body（CART）：**

```javascript
{
  source: 'CART',
  addressId: 123,              // 可选；不传用默认地址
  cartItemIds: [12, 34, 56],
  items: [                     // 改量时带上最新 quantity
    { cartItemId: 12, goodsId: 1, quantity: 2 },
    { cartItemId: 34, goodsId: 2, quantity: 1 }
  ]
}
```

**submit Body（CART）：**

```javascript
{
  source: 'CART',
  addressId: 123,              // 必填
  payType: '1',                // 微信支付
  cartItemIds: [12, 34, 56],
  items: [
    {
      cartItemId: 12,
      goodsId: 1,
      quantity: 2,
      buyerRemark: '尽快发货'   // 每行独立备注
    },
    { cartItemId: 34, goodsId: 2, quantity: 1, buyerRemark: '' }
  ]
}
```

### 4.3 支付 `api/order.js`（复用单商品）

| 方法 | HTTP | 路径 | 说明 |
|------|------|------|------|
| `payOrder(orderId)` | POST | `/api/order/{orderId}/pay` | v1 Mock 直接成功 |
| `cancelPay(orderId)` | POST | `/api/order/{orderId}/pay/cancel` | 用户取消收银台 |

### 4.4 地址（复用）

| 方法 | HTTP | 路径 |
|------|------|------|
| `getAddressList` | GET | `/api/member/address/list` |

维护地址 → 跳转 `subpackage/account/address-edit?mode=add`。

---

## 5. 页面结构（checkout-cart.vue）

```text
确认订单 checkout-cart.vue
├── 导航栏「确认订单」
├── AddressBar（整单统一地址）
├── 店铺头（avatar + shopName，一单同店）
├── goods-card
│     └── CheckoutMultiGoodsRow × N
│           ├── 主图、名称、specList、serviceDesc
│           ├── 单价、数量 stepper（1～maxStock）
│           ├── 行小计
│           └── 行备注 input（≤200 字）
├── 金额区
│     ├── 商品总价 goodsAmountText
│     ├── 运费 freightAmountText + freightDesc
│     └── （应付在底栏展示）
├── 支付方式（微信支付，固定选中）
└── 底栏：应付 payAmountText + 「提交订单」
```

与单商品页差异：**无整单备注区**；备注在 **每个商品行** 内填写。

---

## 6. 交互流程

### 6.1 购物车 → 确认页

```text
cart · 去结算
    → 校验：至少勾选 1 件可购商品、且仅 1 家店（跨店 Toast）
    → prepareCartCheckout(cartItemIds)
    → goCartCheckout(ids)
         · setStorage cart_checkout_item_ids
         · navigateTo PAGE_CHECKOUT_CART?cartItemIds=12,34
checkout-cart onLoad
    → 解析 cartItemIds（query 或 storage）
checkout-cart onShow
    → previewCheckout({ source: 'CART', cartItemIds, items? })
    → mapCheckoutPreviewCart → 渲染 address / shop / goodsList / 金额
```

### 6.2 改数量

```text
用户在某行 +/- quantity
    → 本地更新该行 quantity（≥1，≤ maxStock）
    → debounce 300ms → previewCheckout（带 addressId + 全量 items 数量）
    → 刷新各行 subtotal、goodsAmount / freight / payAmount
```

### 6.3 行备注

```text
用户在行内 input 输入 buyerRemark
    → 仅更新本地 preview.goodsList[].buyerRemark
    → **不** 触发 preview（提交时一并带上）
    → preview 刷新时通过 remarkMap 保留已输入备注
```

### 6.4 切换地址

```text
点击 AddressBar
    → 弹层 getAddressList()
    → 选择 addressId → debounce preview
    → 运费可能变化，刷新应付
无地址
    → 展示空态 / submit 拦截「请选择收货地址」
    → 引导跳转新增地址
```

### 6.5 提交与支付

```text
点击「提交订单」
    → 校验 addressId
    → submitCheckout（每行 buyerRemark）
    → 成功得 orderId → Mock 支付弹窗
        ├── 确认支付 → payOrder → pay-result?status=success
        │     → 清除 cart_checkout_item_ids storage
        └── 取消支付 → cancelPay → pay-result?status=closed
```

| 规则 | 前端 |
|------|------|
| 防重复提交 | submit/pay 中 loading + 按钮 disabled |
| 取消关闭订单 | 弹窗「取消支付」→ cancelPay |
| 库存/下架失败 | 展示后端 `msg`；不生成订单 |
| 支付成功删购物车行 | **后端** 处理；前端支付成功后返回购物车 Tab 将看到行已消失 |

---

## 7. 数据映射 `checkoutDisplay.js`

```javascript
/** preview data → 多商品页面模型 */
export function mapCheckoutPreviewCart(data, remarkMap = {}) {
  const base = mapShopAndAddress(data)  // shop、address、金额文案
  const goodsList = (data.items || []).map((row) => {
    const item = mapGoodsItem(row)      // specList、priceText、maxStock…
    if (item.cartItemId && remarkMap[item.cartItemId] != null) {
      item.buyerRemark = remarkMap[item.cartItemId]  // 保留用户已填备注
    }
    return item
  }).filter(Boolean)
  return { ...base, goodsList }
}
```

`mapGoodsItem` 内 `parseCartSpecText(specText)` 将 `§` 分隔规格拆成列表展示。

---

## 8. 导航工具 `checkoutNav.js`

```javascript
import { PAGE_CHECKOUT_CART } from '@/utils/pageRoute'
import { CART_CHECKOUT_IDS_KEY } from '@/constants/checkout'

export function goCartCheckout(cartItemIds) {
  const ids = (cartItemIds || []).filter(Boolean)
  if (!ids.length) return
  uni.setStorageSync(CART_CHECKOUT_IDS_KEY, ids)
  uni.navigateTo({
    url: `${PAGE_CHECKOUT_CART}?cartItemIds=${ids.join(',')}`
  })
}
```

---

## 9. 与单商品确认页分工

| 项 | 单商品 `checkout` | 多商品 `checkout-cart` |
|----|---------------------|------------------------|
| 入口 | 商品详情 · 立即购买 | 购物车 · 去结算 |
| source | `BUY_NOW` | `CART` |
| 入参 | `goodsId/quantity/specText` | `cartItemIds` |
| 商品行 | 1 行 `CheckoutGoodsRow` | N 行 `CheckoutMultiGoodsRow` |
| 备注 | **整单一条** textarea | **每商品一行** input |
| 进页门禁 | 详情 `ensureCanPurchase` | `prepareCartCheckout` |
| 支付结果 | 共用 `pay-result` | 同上 |

---

## 10. 联调检查清单

| # | 项 |
|---|-----|
| 1 | 未登录点去结算 → 引导登录 |
| 2 | 跨店勾选 → Toast「请选择同一店铺的商品结算」 |
| 3 | prepare 失败（下架/库存不足）→ 不跳转确认页 |
| 4 | preview 返回多行、默认地址、同店 shop 头 |
| 5 | 改某行数量后总价/运费/应付刷新 |
| 6 | 换地址后模版运费变化 |
| 7 | 各行备注提交后写入订单（后端校验） |
| 8 | submit → pay → 待发货；购物车对应行清除 |
| 9 | cancelPay → 订单已关闭 |
| 10 | cartItemIds 缺失 → 提示并返回 |

---

## 11. 非本期

| 项 | 说明 |
|----|------|
| 微信 JSAPI 真实唤起 | Mock pay 接口 |
| 我的订单列表续付 | 另册 |
| 跨店合单 | 业务不支持 |
| 整单备注（多商品） | 需求为 **行备注** |
| 单页合并 checkout + mode | v1 独立路由，后续可合并 |

---

## 12. 修订记录

| 版本 | 说明 |
|------|------|
| **v1.0** | 首版：checkout-cart 页面、CheckoutMultiGoodsRow、购物车跳转、CART preview/submit、前端技术方案 |

---

*文档版本：v1.0 · 工程目录 `shop-app` · 不修改《确认订单页（多商品）技术方案.md》（后端方案）*
