Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 

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