xsh_1997 2 週間 前
コミット
73944df636
共有28 個のファイルを変更した2923 個の追加136 個の削除を含む
  1. 1 1
      doc/消费者APP/会员注册登录/会员注册登录前端技术方案.md
  2. 214 0
      doc/消费者APP/我的服务/我的服务前端技术方案.md
  3. 1 1
      doc/消费者APP/我的服务/我的服务技术方案.md
  4. 10 3
      shop-app/PAGES.md
  5. 61 0
      shop-app/api/member.js
  6. 39 0
      shop-app/api/merchantEntry.js
  7. 113 0
      shop-app/components/mine/ImageUpload.vue
  8. 87 0
      shop-app/components/mine/RegionFields.vue
  9. 26 0
      shop-app/components/mine/entry/EntryEnterpriseBiz.vue
  10. 144 0
      shop-app/components/mine/entry/EntryEnterpriseSubject.vue
  11. 32 0
      shop-app/components/mine/entry/EntryPersonBiz.vue
  12. 105 0
      shop-app/components/mine/entry/EntryPersonSubject.vue
  13. 44 0
      shop-app/components/mine/entry/EntryShopFields.vue
  14. 71 0
      shop-app/components/mine/entry/ValidPeriodRow.vue
  15. 46 0
      shop-app/pages.json
  16. 134 130
      shop-app/pages/mine/index.vue
  17. 230 0
      shop-app/styles/mine.scss
  18. 155 0
      shop-app/subpackage/account/address-edit.vue
  19. 195 0
      shop-app/subpackage/account/address-list.vue
  20. 303 0
      shop-app/subpackage/account/entry-apply.vue
  21. 161 0
      shop-app/subpackage/account/entry-detail.vue
  22. 150 0
      shop-app/subpackage/account/entry-list.vue
  23. 101 0
      shop-app/subpackage/account/password.vue
  24. 172 0
      shop-app/subpackage/account/profile.vue
  25. 58 0
      shop-app/utils/entryConstants.js
  26. 237 0
      shop-app/utils/entryForm.js
  27. 18 0
      shop-app/utils/mineNav.js
  28. 15 1
      shop-app/utils/pageRoute.js

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

@@ -53,7 +53,7 @@
53 53
 | 校验 | `shop-app/utils/memberValidate.js` | 手机号、密码 |
54 54
 | 登录引导 | `shop-app/utils/apiAuth.js` | `ensureApiToken` → 登录页 |
55 55
 | 样式 | `shop-app/styles/auth.scss` | 登录/注册卡片样式 |
56
-| 我的 | `shop-app/pages/mine/index.vue` | 登录/注册入口、退出 |
56
+| 我的 | `shop-app/pages/mine/index.vue` | 登录/注册入口;**我的服务**菜单见《我的服务前端技术方案》 |
57 57
 
58 58
 > `api/login.js`(`/login`、`/captchaImage`)已 **废弃**,勿再用于 C 端。
59 59
 

+ 214 - 0
doc/消费者APP/我的服务/我的服务前端技术方案.md

@@ -0,0 +1,214 @@
1
+# 我的服务 — 前端技术方案(C 端 · shop-app)
2
+
3
+> **依据:** 《我的服务功能需求.md》v1.0、《我的服务技术方案.md》v1.1  
4
+> **关联:** 《会员注册登录前端技术方案.md》(登录前置)、后续《订单管理》等(地址被结算引用)  
5
+> **范围:** C 端 **个人资料、修改密码、收货地址、商家入驻**;**不** 改后端、**不** 实现我的订单/消息中心/换绑手机。  
6
+> **实现状态:** 我的 Tab 入口 + 分包页面已落地,待与 `/api/member/**`、`/api/merchant/entry/**` 联调。
7
+
8
+---
9
+
10
+## 1. 技术栈与约定
11
+
12
+| 项 | 说明 |
13
+|----|------|
14
+| 框架 | uni-app **Vue 3** + **uview-plus** |
15
+| 请求 | `@/utils/request`;须登录接口默认带 `Authorization: Bearer {token}` |
16
+| 登录前置 | **MS0**:子功能经 `ensureApiToken` / `navigateMinePage` 拦截,引导 `PAGE_LOGIN` |
17
+| 上传 | `@/utils/upload` → `POST /common/upload`,返回 `url` 写入表单 |
18
+| 样式 | `styles/mine.scss`(与登录页绿色主色一致) |
19
+| 路由 | 业务页在 **`subpackage/account/`**;Tab 仍在 **`pages/mine/index`** |
20
+
21
+---
22
+
23
+## 2. 页面与路由
24
+
25
+| 页面 | 路径 | 包 | 入口 |
26
+|------|------|-----|------|
27
+| 我的(服务入口) | `pages/mine/index` | 主包 Tab | TabBar「我的」 |
28
+| 个人资料 | `subpackage/account/profile` | 分包 | 我的 → 编辑个人资料 / 点头像 |
29
+| 修改密码 | `subpackage/account/password` | 分包 | 我的 → 修改密码 |
30
+| 收货地址列表 | `subpackage/account/address-list` | 分包 | 我的 → 收货地址 |
31
+| 地址编辑 | `subpackage/account/address-edit` | 分包 | 列表新增/编辑 |
32
+| 商家入驻 | `subpackage/account/entry-apply` | 分包 | 我的 → 我要入驻 |
33
+| 我的入驻申请 | `subpackage/account/entry-list` | 分包 | 我的 → 我的入驻申请 |
34
+| 申请详情 | `subpackage/account/entry-detail` | 分包 | 列表项 |
35
+
36
+**Query:**
37
+
38
+| 页面 | 参数 | 说明 |
39
+|------|------|------|
40
+| address-edit | `mode=add\|edit`、`id` | 编辑时传 `addressId` |
41
+
42
+**路径常量:** `utils/pageRoute.js` → `PAGE_PROFILE`、`PAGE_PASSWORD`、`PAGE_ADDRESS_*`、`PAGE_ENTRY_*`
43
+
44
+---
45
+
46
+## 3. 文件清单
47
+
48
+| 类型 | 路径 | 说明 |
49
+|------|------|------|
50
+| 我的 Tab | `shop-app/pages/mine/index.vue` | 登录态、菜单分区、退出 |
51
+| 资料 | `shop-app/subpackage/account/profile.vue` | 只读 ID/手机号/会员名 + 可编辑项 |
52
+| 密码 | `shop-app/subpackage/account/password.vue` | 旧密码 + 新密码 + 确认 |
53
+| 地址列表 | `shop-app/subpackage/account/address-list.vue` | 默认置顶、设默认、删改 |
54
+| 地址编辑 | `shop-app/subpackage/account/address-edit.vue` | 省市区手填 + 详细地址 |
55
+| 入驻申请 | `shop-app/subpackage/account/entry-apply.vue` | 分步:类型→主体→经营→店铺→提交 |
56
+| 入驻列表 | `shop-app/subpackage/account/entry-list.vue` | 状态、驳回原因摘要 |
57
+| 入驻详情 | `shop-app/subpackage/account/entry-detail.vue` | 公示期、驳回、完成说明 |
58
+| 会员 API | `shop-app/api/member.js` | profile / password / address |
59
+| 入驻 API | `shop-app/api/merchantEntry.js` | agreement / apply / my |
60
+| 导航 | `shop-app/utils/mineNav.js` | 登录后 `navigateTo` |
61
+| 入驻常量 | `shop-app/utils/entryConstants.js` | 状态文案、阻塞判断 |
62
+| 入驻表单 | `shop-app/utils/entryForm.js` | 空白表单、分步校验、提交体 |
63
+| 图片上传 | `shop-app/components/mine/ImageUpload.vue` | 头像/证件/Logo |
64
+| 地区 | `shop-app/components/mine/RegionFields.vue` | 省市区三栏(code=name 拼接) |
65
+| 入驻字段块 | `shop-app/components/mine/entry/*.vue` | 个人/企业分步表单项 |
66
+| 协议 | `shop-app/components/account/AgreementBlock.vue` | 入驻提交勾选 |
67
+
68
+---
69
+
70
+## 4. 接口封装
71
+
72
+### 4.1 会员 `/api/member`(须 Token)
73
+
74
+| 方法 | HTTP | 路径 |
75
+|------|------|------|
76
+| `getMemberProfile` | GET | `/profile` |
77
+| `updateMemberProfile` | PUT | `/profile` |
78
+| `changeMemberPassword` | PUT | `/password` |
79
+| `getAddressList` | GET | `/address/list` |
80
+| `addAddress` | POST | `/address` |
81
+| `updateAddress` | PUT | `/address` |
82
+| `deleteAddress` | DELETE | `/address/{id}` |
83
+| `setDefaultAddress` | PUT | `/address/{id}/default` |
84
+
85
+**资料可写字段:** `nickName`、`avatar`、`email`、`sex`、`birthday`(`memberId`/`mobile`/`memberCode` 只读)。
86
+
87
+**密码 Body:** `oldPassword`、`newPassword`、`confirmPassword`。
88
+
89
+**地址 Body:** `consigneeName`、`mobile`、`province`、`city`、`district`、`detailAddress`、`isDefault`(`0`/`1`)。
90
+
91
+### 4.2 入驻 `/api/merchant/entry`
92
+
93
+| 方法 | HTTP | 路径 | 鉴权 |
94
+|------|------|------|------|
95
+| `getEntryAgreement` | GET | `/agreement` | 匿名 |
96
+| `getEntryStatus` | GET | `/status` | 匿名 |
97
+| `submitEntryApply` | POST | `/apply` | Token |
98
+| `getMyEntryApplies` | GET | `/my` | Token |
99
+
100
+**提交 Body:**
101
+
102
+```json
103
+{
104
+  "subject": { "merchantType": "1", "...": "个人或企业主体字段" },
105
+  "biz": { "merchantType": "1", "...": "经营信息" },
106
+  "shop": { "shopName", "shopAvatar", "shopPhone", "shopDesc" },
107
+  "agreementAccepted": true
108
+}
109
+```
110
+
111
+**申请状态(C 端展示):**
112
+
113
+| applyStatus | 含义 |
114
+|-------------|------|
115
+| `0` | 待审核 |
116
+| `3` | 公示中 |
117
+| `1` | 已完成入驻 |
118
+| `2` | 审核未通过(展示 `rejectReason`,可重新申请) |
119
+
120
+**阻塞新申请:** 存在 status `0` 或 `3` 时,入驻页提示先去列表查看(**MS-E6**)。
121
+
122
+---
123
+
124
+## 5. 我的 Tab 结构(`pages/mine/index`)
125
+
126
+```text
127
+顶栏(绿渐变)
128
+├── 未登录:提示 + 登录 / 注册
129
+└── 已登录:头像、昵称、手机号 → 点击进个人资料
130
+
131
+账号管理
132
+├── 编辑个人资料
133
+└── 修改密码
134
+
135
+收货地址
136
+└── 收货地址
137
+
138
+商家入驻
139
+├── 我要入驻(分步表单)
140
+└── 我的入驻申请
141
+
142
+退出登录(仅本地 fedLogOut)
143
+```
144
+
145
+---
146
+
147
+## 6. 关键交互与规则
148
+
149
+| 规则 | 前端落实 |
150
+|------|----------|
151
+| MS0 | 子页 `onLoad` / `onShow` 调 `ensureApiToken` |
152
+| MS-P1~P4 | 资料页只读项;昵称必填;头像上传 |
153
+| MS-W1~W3 | 密码页前端校验;调 `PUT /password` |
154
+| MS-A1~A3 | 列表默认置顶;设默认调专用接口;删除二次确认 |
155
+| MS-A4 | 删默认后 **不** 自动设新默认(与需求建议一致) |
156
+| MS-E5~E7 | 提交须勾选协议;待审/公示中阻塞新单;驳回后可 `entry-apply` |
157
+| MS-E9 | 提交前 `showModal` 二次确认;提交后 `redirectTo` 列表 |
158
+
159
+**入驻表单:** 个人 `merchantType=1` / 企业 `merchantType=2`;分 5 步;省市区用 `RegionFields`(`code`=`省|市|区`,`name` 空格拼接);影像字段存上传返回 URL。
160
+
161
+**地区说明:** 本期未接省市区数据字典,采用 **手填省/市/区** 满足后端 `bizRegionCode`/`bizRegionName` 非空校验;后续可换级联组件。
162
+
163
+---
164
+
165
+## 7. 登录态
166
+
167
+```text
168
+我的 Tab onShow
169
+    → getToken ?
170
+        → 是:展示菜单;必要时 getMemberProfile 刷新 store
171
+        → 否:仅展示登录/注册
172
+
173
+子页保存资料成功
174
+    → userStore.fetchUserInfo() 同步 Tab 展示
175
+```
176
+
177
+---
178
+
179
+## 8. 联调检查清单
180
+
181
+- [ ] 未登录点击「收货地址」→ Toast + 跳转登录
182
+- [ ] `GET/PUT /api/member/profile` 资料读写
183
+- [ ] `PUT /api/member/password` 旧密码错误 / 成功
184
+- [ ] 地址增删改、设默认、列表排序(默认在上)
185
+- [ ] `GET /api/merchant/entry/agreement` + 关闭时不可提交
186
+- [ ] `POST /api/merchant/entry/apply` 个人/企业完整字段
187
+- [ ] 待审中单存在时不可再进申请页提交
188
+- [ ] `GET /api/merchant/entry/my` 列表与详情状态、驳回原因、公示时间
189
+- [ ] 图片上传 Token 与 `/common/upload` 返回 URL 可访问
190
+
191
+---
192
+
193
+## 9. 非本期
194
+
195
+| 项 | 说明 |
196
+|----|------|
197
+| 我的订单、购物车正式页 | 另模块 |
198
+| 手机号换绑、注销 | — |
199
+| 站内消息列表 | 另册 |
200
+| 入驻草稿、待审撤销/改资料 | — |
201
+| 省市区全国级联数据 | 可后续接字典 API |
202
+| 会员名称在线修改 | 需求未列 |
203
+
204
+---
205
+
206
+## 10. 修订记录
207
+
208
+| 版本 | 说明 |
209
+|------|------|
210
+| **v1.0** | 首版:我的 Tab 服务菜单、资料/密码/地址/入驻分包页与 API |
211
+
212
+---
213
+
214
+*文档版本:v1.0 · 关联《我的服务技术方案.md》v1.1、《我的服务功能需求.md》v1.0*

+ 1 - 1
doc/消费者APP/我的服务/我的服务技术方案.md

@@ -448,7 +448,7 @@ submitApply(memberId, dto)
448 448
 | 地址 CRUD + 默认互斥 | **已实现** | |
449 449
 | 入驻协议 GET | **已实现** | |
450 450
 | POST `/apply`、GET `/my` | **已实现** | 含公示时间 |
451
-| C 端前端 | **未实现** | |
451
+| C 端前端 | **已实现**(见《我的服务前端技术方案.md》) | |
452 452
 
453 453
 ---
