Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

1163 rindas
49 KiB

  1. <template>
  2. <div class="min-h-screen overflow-x-hidden bg-[var(--c-f3f7fb)] pb-24 lg:pb-12">
  3. <AppHero
  4. kicker="SMART ID PHOTO"
  5. title="一张自拍,制作标准证件照"
  6. desc="上传一张清晰正面照,自动换好底色、裁成所需尺寸,几秒获得可直接下载使用的高清证件照。"
  7. >
  8. <template #actions>
  9. <button
  10. type="button"
  11. class="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-colors hover:bg-[var(--c-087de5)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 lg:h-11"
  12. @click="triggerUpload"
  13. >
  14. <UIcon name="i-lucide-upload" class="size-4" />
  15. 上传照片
  16. </button>
  17. <button
  18. type="button"
  19. class="inline-flex h-10 items-center gap-2 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-52647b)] shadow-sm transition-colors hover:bg-[var(--c-f8fbff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] lg:h-11"
  20. @click="openHistory"
  21. >
  22. <UIcon name="i-lucide-history" class="size-4" />
  23. 历史记录
  24. </button>
  25. </template>
  26. <template #aside>
  27. <div class="hidden w-[390px] overflow-hidden rounded-[28px] border border-white/80 bg-[var(--c-ffffff)]/88 p-5 shadow-[0_20px_48px_var(--sh-31-62-112-130)] backdrop-blur lg:block">
  28. <div class="flex items-center gap-5">
  29. <div class="relative h-[145px] w-[116px] shrink-0">
  30. <div class="absolute left-3 top-1 h-[136px] w-[102px] rotate-6 rounded-[20px] border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/75 shadow-sm" />
  31. <div class="absolute inset-y-0 left-0 w-[104px] overflow-hidden rounded-[20px] border-[5px] border-[var(--c-e8eef7)] bg-[var(--c-438edb)] shadow-[0_14px_28px_var(--sh-44-104-168-240)]">
  32. <img
  33. src="https://cdn.aionline.cc/ai/anime/id-photo-demo.jpg"
  34. alt="蓝底标准证件照效果示例"
  35. width="295"
  36. height="413"
  37. class="h-full w-full object-cover"
  38. loading="eager"
  39. decoding="async"
  40. fetchpriority="high"
  41. >
  42. <span class="absolute left-2 top-2 rounded-full bg-[var(--c-ffffff)]/90 px-2 py-1 text-[9px] font-extrabold text-[var(--c-0b8cff)]">一寸</span>
  43. </div>
  44. <span class="absolute bottom-0 right-0 flex size-8 items-center justify-center rounded-full border-4 border-[var(--c-e8eef7)] bg-[var(--c-19ad72)] text-white shadow-md">
  45. <UIcon name="i-lucide-check" class="size-4" />
  46. </span>
  47. </div>
  48. <div class="min-w-0 flex-1">
  49. <span class="inline-flex rounded-full bg-[var(--c-fff2e8)] px-2.5 py-1 text-[10px] font-extrabold tracking-wide text-[var(--c-f06b1d)]">上传 1 张,直接得成品</span>
  50. <h2 class="mt-2 text-lg font-extrabold leading-7 text-[var(--c-172033)]">换底、裁剪,一次完成</h2>
  51. <ul class="mt-3 space-y-2 text-xs font-bold text-[var(--c-5f7087)]">
  52. <li v-for="item in heroBenefits" :key="item" class="flex items-center gap-2">
  53. <UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0 text-[var(--c-18a66a)]" />
  54. {{ item }}
  55. </li>
  56. </ul>
  57. </div>
  58. </div>
  59. </div>
  60. </template>
  61. </AppHero>
  62. <main class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10">
  63. <div class="grid items-start gap-6 xl:grid-cols-[minmax(0,1.18fr)_minmax(380px,0.82fr)]">
  64. <div class="min-w-0 space-y-5">
  65. <section class="rounded-3xl border border-[var(--c-e9eff7)] bg-[var(--c-ffffff)] p-4 shadow-[0_16px_42px_var(--sh-35-64-105-80)] sm:p-6">
  66. <div class="mb-5 flex flex-wrap items-center justify-between gap-3">
  67. <div>
  68. <h2 class="text-lg font-extrabold text-[var(--c-172033)]">照片预览</h2>
  69. <p class="mt-1 text-xs text-[var(--c-8794a6)]">{{ workspaceHint }}</p>
  70. </div>
  71. <div v-if="localFile" class="flex items-center gap-2">
  72. <button
  73. type="button"
  74. class="inline-flex h-9 items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-4 text-xs font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-dfefff)]"
  75. @click="triggerUpload"
  76. >
  77. <UIcon name="i-lucide-refresh-cw" class="size-3.5" />
  78. 更换照片
  79. </button>
  80. <button
  81. v-if="status !== 'processing'"
  82. type="button"
  83. aria-label="移除照片"
  84. class="flex size-9 items-center justify-center rounded-full bg-[var(--c-f7f9fc)] text-[var(--c-8190a4)] transition-colors hover:bg-[var(--c-fff0ef)] hover:text-[var(--c-e5484d)]"
  85. @click="clearPhoto"
  86. >
  87. <UIcon name="i-lucide-trash-2" class="size-4" />
  88. </button>
  89. </div>
  90. </div>
  91. <input
  92. ref="fileInput"
  93. class="sr-only"
  94. type="file"
  95. accept="image/jpeg,image/png,image/webp"
  96. @change="onFileChange"
  97. >
  98. <div
  99. v-if="!localFile"
  100. class="group flex min-h-[420px] cursor-pointer flex-col items-center justify-center rounded-[28px] border-2 border-dashed px-6 text-center transition-all sm:min-h-[520px]"
  101. :class="dragActive
  102. ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]'
  103. : 'border-[var(--c-d9e3ef)] bg-[var(--c-f8fbfe)] hover:border-[var(--c-0b8cff)]/60 hover:bg-[var(--c-f3f9ff)]'"
  104. role="button"
  105. tabindex="0"
  106. aria-label="选择证件照原图"
  107. @click="triggerUpload"
  108. @keydown.enter.prevent="triggerUpload"
  109. @keydown.space.prevent="triggerUpload"
  110. @dragenter.prevent="dragActive = true"
  111. @dragover.prevent="dragActive = true"
  112. @dragleave.prevent="dragActive = false"
  113. @drop.prevent="onDrop"
  114. >
  115. <span class="flex size-20 items-center justify-center rounded-3xl bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] shadow-[0_12px_30px_var(--sh-25-101-170-120)] transition-transform group-hover:-translate-y-1">
  116. <UIcon name="i-lucide-image-up" class="size-9" />
  117. </span>
  118. <h3 class="mt-6 text-lg font-extrabold text-[var(--c-172033)]">点击或拖入一张照片</h3>
  119. <p class="mt-2 max-w-md text-sm leading-6 text-[var(--c-718096)]">
  120. 支持 JPG、PNG、WebP,文件不超过 10MB。推荐竖版半身照,避免遮挡面部。
  121. </p>
  122. <span class="mt-6 inline-flex h-11 items-center rounded-full bg-[var(--c-0b8cff)] px-6 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)]">
  123. 选择照片
  124. </span>
  125. <div class="mt-8 grid w-full max-w-lg grid-cols-3 gap-3">
  126. <div v-for="tip in uploadTips" :key="tip.label" class="rounded-2xl bg-[var(--c-ffffff)] px-2 py-3">
  127. <UIcon :name="tip.icon" class="mx-auto size-4 text-[var(--c-18a66a)]" />
  128. <div class="mt-1.5 text-[11px] font-bold text-[var(--c-607087)]">{{ tip.label }}</div>
  129. </div>
  130. </div>
  131. </div>
  132. <div
  133. v-else
  134. class="relative flex min-h-[420px] items-center justify-center rounded-[28px] bg-[linear-gradient(135deg,var(--c-edf2f8)_25%,var(--c-f8fafc)_25%,var(--c-f8fafc)_50%,var(--c-edf2f8)_50%,var(--c-edf2f8)_75%,var(--c-f8fafc)_75%)] bg-[length:24px_24px] sm:min-h-[520px]"
  135. :class="result ? 'overflow-visible' : 'overflow-hidden'"
  136. @dragenter.prevent="dragActive = true"
  137. @dragover.prevent="dragActive = true"
  138. @dragleave.prevent="dragActive = false"
  139. @drop.prevent="onDrop"
  140. >
  141. <img
  142. :src="displayImage"
  143. class="max-h-[520px] max-w-full object-contain"
  144. :class="result ? 'rounded-[4px]' : ''"
  145. :style="result ? resultImageStyle : undefined"
  146. :alt="result ? '制作完成的证件照' : '待处理照片预览'"
  147. @error="handleImageError"
  148. >
  149. <div v-if="result" class="absolute left-4 top-4 rounded-full bg-[var(--c-ink)]/70 px-3 py-1.5 text-xs font-bold text-white backdrop-blur">
  150. {{ result.spec_name }} · {{ result.width }}×{{ result.height }}px
  151. </div>
  152. <div v-if="status === 'processing'" class="absolute inset-0 flex items-center justify-center bg-[var(--c-132238)]/70 p-5 backdrop-blur-sm">
  153. <div class="w-full max-w-md rounded-3xl bg-[var(--c-ffffff)] p-6 shadow-2xl sm:p-8" aria-live="polite">
  154. <span class="mx-auto flex size-16 items-center justify-center rounded-3xl bg-[var(--c-eef7ff)]">
  155. <UIcon name="i-lucide-scan-face" class="size-8 animate-pulse text-[var(--c-0b8cff)]" />
  156. </span>
  157. <div class="mt-5 flex items-end justify-between gap-4">
  158. <div class="min-w-0 text-left">
  159. <h3 class="text-lg font-extrabold text-[var(--c-172033)]">正在制作证件照</h3>
  160. <p class="mt-1 text-sm text-[var(--c-7c899b)]">{{ processingStages[processingStage] }}</p>
  161. </div>
  162. <span class="shrink-0 text-2xl font-extrabold tabular-nums text-[var(--c-0b8cff)]">{{ processingProgress }}%</span>
  163. </div>
  164. <div
  165. class="mt-5 h-2.5 overflow-hidden rounded-full bg-[var(--c-eaf0f7)]"
  166. role="progressbar"
  167. aria-label="证件照制作进度"
  168. aria-valuemin="0"
  169. aria-valuemax="100"
  170. :aria-valuenow="processingProgress"
  171. >
  172. <div
  173. class="h-full rounded-full bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-46c2ff)] transition-[width] duration-500"
  174. :style="{ width: `${processingProgress}%` }"
  175. />
  176. </div>
  177. <div class="mt-3 flex items-center justify-between gap-3 text-[11px]">
  178. <span class="font-bold text-[var(--c-7f8da0)]">整体进度</span>
  179. <span class="inline-flex items-center gap-1.5 font-bold text-[var(--c-46698d)]">
  180. <UIcon name="i-lucide-clock-3" class="size-3.5" />
  181. {{ processingRemainingText }}
  182. </span>
  183. </div>
  184. <div class="mt-5 grid grid-cols-4 gap-2">
  185. <span
  186. v-for="(_, index) in processingStages"
  187. :key="index"
  188. class="h-1.5 rounded-full transition-colors"
  189. :class="index <= processingStage ? 'bg-[var(--c-0b8cff)]' : 'bg-[var(--c-e4ebf4)]'"
  190. />
  191. </div>
  192. <p class="mt-5 text-center text-xs text-[var(--c-9aa6b5)]">请保持页面打开,完成后将自动展示成品</p>
  193. </div>
  194. </div>
  195. </div>
  196. <div v-if="photoMeta.width" class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-[var(--c-7f8c9f)]">
  197. <span class="inline-flex items-center gap-1.5">
  198. <UIcon name="i-lucide-maximize" class="size-3.5" />
  199. 原图 {{ photoMeta.width }}×{{ photoMeta.height }}px
  200. </span>
  201. <span class="inline-flex items-center gap-1.5">
  202. <UIcon name="i-lucide-file-image" class="size-3.5" />
  203. {{ fileSizeText }}
  204. </span>
  205. <span v-if="result" class="inline-flex items-center gap-1.5 text-[var(--c-18a66a)]">
  206. <UIcon name="i-lucide-circle-check" class="size-3.5" />
  207. 已生成 300 DPI 成品
  208. </span>
  209. </div>
  210. <div v-if="errorMessage" class="mt-4 flex items-start gap-2 rounded-2xl bg-[var(--c-fff2f1)] px-4 py-3 text-sm text-[var(--c-c63d42)]">
  211. <UIcon name="i-lucide-circle-alert" class="mt-0.5 size-4 shrink-0" />
  212. <span>{{ errorMessage }}</span>
  213. </div>
  214. <div v-if="result" class="mt-5 grid gap-3 sm:grid-cols-2">
  215. <button
  216. type="button"
  217. class="inline-flex h-12 items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] text-sm font-extrabold text-white transition-colors hover:bg-[var(--c-087de5)]"
  218. @click="downloadResult"
  219. >
  220. <UIcon name="i-lucide-download" class="size-4" />
  221. 下载证件照
  222. </button>
  223. <button
  224. type="button"
  225. class="inline-flex h-12 items-center justify-center gap-2 rounded-full bg-[var(--c-eef7ff)] text-sm font-extrabold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-dfefff)]"
  226. @click="startOver"
  227. >
  228. <UIcon name="i-lucide-plus" class="size-4" />
  229. 制作新照片
  230. </button>
  231. </div>
  232. </section>
  233. <section class="rounded-3xl border border-[var(--c-e9eff7)] bg-[var(--c-ffffff)] p-5 shadow-[0_12px_34px_var(--sh-35-64-105-60)] sm:p-6">
  234. <div>
  235. <h2 class="text-base font-extrabold text-[var(--c-172033)]">背景与自然优化</h2>
  236. <p class="mt-1 text-xs text-[var(--c-8794a6)]">选择底色和画面效果,预览区会展示最终成品</p>
  237. </div>
  238. <div class="mt-5 grid gap-6 sm:grid-cols-[1.05fr_0.95fr]">
  239. <div>
  240. <div class="text-xs font-bold text-[var(--c-52647b)]">背景颜色</div>
  241. <div class="mt-3 grid grid-cols-3 gap-3">
  242. <button
  243. v-for="background in backgrounds"
  244. :key="background.key"
  245. type="button"
  246. class="group cursor-pointer rounded-2xl border p-2 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
  247. :class="isBackgroundSelected(background.color)
  248. ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]'
  249. : 'border-[var(--c-e8eef6)] hover:border-[var(--c-bcdcff)]'"
  250. :aria-label="`选择${background.name}背景`"
  251. @click="setBackground(background.color)"
  252. >
  253. <span
  254. class="relative mx-auto flex size-8 items-center justify-center rounded-full border border-black/10 shadow-inner"
  255. :style="{ backgroundColor: background.color }"
  256. >
  257. <UIcon
  258. v-if="isBackgroundSelected(background.color)"
  259. name="i-lucide-check"
  260. class="size-4"
  261. :class="isLightBackground(background.color) ? 'text-[var(--c-0b8cff)]' : 'text-white'"
  262. />
  263. </span>
  264. <span class="mt-1.5 block truncate text-[10px] font-bold text-[var(--c-64748a)]">{{ background.name }}</span>
  265. </button>
  266. <UPopover
  267. :content="{ align: 'start', side: 'bottom', sideOffset: 10 }"
  268. :ui="{ content: 'rounded-2xl border border-[var(--c-dfe8f2)] bg-[var(--c-ffffff)] p-0 shadow-[0_18px_48px_var(--sh-31-62-112-160)]' }"
  269. >
  270. <button
  271. type="button"
  272. aria-label="选择自定义背景色"
  273. class="group cursor-pointer rounded-2xl border p-2 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
  274. :class="isCustomBackground
  275. ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]'
  276. : 'border-[var(--c-e8eef6)] hover:border-[var(--c-bcdcff)]'"
  277. >
  278. <span
  279. class="relative mx-auto flex size-8 items-center justify-center rounded-full border border-black/10 shadow-inner"
  280. :style="{ backgroundColor: settings.background }"
  281. >
  282. <UIcon
  283. v-if="isCustomBackground"
  284. name="i-lucide-check"
  285. class="size-4"
  286. :class="isLightBackground(settings.background) ? 'text-[var(--c-0b8cff)]' : 'text-white'"
  287. />
  288. <span class="absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full border border-[var(--c-dfe8f2)] bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] shadow-sm">
  289. <UIcon name="i-lucide-palette" class="size-2.5" />
  290. </span>
  291. </span>
  292. <span class="mt-1.5 block truncate text-[10px] font-bold text-[var(--c-64748a)]">自定义</span>
  293. </button>
  294. <template #content>
  295. <div class="w-[220px] p-4">
  296. <div class="mb-3 flex items-center justify-between gap-3">
  297. <div>
  298. <div class="text-xs font-extrabold text-[var(--c-172033)]">自定义背景色</div>
  299. <div class="mt-0.5 text-[10px] text-[var(--c-8794a6)]">拖动选取,成品实时应用</div>
  300. </div>
  301. <span class="shrink-0 font-mono text-[11px] font-bold uppercase text-[var(--c-52647b)]">
  302. {{ settings.background }}
  303. </span>
  304. </div>
  305. <div role="group" aria-label="自定义证件照背景颜色">
  306. <UColorPicker
  307. :model-value="settings.background"
  308. format="hex"
  309. size="sm"
  310. :ui="{
  311. selector: 'shrink-0',
  312. track: 'w-2 shrink-0',
  313. trackThumb: '-translate-x-1 rtl:translate-x-1',
  314. }"
  315. @update:model-value="setBackground"
  316. />
  317. </div>
  318. </div>
  319. </template>
  320. </UPopover>
  321. </div>
  322. </div>
  323. <div>
  324. <div class="text-xs font-bold text-[var(--c-52647b)]">画面优化</div>
  325. <div class="mt-3 grid grid-cols-2 gap-3">
  326. <button
  327. v-for="option in beautyOptions"
  328. :key="option.key"
  329. type="button"
  330. class="rounded-2xl border p-3 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
  331. :class="settings.beauty === option.key
  332. ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)]'
  333. : 'border-[var(--c-e8eef6)] hover:border-[var(--c-bcdcff)]'"
  334. @click="settings.beauty = option.key"
  335. >
  336. <span class="flex items-center gap-2 text-xs font-extrabold text-[var(--c-172033)]">
  337. <UIcon :name="option.key === 'natural' ? 'i-lucide-wand-sparkles' : 'i-lucide-scan-face'" class="size-4 text-[var(--c-0b8cff)]" />
  338. {{ option.name }}
  339. </span>
  340. <span class="mt-1.5 block text-[10px] leading-4 text-[var(--c-8794a6)]">{{ option.desc }}</span>
  341. </button>
  342. </div>
  343. </div>
  344. </div>
  345. <div v-if="result" class="mt-6 border-t border-[var(--c-edf1f6)] pt-5">
  346. <div class="flex items-center justify-between">
  347. <div class="text-xs font-bold text-[var(--c-52647b)]">构图微调</div>
  348. <button type="button" class="text-[11px] font-bold text-[var(--c-0b8cff)]" @click="resetAdjustments">恢复默认</button>
  349. </div>
  350. <div class="mt-4 grid gap-5 sm:grid-cols-3">
  351. <label class="block">
  352. <span class="flex justify-between text-[11px] text-[var(--c-7b899c)]">
  353. <span>人物大小</span><span>{{ Math.round(settings.scale * 100) }}%</span>
  354. </span>
  355. <input v-model.number="settings.scale" class="id-range mt-2 w-full" type="range" min="0.75" max="1.4" step="0.01">
  356. </label>
  357. <label class="block">
  358. <span class="flex justify-between text-[11px] text-[var(--c-7b899c)]">
  359. <span>水平位置</span><span>{{ adjustmentLabel(settings.offset_x) }}</span>
  360. </span>
  361. <input v-model.number="settings.offset_x" class="id-range mt-2 w-full" type="range" min="-1" max="1" step="0.02">
  362. </label>
  363. <label class="block">
  364. <span class="flex justify-between text-[11px] text-[var(--c-7b899c)]">
  365. <span>垂直位置</span><span>{{ adjustmentLabel(settings.offset_y) }}</span>
  366. </span>
  367. <input v-model.number="settings.offset_y" class="id-range mt-2 w-full" type="range" min="-1" max="1" step="0.02">
  368. </label>
  369. </div>
  370. <div class="mt-5 flex flex-col items-center justify-between gap-2 sm:flex-row">
  371. <p class="text-[10px] text-[var(--c-9ba7b7)]">调整已生成照片不重复扣点</p>
  372. <button
  373. type="button"
  374. class="flex h-11 w-full items-center justify-center gap-2 rounded-full px-6 text-sm font-extrabold transition-colors sm:w-auto"
  375. :class="adjustDirty && !rerendering
  376. ? 'bg-[var(--c-ink)] text-white hover:bg-[var(--c-26354b)]'
  377. : 'cursor-not-allowed bg-[var(--c-edf1f6)] text-[var(--c-a0aaba)]'"
  378. :disabled="!adjustDirty || rerendering"
  379. @click="applyAdjustments"
  380. >
  381. <span v-if="rerendering" class="size-4 animate-spin rounded-full border-2 border-white/35 border-t-white" />
  382. <UIcon v-else name="i-lucide-sliders-horizontal" class="size-4" />
  383. {{ rerendering ? '正在应用...' : '应用调整' }}
  384. </button>
  385. </div>
  386. </div>
  387. </section>
  388. </div>
  389. <aside class="min-w-0 space-y-5">
  390. <section class="rounded-3xl border border-[var(--c-e9eff7)] bg-[var(--c-ffffff)] p-5 shadow-[0_12px_34px_var(--sh-35-64-105-60)] sm:p-6">
  391. <div class="flex items-center justify-between">
  392. <div>
  393. <h2 class="text-base font-extrabold text-[var(--c-172033)]">选择规格</h2>
  394. <p class="mt-1 text-xs text-[var(--c-8794a6)]">成品将严格输出为所选像素尺寸</p>
  395. </div>
  396. <span v-if="selectedSpec" class="rounded-full bg-[var(--c-eef7ff)] px-3 py-1 text-[11px] font-extrabold text-[var(--c-0b8cff)]">
  397. {{ selectedSpec.dpi }} DPI
  398. </span>
  399. </div>
  400. <div class="mt-5 flex gap-2 overflow-x-auto pb-1 no-scrollbar">
  401. <button
  402. v-for="category in categories"
  403. :key="category.key"
  404. type="button"
  405. class="h-9 shrink-0 rounded-full px-4 text-xs font-bold transition-colors"
  406. :class="selectedCategory === category.key
  407. ? 'bg-[var(--c-ink)] text-white'
  408. : 'bg-[var(--c-f3f6fa)] text-[var(--c-64748a)] hover:bg-[var(--c-eaf0f7)]'"
  409. @click="selectedCategory = category.key"
  410. >
  411. {{ category.name }}
  412. </button>
  413. </div>
  414. <label class="mt-4 flex h-10 items-center gap-2 rounded-xl bg-[var(--c-f5f8fc)] px-3 text-[var(--c-8996a8)]">
  415. <UIcon name="i-lucide-search" class="size-4" />
  416. <input
  417. v-model.trim="specKeyword"
  418. class="min-w-0 flex-1 bg-transparent text-sm text-[var(--c-172033)] outline-none placeholder:text-[var(--c-a8b2c0)]"
  419. placeholder="搜索尺寸或用途"
  420. >
  421. </label>
  422. <div v-if="configLoading" class="mt-4 grid grid-cols-2 gap-3">
  423. <div v-for="index in 4" :key="index" class="h-[86px] animate-pulse rounded-2xl bg-[var(--c-eef2f7)]" />
  424. </div>
  425. <div v-else class="mt-4 grid grid-cols-2 gap-3">
  426. <button
  427. v-for="spec in visibleSpecs"
  428. :key="spec.key"
  429. type="button"
  430. class="relative min-w-0 rounded-2xl border p-3 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
  431. :class="selectedSpecKey === spec.key
  432. ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)] shadow-[0_7px_18px_var(--sh-11-140-255-100)]'
  433. : 'border-[var(--c-e8eef6)] bg-[var(--c-ffffff)] hover:border-[var(--c-bcdcff)]'"
  434. @click="selectedSpecKey = spec.key"
  435. >
  436. <span v-if="spec.hot" class="absolute right-2.5 top-2.5 rounded-full bg-[var(--c-fff1e8)] px-1.5 py-0.5 text-[9px] font-extrabold text-[var(--c-f36b21)]">常用</span>
  437. <span class="block truncate pr-8 text-sm font-extrabold text-[var(--c-172033)]">{{ spec.name }}</span>
  438. <span class="mt-1.5 block text-[11px] text-[var(--c-78869a)]">{{ spec.width }}×{{ spec.height }}px</span>
  439. <span class="mt-0.5 block truncate text-[10px] text-[var(--c-a0aaba)]">{{ spec.print }}</span>
  440. </button>
  441. </div>
  442. <p v-if="!configLoading && !visibleSpecs.length" class="py-8 text-center text-sm text-[var(--c-9aa5b4)]">没有匹配的规格</p>
  443. </section>
  444. <section class="hidden rounded-3xl border border-[var(--c-ddecfb)] bg-[var(--c-f8fbff)] p-5 lg:block">
  445. <div class="flex items-center justify-between gap-4">
  446. <div class="min-w-0">
  447. <div
  448. class="text-xs"
  449. :class="isFreeUsage
  450. ? 'font-bold text-[var(--c-18a66a)]'
  451. : isBalanceInsufficient
  452. ? 'font-bold text-[var(--c-e5484d)]'
  453. : 'text-[var(--c-77879b)]'"
  454. >
  455. {{ billingText }}
  456. </div>
  457. <div class="mt-1 text-base font-extrabold text-[var(--c-172033)]">{{ selectedSpec?.name || '请选择规格' }}</div>
  458. </div>
  459. <button
  460. type="button"
  461. class="inline-flex h-12 min-w-[180px] shrink-0 items-center justify-center gap-2 rounded-full text-sm font-extrabold text-white shadow-[0_12px_24px_var(--sh-11-140-255-240)] transition-all"
  462. :class="canCreate && status !== 'processing'
  463. ? 'bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-39b6ff)] hover:-translate-y-0.5'
  464. : 'cursor-not-allowed bg-[var(--c-9cc8eb)]'"
  465. :disabled="!canCreate || status === 'processing'"
  466. @click="createPhoto"
  467. >
  468. <UIcon name="i-lucide-zap" class="size-4" />
  469. {{ createButtonText }}
  470. </button>
  471. </div>
  472. </section>
  473. </aside>
  474. </div>
  475. </main>
  476. <div class="fixed inset-x-0 bottom-0 z-50 border-t border-[var(--c-e6edf6)] bg-[var(--c-ffffff)]/95 px-4 py-3 shadow-[0_-10px_30px_var(--sh-32-56-92-100)] backdrop-blur lg:hidden" style="padding-bottom: calc(12px + env(safe-area-inset-bottom))">
  477. <div class="mx-auto flex max-w-2xl items-center gap-3">
  478. <button
  479. type="button"
  480. class="flex size-12 shrink-0 items-center justify-center rounded-full bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]"
  481. aria-label="查看历史记录"
  482. @click="openHistory"
  483. >
  484. <UIcon name="i-lucide-history" class="size-5" />
  485. </button>
  486. <button
  487. type="button"
  488. class="flex h-12 min-w-0 flex-1 items-center justify-center gap-2 rounded-full text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)]"
  489. :class="canCreate && status !== 'processing'
  490. ? 'bg-gradient-to-r from-[var(--c-0b8cff)] to-[var(--c-39b6ff)]'
  491. : 'bg-[var(--c-9cc8eb)]'"
  492. :disabled="!canCreate || status === 'processing'"
  493. @click="createPhoto"
  494. >
  495. <span v-if="status === 'processing'" class="size-4 animate-spin rounded-full border-2 border-white/35 border-t-white" />
  496. <UIcon v-else name="i-lucide-zap" class="size-4" />
  497. {{ createButtonText }}
  498. </button>
  499. </div>
  500. </div>
  501. <Teleport to="body">
  502. <Transition name="id-dialog">
  503. <div
  504. v-if="insufficientOpen"
  505. class="fixed inset-0 z-[120] flex items-center justify-center bg-[var(--c-0d1b2f)]/55 p-4 backdrop-blur-[2px]"
  506. role="dialog"
  507. aria-modal="true"
  508. aria-labelledby="insufficient-points-title"
  509. @click.self="insufficientOpen = false"
  510. >
  511. <div class="w-full max-w-[420px] rounded-[28px] bg-[var(--c-ffffff)] p-6 shadow-[0_28px_80px_var(--sh-8-27-54-280)] sm:p-7">
  512. <div class="flex items-start justify-between gap-4">
  513. <span class="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-[var(--c-fff2e8)] text-[var(--c-f06b1d)]">
  514. <UIcon name="i-lucide-coins" class="size-6" />
  515. </span>
  516. <button
  517. type="button"
  518. class="flex size-9 items-center justify-center rounded-full text-[var(--c-8492a6)] transition-colors hover:bg-[var(--c-f3f6fa)] hover:text-[var(--c-172033)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
  519. aria-label="关闭点数不足提示"
  520. @click="insufficientOpen = false"
  521. >
  522. <UIcon name="i-lucide-x" class="size-5" />
  523. </button>
  524. </div>
  525. <h2 id="insufficient-points-title" class="mt-5 text-xl font-extrabold text-[var(--c-172033)]">点数不足</h2>
  526. <p class="mt-2 text-sm leading-6 text-[var(--c-6f7f93)]">本次制作需要 {{ quotePoints }} 点,充值后即可继续制作当前照片。</p>
  527. <div class="mt-5 grid grid-cols-2 divide-x divide-[var(--c-e7edf5)] rounded-2xl bg-[var(--c-f6f9fc)] px-3 py-4 text-center">
  528. <div>
  529. <div class="text-[11px] text-[var(--c-8a97a8)]">当前余额</div>
  530. <div class="mt-1 text-lg font-extrabold text-[var(--c-172033)]">{{ balancePoints }} 点</div>
  531. </div>
  532. <div>
  533. <div class="text-[11px] text-[var(--c-8a97a8)]">还差</div>
  534. <div class="mt-1 text-lg font-extrabold text-[var(--c-f06b1d)]">{{ missingPoints }} 点</div>
  535. </div>
  536. </div>
  537. <div class="mt-6 grid grid-cols-2 gap-3">
  538. <button
  539. type="button"
  540. class="h-11 rounded-full border border-[var(--c-dfe6ef)] text-sm font-extrabold text-[var(--c-64748a)] transition-colors hover:bg-[var(--c-f7f9fc)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
  541. @click="insufficientOpen = false"
  542. >
  543. 暂不充值
  544. </button>
  545. <button
  546. type="button"
  547. class="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-220)] transition-colors hover:bg-[var(--c-087de5)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2"
  548. @click="goRecharge"
  549. >
  550. <UIcon name="i-lucide-wallet-cards" class="size-4" />
  551. 去充值
  552. </button>
  553. </div>
  554. </div>
  555. </div>
  556. </Transition>
  557. </Teleport>
  558. <WorkHistoryDrawer v-model="historyOpen" type="photo" @select="openHistoryItem" />
  559. <WorkDetailModal v-model="detailOpen" :work="currentWork" />
  560. </div>
  561. </template>
  562. <script setup>
  563. const api = useApi()
  564. const { create, createRequestId } = useIdPhoto()
  565. const user = useUser()
  566. const toast = useToast()
  567. const route = useRoute()
  568. const router = useRouter()
  569. useHead({
  570. title: '智能证件照 - 奇想宇宙',
  571. meta: [
  572. {
  573. name: 'description',
  574. content: '在线制作标准证件照,支持自动抠图、背景替换、尺寸裁剪与自然优化。',
  575. },
  576. ],
  577. })
  578. const FALLBACK_BACKGROUNDS = [
  579. { key: 'blue', name: '常用蓝', color: '#438edb' },
  580. { key: 'education-blue', name: '学历浅蓝', color: '#64c5ff' },
  581. { key: 'white', name: '白色', color: '#ffffff' },
  582. { key: 'red', name: '常用红', color: '#9d1016' },
  583. { key: 'gray', name: '浅灰', color: '#f0f0f0' },
  584. ]
  585. const FALLBACK_BEAUTY = [
  586. { key: 'none', name: '关闭美化', desc: '保留原始肤色与细节' },
  587. { key: 'natural', name: '自然优化', desc: '轻微提亮、降噪和锐化' },
  588. ]
  589. const heroBenefits = ['常用证件照尺寸可选', '蓝、白、红等底色自由切换', '300 DPI 高清成品直接下载']
  590. const uploadTips = [
  591. { label: '正面平视', icon: 'i-lucide-scan-face' },
  592. { label: '光线均匀', icon: 'i-lucide-sun' },
  593. { label: '单人半身', icon: 'i-lucide-user-round' },
  594. ]
  595. const processingStages = [
  596. '正在上传并检查照片',
  597. '正在识别人像并去除背景',
  598. '正在按证件照标准裁剪',
  599. '正在生成高清成品',
  600. ]
  601. const PROCESSING_ESTIMATE_SECONDS = 12
  602. const fileInput = ref(null)
  603. const localFile = ref(null)
  604. const localPreview = ref('')
  605. const photoMeta = reactive({ width: 0, height: 0 })
  606. const dragActive = ref(false)
  607. const configLoading = ref(true)
  608. const categories = ref([])
  609. const specs = ref([])
  610. const backgrounds = ref(FALLBACK_BACKGROUNDS)
  611. const beautyOptions = ref(FALLBACK_BEAUTY)
  612. const billing = ref(null)
  613. const selectedCategory = ref('common')
  614. const selectedSpecKey = ref('one-inch')
  615. const specKeyword = ref('')
  616. const status = ref('idle')
  617. const errorMessage = ref('')
  618. const result = ref(null)
  619. const processingStage = ref(0)
  620. const processingProgress = ref(8)
  621. const processingElapsedSeconds = ref(0)
  622. const rerendering = ref(false)
  623. const adjustDirty = ref(false)
  624. const historyOpen = ref(false)
  625. const detailOpen = ref(false)
  626. const insufficientOpen = ref(false)
  627. const currentWork = ref({})
  628. let processingTimer = null
  629. let processingStartedAt = 0
  630. let objectUrl = ''
  631. const settings = reactive({
  632. background: '#438edb',
  633. beauty: 'natural',
  634. scale: 1,
  635. offset_x: 0,
  636. offset_y: 0,
  637. })
  638. function normalizeHexColor(value) {
  639. const color = String(value || '').trim().toLowerCase()
  640. return /^#[0-9a-f]{6}$/.test(color) ? color : ''
  641. }
  642. function setBackground(value) {
  643. const color = normalizeHexColor(value)
  644. if (color) settings.background = color
  645. }
  646. function isBackgroundSelected(value) {
  647. return normalizeHexColor(value) === normalizeHexColor(settings.background)
  648. }
  649. function isLightBackground(value) {
  650. const color = normalizeHexColor(value)
  651. if (!color) return false
  652. const red = Number.parseInt(color.slice(1, 3), 16)
  653. const green = Number.parseInt(color.slice(3, 5), 16)
  654. const blue = Number.parseInt(color.slice(5, 7), 16)
  655. return (red * 299 + green * 587 + blue * 114) / 1000 >= 170
  656. }
  657. const isCustomBackground = computed(
  658. () => !backgrounds.value.some(background => isBackgroundSelected(background.color)),
  659. )
  660. const selectedSpec = computed(() => specs.value.find(item => item.key === selectedSpecKey.value))
  661. const visibleSpecs = computed(() => {
  662. const keyword = specKeyword.value.toLowerCase()
  663. return specs.value.filter((item) => {
  664. const matchesCategory = keyword || item.category === selectedCategory.value
  665. const matchesKeyword = !keyword || `${item.name} ${item.print} ${item.width} ${item.height}`.toLowerCase().includes(keyword)
  666. return matchesCategory && matchesKeyword
  667. })
  668. })
  669. const displayImage = computed(() => result.value?.result_url || localPreview.value)
  670. const resultImageStyle = computed(() => {
  671. const width = Number(result.value?.width || 0)
  672. const height = Number(result.value?.height || 0)
  673. return width && height ? { aspectRatio: `${width} / ${height}` } : {}
  674. })
  675. const fileSizeText = computed(() => {
  676. const bytes = Number(localFile.value?.size || 0)
  677. if (!bytes) return ''
  678. return bytes >= 1024 * 1024
  679. ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
  680. : `${Math.ceil(bytes / 1024)} KB`
  681. })
  682. const canCreate = computed(() => Boolean(localFile.value && selectedSpec.value))
  683. const quotePoints = computed(() => Number(billing.value?.quote?.points || 0))
  684. const balancePoints = computed(() => Number(billing.value?.balance?.points ?? user.userInfo.value?.balance ?? 0))
  685. const isFreeUsage = computed(() => (
  686. !configLoading.value
  687. && Boolean(billing.value?.quote)
  688. && quotePoints.value === 0
  689. ))
  690. const isBalanceInsufficient = computed(() => (
  691. user.isLogin.value
  692. && quotePoints.value > 0
  693. && balancePoints.value >= 0
  694. && balancePoints.value < quotePoints.value
  695. ))
  696. const missingPoints = computed(() => Math.max(0, quotePoints.value - Math.max(0, balancePoints.value)))
  697. const billingText = computed(() => {
  698. if (configLoading.value) return '计费信息加载中'
  699. if (isFreeUsage.value) return '免费使用'
  700. if (!user.isLogin.value) return quotePoints.value ? `登录后制作 · ${quotePoints.value}点/张` : '登录后制作'
  701. if (balancePoints.value < 0) return quotePoints.value ? `不限点数 · 本次预计 ${quotePoints.value} 点` : '不限点数'
  702. if (isBalanceInsufficient.value) return `余额 ${balancePoints.value} 点,还差 ${missingPoints.value} 点`
  703. if (!quotePoints.value) return `当前余额 ${balancePoints.value} 点`
  704. return `余额 ${balancePoints.value} 点 · 本次预计 ${quotePoints.value} 点`
  705. })
  706. const createButtonText = computed(() => {
  707. if (status.value === 'processing') return '正在制作...'
  708. if (result.value) return '重新制作'
  709. if (isFreeUsage.value) return user.isLogin.value ? '免费制作' : '登录后免费制作'
  710. if (!user.isLogin.value) return quotePoints.value ? `登录后制作 · ${quotePoints.value}点` : '登录后制作'
  711. if (isBalanceInsufficient.value) return '点数不足 · 去充值'
  712. return quotePoints.value ? `立即制作 · ${quotePoints.value}点` : '立即制作'
  713. })
  714. const workspaceHint = computed(() => {
  715. if (status.value === 'processing') return '图片处理中,请稍候'
  716. if (result.value) return '制作完成,可下载或继续微调'
  717. if (localFile.value) return '确认右侧规格后开始制作'
  718. return '上传清晰正面照片以获得更稳定的效果'
  719. })
  720. const processingRemainingText = computed(() => {
  721. const remaining = Math.max(0, Math.ceil(PROCESSING_ESTIMATE_SECONDS - processingElapsedSeconds.value))
  722. return remaining > 0 ? `预计还需约 ${remaining} 秒` : '即将完成,请稍候'
  723. })
  724. function showToast(message, color = 'error') {
  725. toast.add({ title: message, color })
  726. }
  727. function triggerUpload() {
  728. fileInput.value?.click()
  729. }
  730. function onFileChange(event) {
  731. const file = event.target.files?.[0]
  732. if (file) selectFile(file)
  733. event.target.value = ''
  734. }
  735. function onDrop(event) {
  736. dragActive.value = false
  737. const file = event.dataTransfer?.files?.[0]
  738. if (file) selectFile(file)
  739. }
  740. async function selectFile(file) {
  741. errorMessage.value = ''
  742. const types = ['image/jpeg', 'image/png', 'image/webp']
  743. if (!types.includes(file.type)) {
  744. showToast('仅支持 JPG、PNG、WebP 图片')
  745. return
  746. }
  747. if (file.size > 10 * 1024 * 1024) {
  748. showToast('图片大小不能超过 10MB')
  749. return
  750. }
  751. revokeObjectUrl()
  752. objectUrl = URL.createObjectURL(file)
  753. localFile.value = file
  754. localPreview.value = objectUrl
  755. result.value = null
  756. status.value = 'ready'
  757. adjustDirty.value = false
  758. resetAdjustments()
  759. try {
  760. const size = await readImageSize(objectUrl)
  761. photoMeta.width = size.width
  762. photoMeta.height = size.height
  763. if (size.width < 300 || size.height < 300) {
  764. showToast('图片分辨率偏低,建议换一张更清晰的照片', 'warning')
  765. }
  766. } catch {
  767. clearPhoto()
  768. showToast('无法读取图片,请更换后重试')
  769. }
  770. }
  771. function readImageSize(url) {
  772. return new Promise((resolve, reject) => {
  773. const image = new Image()
  774. image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight })
  775. image.onerror = reject
  776. image.src = url
  777. })
  778. }
  779. function clearPhoto() {
  780. revokeObjectUrl()
  781. localFile.value = null
  782. localPreview.value = ''
  783. photoMeta.width = 0
  784. photoMeta.height = 0
  785. result.value = null
  786. status.value = 'idle'
  787. errorMessage.value = ''
  788. adjustDirty.value = false
  789. }
  790. function startOver() {
  791. clearPhoto()
  792. nextTick(triggerUpload)
  793. }
  794. function revokeObjectUrl() {
  795. if (objectUrl) URL.revokeObjectURL(objectUrl)
  796. objectUrl = ''
  797. }
  798. function startProcessingProgress() {
  799. clearInterval(processingTimer)
  800. processingStartedAt = Date.now()
  801. updateProcessingProgress()
  802. processingTimer = setInterval(updateProcessingProgress, 500)
  803. }
  804. function updateProcessingProgress() {
  805. const elapsed = Math.max(0, Date.now() - processingStartedAt)
  806. const seconds = elapsed / 1000
  807. processingElapsedSeconds.value = seconds
  808. if (seconds < 1.5) {
  809. processingStage.value = 0
  810. processingProgress.value = Math.round(8 + seconds / 1.5 * 12)
  811. return
  812. }
  813. if (seconds < 8) {
  814. processingStage.value = 1
  815. processingProgress.value = Math.round(20 + (seconds - 1.5) / 6.5 * 52)
  816. return
  817. }
  818. if (seconds < 10) {
  819. processingStage.value = 2
  820. processingProgress.value = Math.round(72 + (seconds - 8) / 2 * 16)
  821. return
  822. }
  823. processingStage.value = 3
  824. processingProgress.value = Math.min(96, Math.round(88 + Math.min(seconds - 10, 2) * 2 + Math.max(0, seconds - 12) / 3))
  825. }
  826. function stopProcessingProgress(success = false) {
  827. clearInterval(processingTimer)
  828. processingTimer = null
  829. if (success) {
  830. processingStage.value = processingStages.length - 1
  831. processingProgress.value = 100
  832. processingElapsedSeconds.value = PROCESSING_ESTIMATE_SECONDS
  833. }
  834. }
  835. async function createPhoto() {
  836. if (!user.isLogin.value) {
  837. user.showLogin.value = true
  838. return
  839. }
  840. if (!localFile.value) {
  841. showToast('请先上传一张照片')
  842. triggerUpload()
  843. return
  844. }
  845. if (!selectedSpec.value || status.value === 'processing') return
  846. if (isBalanceInsufficient.value) {
  847. insufficientOpen.value = true
  848. return
  849. }
  850. status.value = 'processing'
  851. errorMessage.value = ''
  852. startProcessingProgress()
  853. try {
  854. const response = await create(localFile.value, {
  855. request_id: createRequestId(),
  856. spec_key: selectedSpecKey.value,
  857. ...settings,
  858. })
  859. stopProcessingProgress(true)
  860. result.value = response.data || null
  861. status.value = 'success'
  862. adjustDirty.value = false
  863. currentWork.value = {
  864. ...(response.data || {}),
  865. id: response.data?.photo_open_id,
  866. work_type: 'photo',
  867. cover: response.data?.result_url,
  868. original: response.data?.source_url,
  869. }
  870. await user.refreshBalance()
  871. if (billing.value?.balance) billing.value.balance.points = user.userInfo.value.balance
  872. showToast('证件照制作完成', 'success')
  873. } catch (error) {
  874. stopProcessingProgress()
  875. if (Number(error?.code || error?.data?.code) === 1011) {
  876. status.value = 'ready'
  877. insufficientOpen.value = true
  878. return
  879. }
  880. status.value = 'error'
  881. errorMessage.value = friendlyError(error)
  882. showToast(errorMessage.value)
  883. }
  884. }
  885. function friendlyError(error) {
  886. const serviceCode = error?.data?.error
  887. const map = {
  888. NO_FACE: '没有检测到清晰人脸,请上传正面半身照',
  889. MULTIPLE_FACES: '检测到多张人脸,请上传单人照片',
  890. FACE_TOO_SMALL: '人脸在画面中太小,请上传更清晰的近距离照片',
  891. FACE_TILTED: '人脸倾斜角度较大,请上传正面平视照片',
  892. QUEUE_FULL: '当前制作人数较多,请稍后再试',
  893. }
  894. return map[serviceCode] || error?.message || '证件照制作失败,请稍后重试'
  895. }
  896. async function applyAdjustments() {
  897. if (!result.value?.photo_open_id || !adjustDirty.value || rerendering.value) return
  898. rerendering.value = true
  899. errorMessage.value = ''
  900. try {
  901. const response = await api.post('/idphoto/rerender', {
  902. photo_open_id: result.value.photo_open_id,
  903. ...settings,
  904. })
  905. result.value = { ...result.value, ...(response.data || {}) }
  906. currentWork.value = {
  907. ...currentWork.value,
  908. ...result.value,
  909. cover: result.value.result_url,
  910. original: result.value.source_url,
  911. }
  912. adjustDirty.value = false
  913. showToast('调整已应用', 'success')
  914. } catch (error) {
  915. errorMessage.value = friendlyError(error)
  916. showToast(errorMessage.value)
  917. } finally {
  918. rerendering.value = false
  919. }
  920. }
  921. function resetAdjustments() {
  922. settings.scale = 1
  923. settings.offset_x = 0
  924. settings.offset_y = 0
  925. }
  926. function adjustmentLabel(value) {
  927. const number = Number(value)
  928. if (Math.abs(number) < 0.01) return '居中'
  929. return number > 0 ? `+${Math.round(number * 100)}` : `${Math.round(number * 100)}`
  930. }
  931. function openHistory() {
  932. if (!user.isLogin.value) {
  933. user.showLogin.value = true
  934. return
  935. }
  936. historyOpen.value = true
  937. }
  938. function goRecharge() {
  939. insufficientOpen.value = false
  940. router.push('/recharge')
  941. }
  942. function openHistoryItem(item) {
  943. currentWork.value = item
  944. detailOpen.value = true
  945. }
  946. async function downloadResult() {
  947. const url = result.value?.result_url
  948. if (!url) return
  949. try {
  950. const response = await fetch(url)
  951. if (!response.ok) throw new Error('download failed')
  952. const blob = await response.blob()
  953. const object = URL.createObjectURL(blob)
  954. const anchor = document.createElement('a')
  955. anchor.href = object
  956. anchor.download = `${result.value.spec_name || '证件照'}-${result.value.width}x${result.value.height}.jpg`
  957. anchor.click()
  958. URL.revokeObjectURL(object)
  959. } catch {
  960. window.open(url, '_blank', 'noopener')
  961. }
  962. }
  963. function handleImageError() {
  964. errorMessage.value = result.value ? '成品图片加载失败,请稍后重试' : '照片预览失败,请重新选择'
  965. }
  966. async function loadConfig() {
  967. configLoading.value = true
  968. try {
  969. const response = await api.post('/idphoto/config')
  970. const data = response.data || {}
  971. categories.value = Array.isArray(data.categories) ? data.categories : []
  972. specs.value = Array.isArray(data.specs) ? data.specs : []
  973. backgrounds.value = Array.isArray(data.backgrounds) && data.backgrounds.length
  974. ? data.backgrounds
  975. : FALLBACK_BACKGROUNDS
  976. beautyOptions.value = Array.isArray(data.beauty_options) && data.beauty_options.length
  977. ? data.beauty_options
  978. : FALLBACK_BEAUTY
  979. billing.value = data.billing || null
  980. if (!selectedSpec.value && specs.value.length) {
  981. selectedSpecKey.value = specs.value[0].key
  982. selectedCategory.value = specs.value[0].category
  983. }
  984. } catch (error) {
  985. errorMessage.value = error.message || '证件照配置加载失败'
  986. showToast(errorMessage.value)
  987. } finally {
  988. configLoading.value = false
  989. }
  990. }
  991. async function loadWorkFromRoute() {
  992. const id = String(route.query.work || '')
  993. if (!id || !user.isLogin.value) return
  994. try {
  995. const response = await api.post('/idphoto/getdetail', { photo_open_id: id })
  996. if (response.data) {
  997. currentWork.value = { ...response.data, work_type: 'photo' }
  998. detailOpen.value = true
  999. }
  1000. } catch {}
  1001. }
  1002. watch(
  1003. () => [settings.background, settings.beauty, settings.scale, settings.offset_x, settings.offset_y],
  1004. () => {
  1005. if (result.value && !rerendering.value) adjustDirty.value = true
  1006. },
  1007. )
  1008. watch(() => user.isLogin.value, (loggedIn) => {
  1009. if (loggedIn) {
  1010. user.refreshBalance()
  1011. loadConfig()
  1012. }
  1013. })
  1014. onMounted(async () => {
  1015. await loadConfig()
  1016. if (user.isLogin.value) {
  1017. await user.refreshBalance()
  1018. await loadWorkFromRoute()
  1019. }
  1020. })
  1021. onBeforeUnmount(() => {
  1022. clearInterval(processingTimer)
  1023. revokeObjectUrl()
  1024. })
  1025. </script>
  1026. <style scoped>
  1027. .no-scrollbar {
  1028. scrollbar-width: none;
  1029. -ms-overflow-style: none;
  1030. }
  1031. .no-scrollbar::-webkit-scrollbar {
  1032. display: none;
  1033. }
  1034. .id-range {
  1035. height: 4px;
  1036. appearance: none;
  1037. border-radius: 999px;
  1038. background: var(--c-dfe7f0);
  1039. outline: none;
  1040. }
  1041. .id-range::-webkit-slider-thumb {
  1042. width: 18px;
  1043. height: 18px;
  1044. appearance: none;
  1045. cursor: pointer;
  1046. border: 3px solid var(--c-ffffff);
  1047. border-radius: 50%;
  1048. background: var(--c-0b8cff);
  1049. box-shadow: 0 2px 8px var(--sh-11-140-255-350);
  1050. }
  1051. .id-range::-moz-range-thumb {
  1052. width: 14px;
  1053. height: 14px;
  1054. cursor: pointer;
  1055. border: 3px solid var(--c-ffffff);
  1056. border-radius: 50%;
  1057. background: var(--c-0b8cff);
  1058. box-shadow: 0 2px 8px var(--sh-11-140-255-350);
  1059. }
  1060. .id-dialog-enter-active,
  1061. .id-dialog-leave-active {
  1062. transition: opacity 180ms ease;
  1063. }
  1064. .id-dialog-enter-active > div,
  1065. .id-dialog-leave-active > div {
  1066. transition: transform 180ms ease, opacity 180ms ease;
  1067. }
  1068. .id-dialog-enter-from,
  1069. .id-dialog-leave-to {
  1070. opacity: 0;
  1071. }
  1072. .id-dialog-enter-from > div,
  1073. .id-dialog-leave-to > div {
  1074. opacity: 0;
  1075. transform: translateY(12px) scale(0.98);
  1076. }
  1077. @media (prefers-reduced-motion: reduce) {
  1078. * {
  1079. scroll-behavior: auto !important;
  1080. }
  1081. .id-dialog-enter-active,
  1082. .id-dialog-leave-active,
  1083. .id-dialog-enter-active > div,
  1084. .id-dialog-leave-active > div {
  1085. transition: none;
  1086. }
  1087. }
  1088. </style>