Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 

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