454 454
 

+ 10 - 3
shop-app/PAGES.md

@@ -7,7 +7,7 @@ pages/
7 7
 ├── index/index.vue      # 首页 Tab
8 8
 ├── category/index.vue   # 分类 Tab
9 9
 ├── cart/index.vue       # 购物车 Tab(待正式开发)
10
-├── mine/index.vue       # 我的 Tab(待正式开发
10
+├── mine/index.vue       # 我的 Tab(我的服务入口
11 11
 └── login/index.vue      # 登录
12 12
 ```
13 13
 
@@ -15,8 +15,15 @@ pages/
15 15
 
16 16
 ```
17 17
 subpackage/
18
-├── account/             # 会员
19
-│   └── register.vue
18
+├── account/             # 会员 · 我的服务
19
+│   ├── register.vue
20
+│   ├── profile.vue
21
+│   ├── password.vue
22
+│   ├── address-list.vue
23
+│   ├── address-edit.vue
24
+│   ├── entry-apply.vue
25
+│   ├── entry-list.vue
26
+│   └── entry-detail.vue
20 27
 ├── category/            # 分类子页
21 28
 │   ├── level1.vue
22 29
 │   └── goods-list.vue

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

@@ -40,3 +40,64 @@ export function getMemberProfile() {
40 40
     method: 'GET'
41 41
   })
42 42
 }
43
+
44
+/** 更新个人资料 */
45
+export function updateMemberProfile(data) {
46
+  return request({
47
+    url: '/api/member/profile',
48
+    method: 'PUT',
49
+    data
50
+  })
51
+}
52
+
53
+/** 修改密码 */
54
+export function changeMemberPassword(data) {
55
+  return request({
56
+    url: '/api/member/password',
57
+    method: 'PUT',
58
+    data,
59
+    header: { repeatSubmit: false }
60
+  })
61
+}
62
+
63
+/** 收货地址列表 */
64
+export function getAddressList() {
65
+  return request({
66
+    url: '/api/member/address/list',
67
+    method: 'GET'
68
+  })
69
+}
70
+
71
+/** 新增收货地址 */
72
+export function addAddress(data) {
73
+  return request({
74
+    url: '/api/member/address',
75
+    method: 'POST',
76
+    data
77
+  })
78
+}
79
+
80
+/** 修改收货地址 */
81
+export function updateAddress(data) {
82
+  return request({
83
+    url: '/api/member/address',
84
+    method: 'PUT',
85
+    data
86
+  })
87
+}
88
+
89
+/** 删除收货地址 */
90
+export function deleteAddress(addressId) {
91
+  return request({
92
+    url: `/api/member/address/${addressId}`,
93
+    method: 'DELETE'
94
+  })
95
+}
96
+
97
+/** 设为默认地址 */
98
+export function setDefaultAddress(addressId) {
99
+  return request({
100
+    url: `/api/member/address/${addressId}/default`,
101
+    method: 'PUT'
102
+  })
103
+}

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

@@ -0,0 +1,39 @@
1
+import request from '@/utils/request'
2
+
3
+/** 入驻协议正文(匿名) */
4
+export function getEntryAgreement() {
5
+  return request({
6
+    url: '/api/merchant/entry/agreement',
7
+    method: 'GET',
8
+    header: { isToken: false },
9
+    silent: true
10
+  })
11
+}
12
+
13
+/** 入驻是否开放(匿名) */
14
+export function getEntryStatus() {
15
+  return request({
16
+    url: '/api/merchant/entry/status',
17
+    method: 'GET',
18
+    header: { isToken: false },
19
+    silent: true
20
+  })
21
+}
22
+
23
+/** 提交入驻申请 */
24
+export function submitEntryApply(data) {
25
+  return request({
26
+    url: '/api/merchant/entry/apply',
27
+    method: 'POST',
28
+    data,
29
+    header: { repeatSubmit: false }
30
+  })
31
+}
32
+
33
+/** 我的入驻申请列表 */
34
+export function getMyEntryApplies() {
35
+  return request({
36
+    url: '/api/merchant/entry/my',
37
+    method: 'GET'
38
+  })
39
+}

+ 113 - 0
shop-app/components/mine/ImageUpload.vue

