|
- <template>
- <view class="page">
- <scroll-view class="scroll" scroll-y>
- <view class="wrap">
- <!-- 处理中 / 失败 -->
- <view v-if="!isSuccess" class="state-card">
- <view v-if="isPending" class="spinner"></view>
- <text class="state-title">{{ stateTitle }}</text>
- <text v-if="isPending" class="count">{{ detail.success_count || 0 }} / {{ detail.count || 0 }} 张 · {{ countDown }}S</text>
- <text v-if="!isPending" class="error">{{ detail.error_msg || '失败的照片不扣点,可以重新生成' }}</text>
- </view>
-
- <template v-else>
- <!-- 主图 + 缩略图切换 -->
- <view class="image-card" @click="preview">
- <image class="work-img" :src="activeUrl" mode="widthFix" />
- <view class="index-chip">{{ activeIndex + 1 }} / {{ resultUrls.length }}</view>
- </view>
- <view v-if="resultUrls.length > 1" class="thumb-row">
- <image
- v-for="(url, index) in resultUrls"
- :key="url"
- class="thumb"
- :class="{ active: index === activeIndex }"
- :src="url"
- mode="aspectFill"
- @click="activeIndex = index"
- />
- </view>
- </template>
-
- <view class="info-card">
- <view class="title">生成信息</view>
- <view class="cell">
- <text class="cell-label">照片风格:</text>
- <text class="cell-content">{{ detail.style_name || '-' }}</text>
- </view>
- <view v-for="item in optionTexts" :key="item.label" class="cell">
- <text class="cell-label">{{ item.label }}:</text>
- <text class="cell-content">{{ item.value }}</text>
- </view>
- <view class="cell">
- <text class="cell-label">生成模型:</text>
- <text class="cell-content">{{ detail.model_name || '-' }}</text>
- </view>
- <view class="cell">
- <text class="cell-label">图片比例:</text>
- <text class="cell-content">{{ detail.ratio || '-' }}</text>
- </view>
- <view class="cell">
- <text class="cell-label">生成张数:</text>
- <text class="cell-content">
- 成功 {{ detail.success_count || 0 }} 张<text v-if="detail.fail_count">,失败 {{ detail.fail_count }} 张</text>
- </text>
- </view>
- <view v-if="detail.extra_prompt" class="cell">
- <text class="cell-label">补充描述:</text>
- <text class="cell-content">{{ detail.extra_prompt }}</text>
- </view>
- <view class="cell">
- <text class="cell-label">创建时间:</text>
- <text class="cell-content">{{ formatTime(detail.add_time) }}</text>
- </view>
- <view class="cell">
- <text class="cell-label">功能来源:</text>
- <text class="link" @click="createAgain">形象照 / 职业照 / 写真</text>
- </view>
- <text class="note">AI 会重绘服装与背景,五官可能出现轻微变化,建议对照原图确认。</text>
- </view>
- </view>
- </scroll-view>
-
- <view v-if="isSuccess" class="bottom-bar">
- <view class="delete-btn" @click="deleteRecord">删除</view>
- <view class="bar-btn" @click="save">保存</view>
- <view class="bar-btn" @click="openCompare">对比</view>
- <view class="main-btn" @click="createAgain">再拍一组</view>
- </view>
-
- <!-- 前后对比:样式与动漫头像一致 -->
- <view v-if="compareVisible" class="compare-modal">
- <view class="compare-backdrop" @click="closeCompare"></view>
- <view class="compare-panel">
- <view class="compare-panel-head">
- <text>效果对比</text>
- <view class="close-btn" @click="closeCompare">×</view>
- </view>
- <view
- class="large-compare"
- @touchstart.stop="onCompareTouch"
- @touchmove.stop.prevent="onCompareTouch"
- @click.stop="onCompareTouch"
- >
- <image class="compare-img" :src="activeUrl" mode="aspectFit" />
- <view class="compare-before" :style="{ width: comparePercent + '%' }">
- <image class="compare-img" :src="detail.source_url" mode="aspectFit" />
- </view>
- <view class="compare-line large" :style="{ left: comparePercent + '%' }">
- <view class="compare-handle large">
- <text>‹</text>
- <text>›</text>
- </view>
- </view>
- <view class="compare-tag left">原图</view>
- <view class="compare-tag right">生成图</view>
- </view>
- <text class="compare-guide">左右滑动中间按钮查看变化</text>
- </view>
- </view>
- </view>
- </template>
-
- <script setup>
- import { computed, ref } from 'vue'
- import { onLoad, onUnload } from '@dcloudio/uni-app'
- import dayjs from 'dayjs'
- import { portraitApi } from '@/api/index.js'
- import { useApp } from '@/utils/useApp.js'
-
- const { toast } = useApp()
- const id = ref('')
- const detail = ref({ status: 10 })
- const activeIndex = ref(0)
- const countDown = ref(0)
- const compareVisible = ref(false)
- const comparePercent = ref(50)
- let timer = 0
- let countTimer = 0
-
- // 后端 options 是 key,这里映射成中文;名称与 utils/portrait.js 的预设保持一致
- const OPTION_LABELS = {
- background: '背景',
- outfit: '服装',
- framing: '取景',
- art_style: '写真风格'
- }
- const OPTION_NAMES = {
- white: '白色', gray: '浅灰', blue: '商务蓝', 'deep-blue': '深蓝', beige: '米色',
- office: '办公室', bookshelf: '书架', cafe: '咖啡角', window: '落地窗',
- formal: '正装', shirt: '衬衫', knit: '针织衫', 'tshirt-jeans': 'T恤配牛仔裤',
- 'blazer-tshirt': '西装外套配T恤', bust: '胸像', half: '半身',
- guofeng: '古风', zangzu: '藏族', miaozu: '苗族', urban: '都市青年', trendy: '潮人',
- dunhuang: '敦煌飞仙', hongkong: '港风复古', newchinese: '新中式', french: '法式油画'
- }
-
- const resultUrls = computed(() => detail.value.result_urls || [])
- const activeUrl = computed(() => resultUrls.value[activeIndex.value] || resultUrls.value[0] || '')
- const isPending = computed(() => [10, 20].includes(Number(detail.value.status)))
- const isSuccess = computed(() => Number(detail.value.status) === 30 && resultUrls.value.length > 0)
- const stateTitle = computed(() => {
- if (Number(detail.value.status) === 10) return '排队中,请耐心等待'
- if (Number(detail.value.status) === 20) return 'AI 正在生成形象照'
- return '生成失败'
- })
- const optionTexts = computed(() => {
- const options = detail.value.options || {}
- return Object.keys(options).map((key) => ({
- label: OPTION_LABELS[key] || key,
- value: OPTION_NAMES[options[key]] || options[key]
- }))
- })
-
- onLoad((options) => {
- id.value = (options && options.id) || ''
- loadDetail()
- })
-
- onUnload(() => {
- clearTimeout(timer)
- clearInterval(countTimer)
- })
-
- async function loadDetail() {
- if (!id.value) return
- try {
- const res = await portraitApi.getDetail({ photo_open_id: id.value })
- detail.value = res.data || {}
- if (activeIndex.value >= resultUrls.value.length) activeIndex.value = 0
- if (isPending.value) waitNext()
- } catch (e) {
- toast((e && e.msg) || '记录加载失败')
- }
- }
-
- // 处理中就 3 秒拉一次,后端会在这一步推进任务并转存结果
- function waitNext() {
- clearTimeout(timer)
- clearInterval(countTimer)
- countDown.value = 3
- countTimer = setInterval(() => {
- countDown.value -= 1
- if (countDown.value <= 0) clearInterval(countTimer)
- }, 1000)
- timer = setTimeout(loadDetail, 3000)
- }
-
- function formatTime(value) {
- if (!value) return '-'
- return dayjs(Number(value) * 1000).format('YYYY-MM-DD HH:mm')
- }
-
- function preview() {
- if (!resultUrls.value.length) return
- uni.previewImage({ urls: resultUrls.value, current: activeUrl.value })
- }
-
- function save() {
- if (!activeUrl.value) return
- uni.showLoading({ title: '保存中...', mask: true })
- uni.downloadFile({
- url: activeUrl.value,
- success: (res) => {
- uni.saveImageToPhotosAlbum({
- filePath: res.tempFilePath,
- success: () => toast('已保存到相册'),
- fail: () => toast('保存失败,请检查相册权限')
- })
- },
- fail: () => toast('图片下载失败'),
- complete: () => uni.hideLoading()
- })
- }
-
- function deleteRecord() {
- if (!id.value) return
- uni.showModal({
- title: '删除记录',
- content: '删除后将不再展示该形象照记录,是否继续?',
- confirmText: '删除',
- confirmColor: '#f04438',
- success: async (res) => {
- if (!res.confirm) return
- try {
- await portraitApi.delete({ photo_open_id: id.value })
- toast('删除成功')
- const pages = getCurrentPages()
- if (pages.length > 1) {
- uni.navigateBack()
- } else {
- uni.redirectTo({ url: '/pages/works/works' })
- }
- } catch (e) {
- toast((e && e.msg) || '删除失败')
- }
- }
- })
- }
-
- function openCompare() {
- if (!activeUrl.value || !detail.value.source_url) return
- comparePercent.value = 50
- compareVisible.value = true
- }
-
- function closeCompare() {
- compareVisible.value = false
- }
-
- function onCompareTouch(e) {
- const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || e
- const x = touch.clientX || 0
- uni.createSelectorQuery()
- .select('.large-compare')
- .boundingClientRect((rect) => {
- if (!rect || !rect.width) return
- const percent = ((x - rect.left) / rect.width) * 100
- comparePercent.value = Math.max(6, Math.min(94, Number(percent.toFixed(1))))
- })
- .exec()
- }
-
- function createAgain() {
- const delta = getPageStackDelta('pages/tool/portrait-photo')
- if (delta > 0) {
- uni.navigateBack({ delta })
- return
- }
- uni.navigateTo({ url: '/pages/tool/portrait-photo' })
- }
-
- function getPageStackDelta(routeName) {
- const pages = getCurrentPages()
- const index = pages.findIndex((item) => item.route === routeName)
- return index >= 0 ? pages.length - 1 - index : -1
- }
- </script>
-
- <style lang="scss" scoped>
- .page {
- height: 100vh;
- background: #f4f7fb;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- .scroll { flex: 1; height: 0; }
- /* 底栏是 fixed 的,留够 300rpx */
- .wrap { padding: 28rpx 28rpx 300rpx; }
-
- .state-card,
- .image-card,
- .info-card {
- background: #fff;
- border-radius: 18rpx;
- overflow: hidden;
- }
-
- .state-card {
- min-height: 540rpx;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- color: #172033;
- font-size: 30rpx;
- }
- .spinner {
- width: 72rpx;
- height: 72rpx;
- margin-bottom: 28rpx;
- border: 8rpx solid #e9edf5;
- border-top-color: #0b8cff;
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
- }
- .count,
- .error { margin-top: 18rpx; color: #7f8896; font-size: 26rpx; }
- .error { color: #f04438; padding: 0 40rpx; text-align: center; }
-
- .image-card { position: relative; }
- .work-img { width: 100%; display: block; }
- .index-chip {
- position: absolute;
- right: 18rpx;
- bottom: 18rpx;
- height: 48rpx;
- padding: 0 20rpx;
- border-radius: 999rpx;
- color: #fff;
- font-size: 23rpx;
- font-weight: 800;
- line-height: 48rpx;
- background: rgba(15, 23, 42, 0.55);
- }
-
- .thumb-row {
- margin-top: 18rpx;
- display: flex;
- gap: 14rpx;
- }
- .thumb {
- width: 150rpx;
- height: 150rpx;
- border-radius: 16rpx;
- background: #eef2f7;
- border: 4rpx solid transparent;
- }
- .thumb.active { border-color: #0b8cff; }
-
- .info-card { margin-top: 24rpx; padding: 28rpx; }
- .title { margin-bottom: 16rpx; color: #172033; font-size: 32rpx; font-weight: 700; }
- .cell {
- padding: 10rpx 0;
- display: flex;
- color: #7f8896;
- font-size: 27rpx;
- line-height: 1.6;
- }
- .cell-label { flex-shrink: 0; }
- .cell-content { flex: 1; min-width: 0; color: #172033; }
- .link { color: #0b8cff; }
- .note {
- display: block;
- margin-top: 16rpx;
- padding-top: 16rpx;
- border-top: 2rpx solid #f1f5fa;
- color: #8a95a6;
- font-size: 23rpx;
- line-height: 1.5;
- }
-
- .bottom-bar {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
- background: #fff;
- display: flex;
- gap: 18rpx;
- box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08);
- }
- .bar-btn,
- .delete-btn,
- .main-btn {
- height: 82rpx;
- border-radius: 42rpx;
- font-size: 29rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .bar-btn { width: 136rpx; color: #0b8cff; background: #eef6ff; }
- .delete-btn { width: 136rpx; color: #f04438; background: #fff1f0; }
- .main-btn { flex: 1; color: #fff; background: #0b8cff; }
-
- /* 对比弹层:与动漫头像保持一致 */
- .compare-modal {
- position: fixed;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- z-index: 120;
- }
- .compare-backdrop {
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- background: rgba(15, 23, 42, 0.58);
- }
- .compare-panel {
- position: absolute;
- left: 36rpx;
- right: 36rpx;
- top: 50%;
- transform: translateY(-50%);
- padding: 24rpx;
- border-radius: 28rpx;
- background: #ffffff;
- }
- .compare-panel-head {
- margin-bottom: 20rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- color: #172033;
- font-size: 32rpx;
- font-weight: 800;
- }
- .close-btn {
- width: 56rpx;
- height: 56rpx;
- border-radius: 50%;
- color: #64748b;
- background: #f1f5f9;
- font-size: 42rpx;
- line-height: 50rpx;
- text-align: center;
- font-weight: 300;
- }
- .large-compare {
- position: relative;
- width: 100%;
- height: 900rpx;
- max-height: 70vh;
- border-radius: 22rpx;
- background: #dfe8f4;
- overflow: hidden;
- }
- .compare-img { width: 100%; height: 100%; }
- .compare-before {
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- overflow: hidden;
- }
- .large-compare .compare-before .compare-img { width: calc(100vw - 120rpx); }
- .compare-line {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 4rpx;
- margin-left: -2rpx;
- background: rgba(255, 255, 255, 0.94);
- box-shadow: 0 0 12rpx rgba(20, 37, 66, 0.22);
- }
- .compare-line.large { width: 6rpx; margin-left: -3rpx; }
- .compare-handle {
- position: absolute;
- left: 50%;
- top: 50%;
- width: 38rpx;
- height: 58rpx;
- margin-left: -19rpx;
- margin-top: -29rpx;
- border-radius: 999rpx;
- background: #ffffff;
- box-shadow: 0 8rpx 18rpx rgba(20, 37, 66, 0.22);
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 1rpx;
- color: #1f8cff;
- font-size: 24rpx;
- font-weight: 900;
- }
- .compare-handle.large {
- width: 54rpx;
- height: 78rpx;
- margin-left: -27rpx;
- margin-top: -39rpx;
- font-size: 32rpx;
- }
- .compare-tag {
- position: absolute;
- top: 12rpx;
- padding: 6rpx 12rpx;
- border-radius: 999rpx;
- color: #ffffff;
- background: rgba(15, 23, 42, 0.46);
- font-size: 20rpx;
- font-weight: 700;
- }
- .compare-tag.left { left: 12rpx; }
- .compare-tag.right { right: 12rpx; }
- .compare-guide {
- display: block;
- margin-top: 18rpx;
- color: #64748b;
- font-size: 24rpx;
- text-align: center;
- }
-
- @keyframes spin {
- to { transform: rotate(360deg); }
- }
- </style>
|