1 parent
99dcb6e43b
commit
301cb5abce
48 files changed
+5444
-1015
No files matched your search
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user