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.
 
 
 

596 Zeilen
12 KiB

  1. <template>
  2. <view class="works">
  3. <ai-nav-title title="我的作品" />
  4. <scroll-view
  5. class="page-scroll"
  6. scroll-y
  7. refresher-enabled
  8. :refresher-triggered="refreshing"
  9. @refresherrefresh="onRefresh"
  10. @scrolltolower="loadMore"
  11. >
  12. <view class="works-content">
  13. <view class="tabs">
  14. <view
  15. v-for="item in tabs"
  16. :key="item.key"
  17. class="tab"
  18. :class="{ active: activeTab === item.key }"
  19. @click="changeTab(item.key)"
  20. >
  21. <text class="tab-name">{{ item.name }}</text>
  22. <text class="tab-count">{{ counts[item.key] || 0 }}</text>
  23. </view>
  24. </view>
  25. <view v-if="list.length" class="works-grid">
  26. <view
  27. v-for="item in list"
  28. :key="item.id"
  29. class="work-card"
  30. :class="{ 'name-work': item.work_type === 'ainame' }"
  31. @click="openWork(item)"
  32. >
  33. <view v-if="item.work_type === 'ainame'" class="name-preview">
  34. <view class="card-top">
  35. <text class="type-chip name">AI取名</text>
  36. <text class="status-chip" :class="statusClass(item)">{{ item.status_text }}</text>
  37. </view>
  38. <view class="name-list">
  39. <text v-for="name in item.names" :key="name" class="name-chip">{{ name }}</text>
  40. <text v-if="!item.names || !item.names.length" class="name-placeholder">名字整理中</text>
  41. </view>
  42. <text class="name-summary">{{ item.summary || item.subtitle }}</text>
  43. </view>
  44. <view v-else class="image-preview">
  45. <image v-if="item.cover" class="cover" :src="item.cover" mode="aspectFill" />
  46. <view v-else class="cover-placeholder">
  47. <view v-if="Number(item.status) === 0" class="spinner"></view>
  48. <text>{{ Number(item.status) === 0 ? '生成中' : '暂无封面' }}</text>
  49. </view>
  50. <view class="image-mask"></view>
  51. <view class="card-top overlay">
  52. <text class="type-chip">{{ typeText(item.work_type) }}</text>
  53. <text class="status-chip" :class="statusClass(item)">{{ item.status_text }}</text>
  54. </view>
  55. <view v-if="Number(item.status) === 0" class="pending-mask">
  56. <view class="spinner light"></view>
  57. <text>正在创作</text>
  58. </view>
  59. </view>
  60. <view class="card-body">
  61. <text class="work-time">{{ formatTime(item.add_time) }}</text>
  62. </view>
  63. </view>
  64. </view>
  65. <view v-if="!list.length && !loading" class="empty-state">
  66. <view class="empty-icon">
  67. <view></view>
  68. <view></view>
  69. <view></view>
  70. <view></view>
  71. </view>
  72. <text class="empty-title">{{ userStore.isLogin ? '还没有作品' : '登录后查看作品' }}</text>
  73. <text class="empty-desc">
  74. {{ userStore.isLogin ? '去工具箱生成第一张头像、照片或名字方案。' : '登录后会同步你的作品、点数和创作记录。' }}
  75. </text>
  76. <view class="empty-action" @click="handleEmptyAction">
  77. {{ userStore.isLogin ? '去创作' : '去登录' }}
  78. </view>
  79. </view>
  80. <view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
  81. <view v-if="list.length && list.length >= total && !loading" class="end-text">没有更多了</view>
  82. </view>
  83. </scroll-view>
  84. </view>
  85. </template>
  86. <script setup>
  87. import { computed, ref } from 'vue'
  88. import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
  89. import dayjs from 'dayjs'
  90. import { toolApi } from '@/api/index.js'
  91. import { useUserStore } from '@/store/user.js'
  92. import { useApp } from '@/utils/useApp.js'
  93. const userStore = useUserStore()
  94. const { toast } = useApp()
  95. const activeTab = ref('all')
  96. const list = ref([])
  97. const total = ref(0)
  98. const page = ref(1)
  99. const pageSize = 10
  100. const loading = ref(false)
  101. const refreshing = ref(false)
  102. const counts = ref({ all: 0, ainame: 0, anime: 0, image: 0, photo: 0 })
  103. let timer = 0
  104. const tabs = computed(() => [
  105. { key: 'all', name: '全部' },
  106. { key: 'ainame', name: 'AI取名' },
  107. { key: 'anime', name: '动漫头像' },
  108. { key: 'photo', name: '证件照' }
  109. ])
  110. onShow(() => {
  111. refreshSilent()
  112. })
  113. onHide(() => clearTimer())
  114. onUnload(() => clearTimer())
  115. function formatTime(value) {
  116. if (!value) return ''
  117. return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
  118. }
  119. async function fetchList(reset = false, silent = false) {
  120. if (!userStore.isLogin) {
  121. list.value = []
  122. total.value = 0
  123. refreshing.value = false
  124. uni.stopPullDownRefresh()
  125. return
  126. }
  127. if (loading.value) {
  128. if (refreshing.value) uni.stopPullDownRefresh()
  129. return
  130. }
  131. loading.value = true
  132. if (reset) page.value = 1
  133. try {
  134. const res = await toolApi.getMyWorkList({
  135. type: activeTab.value,
  136. page_no: page.value,
  137. page_size: pageSize
  138. })
  139. const rows = res.list || []
  140. const data = res.data || {}
  141. list.value = reset ? rows : list.value.concat(rows)
  142. total.value = Number(data.total || rows.length)
  143. counts.value = normalizeCounts(data.counts || {})
  144. scheduleRefresh()
  145. } catch (e) {
  146. if (!silent) toast(e.msg || '作品加载失败')
  147. } finally {
  148. loading.value = false
  149. refreshing.value = false
  150. uni.stopPullDownRefresh()
  151. }
  152. }
  153. function normalizeCounts(data = {}) {
  154. return {
  155. all: Number(data.all || 0),
  156. ainame: Number(data.ainame || 0),
  157. anime: Number(data.anime || 0),
  158. image: Number(data.image || 0),
  159. photo: Number(data.photo || 0)
  160. }
  161. }
  162. function refreshSilent(silent = false) {
  163. fetchList(true, silent)
  164. }
  165. function onRefresh() {
  166. refreshing.value = true
  167. refreshSilent()
  168. }
  169. function loadMore() {
  170. if (loading.value || list.value.length >= total.value) return
  171. page.value += 1
  172. fetchList()
  173. }
  174. function changeTab(key) {
  175. if (activeTab.value === key) return
  176. activeTab.value = key
  177. refreshSilent()
  178. }
  179. function scheduleRefresh() {
  180. clearTimer()
  181. if (list.value.some((item) => Number(item.status) === 0)) {
  182. timer = setTimeout(() => refreshSilent(true), 3000)
  183. }
  184. }
  185. function clearTimer() {
  186. clearTimeout(timer)
  187. timer = 0
  188. }
  189. function openWork(item) {
  190. if (!item.detail_url) return
  191. uni.navigateTo({ url: item.detail_url })
  192. }
  193. function statusClass(item) {
  194. const status = Number(item.status)
  195. if (status === 0) return 'pending'
  196. if (status < 0) return 'failed'
  197. return 'done'
  198. }
  199. function typeText(type) {
  200. const map = {
  201. ainame: 'AI取名',
  202. anime: '动漫头像',
  203. image: 'AI作品',
  204. photo: '证件照'
  205. }
  206. return map[type] || 'AI作品'
  207. }
  208. function goCreate() {
  209. uni.switchTab({ url: '/pages/toolbox/toolbox' })
  210. }
  211. function goLogin() {
  212. uni.navigateTo({ url: '/pages/login/login' })
  213. }
  214. function handleEmptyAction() {
  215. if (userStore.isLogin) {
  216. goCreate()
  217. return
  218. }
  219. goLogin()
  220. }
  221. </script>
  222. <style lang="scss" scoped>
  223. .works {
  224. height: 100vh;
  225. display: flex;
  226. flex-direction: column;
  227. overflow: hidden;
  228. background: #f6f8fb;
  229. }
  230. .page-scroll {
  231. flex: 1;
  232. height: 0;
  233. }
  234. .works-content {
  235. min-height: 100%;
  236. padding: 0 24rpx calc(132rpx + env(safe-area-inset-bottom));
  237. background: #f6f8fb;
  238. box-sizing: border-box;
  239. }
  240. .tabs {
  241. position: sticky;
  242. top: 0;
  243. z-index: 3;
  244. height: 88rpx;
  245. margin: 0 -24rpx;
  246. padding: 0 24rpx;
  247. border-top: 1rpx solid #eef1f6;
  248. background: rgba(255, 255, 255, 0.96);
  249. display: flex;
  250. align-items: center;
  251. gap: 14rpx;
  252. box-sizing: border-box;
  253. }
  254. .tab {
  255. height: 56rpx;
  256. padding: 0 20rpx;
  257. border-radius: 999rpx;
  258. display: flex;
  259. align-items: center;
  260. gap: 8rpx;
  261. color: #687386;
  262. background: #f4f7fb;
  263. box-sizing: border-box;
  264. flex-shrink: 0;
  265. &.active {
  266. color: #0b8cff;
  267. background: #eaf5ff;
  268. }
  269. }
  270. .tab-name {
  271. font-size: 25rpx;
  272. font-weight: 800;
  273. line-height: 1;
  274. }
  275. .tab-count {
  276. min-width: 30rpx;
  277. height: 30rpx;
  278. padding: 0 8rpx;
  279. border-radius: 999rpx;
  280. color: #8a95a8;
  281. background: #ffffff;
  282. font-size: 19rpx;
  283. font-weight: 800;
  284. line-height: 30rpx;
  285. text-align: center;
  286. box-sizing: border-box;
  287. }
  288. .tab.active .tab-count {
  289. color: #0b8cff;
  290. }
  291. .works-grid {
  292. padding-top: 22rpx;
  293. display: grid;
  294. grid-template-columns: repeat(2, minmax(0, 1fr));
  295. gap: 18rpx;
  296. }
  297. .work-card {
  298. min-width: 0;
  299. border-radius: 22rpx;
  300. background: #ffffff;
  301. overflow: hidden;
  302. box-shadow: 0 10rpx 28rpx rgba(34, 68, 112, 0.06);
  303. }
  304. .image-preview {
  305. position: relative;
  306. width: 100%;
  307. aspect-ratio: 1 / 1;
  308. background: #eef3f8;
  309. overflow: hidden;
  310. }
  311. .cover {
  312. width: 100%;
  313. height: 100%;
  314. display: block;
  315. }
  316. .cover-placeholder {
  317. width: 100%;
  318. height: 100%;
  319. display: flex;
  320. flex-direction: column;
  321. align-items: center;
  322. justify-content: center;
  323. color: #8a95a8;
  324. font-size: 24rpx;
  325. font-weight: 800;
  326. }
  327. .image-mask {
  328. position: absolute;
  329. left: 0;
  330. right: 0;
  331. top: 0;
  332. height: 110rpx;
  333. background: linear-gradient(180deg, rgba(9, 16, 28, 0.36), rgba(9, 16, 28, 0));
  334. z-index: 1;
  335. }
  336. .card-top {
  337. display: flex;
  338. align-items: center;
  339. justify-content: space-between;
  340. gap: 12rpx;
  341. }
  342. .card-top.overlay {
  343. position: absolute;
  344. left: 12rpx;
  345. right: 12rpx;
  346. top: 12rpx;
  347. z-index: 3;
  348. }
  349. .type-chip,
  350. .status-chip {
  351. height: 36rpx;
  352. padding: 0 12rpx;
  353. border-radius: 999rpx;
  354. font-size: 19rpx;
  355. font-weight: 900;
  356. line-height: 36rpx;
  357. flex-shrink: 0;
  358. }
  359. .type-chip {
  360. color: #172033;
  361. background: rgba(255, 255, 255, 0.9);
  362. }
  363. .type-chip.name {
  364. color: #0b8cff;
  365. background: #eaf5ff;
  366. }
  367. .status-chip.done {
  368. color: #16a15d;
  369. background: #ecfbf3;
  370. }
  371. .status-chip.pending {
  372. color: #ff8a00;
  373. background: #fff5e8;
  374. }
  375. .status-chip.failed {
  376. color: #f04438;
  377. background: #fff0ef;
  378. }
  379. .pending-mask {
  380. position: absolute;
  381. inset: 0;
  382. z-index: 2;
  383. background: rgba(9, 16, 28, 0.42);
  384. display: flex;
  385. flex-direction: column;
  386. align-items: center;
  387. justify-content: center;
  388. color: #ffffff;
  389. font-size: 24rpx;
  390. font-weight: 800;
  391. }
  392. .spinner {
  393. width: 44rpx;
  394. height: 44rpx;
  395. margin-bottom: 14rpx;
  396. border: 6rpx solid rgba(11, 140, 255, 0.18);
  397. border-top-color: #0b8cff;
  398. border-radius: 50%;
  399. animation: spin 0.8s linear infinite;
  400. }
  401. .spinner.light {
  402. border-color: rgba(255, 255, 255, 0.35);
  403. border-top-color: #ffffff;
  404. }
  405. .name-preview {
  406. min-height: 278rpx;
  407. padding: 18rpx 18rpx 10rpx;
  408. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
  409. box-sizing: border-box;
  410. }
  411. .name-list {
  412. min-height: 128rpx;
  413. margin-top: 22rpx;
  414. display: flex;
  415. flex-wrap: wrap;
  416. gap: 12rpx 10rpx;
  417. align-content: flex-start;
  418. }
  419. .name-chip {
  420. max-width: 100%;
  421. height: 48rpx;
  422. padding: 0 14rpx;
  423. border-radius: 999rpx;
  424. color: #0b8cff;
  425. background: #eaf5ff;
  426. font-size: 26rpx;
  427. font-weight: 900;
  428. line-height: 48rpx;
  429. overflow: hidden;
  430. text-overflow: ellipsis;
  431. white-space: nowrap;
  432. box-sizing: border-box;
  433. }
  434. .name-placeholder {
  435. color: #8a95a8;
  436. font-size: 24rpx;
  437. line-height: 44rpx;
  438. }
  439. .name-summary {
  440. display: -webkit-box;
  441. margin-top: 4rpx;
  442. color: #687386;
  443. font-size: 23rpx;
  444. line-height: 1.45;
  445. overflow: hidden;
  446. text-overflow: ellipsis;
  447. -webkit-line-clamp: 2;
  448. -webkit-box-orient: vertical;
  449. }
  450. .card-body {
  451. padding: 12rpx 16rpx 16rpx;
  452. }
  453. .work-time {
  454. display: block;
  455. color: #a0a8b8;
  456. font-size: 20rpx;
  457. line-height: 1;
  458. }
  459. .empty-state {
  460. min-height: 760rpx;
  461. display: flex;
  462. flex-direction: column;
  463. align-items: center;
  464. justify-content: center;
  465. text-align: center;
  466. }
  467. .empty-icon {
  468. width: 112rpx;
  469. height: 112rpx;
  470. padding: 18rpx;
  471. border-radius: 34rpx;
  472. background: #ffffff;
  473. box-shadow: 0 10rpx 28rpx rgba(34, 68, 112, 0.06);
  474. display: grid;
  475. grid-template-columns: repeat(2, 1fr);
  476. gap: 10rpx;
  477. box-sizing: border-box;
  478. view {
  479. border-radius: 12rpx;
  480. background: #dceeff;
  481. }
  482. view:nth-child(2),
  483. view:nth-child(3) {
  484. background: #b9ddff;
  485. }
  486. }
  487. .empty-title {
  488. margin-top: 28rpx;
  489. color: #172033;
  490. font-size: 32rpx;
  491. font-weight: 900;
  492. line-height: 1;
  493. }
  494. .empty-desc {
  495. width: 520rpx;
  496. max-width: 82%;
  497. margin-top: 16rpx;
  498. color: #7f8896;
  499. font-size: 25rpx;
  500. line-height: 1.5;
  501. }
  502. .empty-action {
  503. height: 72rpx;
  504. min-width: 180rpx;
  505. margin-top: 34rpx;
  506. padding: 0 36rpx;
  507. border-radius: 999rpx;
  508. color: #ffffff;
  509. background: #0b8cff;
  510. font-size: 27rpx;
  511. font-weight: 900;
  512. line-height: 72rpx;
  513. box-sizing: border-box;
  514. }
  515. .loading,
  516. .end-text {
  517. padding: 30rpx 0 8rpx;
  518. color: #9aa4b2;
  519. font-size: 24rpx;
  520. text-align: center;
  521. }
  522. @keyframes spin {
  523. to {
  524. transform: rotate(360deg);
  525. }
  526. }
  527. </style>