xsh_1997 недель назад: 2
Родитель
Сommit
9ec2790d37

+ 210 - 0
doc/消费者APP/店铺主页/店铺主页前端技术方案.md

@@ -0,0 +1,210 @@
1
+# 店铺主页 — 前端技术方案(C 端 · shop-app)
2
+
3
+> **依据:** 《店铺主页功能需求.md》v1.0、《店铺主页技术方案.md》v1.0(仅作接口对照,**本文档独立维护**)  
4
+> **关联:** 《搜索页前端技术方案》、《商品分类前端技术方案》、《商品详情内页前端技术方案》  
5
+> **范围:** C 端 **店铺介绍、关注、店内分类浏览、店内搜索、进商品详情**;**不** 改后端、**不** 实现全站搜索/购物车/我的关注列表页。  
6
+> **实现状态:** 页面与 API 封装已落地,待与 `/api/shop/**` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | uni-app **Vue 3** + **uview-plus** |
15
+| 请求 | `@/utils/request`;读接口 `header: { isToken: false }`;关注写接口 **须 Token** |
16
+| 商品列表响应 | **`TableDataInfo`**:`rows` + `total`(与分类/搜索一致) |
17
+| 分类 VO | 与平台分类相同 `CategoryVisibleVO`;商品筛选参数名为 **`shopCategoryId`**(二级 ID) |
18
+| 排序 | 后端固定 **销量降序**;前端 **不提供** sortBy Tab |
19
+| 虚拟 Tab | 一级导航首项 **「全部商品」** 由前端常量注入,不调分类接口 |
20
+| 参考组件 | `GoodsGrid`、`category/level1` Tab 样式、`search/index` 搜索栏 |
21
+
22
+---
23
+
24
+## 2. 页面与路由
25
+
26
+| 页面 | 需求代号 | 路径 | 入口 |
27
+|------|----------|------|------|
28
+| 店铺主页 | **A** | `subpackage/shop/index` | 搜索页店铺 Tab、详情「进店」、带 `shopId` 链入 |
29
+| 店内搜索 | **B+C** | `subpackage/shop/search` | A 页「搜索本店商品」;B 输入 + C 结果 **同页** |
30
+
31
+**`pages.json` 分包:** `pkg-shop` → `root: subpackage/shop`
32
+
33
+| 页面 | navigationBarTitleText |
34
+|------|------------------------|
35
+| index | 店铺(加载成功后改为店名) |
36
+| search | 搜本店商品 |
37
+
38
+**Query:**
39
+
40
+| 页面 | 参数 | 说明 |
41
+|------|------|------|
42
+| A | `shopId`(必填) | 缺失 → 提示并返回 |
43
+| B+C | `shopId`(必填) | 同上 |
44
+| B+C | `keyword`(可选) | 预填并自动发起搜索 |
45
+
46
+**路径常量:** `utils/pageRoute.js` → `PAGE_SHOP_HOME`、`PAGE_SHOP_SEARCH`
47
+
48
+**跳转工具:** `utils/shopNav.js`
49
+
50
+| 方法 | 行为 |
51
+|------|------|
52
+| `goShopHome(shopId)` | `navigateTo` A 页 |
53
+| `goShopSearch(shopId, keyword?)` | `navigateTo` 店内搜索页 |
54
+
55
+---
56
+
57
+## 3. 文件清单
58
+
59
+| 类型 | 路径 | 说明 |
60
+|------|------|------|
61
+| A 页 | `shop-app/subpackage/shop/index.vue` | 介绍区 + 关注 + 一二级 Tab + 商品列表 |
62
+| 店内搜索 | `shop-app/subpackage/shop/search.vue` | 输入 + 结果列表(同页) |
63
+| 商品列表块 | `shop-app/components/shop/ShopGoodsBlock.vue` | 分页、`GoodsGrid`、重试 |
64
+| 店铺 API | `shop-app/api/shop.js` | `/api/shop/**` |
65
+| 展示映射 | `shop-app/utils/shopDisplay.js` | 介绍区、分类树、搜索店卡片 |
66
+| 导航 | `shop-app/utils/shopNav.js` | 进店跳转 |
67
+| 常量 | `shop-app/constants/shop.js` | 营业态、全部 Tab、分页、占位文案 |
68
+
69
+**已改入口(联调):**
70
+
71
+| 文件 | 改动 |
72
+|------|------|
73
+| `subpackage/search/result.vue` | 店铺 Tab `@item-click` → `goShopHome` |
74
+| `subpackage/goods/detail.vue` | `onEnterShop` → `goShopHome(shopId)` |
75
+
76
+---
77
+
78
+## 4. 接口封装(`api/shop.js`)
79
+
80
+**基路径:** `/api/shop`
81
+
82
+| 方法 | HTTP | 路径 | 鉴权 | 页面 |
83
+|------|------|------|------|------|
84
+| `getShopProfile` | GET | `/{shopId}` | 匿名(Token 可选) | A |
85
+| `getShopCategories` | GET | `/{shopId}/categories` | 匿名 | A |
86
+| `getShopLevel2Tabs` | GET | `/{shopId}/categories/{level1Id}/level2-tabs` | 匿名 | A |
87
+| `getShopGoods` | GET | `/{shopId}/goods` | 匿名 | A / 店内搜索 |
88
+| `followShop` | POST | `/{shopId}/follow` | **须 Token** | A |
89
+| `unfollowShop` | DELETE | `/{shopId}/follow` | **须 Token** | A |
90
+
91
+### 4.1 商品列表 Query
92
+
93
+| 场景 | 参数 |
94
+|------|------|
95
+| 全部商品 | 仅 `pageNum`、`pageSize` |
96
+| 某二级分类 | `shopCategoryId` |
97
+| 店内搜索 | `keyword`(**不传** `shopCategoryId`) |
98
+
99
+空关键词:前端 **不请求**;后端 trim 后空白会报错。
100
+
101
+### 4.2 关注交互
102
+
103
+| 规则 | 前端实现 |
104
+|------|----------|
105
+| SH-F1 未登录 | `ensureApiToken()` → 跳转登录 |
106
+| SH-F2/F3 | 成功后本地更新 `followed`、`fansCount ±1` |
107
+| 失败 | request Toast;**不** 改按钮态 |
108
+
109
+---
110
+
111
+## 5. 页面逻辑说明
112
+
113
+### 5.1 A. 店铺主页 `index.vue`
114
+
115
+```text
116
+onLoad(shopId)
117
+  → loadProfile(失败:整页错误 + 重试/返回)
118
+  → loadCategories(一级树,不含「全部」)
119
+  → 默认「全部商品」→ ShopGoodsBlock 无 shopCategoryId
120
+
121
+切换一级(非全部)
122
+  → loadLevel2Tabs
123
+  → 有二级:展示二级 Tab,传 shopCategoryId
124
+  → 无二级:不请求商品,空态「该分类下暂无商品」
125
+
126
+点击商品 → goGoodsDetail(goodsId)
127
+点击搜索条 → goShopSearch(shopId)
128
+```
129
+
130
+| 展示 | 说明 |
131
+|------|------|
132
+| 停业 `shopStatus=1` | 名称旁 **「休息中」** 标签;列表仍展示(SH8) |
133
+| 评分 | `rating` 为 null 时不展示评分行 |
134
+| 粉丝 | 始终展示数字(无统计为 0) |
135
+| 描述 | 两行截断展示 |
136
+
137
+### 5.2 店内搜索 `search.vue`
138
+
139
+| 规则 | 实现 |
140
+|------|------|
141
+| SH-S2 空关键词 | Toast,不展示结果区 |
142
+| SH-S3/S4 | `ShopGoodsBlock` 带 `keyword` |
143
+| 无匹配 | `emptyText="暂无相关"` |
144
+| 回车 | `@confirm` 等同点搜索 |
145
+
146
+### 5.3 `ShopGoodsBlock.vue`
147
+
148
+- 监听 `shopId`、`shopCategoryId`、`keyword`、`scrollTopKey`、`enabled`
149
+- 上拉 `scrolltolower` 加载更多
150
+- `loadFailed` 时显示「点击重试」
151
+- 卡片字段经 `mapGoodsCardList`:主图、名称、售价、店名
152
+
153
+---
154
+
155
+## 6. 数据映射(`shopDisplay.js`)
156
+
157
+| 函数 | 用途 |
158
+|------|------|
159
+| `mapShopProfile` | 介绍区:头像 URL、`isClosed`、`showRating`、`followed` |
160
+| `mapShopCategoryTree` | 复用 `mapCategoryTree` |
161
+| `mapShopLevel2Tabs` | 复用 `mapLevel2Tabs` |
162
+| `sortShopTabsWithHot` | `hot_flag=1` 靠前 |
163
+| `mapShopCard` | 全站搜索店铺 Tab(已有) |
164
+
165
+---
166
+
167
+## 7. 异常与边界(前端)
168
+
169
+| 情形 | 行为 |
170
+|------|------|
171
+| shopId 缺失 | Toast + `smartNavigateBack` |
172
+| 店铺 404 | `pageError` + 重试 / 返回 |
173
+| 分类/列表失败 | 分类树空;列表块内重试 |
174
+| 一级无二级 | `goodsBlockEnabled=false`,专用空态文案 |
175
+| 浏览 vs 下单 | 本模块不加购;停业/四条件在 **详情页** 拦截 |
176
+
177
+---
178
+
179
+## 8. 联调检查清单
180
+
181
+- [ ] `GET /api/shop/{id}` 返回介绍字段;登录后 `followed` 正确
182
+- [ ] `GET .../categories` 仅可见分类;「全部」下商品分页
183
+- [ ] 选二级 `shopCategoryId` 筛选正确
184
+- [ ] `GET .../goods?keyword=` 仅本店出售中、销量序
185
+- [ ] `POST/DELETE .../follow` 粉丝数与按钮态一致
186
+- [ ] 搜索页店铺卡片、详情「进店」跳转 A 页
187
+- [ ] 已删店 404 文案与返回
188
+
189
+---
190
+
191
+## 9. 非本期(前端不实现)
192
+
193
+| 项 | 说明 |
194
+|------|------|
195
+| 全站搜索 / 搜索历史 | 搜索分包 |
196
+| 店内按价格排序 | 需求未要求 |
197
+| 我的关注列表页 | 我的服务另册 |
198
+| 电话一键拨打、分享 | 可选增强 |
199
+
200
+---
201
+
202
+## 10. 修订记录
203
+
204
+| 版本 | 说明 |
205
+|------|------|
206
+| **v1.0** | 首版:A 主页 + 店内搜索同页、API/组件/入口联调说明 |
207
+
208
+---
209
+
210
+*文档版本:v1.0 · 不修改《店铺主页技术方案.md》(后端方案)。*

