浏览代码

商品详情页

xsh_1997 2 周之前
父节点
当前提交
1f629a76f9
共有 34 个文件被更改,包括 3275 次插入68 次删除
  1. 40 20
      doc/消费者APP/商品分类/商品分类前端技术方案.md
  2. 168 0
      doc/消费者APP/商品详情内页/商品详情内页前端技术方案.md
  3. 1 1
      doc/消费者APP/商品详情内页/商品详情内页技术方案.md
  4. 220 0
      doc/消费者APP/搜索页/搜索页前端技术方案.md
  5. 53 0
      shop-app/PAGES.md
  6. 32 0
      shop-app/api/goods.js
  7. 37 0
      shop-app/api/login.js
  8. 16 0
      shop-app/api/search.js
  9. 80 0
      shop-app/components/goods/DetailBottomBar.vue
  10. 123 0
      shop-app/components/goods/ReviewCard.vue
  11. 61 11
      shop-app/components/mall/SearchEntry.vue
  12. 98 0
      shop-app/components/search/SearchResultTabs.vue
  13. 75 0
      shop-app/components/search/ShopList.vue
  14. 34 0
      shop-app/constants/search.js
  15. 8 1
      shop-app/manifest.json
  16. 71 10
      shop-app/pages.json
  17. 2 2
      shop-app/pages/category/index.vue
  18. 6 8
      shop-app/pages/index/index.vue
  19. 422 0
      shop-app/pages/login/index.vue
  20. 84 2
      shop-app/pages/mine/index.vue
  21. 98 9
      shop-app/store/user.js
  22. 13 0
      shop-app/styles/morandi.scss
  23. 3 2
      shop-app/pages/category/goods-list.vue
  24. 3 2
      shop-app/pages/category/level1.vue
  25. 597 0
      shop-app/subpackage/goods/detail.vue
  26. 123 0
      shop-app/subpackage/goods/reviews.vue
  27. 198 0
      shop-app/subpackage/search/index.vue
  28. 332 0
      shop-app/subpackage/search/result.vue
  29. 112 0
      shop-app/utils/goodsDetail.js
  30. 40 0
      shop-app/utils/pageRoute.js
  31. 27 0
      shop-app/utils/purchaseAction.js
  32. 55 0
      shop-app/utils/searchHistory.js
  33. 19 0
      shop-app/utils/searchNav.js
  34. 24 0
      shop-app/utils/shopDisplay.js

+ 40 - 20
doc/消费者APP/商品分类/商品分类前端技术方案.md

@@ -19,20 +19,40 @@
19 19
 
20 20
 ---
21 21
 
22
-## 2. 页面与路由
22
+## 2. 目录与分包
23
+
24
+```text
25
+shop-app/
26
+├── pages/                    # 主包(Tab + 登录)
27
+│   ├── index/index.vue
28
+│   ├── category/index.vue    # 分类 Tab
29
+│   ├── cart/index.vue
30
+│   ├── mine/index.vue
31
+│   └── login/index.vue
32
+└── subpackage/               # 分包页面
33
+    ├── category/level1.vue、goods-list.vue
34
+    ├── goods/detail.vue、reviews.vue
35
+    └── search/index.vue、result.vue
36
+```
37
+
38
+路径常量见 `utils/pageRoute.js`;`pages.json` 中 `subPackages[].root` 与上表 `subpackage/*` 一致。
39
+
40
+---
41
+
42
+## 3. 页面与路由
23 43
 
24 44
 | 页面 | 需求代号 | 路径 | 入口 |
25 45
 |------|----------|------|------|
26 46
 | 全部分类(Tab) | **A** | `pages/category/index` | 底部 Tab「分类」;首页「更多」`switchTab` |
27
-| 一级分类商品 | **B** | `pages/category/level1` | 首页点击一级类目 `navigateTo` + `level1Id` |
28
-| 二级商品列表 | **C** | `pages/category/goods-list` | A 页点击二级 `navigateTo` + `categoryId` |
47
+| 一级分类商品 | **B** | `subpackage/category/level1` | 首页点击一级类目 `navigateTo` + `level1Id` |
48
+| 二级商品列表 | **C** | `subpackage/category/goods-list` | A 页点击二级 `navigateTo` + `categoryId` |
29 49
 
30 50
 **`pages.json` 注册:**
31 51
 
32 52
 ```text
33 53
 pages/category/index        → navigationBarTitleText: 分类
34
-pages/category/level1       → 动态 setNavigationBarTitle(level1Name)
35
-pages/category/goods-list   → 动态 setNavigationBarTitle(level2Name)
54
+subpackage/category/level1       → 动态 setNavigationBarTitle(level1Name)
55
+subpackage/category/goods-list   → 动态 setNavigationBarTitle(level2Name)
36 56
 ```
37 57
 
38 58
 **路由参数:**
@@ -44,13 +64,13 @@ pages/category/goods-list   → 动态 setNavigationBarTitle(level2Name)
44 64
 
45 65
 ---
46 66
 
47
-## 3. 文件清单
67
+## 4. 文件清单
48 68
 
49 69
 | 类型 | 路径 | 说明 |
50 70
 |------|------|------|
51 71
 | A 页 | `shop-app/pages/category/index.vue` | 左一级 / 右二级 |
52
-| B 页 | `shop-app/pages/category/level1.vue` | 搜索 + Tab + 列表 |
53
-| C 页 | `shop-app/pages/category/goods-list.vue` | 搜索 + 面包屑 + 列表 |
72
+| B 页 | `shop-app/subpackage/category/level1.vue` | 搜索 + Tab + 列表 |
73
+| C 页 | `shop-app/subpackage/category/goods-list.vue` | 搜索 + 面包屑 + 列表 |
54 74
 | 列表块 | `shop-app/components/category/GoodsListBlock.vue` | 排序 + 分页列表(B/C 共用) |
55 75
 | 搜索入口 | `shop-app/components/mall/SearchEntry.vue` | 占位「搜索兽药、饲料、店铺」 |
56 76
 | 商品网格 | `shop-app/components/mall/GoodsGrid.vue` | 双列卡片 |
@@ -62,7 +82,7 @@ pages/category/goods-list   → 动态 setNavigationBarTitle(level2Name)
62 82
 
63 83
 ---
64 84
 
65
-## 4. 接口封装
85
+## 5. 接口封装
66 86
 
67 87
 **模块:** `shop-app/api/category.js`
68 88
 
@@ -85,9 +105,9 @@ pages/category/goods-list   → 动态 setNavigationBarTitle(level2Name)
85 105
 
86 106
 ---
87 107
 
88
-## 5. 页面结构
108
+## 6. 页面结构
89 109
 
90
-### 5.1 A — 全部分类 `pages/category/index`
110
+### 6.1 A — 全部分类 `pages/category/index`
91 111
 
92 112
 ```text
93 113
 全部分类(Tab)
@@ -106,7 +126,7 @@ pages/category/goods-list   → 动态 setNavigationBarTitle(level2Name)
106 126
 | 切换一级 | 仅刷新右侧 `children`,不跳转 |
107 127
 | 空树 | `u-empty`「暂无分类」 |
108 128
 
109
-### 5.2 B — 一级分类商品 `pages/category/level1`
129
+### 6.2 B — 一级分类商品 `subpackage/category/level1`
110 130
 
111 131
 ```text
112 132
 一级分类商品页
@@ -124,7 +144,7 @@ pages/category/goods-list   → 动态 setNavigationBarTitle(level2Name)
124 144
 | 排序 | Tab 切换 **保留** `sortBy`(父组件 state 不变) |
125 145
 | 商品点击 | 暂 Toast「商品详情开发中」 |
126 146
 
127
-### 5.3 C — 二级列表 `pages/category/goods-list`
147
+### 6.3 C — 二级列表 `subpackage/category/goods-list`
128 148
 
129 149
 ```text
130 150
 二级商品列表页
@@ -138,7 +158,7 @@ pages/category/goods-list   → 动态 setNavigationBarTitle(level2Name)
138 158
 | 无 Tab | 仅展示当前二级商品 |
139 159
 | 返回 | 系统返回栈 → A 页 |
140 160
 
141
-### 5.4 商品列表块 `GoodsListBlock`(B/C 共用 §7)
161
+### 6.4 商品列表块 `GoodsListBlock`(B/C 共用 §7)
142 162
 
143 163
 ```text
144 164
 GoodsListBlock
@@ -158,18 +178,18 @@ GoodsListBlock
158 178
 
159 179
 ---
160 180
 
161
-## 6. 与商城首页协作
181
+## 7. 与商城首页协作
162 182
 
163 183
 | 首页操作 | 前端跳转 |
164 184
 |----------|----------|
165
-| 点击一级类目 | `navigateTo` `/pages/category/level1?level1Id=&level1Name=` |
185
+| 点击一级类目 | `navigateTo` `/subpackage/category/level1?level1Id=&level1Name=` |
166 186
 | 点击「更多」 | `switchTab` `/pages/category/index` |
167 187
 
168 188
 **勿混用接口:** 首页横向导航用 `/api/home/categories`(仅一级);分类模块用 `/api/category/**`。
169 189
 
170 190
 ---
171 191
 
172
-## 7. 业务规则映射(前端)
192
+## 8. 业务规则映射(前端)
173 193
 
174 194
 | 规则 | 前端实现 |
175 195
 |------|----------|
@@ -187,7 +207,7 @@ GoodsListBlock
187 207
 
188 208
 ---
189 209
 
190
-## 8. 联调检查清单
210
+## 9. 联调检查清单
191 211
 
192 212
 | # | 项 |
193 213
 |---|-----|
@@ -200,7 +220,7 @@ GoodsListBlock
200 220
 
201 221
 ---
202 222
 
203
-## 9. 待建设(非本方案)
223
+## 10. 待建设(非本方案)
204 224
 
205 225
 | 项 | 说明 |
206 226
 |----|------|
@@ -210,7 +230,7 @@ GoodsListBlock
210 230
 
211 231
 ---
212 232
 
213
-## 10. 修订记录
233
+## 11. 修订记录
214 234
 
215 235
 | 版本 | 说明 |
216 236
 |------|------|

+ 168 - 0
doc/消费者APP/商品详情内页/商品详情内页前端技术方案.md

@@ -0,0 +1,168 @@
1
+# 商品详情内页 — 前端技术方案(C 端 · shop-app)
2
+
3
+> **依据:** 《商品详情内页功能需求.md》v1.0、《商品详情内页技术方案.md》v1.4  
4
+> **关联:** 《商城首页前端技术方案》(热销跳转)、《商品分类前端技术方案》(列表跳转)  
5
+> **范围:** 消费者 APP **`shop-app`** 商品详情页、全部评价页;**不** 改后端、**不** 实现购物车 POST / 确认订单 / 店铺主页 / 评价发表。  
6
+> **实现状态:** 页面与 API 封装已落地,待与 `/api/goods/**` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | uni-app **Vue 3** + **uview-plus** |
15
+| 请求 | `@/utils/request`;详情/可购为 `data`;评价为 `rows`/`total` |
16
+| 鉴权 | 读接口 **`isToken: false`**;加购/购买前 **`getToken()`** 校验登录 |
17
+| 规格 | **v1 统一规格**:仅 **数量** 选择;`specDisplay` **只读展示** |
18
+| 参考 | `pages/index/index.vue`(价格卡片)、`components/mall/GoodsGrid.vue` |
19
+
20
+---
21
+
22
+## 2. 页面与路由
23
+
24
+| 页面 | 路径 | 入口 |
25
+|------|------|------|
26
+| 商品详情 | `subpackage/goods/detail` | 首页热销、分类列表等带 `goodsId` |
27
+| 全部评价 | `subpackage/goods/reviews` | 详情页「查看全部」 |
28
+
29
+**Query:**
30
+
31
+| 页面 | 参数 |
32
+|------|------|
33
+| detail | `goodsId`(必填) |
34
+| reviews | `goodsId`(必填) |
35
+
36
+**统一跳转:** `goGoodsDetail(goodsId)` — `utils/goodsDetail.js`
37
+
38
+---
39
+
40
+## 3. 文件清单
41
+
42
+| 类型 | 路径 | 说明 |
43
+|------|------|------|
44
+| 详情页 | `shop-app/subpackage/goods/detail.vue` | 主页面(§3.1 结构) |
45
+| 评价页 | `shop-app/subpackage/goods/reviews.vue` | 评价分页列表 |
46
+| 底栏 | `shop-app/components/goods/DetailBottomBar.vue` | 店铺 / 加购 / 购买 |
47
+| 评价卡 | `shop-app/components/goods/ReviewCard.vue` | 单条评价展示 |
48
+| API | `shop-app/api/goods.js` | detail / can-purchase / reviews |
49
+| 映射 | `shop-app/utils/goodsDetail.js` | VO 映射、图片 URL、售后文案 |
50
+| 交易校验 | `shop-app/utils/purchaseAction.js` | 登录 + `/can-purchase` |
51
+
52
+---
53
+
54
+## 4. 接口封装
55
+
56
+**模块:** `shop-app/api/goods.js`
57
+
58
+| 方法 | HTTP | 路径 | 响应 |
59
+|------|------|------|------|
60
+| `getGoodsDetail(goodsId)` | GET | `/api/goods/{goodsId}` | `data: GoodsDetailAppVO` |
61
+| `getGoodsCanPurchase(goodsId)` | GET | `/api/goods/{goodsId}/can-purchase` | `data: { allowed, reason }` |
62
+| `getGoodsReviews(goodsId, params)` | GET | `/api/goods/{goodsId}/reviews` | `rows`, `total` |
63
+
64
+**详情核心字段(前端使用):**
65
+
66
+| 字段 | UI 区块 |
67
+|------|---------|
68
+| pics[] | 轮播(主图+副图,URL 经 `resolveFileUrl`) |
69
+| salePrice / goodsName / goodsBrief | 价格区 |
70
+| stock / salesCount / categoryPath | 元信息 |
71
+| goodsStatus / purchase | 状态条、底栏是否可点 |
72
+| attributes / specDisplay | 商品参数 / 规格信息(只读) |
73
+| logistics | 物流说明(有值才展示) |
74
+| services | 服务说明 |
75
+| detailContent | 图文详情 `rich-text` |
76
+| shop | 进店区 |
77
+| afterSalePhone | 售后服务文案 |
78
+
79
+---
80
+
81
+## 5. 详情页结构(自上而下)
82
+
83
+```text
84
+商品详情 detail.vue
85
+├── 图片轮播 swiper(点击 previewImage)
86
+├── 状态提示条(下架 / 不可购 / 库存不足等)
87
+├── 价格与名称、简述、分类路径、库存、销量
88
+├── 数量(点击弹出 u-popup + u-number-box)
89
+├── 商品参数 attributes
90
+├── 规格信息 specDisplay(只读,v1)
91
+├── 物流说明 logistics(有字段才显示)
92
+├── 服务说明 services
93
+├── 评价摘要(GET reviews pageSize=2)+ 查看全部
94
+├── 售后服务(固定模板 + 客服电话)
95
+├── 图文详情 rich-text
96
+├── 店铺入口(进店)
97
+└── 固定底栏 DetailBottomBar
98
+```
99
+
100
+---
101
+
102
+## 6. 交互与业务规则(前端)
103
+
104
+| 规则 | 实现 |
105
+|------|------|
106
+| GD4 轮播 | `pics` 数组 swiper |
107
+| GD10 不可购 | `purchase.allowed`、`goodsStatus`、库存 → `canOperate` 控制底栏 |
108
+| 底栏置灰 | `:disabled="!canOperate"` |
109
+| 加购 / 购买 | 先登录 → 可选数量弹层 → `getCanPurchase` → Toast 待建设模块 |
110
+| 立即购买 | 打开数量弹层后确认 → 同上 |
111
+| 进店 | Toast「店铺主页开发中」 |
112
+| 评价 2 条 | `pageSize=2`;无数据「暂无评价」 |
113
+| 全部评价 | `navigateTo` reviews 页,`pageSize=10` 上拉加载 |
114
+| v1 统一规格 | 无 SKU 矩阵;仅数量 1~stock |
115
+
116
+**可购校验顺序(后端 Facade,前端点击前重调):**
117
+
118
+商品存在 → 出售中 → 库存>0 → 店铺开业 → 分类可见
119
+
120
+---
121
+
122
+## 7. 上游入口改造
123
+
124
+| 来源 | 改动 |
125
+|------|------|
126
+| `pages/index/index.vue` | 热销卡片 `goGoodsDetail` |
127
+| `subpackage/category/level1.vue` | 列表 `@goods-click` |
128
+| `subpackage/category/goods-list.vue` | 同上 |
129
+| `GoodsListBlock.vue` | 继续 `emit('goods-click', item)` |
130
+
131
+---
132
+
133
+## 8. 联调检查清单
134
+
135
+| # | 项 |
136
+|---|-----|
137
+| 1 | `GET /api/goods/{id}` 返回 pics 至少 1 张 |
138
+| 2 | 非出售中商品详情 200 但底栏不可点、有提示文案 |
139
+| 3 | `purchase.allowed=false` 时展示 `reason` |
140
+| 4 | 点击加购未登录提示「请先登录」 |
141
+| 5 | 登录后加购调 `/can-purchase` 失败展示后端 `reason` |
142
+| 6 | 评价接口空列表显示「暂无评价」 |
143
+| 7 | 图文详情 HTML 在 H5/小程序渲染正常 |
144
+
145
+---
146
+
147
+## 9. 待建设(非本方案)
148
+
149
+| 项 | 说明 |
150
+|----|------|
151
+| POST 加入购物车 | 购物车模块 |
152
+| 立即购买 → 确认订单 | 订单模块 |
153
+| 店铺主页 | 店铺模块 |
154
+| 登录/注册页 | 会员模块 |
155
+| 多规格 SKU 选择 | `biz_goods_sku` 落地后扩展 |
156
+| 评价发表 | 订单完成链 |
157
+
158
+---
159
+
160
+## 10. 修订记录
161
+
162
+| 版本 | 说明 |
163
+|------|------|
164
+| **v1.0** | 首版:详情页 + 全部评价页;三读接口封装;上游入口打通 |
165
+
166
+---
167
+
168
+*文档版本:v1.0 · 工程目录 `shop-app` · 关联《商品详情内页功能需求.md》v1.0、《商品详情内页技术方案.md》v1.4*

