Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 

446 linhas
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>
  14. <text class="eyebrow">PORTRAIT GALLERY</text>
  15. <text class="page-title">我的形象照</text>
  16. <text class="page-desc">每次生成的照片都会保存,完成后可查看大图、保存原图或继续创作。</text>
  17. </view>
  18. <view class="create-btn" @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">{{ photoCount }}</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" class="work-card">
  36. <view class="poster" @click="openItem(item)">
  37. <image v-if="item.cover" class="poster-img" :src="item.cover" mode="aspectFill" />
  38. <view v-else class="poster-empty"></view>
  39. <view class="status-chip" :class="statusClass(item)">{{ item.status_text }}</view>
  40. <view v-if="item.success_count > 1" class="count-chip">{{ item.success_count }}张</view>
  41. <view v-if="isPending(item)" class="progress-mask">
  42. <view class="spinner"></view>
  43. <text>{{ item.success_count || 0 }}/{{ item.count || 0 }} 张</text>
  44. </view>
  45. <view v-else-if="Number(item.status) < 0" class="failed-mask">
  46. <text>{{ item.error_msg || '生成失败' }}</text>
  47. </view>
  48. </view>
  49. <view class="card-info">
  50. <text class="style-name">{{ item.style_name || '形象照' }}</text>
  51. <view class="time-line">
  52. <text>{{ formatTime(item.add_time) }}</text>
  53. <view class="delete-btn" @click.stop="deleteItem(item)">删除</view>
  54. </view>
  55. </view>
  56. </view>
  57. </view>
  58. <view v-if="!list.length && !loading" class="empty-state">
  59. <view class="empty-preview">
  60. <view class="empty-photo"></view>
  61. </view>
  62. <text class="empty-title">还没有形象照</text>
  63. <text class="empty-desc">上传一张人像照片,试试商务形象照或艺术写真。</text>
  64. <view class="empty-action" @click="goCreate">去生成</view>
  65. </view>
  66. <view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
  67. <view v-if="list.length && list.length >= total && !loading" class="end-text">已经到底了</view>
  68. </view>
  69. </scroll-view>
  70. </view>
  71. </template>
  72. <script setup>
  73. import { computed, ref } from 'vue'
  74. import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
  75. import dayjs from 'dayjs'
  76. import { portraitApi } from '@/api/index.js'
  77. import { useApp } from '@/utils/useApp.js'
  78. const { toast } = useApp()
  79. const list = ref([])
  80. const page = ref(1)
  81. const pageSize = 10
  82. const total = ref(0)
  83. const loading = ref(false)
  84. const refreshing = ref(false)
  85. let fetching = false
  86. let firstShow = true
  87. let timer = 0
  88. const pendingCount = computed(() => list.value.filter((item) => isPending(item)).length)
  89. const photoCount = computed(() =>
  90. list.value.reduce((sum, item) => sum + Number(item.success_count || 0), 0)
  91. )
  92. onLoad(() => fetchList({ reset: true }))
  93. onShow(() => {
  94. if (firstShow) {
  95. firstShow = false
  96. return
  97. }
  98. refreshLoaded(true)
  99. })
  100. onUnload(() => clearTimeout(timer))
  101. function isPending(item) {
  102. return [10, 20].includes(Number(item.status))
  103. }
  104. function formatTime(value) {
  105. if (!value) return ''
  106. return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
  107. }
  108. async function fetchList(options = {}) {
  109. const {
  110. reset = false,
  111. silent = false,
  112. replace = reset,
  113. size = pageSize
  114. } = options
  115. let queryPage = options.pageNo || page.value
  116. if (fetching) {
  117. finishRefresh()
  118. return
  119. }
  120. fetching = true
  121. if (!silent) loading.value = true
  122. if (reset) {
  123. page.value = 1
  124. queryPage = 1
  125. }
  126. try {
  127. const res = await portraitApi.getList({ page_no: queryPage, page_size: size })
  128. const rows = res.list || []
  129. total.value = Number((res.data && res.data.total) || rows.length)
  130. list.value = replace ? rows : list.value.concat(rows)
  131. scheduleRefresh()
  132. } catch (e) {
  133. if (!silent) toast((e && e.msg) || '记录加载失败')
  134. } finally {
  135. fetching = false
  136. loading.value = false
  137. finishRefresh()
  138. }
  139. }
  140. // 有进行中的任务就轮询:后端 getlist 会顺带推进任务状态
  141. function scheduleRefresh() {
  142. clearTimeout(timer)
  143. if (list.value.some((item) => isPending(item))) {
  144. timer = setTimeout(() => refreshLoaded(true), 3000)
  145. }
  146. }
  147. function finishRefresh() {
  148. refreshing.value = false
  149. uni.stopPullDownRefresh()
  150. }
  151. function refresh() {
  152. refreshing.value = true
  153. fetchList({ reset: true })
  154. }
  155. function refreshLoaded(silent = true) {
  156. const currentPage = Math.max(1, Number(page.value) || 1)
  157. fetchList({
  158. pageNo: 1,
  159. size: currentPage * pageSize,
  160. replace: true,
  161. silent
  162. })
  163. }
  164. function loadMore() {
  165. if (fetching) return
  166. if (list.value.length >= total.value) return
  167. page.value += 1
  168. fetchList()
  169. }
  170. function openItem(item) {
  171. if (Number(item.status) < 0) return toast(item.error_msg || '该记录生成失败')
  172. uni.navigateTo({ url: `/pages/tool/portrait-detail?id=${item.photo_open_id}` })
  173. }
  174. function statusClass(item) {
  175. if (isPending(item)) return 'pending'
  176. if (Number(item.status) < 0) return 'failed'
  177. return 'done'
  178. }
  179. function goCreate() {
  180. const delta = getPageStackDelta('pages/tool/portrait-photo')
  181. if (delta > 0) {
  182. uni.navigateBack({ delta })
  183. return
  184. }
  185. uni.navigateTo({ url: '/pages/tool/portrait-photo' })
  186. }
  187. function getPageStackDelta(routeName) {
  188. const pages = getCurrentPages()
  189. const index = pages.findIndex((item) => item.route === routeName)
  190. return index >= 0 ? pages.length - 1 - index : -1
  191. }
  192. function deleteItem(item) {
  193. uni.showModal({
  194. title: '温馨提示',
  195. content: '是否确认删除该记录?',
  196. confirmText: '确认删除',
  197. success: async (res) => {
  198. if (!res.confirm) return
  199. try {
  200. await portraitApi.delete({ photo_open_id: item.photo_open_id })
  201. toast('删除成功')
  202. refreshLoaded(true)
  203. } catch (e) {
  204. toast((e && e.msg) || '删除失败')
  205. }
  206. }
  207. })
  208. }
  209. </script>
  210. <style lang="scss" scoped>
  211. .page,
  212. .scroll {
  213. height: 100vh;
  214. background: #f6f8fc;
  215. }
  216. .wrap { padding: 24rpx 28rpx 70rpx; }
  217. .studio-head {
  218. padding: 30rpx;
  219. border-radius: 26rpx;
  220. background: #ffffff;
  221. display: flex;
  222. align-items: center;
  223. gap: 24rpx;
  224. box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05);
  225. }
  226. .studio-head > view:first-child { flex: 1; min-width: 0; }
  227. .eyebrow { display: block; color: #1f8cff; font-size: 20rpx; font-weight: 800; }
  228. .page-title {
  229. display: block;
  230. margin-top: 10rpx;
  231. color: #172033;
  232. font-size: 40rpx;
  233. line-height: 1.2;
  234. font-weight: 900;
  235. }
  236. .page-desc { display: block; margin-top: 12rpx; color: #64748b; font-size: 24rpx; line-height: 1.5; }
  237. .create-btn {
  238. width: 112rpx;
  239. height: 68rpx;
  240. border-radius: 999rpx;
  241. color: #ffffff;
  242. background: #1f8cff;
  243. font-size: 26rpx;
  244. line-height: 68rpx;
  245. text-align: center;
  246. font-weight: 800;
  247. box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.22);
  248. flex-shrink: 0;
  249. }
  250. .stats-row {
  251. margin-top: 18rpx;
  252. display: grid;
  253. grid-template-columns: repeat(3, 1fr);
  254. gap: 14rpx;
  255. }
  256. .stat-card {
  257. padding: 20rpx 10rpx;
  258. border-radius: 20rpx;
  259. background: #ffffff;
  260. text-align: center;
  261. box-shadow: 0 8rpx 20rpx rgba(31, 52, 86, 0.04);
  262. }
  263. .stat-num { display: block; color: #172033; font-size: 34rpx; line-height: 1; font-weight: 900; }
  264. .stat-label { display: block; margin-top: 10rpx; color: #7c8a9b; font-size: 22rpx; }
  265. .gallery {
  266. margin-top: 24rpx;
  267. display: grid;
  268. grid-template-columns: repeat(2, minmax(0, 1fr));
  269. gap: 22rpx;
  270. }
  271. .work-card {
  272. border-radius: 24rpx;
  273. background: #ffffff;
  274. overflow: hidden;
  275. box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.06);
  276. }
  277. .poster {
  278. position: relative;
  279. width: 100%;
  280. height: 440rpx;
  281. background: #dfe7f2;
  282. overflow: hidden;
  283. }
  284. .poster-img,
  285. .poster-empty { width: 100%; height: 100%; }
  286. .poster-empty { background: linear-gradient(135deg, #eef4fb, #dbe6f4); }
  287. .status-chip {
  288. position: absolute;
  289. left: 14rpx;
  290. top: 14rpx;
  291. height: 42rpx;
  292. padding: 0 16rpx;
  293. border-radius: 999rpx;
  294. color: #ffffff;
  295. font-size: 22rpx;
  296. line-height: 42rpx;
  297. font-weight: 800;
  298. background: rgba(15, 23, 42, 0.48);
  299. }
  300. .status-chip.done { background: rgba(31, 140, 255, 0.9); }
  301. .status-chip.pending { background: rgba(249, 115, 22, 0.92); }
  302. .status-chip.failed { background: rgba(239, 68, 68, 0.9); }
  303. .count-chip {
  304. position: absolute;
  305. right: 14rpx;
  306. top: 14rpx;
  307. height: 42rpx;
  308. padding: 0 14rpx;
  309. border-radius: 999rpx;
  310. color: #ffffff;
  311. font-size: 21rpx;
  312. line-height: 42rpx;
  313. font-weight: 800;
  314. background: rgba(15, 23, 42, 0.48);
  315. }
  316. .progress-mask,
  317. .failed-mask {
  318. position: absolute;
  319. left: 0;
  320. right: 0;
  321. top: 0;
  322. bottom: 0;
  323. padding: 24rpx;
  324. display: flex;
  325. flex-direction: column;
  326. align-items: center;
  327. justify-content: center;
  328. color: #ffffff;
  329. font-size: 26rpx;
  330. text-align: center;
  331. background: rgba(15, 23, 42, 0.52);
  332. }
  333. .failed-mask { background: rgba(127, 29, 29, 0.72); font-size: 24rpx; }
  334. .spinner {
  335. width: 48rpx;
  336. height: 48rpx;
  337. margin-bottom: 18rpx;
  338. border: 6rpx solid rgba(255, 255, 255, 0.45);
  339. border-top-color: #ffffff;
  340. border-radius: 50%;
  341. animation: spin 0.8s linear infinite;
  342. }
  343. .card-info { padding: 18rpx; }
  344. .style-name {
  345. display: block;
  346. margin-bottom: 10rpx;
  347. color: #172033;
  348. font-size: 25rpx;
  349. font-weight: 800;
  350. }
  351. .time-line { display: flex; align-items: center; gap: 12rpx; color: #64748b; font-size: 22rpx; }
  352. .time-line text { flex: 1; min-width: 0; }
  353. .delete-btn {
  354. width: 74rpx;
  355. height: 44rpx;
  356. border-radius: 999rpx;
  357. color: #ef4444;
  358. background: #fff1f2;
  359. font-size: 22rpx;
  360. line-height: 44rpx;
  361. text-align: center;
  362. flex-shrink: 0;
  363. }
  364. .empty-state {
  365. margin-top: 86rpx;
  366. padding: 56rpx 34rpx;
  367. border-radius: 28rpx;
  368. background: #ffffff;
  369. display: flex;
  370. flex-direction: column;
  371. align-items: center;
  372. text-align: center;
  373. box-shadow: 0 10rpx 28rpx rgba(31, 52, 86, 0.05);
  374. }
  375. .empty-preview {
  376. width: 150rpx;
  377. height: 200rpx;
  378. border-radius: 32rpx;
  379. background: linear-gradient(135deg, #edf6ff, #eef2ff);
  380. display: flex;
  381. align-items: center;
  382. justify-content: center;
  383. }
  384. .empty-photo {
  385. width: 88rpx;
  386. height: 108rpx;
  387. border-radius: 14rpx;
  388. border: 6rpx solid #1f8cff;
  389. background: linear-gradient(180deg, #c9d8ef 40%, #2f4468 40%);
  390. }
  391. .empty-title { margin-top: 28rpx; color: #172033; font-size: 32rpx; font-weight: 900; }
  392. .empty-desc { margin-top: 12rpx; color: #64748b; font-size: 25rpx; line-height: 1.5; }
  393. .empty-action {
  394. margin-top: 28rpx;
  395. width: 220rpx;
  396. height: 72rpx;
  397. border-radius: 999rpx;
  398. color: #ffffff;
  399. background: #1f8cff;
  400. font-size: 28rpx;
  401. line-height: 72rpx;
  402. font-weight: 800;
  403. }
  404. .loading,
  405. .end-text { padding: 46rpx 0 20rpx; color: #7c8a9b; font-size: 24rpx; text-align: center; }
  406. @keyframes spin {
  407. to { transform: rotate(360deg); }
  408. }
  409. </style>