選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

346 行
14 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 relative 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. <button
  48. type="button"
  49. class="absolute right-3 top-3 z-10 flex size-8 items-center justify-center rounded-full bg-white/90 text-[var(--c-f04438)] opacity-0 shadow-[0_8px_20px_var(--sh-15-23-42-160)] backdrop-blur transition-opacity hover:bg-[var(--c-fff1f0)] group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-f04438)]"
  50. aria-label="删除作品"
  51. @click.stop="deleteWork(item)"
  52. >
  53. <UIcon name="i-lucide-trash-2" class="size-4" />
  54. </button>
  55. <!-- AI 取名卡片 -->
  56. <div
  57. v-if="item.work_type === 'ainame'"
  58. class="min-h-[190px] bg-gradient-to-b from-[var(--c-f8fbff)] to-[var(--c-ffffff)] p-3.5"
  59. >
  60. <div class="flex items-center justify-between gap-2">
  61. <span class="rounded-full bg-[var(--c-eaf5ff)] px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-0b8cff)]">AI取名</span>
  62. <span class="rounded-full px-2 py-0.5 text-[10px] font-extrabold" :class="statusClass(item)">
  63. {{ item.status_text }}
  64. </span>
  65. </div>
  66. <div class="mt-3 flex min-h-[76px] flex-wrap content-start gap-1.5">
  67. <span
  68. v-for="name in item.names"
  69. :key="name"
  70. class="rounded-full bg-[var(--c-eaf5ff)] px-2.5 py-1 text-xs font-extrabold text-[var(--c-0b8cff)]"
  71. >{{ name }}</span>
  72. <span v-if="!item.names || !item.names.length" class="text-xs text-[var(--c-8a95a8)]">名字整理中</span>
  73. </div>
  74. <div class="mt-1 line-clamp-2 text-[11px] leading-relaxed text-[var(--c-687386)]">
  75. {{ item.summary || item.subtitle }}
  76. </div>
  77. </div>
  78. <!-- 图片类作品 -->
  79. <div v-else class="relative aspect-square overflow-hidden bg-[var(--c-eef3f8)]">
  80. <img
  81. v-if="item.cover"
  82. :src="item.cover"
  83. class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
  84. :alt="typeText(item.work_type)"
  85. loading="lazy"
  86. >
  87. <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)]">
  88. <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)]" />
  89. <span>{{ Number(item.status) === 0 ? '生成中' : '暂无封面' }}</span>
  90. </div>
  91. <!-- 顶部渐变 + 标签 -->
  92. <div class="pointer-events-none absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-black/35 to-transparent" />
  93. <div class="absolute inset-x-3 top-3 flex items-center justify-between gap-2">
  94. <span class="rounded-full bg-[var(--c-ffffff)]/90 px-2 py-0.5 text-[10px] font-extrabold text-[var(--c-172033)]">
  95. {{ typeText(item.work_type) }}
  96. </span>
  97. <span class="rounded-full px-2 py-0.5 text-[10px] font-extrabold" :class="statusClass(item)">
  98. {{ item.status_text }}
  99. </span>
  100. </div>
  101. <!-- 生成中遮罩 -->
  102. <div
  103. v-if="Number(item.status) === 0"
  104. class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-xs font-bold text-white"
  105. style="background-color: var(--sh-9-16-28-420)"
  106. >
  107. <span class="size-8 animate-spin rounded-full border-[3px] border-white/35 border-t-white" />
  108. <span>正在创作</span>
  109. </div>
  110. </div>
  111. <div class="px-3.5 py-3">
  112. <div class="text-[11px] text-[var(--c-a0a8b8)]">{{ formatTime(item.add_time) }}</div>
  113. </div>
  114. </div>
  115. </div>
  116. <!-- 空态 -->
  117. <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)]">
  118. <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)]">
  119. <span class="rounded-md bg-[var(--c-dceeff)]" />
  120. <span class="rounded-md bg-[var(--c-b9ddff)]" />
  121. <span class="rounded-md bg-[var(--c-b9ddff)]" />
  122. <span class="rounded-md bg-[var(--c-dceeff)]" />
  123. </div>
  124. <div class="text-base font-extrabold text-[var(--c-172033)]">还没有作品</div>
  125. <div class="mx-auto mt-2 max-w-sm text-sm text-[var(--c-7f8896)]">去工具箱生成第一张头像、照片或名字方案。</div>
  126. <NuxtLink
  127. to="/"
  128. 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)]"
  129. >
  130. 去创作
  131. </NuxtLink>
  132. </div>
  133. <!-- 加载 / 加载更多 -->
  134. <div v-if="loading" class="py-8 text-center text-sm text-[var(--c-9aa4b2)]">
  135. {{ list.length ? '加载更多...' : '加载中...' }}
  136. </div>
  137. <div v-else-if="hasMore" class="mt-8 flex justify-center">
  138. <button
  139. 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)]"
  140. @click="loadMore"
  141. >
  142. 加载更多
  143. </button>
  144. </div>
  145. <div v-else-if="list.length" class="py-8 text-center text-sm text-[var(--c-9aa4b2)]">没有更多了</div>
  146. </template>
  147. </div>
  148. <!-- 作品详情弹窗 -->
  149. <WorkDetailModal v-model="detailOpen" :work="currentWork" @deleted="handleDeleted" />
  150. </div>
  151. </template>
  152. <script setup>
  153. import dayjs from 'dayjs'
  154. useSeoMeta({
  155. title: '我的作品 - AI在线',
  156. description: '查看 AI 取名、动漫头像、证件照等全部创作记录',
  157. })
  158. const { isLogin, showLogin } = useUser()
  159. const { post } = useApi()
  160. const toast = useToast()
  161. const tabs = [
  162. { key: 'all', name: '全部' },
  163. { key: 'ainame', name: 'AI取名' },
  164. { key: 'anime', name: '动漫头像' },
  165. { key: 'photo', name: '证件照' },
  166. { key: 'oldphoto', name: '老照片' },
  167. { key: 'portrait', name: '形象照' },
  168. ]
  169. const activeTab = ref('all')
  170. const list = ref([])
  171. const total = ref(0)
  172. const page = ref(1)
  173. const pageSize = 20
  174. const loading = ref(false)
  175. const counts = ref({ all: 0, ainame: 0, anime: 0, image: 0, photo: 0, oldphoto: 0, portrait: 0 })
  176. let timer = null
  177. const hasMore = computed(() => list.value.length < total.value)
  178. function formatTime(value) {
  179. if (!value) return ''
  180. return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
  181. }
  182. async function fetchList(reset = false, silent = false) {
  183. if (!isLogin.value) {
  184. list.value = []
  185. total.value = 0
  186. return
  187. }
  188. if (loading.value) return
  189. loading.value = true
  190. if (reset) page.value = 1
  191. try {
  192. const res = await post('/work/getlist', {
  193. type: activeTab.value,
  194. page_no: page.value,
  195. page_size: pageSize,
  196. })
  197. const rows = res.list || []
  198. const data = res.data || {}
  199. list.value = reset ? rows : list.value.concat(rows)
  200. total.value = Number(data.total || rows.length)
  201. counts.value = normalizeCounts(data.counts || {})
  202. scheduleRefresh()
  203. } catch (e) {
  204. if (!silent) toast.add({ title: e.message || '作品加载失败', color: 'error' })
  205. } finally {
  206. loading.value = false
  207. }
  208. }
  209. function normalizeCounts(data = {}) {
  210. return {
  211. all: Number(data.all || 0),
  212. ainame: Number(data.ainame || 0),
  213. anime: Number(data.anime || 0),
  214. image: Number(data.image || 0),
  215. photo: Number(data.photo || 0),
  216. oldphoto: Number(data.oldphoto || 0),
  217. portrait: Number(data.portrait || 0),
  218. }
  219. }
  220. // 有生成中的作品时自动轮询刷新
  221. function scheduleRefresh() {
  222. clearTimer()
  223. if (list.value.some(item => Number(item.status) === 0)) {
  224. timer = setTimeout(() => fetchList(true, true), 3000)
  225. }
  226. }
  227. function clearTimer() {
  228. if (timer) clearTimeout(timer)
  229. timer = null
  230. }
  231. function changeTab(key) {
  232. if (activeTab.value === key) return
  233. activeTab.value = key
  234. fetchList(true)
  235. }
  236. function loadMore() {
  237. if (loading.value || !hasMore.value) return
  238. page.value += 1
  239. fetchList()
  240. }
  241. // 点击作品用弹窗查看详情(不再跳回创作页)
  242. const detailOpen = ref(false)
  243. const currentWork = ref({})
  244. function openWork(item) {
  245. // 生成中的作品也可打开,弹窗内会显示进度状态
  246. currentWork.value = item
  247. detailOpen.value = true
  248. }
  249. function resolveWorkId(item = {}) {
  250. return item.photo_open_id || item.chat_open_id || item.id || ''
  251. }
  252. function getDeleteRequest(item = {}) {
  253. const id = resolveWorkId(item)
  254. if (!id) return null
  255. if (item.work_type === 'photo') return { path: '/idphoto/del', body: { photo_open_id: id } }
  256. if (item.work_type === 'oldphoto') return { path: '/oldphoto/del', body: { photo_open_id: id } }
  257. if (item.work_type === 'portrait') return { path: '/portrait/del', body: { photo_open_id: id } }
  258. if (item.work_type === 'ainame') return { path: '/ainame/del', body: { chat_open_id: id } }
  259. if (item.work_type === 'anime') return { path: '/anime/del', body: { chat_open_id: id } }
  260. return { path: '/chat/del', body: { chat_open_id: id } }
  261. }
  262. function removeDeletedFromList(item = {}) {
  263. const id = String(resolveWorkId(item))
  264. if (!id) return
  265. const deleted = list.value.find(row => String(resolveWorkId(row)) === id)
  266. list.value = list.value.filter(row => String(resolveWorkId(row)) !== id)
  267. if (deleted) {
  268. total.value = Math.max(0, total.value - 1)
  269. const type = deleted.work_type || 'all'
  270. counts.value = {
  271. ...counts.value,
  272. all: Math.max(0, Number(counts.value.all || 0) - 1),
  273. [type]: Math.max(0, Number(counts.value[type] || 0) - 1),
  274. }
  275. }
  276. }
  277. async function deleteWork(item) {
  278. const req = getDeleteRequest(item)
  279. if (!req) return
  280. if (!window.confirm('确认删除该作品记录?删除后将不再展示。')) return
  281. try {
  282. await post(req.path, req.body)
  283. removeDeletedFromList(item)
  284. toast.add({ title: '删除成功', color: 'success' })
  285. } catch (e) {
  286. toast.add({ title: e.message || '删除失败', color: 'error' })
  287. }
  288. }
  289. function handleDeleted(item) {
  290. removeDeletedFromList(item)
  291. fetchList(true, true)
  292. }
  293. function statusClass(item) {
  294. const status = Number(item.status)
  295. if (status === 0) return 'bg-[var(--c-fff5e8)] text-[var(--c-ff8a00)]'
  296. if (status < 0) return 'bg-[var(--c-fff0ef)] text-[var(--c-f04438)]'
  297. return 'bg-[var(--c-ecfbf3)] text-[var(--c-16a15d)]'
  298. }
  299. function typeText(type) {
  300. const map = {
  301. ainame: 'AI取名',
  302. anime: '动漫头像',
  303. image: 'AI作品',
  304. photo: '证件照',
  305. oldphoto: '老照片',
  306. portrait: '形象照',
  307. }
  308. return map[type] || 'AI作品'
  309. }
  310. onMounted(() => fetchList(true))
  311. onUnmounted(() => clearTimer())
  312. watch(isLogin, (v) => { if (v) fetchList(true) })
  313. </script>