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

+ 218 - 0
doc/消费者APP/会员注册登录/会员注册登录前端技术方案.md

@@ -0,0 +1,218 @@
1
+# 会员注册登录 — 前端技术方案(C 端 · shop-app)
2
+
3
+> **依据:** 《会员注册登录技术方案.md》v1.2  
4
+> **关联:** 《商品详情内页前端技术方案》(加购登录引导)、《搜索页前端技术方案》  
5
+> **范围:** 消费者 APP **会员注册、登录、服务协议勾选**;**不** 改后端、**不** 实现短信验证码、忘记密码、资料/地址编辑(另册)。  
6
+> **实现状态:** 页面与 API 已落地,待与 `/api/member/**` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | uni-app **Vue 3** + **uview-plus** |
15
+| 请求 | `@/utils/request`;成功 `code=200`,业务数据在 `data` |
16
+| Token | `config` → `TOKEN_KEY = shop-Admin-Token`;请求头 `Authorization: Bearer {token}` |
17
+| 登录账号 | **手机号** 或 **会员名称**(`memberCode`,产品所称「会员 ID」) |
18
+| 注册 | **无短信验证码**;`mobile + password + confirmPassword` |
19
+| 样式 | 登录/注册共用 `styles/auth.scss` |
20
+
21
+---
22
+
23
+## 2. 页面与路由
24
+
25
+| 页面 | 路径 | 主包/分包 | 入口 |
26
+|------|------|-----------|------|
27
+| 会员登录 | `pages/login/index` | **主包** | 我的、加购拦截、401 |
28
+| 会员注册 | `subpackage/account/register` | **分包** | 登录页「立即注册」、我的 |
29
+
30
+**Query:**
31
+
32
+| 页面 | 参数 | 说明 |
33
+|------|------|------|
34
+| login | `account`(可选) | 注册成功或记住账号预填 |
35
+| register | `mobile`(可选) | 从登录页带手机号 |
36
+
37
+**路径常量:** `utils/pageRoute.js` → `PAGE_LOGIN`、`PAGE_REGISTER`
38
+
39
+---
40
+
41
+## 3. 文件清单
42
+
43
+| 类型 | 路径 | 说明 |
44
+|------|------|------|
45
+| 登录页 | `shop-app/pages/login/index.vue` | 账号+密码+协议+记住账号 |
46
+| 注册页 | `shop-app/subpackage/account/register.vue` | 手机号注册表单 |
47
+| 协议块 | `shop-app/components/account/AgreementBlock.vue` | 勾选 + 弹窗 **`rich-text`** 渲染(同商品详情) |
48
+| HTML 预处理 | `shop-app/utils/htmlContent.js` | 实体解码、图片 URL、空 `<p>` 清理 |
49
+| API | `shop-app/api/member.js` | register / login / serviceAgreement / profile |
50
+| 状态 | `shop-app/store/user.js` | 登录态、资料缓存 |
51
+| 协议加载 | `shop-app/utils/memberAgreement.js` | 初始化协议配置 |
52
+| 校验 | `shop-app/utils/memberValidate.js` | 手机号、密码 |
53
+| 登录引导 | `shop-app/utils/apiAuth.js` | `ensureApiToken` → 登录页 |
54
+| 样式 | `shop-app/styles/auth.scss` | 登录/注册卡片样式 |
55
+| 我的 | `shop-app/pages/mine/index.vue` | 登录/注册入口、退出 |
56
+
57
+> `api/login.js`(`/login`、`/captchaImage`)已 **废弃**,勿再用于 C 端。
58
+
59
+---
60
+
61
+## 4. 接口封装
62
+
63
+**模块:** `shop-app/api/member.js`
64
+
65
+| 方法 | HTTP | 路径 | 鉴权 |
66
+|------|------|------|------|
67
+| `memberRegister(data)` | POST | `/api/member/register` | 匿名 |
68
+| `memberLogin(data)` | POST | `/api/member/login` | 匿名 |
69
+| `getMemberServiceAgreement()` | GET | `/api/member/serviceAgreement` | 匿名 |
70
+| `getMemberProfile()` | GET | `/api/member/profile` | Token |
71
+
72
+### 4.1 注册 Body
73
+
74
+| 字段 | 必填 | 前端 |
75
+|------|:----:|------|
76
+| mobile | 是 | 11 位手机号校验 |
77
+| password | 是 | 至少 6 位 |
78
+| confirmPassword | 是 | 与 password 一致 |
79
+| memberCode | 否 | 会员名称,空则后端自动生成 |
80
+| agreementAccepted | 条件 | 协议 `enabled` 时须勾选 |
81
+
82
+### 4.2 登录 Body
83
+
84
+| 字段 | 必填 | 前端 |
85
+|------|:----:|------|
86
+| account | 是 | 手机号或会员名称 |
87
+| password | 是 | — |
88
+| agreementAccepted | 条件 | `requireAgreementOnLogin` 为 true 时须勾选 |
89
+
90
+**成功 `data`:** `{ token, memberId }`(`memberId` 为系统主键,**不能**当登录账号)。
91
+
92
+### 4.3 服务协议 `data` 要点
93
+
94
+| 字段 | 用途 |
95
+|------|------|
96
+| enabled | 是否展示协议勾选与正文 |
97
+| registrationOpen | 注册页是否开放 |
98
+| requireAgreementOnLogin | 登录是否须勾选 |
99
+| checkboxLabel / content | 勾选文案、弹窗 HTML |
100
+
101
+---
102
+
103
+## 5. 页面结构
104
+
105
+### 5.1 登录 `pages/login/index`
106
+
107
+```text
108
+顶栏品牌区
109
+└── 卡片
110
+    ├── 账号(手机号或会员名称)
111
+    ├── 密码
112
+    ├── 记住账号(localStorage: shop_login_account)
113
+    ├── AgreementBlock(协议启用且 requireAgreementOnLogin 时显示)
114
+    ├── 登录按钮 → memberLogin → fetchUserInfo → switchTab 首页
115
+    └── 立即注册 → subpackage/account/register
116
+```
117
+
118
+| 规则 | 实现 |
119
+|------|------|
120
+| RL3 | 单输入框 `account` |
121
+| RL4 | 错误文案由后端统一返回,前端 Toast `msg` |
122
+| RL6 | `needAgreement` 计算属性控制勾选 |
123
+| 已登录进页 | 有 Token 直接 `switchTab` 首页 |
124
+
125
+### 5.2 注册 `subpackage/account/register`
126
+
127
+```text
128
+顶栏「会员注册」
129
+└── 卡片
130
+    ├── registrationOpen=false → 展示关闭提示
131
+    └── 开放时:
132
+        ├── 手机号、密码、确认密码
133
+        ├── 会员名称(选填)
134
+        ├── AgreementBlock(协议 enabled 时)
135
+        ├── 注册 → 成功 Toast → 带 mobile 跳转登录页
136
+        └── 去登录
137
+```
138
+
139
+| 规则 | 实现 |
140
+|------|------|
141
+| RL1 | **无** 验证码表单项 |
142
+| RL2 | 前端校验两次密码一致 |
143
+| 注册关闭 | 展示 `agreement.message` 或默认文案 |
144
+
145
+### 5.3 我的 `pages/mine/index`
146
+
147
+| 状态 | UI |
148
+|------|-----|
149
+| 未登录 | 「登录」「注册会员」 |
150
+| 已登录 | 昵称/会员名、手机号、`/api/member/profile` 头像、退出 |
151
+
152
+---
153
+
154
+## 6. 登录态与跳转
155
+
156
+```text
157
+memberLogin 成功
158
+    → setToken(token)
159
+    → getMemberProfile() 写入 store
160
+    → 业务请求带 Authorization
161
+
162
+ensureApiToken() 无 Token
163
+    → Toast「请先登录」
164
+    → navigateTo PAGE_LOGIN
165
+
166
+request 401(需登录接口)
167
+    → fedLogOut + reLaunch 登录页
168
+```
169
+
170
+**退出:** 仅本地 `fedLogOut`(后端无 C 端 logout 接口)。
171
+
172
+---
173
+
174
+## 7. 业务规则对照
175
+
176
+| 编号 | 前端落实 |
177
+|------|----------|
178
+| RL1 | 注册页无验证码 |
179
+| RL2 | 确认密码前端校验 |
180
+| RL3 | 登录 `account` 字段 |
181
+| RL4 | 不区分「用户不存在/密码错误」展示 |
182
+| RL6 | `AgreementBlock` + 提交前校验 |
183
+| RL8 | UI 提示「会员名称」;`memberId` 仅展示在资料区(若有) |
184
+
185
+---
186
+
187
+## 8. 联调检查清单
188
+
189
+- [ ] `GET /api/member/serviceAgreement` 返回开关与正文
190
+- [ ] `POST /api/member/register` 成功 → 登录页预填手机号
191
+- [ ] `POST /api/member/login` 手机号 / 会员名称均可登录
192
+- [ ] 协议未勾选时注册/登录被后端拒绝且 Toast 文案正确
193
+- [ ] 登录后 `GET /api/member/profile` 我的页展示昵称/手机号
194
+- [ ] 商品详情加购无 Token 跳转登录页
195
+- [ ] Token 过期 401 跳转登录且本地 Token 清除
196
+
197
+---
198
+
199
+## 9. 非本期
200
+
201
+| 项 | 说明 |
202
+|----|------|
203
+| 短信验证码 | 不做 |
204
+| 忘记密码 / 换绑手机 | 不做 |
205
+| 资料编辑 / 地址管理 | 另册;接口已存在 `/profile`、`/address/**` |
206
+| 微信 OAuth | 不做 |
207
+
208
+---
209
+
210
+## 10. 修订记录
211
+
212
+| 版本 | 说明 |
213
+|------|------|
214
+| **v1.0** | 首版:会员登录改造、注册分包页、协议组件、我的页入口 |
215
+
216
+---
217
+
218
+*文档版本:v1.0 · 关联《会员注册登录技术方案.md》v1.2*