+ 1 - 1
doc/消费者APP/商品详情内页/商品详情内页技术方案.md

@@ -507,7 +507,7 @@ GoodsAppServiceImpl
507 507
 | `BizGoodsPicMapper`(goods 模块) | **已实现**(C 端读复用) |
508 508
 | `BizGoodsReviewMapper` | **待创建**(表落地后) |
509 509
 | 单元/API 测试 | **已通过**(`GoodsAppServiceImplTest`、`GoodsAppControllerTest`、`GoodsPicSupportTest`) |
510
-| C 端详情前端 | **未实现** |
510
+| C 端详情前端 | **已实现**(见《商品详情内页前端技术方案.md》v1.0) |
511 511
 
512 512
 ---
513 513
 

+ 220 - 0
doc/消费者APP/搜索页/搜索页前端技术方案.md

@@ -0,0 +1,220 @@
1
+# 搜索页 — 前端技术方案(C 端 · shop-app)
2
+
3
+> **依据:** 《搜索页功能需求.md》v1.0、《搜索页技术方案.md》v1.2  
4
+> **关联:** 《商城首页前端技术方案》、《商品分类前端技术方案》、《商品详情内页前端技术方案》  
5
+> **范围:** 消费者 APP **`shop-app`** 搜索输入页(A)、搜索结果页(B);**不** 改后端、**不** 实现店铺主页、店铺内搜索、联想/热搜。  
6
+> **实现状态:** 页面与 API 封装已落地,待与 `GET /api/search` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | uni-app **Vue 3** + **uview-plus** |
15
+| 请求 | `@/utils/request`;搜索聚合结果为 `data.goods` / `data.shops` |
16
+| 鉴权 | **`header: { isToken: false }`**(`@Anonymous`) |
17
+| 历史 | **仅本地** `uni.setStorageSync`,键 `shop_search_history`,最多 **20** 条 |
18
+| 占位文案 | **「搜索兽药、饲料、店铺」**(SRH15,与首页/分类一致) |
19
+| 参考 | `components/mall/GoodsGrid.vue`、`components/category/GoodsListBlock.vue` |
20
+
21
+---
22
+
23
+## 2. 页面与路由
24
+
25
+| 页面 | 需求代号 | 路径 | 入口 |
26
+|------|----------|------|------|
27
+| 搜索输入 | **A** | `subpackage/search/index` | 首页顶栏搜索、分类页 `SearchEntry` |
28
+| 搜索结果 | **B** | `subpackage/search/result` | A 页提交搜索;B 页「重新搜索」回 A 并带关键词 |
29
+
30
+**`pages.json`:**
31
+
32
+```text
33
+subpackage/search/index   → navigationBarTitleText: 搜索
34
+subpackage/search/result  → navigationBarTitleText: 搜索结果
35
+```
36
+
37
+**Query:**
38
+
39
+| 页面 | 参数 | 说明 |
40
+|------|------|------|
41
+| A | `keyword`(可选) | B 页「重新搜索」预填 |
42
+| B | `keyword`(必填) | trim 后检索;空则 Toast 并返回 |
43
+
44
+**跳转工具:** `utils/searchNav.js`
45
+
46
+| 方法 | 行为 |
47
+|------|------|
48
+| `goSearchInput(keyword?)` | `navigateTo` A 页 |
49
+| `goSearchResult(keyword)` | `navigateTo` B 页(内部 trim,空不跳) |
50
+
51
+---
52
+
53
+## 3. 文件清单
54
+
55
+| 类型 | 路径 | 说明 |
56
+|------|------|------|
57
+| A 页 | `shop-app/subpackage/search/index.vue` | 输入 + 历史 |
58
+| B 页 | `shop-app/subpackage/search/result.vue` | 重新搜索条 + Tab + 列表 |
59
+| Tab | `shop-app/components/search/SearchResultTabs.vue` | 按销量 / 按价格 / 店铺 |
60
+| 店铺列表 | `shop-app/components/search/ShopList.vue` | 店铺卡片 |
61
+| 商品网格 | `shop-app/components/mall/GoodsGrid.vue` | 与分类/首页共用 |
62
+| 搜索入口 | `shop-app/components/mall/SearchEntry.vue` | 跳转 A 页 |
63
+| API | `shop-app/api/search.js` | `searchAll` |
64
+| 常量 | `shop-app/constants/search.js` | Tab、价序、分页大小 |
65
+| 历史 | `shop-app/utils/searchHistory.js` | 增删清空、去重置顶 |
66
+| 导航 | `shop-app/utils/searchNav.js` | A/B 路由 |
67
+| 路径常量 | `shop-app/utils/pageRoute.js` | 主包 + 分包路径统一维护 |
68
+| 商品映射 | `shop-app/utils/goodsDisplay.js` | `mapGoodsCardList` |
69
+| 店铺映射 | `shop-app/utils/shopDisplay.js` | `mapShopCardList` |
70
+
71
+---
72
+
73
+## 4. 接口封装
74
+
75
+**模块:** `shop-app/api/search.js`
76
+
77
+| 方法 | HTTP | 路径 | 响应 |
78
+|------|------|------|------|
79
+| `searchAll(params)` | GET | `/api/search` | `data: { goods: { total, rows }, shops: { total, rows } }` |
80
+
81
+**Query:**
82
+
83
+| 参数 | 默认 | 说明 |
84
+|------|------|------|
85
+| keyword | 必填 | trim 后非空(空搜在 A 页拦截,不调接口) |
86
+| sortBy | `sales_desc` | 仅影响 **商品** 排序 |
87
+| goodsPageNum | 1 | 商品分页 |
88
+| goodsPageSize | 10 | 与 `SEARCH_PAGE_SIZE` 一致 |
89
+| shopPageNum | 1 | 店铺分页 |
90
+| shopPageSize | 10 | 同上 |
91
+
92
+**sortBy 与 UI Tab:**
93
+
94
+| Tab | sortBy |
95
+|-----|--------|
96
+| 按销量 | `sales_desc` |
97
+| 按价格 · 从低到高 | `price_asc` |
98
+| 按价格 · 从高到低 | `price_desc` |
99
+| 店铺 | 请求仍带 sortBy,后端 **只查店铺** 列表 |
100
+
101
+**错误处理:** `silent: true`;失败当前 Tab **空态** +「点击重试」,不 Toast 刷屏(对齐 §8.2)。
102
+
103
+---
104
+
105
+## 5. 搜索历史(本地 SRH4)
106
+
107
+**存储键:** `shop_search_history`  
108
+**结构:** `[{ keyword, time }]`,展示按 `time` **倒序**。
109
+
110
+| 行为 | 实现 |
111
+|------|------|
112
+| 写入时机 | **成功进入 B 页**(A 提交、B 页 onLoad) |
113
+| 空关键词 | 不写历史、不调接口 |
114
+| 去重 | 同词再次搜索 → 更新 `time` 并 **置顶** |
115
+| 单条删除 | 历史行右侧关闭图标 |
116
+| 全部清空 | 标题栏「清空」+ 二次确认 |
117
+| 无历史 | 文案「暂无搜索历史」 |
118
+
119
+---
120
+
121
+## 6. 页面结构
122
+
123
+### 6.1 A — 搜索输入 `subpackage/search/index`
124
+
125
+```text
126
+搜索输入页
127
+├── 顶栏:返回 + 输入框 + 清空 +「搜索」
128
+├── 键盘回车 / 搜索按钮 → 校验非空 → 写历史 → B 页
129
+└── 搜索历史(有则展示,无则空态文案)
130
+```
131
+
132
+| 规则 | 实现 |
133
+|------|------|
134
+| SRH3 空搜 | Toast「请输入搜索内容」,不跳转 |
135
+| 预填 | `?keyword=` 来自 B 页重新搜索 |
136
+
137
+### 6.2 B — 搜索结果 `subpackage/search/result`
138
+
139
+```text
140
+搜索结果页
141
+├── 顶栏:返回 + 可点搜索条(当前关键词)→ A 页预填
142
+├── SearchResultTabs(三 Tab;按价格下再选升/降序)
143
+└── scroll-view 列表
144
+    ├── 按销量 / 按价格 → GoodsGrid + 上拉加载 goodsPageNum
145
+    ├── 店铺 → ShopList + 上拉加载 shopPageNum
146
+    └── 空态「暂无相关」;失败可重试
147
+```
148
+
149
+| 行为 | 实现 |
150
+|------|------|
151
+| 默认 Tab | **按销量**(SRH8) |
152
+| 切 Tab | 商品 Tab 重置 `goodsPageNum` 并请求;店铺 Tab **优先用** 同次搜索已返回的 `shops` 缓存 |
153
+| 价序切换 | 仅「按价格」Tab 下;改 `sortBy` 后重置商品列表 |
154
+| 商品点击 | `goGoodsDetail(goodsId)`(SRH11) |
155
+| 店铺点击 | Toast「店铺主页开发中」(SRH12,待店铺主页模块) |
156
+| rating/fansCount | 后端为 null 时 **不展示** 对应行 |
157
+
158
+---
159
+
160
+## 7. 入口改造
161
+
162
+| 位置 | 改动 |
163
+|------|------|
164
+| `pages/index/index.vue` | `onSearchTap` → `/subpackage/search/index` |
165
+| `components/mall/SearchEntry.vue` | 点击 → `goSearchInput()` |
166
+| 分类 A/B/C 页 | 已引用 `SearchEntry`,无需再改 |
167
+
168
+---
169
+
170
+## 8. 业务规则对照(前端)
171
+
172
+| 编号 | 前端落实 |
173
+|------|----------|
174
+| SRH2 | 首页/分类搜索栏 → A 页 |
175
+| SRH3 | A 页空关键词拦截 |
176
+| SRH4 | `searchHistory.js` 本地存储 |
177
+| SRH5 | B 顶栏点击 → A 带 `keyword` |
178
+| SRH7 | 列表 **不** 做四条件过滤(后端浏览层) |
179
+| SRH8~SRH9 | 三 Tab + 价格升/降 |
180
+| SRH11 | 商品 → 详情 |
181
+| SRH12 | 店铺 → 占位 Toast |
182
+| SRH13 | 空态「暂无相关」 |
183
+| SRH14 | 不在搜索页加购/下单 |
184
+| SRH15 | 占位文案常量 `SEARCH_PLACEHOLDER` |
185
+
186
+---
187
+
188
+## 9. 联调检查清单
189
+
190
+- [ ] `GET /api/search?keyword=兽药` 返回 `data.goods.rows`、`data.shops.rows`
191
+- [ ] 按销量 / 按价格 / 店铺 三 Tab 列表与空态独立
192
+- [ ] 商品上拉加载 `goodsPageNum` 递增
193
+- [ ] 店铺上拉 `shopPageNum` 递增(首屏已从聚合接口缓存时从第 2 页请求)
194
+- [ ] 空关键词、仅空格:A 页 Toast,不进 B
195
+- [ ] 历史去重、清空、单删
196
+- [ ] 从首页、分类页点击搜索栏进入 A 页
197
+
198
+---
199
+
200
+## 10. 非本期(前端不实现)
201
+
202
+| 项 | 说明 |
203
+|----|------|
204
+| 店铺主页 | 店铺卡片暂 Toast |
205
+| 店铺内搜索 | 店铺主页专册 |
206
+| 联想 / 热搜 / 语音 | — |
207
+| 历史云端同步 | 仅 localStorage |
208
+| 关键词高亮 | UI 可选增强 |
209
+
210
+---
211
+
212
+## 11. 修订记录
213
+
214
+| 版本 | 说明 |
215
+|------|------|
216
+| **v1.0** | 首版:A/B 两页、统一搜索接口封装、本地历史、入口打通 |
217
+
218
+---
219
+
220
+*文档版本:v1.0 · 关联《搜索页功能需求.md》v1.0、《搜索页技术方案.md》v1.2*

+ 53 - 0
shop-app/PAGES.md

@@ -0,0 +1,53 @@
1
+# shop-app 页面目录约定
2
+
3
+## 主包 `pages/`(仅 5 页)
4
+
5
+```
6
+pages/
7
+├── index/index.vue      # 首页 Tab
8
+├── category/index.vue   # 分类 Tab
9
+├── cart/index.vue       # 购物车 Tab(待正式开发)
10
+├── mine/index.vue       # 我的 Tab(待正式开发)
11
+└── login/index.vue      # 登录
12
+```
13
+
14
+## 分包 `subpackage/`(其它业务页一律放这里)
15
+
16
+```
17
+subpackage/
18
+├── category/            # 分类子页
19
+│   ├── level1.vue
20
+│   └── goods-list.vue
21
+├── goods/               # 商品
22
+│   ├── detail.vue
23
+│   └── reviews.vue
24
+└── search/              # 搜索
25
+    ├── index.vue
26
+    └── result.vue
27
+```
28
+
29
+后续示例(待建):
30
+
31
+```
32
+subpackage/
33
+├── shop/                # 店铺主页
34
+├── order/               # 订单、确认订单
35
+├── cart/                # 购物车子页(Tab 仍在 pages/cart)
36
+└── user/                # 地址、设置等(Tab 仍在 pages/mine)
37
+```
38
+
39
+## 新建页面三步
40
+
41
+1. **建文件**:`subpackage/{模块}/{页面}.vue`
42
+2. **注册路由**:`pages.json` → `subPackages`(勿写入顶部 `pages` 主包列表)
43
+3. **路径常量**:`utils/pageRoute.js` 增加 `PAGE_*`,跳转时引用
44
+
45
+## 路由示例
46
+
47
+| 常量(pageRoute.js) | 完整路径 |
48
+|----------------------|----------|
49
+| `PAGE_HOME` | `/pages/index/index` |
50
+| `PAGE_GOODS_DETAIL` | `/subpackage/goods/detail` |
51
+| `PAGE_SEARCH_INDEX` | `/subpackage/search/index` |
52
+
53
+配置详见 `pages.json` 中 `subPackages` 与 `preloadRule`。

+ 32 - 0
shop-app/api/goods.js

@@ -0,0 +1,32 @@
1
+import request from '@/utils/request'
2
+
3
+/** 商品详情聚合(匿名) */
4
+export function getGoodsDetail(goodsId) {
5
+  return request({
6
+    url: `/api/goods/${goodsId}`,
7
+    method: 'GET',
8
+    header: { isToken: false },
9
+    silent: true
10
+  })
11
+}
12
+
13
+/** 可购四条件校验(加购/购买前可重调) */
14
+export function getGoodsCanPurchase(goodsId) {
15
+  return request({
16
+    url: `/api/goods/${goodsId}/can-purchase`,
17
+    method: 'GET',
18
+    header: { isToken: false },
19
+    silent: true
20
+  })
21
+}
22
+
23
+/** 商品评价分页 */
24
+export function getGoodsReviews(goodsId, params) {
25
+  return request({
26
+    url: `/api/goods/${goodsId}/reviews`,
27
+    method: 'GET',
28
+    params,
29
+    header: { isToken: false },
30
+    silent: true
31
+  })
32
+}

+ 37 - 0
shop-app/api/login.js

@@ -0,0 +1,37 @@
1
+import request from '@/utils/request'
2
+
3
+/** 会员/用户登录 */
4
+export function login(username, password, code, uuid) {
5
+  return request({
6
+    url: '/login',
7
+    method: 'POST',
8
+    header: { isToken: false, repeatSubmit: false },
9
+    data: { username, password, code, uuid }
10
+  })
11
+}
12
+
13
+/** 获取当前登录用户信息 */
14
+export function getInfo() {
15
+  return request({
16
+    url: '/getInfo',
17
+    method: 'GET'
18
+  })
19
+}
20
+
21
+/** 退出登录 */
22
+export function logout() {
23
+  return request({
24
+    url: '/logout',
25
+    method: 'POST'
26
+  })
27
+}
28
+
29
+/** 图形验证码 */
30
+export function getCodeImg() {
31
+  return request({
32
+    url: '/captchaImage',
33
+    method: 'GET',
34
+    header: { isToken: false },
35
+    timeout: 20000
36
+  })
37
+}

+ 16 - 0
shop-app/api/search.js

