You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

718 rivejä
31 KiB

  1. <template>
  2. <div class="min-h-screen bg-[var(--c-f3f7fb)]">
  3. <!-- ========== Hero ========== -->
  4. <AppHero
  5. kicker="OLD PHOTO RESTORE"
  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 class="mt-3 flex flex-wrap gap-2.5">
  84. <span class="rounded-full bg-[var(--c-f1f5fa)] px-3 py-1 text-[11px] font-bold text-[var(--c-526174)]">黑白照片</span>
  85. <span class="rounded-full bg-[var(--c-f1f5fa)] px-3 py-1 text-[11px] font-bold text-[var(--c-526174)]">泛黄褪色</span>
  86. <span class="rounded-full bg-[var(--c-f1f5fa)] px-3 py-1 text-[11px] font-bold text-[var(--c-526174)]">划痕破损</span>
  87. </div>
  88. </div>
  89. <!-- 2. 修复档位 -->
  90. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
  91. <div class="flex items-start justify-between gap-4">
  92. <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">修复档位</h3>
  93. <span class="mt-1 text-xs text-[var(--c-8a97a8)]">按张计费</span>
  94. </div>
  95. <!-- 加载骨架:>300ms 的等待都要有反馈 -->
  96. <div v-if="configLoading" class="mt-4 space-y-2.5">
  97. <div v-for="i in 3" :key="i" class="h-[76px] animate-pulse rounded-2xl bg-[var(--c-f1f5fa)]" />
  98. </div>
  99. <div v-else class="mt-4 space-y-2.5">
  100. <button
  101. v-for="item in modes"
  102. :key="item.key"
  103. type="button"
  104. class="flex w-full cursor-pointer items-center gap-4 rounded-2xl border-2 p-4 text-left transition-colors"
  105. :class="activeMode === item.key
  106. ? 'border-[var(--c-1f8cff)] bg-[var(--c-eef7ff)]'
  107. : 'border-transparent bg-[var(--c-f6f9fd)] hover:border-[var(--c-e5edf7)]'"
  108. :aria-pressed="activeMode === item.key"
  109. @click="activeMode = item.key"
  110. >
  111. <span
  112. class="flex size-5 flex-shrink-0 items-center justify-center rounded-full border-2 transition-colors"
  113. :class="activeMode === item.key ? 'border-[var(--c-1f8cff)]' : 'border-[var(--c-c3cddb)]'"
  114. >
  115. <span v-if="activeMode === item.key" class="size-2.5 rounded-full bg-[var(--c-1f8cff)]" />
  116. </span>
  117. <span class="min-w-0 flex-1">
  118. <span class="block text-sm font-extrabold text-[var(--c-172033)]">{{ item.name }}</span>
  119. <span class="mt-1 block text-xs leading-relaxed text-[var(--c-8a97a8)]">{{ item.desc }}</span>
  120. </span>
  121. <span class="flex-shrink-0 text-sm font-extrabold text-[var(--c-0b8cff)]">{{ item.points }}点</span>
  122. </button>
  123. </div>
  124. </div>
  125. <!-- 3. 补充说明 -->
  126. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-[0_10px_28px_var(--sh-32-56-92-50)] lg:p-6">
  127. <div class="flex items-start justify-between gap-4">
  128. <h3 class="text-base font-extrabold text-[var(--c-172033)] lg:text-lg">补充说明</h3>
  129. <span class="mt-1 text-xs text-[var(--c-8a97a8)]">选填</span>
  130. </div>
  131. <label class="mt-4 block text-xs font-bold text-[var(--c-526174)]" for="old-photo-extra">照片里的事实信息</label>
  132. <input
  133. id="old-photo-extra"
  134. v-model="extraPrompt"
  135. type="text"
  136. maxlength="100"
  137. placeholder="例如:军装是绿色的、背景是老家的院子"
  138. 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"
  139. >
  140. <p class="mt-2 text-xs text-[var(--c-8a97a8)]">只用来补充客观信息,不要写风格化描述,避免影响人物还原。</p>
  141. </div>
  142. <!-- 4. 说明 -->
  143. <div 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">
  144. <div v-for="(tip, index) in tips" :key="tip" class="flex items-start">
  145. <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)]">
  146. {{ String(index + 1).padStart(2, '0') }}
  147. </span>
  148. <span class="text-sm leading-relaxed text-[var(--c-5f6f83)]">{{ tip }}</span>
  149. </div>
  150. </div>
  151. <!-- Desktop 提交 -->
  152. <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">
  153. <div class="min-w-0 flex-1">
  154. <div class="whitespace-nowrap text-sm font-bold text-[var(--c-172033)]">{{ balanceText }}</div>
  155. <button v-if="isBalanceInsufficient" type="button" class="mt-1 cursor-pointer text-xs text-[var(--c-f04438)] hover:underline" @click="goRecharge">
  156. 点数不足,前往充值
  157. </button>
  158. </div>
  159. <button
  160. type="button"
  161. 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)]"
  162. @click="historyOpen = true"
  163. >
  164. <UIcon name="i-lucide-history" class="size-4" />
  165. 历史记录
  166. </button>
  167. <button
  168. type="button"
  169. 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"
  170. :class="submitting ? 'bg-[var(--c-1979e6)]' : 'bg-gradient-to-r from-[var(--c-1f8cff)] to-[var(--c-41b9ff)] hover:opacity-90'"
  171. :disabled="!canSubmit"
  172. @click="handleSubmit"
  173. >
  174. <UIcon v-if="!submitting" name="i-lucide-zap" class="mr-1.5 size-4" />
  175. <span>{{ submitting ? '提交中...' : submitText }}</span>
  176. </button>
  177. </div>
  178. </div>
  179. <!-- ===== RIGHT: 结果 ===== -->
  180. <div class="lg:col-span-5">
  181. <div class="space-y-4 lg:sticky lg:top-20">
  182. <!-- 空态 -->
  183. <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">
  184. <UIcon name="i-lucide-image" class="mb-4 size-12 text-[var(--c-a0aabb)]" />
  185. <div class="mb-1 text-base font-medium text-[var(--c-172033)]">等待修复</div>
  186. <div class="text-sm text-[var(--c-a0aabb)]">上传老照片并选择档位后</div>
  187. <div class="text-sm text-[var(--c-a0aabb)]">点击"立即修复"即可看到效果</div>
  188. </div>
  189. <!-- 处理中 -->
  190. <div v-if="resultState.loading" class="rounded-2xl bg-[var(--c-ffffff)] p-6 shadow-sm lg:p-8">
  191. <div class="mb-1 text-base font-bold text-[var(--c-172033)]">{{ pendingTitle }}</div>
  192. <div class="mb-6 text-sm text-[var(--c-7f8896)]">混元生图未购买并发,高峰期会排队,请耐心等待</div>
  193. <div class="flex flex-col items-center rounded-2xl bg-[var(--c-f7f9fd)] py-10">
  194. <div class="mb-4 flex gap-1.5">
  195. <span class="animate-pulse-dot size-2.5 rounded-full bg-[var(--c-1f8cff)]" />
  196. <span class="animate-pulse-dot2 size-2.5 rounded-full bg-[var(--c-1f8cff)]" />
  197. <span class="animate-pulse-dot3 size-2.5 rounded-full bg-[var(--c-1f8cff)]" />
  198. </div>
  199. <div class="text-sm font-bold text-[var(--c-172033)]">已等待 {{ waitedSeconds }}s</div>
  200. <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[var(--c-8a95a6)]">下次刷新 {{ pollCountdown }}s</div>
  201. </div>
  202. </div>
  203. <!-- 失败 -->
  204. <div v-if="resultState.error" class="rounded-2xl bg-[var(--c-ffffff)] p-6 py-10 text-center shadow-sm lg:p-8">
  205. <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-12 text-[var(--c-f04438)]" />
  206. <div class="mb-2 text-base font-medium text-[var(--c-f04438)]">修复失败</div>
  207. <div class="mb-4 text-sm text-[var(--c-7f8896)]">{{ resultState.errorMsg }}</div>
  208. <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton>
  209. </div>
  210. <!-- 成功 -->
  211. <div v-if="resultState.success" class="space-y-4">
  212. <div class="rounded-2xl bg-[var(--c-ffffff)] p-5 shadow-sm lg:p-6">
  213. <div class="mb-4 flex items-center justify-between gap-3">
  214. <div class="text-base font-bold text-[var(--c-172033)]">修复结果</div>
  215. <span class="rounded-full bg-[var(--c-edfdf4)] px-2.5 py-1 text-[11px] font-extrabold text-[var(--c-159a54)]">{{ job.mode_name }}</span>
  216. </div>
  217. <!-- 主视图直接看成品,对比放进弹层 -->
  218. <div class="overflow-hidden rounded-xl bg-[var(--c-f7f9fd)]">
  219. <img :src="job.result_url" class="w-full" alt="老照片修复结果">
  220. </div>
  221. <div class="mt-5 flex gap-3">
  222. <button
  223. type="button"
  224. 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)]"
  225. @click="openResultCompare"
  226. >
  227. <UIcon name="i-lucide-columns-2" class="mr-1.5 size-4" />
  228. 查看对比
  229. </button>
  230. <button
  231. type="button"
  232. 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)]"
  233. @click="downloadResult"
  234. >
  235. <UIcon name="i-lucide-download" class="mr-1.5 size-4" />
  236. 下载
  237. </button>
  238. </div>
  239. </div>
  240. <button
  241. type="button"
  242. 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)]"
  243. @click="resetJob"
  244. >
  245. 再修一张
  246. </button>
  247. <p class="text-center text-xs leading-relaxed text-[var(--c-8a97a8)]">
  248. AI 会重绘上色,五官可能出现轻微变化,建议对照原图确认
  249. </p>
  250. </div>
  251. </div>
  252. </div>
  253. </div>
  254. </div>
  255. <!-- ========== 移动端底部操作条 ========== -->
  256. <div
  257. 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"
  258. style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))"
  259. >
  260. <div class="mb-2.5 flex items-center justify-between gap-3 text-sm">
  261. <div class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div>
  262. <button v-if="isBalanceInsufficient" type="button" class="flex-shrink-0 cursor-pointer text-xs font-extrabold text-[var(--c-1f8cff)]" @click="goRecharge">
  263. 去充值
  264. </button>
  265. </div>
  266. <div class="flex gap-3">
  267. <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">
  268. 历史记录
  269. </button>
  270. <button
  271. type="button"
  272. 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"
  273. :disabled="!canSubmit"
  274. @click="handleSubmit"
  275. >
  276. <UIcon v-if="!submitting" name="i-lucide-zap" class="mr-1.5 size-4" />
  277. {{ submitting ? '提交中...' : submitText }}
  278. </button>
  279. </div>
  280. </div>
  281. <!-- ========== 历史记录 / 详情 ========== -->
  282. <WorkHistoryDrawer v-model="historyOpen" type="oldphoto" @select="openHistoryItem" />
  283. <WorkDetailModal v-model="detailOpen" :work="currentWork" />
  284. <!-- ========== 对比弹层(demo 与成品共用) ========== -->
  285. <Teleport to="body">
  286. <Transition name="modal">
  287. <div v-if="compareVisible" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
  288. <div class="absolute inset-0 bg-[var(--c-0f172a)]/60" @click="closeCompare" />
  289. <div class="relative w-full max-w-2xl overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-2xl">
  290. <div class="flex items-center justify-between border-b border-[var(--c-f3f4f6)] px-5 py-4">
  291. <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTarget === 'result' ? '修复结果对比' : '效果对比' }}</h3>
  292. <button
  293. type="button"
  294. 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)]"
  295. aria-label="关闭对比"
  296. @click="closeCompare"
  297. >
  298. ×
  299. </button>
  300. </div>
  301. <div class="p-5">
  302. <div
  303. ref="largeCompareRef"
  304. class="relative max-h-[70vh] w-full cursor-ew-resize select-none overflow-hidden rounded-2xl bg-[var(--c-dfe8f4)]"
  305. style="aspect-ratio: 3 / 4"
  306. @mousedown="onCompareStart"
  307. @touchstart.passive="onCompareStart"
  308. >
  309. <img :src="comparePair.after" class="pointer-events-none absolute inset-0 h-full w-full object-contain" alt="修复后">
  310. <img
  311. :src="comparePair.before"
  312. class="pointer-events-none absolute inset-0 h-full w-full object-contain"
  313. :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }"
  314. alt="原图"
  315. >
  316. <div
  317. 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)]"
  318. :style="{ left: comparePercent + '%' }"
  319. >
  320. <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)]">
  321. <span>‹</span><span>›</span>
  322. </div>
  323. </div>
  324. <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>
  325. <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>
  326. </div>
  327. <div class="mt-3 text-center text-xs text-[var(--c-64748b)]">左右拖动中间按钮查看变化</div>
  328. </div>
  329. </div>
  330. </div>
  331. </Transition>
  332. </Teleport>
  333. </div>
  334. </template>
  335. <script setup>
  336. useSeoMeta({
  337. title: '老照片修复上色 - AI在线',
  338. description: '上传黑白、泛黄、有划痕的老照片,AI 自动修复破损并自然上色,支持前后对比查看与原图下载。',
  339. })
  340. const { post } = useApi()
  341. const { create: createOldPhoto, createRequestId } = useOldPhoto()
  342. const user = useUser()
  343. const router = useRouter()
  344. const toast = useToast()
  345. const POLL_INTERVAL = 3000
  346. const POLL_LIMIT = 5 * 60 * 1000
  347. const DEMO_CONFIG_KEY = 'old_photo_demo'
  348. const APP_KEY = 'old-photo'
  349. // ==================== State ====================
  350. const fileInput = ref(null)
  351. const selectedFile = ref(null)
  352. const previewImage = ref('')
  353. const extraPrompt = ref('')
  354. const activeMode = ref('colorize')
  355. const modes = ref([])
  356. const tips = ref([])
  357. const configLoading = ref(true)
  358. const submitting = ref(false)
  359. const balance = reactive({ points: 0 })
  360. const job = ref({})
  361. const demoPair = ref({ original: '', result: '' })
  362. const historyOpen = ref(false)
  363. const detailOpen = ref(false)
  364. const currentWork = ref({})
  365. const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' })
  366. const pollCountdown = ref(3)
  367. const waitedSeconds = ref(0)
  368. let pollTimer = null
  369. let countTimer = null
  370. let pollStart = 0
  371. // ==================== Computed ====================
  372. const hasDemo = computed(() => Boolean(demoPair.value.original && demoPair.value.result))
  373. const currentMode = computed(() => modes.value.find(item => item.key === activeMode.value) || {})
  374. const currentCost = computed(() => Number(currentMode.value.points || 0))
  375. const currentPoints = computed(() => Number(balance.points || 0))
  376. const isBalanceInsufficient = computed(
  377. () => user.isLogin.value && currentCost.value > 0 && currentPoints.value < currentCost.value,
  378. )
  379. const canSubmit = computed(() => Boolean(selectedFile.value) && !submitting.value && !resultState.value.loading)
  380. const balanceText = computed(() => {
  381. const cost = currentCost.value
  382. if (!user.isLogin.value) return cost ? `老照片修复 · ${cost}点/张` : '老照片修复'
  383. return cost ? `余额${currentPoints.value}点 · 预计消耗${cost}点` : `余额${currentPoints.value}点`
  384. })
  385. const submitText = computed(() => {
  386. const cost = currentCost.value
  387. if (!user.isLogin.value) return cost ? `登录后修复 · ${cost}点` : '登录后修复'
  388. return cost ? `立即修复 · ${cost}点` : '立即修复'
  389. })
  390. const pendingTitle = computed(() => (Number(job.value.status) === 10 ? '排队中,等待 AI 处理' : 'AI 正在修复上色'))
  391. function showToast(message, color = 'error') {
  392. toast.add({ title: message, color })
  393. }
  394. // ==================== 配置 / 示例 / 余额 ====================
  395. async function loadConfig() {
  396. try {
  397. const res = await post('/oldphoto/config', { mode: activeMode.value })
  398. const data = res.data || {}
  399. modes.value = data.modes || []
  400. tips.value = data.tips || []
  401. if (data.default_mode && !modes.value.some(item => item.key === activeMode.value)) {
  402. activeMode.value = data.default_mode
  403. }
  404. if (data.billing?.balance) {
  405. balance.points = data.billing.balance.points || data.billing.balance.point_balance || 0
  406. }
  407. } catch (e) {
  408. showToast(e.message || '配置加载失败')
  409. } finally {
  410. configLoading.value = false
  411. }
  412. }
  413. /** 示例对比图走配置项 old_photo_demo,格式 [{ original, result }] */
  414. async function loadDemo() {
  415. try {
  416. const res = await post('/common/getconfig', { keys: DEMO_CONFIG_KEY })
  417. const rows = Array.isArray(res.list) ? res.list : []
  418. const item = rows.find(it => it.key === DEMO_CONFIG_KEY)
  419. if (!item?.value) return
  420. const data = typeof item.value === 'string' ? JSON.parse(item.value) : item.value
  421. const first = Array.isArray(data) ? data[0] : data
  422. if (!first) return
  423. demoPair.value = {
  424. original: first.original || first.before || first.source || '',
  425. result: first.result || first.after || '',
  426. }
  427. } catch (e) {
  428. console.error('[old-photo] loadDemo failed:', e)
  429. }
  430. }
  431. async function loadBalance() {
  432. try {
  433. const res = await post('/point/balance', {})
  434. if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0
  435. } catch (e) {
  436. console.error('[old-photo] loadBalance failed:', e)
  437. }
  438. user.refreshBalance()
  439. }
  440. onMounted(() => {
  441. loadConfig()
  442. loadDemo()
  443. if (user.isLogin.value) loadBalance()
  444. })
  445. onUnmounted(() => clearPoll())
  446. // ==================== 上传 ====================
  447. function triggerUpload() {
  448. if (!user.isLogin.value) {
  449. user.showLogin.value = true
  450. return
  451. }
  452. fileInput.value?.click()
  453. }
  454. function onFileChange(e) {
  455. const file = e.target.files?.[0]
  456. if (file) handleFile(file)
  457. e.target.value = ''
  458. }
  459. function onDrop(e) {
  460. if (!user.isLogin.value) {
  461. user.showLogin.value = true
  462. return
  463. }
  464. const file = e.dataTransfer?.files?.[0]
  465. if (file) handleFile(file)
  466. }
  467. function handleFile(file) {
  468. if (!/^image\//.test(file.type)) return showToast('请选择图片文件')
  469. if (file.size > 10 * 1024 * 1024) return showToast('图片不能超过 10M')
  470. if (previewImage.value) URL.revokeObjectURL(previewImage.value)
  471. selectedFile.value = file
  472. previewImage.value = URL.createObjectURL(file)
  473. resultState.value = { visible: false, loading: false, error: false, success: false, errorMsg: '' }
  474. job.value = {}
  475. }
  476. // ==================== 提交与轮询 ====================
  477. function clearPoll() {
  478. clearTimeout(pollTimer)
  479. clearInterval(countTimer)
  480. pollTimer = null
  481. countTimer = null
  482. }
  483. async function handleSubmit() {
  484. if (!user.isLogin.value) {
  485. user.showLogin.value = true
  486. return
  487. }
  488. if (!selectedFile.value) return showToast('请先上传老照片')
  489. if (!canSubmit.value) return
  490. if (isBalanceInsufficient.value) return showToast('点数不足,请充值后重试')
  491. submitting.value = true
  492. resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' }
  493. waitedSeconds.value = 0
  494. pollStart = Date.now()
  495. try {
  496. const res = await createOldPhoto(selectedFile.value, {
  497. mode: activeMode.value,
  498. extra_prompt: extraPrompt.value,
  499. request_id: createRequestId(),
  500. })
  501. job.value = res.data || {}
  502. if (Number(job.value.status) === 30) {
  503. onSuccess()
  504. } else {
  505. startPoll()
  506. }
  507. } catch (e) {
  508. const msg = e.code === 1011 ? '点数不足,请充值后重试' : (e.message || '提交失败,请稍后重试')
  509. resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: msg }
  510. } finally {
  511. submitting.value = false
  512. }
  513. }
  514. function startPoll() {
  515. clearPoll()
  516. pollCountdown.value = POLL_INTERVAL / 1000
  517. countTimer = setInterval(() => {
  518. pollCountdown.value = Math.max(0, pollCountdown.value - 1)
  519. waitedSeconds.value = Math.round((Date.now() - pollStart) / 1000)
  520. }, 1000)
  521. pollTimer = setTimeout(async () => {
  522. if (Date.now() - pollStart > POLL_LIMIT) {
  523. clearPoll()
  524. resultState.value = {
  525. visible: true,
  526. loading: false,
  527. error: true,
  528. success: false,
  529. errorMsg: '生成时间较长,可稍后从历史记录查看结果',
  530. }
  531. return
  532. }
  533. try {
  534. const res = await post('/oldphoto/getdetail', { photo_open_id: job.value.photo_open_id })
  535. const data = res.data || {}
  536. job.value = data
  537. const status = Number(data.status)
  538. if (status === 30) {
  539. clearPoll()
  540. onSuccess()
  541. } else if (status < 0) {
  542. clearPoll()
  543. resultState.value = {
  544. visible: true,
  545. loading: false,
  546. error: true,
  547. success: false,
  548. errorMsg: data.error_msg || '修复失败,本次未扣点',
  549. }
  550. } else {
  551. startPoll()
  552. }
  553. } catch (e) {
  554. // 单次查询失败不打断轮询
  555. startPoll()
  556. }
  557. }, POLL_INTERVAL)
  558. }
  559. function onSuccess() {
  560. resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' }
  561. loadBalance()
  562. }
  563. function resetJob() {
  564. clearPoll()
  565. job.value = {}
  566. selectedFile.value = null
  567. if (previewImage.value) URL.revokeObjectURL(previewImage.value)
  568. previewImage.value = ''
  569. extraPrompt.value = ''
  570. resultState.value = { visible: false, loading: false, error: false, success: false, errorMsg: '' }
  571. }
  572. async function downloadResult() {
  573. if (!job.value.result_url) return
  574. try {
  575. const res = await fetch(job.value.result_url)
  576. const blob = await res.blob()
  577. const link = document.createElement('a')
  578. link.href = URL.createObjectURL(blob)
  579. link.download = `old-photo-${Date.now()}.jpg`
  580. link.click()
  581. URL.revokeObjectURL(link.href)
  582. } catch {
  583. window.open(job.value.result_url, '_blank')
  584. }
  585. }
  586. function goRecharge() {
  587. router.push('/recharge')
  588. }
  589. function openHistoryItem(item) {
  590. currentWork.value = item
  591. detailOpen.value = true
  592. }
  593. // ==================== 对比弹层 ====================
  594. const compareVisible = ref(false)
  595. const comparePercent = ref(50)
  596. const compareTarget = ref('demo')
  597. const largeCompareRef = ref(null)
  598. let dragging = false
  599. const comparePair = computed(() => {
  600. if (compareTarget.value === 'result') {
  601. return { before: job.value.source_url || previewImage.value, after: job.value.result_url }
  602. }
  603. return { before: demoPair.value.original, after: demoPair.value.result }
  604. })
  605. function openDemoCompare() {
  606. if (!hasDemo.value) return showToast('暂无对比图')
  607. comparePercent.value = 50
  608. compareTarget.value = 'demo'
  609. compareVisible.value = true
  610. }
  611. function openResultCompare() {
  612. if (!job.value.result_url) return showToast('结果图暂不可用')
  613. comparePercent.value = 50
  614. compareTarget.value = 'result'
  615. compareVisible.value = true
  616. }
  617. function closeCompare() {
  618. compareVisible.value = false
  619. onCompareEnd()
  620. }
  621. function onCompareStart(e) {
  622. dragging = true
  623. updateComparePercent(e.touches?.[0] || e)
  624. window.addEventListener('mousemove', onCompareMove)
  625. window.addEventListener('mouseup', onCompareEnd)
  626. window.addEventListener('touchmove', onCompareMove, { passive: false })
  627. window.addEventListener('touchend', onCompareEnd)
  628. }
  629. function onCompareMove(e) {
  630. if (!dragging) return
  631. if (e.cancelable) e.preventDefault()
  632. updateComparePercent(e.touches?.[0] || e)
  633. }
  634. function onCompareEnd() {
  635. dragging = false
  636. window.removeEventListener('mousemove', onCompareMove)
  637. window.removeEventListener('mouseup', onCompareEnd)
  638. window.removeEventListener('touchmove', onCompareMove)
  639. window.removeEventListener('touchend', onCompareEnd)
  640. }
  641. function updateComparePercent(e) {
  642. if (!largeCompareRef.value) return
  643. const rect = largeCompareRef.value.getBoundingClientRect()
  644. const percent = (((e.clientX || 0) - rect.left) / rect.width) * 100
  645. comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1))))
  646. }
  647. </script>
  648. <style scoped>
  649. .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; }
  650. .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; }
  651. .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; }
  652. @keyframes dotPulse {
  653. 0%, 100% { opacity: 0.35; transform: scale(0.82); }
  654. 50% { opacity: 1; transform: scale(1); }
  655. }
  656. .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
  657. .modal-enter-from, .modal-leave-to { opacity: 0; }
  658. /* 尊重系统的减少动效设置 */
  659. @media (prefers-reduced-motion: reduce) {
  660. .animate-pulse-dot,
  661. .animate-pulse-dot2,
  662. .animate-pulse-dot3 { animation: none; }
  663. .modal-enter-active, .modal-leave-active { transition: none; }
  664. }
  665. </style>