xsh_1997 2 тижнів тому
батько
коміт
c4cdd4bb58

+ 174 - 0
doc/消费者APP/我的店铺关注/我的店铺关注前端技术方案.md

@@ -0,0 +1,174 @@
1
+# 我的店铺关注 — 前端技术方案(C 端 · shop-app)
2
+
3
+> **依据:** 《我的店铺关注功能需求.md》v1.0、《我的店铺关注技术方案.md》v1.0(仅作接口对照,**本文档独立维护**)  
4
+> **关联:** 《店铺主页前端技术方案》、《我的服务前端技术方案》  
5
+> **范围:** C 端 **已关注店铺列表、进店、列表内取消关注**;**不** 改后端、**不** 在本模块新增关注(在店铺主页完成)。  
6
+> **实现状态:** 页面与 API 封装已落地,待与 `GET /api/member/shop-follows` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | uni-app **Vue 3** + **uview-plus** |
15
+| 请求 | `@/utils/request`;列表为 **`TableDataInfo`**(`rows` + `total`) |
16
+| 鉴权 | **须登录**;`ensureApiToken` / `navigateMinePage` 与我的服务 **MS0** 一致 |
17
+| 写操作 | 取消关注 **复用** `DELETE /api/shop/{shopId}/follow`(`api/shop.js`) |
18
+| 展示映射 | `mapShopFollowList`(`utils/shopDisplay.js`),字段口径与店铺主页一致 |
19
+| 导航返回 | `SafeNavBar` + `smartNavigateBack`(从 Tab「我的」进入分包) |
20
+
21
+---
22
+
23
+## 2. 页面与路由
24
+
25
+| 页面 | 需求代号 | 路径 | 入口 |
26
+|------|----------|------|------|
27
+| 我的店铺关注列表 | **A** | `subpackage/account/shop-follow-list` | 我的 Tab →「我的店铺关注」 |
28
+
29
+**`pages.json`:** 分包 `pkg-account`,`navigationStyle: custom`(与入驻列表一致)
30
+
31
+**路径常量:** `PAGE_SHOP_FOLLOW_LIST`(`utils/pageRoute.js`)
32
+
33
+**无 Query 参数**;未登录由 `ensureApiToken` 拦截并引导登录。
34
+
35
+---
36
+
37
+## 3. 文件清单
38
+
39
+| 类型 | 路径 | 说明 |
40
+|------|------|------|
41
+| 列表页 | `shop-app/subpackage/account/shop-follow-list.vue` | 下拉刷新、上拉分页、空态、取关 |
42
+| 列表 UI | `shop-app/components/shop/ShopFollowList.vue` | 店铺卡片 +「取消关注」 |
43
+| 会员 API | `shop-app/api/member.js` | `getShopFollowList` |
44
+| 店铺 API | `shop-app/api/shop.js` | `unfollowShop`(复用) |
45
+| 常量 | `shop-app/constants/shopFollow.js` | 分页大小、空态文案 |
46
+| 我的 Tab | `shop-app/pages/mine/index.vue` | 菜单「我的店铺关注」 |
47
+
48
+---
49
+
50
+## 4. 接口封装
51
+
52
+### 4.1 列表(本模块)
53
+
54
+| 方法 | HTTP | 路径 | 鉴权 |
55
+|------|------|------|------|
56
+| `getShopFollowList(params)` | GET | `/api/member/shop-follows` | **须 Token** |
57
+
58
+**Query:** `pageNum`、`pageSize`(默认 10,`SHOP_FOLLOW_PAGE_SIZE`)
59
+
60
+**`rows` 元素映射为:**
61
+
62
+| 字段 | 前端用途 |
63
+|------|----------|
64
+| shopId | 进店、取关 |
65
+| shopName | 卡片标题 |
66
+| shopAvatar | `displayAvatar` |
67
+| shopStatus | `isClosed` →「休息中」标签 |
68
+| rating | `showRating` / `ratingText` |
69
+| fansCount | 粉丝展示 |
70
+| followTime | 仅排序依据,**UI 不展示** |
71
+
72
+### 4.2 取消关注(复用店铺主页)
73
+
74
+| 方法 | HTTP | 路径 |
75
+|------|------|------|
76
+| `unfollowShop(shopId)` | DELETE | `/api/shop/{shopId}/follow` |
77
+
78
+成功后 **本地移除** 该项(可选再调列表刷新;当前实现为本地 filter)。
79
+
80
+### 4.3 进店
81
+
82
+| 方法 | 说明 |
83
+|------|------|
84
+| `goShopHome(shopId)` | `utils/shopNav.js` → 店铺主页 |
85
+
86
+---
87
+
88
+## 5. 页面逻辑(`shop-follow-list.vue`)
89
+
90
+```text
91
+onShow
92
+  → ensureApiToken(false) 未登录则 return
93
+  → reloadList(true) 拉第一页
94
+
95
+下拉刷新
96
+  → refresherrefresh → reloadList(true)
97
+
98
+上拉触底
99
+  → scrolltolower → fetchPage(false)
100
+
101
+点击卡片
102
+  → goShopHome(shopId)
103
+
104
+点击「取消关注」
105
+  → showModal 确认
106
+  → unfollowShop
107
+  → 成功:列表移除该项 + Toast
108
+
109
+空列表
110
+  → 「暂无关注的店铺」+ 按钮「去逛逛」→ switchTab 首页
111
+```
112
+
113
+| 规则编号 | 前端实现 |
114
+|----------|----------|
115
+| SF0 / SF2 | `ensureApiToken`、`navigateMinePage` |
116
+| SF3 | 后端 `ORDER BY create_time DESC` |
117
+| SF5 | 整卡点击进店 |
118
+| SF6 | 本页 **无** 关注按钮 |
119
+| SF7 | 已删店由后端 JOIN 过滤 |
120
+| SF8 | 取关后项消失 |
121
+| 停业展示 | `shopStatus=1` 显示「休息中」,仍可进店 |
122
+
123
+---
124
+
125
+## 6. 组件说明(`ShopFollowList.vue`)
126
+
127
+- 布局对齐 `components/search/ShopList.vue`,增加 **取消关注**(`@click.stop` 防冒泡)
128
+- 停业标签文案:`SHOP_CLOSED_LABEL`(`constants/shop.js`)
129
+
130
+---
131
+
132
+## 7. 异常与空态
133
+
134
+| 情形 | 行为 |
135
+|------|------|
136
+| 从未关注 | `u-empty` +「去逛逛」 |
137
+| 首屏加载失败 | 错误区 +「点击重试」 |
138
+| 取关失败 | request Toast;**不** 移除列表项 |
139
+| Token 失效 | request 401 → 登录引导(全局拦截) |
140
+
141
+---
142
+
143
+## 8. 联调检查清单
144
+
145
+- [ ] 登录后 `GET /api/member/shop-follows` 返回分页数据
146
+- [ ] 关注时间倒序与店主页关注后刷新可见
147
+- [ ] 点击进店跳转 `subpackage/shop/index?shopId=`
148
+- [ ] 列表取关后粉丝数在店主页同步(刷新店主页)
149
+- [ ] 已删店不出现在列表
150
+- [ ] 停业店显示「休息中」且可进店
151
+- [ ] 未登录从「我的」点菜单 → 引导登录
152
+
153
+---
154
+
155
+## 9. 非本期(前端不实现)
156
+
157
+| 项 | 说明 |
158
+|------|------|
159
+| 列表内搜索/筛选 | 需求未要求 |
160
+| 本页新增关注 | 仅店铺主页 |
161
+| 批量取消关注 | 未要求 |
162
+| 展示关注时间 | UI 可不展示 |
163
+
164
+---
165
+
166
+## 10. 修订记录
167
+
168
+| 版本 | 说明 |
169
+|------|------|
170
+| **v1.0** | 首版:关注列表页、分页、下拉刷新、取关、我的 Tab 入口 |
171
+
172
+---
173
+
174
+*文档版本:v1.0 · 不修改《我的店铺关注技术方案.md》(后端方案)。*

