xsh_1997 1 nedēļu atpakaļ
vecāks
revīzija
5bffcba3dd

+ 245 - 80
doc/店铺后台/商品管理/评价管理/评价管理前端技术方案.md

@@ -1,9 +1,9 @@
1 1
 # 评价管理 — 前端技术方案
2 2
 
3
-> **依据:** 《评价管理功能需求.md》v1.1、《评价管理技术方案.md》v1.1  
3
+> **依据:** 《评价管理功能需求.md》v1.2、《评价管理技术方案.md》v1.2  
4 4
 > **前端规范:** `doc/前端设计/前端设计.md`  
5 5
 > **范围:** 仅 **ruoyi-ui 商家端** 当前店铺评价列表、检索、详情查看、商家回复、逻辑删除;**不含** C 端发表评价、平台审核。  
6
-> **实现状态:** 页面与 API 封装已落地,待菜单配置及 `/agri/seller/review` 联调。
6
+> **实现状态:** `index.vue`、`detail.vue`、`api/agri/seller/review.js` **已按 v1.2 一行一评落地**;待菜单配置及 `/agri/seller/review` 联调。
7 7
 
8 8
 ---
9 9
 
@@ -15,24 +15,45 @@
15 15
 | 请求 | `@/utils/request` + `sellerShopHeaders()` 携带 **`X-Shop-Id`** |
16 16
 | 参考页面 | `agri/seller/order/index.vue`(检索+页签+列表)、`agri/seller/goods/detail.vue`(详情抽屉) |
17 17
 | 布局 | 检索 `el-card` + `<br/>` + 列表 `el-card` + `border` 表格 |
18
+| 详情 | 右侧 `el-drawer`(`size="72%"`,`append-to-body`) |
19
+| 图片 | 全局 `image-preview` 组件(列表缩略、详情多图) |
18 20
 | 店铺切换 | **仅 Navbar**;业务页禁止展示店铺选择器 |
19 21
 
20 22
 ---
21 23
 
22
-## 2. 文件清单
24
+## 2. v1.2 业务变更(相对 v1.0/v1.1)
25
+
26
+| 项 | 旧口径 | v1.2 定稿 |
27
+|----|--------|-----------|
28
+| 评价粒度 | 一单一评 | **一行一评**(`order_item_id` / `uk_order_item_review`) |
29
+| 列表行 | 整单首商品 | **一条评价 = 一个订单商品行** 快照 |
30
+| 商品名称 | — | 后端 **JOIN `biz_order_item`** 取 `goods_name` |
31
+| 删除后买家 | 不可再评该订单 | 不可再评该 **商品行**(RM3) |
32
+| C 端协作 | — | 与《我的订单》v1.2 `items[]` 按行评价一致 |
33
+
34
+**前端不改接口路径**;列表/详情仍用 `goodsName`、`goodsSpec`、`goodsMainPic` 展示评价对象。
35
+
36
+---
37
+
38
+## 3. 文件清单
23 39
 
24 40
 | 类型 | 路径 | 说明 |
25 41
 |------|------|------|
26
-| 列表页 | `ruoyi-ui/src/views/agri/seller/review/index.vue` | 检索、回复状态页签、列表、回复弹窗 |
27
-| 详情抽屉 | `ruoyi-ui/src/views/agri/seller/review/detail.vue` | 分区展示、回复/删除 |
42
+| 列表页 | `ruoyi-ui/src/views/agri/seller/review/index.vue` | 检索、回复状态页签、表、回复弹窗、详情抽屉宿主 |
43
+| 详情抽屉 | `ruoyi-ui/src/views/agri/seller/review/detail.vue` | 分区展示、一行一评说明、回复/删除(事件上抛) |
28 44
 | 评价 API | `ruoyi-ui/src/api/agri/seller/review.js` | list、detail、reply、remove |
29 45
 | 店铺上下文 | `api/agri/seller/context.js` + `utils/sellerShop.js` | X-Shop-Id |
30 46
 
31
-**组件 name(keep-alive):** `AgriSellerReview`
47
+**组件 name(keep-alive):**
48
+
49
+| 组件 | name |
50
+|------|------|
51
+| 列表页 | `AgriSellerReview` |
52
+| 详情抽屉 | `SellerReviewDetail` |
32 53
 
33 54
 ---
34 55
 
35
-## 3. 菜单与路由
56
+## 4. 菜单与路由
36 57
 
37 58
 | 菜单名称 | 组件路径 | 路由 path(建议) | 权限标识 |
38 59
 |----------|----------|-------------------|----------|
@@ -40,54 +61,190 @@
40 61
 
41 62
 **上级菜单:** 店铺经营管理端 → **商品管理**
42 63
 
43
-| 按钮权限 | 标识 | 说明 |
44
-|----------|------|------|
45
-| 列表 | `agri:seller:review:list` | 分页列表 |
46
-| 详情 | `agri:seller:review:query` | 查看详情抽屉 |
47
-| 回复 | `agri:seller:review:reply` | 新增/修改商家回复 |
48
-| 删除 | `agri:seller:review:remove` | 逻辑删除 |
64
+| 按钮权限 | 标识 | 页面落点 |
65
+|----------|------|----------|
66
+| 列表 | `agri:seller:review:list` | 进入列表页 |
67
+| 详情 | `agri:seller:review:query` | 操作列「查看」 |
68
+| 回复 | `agri:seller:review:reply` | 操作列「回复」、详情「回复评价/修改回复」 |
69
+| 删除 | `agri:seller:review:remove` | 操作列「删除」、详情「删除评价」 |
49 70
 
50 71
 ---
51 72
 
52
-## 4. 页面结构
73
+## 5. 页面结构(与代码一致)
53 74
 
54 75
 ```text
55
-评价管理
56
-├── 检索区(el-card)
57
-│   ├── 会员名称 memberNickName(模糊)
58
-│   ├── 商品名称 goodsName(模糊,匹配评价对象快照)
59
-│   ├── 评分 score(1~5 星,可选)
60
-│   └── 评价时间 beginTime~endTime(daterange)
61
-├── 列表区(el-card + border 表格)
62
-│   ├── 回复状态页签:全部 | 未回复 | 已回复
63
-│   ├── 列:会员名称、评价对象、评价内容、晒图、评分、评价时间、回复状态
64
-│   └── 操作:查看、回复、删除
65
-├── 详情抽屉 detail.vue(72% 宽)
66
-│   ├── 评价信息 / 评价对象 / 买家信息 / 关联订单 / 商家回复
67
-│   └── 操作:回复(或修改回复)、删除
68
-└── 回复弹窗
69
-    └── 回复内容 replyContent(必填,≤500 字)
76
+评价管理 index.vue
77
+├── 检索区 search-card(el-card,v-show 受 right-toolbar 控制)
78
+│   ├── 会员名称 memberNickName(140px,回车搜索)
79
+│   ├── 商品名称 goodsName(placeholder「评价对象名称」)
80
+│   ├── 评分 score(el-select 1~5 星,可清空)
81
+│   ├── 评价时间 dateRange(daterange → beginTime/endTime)
82
+│   └── 搜索 / 重置
83
+├── 列表区 table-card(el-card)
84
+│   ├── 回复状态页签 replyTab:全部(all) | 未回复(UNREPLIED) | 已回复(REPLIED)
85
+│   ├── right-toolbar(显隐检索、刷新列表)
86
+│   ├── el-table border(empty-text 动态)
87
+│   │   ├── 会员名称 width=110
88
+│   │   ├── 评价对象 min-width=220(图+名+规格+一行一评提示)
89
+│   │   ├── 评价内容 min-width=180(截断 60 字)
90
+│   │   ├── 晒图 width=90(首图 +N 角标)
91
+│   │   ├── 评分 width=130(el-rate disabled)
92
+│   │   ├── 评价时间 width=160(parseTime)
93
+│   │   ├── 回复状态 width=90(Tag)
94
+│   │   └── 操作 width=200 fixed=right:查看 / 回复 / 删除
95
+│   └── pagination
96
+├── 详情抽屉 SellerReviewDetail(见 §6)
97
+└── 回复弹窗 el-dialog width=560px(由 index 统一承载)
98
+    └── replyContent textarea,maxlength=500,show-word-limit
70 99
 ```
71 100
 
72 101
 **不提供:** 页内店铺选择、修改买家评价、批量回复/删除、导出。
73 102
 
74 103
 ---
75 104
 
76
-## 5. 回复状态页签与 Query
105
+## 6. 详情抽屉 detail.vue
106
+
107
+### 6.1 对外接口
108
+
109
+| 类型 | 名称 | 说明 |
110
+|------|------|------|
111
+| props | `visible` | 抽屉显隐(`.sync`) |
112
+| props | `reviewId` | 当前评价主键 |
113
+| emit | `update:visible` | 关闭抽屉 |
114
+| emit | `reply` | 点击回复/修改回复,payload 为详情对象 |
115
+| emit | `view-order` | 点击订单编号,payload 含 `orderNo` |
116
+| emit | `success` | 删除成功后通知父页刷新列表 |
117
+| 方法 | `loadDetail()` | 父页回复成功后可选调用重载详情 |
118
+
119
+### 6.2 分区结构
120
+
121
+```text
122
+el-drawer 标题:「评价详情 · {memberNickName}」
123
+├── 评价信息(el-descriptions 2 列)
124
+│   ├── 评分:el-rate disabled + show-score
125
+│   ├── 评价时间
126
+│   ├── 评价内容(pre-wrap 全文)
127
+│   └── 晒图(有图时 span=2,72×72 多图 image-preview)
128
+├── 评价对象
129
+│   ├── section-tip:一行一评说明文案
130
+│   └── 灰底卡片:主图 64×64 + 商品名 + 规格(默认「默认」)
131
+├── 买家信息:会员名称、头像
132
+├── 关联订单:订单编号(el-button text 可点)
133
+├── 商家回复
134
+│   ├── 回复状态 Tag
135
+│   ├── 回复内容 / 回复人 / 回复时间(有则展示)
136
+│   └── 未回复时提示:「待回复,买家评价已在 C 端展示」
137
+└── action-bar:回复评价或修改回复、删除评价
138
+```
139
+
140
+**删除:** 抽屉内二次确认(同列表 RM3 文案)→ `delSellerReview` → 关闭抽屉 → `$emit('success')`。
141
+
142
+---
143
+
144
+## 7. 父子协作与数据流
145
+
146
+### 7.1 页面初始化
147
+
148
+```text
149
+created → initPage()
150
+    → loadShopContext()(GET /agri/seller/context → setSellerShopContext)
151
+        ├── 成功 / 失败均继续
152
+        └── getList()
153
+```
154
+
155
+### 7.2 列表检索与页签
156
+
157
+```text
158
+handleQuery / handleTabClick / pagination
159
+    → 组装 queryParams + dateRange → beginTime/endTime
160
+    → listSellerReview(params)
161
+    → reviewList = rows;total
162
+
163
+replyTab 与 queryParams.replyStatus 同步:
164
+    all → replyStatus 不传
165
+    UNREPLIED / REPLIED → 原样传参
166
+
167
+resetQuery:清空 dateRange、replyTab=all、resetForm、replyStatus=undefined
168
+```
169
+
170
+### 7.3 查看详情
171
+
172
+```text
173
+handleDetail(row) → currentReviewId = row.reviewId → detailOpen = true
174
+    → detail watch visible → getSellerReview(reviewId)
175
+```
176
+
177
+### 7.4 回复(列表与详情共用弹窗)
178
+
179
+```text
180
+列表 handleReply(row) / 详情 @reply
181
+    → currentReplyReviewId = reviewId
182
+    → replyDialogTitle:已回复「修改回复」否则「回复评价」
183
+    → replyForm.replyContent 回显 row.replyContent
184
+    → replyOpen = true
185
+
186
+submitReply:校验 → trim → replySellerReview(id, { replyContent })
187
+    → 成功:关闭弹窗、getList()
188
+    → 若详情抽屉打开且为同一条评价:refs.reviewDetail.loadDetail()
189
+```
190
+
191
+### 7.5 删除
192
+
193
+```text
194
+列表 handleDelete(row) → confirm(RM3) → delSellerReview → getList()
195
+
196
+详情 handleDelete → confirm(RM3) → delSellerReview → 关抽屉 → @success → getList()
197
+```
198
+
199
+### 7.6 跳转订单
200
+
201
+```text
202
+详情 @view-order → index handleViewOrder
203
+    → $router.push({ path: '/seller/order', query: { orderNo } })
204
+```
205
+
206
+---
207
+
208
+## 8. 回复状态页签与 Query
77 209
 
