25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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