+ 3 - 1
shop-app/PAGES.md

@@ -23,7 +23,8 @@ subpackage/
23 23
 │   ├── address-edit.vue
24 24
 │   ├── entry-apply.vue
25 25
 │   ├── entry-list.vue
26
-│   └── entry-detail.vue
26
+│   ├── entry-detail.vue
27
+│   └── shop-follow-list.vue
27 28
 ├── category/            # 分类子页
28 29
 │   ├── level1.vue
29 30
 │   └── goods-list.vue
@@ -60,6 +61,7 @@ subpackage/
60 61
 | `PAGE_HOME` | `/pages/index/index` |
61 62
 | `PAGE_GOODS_DETAIL` | `/subpackage/goods/detail` |
62 63
 | `PAGE_SEARCH_INDEX` | `/subpackage/search/index` |
64
+| `PAGE_SHOP_FOLLOW_LIST` | `/subpackage/account/shop-follow-list` |
63 65
 | `PAGE_SHOP_HOME` | `/subpackage/shop/index` |
64 66
 | `PAGE_SHOP_SEARCH` | `/subpackage/shop/search` |
65 67
 | `PAGE_REGISTER` | `/subpackage/account/register` |

+ 12 - 0
shop-app/api/member.js

@@ -101,3 +101,15 @@ export function setDefaultAddress(addressId) {
101 101
     method: 'PUT'
102 102
   })
