Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 

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