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.
 
 
 

729 linhas
16 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="userStore.isLogin"
  8. :refresher-triggered="refreshing"
  9. @refresherrefresh="onRefresh"
  10. @scrolltolower="loadMore"
  11. >
  12. <view class="works-content">
  13. <!-- 类型标签会随功能增加变多,横向滚动,选中项自动滚到居中 -->
  14. <scroll-view
  15. id="tabs-scroll"
  16. class="tabs-scroll"
  17. scroll-x
  18. scroll-with-animation
  19. :scroll-left="tabsScrollLeft"
  20. :show-scrollbar="false"
  21. :enhanced="true"
  22. >
  23. <view class="tabs">
  24. <view
  25. v-for="item in tabs"
  26. :key="item.key"
  27. :id="`tab-${item.key}`"
  28. class="tab"
  29. :class="{ active: activeTab === item.key }"
  30. @click="changeTab(item.key)"
  31. >
  32. <text class="tab-name">{{ item.name }}</text>
  33. <text v-if="userStore.isLogin" class="tab-count">{{ counts[item.key] || 0 }}</text>
  34. </view>
  35. </view>
  36. </scroll-view>
  37. <view v-if="list.length" class="works-grid">
  38. <view
  39. v-for="item in list"
  40. :key="item.id"
  41. class="work-card"
  42. :class="{ 'name-work': item.work_type === 'ainame' }"
  43. @click="openWork(item)"
  44. >
  45. <view v-if="item.work_type === 'ainame'" class="name-preview">
  46. <view class="card-top">
  47. <text class="type-chip name">AI取名</text>
  48. <text class="status-chip" :class="statusClass(item)">{{ item.status_text }}</text>
  49. </view>
  50. <view class="name-list">
  51. <text v-for="name in item.names" :key="name" class="name-chip">{{ name }}</text>
  52. <text v-if="!item.names || !item.names.length" class="name-placeholder">名字整理中</text>
  53. </view>
  54. <text class="name-summary">{{ item.summary || item.subtitle }}</text>
  55. </view>
  56. <view v-else class="image-preview">
  57. <image v-if="item.cover" class="cover" :src="item.cover" mode="aspectFill" />
  58. <view v-else class="cover-placeholder">
  59. <view v-if="Number(item.status) === 0" class="spinner"></view>
  60. <text>{{ Number(item.status) === 0 ? '生成中' : '暂无封面' }}</text>
  61. </view>
  62. <view class="image-mask"></view>
  63. <view class="card-top overlay">
  64. <text class="type-chip">{{ typeText(item.work_type) }}</text>
  65. <text class="status-chip" :class="statusClass(item)">{{ item.status_text }}</text>
  66. </view>
  67. <view v-if="Number(item.status) === 0" class="pending-mask">
  68. <view class="spinner light"></view>
  69. <text>正在创作</text>
  70. </view>
  71. </view>
  72. <view class="card-body">
  73. <text class="work-time">{{ formatTime(item.add_time) }}</text>
  74. </view>
  75. </view>
  76. </view>
  77. <view v-if="!list.length && !loading" class="empty-state">
  78. <view class="empty-icon">
  79. <view></view>
  80. <view></view>
  81. <view></view>
  82. <view></view>
  83. </view>
  84. <text class="empty-title">{{ userStore.isLogin ? '还没有作品' : '登录后查看作品' }}</text>
  85. <text class="empty-desc">
  86. {{ userStore.isLogin ? '去工具箱生成第一张头像、照片或名字方案。' : '登录后会同步你的作品、点数和创作记录。' }}
  87. </text>
  88. <view class="empty-action" @click="handleEmptyAction">
  89. {{ userStore.isLogin ? '去创作' : '去登录' }}
  90. </view>
  91. </view>
  92. <view v-if="loading" class="loading">{{ list.length ? '加载更多...' : '加载中...' }}</view>
  93. <view v-if="list.length && list.length >= total && !loading" class="end-text">没有更多了</view>
  94. </view>
  95. </scroll-view>
  96. </view>
  97. </template>
  98. <script setup>
  99. import { computed, nextTick, ref } from 'vue'
  100. import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
  101. import dayjs from 'dayjs'
  102. import { toolApi } from '@/api/index.js'
  103. import { useUserStore } from '@/store/user.js'
  104. import { useApp } from '@/utils/useApp.js'
  105. const userStore = useUserStore()
  106. const { toast } = useApp()
  107. const activeTab = ref('all')
  108. const list = ref([])
  109. const total = ref(0)
  110. const page = ref(1)
  111. const pageSize = 10
  112. const loading = ref(false)
  113. const refreshing = ref(false)
  114. const counts = ref({ all: 0, ainame: 0, anime: 0, image: 0, photo: 0, oldphoto: 0, portrait: 0 })
  115. // 类型标签栏的横向滚动位置
  116. const tabsScrollLeft = ref(0)
  117. let fetching = false
  118. let timer = 0
  119. const tabs = computed(() => [
  120. { key: 'all', name: '全部' },
  121. { key: 'ainame', name: 'AI取名' },
  122. { key: 'anime', name: '动漫头像' },
  123. { key: 'photo', name: '证件照' },
  124. { key: 'oldphoto', name: '老照片' },
  125. { key: 'portrait', name: '形象照' }
  126. ])
  127. onShow(() => {
  128. const preferredTab = uni.getStorageSync('works_active_tab')
  129. let needReset = false
  130. if (tabs.value.some((item) => item.key === preferredTab)) {
  131. needReset = activeTab.value !== preferredTab
  132. activeTab.value = preferredTab
  133. uni.removeStorageSync('works_active_tab')
  134. }
  135. if (needReset) {
  136. fetchList({ reset: true })
  137. } else {
  138. refreshLoaded(list.value.length > 0)
  139. }
  140. // 从其它页面带着类型进来时,把该类型滚到可视区中间(等渲染完成再量位置)
  141. nextTick(() => centerTab(activeTab.value))
  142. })
  143. onHide(() => clearTimer())
  144. onUnload(() => clearTimer())
  145. function formatTime(value) {
  146. if (!value) return ''
  147. return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
  148. }
  149. async function fetchList(options = {}) {
  150. const {
  151. reset = false,
  152. silent = false,
  153. replace = reset,
  154. size = pageSize
  155. } = options
  156. let queryPage = options.pageNo || page.value
  157. if (!userStore.isLogin) {
  158. list.value = []
  159. total.value = 0
  160. refreshing.value = false
  161. uni.stopPullDownRefresh()
  162. return
  163. }
  164. if (fetching) {
  165. if (refreshing.value) uni.stopPullDownRefresh()
  166. return
  167. }
  168. fetching = true
  169. if (!silent) loading.value = true
  170. if (reset) {
  171. page.value = 1
  172. queryPage = 1
  173. }
  174. try {
  175. const res = await toolApi.getMyWorkList({
  176. type: activeTab.value,
  177. page_no: queryPage,
  178. page_size: size
  179. }, { authSilent: true })
  180. const rows = res.list || []
  181. const data = res.data || {}
  182. if (res.authExpired || data.authExpired) {
  183. userStore.clear()
  184. list.value = []
  185. total.value = 0
  186. counts.value = normalizeCounts({})
  187. clearTimer()
  188. return
  189. }
  190. list.value = replace ? rows : list.value.concat(rows)
  191. total.value = Number(data.total || rows.length)
  192. counts.value = normalizeCounts(data.counts || {})
  193. scheduleRefresh()
  194. } catch (e) {
  195. if (Number(e?.code) === 1009) {
  196. userStore.clear()
  197. list.value = []
  198. total.value = 0
  199. counts.value = normalizeCounts({})
  200. clearTimer()
  201. return
  202. }
  203. if (!silent) toast(e.msg || '作品加载失败')
  204. } finally {
  205. fetching = false
  206. loading.value = false
  207. refreshing.value = false
  208. uni.stopPullDownRefresh()
  209. }
  210. }
  211. function normalizeCounts(data = {}) {
  212. return {
  213. all: Number(data.all || 0),
  214. ainame: Number(data.ainame || 0),
  215. anime: Number(data.anime || 0),
  216. image: Number(data.image || 0),
  217. photo: Number(data.photo || 0),
  218. oldphoto: Number(data.oldphoto || 0),
  219. portrait: Number(data.portrait || 0)
  220. }
  221. }
  222. function refreshLoaded(silent = true) {
  223. const currentPage = Math.max(1, Number(page.value) || 1)
  224. fetchList({
  225. pageNo: 1,
  226. size: currentPage * pageSize,
  227. replace: true,
  228. silent
  229. })
  230. }
  231. function onRefresh() {
  232. refreshing.value = true
  233. if (!userStore.isLogin) {
  234. // 先让原生 scroll-view 接收到 triggered=true,再在下一次渲染后关闭,避免刷新指示器滞留
  235. nextTick(() => {
  236. refreshing.value = false
  237. uni.stopPullDownRefresh()
  238. })
  239. return
  240. }
  241. fetchList({ reset: true })
  242. }
  243. function loadMore() {
  244. if (fetching || list.value.length >= total.value) return
  245. page.value += 1
  246. fetchList()
  247. }
  248. function changeTab(key) {
  249. centerTab(key)
  250. if (activeTab.value === key) return
  251. activeTab.value = key
  252. fetchList({ reset: true })
  253. }
  254. /**
  255. * 把指定标签滚到可视区中间。
  256. * scroll-into-view 只能把元素对齐到左边缘,所以这里量一次位置自己算 scroll-left:
  257. * 目标 = 标签中心 - 容器中心(含容器当前已滚动的距离),左右到头时由 scroll-view 自行收敛。
  258. */
  259. function centerTab(key) {
  260. uni.createSelectorQuery()
  261. .select('#tabs-scroll')
  262. .boundingClientRect()
  263. .select('#tabs-scroll')
  264. .scrollOffset()
  265. .select(`#tab-${key}`)
  266. .boundingClientRect()
  267. .exec((res) => {
  268. const wrap = res && res[0]
  269. const offset = res && res[1]
  270. const tab = res && res[2]
  271. if (!wrap || !offset || !tab || !wrap.width) return
  272. const target = tab.left - wrap.left + offset.scrollLeft - (wrap.width - tab.width) / 2
  273. tabsScrollLeft.value = Math.max(0, Math.round(target))
  274. })
  275. }
  276. function scheduleRefresh() {
  277. clearTimer()
  278. if (list.value.some((item) => Number(item.status) === 0)) {
  279. timer = setTimeout(() => refreshLoaded(true), 3000)
  280. }
  281. }
  282. function clearTimer() {
  283. clearTimeout(timer)
  284. timer = 0
  285. }
  286. function openWork(item) {
  287. if (item.work_type === 'photo') {
  288. const workId = item.photo_open_id || item.id
  289. if (workId) uni.navigateTo({ url: `/pages/tool/id-photo-detail?id=${workId}` })
  290. return
  291. }
  292. if (item.work_type === 'oldphoto') {
  293. const workId = item.photo_open_id || item.id
  294. if (workId) uni.navigateTo({ url: `/pages/tool/old-photo-detail?id=${workId}` })
  295. return
  296. }
  297. if (item.work_type === 'portrait') {
  298. const workId = item.photo_open_id || item.id
  299. if (workId) uni.navigateTo({ url: `/pages/tool/portrait-detail?id=${workId}` })
  300. return
  301. }
  302. if (!item.detail_url) return
  303. uni.navigateTo({ url: item.detail_url })
  304. }
  305. function statusClass(item) {
  306. const status = Number(item.status)
  307. if (status === 0) return 'pending'
  308. if (status < 0) return 'failed'
  309. return 'done'
  310. }
  311. function typeText(type) {
  312. const map = {
  313. ainame: 'AI取名',
  314. anime: '动漫头像',
  315. image: 'AI作品',
  316. photo: '证件照',
  317. oldphoto: '老照片',
  318. portrait: '形象照'
  319. }
  320. return map[type] || 'AI作品'
  321. }
  322. function goCreate() {
  323. uni.switchTab({ url: '/pages/toolbox/toolbox' })
  324. }
  325. function goLogin() {
  326. uni.navigateTo({ url: '/pages/login/login' })
  327. }
  328. function handleEmptyAction() {
  329. if (userStore.isLogin) {
  330. goCreate()
  331. return
  332. }
  333. goLogin()
  334. }
  335. </script>
  336. <style lang="scss" scoped>
  337. .works {
  338. height: 100vh;
  339. display: flex;
  340. flex-direction: column;
  341. overflow: hidden;
  342. background: #f6f8fb;
  343. }
  344. .page-scroll {
  345. flex: 1;
  346. width: 100%;
  347. min-width: 0;
  348. height: 0;
  349. }
  350. .works-content {
  351. min-height: 100%;
  352. padding: 0 24rpx calc(132rpx + env(safe-area-inset-bottom));
  353. background: #f6f8fb;
  354. box-sizing: border-box;
  355. }
  356. /* 吸顶放在滚动容器上,内部只负责横向排列 */
  357. .tabs-scroll {
  358. position: sticky;
  359. top: 0;
  360. z-index: 3;
  361. width: calc(100% + 48rpx);
  362. height: 88rpx;
  363. margin: 0 -24rpx;
  364. border-top: 1rpx solid #eef1f6;
  365. background: rgba(255, 255, 255, 0.96);
  366. white-space: nowrap;
  367. box-sizing: border-box;
  368. /* 隐藏横向滚动条,H5 下默认会占高度 */
  369. ::-webkit-scrollbar {
  370. display: none;
  371. width: 0;
  372. height: 0;
  373. }
  374. }
  375. .tabs {
  376. height: 88rpx;
  377. padding: 0 24rpx;
  378. display: inline-flex;
  379. align-items: center;
  380. gap: 14rpx;
  381. box-sizing: border-box;
  382. }
  383. .tab {
  384. height: 56rpx;
  385. padding: 0 20rpx;
  386. border-radius: 999rpx;
  387. display: flex;
  388. align-items: center;
  389. gap: 8rpx;
  390. color: #687386;
  391. background: #f4f7fb;
  392. box-sizing: border-box;
  393. flex-shrink: 0;
  394. &.active {
  395. color: #0b8cff;
  396. background: #eaf5ff;
  397. }
  398. }
  399. .tab-name {
  400. font-size: 25rpx;
  401. font-weight: 800;
  402. line-height: 1;
  403. }
  404. .tab-count {
  405. min-width: 30rpx;
  406. height: 30rpx;
  407. padding: 0 8rpx;
  408. border-radius: 999rpx;
  409. color: #8a95a8;
  410. background: #ffffff;
  411. font-size: 19rpx;
  412. font-weight: 800;
  413. line-height: 30rpx;
  414. text-align: center;
  415. box-sizing: border-box;
  416. }
  417. .tab.active .tab-count {
  418. color: #0b8cff;
  419. }
  420. .works-grid {
  421. padding-top: 22rpx;
  422. display: grid;
  423. grid-template-columns: repeat(2, minmax(0, 1fr));
  424. gap: 18rpx;
  425. }
  426. .work-card {
  427. min-width: 0;
  428. border-radius: 22rpx;
  429. background: #ffffff;
  430. overflow: hidden;
  431. box-shadow: 0 10rpx 28rpx rgba(34, 68, 112, 0.06);
  432. }
  433. .image-preview {
  434. position: relative;
  435. width: 100%;
  436. aspect-ratio: 1 / 1;
  437. background: #eef3f8;
  438. overflow: hidden;
  439. }
  440. .cover {
  441. width: 100%;
  442. height: 100%;
  443. display: block;
  444. }
  445. .cover-placeholder {
  446. width: 100%;
  447. height: 100%;
  448. display: flex;
  449. flex-direction: column;
  450. align-items: center;
  451. justify-content: center;
  452. color: #8a95a8;
  453. font-size: 24rpx;
  454. font-weight: 800;
  455. }
  456. .image-mask {
  457. position: absolute;
  458. left: 0;
  459. right: 0;
  460. top: 0;
  461. height: 110rpx;
  462. background: linear-gradient(180deg, rgba(9, 16, 28, 0.36), rgba(9, 16, 28, 0));
  463. z-index: 1;
  464. }
  465. .card-top {
  466. display: flex;
  467. align-items: center;
  468. justify-content: space-between;
  469. gap: 12rpx;
  470. }
  471. .card-top.overlay {
  472. position: absolute;
  473. left: 12rpx;
  474. right: 12rpx;
  475. top: 12rpx;
  476. z-index: 3;
  477. }
  478. .type-chip,
  479. .status-chip {
  480. height: 36rpx;
  481. padding: 0 12rpx;
  482. border-radius: 999rpx;
  483. font-size: 19rpx;
  484. font-weight: 900;
  485. line-height: 36rpx;
  486. flex-shrink: 0;
  487. }
  488. .type-chip {
  489. color: #172033;
  490. background: rgba(255, 255, 255, 0.9);
  491. }
  492. .type-chip.name {
  493. color: #0b8cff;
  494. background: #eaf5ff;
  495. }
  496. .status-chip.done {
  497. color: #16a15d;
  498. background: #ecfbf3;
  499. }
  500. .status-chip.pending {
  501. color: #ff8a00;
  502. background: #fff5e8;
  503. }
  504. .status-chip.failed {
  505. color: #f04438;
  506. background: #fff0ef;
  507. }
  508. .pending-mask {
  509. position: absolute;
  510. inset: 0;
  511. z-index: 2;
  512. background: rgba(9, 16, 28, 0.42);
  513. display: flex;
  514. flex-direction: column;
  515. align-items: center;
  516. justify-content: center;
  517. color: #ffffff;
  518. font-size: 24rpx;
  519. font-weight: 800;
  520. }
  521. .spinner {
  522. width: 44rpx;
  523. height: 44rpx;
  524. margin-bottom: 14rpx;
  525. border: 6rpx solid rgba(11, 140, 255, 0.18);
  526. border-top-color: #0b8cff;
  527. border-radius: 50%;
  528. animation: spin 0.8s linear infinite;
  529. }
  530. .spinner.light {
  531. border-color: rgba(255, 255, 255, 0.35);
  532. border-top-color: #ffffff;
  533. }
  534. .name-preview {
  535. min-height: 278rpx;
  536. padding: 18rpx 18rpx 10rpx;
  537. background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
  538. box-sizing: border-box;
  539. }
  540. .name-list {
  541. min-height: 128rpx;
  542. margin-top: 22rpx;
  543. display: flex;
  544. flex-wrap: wrap;
  545. gap: 12rpx 10rpx;
  546. align-content: flex-start;
  547. }
  548. .name-chip {
  549. max-width: 100%;
  550. height: 48rpx;
  551. padding: 0 14rpx;
  552. border-radius: 999rpx;
  553. color: #0b8cff;
  554. background: #eaf5ff;
  555. font-size: 26rpx;
  556. font-weight: 900;
  557. line-height: 48rpx;
  558. overflow: hidden;
  559. text-overflow: ellipsis;
  560. white-space: nowrap;
  561. box-sizing: border-box;
  562. }
  563. .name-placeholder {
  564. color: #8a95a8;
  565. font-size: 24rpx;
  566. line-height: 44rpx;
  567. }
  568. .name-summary {
  569. display: -webkit-box;
  570. margin-top: 4rpx;
  571. color: #687386;
  572. font-size: 23rpx;
  573. line-height: 1.45;
  574. overflow: hidden;
  575. text-overflow: ellipsis;
  576. -webkit-line-clamp: 2;
  577. -webkit-box-orient: vertical;
  578. }
  579. .card-body {
  580. padding: 12rpx 16rpx 16rpx;
  581. }
  582. .work-time {
  583. display: block;
  584. color: #a0a8b8;
  585. font-size: 20rpx;
  586. line-height: 1;
  587. }
  588. .empty-state {
  589. min-height: 760rpx;
  590. display: flex;
  591. flex-direction: column;
  592. align-items: center;
  593. justify-content: center;
  594. text-align: center;
  595. }
  596. .empty-icon {
  597. width: 112rpx;
  598. height: 112rpx;
  599. padding: 18rpx;
  600. border-radius: 34rpx;
  601. background: #ffffff;
  602. box-shadow: 0 10rpx 28rpx rgba(34, 68, 112, 0.06);
  603. display: grid;
  604. grid-template-columns: repeat(2, 1fr);
  605. gap: 10rpx;
  606. box-sizing: border-box;
  607. view {
  608. border-radius: 12rpx;
  609. background: #dceeff;
  610. }
  611. view:nth-child(2),
  612. view:nth-child(3) {
  613. background: #b9ddff;
  614. }
  615. }
  616. .empty-title {
  617. margin-top: 28rpx;
  618. color: #172033;
  619. font-size: 32rpx;
  620. font-weight: 900;
  621. line-height: 1;
  622. }
  623. .empty-desc {
  624. width: 520rpx;
  625. max-width: 82%;
  626. margin-top: 16rpx;
  627. color: #7f8896;
  628. font-size: 25rpx;
  629. line-height: 1.5;
  630. }
  631. .empty-action {
  632. height: 72rpx;
  633. min-width: 180rpx;
  634. margin-top: 34rpx;
  635. padding: 0 36rpx;
  636. border-radius: 999rpx;
  637. color: #ffffff;
  638. background: #0b8cff;
  639. font-size: 27rpx;
  640. font-weight: 900;
  641. line-height: 72rpx;
  642. box-sizing: border-box;
  643. }
  644. .loading,
  645. .end-text {
  646. padding: 30rpx 0 8rpx;
  647. color: #9aa4b2;
  648. font-size: 24rpx;
  649. text-align: center;
  650. }
  651. @keyframes spin {
  652. to {
  653. transform: rotate(360deg);
  654. }
  655. }
  656. </style>