103 103
 }
104
+
105
+/**
106
+ * 我的店铺关注列表(须登录)
107
+ * @returns {Promise<{code,msg,rows,total}>}
108
+ */
109
+export function getShopFollowList(params) {
110
+  return request({
111
+    url: '/api/member/shop-follows',
112
+    method: 'GET',
113
+    params
114
+  })
115
+}

+ 103 - 0
shop-app/components/shop/ShopFollowList.vue

@@ -0,0 +1,103 @@
1
+<template>
2
+	<view class="follow-list">
3
+		<view
4
+			v-for="item in list"
5
+			:key="item.shopId"
6
+			class="follow-card"
7
+			@click="emit('item-click', item)"
8
+		>
9
+			<image class="follow-card__avatar" :src="item.displayAvatar" mode="aspectFill" />
10
+			<view class="follow-card__body">
11
+				<view class="follow-card__title-row">
12
+					<text class="follow-card__name">{{ item.shopName }}</text>
13
+					<text v-if="item.isClosed" class="follow-card__tag">{{ closedLabel }}</text>
14
+				</view>
15
+				<view v-if="item.showRating || item.showFans" class="follow-card__meta">
16
+					<text v-if="item.showRating" class="follow-card__meta-item">评分 {{ item.ratingText }}</text>
17
+					<text v-if="item.showFans" class="follow-card__meta-item">粉丝 {{ item.fansCount }}</text>
18
+				</view>
19
+			</view>
20
+			<text class="follow-card__unfollow" @click.stop="emit('unfollow', item)">取消关注</text>
21
+		</view>
22
+	</view>
23
+</template>
24
+
25
+<script setup>
26
+import { SHOP_CLOSED_LABEL } from '@/constants/shop'
27
+
28
+defineProps({
29
+	list: {
30
+		type: Array,
31
+		default: () => []
32
+	}
33
+})
34
+
35
+const emit = defineEmits(['item-click', 'unfollow'])
36
+const closedLabel = SHOP_CLOSED_LABEL
37
+</script>
38
+
39
+<style lang="scss" scoped>
40
+.follow-list {
41
+	padding: 0 24rpx;
42
+}
43
+.follow-card {
44
+	display: flex;
45
+	align-items: center;
46
+	padding: 24rpx;
47
+	margin-bottom: 16rpx;
48
+	background: #fff;
49
+	border-radius: 12rpx;
50
+	box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
51
+}
52
+.follow-card__avatar {
53
+	width: 96rpx;
54
+	height: 96rpx;
55
+	border-radius: 12rpx;
56
+	background: #eee;
57
+	flex-shrink: 0;
58
+}
59
+.follow-card__body {
60
+	flex: 1;
61
+	margin: 0 16rpx;
62
+	min-width: 0;
63
+}
64
+.follow-card__title-row {
65
+	display: flex;
66
+	align-items: center;
67
+	flex-wrap: wrap;
68
+	gap: 8rpx;
69
+}
70
+.follow-card__name {
71
+	font-size: 30rpx;
72
+	color: #333;
73
+	font-weight: 500;
74
+	overflow: hidden;
75
+	text-overflow: ellipsis;
76
+	white-space: nowrap;
77
+	max-width: 280rpx;
78
+}
79
+.follow-card__tag {
80
+	font-size: 22rpx;
81
+	color: #e65100;
82
+	background: #fff3e0;
83
+	padding: 4rpx 10rpx;
84
+	border-radius: 6rpx;
85
+	flex-shrink: 0;
86
+}
87
+.follow-card__meta {
88
+	margin-top: 8rpx;
89
+	display: flex;
90
+	flex-wrap: wrap;
91
+	gap: 16rpx;
92
+}
93
+.follow-card__meta-item {
94
+	font-size: 24rpx;
95
+	color: #999;
96
+}
97
+.follow-card__unfollow {
98
+	flex-shrink: 0;
99
+	font-size: 24rpx;
100
+	color: #999;
101
+	padding: 8rpx 0 8rpx 8rpx;
102
+}
103
+</style>

