新增资源管理并修复了朋友圈的资源缓存路径

This commit is contained in:
cc committed 2026-04-06 23:32:59 +08:00
1 parent 20c5381211
commit d128bedffa
23 files changed
+3860 -86

No files matched your search

+2
View File
@@ -22,6 +22,7 @@ import ImageWindow from './pages/ImageWindow'
import SnsPage from './pages/SnsPage'
import BizPage from './pages/BizPage'
import ContactsPage from './pages/ContactsPage'
import ResourcesPage from './pages/ResourcesPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
@@ -743,6 +744,7 @@ function App() {
<Route path="/sns" element={<SnsPage />} />
<Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes>
+11 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
@@ -429,6 +429,16 @@ function Sidebar({ collapsed }: SidebarProps) {
<span className="nav-label">通讯录</span>
</NavLink>
{/* 资源浏览 */}
<NavLink
to="/resources"
className={`nav-item ${isActive('/resources') ? 'active' : ''}`}
title={collapsed ? '资源浏览' : undefined}
>
<span className="nav-icon"><FolderClosed size={20} /></span>
<span className="nav-label">资源浏览</span>
</NavLink>
{/* 聊天分析 */}
<NavLink
to="/analytics"
+620
View File
@@ -0,0 +1,620 @@
.resources-page.stream-rebuild {
--stream-columns: 4;
--stream-grid-gap: 12px;
--stream-card-width: 272px;
--stream-card-height: 356px;
--stream-visual-height: 236px;
--stream-slot-width: calc(var(--stream-card-width) + var(--stream-grid-gap));
--stream-slot-height: calc(var(--stream-card-height) + var(--stream-grid-gap));
--stream-grid-width: calc(var(--stream-slot-width) * var(--stream-columns));
height: calc(100% + 48px);
margin: -24px;
padding: 16px 18px;
position: relative;
background: var(--bg-primary);
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
.stream-toolbar {
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
background: var(--card-bg, #f8f9fb);
border-radius: 16px;
padding: 12px;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.toolbar-left {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
flex: 1;
}
.media-tabs {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 4px;
border-radius: 12px;
background: color-mix(in srgb, var(--bg-secondary) 85%, transparent);
border: 1px solid var(--border-color);
button {
border: none;
background: transparent;
color: var(--text-secondary, #5f6674);
border-radius: 9px;
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
&.active {
background: color-mix(in srgb, var(--primary) 18%, var(--card-bg));
color: var(--text-primary, #1c2230);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 45%, transparent);
}
}
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
.filter-field {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid color-mix(in srgb, var(--border-color, #d2d7df) 95%, transparent);
background: var(--bg-secondary, #f3f5f8);
color: var(--text-secondary, #566074);
border-radius: 10px;
padding: 0 10px;
min-height: 36px;
box-sizing: border-box;
svg {
color: var(--text-tertiary, #8a92a3);
flex: 0 0 auto;
}
}
.filter-select {
min-width: 300px;
}
.filter-date {
min-width: 160px;
}
input,
select {
border: none;
outline: none;
background: transparent;
color: var(--text-primary, #1c2230);
font-size: 13px;
min-width: 0;
height: 34px;
line-height: 34px;
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
appearance: none;
}
.contact-select {
width: 100%;
min-width: 220px;
padding-right: 8px;
}
.date-input {
width: 128px;
min-width: 128px;
}
.sep {
color: var(--text-tertiary);
font-size: 12px;
}
.ghost {
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary, #5f6674);
border-radius: 10px;
height: 36px;
padding: 0 12px;
cursor: pointer;
}
.reset-btn {
border-color: color-mix(in srgb, var(--border-color, #d2d7df) 95%, transparent);
background: var(--bg-secondary, #f3f5f8);
}
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
button {
border: 1px solid var(--border-color);
background: var(--bg-secondary, #f3f5f8);
color: var(--text-secondary, #5f6674);
border-radius: 10px;
height: 34px;
padding: 0 12px;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
&:disabled {
opacity: 0.58;
cursor: not-allowed;
}
&.danger {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-color));
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--bg-secondary));
}
}
}
.stream-summary {
display: flex;
gap: 14px;
font-size: 12px;
color: var(--text-tertiary);
padding: 0 4px;
flex-wrap: wrap;
}
.stream-state {
height: 120px;
border: 1px dashed var(--border-color);
border-radius: 12px;
background: var(--card-bg);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&.error {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-color));
}
}
.stream-grid-wrap {
flex: 1;
min-height: 0;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
border-radius: 16px;
background: color-mix(in srgb, var(--card-bg) 94%, transparent);
overflow: hidden;
display: flex;
flex-direction: column;
}
.stream-grid {
flex: 1;
min-height: 0;
height: 100%;
overflow-anchor: none;
}
.stream-grid-list,
.virtuoso-grid-list {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: flex-start;
align-content: flex-start;
padding: 10px 0 2px;
width: var(--stream-grid-width);
min-width: var(--stream-grid-width);
max-width: var(--stream-grid-width);
margin: 0 auto;
}
.stream-grid-item,
.virtuoso-grid-item {
box-sizing: border-box;
width: var(--stream-slot-width);
min-width: var(--stream-slot-width);
max-width: var(--stream-slot-width);
flex: 0 0 var(--stream-slot-width);
height: var(--stream-slot-height);
padding-right: var(--stream-grid-gap);
padding-bottom: var(--stream-grid-gap);
display: flex;
}
.stream-grid-item > *,
.virtuoso-grid-item > * {
width: 100%;
height: 100%;
}
.media-card {
width: 100%;
height: 100%;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 14px;
overflow: hidden;
transition: border-color 0.16s ease;
position: relative;
display: flex;
flex-direction: column;
&:hover {
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
}
&.selected {
border-color: color-mix(in srgb, var(--primary) 56%, var(--border-color));
outline: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
outline-offset: -1px;
}
&.decrypting {
.card-visual {
opacity: 0.68;
}
}
}
.floating-delete {
position: absolute;
top: 10px;
right: 10px;
z-index: 4;
width: 28px;
height: 28px;
border-radius: 9px;
border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-color));
color: var(--danger);
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(-2px) scale(0.96);
pointer-events: none;
transition: opacity 0.16s ease, transform 0.16s ease;
}
.media-card:hover .floating-delete,
.media-card:focus-within .floating-delete {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.floating-update {
position: absolute;
top: 10px;
left: 10px;
z-index: 4;
border: 1px solid color-mix(in srgb, var(--primary) 45%, var(--border-color));
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
color: var(--text-primary);
border-radius: 9px;
height: 28px;
padding: 0 8px;
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.card-visual {
width: 100%;
height: var(--stream-visual-height);
min-height: var(--stream-visual-height);
max-height: var(--stream-visual-height);
border: none;
cursor: pointer;
background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&:disabled {
cursor: not-allowed;
}
&.image img,
&.video img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
&.image img.long-image {
object-fit: cover;
object-position: top center;
}
.placeholder {
width: 100%;
min-height: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary);
padding: 12px;
text-align: center;
span {
font-size: 12px;
color: var(--text-secondary);
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.decrypting-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background: linear-gradient(140deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.04));
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: -40%;
background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.35) 50%, transparent 65%);
animation: decrypt-sheen 1.6s linear infinite;
pointer-events: none;
}
}
.decrypting-spinner {
position: relative;
z-index: 1;
width: 30px;
height: 30px;
border-radius: 999px;
border: 2px solid rgba(15, 23, 42, 0.2);
border-top-color: color-mix(in srgb, var(--primary) 78%, #ffffff);
animation: decrypt-spin 0.85s linear infinite, decrypt-pulse 1.2s ease-in-out infinite;
box-shadow:
0 0 0 8px rgba(255, 255, 255, 0.26),
0 10px 24px rgba(15, 23, 42, 0.12);
}
}
.card-meta {
padding: 9px 10px 8px;
min-height: 66px;
margin-top: auto;
cursor: pointer;
border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
transition: background-color 0.15s ease;
&:hover {
background: color-mix(in srgb, var(--bg-secondary) 68%, transparent);
}
}
.title-row,
.sub-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.title-row {
.session {
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 62%;
}
.time {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
}
.sub-row {
margin-top: 4px;
font-size: 11px;
color: var(--text-tertiary);
}
.grid-loading-more,
.grid-end {
height: 34px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: var(--text-tertiary);
}
.spin {
animation: resources-spin 1s linear infinite;
}
.action-message {
color: color-mix(in srgb, var(--primary) 75%, var(--text-secondary));
font-weight: 600;
}
.resource-dialog-mask {
position: absolute;
inset: 0;
background: rgba(8, 11, 18, 0.24);
display: flex;
align-items: center;
justify-content: center;
z-index: 30;
}
.resource-dialog {
width: min(420px, calc(100% - 32px));
background: var(--card-bg, #ffffff);
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.22);
overflow: hidden;
}
.dialog-header {
padding: 12px 14px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 85%, transparent);
}
.dialog-body {
padding: 16px 14px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.55;
white-space: pre-wrap;
}
.dialog-actions {
padding: 0 14px 14px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.dialog-btn {
min-width: 72px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border-color);
font-size: 13px;
cursor: pointer;
&.ghost {
background: var(--bg-secondary);
color: var(--text-secondary);
}
&.solid {
background: color-mix(in srgb, var(--primary) 16%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
color: var(--text-primary);
}
}
}
@media (max-width: 900px) {
.resources-page.stream-rebuild {
.stream-toolbar {
flex-direction: column;
}
.toolbar-right {
justify-content: flex-start;
}
.filters {
.filter-select {
min-width: 220px;
}
}
}
}
@media (max-width: 680px) {
.resources-page.stream-rebuild {
--stream-grid-width: calc(var(--stream-slot-width) * var(--stream-columns));
.stream-grid-list,
.virtuoso-grid-list {
margin: 0;
padding-left: 0;
padding-right: 0;
}
}
}
@keyframes resources-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes decrypt-sheen {
from {
transform: translateX(-45%);
}
to {
transform: translateX(45%);
}
}
@keyframes decrypt-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes decrypt-pulse {
0%,
100% {
opacity: 0.92;
}
50% {
opacity: 0.68;
}
}
+1265
View File
@@ -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
+196
View File
@@ -1642,6 +1642,202 @@
}
}
.sns-cache-migration-dialog {
background: var(--bg-secondary);
border-radius: var(--sns-border-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
width: 540px;
max-width: 92vw;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.sns-cache-migration-close {
position: absolute;
right: 12px;
top: 12px;
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
&:hover:not(:disabled) {
background: var(--bg-primary);
color: var(--text-primary);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.sns-cache-migration-header {
padding: 18px 20px 12px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
}
.sns-cache-migration-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.sns-cache-migration-subtitle {
margin-top: 6px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.sns-cache-migration-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sns-cache-migration-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 12px;
strong {
font-size: 16px;
color: var(--text-primary);
}
}
.sns-cache-migration-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.sns-cache-migration-progress-bar {
width: 100%;
height: 8px;
border-radius: 999px;
background: var(--bg-tertiary);
overflow: hidden;
}
.sns-cache-migration-progress-fill {
height: 100%;
background: linear-gradient(90deg, #34d399, #10b981);
transition: width 0.2s ease;
}
.sns-cache-migration-progress-text {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.sns-cache-migration-items {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 180px;
overflow-y: auto;
padding-right: 4px;
}
.sns-cache-migration-item {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-tertiary);
padding: 8px 10px;
}
.sns-cache-migration-item-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.sns-cache-migration-item-detail {
margin-top: 4px;
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
line-height: 1.45;
}
.sns-cache-migration-error,
.sns-cache-migration-success {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
padding: 8px 10px;
border-radius: 8px;
}
.sns-cache-migration-error {
background: rgba(244, 67, 54, 0.1);
color: var(--color-error, #f44336);
}
.sns-cache-migration-success {
background: rgba(76, 175, 80, 0.1);
color: var(--color-success, #4caf50);
}
.sns-cache-migration-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 0 20px 18px;
}
.sns-cache-migration-btn {
min-width: 110px;
height: 38px;
border-radius: 9px;
border: 1px solid transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&.primary {
background: var(--primary, #576b95);
color: #fff;
}
&.secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
border-color: var(--border-color);
}
&:hover:not(:disabled) {
filter: brightness(1.05);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.author-timeline-dialog {
background: var(--sns-card-bg);
border-radius: var(--sns-border-radius-lg);
+274 -1
View File
@@ -66,6 +66,33 @@ type OverviewStatsStatus = 'loading' | 'ready' | 'error'
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1'
interface SnsCacheMigrationItem {
label: string
sourceDir: string
targetDir: string
fileCount: number
}
interface SnsCacheMigrationStatus {
totalFiles: number
legacyBaseDir?: string
currentBaseDir?: string
items: SnsCacheMigrationItem[]
}
interface SnsCacheMigrationProgress {
status: 'running' | 'done' | 'error'
phase: 'copying' | 'cleanup' | 'done' | 'error'
current: number
total: number
copied: number
skipped: number
remaining: number
message?: string
currentItemLabel?: string
}
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
try {
@@ -162,6 +189,12 @@ export default function SnsPage() {
const [triggerInstalled, setTriggerInstalled] = useState<boolean | null>(null)
const [triggerLoading, setTriggerLoading] = useState(false)
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [showCacheMigrationDialog, setShowCacheMigrationDialog] = useState(false)
const [cacheMigrationStatus, setCacheMigrationStatus] = useState<SnsCacheMigrationStatus | null>(null)
const [cacheMigrationProgress, setCacheMigrationProgress] = useState<SnsCacheMigrationProgress | null>(null)
const [cacheMigrationRunning, setCacheMigrationRunning] = useState(false)
const [cacheMigrationDone, setCacheMigrationDone] = useState(false)
const [cacheMigrationError, setCacheMigrationError] = useState<string | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(null)
const jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null)
@@ -185,6 +218,7 @@ export default function SnsPage() {
const contactsCountBatchTimerRef = useRef<number | null>(null)
const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map())
const jumpDateRequestSeqRef = useRef(0)
const checkedCacheMigrationRef = useRef(false)
// Sync posts ref
useEffect(() => {
@@ -595,6 +629,133 @@ export default function SnsPage() {
}
}, [persistSnsPageCache])
const markCacheMigrationPrompted = useCallback(() => {
try {
window.sessionStorage.setItem(SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY, '1')
} catch {
// ignore session storage failures
}
}, [])
const hasCacheMigrationPrompted = useCallback(() => {
try {
return window.sessionStorage.getItem(SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY) === '1'
} catch {
return false
}
}, [])
const checkCacheMigrationStatus = useCallback(async () => {
if (checkedCacheMigrationRef.current) return
checkedCacheMigrationRef.current = true
if (hasCacheMigrationPrompted()) return
try {
const result = await window.electronAPI.sns.getCacheMigrationStatus()
if (!result?.success || !result.needed) return
const totalFiles = Math.max(0, Number(result.totalFiles || 0))
const items = Array.isArray(result.items)
? result.items.map((item) => ({
label: String(item.label || '').trim(),
sourceDir: String(item.sourceDir || '').trim(),
targetDir: String(item.targetDir || '').trim(),
fileCount: Math.max(0, Number(item.fileCount || 0))
})).filter((item) => item.label && item.sourceDir && item.targetDir && item.fileCount > 0)
: []
if (totalFiles <= 0 || items.length === 0) return
setCacheMigrationStatus({
totalFiles,
legacyBaseDir: result.legacyBaseDir,
currentBaseDir: result.currentBaseDir,
items
})
setCacheMigrationProgress(null)
setCacheMigrationDone(false)
setCacheMigrationError(null)
setShowCacheMigrationDialog(true)
markCacheMigrationPrompted()
} catch (error) {
console.error('Failed to check SNS cache migration status:', error)
}
}, [hasCacheMigrationPrompted, markCacheMigrationPrompted])
const startCacheMigration = useCallback(async () => {
const total = Math.max(0, cacheMigrationStatus?.totalFiles || 0)
setCacheMigrationError(null)
setCacheMigrationDone(false)
setCacheMigrationRunning(true)
setCacheMigrationProgress({
status: 'running',
phase: 'copying',
current: 0,
total,
copied: 0,
skipped: 0,
remaining: total,
message: '准备迁移...'
})
const removeProgress = window.electronAPI.sns.onCacheMigrationProgress((payload) => {
if (!payload) return
setCacheMigrationProgress({
status: payload.status,
phase: payload.phase,
current: Math.max(0, Number(payload.current || 0)),
total: Math.max(0, Number(payload.total || 0)),
copied: Math.max(0, Number(payload.copied || 0)),
skipped: Math.max(0, Number(payload.skipped || 0)),
remaining: Math.max(0, Number(payload.remaining || 0)),
message: payload.message,
currentItemLabel: payload.currentItemLabel
})
if (payload.status === 'done') {
setCacheMigrationDone(true)
setCacheMigrationError(null)
} else if (payload.status === 'error') {
setCacheMigrationError(payload.message || '迁移失败')
}
})
try {
const result = await window.electronAPI.sns.startCacheMigration()
if (!result?.success) {
setCacheMigrationError(result?.error || '迁移失败')
} else {
const totalFiles = Math.max(0, Number(result.totalFiles || 0))
if (totalFiles === 0) {
setCacheMigrationDone(true)
setCacheMigrationProgress({
status: 'done',
phase: 'done',
current: 0,
total: 0,
copied: 0,
skipped: 0,
remaining: 0,
message: result.message || '无需迁移'
})
} else {
// 兜底:若 done 事件因时序原因未到达,仍以返回结果收敛到完成态。
setCacheMigrationDone(true)
setCacheMigrationProgress((prev) => prev || {
status: 'done',
phase: 'done',
current: totalFiles,
total: totalFiles,
copied: Math.max(0, Number(result.copied || 0)),
skipped: Math.max(0, Number(result.skipped || 0)),
remaining: 0,
message: '迁移完成'
})
}
}
} catch (error) {
setCacheMigrationError(String((error as Error)?.message || error || '迁移失败'))
} finally {
removeProgress()
setCacheMigrationRunning(false)
}
}, [cacheMigrationStatus?.totalFiles])
const renderOverviewRangeText = () => {
if (overviewStatsStatus === 'error') {
return (
@@ -1256,7 +1417,8 @@ export default function SnsPage() {
void hydrateSnsPageCache()
loadContacts()
loadOverviewStats()
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
void checkCacheMigrationStatus()
}, [checkCacheMigrationStatus, hydrateSnsPageCache, loadContacts, loadOverviewStats])
useEffect(() => {
const syncCurrentUserProfile = async () => {
@@ -1659,6 +1821,117 @@ export default function SnsPage() {
</div>
)}
{showCacheMigrationDialog && cacheMigrationStatus && (
<div
className="modal-overlay"
onClick={() => {
if (cacheMigrationRunning) return
setShowCacheMigrationDialog(false)
}}
>
<div className="sns-cache-migration-dialog" onClick={(e) => e.stopPropagation()}>
<button
className="close-btn sns-cache-migration-close"
onClick={() => !cacheMigrationRunning && setShowCacheMigrationDialog(false)}
disabled={cacheMigrationRunning}
>
<X size={18} />
</button>
<div className="sns-cache-migration-header">
<div className="sns-cache-migration-title">发现旧版朋友圈缓存</div>
<div className="sns-cache-migration-subtitle">
建议迁移到当前缓存目录,避免目录分散和重复占用空间
</div>
</div>
<div className="sns-cache-migration-body">
<div className="sns-cache-migration-meta">
<span>待处理文件</span>
<strong>{cacheMigrationStatus.totalFiles}</strong>
</div>
{cacheMigrationProgress && (
<div className="sns-cache-migration-progress">
<div className="sns-cache-migration-progress-bar">
<div
className="sns-cache-migration-progress-fill"
style={{
width: cacheMigrationProgress.total > 0
? `${Math.min(100, Math.round((cacheMigrationProgress.current / cacheMigrationProgress.total) * 100))}%`
: '100%'
}}
/>
</div>
<div className="sns-cache-migration-progress-text">
<span>{cacheMigrationProgress.message || '迁移中...'}</span>
<span>
已迁移 {cacheMigrationProgress.copied},剩余 {cacheMigrationProgress.remaining},跳过重复 {cacheMigrationProgress.skipped}
</span>
</div>
</div>
)}
{!cacheMigrationProgress && (
<div className="sns-cache-migration-items">
{cacheMigrationStatus.items.map((item, idx) => (
<div className="sns-cache-migration-item" key={`${item.label}-${idx}`}>
<div className="sns-cache-migration-item-title">{item.label}</div>
<div className="sns-cache-migration-item-detail">
{item.fileCount} 个文件 · {item.sourceDir} → {item.targetDir}
</div>
</div>
))}
</div>
)}
{cacheMigrationError && (
<div className="sns-cache-migration-error">
<AlertCircle size={14} />
<span>{cacheMigrationError}</span>
</div>
)}
{cacheMigrationDone && !cacheMigrationError && (
<div className="sns-cache-migration-success">
<CheckCircle size={14} />
<span>迁移完成,旧目录已清理。</span>
</div>
)}
</div>
<div className="sns-cache-migration-actions">
{!cacheMigrationDone ? (
<>
<button
className="sns-cache-migration-btn secondary"
onClick={() => setShowCacheMigrationDialog(false)}
disabled={cacheMigrationRunning}
>
稍后再说
</button>
<button
className="sns-cache-migration-btn primary"
onClick={() => { void startCacheMigration() }}
disabled={cacheMigrationRunning}
>
{cacheMigrationRunning ? '迁移中...' : '开始迁移'}
</button>
</>
) : (
<button
className="sns-cache-migration-btn primary"
onClick={() => setShowCacheMigrationDialog(false)}
disabled={cacheMigrationRunning}
>
完成
</button>
)}
</div>
</div>
</div>
)}
{/* 朋友圈防删除插件对话框 */}
{showTriggerDialog && (
<div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
+20 -5
View File
@@ -16,6 +16,7 @@ const isLinux = navigator.userAgent.toLowerCase().includes('linux')
const isWindows = !isMac && !isLinux
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试'
const dbPathPlaceholder = isMac
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
: isLinux
@@ -221,10 +222,23 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (!path) return null
// 检测中文字符和其他可能有问题的特殊字符
if (/[\u4e00-\u9fa5]/.test(path)) {
return '路径包含中文字符,请迁移至全英文目录'
return DB_PATH_CHINESE_ERROR
}
return null
}
const dbPathValidationError = validatePath(dbPath)
const handleDbPathChange = (value: string) => {
setDbPath(value)
const validationError = validatePath(value)
if (validationError) {
setError(validationError)
return
}
if (error === DB_PATH_CHINESE_ERROR) {
setError('')
}
}
const handleSelectPath = async () => {
try {
@@ -236,10 +250,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
const validationError = validatePath(selectedPath)
setDbPath(selectedPath)
if (validationError) {
setError(validationError)
} else {
setDbPath(selectedPath)
setError('')
}
}
@@ -256,10 +270,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const result = await window.electronAPI.dbPath.autoDetect()
if (result.success && result.path) {
const validationError = validatePath(result.path)
setDbPath(result.path)
if (validationError) {
setError(validationError)
} else {
setDbPath(result.path)
setError('')
}
} else {
@@ -426,7 +440,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const canGoNext = () => {
if (currentStep.id === 'intro') return true
if (currentStep.id === 'db') return Boolean(dbPath)
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
if (currentStep.id === 'cache') return true
if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid)
if (currentStep.id === 'image') return true
@@ -442,6 +456,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const handleNext = () => {
if (!canGoNext()) {
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
if (currentStep.id === 'key') {
if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符')
else if (!wxid) setError('未能自动识别 wxid,请尝试重新获取或检查目录')
@@ -664,7 +679,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
className="field-input"
placeholder={dbPathPlaceholder}
value={dbPath}
onChange={(e) => setDbPath(e.target.value)}
onChange={(e) => handleDbPathChange(e.target.value)}
/>
</div>
<div className="action-row">
+91 -3
View File
@@ -343,6 +343,52 @@ export interface ElectronAPI {
}>
getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; error?: string }>
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
getResourceMessages: (options?: {
sessionId?: string
types?: Array<'image' | 'video' | 'voice' | 'file'>
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => Promise<{
success: boolean
items?: Array<Message & {
sessionId: string
sessionDisplayName?: string
resourceType: 'image' | 'video' | 'voice' | 'file'
}>
total?: number
hasMore?: boolean
error?: string
}>
getMediaStream: (options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => Promise<{
success: boolean
items?: Array<{
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
}>
hasMore?: boolean
nextOffset?: number
error?: string
}>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void
@@ -357,13 +403,33 @@ export interface ElectronAPI {
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean }
) => Promise<{
success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
error?: string
}>
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean }
) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
onDecryptProgress: (callback: (payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => void) => () => void
}
video: {
getVideoInfo: (videoMd5: string) => Promise<{
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => Promise<{
success: boolean
exists: boolean
videoUrl?: string
@@ -881,6 +947,28 @@ export interface ElectronAPI {
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }>
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }>
getCacheMigrationStatus: () => Promise<{
success: boolean
needed: boolean
inProgress?: boolean
totalFiles?: number
legacyBaseDir?: string
currentBaseDir?: string
items?: Array<{ label: string; sourceDir: string; targetDir: string; fileCount: number }>
error?: string
}>
startCacheMigration: () => Promise<{ success: boolean; copied?: number; skipped?: number; totalFiles?: number; message?: string; error?: string }>
onCacheMigrationProgress: (callback: (payload: {
status: 'running' | 'done' | 'error'
phase: 'copying' | 'cleanup' | 'done' | 'error'
current: number
total: number
copied: number
skipped: number
remaining: number
message?: string
currentItemLabel?: string
}) => void) => () => void
}
cloud: {
init: () => Promise<void>