+ 5 - 1
shop-app/PAGES.md

@@ -33,13 +33,15 @@ subpackage/
33 33
 └── search/              # 搜索
34 34
     ├── index.vue
35 35
     └── result.vue
36
+└── shop/                # 店铺主页
37
+    ├── index.vue
38
+    └── search.vue
36 39
 ```
37 40
 
38 41
 后续示例(待建):
39 42
 
40 43
 ```
41 44
 subpackage/
42
-├── shop/                # 店铺主页
43 45
 ├── order/               # 订单、确认订单
44 46
 ├── cart/                # 购物车子页(Tab 仍在 pages/cart)
45 47
 └── user/                # 地址、设置等(Tab 仍在 pages/mine)
@@ -58,6 +60,8 @@ subpackage/
58 60
 | `PAGE_HOME` | `/pages/index/index` |
59 61
 | `PAGE_GOODS_DETAIL` | `/subpackage/goods/detail` |
60 62
 | `PAGE_SEARCH_INDEX` | `/subpackage/search/index` |
63
+| `PAGE_SHOP_HOME` | `/subpackage/shop/index` |
64
+| `PAGE_SHOP_SEARCH` | `/subpackage/shop/search` |
61 65
 | `PAGE_REGISTER` | `/subpackage/account/register` |
62 66
 
63 67
 配置详见 `pages.json` 中 `subPackages` 与 `preloadRule`。

+ 60 - 0
shop-app/api/shop.js