+ 5 - 0
shop-app/constants/shopFollow.js

@@ -0,0 +1,5 @@
1
+/** 我的店铺关注列表每页条数 */
2
+export const SHOP_FOLLOW_PAGE_SIZE = 10
3
+
4
+/** 空列表文案 */
5
+export const SHOP_FOLLOW_EMPTY_TEXT = '暂无关注的店铺'

+ 7 - 0
shop-app/pages.json

@@ -96,6 +96,13 @@
96 96
 						"navigationBarTitleText": "申请详情",
97 97
 						"navigationStyle": "custom"
98 98
 					}
99
+				},
100
+				{
101
+					"path": "shop-follow-list",
102
+					"style": {
103
+						"navigationBarTitleText": "我的店铺关注",
104
+						"navigationStyle": "custom"
105
+					}
99 106
 				}
100 107
 			]
101 108
 		},

+ 6 - 1
shop-app/pages/mine/index.vue

@@ -58,7 +58,8 @@ import {
58 58
   PAGE_PASSWORD,
59 59
   PAGE_ADDRESS_LIST,
60 60
   PAGE_ENTRY_APPLY,
61
-  PAGE_ENTRY_LIST
61
+  PAGE_ENTRY_LIST,
62
+  PAGE_SHOP_FOLLOW_LIST
62 63
 } from '@/utils/pageRoute'
63 64
 
64 65
 const loggedIn = ref(false)
@@ -86,6 +87,10 @@ const menuSections = [
86 87
     title: '收货地址',
87 88
     items: [{ label: '收货地址', path: PAGE_ADDRESS_LIST, icon: 'map' }]
88 89
   },
