feat(export): add persistent session export records in detail panel

This commit is contained in:
tisonhuang
2026-03-04 19:20:16 +08:00
parent 4b57e3e350
commit b6fd842d4e
4 changed files with 232 additions and 13 deletions

View File

@@ -151,6 +151,10 @@ const contentTypeLabels: Record<ContentType, string> = {
emoji: '表情包'
}
const getContentTypeLabel = (type: ContentType): string => {
return contentTypeLabels[type] || type
}
const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
@@ -385,6 +389,14 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
return `${y}-${m}-${day} ${h}:${min}`
}
const formatPathBrief = (value: string, maxLength = 52): string => {
const normalized = String(value || '')
if (normalized.length <= maxLength) return normalized
const headLength = Math.max(10, Math.floor(maxLength * 0.55))
const tailLength = Math.max(8, maxLength - headLength - 1)
return `${normalized.slice(0, headLength)}${normalized.slice(-tailLength)}`
}
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return ''
const diff = Math.max(0, now - timestamp)
@@ -931,6 +943,7 @@ function ExportPage() {
const [tasks, setTasks] = useState<ExportTask[]>([])
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({})
const [exportRecordsBySession, setExportRecordsBySession] = useState<Record<string, configService.ExportSessionRecordEntry[]>>({})
const [lastSnsExportPostCount, setLastSnsExportPostCount] = useState(0)
const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({
totalPosts: 0,
@@ -1331,7 +1344,7 @@ function ExportPage() {
setIsBaseConfigLoading(true)
let isReady = true
try {
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, exportCacheScope] = await Promise.all([
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, exportCacheScope] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultFormat(),
configService.getExportDefaultMedia(),
@@ -1342,6 +1355,7 @@ function ExportPage() {
configService.getExportWriteLayout(),
configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(),
configService.getExportLastSnsPostCount(),
ensureExportCacheScope()
])
@@ -1358,6 +1372,7 @@ function ExportPage() {
setWriteLayout(savedWriteLayout)
setLastExportBySession(savedSessionMap)
setLastExportByContent(savedContentMap)
setExportRecordsBySession(savedSessionRecordMap)
setLastSnsExportPostCount(savedSnsPostCount)
if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) {
@@ -2252,6 +2267,67 @@ function ExportPage() {
})
}, [])
const resolveTaskExportContentLabel = useCallback((payload: ExportTaskPayload): string => {
if (payload.scope === 'content' && payload.contentType) {
return getContentTypeLabel(payload.contentType)
}
if (payload.scope === 'sns') return '朋友圈'
const labels: string[] = ['聊天文本']
const opts = payload.options
if (opts?.exportMedia) {
if (opts.exportImages) labels.push('图片')
if (opts.exportVoices) labels.push('语音')
if (opts.exportVideos) labels.push('视频')
if (opts.exportEmojis) labels.push('表情包')
}
return Array.from(new Set(labels)).join('、')
}, [])
const markSessionExportRecords = useCallback((
sessionIds: string[],
content: string,
outputDir: string,
exportTime: number
) => {
const normalizedContent = String(content || '').trim()
const normalizedOutputDir = String(outputDir || '').trim()
const normalizedExportTime = Number.isFinite(exportTime) ? Math.max(0, Math.floor(exportTime)) : Date.now()
if (!normalizedContent || !normalizedOutputDir) return
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return
setExportRecordsBySession(prev => {
const next: Record<string, configService.ExportSessionRecordEntry[]> = { ...prev }
let changed = false
for (const rawSessionId of sessionIds) {
const sessionId = String(rawSessionId || '').trim()
if (!sessionId) continue
const existingList = Array.isArray(next[sessionId]) ? [...next[sessionId]] : []
const lastRecord = existingList[existingList.length - 1]
if (
lastRecord &&
lastRecord.content === normalizedContent &&
lastRecord.outputDir === normalizedOutputDir &&
Math.abs(Number(lastRecord.exportTime || 0) - normalizedExportTime) <= 2000
) {
continue
}
existingList.push({
exportTime: normalizedExportTime,
content: normalizedContent,
outputDir: normalizedOutputDir
})
next[sessionId] = existingList.slice(-80)
changed = true
}
if (!changed) return prev
void configService.setExportSessionRecordMap(next)
return next
})
}, [])
const inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => {
const types: ContentType[] = ['text']
if (opts.exportMedia) {
@@ -2286,6 +2362,7 @@ function ExportPage() {
? (task.performance || createEmptyTaskPerformance())
: task.performance
}))
const taskExportContentLabel = resolveTaskExportContentLabel(next.payload)
progressUnsubscribeRef.current?.()
const settledSessionIdsFromProgress = new Set<string>()
@@ -2323,6 +2400,7 @@ function ExportPage() {
if (contentTypes.length > 0) {
markContentExported([currentSessionId], contentTypes, now)
}
markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now)
}
}
@@ -2427,8 +2505,14 @@ function ExportPage() {
? result.successSessionIds
: []
if (successSessionIds.length > 0) {
markSessionExported(successSessionIds, doneAt)
markContentExported(successSessionIds, contentTypes, doneAt)
const unsettledSuccessSessionIds = successSessionIds.filter((sessionId) => !settledSessionIdsFromProgress.has(sessionId))
if (unsettledSuccessSessionIds.length > 0) {
markSessionExported(unsettledSuccessSessionIds, doneAt)
markSessionExportRecords(unsettledSuccessSessionIds, taskExportContentLabel, next.payload.outputDir, doneAt)
if (contentTypes.length > 0) {
markContentExported(unsettledSuccessSessionIds, contentTypes, doneAt)
}
}
}
updateTask(next.id, task => ({
@@ -2462,7 +2546,15 @@ function ExportPage() {
runningTaskIdRef.current = null
void runNextTask()
}
}, [updateTask, markSessionExported, markContentExported, loadSnsStats, lastSnsExportPostCount])
}, [
updateTask,
markSessionExported,
markSessionExportRecords,
markContentExported,
resolveTaskExportContentLabel,
loadSnsStats,
lastSnsExportPostCount
])
useEffect(() => {
void runNextTask()
@@ -2945,6 +3037,15 @@ function ExportPage() {
return map
}, [contactsList])
const currentSessionExportRecords = useMemo(() => {
const sessionId = String(sessionDetail?.wxid || '').trim()
if (!sessionId) return [] as configService.ExportSessionRecordEntry[]
const records = Array.isArray(exportRecordsBySession[sessionId]) ? exportRecordsBySession[sessionId] : []
return [...records]
.sort((a, b) => Number(b.exportTime || 0) - Number(a.exportTime || 0))
.slice(0, 20)
}, [sessionDetail?.wxid, exportRecordsBySession])
const applySessionDetailStats = useCallback((
sessionId: string,
metric: SessionExportMetric,
@@ -4253,6 +4354,42 @@ function ExportPage() {
)}
</div>
<div className="detail-section">
<div className="section-title">
<ClipboardList size={14} />
<span> 20 </span>
</div>
{currentSessionExportRecords.length === 0 ? (
<div className="detail-record-empty"></div>
) : (
<div className="detail-record-list">
{currentSessionExportRecords.map((record, index) => (
<div className="detail-record-item" key={`${record.exportTime}-${record.content}-${index}`}>
<div className="record-row">
<span className="label"></span>
<span className="value">{formatYmdHmDateTime(record.exportTime)}</span>
</div>
<div className="record-row">
<span className="label"></span>
<span className="value">{record.content}</span>
</div>
<div className="record-row">
<span className="label"></span>
<span className="value path" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span>
<button
className="detail-inline-btn"
type="button"
onClick={() => void window.electronAPI.shell.openPath(record.outputDir)}
>
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="detail-section">
<div className="section-title">
<MessageSquare size={14} />