您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 

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