78 210
 | 页签 | `replyTab` | 传参 `replyStatus` |
79 211
 |------|------------|-------------------|
80
-| 全部 | `all` | 不传 |
212
+| 全部 | `all` | 不传(`undefined`) |
81 213
 | 未回复 | `UNREPLIED` | `UNREPLIED` |
82 214
 | 已回复 | `REPLIED` | `REPLIED` |
83 215
 
84
-页签与高级检索 **AND** 组合;切换页签重置 `pageNum=1` 并刷新列表。
216
+页签与高级检索 **AND** 组合;`handleTabClick` 重置 `pageNum=1` 并刷新
85 217
 
86 218
 **列表排序:** 后端 `create_time DESC`;前端不再二次排序。
87 219
 
220
+**一行一评展示(列表评价对象列):**
221
+
222
+- `image-preview` 44×44(有 `goodsMainPic` 时)
223
+- 商品名 + 规格(`goodsSpec` 空则「默认」)
224
+- 灰色小字:`一行一评 · 单商品行快照`(class `item-tip`)
225
+
88 226
 ---
89 227
 
90
-## 6. 店铺上下文(X-Shop-Id)
228
+## 9. 展示与空态(页面已实现)
229
+
230
+| 场景 | 实现 |
231
+|------|------|
232
+| 评价内容列表 | `contentSummary`:空→「—」;超 60 字加「…」 |
233
+| 晒图列表 | 首图 40×40;多于 1 张右下角 `+N` 角标 |
234
+| 回复状态 Tag | `UNREPLIED`→warning「未回复」;`REPLIED`→success「已回复」 |
235
+| 空表格文案 | 计算属性 `hasSearchFilter`:含检索字段、日期区间、**或** `replyTab !== 'all'` |
236
+| 无筛选空态 | `暂无买家评价` |
237
+| 有筛选空态 | `未找到符合条件的评价` |
238
+
239
+**删除确认文案(RM3,列表与详情一致):**
240
+
241
+```text
242
+删除后 C 端将不再展示该评价,且买家无法再次评价该商品行(一行一评),是否继续?
243
+```
244
+
245
+---
246
+
247
+## 10. 店铺上下文(X-Shop-Id)
91 248
 
92 249
 | 步骤 | 说明 |
93 250
 |------|------|
@@ -97,7 +254,7 @@
97 254
 
98 255
 ---
99 256
 
100
-## 7. API 封装
257
+## 11. API 封装
101 258
 
102 259
 **模块:** `ruoyi-ui/src/api/agri/seller/review.js`  
103 260
 **Base URL:** `/agri/seller/review`
@@ -109,41 +266,41 @@
109 266
 | `replySellerReview(reviewId, data)` | PUT | `/{reviewId}/reply` | 商家回复 |
110 267
 | `delSellerReview(reviewId)` | DELETE | `/{reviewId}` | 逻辑删除 |
111 268
 
112
-### 7.1 列表 Query
269
+### 11.1 列表 Query(`queryParams`)
113 270
 
114
-| 字段 | 说明 |
115
-|------|------|
116
-| pageNum, pageSize | 分页 |
117
-| replyStatus | 空=全部;`UNREPLIED` / `REPLIED` |
118
-| score | 1~5 |
119
-| goodsName, memberNickName | 模糊 |
120
-| beginTime, endTime | 评价时间区间(`yyyy-MM-dd`) |
271
+| 字段 | 页面绑定 | 说明 |
272
+|------|----------|------|
273
+| pageNum, pageSize | pagination | 默认 1 / 10 |
274
+| replyStatus | `replyTab` 同步 | 空=全部 |
275
+| score | 检索表单项 | 1~5 |
276
+| goodsName, memberNickName | 检索表单项 | 模糊 |
277
+| beginTime, endTime | `dateRange[0/1]` | `yyyy-MM-dd` |
121 278
 
122
-### 7.2 列表行 VO(SellerReviewListRowVO)
279
+### 11.2 列表行 VO(SellerReviewListRowVO)
123 280
 
124 281
 | 字段 | 前端用途 |
125 282
 |------|----------|
126
-| reviewId | 主键、操作入参 |
127
-| memberNickName | 会员名称列 |
128
-| goodsId, goodsName, goodsMainPic, goodsSpec | 评价对象列 |
129
-| content | 评价内容(前端截断 60 字) |
130
-| reviewPics | 晒图数组;首图缩略 + 数量角标 |
131
-| score | `el-rate` 只读展示 |
132
-| createTime | 评价时间 |
133
-| replyStatus | `UNREPLIED` / `REPLIED` → Tag |
134
-| replyContent | 回复弹窗回显(已回复时) |
135
-
136
-### 7.3 详情 VO(SellerReviewDetailVO)
283
+| reviewId | 主键、详情/回复/删除入参 |
284
+| memberNickName | 会员名称列(空→「—」) |
285
+| goodsName, goodsMainPic, goodsSpec | 评价对象列 |
286
+| content | `contentSummary` 截断 |
287
+| reviewPics | 晒图数组;首图 + 角标 |
288
+| score | `el-rate` 只读 |
289
+| createTime | `parseTime` |
290
+| replyStatus | Tag + 回复弹窗标题判断 |
291
+| replyContent | 回复弹窗回显 |
292
+
293
+### 11.3 详情 VO(SellerReviewDetailVO)
137 294
 
138 295
 | 分区 | 字段 |
139 296
 |------|------|
140
-| 评价 | reviewId, score, content, reviewPics[], createTime |
141
-| 对象 | goodsId, goodsName, goodsMainPic, goodsSpec |
297
+| 评价 | score, content, reviewPics[], createTime |
298
+| 对象 | goodsName, goodsMainPic, goodsSpec |
142 299
 | 买家 | memberNickName, memberAvatar |
143
-| 订单 | orderId, orderNo |
144
-| 回复 | replyContent, replyBy, replyTime, replyStatus |
300
+| 订单 | orderNo(跳转全部订单) |
301
+| 回复 | replyStatus, replyContent, replyBy, replyTime |
145 302
 
146
-### 7.4 回复 Body
303
+### 11.4 回复 Body
147 304
 
148 305
 ```json
149 306
 { "replyContent": "感谢您的认可,欢迎再次光临。" }
@@ -151,45 +308,53 @@
151 308
 
152 309
 | 规则 | 前端 |
153 310
 |------|------|
154
-| 必填 | 表单校验 |
311
+| 必填 | `replyRules.replyContent` |
155 312
 | 长度 ≤500 | `maxlength` + `show-word-limit` |
313
+| 提交前 trim | `submitReply` 内 `(replyContent).trim()` |
156 314
 | 已回复可修改 | 弹窗标题「修改回复」;PUT 覆盖 |
157 315
 
158 316
 ---
159 317
 
160
-## 8. 交互要点
318
+## 12. 样式要点(scoped)
161 319
 
162
-| 场景 | 行为 |
163
-|------|------|
164
-| 未回复 | 回复状态 Tag `warning`;页签可快速筛选 |
165
-| 晒图 | 列表首图 + 角标;详情多图 `image-preview` 可放大 |
166
-| 回复 | 列表/详情均可打开弹窗;已回复回显 `replyContent` |
167
-| 删除 | 二次确认;提示 C 端不可见且买家不可再评 |
168
-| 关联订单 | 详情订单编号点击 → `/seller/order?orderNo=xxx`(预填检索,依赖订单页读取 query) |
169
-| 空状态 | 后端无数据时表格为空;无额外 mock |
170
-| 回复成功 | 刷新列表;若详情抽屉打开则 `loadDetail()` 重载 |
320
+| class | 位置 | 说明 |
321
+|-------|------|------|
322
+| `goods-target` | 列表/详情 | flex 横排:图 + 文案 |
323
+| `sub-text` | 列表/详情 | 规格 12px 灰色 |
324
+| `item-tip` | 列表评价对象 | 11px 浅灰「一行一评」提示 |
325
+| `pic-cell` / `pic-count` | 列表晒图 | 相对定位 +N 角标 |
326
+| `section-header` | 详情 | 左侧蓝色竖线分区标题 |
327
+| `section-tip` | 详情评价对象 | 一行一评说明 |
328
+| `goods-target`(详情) | 灰底 `#fafafa` 圆角卡片 |
329
+| `pending-tip` | 详情未回复 | 橙色提示文案 |
330
+| `action-bar` | 详情底部 | 上边框 + 回复/删除按钮 |
171 331
 
172 332
 ---
173 333
 
174
-## 9. 联调检查清单
334
+## 13. 联调检查清单
175 335
 
176
-- [ ] 菜单组件路径 `agri/seller/review/index` 及按钮权限
336
+- [ ] 菜单组件路径 `agri/seller/review/index` 及四个按钮权限
177 337
 - [ ] Navbar 切换店铺后列表数据隔离
