mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(export): add persistent session export records in detail panel
This commit is contained in:
@@ -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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -35,6 +35,7 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
||||
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
||||
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
|
||||
EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap',
|
||||
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
|
||||
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
||||
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)
|
||||
}
|
||||
|
||||
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> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT)
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
|
||||
Reference in New Issue
Block a user