90
+  {
91
+    title: '店铺关注',
92
+    items: [{ label: '我的店铺关注', path: PAGE_SHOP_FOLLOW_LIST, icon: 'heart' }]
93
+  },
89 94
   {
90 95
     title: '商家入驻',
91 96
     items: [

+ 215 - 0
shop-app/subpackage/account/shop-follow-list.vue

@@ -0,0 +1,215 @@
1
+<template>
2
+	<view class="follow-page-wrap">
3
+		<safe-nav-bar title="我的店铺关注" />
4
+		<view class="follow-page">
5
+			<scroll-view
6
+				class="follow-page__scroll"
7
+				scroll-y
8
+				:style="{ height: scrollHeight }"
9
+				refresher-enabled
10
+				:refresher-triggered="refreshing"
11
+				@refresherrefresh="onPullRefresh"
12
+				@scrolltolower="onLoadMore"
13
+			>
14
+				<view v-if="loading && !list.length" class="follow-page__loading">
15
+					<u-loading-icon mode="circle" />
16
+				</view>
17
+
18
+				<view v-else-if="loadFailed && !list.length" class="follow-page__error">
19
+					<u-empty mode="list" text="加载失败" icon-size="80" />
20
+					<text class="follow-page__retry" @click="reloadList(true)">点击重试</text>
21
+				</view>
22
+
23
+				<view v-else-if="!list.length" class="follow-empty">
24
+					<u-empty mode="list" :text="emptyText" icon-size="80" />
25
+					<button class="mine-btn-primary follow-empty__btn" @click="goHome">去逛逛</button>
26
+				</view>
27
+
28
+				<template v-else>
29
+					<shop-follow-list
30
+						:list="list"
31
+						@item-click="onShopClick"
32
+						@unfollow="onUnfollow"
33
+					/>
34
+					<view class="follow-page__footer">
35
+						<text v-if="loadingMore">加载中...</text>
36
+						<text v-else-if="finished">没有更多了</text>
37
+					</view>
38
+				</template>
39
+			</scroll-view>
40
+		</view>
41
+	</view>
42
+</template>
43
+
44
+<script setup>
45
+import { ref } from 'vue'
46
+import { onShow } from '@dcloudio/uni-app'
47
+import SafeNavBar from '@/components/common/SafeNavBar.vue'
48
+import ShopFollowList from '@/components/shop/ShopFollowList.vue'
49
+import { getShopFollowList } from '@/api/member'
50
+import { unfollowShop } from '@/api/shop'
51
+import { mapShopFollowList } from '@/utils/shopDisplay'
52
+import { ensureApiToken } from '@/utils/apiAuth'
53
+import { goShopHome } from '@/utils/shopNav'
54
+import { SHOP_FOLLOW_PAGE_SIZE, SHOP_FOLLOW_EMPTY_TEXT } from '@/constants/shopFollow'
55
+import { PAGE_HOME } from '@/utils/pageRoute'
56
+
57
+const list = ref([])
58
+const loading = ref(false)
59
+const loadingMore = ref(false)
60
+const finished = ref(false)
61
+const loadFailed = ref(false)
62
+const refreshing = ref(false)
63
+const pageNum = ref(1)
64
+const scrollHeight = ref('600px')
65
+const emptyText = SHOP_FOLLOW_EMPTY_TEXT
66
+const unfollowLoading = ref(false)
67
+
68
+function calcScrollHeight() {
69
+	try {
70
+		const sys = uni.getSystemInfoSync()
71
+		const h = sys.windowHeight || 600
72
+		// 自定义导航栏 + 状态栏约 88px
73
+		scrollHeight.value = `${h - 88}px`
74
+	} catch (e) {
75
+		scrollHeight.value = '600px'
76
+	}
77
+}
78
+
79
+async function fetchPage(isReset) {
80
+	if (isReset) {
81
+		loading.value = true
82
+		loadFailed.value = false
83
+		pageNum.value = 1
84
+		finished.value = false
85
+	} else {
86
+		if (finished.value || loadingMore.value) return
87
+		loadingMore.value = true
88
+	}
89
+	try {
90
+		const res = await getShopFollowList({
91
+			pageNum: pageNum.value,
92
+			pageSize: SHOP_FOLLOW_PAGE_SIZE
93
+		})
94
+		const rows = mapShopFollowList(res.rows || [])
95
+		const total = Number(res.total) || 0
96
+		if (isReset) {
97
+			list.value = rows
98
+		} else {
99
+			list.value = list.value.concat(rows)
100
+		}
101
+		finished.value =
102
+			rows.length < SHOP_FOLLOW_PAGE_SIZE || (total > 0 && list.value.length >= total)
103
+		if (!finished.value) {
104
+			pageNum.value += 1
105
+		}
106
+	} catch (e) {
107
+		loadFailed.value = true
108
+		if (isReset) {
109
+			list.value = []
110
+		}
111
+	} finally {
112
+		loading.value = false
113
+		loadingMore.value = false
114
+		refreshing.value = false
115
+	}
116
+}
117
+
118
+function reloadList(isReset) {
119
+	if (isReset) {
120
+		pageNum.value = 1
121
+	}
122
+	fetchPage(isReset)
123
+}
124
+
125
+function onPullRefresh() {
126
+	refreshing.value = true
127
+	reloadList(true)
128
+}
129
+
130
+function onLoadMore() {
131
+	if (!loading.value && !loadingMore.value && !finished.value && list.value.length) {
132
+		fetchPage(false)
133
+	}
134
+}
135
+
136
+function onShopClick(item) {
137
+	if (item && item.shopId) {
138
+		goShopHome(item.shopId)
139
+	}
140
+}
141
+
142
+function onUnfollow(item) {
143
+	if (!item || !item.shopId || unfollowLoading.value) return
144
+	uni.showModal({
145
+		title: '提示',
146
+		content: `确定取消关注「${item.shopName || '该店铺'}」吗?`,
147
+		success: (res) => {
148
+			if (!res.confirm) return
149
+			unfollowLoading.value = true
150
+			unfollowShop(item.shopId)
151
+				.then(() => {
152
+					list.value = list.value.filter((row) => row.shopId !== item.shopId)
153
+					uni.showToast({ title: '已取消关注', icon: 'none' })
154
+				})
155
+				.catch(() => {})
156
+				.finally(() => {
157
+					unfollowLoading.value = false
158
+				})
159
+		}
160
+	})
161
+}
162
+
163
+function goHome() {
164
+	uni.switchTab({ url: PAGE_HOME })
165
+}
166
+
167
+onShow(() => {
168
+	if (!ensureApiToken(false)) return
169
+	calcScrollHeight()
170
+	reloadList(true)
171
+})
172
+</script>
173
+
174
+<style lang="scss" scoped>
175
+@import '@/styles/mine.scss';
176
+
177
+.follow-page-wrap {
178
+	min-height: 100vh;
179
+	background: #f5f6f8;
180
+}
181
+.follow-page {
182
+	flex: 1;
183
+}
184
+.follow-page__scroll {
185
+	width: 100%;
186
+}
187
+.follow-page__loading,
188
+.follow-page__error {
189
+	padding: 80rpx 24rpx;
190
+	display: flex;
191
+	flex-direction: column;
192
+	align-items: center;
193
+}
194
+.follow-page__retry {
195
+	margin-top: 24rpx;
196
+	font-size: 28rpx;
197
+	color: #2e7d32;
198
+}
199
+.follow-empty {
200
+	padding: 80rpx 48rpx;
201
+	display: flex;
202
+	flex-direction: column;
203
+	align-items: center;
204
+}
205
+.follow-empty__btn {
206
+	margin-top: 40rpx;
207
+	width: 320rpx;
208
+}
209
+.follow-page__footer {
210
+	padding: 24rpx;
211
+	text-align: center;
212
+	font-size: 24rpx;
213
+	color: #999;
214
+}
215
+</style>

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

@@ -36,6 +36,8 @@ export const PAGE_ENTRY_APPLY = '/subpackage/account/entry-apply'
36 36
 export const PAGE_ENTRY_LIST = '/subpackage/account/entry-list'
37 37
 /** 入驻申请详情 */
38 38
 export const PAGE_ENTRY_DETAIL = '/subpackage/account/entry-detail'
39
+/** 我的店铺关注列表 */
40
+export const PAGE_SHOP_FOLLOW_LIST = '/subpackage/account/shop-follow-list'
39 41
 
40 42
 // —— 分包:分类 ——
41 43
 

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

@@ -66,3 +66,28 @@ export function mapShopCardList(list) {
66 66
   if (!Array.isArray(list)) return []
67 67
   return list.map(mapShopCard).filter(Boolean)
68 68
 }
69
+
70
+/** 我的店铺关注列表行 */
71
+export function mapShopFollowItem(row) {
72
+  if (!row) return null
73
+  const rating = row.rating
74
+  const fansCount = row.fansCount != null ? Number(row.fansCount) : 0
75
+  return {
76
+    shopId: row.shopId,
77
+    shopName: row.shopName || '',
78
+    shopAvatar: row.shopAvatar,
79
+    displayAvatar: resolveFileUrl(row.shopAvatar) || SHOP_PLACEHOLDER,
80
+    shopStatus: row.shopStatus,
81
+    isClosed: String(row.shopStatus) === SHOP_STATUS_CLOSED,
82
+    rating,
83
+    fansCount,
84
+    showRating: rating != null && rating !== '',
85
+    ratingText: rating != null && rating !== '' ? String(rating) : '',
86
+    followTime: row.followTime
87
+  }
88
+}
89
+
90
+export function mapShopFollowList(list) {
91
+  if (!Array.isArray(list)) return []
92
+  return list.map(mapShopFollowItem).filter(Boolean)
93
+}