This commit is contained in:
cc committed 2026-07-01 15:34:37 +08:00
1 parent 99dcb6e43b
commit 301cb5abce
48 files changed
+5444 -1015

No files matched your search

+69 -1
View File
@@ -6,7 +6,7 @@
*/
import { memo, useEffect, useState, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import ExportTopBar from './components/ExportTopBar'
import SessionTable from './components/SessionTable'
import ExportDialog from './components/ExportDialog'
@@ -28,18 +28,26 @@ import { AutomationTaskForm } from './components/Automation/AutomationTaskForm'
import type { SessionRow } from './types'
import { getSelectionScopeFromRows, resolveScopeDisplayNames } from './utils/session'
import {
emitSingleExportDialogStatus,
onOpenSingleExport,
type OpenSingleExportPayload
} from '../../services/exportBridge'
import './ExportPage.scss'
function ExportPage() {
const location = useLocation()
const navigate = useNavigate()
const isExportRoute = location.pathname === '/export'
const [selectedSessionIds, setSelectedSessionIds] = useState<Set<string>>(new Set())
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
const [isAutomationModalOpen, setIsAutomationModalOpen] = useState(false)
const [draftAutomationPayload, setDraftAutomationPayload] = useState<any>(null)
const [pendingSingleExportRequest, setPendingSingleExportRequest] = useState<OpenSingleExportPayload | null>(null)
const {
isLoaded: isConfigLoaded,
options,
updateOptions,
exportPath,
@@ -63,6 +71,7 @@ function ExportPage() {
const { metricsMap, fetchMetrics } = useSessionMetrics()
const {
sessions,
filteredSessions,
isLoading: isSessionsLoading,
activeTab,
@@ -115,6 +124,13 @@ function ExportPage() {
// ── 4. Dialog & Exports ──
const { dialogState, openDialog, closeDialog } = useExportDialog()
const resolveSingleExportName = useCallback((payload: OpenSingleExportPayload) => {
const sessionId = String(payload.sessionId || '').trim()
const payloadName = typeof payload.sessionName === 'string' ? payload.sessionName.trim() : ''
const sessionRow = sessions.find(s => s.username === sessionId)
return payloadName || sessionRow?.displayName || sessionRow?.remark || sessionRow?.nickname || sessionId
}, [sessions])
const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => {
updateOptions({
...(patch.format ? { defaultFormat: patch.format as any } : {}),
@@ -171,6 +187,58 @@ function ExportPage() {
})
}, [displayedSessions, options.displayNamePreference, openDialog])
const handleOpenSingleExportRequest = useCallback((payload: OpenSingleExportPayload) => {
const requestId = typeof payload?.requestId === 'string' ? payload.requestId.trim() : ''
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId.trim() : ''
if (!requestId || !sessionId) {
if (requestId) {
emitSingleExportDialogStatus({
requestId,
status: 'failed',
message: '无法打开导出配置:缺少会话信息'
})
}
return
}
emitSingleExportDialogStatus({ requestId, status: 'initializing' })
if (!isConfigLoaded) {
setPendingSingleExportRequest({
...payload,
requestId,
sessionId
})
return
}
const sessionName = resolveSingleExportName(payload)
setSelectedSessionIds(new Set([sessionId]))
setActiveTab(sessionId.includes('@chatroom') ? 'group' : 'private')
navigate('/export')
openDialog({
intent: 'manual',
scope: 'single',
sessionIds: [sessionId],
sessionNames: [sessionName],
title: `导出: ${sessionName}`
})
emitSingleExportDialogStatus({ requestId, status: 'opened' })
}, [isConfigLoaded, navigate, openDialog, resolveSingleExportName, setActiveTab])
useEffect(() => {
return onOpenSingleExport(handleOpenSingleExportRequest)
}, [handleOpenSingleExportRequest])
useEffect(() => {
if (!isConfigLoaded || !pendingSingleExportRequest) return
setPendingSingleExportRequest(null)
handleOpenSingleExportRequest(pendingSingleExportRequest)
}, [handleOpenSingleExportRequest, isConfigLoaded, pendingSingleExportRequest])
const handleConfirmExport = useCallback((finalOptions: any) => {
startTask({
sessionIds: dialogState.sessionIds,
@@ -326,7 +326,7 @@
}
}
.file-size-limit, .concurrency-control, .path-style-control {
.file-size-limit, .concurrency-control, .path-style-control, .conflict-strategy-control {
margin-top: 16px;
display: flex;
align-items: center;
@@ -354,7 +354,7 @@
}
}
.path-style-control {
.path-style-control, .conflict-strategy-control {
flex: 1 1 100%;
margin-top: 4px;
@@ -362,7 +362,7 @@
flex: 0 0 auto;
}
.path-style-segmented {
.path-style-segmented, .conflict-strategy-segmented {
display: inline-flex;
min-height: 34px;
padding: 3px;
@@ -21,7 +21,7 @@ import {
type ExportDefaultDateRangeConfig
} from '../../../../utils/exportDateRange'
import type { ExportDialogState, ExportOptions, TextExportFormat } from '../../types'
import { formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { conflictStrategyOptions, formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportDialog.scss'
@@ -282,6 +282,23 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
</div>
</div>
<div className="conflict-strategy-control">
<span>同名文件:</span>
<div className="conflict-strategy-segmented" title="控制导出目标已有同名文件时的处理方式">
{conflictStrategyOptions.map(item => (
<button
key={item.value}
type="button"
className={options.exportConflictStrategy === item.value ? 'active' : ''}
title={item.desc}
onClick={() => onOptionsChange({ exportConflictStrategy: item.value })}
>
{item.label}
</button>
))}
</div>
</div>
<div className="concurrency-control">
<span>导出并发数:</span>
<select
+9
View File
@@ -7,6 +7,7 @@ import type {
ContentType,
ConversationTab,
DisplayNamePreference,
ExportConflictStrategy,
TextExportFormat,
TaskProgress,
ContactsSortConfig
@@ -45,6 +46,7 @@ export const formatOptions: Array<{ value: TextExportFormat; label: string; desc
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'markdown', label: 'Markdown', desc: '支持文本、图片与链接,适合 AI 场景' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
@@ -59,6 +61,12 @@ export const displayNameOptions: Array<{ value: DisplayNamePreference; label: st
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
]
export const conflictStrategyOptions: Array<{ value: ExportConflictStrategy; label: string; desc: string }> = [
{ value: 'incremental', label: '增量跳过', desc: '复用已有同名媒体;聊天文件有变化时更新原文件' },
{ value: 'overwrite', label: '全量覆盖', desc: '同名文件直接替换,适合强制刷新导出结果' },
{ value: 'rename', label: '保留副本', desc: '同名导出追加序号,保留旧版本' }
]
// ─── Write layout options ────────────────────────────────────
export const writeLayoutOptions: Array<{ value: ExportWriteLayout; label: string; desc: string }> = [
@@ -163,6 +171,7 @@ export const createDefaultExportOptions = (): import('./types').ExportOptions =>
maxFileSizeMb: 200,
exportVoiceAsText: false,
exportPathStyle: 'auto',
exportConflictStrategy: 'incremental',
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
+19 -5
View File
@@ -11,6 +11,7 @@ import { createEmptyProgress } from '../constants'
import { useExportTaskStore } from '../../../stores/exportTaskStore'
import { buildProgressPayloadSignature } from '../utils/progress'
import { resolvePerfStageByPhase, applyProgressToTaskPerformance } from '../utils/performance'
import { emitExportSessionStatus, onExportSessionStatusRequest } from '../../../services/exportBridge'
import type { ExportProgress } from '../types'
export interface ExportTasksResult {
@@ -32,19 +33,32 @@ export function useExportTasks(): ExportTasksResult {
const { setSessionStatus } = useExportTaskStore()
// Track the ongoing tasks to update the global zustand store badge
useEffect(() => {
const publishSessionStatus = useCallback(() => {
const activeTasks = tasksRef.current.filter(t => t.status === 'running' || t.status === 'cancel_requested')
const inProgressSessionIds = new Set<string>()
activeTasks.forEach(task => {
task.payload.sessionIds.forEach(id => inProgressSessionIds.add(id))
})
setSessionStatus({
const payload = {
activeTaskCount: activeTasks.length,
inProgressSessionIds: Array.from(inProgressSessionIds)
})
}, [tasks, setSessionStatus])
}
setSessionStatus(payload)
emitExportSessionStatus(payload)
}, [setSessionStatus])
// Track the ongoing tasks to update the global zustand store badge
useEffect(() => {
publishSessionStatus()
}, [tasks, publishSessionStatus])
useEffect(() => {
const unsubscribe = onExportSessionStatusRequest(publishSessionStatus)
publishSessionStatus()
return unsubscribe
}, [publishSessionStatus])
const updateTask = useCallback((taskId: string, updater: (prev: ExportTask) => ExportTask) => {
setTasks(prev => prev.map(t => t.id === taskId ? updater(t) : t))
+4
View File
@@ -30,12 +30,15 @@ export type SessionLayout = 'shared' | 'per-session'
export type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
export type ExportConflictStrategy = 'incremental' | 'overwrite' | 'rename'
export type TextExportFormat =
| 'chatlab'
| 'chatlab-jsonl'
| 'json'
| 'arkme-json'
| 'html'
| 'markdown'
| 'txt'
| 'excel'
| 'weclone'
@@ -67,6 +70,7 @@ export interface ExportOptions {
maxFileSizeMb: number
exportVoiceAsText: boolean
exportPathStyle: 'auto' | 'posix' | 'windows'
exportConflictStrategy: ExportConflictStrategy
excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: DisplayNamePreference