xsh_1997 1 settimana fa
parent
commit
a43c45780c

+ 15 - 12
doc/消费者APP/确认订单页(单商品)(取消支付前)/确认订单页(单商品)前端技术方案.md

@@ -3,7 +3,7 @@
3 3
 > **依据:** 《确认订单页(单商品)功能需求.md》v1.0、《确认订单页(单商品)技术方案.md》v1.0  
4 4
 > **关联:** 《商品详情内页前端技术方案》v1.0、《我的服务前端技术方案》、《确认订单页(多商品)技术方案》v1.1(接口同源)  
5 5
 > **范围:** 消费者 APP **`shop-app`** 立即购买确认页、支付结果交互;**不** 改后端、**不** 实现购物车合单页、**不** 实现我的订单列表。  
6
-> **实现状态:** **待建设**;商品详情 `doBuyNow` 当前为占位 Toast
6
+> **实现状态:** 页面与 API 已落地(v1.1);支付为 **Mock 弹窗** + `pay`/`cancel` 接口,待联调
7 7
 
8 8
 ---
9 9
 
@@ -47,26 +47,28 @@ export const PAGE_PAY_RESULT = '/subpackage/order/pay-result'
47 47
 
48 48
 ---
49 49
 
50
-## 3. 文件清单(规划)
50
+## 3. 文件清单
51 51
 
52 52
 | 类型 | 路径 | 说明 |
53 53
 |------|------|------|
54
-| 确认页 | `shop-app/subpackage/order/checkout.vue` | 地址 + 商品 + 金额 + 备注 + 提交 |
55
-| 支付结果 | `shop-app/subpackage/order/pay-result.vue` | 成功/关闭文案(可选) |
56
-| 地址条 | `shop-app/components/order/AddressBar.vue` | 展示/切换地址 |
57
-| 商品行 | `shop-app/components/order/CheckoutGoodsRow.vue` | 单商品展示 + 改量 |
54
+| 确认页 | `shop-app/subpackage/order/checkout.vue` | 地址 + 商品 + 金额 + 备注 + 提交 + Mock 支付弹窗 |
55
+| 支付结果 | `shop-app/subpackage/order/pay-result.vue` | 成功/关闭结果页 |
56
+| 地址条 | `shop-app/components/order/AddressBar.vue` | 展示/点击切换 |
57
+| 商品行 | `shop-app/components/order/CheckoutGoodsRow.vue` | 单商品 + 规格列表 + 改量 |
58 58
 | API | `shop-app/api/checkout.js` | preview / submit |
59 59
 | API | `shop-app/api/order.js` | pay / cancel / detail |
60 60
 | 映射 | `shop-app/utils/checkoutDisplay.js` | preview VO → 页面模型 |
61
-| 常量 | `shop-app/constants/checkout.js` | `SOURCE_BUY_NOW`、错误文案 |
61
+| 常量 | `shop-app/constants/checkout.js` | `SOURCE_BUY_NOW`、`PAY_TYPE=1`、备注上限 |
62 62
 
63
-**改上游:**
63
+**改上游:**
64 64
 
65 65
 | 文件 | 改动 |
66 66
 |------|------|
67
-| `subpackage/goods/detail.vue` | `doBuyNow` → `navigateTo` checkout 带参 |
68
-| `pages.json` | 注册 `subpackage/order/*` |
69
-| `utils/goodsDetail.js` | 已有 `formatSpecDisplayText` |
67
+| `subpackage/goods/detail.vue` | `doBuyNow` → `PAGE_CHECKOUT` 带 `goodsId/quantity/specText` |
68
+| `pages.json` | 分包 `pkg-order` |
69
+| `utils/pageRoute.js` | `PAGE_CHECKOUT`、`PAGE_PAY_RESULT` |
70
+| `utils/goodsDetail.js` | `formatSpecDisplayText`(`§` 分隔规格) |
71
+| `utils/cartSpec.js` | `parseCartSpecText` 确认页规格展示复用 |
70 72
 
71 73
 ---
72 74
 
