gongxiangp2/components/work-review/WorkReviewImageGallery.tsx

273 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
// LEGACY_READ_ONLY: 旧域名仅用于展示历史审核素材。
import Image from 'next/image'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { WorkReviewImageGroup } from '@/types/work-review'
import { WORK_REVIEW_IMAGE_LABELS } from '@/types/work-review'
import {
WorkReviewAnnotationMarker,
type WorkReviewDisplayAnnotation,
} from './WorkReviewAnnotationMarker'
const PROXY_HOSTS = new Set(['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'])
function normalizeImageUrl(value: string) {
const url = String(value || '').trim()
if (!url || !/^https?:\/\//i.test(url)) return url
try {
const parsed = new URL(url)
return PROXY_HOSTS.has(parsed.hostname.toLowerCase())
? `/api/proxy-media?url=${encodeURIComponent(url)}`
: url
} catch {
return url
}
}
function normalizeGroups(groups: WorkReviewImageGroup[], imageUrls: string[]): WorkReviewImageGroup[] {
if (groups.length > 0) {
return groups.map((group, index) => ({
...group,
sourceName: group.sourceName || `作品图片 ${index + 1}`,
previewUrl: normalizeImageUrl(group.previewUrl || group.slices?.[0]?.url || ''),
slices: (group.slices || []).map((slice) => ({
...slice,
url: normalizeImageUrl(slice.url),
})),
})).filter((group) => Boolean(group.previewUrl))
}
return imageUrls.map((url, index) => ({
sourceName: `作品图片 ${index + 1}`,
previewUrl: normalizeImageUrl(url),
previewWidth: 1200,
previewHeight: 900,
width: 1200,
height: 900,
slices: [{ url: normalizeImageUrl(url), width: 1200, height: 900 }],
}))
}
export function WorkReviewImageGallery({
groups,
imageUrls = [],
compact = false,
annotationsByImageIndex = [],
}: {
groups: WorkReviewImageGroup[]
imageUrls?: string[]
compact?: boolean
annotationsByImageIndex?: WorkReviewDisplayAnnotation[][]
}) {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const displayGroups = useMemo(() => normalizeGroups(groups || [], imageUrls || []), [groups, imageUrls])
if (displayGroups.length === 0) {
return (
<div className="rounded-lg border border-dashed border-[#cfd7e3] bg-[#fafbfc] px-4 py-8 text-center text-[12px] font-semibold text-[#7b8494]">
暂无作品图片
</div>
)
}
return (
<>
<div className={`grid gap-2 ${compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2 md:grid-cols-3'}`}>
{displayGroups.map((group, index) => (
<button
key={group.imageId || `${group.sourceName}-${index}`}
type="button"
onClick={() => setActiveIndex(index)}
className="group overflow-hidden rounded-lg border border-[#e1e7ef] bg-[#f6f8fb] text-left transition-colors hover:border-[#8ab9ff]"
aria-label={`查看${group.sourceName}`}
>
<div className={`relative overflow-hidden ${compact ? 'aspect-[4/3]' : 'aspect-[4/3]'}`}>
<Image
src={group.previewUrl}
alt={group.sourceName}
fill
unoptimized
className="object-cover object-top transition-transform duration-300 group-hover:scale-[1.02]"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2.5 pb-2 pt-7">
{group.assetType && <div className="mb-1 text-xs font-bold text-white">{WORK_REVIEW_IMAGE_LABELS[group.assetType]}</div>}
<div className="truncate text-[11px] font-semibold text-white">{group.sourceName}</div>
</div>
{group.slices.length > 1 && (
<span className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white">
长图 · {group.slices.length} 段
</span>
)}
{(annotationsByImageIndex[index]?.length || 0) > 0 && (
<span className="absolute left-2 top-2 rounded-full bg-[#8a5200] px-2 py-0.5 text-[10px] font-bold text-white">
{annotationsByImageIndex[index].length} 个批注
</span>
)}
</div>
</button>
))}
</div>
<WorkReviewImageViewer
group={activeIndex == null ? null : displayGroups[activeIndex]}
index={activeIndex}
total={displayGroups.length}
annotations={activeIndex == null ? [] : annotationsByImageIndex[activeIndex] || []}
onClose={() => setActiveIndex(null)}
onPrevious={() => setActiveIndex((current) => (
current == null ? null : (current - 1 + displayGroups.length) % displayGroups.length
))}
onNext={() => setActiveIndex((current) => (
current == null ? null : (current + 1) % displayGroups.length
))}
/>
</>
)
}
function WorkReviewImageViewer({
group,
index,
total,
annotations,
onClose,
onPrevious,
onNext,
}: {
group: WorkReviewImageGroup | null
index: number | null
total: number
annotations: WorkReviewDisplayAnnotation[]
onClose: () => void
onPrevious: () => void
onNext: () => void
}) {
const [portalReady, setPortalReady] = useState(false)
const dialogRef = useRef<HTMLDivElement | null>(null)
const closeRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => setPortalReady(true), [])
useEffect(() => {
if (!group) return
const previousOverflow = document.body.style.overflow
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
document.body.style.overflow = 'hidden'
const frame = window.requestAnimationFrame(() => closeRef.current?.focus())
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
} else if (event.key === 'ArrowLeft' && total > 1) {
event.preventDefault()
onPrevious()
} else if (event.key === 'ArrowRight' && total > 1) {
event.preventDefault()
onNext()
} else if (event.key === 'Tab') {
const buttons = Array.from(dialogRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') || [])
if (buttons.length === 0) return
const first = buttons[0]
const last = buttons[buttons.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
previousFocus?.focus()
}
}, [group, onClose, onNext, onPrevious, total])
if (!group || index == null || !portalReady) return null
return createPortal(
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`查看${group.sourceName}`}
tabIndex={-1}
className="fixed inset-0 z-[1200] flex flex-col bg-black/[0.94] text-white"
onClick={onClose}
>
<div className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-white/10 px-4" onClick={(event) => event.stopPropagation()}>
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold">{group.sourceName}</div>
<div className="mt-0.5 text-[11px] text-white/55">{index + 1} / {total}</div>
</div>
<button
ref={closeRef}
type="button"
onClick={onClose}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-[27px] font-light hover:bg-white/20"
aria-label="关闭图片预览"
>
×
</button>
</div>
<div
className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-3 sm:px-6"
onClick={(event) => event.stopPropagation()}
>
{total > 1 && (
<>
<button
type="button"
onClick={onPrevious}
className="fixed left-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-2xl hover:bg-black/70"
aria-label="上一张"
>
‹
</button>
<button
type="button"
onClick={onNext}
className="fixed right-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-2xl hover:bg-black/70"
aria-label="下一张"
>
›
</button>
</>
)}
<div className="relative mx-auto max-w-[1200px] overflow-hidden rounded bg-white">
{(group.slices.length > 0 ? group.slices : [{
url: group.previewUrl,
width: group.previewWidth,
height: group.previewHeight,
}]).map((slice, sliceIndex) => (
<Image
key={`${slice.url}-${sliceIndex}`}
src={slice.url}
alt={`${group.sourceName}${sliceIndex + 1}`}
width={slice.width}
height={slice.height}
unoptimized
loading="lazy"
className="block h-auto w-full"
/>
))}
{annotations.map((item) => (
<WorkReviewAnnotationMarker
key={`${item.reviewerRole}-${item.annotation.id}`}
item={item}
/>
))}
</div>
</div>
</div>,
document.body,
)
}