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

566 lines
24 KiB

  1. <template>
  2. <Teleport to="body">
  3. <Transition name="wd-modal">
  4. <div v-if="modelValue" class="fixed inset-0 z-[120] flex items-center justify-center overflow-hidden p-3 sm:p-4">
  5. <div class="absolute inset-0 bg-[var(--c-0f172a)]/55 backdrop-blur-sm" @click="close" />
  6. <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]">
  7. <!-- 头部 -->
  8. <div class="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--c-eef2f7)] px-5 py-4">
  9. <div class="min-w-0">
  10. <h3 class="truncate text-base font-extrabold text-[var(--c-172033)]">{{ headerTitle }}</h3>
  11. <div v-if="addTimeText" class="mt-0.5 text-xs text-[var(--c-8a97a8)]">{{ addTimeText }}</div>
  12. </div>
  13. <button
  14. 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)]"
  15. @click="close"
  16. >
  17. <UIcon name="i-lucide-x" class="size-5" />
  18. </button>
  19. </div>
  20. <!-- 内容 -->
  21. <div class="detail-scroll min-h-[220px] flex-1 overflow-y-auto overscroll-contain px-5 py-5">
  22. <!-- 加载中 -->
  23. <div v-if="loading" class="flex flex-col items-center justify-center gap-3 py-16">
  24. <span class="size-8 animate-spin rounded-full border-[3px] border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)]" />
  25. <span class="text-sm text-[var(--c-8a95a6)]">加载详情中...</span>
  26. </div>
  27. <!-- 出错 -->
  28. <div v-else-if="errorMsg" class="py-16 text-center">
  29. <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-10 text-[var(--c-f04438)]" />
  30. <div class="text-sm text-[var(--c-f04438)]">{{ errorMsg }}</div>
  31. <button
  32. class="mt-4 rounded-full bg-[var(--c-eef7ff)] px-5 py-2 text-xs font-bold text-[var(--c-0b8cff)]"
  33. @click="load"
  34. >
  35. 重试
  36. </button>
  37. </div>
  38. <template v-else>
  39. <!-- ============ AI 取名详情 ============ -->
  40. <template v-if="isName">
  41. <div v-if="detail.question_content" class="mb-4 rounded-2xl bg-[var(--c-f7f9fd)] p-4">
  42. <div class="mb-1 text-xs font-bold text-[var(--c-8a97a8)]">取名条件</div>
  43. <div class="whitespace-pre-wrap text-sm leading-relaxed text-[var(--c-4b5b70)]">{{ detail.question_content }}</div>
  44. </div>
  45. <div v-if="nameResult.summary" class="mb-4 rounded-2xl bg-[var(--c-eef7ff)] p-4 text-sm leading-relaxed text-[var(--c-3a5878)]">
  46. {{ nameResult.summary }}
  47. </div>
  48. <div v-if="nameResult.names.length" class="space-y-3">
  49. <div
  50. v-for="(item, idx) in nameResult.names"
  51. :key="idx"
  52. class="rounded-2xl border border-[var(--c-eef2f7)] p-4"
  53. >
  54. <div class="mb-2 flex items-center justify-between">
  55. <span class="text-2xl font-extrabold text-[var(--c-0b8cff)]">{{ item.name }}</span>
  56. <span v-if="item.score" class="text-base font-extrabold text-[var(--c-ff8a00)]">{{ item.score }}分</span>
  57. </div>
  58. <div v-if="item.pinyin" class="mb-3 text-xs text-[var(--c-8a97a8)]">{{ item.pinyin }}</div>
  59. <div class="space-y-2">
  60. <div v-for="f in NAME_FIELDS" :key="f.key">
  61. <div v-if="item[f.key]" class="flex items-start gap-2.5">
  62. <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)]">
  63. {{ f.label }}
  64. </span>
  65. <span class="pt-0.5 text-sm leading-relaxed text-[var(--c-172033)]">{{ item[f.key] }}</span>
  66. </div>
  67. </div>
  68. </div>
  69. </div>
  70. </div>
  71. <!-- 生成中 / 无结果 -->
  72. <div v-else class="py-14 text-center">
  73. <template v-if="isPending">
  74. <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)]" />
  75. <div class="text-sm font-bold text-[var(--c-172033)]">名字整理中</div>
  76. <div class="mt-1 text-xs text-[var(--c-8a95a6)]">稍后可再次打开查看</div>
  77. </template>
  78. <template v-else>
  79. <UIcon name="i-lucide-search-x" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" />
  80. <div class="text-sm text-[var(--c-8a95a6)]">暂无名字结果</div>
  81. </template>
  82. </div>
  83. </template>
  84. <!-- ============ 图片类详情(动漫头像 / 证件照等) ============ -->
  85. <template v-else>
  86. <div
  87. v-if="imageUrl"
  88. class="grid min-h-0 gap-3 sm:gap-4"
  89. :class="isPhoto ? 'mx-auto w-full max-w-md grid-cols-1' : 'grid-cols-2'"
  90. >
  91. <!-- 原图 -->
  92. <div v-if="originalUrl && !isPhoto" class="min-w-0">
  93. <div class="mb-2 text-xs font-bold text-[var(--c-8a97a8)]">原图</div>
  94. <div
  95. 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)]"
  96. :class="{ 'aspect-square': !isPhoto }"
  97. :style="imageAspectStyle"
  98. >
  99. <img :src="originalUrl" class="size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" alt="原图">
  100. </div>
  101. </div>
  102. <!-- 结果图 -->
  103. <div
  104. class="min-w-0"
  105. :class="{ 'col-span-2 mx-auto w-full max-w-md': !originalUrl && !isPhoto }"
  106. >
  107. <div class="mb-2 text-xs font-bold text-[var(--c-8a97a8)]">{{ resultLabel }}</div>
  108. <div
  109. 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)]"
  110. :class="{ 'aspect-square': !isPhoto }"
  111. :style="imageAspectStyle"
  112. >
  113. <img :src="imageUrl" class="size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" :alt="resultLabel">
  114. </div>
  115. </div>
  116. </div>
  117. <!-- 形象照一单多张:缩略图切换 -->
  118. <div v-if="imageUrl && portraitUrls.length > 1" class="mt-4 flex flex-wrap gap-2.5">
  119. <button
  120. v-for="(url, index) in portraitUrls"
  121. :key="url"
  122. type="button"
  123. class="size-14 cursor-pointer overflow-hidden rounded-xl border-2 transition-colors"
  124. :class="activePortraitIndex === index ? 'border-[var(--c-0b8cff)]' : 'border-transparent hover:border-[var(--c-e5edf7)]'"
  125. :aria-label="`查看第 ${index + 1} 张`"
  126. :aria-pressed="activePortraitIndex === index"
  127. @click="activePortraitIndex = index"
  128. >
  129. <img :src="url" class="size-full object-cover" :alt="`第 ${index + 1} 张`">
  130. </button>
  131. </div>
  132. <div v-if="!imageUrl" class="py-14 text-center">
  133. <template v-if="isPending">
  134. <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)]" />
  135. <div class="text-sm font-bold text-[var(--c-172033)]">正在创作</div>
  136. <div class="mt-1 text-xs text-[var(--c-8a95a6)]">完成后可在此查看结果</div>
  137. </template>
  138. <template v-else-if="isFailed">
  139. <UIcon name="i-lucide-circle-alert" class="mx-auto mb-3 size-10 text-[var(--c-f04438)]" />
  140. <div class="text-sm text-[var(--c-f04438)]">{{ detail.err_msg || '生成失败' }}</div>
  141. </template>
  142. <template v-else>
  143. <UIcon name="i-lucide-image-off" class="mx-auto mb-3 size-10 text-[var(--c-a0aabb)]" />
  144. <div class="text-sm text-[var(--c-8a95a6)]">暂无图片</div>
  145. </template>
  146. </div>
  147. </template>
  148. </template>
  149. </div>
  150. <!-- 底部操作 -->
  151. <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">
  152. <button
  153. v-if="canDelete"
  154. type="button"
  155. :disabled="deleting"
  156. class="mr-auto inline-flex items-center gap-1.5 rounded-full border border-[var(--c-f04438)]/30 px-5 py-2.5 text-sm font-bold text-[var(--c-f04438)] transition-colors hover:bg-[var(--c-fff1f0)] disabled:cursor-not-allowed disabled:opacity-60"
  157. @click="deleteRecord"
  158. >
  159. <UIcon name="i-lucide-trash-2" class="size-4" />
  160. {{ deleting ? '删除中...' : '删除记录' }}
  161. </button>
  162. <button
  163. v-if="canCompare"
  164. type="button"
  165. 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"
  166. @click="openCompare"
  167. >
  168. <UIcon name="i-lucide-split-square-horizontal" class="size-4" />
  169. 对比效果
  170. </button>
  171. <button
  172. 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)]"
  173. @click="close"
  174. >
  175. 关闭
  176. </button>
  177. <button
  178. v-if="isName && nameResult.names.length"
  179. 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)]"
  180. @click="copyNames"
  181. >
  182. 复制结果
  183. </button>
  184. <a
  185. v-if="!isName && imageUrl"
  186. :href="imageUrl"
  187. target="_blank"
  188. rel="noopener"
  189. download
  190. 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)]"
  191. >
  192. {{ isPhoto ? '下载证件照' : '下载图片' }}
  193. </a>
  194. </div>
  195. </div>
  196. </div>
  197. </Transition>
  198. <Transition name="wd-modal">
  199. <div v-if="modelValue && compareOpen" class="fixed inset-0 z-[140] flex items-center justify-center overflow-hidden p-3 sm:p-4">
  200. <div class="absolute inset-0 bg-[var(--c-0f172a)]/70 backdrop-blur-sm" @click="closeCompare" />
  201. <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]">
  202. <div class="flex shrink-0 items-center justify-between border-b border-[var(--c-eef2f7)] px-5 py-4">
  203. <div>
  204. <h3 class="text-base font-extrabold text-[var(--c-172033)]">{{ compareTitle }}</h3>
  205. <p class="mt-0.5 text-xs text-[var(--c-8a97a8)]">左右拖动分割线查看生成前后变化</p>
  206. </div>
  207. <button
  208. type="button"
  209. aria-label="关闭对比"
  210. 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)]"
  211. @click="closeCompare"
  212. >
  213. <UIcon name="i-lucide-x" class="size-5" />
  214. </button>
  215. </div>
  216. <div class="min-h-0 flex-1 p-4 sm:p-5">
  217. <div
  218. ref="compareSurface"
  219. 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)]"
  220. :class="{ 'aspect-square': !isPhoto }"
  221. :style="imageAspectStyle"
  222. @pointerdown="startCompareDrag"
  223. >
  224. <img :src="imageUrl" class="pointer-events-none absolute inset-0 size-full" :class="isPhoto ? 'object-contain' : 'object-cover'" :alt="resultLabel">
  225. <img
  226. :src="originalUrl"
  227. class="pointer-events-none absolute inset-0 size-full"
  228. :class="isPhoto ? 'object-contain' : 'object-cover'"
  229. :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }"
  230. alt="原图"
  231. >
  232. <div
  233. 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)]"
  234. :style="{ left: `${comparePercent}%` }"
  235. >
  236. <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)]">
  237. <span>‹</span><span>›</span>
  238. </div>
  239. </div>
  240. <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>
  241. <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>
  242. </div>
  243. </div>
  244. </div>
  245. </div>
  246. </Transition>
  247. </Teleport>
  248. </template>
  249. <script setup>
  250. import dayjs from 'dayjs'
  251. import { parseNameResult, buildNameCopyText, NAME_FIELDS } from '~/utils/nameParse'
  252. const props = defineProps({
  253. modelValue: { type: Boolean, default: false },
  254. /** 作品项:需含 work_type 与 chat_open_id(或 id) */
  255. work: { type: Object, default: () => ({}) },
  256. })
  257. const emit = defineEmits(['update:modelValue', 'deleted'])
  258. const { post } = useApi()
  259. const toast = useToast()
  260. const loading = ref(false)
  261. const deleting = ref(false)
  262. const errorMsg = ref('')
  263. const detail = ref({})
  264. const compareOpen = ref(false)
  265. const comparePercent = ref(50)
  266. const compareSurface = ref(null)
  267. let compareDragging = false
  268. const isName = computed(() => props.work?.work_type === 'ainame')
  269. const isPhoto = computed(() => props.work?.work_type === 'photo')
  270. const isOldPhoto = computed(() => props.work?.work_type === 'oldphoto')
  271. const isPortrait = computed(() => props.work?.work_type === 'portrait')
  272. /**
  273. * 状态归一。
  274. * 老照片与形象照任务用的是自己的一套状态(10排队 20处理中 30成功 -10失败 -20审核不通过),
  275. * 这里换算成作品列表通用的 0生成中 / 1完成 / -1失败,避免详情加载完还一直显示「生成中」。
  276. */
  277. const statusValue = computed(() => {
  278. if ((isOldPhoto.value || isPortrait.value) && detail.value.status !== undefined) {
  279. const status = Number(detail.value.status)
  280. if (status === 30) return 1
  281. return status < 0 ? -1 : 0
  282. }
  283. return Number(detail.value.is_suc ?? props.work?.status ?? 0)
  284. })
  285. const isPending = computed(() => statusValue.value === 0)
  286. const isFailed = computed(() => statusValue.value < 0)
  287. const resultLabel = computed(() => {
  288. if (isPhoto.value) return '证件照'
  289. if (isOldPhoto.value) return '修复结果'
  290. return '生成结果'
  291. })
  292. const compareTitle = computed(() => {
  293. if (isPhoto.value) return '证件照效果对比'
  294. if (isOldPhoto.value) return '老照片修复对比'
  295. if (isPortrait.value) return '形象照效果对比'
  296. return '动漫头像效果对比'
  297. })
  298. /** 形象照一单可能有多张图,详情里支持切换,切换后对比与下载都跟着走 */
  299. const portraitUrls = computed(() => {
  300. if (!isPortrait.value) return []
  301. const urls = detail.value.result_urls || props.work?.pic_list || []
  302. return Array.isArray(urls) ? urls.filter(Boolean) : []
  303. })
  304. const activePortraitIndex = ref(0)
  305. const activePortraitUrl = computed(
  306. () => portraitUrls.value[activePortraitIndex.value] || portraitUrls.value[0] || '',
  307. )
  308. const imageAspectStyle = computed(() => {
  309. if (!isPhoto.value) return {}
  310. const width = Number(detail.value.width || props.work?.width || 0)
  311. const height = Number(detail.value.height || props.work?.height || 0)
  312. return width > 0 && height > 0
  313. ? { aspectRatio: `${width} / ${height}` }
  314. : { aspectRatio: '5 / 7' }
  315. })
  316. const headerTitle = computed(() => {
  317. const map = {
  318. ainame: 'AI 取名详情',
  319. anime: '动漫头像详情',
  320. photo: '证件照详情',
  321. oldphoto: '老照片详情',
  322. portrait: '形象照详情',
  323. image: '作品详情',
  324. }
  325. return map[props.work?.work_type] || '作品详情'
  326. })
  327. const addTimeText = computed(() => {
  328. const t = detail.value.add_time || props.work?.add_time
  329. return t ? dayjs(Number(t) * 1000).format('YYYY-MM-DD HH:mm') : ''
  330. })
  331. const nameResult = computed(() => {
  332. if (!isName.value) return { summary: '', names: [] }
  333. // 优先用后端结构化的 detail,其次解析 content 文本
  334. const d = detail.value.detail
  335. if (d && Array.isArray(d.names) && d.names.length) {
  336. return { summary: d.summary || '', names: d.names }
  337. }
  338. return parseNameResult(detail.value.content || '')
  339. })
  340. const imageUrl = computed(() =>
  341. (isPortrait.value ? activePortraitUrl.value : '')
  342. || detail.value.answer
  343. || detail.value.result_url
  344. || detail.value.url
  345. || (!isName.value ? detail.value.content : '')
  346. || props.work?.cover
  347. || (!isName.value ? props.work?.content : '')
  348. || '')
  349. const originalUrl = computed(() => {
  350. const imgs = detail.value.original
  351. || detail.value.original_url
  352. || detail.value.original_imgs
  353. || detail.value.imgs
  354. || detail.value.img
  355. || props.work?.original
  356. || props.work?.original_url
  357. || props.work?.imgs
  358. || props.work?.img
  359. || ''
  360. if (Array.isArray(imgs)) return imgs[0] || ''
  361. return typeof imgs === 'string' && imgs ? imgs.split(',')[0] : ''
  362. })
  363. const canCompare = computed(() =>
  364. !isName.value
  365. && !isPhoto.value
  366. && Boolean(originalUrl.value && imageUrl.value)
  367. && !isPending.value
  368. && !isFailed.value)
  369. const canDelete = computed(() => Boolean(resolveWorkId()))
  370. function resolveWorkId() {
  371. return props.work?.photo_open_id || props.work?.chat_open_id || props.work?.id || ''
  372. }
  373. async function load() {
  374. const id = resolveWorkId()
  375. if (!id) {
  376. errorMsg.value = '缺少作品标识'
  377. return
  378. }
  379. loading.value = true
  380. errorMsg.value = ''
  381. try {
  382. let path = isName.value ? '/ainame/getdetail' : '/anime/getdetail'
  383. let body = { chat_open_id: id }
  384. if (isPhoto.value) {
  385. path = '/idphoto/getdetail'
  386. body = { photo_open_id: id }
  387. }
  388. if (isOldPhoto.value) {
  389. path = '/oldphoto/getdetail'
  390. body = { photo_open_id: id }
  391. }
  392. if (isPortrait.value) {
  393. path = '/portrait/getdetail'
  394. body = { photo_open_id: id }
  395. }
  396. const res = await post(path, body)
  397. detail.value = res.data || {}
  398. // 还在处理中就继续拉:老照片是异步任务,弹窗里也要能等到结果
  399. schedulePendingReload()
  400. } catch (e) {
  401. errorMsg.value = e.message || '详情加载失败'
  402. } finally {
  403. loading.value = false
  404. }
  405. }
  406. function getDeleteRequest() {
  407. const id = resolveWorkId()
  408. if (!id) return null
  409. if (isPhoto.value) return { path: '/idphoto/del', body: { photo_open_id: id } }
  410. if (isOldPhoto.value) return { path: '/oldphoto/del', body: { photo_open_id: id } }
  411. if (isPortrait.value) return { path: '/portrait/del', body: { photo_open_id: id } }
  412. if (isName.value) return { path: '/ainame/del', body: { chat_open_id: id } }
  413. if (props.work?.work_type === 'anime') return { path: '/anime/del', body: { chat_open_id: id } }
  414. return { path: '/chat/del', body: { chat_open_id: id } }
  415. }
  416. async function deleteRecord() {
  417. const req = getDeleteRequest()
  418. if (!req || deleting.value) return
  419. if (!window.confirm('确认删除该作品记录?删除后将不再展示。')) return
  420. deleting.value = true
  421. try {
  422. await post(req.path, req.body)
  423. toast.add({ title: '删除成功', color: 'success' })
  424. emit('deleted', props.work)
  425. close()
  426. } catch (e) {
  427. toast.add({ title: e.message || '删除失败', color: 'error' })
  428. } finally {
  429. deleting.value = false
  430. }
  431. }
  432. let pendingTimer = null
  433. function schedulePendingReload() {
  434. clearPendingReload()
  435. if (!props.modelValue || !isPending.value) return
  436. pendingTimer = setTimeout(() => load(), 3000)
  437. }
  438. function clearPendingReload() {
  439. if (pendingTimer) clearTimeout(pendingTimer)
  440. pendingTimer = null
  441. }
  442. onUnmounted(() => clearPendingReload())
  443. function close() {
  444. closeCompare()
  445. clearPendingReload()
  446. emit('update:modelValue', false)
  447. }
  448. function openCompare() {
  449. if (!canCompare.value) {
  450. toast.add({ title: '当前作品缺少原图或结果图', color: 'error' })
  451. return
  452. }
  453. comparePercent.value = 50
  454. compareOpen.value = true
  455. }
  456. function closeCompare() {
  457. compareOpen.value = false
  458. stopCompareDrag()
  459. }
  460. function startCompareDrag(event) {
  461. if (!compareSurface.value) return
  462. compareDragging = true
  463. updateComparePercent(event)
  464. window.addEventListener('pointermove', moveCompareDrag)
  465. window.addEventListener('pointerup', stopCompareDrag)
  466. window.addEventListener('pointercancel', stopCompareDrag)
  467. }
  468. function moveCompareDrag(event) {
  469. if (!compareDragging) return
  470. if (event.cancelable) event.preventDefault()
  471. updateComparePercent(event)
  472. }
  473. function stopCompareDrag() {
  474. compareDragging = false
  475. window.removeEventListener('pointermove', moveCompareDrag)
  476. window.removeEventListener('pointerup', stopCompareDrag)
  477. window.removeEventListener('pointercancel', stopCompareDrag)
  478. }
  479. function updateComparePercent(event) {
  480. if (!compareSurface.value) return
  481. const rect = compareSurface.value.getBoundingClientRect()
  482. const percent = ((Number(event.clientX) - rect.left) / rect.width) * 100
  483. comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1))))
  484. }
  485. function copyNames() {
  486. const text = buildNameCopyText(nameResult.value)
  487. if (!text) {
  488. toast.add({ title: '暂无可复制内容', color: 'error' })
  489. return
  490. }
  491. navigator.clipboard.writeText(text)
  492. .then(() => toast.add({ title: '复制成功', color: 'success' }))
  493. .catch(() => toast.add({ title: '复制失败', color: 'error' }))
  494. }
  495. // 打开时加载详情
  496. watch(() => props.modelValue, (open) => {
  497. if (open) {
  498. detail.value = {}
  499. compareOpen.value = false
  500. activePortraitIndex.value = 0
  501. load()
  502. } else {
  503. closeCompare()
  504. clearPendingReload()
  505. }
  506. })
  507. onBeforeUnmount(stopCompareDrag)
  508. </script>
  509. <style scoped>
  510. .wd-modal-enter-active,
  511. .wd-modal-leave-active { transition: opacity 0.2s ease; }
  512. .wd-modal-enter-from,
  513. .wd-modal-leave-to { opacity: 0; }
  514. .detail-scroll {
  515. scrollbar-width: none;
  516. -ms-overflow-style: none;
  517. }
  518. .detail-scroll::-webkit-scrollbar { display: none; }
  519. @media (prefers-reduced-motion: reduce) {
  520. .wd-modal-enter-active,
  521. .wd-modal-leave-active { transition: none; }
  522. }
  523. </style>