Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 

504 rader
14 KiB

  1. <template>
  2. <view class="page">
  3. <scroll-view class="scroll" scroll-y>
  4. <view class="wrap">
  5. <!-- 处理中 / 失败 -->
  6. <view v-if="!isSuccess" class="state-card">
  7. <view v-if="isPending" class="spinner"></view>
  8. <text class="state-title">{{ stateTitle }}</text>
  9. <text v-if="isPending" class="count">{{ detail.success_count || 0 }} / {{ detail.count || 0 }} 张 · {{ countDown }}S</text>
  10. <text v-if="!isPending" class="error">{{ detail.error_msg || '失败的照片不扣点,可以重新生成' }}</text>
  11. </view>
  12. <template v-else>
  13. <!-- 主图 + 缩略图切换 -->
  14. <view class="image-card" @click="preview">
  15. <image class="work-img" :src="activeUrl" mode="widthFix" />
  16. <view class="index-chip">{{ activeIndex + 1 }} / {{ resultUrls.length }}</view>
  17. </view>
  18. <view v-if="resultUrls.length > 1" class="thumb-row">
  19. <image
  20. v-for="(url, index) in resultUrls"
  21. :key="url"
  22. class="thumb"
  23. :class="{ active: index === activeIndex }"
  24. :src="url"
  25. mode="aspectFill"
  26. @click="activeIndex = index"
  27. />
  28. </view>
  29. </template>
  30. <view class="info-card">
  31. <view class="title">生成信息</view>
  32. <view class="cell">
  33. <text class="cell-label">照片风格:</text>
  34. <text class="cell-content">{{ detail.style_name || '-' }}</text>
  35. </view>
  36. <view v-for="item in optionTexts" :key="item.label" class="cell">
  37. <text class="cell-label">{{ item.label }}:</text>
  38. <text class="cell-content">{{ item.value }}</text>
  39. </view>
  40. <view class="cell">
  41. <text class="cell-label">生成模型:</text>
  42. <text class="cell-content">{{ detail.model_name || '-' }}</text>
  43. </view>
  44. <view class="cell">
  45. <text class="cell-label">图片比例:</text>
  46. <text class="cell-content">{{ detail.ratio || '-' }}</text>
  47. </view>
  48. <view class="cell">
  49. <text class="cell-label">生成张数:</text>
  50. <text class="cell-content">
  51. 成功 {{ detail.success_count || 0 }} 张<text v-if="detail.fail_count">,失败 {{ detail.fail_count }} 张</text>
  52. </text>
  53. </view>
  54. <view v-if="detail.extra_prompt" class="cell">
  55. <text class="cell-label">补充描述:</text>
  56. <text class="cell-content">{{ detail.extra_prompt }}</text>
  57. </view>
  58. <view class="cell">
  59. <text class="cell-label">创建时间:</text>
  60. <text class="cell-content">{{ formatTime(detail.add_time) }}</text>
  61. </view>
  62. <view class="cell">
  63. <text class="cell-label">功能来源:</text>
  64. <text class="link" @click="createAgain">形象照 / 职业照 / 写真</text>
  65. </view>
  66. <text class="note">AI 会重绘服装与背景,五官可能出现轻微变化,建议对照原图确认。</text>
  67. </view>
  68. </view>
  69. </scroll-view>
  70. <view v-if="isSuccess" class="bottom-bar">
  71. <view class="bar-btn" @click="save">保存</view>
  72. <view class="bar-btn" @click="openCompare">对比</view>
  73. <view class="main-btn" @click="createAgain">再拍一组</view>
  74. </view>
  75. <!-- 前后对比:样式与动漫头像一致 -->
  76. <view v-if="compareVisible" class="compare-modal">
  77. <view class="compare-backdrop" @click="closeCompare"></view>
  78. <view class="compare-panel">
  79. <view class="compare-panel-head">
  80. <text>效果对比</text>
  81. <view class="close-btn" @click="closeCompare">×</view>
  82. </view>
  83. <view
  84. class="large-compare"
  85. @touchstart.stop="onCompareTouch"
  86. @touchmove.stop.prevent="onCompareTouch"
  87. @click.stop="onCompareTouch"
  88. >
  89. <image class="compare-img" :src="activeUrl" mode="aspectFit" />
  90. <view class="compare-before" :style="{ width: comparePercent + '%' }">
  91. <image class="compare-img" :src="detail.source_url" mode="aspectFit" />
  92. </view>
  93. <view class="compare-line large" :style="{ left: comparePercent + '%' }">
  94. <view class="compare-handle large">
  95. <text>‹</text>
  96. <text>›</text>
  97. </view>
  98. </view>
  99. <view class="compare-tag left">原图</view>
  100. <view class="compare-tag right">生成图</view>
  101. </view>
  102. <text class="compare-guide">左右滑动中间按钮查看变化</text>
  103. </view>
  104. </view>
  105. </view>
  106. </template>
  107. <script setup>
  108. import { computed, ref } from 'vue'
  109. import { onLoad, onUnload } from '@dcloudio/uni-app'
  110. import dayjs from 'dayjs'
  111. import { portraitApi } from '@/api/index.js'
  112. import { useApp } from '@/utils/useApp.js'
  113. const { toast } = useApp()
  114. const id = ref('')
  115. const detail = ref({ status: 10 })
  116. const activeIndex = ref(0)
  117. const countDown = ref(0)
  118. const compareVisible = ref(false)
  119. const comparePercent = ref(50)
  120. let timer = 0
  121. let countTimer = 0
  122. // 后端 options 是 key,这里映射成中文;名称与 utils/portrait.js 的预设保持一致
  123. const OPTION_LABELS = {
  124. background: '背景',
  125. outfit: '服装',
  126. framing: '取景',
  127. art_style: '写真风格'
  128. }
  129. const OPTION_NAMES = {
  130. white: '白色', gray: '浅灰', blue: '商务蓝', 'deep-blue': '深蓝', beige: '米色',
  131. office: '办公室', bookshelf: '书架', cafe: '咖啡角', window: '落地窗',
  132. formal: '正装', shirt: '衬衫', knit: '针织衫', 'tshirt-jeans': 'T恤配牛仔裤',
  133. 'blazer-tshirt': '西装外套配T恤', bust: '胸像', half: '半身',
  134. guofeng: '古风', zangzu: '藏族', miaozu: '苗族', urban: '都市青年', trendy: '潮人',
  135. dunhuang: '敦煌飞仙', hongkong: '港风复古', newchinese: '新中式', french: '法式油画'
  136. }
  137. const resultUrls = computed(() => detail.value.result_urls || [])
  138. const activeUrl = computed(() => resultUrls.value[activeIndex.value] || resultUrls.value[0] || '')
  139. const isPending = computed(() => [10, 20].includes(Number(detail.value.status)))
  140. const isSuccess = computed(() => Number(detail.value.status) === 30 && resultUrls.value.length > 0)
  141. const stateTitle = computed(() => {
  142. if (Number(detail.value.status) === 10) return '排队中,请耐心等待'
  143. if (Number(detail.value.status) === 20) return 'AI 正在生成形象照'
  144. return '生成失败'
  145. })
  146. const optionTexts = computed(() => {
  147. const options = detail.value.options || {}
  148. return Object.keys(options).map((key) => ({
  149. label: OPTION_LABELS[key] || key,
  150. value: OPTION_NAMES[options[key]] || options[key]
  151. }))
  152. })
  153. onLoad((options) => {
  154. id.value = (options && options.id) || ''
  155. loadDetail()
  156. })
  157. onUnload(() => {
  158. clearTimeout(timer)
  159. clearInterval(countTimer)
  160. })
  161. async function loadDetail() {
  162. if (!id.value) return
  163. try {
  164. const res = await portraitApi.getDetail({ photo_open_id: id.value })
  165. detail.value = res.data || {}
  166. if (activeIndex.value >= resultUrls.value.length) activeIndex.value = 0
  167. if (isPending.value) waitNext()
  168. } catch (e) {
  169. toast((e && e.msg) || '记录加载失败')
  170. }
  171. }
  172. // 处理中就 3 秒拉一次,后端会在这一步推进任务并转存结果
  173. function waitNext() {
  174. clearTimeout(timer)
  175. clearInterval(countTimer)
  176. countDown.value = 3
  177. countTimer = setInterval(() => {
  178. countDown.value -= 1
  179. if (countDown.value <= 0) clearInterval(countTimer)
  180. }, 1000)
  181. timer = setTimeout(loadDetail, 3000)
  182. }
  183. function formatTime(value) {
  184. if (!value) return '-'
  185. return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
  186. }
  187. function preview() {
  188. if (!resultUrls.value.length) return
  189. uni.previewImage({ urls: resultUrls.value, current: activeUrl.value })
  190. }
  191. function save() {
  192. if (!activeUrl.value) return
  193. uni.showLoading({ title: '保存中...', mask: true })
  194. uni.downloadFile({
  195. url: activeUrl.value,
  196. success: (res) => {
  197. uni.saveImageToPhotosAlbum({
  198. filePath: res.tempFilePath,
  199. success: () => toast('已保存到相册'),
  200. fail: () => toast('保存失败,请检查相册权限')
  201. })
  202. },
  203. fail: () => toast('图片下载失败'),
  204. complete: () => uni.hideLoading()
  205. })
  206. }
  207. function openCompare() {
  208. if (!activeUrl.value || !detail.value.source_url) return
  209. comparePercent.value = 50
  210. compareVisible.value = true
  211. }
  212. function closeCompare() {
  213. compareVisible.value = false
  214. }
  215. function onCompareTouch(e) {
  216. const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || e
  217. const x = touch.clientX || 0
  218. uni.createSelectorQuery()
  219. .select('.large-compare')
  220. .boundingClientRect((rect) => {
  221. if (!rect || !rect.width) return
  222. const percent = ((x - rect.left) / rect.width) * 100
  223. comparePercent.value = Math.max(6, Math.min(94, Number(percent.toFixed(1))))
  224. })
  225. .exec()
  226. }
  227. function createAgain() {
  228. const delta = getPageStackDelta('pages/tool/portrait-photo')
  229. if (delta > 0) {
  230. uni.navigateBack({ delta })
  231. return
  232. }
  233. uni.navigateTo({ url: '/pages/tool/portrait-photo' })
  234. }
  235. function getPageStackDelta(routeName) {
  236. const pages = getCurrentPages()
  237. const index = pages.findIndex((item) => item.route === routeName)
  238. return index >= 0 ? pages.length - 1 - index : -1
  239. }
  240. </script>
  241. <style lang="scss" scoped>
  242. .page {
  243. height: 100vh;
  244. background: #f4f7fb;
  245. display: flex;
  246. flex-direction: column;
  247. overflow: hidden;
  248. }
  249. .scroll { flex: 1; height: 0; }
  250. /* 底栏是 fixed 的,留够 300rpx */
  251. .wrap { padding: 28rpx 28rpx 300rpx; }
  252. .state-card,
  253. .image-card,
  254. .info-card {
  255. background: #fff;
  256. border-radius: 18rpx;
  257. overflow: hidden;
  258. }
  259. .state-card {
  260. min-height: 540rpx;
  261. display: flex;
  262. flex-direction: column;
  263. align-items: center;
  264. justify-content: center;
  265. color: #172033;
  266. font-size: 30rpx;
  267. }
  268. .spinner {
  269. width: 72rpx;
  270. height: 72rpx;
  271. margin-bottom: 28rpx;
  272. border: 8rpx solid #e9edf5;
  273. border-top-color: #0b8cff;
  274. border-radius: 50%;
  275. animation: spin 0.8s linear infinite;
  276. }
  277. .count,
  278. .error { margin-top: 18rpx; color: #7f8896; font-size: 26rpx; }
  279. .error { color: #f04438; padding: 0 40rpx; text-align: center; }
  280. .image-card { position: relative; }
  281. .work-img { width: 100%; display: block; }
  282. .index-chip {
  283. position: absolute;
  284. right: 18rpx;
  285. bottom: 18rpx;
  286. height: 48rpx;
  287. padding: 0 20rpx;
  288. border-radius: 999rpx;
  289. color: #fff;
  290. font-size: 23rpx;
  291. font-weight: 800;
  292. line-height: 48rpx;
  293. background: rgba(15, 23, 42, 0.55);
  294. }
  295. .thumb-row {
  296. margin-top: 18rpx;
  297. display: flex;
  298. gap: 14rpx;
  299. }
  300. .thumb {
  301. width: 150rpx;
  302. height: 150rpx;
  303. border-radius: 16rpx;
  304. background: #eef2f7;
  305. border: 4rpx solid transparent;
  306. }
  307. .thumb.active { border-color: #0b8cff; }
  308. .info-card { margin-top: 24rpx; padding: 28rpx; }
  309. .title { margin-bottom: 16rpx; color: #172033; font-size: 32rpx; font-weight: 700; }
  310. .cell {
  311. padding: 10rpx 0;
  312. display: flex;
  313. color: #7f8896;
  314. font-size: 27rpx;
  315. line-height: 1.6;
  316. }
  317. .cell-label { flex-shrink: 0; }
  318. .cell-content { flex: 1; min-width: 0; color: #172033; }
  319. .link { color: #0b8cff; }
  320. .note {
  321. display: block;
  322. margin-top: 16rpx;
  323. padding-top: 16rpx;
  324. border-top: 2rpx solid #f1f5fa;
  325. color: #8a95a6;
  326. font-size: 23rpx;
  327. line-height: 1.5;
  328. }
  329. .bottom-bar {
  330. position: fixed;
  331. left: 0;
  332. right: 0;
  333. bottom: 0;
  334. padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
  335. background: #fff;
  336. display: flex;
  337. gap: 18rpx;
  338. box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08);
  339. }
  340. .bar-btn,
  341. .main-btn {
  342. height: 82rpx;
  343. border-radius: 42rpx;
  344. font-size: 29rpx;
  345. display: flex;
  346. align-items: center;
  347. justify-content: center;
  348. }
  349. .bar-btn { width: 150rpx; color: #0b8cff; background: #eef6ff; }
  350. .main-btn { flex: 1; color: #fff; background: #0b8cff; }
  351. /* 对比弹层:与动漫头像保持一致 */
  352. .compare-modal {
  353. position: fixed;
  354. left: 0;
  355. right: 0;
  356. top: 0;
  357. bottom: 0;
  358. z-index: 120;
  359. }
  360. .compare-backdrop {
  361. position: absolute;
  362. left: 0;
  363. right: 0;
  364. top: 0;
  365. bottom: 0;
  366. background: rgba(15, 23, 42, 0.58);
  367. }
  368. .compare-panel {
  369. position: absolute;
  370. left: 36rpx;
  371. right: 36rpx;
  372. top: 50%;
  373. transform: translateY(-50%);
  374. padding: 24rpx;
  375. border-radius: 28rpx;
  376. background: #ffffff;
  377. }
  378. .compare-panel-head {
  379. margin-bottom: 20rpx;
  380. display: flex;
  381. align-items: center;
  382. justify-content: space-between;
  383. color: #172033;
  384. font-size: 32rpx;
  385. font-weight: 800;
  386. }
  387. .close-btn {
  388. width: 56rpx;
  389. height: 56rpx;
  390. border-radius: 50%;
  391. color: #64748b;
  392. background: #f1f5f9;
  393. font-size: 42rpx;
  394. line-height: 50rpx;
  395. text-align: center;
  396. font-weight: 300;
  397. }
  398. .large-compare {
  399. position: relative;
  400. width: 100%;
  401. height: 900rpx;
  402. max-height: 70vh;
  403. border-radius: 22rpx;
  404. background: #dfe8f4;
  405. overflow: hidden;
  406. }
  407. .compare-img { width: 100%; height: 100%; }
  408. .compare-before {
  409. position: absolute;
  410. left: 0;
  411. top: 0;
  412. bottom: 0;
  413. overflow: hidden;
  414. }
  415. .large-compare .compare-before .compare-img { width: calc(100vw - 120rpx); }
  416. .compare-line {
  417. position: absolute;
  418. top: 0;
  419. bottom: 0;
  420. width: 4rpx;
  421. margin-left: -2rpx;
  422. background: rgba(255, 255, 255, 0.94);
  423. box-shadow: 0 0 12rpx rgba(20, 37, 66, 0.22);
  424. }
  425. .compare-line.large { width: 6rpx; margin-left: -3rpx; }
  426. .compare-handle {
  427. position: absolute;
  428. left: 50%;
  429. top: 50%;
  430. width: 38rpx;
  431. height: 58rpx;
  432. margin-left: -19rpx;
  433. margin-top: -29rpx;
  434. border-radius: 999rpx;
  435. background: #ffffff;
  436. box-shadow: 0 8rpx 18rpx rgba(20, 37, 66, 0.22);
  437. display: flex;
  438. align-items: center;
  439. justify-content: center;
  440. gap: 1rpx;
  441. color: #1f8cff;
  442. font-size: 24rpx;
  443. font-weight: 900;
  444. }
  445. .compare-handle.large {
  446. width: 54rpx;
  447. height: 78rpx;
  448. margin-left: -27rpx;
  449. margin-top: -39rpx;
  450. font-size: 32rpx;
  451. }
  452. .compare-tag {
  453. position: absolute;
  454. top: 12rpx;
  455. padding: 6rpx 12rpx;
  456. border-radius: 999rpx;
  457. color: #ffffff;
  458. background: rgba(15, 23, 42, 0.46);
  459. font-size: 20rpx;
  460. font-weight: 700;
  461. }
  462. .compare-tag.left { left: 12rpx; }
  463. .compare-tag.right { right: 12rpx; }
  464. .compare-guide {
  465. display: block;
  466. margin-top: 18rpx;
  467. color: #64748b;
  468. font-size: 24rpx;
  469. text-align: center;
  470. }
  471. @keyframes spin {
  472. to { transform: rotate(360deg); }
  473. }
  474. </style>