@@ -0,0 +1,60 @@
1
+import request from '@/utils/request'
2
+
3
+/** 店铺介绍 A 页(匿名可访问;本地有 Token 时自动带上以返回 followed) */
4
+export function getShopProfile(shopId) {
5
+  return request({
6
+    url: `/api/shop/${shopId}`,
7
+    method: 'GET',
8
+    silent: true
9
+  })
10
+}
11
+
12
+/** 店铺可见分类树(匿名) */
13
+export function getShopCategories(shopId) {
14
+  return request({
15
+    url: `/api/shop/${shopId}/categories`,
16
+    method: 'GET',
17
+    header: { isToken: false },
18
+    silent: true
19
+  })
20
+}
21
+
22
+/** 指定一级下二级 Tab(匿名) */
23
+export function getShopLevel2Tabs(shopId, level1Id) {
24
+  return request({
25
+    url: `/api/shop/${shopId}/categories/${level1Id}/level2-tabs`,
26
+    method: 'GET',
27
+    header: { isToken: false },
28
+    silent: true
29
+  })
30
+}
31
+
32
+/**
33
+ * 店内商品列表 / 店内搜索(匿名)
34
+ * @returns {Promise<{code,msg,rows,total}>}
35
+ */
36
+export function getShopGoods(shopId, params) {
37
+  return request({
38
+    url: `/api/shop/${shopId}/goods`,
39
+    method: 'GET',
40
+    params,
41
+    header: { isToken: false },
42
+    silent: true
43
+  })
44
+}
45
+
46
+/** 关注店铺(须会员 Token) */
47
+export function followShop(shopId) {
48
+  return request({
49
+    url: `/api/shop/${shopId}/follow`,
50
+    method: 'POST'
51
+  })
52
+}
53
+
54
+/** 取消关注(须会员 Token) */
55
+export function unfollowShop(shopId) {
56
+  return request({
57
+    url: `/api/shop/${shopId}/follow`,
58
+    method: 'DELETE'
59
+  })
60
+}

+ 186 - 0
shop-app/components/shop/ShopGoodsBlock.vue

@@ -0,0 +1,186 @@
1
+<template>
2
+	<view class="shop-goods-block">
3
+		<scroll-view
4
+			class="shop-goods-block__scroll"
5
+			scroll-y
6
+			:scroll-top="listScrollTop"
7
+			:style="{ height: scrollHeight }"
8
+			@scrolltolower="onLoadMore"
9
+		>
10
+			<view v-if="loading && !goodsList.length" class="shop-goods-block__loading">
11
+				<u-loading-icon mode="circle" />
12
+			</view>
13
+			<goods-grid v-else-if="goodsList.length" :list="goodsList" @item-click="onGoodsClick" />
14
+			<view v-else class="shop-goods-block__empty">
15
+				<u-empty mode="list" :text="emptyText" icon-size="80" />
16
+				<text v-if="loadFailed" class="shop-goods-block__retry" @click="reloadGoods(true)">点击重试</text>
17
+			</view>
18
+			<view v-if="goodsList.length" class="shop-goods-block__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, watch } from 'vue'
28
+import { getShopGoods } from '@/api/shop'
29
+import { mapGoodsCardList } from '@/utils/goodsDisplay'
30
+import { SHOP_GOODS_PAGE_SIZE } from '@/constants/shop'
31
+import GoodsGrid from '@/components/mall/GoodsGrid.vue'
32
+
33
+const props = defineProps({
34
+	shopId: {
35
+		type: [Number, String],
36
+		default: ''
37
+	},
38
+	/** 本店二级分类 ID;空表示全店 */
39
+	shopCategoryId: {
40
+		type: [Number, String],
41
+		default: ''
42
+	},
43
+	/** 店内搜索关键词;有值时忽略 shopCategoryId */
44
+	keyword: {
45
+		type: String,
46
+		default: ''
47
+	},
48
+	scrollHeight: {
49
+		type: String,
50
+		default: '600px'
51
+	},
52
+	scrollTopKey: {
53
+		type: Number,
54
+		default: 0
55
+	},
56
+	pageSize: {
57
+		type: Number,
58
+		default: SHOP_GOODS_PAGE_SIZE
59
+	},
60
+	emptyText: {
61
+		type: String,
62
+		default: '暂无商品'
63
+	},
64
+	/** 为 false 时不拉取(如一级下无二级 Tab) */
65
+	enabled: {
66
+		type: Boolean,
67
+		default: true
68
+	}
69
+})
70
+
71
+const emit = defineEmits(['goods-click'])
72
+
73
+const goodsList = ref([])
74
+const loading = ref(false)
75
+const loadingMore = ref(false)
76
+const finished = ref(false)
77
+const pageNum = ref(1)
78
+const listScrollTop = ref(0)
79
+const loadFailed = ref(false)
80
+
81
+watch(
82
+	() => [props.shopId, props.shopCategoryId, props.keyword, props.scrollTopKey, props.enabled],
83
+	() => {
84
+		if (!props.shopId || !props.enabled) {
85
+			goodsList.value = []
86
+			return
87
+		}
88
+		reloadGoods(true)
89
+	},
90
+	{ immediate: true }
91
+)
92
+
93
+function buildParams() {
94
+	const params = {
95
+		pageNum: pageNum.value,
96
+		pageSize: props.pageSize
97
+	}
98
+	const kw = (props.keyword || '').trim()
99
+	if (kw) {
100
+		params.keyword = kw
101
+	} else if (props.shopCategoryId) {
102
+		params.shopCategoryId = props.shopCategoryId
103
+	}
104
+	return params
105
+}
106
+
107
+async function fetchPage(isReset) {
108
+	if (!props.shopId || !props.enabled) return
109
+	if (isReset) {
110
+		loading.value = true
111
+		loadFailed.value = false
112
+		pageNum.value = 1
113
+		finished.value = false
114
+	} else {
115
+		if (finished.value || loadingMore.value) return
116
+		loadingMore.value = true
117
+	}
118
+	try {
119
+		const res = await getShopGoods(props.shopId, buildParams())
120
+		const rows = mapGoodsCardList(res.rows || [])
121
+		const total = Number(res.total) || 0
122
+		if (isReset) {
123
+			goodsList.value = rows
124
+		} else {
125
+			goodsList.value = goodsList.value.concat(rows)
126
+		}
127
+		finished.value =
128
+			rows.length < props.pageSize || (total > 0 && goodsList.value.length >= total)
129
+		if (!finished.value) {
130
+			pageNum.value += 1
131
+		}
132
+	} catch (e) {
133
+		loadFailed.value = true
134
+		if (isReset) {
135
+			goodsList.value = []
136
+		}
137
+	} finally {
138
+		loading.value = false
139
+		loadingMore.value = false
140
+	}
141
+}
142
+
143
+function reloadGoods(resetScroll) {
144
+	if (resetScroll) {
145
+		listScrollTop.value = listScrollTop.value === 0 ? 0.1 : 0
146
+	}
147
+	pageNum.value = 1
148
+	fetchPage(true)
149
+}
150
+
151
+function onLoadMore() {
152
+	if (!loading.value && !loadingMore.value && !finished.value) {
153
+		fetchPage(false)
154
+	}
155
+}
156
+
157
+function onGoodsClick(item) {
158
+	emit('goods-click', item)
159
+}
160
+
161
+defineExpose({ reloadGoods })
162
+</script>
163
+
164
+<style lang="scss" scoped>
165
+.shop-goods-block__scroll {
166
+	width: 100%;
167
+}
168
+.shop-goods-block__loading,
169
+.shop-goods-block__empty {
170
+	padding: 80rpx 0;
171
+	display: flex;
172
+	flex-direction: column;
173
+	align-items: center;
174
+}
175
+.shop-goods-block__retry {
176
+	margin-top: 24rpx;
177
+	font-size: 28rpx;
178
+	color: #2e7d32;
179
+}
180
+.shop-goods-block__footer {
181
+	padding: 24rpx;
182
+	text-align: center;
183
+	font-size: 24rpx;
184
+	color: #999;
185
+}
186
+</style>

