xsh_1997 2 週間 前
コミット
8117e11555

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

@@ -1,9 +1,9 @@
1 1
 # 我的服务 — 前端技术方案(C 端 · shop-app)
2 2
 
3
-> **依据:** 《我的服务功能需求.md》v1.1、《我的服务技术方案.md》v1.2  
3
+> **依据:** 《我的服务功能需求.md》v1.1、《我的服务技术方案.md》v1.3(仅作接口对照,**本文档独立维护**)  
4 4
 > **关联:** 《会员注册登录前端技术方案.md》(登录前置)、后续《订单管理》等(地址被结算引用)  
5 5
 > **范围:** C 端 **个人资料、修改密码、收货地址、商家入驻**;**不** 改后端、**不** 实现我的订单/消息中心/换绑手机。  
6
-> **实现状态:** 我的 Tab + 分包页面已落地;资料页已对齐 v1.2(**无出生日期**),待联调。
6
+> **实现状态:** 已对齐 v1.3(地址 `regionCode`/`regionName` + 省市区级联),待联调。
7 7
 
8 8
 ---
9 9
 
@@ -13,10 +13,11 @@
13 13
 |----|------|
14 14
 | 框架 | uni-app **Vue 3** + **uview-plus** |
15 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`** |
16
+| 登录前置 | **MS0**:子功能经 `ensureApiToken` / `navigateMinePage` 拦截 |
17
+| 上传 | `@/utils/upload` → `POST /common/upload` |
18
+| 省市区 | `GET /api/region/tree`(匿名)+ `up-cascader`;逻辑对齐 `ruoyi-ui/utils/region.js` |
19
+| 样式 | `styles/mine.scss` |
20
+| 路由 | 业务页在 **`subpackage/account/`**;Tab 在 **`pages/mine/index`** |
20 21
 
21 22
 ---
22 23
 
@@ -25,20 +26,14 @@
25 26
 | 页面 | 路径 | 包 | 入口 |
26 27
 |------|------|-----|------|
27 28
 | 我的(服务入口) | `pages/mine/index` | 主包 Tab | TabBar「我的」 |
28
-| 个人资料 | `subpackage/account/profile` | 分包 | 我的 → 编辑个人资料 / 点头像 |
29
+| 个人资料 | `subpackage/account/profile` | 分包 | 我的 → 编辑个人资料 |
29 30
 | 修改密码 | `subpackage/account/password` | 分包 | 我的 → 修改密码 |
30 31
 | 收货地址列表 | `subpackage/account/address-list` | 分包 | 我的 → 收货地址 |
31 32
 | 地址编辑 | `subpackage/account/address-edit` | 分包 | 列表新增/编辑 |
32
-| 商家入驻 | `subpackage/account/entry-apply` | 分包 | 我的 → 我要入驻 |
33
-| 我的入驻申请 | `subpackage/account/entry-list` | 分包 | 我的 → 我的入驻申请 |
33
+| 商家入驻 | `subpackage/account/entry-apply` | 分包 | 我要入驻 |
34
+| 我的入驻申请 | `subpackage/account/entry-list` | 分包 | 我的入驻申请 |
34 35
 | 申请详情 | `subpackage/account/entry-detail` | 分包 | 列表项 |
35 36
 
36
-**Query:**
37
-
38
-| 页面 | 参数 | 说明 |
39
-|------|------|------|
40
-| address-edit | `mode=add\|edit`、`id` | 编辑时传 `addressId` |
41
-
42 37
 **路径常量:** `utils/pageRoute.js` → `PAGE_PROFILE`、`PAGE_PASSWORD`、`PAGE_ADDRESS_*`、`PAGE_ENTRY_*`
43 38
 
44 39
 ---
@@ -47,23 +42,15 @@
47 42
 
48 43
 | 类型 | 路径 | 说明 |
49 44
 |------|------|------|
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` | 公示期、驳回、完成说明 |
45
+| 我的 Tab | `shop-app/pages/mine/index.vue` | 服务菜单入口 |
46
+| 资料/密码/地址/入驻 | `shop-app/subpackage/account/*.vue` | 各子页 |
58 47
 | 会员 API | `shop-app/api/member.js` | profile / password / address |