@@ -0,0 +1,113 @@
1
+<template>
2
+	<view class="img-upload">
3
+		<text v-if="label" class="img-upload__label">{{ label }}</text>
4
+		<view class="img-upload__box" @click="chooseImage">
5
+			<image
6
+				v-if="displayUrl"
7
+				class="img-upload__preview"
8
+				:src="displayUrl"
9
+				mode="aspectFill"
10
+			/>
11
+			<view v-else class="img-upload__placeholder">
12
+				<u-icon name="camera-fill" color="#9a938c" size="40" />
13
+				<text class="img-upload__tip">{{ placeholder }}</text>
14
+			</view>
15
+			<view v-if="uploading" class="img-upload__mask">
16
+				<text>上传中…</text>
17
+			</view>
18
+		</view>
19
+	</view>
20
+</template>
21
+
22
+<script setup>
23
+import { ref, computed } from 'vue'
24
+import { joinApiUrl } from '@/config'
25
+import { uploadFile } from '@/utils/upload'
26
+
27
+const props = defineProps({
28
+	modelValue: { type: String, default: '' },
29
+	label: { type: String, default: '' },
30
+	placeholder: { type: String, default: '点击上传' }
31
+})
32
+
33
+const emit = defineEmits(['update:modelValue'])
34
+
35
+const uploading = ref(false)
36
+
37
+const displayUrl = computed(() => {
38
+  const v = props.modelValue || ''
39
+  if (!v) return ''
40
+  if (/^https?:\/\//i.test(v)) return v
41
+  return joinApiUrl(v)
42
+})
43
+
44
+function chooseImage() {
45
+  if (uploading.value) return
46
+  uni.chooseImage({
47
+    count: 1,
48
+    sizeType: ['compressed'],
49
+    success: (res) => {
50
+      const path = res.tempFilePaths && res.tempFilePaths[0]
51
+      if (!path) return
52
+      uploading.value = true
53
+      uploadFile(path)
54
+        .then((url) => {
55
+          emit('update:modelValue', url)
56
+        })
57
+        .catch((e) => {
58
+          uni.showToast({ title: e.message || '上传失败', icon: 'none' })
59
+        })
60
+        .finally(() => {
61
+          uploading.value = false
62
+        })
63
+    }
64
+  })
65
+}
66
+</script>
67
+
68
+<style lang="scss" scoped>
69
+.img-upload {
70
+  padding: 16rpx 0;
71
+}
72
+.img-upload__label {
73
+  display: block;
74
+  margin-bottom: 12rpx;
75
+  font-size: 28rpx;
76
+  color: #5c5652;
77
+}
78
+.img-upload__box {
79
+  position: relative;
80
+  width: 200rpx;
81
+  height: 200rpx;
82
+  border-radius: 12rpx;
83
+  background: #f5f2ef;
84
+  overflow: hidden;
85
+}
86
+.img-upload__preview {
87
+  width: 100%;
88
+  height: 100%;
89
+}
90
+.img-upload__placeholder {
91
+  width: 100%;
92
+  height: 100%;
93
+  display: flex;
94
+  flex-direction: column;
95
+  align-items: center;
96
+  justify-content: center;
97
+  gap: 8rpx;
98
+}
99
+.img-upload__tip {
100
+  font-size: 22rpx;
101
+  color: #9a938c;
102
+}
103
+.img-upload__mask {
104
+  position: absolute;
105
+  inset: 0;
106
+  display: flex;
107
+  align-items: center;
108
+  justify-content: center;
109
+  background: rgba(0, 0, 0, 0.45);
110
+  color: #fff;
111
+  font-size: 24rpx;
112
+}
113
+</style>

+ 87 - 0
shop-app/components/mine/RegionFields.vue

@@ -0,0 +1,87 @@
1
+<template>
2
+	<view class="region-fields">
3
+		<view class="form-row">
4
+			<text class="form-row__label form-row__label--req">省份</text>
5
+			<input
6
+				v-model="province"
7
+				class="form-row__input"
8
+				placeholder="如:青海省"
9
+				@input="emitChange"
10
+			/>
11
+		</view>
12
+		<view class="form-row">
13
+			<text class="form-row__label form-row__label--req">城市</text>
14
+			<input
15
+				v-model="city"
16
+				class="form-row__input"
17
+				placeholder="如:西宁市"
18
+				@input="emitChange"
19
+			/>
20
+		</view>
21
+		<view class="form-row">
22
+			<text class="form-row__label form-row__label--req">区县</text>
23
+			<input
24
+				v-model="district"
25
+				class="form-row__input"
26
+				placeholder="如:城西区"
27
+				@input="emitChange"
28
+			/>
29
+		</view>
30
+	</view>
31
+</template>
32
+
33
+<script setup>
34
+import { ref, watch } from 'vue'
35
+
36
+const props = defineProps({
37
+	modelValue: {
38
+		type: Object,
39
+		default: () => ({ code: '', name: '', province: '', city: '', district: '' })
40
+	}
41
+})
42
+
43
+const emit = defineEmits(['update:modelValue'])
44
+
45
+const province = ref('')
46
+const city = ref('')
47
+const district = ref('')
48
+
49
+watch(
50
+  () => props.modelValue,
51
+  (v) => {
52
+    if (!v) return
53
+    province.value = v.province || ''
54
+    city.value = v.city || ''
55
+    district.value = v.district || ''
56
+    if (!province.value && v.name) {
57
+      const parts = String(v.name).split(/\s+/)
58
+      province.value = parts[0] || ''
59
+      city.value = parts[1] || ''
60
+      district.value = parts[2] || ''
61
+    }
62
+  },
63
+  { immediate: true, deep: true }
64
+)
65
+
66
+function emitChange() {
67
+  const p = (province.value || '').trim()
68
+  const c = (city.value || '').trim()
69
+  const d = (district.value || '').trim()
70
+  const name = [p, c, d].filter(Boolean).join(' ')
71
+  const code = [p, c, d].filter(Boolean).join('|')
72
+  emit('update:modelValue', {
73
+    province: p,
74
+    city: c,
75
+    district: d,
76
+    name,
77
+    code
78
+  })
79
+}
80
+</script>
81
+
82
+<style lang="scss" scoped>
83
+@import '@/styles/mine.scss';
84
+.region-fields .form-row:last-child {
85
+  border-bottom: none;
86
+}
87
+</style>

+ 26 - 0
shop-app/components/mine/entry/EntryEnterpriseBiz.vue

@@ -0,0 +1,26 @@
1
+<template>
2
+	<view>
3
+		<region-fields :model-value="region" @update:model-value="$emit('update:region', $event)" />
4
+		<view class="form-row">
5
+			<text class="form-row__label form-row__label--req">经营详细地址</text>
6
+			<input v-model="biz.bizDetailAddress" class="form-row__input" />
7
+		</view>
8
+		<image-upload v-model="biz.businessLicense" label="营业执照电子版" />
9
+	</view>
10
+</template>
11
+
12
+<script setup>
13
+import RegionFields from '@/components/mine/RegionFields.vue'
14
+import ImageUpload from '@/components/mine/ImageUpload.vue'
15
+
16
+defineProps({
17
+  biz: { type: Object, required: true },
18
+  region: { type: Object, required: true }
19
+})
20
+
21
+defineEmits(['update:region'])
22
+</script>
23
+
24
+<style lang="scss" scoped>
25
+@import '@/styles/mine.scss';
26
+</style>

+ 144 - 0
shop-app/components/mine/entry/EntryEnterpriseSubject.vue

@@ -0,0 +1,144 @@
1
+<template>
2
+	<view>
3
+		<text class="section-title">企业信息</text>
4
+		<view class="form-row">
5
+			<text class="form-row__label form-row__label--req">企业名称</text>
6
+			<input v-model="subject.companyName" class="form-row__input" />
7
+		</view>
8
+		<region-fields :model-value="regionReg" @update:model-value="$emit('update:regionReg', $event)" />
9
+		<view class="form-row">
10
+			<text class="form-row__label form-row__label--req">注册详细地址</text>
11
+			<input v-model="subject.companyDetailAddress" class="form-row__input" />
12
+		</view>
13
+		<view class="form-row">
14
+			<text class="form-row__label form-row__label--req">经营范围</text>
15
+			<input v-model="subject.businessScope" class="form-row__input" />
16
+		</view>
17
+		<view class="form-row">
18
+			<text class="form-row__label form-row__label--req">信用代码</text>
19
+			<input v-model="subject.creditCode" class="form-row__input" />
20
+		</view>
21
+		<valid-period-row
22
+			v-model:valid-type="subject.licenseValidType"
23
+			v-model:start="subject.licenseValidStart"
24
+			v-model:end="subject.licenseValidEnd"
25
+		/>
26
+
27
+		<text class="section-title">法定代表人</text>
28
+		<view class="form-row">
29
+			<text class="form-row__label form-row__label--req">法人姓名</text>
30
+			<input v-model="subject.legalName" class="form-row__input" />
31
+		</view>
32
+		<view class="form-row">
33
+			<text class="form-row__label form-row__label--req">法人性别</text>
34
+			<picker :range="genderLabels" :value="legalGenderIdx" @change="onLegalGender">
35
+				<view class="form-row__picker">
36
+					<text>{{ legalGenderIdx >= 0 ? genderLabels[legalGenderIdx] : '请选择' }}</text>
37
+				</view>
38
+			</picker>
39
+		</view>
40
+		<view class="form-row">
41
+			<text class="form-row__label form-row__label--req">法人出生日期</text>
42
+			<picker mode="date" :value="subject.legalBirthDate" @change="onLegalBirthDate">
43
+				<view class="form-row__picker">
44
+					<text :class="{ 'form-row__placeholder': !subject.legalBirthDate }">
45
+						{{ subject.legalBirthDate || '请选择' }}
46
+					</text>
47
+				</view>
48
+			</picker>
49
+		</view>
50
+		<view class="form-row">
51
+			<text class="form-row__label form-row__label--req">法人证件号</text>
52
+			<input v-model="subject.legalIdCardNo" class="form-row__input" />
53
+		</view>
54
+		<valid-period-row
55
+			v-model:valid-type="subject.legalIdValidType"
56
+			v-model:start="subject.legalIdValidStart"
57
+			v-model:end="subject.legalIdValidEnd"
58
+		/>
59
+		<view class="form-row">
60
+			<text class="form-row__label form-row__label--req">法人居住地址</text>
61
+			<input v-model="subject.legalResidence" class="form-row__input" />
62
+		</view>
63
+		<image-upload v-model="subject.legalIdCardFront" label="法人证件正面" />
64
+		<image-upload v-model="subject.legalIdCardBack" label="法人证件反面" />
65
+
66
+		<text class="section-title">联系与结算</text>
67
+		<view class="form-row">
68
+			<text class="form-row__label form-row__label--req">联系人</text>
69
+			<input v-model="subject.contactName" class="form-row__input" />
70
+		</view>
71
+		<view class="form-row">
72
+			<text class="form-row__label form-row__label--req">联系人手机</text>
73
+			<input v-model="subject.contactPhone" class="form-row__input" type="number" maxlength="11" />
74
+		</view>
75
+		<view class="form-row">
76
+			<text class="form-row__label form-row__label--req">邮箱</text>
77
+			<input v-model="subject.contactEmail" class="form-row__input" />
78
+		</view>
79
+		<view class="form-row">
80
+			<text class="form-row__label form-row__label--req">开户银行</text>
81
+			<input v-model="subject.bankName" class="form-row__input" />
82
+		</view>
83
+		<view class="form-row">
84
+			<text class="form-row__label form-row__label--req">开户支行</text>
85
+			<input v-model="subject.bankBranch" class="form-row__input" />
86
+		</view>
87
+		<view class="form-row">
88
+			<text class="form-row__label form-row__label--req">银行账号</text>
89
+			<input v-model="subject.bankAccount" class="form-row__input" />
90
+		</view>
91
+		<view class="form-row">
92
+			<text class="form-row__label form-row__label--req">对公账号</text>
93
+			<input v-model="subject.corpBankAccount" class="form-row__input" />
94
+		</view>
95
+		<image-upload v-model="subject.accountPermit" label="开户许可证" />
96
+	</view>
97
+</template>
98
+
99
+<script setup>
100
+import { ref, watch } from 'vue'
101
+import { GENDER_OPTIONS } from '@/utils/entryConstants'
102
+import ImageUpload from '@/components/mine/ImageUpload.vue'
103
+import RegionFields from '@/components/mine/RegionFields.vue'
104
+import ValidPeriodRow from '@/components/mine/entry/ValidPeriodRow.vue'
105
+
106
+const props = defineProps({
107
+  subject: { type: Object, required: true },
108
+  regionReg: { type: Object, required: true }
109
+})
110
+
111
+defineEmits(['update:regionReg'])
112
+
113
+const genderLabels = GENDER_OPTIONS.map((g) => g.label)
114
+const legalGenderIdx = ref(-1)
115
+
116
+watch(
117
+  () => props.subject.legalGender,
118
+  (v) => {
119
+    legalGenderIdx.value = GENDER_OPTIONS.findIndex((g) => g.value === v)
120
+  },
121
+  { immediate: true }
122
+)
123
+
124
+function onLegalGender(e) {
125
+  const idx = Number(e.detail.value)
126
+  legalGenderIdx.value = idx
127
+  props.subject.legalGender = GENDER_OPTIONS[idx]?.value || ''
128
+}
129
+
130
+function onLegalBirthDate(e) {
131
+  props.subject.legalBirthDate = e.detail.value
132
+}
133
+</script>
134
+
135
+<style lang="scss" scoped>
136
+@import '@/styles/mine.scss';
137
+.section-title {
138
+  display: block;
139
+  padding: 24rpx 0 8rpx;
140
+  font-size: 28rpx;
141
+  font-weight: 600;
142
+  color: #2e7d32;
143
+}
144
+</style>

+ 32 - 0
shop-app/components/mine/entry/EntryPersonBiz.vue

@@ -0,0 +1,32 @@
1
+<template>
2
+	<view>
3
+		<view class="form-row">
4
+			<text class="form-row__label form-row__label--req">商户名称</text>
5
+			<input v-model="biz.merchantName" class="form-row__input" />
6
+		</view>
7
+		<view class="form-row">
8
+			<text class="form-row__label form-row__label--req">客服电话</text>
9
+			<input v-model="biz.servicePhone" class="form-row__input" />
10
+		</view>
11
+		<region-fields :model-value="region" @update:model-value="$emit('update:region', $event)" />
12
+		<view class="form-row">
13
+			<text class="form-row__label form-row__label--req">详细地址</text>
14
+			<input v-model="biz.bizDetailAddress" class="form-row__input" placeholder="街道门牌" />
15
+		</view>
16
+	</view>
17
+</template>
18
+
19
+<script setup>
20
+import RegionFields from '@/components/mine/RegionFields.vue'
21
+
22
+defineProps({
23
+  biz: { type: Object, required: true },
24
+  region: { type: Object, required: true }
25
+})
26
+
27
+defineEmits(['update:region'])
28
+</script>
29
+
30
+<style lang="scss" scoped>
31
+@import '@/styles/mine.scss';
32
+</style>

+ 105 - 0
shop-app/components/mine/entry/EntryPersonSubject.vue

@@ -0,0 +1,105 @@
1
+<template>
2
+	<view>
3
+		<view class="form-row">
4
+			<text class="form-row__label form-row__label--req">姓名</text>
5
+			<input v-model="subject.personName" class="form-row__input" placeholder="请输入" />
6
+		</view>
7
+		<view class="form-row">
8
+			<text class="form-row__label form-row__label--req">性别</text>
9
+			<picker :range="genderLabels" :value="genderIdx" @change="onGender">
10
+				<view class="form-row__picker">
11
+					<text :class="{ 'form-row__placeholder': genderIdx < 0 }">
12
+						{{ genderIdx >= 0 ? genderLabels[genderIdx] : '请选择' }}
13
+					</text>
14
+				</view>
15
+			</picker>
16
+		</view>
17
+		<view class="form-row">
18
+			<text class="form-row__label form-row__label--req">出生日期</text>
19
+			<picker mode="date" :value="subject.birthDate" :end="today" @change="onBirthDate">
20
+				<view class="form-row__picker">
21
+					<text :class="{ 'form-row__placeholder': !subject.birthDate }">{{ subject.birthDate || '请选择' }}</text>
22
+				</view>
23
+			</picker>
24
+		</view>
25
+		<view class="form-row">
26
+			<text class="form-row__label form-row__label--req">证件号码</text>
27
+			<input v-model="subject.idCardNo" class="form-row__input" placeholder="身份证号" />
28
+		</view>
29
+		<valid-period-row
30
+			v-model:valid-type="subject.idValidType"
31
+			v-model:start="subject.idValidStart"
32
+			v-model:end="subject.idValidEnd"
33
+		/>
34
+		<view class="form-row">
35
+			<text class="form-row__label form-row__label--req">居住地址</text>
36
+			<input v-model="subject.residenceAddress" class="form-row__input" placeholder="详细地址" />
37
+		</view>
38
+		<image-upload v-model="subject.idCardFront" label="证件照正面" />
39
+		<image-upload v-model="subject.idCardBack" label="证件照反面" />
40
+		<view class="form-row">
41
+			<text class="form-row__label form-row__label--req">联系人</text>
42
+			<input v-model="subject.contactName" class="form-row__input" />
43
+		</view>
44
+		<view class="form-row">
45
+			<text class="form-row__label form-row__label--req">联系人手机</text>
46
+			<input v-model="subject.contactPhone" class="form-row__input" type="number" maxlength="11" />
47
+		</view>
48
+		<view class="form-row">
49
+			<text class="form-row__label form-row__label--req">邮箱</text>
50
+			<input v-model="subject.contactEmail" class="form-row__input" />
51
+		</view>
52
+		<view class="form-row">
53
+			<text class="form-row__label form-row__label--req">开户银行</text>
54
+			<input v-model="subject.bankName" class="form-row__input" />
55
+		</view>
56
+		<view class="form-row">
57
+			<text class="form-row__label form-row__label--req">开户支行</text>
58
+			<input v-model="subject.bankBranch" class="form-row__input" />
59
+		</view>
60
+		<view class="form-row">
61
+			<text class="form-row__label form-row__label--req">银行账号</text>
62
+			<input v-model="subject.bankAccount" class="form-row__input" />
63
+		</view>
64
+	</view>
65
+</template>
66
+
67
+<script setup>
68
+import { ref, computed, watch } from 'vue'
69
+import { GENDER_OPTIONS } from '@/utils/entryConstants'
70
+import ImageUpload from '@/components/mine/ImageUpload.vue'
71
+import ValidPeriodRow from '@/components/mine/entry/ValidPeriodRow.vue'
72
+
73
+const props = defineProps({
74
+  subject: { type: Object, required: true }
75
+})
76
+
77
+const genderLabels = GENDER_OPTIONS.map((g) => g.label)
78
+const genderIdx = ref(-1)
79
+const today = computed(() => {
80
+  const d = new Date()
81
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
82
+})
83
+
84
+watch(
85
+  () => props.subject.gender,
86
+  (v) => {
87
+    genderIdx.value = GENDER_OPTIONS.findIndex((g) => g.value === v)
88
+  },
89
+  { immediate: true }
90
+)
91
+
92
+function onGender(e) {
93
+  const idx = Number(e.detail.value)
94
+  genderIdx.value = idx
95
+  props.subject.gender = GENDER_OPTIONS[idx]?.value || ''
96
+}
97
+
98
+function onBirthDate(e) {
99
+  props.subject.birthDate = e.detail.value
100
+}
101
+</script>
102
+
103
+<style lang="scss" scoped>
104
+@import '@/styles/mine.scss';
105
+</style>

+ 44 - 0
shop-app/components/mine/entry/EntryShopFields.vue

@@ -0,0 +1,44 @@
1
+<template>
2
+	<view>
3
+		<view class="form-row">
4
+			<text class="form-row__label form-row__label--req">店铺名称</text>
5
+			<input v-model="shop.shopName" class="form-row__input" />
6
+		</view>
7
+		<image-upload v-model="shop.shopAvatar" label="店铺 Logo" />
8
+		<view class="form-row">
9
+			<text class="form-row__label form-row__label--req">联系电话</text>
10
+			<input v-model="shop.shopPhone" class="form-row__input" />
11
+		</view>
12
+		<view class="form-row form-row--col">
13
+			<text class="form-row__label">店铺简介</text>
14
+			<textarea
15
+				v-model="shop.shopDesc"
16
+				class="shop-desc"
17
+				placeholder="选填"
18
+				maxlength="500"
19
+			/>
20
+		</view>
21
+	</view>
22
+</template>
23
+
24
+<script setup>
25
+import ImageUpload from '@/components/mine/ImageUpload.vue'
26
+
27
+defineProps({
28
+  shop: { type: Object, required: true }
29
+})
30
+</script>
31
+
32
+<style lang="scss" scoped>
33
+@import '@/styles/mine.scss';
34
+.shop-desc {
35
+  width: 100%;
36
+  min-height: 160rpx;
37
+  margin-top: 12rpx;
38
+  padding: 16rpx;
39
+  font-size: 28rpx;
40
+  background: #f5f2ef;
41
+  border-radius: 8rpx;
42
+  box-sizing: border-box;
43
+}
44
+</style>

+ 71 - 0
shop-app/components/mine/entry/ValidPeriodRow.vue

@@ -0,0 +1,71 @@
1
+<template>
2
+	<view>
3
+		<view class="form-row">
4
+			<text class="form-row__label form-row__label--req">有效期类型</text>
5
+			<picker :range="validLabels" :value="validIndex" @change="onValidType">
6
+				<view class="form-row__picker">
7
+					<text>{{ validLabels[validIndex] || '请选择' }}</text>
8
+				</view>
9
+			</picker>
10
+		</view>
11
+		<view v-if="validType === VALID_RANGE" class="form-row">
12
+			<text class="form-row__label form-row__label--req">开始日期</text>
13
+			<picker mode="date" :value="start" @change="onStart">
14
+				<view class="form-row__picker">
15
+					<text :class="{ 'form-row__placeholder': !start }">{{ start || '请选择' }}</text>
16
+				</view>
17
+			</picker>
18
+		</view>
19
+		<view v-if="validType === VALID_RANGE" class="form-row">
20
+			<text class="form-row__label form-row__label--req">结束日期</text>
21
+			<picker mode="date" :value="end" @change="onEnd">
22
+				<view class="form-row__picker">
23
+					<text :class="{ 'form-row__placeholder': !end }">{{ end || '请选择' }}</text>
24
+				</view>
25
+			</picker>
26
+		</view>
27
+	</view>
28
+</template>
29
+
30
+<script setup>
31
+import { ref, watch, computed } from 'vue'
32
+import { VALID_TYPE_OPTIONS, VALID_RANGE } from '@/utils/entryConstants'
33
+
34
+const props = defineProps({
35
+	validType: { type: String, default: '1' },
36
+	start: { type: String, default: '' },
37
+	end: { type: String, default: '' }
38
+})
39
+
40
+const emit = defineEmits(['update:validType', 'update:start', 'update:end'])
41
+
42
+const validLabels = VALID_TYPE_OPTIONS.map((o) => o.label)
43
+const validIndex = ref(0)
44
+
45
+watch(
46
+  () => props.validType,
47
+  (v) => {
48
+    const idx = VALID_TYPE_OPTIONS.findIndex((o) => o.value === v)
49
+    validIndex.value = idx >= 0 ? idx : 0
50
+  },
51
+  { immediate: true }
52
+)
53
+
54
+function onValidType(e) {
55
+  const idx = Number(e.detail.value)
56
+  validIndex.value = idx
57
+  emit('update:validType', VALID_TYPE_OPTIONS[idx].value)
58
+}
59
+
60
+function onStart(e) {
61
+  emit('update:start', e.detail.value)
62
+}
63
+
64
+function onEnd(e) {
65
+  emit('update:end', e.detail.value)
66
+}
67
+</script>
68
+
69
+<style lang="scss" scoped>
70
+@import '@/styles/mine.scss';
71
+</style>

+ 46 - 0
shop-app/pages.json

@@ -51,6 +51,48 @@
51 51
 					"style": {
52 52
 						"navigationBarTitleText": "会员注册"
53 53
 					}
54
+				},
55
+				{
56
+					"path": "profile",
57
+					"style": {
58
+						"navigationBarTitleText": "个人资料"
59
+					}
60
+				},
61
+				{
62
+					"path": "password",
63
+					"style": {
64
+						"navigationBarTitleText": "修改密码"
65
+					}
66
+				},
67
+				{
68
+					"path": "address-list",
69
+					"style": {
70
+						"navigationBarTitleText": "收货地址"
71
+					}
72
+				},
73
+				{
74
+					"path": "address-edit",
75
+					"style": {
76
+						"navigationBarTitleText": "编辑地址"
77
+					}
78
+				},
79
+				{
80
+					"path": "entry-apply",
81
+					"style": {
82
+						"navigationBarTitleText": "商家入驻"
83
+					}
84
+				},
85
+				{
86
+					"path": "entry-list",
87
+					"style": {
88
+						"navigationBarTitleText": "我的入驻申请"
89
+					}
90
+				},
91
+				{
92
+					"path": "entry-detail",
93
+					"style": {
94
+						"navigationBarTitleText": "申请详情"
95
+					}
54 96
 				}
55 97
 			]
56 98
 		},
@@ -117,6 +159,10 @@
117 159
 		"pages/category/index": {
118 160
 			"network": "all",
119 161
 			"packages": ["pkg-category", "pkg-goods"]
162
+		},
163
+		"pages/mine/index": {
164
+			"network": "all",
165
+			"packages": ["pkg-account"]
120 166
 		}
