mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): add persistent session export records in detail panel
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user