48
+| 地区 API | `shop-app/api/region.js` | `GET /api/region/tree` |
59 49
 | 入驻 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` | 省市区三栏 |
65
-| 入驻字段块 | `shop-app/components/mine/entry/*.vue` | 个人/企业分步表单项 |
66
-| 协议 | `shop-app/components/account/AgreementBlock.vue` | 入驻提交勾选 |
50
+| 地区工具 | `shop-app/utils/region.js` | normalize、路径回显、解析区县 |
51
+| 地区组件 | `shop-app/components/mine/RegionFields.vue` | **级联选择**(替换手填三栏) |
52
+| 入驻表单 | `shop-app/utils/entryForm.js` | 分步校验、提交体 |
53
+| 其它 | `mineNav.js`、`entryConstants.js`、`ImageUpload`、`entry/*` | 见 v1.0 |
67 54
 
68 55
 ---
69 56
 
@@ -84,119 +71,99 @@
84 71
 
85 72
 #### 个人资料(v1.2)
86 73
 
87
-| 字段 | GET 展示 | PUT 可写 | 说明 |
88
-|------|:--------:|:--------:|------|
89
-| memberId | 是 | 否 | 用户 ID(只读) |
90
-| memberCode | 是 | 否 | 会员名称(登录账号,只读) |
91
-| mobile | 是 | 否 | 手机号(只读) |
92
-| nickName | 是 | 是 | 必填,前端 `maxlength=30` |
93
-| avatar | 是 | 是 | 图片上传 URL |
94
-| email | 是 | 是 | 可空,格式校验 |
95
-| sex | 是 | 是 | `0` 男 / `1` 女 / `2` 保密 |
96
-| ~~birthday~~ | — | — | **v1.2 已移除**,与后端 `MemberProfileAppVO` 一致 |
74
+可写:`nickName`、`avatar`、`email`、`sex`。**无** `birthday`。
97 75
 
98
-> **说明:** 入驻申请中的 **出生日期**(个人主体 `birthDate`、法人 `legalBirthDate`)属于 **商户主体字段**,与个人资料无关,仍在 `entry-apply` 分步表单中填写。
76
+#### 收货地址(v1.3 · 重要变更)
99 77
 
100
-**密码 Body:** `oldPassword`、`newPassword`、`confirmPassword`。
78
+| 字段 | 说明 |
79
+|------|------|
80
+| `regionCode` | 区县级 `code`(字符串提交) |
81
+| `regionName` | 省/市/区,**`/` 拼接**(如 `青海省/西宁市/城西区`) |
82
+| `detailAddress` | 街道门牌 |
83
+| ~~province/city/district~~ | **已废弃**,勿再提交 |
101 84
 
102
-**地址 Body:** `consigneeName`、`mobile`、`regionCode`、`regionName`、`detailAddress`、`isDefault`(`0`/`1`)。
85
+列表项 `MemberAddressVO`:`regionCode`、`regionName`、`fullAddress`(后端拼接展示用)。
103 86
 
104
-### 4.2 入驻 `/api/merchant/entry`
87
+### 4.2 省市区 `/api/region`
105 88
 
106 89
 | 方法 | HTTP | 路径 | 鉴权 |
107 90
 |------|------|------|------|
108
-| `getEntryAgreement` | GET | `/agreement` | 匿名 |
109
-| `getEntryStatus` | GET | `/status` | 匿名 |
110
-| `submitEntryApply` | POST | `/apply` | Token |
111
-| `getMyEntryApplies` | GET | `/my` | Token |
91
+| `getRegionTree` | GET | `/tree` | **匿名** |
112 92
 
113
-**提交 Body:** `subject` + `biz` + `shop` + `agreementAccepted`(结构见 `utils/entryForm.js`)
93
+响应:树节点 `code`、`name`、`type`(1 省 2 市 3 区县)、`children`。
114 94
 
115
-**申请状态:**
95
+**前端规则:**
116 96
 
117
-| applyStatus | 含义 |
118
-|-------------|------|
119
-| `0` | 待审核 |
120
-| `3` | 公示中 |
121
-| `1` | 已完成入驻 |
122
-| `2` | 审核未通过 |
97
+- `normalizeRegionTree` 供 `up-cascader` 使用;
98
+- 确认时 `parseRegionSelection`:**末级须 type=3**;
99
+- `regionName = 路径名称 join('/')`;
100
+- `regionCode = 末级 code`。
123 101
 
124
-**阻塞新申请:** 存在 status `0` 或 `3` 时不可再提交(**MS-E6**)。
102
+### 4.3 入驻 `/api/merchant/entry`
125 103
 
126
-**入驻前置:** 后端要求会员 `nick_name` 非空;须先在资料页填写昵称
104
+与 v1.2 相同;经营/注册地址字段为 `bizRegionCode`/`bizRegionName`、`regRegionCode`/`regRegionName`,UI 共用 `RegionFields`
127 105
 
128 106
 ---
129 107
 
130
-## 5. 我的 Tab 结构(`pages/mine/index`)
108
+## 5. 地区组件(`RegionFields.vue`)
131 109
 
132 110
 ```text
133
-顶栏(绿渐变)
134
-├── 未登录:提示 + 登录 / 注册
135
-└── 已登录:头像、昵称、手机号 → 点击进个人资料
136
-
137
-账号管理 → 个人资料、修改密码
138
-收货地址 → 地址列表
139
-商家入驻 → 我要入驻、我的入驻申请
140
-退出登录
111
+点击「所在地区」行
112
+    → 弹出 up-cascader(省 → 市 → 区)
113
+    → 确认后 emit:
114
+        regionCode、regionName、pathCodes(回显用)
115
+        兼容 code、name(入驻 applyRegion 使用)
141 116
 ```
142 117
 
143
----
144
-
145
-## 6. 关键交互与规则
118
+**使用页面:**
146 119
 
147
-| 规则 | 前端落实 |
148
-|------|----------|
149
-| MS0 | 子页 `ensureApiToken` |
150
-| MS-P1 | 用户 ID、手机号、会员名称只读 |
151
-| MS-P2 | 昵称必填、≤30 字 |
152
-| MS-P3 | 头像须上传成功 |
153
-| MS-P4 | 保存后 `fetchUserInfo` 刷新 Tab |
154
-| MS-W1~W3 | `PUT /password` |
155
-| MS-A1~A4 | 地址默认互斥、删除确认 |
156
-| MS-E5~E9 | 协议勾选、阻塞待审、提交确认 |
120
+- `address-edit.vue` — 收货地址;
121
+- `EntryPersonBiz` / `EntryEnterpriseBiz` / `EntryEnterpriseSubject` — 入驻经营/注册地址。
157 122
 
158
-**地区:** `regionCode` = 区县 `code`;`regionName` = 省/市/区 `/` 拼接(与商户 `biz_region_*` 一致);UI 暂用手填三栏,后续可接 `GET /api/region/tree` 级联
123
+**缓存:** `loadRegionCascaderTree()` 模块内缓存,避免重复请求。
159 124
 
160 125
 ---
161 126
 
162
-## 7. 登录态
127
+## 6. 关键交互与规则
163 128
 
164
-```text
165
-我的 Tab onShow → 有 Token 则拉 profile 刷新 store
166
-子页保存资料 → userStore.fetchUserInfo()
167
-```
129
+| 规则 | 前端落实 |
130
+|------|----------|
131
+| MS-A · 级联 | 功能需求 §8.3「省/市/区级联」→ `RegionFields` + `/api/region/tree` |
132
+| MS-A1~A4 | 默认互斥、删除确认、列表用 `fullAddress` 或 `regionName`+详细 |
133
+| MS-P2 | 昵称 ≤30 字 |
134
+| MS-E6 | 待审/公示阻塞新入驻 |
168 135
 
169 136
 ---
170 137
 
171
-## 8. 联调检查清单
138
+## 7. 联调检查清单
172 139
 
173
-- [ ] 资料页 **无** 出生日期项;`PUT /profile` 仅 nickName/avatar/email/sex
174
-- [ ] 昵称空、超长 30 字前端拦截
175
-- [ ] 密码、地址 CRUD
176
-- [ ] 入驻个人/企业提交;待审阻塞
177
-- [ ] 未填昵称时入驻提交后端提示(引导完善资料)
140
+- [ ] `GET /api/region/tree` 返回三级树
141
+- [ ] 地址新增/编辑提交 `regionCode`+`regionName`,**无** province/city/district
142
+- [ ] 编辑地址能按 `regionCode` 回显级联路径
143
+- [ ] 未选至区县时前端提示「请选择完整的省市区」
144
+- [ ] 入驻经营/注册地址同样走级联
145
+- [ ] 资料、密码、入驻其它项同 v1.2 清单
178 146
 
179 147
 ---
180 148
 
181
-## 9. 非本期
149
+## 8. 非本期
182 150
 
183 151
 | 项 | 说明 |
184 152
 |----|------|
185
-| 个人资料出生日期 | v1.2 明确不做 |
186 153
 | 我的订单、消息中心 | 另模块 |
187 154
 | 手机号换绑 | — |
188
-| 省市区级联字典 | 可后续接 API |
189 155
 
190 156
 ---
191 157
 
192
-## 10. 修订记录
158
+## 9. 修订记录
193 159
 
194 160
 | 版本 | 说明 |
195 161
 |------|------|
196 162
 | **v1.0** | 首版:我的 Tab、资料/密码/地址/入驻分包 |
197
-| **v1.1** | 对齐后端 v1.1:password 独立接口、email/sex |
198
-| **v1.2** | 对齐后端/需求 v1.2:**移除资料页出生日期**;昵称 ≤30 字;更新联调清单 |
163
+| **v1.1** | password 独立;email/sex |
164
+| **v1.2** | 资料移除 birthday;昵称 ≤30 |
165
+| **v1.3** | 地址字段改为 **regionCode/regionName**;`api/region` + `RegionFields` 级联;入驻/地址联调清单更新 |
199 166
 
200 167
 ---
201 168
 
202
-*文档版本:v1.2 · 关联《我的服务技术方案.md》v1.2、《我的服务功能需求.md》v1.1*
169
+*文档版本:v1.3 · 关联《我的服务功能需求.md》v1.1*

+ 11 - 0
shop-app/api/region.js

@@ -0,0 +1,11 @@
1
+import request from '@/utils/request'
2
+
3
+/** 省市区三级树(匿名,收货地址/入驻地址共用) */
4
+export function getRegionTree() {
5
+  return request({
6
+    url: '/api/region/tree',
7
+    method: 'GET',
8
+    header: { isToken: false },
9
+    silent: true
10
+  })
11
+}