+ 1 - 1
doc/消费者APP/会员注册登录/会员注册登录技术方案.md

@@ -298,7 +298,7 @@ MemberAppServiceImpl.login
298 298
 | `POST /login`(account:手机号/会员名称) | **已实现** |
299 299
 | `BizMemberMapper.selectByMemberCode` | **已实现** |
300 300
 | `POST /sms/send` | **保留代码、非本期** |
301
-| C 端注册/登录 **前端** | **未实现** |
301
+| C 端注册/登录 **前端** | **已实现**(见《会员注册登录前端技术方案.md》) |
302 302
 
303 303
 ---
304 304
 

+ 3 - 0
shop-app/PAGES.md

@@ -15,6 +15,8 @@ pages/
15 15
 
16 16
 ```
17 17
 subpackage/
18
+├── account/             # 会员
19
+│   └── register.vue
18 20
 ├── category/            # 分类子页
19 21
 │   ├── level1.vue
20 22
 │   └── goods-list.vue
@@ -49,5 +51,6 @@ subpackage/
49 51
 | `PAGE_HOME` | `/pages/index/index` |
50 52
 | `PAGE_GOODS_DETAIL` | `/subpackage/goods/detail` |
51 53
 | `PAGE_SEARCH_INDEX` | `/subpackage/search/index` |
54
+| `PAGE_REGISTER` | `/subpackage/account/register` |
52 55
 
53 56
 配置详见 `pages.json` 中 `subPackages` 与 `preloadRule`。

+ 4 - 4
shop-app/api/login.js

@@ -1,6 +1,9 @@
1
+/**
2
+ * @deprecated C 端会员请使用 @/api/member.js(/api/member/login、/profile)
3
+ * 本文件保留仅为历史引用,新代码勿再使用 /login、/getInfo、/captchaImage。
4
+ */
1 5
 import request from '@/utils/request'
2 6
 
3
-/** 会员/用户登录 */
4 7
 export function login(username, password, code, uuid) {
5 8
   return request({
6 9
     url: '/login',
@@ -10,7 +13,6 @@ export function login(username, password, code, uuid) {
10 13
   })
11 14
 }
12 15
 
13
-/** 获取当前登录用户信息 */
14 16
 export function getInfo() {
15 17
   return request({
16 18
     url: '/getInfo',
@@ -18,7 +20,6 @@ export function getInfo() {
18 20
   })
19 21
 }
20 22
 
21
-/** 退出登录 */
22 23
 export function logout() {
23 24
   return request({
24 25
     url: '/logout',
@@ -26,7 +27,6 @@ export function logout() {
26 27
   })
27 28
 }
28 29
 
29
-/** 图形验证码 */
30 30
 export function getCodeImg() {
31 31
   return request({
32 32
     url: '/captchaImage',

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

@@ -0,0 +1,39 @@
1
+import request from '@/utils/request'
2
+
3
+/** 会员注册(匿名) */
4
+export function memberRegister(data) {
5
+  return request({
6
+    url: '/api/member/register',
7
+    method: 'POST',
8
+    header: { isToken: false, repeatSubmit: false },
9
+    data
10
+  })
11
+}
12
+
13
+/** 会员登录(匿名) */
14
+export function memberLogin(data) {
15
+  return request({
16
+    url: '/api/member/login',
17
+    method: 'POST',
18
+    header: { isToken: false, repeatSubmit: false },
19
+    data
20
+  })
21
+}
22
+
23
+/** 买家服务协议(注册/登录页初始化) */
24
+export function getMemberServiceAgreement() {
25
+  return request({
26
+    url: '/api/member/serviceAgreement',
27
+    method: 'GET',
28
+    header: { isToken: false },
29
+    silent: true
30
+  })
31
+}
32
+
33
+/** 会员资料(须登录) */
34
+export function getMemberProfile() {
35
+  return request({
36
+    url: '/api/member/profile',
37
+    method: 'GET'
38
+  })
39
+}

+ 190 - 0
shop-app/components/account/AgreementBlock.vue

@@ -0,0 +1,190 @@
1
+<template>
2
+	<view v-if="visible" class="agreement-card">
3
+		<view class="agreement-card__inner" @click="toggle">
4
+			<view :class="['agreement-card__box', { 'agreement-card__box--on': modelValue }]">
5
+				<u-icon v-if="modelValue" name="checkmark" color="#fff" size="12" />
6
+			</view>
7
+			<view class="agreement-card__text">
8
+				<template v-if="useStructured">
9
+					<text class="agreement-card__plain">我已阅读并同意</text>
10
+					<text
11
+						v-if="hasContent"
12
+						class="agreement-card__link"
13
+						@click.stop="openPopup"
14
+					>《{{ agreementTitle }}》</text>
15
+					<!-- <text v-if="displayVersion" class="agreement-card__ver">{{ displayVersion }}</text> -->
16
+				</template>
17
+				<text v-else class="agreement-card__fallback">{{ checkboxLabel }}</text>
18
+			</view>
19
+		</view>
20
+		<!-- <text v-if="hasContent && useStructured" class="agreement-card__action" @click="openPopup">
21
+			查看全文
22
+		</text> -->
23
+
24
+		<u-popup :show="popupShow" mode="bottom" round="16" @close="popupShow = false">
25
+			<view class="agreement-popup">
26
+				<view class="agreement-popup__head">
27
+					<text class="agreement-popup__title">{{ agreementTitle || '服务协议' }}</text>
28
+					<u-icon name="close" size="20" @click="popupShow = false" />
29
+				</view>
30
+				<scroll-view v-if="popupShow" class="agreement-popup__scroll" scroll-y>
31
+					<rich-text class="agreement-rich" :nodes="richHtml" />
32
+				</scroll-view>
33
+			</view>
34
+		</u-popup>
35
+	</view>
36
+</template>
37
+
38
+<script setup>
39
+import { ref, computed } from 'vue'
40
+import { prepareRichHtml } from '@/utils/htmlContent'
41
+
42
+const props = defineProps({
43
+	modelValue: { type: Boolean, default: false },
44
+	checkboxLabel: { type: String, default: '我已阅读并同意相关服务协议' },
45
+	agreementTitle: { type: String, default: '' },
46
+	versionLabel: { type: String, default: '' },
47
+	content: { type: String, default: '' },
48
+	enabled: { type: Boolean, default: true }
49
+})
50
+
51
+const emit = defineEmits(['update:modelValue'])
52
+
53
+const popupShow = ref(false)
54
+
55
+const visible = computed(() => props.enabled)
56
+const hasContent = computed(() => !!(props.content || '').trim())
57
+/** 有协议标题时用分行排版,避免整段 checkboxLabel 撑破布局 */
58
+const useStructured = computed(() => !!(props.agreementTitle || '').trim())
59
+
60
+/**
61
+ * 与商品详情一致用 rich-text 渲染 HTML(平台 Quill 输出)
62
+ * up-parse 在 Vue3 下内部仍用 this.$set,易出现标签被当纯文本显示
63
+ */
64
+const richHtml = computed(() => prepareRichHtml(props.content))
65
+
66
+function toggle() {
67
+	emit('update:modelValue', !props.modelValue)
68
+}
69
+
70
+function openPopup() {
71
+	if (hasContent.value) {
72
+		popupShow.value = true
73
+	}
74
+}
75
+</script>
76
+
77
+<style lang="scss" scoped>
78
+.agreement-card {
79
+	margin-top: 24rpx;
80
+	// padding: 20rpx 24rpx;
81
+	background: #f5faf6;
82
+	border: 1rpx solid #e8f5e9;
83
+	border-radius: 12rpx;
84
+	box-sizing: border-box;
85
+}
86
+
87
+.agreement-card__inner {
88
+	display: flex;
89
+	align-items: flex-start;
90
+}
91
+
92
+.agreement-card__box {
93
+	width: 36rpx;
94
+	height: 36rpx;
95
+	margin-top: 2rpx;
96
+	margin-right: 16rpx;
97
+	border: 2rpx solid #a5d6a7;
98
+	border-radius: 8rpx;
99
+	display: flex;
100
+	align-items: center;
101
+	justify-content: center;
102
+	flex-shrink: 0;
103
+	background: #fff;
104
+}
105
+
106
+.agreement-card__box--on {
107
+	background: #2e7d32;
108
+	border-color: #2e7d32;
109
+}
110
+
111
+.agreement-card__text {
112
+	flex: 1;
113
+	min-width: 0;
114
+	display: flex;
115
+	flex-wrap: wrap;
116
+	align-items: center;
117
+	line-height: 40rpx;
118
+}
119
+
120
+.agreement-card__plain,
121
+.agreement-card__ver {
122
+	font-size: 24rpx;
123
+	color: #666;
124
+}
125
+
126
+.agreement-card__ver {
127
+	margin-left: 4rpx;
128
+}
129
+
130
+.agreement-card__link {
131
+	font-size: 24rpx;
132
+	color: #2e7d32;
133
+	font-weight: 500;
134
+	text-decoration: underline;
135
+}
136
+
137
+.agreement-card__fallback {
138
+	font-size: 24rpx;
139
+	color: #666;
140
+	line-height: 1.6;
141
+	word-break: break-all;
142
+	white-space: normal;
143
+}
144
+
145
+.agreement-card__action {
146
+	display: block;
147
+	margin-top: 12rpx;
148
+	margin-left: 52rpx;
149
+	font-size: 24rpx;
150
+	color: #43a047;
151
+}
152
+
153
+.agreement-popup {
154
+	max-height: 70vh;
155
+	display: flex;
156
+	flex-direction: column;
157
+}
158
+
159
+.agreement-popup__head {
160
+	display: flex;
161
+	align-items: center;
162
+	justify-content: space-between;
163
+	padding: 28rpx 32rpx;
164
+	border-bottom: 1rpx solid #eee;
165
+}
166
+
167
+.agreement-popup__title {
168
+	flex: 1;
169
+	margin-right: 16rpx;
170
+	font-size: 30rpx;
171
+	font-weight: 600;
172
+	color: #333;
173
+}
174
+
175
+.agreement-popup__scroll {
176
+	max-height: 60vh;
177
+	padding: 24rpx 32rpx 48rpx;
178
+	box-sizing: border-box;
179
+}
180
+
181
+.agreement-rich {
182
+	display: block;
183
+	width: 100%;
184
+	font-size: 28rpx;
185
+	color: #444;
186
+	line-height: 1.7;
187
+	word-break: break-word;
188
+}
189
+
190
+</style>

+ 4 - 1
shop-app/config/index.js

@@ -52,4 +52,7 @@ export function joinApiUrl(path) {
52 52
 
53 53
 /** 交易市场项目专用,与 ruoyi-ui-app 的 Admin-Token 隔离,避免同端登录态串用 */
54 54
 export const TOKEN_KEY = 'shop-Admin-Token'
55
-export const REMEMBER_USERNAME_KEY = 'shop_login_username'
55
+/** 登录页「记住账号」(手机号或会员名称) */
56
+export const REMEMBER_ACCOUNT_KEY = 'shop_login_account'
57
+/** @deprecated 请用 REMEMBER_ACCOUNT_KEY */
58
+export const REMEMBER_USERNAME_KEY = REMEMBER_ACCOUNT_KEY

+ 12 - 0
shop-app/pages.json

@@ -42,6 +42,18 @@
42 42
 		}
43 43
 	],
44 44
 	"subPackages": [
45
+		{
46
+			"root": "subpackage/account",
47
+			"name": "pkg-account",
48
+			"pages": [
49
+				{
50
+					"path": "register",
51
+					"style": {
52
+						"navigationBarTitleText": "会员注册"
53
+					}
54
+				}
55
+			]
56
+		},
45 57
 		{
46 58
 			"root": "subpackage/category",
47 59
 			"name": "pkg-category",

+ 161 - 361
shop-app/pages/login/index.vue

@@ -1,422 +1,222 @@
1 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>
2
+	<view class="auth-page">
3
+		<view class="auth-hero">
4
+			<view class="auth-hero__brand">
5
+				<text class="auth-hero__app">巴青农资商城</text>
6
+				<text class="auth-hero__welcome">欢迎登录农资商城</text>
11 7
 			</view>
12 8
 		</view>
13 9
 
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>
10
+		<view class="auth-body">
11
+			<view class="auth-card">
12
+				<text class="auth-card__title">会员登录</text>
13
+				<text class="auth-card__sub">可使用手机号或会员名称登录</text>
19 14
 
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>
15
+				<view class="auth-field">
16
+					<view class="auth-field__box">
17
+						<u-icon name="account" color="#6b7f72" size="22" />
25 18
 						<input
26
-							v-model="form.username"
27
-							class="login-field__input"
19
+							v-model="form.account"
20
+							class="auth-field__input"
28 21
 							type="text"
29
-							placeholder="请输入账号"
30
-							placeholder-class="login-field__placeholder"
22
+							placeholder="手机号或会员名称"
23
+							placeholder-class="auth-field__placeholder"
31 24
 							confirm-type="next"
32 25
 						/>
33 26
 					</view>
34 27
 				</view>
35 28
 
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>
29
+				<view class="auth-field">
30
+					<view class="auth-field__box">
31
+						<u-icon name="lock" color="#6b7f72" size="22" />
41 32
 						<input
42 33
 							v-model="form.password"
43
-							class="login-field__input"
34
+							class="auth-field__input"
44 35
 							type="password"
45 36
 							placeholder="请输入密码"
46
-							placeholder-class="login-field__placeholder"
37
+							placeholder-class="auth-field__placeholder"
47 38
 							confirm-type="done"
48 39
 							@confirm="handleLogin"
49 40
 						/>
50 41
 					</view>
51 42
 				</view>
52 43
 
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 }]">
44
+				<view class="auth-remember" @click="form.rememberMe = !form.rememberMe">
45
+					<view :class="['auth-remember__check', { 'auth-remember__check--on': form.rememberMe }]">
77 46
 						<u-icon v-if="form.rememberMe" name="checkmark" color="#ffffff" size="14" />
78 47
 					</view>
79
-					<text class="login-remember__txt">记住账号</text>
48
+					<text class="auth-remember__txt">记住账号</text>
80 49
 				</view>
81 50
 
51
+				<agreement-block
52
+					v-model="form.agreementAccepted"
53
+					:enabled="needAgreement"
54
+					:checkbox-label="agreement.checkboxLabel"
55
+					:agreement-title="agreement.agreementTitle"
56
+					:version-label="agreement.versionLabel"
57
+					:content="agreement.content"
58
+				/>
59
+
82 60
 				<view
83
-					:class="['login-btn', { 'login-btn--loading': loading }]"
61
+					:class="['auth-btn', { 'auth-btn--loading': loading }]"
84 62
 					@click="handleLogin"
85 63
 				>
86
-					<text class="login-btn__txt">{{ loading ? '登录中…' : '登 录' }}</text>
64
+					<text class="auth-btn__txt">{{ loading ? '登录中…' : '登 录' }}</text>
65
+				</view>
66
+
67
+				<view class="auth-switch">
68
+					<text>还没有账号?</text>
69
+					<text class="auth-switch__link" @click="goRegister">立即注册</text>
87 70
 				</view>
88 71
 			</view>
89 72
 
90
-			<text class="login-footer">巴青农资商城 · 消费者端</text>
73
+			<text class="auth-footer">巴青农资商城</text>
91 74
 		</view>
92 75
 	</view>
93 76
 </template>
94 77
 
95
-<script>
96
-import { getCodeImg } from '@/api/login'
78
+<script setup>
79
+import { ref, reactive, computed } from 'vue'
80
+import { onLoad } from '@dcloudio/uni-app'
97 81
 import { getToken } from '@/utils/auth'
82
+import { REMEMBER_ACCOUNT_KEY } from '@/config'
98 83
 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
-	}
84
+import { loadServiceAgreement } from '@/utils/memberAgreement'
85
+import { PAGE_HOME, PAGE_REGISTER } from '@/utils/pageRoute'
86
+import AgreementBlock from '@/components/account/AgreementBlock.vue'
87
+
88
+const loading = ref(false)
89
+const agreement = reactive({
90
+  enabled: false,
91
+  requireAgreementOnLogin: false,
92
+  agreementTitle: '',
93
+  versionLabel: '',
94
+  content: '',
95
+  checkboxLabel: ''
96
+})
97
+
98
+const form = reactive({
99
+  account: '',
100
+  password: '',
101
+  rememberMe: false,
102
+  agreementAccepted: false
103
+})
104
+
105
+const needAgreement = computed(
106
+  () => agreement.enabled && agreement.requireAgreementOnLogin
107
+)
108
+
109
+onLoad(async (options) => {
110
+  if (getToken()) {
111
+    goHome()
112
+    return
113
+  }
114
+  const cfg = await loadServiceAgreement()
115
+  Object.assign(agreement, cfg)
116
+  if (options && options.account) {
117
+    form.account = decodeURIComponent(options.account)
118
+  }
119
+  loadRememberedAccount()
120
+})
121
+
122
+function loadRememberedAccount() {
123
+  const saved =
124
+    uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || uni.getStorageSync('shop_login_username')
125
+  if (saved) {
126
+    form.account = saved
127
+    form.rememberMe = true
128
+  }
129
+}
130
+
131
+function goHome() {
132
+  uni.switchTab({ url: PAGE_HOME })
133
+}
134
+
135
+function goRegister() {
136
+  const q = form.account ? `?mobile=${encodeURIComponent(form.account.trim())}` : ''
137
+  uni.navigateTo({ url: `${PAGE_REGISTER}${q}` })
138
+}
139
+
140
+function validateForm() {
141
+  if (!(form.account || '').trim()) {
142
+    uni.showToast({ title: '请输入账号', icon: 'none' })
143
+    return false
144
+  }
145
+  if (!form.password) {
146
+    uni.showToast({ title: '请输入密码', icon: 'none' })
147
+    return false
148
+  }
149
+  if (needAgreement.value && !form.agreementAccepted) {
150
+    uni.showToast({ title: '请先阅读并同意服务协议', icon: 'none' })
151
+    return false
152
+  }
153
+  return true
154
+}
155
+
156
+function handleLogin() {
157
+  if (!validateForm() || loading.value) {
158
+    return
159
+  }
160
+  const account = form.account.trim()
161
+  if (form.rememberMe) {
162
+    uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account)
163
+  } else {
164
+    uni.removeStorageSync(REMEMBER_ACCOUNT_KEY)
165
+  }
166
+  loading.value = true
167
+  const userStore = useUserStore()
168
+  userStore.fedLogOut()
169
+  userStore
170
+    .login({
171
+      account,
172
+      password: form.password,
173
+      agreementAccepted: needAgreement.value ? form.agreementAccepted : true
174
+    })
175
+    .then(() => userStore.fetchUserInfo())
176
+    .then(() => goHome())
177
+    .catch(() => {})
178
+    .finally(() => {
179
+      loading.value = false
180
+    })
201 181
 }
202 182
 </script>
203 183
 
204 184
 <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
-}
185
+@import '@/styles/auth.scss';
293 186
 
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 {
187
+.auth-remember {
341 188
 	display: flex;
342
-	flex-direction: row;
343 189
 	align-items: center;
344
-	gap: 16rpx;
190
+	margin-top: 8rpx;
345 191
 }
346 192
 
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);
193
+.auth-remember__check {
194
+	width: 32rpx;
195
+	height: 32rpx;
196
+	margin-right: 12rpx;
197
+	border: 2rpx solid #c8e6c9;
198
+	border-radius: 6rpx;
398 199
 	display: flex;
399 200
 	align-items: center;
400 201
 	justify-content: center;
202
+	background: #fff;
401 203
 }
402 204
 
403
-.login-btn--loading {
404
-	opacity: 0.75;
205
+.auth-remember__check--on {
206
+	background: #2e7d32;
207
+	border-color: #2e7d32;
405 208
 }
406 209
 
407
-.login-btn__txt {
408
-	font-size: 32rpx;
409
-	font-weight: 600;
410
-	color: #ffffff;
411
-	letter-spacing: 4rpx;
210
+.auth-remember__txt {
211
+	font-size: 26rpx;
212
+	color: #666;
412 213
 }
413 214
 
414
-.login-footer {
215
+.auth-footer {
415 216
 	display: block;
416
-	margin-top: 40rpx;
217
+	margin-top: 48rpx;
417 218
 	text-align: center;
418 219
 	font-size: 24rpx;
419
-	color: $morandi-text-soft;
420
-	line-height: 1.5;
220
+	color: #aaa;
421 221
 }
422 222
 </style>

+ 145 - 85
shop-app/pages/mine/index.vue

@@ -1,105 +1,165 @@
1 1
 <template>
2 2
 	<view class="page">
3
-		<view v-if="loggedIn" class="user-box">
4
-			<text class="user-name">{{ displayName }}</text>
3
+		<view v-if="loggedIn" class="user-panel">
4
+			<view class="user-panel__head">
5
+				<image
6
+					class="user-panel__avatar"
7
+					:src="avatarUrl"
8
+					mode="aspectFill"
9
+				/>
10
+				<view class="user-panel__info">
11
+					<text class="user-panel__name">{{ displayName }}</text>
12
+					<text v-if="mobileText" class="user-panel__sub">{{ mobileText }}</text>
13
+					<text v-if="memberCodeText" class="user-panel__sub">会员名称:{{ memberCodeText }}</text>
14
+				</view>
15
+			</view>
5 16
 			<button class="btn-outline" @click="handleLogout">退出登录</button>
6 17
 		</view>
7 18
 		<view v-else class="guest-box">
8
-			<text class="tip">登录后享受完整购物服务</text>
9
-			<button class="btn-primary" @click="goLogin">去登录</button>
19
+			<text class="guest-box__title">登录后享受完整购物服务</text>
20
+			<text class="guest-box__tip">下单、加购需登录会员账号</text>
21
+			<button class="btn-primary" @click="goLogin">登录</button>
22
+			<button class="btn-outline" @click="goRegister">注册会员</button>
10 23
 		</view>
11 24
 	</view>
12 25
 </template>
13 26
 
14
-<script>
27
+<script setup>
28
+import { ref } from 'vue'
29
+import { onShow } from '@dcloudio/uni-app'
15 30
 import { getToken } from '@/utils/auth'
16 31
 import { useUserStore } from '@/store/user'
32
+import { PAGE_LOGIN, PAGE_REGISTER } from '@/utils/pageRoute'
17 33
 
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() || '会员'
34
+const loggedIn = ref(false)
35
+const displayName = ref('会员')
36
+const mobileText = ref('')
37
+const memberCodeText = ref('')
38
+const avatarUrl = ref('/static/logo.png')
39
+
40
+const userStore = useUserStore()
41
+
42
+onShow(() => {
43
+	loggedIn.value = !!getToken()
44
+	if (!loggedIn.value) {
45
+		return
46
+	}
47
+	displayName.value = userStore.displayName() || '会员'
48
+	mobileText.value = userStore.state.mobile || ''
49
+	memberCodeText.value = userStore.state.memberCode || ''
50
+	avatarUrl.value = userStore.state.avatar || '/static/logo.png'
51
+	if (!userStore.state.memberId) {
52
+		userStore.fetchUserInfo().then(() => {
53
+			displayName.value = userStore.displayName() || '会员'
54
+			mobileText.value = userStore.state.mobile || ''
55
+			memberCodeText.value = userStore.state.memberCode || ''
56
+			avatarUrl.value = userStore.state.avatar || '/static/logo.png'
57
+		})
58
+	}
59
+})
60
+
61
+function goLogin() {
62
+	uni.navigateTo({ url: PAGE_LOGIN })
63
+}
64
+
65
+function goRegister() {
66
+	uni.navigateTo({ url: PAGE_REGISTER })
67
+}
68
+
69
+function handleLogout() {
70
+	uni.showModal({
71
+		title: '提示',
72
+		content: '确定退出当前账号吗?',
73
+		success: (res) => {
74
+			if (res.confirm) {
75
+				userStore.logOut().then(() => {
76
+					loggedIn.value = false
77
+					displayName.value = '会员'
78
+					mobileText.value = ''
79
+					memberCodeText.value = ''
80
+					uni.showToast({ title: '已退出', icon: 'none' })
33 81
 				})
34 82
 			}
35 83
 		}
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
-	}
84
+	})
58 85
 }
59 86
 </script>
60 87
 
61 88
 <style scoped>
62
-	.page {
63
-		min-height: 100vh;
64
-		display: flex;
65
-		align-items: center;
66
-		justify-content: center;
67
-		background-color: #f5f5f5;
68
-	}
69
-	.tip {
70
-		font-size: 28rpx;
71
-		color: #999;
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
-	}
89
+.page {
90
+	min-height: 100vh;
91
+	padding: 48rpx 32rpx;
92
+	background: linear-gradient(180deg, #e8f5e9 0%, #f5f6f8 40%);
93
+	box-sizing: border-box;
94
+}
95
+.user-panel,
96
+.guest-box {
97
+	display: flex;
98
+	flex-direction: column;
99
+	align-items: center;
100
+	gap: 32rpx;
101
+}
102
+.user-panel__head {
103
+	width: 100%;
104
+	display: flex;
105
+	align-items: center;
106
+	padding: 32rpx;
107
+	background: #fff;
108
+	border-radius: 16rpx;
109
+	box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
110
+}
111
+.user-panel__avatar {
112
+	width: 120rpx;
113
+	height: 120rpx;
114
+	border-radius: 60rpx;
115
+	background: #eee;
116
+	flex-shrink: 0;
117
+}
118
+.user-panel__info {
119
+	flex: 1;
120
+	margin-left: 24rpx;
121
+}
122
+.user-panel__name {
123
+	display: block;
124
+	font-size: 36rpx;
125
+	font-weight: 600;
126
+	color: #333;
127
+}
128
+.user-panel__sub {
129
+	display: block;
130
+	margin-top: 8rpx;
131
+	font-size: 26rpx;
132
+	color: #999;
133
+}
134
+.guest-box__title {
135
+	font-size: 32rpx;
136
+	font-weight: 600;
137
+	color: #333;
138
+}
139
+.guest-box__tip {
140
+	font-size: 26rpx;
141
+	color: #999;
142
+}
143
+.btn-primary {
144
+	width: 100%;
145
+	max-width: 480rpx;
146
+	height: 88rpx;
147
+	line-height: 88rpx;
148
+	background: #2e7d32;
149
+	color: #fff;
150
+	font-size: 30rpx;
151
+	border-radius: 44rpx;
152
+	border: none;
153
+}
154
+.btn-outline {
155
+	width: 100%;
156
+	max-width: 480rpx;
157
+	height: 80rpx;
158
+	line-height: 80rpx;
159
+	background: #fff;
160
+	color: #2e7d32;
161
+	font-size: 28rpx;
162
+	border-radius: 40rpx;
163
+	border: 1rpx solid #2e7d32;
164
+}
105 165
 </style>

+ 28 - 42
shop-app/store/user.js

@@ -1,16 +1,16 @@
1 1
 import { reactive } from 'vue'
2
-import { login as loginApi, logout as logoutApi, getInfo } from '@/api/login'
2
+import { memberLogin, getMemberProfile } from '@/api/member'
3 3
 import { getToken, setToken, removeToken } from '@/utils/auth'
4 4
 import { joinApiUrl } from '@/config'
5 5
 
6 6
 const state = reactive({
7 7
   token: getToken(),
8
-  id: '',
8
+  memberId: '',
9 9
   name: '',
10 10
   nickName: '',
11
+  mobile: '',
11 12
   avatar: '',
12
-  roles: [],
13
-  permissions: []
13
+  memberCode: ''
14 14
 })
15 15
 
16 16
 function isHttp(url) {
@@ -24,70 +24,56 @@ function resolveAvatar(path) {
24 24
 }
25 25
 
26 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)
27
+  /** 会员登录:account = 手机号或会员名称 */
28
+  const login = (payload) => {
29
+    const account = (payload.account || payload.username || '').trim()
30
+    const password = payload.password
31
+    const agreementAccepted = !!payload.agreementAccepted
32
+    return memberLogin({ account, password, agreementAccepted }).then((res) => {
33
+      const data = res.data || {}
34
+      const token = data.token
34 35
       if (!token) {
35 36
         return Promise.reject(new Error('登录失败,未返回令牌'))
36 37
       }
37 38
       setToken(token)
38 39
       state.token = token
40
+      state.memberId = data.memberId || ''
39 41
       return res
40 42
     })
41 43
   }
42 44
 
45
+  /** 会员资料 */
43 46
   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)
47
+    return getMemberProfile().then((res) => {
48
+      const m = res.data || {}
49
+      state.memberId = m.memberId
50
+      state.memberCode = m.memberCode || ''
51
+      state.name = m.memberCode || ''
52
+      state.nickName = m.nickName || ''
53
+      state.mobile = m.mobile || ''
54
+      state.avatar = resolveAvatar(m.avatar)
59 55
       return res
60 56
     })
61 57
   }
62 58
 
59
+  /** 本地退出(C 端会员暂无 logout 接口) */
63 60
   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
-      })
61
+    return fedLogOut()
76 62
   }
77 63
 
78 64
   const fedLogOut = () => {
79 65
     state.token = ''
80
-    state.roles = []
81
-    state.permissions = []
82
-    state.id = ''
66
+    state.memberId = ''
83 67
     state.name = ''
84 68
     state.nickName = ''
69
+    state.mobile = ''
85 70
     state.avatar = ''
71
+    state.memberCode = ''
86 72
     removeToken()
87 73
     return Promise.resolve()
88 74
   }
89 75
 
90
-  const displayName = () => state.nickName || state.name || ''
76
+  const displayName = () => state.nickName || state.memberCode || state.mobile || '会员'
91 77
 
92 78
   const isLoggedIn = () => !!state.token
93 79
 

+ 120 - 0
shop-app/styles/auth.scss

@@ -0,0 +1,120 @@
1
+@import '@/styles/morandi.scss';
2
+
3
+.auth-page {
4
+	min-height: 100%;
5
+	display: flex;
6
+	flex-direction: column;
7
+	background: $morandi-bg-page;
8
+}
9
+
10
+.auth-hero {
11
+	flex-shrink: 0;
12
+	min-height: 320rpx;
13
+	padding: 80rpx 48rpx 100rpx;
14
+	background: linear-gradient(145deg, #3d9b6e 0%, #22c55e 42%, #86efac 100%);
15
+}
16
+
17
+.auth-hero__app {
18
+	display: block;
19
+	font-size: 40rpx;
20
+	font-weight: 700;
21
+	color: #fff;
22
+}
23
+
24
+.auth-hero__welcome {
25
+	display: block;
26
+	margin-top: 12rpx;
27
+	font-size: 28rpx;
28
+	color: rgba(255, 255, 255, 0.9);
29
+}
30
+
31
+.auth-body {
32
+	flex: 1;
33
+	margin-top: -60rpx;
34
+	padding: 0 32rpx 48rpx;
35
+}
36
+
37
+.auth-card {
38
+	padding: 40rpx 32rpx 32rpx;
39
+	background: #fff;
40
+	border-radius: 24rpx;
41
+	box-shadow: 0 8rpx 32rpx rgba(46, 125, 50, 0.08);
42
+}
43
+
44
+.auth-card__title {
45
+	font-size: 36rpx;
46
+	font-weight: 600;
47
+	color: #333;
48
+}
49
+
50
+.auth-card__sub {
51
+	display: block;
52
+	margin-top: 8rpx;
53
+	margin-bottom: 32rpx;
54
+	font-size: 24rpx;
55
+	color: #999;
56
+}
57
+
58
+.auth-closed {
59
+	padding: 48rpx 0;
60
+	text-align: center;
61
+	font-size: 28rpx;
62
+	color: #999;
63
+}
64
+
65
+.auth-field {
66
+	margin-bottom: 24rpx;
67
+}
68
+
69
+.auth-field__box {
70
+	display: flex;
71
+	align-items: center;
72
+	height: 88rpx;
73
+	padding: 0 24rpx;
74
+	background: #f5faf6;
75
+	border: 1rpx solid #e0e0e0;
76
+	border-radius: 16rpx;
77
+}
78
+
79
+.auth-field__input {
80
+	flex: 1;
81
+	margin-left: 16rpx;
82
+	font-size: 28rpx;
83
+	color: #333;
84
+}
85
+
86
+.auth-field__placeholder {
87
+	color: #bbb;
88
+}
89
+
90
+.auth-btn {
91
+	margin-top: 32rpx;
92
+	height: 88rpx;
93
+	line-height: 88rpx;
94
+	text-align: center;
95
+	background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
96
+	border-radius: 44rpx;
97
+}
98
+
99
+.auth-btn--loading {
100
+	opacity: 0.7;
101
+}
102
+
103
+.auth-btn__txt {
104
+	font-size: 32rpx;
105
+	color: #fff;
106
+	font-weight: 500;
107
+	letter-spacing: 4rpx;
108
+}
109
+
110
+.auth-switch {
111
+	margin-top: 32rpx;
112
+	text-align: center;
113
+	font-size: 26rpx;
114
+	color: #999;
115
+}
116
+
117
+.auth-switch__link {
118
+	margin-left: 8rpx;
119
+	color: #2e7d32;
120
+}

+ 190 - 0
shop-app/subpackage/account/register.vue

@@ -0,0 +1,190 @@
1
+<template>
2
+	<view class="auth-page">
3
+		<view class="auth-hero">
4
+			<view class="auth-hero__brand">
5
+				<text class="auth-hero__app">巴青农资商城</text>
6
+				<text class="auth-hero__welcome">会员注册</text>
7
+			</view>
8
+		</view>
9
+
10
+		<view class="auth-body">
11
+			<view class="auth-card">
12
+				<text class="auth-card__title">手机号注册</text>
13
+				<text class="auth-card__sub">无需短信验证码,设置密码即可注册</text>
14
+
15
+				<view v-if="!agreement.registrationOpen" class="auth-closed">
16
+					<text>{{ agreement.message || '会员注册暂未开放' }}</text>
17
+				</view>
18
+
19
+				<template v-else>
20
+					<view class="auth-field">
21
+						<view class="auth-field__box">
22
+							<u-icon name="phone" color="#6b7f72" size="22" />
23
+							<input
24
+								v-model="form.mobile"
25
+								class="auth-field__input"
26
+								type="number"
27
+								maxlength="11"
28
+								placeholder="请输入手机号"
29
+								placeholder-class="auth-field__placeholder"
30
+							/>
31
+						</view>
32
+					</view>
33
+
34
+					<view class="auth-field">
35
+						<view class="auth-field__box">
36
+							<u-icon name="lock" color="#6b7f72" size="22" />
37
+							<input
38
+								v-model="form.password"
39
+								class="auth-field__input"
40
+								type="password"
41
+								placeholder="请设置登录密码(至少6位)"
42
+								placeholder-class="auth-field__placeholder"
43
+							/>
44
+						</view>
45
+					</view>
46
+
47
+					<view class="auth-field">
48
+						<view class="auth-field__box">
49
+							<u-icon name="lock-fill" color="#6b7f72" size="22" />
50
+							<input
51
+								v-model="form.confirmPassword"
52
+								class="auth-field__input"
53
+								type="password"
54
+								placeholder="请再次输入密码"
55
+								placeholder-class="auth-field__placeholder"
56
+							/>
57
+						</view>
58
+					</view>
59
+
60
+					<view class="auth-field">
61
+						<view class="auth-field__box">
62
+							<u-icon name="account" color="#6b7f72" size="22" />
63
+							<input
64
+								v-model="form.memberCode"
65
+								class="auth-field__input"
66
+								type="text"
67
+								placeholder="会员名称(选填,不填则自动生成)"
68
+								placeholder-class="auth-field__placeholder"
69
+							/>
70
+						</view>
71
+					</view>
72
+
73
+					<agreement-block
74
+						v-model="form.agreementAccepted"
75
+						:enabled="agreement.enabled"
76
+						:checkbox-label="agreement.checkboxLabel"
77
+						:agreement-title="agreement.agreementTitle"
78
+						:version-label="agreement.versionLabel"
79
+						:content="agreement.content"
80
+					/>
81
+
82
+					<view
83
+						:class="['auth-btn', { 'auth-btn--loading': loading }]"
84
+						@click="handleRegister"
85
+					>
86
+						<text class="auth-btn__txt">{{ loading ? '提交中…' : '注 册' }}</text>
87
+					</view>
88
+				</template>
89
+
90
+				<view class="auth-switch">
91
+					<text>已有账号?</text>
92
+					<text class="auth-switch__link" @click="goLogin">去登录</text>
93
+				</view>
94
+			</view>
95
+		</view>
96
+	</view>
97
+</template>
98
+
99
+<script setup>
100
+import { ref, reactive } from 'vue'
101
+import { onLoad } from '@dcloudio/uni-app'
102
+import { memberRegister } from '@/api/member'
103
+import { loadServiceAgreement } from '@/utils/memberAgreement'
104
+import { validateMobile, validatePassword } from '@/utils/memberValidate'
105
+import { PAGE_LOGIN } from '@/utils/pageRoute'
106
+import AgreementBlock from '@/components/account/AgreementBlock.vue'
107
+
108
+const loading = ref(false)
109
+const agreement = reactive({
110
+  enabled: false,
111
+  registrationOpen: true,
112
+  requireAgreementOnLogin: false,
113
+  message: '',
114
+  agreementTitle: '',
115
+  versionLabel: '',
116
+  content: '',
117
+  checkboxLabel: ''
118
+})
119
+
120
+const form = reactive({
121
+  mobile: '',
122
+  password: '',
123
+  confirmPassword: '',
124
+  memberCode: '',
125
+  agreementAccepted: false
126
+})
127
+
128
+onLoad(async (options) => {
129
+  const cfg = await loadServiceAgreement()
130
+  Object.assign(agreement, cfg)
131
+  if (options && options.mobile) {
132
+    form.mobile = decodeURIComponent(options.mobile)
133
+  }
134
+})
135
+
136
+function goLogin() {
137
+  const q = form.mobile ? `?account=${encodeURIComponent(form.mobile.trim())}` : ''
138
+  uni.navigateTo({ url: `${PAGE_LOGIN}${q}` })
139
+}
140
+
141
+function validateForm() {
142
+  const mobileMsg = validateMobile(form.mobile)
143
+  if (mobileMsg) {
144
+    uni.showToast({ title: mobileMsg, icon: 'none' })
145
+    return false
146
+  }
147
+  const pwdMsg = validatePassword(form.password)
148
+  if (pwdMsg) {
149
+    uni.showToast({ title: pwdMsg, icon: 'none' })
150
+    return false
151
+  }
152
+  if (form.password !== form.confirmPassword) {
153
+    uni.showToast({ title: '两次输入的密码不一致', icon: 'none' })
154
+    return false
155
+  }
156
+  if (agreement.enabled && !form.agreementAccepted) {
157
+    uni.showToast({ title: '请先阅读并同意服务协议', icon: 'none' })
158
+    return false
159
+  }
160
+  return true
161
+}
162
+
163
+function handleRegister() {
164
+  if (!agreement.registrationOpen || loading.value || !validateForm()) {
165
+    return
166
+  }
167
+  loading.value = true
168
+  memberRegister({
169
+    mobile: form.mobile.trim(),
170
+    password: form.password,
171
+    confirmPassword: form.confirmPassword,
172
+    memberCode: (form.memberCode || '').trim() || undefined,
173
+    agreementAccepted: agreement.enabled ? form.agreementAccepted : true
174
+  })
175
+    .then(() => {
176
+      uni.showToast({ title: '注册成功,请登录', icon: 'none', duration: 2000 })
177
+      setTimeout(() => {
178
+        goLogin()
179
+      }, 1500)
180
+    })
181
+    .catch(() => {})
182
+    .finally(() => {
183
+      loading.value = false
184
+    })
185
+}
186
+</script>
187
+
188
+<style lang="scss" scoped>
189
+@import '@/styles/auth.scss';
190
+</style>

+ 3 - 3
shop-app/subpackage/search/index.vue

@@ -2,7 +2,7 @@
2 2
 	<view class="page-search">
3 3
 		<view class="search-header">
4 4
 			<view class="search-header__bar">
5
-				<u-icon name="arrow-left" size="20" color="#333" @click="onBack" />
5
+				<!-- <u-icon name="arrow-left" size="20" color="#333" @click="onBack" /> -->
6 6
 				<view class="search-header__input-wrap">
7 7
 					<u-icon name="search" color="#999" size="18" />
8 8
 					<input
@@ -58,7 +58,7 @@ import {
58 58
   removeSearchHistoryItem,
59 59
   clearSearchHistory
60 60
 } from '@/utils/searchHistory'
61
-import { goSearchResult } from '@/utils/searchNav'
61
+import { goSearchResult, navigateBackFromSearchIndex } from '@/utils/searchNav'
62 62
 
63 63
 const placeholder = SEARCH_PLACEHOLDER
64 64
 const keyword = ref('')
@@ -70,7 +70,7 @@ function refreshHistory() {
70 70
 }
71 71
 
72 72
 function onBack() {
73
-  uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
73
+  navigateBackFromSearchIndex()
74 74
 }
75 75
 
76 76
 function submitSearch() {

+ 8 - 4
shop-app/subpackage/search/result.vue

@@ -1,7 +1,7 @@
1 1
 <template>
2 2
 	<view class="page-result">
3 3
 		<view class="result-header" @click="onReSearch">
4
-			<u-icon name="arrow-left" size="20" color="#333" @click.stop="onBack" />
4
+			<!-- <u-icon name="arrow-left" size="20" color="#333" @click.stop="onBack" /> -->
5 5
 			<view class="result-header__keyword">
6 6
 				<u-icon name="search" color="#999" size="16" />
7 7
 				<text class="result-header__text">{{ keyword }}</text>
@@ -277,14 +277,15 @@ onLoad((options) => {
277 277
 .page-result {
278 278
 	display: flex;
279 279
 	flex-direction: column;
280
-	min-height: 100vh;
280
+	height: calc(100vh - 100rpx);
281 281
 	background: #f5f6f8;
282 282
 }
283 283
 .result-header {
284 284
 	display: flex;
285 285
 	align-items: center;
286 286
 	padding: 16rpx 24rpx;
287
-	background: #fff;
287
+	background: #e8f5e9;
288
+	border-bottom: 1rpx solid #c8e6c9;
288 289
 }
289 290
 .result-header__keyword {
290 291
 	flex: 1;
@@ -293,7 +294,8 @@ onLoad((options) => {
293 294
 	height: 64rpx;
294 295
 	margin-left: 16rpx;
295 296
 	padding: 0 20rpx;
296
-	background: #f5f6f8;
297
+	background: #fff;
298
+	border: 1rpx solid #a5d6a7;
297 299
 	border-radius: 32rpx;
298 300
 }
299 301
 .result-header__text {
@@ -306,6 +308,8 @@ onLoad((options) => {
306 308
 }
307 309
 .result-scroll {
308 310
 	flex: 1;
311
+  box-sizing: border-box;
312
+  padding: 10rpx;
309 313
 }
310 314
 .result-loading {
311 315
 	padding: 80rpx 0;

+ 3 - 1
shop-app/utils/apiAuth.js

@@ -1,6 +1,8 @@
1 1
 import { getToken } from '@/utils/auth'
2 2
 
3
-const LOGIN_URL = '/pages/login/index'
3
+import { PAGE_LOGIN } from '@/utils/pageRoute'
4
+
5
+const LOGIN_URL = PAGE_LOGIN
4 6
 
5 7
 /** 避免连续触发多次跳转登录页 */
6 8
 let loginRedirecting = false

+ 58 - 0
shop-app/utils/htmlContent.js

@@ -0,0 +1,58 @@
1
+import { resolveFileUrl } from '@/utils/image'
2
+
3
+/**
4
+ * 解码常见 HTML 实体(后台若存了 &lt;p&gt; 会原样显示标签)
5
+ */
6
+export function decodeHtmlEntities(html) {
7
+  if (!html || typeof html !== 'string') {
8
+    return ''
9
+  }
10
+  let s = html
11
+  if (!/&(?:lt|gt|amp|quot|nbsp|#)/i.test(s)) {
12
+    return s
13
+  }
14
+  s = s.replace(/&nbsp;/gi, '\u00A0')
15
+  s = s.replace(/&lt;/gi, '<')
16
+  s = s.replace(/&gt;/gi, '>')
17
+  s = s.replace(/&amp;/gi, '&')
18
+  s = s.replace(/&quot;/gi, '"')
19
+  s = s.replace(/&#39;/g, "'")
20
+  s = s.replace(/&#(\d+);/g, (_, code) => {
21
+    const n = Number(code)
22
+    return Number.isNaN(n) ? '' : String.fromCharCode(n)
23
+  })
24
+  s = s.replace(/&#x([0-9a-f]+);/gi, (_, hex) => {
25
+    const n = parseInt(hex, 16)
26
+    return Number.isNaN(n) ? '' : String.fromCharCode(n)
27
+  })
28
+  return s
29
+}
30
+
31
+/** 富文本 img 相对路径转完整 URL */
32
+export function fixHtmlImageSrc(html) {
33
+  if (!html) return ''
34
+  return html.replace(/src=(["'])([^"']+)\1/gi, (match, quote, src) => {
35
+    const full = resolveFileUrl(src)
36
+    return full ? `src=${quote}${full}${quote}` : match
37
+  })
38
+}
39
+
40
+/** 去掉 Quill 常见空段落,避免占高度 */
41
+export function trimEmptyParagraphs(html) {
42
+  if (!html) return ''
43
+  return html
44
+    .replace(/<p>(\s|&nbsp;|<br\s*\/?>)*<\/p>/gi, '')
45
+    .replace(/<p><\/p>/gi, '')
46
+}
47
+
48
+/**
49
+ * 协议/商品详情等富文本统一预处理(供 rich-text :nodes 使用)
50
+ */
51
+export function prepareRichHtml(html) {
52
+  let s = (html || '').trim()
53
+  if (!s) return ''
54
+  s = decodeHtmlEntities(s)
55
+  s = fixHtmlImageSrc(s)
56
+  s = trimEmptyParagraphs(s)
57
+  return s
58
+}

+ 32 - 0
shop-app/utils/memberAgreement.js

@@ -0,0 +1,32 @@
1
+import { getMemberServiceAgreement } from '@/api/member'
2
+
3
+const defaultAgreement = () => ({
4
+  enabled: false,
5
+  registrationOpen: true,
6
+  requireAgreementOnLogin: false,
7
+  message: '',
8
+  agreementTitle: '',
9
+  versionLabel: '',
10
+  content: '',
11
+  checkboxLabel: '我已阅读并同意相关服务协议'
12
+})
13
+
14
+/** 拉取服务协议配置(注册/登录页共用) */
15
+export async function loadServiceAgreement() {
16
+  try {
17
+    const res = await getMemberServiceAgreement()
18
+    const data = res.data || {}
19
+    return {
20
+      enabled: !!data.enabled,
21
+      registrationOpen: data.registrationOpen !== false,
22
+      requireAgreementOnLogin: !!data.requireAgreementOnLogin,
23
+      message: data.message || '',
24
+      agreementTitle: data.agreementTitle || '',
25
+      versionLabel: data.versionLabel || '',
26
+      content: data.content || '',
27
+      checkboxLabel: data.checkboxLabel || defaultAgreement().checkboxLabel
28
+    }
29
+  } catch (e) {
30
+    return defaultAgreement()
31
+  }
32
+}

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

@@ -0,0 +1,27 @@
1
+/** 大陆 11 位手机号 */
2
+export const MOBILE_REG = /^1\d{10}$/
3
+
4
+export function isMobile(value) {
5
+  return MOBILE_REG.test(String(value || '').trim())
6
+}
7
+
8
+export function validateMobile(mobile) {
9
+  const text = (mobile || '').trim()
10
+  if (!text) {
11
+    return '请输入手机号'
12
+  }
13
+  if (!isMobile(text)) {
14
+    return '请输入正确的手机号'
15
+  }
16
+  return ''
17
+}
18
+
19
+export function validatePassword(password, label = '密码') {
20
+  if (!password) {
21
+    return `请输入${label}`
22
+  }
23
+  if (password.length < 6) {
24
+    return `${label}至少 6 位`
25
+  }
26
+  return ''
27
+}

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

@@ -19,6 +19,10 @@ export const PAGE_MINE = '/pages/mine/index'
19 19
 /** 登录 */
20 20
 export const PAGE_LOGIN = '/pages/login/index'
21 21
 
22
+// —— 分包:会员账号 ——
23
+
24
+export const PAGE_REGISTER = '/subpackage/account/register'
25
+
22 26
 // —— 分包:分类 ——
23 27
 
24 28
 export const PAGE_CATEGORY_LEVEL1 = '/subpackage/category/level1'

+ 3 - 1
shop-app/utils/request.js

@@ -7,7 +7,9 @@ import { useUserStore } from '@/store/user'
7 7
 export const isRelogin = { show: false }
8 8
 
9 9
 const SESSION_EXPIRED_MSG = '登录过期,即将登录'
10
-const LOGIN_PAGE_URL = '/pages/login/index'
10
+import { PAGE_LOGIN } from '@/utils/pageRoute'
11
+
12
+const LOGIN_PAGE_URL = PAGE_LOGIN
11 13
 const LOGIN_REDIRECT_DELAY_MS = 400
12 14
 
13 15
 /** 与 ruoyi-ui tansParams 一致:null / undefined / 空字符串不参与序列化 */

+ 53 - 2
shop-app/utils/searchNav.js

@@ -1,11 +1,38 @@
1 1
 import { PAGE_SEARCH_INDEX, PAGE_SEARCH_RESULT } from '@/utils/pageRoute'
2 2
 
3
+/** 当前页路由(如 subpackage/search/result) */
4
+function getCurrentRoute() {
5
+  const pages = getCurrentPages()
6
+  const page = pages[pages.length - 1]
7
+  return (page && page.route) || ''
8
+}
9
+
10
+function isSearchResultRoute(route) {
11
+  return route.includes('search/result')
12
+}
13
+
14
+function isSearchIndexRoute(route) {
15
+  return route.includes('search/index')
16
+}
17
+
3 18
 /** 跳转搜索输入页 A */
4 19
 export function goSearchInput(keyword) {
5 20
   const text = (keyword || '').trim()
6 21
   const url = text
7 22
     ? `${PAGE_SEARCH_INDEX}?keyword=${encodeURIComponent(text)}`
8 23
     : PAGE_SEARCH_INDEX
24
+
25
+  const route = getCurrentRoute()
26
+  // 从结果页「重新搜索」:替换当前页,避免栈变成 result → index 导致返回循环
27
+  if (isSearchResultRoute(route)) {
28
+    uni.redirectTo({ url })
29
+    return
30
+  }
31
+  // 已在输入页:仅刷新当前页参数
32
+  if (isSearchIndexRoute(route)) {
33
+    uni.redirectTo({ url })
34
+    return
35
+  }
9 36
   uni.navigateTo({ url })
10 37
 }
11 38
 
@@ -13,7 +40,31 @@ export function goSearchInput(keyword) {
13 40
 export function goSearchResult(keyword) {
14 41
   const text = (keyword || '').trim()
15 42
   if (!text) return
16
-  uni.navigateTo({
17
-    url: `${PAGE_SEARCH_RESULT}?keyword=${encodeURIComponent(text)}`
43
+  const url = `${PAGE_SEARCH_RESULT}?keyword=${encodeURIComponent(text)}`
44
+
45
+  const route = getCurrentRoute()
46
+  // 已在结果页:改关键词时用替换,不叠一层
47
+  if (isSearchResultRoute(route)) {
48
+    uni.redirectTo({ url })
49
+    return
50
+  }
51
+  uni.navigateTo({ url })
52
+}
53
+
54
+/** 输入页返回:若栈里下层仍是结果页,多退一层,防止 index ↔ result 循环 */
55
+export function navigateBackFromSearchIndex() {
56
+  const pages = getCurrentPages()
57
+  if (pages.length >= 2) {
58
+    const prev = pages[pages.length - 2]
59
+    const prevRoute = (prev && prev.route) || ''
60
+    if (isSearchResultRoute(prevRoute)) {
61
+      uni.navigateBack({ delta: 2 })
62
+      return
63
+    }
64
+  }
65
+  uni.navigateBack({
66
+    fail: () => {
67
+      uni.switchTab({ url: '/pages/index/index' })
68
+    }
18 69
   })
19 70
 }