178
-- [ ] 回复状态页签与高级检索组合正确
179
-- [ ] 查看详情:评分、晒图、快照商品、订单号、回复区
180
-- [ ] 首次回复 → 状态变已回复 → C 端商品详情可见回复
181
-- [ ] 修改回复 → 内容更新
182
-- [ ] 删除 → 列表移除;C 端不可见
338
+- [ ] 回复状态页签与高级检索组合;重置清空页签
339
+- [ ] 空态:无筛选「暂无买家评价」;有筛选「未找到符合条件的评价」
340
+- [ ] 一单多商品:列表 **多行评价**,评价对象列快照与提示文案正确
341
+- [ ] 查看详情:五分区、晒图、一行一评说明、未回复橙色提示
342
+- [ ] 列表/详情均可打开回复弹窗;已回复回显与「修改回复」标题
343
+- [ ] 回复成功:列表刷新;详情抽屉同 ID 时 `loadDetail` 重载
344
+- [ ] 删除:RM3 文案;列表移除;详情关抽屉并刷新
345
+- [ ] 订单编号跳转 `/seller/order?orderNo=xxx`
183 346
 - [ ] 未选店铺时后端返回「请先选择当前店铺」
184 347
 
185 348
 ---
186 349
 
187
-## 10. 修订记录
350
+## 14. 修订记录
188 351
 
189 352
 | 版本 | 说明 |
190 353
 |------|------|
191
-| v1.0 | 首版:列表 + 回复状态页签 + 详情抽屉 + 回复弹窗 + 删除 |
354
+| **v1.3** | **按已落地页面回写**:列表列宽与组件、详情抽屉 props/emit/分区、父子协作数据流、空态计算属性、样式 class、回复弹窗由 index 统一承载 |
355
+| **v1.2** | 对齐需求 v1.2 **一行一评**:删除文案、评价对象说明、空态分场景、列表行语义 |
356
+| **v1.0** | 首版:列表 + 回复状态页签 + 详情抽屉 + 回复弹窗 + 删除 |
192 357
 
193 358
 ---
194 359
 
195
-*文档版本:v1.0 · 关联《评价管理功能需求.md》v1.1、《评价管理技术方案.md》v1.1*
360
+*文档版本:v1.3 · 关联《评价管理功能需求.md》v1.2、《评价管理技术方案.md》v1.2*

+ 8 - 1
ruoyi-ui/src/views/agri/seller/review/detail.vue

@@ -32,6 +32,7 @@
32 32
       </el-descriptions>
33 33
 
34 34
       <h4 class="section-header">评价对象</h4>
35
+      <p class="section-tip">一行一评:本条评价对应订单中的一个商品行,展示提交时商品快照(名称来自订单明细)。</p>
35 36
       <div class="goods-target mb16">
36 37
         <image-preview v-if="detail.goodsMainPic" :src="detail.goodsMainPic" :width="64" :height="64" />
37 38
         <div class="goods-text">
@@ -139,7 +140,7 @@ export default {
139 140
       this.$emit("reply", this.detail)
140 141
     },