121 167
 	},
122 168
 	"globalStyle": {

+ 134 - 130
shop-app/pages/mine/index.vue

@@ -1,165 +1,169 @@
1 1
 <template>
2
-	<view class="page">
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>
2
+	<view class="mine-page">
3
+		<view class="mine-header" @click="onHeaderClick">
4
+			<view class="mine-header__row">
5
+				<image class="mine-header__avatar" :src="avatarUrl" mode="aspectFill" />
6
+				<view class="mine-header__info">
7
+					<text class="mine-header__name">{{ headerTitle }}</text>
8
+					<text v-if="loggedIn && mobileText" class="mine-header__sub">{{ mobileText }}</text>
9
+					<text v-else-if="!loggedIn" class="mine-header__sub">登录后管理资料与地址</text>
14 10
 				</view>
11
+				<u-icon v-if="loggedIn" name="arrow-right" color="#fff" size="18" class="mine-header__arrow" />
15 12
 			</view>
16
-			<button class="btn-outline" @click="handleLogout">退出登录</button>
17 13
 		</view>
18
-		<view v-else class="guest-box">
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>
14
+
15
+		<view class="mine-body">
16
+			<view v-if="!loggedIn" class="mine-guest">
17
+				<text class="mine-guest__title">登录后享受完整购物服务</text>
18
+				<text class="mine-guest__tip">下单、加购需登录会员账号</text>
19
+				<view class="mine-guest__btns">
20
+					<button class="mine-btn-primary" @click="goLogin">登录</button>
21
+					<button class="mine-btn-outline" @click="goRegister">注册会员</button>
22
+				</view>
23
+			</view>
24
+
25
+			<template v-else>
26
+				<view v-for="section in menuSections" :key="section.title" class="mine-card">
27
+					<text class="mine-card__title">{{ section.title }}</text>
28
+					<view
29
+						v-for="item in section.items"
30
+						:key="item.path"
31
+						class="mine-menu-item"
32
+						@click="goPage(item.path)"
33
+					>
34
+						<u-icon :name="item.icon" color="#5c5652" size="22" />
35
+						<text class="mine-menu-item__label">{{ item.label }}</text>
36
+						<u-icon name="arrow-right" color="#ccc" size="16" />
37
+					</view>
38
+				</view>
39
+
40
+				<view class="mine-logout">
41
+					<button class="mine-btn-outline" @click="handleLogout">退出登录</button>
42
+				</view>
43
+			</template>
23 44
 		</view>
24 45
 	</view>
25 46
 </template>
26 47
 
27 48
 <script setup>
28
-import { ref } from 'vue'
49
+import { ref, computed } from 'vue'
29 50
 import { onShow } from '@dcloudio/uni-app'
30 51
 import { getToken } from '@/utils/auth'
31 52
 import { useUserStore } from '@/store/user'
32
-import { PAGE_LOGIN, PAGE_REGISTER } from '@/utils/pageRoute'
53
+import { navigateMinePage } from '@/utils/mineNav'
54
+import {
55
+  PAGE_LOGIN,
56
+  PAGE_REGISTER,
57
+  PAGE_PROFILE,
58
+  PAGE_PASSWORD,
59
+  PAGE_ADDRESS_LIST,
60
+  PAGE_ENTRY_APPLY,
61
+  PAGE_ENTRY_LIST
62
+} from '@/utils/pageRoute'
33 63
 
34 64
 const loggedIn = ref(false)
35
-const displayName = ref('会员')
65
+const displayName = ref('点击登录')
36 66
 const mobileText = ref('')
37
-const memberCodeText = ref('')
38 67
 const avatarUrl = ref('/static/logo.png')
39 68
 
40 69
 const userStore = useUserStore()
41 70
 
71
+const headerTitle = computed(() => {
72
+  if (!loggedIn.value) return '未登录'
73
+  return displayName.value
74
+})
75
+
76
+/** 我的服务菜单(对齐功能需求 §3) */
77
+const menuSections = [
78
+  {
79
+    title: '账号管理',
80
+    items: [
81
+      { label: '编辑个人资料', path: PAGE_PROFILE, icon: 'account' },
82
+      { label: '修改密码', path: PAGE_PASSWORD, icon: 'lock' }
83
+    ]
84
+  },
85
+  {
86
+    title: '收货地址',
87
+    items: [{ label: '收货地址', path: PAGE_ADDRESS_LIST, icon: 'map' }]
88
+  },
89
+  {
90
+    title: '商家入驻',
91
+    items: [
92
+      { label: '我要入驻', path: PAGE_ENTRY_APPLY, icon: 'home' },
93
+      { label: '我的入驻申请', path: PAGE_ENTRY_LIST, icon: 'list' }
94
+    ]
95
+  }
96
+]
97
+
42 98
 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
-	}
99
+  loggedIn.value = !!getToken()
100
+  if (!loggedIn.value) {
101
+    displayName.value = '点击登录'
102
+    mobileText.value = ''
103
+    avatarUrl.value = '/static/logo.png'
104
+    return
105
+  }
106
+  refreshUser()
59 107
 })
60 108
 
109
+function refreshUser() {
110
+  displayName.value = userStore.displayName() || '会员'
111
+  mobileText.value = userStore.state.mobile || ''
112
+  avatarUrl.value = userStore.state.avatar || '/static/logo.png'
113
+  if (!userStore.state.memberId) {
114
+    userStore.fetchUserInfo().then(() => {
115
+      displayName.value = userStore.displayName() || '会员'
116
+      mobileText.value = userStore.state.mobile || ''
117
+      avatarUrl.value = userStore.state.avatar || '/static/logo.png'
118
+    })
119
+  }
120
+}
121
+
122
+function onHeaderClick() {
123
+  if (!loggedIn.value) {
124
+    goLogin()
125
+    return
126
+  }
127
+  navigateMinePage(PAGE_PROFILE)
128
+}
129
+
130
+function goPage(path) {
131
+  navigateMinePage(path)
132
+}
133
+
61 134
 function goLogin() {
62
-	uni.navigateTo({ url: PAGE_LOGIN })
135
+  uni.navigateTo({ url: PAGE_LOGIN })
63 136
 }
64 137
 
65 138
 function goRegister() {
66
-	uni.navigateTo({ url: PAGE_REGISTER })
139
+  uni.navigateTo({ url: PAGE_REGISTER })
67 140
 }
68 141
 
69 142
 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' })
81
-				})
82
-			}
83
-		}
84
-	})
143
+  uni.showModal({
144
+    title: '提示',
145
+    content: '确定退出当前账号吗?',
146
+    success: (res) => {
147
+      if (res.confirm) {
148
+        userStore.logOut().then(() => {
149
+          loggedIn.value = false
150
+          displayName.value = '点击登录'
151
+          mobileText.value = ''
152
+          uni.showToast({ title: '已退出', icon: 'none' })
153
+        })
154
+      }
155
+    }
156
+  })
85 157
 }
86 158
 </script>
87 159
 
88
-<style scoped>
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;
160
+<style lang="scss" scoped>
161
+@import '@/styles/mine.scss';
162
+
163
+.mine-menu-item u-icon:first-child {
164
+  margin-right: 16rpx;
153 165
 }
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;
166
+.mine-menu-item {
167
+  gap: 16rpx;
164 168
 }
165 169
 </style>

+ 230 - 0
shop-app/styles/mine.scss

