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.
 
 
 

1139 rindas
34 KiB

  1. <template>
  2. <view class="wrap">
  3. <view class="hero-card portrait-hero">
  4. <view class="hero-copy">
  5. <text class="eyebrow">PORTRAIT PHOTO STUDIO</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 前后对比缩略图(配置项 portrait_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 v-else class="hero-preview">
  27. <view class="portrait-card-mini main">
  28. <image v-if="localImage" class="mini-img" :src="localImage" mode="aspectFill" />
  29. <template v-else>
  30. <view class="portrait-face"></view>
  31. <view class="portrait-suit"></view>
  32. </template>
  33. </view>
  34. <view class="portrait-card-mini sub">
  35. <view class="portrait-face"></view>
  36. <view class="portrait-suit"></view>
  37. </view>
  38. </view>
  39. </view>
  40. <!-- ==================== 进度 / 结果 ==================== -->
  41. <view v-if="job.photo_open_id" class="section">
  42. <view class="section-head">
  43. <text class="tool-section-title">{{ job.style_name || '生成结果' }}</text>
  44. <text class="section-tip">{{ progressText }}</text>
  45. </view>
  46. <view v-if="isPending" class="progress-box">
  47. <view class="progress-ring"></view>
  48. <text class="progress-title">{{ pendingTitle }}</text>
  49. <text class="progress-sub">已等待 {{ waitedSeconds }} 秒,可以留在本页等待,也可以稍后从记录里查看</text>
  50. </view>
  51. <view v-else-if="job.status < 0" class="fail-box">
  52. <text class="fail-title">{{ job.error_msg || '生成失败' }}</text>
  53. <text class="fail-sub">失败的照片不扣点,可以换一张更清晰的照片重试</text>
  54. </view>
  55. <view v-else class="result-box">
  56. <view class="result-grid" :class="{ single: resultUrls.length === 1 }">
  57. <image
  58. v-for="(url, index) in resultUrls"
  59. :key="url"
  60. class="result-img"
  61. :src="url"
  62. mode="aspectFill"
  63. @click="previewResult(index)"
  64. />
  65. </view>
  66. <view class="result-actions">
  67. <view class="ghost-btn" @click="goDetail">查看详情</view>
  68. <view class="ghost-btn" @click="saveAll">保存全部</view>
  69. <view class="ghost-btn" @click="resetJob">再拍一组</view>
  70. </view>
  71. </view>
  72. </view>
  73. <!-- ==================== 上传 ==================== -->
  74. <view v-if="canEdit" class="upload-card">
  75. <view class="card-head">
  76. <view>
  77. <text class="card-title"><text class="required">*</text>上传照片</text>
  78. <text class="card-desc">建议选择五官无遮挡、光线均匀的半身照,单张不超过 10M</text>
  79. </view>
  80. <text class="card-tip">{{ localImage ? '1/1' : '0/1' }}</text>
  81. </view>
  82. <view class="upload-strip" @click="chooseImage">
  83. <view class="upload-thumb">
  84. <image v-if="localImage" class="upload-img" :src="localImage" mode="aspectFill" />
  85. <text v-else class="upload-plus">+</text>
  86. </view>
  87. <view class="upload-copy">
  88. <text class="upload-title">{{ localImage ? '已选择照片,可点击更换' : '上传正面清晰人像照片' }}</text>
  89. <text class="upload-sub">支持自拍、半身照,生成商务照、职业照或写真</text>
  90. </view>
  91. <view class="upload-action">{{ localImage ? '更换' : '上传' }}</view>
  92. </view>
  93. </view>
  94. <!-- ==================== 模型(只有一个时不展示) ==================== -->
  95. <view v-if="canEdit && models.length > 1" class="section">
  96. <view class="section-head">
  97. <text class="tool-section-title">生成模型</text>
  98. <text class="section-tip">不同模型价格可能不同</text>
  99. </view>
  100. <view class="option-grid two">
  101. <view
  102. v-for="item in models"
  103. :key="item.key"
  104. class="option"
  105. :class="{ active: activeModel === item.key }"
  106. @click="selectModel(item.key)"
  107. >
  108. {{ item.name }}
  109. </view>
  110. </view>
  111. </view>
  112. <!-- ==================== 档位与选项 ==================== -->
  113. <view v-if="canEdit" class="section">
  114. <view class="section-head">
  115. <text class="tool-section-title">照片风格</text>
  116. <text class="section-tip">选择生成方向</text>
  117. </view>
  118. <view v-if="configLoading" class="skeleton-list">
  119. <view v-for="i in 3" :key="i" class="skeleton-row"></view>
  120. </view>
  121. <template v-else>
  122. <view class="segmented">
  123. <view
  124. v-for="item in styles"
  125. :key="item.key"
  126. class="segment"
  127. :class="{ active: activeStyle === item.key }"
  128. @click="selectStyle(item.key)"
  129. >
  130. {{ item.name }}
  131. </view>
  132. </view>
  133. <view class="config-panel">
  134. <view v-for="group in currentGroups" :key="group.key" class="sub-block">
  135. <text class="sub-title">{{ group.name }}</text>
  136. <view class="option-grid" :class="group.items.length <= 2 ? 'two' : 'three'">
  137. <view
  138. v-for="item in group.items"
  139. :key="item.key"
  140. class="option"
  141. :class="{ active: options[group.key] === item.key }"
  142. @click="options[group.key] = item.key"
  143. >
  144. {{ item.name }}
  145. </view>
  146. </view>
  147. </view>
  148. </view>
  149. </template>
  150. </view>
  151. <!-- ==================== 比例 ==================== -->
  152. <view v-if="canEdit" class="section">
  153. <view class="section-head">
  154. <text class="tool-section-title">图片比例</text>
  155. <text class="section-tip">构图尺寸</text>
  156. </view>
  157. <view class="ratio-grid">
  158. <view
  159. v-for="item in ratios"
  160. :key="item.key"
  161. class="ratio"
  162. :class="{ active: activeRatio === item.key }"
  163. @click="activeRatio = item.key"
  164. >
  165. {{ item.name }}
  166. </view>
  167. </view>
  168. </view>
  169. <!-- ==================== 数量 ==================== -->
  170. <view v-if="canEdit" class="section">
  171. <view class="section-head">
  172. <text class="tool-section-title">生成数量</text>
  173. <text class="price">{{ unitPoints }}点/张,共{{ totalPoints }}点</text>
  174. </view>
  175. <view class="quantity">
  176. <view class="quantity-btn" @click="decreaseQuantity">-</view>
  177. <text class="quantity-value">{{ quantity }}</text>
  178. <view class="quantity-btn" @click="increaseQuantity">+</view>
  179. </view>
  180. <text class="note">按单张收费,一次最多生成{{ maxCount }}张,失败的不扣点</text>
  181. </view>
  182. <!-- ==================== 补充描述 ==================== -->
  183. <view v-if="canEdit" class="section">
  184. <view class="section-head">
  185. <text class="tool-section-title">补充描述</text>
  186. <text class="section-tip">选填</text>
  187. </view>
  188. <textarea
  189. v-model="extraPrompt"
  190. class="textarea"
  191. maxlength="100"
  192. placeholder="可补充职业、服装、背景等客观信息,例如:程序员、深灰西装"
  193. />
  194. <view class="tips">
  195. <text v-for="tip in tips" :key="tip" class="tip-item">· {{ tip }}</text>
  196. </view>
  197. </view>
  198. <!-- ==================== 底部操作 ==================== -->
  199. <view class="bottom-bar">
  200. <view class="balance">
  201. <text class="balance-copy">{{ balanceText }}</text>
  202. <view v-if="isBalanceInsufficient" class="recharge-link" @click="goRecharge">去充值</view>
  203. </view>
  204. <view class="buttons">
  205. <view class="history-btn" @click="goHistory">生成记录</view>
  206. <view class="submit-btn" :class="{ disabled: submitDisabled }" @click="submit">
  207. <image class="submit-icon" src="/static/icons/bolt.svg" mode="aspectFit" />
  208. <text>{{ submitText }}</text>
  209. </view>
  210. </view>
  211. </view>
  212. <!-- ==================== 前后对比(样式同老照片 / 动漫头像) ==================== -->
  213. <view v-if="compareVisible" class="compare-modal">
  214. <view class="compare-backdrop" @click="closeCompare"></view>
  215. <view class="compare-panel">
  216. <view class="compare-panel-head">
  217. <text>{{ comparePair.title }}</text>
  218. <view class="close-btn" @click="closeCompare">×</view>
  219. </view>
  220. <view
  221. class="large-compare"
  222. @touchstart.stop="onCompareTouch"
  223. @touchmove.stop.prevent="onCompareTouch"
  224. @click.stop="onCompareTouch"
  225. >
  226. <image class="compare-img" :src="comparePair.after" mode="aspectFit" />
  227. <view class="compare-before" :style="{ width: comparePercent + '%' }">
  228. <image class="compare-img" :src="comparePair.before" mode="aspectFit" />
  229. </view>
  230. <view class="compare-line large" :style="{ left: comparePercent + '%' }">
  231. <view class="compare-handle large">
  232. <text>‹</text>
  233. <text>›</text>
  234. </view>
  235. </view>
  236. <view class="compare-tag left">原图</view>
  237. <view class="compare-tag right">艺术照</view>
  238. </view>
  239. <text class="compare-guide">左右滑动中间按钮查看变化</text>
  240. </view>
  241. </view>
  242. </view>
  243. </template>
  244. <script setup>
  245. import { computed, reactive, ref, watch } from 'vue'
  246. import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
  247. import { commonApi, portraitApi, userApi } from '@/api/index.js'
  248. import { createPortrait } from '@/utils/upload.js'
  249. import { guid } from '@/utils/util.js'
  250. import { useUserStore } from '@/store/user.js'
  251. import { useApp } from '@/utils/useApp.js'
  252. const userStore = useUserStore()
  253. const { toast } = useApp()
  254. const POLL_INTERVAL = 3000
  255. const POLL_LIMIT = 8 * 60 * 1000
  256. // 首屏示例对比图由配置项 portrait_photo_demo 下发(同 old_photo_demo),不在端上硬编码
  257. const DEMO_CONFIG_KEY = 'portrait_photo_demo'
  258. const models = ref([])
  259. const styles = ref([])
  260. const ratios = ref([])
  261. const tips = ref([])
  262. const activeModel = ref('')
  263. const activeStyle = ref('')
  264. const activeRatio = ref('')
  265. const options = reactive({})
  266. const quantity = ref(1)
  267. const maxCount = ref(4)
  268. const unitPoints = ref(0)
  269. const extraPrompt = ref('')
  270. const localImage = ref('')
  271. const balance = ref({ points: 0 })
  272. const job = ref({})
  273. const configLoading = ref(true)
  274. const submitting = ref(false)
  275. const waitedSeconds = ref(0)
  276. // 示例对比图(配置下发)与对比弹层的数据源
  277. const demoPair = ref({ original: '', result: '' })
  278. const comparePair = ref({ before: '', after: '', title: '效果对比' })
  279. const compareVisible = ref(false)
  280. const comparePercent = ref(50)
  281. let pollTimer = null
  282. let pollStart = 0
  283. const hasDemo = computed(() => Boolean(demoPair.value.original && demoPair.value.result))
  284. const currentStyle = computed(() => styles.value.find((item) => item.key === activeStyle.value) || {})
  285. const currentGroups = computed(() => currentStyle.value.groups || [])
  286. const currentPoints = computed(() => Number(balance.value.points || balance.value.point_balance || 0))
  287. const totalPoints = computed(() => unitPoints.value * quantity.value)
  288. const isPending = computed(() => [10, 20].includes(Number(job.value.status)))
  289. const canEdit = computed(() => !job.value.photo_open_id || Number(job.value.status) < 0)
  290. const resultUrls = computed(() => job.value.result_urls || [])
  291. const balanceText = computed(() => {
  292. if (!userStore.isLogin) return unitPoints.value ? `形象照 · ${unitPoints.value}点/张` : '形象照'
  293. return totalPoints.value
  294. ? `余额${currentPoints.value}点 · 预计消耗${totalPoints.value}点`
  295. : `余额${currentPoints.value}点`
  296. })
  297. const isBalanceInsufficient = computed(
  298. () => userStore.isLogin && totalPoints.value > 0 && currentPoints.value < totalPoints.value
  299. )
  300. const submitDisabled = computed(() => submitting.value || isPending.value)
  301. const submitText = computed(() => {
  302. if (submitting.value) return '提交中...'
  303. if (isPending.value) return '正在生成...'
  304. if (!userStore.isLogin) return totalPoints.value ? `登录后生成 · ${totalPoints.value}点` : '登录后生成'
  305. return totalPoints.value ? `立即生成 · ${totalPoints.value}点` : '立即生成'
  306. })
  307. const pendingTitle = computed(() =>
  308. Number(job.value.status) === 10 ? '排队中,等待 AI 处理' : 'AI 正在生成形象照'
  309. )
  310. const progressText = computed(() => {
  311. if (!job.value.photo_open_id) return ''
  312. if (isPending.value) {
  313. return `${job.value.success_count || 0}/${job.value.count || 0} 张已完成`
  314. }
  315. return job.value.status_text || ''
  316. })
  317. onLoad((options) => {
  318. loadConfig()
  319. loadDemo()
  320. if (options && options.work) loadJob(options.work, true)
  321. })
  322. onShow(() => {
  323. if (userStore.isLogin) loadBalance()
  324. const workId = uni.getStorageSync('portrait_work_id')
  325. if (workId) {
  326. uni.removeStorageSync('portrait_work_id')
  327. loadJob(workId)
  328. }
  329. })
  330. onUnload(() => stopPoll())
  331. // 换模型/换张数会影响报价,重新取一次配置
  332. watch([activeModel, quantity], () => {
  333. if (!configLoading.value) loadConfig(true)
  334. })
  335. async function loadConfig(silent = false) {
  336. try {
  337. const res = await portraitApi.getConfig({
  338. style: activeStyle.value || undefined,
  339. model: activeModel.value || undefined,
  340. count: quantity.value
  341. })
  342. const data = res.data || {}
  343. models.value = data.models || []
  344. styles.value = data.styles || []
  345. ratios.value = data.ratios || []
  346. tips.value = data.tips || []
  347. maxCount.value = Number(data.max_count || 4)
  348. unitPoints.value = Number(data.unit_points || 0)
  349. if (!activeModel.value) activeModel.value = data.default_model || (models.value[0] || {}).key || ''
  350. if (!activeRatio.value) {
  351. activeRatio.value = (ratios.value.find((item) => item.is_default) || ratios.value[0] || {}).key || ''
  352. }
  353. if (!activeStyle.value) {
  354. activeStyle.value = data.default_style || (styles.value[0] || {}).key || ''
  355. applyStyleDefaults()
  356. }
  357. if (data.billing && data.billing.balance) balance.value = data.billing.balance
  358. } catch (e) {
  359. if (!silent) toast(e.msg || '配置加载失败')
  360. } finally {
  361. configLoading.value = false
  362. }
  363. }
  364. /** 切换档位时把该档位的选项重置为默认值 */
  365. function applyStyleDefaults() {
  366. Object.keys(options).forEach((key) => delete options[key])
  367. for (const group of currentGroups.value) {
  368. const picked = group.items.find((item) => item.is_default) || group.items[0]
  369. if (picked) options[group.key] = picked.key
  370. }
  371. }
  372. function selectStyle(key) {
  373. if (activeStyle.value === key) return
  374. activeStyle.value = key
  375. applyStyleDefaults()
  376. }
  377. function selectModel(key) {
  378. if (activeModel.value === key) return
  379. activeModel.value = key
  380. }
  381. async function loadBalance() {
  382. try {
  383. const res = await userApi.getBalance()
  384. balance.value = res.data || {}
  385. } catch (e) {}
  386. }
  387. /**
  388. * 示例对比图:配置项 portrait_photo_demo,格式 [{ original, result }]
  389. * 兼容 before/after 等字段名与「原图,结果图」的逗号分隔写法;配置缺失就回落到占位插画。
  390. */
  391. async function loadDemo() {
  392. try {
  393. const res = await commonApi.getConfig({ keys: DEMO_CONFIG_KEY })
  394. const rows = Array.isArray(res.list) ? res.list : []
  395. const item = rows.find((it) => it.key === DEMO_CONFIG_KEY)
  396. demoPair.value = normalizeDemo(item && item.value)
  397. } catch (e) {}
  398. }
  399. function normalizeDemo(value) {
  400. const empty = { original: '', result: '' }
  401. if (!value) return empty
  402. try {
  403. const data = typeof value === 'string' ? JSON.parse(value) : value
  404. const first = Array.isArray(data) ? data[0] : data
  405. if (!first) return empty
  406. return {
  407. original: first.original || first.before || first.source || '',
  408. result: first.result || first.after || ''
  409. }
  410. } catch (e) {
  411. const pics = String(value).split(',').map((item) => item.trim()).filter(Boolean)
  412. if (pics.length >= 2) return { original: pics[0], result: pics[1] }
  413. return empty
  414. }
  415. }
  416. function openDemoCompare() {
  417. if (!hasDemo.value) return
  418. comparePair.value = {
  419. before: demoPair.value.original,
  420. after: demoPair.value.result,
  421. title: '效果对比'
  422. }
  423. comparePercent.value = 50
  424. compareVisible.value = true
  425. }
  426. function closeCompare() {
  427. compareVisible.value = false
  428. }
  429. function onCompareTouch(e) {
  430. const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || e
  431. const x = touch.clientX || 0
  432. uni.createSelectorQuery()
  433. .select('.large-compare')
  434. .boundingClientRect((rect) => {
  435. if (!rect || !rect.width) return
  436. const percent = ((x - rect.left) / rect.width) * 100
  437. comparePercent.value = Math.max(6, Math.min(94, Number(percent.toFixed(1))))
  438. })
  439. .exec()
  440. }
  441. function chooseImage() {
  442. if (!requireLogin()) return
  443. uni.chooseImage({
  444. count: 1,
  445. sizeType: ['original'],
  446. sourceType: ['album', 'camera'],
  447. success: (res) => {
  448. const file = (res.tempFiles && res.tempFiles[0]) || {}
  449. if (Number(file.size || 0) > 10 * 1024 * 1024) {
  450. toast('图片不能超过 10M')
  451. return
  452. }
  453. localImage.value = res.tempFilePaths[0]
  454. job.value = {}
  455. }
  456. })
  457. }
  458. async function submit() {
  459. if (!requireLogin()) return
  460. if (submitDisabled.value) return
  461. if (!localImage.value) return toast('请先上传人像照片')
  462. if (isBalanceInsufficient.value) return showBalanceModal()
  463. submitting.value = true
  464. uni.showLoading({ title: '提交中...', mask: true })
  465. try {
  466. const res = await createPortrait(localImage.value, {
  467. style: activeStyle.value,
  468. model: activeModel.value,
  469. ratio: activeRatio.value,
  470. count: quantity.value,
  471. extra_prompt: extraPrompt.value,
  472. request_id: guid(),
  473. ...options
  474. })
  475. job.value = res.data || {}
  476. if (Number(job.value.status) === 30) onJobDone()
  477. else startPoll()
  478. } catch (e) {
  479. if (e && e.code === 1011) showBalanceModal()
  480. else toast((e && e.msg) || '提交失败,请重试')
  481. } finally {
  482. submitting.value = false
  483. uni.hideLoading()
  484. }
  485. }
  486. async function loadJob(photoOpenId, silent = false) {
  487. try {
  488. const res = await portraitApi.getDetail({ photo_open_id: photoOpenId })
  489. job.value = res.data || {}
  490. if (isPending.value) startPoll()
  491. else if (Number(job.value.status) === 30) onJobDone()
  492. } catch (e) {
  493. if (!silent) toast((e && e.msg) || '记录加载失败')
  494. }
  495. }
  496. function startPoll() {
  497. stopPoll()
  498. pollStart = Date.now()
  499. waitedSeconds.value = 0
  500. pollTimer = setInterval(async () => {
  501. waitedSeconds.value = Math.round((Date.now() - pollStart) / 1000)
  502. if (Date.now() - pollStart > POLL_LIMIT) {
  503. stopPoll()
  504. toast('生成时间较长,可稍后从生成记录查看')
  505. return
  506. }
  507. try {
  508. const res = await portraitApi.getDetail({ photo_open_id: job.value.photo_open_id })
  509. const data = res.data || {}
  510. job.value = data
  511. if (Number(data.status) === 30) {
  512. stopPoll()
  513. onJobDone()
  514. } else if (Number(data.status) < 0) {
  515. stopPoll()
  516. toast(data.error_msg || '生成失败,本次未扣点')
  517. }
  518. } catch (e) {
  519. // 单次失败不中断轮询
  520. }
  521. }, POLL_INTERVAL)
  522. }
  523. function stopPoll() {
  524. if (pollTimer) clearInterval(pollTimer)
  525. pollTimer = null
  526. }
  527. function onJobDone() {
  528. loadBalance()
  529. }
  530. function resetJob() {
  531. stopPoll()
  532. job.value = {}
  533. localImage.value = ''
  534. extraPrompt.value = ''
  535. }
  536. function previewResult(index) {
  537. if (!resultUrls.value.length) return
  538. uni.previewImage({ urls: resultUrls.value, current: resultUrls.value[index] })
  539. }
  540. function goDetail() {
  541. if (!job.value.photo_open_id) return
  542. uni.navigateTo({ url: `/pages/tool/portrait-detail?id=${job.value.photo_open_id}` })
  543. }
  544. /** 多张依次保存,避免并发触发相册权限弹窗 */
  545. async function saveAll() {
  546. if (!resultUrls.value.length) return
  547. uni.showLoading({ title: '保存中...', mask: true })
  548. let ok = 0
  549. for (const url of resultUrls.value) {
  550. try {
  551. const file = await new Promise((resolve, reject) => {
  552. uni.downloadFile({ url, success: (res) => resolve(res.tempFilePath), fail: reject })
  553. })
  554. await new Promise((resolve, reject) => {
  555. uni.saveImageToPhotosAlbum({ filePath: file, success: resolve, fail: reject })
  556. })
  557. ok++
  558. } catch (e) {
  559. break
  560. }
  561. }
  562. uni.hideLoading()
  563. toast(ok ? `已保存 ${ok} 张到相册` : '保存失败,请检查相册权限')
  564. }
  565. function goHistory() {
  566. if (!requireLogin()) return
  567. uni.navigateTo({ url: '/pages/tool/portrait-history' })
  568. }
  569. function requireLogin() {
  570. if (userStore.isLogin) return true
  571. uni.navigateTo({ url: '/pages/login/login' })
  572. return false
  573. }
  574. function showBalanceModal() {
  575. uni.showModal({
  576. title: '温馨提示',
  577. content: totalPoints.value
  578. ? `本次生成需要 ${totalPoints.value} 点,当前点数不足,请充值后继续。`
  579. : '点数不足,请充值后继续。',
  580. confirmText: '去充值',
  581. cancelText: '知道了',
  582. success: (res) => {
  583. if (res.confirm) uni.navigateTo({ url: '/pages/recharge/recharge' })
  584. }
  585. })
  586. }
  587. function goRecharge() {
  588. uni.navigateTo({ url: '/pages/recharge/recharge' })
  589. }
  590. function decreaseQuantity() {
  591. quantity.value = Math.max(1, quantity.value - 1)
  592. }
  593. function increaseQuantity() {
  594. quantity.value = Math.min(maxCount.value, quantity.value + 1)
  595. }
  596. </script>
  597. <style lang="scss" scoped>
  598. .wrap {
  599. min-height: 100vh;
  600. padding: 24rpx 28rpx calc(300rpx + env(safe-area-inset-bottom));
  601. background: #f3f7fb;
  602. box-sizing: border-box;
  603. }
  604. .hero-card {
  605. position: relative;
  606. padding: 30rpx;
  607. border-radius: 28rpx;
  608. color: #fff;
  609. overflow: hidden;
  610. display: flex;
  611. align-items: center;
  612. box-sizing: border-box;
  613. }
  614. .portrait-hero { background: linear-gradient(135deg, #1b2a4a 0%, #3f5f9c 55%, #7aa7f0 100%); }
  615. .hero-card::before {
  616. content: '';
  617. position: absolute;
  618. right: -90rpx;
  619. top: -90rpx;
  620. width: 280rpx;
  621. height: 280rpx;
  622. border-radius: 50%;
  623. background: rgba(255, 255, 255, 0.12);
  624. }
  625. .hero-copy { position: relative; z-index: 1; flex: 1; min-width: 0; }
  626. .eyebrow { display: block; color: rgba(255, 255, 255, 0.68); font-size: 21rpx; font-weight: 800; }
  627. .hero-title { display: block; margin-top: 14rpx; color: #fff; font-size: 38rpx; font-weight: 900; }
  628. .hero-desc {
  629. display: block;
  630. margin-top: 14rpx;
  631. color: rgba(255, 255, 255, 0.82);
  632. font-size: 23rpx;
  633. line-height: 1.5;
  634. }
  635. .hero-actions { margin-top: 22rpx; display: flex; gap: 16rpx; }
  636. .hero-pill,
  637. .hero-link {
  638. height: 60rpx;
  639. padding: 0 24rpx;
  640. border-radius: 999rpx;
  641. font-size: 25rpx;
  642. font-weight: 800;
  643. line-height: 60rpx;
  644. text-align: center;
  645. }
  646. .hero-pill { color: #1b2a4a; background: #fff; }
  647. .hero-link {
  648. color: #fff;
  649. background: rgba(255, 255, 255, 0.18);
  650. border: 2rpx solid rgba(255, 255, 255, 0.42);
  651. line-height: 56rpx;
  652. }
  653. .hero-thumb {
  654. position: relative;
  655. z-index: 1;
  656. margin-left: 22rpx;
  657. flex-shrink: 0;
  658. }
  659. /* 对比组件:与老照片 / 动漫头像保持一致 */
  660. .mini-compare,
  661. .large-compare {
  662. position: relative;
  663. overflow: hidden;
  664. background: #dfe8f4;
  665. }
  666. .mini-compare {
  667. width: 168rpx;
  668. height: 252rpx;
  669. border-radius: 28rpx;
  670. border: 2rpx solid rgba(255, 255, 255, 0.45);
  671. box-shadow: 0 18rpx 38rpx rgba(20, 37, 66, 0.28);
  672. }
  673. .compare-img { width: 100%; height: 100%; }
  674. .compare-before {
  675. position: absolute;
  676. left: 0;
  677. top: 0;
  678. bottom: 0;
  679. overflow: hidden;
  680. }
  681. .mini-before { width: 50%; }
  682. .compare-before .compare-img { width: 164rpx; }
  683. .compare-line {
  684. position: absolute;
  685. top: 0;
  686. bottom: 0;
  687. width: 4rpx;
  688. margin-left: -2rpx;
  689. background: rgba(255, 255, 255, 0.94);
  690. box-shadow: 0 0 12rpx rgba(20, 37, 66, 0.22);
  691. }
  692. .compare-line.mini-line { left: 50%; }
  693. .compare-line.large { width: 6rpx; margin-left: -3rpx; }
  694. .compare-handle {
  695. position: absolute;
  696. left: 50%;
  697. top: 50%;
  698. width: 38rpx;
  699. height: 58rpx;
  700. margin-left: -19rpx;
  701. margin-top: -29rpx;
  702. border-radius: 999rpx;
  703. background: #ffffff;
  704. box-shadow: 0 8rpx 18rpx rgba(20, 37, 66, 0.22);
  705. display: flex;
  706. align-items: center;
  707. justify-content: center;
  708. gap: 1rpx;
  709. color: #1f8cff;
  710. font-size: 24rpx;
  711. font-weight: 900;
  712. }
  713. .compare-handle.large {
  714. width: 54rpx;
  715. height: 78rpx;
  716. margin-left: -27rpx;
  717. margin-top: -39rpx;
  718. font-size: 32rpx;
  719. }
  720. .compare-tag {
  721. position: absolute;
  722. top: 12rpx;
  723. padding: 6rpx 12rpx;
  724. border-radius: 999rpx;
  725. color: #ffffff;
  726. background: rgba(15, 23, 42, 0.46);
  727. font-size: 20rpx;
  728. font-weight: 700;
  729. }
  730. .compare-tag.left { left: 12rpx; }
  731. .compare-tag.right { right: 12rpx; }
  732. .hero-preview {
  733. position: relative;
  734. z-index: 1;
  735. margin-left: 22rpx;
  736. width: 190rpx;
  737. height: 240rpx;
  738. flex-shrink: 0;
  739. }
  740. .portrait-card-mini {
  741. position: absolute;
  742. width: 140rpx;
  743. height: 190rpx;
  744. border-radius: 20rpx;
  745. background: #dfe8f4;
  746. border: 2rpx solid rgba(255, 255, 255, 0.5);
  747. overflow: hidden;
  748. box-shadow: 0 16rpx 30rpx rgba(0, 0, 0, 0.24);
  749. }
  750. .portrait-card-mini.main { right: 0; top: 0; z-index: 2; }
  751. .portrait-card-mini.sub { left: 0; bottom: 0; opacity: 0.85; }
  752. .mini-img { width: 100%; height: 100%; }
  753. .portrait-face {
  754. position: absolute;
  755. left: 50%;
  756. top: 34rpx;
  757. width: 56rpx;
  758. height: 56rpx;
  759. margin-left: -28rpx;
  760. border-radius: 50%;
  761. background: #b9c8dd;
  762. }
  763. .portrait-suit {
  764. position: absolute;
  765. left: 50%;
  766. bottom: 0;
  767. width: 120rpx;
  768. height: 90rpx;
  769. margin-left: -60rpx;
  770. border-radius: 60rpx 60rpx 0 0;
  771. background: #2f4468;
  772. }
  773. .upload-card,
  774. .section {
  775. margin-top: 22rpx;
  776. padding: 28rpx;
  777. border-radius: 24rpx;
  778. background: #fff;
  779. box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
  780. }
  781. .card-head,
  782. .section-head { display: flex; align-items: baseline; }
  783. .card-title,
  784. .tool-section-title { display: block; color: #172033; font-size: 31rpx; font-weight: 800; }
  785. .required { margin-right: 4rpx; color: #ef4444; }
  786. .card-desc { display: block; margin-top: 8rpx; color: #8a95a6; font-size: 23rpx; line-height: 1.35; }
  787. .card-tip,
  788. .section-tip { margin-left: auto; color: #8a95a6; font-size: 23rpx; }
  789. .price { margin-left: auto; color: #0b8cff; font-size: 24rpx; font-weight: 800; }
  790. .upload-strip {
  791. margin-top: 22rpx;
  792. min-height: 142rpx;
  793. padding: 18rpx;
  794. border: 2rpx dashed rgba(11, 140, 255, 0.28);
  795. border-radius: 20rpx;
  796. background: #f8fbff;
  797. display: flex;
  798. align-items: center;
  799. box-sizing: border-box;
  800. }
  801. .upload-thumb {
  802. width: 104rpx;
  803. height: 104rpx;
  804. border-radius: 18rpx;
  805. background: #eef6ff;
  806. display: flex;
  807. align-items: center;
  808. justify-content: center;
  809. flex-shrink: 0;
  810. overflow: hidden;
  811. }
  812. .upload-img { width: 100%; height: 100%; }
  813. .upload-plus { color: #0b8cff; font-size: 48rpx; line-height: 1; }
  814. .upload-copy { min-width: 0; flex: 1; margin-left: 20rpx; }
  815. .upload-title { display: block; color: #172033; font-size: 29rpx; font-weight: 800; }
  816. .upload-sub { display: block; margin-top: 8rpx; color: #7f8896; font-size: 23rpx; line-height: 1.4; }
  817. .upload-action { margin-left: 18rpx; color: #0b8cff; font-size: 25rpx; font-weight: 800; }
  818. .skeleton-list { margin-top: 20rpx; }
  819. .skeleton-row {
  820. height: 76rpx;
  821. margin-bottom: 14rpx;
  822. border-radius: 18rpx;
  823. background: #f1f5fa;
  824. }
  825. .segmented {
  826. margin-top: 20rpx;
  827. padding: 6rpx;
  828. border-radius: 999rpx;
  829. background: #f1f5fa;
  830. display: flex;
  831. }
  832. .segment {
  833. flex: 1;
  834. height: 68rpx;
  835. border-radius: 999rpx;
  836. color: #66758a;
  837. font-size: 25rpx;
  838. font-weight: 800;
  839. line-height: 68rpx;
  840. text-align: center;
  841. }
  842. .segment.active { color: #fff; background: #0b8cff; }
  843. .config-panel { margin-top: 8rpx; }
  844. .sub-block { margin-top: 24rpx; }
  845. .sub-title { display: block; color: #172033; font-size: 27rpx; font-weight: 800; }
  846. .option-grid {
  847. margin-top: 16rpx;
  848. display: grid;
  849. grid-template-columns: repeat(3, minmax(0, 1fr));
  850. gap: 14rpx;
  851. }
  852. .option-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  853. .option-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  854. .option {
  855. min-height: 76rpx;
  856. padding: 0 12rpx;
  857. border-radius: 18rpx;
  858. color: #596579;
  859. font-size: 25rpx;
  860. font-weight: 700;
  861. line-height: 76rpx;
  862. text-align: center;
  863. background: #f7f9fd;
  864. border: 2rpx solid transparent;
  865. box-sizing: border-box;
  866. overflow: hidden;
  867. white-space: nowrap;
  868. text-overflow: ellipsis;
  869. }
  870. .option.active { color: #0b8cff; background: #eff8ff; border-color: #0b8cff; }
  871. .ratio-grid {
  872. margin-top: 20rpx;
  873. display: grid;
  874. grid-template-columns: repeat(5, minmax(0, 1fr));
  875. gap: 12rpx;
  876. }
  877. .ratio {
  878. height: 72rpx;
  879. border-radius: 18rpx;
  880. color: #596579;
  881. font-size: 24rpx;
  882. font-weight: 800;
  883. line-height: 72rpx;
  884. text-align: center;
  885. background: #f7f9fd;
  886. border: 2rpx solid transparent;
  887. }
  888. .ratio.active { color: #0b8cff; background: #eff8ff; border-color: #0b8cff; }
  889. .quantity { margin-top: 22rpx; display: flex; align-items: center; gap: 28rpx; }
  890. .quantity-btn {
  891. width: 76rpx;
  892. height: 76rpx;
  893. border-radius: 20rpx;
  894. color: #0b8cff;
  895. font-size: 36rpx;
  896. font-weight: 800;
  897. line-height: 76rpx;
  898. text-align: center;
  899. background: #eef7ff;
  900. }
  901. .quantity-value { min-width: 60rpx; color: #172033; font-size: 32rpx; font-weight: 900; text-align: center; }
  902. .note { display: block; margin-top: 16rpx; color: #8a95a6; font-size: 22rpx; }
  903. .textarea {
  904. margin-top: 20rpx;
  905. width: 100%;
  906. min-height: 150rpx;
  907. padding: 22rpx;
  908. border-radius: 20rpx;
  909. color: #172033;
  910. font-size: 25rpx;
  911. line-height: 1.5;
  912. background: #f7f9fd;
  913. box-sizing: border-box;
  914. }
  915. .tips { margin-top: 18rpx; }
  916. .tip-item { display: block; margin-top: 6rpx; color: #8a95a6; font-size: 22rpx; line-height: 1.5; }
  917. .progress-box { padding: 50rpx 0; display: flex; flex-direction: column; align-items: center; }
  918. .progress-ring {
  919. width: 64rpx;
  920. height: 64rpx;
  921. border: 6rpx solid #e0f0ff;
  922. border-top-color: #0b8cff;
  923. border-radius: 50%;
  924. animation: spin 0.9s linear infinite;
  925. }
  926. @keyframes spin { to { transform: rotate(360deg); } }
  927. .progress-title { margin-top: 22rpx; color: #172033; font-size: 28rpx; font-weight: 800; }
  928. .progress-sub {
  929. margin-top: 10rpx;
  930. padding: 0 40rpx;
  931. color: #8a95a6;
  932. font-size: 23rpx;
  933. line-height: 1.5;
  934. text-align: center;
  935. }
  936. .fail-box { padding: 44rpx 0; display: flex; flex-direction: column; align-items: center; }
  937. .fail-title { color: #f04438; font-size: 28rpx; font-weight: 800; }
  938. .fail-sub { margin-top: 10rpx; color: #8a95a6; font-size: 23rpx; }
  939. .result-box { margin-top: 20rpx; }
  940. .result-grid {
  941. display: grid;
  942. grid-template-columns: repeat(2, minmax(0, 1fr));
  943. gap: 14rpx;
  944. }
  945. .result-grid.single { grid-template-columns: 1fr; }
  946. .result-img {
  947. width: 100%;
  948. height: 420rpx;
  949. border-radius: 18rpx;
  950. background: #f1f5fa;
  951. }
  952. .result-grid.single .result-img { height: 720rpx; }
  953. .result-actions { margin-top: 20rpx; display: flex; gap: 16rpx; }
  954. .ghost-btn {
  955. flex: 1;
  956. height: 76rpx;
  957. border-radius: 999rpx;
  958. color: #1f8cff;
  959. font-size: 25rpx;
  960. font-weight: 800;
  961. line-height: 76rpx;
  962. text-align: center;
  963. background: #eef7ff;
  964. }
  965. .bottom-bar {
  966. position: fixed;
  967. left: 0;
  968. right: 0;
  969. bottom: 0;
  970. z-index: 80;
  971. padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
  972. background: rgba(255, 255, 255, 0.96);
  973. box-shadow: 0 -10rpx 30rpx rgba(32, 56, 92, 0.1);
  974. }
  975. .balance {
  976. margin-bottom: 14rpx;
  977. display: flex;
  978. align-items: center;
  979. justify-content: space-between;
  980. gap: 20rpx;
  981. color: #7b8797;
  982. font-size: 24rpx;
  983. line-height: 1.35;
  984. }
  985. .balance-copy { min-width: 0; flex: 1; }
  986. .recharge-link { color: #0b8cff; font-size: 24rpx; font-weight: 800; flex-shrink: 0; }
  987. .buttons { display: flex; gap: 22rpx; }
  988. .history-btn,
  989. .submit-btn {
  990. height: 86rpx;
  991. border-radius: 999rpx;
  992. font-size: 30rpx;
  993. font-weight: 800;
  994. display: flex;
  995. align-items: center;
  996. justify-content: center;
  997. }
  998. .history-btn { width: 210rpx; color: #1f8cff; background: #eef7ff; }
  999. .submit-btn {
  1000. flex: 1;
  1001. color: #fff;
  1002. background: linear-gradient(135deg, #1f8cff, #41b9ff);
  1003. box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.28);
  1004. }
  1005. .submit-btn.disabled { opacity: 0.6; }
  1006. .submit-icon { width: 24rpx; height: 24rpx; margin-right: 8rpx; }
  1007. /* 对比弹层:与老照片 / 动漫头像保持一致 */
  1008. .compare-modal {
  1009. position: fixed;
  1010. left: 0;
  1011. right: 0;
  1012. top: 0;
  1013. bottom: 0;
  1014. z-index: 120;
  1015. }
  1016. .compare-backdrop {
  1017. position: absolute;
  1018. left: 0;
  1019. right: 0;
  1020. top: 0;
  1021. bottom: 0;
  1022. background: rgba(15, 23, 42, 0.58);
  1023. }
  1024. .compare-panel {
  1025. position: absolute;
  1026. left: 36rpx;
  1027. right: 36rpx;
  1028. top: 50%;
  1029. transform: translateY(-50%);
  1030. padding: 24rpx;
  1031. border-radius: 28rpx;
  1032. background: #ffffff;
  1033. }
  1034. .compare-panel-head {
  1035. margin-bottom: 20rpx;
  1036. display: flex;
  1037. align-items: center;
  1038. justify-content: space-between;
  1039. color: #172033;
  1040. font-size: 32rpx;
  1041. font-weight: 800;
  1042. }
  1043. .close-btn {
  1044. width: 56rpx;
  1045. height: 56rpx;
  1046. border-radius: 50%;
  1047. color: #64748b;
  1048. background: #f1f5f9;
  1049. font-size: 42rpx;
  1050. line-height: 50rpx;
  1051. text-align: center;
  1052. font-weight: 300;
  1053. }
  1054. .large-compare {
  1055. width: 100%;
  1056. height: 900rpx;
  1057. max-height: 70vh;
  1058. border-radius: 22rpx;
  1059. }
  1060. .large-compare .compare-before .compare-img { width: calc(100vw - 120rpx); }
  1061. .compare-guide {
  1062. display: block;
  1063. margin-top: 18rpx;
  1064. color: #64748b;
  1065. font-size: 24rpx;
  1066. text-align: center;
  1067. }
  1068. </style>