141 142
     handleDelete() {
142
-      this.$modal.confirm("删除后 C 端将不再展示该评价,且买家无法再次评价该订单,是否继续?").then(() => {
143
+      this.$modal.confirm("删除后 C 端将不再展示该评价,且买家无法再次评价该商品行(一行一评),是否继续?").then(() => {
143 144
         return delSellerReview(this.reviewId)
144 145
       }).then(() => {
145 146
         this.$modal.msgSuccess("删除成功")
@@ -165,6 +166,12 @@ export default {
165 166
   border-left: 3px solid #409EFF;
166 167
   padding-left: 8px;
167 168
 }
169
+.section-tip {
170
+  margin: 0 0 10px;
171
+  font-size: 12px;
172
+  color: #909399;
173
+  line-height: 1.5;
174
+}
168 175
 .mb16 {
169 176
   margin-bottom: 16px;
170 177
 }

+ 24 - 2
ruoyi-ui/src/views/agri/seller/review/index.vue

@@ -46,7 +46,7 @@
46 46
         <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
47 47
       </el-row>
48 48
 
49
-      <el-table border v-loading="loading" :data="reviewList">
49
+      <el-table border v-loading="loading" :data="reviewList" :empty-text="emptyTableText">
50 50
         <el-table-column label="会员名称" align="center" prop="memberNickName" width="110" :show-overflow-tooltip="true">
51 51
           <template slot-scope="scope">
52 52
             <span>{{ scope.row.memberNickName || '—' }}</span>
@@ -59,6 +59,7 @@
59 59
               <div class="goods-text">
60 60
                 <div>{{ scope.row.goodsName || '—' }}</div>
61 61
                 <div class="sub-text">{{ scope.row.goodsSpec || '默认' }}</div>
62
+                <div class="item-tip">一行一评 · 单商品行快照</div>
62 63
               </div>
63 64
             </div>
64 65
           </template>
@@ -182,6 +183,22 @@ export default {
182 183
       }
183 184
     }
184 185
   },
186
+  computed: {
187
+    /** 是否有检索条件(用于区分空列表文案,对齐需求 §6.3) */
188
+    hasSearchFilter() {
189
+      const q = this.queryParams
190
+      return !!(
191
+        q.memberNickName ||
192
+        q.goodsName ||
193
+        q.score ||
194
+        (this.dateRange && this.dateRange.length === 2) ||
195
+        this.replyTab !== "all"
196
+      )
197
+    },
198
+    emptyTableText() {
199
+      return this.hasSearchFilter ? "未找到符合条件的评价" : "暂无买家评价"
200
+    }
201
+  },
185 202
   created() {
186 203
     this.initPage()
187 204
   },
@@ -292,7 +309,7 @@ export default {
292 309
       })
293 310
     },
294 311
     handleDelete(row) {
295
-      this.$modal.confirm("删除后 C 端将不再展示该评价,且买家无法再次评价该订单,是否继续?").then(() => {
312
+      this.$modal.confirm("删除后 C 端将不再展示该评价,且买家无法再次评价该商品行(一行一评),是否继续?").then(() => {
296 313
         return delSellerReview(row.reviewId)
297 314
       }).then(() => {
298 315
         this.$modal.msgSuccess("删除成功")
@@ -321,6 +338,11 @@ export default {
321 338
   font-size: 12px;
322 339
   color: #909399;
323 340
 }
341
+.item-tip {
342
+  margin-top: 2px;
343
+  font-size: 11px;
344
+  color: #c0c4cc;
345
+}
324 346
 .pic-cell {
325 347
   position: relative;
326 348
   display: inline-block;

+ 1 - 0
shop-app/PAGES.md

@@ -81,6 +81,7 @@ subpackage/
81 81
 | `PAGE_ORDER_DETAIL` | `/subpackage/order/detail` |
82 82
 | `PAGE_ORDER_REVIEW_LIST` | `/subpackage/order/review-list` |
83 83
 | `PAGE_ORDER_REVIEW_EDIT` | `/subpackage/order/review-edit` |
84
+| `PAGE_ORDER_REVIEW_VIEW` | `/subpackage/order/review-view` |
84 85
 | `PAGE_ORDER_AFTERSALE_LIST` | `/subpackage/order/aftersale-list` |
85 86
 | `PAGE_ORDER_AFTERSALE_DETAIL` | `/subpackage/order/aftersale-detail` |
86 87
 | `PAGE_ORDER_AFTERSALE_SUBMIT` | `/subpackage/order/aftersale-submit` |

+ 4 - 3
shop-app/api/orderReview.js

@@ -9,11 +9,12 @@ export function listReviews(params) {
9 9
   })
10 10
 }
11 11
 
12
-/** 查看订单评价 */
13
-export function getOrderReview(orderId) {
12
+/** 查看订单商品行评价 */
13
+export function getOrderReview(orderId, orderItemId) {
14 14
   return request({
15 15
     url: `/api/order/${orderId}/review`,
16
-    method: 'GET'
16
+    method: 'GET',
17
+    params: orderItemId != null && orderItemId !== '' ? { orderItemId } : undefined
17 18
   })
18 19
 }
19 20
 

+ 31 - 2
shop-app/components/order/OrderGoodsRow.vue

@@ -1,7 +1,7 @@
1 1
 <template>
2 2
 	<view class="order-goods-row" @click="emit('click')">
3 3
 		<image class="order-goods-row__pic" :src="item.displayPic" mode="aspectFill" />
4
-		<view class="order-goods-row__main">
4
+		<view class="order-goods-row__main" :class="{ 'order-goods-row__main--compact': reviewAction }">
5 5
 			<text class="order-goods-row__name">{{ item.goodsName }}</text>
6 6
 			<view v-if="item.specList && item.specList.length" class="order-goods-row__specs">
7 7
 				<text
@@ -20,6 +20,13 @@
20 20
 			</text>
21 21
 			<text v-if="item.buyerRemark" class="order-goods-row__remark">备注:{{ item.buyerRemark }}</text>
22 22
 		</view>
23
+		<button
24
+			v-if="reviewAction"
25
+			class="order-goods-row__review-btn"
26
+			@click.stop="emit('review', reviewAction.code)"
27
+		>
28
+			{{ reviewAction.label }}
29
+		</button>
23 30
 	</view>
24 31
 </template>
25 32
 
@@ -32,17 +39,26 @@ defineProps({
32 39
 	showSubtotal: {
33 40
 		type: Boolean,
34 41
 		default: false
42
+	},
43
+	/** { code, label } 交易成功时商品行评价按钮 */
44
+	reviewAction: {
45
+		type: Object,
46
+		default: null
35 47
 	}
36 48
 })
37 49
 
38
-const emit = defineEmits(['click'])
50
+const emit = defineEmits(['click', 'review'])
39 51
 </script>
40 52
 
41 53
 <style lang="scss" scoped>
42 54
 .order-goods-row {
43 55
 	display: flex;
56
+	align-items: flex-start;
44 57
 	padding: 16rpx 0;
45 58
 }
59
+.order-goods-row__main--compact {
60
+	padding-right: 8rpx;
61
+}
46 62
 .order-goods-row__pic {
47 63
 	width: 140rpx;
48 64
 	height: 140rpx;
@@ -104,4 +120,17 @@ const emit = defineEmits(['click'])
104 120
 	font-size: 22rpx;
105 121
 	color: #999;
106 122
 }
123
+.order-goods-row__review-btn {
124
+	flex-shrink: 0;
125
+	margin: 24rpx 0 0 8rpx;
126
+	min-width: 128rpx;
127
+	height: 52rpx;
128
+	line-height: 52rpx;
129
+	padding: 0 16rpx;
130
+	font-size: 24rpx;
131
+	color: #2e7d32;
132
+	background: #fff;
133
+	border: 1rpx solid #2e7d32;
134
+	border-radius: 26rpx;
135
+}
107 136
 </style>

+ 137 - 0
shop-app/components/order/ReviewDoneSummaryCard.vue

@@ -0,0 +1,137 @@
1
+<template>
2
+	<view class="review-done-card">
3
+		<view class="review-done-card__goods">
4
+			<image class="review-done-card__pic" :src="item.displayPic" mode="aspectFill" />
5
+			<view class="review-done-card__goods-meta">
6
+				<text class="review-done-card__name">{{ item.goodsName }}</text>
7
+				<text v-if="item.specText" class="review-done-card__spec">{{ item.specText }}</text>
8
+			</view>
9
+		</view>
10
+		<view class="review-done-card__review">
11
+			<view class="review-done-card__stars">
12
+				<text
13
+					v-for="n in 5"
14
+					:key="n"
15
+					class="review-done-card__star"
16
+					:class="{ 'review-done-card__star--on': n <= score }"
17
+				>★</text>
18
+				<text class="review-done-card__score">{{ score }}分</text>
19
+			</view>
20
+			<text class="review-done-card__content">{{ contentBrief }}</text>
21
+		</view>
22
+		<view class="review-done-card__foot">
23
+			<text class="review-done-card__time">{{ reviewTime }}</text>
24
+			<text class="review-done-card__link">查看详情</text>
25
+		</view>
26
+	</view>
27
+</template>
28
+
29
+<script setup>
30
+defineProps({
31
+	item: {
32
+		type: Object,
33
+		required: true
34
+	},
35
+	score: {
36
+		type: Number,
37
+		default: 0
38
+	},
39
+	contentBrief: {
40
+		type: String,
41
+		default: ''
42
+	},
43
+	reviewTime: {
44
+		type: String,
45
+		default: ''
46
+	}
47
+})
48
+</script>
49
+
50
+<style lang="scss" scoped>
51
+.review-done-card {
52
+	padding: 20rpx 24rpx;
53
+	background: #fff;
54
+	border-radius: 12rpx;
55
+}
56
+.review-done-card__goods {
57
+	display: flex;
58
+	align-items: center;
59
+}
60
+.review-done-card__pic {
61
+	width: 96rpx;
62
+	height: 96rpx;
63
+	border-radius: 8rpx;
64
+	background: #eee;
65
+	flex-shrink: 0;
66
+}
67
+.review-done-card__goods-meta {
68
+	flex: 1;
69
+	min-width: 0;
70
+	margin-left: 16rpx;
71
+}
72
+.review-done-card__name {
73
+	display: block;
74
+	font-size: 28rpx;
75
+	color: #333;
76
+	line-height: 1.35;
77
+	overflow: hidden;
78
+	text-overflow: ellipsis;
79
+	white-space: nowrap;
80
+}
81
+.review-done-card__spec {
82
+	display: block;
83
+	margin-top: 4rpx;
84
+	font-size: 22rpx;
85
+	color: #999;
86
+	overflow: hidden;
87
+	text-overflow: ellipsis;
88
+	white-space: nowrap;
89
+}
90
+.review-done-card__review {
91
+	margin-top: 12rpx;
92
+	padding: 12rpx 16rpx;
93
+	background: #f8f9fa;
94
+	border-radius: 8rpx;
95
+}
96
+.review-done-card__stars {
97
+	display: flex;
98
+	align-items: center;
99
+}
100
+.review-done-card__star {
101
+	font-size: 22rpx;
102
+	color: #ddd;
103
+	line-height: 1;
104
+}
105
+.review-done-card__star--on {
106
+	color: #ffb400;
107
+}
108
+.review-done-card__score {
109
+	margin-left: 8rpx;
110
+	font-size: 22rpx;
111
+	color: #ffb400;
112
+}
113
+.review-done-card__content {
114
+	display: block;
115
+	margin-top: 6rpx;
116
+	font-size: 24rpx;
117
+	color: #666;
118
+	line-height: 1.45;
119
+	overflow: hidden;
120
+	text-overflow: ellipsis;
121
+	white-space: nowrap;
122
+}
123
+.review-done-card__foot {
124
+	margin-top: 12rpx;
125
+	display: flex;
126
+	justify-content: space-between;
127
+	align-items: center;
128
+}
129
+.review-done-card__time {
130
+	font-size: 22rpx;
131
+	color: #bbb;
132
+}
133
+.review-done-card__link {
134
+	font-size: 24rpx;
135
+	color: #2e7d32;
136
+}
137
+</style>

+ 7 - 0
shop-app/constants/order.js

@@ -55,6 +55,13 @@ export const REVIEW_TAB = {
55 55
   DONE: 'DONE'
56 56
 }
57 57
 
58
+/** 订单商品行评价态(与后端 OrderAppConstants 一致) */
59
+export const REVIEW_ITEM_STATUS = {
60
+  NONE: 'NONE',
61
+  PENDING: 'PENDING',
62
+  DONE: 'DONE'
63
+}
64
+
58 65
 export const REVIEW_TABS = [
59 66
   { key: REVIEW_TAB.PENDING, label: '待评价' },
60 67
   { key: REVIEW_TAB.DONE, label: '已评价' }

+ 6 - 0
shop-app/pages.json

@@ -218,6 +218,12 @@
218 218
 						"navigationBarTitleText": "发表评价"
219 219
 					}
220 220
 				},
221
+				{
222
+					"path": "review-view",
223
+					"style": {
224
+						"navigationBarTitleText": "查看评价"
225
+					}
226
+				},
221 227
 				{
222 228
 					"path": "aftersale-list",
223 229
 					"style": {

+ 17 - 1
shop-app/subpackage/order/detail.vue

@@ -42,6 +42,8 @@
42 42
 						:key="item.orderItemId || item.goodsId"
43 43
 						:item="item"
44 44
 						show-subtotal
45
+						:review-action="getItemReviewAction(item)"
46
+						@review="(code) => onItemReview(item, code)"
45 47
 					/>
46 48
 				</view>
47 49
 
@@ -104,10 +106,11 @@
104 106
 import { ref, computed } from 'vue'
105 107
 import { onLoad, onShow } from '@dcloudio/uni-app'
106 108
 import { getOrderDetail } from '@/api/order'
107
-import { mapOrderDetail } from '@/utils/orderDisplay'
109
+import { mapOrderDetail, getItemReviewAction } from '@/utils/orderDisplay'
108 110
 import { ensureApiToken } from '@/utils/apiAuth'
109 111
 import { ORDER_ACTION } from '@/constants/order'
110 112
 import { runOrderAction, openPayModal } from '@/utils/orderAction'
113
+import { goReviewEdit, goReviewView } from '@/utils/orderNav'
111 114
 import OrderStatusBar from '@/components/order/OrderStatusBar.vue'
112 115
 import OrderGoodsRow from '@/components/order/OrderGoodsRow.vue'
113 116
 import { useActionGuard } from '@/utils/actionGuard'
@@ -160,6 +163,19 @@ async function loadDetail() {
160 163
 	}
161 164
 }
162 165
 
166
+function onItemReview(item, code) {
167
+	if (!detail.value) return
168
+	actionGuard.run(() => {
169
+		if (code === ORDER_ACTION.REVIEW) {
170
+			goReviewEdit(detail.value.orderId, item.orderItemId)
171
+			return
172
+		}
173
+		if (code === ORDER_ACTION.VIEW_REVIEW) {
174
+			goReviewView(detail.value.orderId, item.orderItemId)
175
+		}
176
+	})
177
+}
178
+
163 179
 function onAction(code) {
164 180
 	if (!detail.value) return
165 181
 	actionGuard.run(async () => {

+ 26 - 15
shop-app/subpackage/order/list.vue

@@ -54,13 +54,15 @@
54 54
 						<text class="order-card__status">{{ card.statusText }}</text>
55 55
 					</view>
56 56
 
57
-					<order-goods-row
58
-						v-if="card.firstItem"
59
-						:item="card.firstItem"
60
-					/>
61
-					<text v-if="card.itemCount > 1" class="order-card__more">
62
-						共 {{ card.itemCount }} 件商品
63
-					</text>
57
+					<view v-if="card.items && card.items.length" class="order-card__goods">
58
+						<order-goods-row
59
+							v-for="(item, idx) in card.items"
60
+							:key="item.orderItemId || `${card.orderId}-${idx}`"
61
+							:item="item"
62
+							:review-action="getItemReviewAction(item)"
63
+							@review="(code) => onItemReview(card, item, code)"
64
+						/>
65
+					</view>
64 66
 
65 67
 					<view class="order-card__sum">
66 68
 						<text class="order-card__time">{{ card.createTime }}</text>
@@ -96,10 +98,10 @@
96 98
 import { ref } from 'vue'
97 99
 import { onLoad, onShow } from '@dcloudio/uni-app'
98 100
 import { listOrders } from '@/api/order'
99
-import { mapOrderListRow, formatPayCountdown } from '@/utils/orderDisplay'
101
+import { mapOrderListRow, formatPayCountdown, getItemReviewAction } from '@/utils/orderDisplay'
100 102
 import { ORDER_LIST_TABS, ORDER_PAGE_SIZE, ORDER_ACTION } from '@/constants/order'
101 103
 import { ensureApiToken } from '@/utils/apiAuth'
102
-import { goOrderDetail, goReviewList, goAftersaleList } from '@/utils/orderNav'
104
+import { goOrderDetail, goReviewList, goAftersaleList, goReviewEdit, goReviewView } from '@/utils/orderNav'
103 105
 import { runOrderAction, openPayModal } from '@/utils/orderAction'
104 106
 import { PAGE_HOME } from '@/utils/pageRoute'
105 107
 import { useActionGuard } from '@/utils/actionGuard'
@@ -191,6 +193,18 @@ function onCardClick(card) {
191 193
 	goOrderDetail(card.orderId)
192 194
 }
193 195
 
196
+function onItemReview(card, item, code) {
197
+	actionGuard.run(() => {
198
+		if (code === ORDER_ACTION.REVIEW) {
199
+			goReviewEdit(card.orderId, item.orderItemId)
200
+			return
201
+		}
202
+		if (code === ORDER_ACTION.VIEW_REVIEW) {
203
+			goReviewView(card.orderId, item.orderItemId)
204
+		}
205
+	})
206
+}
207
+
194 208
 function onAction(code, card) {
195 209
 	actionGuard.run(async () => {
196 210
 		if (code === ORDER_ACTION.PAY) {
@@ -203,6 +217,7 @@ function onAction(code, card) {
203 217
 		await runOrderAction(code, {
204 218
 			orderId: card.orderId,
205 219
 			firstItem: card.firstItem,
220
+			items: card.items,
206 221
 			onRefresh: () => reloadList(true)
207 222
 		})
208 223
 	})
@@ -315,12 +330,8 @@ onShow(() => {
315 330
 	font-size: 26rpx;
316 331
 	color: #e65100;
317 332
 }
318
-.order-card__more {
319
-	display: block;
320
-	margin-top: 4rpx;
321
-	font-size: 24rpx;
322
-	color: #999;
323
-	text-align: right;
333
+.order-card__goods :deep(.order-goods-row + .order-goods-row) {
334
+	border-top: 1rpx solid #f5f5f5;
324 335
 }
325 336
 .order-card__sum {
326 337
 	margin-top: 12rpx;

+ 43 - 3
shop-app/subpackage/order/review-edit.vue

@@ -5,6 +5,11 @@
5 5
 		</view>
6 6
 
7 7
 		<template v-else>
8
+			<view v-if="goodsItem" class="review-edit-card review-edit-goods">
9
+				<text class="review-edit-card__label">评价商品</text>
10
+				<order-goods-row :item="goodsItem" />
11
+			</view>
12
+
8 13
 			<view class="review-edit-card">
9 14
 				<text class="review-edit-card__label">评分</text>
10 15
 				<view class="review-stars">
@@ -43,15 +48,20 @@
43 48
 <script setup>
44 49
 import { ref } from 'vue'
45 50
 import { onLoad } from '@dcloudio/uni-app'
51
+import { getOrderDetail } from '@/api/order'
46 52
 import { submitReview } from '@/api/orderReview'
53
+import { mapOrderDetail } from '@/utils/orderDisplay'
47 54
 import { ensureApiToken } from '@/utils/apiAuth'
48 55
 import { REVIEW_SCORE_MAX, REVIEW_PIC_MAX, REVIEW_CONTENT_MAX } from '@/constants/order'
49 56
 import { useActionGuard } from '@/utils/actionGuard'
50 57
 import ImageUploadGrid from '@/components/order/ImageUploadGrid.vue'
58
+import OrderGoodsRow from '@/components/order/OrderGoodsRow.vue'
51 59
 
52 60
 const submitGuard = useActionGuard()
53 61
 
54 62
 const orderId = ref('')
63
+const orderItemId = ref('')
64
+const goodsItem = ref(null)
55 65
 const score = ref(5)
56 66
 const content = ref('')
57 67
 const pics = ref([])
@@ -67,11 +77,15 @@ function onSubmit() {
67 77
 			uni.showToast({ title: '请选择星级', icon: 'none' })
68 78
 			return
69 79
 		}
70
-		await submitReview(orderId.value, {
80
+		const payload = {
71 81
 			score: score.value,
72 82
 			content: content.value.trim(),
73 83
 			pics: pics.value
74
-		})
84
+		}
85
+		if (orderItemId.value) {
86
+			payload.orderItemId = Number(orderItemId.value) || orderItemId.value
87
+		}
88
+		await submitReview(orderId.value, payload)
75 89
 		uni.showToast({ title: '评价成功', icon: 'success' })
76 90
 		setTimeout(() => {
77 91
 			uni.navigateBack()
@@ -79,13 +93,36 @@ function onSubmit() {
79 93
 	})
80 94
 }
81 95
 
96
+async function loadGoods() {
97
+	pageLoading.value = true
98
+	try {
99
+		const res = await getOrderDetail(orderId.value)
100
+		const detail = mapOrderDetail(res.data)
101
+		const targetId = String(orderItemId.value)
102
+		goodsItem.value =
103
+			(detail?.items || []).find((row) => String(row.orderItemId) === targetId) || null
104
+		if (!goodsItem.value) {
105
+			uni.showToast({ title: '商品信息不存在', icon: 'none' })
106
+			setTimeout(() => uni.navigateBack(), 800)
107
+		}
108
+	} catch (e) {
109
+		uni.showToast({ title: '加载失败', icon: 'none' })
110
+		setTimeout(() => uni.navigateBack(), 800)
111
+	} finally {
112
+		pageLoading.value = false
113
+	}
114
+}
115
+
82 116
 onLoad((options) => {
83 117
 	if (!ensureApiToken(true)) return
84 118
 	orderId.value = options.orderId || ''
85
-	if (!orderId.value) {
119
+	orderItemId.value = options.orderItemId || ''
120
+	if (!orderId.value || !orderItemId.value) {
86 121
 		uni.showToast({ title: '订单信息缺失', icon: 'none' })
87 122
 		setTimeout(() => uni.navigateBack(), 800)
123
+		return
88 124
 	}
125
+	loadGoods()
89 126
 })
90 127
 </script>
91 128
 
@@ -107,6 +144,9 @@ onLoad((options) => {
107 144
 	background: #fff;
108 145
 	border-radius: 12rpx;
109 146
 }
147
+.review-edit-goods :deep(.order-goods-row) {
148
+	padding-top: 0;
149
+}
110 150
 .review-edit-card__label {
111 151
 	display: block;
112 152
 	margin-bottom: 16rpx;

+ 325 - 87
shop-app/subpackage/order/review-list.vue

@@ -1,254 +1,492 @@
1 1
 <template>
2
+
2 3
 	<view class="review-list-page">
4
+
3 5
 		<view class="review-tabs">
6
+
4 7
 			<view
8
+
5 9
 				v-for="tab in tabs"
10
+
6 11
 				:key="tab.key"
12
+
7 13
 				class="review-tabs__item"
14
+
8 15
 				:class="{ 'review-tabs__item--on': activeTab === tab.key }"
16
+
9 17
 				@click="onTabChange(tab.key)"
18
+
10 19
 			>
20
+
11 21
 				{{ tab.label }}
22
+
12 23
 			</view>
24
+
13 25
 		</view>
14 26
 
27
+
28
+
15 29
 		<scroll-view
30
+
16 31
 			class="review-scroll"
32
+
17 33
 			scroll-y
34
+
18 35
 			:style="{ height: scrollHeight }"
36
+
19 37
 			refresher-enabled
38
+
20 39
 			:refresher-triggered="refreshing"
40
+
21 41
 			@refresherrefresh="onPullRefresh"
42
+
22 43
 			@scrolltolower="onLoadMore"
44
+
23 45
 		>
24
-			<view v-if="loading && !list.length" class="review-state">
46
+
47
+			<view v-if="loading && !displayList.length" class="review-state">
48
+
25 49
 				<u-loading-icon mode="circle" />
50
+
26 51
 			</view>
27
-			<view v-else-if="!list.length" class="review-state">
52
+
53
+			<view v-else-if="!displayList.length" class="review-state">
54
+
28 55
 				<u-empty mode="list" :text="emptyText" icon-size="80" />
56
+
29 57
 			</view>
58
+
30 59
 			<template v-else>
31
-				<view
32
-					v-for="card in list"
33
-					:key="card.orderId"
34
-					class="review-card"
35
-					@click="onCardClick(card)"
36
-				>
37
-					<view class="review-card__head">
38
-						<text class="review-card__shop">{{ card.shopName }}</text>
39
-						<text class="review-card__status">{{ card.statusText }}</text>
60
+
61
+				<!-- 待评价:仅商品;点商品进订单详情,点「评价」进发表评价 -->
62
+
63
+				<template v-if="activeTab === REVIEW_TAB.PENDING">
64
+
65
+					<view
66
+
67
+						v-for="entry in displayList"
68
+
69
+						:key="entry.key"
70
+
71
+						class="pending-item"
72
+
73
+					>
74
+
75
+						<order-goods-row
76
+
77
+							:item="entry.item"
78
+
79
+							:review-action="getItemReviewAction(entry.item)"
80
+
81
+							@click="goOrderDetail(entry.orderId)"
82
+
83
+							@review="(code) => onPendingReview(entry, code)"
84
+
85
+						/>
86
+
40 87
 					</view>
41
-					<order-goods-row v-if="card.firstItem" :item="card.firstItem" />
42
-					<view class="review-card__foot">
43
-						<text class="review-card__time">{{ card.createTime }}</text>
44
-						<button
45
-							v-if="activeTab === 'PENDING'"
46
-							class="review-card__btn"
47
-							@click.stop="goReviewEdit(card.orderId)"
48
-						>
49
-							去评价
50
-						</button>
51
-						<button
52
-							v-else
53
-							class="review-card__btn review-card__btn--plain"
54
-							@click.stop="goOrderDetail(card.orderId)"
55
-						>
56
-							查看订单
57
-						</button>
88
+
89
+				</template>
90
+
91
+				<!-- 已评价:紧凑摘要卡,点击进入查看评价页 -->
92
+
93
+				<template v-else>
94
+
95
+					<view
96
+
97
+						v-for="entry in displayList"
98
+
99
+						:key="entry.key"
100
+
101
+						class="done-item"
102
+
103
+						@click="onDoneItemClick(entry)"
104
+
105
+					>
106
+
107
+						<review-done-summary-card
108
+
109
+							:item="entry.item"
110
+
111
+							:score="entry.score"
112
+
113
+							:content-brief="entry.contentBrief"
114
+
115
+							:review-time="entry.reviewTime"
116
+
117
+						/>
118
+
58 119
 					</view>
59
-				</view>
120
+
121
+				</template>
122
+
60 123
 				<view class="review-footer">
124
+
61 125
 					<text v-if="loadingMore">加载中...</text>
126
+
62 127
 					<text v-else-if="finished">没有更多了</text>
128
+
63 129
 				</view>
130
+
64 131
 			</template>
132
+
65 133
 		</scroll-view>
134
+
66 135
 	</view>
136
+
67 137
 </template>
68 138
 
139
+
140
+
69 141
 <script setup>
142
+
70 143
 import { ref, computed } from 'vue'
144
+
71 145
 import { onLoad, onShow } from '@dcloudio/uni-app'
146
+
72 147
 import { listReviews } from '@/api/orderReview'
73
-import { mapOrderListRow } from '@/utils/orderDisplay'
74
-import { REVIEW_TABS, REVIEW_TAB, ORDER_PAGE_SIZE } from '@/constants/order'
148
+
149
+import {
150
+
151
+	flattenPendingReviewItems,
152
+
153
+	mapReviewDoneRow,
154
+
155
+	getItemReviewAction
156
+
157
+} from '@/utils/orderDisplay'
158
+
159
+import { REVIEW_TABS, REVIEW_TAB, ORDER_PAGE_SIZE, ORDER_ACTION } from '@/constants/order'
160
+
75 161
 import { ensureApiToken } from '@/utils/apiAuth'
76
-import { goOrderDetail, goReviewEdit } from '@/utils/orderNav'
162
+
163
+import { goOrderDetail, goReviewView, goReviewEdit } from '@/utils/orderNav'
164
+
77 165
 import OrderGoodsRow from '@/components/order/OrderGoodsRow.vue'
78 166
 
167
+import ReviewDoneSummaryCard from '@/components/order/ReviewDoneSummaryCard.vue'
168
+
169
+
170
+
79 171
 const tabs = REVIEW_TABS
172
+
80 173
 const activeTab = ref(REVIEW_TAB.PENDING)
81
-const list = ref([])
174
+
175
+/** 待评价为展平商品行;已评价为摘要行 */
176
+
177
+const pendingList = ref([])
178
+
179
+const doneList = ref([])
180
+
82 181
 const loading = ref(false)
182
+
83 183
 const loadingMore = ref(false)
184
+
84 185
 const finished = ref(false)
186
+
85 187
 const refreshing = ref(false)
188
+
86 189
 const pageNum = ref(1)
190
+
87 191
 const scrollHeight = ref('600px')
88 192
 
193
+
194
+
195
+const displayList = computed(() =>
196
+
197
+	activeTab.value === REVIEW_TAB.PENDING ? pendingList.value : doneList.value
198
+
199
+)
200
+
201
+
202
+
89 203
 const emptyText = computed(() =>
90
-	activeTab.value === REVIEW_TAB.PENDING ? '暂无待评价订单' : '暂无已评价订单'
204
+
205
+	activeTab.value === REVIEW_TAB.PENDING ? '暂无待评价商品' : '暂无已评价'
206
+
91 207
 )
92 208
 
209
+
210
+
93 211
 function calcScrollHeight() {
212
+
94 213
 	try {
214
+
95 215
 		const sys = uni.getSystemInfoSync()
216
+
96 217
 		scrollHeight.value = `${(sys.windowHeight || 600) - 52}px`
218
+
97 219
 	} catch (e) {
220
+
98 221
 		scrollHeight.value = '600px'
222
+
223
+	}
224
+
225
+}
226
+
227
+
228
+
229
+function mapRowsByTab(rows) {
230
+
231
+	if (activeTab.value === REVIEW_TAB.PENDING) {
232
+
233
+		return flattenPendingReviewItems(rows)
234
+
99 235
 	}
236
+
237
+	return (rows || []).map(mapReviewDoneRow).filter((row) => row?.item)
238
+
100 239
 }
101 240
 
241
+
242
+
102 243
 async function fetchPage(isReset) {
244
+
103 245
 	if (isReset) {
246
+
104 247
 		loading.value = true
248
+
105 249
 		pageNum.value = 1
250
+
106 251
 		finished.value = false
252
+
107 253
 	} else if (finished.value || loadingMore.value) {
254
+
108 255
 		return
256
+
109 257
 	} else {
258
+
110 259
 		loadingMore.value = true
260
+
111 261
 	}
262
+
112 263
 	try {
264
+
113 265
 		const res = await listReviews({
266
+
114 267
 			tab: activeTab.value,
268
+
115 269
 			pageNum: pageNum.value,
270
+
116 271
 			pageSize: ORDER_PAGE_SIZE
272
+
117 273
 		})
118
-		const rows = (res.rows || []).map(mapOrderListRow).filter(Boolean)
274
+
275
+		const rows = mapRowsByTab(res.rows || [])
276
+
119 277
 		const total = Number(res.total) || 0
278
+
279
+		const targetList =
280
+
281
+			activeTab.value === REVIEW_TAB.PENDING ? pendingList : doneList
282
+
120 283
 		if (isReset) {
121
-			list.value = rows
284
+
285
+			targetList.value = rows
286
+
122 287
 		} else {
123
-			list.value = list.value.concat(rows)
288
+
289
+			targetList.value = targetList.value.concat(rows)
290
+
124 291
 		}
125
-		finished.value = list.value.length >= total || rows.length < ORDER_PAGE_SIZE
292
+
293
+		finished.value = targetList.value.length >= total || rows.length < ORDER_PAGE_SIZE
294
+
126 295
 		if (!finished.value) pageNum.value += 1
296
+
127 297
 	} catch (e) {
128
-		if (isReset) list.value = []
298
+
299
+		if (isReset) {
300
+
301
+			if (activeTab.value === REVIEW_TAB.PENDING) {
302
+
303
+				pendingList.value = []
304
+
305
+			} else {
306
+
307
+				doneList.value = []
308
+
309
+			}
310
+
311
+		}
312
+
129 313
 	} finally {
314
+
130 315
 		loading.value = false
316
+
131 317
 		loadingMore.value = false
318
+
132 319
 		refreshing.value = false
320
+
133 321
 	}
322
+
134 323
 }
135 324
 
325
+
326
+
136 327
 function onTabChange(key) {
328
+
137 329
 	if (activeTab.value === key) return
330
+
138 331
 	activeTab.value = key
332
+
139 333
 	fetchPage(true)
334
+
140 335
 }
141 336
 
337
+
338
+
142 339
 function onPullRefresh() {
340
+
143 341
 	refreshing.value = true
342
+
144 343
 	fetchPage(true)
344
+
145 345
 }
146 346
 
347
+
348
+
147 349
 function onLoadMore() {
350
+
148 351
 	fetchPage(false)
352
+
149 353
 }
150 354
 
151
-function onCardClick(card) {
152
-	if (activeTab.value === REVIEW_TAB.PENDING) {
153
-		goReviewEdit(card.orderId)
154
-	} else {
155
-		goOrderDetail(card.orderId)
156
-	}
355
+
356
+
357
+function onPendingReview(entry, code) {
358
+
359
+	if (code !== ORDER_ACTION.REVIEW || !entry?.orderId) return
360
+
361
+	goReviewEdit(entry.orderId, entry.item?.orderItemId)
362
+
157 363
 }
158 364
 
365
+
366
+
367
+function onDoneItemClick(entry) {
368
+
369
+	if (!entry?.orderId) return
370
+
371
+	goReviewView(entry.orderId, entry.orderItemId)
372
+
373
+}
374
+
375
+
376
+
159 377
 onLoad((options) => {
378
+
160 379
 	calcScrollHeight()
380
+
161 381
 	if (options.tab) activeTab.value = options.tab
382
+
162 383
 })
163 384
 
385
+
386
+
164 387
 onShow(() => {
388
+
165 389
 	if (!ensureApiToken(false)) return
390
+
166 391
 	fetchPage(true)
392
+
167 393
 })
394
+
168 395
 </script>
169 396
 
397
+
398
+
170 399
 <style lang="scss" scoped>
400
+
171 401
 .review-list-page {
402
+
172 403
 	height: calc(100vh - 88rpx);
404
+
173 405
 	background: #f5f6f8;
406
+
174 407
 }
408
+
175 409
 .review-tabs {
410
+
176 411
 	display: flex;
412
+
177 413
 	background: #fff;
414
+
178 415
 }
416
+
179 417
 .review-tabs__item {
418
+
180 419
 	flex: 1;
420
+
181 421
 	text-align: center;
422
+
182 423
 	padding: 24rpx 0;
424
+
183 425
 	font-size: 28rpx;
426
+
184 427
 	color: #666;
428
+
185 429
 }
430
+
186 431
 .review-tabs__item--on {
432
+
187 433
 	color: #2e7d32;
434
+
188 435
 	font-weight: 600;
436
+
189 437
 	border-bottom: 4rpx solid #2e7d32;
438
+
190 439
 }
440
+
191 441
 .review-scroll {
442
+
192 443
 	padding: 16rpx 24rpx;
444
+
193 445
 	box-sizing: border-box;
446
+
194 447
 }
448
+
195 449
 .review-state {
450
+
196 451
 	padding: 120rpx 48rpx;
452
+
197 453
 	display: flex;
454
+
198 455
 	flex-direction: column;
456
+
199 457
 	align-items: center;
458
+
200 459
 }
201
-.review-card {
460
+
461
+.pending-item {
462
+
202 463
 	margin-bottom: 16rpx;
203
-	padding: 20rpx 24rpx;
464
+
465
+	padding: 8rpx 24rpx 12rpx;
466
+
204 467
 	background: #fff;
468
+
205 469
 	border-radius: 12rpx;
470
+
206 471
 }
207
-.review-card__head {
208
-	display: flex;
209
-	justify-content: space-between;
210
-	margin-bottom: 8rpx;
211
-}
212
-.review-card__shop {
213
-	font-size: 28rpx;
214
-	color: #333;
215
-	font-weight: 500;
216
-}
217
-.review-card__status {
218
-	font-size: 26rpx;
219
-	color: #2e7d32;
220
-}
221
-.review-card__foot {
222
-	margin-top: 12rpx;
223
-	display: flex;
224
-	justify-content: space-between;
225
-	align-items: center;
226
-}
227
-.review-card__time {
228
-	font-size: 24rpx;
229
-	color: #999;
230
-}
231
-.review-card__btn {
232
-	margin: 0;
233
-	min-width: 140rpx;
234
-	height: 56rpx;
235
-	line-height: 56rpx;
236
-	padding: 0 20rpx;
237
-	font-size: 26rpx;
238
-	color: #fff;
239
-	background: #2e7d32;
240
-	border-radius: 28rpx;
241
-	border: none;
242
-}
243
-.review-card__btn--plain {
244
-	color: #333;
245
-	background: #fff;
246
-	border: 1rpx solid #ddd;
472
+
473
+.done-item {
474
+
475
+	margin-bottom: 16rpx;
476
+
247 477
 }
478
+
248 479
 .review-footer {
480
+
249 481
 	padding: 24rpx;
482
+
250 483
 	text-align: center;
484
+
251 485
 	font-size: 24rpx;
486
+
252 487
 	color: #999;
488
+
253 489
 }
490
+
254 491
 </style>
492
+

+ 193 - 0
shop-app/subpackage/order/review-view.vue

@@ -0,0 +1,193 @@
1
+<template>
2
+	<view class="review-view-page">
3
+		<view v-if="pageLoading" class="review-view-state">
4
+			<u-loading-icon mode="circle" />
5
+		</view>
6
+
7
+		<view v-else-if="pageError" class="review-view-state">
8
+			<u-empty mode="list" :text="pageError" icon-size="80" />
9
+		</view>
10
+
11
+		<template v-else-if="review">
12
+			<view class="review-view-card">
13
+				<text class="review-view-card__label">评分</text>
14
+				<view class="review-stars">
15
+					<text
16
+						v-for="n in scoreMax"
17
+						:key="n"
18
+						class="review-stars__item"
19
+						:class="{ 'review-stars__item--on': n <= review.score }"
20
+					>★</text>
21
+					<text class="review-stars__text">{{ review.score }} 分</text>
22
+				</view>
23
+			</view>
24
+
25
+			<view class="review-view-card">
26
+				<text class="review-view-card__label">评价内容</text>
27
+				<text class="review-view-content">{{ review.content || '(无文字评价)' }}</text>
28
+				<view v-if="review.pics && review.pics.length" class="review-view-pics">
29
+					<image
30
+						v-for="(pic, idx) in review.pics"
31
+						:key="idx"
32
+						class="review-view-pic"
33
+						:src="pic"
34
+						mode="aspectFill"
35
+						@click="previewPics(idx)"
36
+					/>
37
+				</view>
38
+				<text v-if="review.createTime" class="review-view-time">评价时间:{{ review.createTime }}</text>
39
+			</view>
40
+
41
+			<view v-if="review.replyContent" class="review-view-card review-view-reply">
42
+				<text class="review-view-card__label">商家回复</text>
43
+				<text class="review-view-content">{{ review.replyContent }}</text>
44
+				<text v-if="review.replyTime" class="review-view-time">回复时间:{{ review.replyTime }}</text>
45
+			</view>
46
+
47
+			<view v-if="goodsItem" class="review-view-card review-view-goods">
48
+				<text class="review-view-card__label">评价商品</text>
49
+				<order-goods-row :item="goodsItem" />
50
+			</view>
51
+		</template>
52
+	</view>
53
+</template>
54
+
55
+<script setup>
56
+import { ref } from 'vue'
57
+import { onLoad } from '@dcloudio/uni-app'
58
+import { getOrderDetail } from '@/api/order'
59
+import { getOrderReview } from '@/api/orderReview'
60
+import { mapOrderDetail, mapOrderReview } from '@/utils/orderDisplay'
61
+import { ensureApiToken } from '@/utils/apiAuth'
62
+import { REVIEW_SCORE_MAX } from '@/constants/order'
63
+import OrderGoodsRow from '@/components/order/OrderGoodsRow.vue'
64
+
65
+const orderId = ref('')
66
+const orderItemId = ref('')
67
+const review = ref(null)
68
+const goodsItem = ref(null)
69
+const pageLoading = ref(false)
70
+const pageError = ref('')
71
+const scoreMax = REVIEW_SCORE_MAX
72
+
73
+async function loadReview() {
74
+	if (!orderId.value || !orderItemId.value) {
75
+		pageError.value = '评价信息缺失'
76
+		return
77
+	}
78
+	pageLoading.value = true
79
+	pageError.value = ''
80
+	try {
81
+		const [reviewRes, orderRes] = await Promise.all([
82
+			getOrderReview(orderId.value, orderItemId.value),
83
+			getOrderDetail(orderId.value)
84
+		])
85
+		review.value = mapOrderReview(reviewRes.data)
86
+		if (!review.value) {
87
+			pageError.value = '暂无评价内容'
88
+			return
89
+		}
90
+		const detail = mapOrderDetail(orderRes.data)
91
+		const targetId = String(orderItemId.value)
92
+		goodsItem.value =
93
+			(detail?.items || []).find((row) => String(row.orderItemId) === targetId) || null
94
+	} catch (e) {
95
+		pageError.value = '加载失败'
96
+		review.value = null
97
+		goodsItem.value = null
98
+	} finally {
99
+		pageLoading.value = false
100
+	}
101
+}
102
+
103
+function previewPics(index) {
104
+	const urls = review.value?.pics || []
105
+	uni.previewImage({ urls, current: urls[index] })
106
+}
107
+
108
+onLoad((options) => {
109
+	if (!ensureApiToken(true)) return
110
+	orderId.value = options.orderId || ''
111
+	orderItemId.value = options.orderItemId || ''
112
+	if (!orderId.value || !orderItemId.value) {
113
+		pageError.value = '参数错误'
114
+		return
115
+	}
116
+	loadReview()
117
+})
118
+</script>
119
+
120
+<style lang="scss" scoped>
121
+.review-view-page {
122
+	min-height: 100vh;
123
+	padding: 24rpx;
124
+	background: #f5f6f8;
125
+	box-sizing: border-box;
126
+}
127
+.review-view-state {
128
+	padding: 120rpx 0;
129
+	display: flex;
130
+	justify-content: center;
131
+}
132
+.review-view-card {
133
+	margin-bottom: 16rpx;
134
+	padding: 24rpx;
135
+	background: #fff;
136
+	border-radius: 12rpx;
137
+}
138
+.review-view-card__label {
139
+	display: block;
140
+	margin-bottom: 16rpx;
141
+	font-size: 28rpx;
142
+	color: #333;
143
+	font-weight: 500;
144
+}
145
+.review-stars {
146
+	display: flex;
147
+	align-items: center;
148
+	gap: 8rpx;
149
+}
150
+.review-stars__item {
151
+	font-size: 40rpx;
152
+	color: #ddd;
153
+}
154
+.review-stars__item--on {
155
+	color: #ffb300;
156
+}
157
+.review-stars__text {
158
+	margin-left: 8rpx;
159
+	font-size: 26rpx;
160
+	color: #666;
161
+}
162
+.review-view-content {
163
+	font-size: 28rpx;
164
+	color: #444;
165
+	line-height: 1.6;
166
+	white-space: pre-wrap;
167
+	word-break: break-word;
168
+}
169
+.review-view-pics {
170
+	margin-top: 16rpx;
171
+	display: flex;
172
+	flex-wrap: wrap;
173
+	gap: 12rpx;
174
+}
175
+.review-view-pic {
176
+	width: 160rpx;
177
+	height: 160rpx;
178
+	border-radius: 8rpx;
179
+	background: #eee;
180
+}
181
+.review-view-time {
182
+	display: block;
183
+	margin-top: 16rpx;
184
+	font-size: 24rpx;
185
+	color: #999;
186
+}
187
+.review-view-reply {
188
+	background: #f9fff9;
189
+}
190
+.review-view-goods :deep(.order-goods-row) {
191
+	padding-top: 0;
192
+}
193
+</style>

+ 8 - 4
shop-app/utils/orderAction.js

@@ -5,7 +5,7 @@ const payModalGuard = useActionGuard()
5 5
 const confirmReceiveGuard = useActionGuard()
6 6
 import { ORDER_ACTION } from '@/constants/order'
7 7
 import { goGoodsDetail } from '@/utils/goodsDetail'
8
-import { goReviewEdit, goAftersaleSubmit } from '@/utils/orderNav'
8
+import { goReviewEdit, goReviewView, goReviewList, goAftersaleSubmit } from '@/utils/orderNav'
9 9
 import { PAGE_ORDER_REVIEW_LIST } from '@/utils/pageRoute'
10 10
 
11 11
 /**
@@ -21,10 +21,14 @@ export function runOrderAction(code, ctx = {}) {
21 21
     case ORDER_ACTION.CONFIRM_RECEIVE:
22 22
       return doConfirmReceive(orderId, ctx.onRefresh)
23 23
     case ORDER_ACTION.REVIEW:
24
-      goReviewEdit(orderId)
24
+      goReviewEdit(orderId, ctx.orderItemId)
25 25
       return Promise.resolve()
26 26
     case ORDER_ACTION.VIEW_REVIEW:
27
-      uni.navigateTo({ url: `${PAGE_ORDER_REVIEW_LIST}?tab=DONE&orderId=${orderId}` })
27
+      if (ctx.orderItemId) {
28
+        goReviewView(orderId, ctx.orderItemId)
29
+      } else {
30
+        uni.navigateTo({ url: `${PAGE_ORDER_REVIEW_LIST}?tab=DONE&orderId=${orderId}` })
31
+      }
28 32
       return Promise.resolve()
29 33
     case ORDER_ACTION.AFTERSALE:
30 34
       return openAftersalePicker(ctx)
@@ -94,7 +98,7 @@ function doConfirmReceive(orderId, onRefresh) {
94 98
               cancelText: '稍后',
95 99
               success: (r) => {
96 100
                 if (r.confirm) {
97
-                  goReviewEdit(orderId)
101
+                  goReviewList('PENDING')
98 102
                 }
99 103
               }
100 104
             })

+ 132 - 13
shop-app/utils/orderDisplay.js

@@ -1,15 +1,50 @@
1 1
 import { resolveFileUrl } from '@/utils/image'
2 2
 import { formatPrice } from '@/utils/format'
3 3
 import { parseCartSpecText } from '@/utils/cartSpec'
4
-import { ORDER_ACTION_LABEL } from '@/constants/order'
4
+import {
5
+  ORDER_ACTION,
6
+  ORDER_ACTION_LABEL,
7
+  REVIEW_ITEM_STATUS,
8
+  AFTERSALE_APPLY_TYPE_OPTIONS
9
+} from '@/constants/order'
10
+
11
+/** 订单级不再展示的评价按钮(改在商品行) */
12
+const ORDER_LEVEL_REVIEW_CODES = [ORDER_ACTION.REVIEW, ORDER_ACTION.VIEW_REVIEW]
5 13
 
6 14
 const GOODS_PLACEHOLDER = '/static/logo.png'
7 15
 const SHOP_PLACEHOLDER = '/static/logo.png'
8 16
 
17
+/** 后端凭证图可能是逗号拼接字符串,统一转成数组 */
18
+function normalizePicList(raw) {
19
+  if (!raw) return []
20
+  if (Array.isArray(raw)) return raw.filter(Boolean)
21
+  if (typeof raw === 'string') {
22
+    return raw.split(',').map((s) => s.trim()).filter(Boolean)
23
+  }
24
+  return []
25
+}
26
+
27
+function mapAftersaleApplyTypeText(applyType) {
28
+  const hit = AFTERSALE_APPLY_TYPE_OPTIONS.find((opt) => opt.value === applyType)
29
+  return hit ? hit.label : applyType || ''
30
+}
31
+
32
+function mapAftersaleStatusText(status) {
33
+  if (status === '2') return '售后完结'
34
+  if (status === '1') return '商家处理中'
35
+  return status || ''
36
+}
37
+
38
+function mapAftersaleProgressText(stage) {
39
+  if (stage === 'FINISHED') return '商家处理 → 售后完结(当前:售后完结)'
40
+  return '商家处理 → 售后完结(当前:商家处理)'
41
+}
42
+
9 43
 function mapFirstItem(item) {
10 44
   if (!item) return null
11 45
   const specText = (item.goodsSpec || item.specText || '').trim() || '默认'
12 46
   return {
47
+    orderItemId: item.itemId || item.orderItemId,
13 48
     goodsId: item.goodsId,
14 49
     goodsName: item.goodsName || '',
15 50
     displayPic: resolveFileUrl(item.goodsImage || item.mainPic) || GOODS_PLACEHOLDER,
@@ -17,7 +52,9 @@ function mapFirstItem(item) {
17 52
     specList: parseCartSpecText(specText),
18 53
     quantity: Number(item.quantity) || 1,
19 54
     unitPrice: item.unitPrice,
20
-    priceText: formatPrice(item.unitPrice)
55
+    priceText: formatPrice(item.unitPrice),
56
+    reviewStatus: item.reviewStatus,
57
+    reviewId: item.reviewId
21 58
   }
22 59
 }
23 60
 
@@ -37,12 +74,15 @@ function mapOrderItemRow(row) {
37 74
     priceText: formatPrice(row.unitPrice || row.salePrice),
38 75
     lineAmount: row.lineAmount || row.subtotal,
39 76
     lineAmountText: formatPrice(row.lineAmount || row.subtotal),
40
-    buyerRemark: row.buyerRemark || ''
77
+    buyerRemark: row.buyerRemark || '',
78
+    reviewStatus: row.reviewStatus,
79
+    reviewId: row.reviewId
41 80
   }
42 81
 }
43 82
 
44 83
 function mapActions(actions) {
45 84
   return (actions || [])
85
+    .filter((code) => !ORDER_LEVEL_REVIEW_CODES.includes(code))
46 86
     .map((code) => ({
47 87
       code,
48 88
       label: ORDER_ACTION_LABEL[code] || code
@@ -50,10 +90,77 @@ function mapActions(actions) {
50 90
     .filter((item) => item.label)
51 91
 }
52 92
 
93
+/** 交易成功商品行:待评价→评价,已评价→查看评价 */
94
+export function getItemReviewAction(item) {
95
+  if (!item || !item.reviewStatus) return null
96
+  if (item.reviewStatus === REVIEW_ITEM_STATUS.PENDING) {
97
+    return {
98
+      code: ORDER_ACTION.REVIEW,
99
+      label: ORDER_ACTION_LABEL[ORDER_ACTION.REVIEW]
100
+    }
101
+  }
102
+  if (item.reviewStatus === REVIEW_ITEM_STATUS.DONE) {
103
+    return {
104
+      code: ORDER_ACTION.VIEW_REVIEW,
105
+      label: ORDER_ACTION_LABEL[ORDER_ACTION.VIEW_REVIEW]
106
+    }
107
+  }
108
+  return null
109
+}
110
+
111
+/** 评价列表「待评价」:订单行展平为待评价商品行 */
112
+export function flattenPendingReviewItems(rows) {
113
+  const result = []
114
+  for (const row of rows || []) {
115
+    const card = mapOrderListRow(row)
116
+    if (!card) continue
117
+    for (const item of card.items || []) {
118
+      if (item.reviewStatus !== REVIEW_ITEM_STATUS.PENDING) continue
119
+      result.push({
120
+        key: `pending-${card.orderId}-${item.orderItemId}`,
121
+        orderId: card.orderId,
122
+        item
123
+      })
124
+    }
125
+  }
126
+  return result
127
+}
128
+
129
+/** 评价列表「已评价」行 → 紧凑卡片模型 */
130
+export function mapReviewDoneRow(row) {
131
+  if (!row) return null
132
+  const item = mapFirstItem(row.firstItem)
133
+  const content = (row.content || '').trim()
134
+  return {
135
+    key: `done-${row.reviewId || row.orderId}-${item?.orderItemId || 0}`,
136
+    reviewId: row.reviewId,
137
+    orderId: row.orderId,
138
+    orderItemId: item?.orderItemId,
139
+    score: Number(row.score) || 0,
140
+    content,
141
+    contentBrief: trimText(content, 48, '此用户未填写评价内容'),
142
+    reviewTime: row.reviewTime || row.createTime || '',
143
+    item
144
+  }
145
+}
146
+
147
+function trimText(text, maxLen, emptyFallback = '') {
148
+  const s = (text || '').trim()
149
+  if (!s) return emptyFallback
150
+  if (s.length <= maxLen) return s
151
+  return `${s.slice(0, maxLen)}…`
152
+}
153
+
53 154
 /** 列表行 VO → 卡片模型 */
54 155
 export function mapOrderListRow(row) {
55 156
   if (!row) return null
56
-  const firstItem = mapFirstItem(row.firstItem)
157
+  const rawItems = Array.isArray(row.items) && row.items.length
158
+    ? row.items
159
+    : row.firstItem
160
+      ? [row.firstItem]
161
+      : []
162
+  const items = rawItems.map(mapFirstItem).filter(Boolean)
163
+  const firstItem = items[0] || mapFirstItem(row.firstItem)
57 164
   return {
58 165
     orderId: row.orderId,
59 166
     orderNo: row.orderNo || '',
@@ -67,7 +174,8 @@ export function mapOrderListRow(row) {
67 174
     goodsAmount: row.goodsAmount,
68 175
     freightAmount: row.freightAmount,
69 176
     createTime: row.createTime || '',
70
-    itemCount: Number(row.itemCount) || 0,
177
+    itemCount: Number(row.itemCount) || items.length,
178
+    items,
71 179
     firstItem,
72 180
     payRemainSeconds: row.payRemainSeconds,
73 181
     reviewStatus: row.reviewStatus,
@@ -152,9 +260,12 @@ export function mapAftersaleListRow(row) {
152 260
     aftersaleId: row.aftersaleId,
153 261
     aftersaleNo: row.aftersaleNo || '',
154 262
     aftersaleStatus: row.aftersaleStatus,
155
-    statusText: row.aftersaleStatusText || row.statusText || '',
263
+    statusText:
264
+      row.aftersaleStatusText ||
265
+      row.statusText ||
266
+      mapAftersaleStatusText(row.aftersaleStatus),
156 267
     applyType: row.applyType,
157
-    applyTypeText: row.applyTypeText || '',
268
+    applyTypeText: row.applyTypeText || mapAftersaleApplyTypeText(row.applyType),
158 269
     applyReason: row.applyReason || '',
159 270
     applyAmount: row.applyAmount,
160 271
     applyAmountText: formatPrice(row.applyAmount),
@@ -172,21 +283,29 @@ export function mapAftersaleDetail(data) {
172 283
   if (!data) return null
173 284
   const info = data.info || data
174 285
   const specText = (info.goodsSpec || info.specText || '').trim() || '默认'
286
+  const progressStage = data.progressStage || info.progressStage || ''
287
+  const aftersaleStatus = info.aftersaleStatus || data.aftersaleStatus
175 288
   return {
176 289
     aftersaleId: info.aftersaleId || data.aftersaleId,
177 290
     aftersaleNo: info.aftersaleNo || data.aftersaleNo || '',
178
-    aftersaleStatus: info.aftersaleStatus || data.aftersaleStatus,
179
-    statusText: info.aftersaleStatusText || info.statusText || '',
180
-    progress: data.progress || info.progress || '',
181
-    progressText: data.progressText || info.progressText || '',
291
+    aftersaleStatus,
292
+    statusText:
293
+      info.aftersaleStatusText ||
294
+      info.statusText ||
295
+      mapAftersaleStatusText(aftersaleStatus),
296
+    progress: data.progress || info.progress || progressStage,
297
+    progressText:
298
+      data.progressText ||
299
+      info.progressText ||
300
+      mapAftersaleProgressText(progressStage),
182 301
     applyType: info.applyType,
183
-    applyTypeText: info.applyTypeText || '',
302
+    applyTypeText: info.applyTypeText || mapAftersaleApplyTypeText(info.applyType),
184 303
     applyReason: info.applyReason || '',
185 304
     returnQuantity: info.returnQuantity,
186 305
     applyAmount: info.applyAmount,
187 306
     applyAmountText: formatPrice(info.applyAmount),
188 307
     description: info.description || '',
189
-    evidencePics: (info.evidencePics || []).map((p) => resolveFileUrl(p) || p),
308
+    evidencePics: normalizePicList(info.evidencePics).map((p) => resolveFileUrl(p) || p),
190 309
     createTime: info.createTime || '',
191 310
     finishTime: info.finishTime || '',
192 311
     processResult: data.processResult || info.processResult || '',

+ 16 - 3
shop-app/utils/orderNav.js

@@ -3,6 +3,7 @@ import {
3 3
   PAGE_ORDER_DETAIL,
4 4
   PAGE_ORDER_REVIEW_LIST,
5 5
   PAGE_ORDER_REVIEW_EDIT,
6
+  PAGE_ORDER_REVIEW_VIEW,
6 7
   PAGE_ORDER_AFTERSALE_LIST,
7 8
   PAGE_ORDER_AFTERSALE_DETAIL,
8 9
   PAGE_ORDER_AFTERSALE_SUBMIT
@@ -36,11 +37,23 @@ export function goReviewList(tab = 'PENDING') {
36 37
   })
37 38
 }
38 39
 
39
-/** 评价编辑 */
40
-export function goReviewEdit(orderId) {
40
+/** 评价编辑(一行一评须传 orderItemId) */
41
+export function goReviewEdit(orderId, orderItemId) {
41 42
   if (!orderId) return
43
+  const parts = [`orderId=${orderId}`]
44
+  if (orderItemId != null && orderItemId !== '') {
45
+    parts.push(`orderItemId=${orderItemId}`)
46
+  }
42 47
   uni.navigateTo({
43
-    url: `${PAGE_ORDER_REVIEW_EDIT}?orderId=${orderId}`
48
+    url: `${PAGE_ORDER_REVIEW_EDIT}?${parts.join('&')}`
49
+  })
50
+}
51
+
52
+/** 查看商品行评价 */
53
+export function goReviewView(orderId, orderItemId) {
54
+  if (!orderId || orderItemId == null || orderItemId === '') return
55
+  uni.navigateTo({
56
+    url: `${PAGE_ORDER_REVIEW_VIEW}?orderId=${orderId}&orderItemId=${orderItemId}`
44 57
   })
45 58
 }
46 59
 

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

@@ -81,6 +81,8 @@ export const PAGE_ORDER_DETAIL = '/subpackage/order/detail'
81 81
 export const PAGE_ORDER_REVIEW_LIST = '/subpackage/order/review-list'
82 82
 /** 评价编辑 */
83 83
 export const PAGE_ORDER_REVIEW_EDIT = '/subpackage/order/review-edit'
84
+/** 查看评价 */
85
+export const PAGE_ORDER_REVIEW_VIEW = '/subpackage/order/review-view'
84 86
 /** 售后列表 */
85 87
 export const PAGE_ORDER_AFTERSALE_LIST = '/subpackage/order/aftersale-list'
86 88
 /** 售后详情 */