Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 

922 lignes
40 KiB

  1. <template>
  2. <div class="min-h-screen bg-[var(--c-f3f7fb)]">
  3. <!-- ========== Hero ========== -->
  4. <AppHero
  5. kicker="PORTRAIT PHOTO STUDIO"
  6. title="形象照 / 职业照 / 写真"
  7. desc="上传一张人像照片,AI 重绘服装与背景,生成商务形象照、日常职业照或艺术写真。"
  8. >
  9. <template #actions>
  10. <button
  11. v-if="hasDemo"
  12. type="button"
  13. class="h-10 cursor-pointer 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"
  14. @click="openDemoCompare"
  15. >
  16. 查看对比
  17. </button>
  18. <button
  19. type="button"
  20. class="h-10 cursor-pointer 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"
  21. @click="triggerUpload"
  22. >
  23. 上传照片
  24. </button>
  25. <button
  26. type="button"
  27. class="inline-flex h-10 cursor-pointer 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"
  28. @click="historyOpen = true"
  29. >
  30. <UIcon name="i-lucide-history" class="size-4" />
  31. 历史记录
  32. </button>
  33. </template>
  34. <template #aside>
  35. <HeroComparePreview
  36. v-if="hasDemo"
  37. :before="demoPair.original"
  38. :after="demoPair.result"
  39. after-label="艺术"
  40. @open="openDemoCompare"
  41. />
  42. </template>
  43. </AppHero>
  44. <!-- ========== Main Grid ========== -->
  45. <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:py-8">
  46. <div class="lg:grid lg:grid-cols-12 lg:gap-8">
  47. <!-- ===== LEFT: 表单 ===== -->
  48. <div class="space-y-5 pb-28 lg:col-span-7 lg:pb-0">
  49. <!-- 1. 上传 -->
  50. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
  51. <div class="mb-4 flex items-start justify-between gap-4">
  52. <div class="min-w-0">
  53. <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">人像照片</h3>
  54. <p class="mt-1 text-xs text-[var(--c-8a97a8)]">建议正面、五官无遮挡、光线均匀的半身照,单张不超过 10M</p>
  55. </div>
  56. <span class="flex-shrink-0 rounded-full bg-[var(--c-fff1f1)] px-3 py-1 text-[11px] font-bold text-[var(--c-ff6b6b)]">必选</span>
  57. </div>
  58. <div
  59. class="flex cursor-pointer items-center gap-4 rounded-2xl border-2 border-[var(--c-e5edf7)] bg-[var(--c-f7faff)] p-4 transition-colors hover:border-[var(--c-1f8cff)]/40"
  60. role="button"
  61. tabindex="0"
  62. @click="triggerUpload"
  63. @keydown.enter.prevent="triggerUpload"
  64. @keydown.space.prevent="triggerUpload"
  65. @dragover.prevent
  66. @drop.prevent="onDrop"
  67. >
  68. <div class="relative h-28 w-20 flex-shrink-0 overflow-hidden rounded-2xl bg-[var(--c-edf3fb)]">
  69. <img v-if="previewImage" :src="previewImage" class="h-full w-full object-cover" alt="已选照片预览">
  70. <div v-else class="flex h-full w-full items-center justify-center">
  71. <span class="flex size-9 items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-2xl font-light text-white">+</span>
  72. </div>
  73. </div>
  74. <div class="min-w-0 flex-1">
  75. <div class="text-sm font-extrabold text-[var(--c-172033)] lg:text-base">{{ previewImage ? '已选择照片' : '上传正面清晰的人像照片' }}</div>
  76. <div class="mt-1.5 text-xs text-[var(--c-8a97a8)]">支持自拍与半身照;也可以直接把文件拖进来</div>
  77. </div>
  78. <div class="flex h-9 w-16 flex-shrink-0 items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-xs font-extrabold text-white">
  79. {{ previewImage ? '更换' : '上传' }}
  80. </div>
  81. </div>
  82. <input ref="fileInput" type="file" accept="image/*" class="hidden" aria-label="选择人像照片" @change="onFileChange">
  83. </div>
  84. <!-- 2. 生成模型(只有一个模型时不展示,后续多模型自动出现) -->
  85. <div v-if="models.length > 1" class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
  86. <div class="flex items-start justify-between gap-4">
  87. <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">生成模型</h3>
  88. <span class="mt-1 text-xs text-[var(--c-8a97a8)]">不同模型价格可能不同</span>
  89. </div>
  90. <div class="mt-4 grid gap-2.5 sm:grid-cols-2">
  91. <button
  92. v-for="item in models"
  93. :key="item.key"
  94. type="button"
  95. class="cursor-pointer rounded-2xl border-2 p-4 text-left transition-colors"
  96. :class="activeModel === item.key
  97. ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)]'
  98. : 'border-transparent bg-[var(--c-f6f9fd)] hover:border-[var(--c-e5edf7)]'"
  99. :aria-pressed="activeModel === item.key"
  100. @click="activeModel = item.key"
  101. >
  102. <span class="block text-sm font-extrabold text-[var(--c-172033)]">{{ item.name }}</span>
  103. <span v-if="item.desc" class="mt-1 block text-xs leading-relaxed text-[var(--c-8a97a8)]">{{ item.desc }}</span>
  104. </button>
  105. </div>
  106. </div>
  107. <!-- 3. 照片风格 + 选项 -->
  108. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
  109. <div class="flex items-start justify-between gap-4">
  110. <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">照片风格</h3>
  111. <span class="mt-1 text-xs text-[var(--c-8a97a8)]">选择生成方向</span>
  112. </div>
  113. <!-- 加载骨架:>300ms 的等待都要有反馈 -->
  114. <div v-if="configLoading" class="mt-4 space-y-2.5">
  115. <div v-for="i in 3" :key="i" class="h-[76px] animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" />
  116. </div>
  117. <template v-else>
  118. <div class="mt-4 grid gap-2.5 sm:grid-cols-3">
  119. <button
  120. v-for="item in styles"
  121. :key="item.key"
  122. type="button"
  123. class="cursor-pointer rounded-2xl border-2 p-4 text-left transition-colors"
  124. :class="activeStyle === item.key
  125. ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)]'
  126. : 'border-transparent bg-[var(--c-f6f9fd)] hover:border-[var(--c-e5edf7)]'"
  127. :aria-pressed="activeStyle === item.key"
  128. @click="selectStyle(item.key)"
  129. >
  130. <span class="block text-sm font-extrabold text-[var(--c-172033)]">{{ item.name }}</span>
  131. <span class="mt-1 block text-xs leading-relaxed text-[var(--c-8a97a8)]">{{ item.desc }}</span>
  132. </button>
  133. </div>
  134. <div v-for="group in currentGroups" :key="group.key" class="mt-5">
  135. <div class="text-sm font-extrabold text-[var(--c-172033)]">{{ group.name }}</div>
  136. <div class="mt-2.5 flex flex-wrap gap-2.5">
  137. <button
  138. v-for="item in group.items"
  139. :key="item.key"
  140. type="button"
  141. class="h-10 cursor-pointer rounded-full border-2 px-4 text-xs font-bold transition-colors"
  142. :class="options[group.key] === item.key
  143. ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]'
  144. : 'border-transparent bg-[var(--c-f6f9fd)] text-[var(--c-526174)] hover:border-[var(--c-e5edf7)]'"
  145. :aria-pressed="options[group.key] === item.key"
  146. @click="options[group.key] = item.key"
  147. >
  148. {{ item.name }}
  149. </button>
  150. </div>
  151. </div>
  152. </template>
  153. </div>
  154. <!-- 4. 比例与张数 -->
  155. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
  156. <div class="flex items-start justify-between gap-4">
  157. <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">{{ quantityEnabled ? '图片比例与张数' : '图片比例' }}</h3>
  158. <span class="mt-1 text-xs font-extrabold text-[var(--c-0b8cff)]">{{ quantityPriceText }}</span>
  159. </div>
  160. <div class="mt-4 flex flex-wrap gap-2.5">
  161. <button
  162. v-for="item in ratios"
  163. :key="item.key"
  164. type="button"
  165. class="h-10 w-[76px] cursor-pointer rounded-xl border-2 text-xs font-extrabold transition-colors"
  166. :class="activeRatio === item.key
  167. ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]'
  168. : 'border-transparent bg-[var(--c-f6f9fd)] text-[var(--c-526174)] hover:border-[var(--c-e5edf7)]'"
  169. :aria-pressed="activeRatio === item.key"
  170. @click="activeRatio = item.key"
  171. >
  172. {{ item.name }}
  173. </button>
  174. </div>
  175. <div v-if="configLoading" class="mt-5 h-14 animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" />
  176. <div v-else-if="quantityEnabled" class="mt-5 flex items-center gap-4">
  177. <span class="text-sm font-bold text-[var(--c-526174)]">生成张数</span>
  178. <div class="flex items-center gap-3">
  179. <button
  180. type="button"
  181. class="flex size-10 cursor-pointer items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-lg font-extrabold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)] disabled:cursor-not-allowed disabled:opacity-50"
  182. aria-label="减少张数"
  183. :disabled="quantity <= 1"
  184. @click="quantity = Math.max(1, quantity - 1)"
  185. >
  186. -
  187. </button>
  188. <span class="min-w-8 text-center text-lg font-extrabold text-[var(--c-172033)]">{{ quantity }}</span>
  189. <button
  190. type="button"
  191. class="flex size-10 cursor-pointer items-center justify-center rounded-xl bg-[var(--c-eef7ff)] text-lg font-extrabold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e0f0ff)] disabled:cursor-not-allowed disabled:opacity-50"
  192. aria-label="增加张数"
  193. :disabled="quantity >= maxCount"
  194. @click="quantity = Math.min(maxCount, quantity + 1)"
  195. >
  196. +
  197. </button>
  198. </div>
  199. <span class="text-xs text-[var(--c-8a97a8)]">一次最多 {{ maxCount }} 张,按张扣点,失败的不扣</span>
  200. </div>
  201. <div v-else class="mt-5 rounded-2xl bg-[var(--c-f7faff)] px-4 py-3">
  202. <div class="flex items-center justify-between gap-3">
  203. <span class="text-sm font-bold text-[var(--c-526174)]">生成张数</span>
  204. <span class="rounded-full bg-[var(--c-eef7ff)] px-3 py-1 text-xs font-extrabold text-[var(--c-0b8cff)]">固定 1 张</span>
  205. </div>
  206. <p class="mt-1 text-xs leading-relaxed text-[var(--c-8a97a8)]">后台已关闭多张生成,本次只生成 1 张。</p>
  207. </div>
  208. </div>
  209. <!-- 5. 补充描述 -->
  210. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
  211. <div class="flex items-start justify-between gap-4">
  212. <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">补充描述</h3>
  213. <span class="mt-1 text-xs text-[var(--c-8a97a8)]">选填</span>
  214. </div>
  215. <label class="mt-4 block text-xs font-bold text-[var(--c-526174)]" for="portrait-extra">职业、服装、背景等客观信息</label>
  216. <input
  217. id="portrait-extra"
  218. v-model="extraPrompt"
  219. type="text"
  220. maxlength="100"
  221. placeholder="例如:程序员、深灰西装、浅色背景"
  222. class="mt-2 h-12 w-full 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"
  223. >
  224. <p class="mt-2 text-xs text-[var(--c-8a97a8)]">只用来补充客观信息,风格由上方选项决定。</p>
  225. </div>
  226. <!-- 6. 说明 -->
  227. <div v-if="tips.length" class="space-y-3 rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
  228. <div v-for="(tip, index) in tips" :key="tip" class="flex items-start">
  229. <span class="mr-3 mt-0.5 flex h-5 w-7 flex-shrink-0 items-center justify-center rounded-[14px] bg-[var(--c-eef7ff)] text-[11px] font-extrabold text-[var(--c-1f8cff)]">
  230. {{ String(index + 1).padStart(2, '0') }}
  231. </span>
  232. <span class="text-sm leading-relaxed text-[var(--c-5f6f83)]">{{ tip }}</span>
  233. </div>
  234. </div>
  235. <!-- Desktop 提交 -->
  236. <div class="hidden items-center gap-3 rounded-2xl bg-[var(--c-ffffff)] p-4 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:flex">
  237. <div class="min-w-0 flex-1">
  238. <div class="whitespace-nowrap text-sm font-bold text-[var(--c-172033)]">{{ balanceText }}</div>
  239. <button v-if="isBalanceInsufficient" type="button" class="mt-1 cursor-pointer text-xs text-[var(--c-f04438)] hover:underline" @click="goRecharge">
  240. 点数不足,前往充值
  241. </button>
  242. </div>
  243. <button
  244. type="button"
  245. class="flex h-14 flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-[var(--c-e5edf7)] px-6 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)]"
  246. @click="historyOpen = true"
  247. >
  248. <UIcon name="i-lucide-history" class="size-4" />
  249. 历史记录
  250. </button>
  251. <button
  252. type="button"
  253. class="flex h-14 min-w-[168px] flex-shrink-0 cursor-pointer items-center justify-center rounded-full px-10 text-base font-bold text-white shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-all disabled:cursor-not-allowed disabled:opacity-50"
  254. :class="submitting ? 'bg-[var(--c-1979e6)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] hover:opacity-90'"
  255. :disabled="!canSubmit"
  256. @click="handleSubmit"
  257. >
  258. <UIcon v-if="!submitting" name="i-lucide-zap" class="mr-1.5 size-4" />
  259. <span>{{ submitting ? '提交中...' : submitText }}</span>
  260. </button>
  261. </div>
  262. </div>
  263. <!-- ===== RIGHT: 结果 ===== -->
  264. <div class="lg:col-span-5">
  265. <div class="space-y-4 lg:sticky lg:top-20">
  266. <!-- 空态 -->
  267. <div v-if="!resultState.visible" class="flex min-h-[300px] flex-col items-center justify-center rounded-2xl bg-[var(--c-ffffff)] p-8 text-center shadow-sm lg:p-10">
  268. <UIcon name="i-lucide-image" class="mb-4 size-12 text-[var(--c-a0aabb)]" />
  269. <div class="mb-1 text-base font-medium text-[var(--c-172033)]">等待生成</div>
  270. <div class="text-sm text-[var(--c-a0aabb)]">上传人像并选好风格后</div>
  271. <div class="text-sm text-[var(--c-a0aabb)]">点击"立即生成"即可看到效果</div>
  272. </div>
  273. <!-- 处理中 -->
  274. <div v-if="resultState.loading" class="rounded-2xl bg-[var(--c-ffffff)] p-6 shadow-sm lg:p-8">
  275. <div class="mb-1 text-base font-bold text-[var(--c-172033)]">{{ pendingTitle }}</div>
  276. <div class="mb-6 text-sm text-[var(--c-7f8896)]">每张单独生成,混元未购买并发,高峰期会排队</div>
  277. <div class="flex flex-col items-center rounded-2xl bg-[var(--c-f7f9fd)] py-10">
  278. <div class="mb-4 flex gap-1.5">
  279. <span class="animate-pulse-dot size-2.5 rounded-full bg-[var(--c-1f8cff)]" />
  280. <span class="animate-pulse-dot2 size-2.5 rounded-full bg-[var(--c-1f8cff)]" />
  281. <span class="animate-pulse-dot3 size-2.5 rounded-full bg-[var(--c-1f8cff)]" />
  282. </div>
  283. <div class="text-sm font-bold text-[var(--c-172033)]">
  284. {{ job.success_count || 0 }} / {{ job.count || effectiveQuantity }} 张 · 已等待 {{ waitedSeconds }}s
  285. </div>
  286. <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div>
  287. </div>
  288. </div>
  289. <!-- 失败 -->
  290. <div v-if="resultState.error" class="rounded-2xl bg-[var(--c-ffffff)] p-6 py-10 text-center shadow-sm lg:p-8">
  291. <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-12 text-[var(--c-f04438)]" />
  292. <div class="mb-2 text-base font-medium text-[var(--c-f04438)]">生成失败</div>
  293. <div class="mb-4 text-sm text-[var(--c-7f8896)]">{{ resultState.errorMsg }}</div>
  294. <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton>
  295. </div>
  296. <!-- 成功 -->
  297. <div v-if="resultState.success" class="space-y-4">
  298. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-sm lg:p-6">
  299. <div class="mb-4 flex items-center justify-between gap-3">
  300. <div class="text-base font-bold text-[var(--c-172033)]">生成结果</div>
  301. <span class="rounded-full bg-[var(--c-edfdf4)] px-2.5 py-1 text-[11px] font-extrabold text-[var(--c-159a54)]">{{ job.style_name }}</span>
  302. </div>
  303. <!-- 主视图直接看成品,对比放进弹层 -->
  304. <div class="overflow-hidden rounded-xl bg-[var(--c-f7f9fd)]">
  305. <img :src="activeResultUrl" class="w-full" alt="形象照生成结果">
  306. </div>
  307. <div v-if="resultUrls.length > 1" class="mt-3 flex flex-wrap gap-2.5">
  308. <button
  309. v-for="(url, index) in resultUrls"
  310. :key="url"
  311. type="button"
  312. class="size-16 cursor-pointer overflow-hidden rounded-xl border-2 transition-colors"
  313. :class="activeIndex === index ? 'border-[var(--c-1f8cff)]' : 'border-transparent hover:border-[var(--c-e5edf7)]'"
  314. :aria-label="`查看第 ${index + 1} 张`"
  315. :aria-pressed="activeIndex === index"
  316. @click="activeIndex = index"
  317. >
  318. <img :src="url" class="size-full object-cover" :alt="`第 ${index + 1} 张`">
  319. </button>
  320. </div>
  321. <div v-if="job.fail_count" class="mt-3 text-xs text-[var(--c-f04438)]">
  322. 有 {{ job.fail_count }} 张生成失败,失败的不扣点
  323. </div>
  324. <div class="mt-5 flex gap-3">
  325. <button
  326. type="button"
  327. class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full border-2 border-[var(--c-1f8cff)] font-bold text-[var(--c-1f8cff)] transition-colors hover:bg-[var(--c-eef7ff)]"
  328. @click="openResultCompare"
  329. >
  330. <UIcon name="i-lucide-columns-2" class="mr-1.5 size-4" />
  331. 查看对比
  332. </button>
  333. <button
  334. type="button"
  335. class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full bg-[var(--c-1f8cff)] text-base font-bold text-white shadow-[0_8px_20px_var(--sh-31-140-255-200)] transition-colors hover:bg-[var(--c-1979e6)]"
  336. @click="downloadResult"
  337. >
  338. <UIcon name="i-lucide-download" class="mr-1.5 size-4" />
  339. 下载
  340. </button>
  341. </div>
  342. </div>
  343. <button
  344. type="button"
  345. class="h-12 w-full cursor-pointer rounded-full border-2 border-[var(--c-1f8cff)] font-bold text-[var(--c-1f8cff)] transition-colors hover:bg-[var(--c-eef7ff)]"
  346. @click="resetJob"
  347. >
  348. 再拍一组
  349. </button>
  350. <p class="text-center text-xs leading-relaxed text-[var(--c-8a97a8)]">
  351. AI 会重绘服装与背景,五官可能出现轻微变化,建议对照原图确认
  352. </p>
  353. </div>
  354. </div>
  355. </div>
  356. </div>
  357. </div>
  358. <!-- ========== 移动端底部操作条 ========== -->
  359. <div
  360. class="fixed bottom-0 left-0 right-0 z-50 bg-[var(--c-ffffff)]/95 shadow-[0_-10px_30px_var(--sh-32-56-92-100)] backdrop-blur lg:hidden"
  361. style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))"
  362. >
  363. <div class="mb-2.5 flex items-center justify-between gap-3 text-sm">
  364. <div class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div>
  365. <button v-if="isBalanceInsufficient" type="button" class="flex-shrink-0 cursor-pointer text-xs font-extrabold text-[var(--c-1f8cff)]" @click="goRecharge">
  366. 去充值
  367. </button>
  368. </div>
  369. <div class="flex gap-3">
  370. <button type="button" class="h-12 w-[120px] cursor-pointer rounded-full bg-[var(--c-eef7ff)] text-sm font-extrabold text-[var(--c-1f8cff)]" @click="historyOpen = true">
  371. 历史记录
  372. </button>
  373. <button
  374. type="button"
  375. class="flex h-12 flex-1 cursor-pointer items-center justify-center rounded-full bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] text-sm font-extrabold text-white shadow-[0_12px_24px_var(--sh-31-140-255-280)] transition-opacity disabled:opacity-60"
  376. :disabled="!canSubmit"
  377. @click="handleSubmit"
  378. >
  379. <UIcon v-if="!submitting" name="i-lucide-zap" class="mr-1.5 size-4" />
  380. {{ submitting ? '提交中...' : submitText }}
  381. </button>
  382. </div>
  383. </div>
  384. <!-- ========== 历史记录 / 详情 ========== -->
  385. <WorkHistoryDrawer v-model="historyOpen" type="portrait" @select="openHistoryItem" />
  386. <WorkDetailModal v-model="detailOpen" :work="currentWork" />
  387. <!-- ========== 对比弹层(demo 与成品共用) ========== -->
  388. <Teleport to="body">
  389. <Transition name="modal">
  390. <div v-if="compareVisible" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
  391. <div class="absolute inset-0 bg-[var(--c-0f172a)]/60" @click="closeCompare" />
  392. <div class="relative w-full max-w-2xl overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-2xl">
  393. <div class="flex items-center justify-between border-b border-[var(--c-f3f4f6)] px-5 py-4">
  394. <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTarget === 'result' ? '生成结果对比' : '效果对比' }}</h3>
  395. <button
  396. type="button"
  397. class="flex size-8 cursor-pointer items-center justify-center rounded-full text-2xl leading-none text-[var(--c-a0aabb)] transition-colors hover:bg-[var(--c-f3f4f6)]"
  398. aria-label="关闭对比"
  399. @click="closeCompare"
  400. >
  401. ×
  402. </button>
  403. </div>
  404. <div class="p-5">
  405. <div
  406. ref="largeCompareRef"
  407. class="relative max-h-[70vh] w-full cursor-ew-resize select-none overflow-hidden rounded-2xl bg-[var(--c-dfe8f4)]"
  408. style="aspect-ratio: 3 / 4"
  409. @mousedown="onCompareStart"
  410. @touchstart.passive="onCompareStart"
  411. >
  412. <img :src="comparePair.after" class="pointer-events-none absolute inset-0 h-full w-full object-contain" alt="生成结果">
  413. <img
  414. :src="comparePair.before"
  415. class="pointer-events-none absolute inset-0 h-full w-full object-contain"
  416. :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }"
  417. alt="原图"
  418. >
  419. <div
  420. class="pointer-events-none absolute bottom-0 top-0 -ml-px w-1 bg-[var(--c-ffffff)]/95 shadow-[0_0_12px_var(--sh-20-37-66-220)]"
  421. :style="{ left: comparePercent + '%' }"
  422. >
  423. <div class="absolute left-1/2 top-1/2 flex h-14 w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center gap-px rounded-full bg-[var(--c-ffffff)] text-lg font-extrabold text-[var(--c-1f8cff)] shadow-[0_8px_18px_var(--sh-20-37-66-220)]">
  424. <span>‹</span><span>›</span>
  425. </div>
  426. </div>
  427. <div class="pointer-events-none absolute left-3 top-3 rounded-full bg-[var(--c-0f172a)]/50 px-2 py-0.5 text-[10px] font-bold text-white">原图</div>
  428. <div class="pointer-events-none absolute right-3 top-3 rounded-full bg-[var(--c-0f172a)]/50 px-2 py-0.5 text-[10px] font-bold text-white">艺术照</div>
  429. </div>
  430. <div class="mt-3 text-center text-xs text-[var(--c-64748b)]">左右拖动中间按钮查看变化</div>
  431. </div>
  432. </div>
  433. </div>
  434. </Transition>
  435. </Teleport>
  436. </div>
  437. </template>
  438. <script setup>
  439. useSeoMeta({
  440. title: 'AI 形象照 / 职业照 / 写真 - AI在线',
  441. description: '上传一张人像照片,AI 生成商务形象照、日常职业照与古风、港风等艺术写真,支持多张生成、前后对比与原图下载。',
  442. })
  443. const { post } = useApi()
  444. const { create: createPortrait, createRequestId } = usePortrait()
  445. const user = useUser()
  446. const router = useRouter()
  447. const toast = useToast()
  448. const POLL_INTERVAL = 3000
  449. const POLL_LIMIT = 8 * 60 * 1000
  450. // 首屏示例对比图由配置项下发(同 old_photo_demo),不在端上硬编码
  451. const DEMO_CONFIG_KEY = 'portrait_photo_demo'
  452. // ==================== State ====================
  453. const fileInput = ref(null)
  454. const selectedFile = ref(null)
  455. const previewImage = ref('')
  456. const extraPrompt = ref('')
  457. const models = ref([])
  458. const styles = ref([])
  459. const ratios = ref([])
  460. const tips = ref([])
  461. const activeModel = ref('')
  462. const activeStyle = ref('')
  463. const activeRatio = ref('')
  464. const options = reactive({})
  465. const quantity = ref(1)
  466. const maxCount = ref(4)
  467. const quantityEnabled = ref(true)
  468. const unitPoints = ref(0)
  469. const configLoading = ref(true)
  470. const submitting = ref(false)
  471. const balance = reactive({ points: 0 })
  472. const job = ref({})
  473. const activeIndex = ref(0)
  474. const demoPair = ref({ original: '', result: '' })
  475. const historyOpen = ref(false)
  476. const detailOpen = ref(false)
  477. const currentWork = ref({})
  478. const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' })
  479. const pollCountdown = ref(3)
  480. const waitedSeconds = ref(0)
  481. let pollTimer = null
  482. let countTimer = null
  483. let pollStart = 0
  484. // ==================== Computed ====================
  485. const hasDemo = computed(() => Boolean(demoPair.value.original && demoPair.value.result))
  486. const currentStyle = computed(() => styles.value.find(item => item.key === activeStyle.value) || {})
  487. const currentGroups = computed(() => currentStyle.value.groups || [])
  488. const resultUrls = computed(() => job.value.result_urls || [])
  489. const activeResultUrl = computed(() => resultUrls.value[activeIndex.value] || resultUrls.value[0] || '')
  490. const effectiveQuantity = computed(() => (quantityEnabled.value ? quantity.value : 1))
  491. const totalPoints = computed(() => unitPoints.value * effectiveQuantity.value)
  492. const quantityPriceText = computed(() => {
  493. if (!unitPoints.value) return quantityEnabled.value ? `共${totalPoints.value}点` : '固定1张'
  494. return quantityEnabled.value ? `${unitPoints.value}点/张 · 共${totalPoints.value}点` : `${unitPoints.value}点/张 · 固定1张`
  495. })
  496. const currentPoints = computed(() => Number(balance.points || 0))
  497. const isBalanceInsufficient = computed(
  498. () => user.isLogin.value && totalPoints.value > 0 && currentPoints.value < totalPoints.value,
  499. )
  500. const canSubmit = computed(() => Boolean(selectedFile.value) && !submitting.value && !resultState.value.loading)
  501. const balanceText = computed(() => {
  502. if (!user.isLogin.value) return unitPoints.value ? `形象照 · ${unitPoints.value}点/张` : '形象照'
  503. return totalPoints.value
  504. ? `余额${currentPoints.value}点 · 预计消耗${totalPoints.value}点`
  505. : `余额${currentPoints.value}点`
  506. })
  507. const submitText = computed(() => {
  508. if (!user.isLogin.value) return totalPoints.value ? `登录后生成 · ${totalPoints.value}点` : '登录后生成'
  509. return totalPoints.value ? `立即生成 · ${totalPoints.value}点` : '立即生成'
  510. })
  511. const pendingTitle = computed(() => (Number(job.value.status) === 10 ? '排队中,等待 AI 处理' : 'AI 正在生成形象照'))
  512. function showToast(message, color = 'error') {
  513. toast.add({ title: message, color })
  514. }
  515. function pickFirstDefined(values) {
  516. return values.find(value => value !== undefined && value !== null && value !== '')
  517. }
  518. function normalizeQuantityEnabled(data = {}) {
  519. const raw = pickFirstDefined([
  520. data.quantity_enabled,
  521. data.quantity_supported,
  522. data.product?.quantity_enabled,
  523. data.billing?.product?.quantity_enabled,
  524. data.billing?.quote?.product?.quantity_enabled,
  525. ])
  526. if (raw === undefined) return Number(data.max_count || 1) > 1
  527. if (typeof raw === 'boolean') return raw
  528. return !['0', 'false', 'no', 'off', 'unsupported', 'not_support', '不支持'].includes(String(raw).toLowerCase())
  529. }
  530. function applyQuantityConfig(data = {}) {
  531. const enabled = normalizeQuantityEnabled(data)
  532. const rawMax = pickFirstDefined([
  533. data.max_count,
  534. data.product?.max_quantity,
  535. data.billing?.product?.max_quantity,
  536. data.billing?.quote?.product?.max_quantity,
  537. ])
  538. const nextMax = enabled ? Math.max(1, Number(rawMax || maxCount.value || 1)) : 1
  539. quantityEnabled.value = enabled
  540. maxCount.value = nextMax
  541. quantity.value = enabled ? Math.min(Math.max(1, quantity.value), nextMax) : 1
  542. }
  543. // ==================== 配置 / 示例 / 余额 ====================
  544. async function loadConfig(silent = false) {
  545. try {
  546. const res = await post('/portrait/config', {
  547. style: activeStyle.value || undefined,
  548. model: activeModel.value || undefined,
  549. count: effectiveQuantity.value,
  550. })
  551. const data = res.data || {}
  552. models.value = data.models || []
  553. styles.value = data.styles || []
  554. ratios.value = data.ratios || []
  555. tips.value = data.tips || []
  556. applyQuantityConfig(data)
  557. unitPoints.value = Number(data.unit_points || 0)
  558. if (!activeModel.value) activeModel.value = data.default_model || models.value[0]?.key || ''
  559. if (!activeRatio.value) {
  560. activeRatio.value = (ratios.value.find(item => item.is_default) || ratios.value[0] || {}).key || ''
  561. }
  562. if (!activeStyle.value) {
  563. activeStyle.value = data.default_style || styles.value[0]?.key || ''
  564. applyStyleDefaults()
  565. }
  566. if (data.billing?.balance) {
  567. balance.points = data.billing.balance.points || data.billing.balance.point_balance || 0
  568. }
  569. } catch (e) {
  570. if (!silent) showToast(e.message || '配置加载失败')
  571. } finally {
  572. configLoading.value = false
  573. }
  574. }
  575. /** 切换风格时把该风格的选项重置为默认值 */
  576. function applyStyleDefaults() {
  577. Object.keys(options).forEach(key => delete options[key])
  578. for (const group of currentGroups.value) {
  579. const picked = group.items.find(item => item.is_default) || group.items[0]
  580. if (picked) options[group.key] = picked.key
  581. }
  582. }
  583. function selectStyle(key) {
  584. if (activeStyle.value === key) return
  585. activeStyle.value = key
  586. applyStyleDefaults()
  587. }
  588. // 换模型会影响单价,重新取一次报价
  589. watch(activeModel, (value, old) => {
  590. if (old && value && value !== old) loadConfig(true)
  591. })
  592. /** 示例对比图走配置项 portrait_photo_demo,格式 [{ original, result }] */
  593. async function loadDemo() {
  594. try {
  595. const res = await post('/common/getconfig', { keys: DEMO_CONFIG_KEY })
  596. const rows = Array.isArray(res.list) ? res.list : []
  597. const item = rows.find(it => it.key === DEMO_CONFIG_KEY)
  598. if (!item?.value) return
  599. const data = typeof item.value === 'string' ? JSON.parse(item.value) : item.value
  600. const first = Array.isArray(data) ? data[0] : data
  601. if (!first) return
  602. demoPair.value = {
  603. original: first.original || first.before || first.source || '',
  604. result: first.result || first.after || '',
  605. }
  606. } catch (e) {
  607. console.error('[portrait-photo] loadDemo failed:', e)
  608. }
  609. }
  610. async function loadBalance() {
  611. try {
  612. const res = await post('/point/balance', {})
  613. if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0
  614. } catch (e) {
  615. console.error('[portrait-photo] loadBalance failed:', e)
  616. }
  617. user.refreshBalance()
  618. }
  619. onMounted(() => {
  620. loadConfig()
  621. loadDemo()
  622. if (user.isLogin.value) loadBalance()
  623. })
  624. onUnmounted(() => clearPoll())
  625. // ==================== 上传 ====================
  626. function triggerUpload() {
  627. if (!user.isLogin.value) {
  628. user.showLogin.value = true
  629. return
  630. }
  631. fileInput.value?.click()
  632. }
  633. function onFileChange(e) {
  634. const file = e.target.files?.[0]
  635. if (file) handleFile(file)
  636. e.target.value = ''
  637. }
  638. function onDrop(e) {
  639. if (!user.isLogin.value) {
  640. user.showLogin.value = true
  641. return
  642. }
  643. const file = e.dataTransfer?.files?.[0]
  644. if (file) handleFile(file)
  645. }
  646. function handleFile(file) {
  647. if (!/^image\//.test(file.type)) return showToast('请选择图片文件')
  648. if (file.size > 10 * 1024 * 1024) return showToast('图片不能超过 10M')
  649. if (previewImage.value) URL.revokeObjectURL(previewImage.value)
  650. selectedFile.value = file
  651. previewImage.value = URL.createObjectURL(file)
  652. resultState.value = { visible: false, loading: false, error: false, success: false, errorMsg: '' }
  653. job.value = {}
  654. activeIndex.value = 0
  655. }
  656. // ==================== 提交与轮询 ====================
  657. function clearPoll() {
  658. clearTimeout(pollTimer)
  659. clearInterval(countTimer)
  660. pollTimer = null
  661. countTimer = null
  662. }
  663. async function handleSubmit() {
  664. if (!user.isLogin.value) {
  665. user.showLogin.value = true
  666. return
  667. }
  668. if (!selectedFile.value) return showToast('请先上传人像照片')
  669. if (!canSubmit.value) return
  670. if (isBalanceInsufficient.value) return showToast('点数不足,请充值后重试')
  671. submitting.value = true
  672. resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' }
  673. waitedSeconds.value = 0
  674. activeIndex.value = 0
  675. pollStart = Date.now()
  676. try {
  677. const res = await createPortrait(selectedFile.value, {
  678. style: activeStyle.value,
  679. model: activeModel.value,
  680. ratio: activeRatio.value,
  681. count: effectiveQuantity.value,
  682. extra_prompt: extraPrompt.value,
  683. request_id: createRequestId(),
  684. options: { ...options },
  685. })
  686. job.value = res.data || {}
  687. if (Number(job.value.status) === 30) {
  688. onSuccess()
  689. } else {
  690. startPoll()
  691. }
  692. } catch (e) {
  693. const msg = e.code === 1011 ? '点数不足,请充值后重试' : (e.message || '提交失败,请稍后重试')
  694. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: msg }
  695. } finally {
  696. submitting.value = false
  697. }
  698. }
  699. function startPoll() {
  700. clearPoll()
  701. pollCountdown.value = POLL_INTERVAL / 1000
  702. countTimer = setInterval(() => {
  703. pollCountdown.value = Math.max(0, pollCountdown.value - 1)
  704. waitedSeconds.value = Math.round((Date.now() - pollStart) / 1000)
  705. }, 1000)
  706. pollTimer = setTimeout(async () => {
  707. if (Date.now() - pollStart > POLL_LIMIT) {
  708. clearPoll()
  709. resultState.value = {
  710. visible: true,
  711. loading: false,
  712. error: true,
  713. success: false,
  714. errorMsg: '生成时间较长,可稍后从历史记录查看结果',
  715. }
  716. return
  717. }
  718. try {
  719. const res = await post('/portrait/getdetail', { photo_open_id: job.value.photo_open_id })
  720. const data = res.data || {}
  721. job.value = data
  722. const status = Number(data.status)
  723. if (status === 30) {
  724. clearPoll()
  725. onSuccess()
  726. } else if (status < 0) {
  727. clearPoll()
  728. resultState.value = {
  729. visible: true,
  730. loading: false,
  731. error: true,
  732. success: false,
  733. errorMsg: data.error_msg || '生成失败,本次未扣点',
  734. }
  735. } else {
  736. startPoll()
  737. }
  738. } catch (e) {
  739. // 单次查询失败不打断轮询
  740. startPoll()
  741. }
  742. }, POLL_INTERVAL)
  743. }
  744. function onSuccess() {
  745. activeIndex.value = 0
  746. resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' }
  747. loadBalance()
  748. }
  749. function resetJob() {
  750. clearPoll()
  751. job.value = {}
  752. activeIndex.value = 0
  753. selectedFile.value = null
  754. if (previewImage.value) URL.revokeObjectURL(previewImage.value)
  755. previewImage.value = ''
  756. extraPrompt.value = ''
  757. resultState.value = { visible: false, loading: false, error: false, success: false, errorMsg: '' }
  758. }
  759. async function downloadResult() {
  760. const url = activeResultUrl.value
  761. if (!url) return
  762. try {
  763. const res = await fetch(url)
  764. const blob = await res.blob()
  765. const link = document.createElement('a')
  766. link.href = URL.createObjectURL(blob)
  767. link.download = `portrait-photo-${Date.now()}.jpg`
  768. link.click()
  769. URL.revokeObjectURL(link.href)
  770. } catch {
  771. window.open(url, '_blank')
  772. }
  773. }
  774. function goRecharge() {
  775. router.push('/recharge')
  776. }
  777. function openHistoryItem(item) {
  778. currentWork.value = item
  779. detailOpen.value = true
  780. }
  781. // ==================== 对比弹层 ====================
  782. const compareVisible = ref(false)
  783. const comparePercent = ref(50)
  784. const compareTarget = ref('demo')
  785. const largeCompareRef = ref(null)
  786. let dragging = false
  787. const comparePair = computed(() => {
  788. if (compareTarget.value === 'result') {
  789. return { before: job.value.source_url || previewImage.value, after: activeResultUrl.value }
  790. }
  791. return { before: demoPair.value.original, after: demoPair.value.result }
  792. })
  793. function openDemoCompare() {
  794. if (!hasDemo.value) return showToast('暂无对比图')
  795. comparePercent.value = 50
  796. compareTarget.value = 'demo'
  797. compareVisible.value = true
  798. }
  799. function openResultCompare() {
  800. if (!activeResultUrl.value) return showToast('结果图暂不可用')
  801. comparePercent.value = 50
  802. compareTarget.value = 'result'
  803. compareVisible.value = true
  804. }
  805. function closeCompare() {
  806. compareVisible.value = false
  807. onCompareEnd()
  808. }
  809. function onCompareStart(e) {
  810. dragging = true
  811. updateComparePercent(e.touches?.[0] || e)
  812. window.addEventListener('mousemove', onCompareMove)
  813. window.addEventListener('mouseup', onCompareEnd)
  814. window.addEventListener('touchmove', onCompareMove, { passive: false })
  815. window.addEventListener('touchend', onCompareEnd)
  816. }
  817. function onCompareMove(e) {
  818. if (!dragging) return
  819. if (e.cancelable) e.preventDefault()
  820. updateComparePercent(e.touches?.[0] || e)
  821. }
  822. function onCompareEnd() {
  823. dragging = false
  824. window.removeEventListener('mousemove', onCompareMove)
  825. window.removeEventListener('mouseup', onCompareEnd)
  826. window.removeEventListener('touchmove', onCompareMove)
  827. window.removeEventListener('touchend', onCompareEnd)
  828. }
  829. function updateComparePercent(e) {
  830. if (!largeCompareRef.value) return
  831. const rect = largeCompareRef.value.getBoundingClientRect()
  832. const percent = (((e.clientX || 0) - rect.left) / rect.width) * 100
  833. comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1))))
  834. }
  835. </script>
  836. <style scoped>
  837. .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; }
  838. .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; }
  839. .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; }
  840. @keyframes dotPulse {
  841. 0%, 100% { opacity: 0.35; transform: scale(0.82); }
  842. 50% { opacity: 1; transform: scale(1); }
  843. }
  844. .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
  845. .modal-enter-from, .modal-leave-to { opacity: 0; }
  846. /* 尊重系统的减少动效设置 */
  847. @media (prefers-reduced-motion: reduce) {
  848. .animate-pulse-dot,
  849. .animate-pulse-dot2,
  850. .animate-pulse-dot3 { animation: none; }
  851. .modal-enter-active, .modal-leave-active { transition: none; }
  852. }
  853. </style>