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

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