+ 99 - 55
shop-app/components/mine/RegionFields.vue

@@ -1,87 +1,131 @@
1 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
-			/>
2
+	<view class="region-cascader">
3
+		<view class="form-row" @click="openPicker">
4
+			<text class="form-row__label form-row__label--req">所在地区</text>
5
+			<view class="form-row__picker region-cascader__value">
6
+				<text :class="{ 'form-row__placeholder': !displayText }">
7
+					{{ displayText || '请选择省 / 市 / 区' }}
8
+				</text>
9
+			</view>
10
+			<u-icon name="arrow-right" color="#ccc" size="16" />
29 11
 		</view>
12
+		<up-cascader
13
+			v-if="treeReady"
14
+			:show="pickerShow"
15
+			:data="cascaderTree"
16
+			v-model="pathCodes"
17
+			value-key="code"
18
+			label-key="name"
19
+			children-key="children"
20
+			:closeable="true"
21
+			@update:show="pickerShow = $event"
22
+			@confirm="onConfirm"
23
+		/>
30 24
 	</view>
31 25
 </template>
32 26
 
33 27
 <script setup>
34
-import { ref, watch } from 'vue'
28
+import { ref, computed, watch, onMounted } from 'vue'
29
+import {
30
+  loadRegionCascaderTree,
31
+  findRegionPath,
32
+  parseRegionSelection,
33
+  formatRegionDisplay
34
+} from '@/utils/region'
35 35
 
