|
- <template>
- <Teleport to="body">
- <Transition name="wd-modal">
- <div v-if="modelValue" class="fixed inset-0 z-[120] flex items-center justify-center overflow-hidden p-3 sm:p-4">
- <div class="absolute inset-0 bg-[var(--c-0f172a)]/55 backdrop-blur-sm" @click="close" />
-
- <div class="relative flex max-h-[calc(100dvh-1.5rem)] w-full max-w-3xl flex-col overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-2xl sm:max-h-[88dvh]">
- <!-- 头部 -->
- <div class="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--c-eef2f7)] px-5 py-4">
- <div class="min-w-0">
- <h3 class="truncate text-base font-extrabold text-[var(--c-172033)]">{{ headerTitle }}</h3>
- <div v-if="addTimeText" class="mt-0.5 text-xs text-[var(--c-8a97a8)]">{{ addTimeText }}</div>
- </div>
- <button
- class="flex size-8 shrink-0 items-center justify-center rounded-full text-[var(--c-a0aabb)] transition-colors hover:bg-[var(--c-f1f5fa)] hover:text-[var(--c-172033)]"
- @click="close"
- >
- <UIcon name="i-lucide-x" class="size-5" />
- </button>
- </div>
-
- <!-- 内容 -->
- <div class="detail-scroll min-h-[220px] flex-1 overflow-y-auto overscroll-contain px-5 py-5">
- <!-- 加载中 -->
- <div v-if="loading" class="flex flex-col items-center justify-center gap-3 py-16">
- <span class="size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" />
- <span class="text-sm text-[var(--c-8a95a6)]">加载详情中...</span>
- </div>
-
- <!-- 出错 -->
- <div v-else-if="errorMsg" class="py-16 text-center">
- <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-10 text-[var(--c-f04438)]" />
- <div class="text-sm text-[var(--c-f04438)]">{{ errorMsg }}</div>
- <button
- class="mt-4 rounded-full bg-[var(--c-eef7ff)] px-5 py-2 text-xs font-bold text-[var(--c-0b8cff)]"
- @click="load"
- >
- 重试
- </button>
- </div>
-
- <template v-else>
- <!-- ============ AI 取名详情 ============ -->
- <template v-if="isName">
- <div v-if="detail.question_content" class="mb-4 rounded-2xl bg-[var(--c-f7f9fd)] p-4">
- <div class="mb-1 text-xs font-bold text-[var(--c-8a97a8)]">取名条件</div>
- <div class="whitespace-pre-wrap text-sm leading-relaxed text-[var(--c-4b5b70)]">{{ detail.question_content }}</div>
- </div>
-
- <div v-if="nameResult.summary" class="mb-4 rounded-2xl bg-[var(--c-eef7ff)] p-4 text-sm leading-relaxed text-[var(--c-3a5878)]">
- {{ nameResult.summary }}
- </div>
-
- <div v-if="nameResult.names.length" class="space-y-3">
- <div
- v-for="(item, idx) in nameResult.names"
- :key="idx"
- class="rounded-2xl border border-[var(--c-eef2f7)] p-4"
- >
- <div class="mb-2 flex items-center justify-between">
- <span class="text-2xl font-extrabold text-[var(--c-0b8cff)]">{{ item.name }}</span>
- <span v-if="item.score" class="text-base font-extrabold text-[var(--c-ff8a00)]">{{ item.score }}分</span>
- </div>
- <div v-if="item.pinyin" class="mb-3 text-xs text-[var(--c-8a97a8)]">{{ item.pinyin }}</div>
- <div class="space-y-2">
- <div v-for="f in NAME_FIELDS" :key="f.key">
- <div v-if="item[f.key]" class="flex items-start gap-2.5">
- <span class="inline-flex h-[24px] shrink-0 items-center rounded-full bg-[var(--c-eef6ff)] px-2 text-[11px] font-bold text-[var(--c-0b8cff)]">
- {{ f.label }}
- </span>
- <span class="pt-0.5 text-sm leading-relaxed text-[var(--c-172033)]">{{ item[f.key] }}</span>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- 生成中 / 无结果 -->
- <div v-else class="py-14 text-center">
- <template v-if="isPending">
- <span class="mx-auto mb-3 block size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" />
- <div class="text-sm font-bold text-[var(--c-172033)]">名字整理中</div>
- <div class="mt-1 text-xs text-[var(--c-8a95a6)]">稍后可再次打开查看</div>
- </template>
- <template v-else>
- <UIcon name="i-lucide-search-x" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" />
- <div class="text-sm text-[var(--c-8a95a6)]">暂无名字结果</div>
- </template>
- </div>
- </template>
-
- <!-- ============ 图片类详情(动漫头像 / 证件照等) ============ -->
- <template v-else>
- <div
- v-if="imageUrl"
- class="grid min-h-0 gap-3 sm:gap-4"
- :class="isPhoto ? 'mx-auto w-full max-w-md grid-cols-1' : 'grid-cols-2'"
- >
- <!-- 原图 -->
- <div v-if="originalUrl && !isPhoto" class="min-w-0">
- <div class="mb-2 text-xs font-bold text-[var(--c-8a97a8)]">原图</div>
- <div
- class="flex max-h-[calc(100dvh-13rem)] items-center justify-center overflow-hidden rounded-2xl bg-[var(--c-f7f9fd)] sm:max-h-[calc(88dvh-13rem)]"
- :class="{ 'aspect-square': !isPhoto }"
- :style="imageAspectStyle"
- >
- <img :src="originalUrl" class="size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" alt="原图">
- </div>
- </div>
- <!-- 结果图 -->
- <div
- class="min-w-0"
- :class="{ 'col-span-2 mx-auto w-full max-w-md': !originalUrl && !isPhoto }"
- >
- <div class="mb-2 text-xs font-bold text-[var(--c-8a97a8)]">{{ resultLabel }}</div>
- <div
- class="flex max-h-[calc(100dvh-13rem)] items-center justify-center overflow-hidden rounded-2xl border-2 border-[var(--c-0b8cff)] bg-[var(--c-f7f9fd)] sm:max-h-[calc(88dvh-13rem)]"
- :class="{ 'aspect-square': !isPhoto }"
- :style="imageAspectStyle"
- >
- <img :src="imageUrl" class="size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" :alt="resultLabel">
- </div>
- </div>
- </div>
-
- <!-- 形象照一单多张:缩略图切换 -->
- <div v-if="imageUrl && portraitUrls.length > 1" class="mt-4 flex flex-wrap gap-2.5">
- <button
- v-for="(url, index) in portraitUrls"
- :key="url"
- type="button"
- class="size-14 cursor-pointer overflow-hidden rounded-xl border-2 transition-colors"
- :class="activePortraitIndex === index ? 'border-[var(--c-0b8cff)]' : 'border-transparent hover:border-[var(--c-e5edf7)]'"
- :aria-label="`查看第 ${index + 1} 张`"
- :aria-pressed="activePortraitIndex === index"
- @click="activePortraitIndex = index"
- >
- <img :src="url" class="size-full object-cover" :alt="`第 ${index + 1} 张`">
- </button>
- </div>
-
- <div v-if="!imageUrl" class="py-14 text-center">
- <template v-if="isPending">
- <span class="mx-auto mb-3 block size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" />
- <div class="text-sm font-bold text-[var(--c-172033)]">正在创作</div>
- <div class="mt-1 text-xs text-[var(--c-8a95a6)]">完成后可在此查看结果</div>
- </template>
- <template v-else-if="isFailed">
- <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-10 text-[var(--c-f04438)]" />
- <div class="text-sm text-[var(--c-f04438)]">{{ detail.err_msg || '生成失败' }}</div>
- </template>
- <template v-else>
- <UIcon name="i-lucide-image-off" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" />
- <div class="text-sm text-[var(--c-8a95a6)]">暂无图片</div>
- </template>
- </div>
- </template>
- </template>
- </div>
-
- <!-- 底部操作 -->
- <div v-if="!loading && !errorMsg" class="flex shrink-0 flex-wrap justify-end gap-3 border-t border-[var(--c-eef2f7)] px-5 py-4">
- <button
- v-if="canCompare"
- type="button"
- class="inline-flex items-center gap-1.5 rounded-full border border-[var(--c-0b8cff)] px-5 py-2.5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-eef7ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2"
- @click="openCompare"
- >
- <UIcon name="i-lucide-split-square-horizontal" class="size-4" />
- 对比效果
- </button>
- <button
- class="rounded-full border border-[var(--c-e5edf7)] px-5 py-2.5 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)]"
- @click="close"
- >
- 关闭
- </button>
- <button
- v-if="isName && nameResult.names.length"
- class="rounded-full bg-[var(--c-0b8cff)] px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-[var(--c-0a7ce0)]"
- @click="copyNames"
- >
- 复制结果
- </button>
- <a
- v-if="!isName && imageUrl"
- :href="imageUrl"
- target="_blank"
- rel="noopener"
- download
- class="rounded-full bg-[var(--c-0b8cff)] px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-[var(--c-0a7ce0)]"
- >
- {{ isPhoto ? '下载证件照' : '下载图片' }}
- </a>
- </div>
- </div>
- </div>
- </Transition>
-
- <Transition name="wd-modal">
- <div v-if="modelValue && compareOpen" class="fixed inset-0 z-[140] flex items-center justify-center overflow-hidden p-3 sm:p-4">
- <div class="absolute inset-0 bg-[var(--c-0f172a)]/70 backdrop-blur-sm" @click="closeCompare" />
- <div class="relative flex max-h-[calc(100dvh-1.5rem)] w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-[var(--c-ffffff)] shadow-2xl sm:max-h-[90dvh]">
- <div class="flex shrink-0 items-center justify-between border-b border-[var(--c-eef2f7)] px-5 py-4">
- <div>
- <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTitle }}</h3>
- <p class="mt-0.5 text-xs text-[var(--c-8a97a8)]">左右拖动分割线查看生成前后变化</p>
- </div>
- <button
- type="button"
- aria-label="关闭对比"
- class="flex size-8 shrink-0 items-center justify-center rounded-full text-[var(--c-8a97a8)] transition-colors hover:bg-[var(--c-f1f5fa)] hover:text-[var(--c-172033)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- @click="closeCompare"
- >
- <UIcon name="i-lucide-x" class="size-5" />
- </button>
- </div>
-
- <div class="min-h-0 flex-1 p-4 sm:p-5">
- <div
- ref="compareSurface"
- class="relative mx-auto max-h-[calc(100dvh-8.5rem)] w-full max-w-[min(100%,640px)] touch-none select-none overflow-hidden rounded-2xl bg-[var(--c-e8eef6)] cursor-ew-resize sm:max-h-[calc(90dvh-8.5rem)]"
- :class="{ 'aspect-square': !isPhoto }"
- :style="imageAspectStyle"
- @pointerdown="startCompareDrag"
- >
- <img :src="imageUrl" class="pointer-events-none absolute inset-0 size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" :alt="resultLabel">
- <img
- :src="originalUrl"
- class="pointer-events-none absolute inset-0 size-full"
- :class="isPhoto ? 'object-contain' : 'object-cover'"
- :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }"
- alt="原图"
- >
- <div
- class="pointer-events-none absolute bottom-0 top-0 w-1 -ml-px bg-[var(--c-ffffff)] shadow-[0_0_14px_var(--sh-15-23-42-280)]"
- :style="{ left: `${comparePercent}%` }"
- >
- <div class="absolute left-1/2 top-1/2 flex h-14 w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-[var(--c-ffffff)] text-lg font-extrabold text-[var(--c-0b8cff)] shadow-[0_8px_22px_var(--sh-15-23-42-240)]">
- <span>‹</span><span>›</span>
- </div>
- </div>
- <span class="pointer-events-none absolute left-3 top-3 rounded-full bg-[var(--c-0f172a)]/55 px-2.5 py-1 text-[11px] font-bold text-white">原图</span>
- <span class="pointer-events-none absolute right-3 top-3 rounded-full bg-[var(--c-0f172a)]/55 px-2.5 py-1 text-[11px] font-bold text-white">{{ resultLabel }}</span>
- </div>
- </div>
- </div>
- </div>
- </Transition>
- </Teleport>
- </template>
-
- <script setup>
- import dayjs from 'dayjs'
- import { parseNameResult, buildNameCopyText, NAME_FIELDS } from '~/utils/nameParse'
-
- const props = defineProps({
- modelValue: { type: Boolean, default: false },
- /** 作品项:需含 work_type 与 chat_open_id(或 id) */
- work: { type: Object, default: () => ({}) },
- })
- const emit = defineEmits(['update:modelValue'])
-
- const { post } = useApi()
- const toast = useToast()
-
- const loading = ref(false)
- const errorMsg = ref('')
- const detail = ref({})
- const compareOpen = ref(false)
- const comparePercent = ref(50)
- const compareSurface = ref(null)
- let compareDragging = false
-
- const isName = computed(() => props.work?.work_type === 'ainame')
- const isPhoto = computed(() => props.work?.work_type === 'photo')
- const isOldPhoto = computed(() => props.work?.work_type === 'oldphoto')
- const isPortrait = computed(() => props.work?.work_type === 'portrait')
-
- /**
- * 状态归一。
- * 老照片与形象照任务用的是自己的一套状态(10排队 20处理中 30成功 -10失败 -20审核不通过),
- * 这里换算成作品列表通用的 0生成中 / 1完成 / -1失败,避免详情加载完还一直显示「生成中」。
- */
- const statusValue = computed(() => {
- if ((isOldPhoto.value || isPortrait.value) && detail.value.status !== undefined) {
- const status = Number(detail.value.status)
- if (status === 30) return 1
- return status < 0 ? -1 : 0
- }
- return Number(detail.value.is_suc ?? props.work?.status ?? 0)
- })
- const isPending = computed(() => statusValue.value === 0)
- const isFailed = computed(() => statusValue.value < 0)
- const resultLabel = computed(() => {
- if (isPhoto.value) return '证件照'
- if (isOldPhoto.value) return '修复结果'
- return '生成结果'
- })
- const compareTitle = computed(() => {
- if (isPhoto.value) return '证件照效果对比'
- if (isOldPhoto.value) return '老照片修复对比'
- if (isPortrait.value) return '形象照效果对比'
- return '动漫头像效果对比'
- })
-
- /** 形象照一单可能有多张图,详情里支持切换,切换后对比与下载都跟着走 */
- const portraitUrls = computed(() => {
- if (!isPortrait.value) return []
- const urls = detail.value.result_urls || props.work?.pic_list || []
- return Array.isArray(urls) ? urls.filter(Boolean) : []
- })
- const activePortraitIndex = ref(0)
- const activePortraitUrl = computed(
- () => portraitUrls.value[activePortraitIndex.value] || portraitUrls.value[0] || '',
- )
- const imageAspectStyle = computed(() => {
- if (!isPhoto.value) return {}
- const width = Number(detail.value.width || props.work?.width || 0)
- const height = Number(detail.value.height || props.work?.height || 0)
- return width > 0 && height > 0
- ? { aspectRatio: `${width} / ${height}` }
- : { aspectRatio: '5 / 7' }
- })
-
- const headerTitle = computed(() => {
- const map = {
- ainame: 'AI 取名详情',
- anime: '动漫头像详情',
- photo: '证件照详情',
- oldphoto: '老照片详情',
- portrait: '形象照详情',
- image: '作品详情',
- }
- return map[props.work?.work_type] || '作品详情'
- })
-
- const addTimeText = computed(() => {
- const t = detail.value.add_time || props.work?.add_time
- return t ? dayjs(Number(t) * 1000).format('YYYY-MM-DD HH:mm') : ''
- })
-
- const nameResult = computed(() => {
- if (!isName.value) return { summary: '', names: [] }
- // 优先用后端结构化的 detail,其次解析 content 文本
- const d = detail.value.detail
- if (d && Array.isArray(d.names) && d.names.length) {
- return { summary: d.summary || '', names: d.names }
- }
- return parseNameResult(detail.value.content || '')
- })
-
- const imageUrl = computed(() =>
- (isPortrait.value ? activePortraitUrl.value : '')
- || detail.value.answer
- || detail.value.result_url
- || detail.value.url
- || (!isName.value ? detail.value.content : '')
- || props.work?.cover
- || (!isName.value ? props.work?.content : '')
- || '')
- const originalUrl = computed(() => {
- const imgs = detail.value.original
- || detail.value.original_url
- || detail.value.original_imgs
- || detail.value.imgs
- || detail.value.img
- || props.work?.original
- || props.work?.original_url
- || props.work?.imgs
- || props.work?.img
- || ''
- if (Array.isArray(imgs)) return imgs[0] || ''
- return typeof imgs === 'string' && imgs ? imgs.split(',')[0] : ''
- })
- const canCompare = computed(() =>
- !isName.value
- && !isPhoto.value
- && Boolean(originalUrl.value && imageUrl.value)
- && !isPending.value
- && !isFailed.value)
-
- async function load() {
- const id = props.work?.photo_open_id || props.work?.chat_open_id || props.work?.id
- if (!id) {
- errorMsg.value = '缺少作品标识'
- return
- }
- loading.value = true
- errorMsg.value = ''
- try {
- let path = isName.value ? '/ainame/getdetail' : '/anime/getdetail'
- let body = { chat_open_id: id }
- if (isPhoto.value) {
- path = '/idphoto/getdetail'
- body = { photo_open_id: id }
- }
- if (isOldPhoto.value) {
- path = '/oldphoto/getdetail'
- body = { photo_open_id: id }
- }
- if (isPortrait.value) {
- path = '/portrait/getdetail'
- body = { photo_open_id: id }
- }
- const res = await post(path, body)
- detail.value = res.data || {}
- // 还在处理中就继续拉:老照片是异步任务,弹窗里也要能等到结果
- schedulePendingReload()
- } catch (e) {
- errorMsg.value = e.message || '详情加载失败'
- } finally {
- loading.value = false
- }
- }
-
- let pendingTimer = null
- function schedulePendingReload() {
- clearPendingReload()
- if (!props.modelValue || !isPending.value) return
- pendingTimer = setTimeout(() => load(), 3000)
- }
- function clearPendingReload() {
- if (pendingTimer) clearTimeout(pendingTimer)
- pendingTimer = null
- }
- onUnmounted(() => clearPendingReload())
-
- function close() {
- closeCompare()
- clearPendingReload()
- emit('update:modelValue', false)
- }
-
- function openCompare() {
- if (!canCompare.value) {
- toast.add({ title: '当前作品缺少原图或结果图', color: 'error' })
- return
- }
- comparePercent.value = 50
- compareOpen.value = true
- }
-
- function closeCompare() {
- compareOpen.value = false
- stopCompareDrag()
- }
-
- function startCompareDrag(event) {
- if (!compareSurface.value) return
- compareDragging = true
- updateComparePercent(event)
- window.addEventListener('pointermove', moveCompareDrag)
- window.addEventListener('pointerup', stopCompareDrag)
- window.addEventListener('pointercancel', stopCompareDrag)
- }
-
- function moveCompareDrag(event) {
- if (!compareDragging) return
- if (event.cancelable) event.preventDefault()
- updateComparePercent(event)
- }
-
- function stopCompareDrag() {
- compareDragging = false
- window.removeEventListener('pointermove', moveCompareDrag)
- window.removeEventListener('pointerup', stopCompareDrag)
- window.removeEventListener('pointercancel', stopCompareDrag)
- }
-
- function updateComparePercent(event) {
- if (!compareSurface.value) return
- const rect = compareSurface.value.getBoundingClientRect()
- const percent = ((Number(event.clientX) - rect.left) / rect.width) * 100
- comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1))))
- }
-
- function copyNames() {
- const text = buildNameCopyText(nameResult.value)
- if (!text) {
- toast.add({ title: '暂无可复制内容', color: 'error' })
- return
- }
- navigator.clipboard.writeText(text)
- .then(() => toast.add({ title: '复制成功', color: 'success' }))
- .catch(() => toast.add({ title: '复制失败', color: 'error' }))
- }
-
- // 打开时加载详情
- watch(() => props.modelValue, (open) => {
- if (open) {
- detail.value = {}
- compareOpen.value = false
- activePortraitIndex.value = 0
- load()
- } else {
- closeCompare()
- clearPendingReload()
- }
- })
-
- onBeforeUnmount(stopCompareDrag)
- </script>
-
- <style scoped>
- .wd-modal-enter-active,
- .wd-modal-leave-active { transition: opacity 0.2s ease; }
- .wd-modal-enter-from,
- .wd-modal-leave-to { opacity: 0; }
-
- .detail-scroll {
- scrollbar-width: none;
- -ms-overflow-style: none;
- }
- .detail-scroll::-webkit-scrollbar { display: none; }
-
- @media (prefers-reduced-motion: reduce) {
- .wd-modal-enter-active,
- .wd-modal-leave-active { transition: none; }
- }
- </style>
|