@@ -0,0 +1,16 @@
1
+import request from '@/utils/request'
2
+
3
+/**
4
+ * 全站搜索(商品 + 店铺一次返回)
5
+ * @param {Object} params keyword, sortBy, goodsPageNum, goodsPageSize, shopPageNum, shopPageSize
6
+ * @returns {Promise<{ goods: { total, rows }, shops: { total, rows } }>}
7
+ */
8
+export function searchAll(params) {
9
+  return request({
10
+    url: '/api/search',
11
+    method: 'GET',
12
+    params,
13
+    header: { isToken: false },
14
+    silent: true
15
+  }).then((res) => res.data || { goods: { total: 0, rows: [] }, shops: { total: 0, rows: [] } })
16
+}

+ 80 - 0
shop-app/components/goods/DetailBottomBar.vue

@@ -0,0 +1,80 @@
1
+<template>
2
+	<view class="bottom-bar">
3
+		<view class="bottom-bar__shop" @click="emit('shop')">
4
+			<u-icon name="home" size="22" color="#666" />
5
+			<text class="bottom-bar__shop-text">店铺</text>
6
+		</view>
7
+		<view class="bottom-bar__actions">
8
+			<button class="btn btn-cart" :disabled="disabled" @click="emit('cart')">加入购物车</button>
9
+			<button class="btn btn-buy" :disabled="disabled" @click="emit('buy')">立即购买</button>
10
+		</view>
11
+	</view>
12
+</template>
13
+
14
+<script setup>
15
+defineProps({
16
+	disabled: {
17
+		type: Boolean,
18
+		default: false
19
+	}
20
+})
21
+const emit = defineEmits(['cart', 'buy', 'shop'])
22
+</script>
23
+
24
+<style lang="scss" scoped>
25
+.bottom-bar {
26
+	position: fixed;
27
+	left: 0;
28
+	right: 0;
29
+	bottom: 0;
30
+	z-index: 100;
31
+	display: flex;
32
+	align-items: center;
33
+	padding: 12rpx 24rpx;
34
+	padding-bottom: calc(12rpx + env(safe-area-inset-bottom));
35
+	background: #fff;
36
+	box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
37
+}
38
+.bottom-bar__shop {
39
+	display: flex;
40
+	flex-direction: column;
41
+	align-items: center;
42
+	width: 100rpx;
43
+	flex-shrink: 0;
44
+}
45
+.bottom-bar__shop-text {
46
+	margin-top: 4rpx;
47
+	font-size: 22rpx;
48
+	color: #666;
49
+}
50
+.bottom-bar__actions {
51
+	flex: 1;
52
+	display: flex;
53
+	margin-left: 16rpx;
54
+	gap: 16rpx;
55
+}
56
+.btn {
57
+	flex: 1;
58
+	height: 80rpx;
59
+	line-height: 80rpx;
60
+	margin: 0;
61
+	padding: 0;
62
+	font-size: 28rpx;
63
+	border-radius: 40rpx;
64
+	border: none;
65
+}
66
+.btn-cart {
67
+	background: #fff5e6;
68
+	color: #ff6f00;
69
+}
70
+.btn-cart[disabled] {
71
+	opacity: 0.5;
72
+}
73
+.btn-buy {
74
+	background: linear-gradient(90deg, #2e7d32, #4caf50);
75
+	color: #fff;
76
+}
77
+.btn-buy[disabled] {
78
+	opacity: 0.5;
79
+}
80
+</style>

+ 123 - 0
shop-app/components/goods/ReviewCard.vue

@@ -0,0 +1,123 @@
1
+<template>
2
+	<view class="review-card">
3
+		<view class="review-card__head">
4
+			<image class="review-card__avatar" :src="avatarSrc" mode="aspectFill" />
5
+			<view class="review-card__meta">
6
+				<text class="review-card__name">{{ item.memberNickName }}</text>
7
+				<view v-if="item.score" class="review-card__stars">
8
+					<text v-for="n in 5" :key="n" class="star" :class="{ on: n <= item.score }">★</text>
9
+				</view>
10
+			</view>
11
+			<text v-if="item.createTime" class="review-card__time">{{ item.createTime }}</text>
12
+		</view>
13
+		<text class="review-card__content">{{ item.content }}</text>
14
+		<view v-if="item.pics && item.pics.length" class="review-card__pics">
15
+			<image
16
+				v-for="(pic, idx) in item.pics"
17
+				:key="idx"
18
+				class="review-card__pic"
19
+				:src="pic"
20
+				mode="aspectFill"
21
+				@click="previewPics(idx)"
22
+			/>
23
+		</view>
24
+		<view v-if="item.replyContent" class="review-card__reply">
25
+			<text class="review-card__reply-label">商家回复:</text>
26
+			<text>{{ item.replyContent }}</text>
27
+		</view>
28
+	</view>
29
+</template>
30
+
31
+<script setup>
32
+import { computed } from 'vue'
33
+
34
+const props = defineProps({
35
+	item: {
36
+		type: Object,
37
+		required: true
38
+	}
39
+})
40
+
41
+const avatarSrc = computed(() => props.item.memberAvatar || '/static/logo.png')
42
+
43
+function previewPics(index) {
44
+	const urls = props.item.pics || []
45
+	if (!urls.length) return
46
+	uni.previewImage({ urls, current: urls[index] })
47
+}
48
+</script>
49
+
50
+<style lang="scss" scoped>
51
+.review-card {
52
+	padding: 24rpx 0;
53
+	border-bottom: 1rpx solid #f0f0f0;
54
+}
55
+.review-card__head {
56
+	display: flex;
57
+	align-items: flex-start;
58
+}
59
+.review-card__avatar {
60
+	width: 64rpx;
61
+	height: 64rpx;
62
+	border-radius: 50%;
63
+	background: #eee;
64
+	flex-shrink: 0;
65
+}
66
+.review-card__meta {
67
+	flex: 1;
68
+	margin-left: 16rpx;
69
+	min-width: 0;
70
+}
71
+.review-card__name {
72
+	font-size: 26rpx;
73
+	color: #333;
74
+	font-weight: 500;
75
+}
76
+.review-card__stars {
77
+	margin-top: 4rpx;
78
+}
79
+.star {
80
+	font-size: 22rpx;
81
+	color: #ddd;
82
+	margin-right: 2rpx;
83
+}
84
+.star.on {
85
+	color: #ff9800;
86
+}
87
+.review-card__time {
88
+	font-size: 22rpx;
89
+	color: #999;
90
+	flex-shrink: 0;
91
+}
92
+.review-card__content {
93
+	display: block;
94
+	margin-top: 16rpx;
95
+	font-size: 28rpx;
96
+	color: #444;
97
+	line-height: 1.5;
98
+}
99
+.review-card__pics {
100
+	display: flex;
101
+	flex-wrap: wrap;
102
+	margin-top: 16rpx;
103
+	gap: 12rpx;
104
+}
105
+.review-card__pic {
106
+	width: 160rpx;
107
+	height: 160rpx;
108
+	border-radius: 8rpx;
109
+	background: #f5f5f5;
110
+}
111
+.review-card__reply {
112
+	margin-top: 16rpx;
113
+	padding: 16rpx;
114
+	background: #f8f8f8;
115
+	border-radius: 8rpx;
116
+	font-size: 24rpx;
117
+	color: #666;
118
+	line-height: 1.5;
119
+}
120
+.review-card__reply-label {
121
+	color: #2e7d32;
122
+}
123
+</style>

+ 61 - 11
shop-app/components/mall/SearchEntry.vue

@@ -1,29 +1,79 @@
1 1
 <template>
2
-	<view class="search-entry" @click="onTap">
3
-		<u-icon name="search" color="#999" size="18" />
4
-		<text class="search-entry__text">搜索兽药、饲料、店铺</text>
2
+	<view class="search-entry-wrap">
3
+		<view class="search-entry" @click="onTap">
4
+			<view class="search-entry__icon">
5
+				<u-icon name="search" color="#2e7d32" size="20" />
6
+			</view>
7
+			<text class="search-entry__placeholder">搜索兽药、饲料、店铺</text>
8
+			<view class="search-entry__btn">
9
+				<text>搜索</text>
10
+			</view>
11
+		</view>
5 12
 	</view>
6 13
 </template>
7 14
 
8 15
 <script setup>
16
+import { goSearchInput } from '@/utils/searchNav'
17
+
9 18
 function onTap() {
10
-	uni.showToast({ title: '搜索功能开发中', icon: 'none' })
19
+	goSearchInput()
11 20
 }
12 21
 </script>
13 22
 
14 23
 <style lang="scss" scoped>
24
+.search-entry-wrap {
25
+	padding: 16rpx 24rpx;
26
+	background: #e8f5e9;
27
+}
28
+
15 29
 .search-entry {
16 30
 	display: flex;
17 31
 	align-items: center;
18
-	height: 72rpx;
19
-	margin: 16rpx 24rpx;
20
-	padding: 0 24rpx;
21
-	background: #f5f6f8;
22
-	border-radius: 36rpx;
32
+	height: 76rpx;
33
+	padding: 0 8rpx 0 20rpx;
34
+	background: #f5faf6;
35
+	border: 2rpx solid #a5d6a7;
36
+	border-radius: 40rpx;
37
+	box-shadow: 0 4rpx 16rpx rgba(46, 125, 50, 0.08);
23 38
 }
24
-.search-entry__text {
25
-	margin-left: 12rpx;
39
+
40
+.search-entry__icon {
41
+	display: flex;
42
+	align-items: center;
43
+	justify-content: center;
44
+	width: 48rpx;
45
+	height: 48rpx;
46
+	margin-right: 12rpx;
47
+	background: rgba(46, 125, 50, 0.1);
48
+	border-radius: 50%;
49
+	flex-shrink: 0;
50
+}
51
+
52
+.search-entry__placeholder {
53
+	flex: 1;
26 54
 	font-size: 28rpx;
27 55
 	color: #999;
56
+	overflow: hidden;
57
+	text-overflow: ellipsis;
58
+	white-space: nowrap;
59
+}
60
+
61
+.search-entry__btn {
62
+	flex-shrink: 0;
63
+	display: flex;
64
+	align-items: center;
65
+	justify-content: center;
66
+	height: 60rpx;
67
+	padding: 0 28rpx;
68
+	margin-left: 12rpx;
69
+	background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
70
+	border-radius: 30rpx;
71
+}
72
+
73
+.search-entry__btn text {
74
+	font-size: 26rpx;
75
+	color: #fff;
76
+	font-weight: 500;
77
+	letter-spacing: 2rpx;
28 78
 }
29 79
 </style>

+ 98 - 0
shop-app/components/search/SearchResultTabs.vue

@@ -0,0 +1,98 @@
1
+<template>
2
+	<view class="result-tabs">
3
+		<view class="result-tabs__row">
4
+			<text
5
+				v-for="item in tabOptions"
6
+				:key="item.value"
7
+				class="result-tabs__item"
8
+				:class="{ 'result-tabs__item--active': modelValue === item.value }"
9
+				@click="onTab(item.value)"
10
+			>
11
+				{{ item.label }}
12
+			</text>
13
+		</view>
14
+		<view v-if="modelValue === priceTab" class="result-tabs__price">
15
+			<text
16
+				v-for="item in priceOptions"
17
+				:key="item.value"
18
+				class="result-tabs__price-item"
19
+				:class="{ 'result-tabs__price-item--active': priceOrder === item.value }"
20
+				@click="onPriceOrder(item.value)"
21
+			>
22
+				{{ item.label }}
23
+			</text>
24
+		</view>
25
+	</view>
26
+</template>
27
+
28
+<script setup>
29
+import {
30
+  SEARCH_TAB_OPTIONS,
31
+  SEARCH_TAB_PRICE,
32
+  PRICE_ORDER_OPTIONS
33
+} from '@/constants/search'
34
+
35
+defineProps({
36
+  modelValue: {
37
+    type: String,
38
+    default: 'sales'
39
+  },
40
+  priceOrder: {
41
+    type: String,
42
+    default: 'price_asc'
43
+  }
44
+})
45
+
46
+const emit = defineEmits(['update:modelValue', 'update:priceOrder', 'change', 'price-change'])
47
+
48
+const tabOptions = SEARCH_TAB_OPTIONS
49
+const priceOptions = PRICE_ORDER_OPTIONS
50
+const priceTab = SEARCH_TAB_PRICE
51
+
52
+function onTab(value) {
53
+  emit('update:modelValue', value)
54
+  emit('change', value)
55
+}
56
+
57
+function onPriceOrder(value) {
58
+  emit('update:priceOrder', value)
59
+  emit('price-change', value)
60
+}
61
+</script>
62
+
63
+<style lang="scss" scoped>
64
+.result-tabs {
65
+	background: #fff;
66
+	border-bottom: 1rpx solid #f0f0f0;
67
+}
68
+.result-tabs__row {
69
+	display: flex;
70
+	align-items: center;
71
+	padding: 16rpx 24rpx;
72
+}
73
+.result-tabs__item {
74
+	margin-right: 40rpx;
75
+	font-size: 28rpx;
76
+	color: #666;
77
+}
78
+.result-tabs__item--active {
79
+	color: #2e7d32;
80
+	font-weight: 600;
81
+}
82
+.result-tabs__price {
83
+	display: flex;
84
+	padding: 0 24rpx 16rpx;
85
+}
86
+.result-tabs__price-item {
87
+	margin-right: 24rpx;
88
+	font-size: 24rpx;
89
+	color: #999;
90
+	padding: 8rpx 20rpx;
91
+	background: #f5f6f8;
92
+	border-radius: 24rpx;
93
+}
94
+.result-tabs__price-item--active {
95
+	color: #2e7d32;
96
+	background: rgba(46, 125, 50, 0.1);
97
+}
98
+</style>

+ 75 - 0
shop-app/components/search/ShopList.vue

@@ -0,0 +1,75 @@
1
+<template>
2
+	<view class="shop-list">
3
+		<view
4
+			v-for="item in list"
5
+			:key="item.shopId"
6
+			class="shop-card"
7
+			@click="emit('item-click', item)"
8
+		>
9
+			<image class="shop-card__avatar" :src="item.displayAvatar" mode="aspectFill" />
10
+			<view class="shop-card__body">
11
+				<text class="shop-card__name">{{ item.shopName }}</text>
12
+				<view v-if="item.showRating || item.showFans" class="shop-card__meta">
13
+					<text v-if="item.showRating" class="shop-card__meta-item">评分 {{ item.rating }}</text>
14
+					<text v-if="item.showFans" class="shop-card__meta-item">粉丝 {{ item.fansCount }}</text>
15
+				</view>
16
+			</view>
17
+			<u-icon name="arrow-right" color="#ccc" size="16" />
18
+		</view>
19
+	</view>
20
+</template>
21
+
22
+<script setup>
23
+defineProps({
24
+	list: {
25
+		type: Array,
26
+		default: () => []
27
+	}
28
+})
29
+const emit = defineEmits(['item-click'])
30
+</script>
31
+
32
+<style lang="scss" scoped>
33
+.shop-list {
34
+	padding: 0 24rpx;
35
+}
36
+.shop-card {
37
+	display: flex;
38
+	align-items: center;
39
+	padding: 24rpx;
40
+	margin-bottom: 16rpx;
41
+	background: #fff;
42
+	border-radius: 12rpx;
43
+	box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
44
+}
45
+.shop-card__avatar {
46
+	width: 96rpx;
47
+	height: 96rpx;
48
+	border-radius: 12rpx;
49
+	background: #eee;
50
+	flex-shrink: 0;
51
+}
52
+.shop-card__body {
53
+	flex: 1;
54
+	margin: 0 20rpx;
55
+	min-width: 0;
56
+}
57
+.shop-card__name {
58
+	font-size: 30rpx;
59
+	color: #333;
60
+	font-weight: 500;
61
+	overflow: hidden;
62
+	text-overflow: ellipsis;
63
+	white-space: nowrap;
64
+}
65
+.shop-card__meta {
66
+	margin-top: 8rpx;
67
+	display: flex;
68
+	flex-wrap: wrap;
69
+	gap: 16rpx;
70
+}
71
+.shop-card__meta-item {
72
+	font-size: 24rpx;
73
+	color: #999;
74
+}
75
+</style>

+ 34 - 0
shop-app/constants/search.js

@@ -0,0 +1,34 @@
1
+/** 搜索占位文案(与首页、分类一致 SRH15) */
2
+export const SEARCH_PLACEHOLDER = '搜索兽药、饲料、店铺'
3
+
4
+/** 本地历史存储键 */
5
+export const SEARCH_HISTORY_KEY = 'shop_search_history'
6
+
7
+/** 历史条数上限 */
8
+export const SEARCH_HISTORY_MAX = 20
9
+
10
+/** 结果页 Tab */
11
+export const SEARCH_TAB_SALES = 'sales'
12
+export const SEARCH_TAB_PRICE = 'price'
13
+export const SEARCH_TAB_SHOP = 'shop'
14
+
15
+export const SEARCH_TAB_OPTIONS = [
16
+  { label: '按销量', value: SEARCH_TAB_SALES },
17
+  { label: '按价格', value: SEARCH_TAB_PRICE },
18
+  { label: '店铺', value: SEARCH_TAB_SHOP }
19
+]
20
+
21
+/** 按价格 Tab 下的价序 */
22
+export const PRICE_ORDER_ASC = 'price_asc'
23
+export const PRICE_ORDER_DESC = 'price_desc'
24
+
25
+export const PRICE_ORDER_OPTIONS = [
26
+  { label: '从低到高', value: PRICE_ORDER_ASC },
27
+  { label: '从高到低', value: PRICE_ORDER_DESC }
28
+]
29
+
30
+/** 默认商品排序(按销量 Tab / 首次进 B 页) */
31
+export const DEFAULT_GOODS_SORT = 'sales_desc'
32
+
33
+/** 列表每页条数 */
34
+export const SEARCH_PAGE_SIZE = 10

+ 8 - 1
shop-app/manifest.json

@@ -68,5 +68,12 @@
68 68
     "uniStatistics" : {
69 69
         "enable" : false
70 70
     },
71
-    "vueVersion" : "3"
71
+    "vueVersion" : "3",
72
+    "h5" : {
73
+        "router" : {
74
+            "mode" : "hash",
75
+            "base" : ""
76
+        },
77
+        "title" : "农资商城"
78
+    }
72 79
 }

+ 71 - 10
shop-app/pages.json

@@ -22,30 +22,91 @@
22 22
 			}