36 36
 const props = defineProps({
37 37
 	modelValue: {
38 38
 		type: Object,
39
-		default: () => ({ code: '', name: '', province: '', city: '', district: '' })
39
+		default: () => ({
40
+			regionCode: '',
41
+			regionName: '',
42
+			code: '',
43
+			name: '',
44
+			pathCodes: []
45
+		})
40 46
 	}
41 47
 })
42 48
 
43 49
 const emit = defineEmits(['update:modelValue'])
44 50
 
45
-const province = ref('')
46
-const city = ref('')
47
-const district = ref('')
51
+const pickerShow = ref(false)
52
+const treeReady = ref(false)
53
+const rawTree = ref([])
54
+const cascaderTree = ref([])
55
+const pathCodes = ref([])
56
+
57
+const displayText = computed(() => {
58
+  const name = props.modelValue?.regionName || props.modelValue?.name || ''
59
+  return formatRegionDisplay(name)
60
+})
48 61
 
49 62
 watch(
50 63
   () => props.modelValue,
51 64
   (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
-    }
65
+    if (!v || !rawTree.value.length) return
66
+    syncPathFromModel(v)
62 67
   },
63
-  { immediate: true, deep: true }
68
+  { deep: true }
64 69
 )
65 70
 
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('|')
71
+onMounted(() => {
72
+  loadRegionCascaderTree()
73
+    .then(({ raw, cascader }) => {
74
+      rawTree.value = raw
75
+      cascaderTree.value = cascader
76
+      treeReady.value = true
77
+      syncPathFromModel(props.modelValue)
78
+    })
79
+    .catch(() => {
80
+      uni.showToast({ title: '地区数据加载失败', icon: 'none' })
81
+    })
82
+})
83
+
84
+function syncPathFromModel(v) {
85
+  if (!v) return
86
+  if (Array.isArray(v.pathCodes) && v.pathCodes.length) {
87
+    pathCodes.value = [...v.pathCodes]
88
+    return
89
+  }
90
+  const code = v.regionCode || v.code
91
+  if (code && rawTree.value.length) {
92
+    const path = findRegionPath(rawTree.value, code)
93
+    pathCodes.value = path || []
94
+  }
95
+}
96
+
97
+function openPicker() {
98
+  if (!treeReady.value) {
99
+    uni.showToast({ title: '地区数据加载中', icon: 'none' })
100
+    return
101
+  }
102
+  pickerShow.value = true
103
+}
104
+
105
+function onConfirm(codes) {
106
+  const parsed = parseRegionSelection(rawTree.value, codes)
107
+  if (!parsed.valid) {
108
+    uni.showToast({ title: '请选择完整的省市区', icon: 'none' })
109
+    return
110
+  }
72 111
   emit('update:modelValue', {
73
-    province: p,
74
-    city: c,
75
-    district: d,
76
-    name,
77
-    code
112
+    regionCode: parsed.code,
113
+    regionName: parsed.name,
114
+    code: parsed.code,
115
+    name: parsed.name,
116
+    pathCodes: codes
78 117
   })
79 118
 }