+ 16 - 0
shop-app/constants/shop.js

@@ -0,0 +1,16 @@
1
+/** 店铺营业状态:0 开业 / 1 停业 */
2
+export const SHOP_STATUS_OPEN = '0'
3
+export const SHOP_STATUS_CLOSED = '1'
4
+
5
+/** 停业时介绍区标签文案 */
6
+export const SHOP_CLOSED_LABEL = '休息中'
7
+
8
+/** 一级导航虚拟 Tab:全部商品 */
9
+export const SHOP_ALL_TAB_ID = '__all__'
10
+export const SHOP_ALL_TAB_NAME = '全部商品'
11
+
12
+/** 店内商品列表每页条数 */
13
+export const SHOP_GOODS_PAGE_SIZE = 10
14
+
15
+/** 店内搜索输入占位 */
16
+export const SHOP_SEARCH_PLACEHOLDER = '搜索本店商品'

+ 19 - 1
shop-app/pages.json

@@ -152,12 +152,30 @@
152 152
 					}
153 153
 				}
154 154
 			]
155
+		},
156
+		{
157
+			"root": "subpackage/shop",
158
+			"name": "pkg-shop",
159
+			"pages": [
160
+				{
161
+					"path": "index",
162
+					"style": {
163
+						"navigationBarTitleText": "店铺"
164
+					}
165
+				},
166
+				{
167
+					"path": "search",
168
+					"style": {
169
+						"navigationBarTitleText": "搜本店商品"
170
+					}
171
+				}
172
+			]
155 173
 		}
156 174
 	],
