Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 

901 Zeilen
27 KiB

  1. <template>
  2. <view class="wrap">
  3. <view class="hero-card">
  4. <view class="hero-copy">
  5. <text class="eyebrow">OLD PHOTO RESTORE</text>
  6. <text class="hero-title">老照片修复 / 上色</text>
  7. <text class="hero-desc">黑白、泛黄、模糊、有划痕的照片都能修。AI 会重绘上色,五官可能有轻微变化。</text>
  8. <view class="hero-actions">
  9. <view v-if="hasDemo" class="hero-pill" @click="openDemoCompare">查看对比</view>
  10. <view class="hero-link" @click="chooseImage">上传照片</view>
  11. </view>
  12. </view>
  13. <!-- demo 前后对比缩略图(配置项 old_photo_demo),点击进大图对比 -->
  14. <view v-if="hasDemo" class="hero-thumb" @click="openDemoCompare">
  15. <view class="mini-compare">
  16. <image class="compare-img" :src="demoPair.result" mode="aspectFill" />
  17. <view class="compare-before mini-before">
  18. <image class="compare-img" :src="demoPair.original" mode="aspectFill" />
  19. </view>
  20. <view class="compare-line mini-line"></view>
  21. <!-- 缩略图只有 168rpx 宽,标签用两个字,否则会挤到中线上 -->
  22. <view class="compare-tag left">原图</view>
  23. <view class="compare-tag right">修复</view>
  24. </view>
  25. </view>
  26. </view>
  27. <!-- ==================== 结果 ==================== -->
  28. <view v-if="job.photo_open_id" class="section">
  29. <view class="section-head">
  30. <text class="tool-section-title">{{ job.title || '修复结果' }}</text>
  31. <text class="section-tip">{{ job.status_text }}</text>
  32. </view>
  33. <!-- 处理中 -->
  34. <view v-if="isPending" class="progress-box">
  35. <view class="progress-ring"></view>
  36. <text class="progress-title">{{ progressTitle }}</text>
  37. <text class="progress-sub">已等待 {{ waitedSeconds }} 秒,可以留在本页等待,也可以稍后从历史记录查看</text>
  38. </view>
  39. <!-- 失败 -->
  40. <view v-else-if="job.status < 0" class="fail-box">
  41. <text class="fail-title">{{ job.error_msg || '生成失败' }}</text>
  42. <text class="fail-sub">本次未扣点,可以换一张更清晰的照片重试</text>
  43. </view>
  44. <!-- 成功:默认直接展示修复后的成品,对比放到弹层里 -->
  45. <view v-else class="result-box">
  46. <view class="result-stage" @click="previewResult">
  47. <image class="result-img" :src="job.result_url" mode="widthFix" />
  48. </view>
  49. <view class="result-actions">
  50. <view class="ghost-btn" @click="openResultCompare">查看对比</view>
  51. <view class="ghost-btn" @click="saveResult">保存到相册</view>
  52. <view class="ghost-btn" @click="resetJob">再修一张</view>
  53. </view>
  54. </view>
  55. </view>
  56. <!-- ==================== 上传 ==================== -->
  57. <view v-if="!job.photo_open_id || job.status < 0" class="upload-card">
  58. <view class="card-head">
  59. <view>
  60. <text class="card-title"><text class="required">*</text>上传照片</text>
  61. <text class="card-desc">支持黑白、泛黄、模糊、划痕或破损照片,单张不超过 10M</text>
  62. </view>
  63. <text class="card-tip">{{ localImage ? '1/1' : '0/1' }}</text>
  64. </view>
  65. <view class="upload-strip" @click="chooseImage">
  66. <view class="upload-thumb">
  67. <image v-if="localImage" class="upload-img" :src="localImage" mode="aspectFill" />
  68. <text v-else class="upload-plus">+</text>
  69. </view>
  70. <view class="upload-copy">
  71. <text class="upload-title">{{ localImage ? '已选择照片,可点击更换' : '上传需要修复的老照片' }}</text>
  72. <text class="upload-sub">建议主体清晰、人物未被严重遮挡</text>
  73. </view>
  74. <view class="upload-action">{{ localImage ? '更换' : '上传' }}</view>
  75. </view>
  76. </view>
  77. <!-- ==================== 档位 ==================== -->
  78. <view v-if="!job.photo_open_id || job.status < 0" class="section">
  79. <view class="section-head">
  80. <text class="tool-section-title">修复档位</text>
  81. <text class="section-tip">按张计费</text>
  82. </view>
  83. <view class="mode-list">
  84. <view
  85. v-for="item in modes"
  86. :key="item.key"
  87. class="mode-item"
  88. :class="{ active: activeMode === item.key }"
  89. @click="selectMode(item.key)"
  90. >
  91. <view class="mode-main">
  92. <text class="mode-name">{{ item.name }}</text>
  93. <text class="mode-desc">{{ item.desc }}</text>
  94. </view>
  95. <text class="mode-points">{{ item.points }}点</text>
  96. </view>
  97. </view>
  98. </view>
  99. <!-- ==================== 补充描述 ==================== -->
  100. <view v-if="!job.photo_open_id || job.status < 0" class="section">
  101. <view class="section-head">
  102. <text class="tool-section-title">补充说明</text>
  103. <text class="section-tip">选填</text>
  104. </view>
  105. <textarea
  106. v-model="extraPrompt"
  107. class="textarea"
  108. maxlength="100"
  109. placeholder="可以补充照片里的事实信息,例如:军装是绿色的、背景是老家的院子"
  110. />
  111. <view class="tips">
  112. <text v-for="tip in tips" :key="tip" class="tip-item">· {{ tip }}</text>
  113. </view>
  114. </view>
  115. <!-- ==================== 底部操作 ==================== -->
  116. <view class="bottom-bar">
  117. <view class="balance">
  118. <text class="balance-copy">{{ balanceText }}</text>
  119. <view v-if="isBalanceInsufficient" class="recharge-link" @click="goRecharge">去充值</view>
  120. </view>
  121. <view class="buttons">
  122. <view class="history-btn" @click="goHistory">历史记录</view>
  123. <view class="submit-btn" :class="{ disabled: submitDisabled }" @click="submit">
  124. <image class="submit-icon" src="https://cdn.aionline.cc/ai/icons/bolt.svg" mode="aspectFit" />
  125. <text>{{ submitText }}</text>
  126. </view>
  127. </view>
  128. </view>
  129. <!-- ==================== 前后对比(demo 与成品共用,样式同动漫头像) ==================== -->
  130. <view v-if="compareVisible" class="compare-modal">
  131. <view class="compare-backdrop" @click="closeCompare"></view>
  132. <view class="compare-panel">
  133. <view class="compare-panel-head">
  134. <text>{{ comparePair.title }}</text>
  135. <view class="close-btn" @click="closeCompare">×</view>
  136. </view>
  137. <view
  138. class="large-compare"
  139. @touchstart.stop="onCompareTouch"
  140. @touchmove.stop.prevent="onCompareTouch"
  141. @click.stop="onCompareTouch"
  142. >
  143. <image class="compare-img" :src="comparePair.after" mode="aspectFit" />
  144. <view class="compare-before" :style="{ width: comparePercent + '%' }">
  145. <image class="compare-img" :src="comparePair.before" mode="aspectFit" />
  146. </view>
  147. <view class="compare-line large" :style="{ left: comparePercent + '%' }">
  148. <view class="compare-handle large">
  149. <text>‹</text>
  150. <text>›</text>
  151. </view>
  152. </view>
  153. <view class="compare-tag left">原图</view>
  154. <view class="compare-tag right">修复后</view>
  155. </view>
  156. <text class="compare-guide">左右滑动中间按钮查看变化</text>
  157. </view>
  158. </view>
  159. </view>
  160. </template>
  161. <script setup>
  162. import { computed, ref } from 'vue'
  163. import { onLoad, onShareAppMessage, onShow, onUnload } from '@dcloudio/uni-app'
  164. import { commonApi, oldPhotoApi, userApi } from '@/api/index.js'
  165. import { createOldPhoto } from '@/utils/upload.js'
  166. import { guid } from '@/utils/util.js'
  167. import { useUserStore } from '@/store/user.js'
  168. import { useApp } from '@/utils/useApp.js'
  169. import { buildShareOptions, showPageShareMenu } from '@/utils/share.js'
  170. const userStore = useUserStore()
  171. const { toast } = useApp()
  172. const pageShare = {
  173. title: '老照片修复上色|让旧照片焕然一新',
  174. source: 'share_old_photo'
  175. }
  176. onShow(() => showPageShareMenu())
  177. onShareAppMessage(() => buildShareOptions(pageShare))
  178. // 轮询:混元未买并发,排队 + 生成通常几十秒,最长按后端 20 分钟超时来兜
  179. const POLL_INTERVAL = 3000
  180. const POLL_LIMIT = 5 * 60 * 1000
  181. // 首屏示例对比图由配置项 old_photo_demo 下发(同动漫头像的 anime_demo_v2),不在端上硬编码
  182. const DEMO_CONFIG_KEY = 'old_photo_demo'
  183. const modes = ref([])
  184. const tips = ref([])
  185. const activeMode = ref('colorize')
  186. const extraPrompt = ref('')
  187. const localImage = ref('')
  188. const balance = ref({ points: 0 })
  189. const quote = ref({ points: 0 })
  190. const job = ref({})
  191. const submitting = ref(false)
  192. const comparePercent = ref(50)
  193. const compareVisible = ref(false)
  194. // 示例对比图(配置下发)与对比弹层的数据源(示例 或 本次成品)
  195. const demoPair = ref({ original: '', result: '' })
  196. const comparePair = ref({ before: '', after: '', title: '效果对比' })
  197. const waitedSeconds = ref(0)
  198. let pollTimer = null
  199. let pollStart = 0
  200. const hasDemo = computed(() => Boolean(demoPair.value.original && demoPair.value.result))
  201. const currentPoints = computed(() => Number(balance.value.points || balance.value.point_balance || 0))
  202. const currentMode = computed(() => modes.value.find((item) => item.key === activeMode.value) || {})
  203. const currentCost = computed(() => Number(currentMode.value.points || quote.value.points || 0))
  204. const isPending = computed(() => [10, 20].includes(Number(job.value.status)))
  205. const balanceText = computed(() => {
  206. const cost = currentCost.value
  207. if (!userStore.isLogin) return cost ? `老照片修复 · ${cost}点/张` : '老照片修复'
  208. return cost ? `余额${currentPoints.value}点 · 预计消耗${cost}点` : `余额${currentPoints.value}点`
  209. })
  210. const isBalanceInsufficient = computed(
  211. () => userStore.isLogin && currentCost.value > 0 && currentPoints.value < currentCost.value
  212. )
  213. const submitDisabled = computed(() => submitting.value || isPending.value)
  214. const submitText = computed(() => {
  215. if (submitting.value) return '提交中...'
  216. if (isPending.value) return '正在生成...'
  217. const cost = currentCost.value
  218. if (!userStore.isLogin) return cost ? `登录后修复 · ${cost}点` : '登录后修复'
  219. return cost ? `立即修复 · ${cost}点` : '立即修复'
  220. })
  221. const progressTitle = computed(() => (Number(job.value.status) === 10 ? '排队中,前面还有任务' : 'AI 正在修复上色'))
  222. onLoad((options) => {
  223. loadConfig()
  224. loadDemo()
  225. if (options && options.work) loadJob(options.work, true)
  226. })
  227. onShow(() => {
  228. if (userStore.isLogin) loadBalance()
  229. // 从历史记录页返回时带回来的记录 id
  230. const workId = uni.getStorageSync('old_photo_work_id')
  231. if (workId) {
  232. uni.removeStorageSync('old_photo_work_id')
  233. loadJob(workId)
  234. }
  235. })
  236. onUnload(() => stopPoll())
  237. async function loadConfig() {
  238. try {
  239. const res = await oldPhotoApi.getConfig({ mode: activeMode.value })
  240. const data = res.data || {}
  241. modes.value = data.modes || []
  242. tips.value = data.tips || []
  243. if (data.default_mode && !modes.value.find((item) => item.key === activeMode.value)) {
  244. activeMode.value = data.default_mode
  245. }
  246. const billing = data.billing || {}
  247. quote.value = billing.quote || {}
  248. if (billing.balance) balance.value = billing.balance
  249. } catch (e) {
  250. toast(e.msg || '配置加载失败')
  251. }
  252. }
  253. async function loadBalance() {
  254. try {
  255. const res = await userApi.getBalance()
  256. balance.value = res.data || {}
  257. } catch (e) {}
  258. }
  259. /**
  260. * 示例对比图:配置项 old_photo_demo,格式 [{ original, result }]
  261. * 兼容 after/anime 字段名与「原图,结果图」这种逗号分隔的老写法;配置缺失就不展示示例入口。
  262. */
  263. async function loadDemo() {
  264. try {
  265. const res = await commonApi.getConfig({ keys: DEMO_CONFIG_KEY })
  266. const rows = Array.isArray(res.list) ? res.list : []
  267. const item = rows.find((it) => it.key === DEMO_CONFIG_KEY)
  268. demoPair.value = normalizeDemo(item && item.value)
  269. } catch (e) {}
  270. }
  271. function normalizeDemo(value) {
  272. const empty = { original: '', result: '' }
  273. if (!value) return empty
  274. try {
  275. const data = typeof value === 'string' ? JSON.parse(value) : value
  276. const first = Array.isArray(data) ? data[0] : data
  277. if (!first) return empty
  278. return {
  279. original: first.original || first.before || first.source || '',
  280. result: first.result || first.after || first.anime || ''
  281. }
  282. } catch (e) {
  283. const pics = String(value).split(',').map((item) => item.trim()).filter(Boolean)
  284. if (pics.length >= 2) return { original: pics[0], result: pics[1] }
  285. return empty
  286. }
  287. }
  288. function selectMode(key) {
  289. if (activeMode.value === key) return
  290. activeMode.value = key
  291. }
  292. function chooseImage() {
  293. if (!requireLogin()) return
  294. uni.chooseImage({
  295. count: 1,
  296. sizeType: ['original'], // 老照片细节要留住,不做端上压缩
  297. sourceType: ['album', 'camera'],
  298. success: (res) => {
  299. const file = (res.tempFiles && res.tempFiles[0]) || {}
  300. if (Number(file.size || 0) > 10 * 1024 * 1024) {
  301. toast('图片不能超过 10M')
  302. return
  303. }
  304. localImage.value = res.tempFilePaths[0]
  305. job.value = {}
  306. }
  307. })
  308. }
  309. async function submit() {
  310. if (!requireLogin()) return
  311. if (submitDisabled.value) return
  312. if (!localImage.value) return toast('请先上传老照片')
  313. if (isBalanceInsufficient.value) return showBalanceModal()
  314. submitting.value = true
  315. uni.showLoading({ title: '提交中...', mask: true })
  316. try {
  317. const res = await createOldPhoto(localImage.value, {
  318. mode: activeMode.value,
  319. extra_prompt: extraPrompt.value,
  320. request_id: guid()
  321. })
  322. job.value = res.data || {}
  323. comparePercent.value = 50
  324. if (Number(job.value.status) === 30) {
  325. onJobDone()
  326. } else {
  327. startPoll()
  328. }
  329. } catch (e) {
  330. // 1011 = 点数不足,后端统一错误码
  331. if (e && e.code === 1011) showBalanceModal()
  332. else toast((e && e.msg) || '提交失败,请重试')
  333. } finally {
  334. submitting.value = false
  335. uni.hideLoading()
  336. }
  337. }
  338. async function loadJob(photoOpenId, silent = false) {
  339. try {
  340. const res = await oldPhotoApi.getDetail({ photo_open_id: photoOpenId })
  341. job.value = res.data || {}
  342. if (job.value.source_url) localImage.value = ''
  343. if (isPending.value) startPoll()
  344. else if (Number(job.value.status) === 30) onJobDone()
  345. } catch (e) {
  346. if (!silent) toast((e && e.msg) || '记录加载失败')
  347. }
  348. }
  349. function startPoll() {
  350. stopPoll()
  351. pollStart = Date.now()
  352. waitedSeconds.value = 0
  353. pollTimer = setInterval(async () => {
  354. waitedSeconds.value = Math.round((Date.now() - pollStart) / 1000)
  355. if (Date.now() - pollStart > POLL_LIMIT) {
  356. stopPoll()
  357. toast('生成时间较长,可稍后从历史记录查看')
  358. return
  359. }
  360. try {
  361. const res = await oldPhotoApi.getDetail({ photo_open_id: job.value.photo_open_id })
  362. const data = res.data || {}
  363. job.value = data
  364. if (Number(data.status) === 30) {
  365. stopPoll()
  366. onJobDone()
  367. } else if (Number(data.status) < 0) {
  368. stopPoll()
  369. toast(data.error_msg || '生成失败,本次未扣点')
  370. }
  371. } catch (e) {
  372. // 单次失败不中断轮询
  373. }
  374. }, POLL_INTERVAL)
  375. }
  376. function stopPoll() {
  377. if (pollTimer) clearInterval(pollTimer)
  378. pollTimer = null
  379. }
  380. function onJobDone() {
  381. comparePercent.value = 50
  382. compareVisible.value = false
  383. loadBalance()
  384. }
  385. function resetJob() {
  386. stopPoll()
  387. job.value = {}
  388. localImage.value = ''
  389. extraPrompt.value = ''
  390. compareVisible.value = false
  391. }
  392. function previewResult() {
  393. if (!job.value.result_url) return
  394. uni.previewImage({
  395. urls: [job.value.result_url],
  396. current: job.value.result_url
  397. })
  398. }
  399. function openDemoCompare() {
  400. if (!hasDemo.value) return
  401. comparePair.value = {
  402. before: demoPair.value.original,
  403. after: demoPair.value.result,
  404. title: '效果对比'
  405. }
  406. comparePercent.value = 50
  407. compareVisible.value = true
  408. }
  409. function openResultCompare() {
  410. if (!job.value.result_url || !job.value.source_url) return
  411. comparePair.value = {
  412. before: job.value.source_url,
  413. after: job.value.result_url,
  414. title: job.value.title || '修复结果'
  415. }
  416. comparePercent.value = 50
  417. compareVisible.value = true
  418. }
  419. function closeCompare() {
  420. compareVisible.value = false
  421. }
  422. function saveResult() {
  423. if (!job.value.result_url) return
  424. uni.showLoading({ title: '保存中...', mask: true })
  425. uni.downloadFile({
  426. url: job.value.result_url,
  427. success: (res) => {
  428. uni.saveImageToPhotosAlbum({
  429. filePath: res.tempFilePath,
  430. success: () => toast('已保存到相册'),
  431. fail: () => toast('保存失败,请检查相册权限')
  432. })
  433. },
  434. fail: () => toast('图片下载失败'),
  435. complete: () => uni.hideLoading()
  436. })
  437. }
  438. function goHistory() {
  439. if (!requireLogin()) return
  440. uni.navigateTo({ url: '/pages/tool/old-photo-history' })
  441. }
  442. function onCompareTouch(e) {
  443. const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || e
  444. const x = touch.clientX || 0
  445. uni.createSelectorQuery()
  446. .select('.large-compare')
  447. .boundingClientRect((rect) => {
  448. if (!rect || !rect.width) return
  449. const percent = ((x - rect.left) / rect.width) * 100
  450. comparePercent.value = Math.max(6, Math.min(94, Number(percent.toFixed(1))))
  451. })
  452. .exec()
  453. }
  454. function requireLogin() {
  455. if (userStore.isLogin) return true
  456. uni.navigateTo({ url: '/pages/login/login' })
  457. return false
  458. }
  459. function showBalanceModal() {
  460. const cost = currentCost.value
  461. uni.showModal({
  462. title: '温馨提示',
  463. content: cost ? `本次修复需要 ${cost} 点,当前点数不足,请充值后继续。` : '点数不足,请充值后继续。',
  464. confirmText: '去充值',
  465. cancelText: '知道了',
  466. success: (res) => {
  467. if (res.confirm) uni.navigateTo({ url: '/pages/recharge/recharge' })
  468. }
  469. })
  470. }
  471. function goRecharge() {
  472. uni.navigateTo({ url: '/pages/recharge/recharge' })
  473. }
  474. </script>
  475. <style lang="scss" scoped>
  476. .wrap {
  477. min-height: 100vh;
  478. padding: 24rpx 28rpx calc(300rpx + env(safe-area-inset-bottom));
  479. background: #f3f7fb;
  480. box-sizing: border-box;
  481. }
  482. .hero-card {
  483. position: relative;
  484. padding: 30rpx;
  485. border-radius: 28rpx;
  486. color: #fff;
  487. background: linear-gradient(135deg, #172033 0%, #2d5d7d 56%, #5cb7c4 100%);
  488. overflow: hidden;
  489. display: flex;
  490. align-items: center;
  491. box-sizing: border-box;
  492. }
  493. .hero-card::before {
  494. content: '';
  495. position: absolute;
  496. right: -90rpx;
  497. top: -90rpx;
  498. width: 280rpx;
  499. height: 280rpx;
  500. border-radius: 50%;
  501. background: rgba(255, 255, 255, 0.12);
  502. }
  503. .hero-copy {
  504. position: relative;
  505. z-index: 1;
  506. flex: 1;
  507. min-width: 0;
  508. }
  509. .eyebrow { display: block; color: rgba(255, 255, 255, 0.68); font-size: 21rpx; font-weight: 800; }
  510. .hero-title { display: block; margin-top: 14rpx; color: #fff; font-size: 38rpx; font-weight: 900; }
  511. .hero-desc {
  512. display: block;
  513. margin-top: 14rpx;
  514. color: rgba(255, 255, 255, 0.82);
  515. font-size: 23rpx;
  516. line-height: 1.5;
  517. }
  518. .hero-actions { margin-top: 22rpx; display: flex; gap: 16rpx; }
  519. .hero-pill,
  520. .hero-link {
  521. height: 60rpx;
  522. padding: 0 24rpx;
  523. border-radius: 999rpx;
  524. font-size: 25rpx;
  525. font-weight: 800;
  526. line-height: 60rpx;
  527. text-align: center;
  528. }
  529. .hero-pill { color: #17466b; background: #fff; }
  530. .hero-link {
  531. color: #fff;
  532. background: rgba(255, 255, 255, 0.18);
  533. border: 2rpx solid rgba(255, 255, 255, 0.42);
  534. line-height: 56rpx;
  535. }
  536. .hero-thumb {
  537. position: relative;
  538. z-index: 1;
  539. margin-left: 22rpx;
  540. flex-shrink: 0;
  541. }
  542. /* 对比组件:与动漫头像保持一致 */
  543. .mini-compare,
  544. .large-compare {
  545. position: relative;
  546. overflow: hidden;
  547. background: #dfe8f4;
  548. }
  549. .mini-compare {
  550. width: 168rpx;
  551. height: 252rpx;
  552. border-radius: 28rpx;
  553. border: 2rpx solid rgba(255, 255, 255, 0.45);
  554. box-shadow: 0 18rpx 38rpx rgba(20, 37, 66, 0.28);
  555. }
  556. .compare-img {
  557. width: 100%;
  558. height: 100%;
  559. }
  560. .compare-before {
  561. position: absolute;
  562. left: 0;
  563. top: 0;
  564. bottom: 0;
  565. overflow: hidden;
  566. }
  567. .mini-before { width: 50%; }
  568. .compare-before .compare-img { width: 164rpx; }
  569. .compare-line {
  570. position: absolute;
  571. top: 0;
  572. bottom: 0;
  573. width: 4rpx;
  574. margin-left: -2rpx;
  575. background: rgba(255, 255, 255, 0.94);
  576. box-shadow: 0 0 12rpx rgba(20, 37, 66, 0.22);
  577. }
  578. .compare-line.mini-line { left: 50%; }
  579. .compare-line.large {
  580. width: 6rpx;
  581. margin-left: -3rpx;
  582. }
  583. .compare-handle {
  584. position: absolute;
  585. left: 50%;
  586. top: 50%;
  587. width: 38rpx;
  588. height: 58rpx;
  589. margin-left: -19rpx;
  590. margin-top: -29rpx;
  591. border-radius: 999rpx;
  592. background: #ffffff;
  593. box-shadow: 0 8rpx 18rpx rgba(20, 37, 66, 0.22);
  594. display: flex;
  595. align-items: center;
  596. justify-content: center;
  597. gap: 1rpx;
  598. color: #1f8cff;
  599. font-size: 24rpx;
  600. font-weight: 900;
  601. }
  602. .compare-handle.large {
  603. width: 54rpx;
  604. height: 78rpx;
  605. margin-left: -27rpx;
  606. margin-top: -39rpx;
  607. font-size: 32rpx;
  608. }
  609. .compare-tag {
  610. position: absolute;
  611. top: 12rpx;
  612. padding: 6rpx 12rpx;
  613. border-radius: 999rpx;
  614. color: #ffffff;
  615. background: rgba(15, 23, 42, 0.46);
  616. font-size: 20rpx;
  617. font-weight: 700;
  618. }
  619. .compare-tag.left { left: 12rpx; }
  620. .compare-tag.right { right: 12rpx; }
  621. .upload-card,
  622. .section {
  623. margin-top: 22rpx;
  624. padding: 28rpx;
  625. border-radius: 24rpx;
  626. background: #fff;
  627. box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
  628. }
  629. .card-head,
  630. .section-head { display: flex; align-items: baseline; }
  631. .card-title,
  632. .tool-section-title { display: block; color: #172033; font-size: 31rpx; font-weight: 800; }
  633. .required { margin-right: 4rpx; color: #ef4444; }
  634. .card-desc { display: block; margin-top: 8rpx; color: #8a95a6; font-size: 23rpx; line-height: 1.35; }
  635. .card-tip,
  636. .section-tip { margin-left: auto; color: #8a95a6; font-size: 23rpx; }
  637. .upload-strip {
  638. margin-top: 22rpx;
  639. min-height: 142rpx;
  640. padding: 18rpx;
  641. border: 2rpx dashed rgba(11, 140, 255, 0.28);
  642. border-radius: 20rpx;
  643. background: #f8fbff;
  644. display: flex;
  645. align-items: center;
  646. box-sizing: border-box;
  647. }
  648. .upload-thumb {
  649. width: 104rpx;
  650. height: 104rpx;
  651. border-radius: 18rpx;
  652. background: #eef6ff;
  653. display: flex;
  654. align-items: center;
  655. justify-content: center;
  656. flex-shrink: 0;
  657. overflow: hidden;
  658. }
  659. .upload-img { width: 100%; height: 100%; }
  660. .upload-plus { color: #0b8cff; font-size: 48rpx; line-height: 1; }
  661. .upload-copy { min-width: 0; flex: 1; margin-left: 20rpx; }
  662. .upload-title { display: block; color: #172033; font-size: 29rpx; font-weight: 800; }
  663. .upload-sub { display: block; margin-top: 8rpx; color: #7f8896; font-size: 23rpx; line-height: 1.4; }
  664. .upload-action { margin-left: 18rpx; color: #0b8cff; font-size: 25rpx; font-weight: 800; }
  665. .mode-list { margin-top: 20rpx; }
  666. .mode-item {
  667. margin-bottom: 14rpx;
  668. padding: 22rpx;
  669. border-radius: 20rpx;
  670. background: #f7f9fd;
  671. border: 2rpx solid transparent;
  672. display: flex;
  673. align-items: center;
  674. box-sizing: border-box;
  675. }
  676. .mode-item:last-child { margin-bottom: 0; }
  677. .mode-item.active { background: #eff8ff; border-color: #0b8cff; }
  678. .mode-main { min-width: 0; flex: 1; }
  679. .mode-name { display: block; color: #172033; font-size: 28rpx; font-weight: 800; }
  680. .mode-desc { display: block; margin-top: 6rpx; color: #8a95a6; font-size: 23rpx; line-height: 1.4; }
  681. .mode-points { margin-left: 18rpx; color: #0b8cff; font-size: 27rpx; font-weight: 800; flex-shrink: 0; }
  682. .textarea {
  683. margin-top: 20rpx;
  684. width: 100%;
  685. min-height: 150rpx;
  686. padding: 22rpx;
  687. border-radius: 20rpx;
  688. color: #172033;
  689. font-size: 25rpx;
  690. line-height: 1.5;
  691. background: #f7f9fd;
  692. box-sizing: border-box;
  693. }
  694. .tips { margin-top: 18rpx; }
  695. .tip-item { display: block; margin-top: 6rpx; color: #8a95a6; font-size: 22rpx; line-height: 1.5; }
  696. .progress-box { padding: 50rpx 0; display: flex; flex-direction: column; align-items: center; }
  697. .progress-ring {
  698. width: 64rpx;
  699. height: 64rpx;
  700. border: 6rpx solid #e0f0ff;
  701. border-top-color: #0b8cff;
  702. border-radius: 50%;
  703. animation: spin 0.9s linear infinite;
  704. }
  705. @keyframes spin { to { transform: rotate(360deg); } }
  706. .progress-title { margin-top: 22rpx; color: #172033; font-size: 28rpx; font-weight: 800; }
  707. .progress-sub {
  708. margin-top: 10rpx;
  709. padding: 0 40rpx;
  710. color: #8a95a6;
  711. font-size: 23rpx;
  712. line-height: 1.5;
  713. text-align: center;
  714. }
  715. .fail-box { padding: 44rpx 0; display: flex; flex-direction: column; align-items: center; }
  716. .fail-title { color: #f04438; font-size: 28rpx; font-weight: 800; }
  717. .fail-sub { margin-top: 10rpx; color: #8a95a6; font-size: 23rpx; }
  718. .result-box { margin-top: 20rpx; }
  719. .result-stage {
  720. border-radius: 20rpx;
  721. background: #f1f5fa;
  722. overflow: hidden;
  723. }
  724. .result-img { display: block; width: 100%; }
  725. .result-actions { margin-top: 20rpx; display: flex; gap: 16rpx; }
  726. .ghost-btn {
  727. flex: 1;
  728. height: 76rpx;
  729. border-radius: 999rpx;
  730. color: #1f8cff;
  731. font-size: 25rpx;
  732. font-weight: 800;
  733. line-height: 76rpx;
  734. text-align: center;
  735. background: #eef7ff;
  736. }
  737. .bottom-bar {
  738. position: fixed;
  739. left: 0;
  740. right: 0;
  741. bottom: 0;
  742. z-index: 80;
  743. padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
  744. background: rgba(255, 255, 255, 0.96);
  745. box-shadow: 0 -10rpx 30rpx rgba(32, 56, 92, 0.1);
  746. }
  747. .balance {
  748. margin-bottom: 14rpx;
  749. display: flex;
  750. align-items: center;
  751. justify-content: space-between;
  752. gap: 20rpx;
  753. color: #7b8797;
  754. font-size: 24rpx;
  755. line-height: 1.35;
  756. }
  757. .balance-copy { min-width: 0; flex: 1; }
  758. .recharge-link { color: #0b8cff; font-size: 24rpx; font-weight: 800; flex-shrink: 0; }
  759. .buttons { display: flex; gap: 22rpx; }
  760. .history-btn,
  761. .submit-btn {
  762. height: 86rpx;
  763. border-radius: 999rpx;
  764. font-size: 30rpx;
  765. font-weight: 800;
  766. display: flex;
  767. align-items: center;
  768. justify-content: center;
  769. }
  770. .history-btn { width: 210rpx; color: #1f8cff; background: #eef7ff; }
  771. .submit-btn {
  772. flex: 1;
  773. color: #fff;
  774. background: linear-gradient(135deg, #1f8cff, #41b9ff);
  775. box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.28);
  776. }
  777. .submit-btn.disabled { opacity: 0.6; }
  778. .submit-icon { width: 24rpx; height: 24rpx; margin-right: 8rpx; }
  779. /* 对比弹层:与动漫头像保持一致 */
  780. .compare-modal {
  781. position: fixed;
  782. left: 0;
  783. right: 0;
  784. top: 0;
  785. bottom: 0;
  786. z-index: 120;
  787. }
  788. .compare-backdrop {
  789. position: absolute;
  790. left: 0;
  791. right: 0;
  792. top: 0;
  793. bottom: 0;
  794. background: rgba(15, 23, 42, 0.58);
  795. }
  796. .compare-panel {
  797. position: absolute;
  798. left: 36rpx;
  799. right: 36rpx;
  800. top: 50%;
  801. transform: translateY(-50%);
  802. padding: 24rpx;
  803. border-radius: 28rpx;
  804. background: #ffffff;
  805. }
  806. .compare-panel-head {
  807. margin-bottom: 20rpx;
  808. display: flex;
  809. align-items: center;
  810. justify-content: space-between;
  811. color: #172033;
  812. font-size: 32rpx;
  813. font-weight: 800;
  814. }
  815. .close-btn {
  816. width: 56rpx;
  817. height: 56rpx;
  818. border-radius: 50%;
  819. color: #64748b;
  820. background: #f1f5f9;
  821. font-size: 42rpx;
  822. line-height: 50rpx;
  823. text-align: center;
  824. font-weight: 300;
  825. }
  826. .large-compare {
  827. width: 100%;
  828. height: 900rpx;
  829. max-height: 70vh;
  830. border-radius: 22rpx;
  831. }
  832. .large-compare .compare-before .compare-img {
  833. width: calc(100vw - 120rpx);
  834. }
  835. .compare-guide {
  836. display: block;
  837. margin-top: 18rpx;
  838. color: #64748b;
  839. font-size: 24rpx;
  840. text-align: center;
  841. }
  842. </style>