@@ -0,0 +1,230 @@
1
+@import '@/styles/morandi.scss';
2
+
3
+.mine-page {
4
+  min-height: 100vh;
5
+  padding-bottom: 48rpx;
6
+  background: $morandi-bg-page;
7
+  box-sizing: border-box;
8
+}
9
+
10
+.mine-header {
11
+  padding: 48rpx 32rpx 64rpx;
12
+  background: linear-gradient(145deg, #3d9b6e 0%, #22c55e 55%, #86efac 100%);
13
+}
14
+
15
+.mine-header__row {
16
+  display: flex;
17
+  align-items: center;
18
+}
19
+
20
+.mine-header__avatar {
21
+  width: 120rpx;
22
+  height: 120rpx;
23
+  border-radius: 60rpx;
24
+  background: rgba(255, 255, 255, 0.35);
25
+  border: 4rpx solid rgba(255, 255, 255, 0.6);
26
+  flex-shrink: 0;
27
+}
28
+
29
+.mine-header__info {
30
+  flex: 1;
31
+  margin-left: 24rpx;
32
+}
33
+
34
+.mine-header__name {
35
+  display: block;
36
+  font-size: 36rpx;
37
+  font-weight: 600;
38
+  color: #fff;
39
+}
40
+
41
+.mine-header__sub {
42
+  display: block;
43
+  margin-top: 8rpx;
44
+  font-size: 24rpx;
45
+  color: rgba(255, 255, 255, 0.88);
46
+}
47
+
48
+.mine-header__arrow {
49
+  margin-left: 8rpx;
50
+}
51
+
52
+.mine-body {
53
+  margin-top: -40rpx;
54
+  padding: 0 24rpx;
55
+}
56
+
57
+.mine-card {
58
+  margin-bottom: 24rpx;
59
+  padding: 8rpx 0;
60
+  background: #fff;
61
+  border-radius: 20rpx;
62
+  box-shadow: 0 4rpx 20rpx rgba(74, 69, 66, 0.06);
63
+}
64
+
65
+.mine-card__title {
66
+  padding: 20rpx 28rpx 8rpx;
67
+  font-size: 24rpx;
68
+  color: $morandi-text-muted;
69
+}
70
+
71
+.mine-menu-item {
72
+  display: flex;
73
+  align-items: center;
74
+  padding: 28rpx;
75
+  border-bottom: 1rpx solid #f0f0f0;
76
+}
77
+
78
+.mine-menu-item:last-child {
79
+  border-bottom: none;
80
+}
81
+
82
+.mine-menu-item__label {
83
+  flex: 1;
84
+  font-size: 30rpx;
85
+  color: $morandi-text;
86
+}
87
+
88
+.mine-guest {
89
+  margin: 24rpx;
90
+  padding: 48rpx 32rpx;
91
+  text-align: center;
92
+  background: #fff;
93
+  border-radius: 20rpx;
94
+}
95
+
96
+.mine-guest__title {
97
+  font-size: 32rpx;
98
+  font-weight: 600;
99
+  color: $morandi-text;
100
+}
101
+
102
+.mine-guest__tip {
103
+  display: block;
104
+  margin-top: 12rpx;
105
+  font-size: 26rpx;
106
+  color: $morandi-text-muted;
107
+}
108
+
109
+.mine-guest__btns {
110
+  margin-top: 40rpx;
111
+  display: flex;
112
+  flex-direction: column;
113
+  gap: 20rpx;
114
+}
115
+
116
+.mine-btn-primary {
117
+  height: 88rpx;
118
+  line-height: 88rpx;
119
+  background: #2e7d32;
120
+  color: #fff;
121
+  font-size: 30rpx;
122
+  border-radius: 44rpx;
123
+  border: none;
124
+}
125
+
126
+.mine-btn-outline {
127
+  height: 80rpx;
128
+  line-height: 80rpx;
129
+  background: #fff;
130
+  color: #2e7d32;
131
+  font-size: 28rpx;
132
+  border-radius: 40rpx;
133
+  border: 1rpx solid #2e7d32;
134
+}
135
+
136
+.mine-logout {
137
+  margin: 16rpx 24rpx 0;
138
+}
139
+
140
+/* 表单页通用 */
141
+.form-page {
142
+  min-height: 100vh;
143
+  padding: 24rpx;
144
+  padding-bottom: 160rpx;
145
+  background: $morandi-bg-page;
146
+  box-sizing: border-box;
147
+}
148
+
149
+.form-card {
150
+  padding: 8rpx 24rpx;
151
+  background: #fff;
152
+  border-radius: 16rpx;
153
+}
154
+
155
+.form-row {
156
+  display: flex;
157
+  align-items: center;
158
+  min-height: 96rpx;
159
+  border-bottom: 1rpx solid #f5f5f5;
160
+}
161
+
162
+.form-row:last-child {
163
+  border-bottom: none;
164
+}
165
+
166
+.form-row--col {
167
+  flex-direction: column;
168
+  align-items: stretch;
169
+  padding: 20rpx 0;
170
+}
171
+
172
+.form-row__label {
173
+  width: 200rpx;
174
+  flex-shrink: 0;
175
+  font-size: 28rpx;
176
+  color: $morandi-text-secondary;
177
+}
178
+
179
+.form-row__label--req::before {
180
+  content: '*';
181
+  color: #d32f2f;
182
+  margin-right: 4rpx;
183
+}
184
+
185
+.form-row__input {
186
+  flex: 1;
187
+  font-size: 28rpx;
188
+  color: $morandi-text;
189
+}
190
+
191
+.form-row__readonly {
192
+  flex: 1;
193
+  font-size: 28rpx;
194
+  color: $morandi-text-muted;
195
+}
196
+
197
+.form-row__picker {
198
+  flex: 1;
199
+  font-size: 28rpx;
200
+  color: $morandi-text;
201
+}
202
+
203
+.form-row__placeholder {
204
+  color: $morandi-text-soft;
205
+}
206
+
207
+.form-footer {
208
+  position: fixed;
209
+  left: 0;
210
+  right: 0;
211
+  bottom: 0;
212
+  padding: 24rpx 32rpx;
213
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
214
+  background: #fff;
215
+  box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
216
+}
217
+
218
+.form-footer__btn {
219
+  height: 88rpx;
220
+  line-height: 88rpx;
221
+  background: #2e7d32;
222
+  color: #fff;
223
+  font-size: 30rpx;
224
+  border-radius: 44rpx;
225
+  border: none;
226
+}
227
+
228
+.form-footer__btn[disabled] {
229
+  opacity: 0.6;
230
+}

+ 155 - 0
shop-app/subpackage/account/address-edit.vue

@@ -0,0 +1,155 @@
1
+<template>
2
+	<view class="form-page">
3
+		<view class="form-card">
4
+			<view class="form-row">
5
+				<text class="form-row__label form-row__label--req">收货人</text>
6
+				<input v-model="form.consigneeName" class="form-row__input" placeholder="请输入收货人" />
7
+			</view>
8
+			<view class="form-row">
9
+				<text class="form-row__label form-row__label--req">手机号</text>
10
+				<input
11
+					v-model="form.mobile"
12
+					class="form-row__input"
13
+					type="number"
14
+					maxlength="11"
15
+					placeholder="11 位手机号"
16
+				/>
17
+			</view>
18
+			<region-fields v-model="region" />
19
+			<view class="form-row form-row--col">
20
+				<text class="form-row__label form-row__label--req">详细地址</text>
21
+				<input
22
+					v-model="form.detailAddress"
23
+					class="form-row__input"
24
+					placeholder="街道、门牌号等"
25
+				/>
26
+			</view>
27
+			<view class="form-row" @click="form.isDefault = form.isDefault === '1' ? '0' : '1'">
28
+				<text class="form-row__label">设为默认</text>
29
+				<switch :checked="form.isDefault === '1'" color="#2e7d32" @change="onDefaultChange" />
30
+			</view>
31
+		</view>
32
+		<view class="form-footer">
33
+			<button class="form-footer__btn" :disabled="saving" @click="handleSave">
34
+				{{ saving ? '保存中…' : '保 存' }}
35
+			</button>
36
+		</view>
37
+	</view>
38
+</template>
39
+
40
+<script setup>
41
+import { ref, reactive } from 'vue'
42
+import { onLoad } from '@dcloudio/uni-app'
43
+import { getAddressList, addAddress, updateAddress } from '@/api/member'
44
+import { ensureApiToken } from '@/utils/apiAuth'
45
+import { validateMobile } from '@/utils/memberValidate'
46
+import RegionFields from '@/components/mine/RegionFields.vue'
47
+
48
+const mode = ref('add')
49
+const addressId = ref(null)
50
+const saving = ref(false)
51
+
52
+const form = reactive({
53
+  consigneeName: '',
54
+  mobile: '',
55
+  province: '',
56
+  city: '',
57
+  district: '',
58
+  detailAddress: '',
59
+  isDefault: '0'
60
+})
61
+
62
+const region = ref({ code: '', name: '', province: '', city: '', district: '' })
63
+
64
+onLoad((options) => {
65
+  if (!ensureApiToken()) return
66
+  mode.value = options.mode === 'edit' ? 'edit' : 'add'
67
+  if (options.id) {
68
+    addressId.value = Number(options.id)
69
+    uni.setNavigationBarTitle({ title: '编辑地址' })
70
+    loadDetail()
71
+  } else {
72
+    uni.setNavigationBarTitle({ title: '新增地址' })
73
+  }
74
+})
75
+
76
+function loadDetail() {
77
+  getAddressList().then((res) => {
78
+    const rows = res.data || []
79
+    const item = rows.find((r) => r.addressId === addressId.value)
80
+    if (!item) return
81
+    form.consigneeName = item.consigneeName || ''
82
+    form.mobile = item.mobile || ''
83
+    form.province = item.province || ''
84
+    form.city = item.city || ''
85
+    form.district = item.district || ''
86
+    form.detailAddress = item.detailAddress || ''
87
+    form.isDefault = item.isDefault === '1' ? '1' : '0'
88
+    region.value = {
89
+      province: form.province,
90
+      city: form.city,
91
+      district: form.district,
92
+      name: [form.province, form.city, form.district].filter(Boolean).join(' '),
93
+      code: [form.province, form.city, form.district].filter(Boolean).join('|')
94
+    }
95
+  })
96
+}
97
+
98
+function onDefaultChange(e) {
99
+  form.isDefault = e.detail.value ? '1' : '0'
100
+}
101
+
102
+function validate() {
103
+  if (!(form.consigneeName || '').trim()) {
104
+    uni.showToast({ title: '请输入收货人', icon: 'none' })
105
+    return false
106
+  }
107
+  const m = validateMobile(form.mobile)
108
+  if (m) {
109
+    uni.showToast({ title: m, icon: 'none' })
110
+    return false
111
+  }
112
+  if (!region.value.name) {
113
+    uni.showToast({ title: '请填写所在地区', icon: 'none' })
114
+    return false
115
+  }
116
+  if (!(form.detailAddress || '').trim()) {
117
+    uni.showToast({ title: '请输入详细地址', icon: 'none' })
118
+    return false
119
+  }
120
+  return true
121
+}
122
+
123
+function buildPayload() {
124
+  const parts = region.value
125
+  return {
126
+    addressId: mode.value === 'edit' ? addressId.value : undefined,
127
+    consigneeName: form.consigneeName.trim(),
128
+    mobile: form.mobile.trim(),
129
+    province: parts.province,
130
+    city: parts.city,
131
+    district: parts.district,
132
+    detailAddress: form.detailAddress.trim(),
133
+    isDefault: form.isDefault
134
+  }
135
+}
136
+
137
+function handleSave() {
138
+  if (saving.value || !validate()) return
139
+  saving.value = true
140
+  const api = mode.value === 'edit' ? updateAddress : addAddress
141
+  api(buildPayload())
142
+    .then(() => {
143
+      uni.showToast({ title: '保存成功', icon: 'none' })
144
+      setTimeout(() => uni.navigateBack(), 600)
145
+    })
146
+    .catch(() => {})
147
+    .finally(() => {
148
+      saving.value = false
149
+    })
150
+}
151
+</script>
152
+
153
+<style lang="scss" scoped>
154
+@import '@/styles/mine.scss';
155
+</style>

+ 195 - 0
shop-app/subpackage/account/address-list.vue

@@ -0,0 +1,195 @@
1
+<template>
2
+	<view class="addr-page">
3
+		<view v-if="loading" class="addr-page__loading">
4
+			<text>加载中…</text>
5
+		</view>
6
+		<view v-else-if="list.length === 0" class="addr-empty">
7
+			<text class="addr-empty__txt">暂无收货地址,请添加</text>
8
+			<button class="mine-btn-primary addr-empty__btn" @click="goAdd">新增地址</button>
9
+		</view>
10
+		<view v-else class="addr-list">
11
+			<view v-for="item in list" :key="item.addressId" class="addr-card">
12
+				<view class="addr-card__head">
13
+					<text class="addr-card__name">{{ item.consigneeName }}</text>
14
+					<text class="addr-card__mobile">{{ item.mobile }}</text>
15
+					<text v-if="item.isDefault === '1'" class="addr-card__tag">默认</text>
16
+				</view>
17
+				<text class="addr-card__addr">{{ item.fullAddress || formatAddr(item) }}</text>
18
+				<view class="addr-card__actions">
19
+					<text
20
+						v-if="item.isDefault !== '1'"
21
+						class="addr-card__act"
22
+						@click="setDefault(item)"
23
+					>设为默认</text>
24
+					<text class="addr-card__act" @click="goEdit(item)">编辑</text>
25
+					<text class="addr-card__act addr-card__act--danger" @click="remove(item)">删除</text>
26
+				</view>
27
+			</view>
28
+		</view>
29
+		<view v-if="list.length > 0" class="addr-footer">
30
+			<button class="form-footer__btn" @click="goAdd">新增地址</button>
31
+		</view>
32
+	</view>
33
+</template>
34
+
35
+<script setup>
36
+import { ref } from 'vue'
37
+import { onShow } from '@dcloudio/uni-app'
38
+import { getAddressList, deleteAddress, setDefaultAddress } from '@/api/member'
39
+import { ensureApiToken } from '@/utils/apiAuth'
40
+import { PAGE_ADDRESS_EDIT } from '@/utils/pageRoute'
41
+
42
+const list = ref([])
43
+const loading = ref(true)
44
+
45
+onShow(() => {
46
+  if (!ensureApiToken(false)) return
47
+  loadList()
48
+})
49
+
50
+function loadList() {
51
+  loading.value = true
52
+  getAddressList()
53
+    .then((res) => {
54
+      const rows = res.data || []
55
+      list.value = rows.sort((a, b) => {
56
+        if (a.isDefault === '1' && b.isDefault !== '1') return -1
57
+        if (b.isDefault === '1' && a.isDefault !== '1') return 1
58
+        return 0
59
+      })
60
+    })
61
+    .catch(() => {
62
+      list.value = []
63
+    })
64
+    .finally(() => {
65
+      loading.value = false
66
+    })
67
+}
68
+
69
+function formatAddr(item) {
70
+  return [item.province, item.city, item.district, item.detailAddress].filter(Boolean).join('')
71
+}
72
+
73
+function goAdd() {
74
+  uni.navigateTo({ url: `${PAGE_ADDRESS_EDIT}?mode=add` })
75
+}
76
+
77
+function goEdit(item) {
78
+  uni.navigateTo({
79
+    url: `${PAGE_ADDRESS_EDIT}?mode=edit&id=${item.addressId}`
80
+  })
81
+}
82
+
83
+function setDefault(item) {
84
+  setDefaultAddress(item.addressId)
85
+    .then(() => {
86
+      uni.showToast({ title: '已设为默认', icon: 'none' })
87
+      loadList()
88
+    })
89
+    .catch(() => {})
90
+}
91
+
92
+function remove(item) {
93
+  uni.showModal({
94
+    title: '提示',
95
+    content: '确定删除该收货地址吗?',
96
+    success: (res) => {
97
+      if (!res.confirm) return
98
+      deleteAddress(item.addressId)
99
+        .then(() => {
100
+          uni.showToast({ title: '已删除', icon: 'none' })
101
+          loadList()
102
+        })
103
+        .catch(() => {})
104
+    }
105
+  })
106
+}
107
+</script>
108
+
109
+<style lang="scss" scoped>
110
+@import '@/styles/mine.scss';
111
+
112
+.addr-page {
113
+  min-height: 100vh;
114
+  padding: 24rpx;
115
+  padding-bottom: 160rpx;
116
+  background: #f0ebe5;
117
+  box-sizing: border-box;
118
+}
119
+.addr-empty {
120
+  padding: 120rpx 48rpx;
121
+  text-align: center;
122
+  background: #fff;
123
+  border-radius: 16rpx;
124
+}
125
+.addr-empty__txt {
126
+  font-size: 28rpx;
127
+  color: #9a938c;
128
+}
129
+.addr-empty__btn {
130
+  margin-top: 40rpx;
131
+  width: 100%;
132
+}
133
+.addr-card {
134
+  margin-bottom: 20rpx;
135
+  padding: 28rpx;
136
+  background: #fff;
137
+  border-radius: 16rpx;
138
+}
139
+.addr-card__head {
140
+  display: flex;
141
+  align-items: center;
142
+  flex-wrap: wrap;
143
+  gap: 12rpx;
144
+}
145
+.addr-card__name {
146
+  font-size: 30rpx;
147
+  font-weight: 600;
148
+  color: #333;
149
+}
150
+.addr-card__mobile {
151
+  font-size: 28rpx;
152
+  color: #666;
153
+}
154
+.addr-card__tag {
155
+  padding: 4rpx 12rpx;
156
+  font-size: 22rpx;
157
+  color: #2e7d32;
158
+  background: #e8f5e9;
159
+  border-radius: 6rpx;
160
+}
161
+.addr-card__addr {
162
+  display: block;
163
+  margin-top: 12rpx;
164
+  font-size: 26rpx;
165
+  color: #666;
166
+  line-height: 1.5;
167
+}
168
+.addr-card__actions {
169
+  margin-top: 20rpx;
170
+  display: flex;
171
+  justify-content: flex-end;
172
+  gap: 32rpx;
173
+}
174
+.addr-card__act {
175
+  font-size: 26rpx;
176
+  color: #2e7d32;
177
+}
178
+.addr-card__act--danger {
179
+  color: #d32f2f;
180
+}
181
+.addr-footer {
182
+  position: fixed;
183
+  left: 0;
184
+  right: 0;
185
+  bottom: 0;
186
+  padding: 24rpx 32rpx;
187
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
188
+  background: #fff;
189
+}
190
+.addr-page__loading {
191
+  padding: 80rpx;
192
+  text-align: center;
193
+  color: #999;
194
+}
195
+</style>

+ 303 - 0
shop-app/subpackage/account/entry-apply.vue

@@ -0,0 +1,303 @@
1
+<template>
2
+	<view class="entry-apply-page">
3
+		<view v-if="!entryOpen" class="entry-closed">
4
+			<text>{{ agreement.message || '商家入驻暂未开放' }}</text>
5
+		</view>
6
+		<view v-else-if="blocked" class="entry-closed">
7
+			<text>您有待审核或公示中的申请,请先在「我的入驻申请」查看进度</text>
8
+			<button class="mine-btn-outline entry-closed__btn" @click="goList">查看申请</button>
9
+		</view>
10
+		<template v-else>
11
+			<view class="entry-steps">
12
+				<text
13
+					v-for="(s, i) in stepTitles"
14
+					:key="s"
15
+					:class="['entry-steps__item', { 'entry-steps__item--on': stepIndex === i }]"
16
+				>{{ s }}</text>
17
+			</view>
18
+			<scroll-view class="entry-scroll" scroll-y>
19
+				<!-- 主体类型 -->
20
+				<view v-if="currentStep === 'type'" class="form-card">
21
+					<view
22
+						:class="['type-card', { 'type-card--on': form.merchantType === '1' }]"
23
+						@click="selectType('1')"
24
+					>
25
+						<text class="type-card__title">个人入驻</text>
26
+						<text class="type-card__sub">身份证 + 个人账户</text>
27
+					</view>
28
+					<view
29
+						:class="['type-card', { 'type-card--on': form.merchantType === '2' }]"
30
+						@click="selectType('2')"
31
+					>
32
+						<text class="type-card__title">企业入驻</text>
33
+						<text class="type-card__sub">法人 + 企业对公账户</text>
34
+					</view>
35
+				</view>
36
+
37
+				<!-- 个人:主体信息 -->
38
+				<view v-else-if="isPerson && currentStep === 'subject'" class="form-card">
39
+					<entry-person-subject :subject="form.subject" />
40
+				</view>
41
+				<view v-else-if="isPerson && currentStep === 'biz'" class="form-card">
42
+					<entry-person-biz :biz="form.biz" :region="regionBiz" @update:region="onBizRegion" />
43
+				</view>
44
+
45
+				<!-- 企业 -->
46
+				<view v-else-if="!isPerson && currentStep === 'subject'" class="form-card">
47
+					<entry-enterprise-subject
48
+						:subject="form.subject"
49
+						:region-reg="regionReg"
50
+						@update:region-reg="onRegRegion"
51
+					/>
52
+				</view>
53
+				<view v-else-if="!isPerson && currentStep === 'biz'" class="form-card">
54
+					<entry-enterprise-biz :biz="form.biz" :region="regionBiz" @update:region="onBizRegion" />
55
+				</view>
56
+
57
+				<!-- 店铺 -->
58
+				<view v-else-if="currentStep === 'shop'" class="form-card">
59
+					<entry-shop-fields :shop="form.shop" />
60
+				</view>
61
+
62
+				<!-- 提交 -->
63
+				<view v-else-if="currentStep === 'submit'" class="form-card">
64
+					<text class="submit-tip">请确认信息无误后勾选协议并提交</text>
65
+					<agreement-block
66
+						v-model="form.agreementAccepted"
67
+						:enabled="agreement.enabled"
68
+						:checkbox-label="agreement.checkboxLabel"
69
+						:agreement-title="agreement.agreementTitle"
70
+						:version-label="agreement.versionLabel"
71
+						:content="agreement.content"
72
+					/>
73
+				</view>
74
+			</scroll-view>
75
+			<view class="entry-actions">
76
+				<button v-if="stepIndex > 0" class="mine-btn-outline entry-actions__prev" @click="prevStep">
77
+					上一步
78
+				</button>
79
+				<button class="form-footer__btn entry-actions__next" :disabled="submitting" @click="nextStep">
80
+					{{ nextBtnText }}
81
+				</button>
82
+			</view>
83
+		</template>
84
+	</view>
85
+</template>
86
+
87
+<script setup>
88
+import { ref, reactive, computed } from 'vue'
89
+import { onLoad } from '@dcloudio/uni-app'
90
+import { getEntryAgreement, getEntryStatus, getMyEntryApplies, submitEntryApply } from '@/api/merchantEntry'
91
+import { ensureApiToken } from '@/utils/apiAuth'
92
+import { hasBlockingApply } from '@/utils/entryConstants'
93
+import { createEntryForm, validateEntryStep, buildEntrySubmitPayload, applyRegion, applyRegRegion } from '@/utils/entryForm'
94
+import { MERCHANT_TYPE_PERSON } from '@/utils/entryConstants'
95
+import AgreementBlock from '@/components/account/AgreementBlock.vue'
96
+import EntryPersonSubject from '@/components/mine/entry/EntryPersonSubject.vue'
97
+import EntryPersonBiz from '@/components/mine/entry/EntryPersonBiz.vue'
98
+import EntryEnterpriseSubject from '@/components/mine/entry/EntryEnterpriseSubject.vue'
99
+import EntryEnterpriseBiz from '@/components/mine/entry/EntryEnterpriseBiz.vue'
100
+import EntryShopFields from '@/components/mine/entry/EntryShopFields.vue'
101
+import { PAGE_ENTRY_LIST } from '@/utils/pageRoute'
102
+
103
+const form = reactive(createEntryForm(MERCHANT_TYPE_PERSON))
104
+const agreement = reactive({
105
+  enabled: false,
106
+  message: '',
107
+  agreementTitle: '',
108
+  versionLabel: '',
109
+  content: '',
110
+  checkboxLabel: '我已阅读并同意《商城入驻协议》'
111
+})
112
+
113
+const stepIndex = ref(0)
114
+const submitting = ref(false)
115
+const entryOpen = ref(true)
116
+const blocked = ref(false)
117
+const regionBiz = ref({ code: '', name: '', province: '', city: '', district: '' })
118
+const regionReg = ref({ code: '', name: '', province: '', city: '', district: '' })
119
+
120
+const stepKeys = computed(() => ['type', 'subject', 'biz', 'shop', 'submit'])
121
+const stepTitles = ['类型', '主体', '经营', '店铺', '提交']
122
+const currentStep = computed(() => stepKeys.value[stepIndex.value])
123
+const isPerson = computed(() => form.merchantType === MERCHANT_TYPE_PERSON)
124
+const nextBtnText = computed(() => {
125
+  if (submitting.value) return '提交中…'
126
+  return currentStep.value === 'submit' ? '提交申请' : '下一步'
127
+})
128
+
129
+onLoad(() => {
130
+  if (!ensureApiToken()) return
131
+  initPage()
132
+})
133
+
134
+function initPage() {
135
+  Promise.all([getEntryAgreement(), getEntryStatus(), getMyEntryApplies()])
136
+    .then(([agRes, stRes, myRes]) => {
137
+      const ag = agRes.data || {}
138
+      agreement.enabled = !!ag.enabled
139
+      agreement.message = ag.message || ''
140
+      agreement.agreementTitle = ag.agreementTitle || '商城入驻协议'
141
+      agreement.versionLabel = ag.versionLabel || ''
142
+      agreement.content = ag.content || ''
143
+      agreement.checkboxLabel = ag.checkboxLabel || agreement.checkboxLabel
144
+      entryOpen.value = stRes.data?.entryOpen !== false && agreement.enabled
145
+      blocked.value = hasBlockingApply(myRes.data || [])
146
+    })
147
+    .catch(() => {})
148
+}
149
+
150
+function selectType(type) {
151
+  form.merchantType = type
152
+  const next = createEntryForm(type)
153
+  form.subject = next.subject
154
+  form.biz = next.biz
155
+  form.shop = next.shop
156
+  form.agreementAccepted = false
157
+  regionBiz.value = { code: '', name: '', province: '', city: '', district: '' }
158
+  regionReg.value = { code: '', name: '', province: '', city: '', district: '' }
159
+}
160
+
161
+function onBizRegion(v) {
162
+  regionBiz.value = v
163
+  applyRegion(form.biz, v)
164
+}
165
+
166
+function onRegRegion(v) {
167
+  regionReg.value = v
168
+  applyRegRegion(form.subject, v)
169
+}
170
+
171
+function prevStep() {
172
+  if (stepIndex.value > 0) stepIndex.value -= 1
173
+}
174
+
175
+function nextStep() {
176
+  const key = currentStep.value
177
+  const err = validateEntryStep(form, key)
178
+  if (err) {
179
+    uni.showToast({ title: err, icon: 'none' })
180
+    return
181
+  }
182
+  if (key === 'submit') {
183
+    doSubmit()
184
+    return
185
+  }
186
+  if (stepIndex.value < stepKeys.value.length - 1) {
187
+    stepIndex.value += 1
188
+  }
189
+}
190
+
191
+function doSubmit() {
192
+  uni.showModal({
193
+    title: '确认提交',
194
+    content: '提交后不可修改,是否确认?',
195
+    success: (res) => {
196
+      if (!res.confirm) return
197
+      submitting.value = true
198
+      submitEntryApply(buildEntrySubmitPayload(form))
199
+        .then(() => {
200
+          uni.showToast({ title: '提交成功,请等待审核', icon: 'none', duration: 2500 })
201
+          setTimeout(() => {
202
+            uni.redirectTo({ url: PAGE_ENTRY_LIST })
203
+          }, 1500)
204
+        })
205
+        .catch(() => {})
206
+        .finally(() => {
207
+          submitting.value = false
208
+        })
209
+    }
210
+  })
211
+}
212
+
213
+function goList() {
214
+  uni.navigateTo({ url: PAGE_ENTRY_LIST })
215
+}
216
+</script>
217
+
218
+<style lang="scss" scoped>
219
+@import '@/styles/mine.scss';
220
+
221
+.entry-apply-page {
222
+  min-height: 100vh;
223
+  display: flex;
224
+  flex-direction: column;
225
+  background: #f0ebe5;
226
+}
227
+.entry-closed {
228
+  padding: 80rpx 40rpx;
229
+  text-align: center;
230
+  font-size: 28rpx;
231
+  color: #666;
232
+}
233
+.entry-closed__btn {
234
+  margin-top: 32rpx;
235
+  width: 100%;
236
+}
237
+.entry-steps {
238
+  display: flex;
239
+  padding: 20rpx 16rpx;
240
+  background: #fff;
241
+}
242
+.entry-steps__item {
243
+  flex: 1;
244
+  text-align: center;
245
+  font-size: 24rpx;
246
+  color: #999;
247
+}
248
+.entry-steps__item--on {
249
+  color: #2e7d32;
250
+  font-weight: 600;
251
+}
252
+.entry-scroll {
253
+  flex: 1;
254
+  height: 0;
255
+  padding: 24rpx;
256
+  box-sizing: border-box;
257
+}
258
+.entry-actions {
259
+  display: flex;
260
+  gap: 20rpx;
261
+  padding: 20rpx 24rpx;
262
+  padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
263
+  background: #fff;
264
+}
265
+.entry-actions__prev {
266
+  flex: 1;
267
+  height: 88rpx;
268
+  line-height: 88rpx;
269
+}
270
+.entry-actions__next {
271
+  flex: 2;
272
+  height: 88rpx;
273
+  line-height: 88rpx;
274
+}
275
+.type-card {
276
+  margin-bottom: 20rpx;
277
+  padding: 32rpx;
278
+  border: 2rpx solid #e5ded6;
279
+  border-radius: 16rpx;
280
+}
281
+.type-card--on {
282
+  border-color: #2e7d32;
283
+  background: #f1f8f4;
284
+}
285
+.type-card__title {
286
+  display: block;
287
+  font-size: 32rpx;
288
+  font-weight: 600;
289
+  color: #333;
290
+}
291
+.type-card__sub {
292
+  display: block;
293
+  margin-top: 8rpx;
294
+  font-size: 24rpx;
295
+  color: #999;
296
+}
297
+.submit-tip {
298
+  display: block;
299
+  padding: 16rpx 0 24rpx;
300
+  font-size: 26rpx;
301
+  color: #666;
302
+}
303
+</style>

+ 161 - 0
shop-app/subpackage/account/entry-detail.vue

@@ -0,0 +1,161 @@
1
+<template>
2
+	<view class="form-page" v-if="detail">
3
+		<view class="form-card detail-card">
4
+			<view class="detail-row">
5
+				<text class="detail-row__label">申请单号</text>
6
+				<text class="detail-row__val">{{ detail.applyNo }}</text>
7
+			</view>
8
+			<view class="detail-row">
9
+				<text class="detail-row__label">状态</text>
10
+				<text class="detail-row__val" :style="{ color: statusInfo.color }">{{ statusInfo.label }}</text>
11
+			</view>
12
+			<view class="detail-row">
13
+				<text class="detail-row__label">申请时间</text>
14
+				<text class="detail-row__val">{{ detail.applyTime || '—' }}</text>
15
+			</view>
16
+			<view v-if="detail.auditTime" class="detail-row">
17
+				<text class="detail-row__label">审核时间</text>
18
+				<text class="detail-row__val">{{ detail.auditTime }}</text>
19
+			</view>
20
+			<view
21
+				v-if="detail.applyStatus === APPLY_STATUS_PUBLICITY"
22
+				class="detail-publicity"
23
+			>
24
+				<text class="detail-publicity__title">公示期</text>
25
+				<text>{{ detail.publicityStartTime || '—' }} 至 {{ detail.publicityEndTime || '—' }}</text>
26
+				<text class="detail-publicity__tip">公示结束后将完成开店建档</text>
27
+			</view>
28
+			<view
29
+				v-if="detail.applyStatus === APPLY_STATUS_REJECT && detail.rejectReason"
30
+				class="detail-reject"
31
+			>
32
+				<text class="detail-reject__title">驳回原因</text>
33
+				<text>{{ detail.rejectReason }}</text>
34
+			</view>
35
+			<view v-if="detail.applyStatus === APPLY_STATUS_DONE" class="detail-done">
36
+				<text>入驻已完成,请使用经营账号登录商家后台进行发品与店铺经营。</text>
37
+				<text v-if="detail.merchantId" class="detail-done__sub">商户 ID:{{ detail.merchantId }}</text>
38
+			</view>
39
+		</view>
40
+		<view v-if="detail.applyStatus === APPLY_STATUS_REJECT && canReapply" class="form-footer">
41
+			<button class="form-footer__btn" @click="goReapply">重新申请</button>
42
+		</view>
43
+	</view>
44
+</template>
45
+
46
+<script setup>
47
+import { ref, computed } from 'vue'
48
+import { onLoad } from '@dcloudio/uni-app'
49
+import { getMyEntryApplies, getEntryStatus } from '@/api/merchantEntry'
50
+import { ensureApiToken } from '@/utils/apiAuth'
51
+import {
52
+  formatApplyStatus,
53
+  APPLY_STATUS_REJECT,
54
+  APPLY_STATUS_PUBLICITY,
55
+  APPLY_STATUS_DONE,
56
+  canSubmitNewApply
57
+} from '@/utils/entryConstants'
58
+import { PAGE_ENTRY_APPLY } from '@/utils/pageRoute'
59
+
60
+const applyId = ref(null)
61
+const detail = ref(null)
62
+const entryOpen = ref(true)
63
+const listCache = ref([])
64
+
65
+const statusInfo = computed(() => formatApplyStatus(detail.value?.applyStatus))
66
+
67
+const canReapply = computed(() => canSubmitNewApply(listCache.value, entryOpen.value))
68
+
69
+onLoad((options) => {
70
+  if (!ensureApiToken()) return
71
+  applyId.value = Number(options.applyId)
72
+  loadDetail()
73
+})
74
+
75
+function loadDetail() {
76
+  Promise.all([getMyEntryApplies(), getEntryStatus()])
77
+    .then(([myRes, stRes]) => {
78
+      listCache.value = myRes.data || []
79
+      entryOpen.value = stRes.data?.entryOpen !== false
80
+      detail.value = listCache.value.find((x) => x.applyId === applyId.value) || null
81
+      if (!detail.value) {
82
+        uni.showToast({ title: '申请不存在', icon: 'none' })
83
+      }
84
+    })
85
+    .catch(() => {})
86
+}
87
+
88
+function goReapply() {
89
+  uni.navigateTo({ url: PAGE_ENTRY_APPLY })
90
+}
91
+</script>
92
+
93
+<style lang="scss" scoped>
94
+@import '@/styles/mine.scss';
95
+
96
+.detail-card {
97
+  padding: 16rpx 24rpx;
98
+}
99
+.detail-row {
100
+  display: flex;
101
+  padding: 20rpx 0;
102
+  border-bottom: 1rpx solid #f5f5f5;
103
+}
104
+.detail-row__label {
105
+  width: 180rpx;
106
+  font-size: 28rpx;
107
+  color: #9a938c;
108
+}
109
+.detail-row__val {
110
+  flex: 1;
111
+  font-size: 28rpx;
112
+  color: #4a4542;
113
+}
114
+.detail-reject {
115
+  margin-top: 24rpx;
116
+  padding: 20rpx;
117
+  background: #ffebee;
118
+  border-radius: 12rpx;
119
+  color: #c62828;
120
+  font-size: 26rpx;
121
+  line-height: 1.5;
122
+}
123
+.detail-reject__title {
124
+  display: block;
125
+  font-weight: 600;
126
+  margin-bottom: 8rpx;
127
+}
128
+.detail-publicity {
129
+  margin-top: 24rpx;
130
+  padding: 20rpx;
131
+  background: #e3f2fd;
132
+  border-radius: 12rpx;
133
+  font-size: 26rpx;
134
+  color: #1565c0;
135
+  line-height: 1.5;
136
+}
137
+.detail-publicity__title {
138
+  display: block;
139
+  font-weight: 600;
140
+  margin-bottom: 8rpx;
141
+}
142
+.detail-publicity__tip {
143
+  display: block;
144
+  margin-top: 8rpx;
145
+  font-size: 24rpx;
146
+}
147
+.detail-done {
148
+  margin-top: 24rpx;
149
+  padding: 20rpx;
150
+  background: #e8f5e9;
151
+  border-radius: 12rpx;
152
+  font-size: 26rpx;
153
+  color: #2e7d32;
154
+  line-height: 1.5;
155
+}
156
+.detail-done__sub {
157
+  display: block;
158
+  margin-top: 8rpx;
159
+  font-size: 24rpx;
160
+}
161
+</style>

+ 150 - 0
shop-app/subpackage/account/entry-list.vue

@@ -0,0 +1,150 @@
1
+<template>
2
+	<view class="entry-list-page">
3
+		<view v-if="loading" class="entry-list-page__loading">加载中…</view>
4
+		<view v-else-if="list.length === 0" class="entry-empty">
5
+			<text>暂无入驻申请记录</text>
6
+			<button class="mine-btn-primary entry-empty__btn" @click="goApply">我要入驻</button>
7
+		</view>
8
+		<view v-else>
9
+			<view
10
+				v-for="item in list"
11
+				:key="item.applyId"
12
+				class="entry-card"
13
+				@click="goDetail(item)"
14
+			>
15
+				<view class="entry-card__row">
16
+					<text class="entry-card__no">{{ item.applyNo }}</text>
17
+					<text class="entry-card__status" :style="{ color: statusStyle(item).color }">
18
+						{{ statusStyle(item).label }}
19
+					</text>
20
+				</view>
21
+				<text class="entry-card__time">申请时间:{{ item.applyTime || '—' }}</text>
22
+				<text
23
+					v-if="item.applyStatus === '2' && item.rejectReason"
24
+					class="entry-card__reject"
25
+				>{{ item.rejectReason }}</text>
26
+			</view>
27
+		</view>
28
+		<view v-if="canApply" class="entry-list-footer">
29
+			<button class="form-footer__btn" @click="goApply">发起新申请</button>
30
+		</view>
31
+	</view>
32
+</template>
33
+
34
+<script setup>
35
+import { ref } from 'vue'
36
+import { onShow } from '@dcloudio/uni-app'
37
+import { getMyEntryApplies } from '@/api/merchantEntry'
38
+import { getEntryStatus } from '@/api/merchantEntry'
39
+import { ensureApiToken } from '@/utils/apiAuth'
40
+import { formatApplyStatus, canSubmitNewApply } from '@/utils/entryConstants'
41
+import { PAGE_ENTRY_APPLY, PAGE_ENTRY_DETAIL } from '@/utils/pageRoute'
42
+
43
+const list = ref([])
44
+const loading = ref(true)
45
+const entryOpen = ref(true)
46
+const canApply = ref(false)
47
+
48
+onShow(() => {
49
+  if (!ensureApiToken(false)) return
50
+  loadData()
51
+})
52
+
53
+function statusStyle(item) {
54
+  return formatApplyStatus(item.applyStatus)
55
+}
56
+
57
+function loadData() {
58
+  loading.value = true
59
+  Promise.all([getMyEntryApplies(), getEntryStatus()])
60
+    .then(([myRes, stRes]) => {
61
+      list.value = myRes.data || []
62
+      entryOpen.value = stRes.data?.entryOpen !== false
63
+      canApply.value = canSubmitNewApply(list.value, entryOpen.value)
64
+    })
65
+    .catch(() => {
66
+      list.value = []
67
+    })
68
+    .finally(() => {
69
+      loading.value = false
70
+    })
71
+}
72
+
73
+function goDetail(item) {
74
+  uni.navigateTo({
75
+    url: `${PAGE_ENTRY_DETAIL}?applyId=${item.applyId}`
76
+  })
77
+}
78
+
79
+function goApply() {
80
+  uni.navigateTo({ url: PAGE_ENTRY_APPLY })
81
+}
82
+</script>
83
+
84
+<style lang="scss" scoped>
85
+@import '@/styles/mine.scss';
86
+
87
+.entry-list-page {
88
+  min-height: 100vh;
89
+  padding: 24rpx;
90
+  padding-bottom: 160rpx;
91
+  background: #f0ebe5;
92
+}
93
+.entry-empty {
94
+  padding: 100rpx 40rpx;
95
+  text-align: center;
96
+  background: #fff;
97
+  border-radius: 16rpx;
98
+  color: #999;
99
+  font-size: 28rpx;
100
+}
101
+.entry-empty__btn {
102
+  margin-top: 40rpx;
103
+  width: 100%;
104
+}
105
+.entry-card {
106
+  margin-bottom: 20rpx;
107
+  padding: 28rpx;
108
+  background: #fff;
109
+  border-radius: 16rpx;
110
+}
111
+.entry-card__row {
112
+  display: flex;
113
+  justify-content: space-between;
114
+  align-items: center;
115
+}
116
+.entry-card__no {
117
+  font-size: 28rpx;
118
+  font-weight: 600;
119
+  color: #333;
120
+}
121
+.entry-card__status {
122
+  font-size: 26rpx;
123
+}
124
+.entry-card__time {
125
+  display: block;
126
+  margin-top: 12rpx;
127
+  font-size: 24rpx;
128
+  color: #999;
129
+}
130
+.entry-card__reject {
131
+  display: block;
132
+  margin-top: 12rpx;
133
+  font-size: 24rpx;
134
+  color: #d32f2f;
135
+}
136
+.entry-list-footer {
137
+  position: fixed;
138
+  left: 0;
139
+  right: 0;
140
+  bottom: 0;
141
+  padding: 24rpx 32rpx;
142
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
143
+  background: #fff;
144
+}
145
+.entry-list-page__loading {
146
+  padding: 80rpx;
147
+  text-align: center;
148
+  color: #999;
149
+}
150
+</style>

+ 101 - 0
shop-app/subpackage/account/password.vue

@@ -0,0 +1,101 @@
1
+<template>
2
+	<view class="form-page">
3
+		<view class="form-card">
4
+			<view class="form-row">
5
+				<text class="form-row__label form-row__label--req">旧密码</text>
6
+				<input
7
+					v-model="form.oldPassword"
8
+					class="form-row__input"
9
+					type="password"
10
+					placeholder="请输入当前密码"
11
+				/>
12
+			</view>
13
+			<view class="form-row">
14
+				<text class="form-row__label form-row__label--req">新密码</text>
15
+				<input
16
+					v-model="form.newPassword"
17
+					class="form-row__input"
18
+					type="password"
19
+					placeholder="至少 6 位"
20
+				/>
21
+			</view>
22
+			<view class="form-row">
23
+				<text class="form-row__label form-row__label--req">确认密码</text>
24
+				<input
25
+					v-model="form.confirmPassword"
26
+					class="form-row__input"
27
+					type="password"
28
+					placeholder="再次输入新密码"
29
+				/>
30
+			</view>
31
+		</view>
32
+		<view class="form-footer">
33
+			<button class="form-footer__btn" :disabled="saving" @click="handleSave">
34
+				{{ saving ? '提交中…' : '确认修改' }}
35
+			</button>
36
+		</view>
37
+	</view>
38
+</template>
39
+
40
+<script setup>
41
+import { ref, reactive } from 'vue'
42
+import { onLoad } from '@dcloudio/uni-app'
43
+import { changeMemberPassword } from '@/api/member'
44
+import { ensureApiToken } from '@/utils/apiAuth'
45
+import { validatePassword } from '@/utils/memberValidate'
46
+
47
+const form = reactive({
48
+  oldPassword: '',
49
+  newPassword: '',
50
+  confirmPassword: ''
51
+})
52
+
53
+const saving = ref(false)
54
+
55
+onLoad(() => {
56
+  ensureApiToken()
57
+})
58
+
59
+function validate() {
60
+  if (!form.oldPassword) {
61
+    uni.showToast({ title: '请输入旧密码', icon: 'none' })
62
+    return false
63
+  }
64
+  const p1 = validatePassword(form.newPassword, '新密码')
65
+  if (p1) {
66
+    uni.showToast({ title: p1, icon: 'none' })
67
+    return false
68
+  }
69
+  if (form.newPassword !== form.confirmPassword) {
70
+    uni.showToast({ title: '两次输入的密码不一致', icon: 'none' })
71
+    return false
72
+  }
73
+  if (form.oldPassword === form.newPassword) {
74
+    uni.showToast({ title: '新密码不能与旧密码相同', icon: 'none' })
75
+    return false
76
+  }
77
+  return true
78
+}
79
+
80
+function handleSave() {
81
+  if (saving.value || !validate()) return
82
+  saving.value = true
83
+  changeMemberPassword({
84
+    oldPassword: form.oldPassword,
85
+    newPassword: form.newPassword,
86
+    confirmPassword: form.confirmPassword
87
+  })
88
+    .then(() => {
89
+      uni.showToast({ title: '修改成功', icon: 'none' })
90
+      setTimeout(() => uni.navigateBack(), 800)
91
+    })
92
+    .catch(() => {})
93
+    .finally(() => {
94
+      saving.value = false
95
+    })
96
+}
97
+</script>
98
+
99
+<style lang="scss" scoped>
100
+@import '@/styles/mine.scss';
101
+</style>

+ 172 - 0
shop-app/subpackage/account/profile.vue

@@ -0,0 +1,172 @@
1
+<template>
2
+	<view class="form-page">
3
+		<view class="form-card">
4
+			<view class="form-row">
5
+				<text class="form-row__label">用户 ID</text>
6
+				<text class="form-row__readonly">{{ profile.memberId || '—' }}</text>
7
+			</view>
8
+			<view class="form-row">
9
+				<text class="form-row__label">会员名称</text>
10
+				<text class="form-row__readonly">{{ profile.memberCode || '—' }}</text>
11
+			</view>
12
+			<view class="form-row">
13
+				<text class="form-row__label">手机号</text>
14
+				<text class="form-row__readonly">{{ profile.mobile || '—' }}</text>
15
+			</view>
16
+			<view class="form-row form-row--col">
17
+				<text class="form-row__label form-row__label--req">头像</text>
18
+				<image-upload v-model="form.avatar" placeholder="上传头像" />
19
+			</view>
20
+			<view class="form-row">
21
+				<text class="form-row__label form-row__label--req">昵称</text>
22
+				<input v-model="form.nickName" class="form-row__input" placeholder="请输入昵称" />
23
+			</view>
24
+			<view class="form-row">
25
+				<text class="form-row__label">邮箱</text>
26
+				<input v-model="form.email" class="form-row__input" placeholder="选填" />
27
+			</view>
28
+			<view class="form-row">
29
+				<text class="form-row__label">性别</text>
30
+				<picker :range="genderLabels" :value="genderIndex" @change="onGenderChange">
31
+					<view class="form-row__picker">
32
+						<text :class="{ 'form-row__placeholder': genderIndex < 0 }">
33
+							{{ genderIndex >= 0 ? genderLabels[genderIndex] : '请选择' }}
34
+						</text>
35
+					</view>
36
+				</picker>
37
+			</view>
38
+			<view class="form-row">
39
+				<text class="form-row__label">出生日期</text>
40
+				<picker mode="date" :value="form.birthday" :end="today" @change="onBirthdayChange">
41
+					<view class="form-row__picker">
42
+						<text :class="{ 'form-row__placeholder': !form.birthday }">
43
+							{{ form.birthday || '请选择' }}
44
+						</text>
45
+					</view>
46
+				</picker>
47
+			</view>
48
+		</view>
49
+		<view class="form-footer">
50
+			<button class="form-footer__btn" :disabled="saving" @click="handleSave">
51
+				{{ saving ? '保存中…' : '保 存' }}
52
+			</button>
53
+		</view>
54
+	</view>
55
+</template>
56
+
57
+<script setup>
58
+import { ref, reactive, computed } from 'vue'
59
+import { onLoad } from '@dcloudio/uni-app'
60
+import { getMemberProfile, updateMemberProfile } from '@/api/member'
61
+import { useUserStore } from '@/store/user'
62
+import { ensureApiToken } from '@/utils/apiAuth'
63
+import { GENDER_OPTIONS } from '@/utils/entryConstants'
64
+import ImageUpload from '@/components/mine/ImageUpload.vue'
65
+
66
+const profile = reactive({
67
+  memberId: '',
68
+  memberCode: '',
69
+  mobile: ''
70
+})
71
+
72
+const form = reactive({
73
+  nickName: '',
74
+  avatar: '',
75
+  email: '',
76
+  sex: '',
77
+  birthday: ''
78
+})
79
+
80
+const saving = ref(false)
81
+const genderLabels = GENDER_OPTIONS.map((g) => g.label)
82
+const genderIndex = ref(-1)
83
+const today = computed(() => {
84
+  const d = new Date()
85
+  const m = `${d.getMonth() + 1}`.padStart(2, '0')
86
+  const day = `${d.getDate()}`.padStart(2, '0')
87
+  return `${d.getFullYear()}-${m}-${day}`
88
+})
89
+
90
+const userStore = useUserStore()
91
+
92
+onLoad(() => {
93
+  if (!ensureApiToken()) return
94
+  loadProfile()
95
+})
96
+
97
+function syncGenderIndex() {
98
+  const idx = GENDER_OPTIONS.findIndex((g) => g.value === form.sex)
99
+  genderIndex.value = idx >= 0 ? idx : -1
100
+}
101
+
102
+function loadProfile() {
103
+  getMemberProfile()
104
+    .then((res) => {
105
+      const m = res.data || {}
106
+      profile.memberId = m.memberId
107
+      profile.memberCode = m.memberCode
108
+      profile.mobile = m.mobile
109
+      form.nickName = m.nickName || ''
110
+      form.avatar = m.avatar || ''
111
+      form.email = m.email || ''
112
+      form.sex = m.sex != null ? String(m.sex) : ''
113
+      form.birthday = m.birthday ? String(m.birthday).slice(0, 10) : ''
114
+      syncGenderIndex()
115
+    })
116
+    .catch(() => {})
117
+}
118
+
119
+function onGenderChange(e) {
120
+  const idx = Number(e.detail.value)
121
+  genderIndex.value = idx
122
+  form.sex = GENDER_OPTIONS[idx]?.value || ''
123
+}
124
+
125
+function onBirthdayChange(e) {
126
+  form.birthday = e.detail.value
127
+}
128
+
129
+function validate() {
130
+  if (!(form.nickName || '').trim()) {
131
+    uni.showToast({ title: '请输入昵称', icon: 'none' })
132
+    return false
133
+  }
134
+  const email = (form.email || '').trim()
135
+  if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
136
+    uni.showToast({ title: '邮箱格式不正确', icon: 'none' })
137
+    return false
138
+  }
139
+  if (form.birthday && form.birthday > today.value) {
140
+    uni.showToast({ title: '出生日期不能为未来', icon: 'none' })
141
+    return false
142
+  }
143
+  return true
144
+}
145
+
146
+function handleSave() {
147
+  if (saving.value || !validate()) return
148
+  saving.value = true
149
+  updateMemberProfile({
150
+    nickName: form.nickName.trim(),
151
+    avatar: form.avatar,
152
+    email: (form.email || '').trim(),
153
+    sex: form.sex,
154
+    birthday: form.birthday || null
155
+  })
156
+    .then(() => {
157
+      uni.showToast({ title: '保存成功', icon: 'none' })
158
+      return userStore.fetchUserInfo()
159
+    })
160
+    .then(() => {
161
+      setTimeout(() => uni.navigateBack(), 800)
162
+    })
163
+    .catch(() => {})
164
+    .finally(() => {
165
+      saving.value = false
166
+    })
167
+}
168
+</script>
169
+
170
+<style lang="scss" scoped>
171
+@import '@/styles/mine.scss';
172
+</style>

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

