新增资源管理并修复了朋友圈的资源缓存路径
This commit is contained in:
1 parent
20c5381211
commit
d128bedffa
23 files changed
+3860
-86
No files matched your search
@@ -0,0 +1,1265 @@
|
||||
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 './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 = 120
|
||||
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18
|
||||
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36
|
||||
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4
|
||||
|
||||
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 {
|
||||
await window.electronAPI.chat.connect()
|
||||
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 {
|
||||
await window.electronAPI.chat.connect()
|
||||
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 })
|
||||
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 })
|
||||
}, [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 - 8, pending.end + 32)
|
||||
resolveImageCacheRange(pending.start - 2, pending.end + 8)
|
||||
return
|
||||
}
|
||||
resolvePosterRange(pending.start, pending.end)
|
||||
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
|
||||
|
||||
const scheduleRangeResolve = useCallback((start: number, end: number) => {
|
||||
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, 80))
|
||||
resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20))
|
||||
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
|
||||
const previewPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
try {
|
||||
for (const item of imageItems) {
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
})
|
||||
if (!result?.success) continue
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(previewPatch).length > 0) {
|
||||
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
|
||||
}
|
||||
if (Object.keys(updatePatch).length > 0) {
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||
}
|
||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`)
|
||||
showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成')
|
||||
} 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
|
||||
Reference in new issue
Block a user