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

645 行
32 KiB

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