157 175
 	"preloadRule": {
158 176
 		"pages/index/index": {
159 177
 			"network": "all",
160
-			"packages": ["pkg-category", "pkg-goods", "pkg-search"]
178
+			"packages": ["pkg-category", "pkg-goods", "pkg-search", "pkg-shop"]
161 179
 		},
162 180
 		"pages/category/index": {
163 181
 			"network": "all",

+ 7 - 1
shop-app/subpackage/goods/detail.vue

@@ -181,6 +181,7 @@ import {
181 181
 } from '@/utils/goodsDetail'
182 182
 import { ensureCanPurchase } from '@/utils/purchaseAction'
183 183
 import { PAGE_GOODS_REVIEWS } from '@/utils/pageRoute'
184
+import { goShopHome } from '@/utils/shopNav'
184 185
 import ReviewCard from '@/components/goods/ReviewCard.vue'
185 186
 import DetailBottomBar from '@/components/goods/DetailBottomBar.vue'
186 187
 
@@ -342,7 +343,12 @@ function showBlockReason() {
342 343
 }
343 344
 
344 345
 function onEnterShop() {
345
-	uni.showToast({ title: '店铺主页开发中', icon: 'none' })
346
+	const sid = detail.value?.shop?.shopId
347
+	if (sid) {
348
+		goShopHome(sid)
349
+	} else {
350
+		uni.showToast({ title: '店铺信息不可用', icon: 'none' })
351
+	}
346 352
 }
347 353
 
348 354
 function goAllReviews() {

+ 5 - 2
shop-app/subpackage/search/result.vue

@@ -61,6 +61,7 @@ import { mapShopCardList } from '@/utils/shopDisplay'
61 61
 import { addSearchHistory } from '@/utils/searchHistory'
62 62
 import { goSearchInput } from '@/utils/searchNav'
63 63
 import { goGoodsDetail } from '@/utils/goodsDetail'
64
+import { goShopHome } from '@/utils/shopNav'
64 65
 import {
65 66
   SEARCH_TAB_SALES,
66 67
   SEARCH_TAB_PRICE,
@@ -255,8 +256,10 @@ function onGoodsClick(item) {
255 256
   }
256 257
 }
257 258
 
258
-function onShopClick() {
259
-  uni.showToast({ title: '店铺主页开发中', icon: 'none' })
259
+function onShopClick(item) {
260
+  if (item && item.shopId) {
261
+    goShopHome(item.shopId)
262
+  }
260 263
 }
261 264
 
262 265
 onLoad((options) => {

+ 444 - 0
shop-app/subpackage/shop/index.vue

@@ -0,0 +1,444 @@
1
+<template>
2
+	<view class="page-shop">
3
+		<view v-if="pageError" class="page-shop__error">
4
+			<u-empty mode="page" :text="pageError" icon-size="80" />
5
+			<u-button type="primary" text="重试" size="small" custom-style="margin-top:24rpx" @click="initPage" />
6
+			<u-button plain text="返回" size="small" custom-style="margin-top:16rpx" @click="onBack" />
7
+		</view>
8
+
9
+		<template v-else>
10
+			<view v-if="profileLoading" class="page-shop__loading">
11
+				<u-loading-icon mode="circle" />
12
+			</view>
13
+
14
+			<template v-else-if="profile">
15
+				<view class="shop-header">
16
+					<image class="shop-header__avatar" :src="profile.shopAvatar" mode="aspectFill" />
17
+					<view class="shop-header__main">
18
+						<view class="shop-header__title-row">
19
+							<text class="shop-header__name">{{ profile.shopName }}</text>
20
+							<text v-if="profile.isClosed" class="shop-header__tag">{{ closedLabel }}</text>
21
+						</view>
22
+						<view v-if="profile.showRating || profile.showFans" class="shop-header__meta">
23
+							<text v-if="profile.showRating" class="shop-header__meta-item">评分 {{ profile.ratingText }}</text>
24
+							<text v-if="profile.showFans" class="shop-header__meta-item">粉丝 {{ profile.fansCount }}</text>
25
+						</view>
26
+						<text v-if="profile.shopDesc" class="shop-header__desc">{{ profile.shopDesc }}</text>
27
+					</view>
28
+					<view
29
+						class="shop-header__follow"
30
+						:class="{ 'shop-header__follow--on': profile.followed }"
31
+						@click="onFollowTap"
32
+					>
33
+						<text>{{ profile.followed ? '已关注' : '+ 关注' }}</text>
34
+					</view>
35
+				</view>
36
+
37
+				<view class="shop-search-bar" @click="onGoShopSearch">
38
+					<u-icon name="search" color="#2e7d32" size="18" />
39
+					<text class="shop-search-bar__text">搜索本店商品</text>
40
+				</view>
41
+
42
+				<scroll-view class="page-shop__l1" scroll-x show-scrollbar="false">
43
+					<view class="tabs-inner">
44
+						<view
45
+							v-for="(tab, index) in level1Tabs"
46
+							:key="tab.categoryId"
47
+							class="tab-item"
48
+							:class="{ 'tab-item--active': activeLevel1Index === index }"
49
+							@click="onLevel1Change(index)"
50
+						>
51
+							<text>{{ tab.categoryName }}</text>
52
+						</view>
53
+					</view>
54
+				</scroll-view>
55
+
56
+				<view v-if="!isAllLevel1 && tabsLoading" class="page-shop__hint">
57
+					<u-loading-icon mode="circle" size="20" />
58
+				</view>
59
+				<scroll-view
60
+					v-else-if="!isAllLevel1 && level2Tabs.length"
61
+					class="page-shop__l2"
62
+					scroll-x
63
+					show-scrollbar="false"
64
+				>
65
+					<view class="tabs-inner">
66
+						<view
67
+							v-for="(tab, index) in level2Tabs"
68
+							:key="tab.categoryId"
69
+							class="tab-item tab-item--sm"
70
+							:class="{ 'tab-item--active': activeLevel2Index === index }"
71
+							@click="onLevel2Change(index)"
72
+						>
73
+							<text>{{ tab.categoryName }}</text>
74
+						</view>
75
+					</view>
76
+				</scroll-view>
77
+
78
+				<shop-goods-block
79
+					:shop-id="shopId"
80
+					:shop-category-id="activeShopCategoryId"
81
+					:scroll-top-key="goodsScrollKey"
82
+					:scroll-height="goodsScrollHeight"
83
+					:enabled="goodsBlockEnabled"
84
+					:empty-text="goodsEmptyText"
85
+					@goods-click="onGoodsClick"
86
+				/>
87
+			</template>
88
+		</template>
89
+	</view>
90
+</template>
91
+
92
+<script setup>
93
+import { ref, computed } from 'vue'
94
+import { onLoad } from '@dcloudio/uni-app'
95
+import { getShopProfile, getShopCategories, getShopLevel2Tabs, followShop, unfollowShop } from '@/api/shop'
96
+import {
97
+	mapShopProfile,
98
+	mapShopCategoryTree,
99
+	mapShopLevel2Tabs,
100
+	sortShopTabsWithHot
101
+} from '@/utils/shopDisplay'
102
+import {
103
+	SHOP_ALL_TAB_ID,
104
+	SHOP_ALL_TAB_NAME,
105
+	SHOP_CLOSED_LABEL
106
+} from '@/constants/shop'
107
+import { goShopSearch } from '@/utils/shopNav'
108
+import { goGoodsDetail } from '@/utils/goodsDetail'
109
+import { ensureApiToken } from '@/utils/apiAuth'
110
+import { smartNavigateBack } from '@/utils/navBack'
111
+import ShopGoodsBlock from '@/components/shop/ShopGoodsBlock.vue'
112
+
113
+const shopId = ref('')
114
+const profile = ref(null)
115
+const profileLoading = ref(true)
116
+const pageError = ref('')
117
+const followLoading = ref(false)
118
+const closedLabel = SHOP_CLOSED_LABEL
119
+
120
+const categoryTree = ref([])
121
+const level2Tabs = ref([])
122
+const activeLevel1Index = ref(0)
123
+const activeLevel2Index = ref(0)
124
+const tabsLoading = ref(false)
125
+const tabsFailed = ref(false)
126
+const goodsScrollKey = ref(0)
127
+const goodsScrollHeight = ref('500px')
128
+
129
+const allTab = { categoryId: SHOP_ALL_TAB_ID, categoryName: SHOP_ALL_TAB_NAME }
130
+
131
+const level1Tabs = computed(() => [allTab, ...categoryTree.value])
132
+
133
+const isAllLevel1 = computed(() => {
134
+	const tab = level1Tabs.value[activeLevel1Index.value]
135
+	return !tab || tab.categoryId === SHOP_ALL_TAB_ID
136
+})
137
+
138
+const activeLevel1Id = computed(() => {
139
+	const tab = level1Tabs.value[activeLevel1Index.value]
140
+	return tab && tab.categoryId !== SHOP_ALL_TAB_ID ? tab.categoryId : ''
141
+})
142
+
143
+const activeShopCategoryId = computed(() => {
144
+	if (isAllLevel1.value) return ''
145
+	if (!level2Tabs.value.length) return ''
146
+	const tab = level2Tabs.value[activeLevel2Index.value]
147
+	return tab ? tab.categoryId : ''
148
+})
149
+
150
+const goodsBlockEnabled = computed(() => {
151
+	if (isAllLevel1.value) return true
152
+	return level2Tabs.value.length > 0
153
+})
154
+
155
+const goodsEmptyText = computed(() => {
156
+	if (!isAllLevel1.value && !level2Tabs.value.length && !tabsLoading.value) {
157
+		return '该分类下暂无商品'
158
+	}
159
+	return '暂无商品'
160
+})
161
+
162
+function calcScrollHeight() {
163
+	try {
164
+		const sys = uni.getSystemInfoSync()
165
+		const h = sys.windowHeight || 600
166
+		goodsScrollHeight.value = `${h - 280}px`
167
+	} catch (e) {
168
+		goodsScrollHeight.value = '500px'
169
+	}
170
+}
171
+
172
+function onBack() {
173
+	smartNavigateBack()
174
+}
175
+
176
+async function loadProfile() {
177
+	profileLoading.value = true
178
+	pageError.value = ''
179
+	try {
180
+		const res = await getShopProfile(shopId.value)
181
+		const mapped = mapShopProfile(res.data)
182
+		if (!mapped) {
183
+			throw new Error('店铺数据异常')
184
+		}
185
+		profile.value = mapped
186
+		const title = mapped.shopName || '店铺'
187
+		uni.setNavigationBarTitle({ title })
188
+	} catch (e) {
189
+		profile.value = null
190
+		const msg = (e && e.msg) || (e && e.message) || '店铺不存在或已关闭'
191
+		pageError.value = msg
192
+	} finally {
193
+		profileLoading.value = false
194
+	}
195
+}
196
+
197
+async function loadCategories() {
198
+	try {
199
+		const res = await getShopCategories(shopId.value)
200
+		categoryTree.value = sortShopTabsWithHot(mapShopCategoryTree(res.data || []))
201
+	} catch (e) {
202
+		categoryTree.value = []
203
+	}
204
+}
205
+
206
+async function loadLevel2Tabs() {
207
+	if (!activeLevel1Id.value) {
208
+		level2Tabs.value = []
209
+		return
210
+	}
211
+	tabsLoading.value = true
212
+	tabsFailed.value = false
213
+	try {
214
+		const res = await getShopLevel2Tabs(shopId.value, activeLevel1Id.value)
215
+		level2Tabs.value = sortShopTabsWithHot(mapShopLevel2Tabs(res.data || []))
216
+		if (activeLevel2Index.value >= level2Tabs.value.length) {
217
+			activeLevel2Index.value = 0
218
+		}
219
+	} catch (e) {
220
+		tabsFailed.value = true
221
+		level2Tabs.value = []
222
+	} finally {
223
+		tabsLoading.value = false
224
+	}
225
+}
226
+
227
+function bumpGoodsList() {
228
+	goodsScrollKey.value += 1
229
+}
230
+
231
+async function onLevel1Change(index) {
232
+	if (activeLevel1Index.value === index) return
233
+	activeLevel1Index.value = index
234
+	activeLevel2Index.value = 0
235
+	if (isAllLevel1.value) {
236
+		level2Tabs.value = []
237
+		bumpGoodsList()
238
+		return
239
+	}
240
+	await loadLevel2Tabs()
241
+	bumpGoodsList()
242
+}
243
+
244
+function onLevel2Change(index) {
245
+	if (activeLevel2Index.value === index) return
246
+	activeLevel2Index.value = index
247
+	bumpGoodsList()
248
+}
249
+
250
+async function onFollowTap() {
251
+	if (!profile.value || followLoading.value) return
252
+	if (!ensureApiToken()) return
253
+	followLoading.value = true
254
+	try {
255
+		if (profile.value.followed) {
256
+			await unfollowShop(shopId.value)
257
+			profile.value.followed = false
258
+			profile.value.fansCount = Math.max(0, profile.value.fansCount - 1)
259
+		} else {
260
+			await followShop(shopId.value)
261
+			profile.value.followed = true
262
+			profile.value.fansCount += 1
263
+		}
264
+	} catch (e) {
265
+		// request 已 Toast
266
+	} finally {
267
+		followLoading.value = false
268
+	}
269
+}
270
+
271
+function onGoShopSearch() {
272
+	goShopSearch(shopId.value)
273
+}
274
+
275
+function onGoodsClick(item) {
276
+	if (item && item.goodsId) {
277
+		goGoodsDetail(item.goodsId)
278
+	}
279
+}
280
+
281
+async function initPage() {
282
+	if (!shopId.value) return
283
+	await loadProfile()
284
+	if (!profile.value) return
285
+	await loadCategories()
286
+	if (!isAllLevel1.value) {
287
+		await loadLevel2Tabs()
288
+	}
289
+}
290
+
291
+onLoad((options) => {
292
+	shopId.value = options.shopId || ''
293
+	calcScrollHeight()
294
+	if (!shopId.value) {
295
+		pageError.value = '无法打开店铺'
296
+		profileLoading.value = false
297
+		setTimeout(() => onBack(), 800)
298
+		return
299
+	}
300
+	initPage()
301
+})
302
+</script>
303
+
304
+<style lang="scss" scoped>
305
+.page-shop {
306
+	display: flex;
307
+	flex-direction: column;
308
+	min-height: 100vh;
309
+	background: #f5f6f8;
310
+}
311
+.page-shop__error,
312
+.page-shop__loading,
313
+.page-shop__hint {
314
+	padding: 80rpx 24rpx;
315
+	display: flex;
316
+	flex-direction: column;
317
+	align-items: center;
318
+}
319
+.shop-header {
320
+	display: flex;
321
+	align-items: flex-start;
322
+	padding: 24rpx;
323
+	background: #fff;
324
+	margin-bottom: 2rpx;
325
+}
326
+.shop-header__avatar {
327
+	width: 120rpx;
328
+	height: 120rpx;
329
+	border-radius: 12rpx;
330
+	background: #eee;
331
+	flex-shrink: 0;
332
+}
333
+.shop-header__main {
334
+	flex: 1;
335
+	margin: 0 20rpx;
336
+	min-width: 0;
337
+}
338
+.shop-header__title-row {
339
+	display: flex;
340
+	align-items: center;
341
+	flex-wrap: wrap;
342
+	gap: 12rpx;
343
+}
344
+.shop-header__name {
345
+	font-size: 32rpx;
346
+	font-weight: 600;
347
+	color: #333;
348
+}
349
+.shop-header__tag {
350
+	font-size: 22rpx;
351
+	color: #e65100;
352
+	background: #fff3e0;
353
+	padding: 4rpx 12rpx;
354
+	border-radius: 6rpx;
355
+}
356
+.shop-header__meta {
357
+	margin-top: 12rpx;
358
+	display: flex;
359
+	flex-wrap: wrap;
360
+	gap: 16rpx;
361
+}
362
+.shop-header__meta-item {
363
+	font-size: 24rpx;
364
+	color: #999;
365
+}
366
+.shop-header__desc {
367
+	margin-top: 12rpx;
368
+	font-size: 24rpx;
369
+	color: #666;
370
+	line-height: 1.5;
371
+	overflow: hidden;
372
+	display: -webkit-box;
373
+	-webkit-line-clamp: 2;
374
+	-webkit-box-orient: vertical;
375
+}
376
+.shop-header__follow {
377
+	flex-shrink: 0;
378
+	padding: 12rpx 24rpx;
379
+	border-radius: 32rpx;
380
+	border: 2rpx solid #2e7d32;
381
+	background: #fff;
382
+}
383
+.shop-header__follow text {
384
+	font-size: 26rpx;
385
+	color: #2e7d32;
386
+}
387
+.shop-header__follow--on {
388
+	background: #e8f5e9;
389
+	border-color: #a5d6a7;
390
+}
391
+.shop-header__follow--on text {
392
+	color: #666;
393
+}
394
+.shop-search-bar {
395
+	display: flex;
396
+	align-items: center;
397
+	margin: 16rpx 24rpx;
398
+	padding: 0 24rpx;
399
+	height: 72rpx;
400
+	background: #fff;
401
+	border-radius: 36rpx;
402
+	border: 2rpx solid #c8e6c9;
403
+}
404
+.shop-search-bar__text {
405
+	margin-left: 12rpx;
406
+	font-size: 28rpx;
407
+	color: #999;
408
+}
409
+.page-shop__l1,
410
+.page-shop__l2 {
411
+	background: #fff;
412
+	white-space: nowrap;
413
+}
414
+.page-shop__l2 {
415
+	border-top: 1rpx solid #f0f0f0;
416
+}
417
+.tabs-inner {
418
+	display: inline-flex;
419
+	padding: 16rpx 12rpx;
420
+}
421
+.tab-item {
422
+	display: inline-flex;
423
+	align-items: center;
424
+	justify-content: center;
425
+	padding: 12rpx 28rpx;
426
+	margin: 0 8rpx;
427
+	border-radius: 32rpx;
428
+	background: #f5f5f5;
429
+}
430
+.tab-item--sm {
431
+	padding: 8rpx 24rpx;
432
+}
433
+.tab-item text {
434
+	font-size: 26rpx;
435
+	color: #666;
436
+}
437
+.tab-item--active {
438
+	background: #e8f5e9;
439
+}
440
+.tab-item--active text {
441
+	color: #2e7d32;
442
+	font-weight: 500;
443
+}
444
+</style>

+ 144 - 0
shop-app/subpackage/shop/search.vue

@@ -0,0 +1,144 @@
1
+<template>
2
+	<view class="page-shop-search">
3
+		<view class="search-header">
4
+			<u-icon name="arrow-left" size="20" color="#333" @click="onBack" />
5
+			<view class="search-header__input-wrap">
6
+				<u-icon name="search" color="#999" size="18" />
7
+				<input
8
+					class="search-header__input"
9
+					v-model="keyword"
10
+					:placeholder="placeholder"
11
+					confirm-type="search"
12
+					:focus="inputFocus"
13
+					@confirm="onSubmit"
14
+				/>
15
+				<u-icon
16
+					v-if="keyword"
17
+					name="close-circle-fill"
18
+					color="#ccc"
19
+					size="18"
20
+					@click="keyword = ''"
21
+				/>
22
+			</view>
23
+			<text class="search-header__btn" @click="onSubmit">搜索</text>
24
+		</view>
25
+
26
+		<text class="search-tip">仅搜索本店出售中商品</text>
27
+
28
+		<shop-goods-block
29
+			v-if="searched"
30
+			:shop-id="shopId"
31
+			:keyword="searchKeyword"
32
+			:scroll-height="scrollHeight"
33
+			:scroll-top-key="scrollKey"
34
+			empty-text="暂无相关"
35
+			@goods-click="onGoodsClick"
36
+		/>
37
+	</view>
38
+</template>
39
+
40
+<script setup>
41
+import { ref } from 'vue'
42
+import { onLoad } from '@dcloudio/uni-app'
43
+import { SHOP_SEARCH_PLACEHOLDER } from '@/constants/shop'
44
+import { goGoodsDetail } from '@/utils/goodsDetail'
45
+import { smartNavigateBack } from '@/utils/navBack'
46
+import ShopGoodsBlock from '@/components/shop/ShopGoodsBlock.vue'
47
+
48
+const shopId = ref('')
49
+const keyword = ref('')
50
+const searchKeyword = ref('')
51
+const searched = ref(false)
52
+const scrollKey = ref(0)
53
+const scrollHeight = ref('600px')
54
+const inputFocus = ref(false)
55
+const placeholder = SHOP_SEARCH_PLACEHOLDER
56
+
57
+function calcScrollHeight() {
58
+	try {
59
+		const sys = uni.getSystemInfoSync()
60
+		const h = sys.windowHeight || 600
61
+		scrollHeight.value = `${h - 120}px`
62
+	} catch (e) {
63
+		scrollHeight.value = '500px'
64
+	}
65
+}
66
+
67
+function onBack() {
68
+	smartNavigateBack()
69
+}
70
+
71
+function onSubmit() {
72
+	const text = (keyword.value || '').trim()
73
+	if (!text) {
74
+		uni.showToast({ title: '请输入搜索内容', icon: 'none' })
75
+		return
76
+	}
77
+	searchKeyword.value = text
78
+	searched.value = true
79
+	scrollKey.value += 1
80
+}
81
+
82
+function onGoodsClick(item) {
83
+	if (item && item.goodsId) {
84
+		goGoodsDetail(item.goodsId)
85
+	}
86
+}
87
+
88
+onLoad((options) => {
89
+	shopId.value = options.shopId || ''
90
+	calcScrollHeight()
91
+	if (!shopId.value) {
92
+		uni.showToast({ title: '无法打开店铺', icon: 'none' })
93
+		setTimeout(() => onBack(), 500)
94
+		return
95
+	}
96
+	const kw = options.keyword ? decodeURIComponent(options.keyword) : ''
97
+	keyword.value = (kw || '').trim()
98
+	if (keyword.value) {
99
+		onSubmit()
100
+	} else {
101
+		inputFocus.value = true
102
+	}
103
+})
104
+</script>
105
+
106
+<style lang="scss" scoped>
107
+.page-shop-search {
108
+	display: flex;
109
+	flex-direction: column;
110
+	min-height: 100vh;
111
+	background: #f5f6f8;
112
+}
113
+.search-header {
114
+	display: flex;
115
+	align-items: center;
116
+	padding: 16rpx 24rpx;
117
+	background: #e8f5e9;
118
+}
119
+.search-header__input-wrap {
120
+	flex: 1;
121
+	display: flex;
122
+	align-items: center;
123
+	height: 72rpx;
124
+	margin: 0 16rpx;
125
+	padding: 0 20rpx;
126
+	background: #fff;
127
+	border-radius: 36rpx;
128
+}
129
+.search-header__input {
130
+	flex: 1;
131
+	margin-left: 12rpx;
132
+	font-size: 28rpx;
133
+}
134
+.search-header__btn {
135
+	font-size: 28rpx;
136
+	color: #2e7d32;
137
+	font-weight: 500;
138
+}
139
+.search-tip {
140
+	padding: 16rpx 24rpx;
141
+	font-size: 24rpx;
142
+	color: #999;
143
+}
144
+</style>

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

@@ -52,7 +52,13 @@ export const PAGE_GOODS_REVIEWS = '/subpackage/goods/reviews'
52 52
 export const PAGE_SEARCH_INDEX = '/subpackage/search/index'
53 53
 export const PAGE_SEARCH_RESULT = '/subpackage/search/result'
54 54
 
55
-// —— 分包:待建模块(示例,落地后取消注释并注册 pages.json)——
56
-// export const PAGE_SHOP_HOME = '/subpackage/shop/index'
55
+// —— 分包:店铺 ——
56
+
57
+/** 店铺主页 */
58
+export const PAGE_SHOP_HOME = '/subpackage/shop/index'
59
+/** 店内搜索 */
60
+export const PAGE_SHOP_SEARCH = '/subpackage/shop/search'
61
+
62
+// —— 分包:待建模块(示例,落地后注册 pages.json)——
57 63
 // export const PAGE_ORDER_LIST = '/subpackage/order/list'
58 64
 // export const PAGE_ORDER_CONFIRM = '/subpackage/order/confirm'

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

@@ -1,7 +1,51 @@
1 1
 import { resolveFileUrl } from '@/utils/image'
2
+import { SHOP_STATUS_CLOSED } from '@/constants/shop'
3
+import { mapCategoryTree, mapLevel2Tabs } from '@/utils/categoryDisplay'
2 4
 
3 5
 const SHOP_PLACEHOLDER = '/static/logo.png'
4 6
 
7
+/** 店铺主页介绍区 */
8
+export function mapShopProfile(row) {
9
+  if (!row) return null
10
+  const rating = row.rating
11
+  const fansCount = row.fansCount != null ? Number(row.fansCount) : 0
12
+  return {
13
+    shopId: row.shopId,
14
+    shopName: row.shopName || '',
15
+    shopAvatar: resolveFileUrl(row.shopAvatar) || SHOP_PLACEHOLDER,
16
+    shopDesc: (row.shopDesc || '').trim(),
17
+    shopStatus: row.shopStatus,
18
+    shopPhone: row.shopPhone || '',
19
+    rating,
20
+    fansCount,
21
+    followed: !!row.followed,
22
+    isClosed: String(row.shopStatus) === SHOP_STATUS_CLOSED,
23
+    showRating: rating != null && rating !== '',
24
+    showFans: true,
25
+    ratingText: rating != null && rating !== '' ? String(rating) : ''
26
+  }
27
+}
28
+
29
+/** 店铺分类树 / 二级 Tab(字段同平台分类 VO) */
30
+export function mapShopCategoryTree(list) {
31
+  return mapCategoryTree(list)
32
+}
33
+
34
+export function mapShopLevel2Tabs(list) {
35
+  return mapLevel2Tabs(list)
36
+}
37
+
38
+/** 热门分类靠前(同序保持 sortNo) */
39
+export function sortShopTabsWithHot(list) {
40
+  if (!Array.isArray(list) || !list.length) return []
41
+  return [...list].sort((a, b) => {
42
+    if (!!a.isHot !== !!b.isHot) return a.isHot ? -1 : 1
43
+    const sa = Number(a.sortNo) || 0
44
+    const sb = Number(b.sortNo) || 0
45
+    return sa - sb
46
+  })
47
+}
48
+
5 49
 /** 搜索店铺卡片 */
6 50
 export function mapShopCard(row) {
7 51
   if (!row) return null

+ 20 - 0
shop-app/utils/shopNav.js

@@ -0,0 +1,20 @@
1
+import { PAGE_SHOP_HOME, PAGE_SHOP_SEARCH } from '@/utils/pageRoute'
2
+
3
+/** 跳转店铺主页 */
4
+export function goShopHome(shopId) {
5
+  if (!shopId) return
6
+  uni.navigateTo({
7
+    url: `${PAGE_SHOP_HOME}?shopId=${shopId}`
8
+  })
9
+}
10
+
11
+/** 跳转店内搜索页 */
12
+export function goShopSearch(shopId, keyword) {
13
+  if (!shopId) return
14
+  let url = `${PAGE_SHOP_SEARCH}?shopId=${shopId}`
15
+  const kw = (keyword || '').trim()
16
+  if (kw) {
17
+    url += `&keyword=${encodeURIComponent(kw)}`
18
+  }
19
+  uni.navigateTo({ url })
20
+}