edit.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. <script setup>
  2. import { onLoad } from '@dcloudio/uni-app'
  3. import { reactive, ref } from 'vue'
  4. import { useUserStore } from '@/stores/user'
  5. import { request, parseResponseJson } from '@/utils/request'
  6. import { fetchChinaRegionTree } from '@/utils/chinaRegion'
  7. const regionTree = ref([])
  8. const userStore = useUserStore()
  9. const addressId = ref('')
  10. const loading = ref(false)
  11. const submitting = ref(false)
  12. const form = reactive({
  13. contactPerson: '',
  14. contactPhone: '',
  15. province: '',
  16. city: '',
  17. district: '',
  18. detailAddress: '',
  19. isDefault: false
  20. })
  21. /** H5 上 mode=region 无数据,使用多列选择器 + 接口 bas_china_region 树 */
  22. const multiIndex = ref([0, 0, 0])
  23. const multiRange = ref([[], [], []])
  24. function updateMultiRange() {
  25. const pi = Math.min(multiIndex.value[0], Math.max(0, regionTree.value.length - 1))
  26. const p = regionTree.value[pi] ?? regionTree.value[0]
  27. const cities = p?.children ?? []
  28. const ci = Math.min(multiIndex.value[1], Math.max(0, cities.length - 1))
  29. const c = cities[ci]
  30. const districts = c?.children ?? []
  31. const row0 = regionTree.value.map((x) => x.label)
  32. const row1 = cities.map((x) => x.label)
  33. let row2 = districts.map((x) => x.label)
  34. if (row2.length === 0 && c) {
  35. row2 = [c.label]
  36. }
  37. if (row1.length === 0) row1.push('')
  38. if (row2.length === 0) row2.push('')
  39. multiRange.value = [row0, row1, row2]
  40. }
  41. function applySelectionFromIndex() {
  42. const pi = Math.min(multiIndex.value[0], regionTree.value.length - 1)
  43. const p = regionTree.value[pi]
  44. const cities = p?.children ?? []
  45. const ci = Math.min(multiIndex.value[1], Math.max(0, cities.length - 1))
  46. const c = cities[ci]
  47. const districts = c?.children ?? []
  48. let di = multiIndex.value[2]
  49. if (districts.length > 0) {
  50. di = Math.min(di, districts.length - 1)
  51. form.province = p?.label ?? ''
  52. form.city = c?.label ?? ''
  53. form.district = districts[di]?.label ?? ''
  54. } else if (c) {
  55. form.province = p?.label ?? ''
  56. form.city = c.label
  57. form.district = c.label
  58. }
  59. }
  60. function onRegionColumnChange(e) {
  61. const col = e.detail.column
  62. const val = e.detail.value
  63. const idx = [...multiIndex.value]
  64. if (col === 0) {
  65. idx[0] = val
  66. idx[1] = 0
  67. idx[2] = 0
  68. } else if (col === 1) {
  69. idx[1] = val
  70. idx[2] = 0
  71. } else {
  72. idx[2] = val
  73. }
  74. idx[0] = Math.min(Math.max(0, idx[0]), regionTree.value.length - 1)
  75. const cities = regionTree.value[idx[0]]?.children ?? []
  76. idx[1] = Math.min(Math.max(0, idx[1]), Math.max(0, cities.length - 1))
  77. const dists = cities[idx[1]]?.children ?? []
  78. idx[2] = dists.length > 0 ? Math.min(Math.max(0, idx[2]), dists.length - 1) : 0
  79. multiIndex.value = idx
  80. updateMultiRange()
  81. }
  82. function onRegionMultiConfirm(e) {
  83. const v = e.detail.value
  84. if (v && v.length >= 3) {
  85. multiIndex.value = [...v]
  86. updateMultiRange()
  87. applySelectionFromIndex()
  88. }
  89. }
  90. function syncMultiFromForm() {
  91. const { province: p, city: c, district: d } = form
  92. if (!p || !c || !d) {
  93. multiIndex.value = [0, 0, 0]
  94. updateMultiRange()
  95. return
  96. }
  97. const pi = regionTree.value.findIndex((x) => x.label === p)
  98. if (pi < 0) {
  99. multiIndex.value = [0, 0, 0]
  100. updateMultiRange()
  101. return
  102. }
  103. const cities = regionTree.value[pi].children ?? []
  104. const ci = cities.findIndex((x) => x.label === c)
  105. if (ci < 0) {
  106. multiIndex.value = [pi, 0, 0]
  107. updateMultiRange()
  108. return
  109. }
  110. const districts = cities[ci].children ?? []
  111. let di = districts.findIndex((x) => x.label === d)
  112. if (di < 0) di = 0
  113. multiIndex.value = [pi, ci, di]
  114. updateMultiRange()
  115. }
  116. function regionLabel() {
  117. if (form.province && form.city && form.district) return `${form.province} ${form.city} ${form.district}`
  118. return '请选择省市区'
  119. }
  120. function toggleDefaultDelivery() {
  121. form.isDefault = !form.isDefault
  122. }
  123. async function loadDetail(id, opts) {
  124. if (!opts?.skipLoading) loading.value = true
  125. try {
  126. const res = await request({ url: `/mini/client/addresses/${id}`, method: 'GET' })
  127. const body = parseResponseJson(res)
  128. if (body.code !== 200 || !body.data) {
  129. uni.showToast({ title: body.message || '地址不存在', icon: 'none' })
  130. setTimeout(() => uni.navigateBack(), 800)
  131. return
  132. }
  133. const d = body.data
  134. form.contactPerson = d.contactPerson || ''
  135. form.contactPhone = d.contactPhone || ''
  136. form.province = d.province || ''
  137. form.city = d.city || ''
  138. form.district = d.district || ''
  139. form.detailAddress = d.detailAddress || ''
  140. form.isDefault = !!d.isDefault
  141. syncMultiFromForm()
  142. } finally {
  143. if (!opts?.skipLoading) loading.value = false
  144. }
  145. }
  146. function validate() {
  147. if (!form.contactPerson.trim()) {
  148. uni.showToast({ title: '请填写联系人', icon: 'none' })
  149. return false
  150. }
  151. if (!form.contactPhone.trim()) {
  152. uni.showToast({ title: '请填写手机号', icon: 'none' })
  153. return false
  154. }
  155. if (!/^1\d{10}$/.test(form.contactPhone.trim())) {
  156. uni.showToast({ title: '请输入正确手机号', icon: 'none' })
  157. return false
  158. }
  159. if (!form.province || !form.city || !form.district) {
  160. uni.showToast({ title: '请选择省市区', icon: 'none' })
  161. return false
  162. }
  163. if (!form.detailAddress.trim()) {
  164. uni.showToast({ title: '请填写详细地址', icon: 'none' })
  165. return false
  166. }
  167. return true
  168. }
  169. async function handleSubmit() {
  170. if (!validate() || submitting.value) return
  171. submitting.value = true
  172. try {
  173. const payload = {
  174. addressType: 'delivery',
  175. contactPerson: form.contactPerson.trim(),
  176. contactPhone: form.contactPhone.trim(),
  177. province: form.province || '',
  178. city: form.city || '',
  179. district: form.district || '',
  180. detailAddress: form.detailAddress.trim(),
  181. isDefault: form.isDefault
  182. }
  183. const res = await request({
  184. url: addressId.value ? `/mini/client/addresses/${addressId.value}` : '/mini/client/addresses',
  185. method: addressId.value ? 'PUT' : 'POST',
  186. data: payload
  187. })
  188. const body = parseResponseJson(res)
  189. if (body.code === 200) {
  190. uni.showToast({ title: addressId.value ? '保存成功' : '添加成功', icon: 'success' })
  191. setTimeout(() => uni.navigateBack(), 500)
  192. } else {
  193. uni.showToast({ title: body.message || '保存失败', icon: 'none' })
  194. }
  195. } catch {
  196. uni.showToast({ title: '网络异常', icon: 'none' })
  197. } finally {
  198. submitting.value = false
  199. }
  200. }
  201. onLoad(async (options) => {
  202. userStore.checkLoginStatus()
  203. if (!userStore.isLoggedIn) {
  204. uni.reLaunch({ url: '/pages/login/login' })
  205. return
  206. }
  207. if (!userStore.isClient) {
  208. uni.showToast({ title: '仅客户可访问', icon: 'none' })
  209. setTimeout(() => userStore.navigateToHome(), 1200)
  210. return
  211. }
  212. loading.value = true
  213. try {
  214. regionTree.value = await fetchChinaRegionTree()
  215. if (regionTree.value.length === 0) {
  216. uni.showToast({ title: '省市区数据未配置', icon: 'none' })
  217. }
  218. updateMultiRange()
  219. const id = (options && options.id) || ''
  220. if (id) {
  221. addressId.value = id
  222. uni.setNavigationBarTitle({ title: '编辑收货地址' })
  223. await loadDetail(id, { skipLoading: true })
  224. } else {
  225. uni.setNavigationBarTitle({ title: '新增收货地址' })
  226. }
  227. } catch {
  228. uni.showToast({ title: '省市区加载失败', icon: 'none' })
  229. } finally {
  230. loading.value = false
  231. }
  232. })
  233. </script>
  234. <template>
  235. <view class="page client-theme client-subpage">
  236. <view class="client-subpage-hero">
  237. <text class="client-subpage-hero__title">{{ addressId ? '编辑收货地址' : '新增收货地址' }}</text>
  238. <text class="client-subpage-hero__sub">请填写真实信息,便于配送联系</text>
  239. </view>
  240. <view v-if="loading" class="state">加载中...</view>
  241. <view v-else class="form client-subpage-body">
  242. <view class="field">
  243. <text class="label">联系人</text>
  244. <input
  245. v-model="form.contactPerson"
  246. class="input"
  247. type="text"
  248. placeholder="请填写联系人姓名"
  249. maxlength="50"
  250. />
  251. </view>
  252. <view class="field">
  253. <text class="label">手机号</text>
  254. <input
  255. v-model="form.contactPhone"
  256. class="input"
  257. type="number"
  258. placeholder="请填写11位手机号"
  259. maxlength="11"
  260. />
  261. </view>
  262. <view class="field">
  263. <text class="label">省市区</text>
  264. <picker
  265. mode="multiSelector"
  266. :value="multiIndex"
  267. :range="multiRange"
  268. @columnchange="onRegionColumnChange"
  269. @change="onRegionMultiConfirm"
  270. >
  271. <view class="picker-row">
  272. <text :class="['picker-text', !form.province && 'placeholder']">{{ regionLabel() }}</text>
  273. </view>
  274. </picker>
  275. </view>
  276. <view class="field column">
  277. <text class="label">详细地址</text>
  278. <textarea
  279. v-model="form.detailAddress"
  280. class="textarea"
  281. placeholder="街道、门牌号等"
  282. maxlength="500"
  283. :auto-height="false"
  284. />
  285. </view>
  286. <view class="field switch-row">
  287. <text class="label switch-label">设为默认收货地址</text>
  288. <view
  289. class="custom-switch"
  290. :class="{ 'is-on': form.isDefault }"
  291. @click="toggleDefaultDelivery"
  292. >
  293. <view class="custom-switch-thumb" />
  294. </view>
  295. </view>
  296. <button
  297. class="submit-btn"
  298. type="default"
  299. :loading="submitting"
  300. :disabled="submitting"
  301. @click="handleSubmit"
  302. >
  303. 保存
  304. </button>
  305. </view>
  306. </view>
  307. </template>
  308. <style lang="scss" scoped>
  309. .page {
  310. min-height: 100vh;
  311. background: var(--c-bg);
  312. box-sizing: border-box;
  313. }
  314. .state {
  315. text-align: center;
  316. color: var(--c-muted);
  317. padding: 48rpx;
  318. font-size: 28rpx;
  319. }
  320. .form {
  321. flex: 1;
  322. min-height: 0;
  323. background: var(--c-card);
  324. border-radius: var(--c-radius-card);
  325. border: 1rpx solid var(--c-border);
  326. padding: 8rpx 24rpx 40rpx;
  327. margin: 0;
  328. box-sizing: border-box;
  329. }
  330. .field {
  331. display: flex;
  332. align-items: center;
  333. min-height: 96rpx;
  334. border-bottom: 1rpx solid rgba(220, 235, 228, 0.65);
  335. &.column {
  336. flex-direction: column;
  337. align-items: stretch;
  338. padding: 20rpx 0;
  339. min-height: auto;
  340. }
  341. &.switch-row {
  342. justify-content: space-between;
  343. border-bottom: none;
  344. padding-top: 8rpx;
  345. gap: 24rpx;
  346. }
  347. }
  348. .label {
  349. width: 160rpx;
  350. font-size: 28rpx;
  351. color: var(--c-text);
  352. }
  353. .input {
  354. flex: 1;
  355. font-size: 28rpx;
  356. color: var(--c-text);
  357. padding: 18rpx 0;
  358. }
  359. .picker-row {
  360. flex: 1;
  361. display: flex;
  362. align-items: center;
  363. justify-content: space-between;
  364. padding: 20rpx 0;
  365. }
  366. .picker-text {
  367. font-size: 28rpx;
  368. color: var(--c-text);
  369. }
  370. .placeholder {
  371. color: #c0c4cc;
  372. }
  373. .textarea {
  374. width: 100%;
  375. font-size: 28rpx;
  376. line-height: 1.5;
  377. color: #303133;
  378. padding: 16rpx;
  379. background: transparent;
  380. border-radius: 20rpx;
  381. box-sizing: border-box;
  382. background: #f8fcfa;
  383. border: 1rpx solid rgba(220, 235, 228, 0.65);
  384. min-height: calc(1.5em * 3 + 32rpx);
  385. }
  386. .switch-label {
  387. width: auto;
  388. flex: 1;
  389. }
  390. .custom-switch {
  391. width: 96rpx;
  392. height: 52rpx;
  393. border-radius: 26rpx;
  394. background: #dcdfe6;
  395. position: relative;
  396. transition: all 0.2s ease;
  397. &.is-on {
  398. background: var(--c-brand-green);
  399. }
  400. }
  401. .custom-switch-thumb {
  402. width: 44rpx;
  403. height: 44rpx;
  404. border-radius: 50%;
  405. background: #fff;
  406. position: absolute;
  407. top: 4rpx;
  408. left: 4rpx;
  409. transition: all 0.2s ease;
  410. }
  411. .custom-switch.is-on .custom-switch-thumb {
  412. left: 48rpx;
  413. }
  414. .submit-btn {
  415. width: 100%;
  416. height: 88rpx;
  417. line-height: 88rpx;
  418. margin-top: 40rpx;
  419. background: linear-gradient(145deg, var(--c-barley-yellow), #e4b458);
  420. color: #ffffff;
  421. border-radius: 44rpx;
  422. font-size: 30rpx;
  423. border: none;
  424. font-weight: 700;
  425. }
  426. .submit-btn[disabled] {
  427. color: #ffffff !important;
  428. opacity: 0.92;
  429. }
  430. </style>