80 119
 </script>
81 120
 
82 121
 <style lang="scss" scoped>
83 122
 @import '@/styles/mine.scss';
84
-.region-fields .form-row:last-child {
123
+
124
+.region-cascader .form-row {
85 125
   border-bottom: none;
86 126
 }
127
+.region-cascader__value {
128
+  flex: 1;
129
+  min-width: 0;
130
+}
87 131
 </style>

+ 2 - 2
shop-app/styles/mine.scss

@@ -1,8 +1,8 @@
1 1
 @import '@/styles/morandi.scss';
2 2
 
3 3
 .mine-page {
4
-  min-height: 100vh;
5
-  padding-bottom: 48rpx;
4
+  height: calc(100vh - 188rpx);
5
+  // padding-bottom: 48rpx;
6 6
   background: $morandi-bg-page;
7 7
   box-sizing: border-box;
8 8
 }

+ 34 - 30
shop-app/subpackage/account/address-edit.vue

@@ -24,7 +24,7 @@
24 24
 					placeholder="街道、门牌号等"
25 25
 				/>
26 26
 			</view>
27
-			<view class="form-row" @click="form.isDefault = form.isDefault === '1' ? '0' : '1'">
27
+			<view class="form-row">
28 28
 				<text class="form-row__label">设为默认</text>
29 29
 				<switch :checked="form.isDefault === '1'" color="#2e7d32" @change="onDefaultChange" />
30 30
 			</view>
@@ -43,6 +43,7 @@ import { onLoad } from '@dcloudio/uni-app'
43 43
 import { getAddressList, addAddress, updateAddress } from '@/api/member'
44 44
 import { ensureApiToken } from '@/utils/apiAuth'
45 45
 import { validateMobile } from '@/utils/memberValidate'
46
+import { loadRegionCascaderTree, findRegionPath } from '@/utils/region'
46 47
 import RegionFields from '@/components/mine/RegionFields.vue'
47 48
 
48 49
 const mode = ref('add')
@@ -52,14 +53,15 @@ const saving = ref(false)
52 53
 const form = reactive({
53 54
   consigneeName: '',
54 55
   mobile: '',
55
-  province: '',
56
-  city: '',
57
-  district: '',
58 56
   detailAddress: '',
59 57
   isDefault: '0'
60 58
 })
61 59
 
62
-const region = ref({ code: '', name: '', province: '', city: '', district: '' })
60
+const region = ref({
61
+  regionCode: '',
62
+  regionName: '',
63
+  pathCodes: []
64
+})
63 65
 
64 66
 onLoad((options) => {
65 67
   if (!ensureApiToken()) return
@@ -74,25 +76,25 @@ onLoad((options) => {
74 76
 })
75 77
 
76 78
 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
-  })
79
+  Promise.all([getAddressList(), loadRegionCascaderTree()])
80
+    .then(([addrRes, { raw }]) => {
81
+      const rows = addrRes.data || []
82
+      const item = rows.find((r) => r.addressId === addressId.value)
83
+      if (!item) return
84
+      form.consigneeName = item.consigneeName || ''
85
+      form.mobile = item.mobile || ''
86
+      form.detailAddress = item.detailAddress || ''
87
+      form.isDefault = item.isDefault === '1' ? '1' : '0'
88
+      const code = item.regionCode || ''
89
+      region.value = {
90
+        regionCode: code,
91
+        regionName: item.regionName || '',
92
+        code,
93
+        name: item.regionName || '',
94
+        pathCodes: code ? findRegionPath(raw, code) || [] : []
95
+      }
96
+    })
97
+    .catch(() => {})
96 98
 }