23 23
 		},
24 24
 		{
25
-			"path": "pages/category/level1",
25
+			"path": "pages/cart/index",
26 26
 			"style": {
27
-				"navigationBarTitleText": "分类商品"
27
+				"navigationBarTitleText": "购物车"
28 28
 			}
29 29
 		},
30 30
 		{
31
-			"path": "pages/category/goods-list",
31
+			"path": "pages/mine/index",
32 32
 			"style": {
33
-				"navigationBarTitleText": "商品列表"
33
+				"navigationBarTitleText": "我的"
34 34
 			}
35 35
 		},
36 36
 		{
37
-			"path": "pages/cart/index",
37
+			"path": "pages/login/index",
38 38
 			"style": {
39
-				"navigationBarTitleText": "购物车"
39
+				"navigationBarTitleText": "登录",
40
+				"navigationStyle": "custom"
40 41
 			}
42
+		}
43
+	],
44
+	"subPackages": [
45
+		{
46
+			"root": "subpackage/category",
47
+			"name": "pkg-category",
48
+			"pages": [
49
+				{
50
+					"path": "level1",
51
+					"style": {
52
+						"navigationBarTitleText": "分类商品"
53
+					}
54
+				},
55
+				{
56
+					"path": "goods-list",
57
+					"style": {
58
+						"navigationBarTitleText": "商品列表"
59
+					}
60
+				}
61
+			]
41 62
 		},
42 63
 		{
43
-			"path": "pages/mine/index",
44
-			"style": {
45
-				"navigationBarTitleText": "我的"
46
-			}
64
+			"root": "subpackage/goods",
65
+			"name": "pkg-goods",
66
+			"pages": [
67
+				{
68
+					"path": "detail",
69
+					"style": {
70
+						"navigationBarTitleText": "商品详情"
71
+					}
72
+				},
73
+				{
74
+					"path": "reviews",
75
+					"style": {
76
+						"navigationBarTitleText": "全部评价"
77
+					}
78
+				}
79
+			]
80
+		},
81
+		{
82
+			"root": "subpackage/search",
83
+			"name": "pkg-search",
84
+			"pages": [
85
+				{
86
+					"path": "index",
87
+					"style": {
88
+						"navigationBarTitleText": "搜索"
89
+					}
90
+				},
91
+				{
92
+					"path": "result",
93
+					"style": {
94
+						"navigationBarTitleText": "搜索结果"
95
+					}
96
+				}
97
+			]
47 98
 		}
48 99
 	],