@@ -0,0 +1,58 @@
1
+/** 商户类型 */
2
+export const MERCHANT_TYPE_PERSON = '1'
3
+export const MERCHANT_TYPE_ENTERPRISE = '2'
4
+
5
+/** 证件类型 */
6
+export const ID_TYPE_MAINLAND = '1'
7
+export const ID_TYPE_HK_PASS = '2'
8
+
9
+/** 有效期类型 */
10
+export const VALID_RANGE = '1'
11
+export const VALID_LONG = '2'
12
+
13
+/** 申请状态 */
14
+export const APPLY_STATUS_PENDING = '0'
15
+export const APPLY_STATUS_DONE = '1'
16
+export const APPLY_STATUS_REJECT = '2'
17
+export const APPLY_STATUS_PUBLICITY = '3'
18
+
19
+export const APPLY_STATUS_MAP = {
20
+  [APPLY_STATUS_PENDING]: { label: '待审核', color: '#ed6a0c' },
21
+  [APPLY_STATUS_DONE]: { label: '已完成入驻', color: '#2e7d32' },
22
+  [APPLY_STATUS_REJECT]: { label: '审核未通过', color: '#d32f2f' },
23
+  [APPLY_STATUS_PUBLICITY]: { label: '公示中', color: '#1976d2' }
24
+}
25
+
26
+export const GENDER_OPTIONS = [
27
+  { label: '男', value: '0' },
28
+  { label: '女', value: '1' },
29
+  { label: '保密', value: '2' }
30
+]
31
+
32
+export const ID_TYPE_OPTIONS = [
33
+  { label: '大陆身份证', value: ID_TYPE_MAINLAND },
34
+  { label: '来往内地通行证', value: ID_TYPE_HK_PASS }
35
+]
36
+
37
+export const VALID_TYPE_OPTIONS = [
38
+  { label: '区间有效', value: VALID_RANGE },
39
+  { label: '长期有效', value: VALID_LONG }
40
+]
41
+
42
+export function formatApplyStatus(status) {
43
+  return APPLY_STATUS_MAP[status] || { label: '未知', color: '#999' }
44
+}
45
+
46
+/** 是否存在阻塞中的申请(待审 / 公示中) */
47
+export function hasBlockingApply(list) {
48
+  if (!Array.isArray(list)) return false
49
+  return list.some((item) =>
50
+    item.applyStatus === APPLY_STATUS_PENDING || item.applyStatus === APPLY_STATUS_PUBLICITY
51
+  )
52
+}
53
+
54
+/** 是否可重新申请(仅驳回或未通过场景由入口引导) */
55
+export function canSubmitNewApply(list, entryOpen) {
56
+  if (!entryOpen) return false
57
+  return !hasBlockingApply(list)
58
+}