@@ -295,7 +297,8 @@ async function doBuyNow() {
295 297
 | 版本 | 说明 |
296 298
 |------|------|
297 299
 | **v1.0** | 首版:BUY_NOW 确认页路由、checkout/order API、详情跳转、支付取消闭环 |
300
+| **v1.1** | 落地 checkout/pay-result 页面与组件;规格展示复用 `parseCartSpecText` |
298 301
 
299 302
 ---
300 303
 
301
-*文档版本:v1.0 · 工程目录 `shop-app` · 关联《确认订单页(单商品)功能需求.md》v1.0、《确认订单页(单商品)技术方案.md》v1.0*
304
+*文档版本:v1.1 · 工程目录 `shop-app` · 不修改《确认订单页(单商品)技术方案.md》(后端方案)*

+ 328 - 0
doc/消费者APP/确认订单页(多商品)/确认订单页(多商品)前端技术方案.md

@@ -0,0 +1,328 @@
1
+# 确认订单页(多商品)— 前端技术方案(C 端 · shop-app)
2
+
3
+> **依据:** 《确认订单页(多商品)功能需求.md》v1.1、《确认订单页(多商品)技术方案.md》v1.1  
4
+> **关联:** 《购物车前端技术方案》、《确认订单页(单商品)前端技术方案》v1.1(接口同源)  
5
+> **范围:** 消费者 APP **`shop-app`** 购物车 **同店去结算** 确认页、支付结果交互;**不** 改后端、**不** 实现我的订单列表。  
6
+> **实现状态:** 页面与 API 已落地(v1.0);支付为 **Mock 弹窗** + `pay`/`cancel` 接口,待联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | uni-app **Vue 3** + **uview-plus** |
15
+| 请求 | `@/utils/request`;须会员 Token |
16
+| 鉴权 | 进入页 **`ensureApiToken()`**;无 Token → 引导登录 |
17
+| 规格 v1 | **统一规格**;`specText` 读购物车快照,展示用 `parseCartSpecText` |
18
+| 数据源 | **`source=CART`**;preview/submit 带 **cartItemIds** + **items 多行** |
19
+| 与单商品分工 | 单商品走 `checkout.vue`(`BUY_NOW`);本页 **独立路由** `checkout-cart.vue` |
20
+| 参考 | `subpackage/order/checkout.vue`(地址/金额/支付)、`pages/cart/index.vue`(去结算入口) |
21
+
22
+---
23
+
24
+## 2. 页面与路由
25
+
26
+| 页面 | 路径 | 入口 |
27
+|------|------|------|
28
+| 确认订单(多商品) | `subpackage/order/checkout-cart` | 购物车 Tab · **去结算** |
29
+| 支付结果(共用) | `subpackage/order/pay-result` | 支付成功/关闭后跳转 |
30
+
31
+**Query / 入参:**
32
+
33
+| 参数 | 必填 | 说明 |
34
+|------|:----:|------|
35
+| cartItemIds | ✓ | 逗号分隔的购物车行 ID,如 `12,34,56` |
36
+
37
+> **兜底:** 同时写入 `uni.setStorageSync('cart_checkout_item_ids')`;query 缺失时从 storage 读取。
38
+
39
+**路径常量:**
40
+
41
+```javascript
42
+// utils/pageRoute.js
43
+export const PAGE_CHECKOUT_CART = '/subpackage/order/checkout-cart'
44
+export const PAGE_PAY_RESULT = '/subpackage/order/pay-result'
45
+```
46
+
47
+**分包注册:** `pages.json` → `pkg-order` 增加 `checkout-cart`;`preloadRule` 为 `pages/cart/index` 预加载 `pkg-order`。
48
+
49
+---
50
+
51
+## 3. 文件清单
52
+
53
+| 类型 | 路径 | 说明 |
54
+|------|------|------|
55
+| 确认页 | `shop-app/subpackage/order/checkout-cart.vue` | 地址 + 多商品 + 金额 + 行备注 + 提交 + Mock 支付 |
56
+| 支付结果 | `shop-app/subpackage/order/pay-result.vue` | 与单商品共用 |
57
+| 地址条 | `shop-app/components/order/AddressBar.vue` | 展示/点击切换(复用) |
58
+| 多商品行 | `shop-app/components/order/CheckoutMultiGoodsRow.vue` | 规格列表 + 改量 + **行备注** |
59
+| 导航 | `shop-app/utils/checkoutNav.js` | `goCartCheckout(cartItemIds)` |
60
+| API | `shop-app/api/checkout.js` | preview / submit |
61
+| API | `shop-app/api/cart.js` | `prepareCartCheckout`(进页前门禁) |
62
+| API | `shop-app/api/order.js` | pay / cancel |
63
+| 映射 | `shop-app/utils/checkoutDisplay.js` | `mapCheckoutPreviewCart`、`mapGoodsItem` |
64
+| 常量 | `shop-app/constants/checkout.js` | `SOURCE_CART`、`CART_CHECKOUT_IDS_KEY`、备注上限 |
65
+
66
+**已改上游:**
67
+
68
+| 文件 | 改动 |
69
+|------|------|
70
+| `pages/cart/index.vue` | `onCheckout`:`prepareCartCheckout` → `goCartCheckout(ids)` |
71
+| `pages.json` | 注册 `checkout-cart`;购物车预加载 `pkg-order` |
72
+| `utils/pageRoute.js` | `PAGE_CHECKOUT_CART` |
73
+
74
+---
75
+
76
+## 4. 接口封装
77
+
78
+### 4.1 进页前门禁 `api/cart.js`
79
+
80
+| 方法 | HTTP | 路径 | 说明 |
81
+|------|------|------|------|
82
+| `prepareCartCheckout(cartItemIds)` | POST | `/api/cart/checkout/prepare` | 同店、四条件、库存等轻量校验 |
83
+
84
+购物车 **去结算** 先调此接口,成功后再跳转确认页。
85
+
86
+### 4.2 确认页 `api/checkout.js`
87
+
88
+| 方法 | HTTP | 路径 | 说明 |
89
+|------|------|------|------|
90
+| `previewCheckout(data)` | POST | `/api/checkout/preview` | 进入/改量/换地址 |
91
+| `submitCheckout(data)` | POST | `/api/checkout/submit` | 提交订单 |
92
+
93
+**preview Body(CART):**
94
+
95
+```javascript
96
+{
97
+  source: 'CART',
98
+  addressId: 123,              // 可选;不传用默认地址
99
+  cartItemIds: [12, 34, 56],
100
+  items: [                     // 改量时带上最新 quantity
101
+    { cartItemId: 12, goodsId: 1, quantity: 2 },
102
+    { cartItemId: 34, goodsId: 2, quantity: 1 }
103
+  ]
104
+}
105
+```
106
+
107
+**submit Body(CART):**
108
+
109
+```javascript
110
+{
111
+  source: 'CART',
112
+  addressId: 123,              // 必填
113
+  payType: '1',                // 微信支付
114
+  cartItemIds: [12, 34, 56],
115
+  items: [
116
+    {
117
+      cartItemId: 12,
118
+      goodsId: 1,
119
+      quantity: 2,
120
+      buyerRemark: '尽快发货'   // 每行独立备注
121
+    },
122
+    { cartItemId: 34, goodsId: 2, quantity: 1, buyerRemark: '' }
123
+  ]
124
+}
125
+```
126
+
127
+### 4.3 支付 `api/order.js`(复用单商品)
128
+
129
+| 方法 | HTTP | 路径 | 说明 |
130
+|------|------|------|------|
131
+| `payOrder(orderId)` | POST | `/api/order/{orderId}/pay` | v1 Mock 直接成功 |
132
+| `cancelPay(orderId)` | POST | `/api/order/{orderId}/pay/cancel` | 用户取消收银台 |
133
+
134
+### 4.4 地址(复用)
135
+
136
+| 方法 | HTTP | 路径 |
137
+|------|------|------|
138
+| `getAddressList` | GET | `/api/member/address/list` |
139
+
140
+维护地址 → 跳转 `subpackage/account/address-edit?mode=add`。
141
+
142
+---
143
+
144
+## 5. 页面结构(checkout-cart.vue)
145
+
146
+```text
147
+确认订单 checkout-cart.vue
148
+├── 导航栏「确认订单」
149
+├── AddressBar(整单统一地址)
150
+├── 店铺头(avatar + shopName,一单同店)
151
+├── goods-card
152
+│     └── CheckoutMultiGoodsRow × N
153
+│           ├── 主图、名称、specList、serviceDesc
154
+│           ├── 单价、数量 stepper(1~maxStock)
155
+│           ├── 行小计
156
+│           └── 行备注 input(≤200 字)
157
+├── 金额区
158
+│     ├── 商品总价 goodsAmountText
159
+│     ├── 运费 freightAmountText + freightDesc
160
+│     └── (应付在底栏展示)
161
+├── 支付方式(微信支付,固定选中)
162
+└── 底栏:应付 payAmountText + 「提交订单」
163
+```
164
+
165
+与单商品页差异:**无整单备注区**;备注在 **每个商品行** 内填写。
166
+
167
+---
168
+
169
+## 6. 交互流程
170
+
171
+### 6.1 购物车 → 确认页
172
+
173
+```text
174
+cart · 去结算
175
+    → 校验:至少勾选 1 件可购商品、且仅 1 家店(跨店 Toast)
176
+    → prepareCartCheckout(cartItemIds)
177
+    → goCartCheckout(ids)
178
+         · setStorage cart_checkout_item_ids
179
+         · navigateTo PAGE_CHECKOUT_CART?cartItemIds=12,34
180
+checkout-cart onLoad
181
+    → 解析 cartItemIds(query 或 storage)
182
+checkout-cart onShow
183
+    → previewCheckout({ source: 'CART', cartItemIds, items? })
184
+    → mapCheckoutPreviewCart → 渲染 address / shop / goodsList / 金额
185
+```
186
+
187
+### 6.2 改数量
188
+
189
+```text
190
+用户在某行 +/- quantity
191
+    → 本地更新该行 quantity(≥1,≤ maxStock)
192
+    → debounce 300ms → previewCheckout(带 addressId + 全量 items 数量)
193
+    → 刷新各行 subtotal、goodsAmount / freight / payAmount
194
+```
195
+
196
+### 6.3 行备注
197
+
198
+```text
199
+用户在行内 input 输入 buyerRemark
200
+    → 仅更新本地 preview.goodsList[].buyerRemark
201
+    → **不** 触发 preview(提交时一并带上)
202
+    → preview 刷新时通过 remarkMap 保留已输入备注
203
+```
204
+
205
+### 6.4 切换地址
206
+
207
+```text
208
+点击 AddressBar
209
+    → 弹层 getAddressList()
210
+    → 选择 addressId → debounce preview
211
+    → 运费可能变化,刷新应付
212
+无地址
213
+    → 展示空态 / submit 拦截「请选择收货地址」
214
+    → 引导跳转新增地址
215
+```
216
+
217
+### 6.5 提交与支付
218
+
219
+```text
220
+点击「提交订单」
221
+    → 校验 addressId
222
+    → submitCheckout(每行 buyerRemark)
223
+    → 成功得 orderId → Mock 支付弹窗
224
+        ├── 确认支付 → payOrder → pay-result?status=success
225
+        │     → 清除 cart_checkout_item_ids storage
226
+        └── 取消支付 → cancelPay → pay-result?status=closed
227
+```
228
+
229
+| 规则 | 前端 |
230
+|------|------|
231
+| 防重复提交 | submit/pay 中 loading + 按钮 disabled |
232
+| 取消关闭订单 | 弹窗「取消支付」→ cancelPay |
233
+| 库存/下架失败 | 展示后端 `msg`;不生成订单 |
234
+| 支付成功删购物车行 | **后端** 处理;前端支付成功后返回购物车 Tab 将看到行已消失 |
235
+
236
+---
237
+
238
+## 7. 数据映射 `checkoutDisplay.js`
239
+
240
+```javascript
241
+/** preview data → 多商品页面模型 */
242
+export function mapCheckoutPreviewCart(data, remarkMap = {}) {
243
+  const base = mapShopAndAddress(data)  // shop、address、金额文案
244
+  const goodsList = (data.items || []).map((row) => {
245
+    const item = mapGoodsItem(row)      // specList、priceText、maxStock…
246
+    if (item.cartItemId && remarkMap[item.cartItemId] != null) {
247
+      item.buyerRemark = remarkMap[item.cartItemId]  // 保留用户已填备注
248
+    }
249
+    return item
250
+  }).filter(Boolean)
251
+  return { ...base, goodsList }
252
+}
253
+```
254
+
255
+`mapGoodsItem` 内 `parseCartSpecText(specText)` 将 `§` 分隔规格拆成列表展示。
256
+
257
+---
258
+
259
+## 8. 导航工具 `checkoutNav.js`
260
+
261
+```javascript
262
+import { PAGE_CHECKOUT_CART } from '@/utils/pageRoute'
263
+import { CART_CHECKOUT_IDS_KEY } from '@/constants/checkout'
264
+
265
+export function goCartCheckout(cartItemIds) {
266
+  const ids = (cartItemIds || []).filter(Boolean)
267
+  if (!ids.length) return
268
+  uni.setStorageSync(CART_CHECKOUT_IDS_KEY, ids)
269
+  uni.navigateTo({
270
+    url: `${PAGE_CHECKOUT_CART}?cartItemIds=${ids.join(',')}`
271
+  })
272
+}
273
+```
274
+
275
+---
276
+
277
+## 9. 与单商品确认页分工
278
+
279
+| 项 | 单商品 `checkout` | 多商品 `checkout-cart` |
280
+|----|---------------------|------------------------|
281
+| 入口 | 商品详情 · 立即购买 | 购物车 · 去结算 |
282
+| source | `BUY_NOW` | `CART` |
283
+| 入参 | `goodsId/quantity/specText` | `cartItemIds` |
284
+| 商品行 | 1 行 `CheckoutGoodsRow` | N 行 `CheckoutMultiGoodsRow` |
285
+| 备注 | **整单一条** textarea | **每商品一行** input |
286
+| 进页门禁 | 详情 `ensureCanPurchase` | `prepareCartCheckout` |
287
+| 支付结果 | 共用 `pay-result` | 同上 |
288
+
289
+---
290
+
291
+## 10. 联调检查清单
292
+
293
+| # | 项 |
294
+|---|-----|
295
+| 1 | 未登录点去结算 → 引导登录 |
296
+| 2 | 跨店勾选 → Toast「请选择同一店铺的商品结算」 |
297
+| 3 | prepare 失败(下架/库存不足)→ 不跳转确认页 |
298
+| 4 | preview 返回多行、默认地址、同店 shop 头 |
299
+| 5 | 改某行数量后总价/运费/应付刷新 |
300
+| 6 | 换地址后模版运费变化 |
301
+| 7 | 各行备注提交后写入订单(后端校验) |
302
+| 8 | submit → pay → 待发货;购物车对应行清除 |
303
+| 9 | cancelPay → 订单已关闭 |
304
+| 10 | cartItemIds 缺失 → 提示并返回 |
305
+
306
+---
307
+
308
+## 11. 非本期
309
+
310
+| 项 | 说明 |
311
+|----|------|
312
+| 微信 JSAPI 真实唤起 | Mock pay 接口 |
313
+| 我的订单列表续付 | 另册 |
314
+| 跨店合单 | 业务不支持 |
315
+| 整单备注(多商品) | 需求为 **行备注** |
316
+| 单页合并 checkout + mode | v1 独立路由,后续可合并 |
317
+
318
+---
319
+
320
+## 12. 修订记录
321
+
322
+| 版本 | 说明 |
323
+|------|------|
324
+| **v1.0** | 首版:checkout-cart 页面、CheckoutMultiGoodsRow、购物车跳转、CART preview/submit、前端技术方案 |
325
+
326
+---
327
+
328
+*文档版本:v1.0 · 工程目录 `shop-app` · 不修改《确认订单页(多商品)技术方案.md》(后端方案)*

+ 7 - 0
shop-app/PAGES.md

@@ -37,6 +37,10 @@ subpackage/
37 37
 └── shop/                # 店铺主页
38 38
     ├── index.vue
39 39
     └── search.vue
40
+└── order/               # 订单确认与支付结果
41
+    ├── checkout.vue         # 立即购买 · 单商品
42
+    ├── checkout-cart.vue    # 购物车 · 多商品同店
43
+    └── pay-result.vue
40 44
 ```
41 45
 
42 46
 后续示例(待建):
@@ -61,6 +65,9 @@ subpackage/
61 65
 | `PAGE_HOME` | `/pages/index/index` |
62 66
 | `PAGE_GOODS_DETAIL` | `/subpackage/goods/detail` |
63 67
 | `PAGE_SEARCH_INDEX` | `/subpackage/search/index` |
68
+| `PAGE_CHECKOUT` | `/subpackage/order/checkout` |
69
+| `PAGE_CHECKOUT_CART` | `/subpackage/order/checkout-cart` |
70
+| `PAGE_PAY_RESULT` | `/subpackage/order/pay-result` |
64 71
 | `PAGE_SHOP_FOLLOW_LIST` | `/subpackage/account/shop-follow-list` |
65 72
 | `PAGE_SHOP_HOME` | `/subpackage/shop/index` |
66 73
 | `PAGE_SHOP_SEARCH` | `/subpackage/shop/search` |

+ 21 - 0
shop-app/api/checkout.js

@@ -0,0 +1,21 @@
1
+import request from '@/utils/request'
2
+
3
+/** 确认订单预览(进入/改量/换地址) */
4
+export function previewCheckout(data) {
5
+  return request({
6
+    url: '/api/checkout/preview',
7
+    method: 'POST',
8
+    data,
9
+    header: { repeatSubmit: false }
10
+  })
11
+}
12
+
13
+/** 提交订单 */
14
+export function submitCheckout(data) {
15
+  return request({
16
+    url: '/api/checkout/submit',
17
+    method: 'POST',
18
+    data,
19
+    header: { repeatSubmit: false }
20
+  })
21
+}

+ 27 - 0
shop-app/api/order.js

@@ -0,0 +1,27 @@
1
+import request from '@/utils/request'
2
+
3
+/** 支付订单(v1 Mock 直接成功) */
4
+export function payOrder(orderId) {
5
+  return request({
6
+    url: `/api/order/${orderId}/pay`,
7
+    method: 'POST',
8
+    header: { repeatSubmit: false }
9
+  })
10
+}
11
+
12
+/** 取消支付(订单关闭) */
13
+export function cancelPay(orderId) {
14
+  return request({
15
+    url: `/api/order/${orderId}/pay/cancel`,
16
+    method: 'POST',
17
+    header: { repeatSubmit: false }
18
+  })
19
+}
20
+
21
+/** 订单详情 */
22
+export function getOrderDetail(orderId) {
23
+  return request({
24
+    url: `/api/order/${orderId}`,
25
+    method: 'GET'
26
+  })
27
+}

+ 78 - 0
shop-app/components/order/AddressBar.vue

@@ -0,0 +1,78 @@
1
+<template>
2
+	<view class="addr-bar" @click="emit('click')">
3
+		<u-icon name="map" color="#2e7d32" size="22" />
4
+		<view v-if="address" class="addr-bar__body">
5
+			<view class="addr-bar__head">
6
+				<text class="addr-bar__name">{{ address.consigneeName }}</text>
7
+				<text class="addr-bar__mobile">{{ address.mobile }}</text>
8
+				<text v-if="address.isDefault" class="addr-bar__tag">默认</text>
9
+			</view>
10
+			<text class="addr-bar__detail">{{ address.fullAddress }}</text>
11
+		</view>
12
+		<view v-else class="addr-bar__empty">
13
+			<text>请添加收货地址</text>
14
+		</view>
15
+		<u-icon name="arrow-right" color="#ccc" size="16" />
16
+	</view>
17
+</template>
18
+
19
+<script setup>
20
+defineProps({
21
+	address: {
22
+		type: Object,
23
+		default: null
24
+	}
25
+})
26
+const emit = defineEmits(['click'])
27
+</script>
28
+
29
+<style lang="scss" scoped>
30
+.addr-bar {
31
+	display: flex;
32
+	align-items: center;
33
+	padding: 24rpx;
34
+	margin: 16rpx 24rpx;
35
+	background: #fff;
36
+	border-radius: 12rpx;
37
+}
38
+.addr-bar__body {
39
+	flex: 1;
40
+	margin: 0 16rpx;
41
+	min-width: 0;
42
+}
43
+.addr-bar__head {
44
+	display: flex;
45
+	align-items: center;
46
+	flex-wrap: wrap;
47
+	gap: 12rpx;
48
+}
49
+.addr-bar__name {
50
+	font-size: 30rpx;
51
+	font-weight: 600;
52
+	color: #333;
53
+}
54
+.addr-bar__mobile {
55
+	font-size: 28rpx;
56
+	color: #666;
57
+}
58
+.addr-bar__tag {
59
+	font-size: 22rpx;
60
+	color: #2e7d32;
61
+	background: #e8f5e9;
62
+	padding: 4rpx 10rpx;
63
+	border-radius: 6rpx;
64
+}
65
+.addr-bar__detail {
66
+	display: block;
67
+	margin-top: 8rpx;
68
+	font-size: 26rpx;
69
+	color: #666;
70
+	line-height: 1.5;
71
+}
72
+.addr-bar__empty {
73
+	flex: 1;
74
+	margin: 0 16rpx;
75
+	font-size: 28rpx;
76
+	color: #999;
77
+}
78
+</style>

+ 133 - 0
shop-app/components/order/CheckoutGoodsRow.vue

@@ -0,0 +1,133 @@
1
+<template>
2
+	<view class="checkout-goods">
3
+		<image class="checkout-goods__pic" :src="goods.displayPic" mode="aspectFill" />
4
+		<view class="checkout-goods__info">
5
+			<text class="checkout-goods__name">{{ goods.goodsName }}</text>
6
+			<view v-if="goods.specList && goods.specList.length" class="checkout-goods__specs">
7
+				<text
8
+					v-for="(spec, index) in goods.specList"
9
+					:key="index"
10
+					class="checkout-goods__spec"
11
+				>{{ spec }}</text>
12
+			</view>
13
+			<text v-if="goods.serviceDesc" class="checkout-goods__service">{{ goods.serviceDesc }}</text>
14
+			<view class="checkout-goods__price-row">
15
+				<text class="checkout-goods__price">¥{{ goods.priceText }}</text>
16
+				<view class="checkout-goods__qty">
17
+					<view class="qty-btn" @click="onMinus">-</view>
18
+					<text class="qty-num">{{ goods.quantity }}</text>
19
+					<view class="qty-btn" @click="onPlus">+</view>
20
+				</view>
21
+			</view>
22
+			<text class="checkout-goods__subtotal">小计 ¥{{ goods.subtotalText }}</text>
23
+		</view>
24
+	</view>
25
+</template>
26
+
27
+<script setup>
28
+const props = defineProps({
29
+	goods: {
30
+		type: Object,
31
+		required: true
32
+	}
33
+})
34
+
35
+const emit = defineEmits(['quantity-change'])
36
+
37
+function onMinus() {
38
+	const next = Math.max(1, props.goods.quantity - 1)
39
+	if (next !== props.goods.quantity) {
40
+		emit('quantity-change', next)
41
+	}
42
+}
43
+
44
+function onPlus() {
45
+	const max = props.goods.maxStock || 999999
46
+	const next = props.goods.quantity + 1
47
+	if (next > max) {
48
+		uni.showToast({ title: '库存不足', icon: 'none' })
49
+		return
50
+	}
51
+	emit('quantity-change', next)
52
+}
53
+</script>
54
+
55
+<style lang="scss" scoped>
56
+.checkout-goods {
57
+	display: flex;
58
+	padding: 20rpx 24rpx;
59
+	background: #fff;
60
+}
61
+.checkout-goods__pic {
62
+	width: 160rpx;
63
+	height: 160rpx;
64
+	border-radius: 8rpx;
65
+	background: #eee;
66
+	flex-shrink: 0;
67
+}
68
+.checkout-goods__info {
69
+	flex: 1;
70
+	margin-left: 16rpx;
71
+	min-width: 0;
72
+}
73
+.checkout-goods__name {
74
+	font-size: 28rpx;
75
+	color: #333;
76
+	line-height: 1.4;
77
+}
78
+.checkout-goods__specs {
79
+	margin-top: 8rpx;
80
+	display: flex;
81
+	flex-direction: column;
82
+	gap: 4rpx;
83
+}
84
+.checkout-goods__spec {
85
+	font-size: 24rpx;
86
+	color: #999;
87
+}
88
+.checkout-goods__service {
89
+	display: block;
90
+	margin-top: 8rpx;
91
+	font-size: 22rpx;
92
+	color: #2e7d32;
93
+}
94
+.checkout-goods__price-row {
95
+	margin-top: 12rpx;
96
+	display: flex;
97
+	align-items: center;
98
+	justify-content: space-between;
99
+}
100
+.checkout-goods__price {
101
+	font-size: 30rpx;
102
+	color: #e53935;
103
+	font-weight: 600;
104
+}
105
+.checkout-goods__qty {
106
+	display: flex;
107
+	align-items: center;
108
+	border: 1rpx solid #e0e0e0;
109
+	border-radius: 8rpx;
110
+	overflow: hidden;
111
+}
112
+.qty-btn {
113
+	width: 52rpx;
114
+	height: 52rpx;
115
+	line-height: 52rpx;
116
+	text-align: center;
117
+	font-size: 32rpx;
118
+	color: #333;
119
+	background: #f5f5f5;
120
+}
121
+.qty-num {
122
+	min-width: 56rpx;
123
+	text-align: center;
124
+	font-size: 26rpx;
125
+}
126
+.checkout-goods__subtotal {
127
+	display: block;
128
+	margin-top: 8rpx;
129
+	font-size: 24rpx;
130
+	color: #666;
131
+	text-align: right;
132
+}
133
+</style>

+ 170 - 0
shop-app/components/order/CheckoutMultiGoodsRow.vue

@@ -0,0 +1,170 @@
1
+<template>
2
+	<view class="multi-goods-row">
3
+		<image class="multi-goods-row__pic" :src="item.displayPic" mode="aspectFill" />
4
+		<view class="multi-goods-row__main">
5
+			<text class="multi-goods-row__name">{{ item.goodsName }}</text>
6
+			<view v-if="item.specList && item.specList.length" class="multi-goods-row__specs">
7
+				<text
8
+					v-for="(spec, index) in item.specList"
9
+					:key="index"
10
+					class="multi-goods-row__spec"
11
+				>{{ spec }}</text>
12
+			</view>
13
+			<text v-if="item.serviceDesc" class="multi-goods-row__service">{{ item.serviceDesc }}</text>
14
+			<view class="multi-goods-row__price-row">
15
+				<text class="multi-goods-row__price">¥{{ item.priceText }}</text>
16
+				<view class="multi-goods-row__qty">
17
+					<view class="qty-btn" @click="onMinus">-</view>
18
+					<text class="qty-num">{{ item.quantity }}</text>
19
+					<view class="qty-btn" @click="onPlus">+</view>
20
+				</view>
21
+			</view>
22
+			<text class="multi-goods-row__subtotal">小计 ¥{{ item.subtotalText }}</text>
23
+			<view class="multi-goods-row__remark">
24
+				<text class="multi-goods-row__remark-label">备注</text>
25
+				<input
26
+					class="multi-goods-row__remark-input"
27
+					:value="item.buyerRemark"
28
+					:maxlength="remarkMax"
29
+					placeholder="选填"
30
+					@input="onRemarkInput"
31
+				/>
32
+			</view>
33
+		</view>
34
+	</view>
35
+</template>
36
+
37
+<script setup>
38
+import { CHECKOUT_REMARK_MAX } from '@/constants/checkout'
39
+
40
+const props = defineProps({
41
+	item: {
42
+		type: Object,
43
+		required: true
44
+	}
45
+})
46
+
47
+const emit = defineEmits(['quantity-change', 'remark-change'])
48
+const remarkMax = CHECKOUT_REMARK_MAX
49
+
50
+function onMinus() {
51
+	const next = Math.max(1, props.item.quantity - 1)
52
+	if (next !== props.item.quantity) {
53
+		emit('quantity-change', next)
54
+	}
55
+}
56
+
57
+function onPlus() {
58
+	const max = props.item.maxStock || 999999
59
+	const next = props.item.quantity + 1
60
+	if (next > max) {
61
+		uni.showToast({ title: '库存不足', icon: 'none' })
62
+		return
63
+	}
64
+	emit('quantity-change', next)
65
+}
66
+
67
+function onRemarkInput(e) {
68
+	const val = (e && e.detail && e.detail.value) || ''
69
+	emit('remark-change', val)
70
+}
71
+</script>
72
+
73
+<style lang="scss" scoped>
74
+.multi-goods-row {
75
+	display: flex;
76
+	padding: 20rpx 24rpx;
77
+	border-bottom: 1rpx solid #f5f5f5;
78
+}
79
+.multi-goods-row__pic {
80
+	width: 160rpx;
81
+	height: 160rpx;
82
+	border-radius: 8rpx;
83
+	background: #eee;
84
+	flex-shrink: 0;
85
+}
86
+.multi-goods-row__main {
87
+	flex: 1;
88
+	margin-left: 16rpx;
89
+	min-width: 0;
90
+}
91
+.multi-goods-row__name {
92
+	font-size: 28rpx;
93
+	color: #333;
94
+	line-height: 1.4;
95
+}
96
+.multi-goods-row__specs {
97
+	margin-top: 8rpx;
98
+	display: flex;
99
+	flex-direction: column;
100
+	gap: 4rpx;
101
+}
102
+.multi-goods-row__spec {
103
+	font-size: 24rpx;
104
+	color: #999;
105
+}
106
+.multi-goods-row__service {
107
+	display: block;
108
+	margin-top: 8rpx;
109
+	font-size: 22rpx;
110
+	color: #2e7d32;
111
+}
112
+.multi-goods-row__price-row {
113
+	margin-top: 12rpx;
114
+	display: flex;
115
+	align-items: center;
116
+	justify-content: space-between;
117
+}
118
+.multi-goods-row__price {
119
+	font-size: 30rpx;
120
+	color: #e53935;
121
+	font-weight: 600;
122
+}
123
+.multi-goods-row__qty {
124
+	display: flex;
125
+	align-items: center;
126
+	border: 1rpx solid #e0e0e0;
127
+	border-radius: 8rpx;
128
+	overflow: hidden;
129
+}
130
+.qty-btn {
131
+	width: 52rpx;
132
+	height: 52rpx;
133
+	line-height: 52rpx;
134
+	text-align: center;
135
+	font-size: 32rpx;
136
+	color: #333;
137
+	background: #f5f5f5;
138
+}
139
+.qty-num {
140
+	min-width: 56rpx;
141
+	text-align: center;
142
+	font-size: 26rpx;
143
+}
144
+.multi-goods-row__subtotal {
145
+	display: block;
146
+	margin-top: 8rpx;
147
+	font-size: 24rpx;
148
+	color: #666;
149
+	text-align: right;
150
+}
151
+.multi-goods-row__remark {
152
+	margin-top: 12rpx;
153
+	display: flex;
154
+	align-items: center;
155
+}
156
+.multi-goods-row__remark-label {
157
+	font-size: 24rpx;
158
+	color: #999;
159
+	margin-right: 12rpx;
160
+	flex-shrink: 0;
161
+}
162
+.multi-goods-row__remark-input {
163
+	flex: 1;
164
+	height: 56rpx;
165
+	padding: 0 16rpx;
166
+	font-size: 24rpx;
167
+	background: #f9f9f9;
168
+	border-radius: 8rpx;
169
+}
170
+</style>

+ 14 - 0
shop-app/constants/checkout.js

@@ -0,0 +1,14 @@
1
+/** 立即购买确认单 */
2
+export const CHECKOUT_SOURCE_BUY_NOW = 'BUY_NOW'
3
+
4
+/** 购物车同店结算 */
5
+export const CHECKOUT_SOURCE_CART = 'CART'
6
+
7
+/** 购物车结算 cartItemIds 缓存键 */
8
+export const CART_CHECKOUT_IDS_KEY = 'cart_checkout_item_ids'
9
+
10
+/** 提交订单支付方式:微信支付(与 OrderConstants.PAY_TYPE_WECHAT 一致) */
11
+export const CHECKOUT_PAY_TYPE_WECHAT = '1'
12
+
13
+/** 买家备注最大字数 */
14
+export const CHECKOUT_REMARK_MAX = 200

+ 32 - 0
shop-app/pages.json

@@ -160,6 +160,30 @@
160 160
 				}
161 161
 			]
162 162
 		},
163
+		{
164
+			"root": "subpackage/order",
165
+			"name": "pkg-order",
166
+			"pages": [
167
+				{
168
+					"path": "checkout",
169
+					"style": {
170
+						"navigationBarTitleText": "确认订单"
171
+					}
172
+				},
173
+				{
174
+					"path": "checkout-cart",
175
+					"style": {
176
+						"navigationBarTitleText": "确认订单"
177
+					}
178
+				},
179
+				{
180
+					"path": "pay-result",
181
+					"style": {
182
+						"navigationBarTitleText": "支付结果"
183
+					}
184
+				}
185
+			]
186
+		},
163 187
 		{
164 188
 			"root": "subpackage/shop",
165 189
 			"name": "pkg-shop",
@@ -191,6 +215,14 @@
191 215
 		"pages/mine/index": {
192 216
 			"network": "all",
193 217
 			"packages": ["pkg-account"]
218
+		},
219
+		"subpackage/goods/detail": {
220
+			"network": "all",
221
+			"packages": ["pkg-order"]
222
+		},
223
+		"pages/cart/index": {
224
+			"network": "all",
225
+			"packages": ["pkg-order"]
194 226
 		}
195 227
 	},
196 228
 	"globalStyle": {

+ 3 - 4
shop-app/pages/cart/index.vue

@@ -84,7 +84,7 @@ import {
84 84
 	getCheckedPurchasableIds,
85 85
 	getAllPurchasableItems
86 86
 } from '@/utils/cartDisplay'
87
-import { saveCheckoutPrepare } from '@/utils/cartCheckout'
87
+import { goCartCheckout } from '@/utils/checkoutNav'
88 88
 import { CART_EMPTY_TEXT, CART_MSG_CROSS_SHOP } from '@/constants/cart'
89 89
 import { goGoodsDetail } from '@/utils/goodsDetail'
90 90
 import { goShopHome } from '@/utils/shopNav'
@@ -280,9 +280,8 @@ async function onCheckout() {
280 280
 		return
281 281
 	}
282 282
 	try {
283
-		const res = await prepareCartCheckout(ids)
284
-		saveCheckoutPrepare(res.data)
285
-		uni.showToast({ title: '确认订单功能开发中', icon: 'none' })
283
+		await prepareCartCheckout(ids)
284
+		goCartCheckout(ids)
286 285
 	} catch (e) {
287 286
 		// request 已提示
288 287
 	}

+ 12 - 2
shop-app/subpackage/goods/detail.vue

@@ -182,7 +182,7 @@ import {
182 182
 } from '@/utils/goodsDetail'
183 183
 import { ensureCanPurchase } from '@/utils/purchaseAction'
184 184
 import { addCartItem } from '@/api/cart'
185
-import { PAGE_GOODS_REVIEWS } from '@/utils/pageRoute'
185
+import { PAGE_GOODS_REVIEWS, PAGE_CHECKOUT } from '@/utils/pageRoute'
186 186
 import { goShopHome } from '@/utils/shopNav'
187 187
 import ReviewCard from '@/components/goods/ReviewCard.vue'
188 188
 import DetailBottomBar from '@/components/goods/DetailBottomBar.vue'
@@ -343,7 +343,17 @@ async function onBuyNow() {
343 343
 async function doBuyNow() {
344 344
 	const ok = await ensureCanPurchase(goodsId.value, detail.value?.purchase?.reason)
345 345
 	if (!ok) return
346
-	uni.showToast({ title: '确认订单功能开发中', icon: 'none' })
346
+	const spec = formatSpecDisplayText(detail.value?.specDisplay)
347
+	const parts = [
348
+		`goodsId=${goodsId.value}`,
349
+		`quantity=${quantity.value}`
350
+	]
351
+	if (spec && spec !== '默认') {
352
+		parts.push(`specText=${encodeURIComponent(spec)}`)
353
+	}
354
+	uni.navigateTo({
355
+		url: `${PAGE_CHECKOUT}?${parts.join('&')}`
356
+	})
347 357
 }
348 358
 
349 359
 function showBlockReason() {

+ 559 - 0
shop-app/subpackage/order/checkout-cart.vue

@@ -0,0 +1,559 @@
1
+<template>
2
+	<view class="checkout-page">
3
+		<view v-if="pageLoading" class="checkout-page__loading">
4
+			<u-loading-icon mode="circle" />
5
+		</view>
6
+
7
+		<view v-else-if="pageError" class="checkout-page__error">
8
+			<u-empty mode="page" :text="pageError" icon-size="80" />
9
+			<u-button type="primary" text="重试" size="small" custom-style="margin-top:24rpx" @click="loadPreview" />
10
+			<u-button plain text="返回" size="small" custom-style="margin-top:16rpx" @click="onBack" />
11
+		</view>
12
+
13
+		<template v-else-if="preview">
14
+			<scroll-view class="checkout-scroll" scroll-y :style="{ height: scrollHeight }">
15
+				<address-bar :address="preview.address" @click="onAddressTap" />
16
+
17
+				<view class="shop-head">
18
+					<image class="shop-head__avatar" :src="preview.shop.shopAvatar" mode="aspectFill" />
19
+					<text class="shop-head__name">{{ preview.shop.shopName }}</text>
20
+				</view>
21
+
22
+				<view class="goods-card">
23
+					<checkout-multi-goods-row
24
+						v-for="item in preview.goodsList"
25
+						:key="item.cartItemId"
26
+						:item="item"
27
+						@quantity-change="(qty) => onQuantityChange(item.cartItemId, qty)"
28
+						@remark-change="(text) => onRemarkChange(item.cartItemId, text)"
29
+					/>
30
+				</view>
31
+
32
+				<view class="amount-card">
33
+					<view class="amount-row">
34
+						<text class="amount-row__label">商品总价</text>
35
+						<text class="amount-row__val">¥{{ preview.goodsAmountText }}</text>
36
+					</view>
37
+					<view class="amount-row">
38
+						<text class="amount-row__label">运费</text>
39
+						<view class="amount-row__right">
40
+							<text class="amount-row__val">¥{{ preview.freightAmountText }}</text>
41
+							<text v-if="preview.freightDesc" class="amount-row__desc">{{ preview.freightDesc }}</text>
42
+						</view>
43
+					</view>
44
+				</view>
45
+
46
+				<view class="pay-card">
47
+					<text class="pay-card__label">支付方式</text>
48
+					<view class="pay-card__item">
49
+						<u-icon name="weixin-fill" color="#09bb07" size="22" />
50
+						<text class="pay-card__text">微信支付</text>
51
+						<u-icon name="checkbox-mark" color="#2e7d32" size="18" />
52
+					</view>
53
+				</view>
54
+			</scroll-view>
55
+
56
+			<view class="checkout-footer">
57
+				<view class="checkout-footer__sum">
58
+					<text class="checkout-footer__label">应付:</text>
59
+					<text class="checkout-footer__price">¥{{ preview.payAmountText }}</text>
60
+				</view>
61
+				<button class="checkout-footer__btn" :disabled="submitting" @click="onSubmit">
62
+					{{ submitting ? '提交中...' : '提交订单' }}
63
+				</button>
64
+			</view>
65
+		</template>
66
+
67
+		<u-popup :show="addressPopupShow" mode="bottom" round="16" @close="addressPopupShow = false">
68
+			<view class="addr-popup">
69
+				<view class="addr-popup__head">
70
+					<text class="addr-popup__title">选择收货地址</text>
71
+					<text class="addr-popup__add" @click="goAddAddress">新增地址</text>
72
+				</view>
73
+				<scroll-view scroll-y class="addr-popup__list">
74
+					<view
75
+						v-for="item in addressOptions"
76
+						:key="item.addressId"
77
+						class="addr-option"
78
+						:class="{ 'addr-option--on': item.addressId === selectedAddressId }"
79
+						@click="onSelectAddress(item)"
80
+					>
81
+						<view class="addr-option__head">
82
+							<text class="addr-option__name">{{ item.consigneeName }}</text>
83
+							<text class="addr-option__mobile">{{ item.mobile }}</text>
84
+							<text v-if="item.isDefault" class="addr-option__tag">默认</text>
85
+						</view>
86
+						<text class="addr-option__detail">{{ item.fullAddress }}</text>
87
+					</view>
88
+					<view v-if="!addressOptions.length" class="addr-popup__empty">
89
+						<text>暂无收货地址,请先新增</text>
90
+					</view>
91
+				</scroll-view>
92
+			</view>
93
+		</u-popup>
94
+
95
+		<u-modal
96
+			:show="payModalShow"
97
+			title="微信支付"
98
+			:content="payModalContent"
99
+			show-cancel-button
100
+			confirm-text="确认支付"
101
+			cancel-text="取消支付"
102
+			@confirm="onConfirmPay"
103
+			@cancel="onCancelPay"
104
+			@close="payModalShow = false"
105
+		/>
106
+	</view>
107
+</template>
108
+
109
+<script setup>
110
+import { ref } from 'vue'
111
+import { onLoad, onShow } from '@dcloudio/uni-app'
112
+import { previewCheckout, submitCheckout } from '@/api/checkout'
113
+import { payOrder, cancelPay } from '@/api/order'
114
+import { getAddressList } from '@/api/member'
115
+import { mapCheckoutPreviewCart, mapAddressOption } from '@/utils/checkoutDisplay'
116
+import {
117
+	CHECKOUT_SOURCE_CART,
118
+	CHECKOUT_PAY_TYPE_WECHAT,
119
+	CART_CHECKOUT_IDS_KEY
120
+} from '@/constants/checkout'
121
+import { ensureApiToken } from '@/utils/apiAuth'
122
+import { PAGE_ADDRESS_EDIT, PAGE_PAY_RESULT } from '@/utils/pageRoute'
123
+import AddressBar from '@/components/order/AddressBar.vue'
124
+import CheckoutMultiGoodsRow from '@/components/order/CheckoutMultiGoodsRow.vue'
125
+
126
+const cartItemIds = ref([])
127
+const selectedAddressId = ref(null)
128
+const preview = ref(null)
129
+const pageLoading = ref(true)
130
+const pageError = ref('')
131
+const submitting = ref(false)
132
+const previewing = ref(false)
133
+const scrollHeight = ref('600px')
134
+
135
+const addressPopupShow = ref(false)
136
+const addressOptions = ref([])
137
+
138
+const payModalShow = ref(false)
139
+const payModalContent = ref('')
140
+const pendingOrderId = ref(null)
141
+const paying = ref(false)
142
+
143
+let previewTimer = null
144
+
145
+function getRemarkMap() {
146
+	const map = {}
147
+	;(preview.value?.goodsList || []).forEach((row) => {
148
+		if (row.cartItemId) {
149
+			map[row.cartItemId] = row.buyerRemark || ''
150
+		}
151
+	})
152
+	return map
153
+}
154
+
155
+function buildLineItems() {
156
+	return (preview.value?.goodsList || []).map((row) => ({
157
+		cartItemId: row.cartItemId,
158
+		goodsId: row.goodsId,
159
+		quantity: row.quantity
160
+	}))
161
+}
162
+
163
+function buildPreviewBody() {
164
+	return {
165
+		source: CHECKOUT_SOURCE_CART,
166
+		addressId: selectedAddressId.value || undefined,
167
+		cartItemIds: cartItemIds.value,
168
+		items: buildLineItems()
169
+	}
170
+}
171
+
172
+function buildSubmitBody() {
173
+	return {
174
+		source: CHECKOUT_SOURCE_CART,
175
+		addressId: selectedAddressId.value,
176
+		payType: CHECKOUT_PAY_TYPE_WECHAT,
177
+		cartItemIds: cartItemIds.value,
178
+		items: (preview.value?.goodsList || []).map((row) => ({
179
+			cartItemId: row.cartItemId,
180
+			goodsId: row.goodsId,
181
+			quantity: row.quantity,
182
+			buyerRemark: (row.buyerRemark || '').trim()
183
+		}))
184
+	}
185
+}
186
+
187
+function calcScrollHeight() {
188
+	try {
189
+		const sys = uni.getSystemInfoSync()
190
+		scrollHeight.value = `${(sys.windowHeight || 600) - 56}px`
191
+	} catch (e) {
192
+		scrollHeight.value = '600px'
193
+	}
194
+}
195
+
196
+async function loadPreview() {
197
+	if (!cartItemIds.value.length || previewing.value) return
198
+	previewing.value = true
199
+	pageLoading.value = !preview.value
200
+	pageError.value = ''
201
+	try {
202
+		const res = await previewCheckout(buildPreviewBody())
203
+		const mapped = mapCheckoutPreviewCart(res.data, getRemarkMap())
204
+		if (!mapped || !mapped.goodsList.length) {
205
+			throw new Error('订单预览数据异常')
206
+		}
207
+		preview.value = mapped
208
+		if (mapped.address && mapped.address.addressId) {
209
+			selectedAddressId.value = mapped.address.addressId
210
+		}
211
+	} catch (e) {
212
+		pageError.value = (e && e.msg) || (e && e.message) || '加载失败'
213
+		if (!preview.value) {
214
+			preview.value = null
215
+		}
216
+	} finally {
217
+		pageLoading.value = false
218
+		previewing.value = false
219
+	}
220
+}
221
+
222
+function schedulePreview() {
223
+	if (previewTimer) clearTimeout(previewTimer)
224
+	previewTimer = setTimeout(() => {
225
+		loadPreview()
226
+	}, 300)
227
+}
228
+
229
+function onQuantityChange(cartItemId, qty) {
230
+	const list = preview.value?.goodsList || []
231
+	const row = list.find((item) => item.cartItemId === cartItemId)
232
+	if (row) {
233
+		row.quantity = qty
234
+		schedulePreview()
235
+	}
236
+}
237
+
238
+function onRemarkChange(cartItemId, text) {
239
+	const list = preview.value?.goodsList || []
240
+	const row = list.find((item) => item.cartItemId === cartItemId)
241
+	if (row) {
242
+		row.buyerRemark = text
243
+	}
244
+}
245
+
246
+async function onAddressTap() {
247
+	try {
248
+		const res = await getAddressList()
249
+		addressOptions.value = (res.data || []).map(mapAddressOption).filter(Boolean)
250
+	} catch (e) {
251
+		addressOptions.value = []
252
+	}
253
+	addressPopupShow.value = true
254
+}
255
+
256
+function onSelectAddress(item) {
257
+	selectedAddressId.value = item.addressId
258
+	addressPopupShow.value = false
259
+	schedulePreview()
260
+}
261
+
262
+function goAddAddress() {
263
+	addressPopupShow.value = false
264
+	uni.navigateTo({ url: `${PAGE_ADDRESS_EDIT}?mode=add` })
265
+}
266
+
267
+async function onSubmit() {
268
+	if (!selectedAddressId.value) {
269
+		uni.showToast({ title: '请选择收货地址', icon: 'none' })
270
+		return
271
+	}
272
+	if (submitting.value) return
273
+	submitting.value = true
274
+	try {
275
+		const res = await submitCheckout(buildSubmitBody())
276
+		const data = res.data || {}
277
+		pendingOrderId.value = data.orderId
278
+		payModalContent.value = `订单号:${data.orderNo || '—'}\n应付金额:¥${data.payAmount != null ? data.payAmount : preview.value?.payAmountText || '—'}`
279
+		payModalShow.value = true
280
+	} catch (e) {
281
+		// request 已 Toast
282
+	} finally {
283
+		submitting.value = false
284
+	}
285
+}
286
+
287
+async function onConfirmPay() {
288
+	if (!pendingOrderId.value || paying.value) return
289
+	paying.value = true
290
+	payModalShow.value = false
291
+	try {
292
+		await payOrder(pendingOrderId.value)
293
+		try {
294
+			uni.removeStorageSync(CART_CHECKOUT_IDS_KEY)
295
+		} catch (e) {
296
+			// ignore
297
+		}
298
+		uni.redirectTo({
299
+			url: `${PAGE_PAY_RESULT}?status=success&orderId=${pendingOrderId.value}`
300
+		})
301
+	} catch (e) {
302
+		// request 已 Toast
303
+	} finally {
304
+		paying.value = false
305
+	}
306
+}
307
+
308
+async function onCancelPay() {
309
+	if (!pendingOrderId.value || paying.value) return
310
+	paying.value = true
311
+	payModalShow.value = false
312
+	try {
313
+		await cancelPay(pendingOrderId.value)
314
+		uni.redirectTo({
315
+			url: `${PAGE_PAY_RESULT}?status=closed&orderId=${pendingOrderId.value}`
316
+		})
317
+	} catch (e) {
318
+		// request 已 Toast
319
+	} finally {
320
+		paying.value = false
321
+	}
322
+}
323
+
324
+function onBack() {
325
+	uni.navigateBack()
326
+}
327
+
328
+function parseCartItemIds(options) {
329
+	const fromQuery = (options.cartItemIds || '')
330
+		.split(',')
331
+		.map((id) => id.trim())
332
+		.filter(Boolean)
333
+	if (fromQuery.length) {
334
+		return fromQuery
335
+	}
336
+	try {
337
+		const stored = uni.getStorageSync(CART_CHECKOUT_IDS_KEY)
338
+		if (Array.isArray(stored) && stored.length) {
339
+			return stored.map(String)
340
+		}
341
+	} catch (e) {
342
+		// ignore
343
+	}
344
+	return []
345
+}
346
+
347
+onLoad((options) => {
348
+	calcScrollHeight()
349
+	cartItemIds.value = parseCartItemIds(options)
350
+	if (!cartItemIds.value.length) {
351
+		pageError.value = '结算商品信息缺失'
352
+		pageLoading.value = false
353
+		setTimeout(() => onBack(), 800)
354
+	}
355
+})
356
+
357
+onShow(() => {
358
+	if (!ensureApiToken(false)) return
359
+	if (!cartItemIds.value.length) return
360
+	loadPreview()
361
+})
362
+</script>
363
+
364
+<style lang="scss" scoped>
365
+.checkout-page {
366
+	min-height: 100vh;
367
+	background: #f5f6f8;
368
+	padding-bottom: env(safe-area-inset-bottom);
369
+}
370
+.checkout-page__loading,
371
+.checkout-page__error {
372
+	padding: 120rpx 48rpx;
373
+	display: flex;
374
+	flex-direction: column;
375
+	align-items: center;
376
+}
377
+.checkout-scroll {
378
+	box-sizing: border-box;
379
+}
380
+.shop-head {
381
+	display: flex;
382
+	align-items: center;
383
+	padding: 16rpx 24rpx;
384
+	margin: 0 24rpx 2rpx;
385
+	background: #fff;
386
+	border-radius: 12rpx 12rpx 0 0;
387
+}
388
+.shop-head__avatar {
389
+	width: 48rpx;
390
+	height: 48rpx;
391
+	border-radius: 8rpx;
392
+	background: #eee;
393
+}
394
+.shop-head__name {
395
+	margin-left: 12rpx;
396
+	font-size: 28rpx;
397
+	color: #333;
398
+	font-weight: 500;
399
+}
400
+.goods-card {
401
+	margin: 0 24rpx;
402
+	background: #fff;
403
+	border-radius: 0 0 12rpx 12rpx;
404
+	overflow: hidden;
405
+}
406
+.amount-card,
407
+.pay-card {
408
+	margin: 16rpx 24rpx;
409
+	padding: 24rpx;
410
+	background: #fff;
411
+	border-radius: 12rpx;
412
+}
413
+.amount-row {
414
+	display: flex;
415
+	justify-content: space-between;
416
+	align-items: flex-start;
417
+	padding: 12rpx 0;
418
+}
419
+.amount-row__label {
420
+	font-size: 28rpx;
421
+	color: #666;
422
+}
423
+.amount-row__right {
424
+	text-align: right;
425
+}
426
+.amount-row__val {
427
+	font-size: 28rpx;
428
+	color: #333;
429
+}
430
+.amount-row__desc {
431
+	display: block;
432
+	margin-top: 4rpx;
433
+	font-size: 22rpx;
434
+	color: #999;
435
+}
436
+.pay-card__label {
437
+	font-size: 28rpx;
438
+	color: #333;
439
+	font-weight: 500;
440
+}
441
+.pay-card__item {
442
+	margin-top: 16rpx;
443
+	display: flex;
444
+	align-items: center;
445
+	gap: 12rpx;
446
+}
447
+.pay-card__text {
448
+	flex: 1;
449
+	font-size: 28rpx;
450
+	color: #333;
451
+}
452
+.checkout-footer {
453
+	position: fixed;
454
+	left: 0;
455
+	right: 0;
456
+	bottom: 0;
457
+	display: flex;
458
+	align-items: center;
459
+	padding: 16rpx 24rpx;
460
+	padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
461
+	background: #fff;
462
+	border-top: 1rpx solid #eee;
463
+	z-index: 10;
464
+}
465
+.checkout-footer__sum {
466
+	flex: 1;
467
+}
468
+.checkout-footer__label {
469
+	font-size: 26rpx;
470
+	color: #333;
471
+}
472
+.checkout-footer__price {
473
+	font-size: 36rpx;
474
+	color: #e53935;
475
+	font-weight: 600;
476
+}
477
+.checkout-footer__btn {
478
+	min-width: 220rpx;
479
+	height: 72rpx;
480
+	line-height: 72rpx;
481
+	padding: 0 32rpx;
482
+	background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
483
+	color: #fff;
484
+	font-size: 28rpx;
485
+	border-radius: 36rpx;
486
+	border: none;
487
+}
488
+.checkout-footer__btn[disabled] {
489
+	opacity: 0.6;
490
+}
491
+.addr-popup {
492
+	padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
493
+	max-height: 70vh;
494
+}
495
+.addr-popup__head {
496
+	display: flex;
497
+	justify-content: space-between;
498
+	align-items: center;
499
+	margin-bottom: 16rpx;
500
+}
501
+.addr-popup__title {
502
+	font-size: 30rpx;
503
+	font-weight: 600;
504
+	color: #333;
505
+}
506
+.addr-popup__add {
507
+	font-size: 26rpx;
508
+	color: #2e7d32;
509
+}
510
+.addr-popup__list {
511
+	max-height: 50vh;
512
+}
513
+.addr-option {
514
+	padding: 20rpx;
515
+	margin-bottom: 12rpx;
516
+	background: #f9f9f9;
517
+	border-radius: 12rpx;
518
+	border: 2rpx solid transparent;
519
+}
520
+.addr-option--on {
521
+	border-color: #2e7d32;
522
+	background: #f1f8f2;
523
+}
524
+.addr-option__head {
525
+	display: flex;
526
+	align-items: center;
527
+	flex-wrap: wrap;
528
+	gap: 12rpx;
529
+}
530
+.addr-option__name {
531
+	font-size: 28rpx;
532
+	font-weight: 500;
533
+	color: #333;
534
+}
535
+.addr-option__mobile {
536
+	font-size: 26rpx;
537
+	color: #666;
538
+}
539
+.addr-option__tag {
540
+	font-size: 22rpx;
541
+	color: #2e7d32;
542
+	background: #e8f5e9;
543
+	padding: 4rpx 10rpx;
544
+	border-radius: 6rpx;
545
+}
546
+.addr-option__detail {
547
+	display: block;
548
+	margin-top: 8rpx;
549
+	font-size: 24rpx;
550
+	color: #666;
551
+	line-height: 1.5;
552
+}
553
+.addr-popup__empty {
554
+	padding: 48rpx;
555
+	text-align: center;
556
+	color: #999;
557
+	font-size: 26rpx;
558
+}
559
+</style>

+ 542 - 0
shop-app/subpackage/order/checkout.vue

@@ -0,0 +1,542 @@
1
+<template>
2
+	<view class="checkout-page">
3
+		<view v-if="pageLoading" class="checkout-page__loading">
4
+			<u-loading-icon mode="circle" />
5
+		</view>
6
+
7
+		<view v-else-if="pageError" class="checkout-page__error">
8
+			<u-empty mode="page" :text="pageError" icon-size="80" />
9
+			<u-button type="primary" text="重试" size="small" custom-style="margin-top:24rpx" @click="loadPreview" />
10
+			<u-button plain text="返回" size="small" custom-style="margin-top:16rpx" @click="onBack" />
11
+		</view>
12
+
13
+		<template v-else-if="preview">
14
+			<scroll-view class="checkout-scroll" scroll-y :style="{ height: scrollHeight }">
15
+				<address-bar :address="preview.address" @click="onAddressTap" />
16
+
17
+				<view class="shop-head">
18
+					<image class="shop-head__avatar" :src="preview.shop.shopAvatar" mode="aspectFill" />
19
+					<text class="shop-head__name">{{ preview.shop.shopName }}</text>
20
+				</view>
21
+
22
+				<checkout-goods-row
23
+					v-if="preview.goods"
24
+					:goods="preview.goods"
25
+					@quantity-change="onQuantityChange"
26
+				/>
27
+
28
+				<view class="amount-card">
29
+					<view class="amount-row">
30
+						<text class="amount-row__label">商品总价</text>
31
+						<text class="amount-row__val">¥{{ preview.goodsAmountText }}</text>
32
+					</view>
33
+					<view class="amount-row">
34
+						<text class="amount-row__label">运费</text>
35
+						<view class="amount-row__right">
36
+							<text class="amount-row__val">¥{{ preview.freightAmountText }}</text>
37
+							<text v-if="preview.freightDesc" class="amount-row__desc">{{ preview.freightDesc }}</text>
38
+						</view>
39
+					</view>
40
+				</view>
41
+
42
+				<view class="remark-card">
43
+					<text class="remark-card__label">买家备注</text>
44
+					<textarea
45
+						class="remark-card__input"
46
+						v-model="buyerRemark"
47
+						:maxlength="remarkMax"
48
+						placeholder="选填,可填写给商家的留言"
49
+						:auto-height="true"
50
+					/>
51
+					<text class="remark-card__count">{{ buyerRemark.length }}/{{ remarkMax }}</text>
52
+				</view>
53
+
54
+				<view class="pay-card">
55
+					<text class="pay-card__label">支付方式</text>
56
+					<view class="pay-card__item">
57
+						<u-icon name="weixin-fill" color="#09bb07" size="22" />
58
+						<text class="pay-card__text">微信支付</text>
59
+						<u-icon name="checkbox-mark" color="#2e7d32" size="18" />
60
+					</view>
61
+				</view>
62
+			</scroll-view>
63
+
64
+			<view class="checkout-footer">
65
+				<view class="checkout-footer__sum">
66
+					<text class="checkout-footer__label">应付:</text>
67
+					<text class="checkout-footer__price">¥{{ preview.payAmountText }}</text>
68
+				</view>
69
+				<button class="checkout-footer__btn" :disabled="submitting" @click="onSubmit">
70
+					{{ submitting ? '提交中...' : '提交订单' }}
71
+				</button>
72
+			</view>
73
+		</template>
74
+
75
+		<!-- 地址选择 -->
76
+		<u-popup :show="addressPopupShow" mode="bottom" round="16" @close="addressPopupShow = false">
77
+			<view class="addr-popup">
78
+				<view class="addr-popup__head">
79
+					<text class="addr-popup__title">选择收货地址</text>
80
+					<text class="addr-popup__add" @click="goAddAddress">新增地址</text>
81
+				</view>
82
+				<scroll-view scroll-y class="addr-popup__list">
83
+					<view
84
+						v-for="item in addressOptions"
85
+						:key="item.addressId"
86
+						class="addr-option"
87
+						:class="{ 'addr-option--on': item.addressId === selectedAddressId }"
88
+						@click="onSelectAddress(item)"
89
+					>
90
+						<view class="addr-option__head">
91
+							<text class="addr-option__name">{{ item.consigneeName }}</text>
92
+							<text class="addr-option__mobile">{{ item.mobile }}</text>
93
+							<text v-if="item.isDefault" class="addr-option__tag">默认</text>
94
+						</view>
95
+						<text class="addr-option__detail">{{ item.fullAddress }}</text>
96
+					</view>
97
+					<view v-if="!addressOptions.length" class="addr-popup__empty">
98
+						<text>暂无收货地址,请先新增</text>
99
+					</view>
100
+				</scroll-view>
101
+			</view>
102
+		</u-popup>
103
+
104
+		<!-- v1 Mock 微信支付 -->
105
+		<u-modal
106
+			:show="payModalShow"
107
+			title="微信支付"
108
+			:content="payModalContent"
109
+			show-cancel-button
110
+			confirm-text="确认支付"
111
+			cancel-text="取消支付"
112
+			@confirm="onConfirmPay"
113
+			@cancel="onCancelPay"
114
+			@close="payModalShow = false"
115
+		/>
116
+	</view>
117
+</template>
118
+
119
+<script setup>
120
+import { ref } from 'vue'
121
+import { onLoad, onShow } from '@dcloudio/uni-app'
122
+import { previewCheckout, submitCheckout } from '@/api/checkout'
123
+import { payOrder, cancelPay } from '@/api/order'
124
+import { getAddressList } from '@/api/member'
125
+import { mapCheckoutPreview, mapAddressOption } from '@/utils/checkoutDisplay'
126
+import {
127
+	CHECKOUT_SOURCE_BUY_NOW,
128
+	CHECKOUT_PAY_TYPE_WECHAT,
129
+	CHECKOUT_REMARK_MAX
130
+} from '@/constants/checkout'
131
+import { ensureApiToken } from '@/utils/apiAuth'
132
+import { PAGE_ADDRESS_EDIT, PAGE_PAY_RESULT } from '@/utils/pageRoute'
133
+import AddressBar from '@/components/order/AddressBar.vue'
134
+import CheckoutGoodsRow from '@/components/order/CheckoutGoodsRow.vue'
135
+
136
+const goodsId = ref('')
137
+const quantity = ref(1)
138
+const specText = ref('')
139
+const selectedAddressId = ref(null)
140
+const buyerRemark = ref('')
141
+const preview = ref(null)
142
+const pageLoading = ref(true)
143
+const pageError = ref('')
144
+const submitting = ref(false)
145
+const previewing = ref(false)
146
+const scrollHeight = ref('600px')
147
+const remarkMax = CHECKOUT_REMARK_MAX
148
+
149
+const addressPopupShow = ref(false)
150
+const addressOptions = ref([])
151
+
152
+const payModalShow = ref(false)
153
+const payModalContent = ref('')
154
+const pendingOrderId = ref(null)
155
+const paying = ref(false)
156
+
157
+let previewTimer = null
158
+
159
+function calcScrollHeight() {
160
+	try {
161
+		const sys = uni.getSystemInfoSync()
162
+		scrollHeight.value = `${(sys.windowHeight || 600) - 56}px`
163
+	} catch (e) {
164
+		scrollHeight.value = '600px'
165
+	}
166
+}
167
+
168
+function buildPreviewBody() {
169
+	return {
170
+		source: CHECKOUT_SOURCE_BUY_NOW,
171
+		addressId: selectedAddressId.value || undefined,
172
+		items: [
173
+			{
174
+				goodsId: goodsId.value,
175
+				quantity: quantity.value,
176
+				specText: specText.value || undefined
177
+			}
178
+		]
179
+	}
180
+}
181
+
182
+function buildSubmitBody() {
183
+	return {
184
+		source: CHECKOUT_SOURCE_BUY_NOW,
185
+		addressId: selectedAddressId.value,
186
+		payType: CHECKOUT_PAY_TYPE_WECHAT,
187
+		items: [
188
+			{
189
+				goodsId: goodsId.value,
190
+				quantity: quantity.value,
191
+				specText: specText.value || undefined,
192
+				buyerRemark: (buyerRemark.value || '').trim()
193
+			}
194
+		]
195
+	}
196
+}
197
+
198
+async function loadPreview() {
199
+	if (!goodsId.value || previewing.value) return
200
+	previewing.value = true
201
+	pageLoading.value = !preview.value
202
+	pageError.value = ''
203
+	try {
204
+		const res = await previewCheckout(buildPreviewBody())
205
+		const mapped = mapCheckoutPreview(res.data)
206
+		if (!mapped || !mapped.goods) {
207
+			throw new Error('订单预览数据异常')
208
+		}
209
+		preview.value = mapped
210
+		quantity.value = mapped.goods.quantity
211
+		if (mapped.address && mapped.address.addressId) {
212
+			selectedAddressId.value = mapped.address.addressId
213
+		}
214
+	} catch (e) {
215
+		pageError.value = (e && e.msg) || (e && e.message) || '加载失败'
216
+		if (!preview.value) {
217
+			preview.value = null
218
+		}
219
+	} finally {
220
+		pageLoading.value = false
221
+		previewing.value = false
222
+	}
223
+}
224
+
225
+function schedulePreview() {
226
+	if (previewTimer) clearTimeout(previewTimer)
227
+	previewTimer = setTimeout(() => {
228
+		loadPreview()
229
+	}, 300)
230
+}
231
+
232
+function onQuantityChange(nextQty) {
233
+	quantity.value = nextQty
234
+	schedulePreview()
235
+}
236
+
237
+async function onAddressTap() {
238
+	try {
239
+		const res = await getAddressList()
240
+		addressOptions.value = (res.data || []).map(mapAddressOption).filter(Boolean)
241
+	} catch (e) {
242
+		addressOptions.value = []
243
+	}
244
+	addressPopupShow.value = true
245
+}
246
+
247
+function onSelectAddress(item) {
248
+	selectedAddressId.value = item.addressId
249
+	addressPopupShow.value = false
250
+	schedulePreview()
251
+}
252
+
253
+function goAddAddress() {
254
+	addressPopupShow.value = false
255
+	uni.navigateTo({ url: `${PAGE_ADDRESS_EDIT}?mode=add` })
256
+}
257
+
258
+async function onSubmit() {
259
+	if (!selectedAddressId.value) {
260
+		uni.showToast({ title: '请选择收货地址', icon: 'none' })
261
+		return
262
+	}
263
+	if (submitting.value) return
264
+	submitting.value = true
265
+	try {
266
+		const res = await submitCheckout(buildSubmitBody())
267
+		const data = res.data || {}
268
+		pendingOrderId.value = data.orderId
269
+		payModalContent.value = `订单号:${data.orderNo || '—'}\n应付金额:¥${data.payAmount != null ? data.payAmount : preview.value?.payAmountText || '—'}`
270
+		payModalShow.value = true
271
+	} catch (e) {
272
+		// request 已 Toast
273
+	} finally {
274
+		submitting.value = false
275
+	}
276
+}
277
+
278
+async function onConfirmPay() {
279
+	if (!pendingOrderId.value || paying.value) return
280
+	paying.value = true
281
+	payModalShow.value = false
282
+	try {
283
+		await payOrder(pendingOrderId.value)
284
+		uni.redirectTo({
285
+			url: `${PAGE_PAY_RESULT}?status=success&orderId=${pendingOrderId.value}`
286
+		})
287
+	} catch (e) {
288
+		// request 已 Toast
289
+	} finally {
290
+		paying.value = false
291
+	}
292
+}
293
+
294
+async function onCancelPay() {
295
+	if (!pendingOrderId.value || paying.value) return
296
+	paying.value = true
297
+	payModalShow.value = false
298
+	try {
299
+		await cancelPay(pendingOrderId.value)
300
+		uni.redirectTo({
301
+			url: `${PAGE_PAY_RESULT}?status=closed&orderId=${pendingOrderId.value}`
302
+		})
303
+	} catch (e) {
304
+		// request 已 Toast
305
+	} finally {
306
+		paying.value = false
307
+	}
308
+}
309
+
310
+function onBack() {
311
+	uni.navigateBack()
312
+}
313
+
314
+onLoad((options) => {
315
+	calcScrollHeight()
316
+	goodsId.value = options.goodsId || ''
317
+	quantity.value = Math.max(1, Number(options.quantity) || 1)
318
+	const st = options.specText ? decodeURIComponent(options.specText) : ''
319
+	specText.value = (st || '').trim()
320
+	if (!goodsId.value) {
321
+		pageError.value = '商品信息缺失'
322
+		pageLoading.value = false
323
+		setTimeout(() => onBack(), 800)
324
+	}
325
+})
326
+
327
+onShow(() => {
328
+	if (!ensureApiToken(false)) return
329
+	if (!goodsId.value) return
330
+	loadPreview()
331
+})
332
+</script>
333
+
334
+<style lang="scss" scoped>
335
+.checkout-page {
336
+	min-height: 100vh;
337
+	background: #f5f6f8;
338
+	padding-bottom: env(safe-area-inset-bottom);
339
+}
340
+.checkout-page__loading,
341
+.checkout-page__error {
342
+	padding: 120rpx 48rpx;
343
+	display: flex;
344
+	flex-direction: column;
345
+	align-items: center;
346
+}
347
+.checkout-scroll {
348
+	box-sizing: border-box;
349
+}
350
+.shop-head {
351
+	display: flex;
352
+	align-items: center;
353
+	padding: 16rpx 24rpx;
354
+	margin: 0 24rpx 2rpx;
355
+	background: #fff;
356
+	border-radius: 12rpx 12rpx 0 0;
357
+}
358
+.shop-head__avatar {
359
+	width: 48rpx;
360
+	height: 48rpx;
361
+	border-radius: 8rpx;
362
+	background: #eee;
363
+}
364
+.shop-head__name {
365
+	margin-left: 12rpx;
366
+	font-size: 28rpx;
367
+	color: #333;
368
+	font-weight: 500;
369
+}
370
+.amount-card,
371
+.remark-card,
372
+.pay-card {
373
+	margin: 16rpx 24rpx;
374
+	padding: 24rpx;
375
+	background: #fff;
376
+	border-radius: 12rpx;
377
+}
378
+.amount-row {
379
+	display: flex;
380
+	justify-content: space-between;
381
+	align-items: flex-start;
382
+	padding: 12rpx 0;
383
+}
384
+.amount-row__label {
385
+	font-size: 28rpx;
386
+	color: #666;
387
+}
388
+.amount-row__right {
389
+	text-align: right;
390
+}
391
+.amount-row__val {
392
+	font-size: 28rpx;
393
+	color: #333;
394
+}
395
+.amount-row__desc {
396
+	display: block;
397
+	margin-top: 4rpx;
398
+	font-size: 22rpx;
399
+	color: #999;
400
+}
401
+.remark-card__label,
402
+.pay-card__label {
403
+	font-size: 28rpx;
404
+	color: #333;
405
+	font-weight: 500;
406
+}
407
+.remark-card__input {
408
+	width: 100%;
409
+	min-height: 120rpx;
410
+	margin-top: 16rpx;
411
+	padding: 16rpx;
412
+	font-size: 26rpx;
413
+	background: #f9f9f9;
414
+	border-radius: 8rpx;
415
+	box-sizing: border-box;
416
+}
417
+.remark-card__count {
418
+	display: block;
419
+	margin-top: 8rpx;
420
+	text-align: right;
421
+	font-size: 22rpx;
422
+	color: #bbb;
423
+}
424
+.pay-card__item {
425
+	margin-top: 16rpx;
426
+	display: flex;
427
+	align-items: center;
428
+	gap: 12rpx;
429
+}
430
+.pay-card__text {
431
+	flex: 1;
432
+	font-size: 28rpx;
433
+	color: #333;
434
+}
435
+.checkout-footer {
436
+	position: fixed;
437
+	left: 0;
438
+	right: 0;
439
+	bottom: 0;
440
+	display: flex;
441
+	align-items: center;
442
+	padding: 16rpx 24rpx;
443
+	padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
444
+	background: #fff;
445
+	border-top: 1rpx solid #eee;
446
+	z-index: 10;
447
+}
448
+.checkout-footer__sum {
449
+	flex: 1;
450
+}
451
+.checkout-footer__label {
452
+	font-size: 26rpx;
453
+	color: #333;
454
+}
455
+.checkout-footer__price {
456
+	font-size: 36rpx;
457
+	color: #e53935;
458
+	font-weight: 600;
459
+}
460
+.checkout-footer__btn {
461
+	min-width: 220rpx;
462
+	height: 72rpx;
463
+	line-height: 72rpx;
464
+	padding: 0 32rpx;
465
+	background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
466
+	color: #fff;
467
+	font-size: 28rpx;
468
+	border-radius: 36rpx;
469
+	border: none;
470
+}
471
+.checkout-footer__btn[disabled] {
472
+	opacity: 0.6;
473
+}
474
+.addr-popup {
475
+	padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
476
+	max-height: 70vh;
477
+}
478
+.addr-popup__head {
479
+	display: flex;
480
+	justify-content: space-between;
481
+	align-items: center;
482
+	margin-bottom: 16rpx;
483
+}
484
+.addr-popup__title {
485
+	font-size: 30rpx;
486
+	font-weight: 600;
487
+	color: #333;
488
+}
489
+.addr-popup__add {
490
+	font-size: 26rpx;
491
+	color: #2e7d32;
492
+}
493
+.addr-popup__list {
494
+	max-height: 50vh;
495
+}
496
+.addr-option {
497
+	padding: 20rpx;
498
+	margin-bottom: 12rpx;
499
+	background: #f9f9f9;
500
+	border-radius: 12rpx;
501
+	border: 2rpx solid transparent;
502
+}
503
+.addr-option--on {
504
+	border-color: #2e7d32;
505
+	background: #f1f8f2;
506
+}
507
+.addr-option__head {
508
+	display: flex;
509
+	align-items: center;
510
+	flex-wrap: wrap;
511
+	gap: 12rpx;
512
+}
513
+.addr-option__name {
514
+	font-size: 28rpx;
515
+	font-weight: 500;
516
+	color: #333;
517
+}
518
+.addr-option__mobile {
519
+	font-size: 26rpx;
520
+	color: #666;
521
+}
522
+.addr-option__tag {
523
+	font-size: 22rpx;
524
+	color: #2e7d32;
525
+	background: #e8f5e9;
526
+	padding: 4rpx 10rpx;
527
+	border-radius: 6rpx;
528
+}
529
+.addr-option__detail {
530
+	display: block;
531
+	margin-top: 8rpx;
532
+	font-size: 24rpx;
533
+	color: #666;
534
+	line-height: 1.5;
535
+}
536
+.addr-popup__empty {
537
+	padding: 48rpx;
538
+	text-align: center;
539
+	color: #999;
540
+	font-size: 26rpx;
541
+}
542
+</style>

+ 96 - 0
shop-app/subpackage/order/pay-result.vue

@@ -0,0 +1,96 @@
1
+<template>
2
+	<view class="pay-result">
3
+		<u-icon
4
+			:name="isSuccess ? 'checkmark-circle-fill' : 'close-circle-fill'"
5
+			:color="isSuccess ? '#2e7d32' : '#999'"
6
+			size="80"
7
+		/>
8
+		<text class="pay-result__title">{{ title }}</text>
9
+		<text v-if="orderNo" class="pay-result__sub">订单号:{{ orderNo }}</text>
10
+		<text class="pay-result__tip">{{ tip }}</text>
11
+		<button v-if="isSuccess" class="pay-result__btn" @click="goHome">返回首页</button>
12
+		<button v-else class="pay-result__btn" @click="goBackDetail">返回继续购买</button>
13
+	</view>
14
+</template>
15
+
16
+<script setup>
17
+import { ref, computed } from 'vue'
18
+import { onLoad } from '@dcloudio/uni-app'
19
+import { getOrderDetail } from '@/api/order'
20
+import { PAGE_HOME } from '@/utils/pageRoute'
21
+
22
+const status = ref('')
23
+const orderId = ref('')
24
+const orderNo = ref('')
25
+
26
+const isSuccess = computed(() => status.value === 'success')
27
+
28
+const title = computed(() => (isSuccess.value ? '支付成功' : '订单已关闭'))
29
+
30
+const tip = computed(() =>
31
+	isSuccess.value
32
+		? '商家将尽快为您发货'
33
+		: '您已取消支付,可返回商品详情重新购买'
34
+)
35
+
36
+function goHome() {
37
+	uni.switchTab({ url: PAGE_HOME })
38
+}
39
+
40
+function goBackDetail() {
41
+	uni.navigateBack({ delta: 2 })
42
+}
43
+
44
+onLoad(async (options) => {
45
+	status.value = options.status || ''
46
+	orderId.value = options.orderId || ''
47
+	if (orderId.value) {
48
+		try {
49
+			const res = await getOrderDetail(orderId.value)
50
+			orderNo.value = (res.data && res.data.orderNo) || ''
51
+		} catch (e) {
52
+			// ignore
53
+		}
54
+	}
55
+})
56
+</script>
57
+
58
+<style lang="scss" scoped>
59
+.pay-result {
60
+	min-height: 100vh;
61
+	padding: 120rpx 48rpx;
62
+	display: flex;
63
+	flex-direction: column;
64
+	align-items: center;
65
+	background: #f5f6f8;
66
+}
67
+.pay-result__title {
68
+	margin-top: 32rpx;
69
+	font-size: 36rpx;
70
+	font-weight: 600;
71
+	color: #333;
72
+}
73
+.pay-result__sub {
74
+	margin-top: 16rpx;
75
+	font-size: 26rpx;
76
+	color: #666;
77
+}
78
+.pay-result__tip {
79
+	margin-top: 24rpx;
80
+	font-size: 26rpx;
81
+	color: #999;
82
+	text-align: center;
83
+	line-height: 1.6;
84
+}
85
+.pay-result__btn {
86
+	margin-top: 64rpx;
87
+	width: 320rpx;
88
+	height: 80rpx;
89
+	line-height: 80rpx;
90
+	background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
91
+	color: #fff;
92
+	font-size: 28rpx;
93
+	border-radius: 40rpx;
94
+	border: none;
95
+}
96
+</style>

+ 111 - 0
shop-app/utils/checkoutDisplay.js

@@ -0,0 +1,111 @@
1
+import { resolveFileUrl } from '@/utils/image'
2
+import { formatPrice } from '@/utils/format'
3
+import { parseCartSpecText } from '@/utils/cartSpec'
4
+
5
+const GOODS_PLACEHOLDER = '/static/logo.png'
6
+const SHOP_PLACEHOLDER = '/static/logo.png'
7
+
8
+function formatAddress(addr) {
9
+  if (!addr) return ''
10
+  const region = (addr.regionName || '').replace(/\//g, '')
11
+  return `${region}${addr.detailAddress || ''}`.trim()
12
+}
13
+
14
+export function mapGoodsItem(item) {
15
+  if (!item) return null
16
+  const specText = item.specText || '默认'
17
+  return {
18
+    cartItemId: item.cartItemId,
19
+    goodsId: item.goodsId,
20
+    goodsName: item.goodsName || '',
21
+    displayPic: resolveFileUrl(item.mainPic) || GOODS_PLACEHOLDER,
22
+    specText,
23
+    specList: parseCartSpecText(specText),
24
+    serviceDesc: item.serviceDesc || '',
25
+    salePrice: item.salePrice,
26
+    priceText: formatPrice(item.salePrice),
27
+    quantity: Number(item.quantity) || 1,
28
+    maxStock: Number(item.maxStock) || 1,
29
+    subtotal: item.subtotal,
30
+    subtotalText: formatPrice(item.subtotal),
31
+    freightType: item.freightType,
32
+    freightDesc: item.freightDesc || '',
33
+    buyerRemark: item.buyerRemark || ''
34
+  }
35
+}
36
+
37
+function mapShopAndAddress(data) {
38
+  const shop = data.shop || {}
39
+  const addr = data.address || null
40
+  return {
41
+    shop: {
42
+      shopId: shop.shopId,
43
+      shopName: shop.shopName || '',
44
+      shopAvatar: resolveFileUrl(shop.shopAvatar) || SHOP_PLACEHOLDER,
45
+      shopStatus: shop.shopStatus
46
+    },
47
+    address: addr
48
+      ? {
49
+          addressId: addr.addressId,
50
+          consigneeName: addr.consigneeName || '',
51
+          mobile: addr.mobile || '',
52
+          regionName: addr.regionName || '',
53
+          detailAddress: addr.detailAddress || '',
54
+          fullAddress: formatAddress(addr),
55
+          isDefault: addr.isDefault === '1'
56
+        }
57
+      : null,
58
+    goodsAmount: data.goodsAmount,
59
+    freightAmount: data.freightAmount,
60
+    freightDesc: data.freightDesc || '',
61
+    payAmount: data.payAmount,
62
+    goodsAmountText: formatPrice(data.goodsAmount),
63
+    freightAmountText: formatPrice(data.freightAmount),
64
+    payAmountText: formatPrice(data.payAmount),
65
+    payType: data.payType || 'WECHAT'
66
+  }
67
+}
68
+
69
+/** preview 接口 data → 单商品页面模型 */
70
+export function mapCheckoutPreview(data) {
71
+  if (!data) return null
72
+  const base = mapShopAndAddress(data)
73
+  return {
74
+    ...base,
75
+    goods: mapGoodsItem((data.items || [])[0])
76
+  }
77
+}
78
+
79
+/** preview 接口 data → 购物车多商品页面模型 */
80
+export function mapCheckoutPreviewCart(data, remarkMap = {}) {
81
+  if (!data) return null
82
+  const base = mapShopAndAddress(data)
83
+  const goodsList = (data.items || [])
84
+    .map((row) => {
85
+      const item = mapGoodsItem(row)
86
+      if (!item) return null
87
+      if (item.cartItemId && remarkMap[item.cartItemId] != null) {
88
+        item.buyerRemark = remarkMap[item.cartItemId]
89
+      }
90
+      return item
91
+    })
92
+    .filter(Boolean)
93
+  return {
94
+    ...base,
95
+    goodsList
96
+  }
97
+}
98
+
99
+/** 会员地址列表行 → 选择弹层 */
100
+export function mapAddressOption(row) {
101
+  if (!row) return null
102
+  const region = (row.regionName || '').replace(/\//g, '')
103
+  const full = row.fullAddress || `${region}${row.detailAddress || ''}`
104
+  return {
105
+    addressId: row.addressId,
106
+    consigneeName: row.consigneeName || '',
107
+    mobile: row.mobile || '',
108
+    fullAddress: full,
109
+    isDefault: row.isDefault === '1'
110
+  }
111
+}

+ 16 - 0
shop-app/utils/checkoutNav.js

@@ -0,0 +1,16 @@
1
+import { PAGE_CHECKOUT_CART } from '@/utils/pageRoute'
2
+import { CART_CHECKOUT_IDS_KEY } from '@/constants/checkout'
3
+
4
+/** 购物车同店去结算 → 多商品确认页 */
5
+export function goCartCheckout(cartItemIds) {
6
+  const ids = (cartItemIds || []).filter(Boolean)
7
+  if (!ids.length) return
8
+  try {
9
+    uni.setStorageSync(CART_CHECKOUT_IDS_KEY, ids)
10
+  } catch (e) {
11
+    // ignore
12
+  }
13
+  uni.navigateTo({
14
+    url: `${PAGE_CHECKOUT_CART}?cartItemIds=${ids.join(',')}`
15
+  })
16
+}

+ 10 - 2
shop-app/utils/pageRoute.js

@@ -61,6 +61,14 @@ export const PAGE_SHOP_HOME = '/subpackage/shop/index'
61 61
 /** 店内搜索 */
62 62
 export const PAGE_SHOP_SEARCH = '/subpackage/shop/search'
63 63
 
64
-// —— 分包:待建模块(示例,落地后注册 pages.json)——
64
+// —— 分包:订单 ——
65
+
66
+/** 确认订单(立即购买 · 单商品) */
67
+export const PAGE_CHECKOUT = '/subpackage/order/checkout'
68
+/** 确认订单(购物车 · 多商品同店) */
69
+export const PAGE_CHECKOUT_CART = '/subpackage/order/checkout-cart'
70
+/** 支付结果 */
71
+export const PAGE_PAY_RESULT = '/subpackage/order/pay-result'
72
+
73
+// —— 分包:待建模块 ——
65 74
 // export const PAGE_ORDER_LIST = '/subpackage/order/list'
66
-// export const PAGE_ORDER_CONFIRM = '/subpackage/order/confirm'