Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 

289 строки
11 KiB

  1. <template>
  2. <div>
  3. <AppHero
  4. kicker="MY CREATIONS"
  5. title="我的作品"
  6. desc="AI 取名、动漫头像、证件照等全部创作记录,生成中的作品会自动刷新。"
  7. />
  8. <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
  9. <!-- 未登录 -->
  10. <div v-if="!isLogin" class="rounded-2xl bg-[var(--c-ffffff)] py-20 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
  11. <UIcon name="i-lucide-image" class="mx-auto mb-4 size-12 text-[var(--c-a0aabb)]" />
  12. <div class="text-base font-extrabold text-[var(--c-172033)]">登录后查看作品</div>
  13. <div class="mx-auto mt-2 max-w-sm text-sm text-[var(--c-8a95a6)]">登录后会同步你的作品、点数和创作记录。</div>
  14. <button
  15. class="mt-5 h-10 rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white transition-colors hover:bg-[var(--c-0a7ce0)]"
  16. @click="showLogin = true"
  17. >
  18. 去登录
  19. </button>
  20. </div>
  21. <template v-else>
  22. <!-- 分类 Tabs(吸顶) -->
  23. <div class="sticky top-16 z-20 -mx-4 mb-5 flex gap-2.5 overflow-x-auto border-b border-[var(--c-eef1f6)] bg-[var(--c-f3f7fb)]/95 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
  24. <button
  25. v-for="item in tabs"
  26. :key="item.key"
  27. type="button"
  28. class="flex h-9 shrink-0 items-center gap-2 rounded-full px-4 transition-colors"
  29. :class="activeTab === item.key ? 'bg-[var(--c-eaf5ff)] text-[var(--c-0b8cff)]' : 'bg-[var(--c-ffffff)] text-[var(--c-687386)] hover:bg-[var(--c-f4f7fb)]'"
  30. @click="changeTab(item.key)"
  31. >
  32. <span class="text-sm font-extrabold">{{ item.name }}</span>
  33. <span
  34. class="min-w-[20px] rounded-full px-1.5 py-0.5 text-center text-[10px] font-extrabold"
  35. :class="activeTab === item.key ? 'bg-[var(--c-ffffff)] text-[var(--c-0b8cff)]' : 'bg-[var(--c-f4f7fb)] text-[var(--c-8a95a8)]'"
  36. >{{ counts[item.key] || 0 }}</span>
  37. </button>
  38. </div>
  39. <!-- 作品网格 -->
  40. <div v-if="list.length" class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
  41. <div
  42. v-for="item in list"
  43. :key="item.id"
  44. class="group cursor-pointer overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-[0_10px_28px_var(--sh-34-68-112-60)] transition-shadow hover:shadow-[0_14px_34px_var(--sh-34-68-112-120)]"
  45. @click="openWork(item)"
  46. >
  47. <!-- AI 取名卡片 -->
  48. <div
  49. v-if="item.work_type === 'ainame'"
  50. class="min-h-[190px] bg-gradient-to-b from-[var(--c-f8fbff)] to-[var(--c-ffffff)] p-3.5"
  51. >
  52. <div class="flex items-center justify-between gap-2">
  53. <span class="rounded-full bg-[var(--c-eaf5ff)] px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-0b8cff)]">AI取名</span>
  54. <span class="rounded-full px-2 py-0.5 text-[10px] font-extrabold" :class="statusClass(item)">
  55. {{ item.status_text }}
  56. </span>
  57. </div>
  58. <div class="mt-3 flex min-h-[76px] flex-wrap content-start gap-1.5">
  59. <span
  60. v-for="name in item.names"
  61. :key="name"
  62. class="rounded-full bg-[var(--c-eaf5ff)] px-2.5 py-1 text-xs font-extrabold text-[var(--c-0b8cff)]"
  63. >{{ name }}</span>
  64. <span v-if="!item.names || !item.names.length" class="text-xs text-[var(--c-8a95a8)]">名字整理中</span>
  65. </div>
  66. <div class="mt-1 line-clamp-2 text-[11px] leading-relaxed text-[var(--c-687386)]">
  67. {{ item.summary || item.subtitle }}
  68. </div>
  69. </div>
  70. <!-- 图片类作品 -->
  71. <div v-else class="relative aspect-square overflow-hidden bg-[var(--c-eef3f8)]">
  72. <img
  73. v-if="item.cover"
  74. :src="item.cover"
  75. class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
  76. :alt="typeText(item.work_type)"
  77. loading="lazy"
  78. >
  79. <div v-else class="flex h-full w-full flex-col items-center justify-center gap-2 text-xs font-bold text-[var(--c-8a95a8)]">
  80. <span v-if="Number(item.status) === 0" class="size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" />
  81. <span>{{ Number(item.status) === 0 ? '生成中' : '暂无封面' }}</span>
  82. </div>
  83. <!-- 顶部渐变 + 标签 -->
  84. <div class="pointer-events-none absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-black/35 to-transparent" />
  85. <div class="absolute inset-x-3 top-3 flex items-center justify-between gap-2">
  86. <span class="rounded-full bg-[var(--c-ffffff)]/90 px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-172033)]">
  87. {{ typeText(item.work_type) }}
  88. </span>
  89. <span class="rounded-full px-2 py-0.5 text-[10px] font-extrabold" :class="statusClass(item)">
  90. {{ item.status_text }}
  91. </span>
  92. </div>
  93. <!-- 生成中遮罩 -->
  94. <div
  95. v-if="Number(item.status) === 0"
  96. class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-xs font-bold text-white"
  97. style="background-color: var(--sh-9-16-28-420)"
  98. >
  99. <span class="size-8 animate-spin rounded-full border-[3px] border-white/35 border-t-white" />
  100. <span>正在创作</span>
  101. </div>
  102. </div>
  103. <div class="px-3.5 py-3">
  104. <div class="text-[11px] text-[var(--c-a0a8b8)]">{{ formatTime(item.add_time) }}</div>
  105. </div>
  106. </div>
  107. </div>
  108. <!-- 空态 -->
  109. <div v-if="!list.length && !loading" class="rounded-2xl bg-[var(--c-ffffff)] py-20 text-center shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
  110. <div class="mx-auto mb-5 grid size-14 grid-cols-2 gap-1.5 rounded-2xl bg-[var(--c-ffffff)] p-2.5 shadow-[0_10px_28px_var(--sh-34-68-112-60)]">
  111. <span class="rounded-md bg-[var(--c-dceeff)]" />
  112. <span class="rounded-md bg-[var(--c-b9ddff)]" />
  113. <span class="rounded-md bg-[var(--c-b9ddff)]" />
  114. <span class="rounded-md bg-[var(--c-dceeff)]" />
  115. </div>
  116. <div class="text-base font-extrabold text-[var(--c-172033)]">还没有作品</div>
  117. <div class="mx-auto mt-2 max-w-sm text-sm text-[var(--c-7f8896)]">去工具箱生成第一张头像、照片或名字方案。</div>
  118. <NuxtLink
  119. to="/"
  120. class="mt-5 inline-flex h-10 items-center rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white transition-colors hover:bg-[var(--c-0a7ce0)]"
  121. >
  122. 去创作
  123. </NuxtLink>
  124. </div>
  125. <!-- 加载 / 加载更多 -->
  126. <div v-if="loading" class="py-8 text-center text-sm text-[var(--c-9aa4b2)]">
  127. {{ list.length ? '加载更多...' : '加载中...' }}
  128. </div>
  129. <div v-else-if="hasMore" class="mt-8 flex justify-center">
  130. <button
  131. class="rounded-full bg-[var(--c-eef7ff)] px-7 py-2.5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)]"
  132. @click="loadMore"
  133. >
  134. 加载更多
  135. </button>
  136. </div>
  137. <div v-else-if="list.length" class="py-8 text-center text-sm text-[var(--c-9aa4b2)]">没有更多了</div>
  138. </template>
  139. </div>
  140. <!-- 作品详情弹窗 -->
  141. <WorkDetailModal v-model="detailOpen" :work="currentWork" />
  142. </div>
  143. </template>
  144. <script setup>
  145. import dayjs from 'dayjs'
  146. useSeoMeta({
  147. title: '我的作品 - AI在线',
  148. description: '查看 AI 取名、动漫头像、证件照等全部创作记录',
  149. })
  150. const { isLogin, showLogin } = useUser()
  151. const { post } = useApi()
  152. const toast = useToast()
  153. const tabs = [
  154. { key: 'all', name: '全部' },
  155. { key: 'ainame', name: 'AI取名' },
  156. { key: 'anime', name: '动漫头像' },
  157. { key: 'photo', name: '证件照' },
  158. { key: 'oldphoto', name: '老照片' },
  159. { key: 'portrait', name: '形象照' },
  160. ]
  161. const activeTab = ref('all')
  162. const list = ref([])
  163. const total = ref(0)
  164. const page = ref(1)
  165. const pageSize = 20
  166. const loading = ref(false)
  167. const counts = ref({ all: 0, ainame: 0, anime: 0, image: 0, photo: 0, oldphoto: 0, portrait: 0 })
  168. let timer = null
  169. const hasMore = computed(() => list.value.length < total.value)
  170. function formatTime(value) {
  171. if (!value) return ''
  172. return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
  173. }
  174. async function fetchList(reset = false, silent = false) {
  175. if (!isLogin.value) {
  176. list.value = []
  177. total.value = 0
  178. return
  179. }
  180. if (loading.value) return
  181. loading.value = true
  182. if (reset) page.value = 1
  183. try {
  184. const res = await post('/work/getlist', {
  185. type: activeTab.value,
  186. page_no: page.value,
  187. page_size: pageSize,
  188. })
  189. const rows = res.list || []
  190. const data = res.data || {}
  191. list.value = reset ? rows : list.value.concat(rows)
  192. total.value = Number(data.total || rows.length)
  193. counts.value = normalizeCounts(data.counts || {})
  194. scheduleRefresh()
  195. } catch (e) {
  196. if (!silent) toast.add({ title: e.message || '作品加载失败', color: 'error' })
  197. } finally {
  198. loading.value = false
  199. }
  200. }
  201. function normalizeCounts(data = {}) {
  202. return {
  203. all: Number(data.all || 0),
  204. ainame: Number(data.ainame || 0),
  205. anime: Number(data.anime || 0),
  206. image: Number(data.image || 0),
  207. photo: Number(data.photo || 0),
  208. oldphoto: Number(data.oldphoto || 0),
  209. portrait: Number(data.portrait || 0),
  210. }
  211. }
  212. // 有生成中的作品时自动轮询刷新
  213. function scheduleRefresh() {
  214. clearTimer()
  215. if (list.value.some(item => Number(item.status) === 0)) {
  216. timer = setTimeout(() => fetchList(true, true), 3000)
  217. }
  218. }
  219. function clearTimer() {
  220. if (timer) clearTimeout(timer)
  221. timer = null
  222. }
  223. function changeTab(key) {
  224. if (activeTab.value === key) return
  225. activeTab.value = key
  226. fetchList(true)
  227. }
  228. function loadMore() {
  229. if (loading.value || !hasMore.value) return
  230. page.value += 1
  231. fetchList()
  232. }
  233. // 点击作品用弹窗查看详情(不再跳回创作页)
  234. const detailOpen = ref(false)
  235. const currentWork = ref({})
  236. function openWork(item) {
  237. // 生成中的作品也可打开,弹窗内会显示进度状态
  238. currentWork.value = item
  239. detailOpen.value = true
  240. }
  241. function statusClass(item) {
  242. const status = Number(item.status)
  243. if (status === 0) return 'bg-[var(--c-fff5e8)] text-[var(--c-ff8a00)]'
  244. if (status < 0) return 'bg-[var(--c-fff0ef)] text-[var(--c-f04438)]'
  245. return 'bg-[var(--c-ecfbf3)] text-[var(--c-16a15d)]'
  246. }
  247. function typeText(type) {
  248. const map = {
  249. ainame: 'AI取名',
  250. anime: '动漫头像',
  251. image: 'AI作品',
  252. photo: '证件照',
  253. oldphoto: '老照片',
  254. portrait: '形象照',
  255. }
  256. return map[type] || 'AI作品'
  257. }
  258. onMounted(() => fetchList(true))
  259. onUnmounted(() => clearTimer())
  260. watch(isLogin, (v) => { if (v) fetchList(true) })
  261. </script>