Files
WeFlow/src/pages/ResourcesPage.tsx

1331 lines
48 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.
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
import { VirtuosoGrid } from 'react-virtuoso'
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
import './ResourcesPage.scss'
type MediaTab = 'image' | 'video'
interface MediaStreamItem {
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}
interface ContactOption {
id: string
name: string
}
type DialogState = {
mode: 'alert' | 'confirm'
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm?: (() => void) | null
}
const PAGE_SIZE = 96
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 3
const INITIAL_IMAGE_PRELOAD_END = 48
const INITIAL_IMAGE_RESOLVE_END = 12
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
const { className = '', ...rest } = props
const mergedClassName = ['stream-grid-list', className].filter(Boolean).join(' ')
return <div ref={ref} className={mergedClassName} {...rest} />
})
function GridItem(props: HTMLAttributes<HTMLDivElement>) {
const { className = '', ...rest } = props
const mergedClassName = ['stream-grid-item', className].filter(Boolean).join(' ')
return <div className={mergedClassName} {...rest} />
}
function getRangeTimestampStart(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T00:00:00`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
}
function getRangeTimestampEnd(date: string): number | undefined {
if (!date) return undefined
const parsed = new Date(`${date}T23:59:59`)
const n = Math.floor(parsed.getTime() / 1000)
return Number.isFinite(n) ? n : undefined
}
function getItemKey(item: MediaStreamItem): string {
const sessionId = String(item.sessionId || '').trim().toLowerCase()
const localId = Number(item.localId || 0)
if (sessionId && Number.isFinite(localId) && localId > 0) {
return `${sessionId}|${localId}`
}
const serverId = String(item.serverId || '').trim().toLowerCase()
const createTime = Number(item.createTime || 0)
const localType = Number(item.localType || 0)
const mediaId = String(
item.mediaType === 'video'
? (item.videoMd5 || '')
: (item.imageMd5 || item.imageDatName || '')
).trim().toLowerCase()
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
}
function formatTimeLabel(timestampSec: number): string {
if (!timestampSec) return '--:--'
return new Date(timestampSec * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function extractVideoTitle(content?: string): string {
const xml = String(content || '')
if (!xml) return '视频'
const match = /<title>([\s\S]*?)<\/title>/i.exec(xml)
const text = String(match?.[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
return text || '视频'
}
function toRenderableMediaSrc(rawPath?: string): string {
const src = String(rawPath || '').trim()
if (!src) return ''
if (/^(data:image\/|blob:|https?:\/\/)/i.test(src)) {
return src
}
if (/^file:\/\//i.test(src)) {
return src.replace(/#/g, '%23')
}
if (src.startsWith('/')) {
return encodeURI(`file://${src}`).replace(/#/g, '%23')
}
if (/^[a-zA-Z]:[\\/]/.test(src)) {
return encodeURI(`file:///${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
return encodeURI(`file://${src.startsWith('/') ? '' : '/'}${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
}
const MediaCard = memo(function MediaCard({
item,
sessionName,
previewPath,
videoPosterPath,
imageIsLong,
hasPreviewUpdate,
selected,
decrypting,
onToggleSelect,
onDelete,
onImagePreviewAction,
onUpdateImageQuality,
onOpenVideo,
onImageLoaded
}: {
item: MediaStreamItem
sessionName: string
previewPath: string
videoPosterPath: string
imageIsLong: boolean
hasPreviewUpdate: boolean
selected: boolean
decrypting: boolean
onToggleSelect: (item: MediaStreamItem) => void
onDelete: (item: MediaStreamItem) => void
onImagePreviewAction: (item: MediaStreamItem) => void
onUpdateImageQuality: (item: MediaStreamItem) => void
onOpenVideo: (item: MediaStreamItem) => void
onImageLoaded: (item: MediaStreamItem, width: number, height: number) => void
}) {
const isImage = item.mediaType === 'image'
const isDecryptingVisual = decrypting
const showDecryptOverlay = isImage && isDecryptingVisual
return (
<article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}>
<button type="button" className="floating-delete" onClick={() => onDelete(item)} aria-label="删除资源">
<Trash2 size={14} />
</button>
{isImage && hasPreviewUpdate && (
<button
type="button"
className="floating-update"
disabled={decrypting}
onClick={() => onUpdateImageQuality(item)}
title="已扫描到高清图,点击更新画质"
aria-label="更新画质"
>
<RefreshCw size={13} />
</button>
)}
<button
type="button"
className={`card-visual ${isImage ? 'image' : 'video'}`}
disabled={isImage && isDecryptingVisual}
onClick={() => {
if (isImage) {
onImagePreviewAction(item)
return
}
onOpenVideo(item)
}}
>
{isImage ? (
previewPath
? <img
src={toRenderableMediaSrc(previewPath)}
alt="图片资源"
className={imageIsLong ? 'long-image' : ''}
loading="lazy"
decoding="async"
onLoad={(event) => {
const width = event.currentTarget.naturalWidth || 0
const height = event.currentTarget.naturalHeight || 0
onImageLoaded(item, width, height)
}}
/>
: <div className="placeholder"><ImageIcon size={30} /></div>
) : (
videoPosterPath
? <img src={toRenderableMediaSrc(videoPosterPath)} alt="视频封面" loading="lazy" decoding="async" />
: <div className="placeholder">
<PlayCircle size={34} />
<span>{extractVideoTitle(item.content)}</span>
</div>
)}
{showDecryptOverlay && (
<div className="decrypting-overlay" aria-hidden="true">
<div className="decrypting-spinner" />
</div>
)}
</button>
<div className="card-meta" onClick={() => onToggleSelect(item)}>
<div className="title-row">
<span className="session" title={sessionName}>{sessionName}</span>
<span className="time">{formatTimeLabel(item.createTime)}</span>
</div>
<div className="sub-row">
<span>{item.mediaType === 'image' ? '图片' : '视频'}</span>
{item.senderUsername && <span>{item.senderUsername}</span>}
</div>
</div>
</article>
)
})
function ResourcesPage() {
const [tab, setTab] = useState<MediaTab>('image')
const [contacts, setContacts] = useState<ContactOption[]>([{ id: 'all', name: '全部联系人' }])
const [selectedContact, setSelectedContact] = useState('all')
const [dateStart, setDateStart] = useState('')
const [dateEnd, setDateEnd] = useState('')
const [items, setItems] = useState<MediaStreamItem[]>([])
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
const [nextOffset, setNextOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState('')
const [batchBusy, setBatchBusy] = useState(false)
const [decryptingKeys, setDecryptingKeys] = useState<Set<string>>(new Set())
const [actionMessage, setActionMessage] = useState('')
const [sessionNameMap, setSessionNameMap] = useState<Record<string, string>>({})
const [previewPathMap, setPreviewPathMap] = useState<Record<string, string>>({})
const [previewUpdateMap, setPreviewUpdateMap] = useState<Record<string, boolean>>({})
const [videoPosterMap, setVideoPosterMap] = useState<Record<string, string>>({})
const [imageAspectMap, setImageAspectMap] = useState<Record<string, number>>({})
const [dialog, setDialog] = useState<DialogState | null>(null)
const previewPathMapRef = useRef<Record<string, string>>({})
const previewUpdateMapRef = useRef<Record<string, boolean>>({})
const videoPosterMapRef = useRef<Record<string, string>>({})
const imageAspectMapRef = useRef<Record<string, number>>({})
const resolvingImageCacheBatchRef = useRef(false)
const pendingImageResolveRangeRef = useRef<{ start: number; end: number } | null>(null)
const imagePreloadUntilRef = useRef<Record<string, number>>({})
const imageCacheMissUntilRef = useRef<Record<string, number>>({})
const resolvingVideoPosterKeysRef = useRef<Set<string>>(new Set())
const attemptedVideoPosterKeysRef = useRef<Set<string>>(new Set())
const resolvedVideoMd5Ref = useRef<Record<string, string>>({})
const previewPatchRef = useRef<Record<string, string>>({})
const updatePatchRef = useRef<Record<string, boolean>>({})
const previewPatchTimerRef = useRef<number | null>(null)
const posterPatchRef = useRef<Record<string, string>>({})
const posterPatchTimerRef = useRef<number | null>(null)
const aspectPatchRef = useRef<Record<string, number>>({})
const aspectPatchTimerRef = useRef<number | null>(null)
const pendingRangeRef = useRef<{ start: number; end: number } | null>(null)
const rangeTimerRef = useRef<number | null>(null)
useEffect(() => {
previewPathMapRef.current = previewPathMap
}, [previewPathMap])
useEffect(() => {
previewUpdateMapRef.current = previewUpdateMap
}, [previewUpdateMap])
useEffect(() => {
videoPosterMapRef.current = videoPosterMap
}, [videoPosterMap])
useEffect(() => {
imageAspectMapRef.current = imageAspectMap
}, [imageAspectMap])
useEffect(() => () => {
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
}
if (posterPatchTimerRef.current !== null) {
window.clearTimeout(posterPatchTimerRef.current)
posterPatchTimerRef.current = null
}
if (aspectPatchTimerRef.current !== null) {
window.clearTimeout(aspectPatchTimerRef.current)
aspectPatchTimerRef.current = null
}
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
}, [])
const showAlert = useCallback((message: string, title: string = '提示') => {
setDialog({
mode: 'alert',
title,
message,
confirmText: '确定',
onConfirm: null
})
}, [])
const showConfirm = useCallback((message: string, onConfirm: () => void, title: string = '确认操作') => {
setDialog({
mode: 'confirm',
title,
message,
confirmText: '确定',
cancelText: '取消',
onConfirm
})
}, [])
const closeDialog = useCallback(() => {
setDialog(null)
}, [])
const isLikelyThumbnailPreview = useCallback((path: string): boolean => {
const lower = String(path || '').toLowerCase()
if (!lower) return false
return lower.includes('_thumb') || lower.includes('_t.') || lower.includes('.t.')
}, [])
const flushPreviewPatch = useCallback(() => {
const pathPatch = previewPatchRef.current
const updatePatch = updatePatchRef.current
previewPatchRef.current = {}
updatePatchRef.current = {}
previewPatchTimerRef.current = null
const hasPathPatch = Object.keys(pathPatch).length > 0
const hasUpdatePatch = Object.keys(updatePatch).length > 0
if (hasPathPatch) {
setPreviewPathMap((prev) => ({ ...prev, ...pathPatch }))
}
if (hasUpdatePatch) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
}
}, [])
const queuePreviewPatch = useCallback((itemKey: string, localPath: string, hasUpdate: boolean) => {
if (!itemKey || !localPath) return
if (previewPathMapRef.current[itemKey] === localPath && previewUpdateMapRef.current[itemKey] === hasUpdate) return
previewPatchRef.current[itemKey] = localPath
updatePatchRef.current[itemKey] = hasUpdate
if (previewPatchTimerRef.current !== null) return
previewPatchTimerRef.current = window.setTimeout(flushPreviewPatch, 16)
}, [flushPreviewPatch])
const flushPosterPatch = useCallback(() => {
const patch = posterPatchRef.current
posterPatchRef.current = {}
posterPatchTimerRef.current = null
if (Object.keys(patch).length === 0) return
setVideoPosterMap((prev) => ({ ...prev, ...patch }))
}, [])
const queuePosterPatch = useCallback((itemKey: string, posterPath: string) => {
if (!itemKey || !posterPath) return
if (videoPosterMapRef.current[itemKey] === posterPath) return
posterPatchRef.current[itemKey] = posterPath
if (posterPatchTimerRef.current !== null) return
posterPatchTimerRef.current = window.setTimeout(flushPosterPatch, 16)
}, [flushPosterPatch])
const flushAspectPatch = useCallback(() => {
const patch = aspectPatchRef.current
aspectPatchRef.current = {}
aspectPatchTimerRef.current = null
if (Object.keys(patch).length === 0) return
setImageAspectMap((prev) => ({ ...prev, ...patch }))
}, [])
const queueAspectPatch = useCallback((itemKey: string, ratio: number) => {
const old = imageAspectMapRef.current[itemKey]
if (typeof old === 'number' && Math.abs(old - ratio) < 0.01) return
aspectPatchRef.current[itemKey] = ratio
if (aspectPatchTimerRef.current !== null) return
aspectPatchTimerRef.current = window.setTimeout(flushAspectPatch, 24)
}, [flushAspectPatch])
const loadStream = useCallback(async (reset: boolean) => {
if (reset) setLoading(true)
else setLoadingMore(true)
if (reset) {
setError('')
setActionMessage('')
}
try {
if (reset) {
const connectResult = await window.electronAPI.chat.connect()
if (!connectResult.success) {
setError(connectResult.error || '连接数据库失败')
return
}
}
const requestOffset = reset ? 0 : nextOffset
const streamResult = await window.electronAPI.chat.getMediaStream({
sessionId: selectedContact === 'all' ? undefined : selectedContact,
mediaType: tab,
beginTimestamp: getRangeTimestampStart(dateStart),
endTimestamp: getRangeTimestampEnd(dateEnd),
offset: requestOffset,
limit: PAGE_SIZE
})
if (!streamResult.success) {
setError(streamResult.error || '加载失败')
if (reset) {
previewPatchRef.current = {}
updatePatchRef.current = {}
posterPatchRef.current = {}
aspectPatchRef.current = {}
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
}
if (posterPatchTimerRef.current !== null) {
window.clearTimeout(posterPatchTimerRef.current)
posterPatchTimerRef.current = null
}
if (aspectPatchTimerRef.current !== null) {
window.clearTimeout(aspectPatchTimerRef.current)
aspectPatchTimerRef.current = null
}
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
pendingRangeRef.current = null
resolvingImageCacheBatchRef.current = false
pendingImageResolveRangeRef.current = null
imagePreloadUntilRef.current = {}
imageCacheMissUntilRef.current = {}
resolvingVideoPosterKeysRef.current.clear()
attemptedVideoPosterKeysRef.current.clear()
resolvedVideoMd5Ref.current = {}
setItems([])
setNextOffset(0)
setHasMore(false)
setSelectedKeys(new Set())
setPreviewPathMap({})
setPreviewUpdateMap({})
setVideoPosterMap({})
setImageAspectMap({})
}
return
}
const incoming = (streamResult.items || []) as MediaStreamItem[]
if (reset) {
previewPatchRef.current = {}
updatePatchRef.current = {}
posterPatchRef.current = {}
aspectPatchRef.current = {}
if (previewPatchTimerRef.current !== null) {
window.clearTimeout(previewPatchTimerRef.current)
previewPatchTimerRef.current = null
}
if (posterPatchTimerRef.current !== null) {
window.clearTimeout(posterPatchTimerRef.current)
posterPatchTimerRef.current = null
}
if (aspectPatchTimerRef.current !== null) {
window.clearTimeout(aspectPatchTimerRef.current)
aspectPatchTimerRef.current = null
}
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
pendingRangeRef.current = null
resolvingImageCacheBatchRef.current = false
pendingImageResolveRangeRef.current = null
imagePreloadUntilRef.current = {}
imageCacheMissUntilRef.current = {}
resolvingVideoPosterKeysRef.current.clear()
attemptedVideoPosterKeysRef.current.clear()
resolvedVideoMd5Ref.current = {}
setItems(incoming)
setSelectedKeys(new Set())
setPreviewPathMap({})
setPreviewUpdateMap({})
setVideoPosterMap({})
setImageAspectMap({})
} else {
setItems((prev) => {
const map = new Map(prev.map((row) => [getItemKey(row), row]))
incoming.forEach((row) => map.set(getItemKey(row), row))
return Array.from(map.values())
})
}
setNextOffset(Number(streamResult.nextOffset || requestOffset + incoming.length))
setHasMore(Boolean(streamResult.hasMore))
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [dateEnd, dateStart, nextOffset, selectedContact, tab])
useEffect(() => {
void loadStream(true)
}, [tab, selectedContact, dateStart, dateEnd])
useEffect(() => {
let cancelled = false
const run = async () => {
try {
const sessionResult = await window.electronAPI.chat.getSessions()
if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) {
const initialNameMap: Record<string, string> = {}
sessionResult.sessions.forEach((session) => {
initialNameMap[session.username] = session.displayName || session.username
})
setSessionNameMap(initialNameMap)
setContacts([
{ id: 'all', name: '全部联系人' },
...sessionResult.sessions.map((session) => ({
id: session.username,
name: session.displayName || session.username
}))
])
}
} catch {
// ignore
}
}
void run()
return () => {
cancelled = true
}
}, [])
const displayItems = useMemo(() => (
items.map((item) => ({
...item,
sessionDisplayName: item.sessionDisplayName || sessionNameMap[item.sessionId] || item.sessionId
}))
), [items, sessionNameMap])
useEffect(() => {
const imageKeySet = new Set(
displayItems
.filter((item) => item.mediaType === 'image')
.map((item) => getItemKey(item))
)
const videoKeySet = new Set(
displayItems
.filter((item) => item.mediaType === 'video')
.map((item) => getItemKey(item))
)
setPreviewPathMap((prev) => {
let changed = false
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setPreviewUpdateMap((prev) => {
let changed = false
const next: Record<string, boolean> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setImageAspectMap((prev) => {
let changed = false
const next: Record<string, number> = {}
for (const [key, value] of Object.entries(prev)) {
if (!imageKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
setVideoPosterMap((prev) => {
let changed = false
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(prev)) {
if (!videoKeySet.has(key)) continue
next[key] = value
}
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
return changed ? next : prev
})
const validKeys = new Set<string>(displayItems.map((item) => getItemKey(item)))
const nextResolvedVideoMd5: Record<string, string> = {}
for (const [key, value] of Object.entries(resolvedVideoMd5Ref.current)) {
if (!validKeys.has(key)) continue
nextResolvedVideoMd5[key] = value
}
resolvedVideoMd5Ref.current = nextResolvedVideoMd5
const nextAttempted = new Set<string>()
attemptedVideoPosterKeysRef.current.forEach((key) => {
if (validKeys.has(key)) nextAttempted.add(key)
})
attemptedVideoPosterKeysRef.current = nextAttempted
const nextImageMissUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imageCacheMissUntilRef.current)) {
if (!validKeys.has(key)) continue
nextImageMissUntil[key] = value
}
imageCacheMissUntilRef.current = nextImageMissUntil
const nextImagePreloadUntil: Record<string, number> = {}
for (const [key, value] of Object.entries(imagePreloadUntilRef.current)) {
if (!validKeys.has(key)) continue
nextImagePreloadUntil[key] = value
}
imagePreloadUntilRef.current = nextImagePreloadUntil
}, [displayItems])
const resolveImageCacheRange = useCallback((start: number, end: number) => {
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
if (to < from) return
const now = Date.now()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
const itemKeys: string[] = []
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
if (!item || item.mediaType !== 'image') continue
const itemKey = getItemKey(item)
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
if (!item.imageMd5 && !item.imageDatName) continue
if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue
payloads.push({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
})
itemKeys.push(itemKey)
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
}
if (payloads.length === 0) return
if (resolvingImageCacheBatchRef.current) {
pendingImageResolveRangeRef.current = { start: from, end: to }
return
}
resolvingImageCacheBatchRef.current = true
void (async () => {
try {
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
disableUpdateCheck: true,
allowCacheIndex: false
})
const rows = Array.isArray(result?.rows) ? result.rows : []
const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
const missUntil = Date.now() + 4500
for (let i = 0; i < itemKeys.length; i += 1) {
const itemKey = itemKeys[i]
const row = rows[i]
if (row?.success && row.localPath) {
delete imageCacheMissUntilRef.current[itemKey]
pathPatch[itemKey] = row.localPath
updatePatch[itemKey] = Boolean(row.hasUpdate)
} else {
imageCacheMissUntilRef.current[itemKey] = missUntil
}
}
if (Object.keys(pathPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...pathPatch }))
}
if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
}
} catch {
const missUntil = Date.now() + 4500
itemKeys.forEach((itemKey) => {
imageCacheMissUntilRef.current[itemKey] = missUntil
})
} finally {
resolvingImageCacheBatchRef.current = false
const pending = pendingImageResolveRangeRef.current
pendingImageResolveRangeRef.current = null
if (pending) {
resolveImageCacheRange(pending.start, pending.end)
}
}
})()
}, [displayItems])
const preloadImageCacheRange = useCallback((start: number, end: number) => {
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
if (to < from) return
const now = Date.now()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
const dedup = new Set<string>()
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
if (!item || item.mediaType !== 'image') continue
const itemKey = getItemKey(item)
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
if (!item.imageMd5 && !item.imageDatName) continue
if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue
const dedupKey = `${item.sessionId || ''}|${item.imageMd5 || ''}|${item.imageDatName || ''}`
if (dedup.has(dedupKey)) continue
dedup.add(dedupKey)
imagePreloadUntilRef.current[itemKey] = now + 12000
payloads.push({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
})
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
}
if (payloads.length === 0) return
void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: false
})
}, [displayItems])
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
const itemKey = getItemKey(item)
const cached = resolvedVideoMd5Ref.current[itemKey]
if (cached) return cached
let md5 = String(item.videoMd5 || '').trim().toLowerCase()
if (md5) {
resolvedVideoMd5Ref.current[itemKey] = md5
return md5
}
const parsed = await window.electronAPI.video.parseVideoMd5(String(item.content || ''))
if (parsed.success && parsed.md5) md5 = String(parsed.md5).trim().toLowerCase()
if (md5) resolvedVideoMd5Ref.current[itemKey] = md5
return md5
}, [])
const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'video') return
const itemKey = getItemKey(item)
if (videoPosterMapRef.current[itemKey]) return
if (attemptedVideoPosterKeysRef.current.has(itemKey)) return
if (resolvingVideoPosterKeysRef.current.has(itemKey)) return
resolvingVideoPosterKeysRef.current.add(itemKey)
try {
const md5 = await resolveItemVideoMd5(item)
if (!md5) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
const info = await window.electronAPI.video.getVideoInfo(md5, { includePoster: true, posterFormat: 'fileUrl' })
if (!info.success || !info.exists) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
const poster = String(info.coverUrl || info.thumbUrl || '')
if (!poster) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
queuePosterPatch(itemKey, poster)
attemptedVideoPosterKeysRef.current.add(itemKey)
} catch {
attemptedVideoPosterKeysRef.current.add(itemKey)
} finally {
resolvingVideoPosterKeysRef.current.delete(itemKey)
}
}, [queuePosterPatch, resolveItemVideoMd5])
const resolvePosterRange = useCallback((start: number, end: number) => {
const from = Math.max(0, start)
const to = Math.min(displayItems.length - 1, end)
if (to < from) return
let resolvedCount = 0
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
if (!item || item.mediaType !== 'video') continue
void resolveVideoPoster(item)
resolvedCount += 1
if (resolvedCount >= MAX_VIDEO_POSTER_RESOLVE_PER_TICK) break
}
}, [displayItems, resolveVideoPoster])
const flushRangeResolve = useCallback(() => {
rangeTimerRef.current = null
const pending = pendingRangeRef.current
if (!pending) return
pendingRangeRef.current = null
if (tab === 'image') {
preloadImageCacheRange(pending.start - 4, pending.end + 20)
resolveImageCacheRange(pending.start - 1, pending.end + 6)
return
}
resolvePosterRange(pending.start, pending.end)
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
const scheduleRangeResolve = useCallback((start: number, end: number) => {
const previous = pendingRangeRef.current
if (previous && start >= previous.start && end <= previous.end) {
return
}
pendingRangeRef.current = { start, end }
if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current)
rangeTimerRef.current = null
}
rangeTimerRef.current = window.setTimeout(flushRangeResolve, 120)
}, [flushRangeResolve])
useEffect(() => {
if (displayItems.length === 0) return
if (tab === 'image') {
preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
return
}
resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
}, [displayItems, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
const selectedItems = useMemo(() => {
if (selectedKeys.size === 0) return []
return displayItems.filter((item) => selectedKeys.has(getItemKey(item)))
}, [displayItems, selectedKeys])
const toggleSelect = useCallback((item: MediaStreamItem) => {
const key = getItemKey(item)
setSelectedKeys((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}, [])
const onImageLoaded = useCallback((item: MediaStreamItem, width: number, height: number) => {
if (item.mediaType !== 'image') return
if (!width || !height) return
const ratio = height / width
if (!Number.isFinite(ratio) || ratio <= 0) return
const itemKey = getItemKey(item)
queueAspectPatch(itemKey, ratio)
}, [queueAspectPatch])
const deleteOne = useCallback((item: MediaStreamItem) => {
showConfirm('确认删除该原始记录?此操作不可恢复。', () => {
void (async () => {
const result = await window.electronAPI.chat.deleteMessage(item.sessionId, item.localId, item.createTime)
if (!result.success) {
showAlert(`删除失败:${result.error || '未知错误'}`, '删除失败')
return
}
const key = getItemKey(item)
setItems((prev) => prev.filter((row) => getItemKey(row) !== key))
setSelectedKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
setPreviewPathMap((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
setPreviewUpdateMap((prev) => {
if (prev[key] === undefined) return prev
const next = { ...prev }
delete next[key]
return next
})
setActionMessage('删除成功')
})()
}, '删除确认')
}, [showAlert, showConfirm])
const batchDelete = useCallback(() => {
if (selectedItems.length === 0 || batchBusy) return
showConfirm(`确认删除选中 ${selectedItems.length} 条记录?此操作不可恢复。`, () => {
void (async () => {
setBatchBusy(true)
let success = 0
const deletedKeys = new Set<string>()
try {
for (const item of selectedItems) {
const result = await window.electronAPI.chat.deleteMessage(item.sessionId, item.localId, item.createTime)
if (result.success) {
success += 1
deletedKeys.add(getItemKey(item))
}
}
setItems((prev) => prev.filter((item) => !deletedKeys.has(getItemKey(item))))
setSelectedKeys(new Set())
setPreviewPathMap((prev) => {
const next = { ...prev }
deletedKeys.forEach((key) => { delete next[key] })
return next
})
setPreviewUpdateMap((prev) => {
const next = { ...prev }
deletedKeys.forEach((key) => { delete next[key] })
return next
})
setActionMessage(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`)
showAlert(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`, '批量删除完成')
} finally {
setBatchBusy(false)
}
})()
}, '批量删除确认')
}, [batchBusy, selectedItems, showAlert, showConfirm])
const decryptImage = useCallback(async (item: MediaStreamItem): Promise<string | undefined> => {
if (item.mediaType !== 'image') return
const key = getItemKey(item)
if (!item.imageMd5 && !item.imageDatName) {
showAlert('当前图片缺少解密所需字段imageMd5/imageDatName', '无法解密')
return
}
setDecryptingKeys((prev) => {
const next = new Set(prev)
next.add(key)
return next
})
try {
const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined,
force: true
})
if (!result?.success) {
showAlert(`解密失败:${result?.error || '未知错误'}`, '解密失败')
return undefined
}
if (result.localPath) {
const localPath = result.localPath as string
setPreviewPathMap((prev) => ({ ...prev, [key]: localPath }))
setPreviewUpdateMap((prev) => ({ ...prev, [key]: isLikelyThumbnailPreview(localPath) }))
setActionMessage('图片解密完成')
return localPath
}
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
})
if (resolved?.success && resolved.localPath) {
const localPath = resolved.localPath
setPreviewPathMap((prev) => ({ ...prev, [key]: localPath }))
setPreviewUpdateMap((prev) => ({ ...prev, [key]: Boolean(resolved.hasUpdate) }))
setActionMessage('图片解密完成')
return localPath
}
} catch {
// ignore
}
setActionMessage('图片解密完成')
return undefined
} catch (e) {
showAlert(`解密失败:${String(e)}`, '解密失败')
return undefined
} finally {
setDecryptingKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
}, [isLikelyThumbnailPreview, showAlert])
const onImagePreviewAction = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'image') return
const key = getItemKey(item)
let localPath = previewPathMapRef.current[key] || previewPatchRef.current[key] || ''
if (localPath) {
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
})
if (resolved?.success && resolved.localPath) {
localPath = resolved.localPath
queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate))
}
} catch {
// ignore
}
if (localPath) {
await window.electronAPI.window.openImageViewerWindow(localPath)
return
}
}
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
})
if (resolved?.success && resolved.localPath) {
localPath = resolved.localPath
queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate))
await window.electronAPI.window.openImageViewerWindow(localPath)
return
}
} catch {
// ignore
}
await decryptImage(item)
}, [decryptImage, queuePreviewPatch])
const updateImageQuality = useCallback(async (item: MediaStreamItem) => {
await decryptImage(item)
}, [decryptImage])
const batchDecryptImage = useCallback(async () => {
if (batchBusy) return
const imageItems = selectedItems.filter((item) => item.mediaType === 'image')
if (imageItems.length === 0) {
showAlert('当前选中中没有图片资源', '无法批量解密')
return
}
setBatchBusy(true)
let success = 0
let failed = 0
const previewPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
const taskId = registerBackgroundTask({
sourcePage: 'other',
title: '资源页图片批量解密',
detail: `正在解密图片0/${imageItems.length}`,
progressText: `0 / ${imageItems.length}`,
cancelable: false
})
try {
let completed = 0
const progressStep = Math.max(1, Math.floor(imageItems.length / TASK_PROGRESS_UPDATE_MAX_STEPS))
let lastProgressBucket = 0
let lastProgressUpdateAt = Date.now()
const updateTaskProgress = (force: boolean = false) => {
const now = Date.now()
const bucket = Math.floor(completed / progressStep)
const crossedBucket = bucket !== lastProgressBucket
const intervalReached = now - lastProgressUpdateAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS
if (!force && !crossedBucket && !intervalReached) return
updateBackgroundTask(taskId, {
detail: `正在解密图片(${completed}/${imageItems.length}`,
progressText: `${completed} / ${imageItems.length}`
})
lastProgressBucket = bucket
lastProgressUpdateAt = now
}
for (const item of imageItems) {
if (!item.imageMd5 && !item.imageDatName) {
failed += 1
completed += 1
updateTaskProgress()
continue
}
const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined,
force: true
})
if (!result?.success) {
failed += 1
} else {
success += 1
if (result.localPath) {
const key = getItemKey(item)
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
}
}
completed += 1
updateTaskProgress()
}
updateTaskProgress(true)
if (Object.keys(previewPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
}
if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
}
setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
progressText: `成功 ${success} / 失败 ${failed}`
})
} catch (e) {
finishBackgroundTask(taskId, 'failed', {
detail: `资源页图片批量解密失败:${String(e)}`
})
showAlert(`批量解密失败:${String(e)}`, '批量解密失败')
} finally {
setBatchBusy(false)
}
}, [batchBusy, isLikelyThumbnailPreview, selectedItems, showAlert])
const openVideo = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'video') return
const md5 = await resolveItemVideoMd5(item)
if (!md5) {
showAlert('未解析到视频资源标识', '无法播放')
return
}
const info = await window.electronAPI.video.getVideoInfo(md5, { includePoster: false })
if (!info.success || !info.exists || !info.videoUrl) {
showAlert(info.error || '未找到视频文件', '无法播放')
return
}
await window.electronAPI.window.openVideoPlayerWindow(info.videoUrl)
}, [resolveItemVideoMd5, showAlert])
return (
<div className="resources-page stream-rebuild">
<header className="stream-toolbar">
<div className="toolbar-left">
<div className="media-tabs">
<button type="button" className={tab === 'image' ? 'active' : ''} onClick={() => setTab('image')}></button>
<button type="button" className={tab === 'video' ? 'active' : ''} onClick={() => setTab('video')}></button>
</div>
<div className="filters">
<label className="filter-field filter-select">
<UserRound size={14} />
<select
className="contact-select"
value={selectedContact}
onChange={(event) => setSelectedContact(event.target.value)}
>
{contacts.map((contact) => (
<option key={contact.id} value={contact.id}>{contact.name}</option>
))}
</select>
</label>
<label className="filter-field filter-date">
<Calendar size={14} />
<input
className="date-input"
type="date"
value={dateStart}
onChange={(event) => setDateStart(event.target.value)}
/>
</label>
<span className="sep"></span>
<label className="filter-field filter-date">
<Calendar size={14} />
<input
className="date-input"
type="date"
value={dateEnd}
onChange={(event) => setDateEnd(event.target.value)}
/>
</label>
<button type="button" className="ghost reset-btn" onClick={() => { setDateStart(''); setDateEnd('') }}></button>
</div>
</div>
<div className="toolbar-right">
<button type="button" onClick={() => void loadStream(true)} disabled={loading || loadingMore}>
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
</button>
{tab === 'image' && (
<button type="button" onClick={() => void batchDecryptImage()} disabled={selectedKeys.size === 0 || batchBusy}>
</button>
)}
<button type="button" className="danger" onClick={() => void batchDelete()} disabled={selectedKeys.size === 0 || batchBusy}>
</button>
</div>
</header>
<div className="stream-summary">
<span> {items.length} </span>
<span> {selectedKeys.size} </span>
<span>{tab === 'image' ? '图片按时间倒序流式展示' : '视频按时间倒序流式展示'}</span>
{actionMessage && <span className="action-message">{actionMessage}</span>}
</div>
{error && (
<div className="stream-state error">{error}</div>
)}
{!error && items.length === 0 && (loading || loadingMore) && (
<div className="stream-state"><Loader2 size={18} className="spin" /> ...</div>
)}
{!error && items.length === 0 && !loading && !loadingMore && (
<div className="stream-state"></div>
)}
{!error && items.length > 0 && (
<div className="stream-grid-wrap">
<VirtuosoGrid
className="stream-grid"
overscan={48}
components={{
List: GridList,
Item: GridItem
}}
data={displayItems}
computeItemKey={(_, item) => getItemKey(item)}
rangeChanged={(range) => {
scheduleRangeResolve(range.startIndex - 3, range.endIndex + 6)
}}
endReached={() => {
if (!hasMore || loading || loadingMore) return
void loadStream(false)
}}
itemContent={(_, item) => {
const itemKey = getItemKey(item)
const aspect = imageAspectMap[itemKey] || 0
return (
<MediaCard
item={item}
sessionName={item.sessionDisplayName || item.sessionId}
previewPath={previewPathMap[itemKey] || ''}
videoPosterPath={videoPosterMap[itemKey] || ''}
imageIsLong={aspect >= 2.8}
hasPreviewUpdate={Boolean(previewUpdateMap[itemKey])}
selected={selectedKeys.has(itemKey)}
decrypting={decryptingKeys.has(itemKey)}
onToggleSelect={toggleSelect}
onDelete={deleteOne}
onImagePreviewAction={onImagePreviewAction}
onUpdateImageQuality={updateImageQuality}
onOpenVideo={openVideo}
onImageLoaded={onImageLoaded}
/>
)
}}
/>
{loadingMore && <div className="grid-loading-more"><Loader2 size={16} className="spin" /> ...</div>}
{!hasMore && <div className="grid-end"></div>}
</div>
)}
{dialog && (
<div className="resource-dialog-mask">
<div className="resource-dialog" role="dialog" aria-modal="true" aria-label={dialog.title}>
<header className="dialog-header">{dialog.title}</header>
<div className="dialog-body">{dialog.message}</div>
<footer className="dialog-actions">
{dialog.mode === 'confirm' && (
<button type="button" className="dialog-btn ghost" onClick={closeDialog}>
{dialog.cancelText || '取消'}
</button>
)}
<button
type="button"
className="dialog-btn solid"
onClick={() => {
const callback = dialog.onConfirm
closeDialog()
callback?.()
}}
>
{dialog.confirmText || '确定'}
</button>
</footer>
</div>
</div>
)}
</div>
)
}
export default ResourcesPage