图片与视频索引优化 #786;修复 #786;修复导出页面打开目录缺失路径的问题;完善朋友圈卡片封面解析

This commit is contained in:
cc
2026-04-18 12:54:14 +08:00
parent 74012ab252
commit 6c84e0c35a
15 changed files with 1250 additions and 573 deletions

View File

@@ -3043,6 +3043,30 @@
gap: 10px;
}
.table-name-summary {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
margin-bottom: 10px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
font-size: 12px;
.table-name-label {
color: var(--text-secondary);
flex-shrink: 0;
}
.table-name-value {
color: var(--text-primary);
font-weight: 500;
word-break: break-all;
user-select: text;
}
}
.detail-table-placeholder {
padding: 11px 12px;
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
@@ -3056,6 +3080,7 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
@@ -3071,15 +3096,17 @@
.db-name {
color: var(--text-primary);
font-weight: 500;
max-width: 62%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.35;
word-break: break-all;
user-select: text;
flex: 1;
}
.table-count {
color: var(--primary);
font-weight: 600;
flex-shrink: 0;
user-select: text;
}
}
}

View File

@@ -7424,14 +7424,29 @@ function ChatPage(props: ChatPageProps) {
<span></span>
</div>
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
<div className="table-list">
{sessionDetail.messageTables.map((t, i) => (
<div key={i} className="table-item">
<span className="db-name">{t.dbName}</span>
<span className="table-count">{t.count.toLocaleString()} </span>
</div>
))}
</div>
<>
<div className="table-name-summary">
<span className="table-name-label"></span>
<span className="table-name-value">
{(() => {
const tableNames = Array.from(new Set(
sessionDetail.messageTables
.map(item => String(item.tableName || '').trim())
.filter(Boolean)
))
return tableNames[0] || '—'
})()}
</span>
</div>
<div className="table-list">
{sessionDetail.messageTables.map((t, i) => (
<div key={`${t.dbName}-${t.tableName}-${i}`} className="table-item">
<span className="db-name">{t.dbName || '—'}</span>
<span className="table-count">{t.count.toLocaleString()} </span>
</div>
))}
</div>
</>
) : (
<div className="detail-table-placeholder">
{isLoadingDetailExtra ? '统计中...' : '暂无统计数据'}

View File

@@ -178,6 +178,7 @@ interface ExportTask {
title: string
status: TaskStatus
settledSessionIds?: string[]
sessionOutputPaths?: Record<string, string>
createdAt: number
startedAt?: number
finishedAt?: number
@@ -653,6 +654,32 @@ const formatPathBrief = (value: string, maxLength = 52): string => {
return `${normalized.slice(0, headLength)}${normalized.slice(-tailLength)}`
}
const resolveParentDir = (value: string): string => {
const normalized = String(value || '').trim()
if (!normalized) return ''
const noTrailing = normalized.replace(/[\\/]+$/, '')
if (!noTrailing) return normalized
const lastSlash = Math.max(noTrailing.lastIndexOf('/'), noTrailing.lastIndexOf('\\'))
if (lastSlash < 0) return normalized
if (lastSlash === 0) return noTrailing.slice(0, 1)
if (/^[A-Za-z]:$/.test(noTrailing.slice(0, lastSlash))) {
return `${noTrailing.slice(0, lastSlash)}\\`
}
return noTrailing.slice(0, lastSlash)
}
const resolveTaskOpenDir = (task: ExportTask): string => {
const sessionIds = Array.isArray(task.payload.sessionIds) ? task.payload.sessionIds : []
if (sessionIds.length === 1) {
const onlySessionId = String(sessionIds[0] || '').trim()
const outputPath = onlySessionId ? String(task.sessionOutputPaths?.[onlySessionId] || '').trim() : ''
if (outputPath) {
return resolveParentDir(outputPath) || task.payload.outputDir
}
}
return task.payload.outputDir
}
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return ''
const diff = Math.max(0, now - timestamp)
@@ -2005,7 +2032,14 @@ const TaskCenterModal = memo(function TaskCenterModal({
{isPerfExpanded ? '收起详情' : '性能详情'}
</button>
)}
<button className="task-action-btn" onClick={() => task.payload.outputDir && void window.electronAPI.shell.openPath(task.payload.outputDir)}>
<button
className="task-action-btn"
onClick={() => {
const openDir = resolveTaskOpenDir(task)
if (!openDir) return
void window.electronAPI.shell.openPath(openDir)
}}
>
<FolderOpen size={14} />
</button>
</div>
@@ -5715,6 +5749,12 @@ function ExportPage() {
...task,
status: 'success',
finishedAt: doneAt,
sessionOutputPaths: {
...(task.sessionOutputPaths || {}),
...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object')
? result.sessionOutputPaths
: {})
},
progress: {
...task.progress,
current: task.progress.total || next.payload.sessionIds.length,

View File

@@ -281,10 +281,10 @@
}
}
.floating-info,
.floating-delete {
position: absolute;
top: 10px;
right: 10px;
z-index: 4;
width: 28px;
height: 28px;
@@ -302,6 +302,18 @@
transition: opacity 0.16s ease, transform 0.16s ease;
}
.floating-info {
right: 10px;
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--text-primary);
}
.floating-delete {
right: 44px;
}
.media-card:hover .floating-info,
.media-card:focus-within .floating-info,
.media-card:hover .floating-delete,
.media-card:focus-within .floating-delete {
opacity: 1;
@@ -490,7 +502,9 @@
.resource-dialog-mask {
position: absolute;
inset: 0;
background: rgba(8, 11, 18, 0.24);
background: rgba(8, 11, 18, 0.46);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
@@ -498,11 +512,13 @@
}
.resource-dialog {
--dialog-surface: color-mix(in srgb, var(--bg-primary, #ffffff) 82%, var(--card-inner-bg, #ffffff) 18%);
--dialog-surface-header: color-mix(in srgb, var(--dialog-surface) 90%, var(--bg-secondary, #ffffff) 10%);
width: min(420px, calc(100% - 32px));
background: var(--card-bg, #ffffff);
background: var(--dialog-surface);
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);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
overflow: hidden;
}
@@ -512,7 +528,7 @@
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);
background: var(--dialog-surface-header);
}
.dialog-body {
@@ -521,6 +537,34 @@
color: var(--text-secondary);
line-height: 1.55;
white-space: pre-wrap;
background: var(--dialog-surface);
}
.dialog-info-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.dialog-info-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 10px;
align-items: flex-start;
}
.info-label {
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.45;
}
.info-value {
color: var(--text-primary);
line-height: 1.5;
word-break: break-all;
white-space: pre-wrap;
user-select: text;
}
.dialog-actions {
@@ -528,6 +572,7 @@
display: flex;
justify-content: flex-end;
gap: 8px;
background: var(--dialog-surface);
}
.dialog-btn {

View File

@@ -1,5 +1,5 @@
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 { Calendar, Image as ImageIcon, Info, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
import { VirtuosoGrid } from 'react-virtuoso'
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
import './ResourcesPage.scss'
@@ -28,9 +28,10 @@ interface ContactOption {
}
type DialogState = {
mode: 'alert' | 'confirm'
mode: 'alert' | 'confirm' | 'info'
title: string
message: string
message?: string
infoRows?: Array<{ label: string; value: string }>
confirmText?: string
cancelText?: string
onConfirm?: (() => void) | null
@@ -115,6 +116,12 @@ function formatTimeLabel(timestampSec: number): string {
})
}
function formatInfoValue(value: unknown): string {
if (value === null || value === undefined) return '-'
const text = String(value).trim()
return text || '-'
}
function extractVideoTitle(content?: string): string {
const xml = String(content || '')
if (!xml) return '视频'
@@ -152,6 +159,7 @@ const MediaCard = memo(function MediaCard({
decrypting,
onToggleSelect,
onDelete,
onShowInfo,
onImagePreviewAction,
onUpdateImageQuality,
onOpenVideo,
@@ -167,6 +175,7 @@ const MediaCard = memo(function MediaCard({
decrypting: boolean
onToggleSelect: (item: MediaStreamItem) => void
onDelete: (item: MediaStreamItem) => void
onShowInfo: (item: MediaStreamItem) => void
onImagePreviewAction: (item: MediaStreamItem) => void
onUpdateImageQuality: (item: MediaStreamItem) => void
onOpenVideo: (item: MediaStreamItem) => void
@@ -178,6 +187,9 @@ const MediaCard = memo(function MediaCard({
return (
<article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}>
<button type="button" className="floating-info" onClick={() => onShowInfo(item)} aria-label="查看资源信息">
<Info size={14} />
</button>
<button type="button" className="floating-delete" onClick={() => onDelete(item)} aria-label="删除资源">
<Trash2 size={14} />
</button>
@@ -796,6 +808,93 @@ function ResourcesPage() {
return md5
}, [])
const showMediaInfo = useCallback(async (item: MediaStreamItem) => {
const itemKey = getItemKey(item)
const mediaLabel = item.mediaType === 'image' ? '图片' : '视频'
const baseRows: Array<{ label: string; value: string }> = [
{ label: '资源类型', value: mediaLabel },
{ label: '会话 ID', value: formatInfoValue(item.sessionId) },
{ label: '消息 LocalId', value: formatInfoValue(item.localId) },
{ label: '消息时间', value: formatTimeLabel(item.createTime) },
{ label: '发送方', value: formatInfoValue(item.senderUsername) },
{ label: '是否我发送', value: item.isSend === 1 ? '是' : (item.isSend === 0 ? '否' : '-') }
]
setDialog({
mode: 'info',
title: `${mediaLabel}信息`,
infoRows: [...baseRows, { label: '状态', value: '正在读取缓存信息...' }],
confirmText: '关闭',
onConfirm: null
})
try {
if (item.mediaType === 'image') {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: true,
suppressEvents: true
})
const previewPath = previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey] || ''
const cachePath = String(resolved?.localPath || previewPath || '').trim()
const rows: Array<{ label: string; value: string }> = [
...baseRows,
{ label: 'imageMd5', value: formatInfoValue(normalizeMediaToken(item.imageMd5)) },
{ label: 'imageDatName', value: formatInfoValue(getSafeImageDatName(item)) },
{ label: '列表预览路径', value: formatInfoValue(previewPath) },
{ label: '缓存命中', value: resolved?.success && cachePath ? '是' : '否' },
{ label: '缓存路径', value: formatInfoValue(cachePath) },
{ label: '缓存可更新', value: resolved?.hasUpdate ? '是' : '否' },
{ label: '缓存状态', value: resolved?.success ? '可用' : formatInfoValue(resolved?.error || resolved?.failureKind || '未命中') }
]
setDialog({
mode: 'info',
title: '图片信息',
infoRows: rows,
confirmText: '关闭',
onConfirm: null
})
return
}
const resolvedMd5 = await resolveItemVideoMd5(item)
const videoInfo = resolvedMd5
? await window.electronAPI.video.getVideoInfo(resolvedMd5, { includePoster: true, posterFormat: 'fileUrl' })
: null
const posterPath = videoPosterMapRef.current[itemKey] || posterPatchRef.current[itemKey] || ''
const rows: Array<{ label: string; value: string }> = [
...baseRows,
{ label: 'videoMd5(消息)', value: formatInfoValue(normalizeMediaToken(item.videoMd5)) },
{ label: 'videoMd5(解析)', value: formatInfoValue(resolvedMd5) },
{ label: '视频文件存在', value: videoInfo?.success && videoInfo.exists ? '是' : '否' },
{ label: '视频路径', value: formatInfoValue(videoInfo?.videoUrl) },
{ label: '同名封面路径', value: formatInfoValue(videoInfo?.coverUrl) },
{ label: '列表封面路径', value: formatInfoValue(posterPath) },
{ label: '视频状态', value: videoInfo?.success ? '可用' : formatInfoValue(videoInfo?.error || '未找到') }
]
setDialog({
mode: 'info',
title: '视频信息',
infoRows: rows,
confirmText: '关闭',
onConfirm: null
})
} catch (e) {
setDialog({
mode: 'info',
title: `${mediaLabel}信息`,
infoRows: [...baseRows, { label: '读取失败', value: formatInfoValue(String(e)) }],
confirmText: '关闭',
onConfirm: null
})
}
}, [resolveItemVideoMd5])
const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => {
if (item.mediaType !== 'video') return
const itemKey = getItemKey(item)
@@ -815,7 +914,7 @@ function ResourcesPage() {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
}
const poster = String(info.coverUrl || info.thumbUrl || '')
const poster = String(info.coverUrl || '')
if (!poster) {
attemptedVideoPosterKeysRef.current.add(itemKey)
return
@@ -1371,6 +1470,7 @@ function ResourcesPage() {
decrypting={decryptingKeys.has(itemKey)}
onToggleSelect={toggleSelect}
onDelete={deleteOne}
onShowInfo={showMediaInfo}
onImagePreviewAction={onImagePreviewAction}
onUpdateImageQuality={updateImageQuality}
onOpenVideo={openVideo}
@@ -1388,7 +1488,20 @@ function ResourcesPage() {
<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>
<div className="dialog-body">
{dialog.mode === 'info' ? (
<div className="dialog-info-list">
{(dialog.infoRows || []).map((row, idx) => (
<div className="dialog-info-row" key={`${row.label}-${idx}`}>
<span className="info-label">{row.label}</span>
<span className="info-value" title={row.value}>{row.value}</span>
</div>
))}
</div>
) : (
dialog.message
)}
</div>
<footer className="dialog-actions">
{dialog.mode === 'confirm' && (
<button type="button" className="dialog-btn ghost" onClick={closeDialog}>