Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 

743 righe
36 KiB

  1. <template>
  2. <div class="min-h-screen bg-[var(--c-f3f7fb)]">
  3. <!-- ========== Hero (统一使用 AppHero,保证各页高度/底色一致) ========== -->
  4. <AppHero
  5. kicker="ANIME AVATAR STUDIO"
  6. title="上传自拍,生成你的动漫头像"
  7. desc="用真实对比看效果。支持竖图、半身照和普通自拍,脸部清晰会更稳定。"
  8. >
  9. <template #actions>
  10. <button
  11. type="button"
  12. class="h-10 rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_20px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-0a7ce0)] lg:h-11"
  13. @click="openCompare"
  14. >
  15. 查看对比
  16. </button>
  17. <button
  18. type="button"
  19. class="h-10 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-0b8cff)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11"
  20. @click="triggerUpload"
  21. >
  22. 上传照片
  23. </button>
  24. <button
  25. type="button"
  26. class="inline-flex h-10 items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-5b6b80)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)] lg:h-11"
  27. @click="historyOpen = true"
  28. >
  29. <UIcon name="i-lucide-history" class="size-4" />
  30. 历史记录
  31. </button>
  32. </template>
  33. <template #aside>
  34. <HeroComparePreview
  35. v-if="demoPair.original && demoPair.anime"
  36. :before="demoPair.original"
  37. :after="demoPair.anime"
  38. after-label="动漫"
  39. @open="openCompare"
  40. />
  41. </template>
  42. </AppHero>
  43. <!-- ========== Main Grid ========== -->
  44. <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8">
  45. <div class="lg:grid lg:grid-cols-12 lg:gap-8">
  46. <!-- ===== LEFT: Form (col-span-7) ===== -->
  47. <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0">
  48. <!-- 1. 参考照片 Upload Card -->
  49. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
  50. <div class="flex items-start justify-between gap-4 mb-4">
  51. <div class="min-w-0">
  52. <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">参考照片</h3>
  53. <p class="mt-1 text-xs text-[var(--c-8a97a8)]">建议使用正面、上半身、脸部无遮挡的照片</p>
  54. </div>
  55. <span class="flex-shrink-0 px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-ff6b6b)] bg-[var(--c-fff1f1)]">必选</span>
  56. </div>
  57. <!-- Upload Strip -->
  58. <div class="rounded-2xl border-2 border-[var(--c-e5edf7)] bg-[var(--c-f7faff)] p-4 flex items-center gap-4 cursor-pointer hover:border-[var(--c-1f8cff)]/40 transition-colors"
  59. @click="triggerUpload" @dragover.prevent @drop.prevent="onDrop">
  60. <div class="w-20 h-28 rounded-2xl bg-[var(--c-edf3fb)] overflow-hidden relative flex-shrink-0">
  61. <img v-if="previewImage" :src="previewImage" class="w-full h-full object-cover" alt="preview" />
  62. <div v-else class="w-full h-full flex items-center justify-center">
  63. <span class="w-9 h-9 rounded-full bg-[var(--c-1f8cff)] text-white text-2xl font-light flex items-center justify-center">+</span>
  64. </div>
  65. <div v-if="uploading" class="absolute inset-0 bg-[var(--c-111e34)]/60 text-white text-xs flex items-center justify-center">上传中</div>
  66. </div>
  67. <div class="flex-1 min-w-0">
  68. <div class="text-sm lg:text-base font-extrabold text-[var(--c-172033)]">{{ previewImage ? '已选择照片' : '选择一张清晰自拍' }}</div>
  69. <div class="mt-1.5 text-xs text-[var(--c-8a97a8)]">不用裁成正方形,竖图和半身照也可以</div>
  70. </div>
  71. <div class="w-16 h-9 rounded-full bg-[var(--c-1f8cff)] text-white text-xs font-extrabold flex items-center justify-center flex-shrink-0">{{ previewImage ? '更换' : '上传' }}</div>
  72. </div>
  73. <input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
  74. <!-- Tips -->
  75. <div class="mt-3 flex gap-2.5">
  76. <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">正面</span>
  77. <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">脸部清晰</span>
  78. <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[var(--c-526174)] bg-[var(--c-f1f5fa)]">避免遮挡</span>
  79. </div>
  80. </div>
  81. <!-- 2. 头像风格 -->
  82. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
  83. <div class="flex items-start justify-between gap-4">
  84. <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">头像风格</h3>
  85. <span class="text-xs text-[var(--c-8a97a8)] mt-1">点选切换</span>
  86. </div>
  87. <div v-if="styleLoading" class="mt-4 flex justify-center py-8">
  88. <span class="w-6 h-6 border-2 border-[var(--c-1f8cff)] border-t-transparent rounded-full animate-spin" />
  89. </div>
  90. <template v-else>
  91. <!-- 风格网格:移动 3 列 / 桌面 4 列,默认 2 行折叠 -->
  92. <div class="mt-4 grid grid-cols-3 lg:grid-cols-4 gap-2.5 lg:gap-3">
  93. <button v-for="s in visibleStyleList" :key="s.value" type="button"
  94. class="p-2 rounded-2xl border-2 transition-all text-center"
  95. :class="form.img2img_style === String(s.value)
  96. ? 'bg-[var(--c-eef7ff)] border-[var(--c-1f8cff)]'
  97. : 'bg-[var(--c-f6f9fd)] border-transparent hover:border-[var(--c-e5edf7)]'"
  98. @click="selectStyle(s)">
  99. <div class="w-full aspect-square rounded-xl overflow-hidden bg-[var(--c-eaf0f8)] flex items-center justify-center">
  100. <img v-if="s.pic" :src="s.pic" class="w-full h-full object-cover" :alt="s.name" />
  101. <span v-else class="text-xs text-[var(--c-77869a)]">{{ s.name }}</span>
  102. </div>
  103. <div class="mt-2 text-xs font-bold text-[var(--c-334155)] truncate">{{ s.name }}</div>
  104. </button>
  105. </div>
  106. <!-- 展开/收起按钮 -->
  107. <button v-if="hasMoreStyles" type="button"
  108. class="mt-3 w-full h-10 rounded-xl border border-[var(--c-e5edf7)] text-[var(--c-1f8cff)] text-sm font-bold hover:bg-[var(--c-eef7ff)] transition-colors flex items-center justify-center gap-1"
  109. @click="styleExpanded = !styleExpanded">
  110. <span>{{ styleExpanded ? '收起' : '展开全部' }}</span>
  111. <UIcon :name="styleExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-4" />
  112. </button>
  113. </template>
  114. </div>
  115. <!-- 3. 自由度 -->
  116. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
  117. <div class="flex items-start justify-between gap-4">
  118. <div>
  119. <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">自由度</h3>
  120. <p class="mt-1 text-xs text-[var(--c-8a97a8)]">{{ strengthCopy }}</p>
  121. </div>
  122. <span class="text-lg font-extrabold text-[var(--c-1f8cff)]">{{ form.img2img_strength.toFixed(2) }}</span>
  123. </div>
  124. <input type="range" min="0.1" max="1" step="0.01" v-model.number="form.img2img_strength"
  125. class="mt-4 w-full h-2 rounded-full appearance-none bg-[var(--c-e7edf5)] accent-[var(--c-1f8cff)] cursor-pointer" />
  126. <div class="mt-2 flex justify-between text-xs text-[var(--c-8a97a8)]">
  127. <span>更像原图</span><span>更有创意</span>
  128. </div>
  129. </div>
  130. <!-- 4. 画面偏好 -->
  131. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
  132. <div class="flex items-start justify-between gap-4">
  133. <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">画面偏好</h3>
  134. <span class="text-xs text-[var(--c-8a97a8)] mt-1">可选</span>
  135. </div>
  136. <div class="mt-4">
  137. <label class="block text-xs font-bold text-[var(--c-526174)] mb-2">描述词</label>
  138. <input v-model="form.content" type="text" maxlength="100" placeholder="例如:浅色背景、清爽、微笑"
  139. class="w-full h-12 rounded-xl bg-[var(--c-f6f9fd)] px-4 text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-1f8cff)]/30" />
  140. </div>
  141. <div class="mt-4">
  142. <label class="block text-xs font-bold text-[var(--c-526174)] mb-2">反面提示词</label>
  143. <input v-model="form.no" type="text" maxlength="100" placeholder="例如:模糊、变形、低清晰度"
  144. class="w-full h-12 rounded-xl bg-[var(--c-f6f9fd)] px-4 text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-1f8cff)]/30" />
  145. </div>
  146. </div>
  147. <!-- 5. Tips -->
  148. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_var(--sh-32-56-92-50)] space-y-3">
  149. <div class="flex items-center">
  150. <span class="w-7 h-5 mr-3 rounded-[14px] bg-[var(--c-eef7ff)] text-[var(--c-1f8cff)] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span>
  151. <span class="text-sm text-[var(--c-5f6f83)] leading-relaxed">上传照片后可直接生成,处理完成会进入作品详情页。</span>
  152. </div>
  153. <div class="flex items-center">
  154. <span class="w-7 h-5 mr-3 rounded-[14px] bg-[var(--c-eef7ff)] text-[var(--c-1f8cff)] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span>
  155. <span class="text-sm text-[var(--c-5f6f83)] leading-relaxed">历史记录里可以查看生成进度,也可以继续打开已完成作品。</span>
  156. </div>
  157. </div>
  158. <!-- Desktop Submit -->
  159. <div class="hidden lg:flex items-center gap-3 rounded-2xl bg-[var(--c-ffffff)] p-4 shadow-[0_10px_28px_var(--sh-32-56-92-50)]">
  160. <div class="flex-1 min-w-0">
  161. <div class="text-sm font-bold text-[var(--c-172033)] whitespace-nowrap">{{ balanceText }}</div>
  162. <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[var(--c-f04438)]">点数不足,请前往充值</div>
  163. </div>
  164. <button type="button"
  165. class="h-14 flex-shrink-0 rounded-full border border-[var(--c-e5edf7)] px-6 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)] flex items-center gap-1.5"
  166. @click="historyOpen = true">
  167. <UIcon name="i-lucide-history" class="size-4" />
  168. 历史记录
  169. </button>
  170. <button type="button"
  171. class="px-10 h-14 rounded-full text-white font-bold text-base shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]"
  172. :class="submitting ? 'bg-[var(--c-1979e6)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] hover:opacity-90'"
  173. :disabled="!canSubmit || submitting" @click="handleSubmit">
  174. <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
  175. <span>{{ submitting ? '生成中...' : submitText }}</span>
  176. </button>
  177. </div>
  178. </div>
  179. <!-- ===== RIGHT: Result (col-span-5, sticky) ===== -->
  180. <div class="lg:col-span-5">
  181. <!-- 吸顶偏移 = header 高度(64px) + 间距,避免被导航遮挡 -->
  182. <div class="space-y-4 lg:sticky lg:top-20 lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto">
  183. <!-- Empty -->
  184. <div v-if="!resultState.visible" class="bg-[var(--c-ffffff)] rounded-2xl p-8 lg:p-10 shadow-sm text-center min-h-[300px] flex flex-col items-center justify-center">
  185. <UIcon name="i-lucide-image" class="size-12 text-[var(--c-a0aabb)] mb-4" />
  186. <div class="text-base font-medium text-[var(--c-172033)] mb-1">等待生成</div>
  187. <div class="text-sm text-[var(--c-a0aabb)]">上传照片并选择风格后</div>
  188. <div class="text-sm text-[var(--c-a0aabb)]">点击"立即生成"即可看到效果</div>
  189. </div>
  190. <!-- Loading -->
  191. <div v-if="resultState.loading" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm">
  192. <div class="text-base font-bold text-[var(--c-172033)] mb-1">AI 正在创作中</div>
  193. <div class="text-sm text-[var(--c-7f8896)] mb-6">预计需要 10-30 秒,请耐心等待...</div>
  194. <div class="rounded-2xl bg-[var(--c-f7f9fd)] py-10 flex flex-col items-center">
  195. <div class="flex gap-1.5 mb-4">
  196. <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot" />
  197. <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot2" />
  198. <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-1f8cff)] animate-pulse-dot3" />
  199. </div>
  200. <div class="text-sm font-bold text-[var(--c-172033)]">正在生成你的动漫头像</div>
  201. <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div>
  202. </div>
  203. </div>
  204. <!-- Error -->
  205. <div v-if="resultState.error" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
  206. <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" />
  207. <div class="text-base font-medium text-[var(--c-f04438)] mb-2">生成失败</div>
  208. <div class="text-sm text-[var(--c-7f8896)] mb-4">{{ resultState.errorMsg }}</div>
  209. <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton>
  210. </div>
  211. <!-- Success -->
  212. <div v-if="resultState.success" class="space-y-4">
  213. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-sm">
  214. <div class="text-base font-bold text-[var(--c-172033)] mb-4">效果对比</div>
  215. <div class="grid grid-cols-2 gap-3">
  216. <div>
  217. <div class="text-xs text-[var(--c-8a95a6)] mb-2">原图</div>
  218. <img v-if="previewImage" :src="previewImage" class="w-full aspect-square rounded-xl object-cover bg-[var(--c-f7f9fd)]" alt="original" />
  219. </div>
  220. <div>
  221. <div class="text-xs text-[var(--c-8a95a6)] mb-2">动漫头像</div>
  222. <div class="flex aspect-square w-full items-center justify-center overflow-hidden rounded-xl border-2 border-[var(--c-1f8cff)] bg-[var(--c-f7f9fd)]">
  223. <img
  224. v-if="genResultUrl && !resultImageFailed"
  225. :src="genResultUrl"
  226. class="h-full w-full object-cover"
  227. alt="动漫头像生成结果"
  228. @error="handleResultImageError"
  229. >
  230. <div v-else class="px-4 text-center">
  231. <UIcon name="i-lucide-image-off" class="mx-auto mb-2 size-8 text-[var(--c-a0aabb)]" />
  232. <div class="text-xs leading-relaxed text-[var(--c-7f8896)]">结果图片暂时无法加载</div>
  233. </div>
  234. </div>
  235. </div>
  236. </div>
  237. <div class="mt-5 flex gap-3">
  238. <button type="button"
  239. class="flex-1 h-12 rounded-full border-2 border-[var(--c-1f8cff)] text-[var(--c-1f8cff)] font-bold hover:bg-[var(--c-eef7ff)] transition-colors flex items-center justify-center"
  240. @click="openResultCompare">
  241. <UIcon name="i-lucide-split-square-horizontal" class="size-4 mr-1.5" />
  242. 对比查看
  243. </button>
  244. <button type="button"
  245. class="flex-1 h-12 rounded-full bg-[var(--c-1f8cff)] hover:bg-[var(--c-1979e6)] text-white font-bold text-base shadow-[0_8px_20px_var(--sh-31-140-255-200)] transition-colors flex items-center justify-center"
  246. @click="downloadResult">
  247. <UIcon name="i-lucide-download" class="size-4 mr-1.5" />
  248. 下载头像
  249. </button>
  250. </div>
  251. </div>
  252. <div class="flex gap-3">
  253. <button type="button"
  254. class="flex-1 h-12 rounded-full border-2 border-[var(--c-1f8cff)] text-[var(--c-1f8cff)] font-bold hover:bg-[var(--c-eef7ff)] transition-colors"
  255. :disabled="submitting" @click="handleSubmit">重新生成</button>
  256. </div>
  257. </div>
  258. </div>
  259. </div>
  260. </div>
  261. </div>
  262. <!-- ========== Mobile Bottom Bar ========== -->
  263. <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-[var(--c-ffffff)]/95 backdrop-blur shadow-[0_-10px_30px_var(--sh-32-56-92-100)]"
  264. style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))">
  265. <div class="flex items-center justify-between mb-2.5 gap-3 text-sm">
  266. <div class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div>
  267. <button v-if="isBalanceInsufficient" class="text-[var(--c-1f8cff)] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button>
  268. </div>
  269. <div class="flex gap-3">
  270. <button class="w-[120px] h-12 rounded-full bg-[var(--c-eef7ff)] text-[var(--c-1f8cff)] text-sm font-extrabold" @click="goHistory">历史记录</button>
  271. <button class="flex-1 h-12 rounded-full text-white text-sm font-extrabold shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-opacity flex items-center justify-center"
  272. :class="(canSubmit && !submitting) ? 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] opacity-60'"
  273. :disabled="!canSubmit || submitting" @click="handleSubmit">
  274. <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
  275. {{ submitting ? '生成中...' : submitText }}
  276. </button>
  277. </div>
  278. </div>
  279. <!-- ========== 历史记录抽屉 + 详情弹窗 ========== -->
  280. <WorkHistoryDrawer v-model="historyOpen" type="anime" @select="openHistoryItem" />
  281. <WorkDetailModal v-model="detailOpen" :work="currentWork" />
  282. <!-- ========== Compare Modal (大图滑块对比,支持 demo 和生成结果) ========== -->
  283. <Teleport to="body">
  284. <Transition name="modal">
  285. <div v-if="compareVisible" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
  286. <div class="absolute inset-0 bg-[var(--c-0f172a)]/60" @click="closeCompare" />
  287. <div class="relative bg-[var(--c-ffffff)] rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
  288. <div class="flex items-center justify-between px-5 py-4 border-b border-[var(--c-f3f4f6)]">
  289. <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTarget === 'result' ? '生成结果对比' : '效果对比' }}</h3>
  290. <button class="text-[var(--c-a0aabb)] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--c-f3f4f6)]" @click="closeCompare">×</button>
  291. </div>
  292. <div class="p-5">
  293. <div ref="largeCompareRef" class="relative w-full aspect-[3/4] max-h-[70vh] rounded-2xl overflow-hidden bg-[var(--c-dfe8f4)] cursor-ew-resize select-none"
  294. @mousedown="onCompareStart" @touchstart.passive="onCompareStart">
  295. <!-- 动漫图(底层,全尺寸) -->
  296. <img v-if="comparePair.anime" :src="comparePair.anime" class="absolute inset-0 w-full h-full object-cover pointer-events-none" alt="anime" />
  297. <!-- 原图(上层,用 clip-path 裁剪,与底层完全重叠) -->
  298. <img v-if="comparePair.original" :src="comparePair.original" class="absolute inset-0 w-full h-full object-cover pointer-events-none"
  299. :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }" alt="original" />
  300. <!-- 分割线 + 拖拽手柄 -->
  301. <div class="absolute top-0 bottom-0 w-1 -ml-px bg-[var(--c-ffffff)]/95 shadow-[0_0_12px_var(--sh-20-37-66-220)] pointer-events-none" :style="{ left: comparePercent + '%' }">
  302. <div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-14 rounded-full bg-[var(--c-ffffff)] shadow-[0_8px_18px_var(--sh-20-37-66-220)] flex items-center justify-center text-[var(--c-1f8cff)] text-lg font-extrabold gap-px">
  303. <span>‹</span><span>›</span>
  304. </div>
  305. </div>
  306. <div class="absolute top-3 left-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[var(--c-0f172a)]/50 pointer-events-none">原图</div>
  307. <div class="absolute top-3 right-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[var(--c-0f172a)]/50 pointer-events-none">{{ compareTarget === 'result' ? '生成图' : '动漫图' }}</div>
  308. </div>
  309. <div class="mt-3 text-center text-xs text-[var(--c-64748b)]">左右拖动中间按钮查看变化</div>
  310. </div>
  311. </div>
  312. </div>
  313. </Transition>
  314. </Teleport>
  315. </div>
  316. </template>
  317. <script setup>
  318. import { computed, reactive, ref, onMounted, onUnmounted } from 'vue'
  319. const _api = useApi()
  320. const api = _api // _api 是 { get, post, request },这里直接别名保持调用方式不变
  321. await useLegacyAppSeo({
  322. appKey: 'anime_avatar',
  323. fallbackTitle: 'AI漫画头像-动漫头像生成',
  324. fallbackDescription: 'AI生成漫画头像是一种新兴的技术,人工智能通过用户上传的图像处理技术,可以快速生成具有卡通、动漫等不同风格的头像。这种技术可以应用于社交媒体、游戏和虚拟现实等领域,为用户提供个性化、有趣的头像选择。',
  325. fallbackKeywords: '漫画头像、动漫头像、漫画头像生成、ai动漫头像',
  326. canonicalPath: '/draw/anime/',
  327. })
  328. const user = useUser()
  329. const router = useRouter()
  330. const { uploadImage } = useUpload()
  331. const toast = useToast()
  332. function showToast(message, color = 'red') {
  333. toast.add({ title: message, color })
  334. }
  335. const ANIME_BILLING_MODEL = 'tencent_anime'
  336. // ==================== Form ====================
  337. const form = reactive({
  338. imgs: [], content: '', ai_type: 3, style_type: 1, ar: '1:1', no: '',
  339. model: ANIME_BILLING_MODEL, model_type: 6, light: '', text: '', speed: '',
  340. img2img_style: '201', age: 80, change_sex_type: '0', img2img_strength: 0.6, num: 1,
  341. app_name: 'anime-avatar',
  342. })
  343. // ==================== State ====================
  344. const fileInput = ref(null)
  345. const previewImage = ref('')
  346. const uploading = ref(false)
  347. const submitting = ref(false)
  348. const styleList = ref([{ value: '201', name: '默认', model: 2, pic: '' }])
  349. const styleLoading = ref(false)
  350. const styleExpanded = ref(false)
  351. const STYLE_COLLAPSED_COUNT = 8 // 2 行 × 4 列(桌面)
  352. const visibleStyleList = computed(() =>
  353. styleExpanded.value ? styleList.value : styleList.value.slice(0, STYLE_COLLAPSED_COUNT)
  354. )
  355. const hasMoreStyles = computed(() => styleList.value.length > STYLE_COLLAPSED_COUNT)
  356. const balance = reactive({ points: 0 })
  357. const quote = reactive({ points: 0 })
  358. const chatOpenId = ref('')
  359. const demoPair = ref({ original: '', anime: '' })
  360. const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' })
  361. const genResultUrl = ref('')
  362. const resultImageFailed = ref(false)
  363. let pollTimer = null
  364. let countTimer = null
  365. const pollCountdown = ref(3)
  366. // ==================== Computed ====================
  367. const canSubmit = computed(() => form.imgs.length > 0 && !uploading.value)
  368. const currentPoints = computed(() => Number(balance.points || 0))
  369. const isBalanceInsufficient = computed(() =>
  370. user.isLogin.value && Number(quote.points || 0) > 0 && currentPoints.value >= 0 && currentPoints.value < Number(quote.points || 0)
  371. )
  372. const balanceText = computed(() => {
  373. const c = Number(quote.points || 0)
  374. if (!user.isLogin.value) return c ? `动漫头像 · ${c}点/张` : '动漫头像'
  375. return c ? `余额${currentPoints.value}点 · 预计消耗${c}点` : `余额${currentPoints.value}点`
  376. })
  377. const submitText = computed(() => {
  378. const c = Number(quote.points || 0)
  379. if (!user.isLogin.value) return c ? `登录后生成 · ${c}点` : '登录后生成'
  380. return c ? `立即生成 · ${c}点` : '立即生成'
  381. })
  382. const strengthCopy = computed(() => '推荐 0.6 - 0.8,保留人脸特征')
  383. // ==================== Upload ====================
  384. function triggerUpload() {
  385. if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return }
  386. fileInput.value?.click()
  387. }
  388. function onFileChange(e) {
  389. const f = e.target.files?.[0]
  390. if (f) handleFile(f)
  391. e.target.value = ''
  392. }
  393. function onDrop(e) {
  394. const f = e.dataTransfer?.files?.[0]
  395. if (f) handleFile(f)
  396. }
  397. async function handleFile(file) {
  398. if (uploading.value) return
  399. uploading.value = true
  400. try {
  401. previewImage.value = URL.createObjectURL(file)
  402. const r = await uploadImage(file)
  403. if (r.data?.url) {
  404. form.imgs = [r.data.url]
  405. if (r.data.width) form.width = r.data.width
  406. if (r.data.height) form.height = r.data.height
  407. } else {
  408. previewImage.value = ''
  409. showToast(r.msg || '上传失败')
  410. }
  411. } catch (e) {
  412. previewImage.value = ''
  413. form.imgs = []
  414. showToast(e.msg || e.message || '上传失败')
  415. } finally {
  416. uploading.value = false
  417. }
  418. }
  419. // ==================== Style List (无需登录) ====================
  420. async function loadStyles() {
  421. styleLoading.value = true
  422. try {
  423. const r = await api.post('/anime/getstylelist', { model: '2' })
  424. const rows = Array.isArray(r.list) ? r.list : (Array.isArray(r.data) ? r.data : [])
  425. const list = rows.map((item) => ({
  426. ...item, value: String(item.value),
  427. pic: item.pic || item.img || item.image || item.cover || item.icon || item.url || '',
  428. }))
  429. if (list.length) {
  430. styleList.value = list
  431. if (!form.img2img_style || !list.some(s => s.value === form.img2img_style)) {
  432. selectStyle(list[0], false)
  433. }
  434. }
  435. } catch (e) { console.error('[anime-avatar] loadStyles failed:', e) }
  436. styleLoading.value = false
  437. }
  438. function selectStyle(item, resetStrength = true) {
  439. form.img2img_style = String(item.value)
  440. form.ai_type = 3
  441. if (resetStrength) form.img2img_strength = 0.6
  442. }
  443. // ==================== Demo (for hero compare) ====================
  444. async function loadDemo() {
  445. try {
  446. const r = await api.post('/common/getconfig', { keys: 'anime_demo_v2,anime_demo' })
  447. const rows = Array.isArray(r.list) ? r.list : []
  448. const v2 = rows.find(it => it.key === 'anime_demo_v2')
  449. const old = rows.find(it => it.key === 'anime_demo')
  450. let list = []
  451. if (v2 && v2.value) {
  452. try {
  453. const data = typeof v2.value === 'string' ? JSON.parse(v2.value) : v2.value
  454. const arr = Array.isArray(data) ? data : [data]
  455. list = arr.map(it => ({
  456. original: it.original || it.before || it.source || '',
  457. anime: it.anime || it.after || it.result || '',
  458. })).filter(it => it.original && it.anime).slice(0, 1)
  459. } catch {}
  460. }
  461. if (!list.length && old && old.value) {
  462. const pics = String(old.value).split(',').map(s => s.trim()).filter(Boolean)
  463. if (pics.length >= 2) list = [{ original: pics[0], anime: pics[1] }]
  464. }
  465. if (list.length) demoPair.value = list[0]
  466. } catch (e) { console.error('[anime-avatar] loadDemo failed:', e) }
  467. }
  468. // ==================== Billing ====================
  469. async function loadBilling() {
  470. try {
  471. const r = await api.post('/billing/config', { app_key: 'anime-avatar', model: ANIME_BILLING_MODEL })
  472. if (r.code === 0 && r.data) {
  473. quote.points = r.data.quote?.points || 0
  474. if (r.data.balance) balance.points = r.data.balance.points || r.data.balance.point_balance || 0
  475. }
  476. } catch (e) { console.error('[anime-avatar] loadBilling failed:', e) }
  477. }
  478. async function loadBalance() {
  479. try {
  480. const r = await api.post('/user/getbalance', {})
  481. if (r.code === 0 && r.data) balance.points = r.data.points || r.data.point_balance || 0
  482. } catch (e) { console.error('[anime-avatar] loadBalance failed:', e) }
  483. // 同步 header 显示的点数
  484. user.refreshBalance()
  485. }
  486. onMounted(() => {
  487. loadStyles()
  488. loadBilling()
  489. loadDemo()
  490. if (user.isLogin.value) loadBalance()
  491. })
  492. onUnmounted(() => clearPoll())
  493. // ==================== 历史记录 / 详情 ====================
  494. const historyOpen = ref(false)
  495. const detailOpen = ref(false)
  496. const currentWork = ref({})
  497. function openHistoryItem(item) {
  498. currentWork.value = item
  499. detailOpen.value = true
  500. }
  501. // ==================== Compare Modal ====================
  502. const compareVisible = ref(false)
  503. const comparePercent = ref(50)
  504. const largeCompareRef = ref(null)
  505. const compareTarget = ref('demo') // 'demo' | 'result'
  506. let dragging = false
  507. const comparePair = computed(() => {
  508. if (compareTarget.value === 'result' && previewImage.value && genResultUrl.value) {
  509. return { original: previewImage.value, anime: genResultUrl.value }
  510. }
  511. return demoPair.value
  512. })
  513. function openCompare() {
  514. if (!demoPair.value.original || !demoPair.value.anime) { showToast('暂无对比图'); return }
  515. comparePercent.value = 50
  516. compareTarget.value = 'demo'
  517. compareVisible.value = true
  518. }
  519. function openResultCompare() {
  520. if (!previewImage.value) {
  521. showToast('原图暂不可用,请重新上传')
  522. return
  523. }
  524. if (!genResultUrl.value || resultImageFailed.value) {
  525. showToast('结果图片暂不可用,请稍后重试')
  526. return
  527. }
  528. comparePercent.value = 50
  529. compareTarget.value = 'result'
  530. compareVisible.value = true
  531. }
  532. function closeCompare() { compareVisible.value = false; dragging = false }
  533. function onCompareStart(e) {
  534. dragging = true
  535. updateComparePercent(e)
  536. window.addEventListener('mousemove', onCompareMove)
  537. window.addEventListener('mouseup', onCompareEnd)
  538. window.addEventListener('touchmove', onCompareMove, { passive: false })
  539. window.addEventListener('touchend', onCompareEnd)
  540. }
  541. function onCompareMove(e) {
  542. if (!dragging) return
  543. if (e.cancelable) e.preventDefault()
  544. updateComparePercent(e.touches?.[0] || e)
  545. }
  546. function onCompareEnd() {
  547. dragging = false
  548. window.removeEventListener('mousemove', onCompareMove)
  549. window.removeEventListener('mouseup', onCompareEnd)
  550. window.removeEventListener('touchmove', onCompareMove)
  551. window.removeEventListener('touchend', onCompareEnd)
  552. }
  553. function updateComparePercent(e) {
  554. if (!largeCompareRef.value) return
  555. const rect = largeCompareRef.value.getBoundingClientRect()
  556. const x = (e.clientX || 0) - rect.left
  557. const percent = (x / rect.width) * 100
  558. comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1))))
  559. }
  560. function resolveResultImage(data = {}) {
  561. const candidates = [
  562. data.content,
  563. data.answer,
  564. data.result_url,
  565. data.resultUrl,
  566. data.image_url,
  567. data.image,
  568. data.url,
  569. ]
  570. for (const candidate of candidates) {
  571. if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
  572. if (candidate && typeof candidate === 'object') {
  573. const nested = candidate.url || candidate.image_url || candidate.result_url
  574. if (typeof nested === 'string' && nested.trim()) return nested.trim()
  575. }
  576. }
  577. return ''
  578. }
  579. function handleResultImageError() {
  580. resultImageFailed.value = true
  581. showToast('结果图片加载失败,请稍后重试')
  582. }
  583. // ==================== Submit ====================
  584. function clearPoll() {
  585. clearTimeout(pollTimer)
  586. clearInterval(countTimer)
  587. }
  588. async function startPoll() {
  589. clearPoll()
  590. pollCountdown.value = 3
  591. countTimer = setInterval(() => { pollCountdown.value = Math.max(0, pollCountdown.value - 1) }, 1000)
  592. pollTimer = setTimeout(async () => {
  593. try {
  594. const r = await api.post('/anime/getdetail', { chat_open_id: chatOpenId.value })
  595. const d = r.data || {}
  596. const s = Number(d.is_suc)
  597. if (s > 0) {
  598. const resultUrl = resolveResultImage(d)
  599. if (!resultUrl) {
  600. resultState.value = {
  601. visible: true,
  602. loading: false,
  603. error: true,
  604. success: false,
  605. errorMsg: '生成已完成,但未获取到结果图片,请稍后重试',
  606. }
  607. } else {
  608. genResultUrl.value = resultUrl
  609. resultImageFailed.value = false
  610. resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' }
  611. }
  612. clearPoll()
  613. loadBalance()
  614. } else if (s < 0) {
  615. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: d.err_msg || d.msg || '生成失败,请重试' }
  616. clearPoll()
  617. } else {
  618. startPoll()
  619. }
  620. } catch (e) {
  621. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: e.msg || e.message || '查询失败' }
  622. }
  623. }, 3000)
  624. }
  625. async function handleSubmit() {
  626. if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return }
  627. if (uploading.value) { showToast('图片正在上传'); return }
  628. if (!form.imgs.length) { showToast('请上传参考图'); return }
  629. if (submitting.value) return
  630. submitting.value = true
  631. resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' }
  632. genResultUrl.value = ''
  633. resultImageFailed.value = false
  634. try {
  635. const params = { ...form, imgs: form.imgs.slice(0, 1).join(',') }
  636. const r = await api.post('/anime/send', params)
  637. if (r.code === 1011) {
  638. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: '点数不足,请充值后重试' }
  639. submitting.value = false
  640. return
  641. }
  642. if (r.code === 1012) {
  643. form.content = ''
  644. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: r.msg || '内容审核不通过,请调整后重试' }
  645. submitting.value = false
  646. return
  647. }
  648. if (r.code !== 0) {
  649. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: r.msg || '提交失败' }
  650. submitting.value = false
  651. return
  652. }
  653. chatOpenId.value = r.data?.answer_chat_open_id || r.data?.chat_open_id || ''
  654. if (!chatOpenId.value) {
  655. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: '未获取到任务ID' }
  656. submitting.value = false
  657. return
  658. }
  659. startPoll()
  660. } catch (e) {
  661. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: e.msg || e.message || '提交失败' }
  662. } finally {
  663. submitting.value = false
  664. }
  665. }
  666. async function downloadResult() {
  667. if (!genResultUrl.value || resultImageFailed.value) {
  668. showToast('结果图片暂不可用,请稍后重试')
  669. return
  670. }
  671. try {
  672. const r = await fetch(genResultUrl.value)
  673. const b = await r.blob()
  674. const a = document.createElement('a')
  675. a.href = URL.createObjectURL(b)
  676. a.download = `anime-avatar-${Date.now()}.png`
  677. a.click()
  678. URL.revokeObjectURL(a.href)
  679. } catch {
  680. window.open(genResultUrl.value, '_blank')
  681. }
  682. }
  683. function goRecharge() { router.push('/recharge') }
  684. // 历史记录改为抽屉,不再跳转页面
  685. function goHistory() { historyOpen.value = true }
  686. </script>
  687. <style scoped>
  688. .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; }
  689. .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; }
  690. .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; }
  691. @keyframes dotPulse {
  692. 0%, 100% { opacity: 0.35; transform: scale(0.82); }
  693. 50% { opacity: 1; transform: scale(1); }
  694. }
  695. .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
  696. .modal-enter-from, .modal-leave-to { opacity: 0; }
  697. </style>