Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 

583 lignes
12 KiB

  1. <template>
  2. <view class="page">
  3. <scroll-view
  4. class="scroll"
  5. scroll-y
  6. refresher-enabled
  7. :refresher-triggered="refreshing"
  8. @refresherrefresh="refresh"
  9. @scrolltolower="loadMore"
  10. >
  11. <view class="wrap">
  12. <view class="studio-head">
  13. <view class="head-copy">
  14. <text class="eyebrow">ID PHOTO RECORDS</text>
  15. <text class="page-title">我的证件照</text>
  16. <text class="page-desc">制作记录会自动保存,可随时查看成品、下载照片或继续制作。</text>
  17. </view>
  18. <view class="create-btn" hover-class="button-pressed" @click="goCreate">制作</view>
  19. </view>
  20. <view class="stats-row">
  21. <view class="stat-card">
  22. <text class="stat-num">{{ total || list.length }}</text>
  23. <text class="stat-label">全部记录</text>
  24. </view>
  25. <view class="stat-card">
  26. <text class="stat-num">{{ pendingCount }}</text>
  27. <text class="stat-label">制作中</text>
  28. </view>
  29. <view class="stat-card">
  30. <text class="stat-num">{{ successCount }}</text>
  31. <text class="stat-label">已完成</text>
  32. </view>
  33. </view>
  34. <view v-if="list.length" class="gallery">
  35. <view v-for="item in list" :key="item.photo_open_id || item.id" class="work-card">
  36. <view class="poster" hover-class="card-pressed" @click="openItem(item)">
  37. <image v-if="item.cover" class="poster-img" :src="item.cover" mode="aspectFit" />
  38. <view v-else class="poster-empty">
  39. <up-icon name="photo" color="#8abff2" size="30" />
  40. </view>
  41. <view class="status-chip" :class="statusClass(item)">{{ item.status_text || statusText(item) }}</view>
  42. <view v-if="isPending(item)" class="progress-mask">
  43. <view class="spinner"></view>
  44. <text>正在制作</text>
  45. </view>
  46. <view v-else-if="Number(item.status) < 0" class="failed-mask">
  47. <text>{{ item.summary || '制作失败' }}</text>
  48. </view>
  49. </view>
  50. <view class="card-info">
  51. <text class="spec-name">{{ item.title || '证件照' }}</text>
  52. <text class="spec-size">{{ item.subtitle || '标准证件照' }}</text>
  53. <view class="time-line">
  54. <text>{{ formatTime(item.add_time) }}</text>
  55. <view class="delete-btn" hover-class="button-pressed" @click.stop="deleteItem(item)">删除</view>
  56. </view>
  57. </view>
  58. </view>
  59. </view>
  60. <view v-if="!list.length && !loading" class="empty-state">
  61. <view class="empty-preview">
  62. <view class="empty-photo">
  63. <view class="empty-head"></view>
  64. <view class="empty-body"></view>
  65. </view>
  66. </view>
  67. <text class="empty-title">还没有证件照</text>
  68. <text class="empty-desc">上传一张清晰正面照,制作符合规格的证件照。</text>
  69. <view class="empty-action" hover-class="button-pressed" @click="goCreate">去制作</view>
  70. </view>
  71. <view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
  72. <view v-if="list.length && list.length >= total && !loading" class="end-text">已经到底了</view>
  73. </view>
  74. </scroll-view>
  75. </view>
  76. </template>
  77. <script setup>
  78. import { computed, ref } from 'vue'
  79. import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
  80. import dayjs from 'dayjs'
  81. import { idPhotoApi } from '@/api/index.js'
  82. import { useApp } from '@/utils/useApp.js'
  83. const { toast } = useApp()
  84. const list = ref([])
  85. const page = ref(1)
  86. const pageSize = 10
  87. const total = ref(0)
  88. const loading = ref(false)
  89. const refreshing = ref(false)
  90. let fetching = false
  91. let firstShow = true
  92. let timer = 0
  93. const pendingCount = computed(() => list.value.filter((item) => isPending(item)).length)
  94. const successCount = computed(() => list.value.filter((item) => Number(item.status) === 1).length)
  95. onShow(() => {
  96. if (firstShow) {
  97. firstShow = false
  98. fetchList({ reset: true })
  99. return
  100. }
  101. refreshLoaded(true)
  102. })
  103. onHide(() => clearTimer())
  104. onUnload(() => clearTimer())
  105. function isPending(item) {
  106. return Number(item.status) === 0
  107. }
  108. function formatTime(value) {
  109. if (!value) return ''
  110. return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
  111. }
  112. async function fetchList(options = {}) {
  113. const {
  114. reset = false,
  115. silent = false,
  116. replace = reset,
  117. size = pageSize
  118. } = options
  119. let queryPage = options.pageNo || page.value
  120. if (fetching) {
  121. finishRefresh()
  122. return
  123. }
  124. fetching = true
  125. if (!silent) loading.value = true
  126. if (reset) {
  127. page.value = 1
  128. queryPage = 1
  129. }
  130. try {
  131. const res = await idPhotoApi.getList({ page_no: queryPage, page_size: size })
  132. const rows = res.list || []
  133. total.value = Number((res.data && res.data.total) || rows.length)
  134. list.value = replace ? rows : list.value.concat(rows)
  135. scheduleRefresh()
  136. } catch (e) {
  137. if (!silent) toast((e && e.msg) || '记录加载失败')
  138. } finally {
  139. fetching = false
  140. loading.value = false
  141. finishRefresh()
  142. }
  143. }
  144. function scheduleRefresh() {
  145. clearTimer()
  146. if (list.value.some((item) => isPending(item))) {
  147. timer = setTimeout(() => refreshLoaded(true), 3000)
  148. }
  149. }
  150. function clearTimer() {
  151. clearTimeout(timer)
  152. timer = 0
  153. }
  154. function finishRefresh() {
  155. refreshing.value = false
  156. uni.stopPullDownRefresh()
  157. }
  158. function refresh() {
  159. refreshing.value = true
  160. fetchList({ reset: true })
  161. }
  162. function refreshLoaded(silent = true) {
  163. const currentPage = Math.max(1, Number(page.value) || 1)
  164. fetchList({
  165. pageNo: 1,
  166. size: currentPage * pageSize,
  167. replace: true,
  168. silent
  169. })
  170. }
  171. function loadMore() {
  172. if (fetching || list.value.length >= total.value) return
  173. page.value += 1
  174. fetchList()
  175. }
  176. function openItem(item) {
  177. const id = item.photo_open_id || item.id
  178. if (!id) return
  179. uni.navigateTo({ url: `/pages/tool/id-photo-detail?id=${id}` })
  180. }
  181. function statusClass(item) {
  182. if (isPending(item)) return 'pending'
  183. if (Number(item.status) < 0) return 'failed'
  184. return 'done'
  185. }
  186. function statusText(item) {
  187. if (isPending(item)) return '制作中'
  188. if (Number(item.status) < 0) return '失败'
  189. return '完成'
  190. }
  191. function goCreate() {
  192. const delta = getPageStackDelta('pages/tool/id-photo')
  193. if (delta > 0) {
  194. uni.navigateBack({ delta })
  195. return
  196. }
  197. uni.navigateTo({ url: '/pages/tool/id-photo' })
  198. }
  199. function getPageStackDelta(routeName) {
  200. const pages = getCurrentPages()
  201. const index = pages.findIndex((item) => item.route === routeName)
  202. return index >= 0 ? pages.length - 1 - index : -1
  203. }
  204. function deleteItem(item) {
  205. const id = item.photo_open_id || item.id
  206. if (!id) return
  207. uni.showModal({
  208. title: '温馨提示',
  209. content: '是否确认删除该证件照记录?',
  210. confirmText: '确认删除',
  211. success: async (res) => {
  212. if (!res.confirm) return
  213. try {
  214. await idPhotoApi.delete({ photo_open_id: id })
  215. toast('删除成功')
  216. refreshLoaded(true)
  217. } catch (e) {
  218. toast((e && e.msg) || '删除失败')
  219. }
  220. }
  221. })
  222. }
  223. </script>
  224. <style lang="scss" scoped>
  225. .page,
  226. .scroll {
  227. height: 100vh;
  228. background: #f6f8fc;
  229. }
  230. .wrap {
  231. padding: 24rpx 28rpx calc(70rpx + env(safe-area-inset-bottom));
  232. }
  233. .studio-head {
  234. padding: 30rpx;
  235. border-radius: 26rpx;
  236. background: #ffffff;
  237. display: flex;
  238. align-items: center;
  239. gap: 24rpx;
  240. box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05);
  241. }
  242. .head-copy {
  243. flex: 1;
  244. min-width: 0;
  245. }
  246. .eyebrow {
  247. display: block;
  248. color: #1f8cff;
  249. font-size: 20rpx;
  250. font-weight: 800;
  251. }
  252. .page-title {
  253. display: block;
  254. margin-top: 10rpx;
  255. color: #172033;
  256. font-size: 40rpx;
  257. line-height: 1.2;
  258. font-weight: 900;
  259. }
  260. .page-desc {
  261. display: block;
  262. margin-top: 12rpx;
  263. color: #64748b;
  264. font-size: 24rpx;
  265. line-height: 1.5;
  266. }
  267. .create-btn {
  268. width: 112rpx;
  269. height: 68rpx;
  270. border-radius: 999rpx;
  271. color: #ffffff;
  272. background: #1f8cff;
  273. font-size: 26rpx;
  274. line-height: 68rpx;
  275. text-align: center;
  276. font-weight: 800;
  277. box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.22);
  278. flex-shrink: 0;
  279. }
  280. .stats-row {
  281. margin-top: 18rpx;
  282. display: grid;
  283. grid-template-columns: repeat(3, minmax(0, 1fr));
  284. gap: 14rpx;
  285. }
  286. .stat-card {
  287. padding: 20rpx 8rpx;
  288. border-radius: 20rpx;
  289. background: #ffffff;
  290. text-align: center;
  291. box-shadow: 0 8rpx 20rpx rgba(31, 52, 86, 0.04);
  292. }
  293. .stat-num,
  294. .stat-label,
  295. .spec-name,
  296. .spec-size,
  297. .empty-title,
  298. .empty-desc {
  299. display: block;
  300. }
  301. .stat-num {
  302. color: #172033;
  303. font-size: 34rpx;
  304. line-height: 1;
  305. font-weight: 900;
  306. }
  307. .stat-label {
  308. margin-top: 10rpx;
  309. color: #7c8a9b;
  310. font-size: 22rpx;
  311. }
  312. .gallery {
  313. margin-top: 24rpx;
  314. display: grid;
  315. grid-template-columns: repeat(2, minmax(0, 1fr));
  316. gap: 22rpx;
  317. }
  318. .work-card {
  319. min-width: 0;
  320. border-radius: 24rpx;
  321. background: #ffffff;
  322. overflow: hidden;
  323. box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.06);
  324. }
  325. .poster {
  326. position: relative;
  327. width: 100%;
  328. height: 440rpx;
  329. background: repeating-linear-gradient(135deg, #edf3f9 0, #edf3f9 18rpx, #f8fbfd 18rpx, #f8fbfd 36rpx);
  330. overflow: hidden;
  331. }
  332. .poster-img,
  333. .poster-empty {
  334. width: 100%;
  335. height: 100%;
  336. }
  337. .poster-empty {
  338. display: flex;
  339. align-items: center;
  340. justify-content: center;
  341. }
  342. .status-chip {
  343. position: absolute;
  344. left: 14rpx;
  345. top: 14rpx;
  346. height: 42rpx;
  347. padding: 0 16rpx;
  348. border-radius: 999rpx;
  349. color: #ffffff;
  350. font-size: 22rpx;
  351. line-height: 42rpx;
  352. font-weight: 800;
  353. background: rgba(15, 23, 42, 0.48);
  354. }
  355. .status-chip.done { background: rgba(31, 140, 255, 0.92); }
  356. .status-chip.pending { background: rgba(249, 115, 22, 0.92); }
  357. .status-chip.failed { background: rgba(239, 68, 68, 0.92); }
  358. .progress-mask,
  359. .failed-mask {
  360. position: absolute;
  361. inset: 0;
  362. padding: 24rpx;
  363. display: flex;
  364. flex-direction: column;
  365. align-items: center;
  366. justify-content: center;
  367. color: #ffffff;
  368. font-size: 26rpx;
  369. text-align: center;
  370. background: rgba(15, 23, 42, 0.52);
  371. }
  372. .failed-mask {
  373. background: rgba(127, 29, 29, 0.72);
  374. font-size: 24rpx;
  375. }
  376. .spinner {
  377. width: 48rpx;
  378. height: 48rpx;
  379. margin-bottom: 18rpx;
  380. border: 6rpx solid rgba(255, 255, 255, 0.45);
  381. border-top-color: #ffffff;
  382. border-radius: 50%;
  383. animation: spin 0.8s linear infinite;
  384. }
  385. .card-info {
  386. padding: 18rpx;
  387. }
  388. .spec-name {
  389. color: #172033;
  390. font-size: 26rpx;
  391. font-weight: 800;
  392. line-height: 1.3;
  393. }
  394. .spec-size {
  395. margin-top: 6rpx;
  396. color: #8994a5;
  397. font-size: 21rpx;
  398. line-height: 1.3;
  399. }
  400. .time-line {
  401. margin-top: 12rpx;
  402. display: flex;
  403. align-items: center;
  404. gap: 10rpx;
  405. color: #64748b;
  406. font-size: 21rpx;
  407. }
  408. .time-line text {
  409. flex: 1;
  410. min-width: 0;
  411. }
  412. .delete-btn {
  413. width: 74rpx;
  414. height: 44rpx;
  415. border-radius: 999rpx;
  416. color: #ef4444;
  417. background: #fff1f2;
  418. font-size: 22rpx;
  419. line-height: 44rpx;
  420. text-align: center;
  421. flex-shrink: 0;
  422. }
  423. .empty-state {
  424. margin-top: 86rpx;
  425. padding: 56rpx 34rpx;
  426. border-radius: 28rpx;
  427. background: #ffffff;
  428. display: flex;
  429. flex-direction: column;
  430. align-items: center;
  431. text-align: center;
  432. box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05);
  433. }
  434. .empty-preview {
  435. width: 150rpx;
  436. height: 200rpx;
  437. border-radius: 30rpx;
  438. background: repeating-linear-gradient(135deg, #edf5fd 0, #edf5fd 14rpx, #f8fbff 14rpx, #f8fbff 28rpx);
  439. display: flex;
  440. align-items: center;
  441. justify-content: center;
  442. }
  443. .empty-photo {
  444. position: relative;
  445. width: 88rpx;
  446. height: 122rpx;
  447. border-radius: 8rpx;
  448. background: #438edb;
  449. overflow: hidden;
  450. }
  451. .empty-head {
  452. position: absolute;
  453. left: 50%;
  454. top: 24rpx;
  455. width: 34rpx;
  456. height: 34rpx;
  457. margin-left: -17rpx;
  458. border-radius: 50%;
  459. background: #ffffff;
  460. }
  461. .empty-body {
  462. position: absolute;
  463. left: 50%;
  464. bottom: -18rpx;
  465. width: 78rpx;
  466. height: 72rpx;
  467. margin-left: -39rpx;
  468. border-radius: 50% 50% 0 0;
  469. background: #ffffff;
  470. }
  471. .empty-title {
  472. margin-top: 28rpx;
  473. color: #172033;
  474. font-size: 32rpx;
  475. font-weight: 900;
  476. }
  477. .empty-desc {
  478. margin-top: 12rpx;
  479. color: #64748b;
  480. font-size: 25rpx;
  481. line-height: 1.5;
  482. }
  483. .empty-action {
  484. margin-top: 28rpx;
  485. width: 220rpx;
  486. height: 72rpx;
  487. border-radius: 999rpx;
  488. color: #ffffff;
  489. background: #1f8cff;
  490. font-size: 28rpx;
  491. line-height: 72rpx;
  492. font-weight: 800;
  493. }
  494. .loading,
  495. .end-text {
  496. padding: 46rpx 0 20rpx;
  497. color: #7c8a9b;
  498. font-size: 24rpx;
  499. text-align: center;
  500. }
  501. .button-pressed,
  502. .card-pressed {
  503. opacity: 0.82;
  504. }
  505. @keyframes spin {
  506. to { transform: rotate(360deg); }
  507. }
  508. @media (min-width: 768px) {
  509. .wrap {
  510. width: 720px;
  511. margin: 0 auto;
  512. box-sizing: border-box;
  513. }
  514. }
  515. </style>