100
+	"preloadRule": {
101
+		"pages/index/index": {
102
+			"network": "all",
103
+			"packages": ["pkg-category", "pkg-goods", "pkg-search"]
104
+		},
105
+		"pages/category/index": {
106
+			"network": "all",
107
+			"packages": ["pkg-category", "pkg-goods"]
108
+		}
109
+	},
49 110
 	"globalStyle": {
50 111
 		"navigationBarTextStyle": "black",
51 112
 		"navigationBarTitleText": "农资商城",

+ 2 - 2
shop-app/pages/category/index.vue

@@ -47,6 +47,7 @@ import { onShow } from '@dcloudio/uni-app'
47 47
 import { getCategoryTree } from '@/api/category'
48 48
 import { mapCategoryTree } from '@/utils/categoryDisplay'
49 49
 import SearchEntry from '@/components/mall/SearchEntry.vue'
50
+import { PAGE_CATEGORY_GOODS_LIST } from '@/utils/pageRoute'
50 51
 
51 52
 const treeList = ref([])
52 53
 const selectedIndex = ref(0)
@@ -84,8 +85,7 @@ function onLevel2Tap(child) {
84 85
 	if (!level1) return
85 86
 	uni.navigateTo({
86 87
 		url:
87
-			'/pages/category/goods-list' +
88
-			`?categoryId=${child.categoryId}` +
88
+			`${PAGE_CATEGORY_GOODS_LIST}?categoryId=${child.categoryId}` +
89 89
 			`&level1Id=${level1.categoryId}` +
90 90
 			`&level1Name=${encodeURIComponent(level1.categoryName || '')}` +
91 91
 			`&level2Name=${encodeURIComponent(child.categoryName || '')}`

+ 6 - 8
shop-app/pages/index/index.vue

@@ -126,6 +126,8 @@ import { onShow } from '@dcloudio/uni-app'
126 126
 import { listHomeBanners, listHomeCategories, listHomeHotGoods } from '@/api/home'
127 127
 import { resolveFileUrl } from '@/utils/image'
128 128
 import { formatPrice } from '@/utils/format'
129
+import { goGoodsDetail } from '@/utils/goodsDetail'
130
+import { PAGE_SEARCH_INDEX, PAGE_CATEGORY_LEVEL1, PAGE_CATEGORY_TAB } from '@/utils/pageRoute'
129 131
 
130 132
 const CATEGORY_PLACEHOLDER = '/static/logo.png'
131 133
 const GOODS_PLACEHOLDER = '/static/logo.png'
@@ -232,7 +234,7 @@ function onScroll(e) {
232 234
 }
233 235
 
234 236
 function onSearchTap() {
235
-	uni.showToast({ title: '搜索功能开发中', icon: 'none' })
237
+	uni.navigateTo({ url: PAGE_SEARCH_INDEX })
236 238
 }
237 239
 
238 240
 function onBannerPreview(index) {
@@ -244,14 +246,13 @@ function onBannerPreview(index) {
244 246
 function onCategoryTap(item) {
245 247
 	uni.navigateTo({
246 248
 		url:
247
-			'/pages/category/level1' +
248
-			`?level1Id=${item.categoryId}` +
249
+			`${PAGE_CATEGORY_LEVEL1}?level1Id=${item.categoryId}` +
249 250
 			`&level1Name=${encodeURIComponent(item.categoryName || '')}`
250 251
 	})
251 252
 }
252 253
 
253 254
 function onMoreCategoryTap() {
254
-	uni.switchTab({ url: '/pages/category/index' })
255
+	uni.switchTab({ url: PAGE_CATEGORY_TAB })
255 256
 }
256 257
 
257 258
 function onCategoryImgError(item) {
@@ -259,10 +260,7 @@ function onCategoryImgError(item) {
259 260
 }
260 261
 
261 262
 function onGoodsTap(item) {
262
-	uni.showToast({
263
-		title: '商品详情开发中',
264
-		icon: 'none'
265
-	})
263
+	goGoodsDetail(item.goodsId)
266 264
 }
267 265
 
268 266
 onMounted(() => {

+ 422 - 0
shop-app/pages/login/index.vue

@@ -0,0 +1,422 @@
1
+<template>
2
+	<view class="login-page">
3
+		<!-- 顶部品牌区 -->
4
+		<view class="login-hero">
5
+			<view class="login-hero__orb login-hero__orb--1" />
6
+			<view class="login-hero__orb login-hero__orb--2" />
7
+			<view class="login-hero__orb login-hero__orb--3" />
8
+			<view class="login-hero__brand">
9
+				<text class="login-hero__app">{{ appName }}</text>
10
+				<text class="login-hero__welcome">欢迎登录农资商城</text>
11
+			</view>
12
+		</view>
13
+
14
+		<!-- 表单卡片 -->
15
+		<view class="login-body">
16
+			<view class="login-card">
17
+				<text class="login-card__title">账号登录</text>
18
+				<text class="login-card__sub">登录后即可浏览商品、加入购物车</text>
19
+
20
+				<view class="login-field">
21
+					<view class="login-field__box">
22
+						<view class="login-field__icon">
23
+							<u-icon name="account" color="#6b7f72" size="22" />
24
+						</view>
25
+						<input
26
+							v-model="form.username"
27
+							class="login-field__input"
28
+							type="text"
29
+							placeholder="请输入账号"
30
+							placeholder-class="login-field__placeholder"
31
+							confirm-type="next"
32
+						/>
33
+					</view>
34
+				</view>
35
+
36
+				<view class="login-field">
37
+					<view class="login-field__box">
38
+						<view class="login-field__icon">
39
+							<u-icon name="lock" color="#6b7f72" size="22" />
40
+						</view>
41
+						<input
42
+							v-model="form.password"
43
+							class="login-field__input"
44
+							type="password"
45
+							placeholder="请输入密码"
46
+							placeholder-class="login-field__placeholder"
47
+							confirm-type="done"
48
+							@confirm="handleLogin"
49
+						/>
50
+					</view>
51
+				</view>
52
+
53
+				<view v-if="captchaEnabled" class="login-field login-captcha">
54
+					<view class="login-field__box login-captcha__input-wrap">
55
+						<view class="login-field__icon">
56
+							<u-icon name="order" color="#6b7f72" size="22" />
57
+						</view>
58
+						<input
59
+							v-model="form.code"
60
+							class="login-field__input"
61
+							type="text"
62
+							placeholder="请输入验证码"
63
+							placeholder-class="login-field__placeholder"
64
+						/>
65
+					</view>
66
+					<image
67
+						v-if="codeUrl"
68
+						class="login-captcha__img"
69
+						:src="codeUrl"
70
+						mode="aspectFit"
71
+						@click="loadCaptcha"
72
+					/>
73
+				</view>
74
+
75
+				<view class="login-remember" @click="form.rememberMe = !form.rememberMe">
76
+					<view :class="['login-remember__check', { 'login-remember__check--on': form.rememberMe }]">
77
+						<u-icon v-if="form.rememberMe" name="checkmark" color="#ffffff" size="14" />
78
+					</view>
79
+					<text class="login-remember__txt">记住账号</text>
80
+				</view>
81
+
82
+				<view
83
+					:class="['login-btn', { 'login-btn--loading': loading }]"
84
+					@click="handleLogin"
85
+				>
86
+					<text class="login-btn__txt">{{ loading ? '登录中…' : '登 录' }}</text>
87
+				</view>
88
+			</view>
89
+
90
+			<text class="login-footer">巴青农资商城 · 消费者端</text>
91
+		</view>
92
+	</view>
93
+</template>
94
+
95
+<script>
96
+import { getCodeImg } from '@/api/login'
97
+import { getToken } from '@/utils/auth'
98
+import { useUserStore } from '@/store/user'
99
+import { REMEMBER_USERNAME_KEY } from '@/config'
100
+
101
+const HOME_URL = '/pages/index/index'
102
+
103
+export default {
104
+	data() {
105
+		return {
106
+			appName: '巴青农资商城',
107
+			loading: false,
108
+			captchaEnabled: false,
109
+			codeUrl: '',
110
+			form: {
111
+				username: '',
112
+				password: '',
113
+				code: '',
114
+				uuid: '',
115
+				rememberMe: false
116
+			}
117
+		}
118
+	},
119
+	onLoad() {
120
+		if (getToken()) {
121
+			this.goHome()
122
+			return
123
+		}
124
+		this.loadRememberedUsername()
125
+		this.loadCaptcha()
126
+	},
127
+	methods: {
128
+		loadCaptcha() {
129
+			getCodeImg()
130
+				.then((res) => {
131
+					this.captchaEnabled =
132
+						res.captchaEnabled === undefined ? true : !!res.captchaEnabled
133
+					if (this.captchaEnabled && res.img) {
134
+						this.codeUrl = 'data:image/gif;base64,' + res.img
135
+						this.form.uuid = res.uuid || ''
136
+					}
137
+				})
138
+				.catch(() => {
139
+					this.captchaEnabled = false
140
+				})
141
+		},
142
+		loadRememberedUsername() {
143
+			const saved = uni.getStorageSync(REMEMBER_USERNAME_KEY)
144
+			if (saved) {
145
+				this.form.username = saved
146
+				this.form.rememberMe = true
147
+			}
148
+		},
149
+		goHome() {
150
+			uni.switchTab({ url: HOME_URL })
151
+		},
152
+		validateForm() {
153
+			if (!(this.form.username || '').trim()) {
154
+				uni.showToast({ title: '请输入账号', icon: 'none' })
155
+				return false
156
+			}
157
+			if (!this.form.password) {
158
+				uni.showToast({ title: '请输入密码', icon: 'none' })
159
+				return false
160
+			}
161
+			if (this.captchaEnabled && !(this.form.code || '').trim()) {
162
+				uni.showToast({ title: '请输入验证码', icon: 'none' })
163
+				return false
164
+			}
165
+			return true
166
+		},
167
+		handleLogin() {
168
+			if (!this.validateForm() || this.loading) {
169
+				return
170
+			}
171
+			if (this.form.rememberMe) {
172
+				uni.setStorageSync(REMEMBER_USERNAME_KEY, this.form.username.trim())
173
+			} else {
174
+				uni.removeStorageSync(REMEMBER_USERNAME_KEY)
175
+			}
176
+			this.loading = true
177
+			const userStore = useUserStore()
178
+			userStore.fedLogOut()
179
+			const payload = {
180
+				username: this.form.username.trim(),
181
+				password: this.form.password,
182
+				code: this.captchaEnabled ? (this.form.code || '').trim() : '',
183
+				uuid: this.captchaEnabled ? this.form.uuid : ''
184
+			}
185
+			userStore
186
+				.login(payload)
187
+				.then(() => userStore.fetchUserInfo())
188
+				.then(() => {
189
+					this.goHome()
190
+				})
191
+				.catch(() => {
192
+					if (this.captchaEnabled) {
193
+						this.loadCaptcha()
194
+					}
195
+				})
196
+				.finally(() => {
197
+					this.loading = false
198
+				})
199
+		}
200
+	}
201
+}
202
+</script>
203
+
204
+<style lang="scss" scoped>
205
+@import '@/styles/morandi.scss';
206
+
207
+.login-page {
208
+	min-height: 100%;
209
+	display: flex;
210
+	flex-direction: column;
211
+	box-sizing: border-box;
212
+	background: $morandi-bg-page;
213
+}
214
+
215
+.login-hero {
216
+	position: relative;
217
+	flex-shrink: 0;
218
+	min-height: 420rpx;
219
+	padding: 80rpx 48rpx 120rpx;
220
+	box-sizing: border-box;
221
+	overflow: hidden;
222
+	background: linear-gradient(145deg, #3d9b6e 0%, #22c55e 42%, #86efac 100%);
223
+}
224
+
225
+.login-hero__orb {
226
+	position: absolute;
227
+	border-radius: 50%;
228
+	background: rgba(255, 255, 255, 0.12);
229
+}
230
+
231
+.login-hero__orb--1 {
232
+	width: 320rpx;
233
+	height: 320rpx;
234
+	top: -80rpx;
235
+	right: -60rpx;
236
+}
237
+
238
+.login-hero__orb--2 {
239
+	width: 200rpx;
240
+	height: 200rpx;
241
+	bottom: 40rpx;
242
+	left: -40rpx;
243
+}
244
+
245
+.login-hero__orb--3 {
246
+	width: 120rpx;
247
+	height: 120rpx;
248
+	top: 120rpx;
249
+	left: 60%;
250
+	background: rgba(255, 255, 255, 0.08);
251
+}
252
+
253
+.login-hero__brand {
254
+	position: relative;
255
+	z-index: 1;
256
+	display: flex;
257
+	flex-direction: column;
258
+	align-items: flex-start;
259
+	gap: 16rpx;
260
+}
261
+
262
+.login-hero__app {
263
+	font-size: 44rpx;
264
+	font-weight: 700;
265
+	color: #ffffff;
266
+	line-height: 1.3;
267
+}
268
+
269
+.login-hero__welcome {
270
+	font-size: 28rpx;
271
+	color: rgba(255, 255, 255, 0.88);
272
+	line-height: 1.5;
273
+}
274
+
275
+.login-body {
276
+	flex: 1;
277
+	margin-top: 24px;
278
+	padding: 0 40rpx 48rpx;
279
+	box-sizing: border-box;
280
+}
281
+
282
+.login-card {
283
+	display: flex;
284
+	flex-direction: column;
285
+	gap: 28rpx;
286
+	padding: 48rpx 40rpx 44rpx;
287
+	border-radius: 32rpx;
288
+	background: $morandi-bg-card;
289
+	border: 1rpx solid rgba(255, 255, 255, 0.8);
290
+	box-shadow: 0 16rpx 48rpx rgba(74, 69, 66, 0.08);
291
+	box-sizing: border-box;
292
+}
293
+
294
+.login-card__title {
295
+	font-size: 36rpx;
296
+	font-weight: 600;
297
+	color: $morandi-text;
298
+}
299
+
300
+.login-card__sub {
301
+	font-size: 26rpx;
302
+	color: $morandi-text-muted;
303
+	line-height: 1.55;
304
+	margin-top: -12rpx;
305
+	margin-bottom: 8rpx;
306
+}
307
+
308
+.login-field__box {
309
+	display: flex;
310
+	flex-direction: row;
311
+	align-items: center;
312
+	gap: 16rpx;
313
+	padding: 8rpx 24rpx 8rpx 20rpx;
314
+	border-radius: 20rpx;
315
+	background: $morandi-composer;
316
+	border: 1rpx solid $morandi-border-soft;
317
+	box-sizing: border-box;
318
+}
319
+
320
+.login-field__icon {
321
+	flex-shrink: 0;
322
+	display: flex;
323
+	align-items: center;
324
+	justify-content: center;
325
+}
326
+
327
+.login-field__input {
328
+	flex: 1;
329
+	min-width: 0;
330
+	min-height: 72rpx;
331
+	font-size: 30rpx;
332
+	color: $morandi-text;
333
+}
334
+
335
+.login-field__placeholder {
336
+	color: $morandi-text-soft;
337
+	font-size: 28rpx;
338
+}
339
+
340
+.login-captcha {
341
+	display: flex;
342
+	flex-direction: row;
343
+	align-items: center;
344
+	gap: 16rpx;
345
+}
346
+
347
+.login-captcha__input-wrap {
348
+	flex: 1;
349
+	min-width: 0;
350
+}
351
+
352
+.login-captcha__img {
353
+	flex-shrink: 0;
354
+	width: 200rpx;
355
+	height: 72rpx;
356
+	border-radius: 12rpx;
357
+	border: 1rpx solid $morandi-border-soft;
358
+	background: $morandi-bg-card-inner;
359
+}
360
+
361
+.login-remember {
362
+	display: flex;
363
+	flex-direction: row;
364
+	align-items: center;
365
+	gap: 16rpx;
366
+	padding: 4rpx 0;
367
+}
368
+
369
+.login-remember__check {
370
+	flex-shrink: 0;
371
+	width: 36rpx;
372
+	height: 36rpx;
373
+	border-radius: 10rpx;
374
+	border: 2rpx solid $morandi-border-strong;
375
+	background: $morandi-bg-card-inner;
376
+	display: flex;
377
+	align-items: center;
378
+	justify-content: center;
379
+	box-sizing: border-box;
380
+}
381
+
382
+.login-remember__check--on {
383
+	background: #22c55e;
384
+	border-color: #22c55e;
385
+}
386
+
387
+.login-remember__txt {
388
+	font-size: 28rpx;
389
+	color: $morandi-text-secondary;
390
+}
391
+
392
+.login-btn {
393
+	margin-top: 12rpx;
394
+	padding: 28rpx 32rpx;
395
+	border-radius: 48rpx;
396
+	background: linear-gradient(90deg, #16a34a 0%, #22c55e 50%, #4ade80 100%);
397
+	box-shadow: 0 12rpx 32rpx rgba(34, 197, 94, 0.35);
398
+	display: flex;
399
+	align-items: center;
400
+	justify-content: center;
401
+}
402
+
403
+.login-btn--loading {
404
+	opacity: 0.75;
405
+}
406
+
407
+.login-btn__txt {
408
+	font-size: 32rpx;
409
+	font-weight: 600;
410
+	color: #ffffff;
411
+	letter-spacing: 4rpx;
412
+}
413
+
414
+.login-footer {
415
+	display: block;
416
+	margin-top: 40rpx;
417
+	text-align: center;
418
+	font-size: 24rpx;
419
+	color: $morandi-text-soft;
420
+	line-height: 1.5;
421
+}
422
+</style>

+ 84 - 2
shop-app/pages/mine/index.vue

@@ -1,11 +1,61 @@
1 1
 <template>
2 2
 	<view class="page">
3
-		<text class="tip">我的(待开发)</text>
3
+		<view v-if="loggedIn" class="user-box">
4
+			<text class="user-name">{{ displayName }}</text>
5
+			<button class="btn-outline" @click="handleLogout">退出登录</button>
6
+		</view>
7
+		<view v-else class="guest-box">
8
+			<text class="tip">登录后享受完整购物服务</text>
9
+			<button class="btn-primary" @click="goLogin">去登录</button>
10
+		</view>
4 11
 	</view>
5 12
 </template>
6 13
 
7 14
 <script>
8
-	export default {}
15
+import { getToken } from '@/utils/auth'
16
+import { useUserStore } from '@/store/user'
17
+
18
+export default {
19
+	data() {
20
+		return {
21
+			loggedIn: false,
22
+			displayName: ''
23
+		}
24
+	},
25
+	onShow() {
26
+		const userStore = useUserStore()
27
+		this.loggedIn = !!getToken()
28
+		if (this.loggedIn) {
29
+			this.displayName = userStore.displayName() || '会员'
30
+			if (!userStore.state.name && !userStore.state.nickName) {
31
+				userStore.fetchUserInfo().then(() => {
32
+					this.displayName = userStore.displayName() || '会员'
33
+				})
34
+			}
35
+		}
36
+	},
37
+	methods: {
38
+		goLogin() {
39
+			uni.navigateTo({ url: '/pages/login/index' })
40
+		},
41
+		handleLogout() {
42
+			const userStore = useUserStore()
43
+			uni.showModal({
44
+				title: '提示',
45
+				content: '确定退出当前账号吗?',
46
+				success: (res) => {
47
+					if (res.confirm) {
48
+						userStore.logOut().then(() => {
49
+							this.loggedIn = false
50
+							this.displayName = ''
51
+							uni.showToast({ title: '已退出', icon: 'none' })
52
+						})
53
+					}
54
+				}
55
+			})
56
+		}
57
+	}
58
+}
9 59
 </script>
10 60
 
11 61
 <style scoped>
@@ -20,4 +70,36 @@
20 70
 		font-size: 28rpx;
21 71
 		color: #999;
22 72
 	}
73
+	.user-box,
74
+	.guest-box {
75
+		display: flex;
76
+		flex-direction: column;
77
+		align-items: center;
78
+		gap: 32rpx;
79
+	}
80
+	.user-name {
81
+		font-size: 36rpx;
82
+		font-weight: 600;
83
+		color: #333;
84
+	}
85
+	.btn-primary {
86
+		padding: 0 64rpx;
87
+		height: 80rpx;
88
+		line-height: 80rpx;
89
+		background: #2e7d32;
90
+		color: #fff;
91
+		font-size: 30rpx;
92
+		border-radius: 40rpx;
93
+		border: none;
94
+	}
95
+	.btn-outline {
96
+		padding: 0 48rpx;
97
+		height: 72rpx;
98
+		line-height: 72rpx;
99
+		background: #fff;
100
+		color: #2e7d32;
101
+		font-size: 28rpx;
102
+		border-radius: 36rpx;
103
+		border: 1rpx solid #2e7d32;
104
+	}
23 105
 </style>

+ 98 - 9
shop-app/store/user.js

@@ -1,14 +1,103 @@
1
-import { removeToken } from '@/utils/auth'
1
+import { reactive } from 'vue'
2
+import { login as loginApi, logout as logoutApi, getInfo } from '@/api/login'
3
+import { getToken, setToken, removeToken } from '@/utils/auth'
4
+import { joinApiUrl } from '@/config'
5
+
6
+const state = reactive({
7
+  token: getToken(),
8
+  id: '',
9
+  name: '',
10
+  nickName: '',
11
+  avatar: '',
12
+  roles: [],
13
+  permissions: []
14
+})
15
+
16
+function isHttp(url) {
17
+  return /^https?:\/\//i.test(url || '')
18
+}
19
+
20
+function resolveAvatar(path) {
21
+  if (!path) return ''
22
+  if (isHttp(path)) return path
23
+  return joinApiUrl(path)
24
+}
2 25
 
3
-/** 简易用户 store(登录模块完善后可替换为 pinia) */
4 26
 export function useUserStore() {
27
+  const login = (userInfo) => {
28
+    const username = (userInfo.username || '').trim()
29
+    const password = userInfo.password
30
+    const code = userInfo.code
31
+    const uuid = userInfo.uuid
32
+    return loginApi(username, password, code, uuid).then((res) => {
33
+      const token = res.token || (res.data && res.data.token)
34
+      if (!token) {
35
+        return Promise.reject(new Error('登录失败,未返回令牌'))
36
+      }
37
+      setToken(token)
38
+      state.token = token
39
+      return res
40
+    })
41
+  }
42
+
43
+  const fetchUserInfo = () => {
44
+    return getInfo().then((res) => {
45
+      const user = res.user || (res.data && res.data.user) || {}
46
+      const roles = res.roles || (res.data && res.data.roles)
47
+      const permissions = res.permissions || (res.data && res.data.permissions)
48
+      if (roles && roles.length > 0) {
49
+        state.roles = roles
50
+        state.permissions = permissions || []
51
+      } else {
52
+        state.roles = ['ROLE_DEFAULT']
53
+        state.permissions = []
54
+      }
55
+      state.id = user.userId
56
+      state.name = user.userName
57
+      state.nickName = user.nickName
58
+      state.avatar = resolveAvatar(user.avatar)
59
+      return res
60
+    })
61
+  }
62
+
63
+  const logOut = () => {
64
+    return logoutApi()
65
+      .catch(() => {})
66
+      .finally(() => {
67
+        state.token = ''
68
+        state.roles = []
69
+        state.permissions = []
70
+        state.id = ''
71
+        state.name = ''
72
+        state.nickName = ''
73
+        state.avatar = ''
74
+        removeToken()
75
+      })
76
+  }
77
+
78
+  const fedLogOut = () => {
79
+    state.token = ''
80
+    state.roles = []
81
+    state.permissions = []
82
+    state.id = ''
83
+    state.name = ''
84
+    state.nickName = ''
85
+    state.avatar = ''
86
+    removeToken()
87
+    return Promise.resolve()
88
+  }
89
+
90
+  const displayName = () => state.nickName || state.name || ''
91
+
92
+  const isLoggedIn = () => !!state.token
93
+
5 94
   return {
6
-    state: {
7
-      token: ''
8
-    },
9
-    fedLogOut() {
10
-      removeToken()
11
-      return Promise.resolve()
12
-    }
95
+    state,
96
+    login,
97
+    fetchUserInfo,
98
+    logOut,
99
+    fedLogOut,
100
+    displayName,
101
+    isLoggedIn
13 102
   }
14 103
 }

+ 13 - 0
shop-app/styles/morandi.scss

@@ -0,0 +1,13 @@
1
+/**
2
+ * 莫兰迪色系(与 ruoyi-ui-app 登录页视觉对齐)
3
+ */
4
+$morandi-bg-page: #f0ebe5;
5
+$morandi-bg-card: #faf8f6;
6
+$morandi-bg-card-inner: #fdfcfa;
7
+$morandi-border-soft: #e5ded6;
8
+$morandi-border-strong: #ddd4c8;
9
+$morandi-text: #4a4542;
10
+$morandi-text-secondary: #5c5652;
11
+$morandi-text-muted: #9a938c;
12
+$morandi-text-soft: #b5aea6;
13
+$morandi-composer: #f5f2ef;

+ 3 - 2
shop-app/pages/category/goods-list.vue

@@ -20,6 +20,7 @@ import { onLoad } from '@dcloudio/uni-app'
20 20
 import { DEFAULT_SORT_BY } from '@/constants/categorySort'
21 21
 import SearchEntry from '@/components/mall/SearchEntry.vue'
22 22
 import GoodsListBlock from '@/components/category/GoodsListBlock.vue'
23
+import { goGoodsDetail } from '@/utils/goodsDetail'
23 24
 
24 25
 const categoryId = ref('')
25 26
 const level1Name = ref('')
@@ -44,8 +45,8 @@ function calcScrollHeight() {
44 45
 	}
45 46
 }
46 47
 
47
-function onGoodsClick() {
48
-	uni.showToast({ title: '商品详情开发中', icon: 'none' })
48
+function onGoodsClick(item) {
49
+	goGoodsDetail(item.goodsId)
49 50
 }
50 51
 
51 52
 onLoad((options) => {

+ 3 - 2
shop-app/pages/category/level1.vue

@@ -45,6 +45,7 @@ import { mapLevel2Tabs } from '@/utils/categoryDisplay'
45 45
 import { DEFAULT_SORT_BY } from '@/constants/categorySort'
46 46
 import SearchEntry from '@/components/mall/SearchEntry.vue'
47 47
 import GoodsListBlock from '@/components/category/GoodsListBlock.vue'
48
+import { goGoodsDetail } from '@/utils/goodsDetail'
48 49
 
49 50
 const level1Id = ref('')
50 51
 const level1Name = ref('')
@@ -97,8 +98,8 @@ function onTabChange(index) {
97 98
 	scrollTopKey.value += 1
98 99
 }
99 100
 
100
-function onGoodsClick() {
101
-	uni.showToast({ title: '商品详情开发中', icon: 'none' })
101
+function onGoodsClick(item) {
102
+	goGoodsDetail(item.goodsId)
102 103
 }
103 104
 
104 105
 onLoad((options) => {

+ 597 - 0
shop-app/subpackage/goods/detail.vue

@@ -0,0 +1,597 @@
1
+<template>
2
+	<view class="detail-page">
3
+		<view v-if="pageLoading" class="detail-loading">
4
+			<u-loading-icon mode="circle" text="加载中" />
5
+		</view>
6
+		<view v-else-if="loadFailed" class="detail-loading">
7
+			<u-empty mode="data" text="商品不存在或已失效" icon-size="80" />
8
+		</view>
9
+		<template v-else-if="detail">
10
+			<scroll-view class="detail-scroll" scroll-y :style="{ height: scrollHeight }">
11
+				<!-- 图片轮播 -->
12
+				<swiper
13
+					class="pic-swiper"
14
+					:indicator-dots="detail.pics.length > 1"
15
+					indicator-color="rgba(255,255,255,0.5)"
16
+					indicator-active-color="#fff"
17
+					:circular="detail.pics.length > 1"
18
+				>
19
+					<swiper-item v-for="(pic, idx) in detail.pics" :key="idx">
20
+						<image class="pic-swiper__img" :src="pic" mode="aspectFill" @click="previewPics(idx)" />
21
+					</swiper-item>
22
+				</swiper>
23
+
24
+				<!-- 状态提示 -->
25
+				<view v-if="statusTip" class="status-tip">
26
+					<text>{{ statusTip }}</text>
27
+				</view>
28
+
29
+				<!-- 基本信息 -->
30
+				<view class="card price-card">
31
+					<view class="price-row">
32
+						<text class="price-symbol">¥</text>
33
+						<text class="price-num">{{ detail.priceText }}</text>
34
+					</view>
35
+					<text class="goods-name">{{ detail.goodsName }}</text>
36
+					<text v-if="detail.goodsBrief" class="goods-brief">{{ detail.goodsBrief }}</text>
37
+					<view class="meta-row">
38
+						<text v-if="detail.categoryPath" class="meta-item">{{ detail.categoryPath }}</text>
39
+						<text class="meta-item">库存 {{ detail.stock }}</text>
40
+						<text class="meta-item">销量 {{ detail.salesCount != null ? detail.salesCount : '—' }}</text>
41
+					</view>
42
+				</view>
43
+
44
+				<!-- 数量选择(v1 统一规格) -->
45
+				<view class="card cell-row" @click="openQtyPopup">
46
+					<text class="cell-label">数量</text>
47
+					<text class="cell-value">{{ quantity }} 件</text>
48
+					<u-icon name="arrow-right" color="#ccc" size="14" />
49
+				</view>
50
+
51
+				<!-- 商品参数 -->
52
+				<view v-if="detail.attributes.length" class="card section-card">
53
+					<view class="section-title">商品参数</view>
54
+					<view v-for="(attr, i) in detail.attributes" :key="i" class="attr-row">
55
+						<text class="attr-name">{{ attr.itemName }}</text>
56
+						<text class="attr-val">{{ formatAttrValues(attr.values) }}</text>
57
+					</view>
58
+				</view>
59
+
60
+				<!-- 展示规格(只读,v1 不参与 SKU) -->
61
+				<view v-if="detail.specDisplay.length" class="card section-card">
62
+					<view class="section-title">规格信息</view>
63
+					<view v-for="(spec, i) in detail.specDisplay" :key="'s' + i" class="attr-row">
64
+						<text class="attr-name">{{ spec.itemName }}</text>
65
+						<text class="attr-val">{{ formatAttrValues(spec.values) }}</text>
66
+					</view>
67
+				</view>
68
+
69
+				<!-- 物流说明 -->
70
+				<view v-if="hasLogistics" class="card section-card">
71
+					<view class="section-title">物流说明</view>
72
+					<view v-if="detail.logistics.shipPromise" class="info-line">
73
+						<text class="info-label">发货承诺</text>
74
+						<text>{{ detail.logistics.shipPromise }}</text>
75
+					</view>
76
+					<view v-if="detail.logistics.freightDesc" class="info-line">
77
+						<text class="info-label">运费</text>
78
+						<text>{{ detail.logistics.freightDesc }}</text>
79
+					</view>
80
+					<view v-if="detail.logistics.shipCity" class="info-line">
81
+						<text class="info-label">发货地</text>
82
+						<text>{{ detail.logistics.shipCity }}</text>
83
+					</view>
84
+				</view>
85
+
86
+				<!-- 服务说明 -->
87
+				<view v-if="detail.services.length" class="card section-card">
88
+					<view class="section-title">服务说明</view>
89
+					<view v-for="svc in detail.services" :key="svc.serviceId" class="service-item">
90
+						<image v-if="svc.serviceIcon" class="service-icon" :src="svc.serviceIcon" mode="aspectFit" />
91
+						<view class="service-body">
92
+							<text class="service-name">{{ svc.serviceName }}</text>
93
+							<text v-if="svc.serviceIntro" class="service-intro">{{ svc.serviceIntro }}</text>
94
+						</view>
95
+					</view>
96
+				</view>
97
+
98
+				<!-- 评价摘要 -->
99
+				<view class="card section-card">
100
+					<view class="section-head">
101
+						<text class="section-title">商品评价</text>
102
+						<text v-if="reviewTotal > 0" class="section-more" @click="goAllReviews">查看全部 ></text>
103
+					</view>
104
+					<view v-if="reviewLoading" class="review-loading">
105
+						<u-loading-icon mode="circle" size="18" />
106
+					</view>
107
+					<template v-else-if="reviewPreview.length">
108
+						<review-card v-for="r in reviewPreview" :key="r.reviewId" :item="r" />
109
+					</template>
110
+					<text v-else class="empty-text">暂无评价</text>
111
+				</view>
112
+
113
+				<!-- 售后服务 -->
114
+				<view class="card section-card">
115
+					<view class="section-title">售后服务</view>
116
+					<text class="after-sale-text">{{ afterSaleText }}</text>
117
+				</view>
118
+
119
+				<!-- 图文详情 -->
120
+				<view class="card section-card">
121
+					<view class="section-title">图文详情</view>
122
+					<view v-if="detail.detailContent" class="rich-wrap">
123
+						<rich-text :nodes="detail.detailContent" />
124
+					</view>
125
+					<text v-else class="empty-text">暂无详情</text>
126
+				</view>
127
+
128
+				<!-- 店铺入口 -->
129
+				<view class="card shop-card" @click="onEnterShop">
130
+					<image class="shop-avatar" :src="detail.shop.shopAvatar" mode="aspectFill" />
131
+					<view class="shop-info">
132
+						<text class="shop-name">{{ detail.shop.shopName || '—' }}</text>
133
+						<text class="shop-enter">进店逛逛 ></text>
134
+					</view>
135
+				</view>
136
+
137
+				<view class="scroll-bottom-gap" />
138
+			</scroll-view>
139
+
140
+			<detail-bottom-bar
141
+				:disabled="!canOperate"
142
+				@shop="onEnterShop"
143
+				@cart="onAddCart"
144
+				@buy="onBuyNow"
145
+			/>
146
+		</template>
147
+
148
+		<!-- 数量弹层 -->
149
+		<u-popup :show="qtyPopupShow" mode="bottom" round="16" @close="qtyPopupShow = false">
150
+			<view class="qty-popup">
151
+				<view class="qty-popup__head">
152
+					<image class="qty-popup__pic" :src="detail?.pics?.[0]" mode="aspectFill" />
153
+					<view>
154
+						<text class="qty-popup__price">¥ {{ detail?.priceText }}</text>
155
+						<text class="qty-popup__stock">库存 {{ detail?.stock }}</text>
156
+					</view>
157
+				</view>
158
+				<view class="qty-popup__row">
159
+					<text>购买数量</text>
160
+					<u-number-box
161
+						v-model="quantity"
162
+						:min="1"
163
+						:max="maxQuantity"
164
+						integer
165
+					/>
166
+				</view>
167
+				<button class="qty-popup__btn" @click="confirmQty">确定</button>
168
+			</view>
169
+		</u-popup>
170
+	</view>
171
+</template>
172
+
173
+<script setup>
174
+import { ref, computed } from 'vue'
175
+import { onLoad } from '@dcloudio/uni-app'
176
+import { getGoodsDetail, getGoodsReviews } from '@/api/goods'
177
+import {
178
+	mapGoodsDetail,
179
+	mapReviewList,
180
+	buildAfterSaleText
181
+} from '@/utils/goodsDetail'
182
+import { ensureCanPurchase } from '@/utils/purchaseAction'
183
+import { PAGE_GOODS_REVIEWS } from '@/utils/pageRoute'
184
+import ReviewCard from '@/components/goods/ReviewCard.vue'
185
+import DetailBottomBar from '@/components/goods/DetailBottomBar.vue'
186
+
187
+const goodsId = ref('')
188
+const detail = ref(null)
189
+const pageLoading = ref(true)
190
+const loadFailed = ref(false)
191
+const scrollHeight = ref('600px')
192
+const quantity = ref(1)
193
+const qtyPopupShow = ref(false)
194
+const pendingAction = ref('')
195
+
196
+const reviewPreview = ref([])
197
+const reviewTotal = ref(0)
198
+const reviewLoading = ref(false)
199
+
200
+const maxQuantity = computed(() => {
201
+	const s = detail.value?.stock
202
+	return s > 0 ? s : 1
203
+})
204
+
205
+const afterSaleText = computed(() => {
206
+	if (!detail.value) return ''
207
+	return buildAfterSaleText(detail.value.afterSalePhone)
208
+})
209
+
210
+const hasLogistics = computed(() => {
211
+	const l = detail.value?.logistics
212
+	if (!l) return false
213
+	return !!(l.shipPromise || l.freightDesc || l.shipCity)
214
+})
215
+
216
+const statusTip = computed(() => {
217
+	if (!detail.value) return ''
218
+	if (detail.value.isOffShelf) return '商品已下架'
219
+	if (!detail.value.isOnSale) return detail.value.goodsStatusLabel || '商品不可购买'
220
+	if (!detail.value.purchase.allowed && detail.value.purchase.reason) {
221
+		return detail.value.purchase.reason
222
+	}
223
+	if (detail.value.stock <= 0) return '当前商品库存不足'
224
+	return ''
225
+})
226
+
227
+const canOperate = computed(() => {
228
+	if (!detail.value) return false
229
+	if (!detail.value.isOnSale || detail.value.isOffShelf) return false
230
+	if (detail.value.stock <= 0) return false
231
+	if (!detail.value.purchase.allowed) return false
232
+	return true
233
+})
234
+
235
+function calcScrollHeight() {
236
+	try {
237
+		const sys = uni.getSystemInfoSync()
238
+		const h = sys.windowHeight || 600
239
+		scrollHeight.value = `${h - 56}px`
240
+	} catch (e) {
241
+		scrollHeight.value = '600px'
242
+	}
243
+}
244
+
245
+function formatAttrValues(values) {
246
+	if (!values || !values.length) return '—'
247
+	return values.join('、')
248
+}
249
+
250
+async function loadDetail() {
251
+	pageLoading.value = true
252
+	loadFailed.value = false
253
+	try {
254
+		const res = await getGoodsDetail(goodsId.value)
255
+		detail.value = mapGoodsDetail(res.data)
256
+		if (!detail.value) {
257
+			loadFailed.value = true
258
+		}
259
+	} catch (e) {
260
+		loadFailed.value = true
261
+		detail.value = null
262
+	} finally {
263
+		pageLoading.value = false
264
+	}
265
+}
266
+
267
+async function loadReviewPreview() {
268
+	reviewLoading.value = true
269
+	try {
270
+		const res = await getGoodsReviews(goodsId.value, { pageNum: 1, pageSize: 2 })
271
+		reviewPreview.value = mapReviewList(res.rows || [])
272
+		reviewTotal.value = Number(res.total) || reviewPreview.value.length
273
+	} catch (e) {
274
+		reviewPreview.value = []
275
+		reviewTotal.value = 0
276
+	} finally {
277
+		reviewLoading.value = false
278
+	}
279
+}
280
+
281
+function previewPics(index) {
282
+	const urls = detail.value?.pics || []
283
+	uni.previewImage({ urls, current: urls[index] })
284
+}
285
+
286
+function openQtyPopup() {
287
+	if (!detail.value) return
288
+	qtyPopupShow.value = true
289
+}
290
+
291
+function confirmQty() {
292
+	qtyPopupShow.value = false
293
+	const action = pendingAction.value
294
+	pendingAction.value = ''
295
+	if (action === 'cart') {
296
+		doAddCart()
297
+	} else if (action === 'buy') {
298
+		doBuyNow()
299
+	}
300
+}
301
+
302
+async function onAddCart() {
303
+	if (!canOperate.value) {
304
+		showBlockReason()
305
+		return
306
+	}
307
+	pendingAction.value = 'cart'
308
+	if (quantity.value > 1) {
309
+		openQtyPopup()
310
+		return
311
+	}
312
+	await doAddCart()
313
+}
314
+
315
+async function doAddCart() {
316
+	const ok = await ensureCanPurchase(goodsId.value, detail.value?.purchase?.reason)
317
+	if (!ok) return
318
+	uni.showToast({ title: '购物车功能开发中', icon: 'none' })
319
+}
320
+
321
+async function onBuyNow() {
322
+	if (!canOperate.value) {
323
+		showBlockReason()
324
+		return
325
+	}
326
+	pendingAction.value = 'buy'
327
+	openQtyPopup()
328
+}
329
+
330
+async function doBuyNow() {
331
+	const ok = await ensureCanPurchase(goodsId.value, detail.value?.purchase?.reason)
332
+	if (!ok) return
333
+	uni.showToast({ title: '确认订单功能开发中', icon: 'none' })
334
+}
335
+
336
+function showBlockReason() {
337
+	const msg =
338
+		statusTip.value ||
339
+		detail.value?.purchase?.reason ||
340
+		'暂不可购买'
341
+	uni.showToast({ title: msg, icon: 'none' })
342
+}
343
+
344
+function onEnterShop() {
345
+	uni.showToast({ title: '店铺主页开发中', icon: 'none' })
346
+}
347
+
348
+function goAllReviews() {
349
+	uni.navigateTo({
350
+		url: `${PAGE_GOODS_REVIEWS}?goodsId=${goodsId.value}`
351
+	})
352
+}
353
+
354
+onLoad((options) => {
355
+	goodsId.value = options.goodsId || ''
356
+	calcScrollHeight()
357
+	if (!goodsId.value) {
358
+		loadFailed.value = true
359
+		pageLoading.value = false
360
+		return
361
+	}
362
+	loadDetail().then(() => loadReviewPreview())
363
+})
364
+</script>
365
+
366
+<style lang="scss" scoped>
367
+.detail-page {
368
+	min-height: 100vh;
369
+	background: #f5f6f8;
370
+}
371
+.detail-loading {
372
+	min-height: 60vh;
373
+	display: flex;
374
+	align-items: center;
375
+	justify-content: center;
376
+}
377
+.pic-swiper {
378
+	height: 750rpx;
379
+	background: #fff;
380
+}
381
+.pic-swiper__img {
382
+	width: 100%;
383
+	height: 750rpx;
384
+}
385
+.status-tip {
386
+	padding: 16rpx 24rpx;
387
+	background: #fff3e0;
388
+	color: #e65100;
389
+	font-size: 26rpx;
390
+}
391
+.card {
392
+	margin: 16rpx 24rpx;
393
+	padding: 24rpx;
394
+	background: #fff;
395
+	border-radius: 16rpx;
396
+}
397
+.price-card .price-row {
398
+	color: #e53935;
399
+	font-weight: 700;
400
+}
401
+.price-symbol {
402
+	font-size: 28rpx;
403
+}
404
+.price-num {
405
+	font-size: 48rpx;
406
+}
407
+.goods-name {
408
+	display: block;
409
+	margin-top: 16rpx;
410
+	font-size: 32rpx;
411
+	font-weight: 600;
412
+	color: #222;
413
+	line-height: 1.4;
414
+}
415
+.goods-brief {
416
+	display: block;
417
+	margin-top: 12rpx;
418
+	font-size: 26rpx;
419
+	color: #888;
420
+}
421
+.meta-row {
422
+	display: flex;
423
+	flex-wrap: wrap;
424
+	margin-top: 16rpx;
425
+	gap: 16rpx;
426
+}
427
+.meta-item {
428
+	font-size: 24rpx;
429
+	color: #999;
430
+}
431
+.cell-row {
432
+	display: flex;
433
+	align-items: center;
434
+}
435
+.cell-label {
436
+	font-size: 28rpx;
437
+	color: #333;
438
+}
439
+.cell-value {
440
+	flex: 1;
441
+	text-align: right;
442
+	margin-right: 8rpx;
443
+	font-size: 28rpx;
444
+	color: #666;
445
+}
446
+.section-title {
447
+	font-size: 30rpx;
448
+	font-weight: 600;
449
+	color: #222;
450
+	margin-bottom: 16rpx;
451
+}
452
+.section-head {
453
+	display: flex;
454
+	align-items: center;
455
+	justify-content: space-between;
456
+	margin-bottom: 8rpx;
457
+}
458
+.section-more {
459
+	font-size: 24rpx;
460
+	color: #2e7d32;
461
+}
462
+.attr-row {
463
+	display: flex;
464
+	padding: 12rpx 0;
465
+	font-size: 26rpx;
466
+	border-bottom: 1rpx solid #f5f5f5;
467
+}
468
+.attr-name {
469
+	width: 180rpx;
470
+	color: #999;
471
+	flex-shrink: 0;
472
+}
473
+.attr-val {
474
+	flex: 1;
475
+	color: #333;
476
+}
477
+.info-line {
478
+	display: flex;
479
+	padding: 10rpx 0;
480
+	font-size: 26rpx;
481
+	color: #444;
482
+}
483
+.info-label {
484
+	width: 140rpx;
485
+	color: #999;
486
+	flex-shrink: 0;
487
+}
488
+.service-item {
489
+	display: flex;
490
+	padding: 16rpx 0;
491
+	border-bottom: 1rpx solid #f5f5f5;
492
+}
493
+.service-icon {
494
+	width: 48rpx;
495
+	height: 48rpx;
496
+	margin-right: 16rpx;
497
+}
498
+.service-name {
499
+	font-size: 28rpx;
500
+	color: #333;
501
+}
502
+.service-intro {
503
+	display: block;
504
+	margin-top: 6rpx;
505
+	font-size: 24rpx;
506
+	color: #999;
507
+}
508
+.after-sale-text {
509
+	font-size: 26rpx;
510
+	color: #666;
511
+	line-height: 1.6;
512
+}
513
+.rich-wrap {
514
+	font-size: 28rpx;
515
+	color: #444;
516
+	overflow: hidden;
517
+}
518
+.empty-text {
519
+	font-size: 26rpx;
520
+	color: #999;
521
+}
522
+.shop-card {
523
+	display: flex;
524
+	align-items: center;
525
+}
526
+.shop-avatar {
527
+	width: 88rpx;
528
+	height: 88rpx;
529
+	border-radius: 50%;
530
+	background: #eee;
531
+}
532
+.shop-info {
533
+	margin-left: 20rpx;
534
+}
535
+.shop-name {
536
+	font-size: 30rpx;
537
+	font-weight: 600;
538
+	color: #333;
539
+}
540
+.shop-enter {
541
+	display: block;
542
+	margin-top: 8rpx;
543
+	font-size: 24rpx;
544
+	color: #2e7d32;
545
+}
546
+.review-loading {
547
+	padding: 24rpx 0;
548
+	display: flex;
549
+	justify-content: center;
550
+}
551
+.scroll-bottom-gap {
552
+	height: 140rpx;
553
+}
554
+.qty-popup {
555
+	padding: 32rpx 24rpx 48rpx;
556
+}
557
+.qty-popup__head {
558
+	display: flex;
559
+	align-items: center;
560
+	margin-bottom: 32rpx;
561
+}
562
+.qty-popup__pic {
563
+	width: 160rpx;
564
+	height: 160rpx;
565
+	border-radius: 12rpx;
566
+	margin-right: 20rpx;
567
+	background: #f5f5f5;
568
+}
569
+.qty-popup__price {
570
+	display: block;
571
+	font-size: 36rpx;
572
+	color: #e53935;
573
+	font-weight: 600;
574
+}
575
+.qty-popup__stock {
576
+	display: block;
577
+	margin-top: 8rpx;
578
+	font-size: 24rpx;
579
+	color: #999;
580
+}
581
+.qty-popup__row {
582
+	display: flex;
583
+	align-items: center;
584
+	justify-content: space-between;
585
+	margin-bottom: 32rpx;
586
+	font-size: 28rpx;
587
+}
588
+.qty-popup__btn {
589
+	height: 88rpx;
590
+	line-height: 88rpx;
591
+	background: #2e7d32;
592
+	color: #fff;
593
+	font-size: 30rpx;
594
+	border-radius: 44rpx;
595
+	border: none;
596
+}
597
+</style>

+ 123 - 0
shop-app/subpackage/goods/reviews.vue

@@ -0,0 +1,123 @@
1
+<template>
2
+	<view class="reviews-page">
3
+		<scroll-view
4
+			class="reviews-scroll"
5
+			scroll-y
6
+			:style="{ height: scrollHeight }"
7
+			@scrolltolower="onLoadMore"
8
+		>
9
+			<view v-if="loading && !list.length" class="reviews-loading">
10
+				<u-loading-icon mode="circle" text="加载中" />
11
+			</view>
12
+			<view v-else-if="list.length" class="reviews-list">
13
+				<review-card v-for="item in list" :key="item.reviewId" :item="item" />
14
+			</view>
15
+			<view v-else class="reviews-empty">
16
+				<u-empty mode="list" text="暂无评价" icon-size="80" />
17
+			</view>
18
+			<view v-if="list.length" class="reviews-footer">
19
+				<text v-if="loadingMore">加载中...</text>
20
+				<text v-else-if="finished">没有更多了</text>
21
+			</view>
22
+		</scroll-view>
23
+	</view>
24
+</template>
25
+
26
+<script setup>
27
+import { ref } from 'vue'
28
+import { onLoad } from '@dcloudio/uni-app'
29
+import { getGoodsReviews } from '@/api/goods'
30
+import { mapReviewList } from '@/utils/goodsDetail'
31
+import ReviewCard from '@/components/goods/ReviewCard.vue'
32
+
33
+const goodsId = ref('')
34
+const list = ref([])
35
+const loading = ref(false)
36
+const loadingMore = ref(false)
37
+const finished = ref(false)
38
+const pageNum = ref(1)
39
+const pageSize = 10
40
+const scrollHeight = ref('600px')
41
+
42
+function calcScrollHeight() {
43
+	try {
44
+		const sys = uni.getSystemInfoSync()
45
+		scrollHeight.value = `${sys.windowHeight || 600}px`
46
+	} catch (e) {
47
+		scrollHeight.value = '600px'
48
+	}
49
+}
50
+
51
+async function fetchPage(isReset) {
52
+	if (!goodsId.value) return
53
+	if (isReset) {
54
+		loading.value = true
55
+		pageNum.value = 1
56
+		finished.value = false
57
+	} else {
58
+		if (finished.value || loadingMore.value) return
59
+		loadingMore.value = true
60
+	}
61
+	try {
62
+		const res = await getGoodsReviews(goodsId.value, {
63
+			pageNum: pageNum.value,
64
+			pageSize
65
+		})
66
+		const rows = mapReviewList(res.rows || [])
67
+		const total = Number(res.total) || 0
68
+		if (isReset) {
69
+			list.value = rows
70
+		} else {
71
+			list.value = list.value.concat(rows)
72
+		}
73
+		finished.value =
74
+			rows.length < pageSize || (total > 0 && list.value.length >= total)
75
+		if (!finished.value) {
76
+			pageNum.value += 1
77
+		}
78
+	} catch (e) {
79
+		if (isReset) list.value = []
80
+	} finally {
81
+		loading.value = false
82
+		loadingMore.value = false
83
+	}
84
+}
85
+
86
+function onLoadMore() {
87
+	if (!loading.value && !loadingMore.value && !finished.value) {
88
+		fetchPage(false)
89
+	}
90
+}
91
+
92
+onLoad((options) => {
93
+	goodsId.value = options.goodsId || ''
94
+	calcScrollHeight()
95
+	fetchPage(true)
96
+})
97
+</script>
98
+
99
+<style lang="scss" scoped>
100
+.reviews-page {
101
+	min-height: 100vh;
102
+	background: #fff;
103
+}
104
+.reviews-scroll {
105
+	box-sizing: border-box;
106
+}
107
+.reviews-list {
108
+	padding: 0 24rpx;
109
+}
110
+.reviews-loading,
111
+.reviews-empty {
112
+	min-height: 50vh;
113
+	display: flex;
114
+	align-items: center;
115
+	justify-content: center;
116
+}
117
+.reviews-footer {
118
+	padding: 24rpx;
119
+	text-align: center;
120
+	font-size: 24rpx;
121
+	color: #999;
122
+}
123
+</style>

+ 198 - 0
shop-app/subpackage/search/index.vue

@@ -0,0 +1,198 @@
1
+<template>
2
+	<view class="page-search">
3
+		<view class="search-header">
4
+			<view class="search-header__bar">
5
+				<u-icon name="arrow-left" size="20" color="#333" @click="onBack" />
6
+				<view class="search-header__input-wrap">
7
+					<u-icon name="search" color="#999" size="18" />
8
+					<input
9
+						class="search-header__input"
10
+						v-model="keyword"
11
+						:placeholder="placeholder"
12
+						confirm-type="search"
13
+						:focus="inputFocus"
14
+						@confirm="onSubmit"
15
+					/>
16
+					<u-icon
17
+						v-if="keyword"
18
+						name="close-circle-fill"
19
+						color="#ccc"
20
+						size="18"
21
+						@click="keyword = ''"
22
+					/>
23
+				</view>
24
+				<text class="search-header__btn" @click="onSubmit">搜索</text>
25
+			</view>
26
+		</view>
27
+
28
+		<view v-if="historyList.length" class="history">
29
+			<view class="history__head">
30
+				<text class="history__title">搜索历史</text>
31
+				<text class="history__clear" @click="onClearAll">清空</text>
32
+			</view>
33
+			<view class="history__list">
34
+				<view v-for="item in historyList" :key="item.keyword" class="history__item">
35
+					<text class="history__text" @click="onHistoryTap(item.keyword)">{{ item.keyword }}</text>
36
+					<u-icon
37
+						name="close"
38
+						color="#bbb"
39
+						size="14"
40
+						@click.stop="onRemoveOne(item.keyword)"
41
+					/>
42
+				</view>
43
+			</view>
44
+		</view>
45
+		<view v-else class="history-empty">
46
+			<text>暂无搜索历史</text>
47
+		</view>
48
+	</view>
49
+</template>
50
+
51
+<script setup>
52
+import { ref } from 'vue'
53
+import { onLoad, onShow } from '@dcloudio/uni-app'
54
+import { SEARCH_PLACEHOLDER } from '@/constants/search'
55
+import {
56
+  getSearchHistory,
57
+  addSearchHistory,
58
+  removeSearchHistoryItem,
59
+  clearSearchHistory
60
+} from '@/utils/searchHistory'
61
+import { goSearchResult } from '@/utils/searchNav'
62
+
63
+const placeholder = SEARCH_PLACEHOLDER
64
+const keyword = ref('')
65
+const historyList = ref([])
66
+const inputFocus = ref(false)
67
+
68
+function refreshHistory() {
69
+  historyList.value = getSearchHistory()
70
+}
71
+
72
+function onBack() {
73
+  uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
74
+}
75
+
76
+function submitSearch() {
77
+  const text = (keyword.value || '').trim()
78
+  if (!text) {
79
+    uni.showToast({ title: '请输入搜索内容', icon: 'none' })
80
+    return
81
+  }
82
+  addSearchHistory(text)
83
+  goSearchResult(text)
84
+}
85
+
86
+function onSubmit() {
87
+  submitSearch()
88
+}
89
+
90
+function onHistoryTap(text) {
91
+  keyword.value = text
92
+  submitSearch()
93
+}
94
+
95
+function onRemoveOne(text) {
96
+  removeSearchHistoryItem(text)
97
+  refreshHistory()
98
+}
99
+
100
+function onClearAll() {
101
+  uni.showModal({
102
+    title: '提示',
103
+    content: '确定清空全部搜索历史吗?',
104
+    success: (res) => {
105
+      if (res.confirm) {
106
+        clearSearchHistory()
107
+        refreshHistory()
108
+      }
109
+    }
110
+  })
111
+}
112
+
113
+onLoad((options) => {
114
+  if (options && options.keyword) {
115
+    keyword.value = decodeURIComponent(options.keyword)
116
+  }
117
+  inputFocus.value = !keyword.value
118
+})
119
+
120
+onShow(() => {
121
+  refreshHistory()
122
+})
123
+</script>
124
+
125
+<style lang="scss" scoped>
126
+.page-search {
127
+	height: calc(100vh - 100rpx);
128
+	background: #f5f6f8;
129
+}
130
+.search-header {
131
+	background: #fff;
132
+	padding: 16rpx 24rpx 24rpx;
133
+}
134
+.search-header__bar {
135
+	display: flex;
136
+	align-items: center;
137
+}
138
+.search-header__input-wrap {
139
+	flex: 1;
140
+	display: flex;
141
+	align-items: center;
142
+	height: 72rpx;
143
+	margin: 0 16rpx;
144
+	padding: 0 20rpx;
145
+	background: #f5f6f8;
146
+	border-radius: 36rpx;
147
+}
148
+.search-header__input {
149
+	flex: 1;
150
+	margin: 0 12rpx;
151
+	font-size: 28rpx;
152
+	color: #333;
153
+}
154
+.search-header__btn {
155
+	font-size: 28rpx;
156
+	color: #2e7d32;
157
+	font-weight: 500;
158
+	flex-shrink: 0;
159
+}
160
+.history {
161
+	margin-top: 16rpx;
162
+	padding: 24rpx;
163
+	background: #fff;
164
+}
165
+.history__head {
166
+	display: flex;
167
+	justify-content: space-between;
168
+	align-items: center;
169
+	margin-bottom: 20rpx;
170
+}
171
+.history__title {
172
+	font-size: 28rpx;
173
+	color: #333;
174
+	font-weight: 600;
175
+}
176
+.history__clear {
177
+	font-size: 26rpx;
178
+	color: #999;
179
+}
180
+.history__item {
181
+	display: flex;
182
+	align-items: center;
183
+	justify-content: space-between;
184
+	padding: 20rpx 0;
185
+	border-bottom: 1rpx solid #f5f5f5;
186
+}
187
+.history__text {
188
+	flex: 1;
189
+	font-size: 28rpx;
190
+	color: #666;
191
+}
192
+.history-empty {
193
+	padding: 80rpx 24rpx;
194
+	text-align: center;
195
+	font-size: 26rpx;
196
+	color: #bbb;
197
+}
198
+</style>

+ 332 - 0
shop-app/subpackage/search/result.vue

@@ -0,0 +1,332 @@
1
+<template>
2
+	<view class="page-result">
3
+		<view class="result-header" @click="onReSearch">
4
+			<u-icon name="arrow-left" size="20" color="#333" @click.stop="onBack" />
5
+			<view class="result-header__keyword">
6
+				<u-icon name="search" color="#999" size="16" />
7
+				<text class="result-header__text">{{ keyword }}</text>
8
+			</view>
9
+		</view>
10
+
11
+		<search-result-tabs
12
+			:model-value="activeTab"
13
+			:price-order="priceOrder"
14
+			@update:model-value="activeTab = $event"
15
+			@update:price-order="priceOrder = $event"
16
+			@change="onTabChange"
17
+			@price-change="onPriceOrderChange"
18
+		/>
19
+
20
+		<scroll-view
21
+			class="result-scroll"
22
+			scroll-y
23
+			:style="{ height: scrollHeight }"
24
+			:scroll-top="listScrollTop"
25
+			@scrolltolower="onLoadMore"
26
+		>
27
+			<view v-if="loading && !displayList.length" class="result-loading">
28
+				<u-loading-icon mode="circle" />
29
+			</view>
30
+
31
+			<template v-else-if="isShopTab">
32
+				<shop-list v-if="shopList.length" :list="shopList" @item-click="onShopClick" />
33
+				<view v-else class="result-empty">
34
+					<u-empty mode="search" :text="emptyText" icon-size="80" />
35
+					<text v-if="loadFailed" class="result-retry" @click="reloadAll">点击重试</text>
36
+				</view>
37
+			</template>
38
+
39
+			<template v-else>
40
+				<goods-grid v-if="goodsList.length" :list="goodsList" @item-click="onGoodsClick" />
41
+				<view v-else class="result-empty">
42
+					<u-empty mode="search" :text="emptyText" icon-size="80" />
43
+					<text v-if="loadFailed" class="result-retry" @click="reloadAll">点击重试</text>
44
+				</view>
45
+			</template>
46
+
47
+			<view v-if="displayList.length" class="result-footer">
48
+				<text v-if="loadingMore">加载中...</text>
49
+				<text v-else-if="finished">没有更多了</text>
50
+			</view>
51
+		</scroll-view>
52
+	</view>
53
+</template>
54
+
55
+<script setup>
56
+import { ref, computed } from 'vue'
57
+import { onLoad } from '@dcloudio/uni-app'
58
+import { searchAll } from '@/api/search'
59
+import { mapGoodsCardList } from '@/utils/goodsDisplay'
60
+import { mapShopCardList } from '@/utils/shopDisplay'
61
+import { addSearchHistory } from '@/utils/searchHistory'
62
+import { goSearchInput } from '@/utils/searchNav'
63
+import { goGoodsDetail } from '@/utils/goodsDetail'
64
+import {
65
+  SEARCH_TAB_SALES,
66
+  SEARCH_TAB_PRICE,
67
+  SEARCH_TAB_SHOP,
68
+  DEFAULT_GOODS_SORT,
69
+  PRICE_ORDER_ASC,
70
+  SEARCH_PAGE_SIZE
71
+} from '@/constants/search'
72
+import SearchResultTabs from '@/components/search/SearchResultTabs.vue'
73
+import GoodsGrid from '@/components/mall/GoodsGrid.vue'
74
+import ShopList from '@/components/search/ShopList.vue'
75
+
76
+const keyword = ref('')
77
+const activeTab = ref(SEARCH_TAB_SALES)
78
+const priceOrder = ref(PRICE_ORDER_ASC)
79
+
80
+const goodsList = ref([])
81
+const shopList = ref([])
82
+const goodsTotal = ref(0)
83
+const shopTotal = ref(0)
84
+const goodsPageNum = ref(1)
85
+const shopPageNum = ref(1)
86
+
87
+const loading = ref(false)
88
+const loadingMore = ref(false)
89
+const finished = ref(false)
90
+const loadFailed = ref(false)
91
+const listScrollTop = ref(0)
92
+const scrollHeight = ref('600px')
93
+
94
+const emptyText = '暂无相关'
95
+
96
+const isShopTab = computed(() => activeTab.value === SEARCH_TAB_SHOP)
97
+
98
+const displayList = computed(() => (isShopTab.value ? shopList.value : goodsList.value))
99
+
100
+const goodsSortBy = computed(() => {
101
+  if (activeTab.value === SEARCH_TAB_SALES) {
102
+    return DEFAULT_GOODS_SORT
103
+  }
104
+  if (activeTab.value === SEARCH_TAB_PRICE) {
105
+    return priceOrder.value
106
+  }
107
+  return DEFAULT_GOODS_SORT
108
+})
109
+
110
+function calcScrollHeight() {
111
+  try {
112
+    const sys = uni.getSystemInfoSync()
113
+    const h = sys.windowHeight || 600
114
+    scrollHeight.value = `${h - 120}px`
115
+  } catch (e) {
116
+    scrollHeight.value = '500px'
117
+  }
118
+}
119
+
120
+function resetScrollTop() {
121
+  listScrollTop.value = listScrollTop.value === 0 ? 0.1 : 0
122
+}
123
+
124
+async function fetchSearch(reset) {
125
+  const kw = (keyword.value || '').trim()
126
+  if (!kw) return
127
+
128
+  if (reset) {
129
+    loading.value = true
130
+    loadFailed.value = false
131
+    finished.value = false
132
+    if (isShopTab.value) {
133
+      shopPageNum.value = 1
134
+    } else {
135
+      goodsPageNum.value = 1
136
+    }
137
+  } else {
138
+    if (finished.value || loadingMore.value) return
139
+    loadingMore.value = true
140
+  }
141
+
142
+  try {
143
+    const params = {
144
+      keyword: kw,
145
+      sortBy: goodsSortBy.value,
146
+      goodsPageNum: goodsPageNum.value,
147
+      goodsPageSize: SEARCH_PAGE_SIZE,
148
+      shopPageNum: shopPageNum.value,
149
+      shopPageSize: SEARCH_PAGE_SIZE
150
+    }
151
+    const data = await searchAll(params)
152
+    const goodsRows = mapGoodsCardList((data.goods && data.goods.rows) || [])
153
+    const shopRows = mapShopCardList((data.shops && data.shops.rows) || [])
154
+    const gTotal = Number((data.goods && data.goods.total) || 0)
155
+    const sTotal = Number((data.shops && data.shops.total) || 0)
156
+
157
+    if (isShopTab.value) {
158
+      if (reset) {
159
+        shopList.value = shopRows
160
+      } else {
161
+        shopList.value = shopList.value.concat(shopRows)
162
+      }
163
+      shopTotal.value = sTotal
164
+      finished.value =
165
+        shopRows.length < SEARCH_PAGE_SIZE || (sTotal > 0 && shopList.value.length >= sTotal)
166
+      if (!finished.value) {
167
+        shopPageNum.value += 1
168
+      }
169
+    } else {
170
+      if (reset) {
171
+        goodsList.value = goodsRows
172
+      } else {
173
+        goodsList.value = goodsList.value.concat(goodsRows)
174
+      }
175
+      goodsTotal.value = gTotal
176
+      finished.value =
177
+        goodsRows.length < SEARCH_PAGE_SIZE || (gTotal > 0 && goodsList.value.length >= gTotal)
178
+      if (!finished.value) {
179
+        goodsPageNum.value += 1
180
+      }
181
+      // 同一次请求顺带更新店铺缓存,切「店铺」Tab 可直接展示
182
+      if (reset && shopPageNum.value === 1) {
183
+        shopList.value = shopRows
184
+        shopTotal.value = sTotal
185
+        if (shopRows.length > 0) {
186
+          shopPageNum.value = 2
187
+        }
188
+      }
189
+    }
190
+  } catch (e) {
191
+    loadFailed.value = true
192
+    if (reset) {
193
+      if (isShopTab.value) {
194
+        shopList.value = []
195
+      } else {
196
+        goodsList.value = []
197
+      }
198
+    }
199
+  } finally {
200
+    loading.value = false
201
+    loadingMore.value = false
202
+  }
203
+}
204
+
205
+function reloadAll() {
206
+  resetScrollTop()
207
+  if (isShopTab.value) {
208
+    shopPageNum.value = 1
209
+  } else {
210
+    goodsPageNum.value = 1
211
+  }
212
+  fetchSearch(true)
213
+}
214
+
215
+function onTabChange() {
216
+  resetScrollTop()
217
+  finished.value = false
218
+  if (isShopTab.value) {
219
+    if (shopList.value.length > 0) {
220
+      finished.value = shopTotal.value > 0 && shopList.value.length >= shopTotal.value
221
+      return
222
+    }
223
+    shopPageNum.value = 1
224
+    fetchSearch(true)
225
+  } else {
226
+    goodsPageNum.value = 1
227
+    fetchSearch(true)
228
+  }
229
+}
230
+
231
+function onPriceOrderChange() {
232
+  if (activeTab.value !== SEARCH_TAB_PRICE) return
233
+  resetScrollTop()
234
+  goodsPageNum.value = 1
235
+  fetchSearch(true)
236
+}
237
+
238
+function onLoadMore() {
239
+  if (!loading.value && !loadingMore.value && !finished.value) {
240
+    fetchSearch(false)
241
+  }
242
+}
243
+
244
+function onBack() {
245
+  uni.navigateBack()
246
+}
247
+
248
+function onReSearch() {
249
+  goSearchInput(keyword.value)
250
+}
251
+
252
+function onGoodsClick(item) {
253
+  if (item && item.goodsId) {
254
+    goGoodsDetail(item.goodsId)
255
+  }
256
+}
257
+
258
+function onShopClick() {
259
+  uni.showToast({ title: '店铺主页开发中', icon: 'none' })
260
+}
261
+
262
+onLoad((options) => {
263
+  calcScrollHeight()
264
+  const kw = options && options.keyword ? decodeURIComponent(options.keyword) : ''
265
+  keyword.value = (kw || '').trim()
266
+  if (!keyword.value) {
267
+    uni.showToast({ title: '请输入搜索内容', icon: 'none' })
268
+    setTimeout(() => uni.navigateBack(), 500)
269
+    return
270
+  }
271
+  addSearchHistory(keyword.value)
272
+  fetchSearch(true)
273
+})
274
+</script>
275
+
276
+<style lang="scss" scoped>
277
+.page-result {
278
+	display: flex;
279
+	flex-direction: column;
280
+	min-height: 100vh;
281
+	background: #f5f6f8;
282
+}
283
+.result-header {
284
+	display: flex;
285
+	align-items: center;
286
+	padding: 16rpx 24rpx;
287
+	background: #fff;
288
+}
289
+.result-header__keyword {
290
+	flex: 1;
291
+	display: flex;
292
+	align-items: center;
293
+	height: 64rpx;
294
+	margin-left: 16rpx;
295
+	padding: 0 20rpx;
296
+	background: #f5f6f8;
297
+	border-radius: 32rpx;
298
+}
299
+.result-header__text {
300
+	margin-left: 12rpx;
301
+	font-size: 28rpx;
302
+	color: #333;
303
+	overflow: hidden;
304
+	text-overflow: ellipsis;
305
+	white-space: nowrap;
306
+}
307
+.result-scroll {
308
+	flex: 1;
309
+}
310
+.result-loading {
311
+	padding: 80rpx 0;
312
+	display: flex;
313
+	justify-content: center;
314
+}
315
+.result-empty {
316
+	padding: 60rpx 0;
317
+	display: flex;
318
+	flex-direction: column;
319
+	align-items: center;
320
+}
321
+.result-retry {
322
+	margin-top: 24rpx;
323
+	font-size: 28rpx;
324
+	color: #2e7d32;
325
+}
326
+.result-footer {
327
+	padding: 24rpx;
328
+	text-align: center;
329
+	font-size: 24rpx;
330
+	color: #999;
331
+}
332
+</style>

+ 112 - 0
shop-app/utils/goodsDetail.js

@@ -0,0 +1,112 @@
1
+import { resolveFileUrl } from '@/utils/image'
2
+import { formatPrice } from '@/utils/format'
3
+import { PAGE_GOODS_DETAIL } from '@/utils/pageRoute'
4
+
5
+const GOODS_PLACEHOLDER = '/static/logo.png'
6
+const SHOP_AVATAR_PLACEHOLDER = '/static/logo.png'
7
+
8
+/** 跳转商品详情 */
9
+export function goGoodsDetail(goodsId) {
10
+  if (!goodsId) return
11
+  uni.navigateTo({
12
+    url: `${PAGE_GOODS_DETAIL}?goodsId=${goodsId}`
13
+  })
14
+}
15
+
16
+export function mapDetailPics(pics) {
17
+  if (!Array.isArray(pics) || !pics.length) {
18
+    return [GOODS_PLACEHOLDER]
19
+  }
20
+  return pics.map((p) => resolveFileUrl(p) || GOODS_PLACEHOLDER).filter(Boolean)
21
+}
22
+
23
+/** 详情接口 data → 页面模型 */
24
+export function mapGoodsDetail(data) {
25
+  if (!data) return null
26
+  const logistics = data.logistics || {}
27
+  const shop = data.shop || {}
28
+  const purchase = data.purchase || {}
29
+  return {
30
+    goodsId: data.goodsId,
31
+    goodsSn: data.goodsSn,
32
+    goodsName: data.goodsName,
33
+    goodsBrief: data.goodsBrief,
34
+    pics: mapDetailPics(data.pics),
35
+    salePrice: data.salePrice,
36
+    priceText: formatPrice(data.salePrice),
37
+    stock: Number(data.stock) || 0,
38
+    salesCount: data.salesCount,
39
+    goodsStatus: data.goodsStatus,
40
+    goodsStatusLabel: data.goodsStatusLabel,
41
+    categoryPath: data.categoryPath,
42
+    detailContent: data.detailContent || '',
43
+    attributes: data.attributes || [],
44
+    specDisplay: data.specDisplay || [],
45
+    services: (data.services || []).map(mapService),
46
+    logistics: {
47
+      shipPromise: logistics.shipPromise,
48
+      freightDesc: logistics.freightDesc,
49
+      shipCity: logistics.shipCity
50
+    },
51
+    shop: {
52
+      shopId: shop.shopId,
53
+      shopName: shop.shopName,
54
+      shopAvatar: resolveFileUrl(shop.shopAvatar) || SHOP_AVATAR_PLACEHOLDER,
55
+      shopStatus: shop.shopStatus,
56
+      shopPhone: shop.shopPhone,
57
+      rating: shop.rating,
58
+      fansCount: shop.fansCount
59
+    },
60
+    purchase: {
61
+      allowed: !!purchase.allowed,
62
+      reason: purchase.reason || ''
63
+    },
64
+    afterSalePhone: data.afterSalePhone || shop.shopPhone || '',
65
+    isOnSale: String(data.goodsStatus) === '2',
66
+    isOffShelf: String(data.goodsStatus) === '4'
67
+  }
68
+}
69
+
70
+function mapService(row) {
71
+  return {
72
+    serviceId: row.serviceId,
73
+    serviceName: row.serviceName,
74
+    serviceIntro: row.serviceIntro,
75
+    serviceIcon: resolveFileUrl(row.serviceIcon)
76
+  }
77
+}
78
+
79
+export function mapReviewRow(row) {
80
+  if (!row) return null
81
+  let pics = row.pics
82
+  if (typeof pics === 'string' && pics) {
83
+    pics = pics.split(',').map((s) => s.trim()).filter(Boolean)
84
+  }
85
+  if (!Array.isArray(pics)) {
86
+    pics = []
87
+  }
88
+  return {
89
+    reviewId: row.reviewId,
90
+    memberNickName: row.memberNickName || '匿名用户',
91
+    memberAvatar: resolveFileUrl(row.memberAvatar),
92
+    content: row.content,
93
+    pics: pics.map((p) => resolveFileUrl(p)).filter(Boolean),
94
+    score: row.score,
95
+    goodsMainPic: resolveFileUrl(row.goodsMainPic),
96
+    goodsSpec: row.goodsSpec,
97
+    replyContent: row.replyContent,
98
+    replyTime: row.replyTime,
99
+    createTime: row.createTime
100
+  }
101
+}
102
+
103
+export function mapReviewList(list) {
104
+  if (!Array.isArray(list)) return []
105
+  return list.map(mapReviewRow).filter(Boolean)
106
+}
107
+
108
+/** 售后服务说明文案 */
109
+export function buildAfterSaleText(phone) {
110
+  const contact = phone ? `【${phone}】` : '【平台客服】'
111
+  return `售后无忧有保障,让您购物更放心!签收后若遇任何问题,随时联系我们${contact},专业售后团队为您妥善处理!`
112
+}

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

@@ -0,0 +1,40 @@
1
+/**
2
+ * 页面路径(主包 pages/ + 分包 subpackage/)
3
+ *
4
+ * 【约定】仅 Tab 五页 + 登录 在主包;其余业务页一律在 subpackage/ 下,
5
+ * 新建页面须在本文件增加常量,并在 pages.json 的 subPackages 注册。
6
+ * 详见 shop-app/PAGES.md 与 .cursor/rules/10-shop-app-subpackage.mdc
7
+ */
8
+
9
+// —— 主包:Tab + 登录(勿随意新增主包页面)——
10
+
11
+/** 首页 Tab */
12
+export const PAGE_HOME = '/pages/index/index'
13
+/** 分类 Tab */
14
+export const PAGE_CATEGORY_TAB = '/pages/category/index'
15
+/** 购物车 Tab(占位,正式功能待开发) */
16
+export const PAGE_CART = '/pages/cart/index'
17
+/** 我的 Tab(占位,正式功能待开发) */
18
+export const PAGE_MINE = '/pages/mine/index'
19
+/** 登录 */
20
+export const PAGE_LOGIN = '/pages/login/index'
21
+
22
+// —— 分包:分类 ——
23
+
24
+export const PAGE_CATEGORY_LEVEL1 = '/subpackage/category/level1'
25
+export const PAGE_CATEGORY_GOODS_LIST = '/subpackage/category/goods-list'
26
+
27
+// —— 分包:商品 ——
28
+
29
+export const PAGE_GOODS_DETAIL = '/subpackage/goods/detail'
30
+export const PAGE_GOODS_REVIEWS = '/subpackage/goods/reviews'
31
+
32
+// —— 分包:搜索 ——
33
+
34
+export const PAGE_SEARCH_INDEX = '/subpackage/search/index'
35
+export const PAGE_SEARCH_RESULT = '/subpackage/search/result'
36
+
37
+// —— 分包:待建模块(示例,落地后取消注释并注册 pages.json)——
38
+// export const PAGE_SHOP_HOME = '/subpackage/shop/index'
39
+// export const PAGE_ORDER_LIST = '/subpackage/order/list'
40
+// export const PAGE_ORDER_CONFIRM = '/subpackage/order/confirm'

+ 27 - 0
shop-app/utils/purchaseAction.js

@@ -0,0 +1,27 @@
1
+import { ensureApiToken } from '@/utils/apiAuth'
2
+import { getGoodsCanPurchase } from '@/api/goods'
3
+
4
+/** 加购/购买前:登录 + 可购校验 */
5
+export async function ensureCanPurchase(goodsId, fallbackReason) {
6
+  if (!ensureApiToken(true)) {
7
+    return false
8
+  }
9
+  try {
10
+    const res = await getGoodsCanPurchase(goodsId)
11
+    const data = res.data || {}
12
+    if (!data.allowed) {
13
+      uni.showToast({
14
+        title: data.reason || fallbackReason || '暂不可购买',
15
+        icon: 'none'
16
+      })
17
+      return false
18
+    }
19
+    return true
20
+  } catch (e) {
21
+    uni.showToast({
22
+      title: fallbackReason || '校验失败,请稍后重试',
23
+      icon: 'none'
24
+    })
25
+    return false
26
+  }
27
+}

+ 55 - 0
shop-app/utils/searchHistory.js

@@ -0,0 +1,55 @@
1
+import { SEARCH_HISTORY_KEY, SEARCH_HISTORY_MAX } from '@/constants/search'
2
+
3
+function readRaw() {
4
+  try {
5
+    const raw = uni.getStorageSync(SEARCH_HISTORY_KEY)
6
+    if (!raw) return []
7
+    const list = typeof raw === 'string' ? JSON.parse(raw) : raw
8
+    return Array.isArray(list) ? list : []
9
+  } catch (e) {
10
+    return []
11
+  }
12
+}
13
+
14
+function writeRaw(list) {
15
+  try {
16
+    uni.setStorageSync(SEARCH_HISTORY_KEY, JSON.stringify(list))
17
+  } catch (e) {
18
+    // 存储失败不影响搜索
19
+  }
20
+}
21
+
22
+/** 历史列表:{ keyword, time }[],按 time 倒序 */
23
+export function getSearchHistory() {
24
+  return readRaw()
25
+    .filter((item) => item && item.keyword)
26
+    .sort((a, b) => (b.time || 0) - (a.time || 0))
27
+}
28
+
29
+/** 成功进入结果页后写入;同词去重并置顶 */
30
+export function addSearchHistory(keyword) {
31
+  const text = (keyword || '').trim()
32
+  if (!text) return
33
+  const now = Date.now()
34
+  let list = readRaw().filter((item) => item.keyword !== text)
35
+  list.unshift({ keyword: text, time: now })
36
+  if (list.length > SEARCH_HISTORY_MAX) {
37
+    list = list.slice(0, SEARCH_HISTORY_MAX)
38
+  }
39
+  writeRaw(list)
40
+}
41
+
42
+export function removeSearchHistoryItem(keyword) {
43
+  const text = (keyword || '').trim()
44
+  if (!text) return
45
+  const list = readRaw().filter((item) => item.keyword !== text)
46
+  writeRaw(list)
47
+}
48
+
49
+export function clearSearchHistory() {
50
+  try {
51
+    uni.removeStorageSync(SEARCH_HISTORY_KEY)
52
+  } catch (e) {
53
+    writeRaw([])
54
+  }
55
+}

+ 19 - 0
shop-app/utils/searchNav.js

@@ -0,0 +1,19 @@
1
+import { PAGE_SEARCH_INDEX, PAGE_SEARCH_RESULT } from '@/utils/pageRoute'
2
+
3
+/** 跳转搜索输入页 A */
4
+export function goSearchInput(keyword) {
5
+  const text = (keyword || '').trim()
6
+  const url = text
7
+    ? `${PAGE_SEARCH_INDEX}?keyword=${encodeURIComponent(text)}`
8
+    : PAGE_SEARCH_INDEX
9
+  uni.navigateTo({ url })
10
+}
11
+
12
+/** 跳转搜索结果页 B */
13
+export function goSearchResult(keyword) {
14
+  const text = (keyword || '').trim()
15
+  if (!text) return
16
+  uni.navigateTo({
17
+    url: `${PAGE_SEARCH_RESULT}?keyword=${encodeURIComponent(text)}`
18
+  })
19
+}

+ 24 - 0
shop-app/utils/shopDisplay.js

@@ -0,0 +1,24 @@
1
+import { resolveFileUrl } from '@/utils/image'
2
+
3
+const SHOP_PLACEHOLDER = '/static/logo.png'
4
+
5
+/** 搜索店铺卡片 */
6
+export function mapShopCard(row) {
7
+  if (!row) return null
8
+  return {
9
+    shopId: row.shopId,
10
+    shopName: row.shopName,
11
+    shopAvatar: row.shopAvatar,
12
+    displayAvatar: resolveFileUrl(row.shopAvatar) || SHOP_PLACEHOLDER,
13
+    shopStatus: row.shopStatus,
14
+    rating: row.rating,
15
+    fansCount: row.fansCount,
16
+    showRating: row.rating != null && row.rating !== '',
17
+    showFans: row.fansCount != null && row.fansCount !== ''
18
+  }
19
+}
20
+
21
+export function mapShopCardList(list) {
22
+  if (!Array.isArray(list)) return []
23
+  return list.map(mapShopCard).filter(Boolean)
24
+}