97 99
 
98 100
 function onDefaultChange(e) {
@@ -109,8 +111,12 @@ function validate() {
109 111
     uni.showToast({ title: m, icon: 'none' })
110 112
     return false
111 113
   }
112
-  if (!region.value.name) {
113
-    uni.showToast({ title: '请填写所在地区', icon: 'none' })
114
+  if (!(region.value.regionCode || '').trim()) {
115
+    uni.showToast({ title: '请选择所在地区', icon: 'none' })
116
+    return false
117
+  }
118
+  if (!(region.value.regionName || '').trim()) {
119
+    uni.showToast({ title: '请选择所在地区', icon: 'none' })
114 120
     return false
115 121
   }
116 122
   if (!(form.detailAddress || '').trim()) {
@@ -121,14 +127,12 @@ function validate() {
121 127
 }
122 128
 
123 129
 function buildPayload() {
124
-  const parts = region.value
125 130
   return {
126 131
     addressId: mode.value === 'edit' ? addressId.value : undefined,
127 132
     consigneeName: form.consigneeName.trim(),
128 133
     mobile: form.mobile.trim(),
129
-    province: parts.province,
130
-    city: parts.city,
131
-    district: parts.district,
134
+    regionCode: region.value.regionCode,
135
+    regionName: region.value.regionName,
132 136
     detailAddress: form.detailAddress.trim(),
133 137
     isDefault: form.isDefault
134 138
   }

+ 5 - 1
shop-app/subpackage/account/address-list.vue

@@ -67,7 +67,11 @@ function loadList() {
67 67
 }
68 68
 
69 69
 function formatAddr(item) {
70
-  return [item.province, item.city, item.district, item.detailAddress].filter(Boolean).join('')
70
+  if (item.fullAddress) {
71
+    return item.fullAddress
72
+  }
73
+  const region = (item.regionName || '').replace(/\//g, '')
74
+  return region + (item.detailAddress || '')
71 75
 }
72 76
 
73 77
 function goAdd() {

+ 4 - 4
shop-app/subpackage/account/entry-apply.vue

@@ -115,8 +115,8 @@ const stepIndex = ref(0)
115 115
 const submitting = ref(false)
116 116
 const entryOpen = ref(true)
117 117
 const blocked = ref(false)
118
-const regionBiz = ref({ code: '', name: '', province: '', city: '', district: '' })
119
-const regionReg = ref({ code: '', name: '', province: '', city: '', district: '' })
118
+const regionBiz = ref({ regionCode: '', regionName: '', pathCodes: [] })
119
+const regionReg = ref({ regionCode: '', regionName: '', pathCodes: [] })
120 120
 const userStore = useUserStore()
121 121
 
122 122
 const stepKeys = computed(() => ['type', 'subject', 'biz', 'shop', 'submit'])
@@ -179,8 +179,8 @@ function selectType(type) {
179 179
   form.biz = next.biz
180 180
   form.shop = next.shop
181 181
   form.agreementAccepted = false
182
-  regionBiz.value = { code: '', name: '', province: '', city: '', district: '' }
183
-  regionReg.value = { code: '', name: '', province: '', city: '', district: '' }
182
+  regionBiz.value = { regionCode: '', regionName: '', pathCodes: [] }
183
+  regionReg.value = { regionCode: '', regionName: '', pathCodes: [] }
184 184
 }
185 185
 
186 186
 function onBizRegion(v) {

+ 3 - 3
shop-app/utils/entryForm.js

@@ -171,7 +171,7 @@ export function validateEntryStep(form, stepKey) {
171 171
       return (
172 172
         requireText(biz.merchantName, '请输入商户名称') ||
173 173
         requireText(biz.servicePhone, '请输入客服电话') ||
174
-        requireText(biz.bizRegionName, '请选择经营地址') ||
174
+        (requireText(biz.bizRegionCode, '请选择经营地址') || requireText(biz.bizRegionName, '请选择经营地址')) ||
175 175
         requireText(biz.bizDetailAddress, '请输入经营详细地址')
176 176
       )
177 177
     }
@@ -179,7 +179,7 @@ export function validateEntryStep(form, stepKey) {
179 179
     if (stepKey === 'subject') {
180 180
       return (
181 181
         requireText(subject.companyName, '请输入企业名称') ||
182
-        requireText(subject.regRegionName, '请选择注册地址') ||
182
+        (requireText(subject.regRegionCode, '请选择注册地址') || requireText(subject.regRegionName, '请选择注册地址')) ||
183 183
         requireText(subject.companyDetailAddress, '请输入注册详细地址') ||
184 184
         requireText(subject.businessScope, '请输入经营范围') ||
185 185
         requireText(subject.creditCode, '请输入统一社会信用代码') ||
@@ -204,7 +204,7 @@ export function validateEntryStep(form, stepKey) {
204 204
     }
205 205
     if (stepKey === 'biz') {
206 206
       return (
207
-        requireText(biz.bizRegionName, '请选择经营地址') ||
207
+        (requireText(biz.bizRegionCode, '请选择经营地址') || requireText(biz.bizRegionName, '请选择经营地址')) ||
208 208
         requireText(biz.bizDetailAddress, '请输入经营详细地址') ||
209 209
         requireText(biz.businessLicense, '请上传营业执照')
210 210
       )

+ 110 - 0
shop-app/utils/region.js

@@ -0,0 +1,110 @@
1
+import { getRegionTree } from '@/api/region'
2
+
3
+/** 与平台 ruoyi-ui `utils/region.js` 逻辑一致 */
4
+export function normalizeRegionTree(nodes) {
5
+  if (!nodes || !nodes.length) {
6
+    return []
7
+  }
8
+  return nodes.map((node) => {
9
+    const hasChildren = node.children && node.children.length > 0
10
+    const children = hasChildren ? normalizeRegionTree(node.children) : undefined
11
+    const isDistrict = node.type === 3
12
+    const item = {
13
+      code: node.code,
14
+      name: node.name,
15
+      type: node.type
16
+    }
17
+    if (children && children.length) {
18
+      item.children = children
19
+    }
20
+    return item
21
+  })
22
+}
23
+
24
+export function findRegionPath(nodes, targetCode, path) {
25
+  if (!nodes || !nodes.length || targetCode == null || targetCode === '') {
26
+    return null
27
+  }
28
+  const target = String(targetCode)
29
+  for (let i = 0; i < nodes.length; i++) {
30
+    const node = nodes[i]
31
+    const nextPath = (path || []).concat(node.code)
32
+    if (String(node.code) === target) {
33
+      return nextPath
34
+    }
35
+    if (node.children && node.children.length) {
36
+      const found = findRegionPath(node.children, target, nextPath)
37
+      if (found) {
38
+        return found
39
+      }
40
+    }
41
+  }
42
+  return null
43
+}
44
+
45
+export function getRegionNamesByCodes(regionTree, codes) {
46
+  const names = []
47
+  let nodes = regionTree
48
+  for (let i = 0; i < codes.length; i++) {
49
+    const code = codes[i]
50
+    const node = (nodes || []).find((item) => String(item.code) === String(code))
51
+    if (!node) {
52
+      break
53
+    }
54
+    names.push(node.name)
55
+    nodes = node.children || []
56
+  }
57
+  return names
58
+}
59
+
60
+export function findRegionNodeByCodes(regionTree, codes) {
61
+  let nodes = regionTree
62
+  let node = null
63
+  for (let i = 0; i < codes.length; i++) {
64
+    node = (nodes || []).find((item) => String(item.code) === String(codes[i]))
65
+    if (!node) {
66
+      return null
67
+    }
68
+    nodes = node.children || []
69
+  }
70
+  return node
71
+}
72
+
73
+/**
74
+ * 解析级联选择结果(须选至区县 type=3)
75
+ * @returns {{ valid: boolean, code?: string, name?: string }}
76
+ */
77
+export function parseRegionSelection(regionTree, codes) {
78
+  if (!codes || !codes.length) {
79
+    return { valid: false }
80
+  }
81
+  const lastNode = findRegionNodeByCodes(regionTree, codes)
82
+  if (!lastNode || lastNode.type !== 3) {
83
+    return { valid: false }
84
+  }
85
+  const names = getRegionNamesByCodes(regionTree, codes)
86
+  return {
87
+    valid: true,
88
+    code: String(codes[codes.length - 1]),
89
+    name: names.join('/')
90
+  }
91
+}
92
+
93
+/** 展示用:省/市/区 → 空格分隔 */
94
+export function formatRegionDisplay(regionName) {
95
+  return (regionName || '').replace(/\//g, ' ')
96
+}
97
+
98
+let cachedRawTree = null
99
+let cachedCascaderTree = null
100
+
101
+/** 加载并缓存级联树(normalize 后供 up-cascader) */
102
+export async function loadRegionCascaderTree(force = false) {
103
+  if (!force && cachedCascaderTree) {
104
+    return { raw: cachedRawTree, cascader: cachedCascaderTree }
105
+  }
106
+  const res = await getRegionTree()
107
+  cachedRawTree = res.data || []
108
+  cachedCascaderTree = normalizeRegionTree(cachedRawTree)
109
+  return { raw: cachedRawTree, cascader: cachedCascaderTree }
110
+}