address.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. <template>
  2. <view class="page client-theme client-subpage">
  3. <view v-if="loading" class="state">
  4. <ClientSkeleton variant="list" :rows="4" />
  5. </view>
  6. <template v-else>
  7. <view class="client-subpage-hero">
  8. <text class="client-subpage-hero__title">收货地址</text>
  9. <text class="client-subpage-hero__sub">管理配送地址,保障送货上门</text>
  10. </view>
  11. <view v-if="list.length === 0" class="empty-wrap">
  12. <image class="empty-image" :src="IMG_EMPTY" mode="aspectFit" />
  13. <text class="empty-text">暂无收货地址</text>
  14. <text class="empty-hint">添加后便于配送员联系与送货</text>
  15. <button class="add-btn" type="default" @click="goAdd">新增收货地址</button>
  16. </view>
  17. <view v-else class="list-shell">
  18. <!-- #ifdef H5 -->
  19. <view class="list-scroll list-scroll--h5" @scroll.passive="onH5ListScroll">
  20. <view class="list-inner">
  21. <view v-for="item in list" :key="item.id" class="card" hover-class="card--pressed" :hover-stay-time="100">
  22. <view class="card-main" @click="goEdit(item)">
  23. <view class="card-top">
  24. <text class="name">{{ item.contactPerson }}</text>
  25. <PhoneDialInline
  26. class="phone-wrap"
  27. :phone="item.contactPhone"
  28. color="#0a9f6d"
  29. hide-icon
  30. />
  31. <view v-if="item.isDefault" class="tag-default">默认</view>
  32. </view>
  33. <text class="addr-line">{{ fullRegion(item) }}{{ item.detailAddress }}</text>
  34. </view>
  35. <view class="card-actions">
  36. <text
  37. v-if="!item.isDefault"
  38. class="act"
  39. @click.stop="handleSetDefault(item.id)"
  40. >设为默认</text>
  41. <text class="act" @click.stop="goEdit(item)">编辑</text>
  42. <text class="act danger" @click.stop="handleDelete(item)">删除</text>
  43. </view>
  44. </view>
  45. <view v-if="loadingMore" class="list-footer-tip">加载中...</view>
  46. <view v-else-if="!hasMore && list.length > 0" class="list-footer-tip muted">没有更多了</view>
  47. </view>
  48. </view>
  49. <!-- #endif -->
  50. <!-- #ifndef H5 -->
  51. <scroll-view
  52. scroll-y
  53. class="list-scroll"
  54. :show-scrollbar="true"
  55. :enable-flex="true"
  56. :lower-threshold="100"
  57. @scrolltolower="onScrollToLower"
  58. >
  59. <view class="list-inner">
  60. <view v-for="item in list" :key="item.id" class="card" hover-class="card--pressed" :hover-stay-time="100">
  61. <view class="card-main" @click="goEdit(item)">
  62. <view class="card-top">
  63. <text class="name">{{ item.contactPerson }}</text>
  64. <PhoneDialInline
  65. class="phone-wrap"
  66. :phone="item.contactPhone"
  67. color="#0a9f6d"
  68. hide-icon
  69. />
  70. <view v-if="item.isDefault" class="tag-default">默认</view>
  71. </view>
  72. <text class="addr-line">{{ fullRegion(item) }}{{ item.detailAddress }}</text>
  73. </view>
  74. <view class="card-actions">
  75. <text
  76. v-if="!item.isDefault"
  77. class="act"
  78. @click.stop="handleSetDefault(item.id)"
  79. >设为默认</text>
  80. <text class="act" @click.stop="goEdit(item)">编辑</text>
  81. <text class="act danger" @click.stop="handleDelete(item)">删除</text>
  82. </view>
  83. </view>
  84. <view v-if="loadingMore" class="list-footer-tip">加载中...</view>
  85. <view v-else-if="!hasMore && list.length > 0" class="list-footer-tip muted">没有更多了</view>
  86. </view>
  87. </scroll-view>
  88. <!-- #endif -->
  89. <view class="footer-bar">
  90. <button class="add-btn-bottom" type="default" @click="goAdd">新增收货地址</button>
  91. </view>
  92. </view>
  93. </template>
  94. <view v-if="deleteDialogVisible" class="delete-dialog-mask" @click="closeDeleteDialog">
  95. <view class="delete-dialog" @click.stop>
  96. <view class="delete-dialog__icon">!</view>
  97. <text class="delete-dialog__title">删除收货地址</text>
  98. <text class="delete-dialog__desc">删除后将无法恢复,确定继续吗?</text>
  99. <view class="delete-dialog__actions">
  100. <view class="delete-dialog__action delete-dialog__action--cancel" @click="closeDeleteDialog">取消</view>
  101. <button
  102. class="delete-dialog__action delete-dialog__action--danger"
  103. :disabled="deleting"
  104. :loading="deleting"
  105. @click="confirmDelete"
  106. >
  107. 删除
  108. </button>
  109. </view>
  110. </view>
  111. </view>
  112. </view>
  113. </template>
  114. <script setup>
  115. import { onShow } from '@dcloudio/uni-app'
  116. import { ref } from 'vue'
  117. import PhoneDialInline from '@/components/phone-dial-inline.vue'
  118. import ClientSkeleton from '@/components/client/client-skeleton.vue'
  119. import { useUserStore } from '@/stores/user'
  120. import { request, parseResponseJson } from '@/utils/request'
  121. import { IMG_EMPTY } from '@/utils/staticAsset'
  122. const userStore = useUserStore()
  123. const list = ref([])
  124. const loading = ref(false)
  125. const loadingMore = ref(false)
  126. const hasMore = ref(true)
  127. const nextPage = ref(1)
  128. const deleteDialogVisible = ref(false)
  129. const deleting = ref(false)
  130. const deletingItem = ref(null)
  131. const PAGE_SIZE = 20
  132. function prioritizeDefaultAddress(items = []) {
  133. if (!Array.isArray(items) || items.length <= 1) return items
  134. const sorted = [...items]
  135. sorted.sort((a, b) => Number(!!b?.isDefault) - Number(!!a?.isDefault))
  136. return sorted
  137. }
  138. /** reset=true:从第一页重新拉(onShow、删改后);false:滚动加载下一页 */
  139. async function fetchList(reset = true) {
  140. if (reset) {
  141. loading.value = true
  142. list.value = []
  143. nextPage.value = 1
  144. hasMore.value = true
  145. } else {
  146. if (!hasMore.value || loadingMore.value || loading.value) return
  147. loadingMore.value = true
  148. }
  149. const page = reset ? 1 : nextPage.value
  150. try {
  151. const res = await request({
  152. url: '/mini/client/addresses',
  153. method: 'GET',
  154. data: { page, pageSize: PAGE_SIZE }
  155. })
  156. const body = parseResponseJson(res)
  157. if (body.code === 200 && body.data) {
  158. const d = body.data
  159. const items = Array.isArray(d.list) ? d.list : []
  160. const totalPages = Number(d.totalPages ?? 0)
  161. if (reset) {
  162. list.value = prioritizeDefaultAddress(items)
  163. } else {
  164. const merged = [...list.value]
  165. const seen = new Set(merged.map((x) => x.id))
  166. for (const it of prioritizeDefaultAddress(items)) {
  167. if (it.id && !seen.has(it.id)) {
  168. merged.push(it)
  169. seen.add(it.id)
  170. }
  171. }
  172. list.value = prioritizeDefaultAddress(merged)
  173. }
  174. hasMore.value = totalPages > 0 ? page < totalPages : items.length >= PAGE_SIZE
  175. nextPage.value = page + 1
  176. } else if (reset) {
  177. list.value = []
  178. hasMore.value = false
  179. }
  180. } finally {
  181. loading.value = false
  182. loadingMore.value = false
  183. }
  184. }
  185. function onScrollToLower() {
  186. void fetchList(false)
  187. }
  188. /** H5:用原生滚动替代 scroll-view,避免路由返回时 uni useScrollViewLoader 访问已销毁节点报错 */
  189. function onH5ListScroll(e) {
  190. const el = e && e.target
  191. if (!el || typeof el.scrollTop !== 'number') return
  192. const { scrollTop, scrollHeight, clientHeight } = el
  193. if (scrollHeight - scrollTop - clientHeight < 100) {
  194. void fetchList(false)
  195. }
  196. }
  197. function fullRegion(addr) {
  198. const p = addr.province || ''
  199. const c = addr.city || ''
  200. const d = addr.district || ''
  201. return `${p}${c}${d}`
  202. }
  203. function goAdd() {
  204. uni.navigateTo({ url: '/pages/client/address/edit' })
  205. }
  206. function goEdit(item) {
  207. uni.navigateTo({ url: `/pages/client/address/edit?id=${encodeURIComponent(item.id)}` })
  208. }
  209. async function handleSetDefault(id) {
  210. try {
  211. const res = await request({
  212. url: `/mini/client/addresses/${id}/set-default`,
  213. method: 'POST'
  214. })
  215. const body = parseResponseJson(res)
  216. if (body.code === 200) {
  217. uni.showToast({ title: '已设为默认', icon: 'success' })
  218. await fetchList(true)
  219. } else {
  220. uni.showToast({ title: body.message || '操作失败', icon: 'none' })
  221. }
  222. } catch {
  223. uni.showToast({ title: '网络异常', icon: 'none' })
  224. }
  225. }
  226. function handleDelete(item) {
  227. deletingItem.value = item
  228. deleteDialogVisible.value = true
  229. }
  230. function closeDeleteDialog(force = false) {
  231. if (deleting.value && !force) return
  232. deleteDialogVisible.value = false
  233. deletingItem.value = null
  234. }
  235. async function confirmDelete() {
  236. const item = deletingItem.value
  237. if (!item?.id || deleting.value) return
  238. deleting.value = true
  239. try {
  240. const req = await request({
  241. url: `/mini/client/addresses/${item.id}`,
  242. method: 'DELETE'
  243. })
  244. const body = parseResponseJson(req)
  245. if (body.code === 200) {
  246. uni.showToast({ title: '已删除', icon: 'success' })
  247. closeDeleteDialog(true)
  248. await fetchList(true)
  249. } else {
  250. uni.showToast({ title: body.message || '删除失败', icon: 'none' })
  251. }
  252. } catch {
  253. uni.showToast({ title: '网络异常', icon: 'none' })
  254. } finally {
  255. deleting.value = false
  256. }
  257. }
  258. onShow(() => {
  259. userStore.checkLoginStatus()
  260. if (!userStore.isLoggedIn) {
  261. uni.reLaunch({ url: '/pages/login/login' })
  262. return
  263. }
  264. if (!userStore.isClient) {
  265. uni.showToast({ title: '仅客户可访问', icon: 'none' })
  266. setTimeout(() => userStore.navigateToHome(), 1200)
  267. return
  268. }
  269. void fetchList(true)
  270. })
  271. </script>
  272. <style lang="scss">
  273. page {
  274. height: 100%;
  275. overflow: hidden;
  276. background-color: #f5f8f7;
  277. }
  278. </style>
  279. <style lang="scss" scoped>
  280. .page {
  281. height: 100%;
  282. display: flex;
  283. flex-direction: column;
  284. background: var(--c-bg);
  285. box-sizing: border-box;
  286. overflow: hidden;
  287. }
  288. .card--pressed {
  289. opacity: 0.94;
  290. }
  291. .state {
  292. flex: 1;
  293. min-height: 0;
  294. padding: 0;
  295. }
  296. .empty-wrap {
  297. flex: 1;
  298. display: flex;
  299. flex-direction: column;
  300. align-items: center;
  301. padding: 88rpx 40rpx 40rpx;
  302. box-sizing: border-box;
  303. overflow-y: auto;
  304. .empty-image {
  305. width: 160rpx;
  306. height: 160rpx;
  307. margin-bottom: 12rpx;
  308. }
  309. .empty-text {
  310. font-size: 30rpx;
  311. color: var(--c-text);
  312. margin-bottom: 8rpx;
  313. text-align: center;
  314. }
  315. .empty-hint {
  316. font-size: 24rpx;
  317. color: var(--c-muted);
  318. margin-bottom: 28rpx;
  319. text-align: center;
  320. }
  321. }
  322. .add-btn,
  323. .add-btn-bottom {
  324. width: 100%;
  325. max-width: 600rpx;
  326. height: 88rpx;
  327. line-height: 88rpx;
  328. background: linear-gradient(145deg, #14b86f, #0a9f6d);
  329. color: #ffffff;
  330. border-radius: 44rpx;
  331. font-size: 30rpx;
  332. border: none;
  333. font-weight: 700;
  334. }
  335. .add-btn-bottom {
  336. max-width: none;
  337. margin: 0;
  338. }
  339. .list-shell {
  340. flex: 1;
  341. min-height: 0;
  342. display: flex;
  343. flex-direction: column;
  344. overflow: hidden;
  345. }
  346. .list-scroll {
  347. flex: 1;
  348. flex-basis: 0;
  349. min-height: 0;
  350. height: 0;
  351. width: 100%;
  352. box-sizing: border-box;
  353. }
  354. /* #ifdef H5 */
  355. .list-scroll--h5 {
  356. overflow-x: hidden;
  357. overflow-y: auto;
  358. -webkit-overflow-scrolling: touch;
  359. }
  360. /* #endif */
  361. .list-inner {
  362. padding: 24rpx 32rpx 16rpx;
  363. padding-bottom: calc(24rpx + 180rpx + env(safe-area-inset-bottom, 0px));
  364. box-sizing: border-box;
  365. }
  366. .list-footer-tip {
  367. text-align: center;
  368. font-size: 24rpx;
  369. color: #9ba8a2;
  370. padding: 24rpx 12rpx 32rpx;
  371. &.muted {
  372. color: #c7d6cf;
  373. }
  374. }
  375. .footer-bar {
  376. position: fixed;
  377. left: 0;
  378. right: 0;
  379. bottom: 0;
  380. z-index: 20;
  381. flex-shrink: 0;
  382. padding: 16rpx 32rpx;
  383. padding-bottom: calc(16rpx + constant(safe-area-inset-bottom));
  384. padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
  385. background: var(--c-bg);
  386. box-shadow: none;
  387. border-top: 1rpx solid rgba(220, 235, 228, 0.65);
  388. box-sizing: border-box;
  389. }
  390. .card {
  391. background: var(--c-card);
  392. border-radius: var(--c-radius-card);
  393. margin-bottom: 24rpx;
  394. overflow: hidden;
  395. box-shadow: none;
  396. border: 1rpx solid var(--c-border);
  397. }
  398. .card-main {
  399. padding: 28rpx 24rpx 20rpx;
  400. }
  401. .card-top {
  402. display: flex;
  403. align-items: center;
  404. flex-wrap: wrap;
  405. gap: 16rpx;
  406. margin-bottom: 12rpx;
  407. .name {
  408. font-size: 30rpx;
  409. font-weight: 700;
  410. color: var(--c-text);
  411. }
  412. .phone-wrap :deep(.phone-dial-inline__text) {
  413. font-size: 28rpx;
  414. color: var(--c-muted);
  415. }
  416. .tag-default {
  417. font-size: 22rpx;
  418. color: var(--c-brand-green);
  419. background: var(--c-brand-green-soft);
  420. padding: 0 16rpx;
  421. min-height: 44rpx;
  422. border-radius: 999rpx;
  423. display: inline-flex;
  424. align-items: center;
  425. }
  426. }
  427. .addr-line {
  428. font-size: 26rpx;
  429. color: var(--c-muted);
  430. line-height: 1.5;
  431. }
  432. .card-actions {
  433. display: flex;
  434. justify-content: flex-end;
  435. align-items: center;
  436. gap: 32rpx;
  437. padding: 16rpx 24rpx;
  438. border-top: 1rpx solid rgba(220, 235, 228, 0.65);
  439. background: #ffffff;
  440. .act {
  441. font-size: 26rpx;
  442. color: var(--c-brand-green);
  443. }
  444. .danger {
  445. color: #ee0a24;
  446. }
  447. }
  448. .delete-dialog-mask {
  449. position: fixed;
  450. inset: 0;
  451. z-index: 120;
  452. background: rgba(18, 28, 24, 0.45);
  453. display: flex;
  454. align-items: center;
  455. justify-content: center;
  456. padding: 40rpx;
  457. box-sizing: border-box;
  458. }
  459. .delete-dialog {
  460. width: 100%;
  461. max-width: 620rpx;
  462. background: #ffffff;
  463. border-radius: 26rpx;
  464. padding: 40rpx 34rpx 32rpx;
  465. box-sizing: border-box;
  466. box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.15);
  467. }
  468. .delete-dialog__icon {
  469. width: 76rpx;
  470. height: 76rpx;
  471. margin: 0 auto 20rpx;
  472. border-radius: 50%;
  473. background: #fff1f0;
  474. color: #ee0a24;
  475. font-size: 42rpx;
  476. font-weight: 700;
  477. display: flex;
  478. align-items: center;
  479. justify-content: center;
  480. }
  481. .delete-dialog__title {
  482. display: block;
  483. text-align: center;
  484. color: var(--c-text);
  485. font-size: 34rpx;
  486. font-weight: 700;
  487. }
  488. .delete-dialog__desc {
  489. display: block;
  490. margin-top: 14rpx;
  491. text-align: center;
  492. color: var(--c-muted);
  493. font-size: 26rpx;
  494. line-height: 1.6;
  495. }
  496. .delete-dialog__actions {
  497. margin-top: 28rpx;
  498. display: grid;
  499. grid-template-columns: repeat(2, 1fr);
  500. column-gap: 16rpx;
  501. }
  502. .delete-dialog__action {
  503. width: 100%;
  504. min-height: 76rpx;
  505. border-radius: 999rpx;
  506. display: flex;
  507. align-items: center;
  508. justify-content: center;
  509. font-size: 28rpx;
  510. font-weight: 600;
  511. border: none;
  512. box-sizing: border-box;
  513. }
  514. .delete-dialog__action--cancel {
  515. background: #f4f7f6;
  516. color: #5b6b64;
  517. border: 1rpx solid #ddebe5;
  518. }
  519. .delete-dialog__action--danger {
  520. background: linear-gradient(145deg, #ff6565, #ee0a24);
  521. color: #ffffff;
  522. }
  523. .delete-dialog__action--danger[disabled] {
  524. color: #ffffff !important;
  525. opacity: 0.92;
  526. }
  527. </style>