+ 237 - 0
shop-app/utils/entryForm.js

@@ -0,0 +1,237 @@
1
+import {
2
+  MERCHANT_TYPE_PERSON,
3
+  MERCHANT_TYPE_ENTERPRISE,
4
+  VALID_RANGE
5
+} from '@/utils/entryConstants'
6
+import { isMobile } from '@/utils/memberValidate'
7
+
8
+const EMAIL_REG = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
9
+
10
+function emptyPersonSubject() {
11
+  return {
12
+    merchantType: MERCHANT_TYPE_PERSON,
13
+    personName: '',
14
+    idCardType: '1',
15
+    idCardNo: '',
16
+    birthDate: '',
17
+    idValidType: '1',
18
+    idValidStart: '',
19
+    idValidEnd: '',
20
+    residenceAddress: '',
21
+    gender: '',
22
+    idCardFront: '',
23
+    idCardBack: '',
24
+    contactName: '',
25
+    contactPhone: '',
26
+    contactEmail: '',
27
+    bankName: '',
28
+    bankBranch: '',
29
+    bankAccount: ''
30
+  }
31
+}
32
+
33
+function emptyPersonBiz() {
34
+  return {
35
+    merchantType: MERCHANT_TYPE_PERSON,
36
+    merchantName: '',
37
+    servicePhone: '',
38
+    bizRegionCode: '',
39
+    bizRegionName: '',
40
+    bizDetailAddress: ''
41
+  }
42
+}
43
+
44
+function emptyEnterpriseSubject() {
45
+  return {
46
+    merchantType: MERCHANT_TYPE_ENTERPRISE,
47
+    companyName: '',
48
+    regRegionCode: '',
49
+    regRegionName: '',
50
+    companyDetailAddress: '',
51
+    businessScope: '',
52
+    creditCode: '',
53
+    licenseValidType: '1',
54
+    licenseValidStart: '',
55
+    licenseValidEnd: '',
56
+    legalName: '',
57
+    legalResidence: '',
58
+    legalGender: '',
59
+    legalBirthDate: '',
60
+    legalIdCardType: '1',
61
+    legalIdCardNo: '',
62
+    legalIdValidType: '1',
63
+    legalIdValidStart: '',
64
+    legalIdValidEnd: '',
65
+    legalIdCardFront: '',
66
+    legalIdCardBack: '',
67
+    contactName: '',
68
+    contactPhone: '',
69
+    contactEmail: '',
70
+    bankName: '',
71
+    bankBranch: '',
72
+    bankAccount: '',
73
+    corpBankAccount: '',
74
+    accountPermit: ''
75
+  }
76
+}
77
+
78
+function emptyEnterpriseBiz() {
79
+  return {
80
+    merchantType: MERCHANT_TYPE_ENTERPRISE,
81
+    bizRegionCode: '',
82
+    bizRegionName: '',
83
+    bizDetailAddress: '',
84
+    businessLicense: ''
85
+  }
86
+}
87
+
88
+function emptyShop() {
89
+  return {
90
+    shopName: '',
91
+    shopAvatar: '',
92
+    shopPhone: '',
93
+    shopDesc: ''
94
+  }
95
+}
96
+
97
+/** 创建空白入驻表单 */
98
+export function createEntryForm(merchantType = MERCHANT_TYPE_PERSON) {
99
+  const isPerson = merchantType === MERCHANT_TYPE_PERSON
100
+  return {
101
+    merchantType,
102
+    subject: isPerson ? emptyPersonSubject() : emptyEnterpriseSubject(),
103
+    biz: isPerson ? emptyPersonBiz() : emptyEnterpriseBiz(),
104
+    shop: emptyShop(),
105
+    agreementAccepted: false
106
+  }
107
+}
108
+
109
+/** 省市区写入 biz/reg 字段 */
110
+export function applyRegion(target, region) {
111
+  if (!target || !region) return
112
+  target.bizRegionCode = region.code || ''
113
+  target.bizRegionName = region.name || ''
114
+}
115
+
116
+export function applyRegRegion(target, region) {
117
+  if (!target || !region) return
118
+  target.regRegionCode = region.code || ''
119
+  target.regRegionName = region.name || ''
120
+}
121
+
122
+function requireText(value, msg) {
123
+  if (!(value || '').trim()) return msg
124
+  return ''
125
+}
126
+
127
+function requireEmail(value) {
128
+  const t = (value || '').trim()
129
+  if (!t) return '请输入常用邮箱'
130
+  if (!EMAIL_REG.test(t)) return '邮箱格式不正确'
131
+  return ''
132
+}
133
+
134
+function checkValidPeriod(label, type, start, end) {
135
+  if (!type) return `请选择${label}有效期类型`
136
+  if (type === VALID_RANGE && (!start || !end)) {
137
+    return `请填写${label}有效期`
138
+  }
139
+  return ''
140
+}
141
+
142
+/** 前端分步校验,返回错误文案或空 */
143
+export function validateEntryStep(form, stepKey) {
144
+  const { merchantType, subject, biz, shop } = form
145
+  const isPerson = merchantType === MERCHANT_TYPE_PERSON
146
+
147
+  if (stepKey === 'type') {
148
+    return merchantType ? '' : '请选择主体类型'
149
+  }
150
+
151
+  if (isPerson) {
152
+    if (stepKey === 'subject') {
153
+      return (
154
+        requireText(subject.personName, '请输入姓名') ||
155
+        requireText(subject.residenceAddress, '请输入居住地址') ||
156
+        requireText(subject.gender, '请选择性别') ||
157
+        requireText(subject.birthDate, '请选择出生日期') ||
158
+        requireText(subject.idCardNo, '请输入证件号码') ||
159
+        checkValidPeriod('证件', subject.idValidType, subject.idValidStart, subject.idValidEnd) ||
160
+        requireText(subject.idCardFront, '请上传证件照正面') ||
161
+        requireText(subject.idCardBack, '请上传证件照反面') ||
162
+        requireText(subject.contactName, '请输入联系人姓名') ||
163
+        (!isMobile(subject.contactPhone) ? '请输入正确的联系人手机' : '') ||
164
+        requireEmail(subject.contactEmail) ||
165
+        requireText(subject.bankName, '请输入开户银行') ||
166
+        requireText(subject.bankBranch, '请输入开户支行') ||
167
+        requireText(subject.bankAccount, '请输入银行账号')
168
+      )
169
+    }
170
+    if (stepKey === 'biz') {
171
+      return (
172
+        requireText(biz.merchantName, '请输入商户名称') ||
173
+        requireText(biz.servicePhone, '请输入客服电话') ||
174
+        requireText(biz.bizRegionName, '请选择经营地址') ||
175
+        requireText(biz.bizDetailAddress, '请输入经营详细地址')
176
+      )
177
+    }
178
+  } else {
179
+    if (stepKey === 'subject') {
180
+      return (
181
+        requireText(subject.companyName, '请输入企业名称') ||
182
+        requireText(subject.regRegionName, '请选择注册地址') ||
183
+        requireText(subject.companyDetailAddress, '请输入注册详细地址') ||
184
+        requireText(subject.businessScope, '请输入经营范围') ||
185
+        requireText(subject.creditCode, '请输入统一社会信用代码') ||
186
+        checkValidPeriod('营业', subject.licenseValidType, subject.licenseValidStart, subject.licenseValidEnd) ||
187
+        requireText(subject.legalName, '请输入法人姓名') ||
188
+        requireText(subject.legalResidence, '请输入法人居住地址') ||
189
+        requireText(subject.legalGender, '请选择法人性别') ||
190
+        requireText(subject.legalBirthDate, '请选择法人出生日期') ||
191
+        requireText(subject.legalIdCardNo, '请输入法人证件号码') ||
192
+        checkValidPeriod('法人证件', subject.legalIdValidType, subject.legalIdValidStart, subject.legalIdValidEnd) ||
193
+        requireText(subject.legalIdCardFront, '请上传法人证件照正面') ||
194
+        requireText(subject.legalIdCardBack, '请上传法人证件照反面') ||
195
+        requireText(subject.contactName, '请输入联系人姓名') ||
196
+        (!isMobile(subject.contactPhone) ? '请输入正确的联系人手机' : '') ||
197
+        requireEmail(subject.contactEmail) ||
198
+        requireText(subject.bankName, '请输入开户银行') ||
199
+        requireText(subject.bankBranch, '请输入开户支行') ||
200
+        requireText(subject.bankAccount, '请输入银行账号') ||
201
+        requireText(subject.corpBankAccount, '请输入对公银行账号') ||
202
+        requireText(subject.accountPermit, '请上传开户许可证')
203
+      )
204
+    }
205
+    if (stepKey === 'biz') {
206
+      return (
207
+        requireText(biz.bizRegionName, '请选择经营地址') ||
208
+        requireText(biz.bizDetailAddress, '请输入经营详细地址') ||
209
+        requireText(biz.businessLicense, '请上传营业执照')
210
+      )
211
+    }
212
+  }
213
+
214
+  if (stepKey === 'shop') {
215
+    return (
216
+      requireText(shop.shopName, '请输入店铺名称') ||
217
+      requireText(shop.shopPhone, '请输入联系电话') ||
218
+      requireText(shop.shopAvatar, '请上传店铺 Logo')
219
+    )
220
+  }
221
+
222
+  if (stepKey === 'submit') {
223
+    if (!form.agreementAccepted) return '请先阅读并同意入驻协议'
224
+  }
225
+
226
+  return ''
227
+}
228
+
229
+/** 提交前组装 DTO */
230
+export function buildEntrySubmitPayload(form) {
231
+  return {
232
+    subject: { ...form.subject, merchantType: form.merchantType },
233
+    biz: { ...form.biz, merchantType: form.merchantType },
234
+    shop: { ...form.shop },
235
+    agreementAccepted: !!form.agreementAccepted
236
+  }
237
+}

