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

@@ -6463,14 +6463,6 @@ class ExportService {
failCount++ failCount++
failedSessionIds.push(sessionId) failedSessionIds.push(sessionId)
console.error(`导出 ${sessionId} 失败:`, result.error) console.error(`导出 ${sessionId} 失败:`, result.error)
onProgress?.({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'complete',
phaseLabel: '导出失败'
})
} }
activeSessionRatios.delete(sessionId) activeSessionRatios.delete(sessionId)
@@ -6480,7 +6472,8 @@ class ExportService {
total: sessionIds.length, total: sessionIds.length,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
currentSessionId: sessionId, currentSessionId: sessionId,
phase: 'exporting' phase: 'complete',
phaseLabel: result.success ? '完成' : '导出失败'
}) })
return 'done' return 'done'
} catch (error) { } catch (error) {

View File

@@ -1901,6 +1901,56 @@
} }
} }
.detail-record-empty {
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-secondary);
font-size: 12px;
color: var(--text-secondary);
}
.detail-record-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.detail-record-item {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 10px;
background: var(--bg-primary);
.record-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
.label {
color: var(--text-secondary);
width: 56px;
flex-shrink: 0;
}
.value {
color: var(--text-primary);
flex: 1;
text-align: right;
word-break: break-all;
&.path {
text-align: left;
}
}
.detail-inline-btn {
flex-shrink: 0;
}
}
}
.table-list { .table-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -151,6 +151,10 @@ const contentTypeLabels: Record<ContentType, string> = {
emoji: '表情包' emoji: '表情包'
} }
const getContentTypeLabel = (type: ContentType): string => {
return contentTypeLabels[type] || type
}
const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [ const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
@@ -385,6 +389,14 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
return `${y}-${m}-${day} ${h}:${min}` 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 => { const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return '' if (!timestamp) return ''
const diff = Math.max(0, now - timestamp) const diff = Math.max(0, now - timestamp)
@@ -931,6 +943,7 @@ function ExportPage() {
const [tasks, setTasks] = useState<ExportTask[]>([]) const [tasks, setTasks] = useState<ExportTask[]>([])
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({}) const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
const [lastExportByContent, setLastExportByContent] = 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 [lastSnsExportPostCount, setLastSnsExportPostCount] = useState(0)
const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({ const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({
totalPosts: 0, totalPosts: 0,
@@ -1331,7 +1344,7 @@ function ExportPage() {
setIsBaseConfigLoading(true) setIsBaseConfigLoading(true)
let isReady = true let isReady = true
try { 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.getExportPath(),
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultMedia(), configService.getExportDefaultMedia(),
@@ -1342,6 +1355,7 @@ function ExportPage() {
configService.getExportWriteLayout(), configService.getExportWriteLayout(),
configService.getExportLastSessionRunMap(), configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(), configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(),
configService.getExportLastSnsPostCount(), configService.getExportLastSnsPostCount(),
ensureExportCacheScope() ensureExportCacheScope()
]) ])
@@ -1358,6 +1372,7 @@ function ExportPage() {
setWriteLayout(savedWriteLayout) setWriteLayout(savedWriteLayout)
setLastExportBySession(savedSessionMap) setLastExportBySession(savedSessionMap)
setLastExportByContent(savedContentMap) setLastExportByContent(savedContentMap)
setExportRecordsBySession(savedSessionRecordMap)
setLastSnsExportPostCount(savedSnsPostCount) setLastSnsExportPostCount(savedSnsPostCount)
if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { 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 inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => {
const types: ContentType[] = ['text'] const types: ContentType[] = ['text']
if (opts.exportMedia) { if (opts.exportMedia) {
@@ -2286,6 +2362,7 @@ function ExportPage() {
? (task.performance || createEmptyTaskPerformance()) ? (task.performance || createEmptyTaskPerformance())
: task.performance : task.performance
})) }))
const taskExportContentLabel = resolveTaskExportContentLabel(next.payload)
progressUnsubscribeRef.current?.() progressUnsubscribeRef.current?.()
const settledSessionIdsFromProgress = new Set<string>() const settledSessionIdsFromProgress = new Set<string>()
@@ -2323,6 +2400,7 @@ function ExportPage() {
if (contentTypes.length > 0) { if (contentTypes.length > 0) {
markContentExported([currentSessionId], contentTypes, now) markContentExported([currentSessionId], contentTypes, now)
} }
markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now)
} }
} }
@@ -2427,8 +2505,14 @@ function ExportPage() {
? result.successSessionIds ? result.successSessionIds
: [] : []
if (successSessionIds.length > 0) { if (successSessionIds.length > 0) {
markSessionExported(successSessionIds, doneAt) const unsettledSuccessSessionIds = successSessionIds.filter((sessionId) => !settledSessionIdsFromProgress.has(sessionId))
markContentExported(successSessionIds, contentTypes, doneAt) 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 => ({ updateTask(next.id, task => ({
@@ -2462,7 +2546,15 @@ function ExportPage() {
runningTaskIdRef.current = null runningTaskIdRef.current = null
void runNextTask() void runNextTask()
} }
}, [updateTask, markSessionExported, markContentExported, loadSnsStats, lastSnsExportPostCount]) }, [
updateTask,
markSessionExported,
markSessionExportRecords,
markContentExported,
resolveTaskExportContentLabel,
loadSnsStats,
lastSnsExportPostCount
])
useEffect(() => { useEffect(() => {
void runNextTask() void runNextTask()
@@ -2945,6 +3037,15 @@ function ExportPage() {
return map return map
}, [contactsList]) }, [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(( const applySessionDetailStats = useCallback((
sessionId: string, sessionId: string,
metric: SessionExportMetric, metric: SessionExportMetric,
@@ -4253,6 +4354,42 @@ function ExportPage() {
)} )}
</div> </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="detail-section">
<div className="section-title"> <div className="section-title">
<MessageSquare size={14} /> <MessageSquare size={14} />

View File

@@ -35,6 +35,7 @@ export const CONFIG_KEYS = {
EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap',
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap', EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
@@ -443,6 +444,44 @@ export async function setExportLastContentRunMap(map: Record<string, number>): P
await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map) await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map)
} }
export interface ExportSessionRecordEntry {
exportTime: number
content: string
outputDir: string
}
export async function getExportSessionRecordMap(): Promise<Record<string, ExportSessionRecordEntry[]>> {
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP)
if (!value || typeof value !== 'object') return {}
const map: Record<string, ExportSessionRecordEntry[]> = {}
const entries = Object.entries(value as Record<string, unknown>)
for (const [sessionId, rawList] of entries) {
if (!Array.isArray(rawList)) continue
const normalizedList: ExportSessionRecordEntry[] = []
for (const rawItem of rawList) {
if (!rawItem || typeof rawItem !== 'object') continue
const exportTime = Number((rawItem as Record<string, unknown>).exportTime)
const content = String((rawItem as Record<string, unknown>).content || '').trim()
const outputDir = String((rawItem as Record<string, unknown>).outputDir || '').trim()
if (!Number.isFinite(exportTime) || exportTime <= 0) continue
if (!content || !outputDir) continue
normalizedList.push({
exportTime: Math.floor(exportTime),
content,
outputDir
})
}
if (normalizedList.length > 0) {
map[sessionId] = normalizedList
}
}
return map
}
export async function setExportSessionRecordMap(map: Record<string, ExportSessionRecordEntry[]>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP, map)
}
export async function getExportLastSnsPostCount(): Promise<number> { export async function getExportLastSnsPostCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT) const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT)
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {