25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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