+ 18 - 0
shop-app/utils/mineNav.js

@@ -0,0 +1,18 @@
1
+import { ensureApiToken } from '@/utils/apiAuth'
2
+
3
+/**
4
+ * 我的服务子页:须登录再跳转
5
+ * @param {string} url 完整路径,如 PAGE_PROFILE
6
+ */
7
+export function navigateMinePage(url) {
8
+  if (!ensureApiToken()) {
9
+    return false
10
+  }
11
+  uni.navigateTo({
12
+    url,
13
+    fail: () => {
14
+      uni.showToast({ title: '页面打开失败', icon: 'none' })
15
+    }
16
+  })
17
+  return true
18
+}

+ 15 - 1
shop-app/utils/pageRoute.js

@@ -14,7 +14,7 @@ export const PAGE_HOME = '/pages/index/index'
14 14
 export const PAGE_CATEGORY_TAB = '/pages/category/index'
15 15
 /** 购物车 Tab(占位,正式功能待开发) */
16 16
 export const PAGE_CART = '/pages/cart/index'
17
-/** 我的 Tab(占位,正式功能待开发) */
17
+/** 我的 Tab */
18 18
 export const PAGE_MINE = '/pages/mine/index'
19 19
 /** 登录 */
20 20
 export const PAGE_LOGIN = '/pages/login/index'
@@ -22,6 +22,20 @@ export const PAGE_LOGIN = '/pages/login/index'
22 22
 // —— 分包:会员账号 ——
23 23
 
24 24
 export const PAGE_REGISTER = '/subpackage/account/register'
25
+/** 编辑个人资料 */
26
+export const PAGE_PROFILE = '/subpackage/account/profile'
27
+/** 修改密码 */
28
+export const PAGE_PASSWORD = '/subpackage/account/password'
29
+/** 收货地址列表 */
30
+export const PAGE_ADDRESS_LIST = '/subpackage/account/address-list'
31
+/** 新增/编辑地址 */
32
+export const PAGE_ADDRESS_EDIT = '/subpackage/account/address-edit'
33
+/** 商家入驻申请 */
34
+export const PAGE_ENTRY_APPLY = '/subpackage/account/entry-apply'
35
+/** 我的入驻申请列表 */
36
+export const PAGE_ENTRY_LIST = '/subpackage/account/entry-list'
37
+/** 入驻申请详情 */
38
+export const PAGE_ENTRY_DETAIL = '/subpackage/account/entry-detail'
25 39
 
26 40
 // —— 分包:分类 ——
27 41