全新出发

This commit is contained in:
cc committed 2026-06-30 00:18:31 +08:00
1 parent b06c117b1e
commit fa4edebed6
463 files changed
+181610 -125

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
.export-v2-page {
min-height: 100%;
height: 100%;
padding: 0 18px 12px; // Adjusted padding instead of negative margin
background: var(--bg-main, var(--bg-primary));
display: flex;
flex-direction: column;
gap: 16px;
overflow-x: hidden;
overflow-y: hidden;
}
// ─── Top panel ───────────────────────────────────────────────
.export-v2-top-panel {
flex-shrink: 0;
position: relative;
z-index: 55;
}
// ─── Body (session list + bottom task panel) ─────────────────
.export-v2-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column; // Changed from row to column
gap: 16px;
overflow: hidden;
}
.export-v2-session-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.export-v2-task-panel {
flex-shrink: 0;
max-height: 300px;
display: flex;
flex-direction: column;
overflow-y: auto;
border-top: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
padding-top: 8px;
}
// ─── Selection Action Bar ────────────────────────────────────
.selection-action-bar {
position: absolute;
bottom: 24px;
left: 0;
right: 0;
margin: 0 auto;
width: max-content;
background: color-mix(in srgb, var(--bg-surface, var(--bg-primary)) 90%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--border-color);
box-shadow: 0 4px 20px color-mix(in srgb, var(--shadow-color) 30%, transparent);
border-radius: 999px;
padding: 8px 12px 8px 20px;
display: flex;
align-items: center;
gap: 16px;
z-index: 100;
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
span {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.primary-btn {
background: var(--primary);
color: white;
border: none;
padding: 6px 20px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
&:hover {
background: color-mix(in srgb, var(--primary) 90%, black);
transform: translateY(-1px);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 40%, transparent);
}
&:active {
transform: translateY(1px);
}
}
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
// ─── Global Settings Modal ───────────────────────────────────
.export-defaults-modal-overlay {
position: fixed;
inset: 0;
z-index: 7700;
background: color-mix(in srgb, black 40%, transparent);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease;
}
.export-defaults-modal {
width: 100%;
max-width: 720px;
height: 90vh;
background: var(--bg-surface, var(--bg-primary));
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 48px color-mix(in srgb, black 30%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
animation: modalScaleUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
.export-defaults-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.close-icon-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.export-defaults-modal-body {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
// Inherit the background logic from ExportDefaultsSettingsForm
}
.export-defaults-modal-actions {
padding: 16px 24px;
border-top: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
display: flex;
justify-content: flex-end;
background: var(--bg-main, var(--bg-secondary));
.secondary-btn {
background: var(--bg-hover);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
padding: 8px 20px;
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: color-mix(in srgb, var(--text-primary) 10%, var(--bg-hover));
}
}
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalScaleUp {
from { opacity: 0; transform: scale(0.96) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
+306
View File
@@ -0,0 +1,306 @@
/**
* ExportPage — Main page container
*
* This is the top-level component for the refactored Export page.
* It composes sub-components and hooks, keeping itself lean (<300 lines target).
*/
import { memo, useEffect, useState, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import ExportTopBar from './components/ExportTopBar'
import SessionTable from './components/SessionTable'
import ExportDialog from './components/ExportDialog'
import TaskCenter from './components/TaskCenter'
import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../../components/Export/ExportDefaultsSettingsForm'
import { X } from 'lucide-react'
import SessionDetail from './components/SessionDetail'
import { useContactsLoader } from './hooks/useContactsLoader'
import { useExportSessions } from './hooks/useExportSessions'
import { useExportConfig } from './hooks/useExportConfig'
import { useExportDialog } from './hooks/useExportDialog'
import { useExportTasks } from './hooks/useExportTasks'
import { useBackgroundTasks } from './hooks/useBackgroundTasks'
import { useSessionMetrics } from './hooks/useSessionMetrics'
import { useAutomationRunner, useAutomationStore } from './hooks/useAutomation'
import { AutomationModal } from './components/Automation/AutomationModal'
import { AutomationTaskForm } from './components/Automation/AutomationTaskForm'
import type { SessionRow } from './types'
import { getSelectionScopeFromRows, resolveScopeDisplayNames } from './utils/session'
import './ExportPage.scss'
function ExportPage() {
const location = useLocation()
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 {
options,
updateOptions,
exportPath,
setExportPath,
writeLayout,
setWriteLayout,
rawDateRangeConfig,
setRawDateRangeConfig
} = useExportConfig()
// ── 2. Load contacts and sessions ──
const {
contactMap,
avatarEntries,
isLoading: isContactsLoading,
loadContacts,
abort: abortContacts
} = useContactsLoader()
// ── 3. Metrics ──
const { metricsMap, fetchMetrics } = useSessionMetrics()
const {
filteredSessions,
isLoading: isSessionsLoading,
activeTab,
setActiveTab,
searchQuery,
setSearchQuery,
loadSessions,
sortConfig,
setSortConfig
} = useExportSessions(contactMap, 'private', metricsMap)
// Initial load
useEffect(() => {
if (!isExportRoute) return
loadContacts('all')
loadSessions()
return () => abortContacts()
}, [isExportRoute, loadContacts, loadSessions, abortContacts])
// Use filteredSessions directly (hook already handles tab + search filtering)
const displayedSessions = filteredSessions
useEffect(() => {
if (displayedSessions.length > 0) {
// Fetch metrics for displayed sessions (in chunks if needed, here we just pass all)
fetchMetrics(displayedSessions.map(s => s.username))
}
}, [displayedSessions, fetchMetrics])
// ── 4. Manage Tasks ──
const {
tasks: exportTasks,
startTask,
cancelTask: cancelExportTask,
clearCompletedTasks: clearCompletedExportTasks
} = useExportTasks()
const { enqueueAutomationTask } = useAutomationRunner(startTask)
const { addTask } = useAutomationStore()
const {
allTasks: backgroundTasks,
pauseTask: pauseBgTask,
resumeTask: resumeBgTask,
cancelTask: cancelBgTask
} = useBackgroundTasks()
// ── 4. Dialog & Exports ──
const { dialogState, openDialog, closeDialog } = useExportDialog()
const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => {
updateOptions({
...(patch.format ? { defaultFormat: patch.format as any } : {}),
...(patch.avatars !== undefined ? { exportAvatars: patch.avatars } : {}),
})
}, [updateOptions])
const handleExportSelected = useCallback(() => {
const selectedRows = displayedSessions.filter(s => selectedSessionIds.has(s.username))
if (selectedRows.length === 0) return
const scope = getSelectionScopeFromRows(selectedRows)
const sessionNames = resolveScopeDisplayNames(selectedRows, options.displayNamePreference)
openDialog({
scope,
intent: 'manual',
sessionIds: selectedRows.map(s => s.username),
sessionNames,
title: '批量导出'
})
}, [displayedSessions, selectedSessionIds, options.displayNamePreference, openDialog])
const handleAutomationExportFromSelection = useCallback(() => {
const selectedRows = displayedSessions.filter(s => selectedSessionIds.has(s.username))
if (selectedRows.length === 0) return
const scope = getSelectionScopeFromRows(selectedRows)
const sessionNames = resolveScopeDisplayNames(selectedRows, options.displayNamePreference)
// B-plan: skip ExportDialog, go directly to AutomationTaskForm
setDraftAutomationPayload({
sessionIds: selectedRows.map(s => s.username),
sessionNames,
scope,
options
})
}, [displayedSessions, selectedSessionIds, options])
const handleSingleExport = useCallback((sessionId: string) => {
const sessionRow = displayedSessions.find(s => s.username === sessionId)
if (!sessionRow) return
setSelectedSessionIds(new Set([sessionId]))
const scope = getSelectionScopeFromRows([sessionRow])
const sessionNames = resolveScopeDisplayNames([sessionRow], options.displayNamePreference)
openDialog({
scope,
intent: 'manual',
sessionIds: [sessionId],
sessionNames,
title: '导出联系人/群聊'
})
}, [displayedSessions, options.displayNamePreference, openDialog])
const handleConfirmExport = useCallback((finalOptions: any) => {
startTask({
sessionIds: dialogState.sessionIds,
sessionNames: dialogState.sessionNames,
scope: dialogState.scope,
source: dialogState.intent === 'automation-create' ? 'automation' : 'manual',
outputDir: exportPath,
options: finalOptions
})
closeDialog()
}, [dialogState, exportPath, startTask, closeDialog])
const handleAutomationSave = useCallback((task: any) => {
void addTask(task)
setDraftAutomationPayload(null)
}, [addTask])
return (
<div className="export-v2-page">
<ExportTopBar
exportPath={exportPath}
writeLayout={writeLayout}
onSelectPath={() => void window.electronAPI.dialog.openFile({ properties: ['openDirectory'] }).then(res => !res.canceled && res.filePaths[0] && setExportPath(res.filePaths[0]))}
onWriteLayoutChange={setWriteLayout}
onGlobalSettingsClick={() => setIsSettingsModalOpen(true)}
/>
<div className="export-v2-body">
<section className="export-v2-session-panel">
<SessionTable
sessions={displayedSessions}
activeTab={activeTab}
onTabChange={setActiveTab}
selectedSessionIds={selectedSessionIds}
onSelectionChange={setSelectedSessionIds}
isLoading={isSessionsLoading || isContactsLoading}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sortConfig={sortConfig}
onSortChange={setSortConfig}
metricsMap={metricsMap}
onSingleExport={handleSingleExport}
onBatchExport={handleExportSelected}
onAutomationExport={handleAutomationExportFromSelection}
/>
</section>
{(exportTasks.length > 0 || backgroundTasks.filter(t => t.sourcePage === 'export').length > 0) && (
<section className="export-v2-task-panel">
<TaskCenter
exportTasks={exportTasks}
onCancelExportTask={cancelExportTask}
onClearCompletedExportTasks={clearCompletedExportTasks}
backgroundTasks={backgroundTasks.filter(t => t.sourcePage === 'export')}
onPauseBackgroundTask={pauseBgTask}
onResumeBackgroundTask={resumeBgTask}
onCancelBackgroundTask={cancelBgTask}
/>
</section>
)}
</div>
<ExportDialog
dialogState={dialogState}
onClose={closeDialog}
options={options}
onOptionsChange={updateOptions}
exportPath={exportPath}
onSelectPath={() => void window.electronAPI.dialog.openFile({ properties: ['openDirectory'] }).then(res => !res.canceled && res.filePaths[0] && setExportPath(res.filePaths[0]))}
rawDateRangeConfig={rawDateRangeConfig}
onDateRangeConfigChange={setRawDateRangeConfig}
onConfirm={handleConfirmExport}
onAutomationCreate={handleConfirmExport}
/>
{isSettingsModalOpen && (
<div
className="export-defaults-modal-overlay"
onClick={() => setIsSettingsModalOpen(false)}
>
<div
className="export-defaults-modal"
role="dialog"
onClick={e => e.stopPropagation()}
>
<div className="export-defaults-modal-header">
<h3>全局配置</h3>
<button className="close-icon-btn" onClick={() => setIsSettingsModalOpen(false)}>
<X size={20} />
</button>
</div>
<div className="export-defaults-modal-body">
<ExportDefaultsSettingsForm layout="split" onDefaultsChanged={handleExportDefaultsChanged} />
</div>
<div className="export-defaults-modal-actions" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button
type="button"
className="secondary-btn"
onClick={() => {
setIsSettingsModalOpen(false)
setIsAutomationModalOpen(true)
}}
>
管理自动化任务
</button>
<button
type="button"
className="secondary-btn"
onClick={() => setIsSettingsModalOpen(false)}
>
关闭
</button>
</div>
</div>
</div>
)}
{isAutomationModalOpen && (
<AutomationModal onClose={() => setIsAutomationModalOpen(false)} />
)}
{draftAutomationPayload && (
<AutomationTaskForm
basePayload={draftAutomationPayload}
onSave={handleAutomationSave}
onCancel={() => setDraftAutomationPayload(null)}
/>
)}
</div>
)
}
export default memo(ExportPage)
@@ -0,0 +1,451 @@
// Automation.scss — Matching WeFlow design system
// Uses CSS custom properties consistent with ExportV2Page, ExportDialog, etc.
// Design language: no hard borders, subtle color-mix transparency, rounded-2xl modals, 8px controls
// ─── Modal Overlay & Container ────────────────────────────────
.automation-modal-overlay {
position: fixed;
inset: 0;
z-index: 7700;
background: color-mix(in srgb, black 40%, transparent);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
backdrop-filter: blur(2px);
animation: fadeIn 0.15s ease;
.automation-modal {
width: 100%;
max-width: 720px;
height: 80vh;
max-height: 780px;
background: var(--bg-surface, var(--bg-primary));
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 48px color-mix(in srgb, black 30%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
animation: modalScaleUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
.header-title {
h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--text-tertiary);
}
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 0;
background: var(--bg-main, var(--bg-secondary));
}
}
}
// ─── Task List ────────────────────────────────────────────────
.automation-task-list {
display: flex;
flex-direction: column;
.empty-state {
padding: 60px 24px;
text-align: center;
color: var(--text-tertiary);
.empty-icon {
margin-bottom: 16px;
opacity: 0.3;
}
p {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--text-secondary);
}
span {
display: block;
font-size: 12px;
color: var(--text-tertiary);
}
}
.task-item {
padding: 16px 24px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
background: var(--bg-surface, var(--bg-primary));
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
transition: background 0.15s;
&:hover {
background: var(--bg-hover, color-mix(in srgb, var(--bg-surface) 92%, var(--text-primary)));
}
&:last-child {
border-bottom: none;
}
.task-info {
flex: 1;
min-width: 0;
.task-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
flex-shrink: 0;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 500;
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
color: var(--text-tertiary);
&.enabled {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
}
.task-details {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
color: var(--text-tertiary);
.detail-row {
display: flex;
align-items: center;
gap: 5px;
line-height: 1.5;
svg {
opacity: 0.45;
flex-shrink: 0;
}
}
}
}
.task-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
.action-row {
display: flex;
gap: 6px;
}
}
}
}
// ─── Shared Button Styles ─────────────────────────────────────
.automation-modal-overlay,
.automation-form-overlay,
.automation-task-list {
.primary-btn {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1.2;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
background: var(--primary);
color: #fff;
transition: opacity 0.15s, box-shadow 0.15s;
&:hover {
opacity: 0.88;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent);
}
&:active { opacity: 0.75; }
}
.secondary-btn {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1.2;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
transition: background 0.15s;
&:hover {
background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-hover));
}
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 6px;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.danger:hover {
color: #e53935;
background: color-mix(in srgb, #e53935 8%, transparent);
}
}
.close-icon-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
// ─── Form Overlay ─────────────────────────────────────────────
.automation-form-overlay {
position: fixed;
inset: 0;
z-index: 7800;
background: color-mix(in srgb, black 40%, transparent);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
backdrop-filter: blur(2px);
animation: fadeIn 0.15s ease;
.automation-form-modal {
width: 100%;
max-width: 560px;
max-height: 85vh;
background: var(--bg-surface, var(--bg-primary));
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 48px color-mix(in srgb, black 30%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
animation: modalScaleUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
.form-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
}
.form-body {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 20px;
.form-section {
display: flex;
flex-direction: column;
gap: 10px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.02em;
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
&.vertical {
flex-direction: column;
align-items: flex-start;
}
> label {
font-size: 13px;
color: var(--text-tertiary);
flex-shrink: 0;
}
input[type="text"],
input[type="number"],
input[type="datetime-local"] {
flex: 1;
min-width: 0;
padding: 8px 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
border-radius: 8px;
font-size: 13px;
outline: none;
background: var(--bg-main, var(--bg-secondary));
color: var(--text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 12%, transparent);
}
&[disabled] {
background: var(--bg-hover);
color: var(--text-tertiary);
cursor: not-allowed;
}
}
.number-input-group {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
input[type="number"] {
width: 72px;
flex: none;
text-align: center;
}
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
user-select: none;
input[type="checkbox"],
input[type="radio"] {
cursor: pointer;
accent-color: var(--primary);
}
}
}
}
.form-summary {
padding: 12px 14px;
background: var(--bg-main, var(--bg-secondary));
border-radius: 8px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.7;
border: 1px solid color-mix(in srgb, var(--border-color) 30%, transparent);
}
}
.form-footer {
padding: 14px 24px;
border-top: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
display: flex;
justify-content: flex-end;
gap: 10px;
background: var(--bg-main, var(--bg-secondary));
}
}
}
// ─── Reuse existing animations from ExportV2Page ──────────────
// fadeIn & modalScaleUp are defined in ExportV2Page.scss
// If not available, define local fallbacks:
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalScaleUp {
from { opacity: 0; transform: scale(0.96) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@@ -0,0 +1,75 @@
import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { ExportAutomationTask } from '../../../../types/exportAutomation'
import { useAutomationStore } from '../../hooks/useAutomation'
import { AutomationTaskList } from './AutomationTaskList'
import { AutomationTaskForm } from './AutomationTaskForm'
import './Automation.scss'
interface AutomationModalProps {
onClose: () => void
}
export const AutomationModal: React.FC<AutomationModalProps> = ({ onClose }) => {
const { tasks, updateTask, deleteTask } = useAutomationStore()
const [editingTask, setEditingTask] = useState<ExportAutomationTask | null>(null)
const handleToggleEnable = (task: ExportAutomationTask, enabled: boolean) => {
void updateTask(task.id, prev => ({ ...prev, enabled, updatedAt: Date.now() }))
}
const handleRunNow = (_task: ExportAutomationTask) => {
// Manual trigger is handled by the automation runner's evaluateSchedules
// For now, just show a hint
alert('手动触发功能需要自动化运行器的支持,任务将在下次调度周期中执行。')
}
const handleDelete = (task: ExportAutomationTask) => {
void deleteTask(task.id)
}
const handleEditSave = (task: ExportAutomationTask) => {
void updateTask(task.id, () => task)
setEditingTask(null)
}
return createPortal(
<>
<div className="automation-modal-overlay" onClick={onClose}>
<div className="automation-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="header-title">
<h3>自动化导出</h3>
<p>管理定时增量备份任务,仅在应用运行期间生效</p>
</div>
<div className="header-actions">
<button className="close-icon-btn" onClick={onClose}>
<X size={18} />
</button>
</div>
</div>
<div className="modal-content">
<AutomationTaskList
tasks={tasks}
onEdit={setEditingTask}
onDelete={handleDelete}
onToggleEnable={handleToggleEnable}
onRunNow={handleRunNow}
/>
</div>
</div>
</div>
{editingTask && (
<AutomationTaskForm
initialTask={editingTask}
onSave={handleEditSave}
onCancel={() => setEditingTask(null)}
/>
)}
</>,
document.body
)
}
@@ -0,0 +1,416 @@
import React, { useState } from 'react'
import { X } from 'lucide-react'
import type { ExportAutomationTask } from '../../../../types/exportAutomation'
import type { ExportTaskPayload } from '../../types'
import {
buildAutomationSchedule,
createAutomationTaskId,
normalizeAutomationIntervalDays,
normalizeAutomationIntervalHours,
AUTOMATION_RANGE_OPTIONS,
type AutomationRangeMode,
normalizeAutomationLastNDays,
AUTOMATION_LAST_N_DAYS_DEFAULT,
buildAutomationLastNDaysConfig,
resolveAutomationRangeMode,
resolveAutomationDateRangeSelection
} from '../../utils/automation'
import type { ExportAutomationDateRangeConfig } from '../../../../types/exportAutomation'
import './Automation.scss'
interface AutomationTaskFormProps {
initialTask?: ExportAutomationTask
basePayload?: Omit<ExportTaskPayload, 'source' | 'automationTaskId'>
onSave: (task: ExportAutomationTask) => void
onCancel: () => void
}
export const AutomationTaskForm: React.FC<AutomationTaskFormProps> = ({
initialTask,
basePayload,
onSave,
onCancel
}) => {
// ─── Derive a sensible default task name ───────────────────
const defaultName = (() => {
const names = initialTask?.sessionNames || basePayload?.sessionNames || []
if (names.length === 0) return '每日增量备份'
if (names.length === 1) return `${names[0]} 自动化导出`
return `${names[0]} 等 ${names.length} 个会话 自动化导出`
})()
const [name, setName] = useState(initialTask?.name || defaultName)
const [enabled, setEnabled] = useState(initialTask?.enabled ?? true)
const [useGlobalOutputDir, setUseGlobalOutputDir] = useState(!initialTask?.outputDir)
const [outputDir, setOutputDir] = useState(initialTask?.outputDir || '')
const schedule = initialTask?.schedule
const [intervalDays, setIntervalDays] = useState(schedule?.intervalDays ?? 1)
const [intervalHours, setIntervalHours] = useState(schedule?.intervalHours ?? 0)
const firstTriggerAt = schedule?.firstTriggerAt
const [firstTriggerEnabled, setFirstTriggerEnabled] = useState(!!firstTriggerAt)
const [firstTriggerValue, setFirstTriggerValue] = useState(() => {
if (firstTriggerAt) {
const d = new Date(firstTriggerAt)
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
return d.toISOString().slice(0, 16)
}
return ''
})
// ─── Date Range Mode ───────────────────────────────────────
const initDateRangeConfig = initialTask?.template?.dateRangeConfig
const initSelection = initDateRangeConfig
? resolveAutomationDateRangeSelection(initDateRangeConfig)
: undefined
const initMode = initDateRangeConfig && initSelection
? resolveAutomationRangeMode(initDateRangeConfig, initSelection)
: 'all'
const [rangeMode, setRangeMode] = useState<AutomationRangeMode>(initMode)
const [lastNDays, setLastNDays] = useState(() => {
if (initDateRangeConfig && typeof initDateRangeConfig === 'object' && (initDateRangeConfig as any).relativeDays) {
return (initDateRangeConfig as any).relativeDays
}
return AUTOMATION_LAST_N_DAYS_DEFAULT
})
// ─── Stop Conditions ──────────────────────────────────────
const stopCondition = initialTask?.stopCondition
const [stopAtEnabled, setStopAtEnabled] = useState(!!stopCondition?.endAt)
const [stopAtValue, setStopAtValue] = useState(() => {
if (stopCondition?.endAt) {
const d = new Date(stopCondition.endAt)
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
return d.toISOString().slice(0, 16)
}
return ''
})
const [maxRunsEnabled, setMaxRunsEnabled] = useState(!!stopCondition?.maxRuns)
const [maxRuns, setMaxRuns] = useState(stopCondition?.maxRuns || 0)
const handleChooseDir = async () => {
const result = await window.electronAPI.dialog.openFile({
properties: ['openDirectory', 'createDirectory'] as any
})
if (!result.canceled && result.filePaths.length > 0) {
setOutputDir(result.filePaths[0])
setUseGlobalOutputDir(false)
}
}
const buildDateRangeConfig = (): ExportAutomationDateRangeConfig | null => {
if (rangeMode === 'all') {
return { version: 1, preset: 'all', useAllTime: true } as any
}
if (rangeMode === 'lastNDays') {
return buildAutomationLastNDaysConfig(normalizeAutomationLastNDays(lastNDays)) as any
}
if (['yesterday', 'last7days', 'last30days', 'last1year', 'today'].includes(rangeMode)) {
return { version: 1, preset: rangeMode, useAllTime: false } as any
}
return null
}
const handleSave = () => {
const trimmedName = name.trim()
if (!trimmedName) {
alert('请输入任务名称')
return
}
const days = normalizeAutomationIntervalDays(intervalDays)
const hours = normalizeAutomationIntervalHours(intervalHours)
if (days <= 0 && hours <= 0) {
alert('执行间隔不能为0,请至少设置天数或小时')
return
}
let firstTriggerTimestamp: number | undefined
if (firstTriggerEnabled) {
if (!firstTriggerValue) {
alert('请填写有效的首次触发时间')
return
}
firstTriggerTimestamp = new Date(firstTriggerValue).getTime()
}
let endAtTimestamp: number | undefined
if (stopAtEnabled) {
if (!stopAtValue) {
alert('请填写有效的终止时间')
return
}
endAtTimestamp = new Date(stopAtValue).getTime()
}
const maxRunsValue = maxRunsEnabled ? Math.max(0, Math.floor(maxRuns)) : undefined
const sessionIds = initialTask?.sessionIds || basePayload?.sessionIds || []
const sessionNames = initialTask?.sessionNames || basePayload?.sessionNames || []
const templateOptions = initialTask?.template.optionTemplate || basePayload?.options || {}
const scope = initialTask?.template.scope || basePayload?.scope || 'multi'
const contentType = initialTask?.template.contentType || basePayload?.contentType
const task: ExportAutomationTask = {
id: initialTask?.id || createAutomationTaskId(),
name: trimmedName,
enabled,
sessionIds,
sessionNames,
outputDir: useGlobalOutputDir ? undefined : outputDir,
schedule: buildAutomationSchedule(days, hours, firstTriggerTimestamp || 0),
condition: {
type: 'new-message-since-last-success'
},
stopCondition: {
endAt: endAtTimestamp,
maxRuns: maxRunsValue
},
template: {
scope: scope as any,
contentType,
optionTemplate: templateOptions as any,
dateRangeConfig: buildDateRangeConfig() as any
},
runState: initialTask?.runState,
createdAt: initialTask?.createdAt || Date.now(),
updatedAt: Date.now()
}
onSave(task)
}
// ─── Summary ───────────────────────────────────────────────
const sessionIds = initialTask?.sessionIds || basePayload?.sessionIds || []
const sessionNamesForSummary = initialTask?.sessionNames || basePayload?.sessionNames || []
const rangeLabelMap: Record<AutomationRangeMode, string> = {
all: '每次触发导出全部历史消息',
today: '每次触发导出当天',
yesterday: '每次触发导出前1天',
last7days: '每次触发导出前7天',
last30days: '每次触发导出前30天',
last1year: '每次触发导出前1年',
lastNDays: `每次触发导出前 ${normalizeAutomationLastNDays(lastNDays)} 天`,
custom: '完整时间'
}
return (
<div className="automation-form-overlay" onClick={onCancel}>
<div className="automation-form-modal" onClick={e => e.stopPropagation()}>
<div className="form-header">
<h3>{initialTask ? '编辑自动化任务' : '创建自动化任务'}</h3>
<button className="close-icon-btn" onClick={onCancel}>
<X size={18} />
</button>
</div>
<div className="form-body">
{/* 基本信息 */}
<div className="form-section">
<h4>任务名称</h4>
<div className="form-row">
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="例如:每天凌晨备份"
/>
</div>
</div>
{/* 执行频率 */}
<div className="form-section">
<h4>执行频率</h4>
<div className="form-row">
<label>间隔天数</label>
<div className="number-input-group">
<input
type="number"
min="0"
value={intervalDays}
onChange={e => setIntervalDays(Number(e.target.value))}
/>
</div>
<label>间隔小时</label>
<div className="number-input-group">
<input
type="number"
min="0"
max="23"
value={intervalHours}
onChange={e => setIntervalHours(Number(e.target.value))}
/>
</div>
</div>
</div>
{/* 首次触发 */}
<div className="form-section">
<h4>首次触发时间(可选)</h4>
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={firstTriggerEnabled}
onChange={e => setFirstTriggerEnabled(e.target.checked)}
/>
指定第一次触发时间
</label>
</div>
{firstTriggerEnabled && (
<div className="form-row">
<input
type="datetime-local"
value={firstTriggerValue}
onChange={e => setFirstTriggerValue(e.target.value)}
/>
</div>
)}
</div>
{/* 导出时间范围 */}
<div className="form-section">
<h4>导出时间范围(按触发时间动态计算)</h4>
<div className="form-row" style={{ flexWrap: 'wrap', gap: '8px' }}>
{AUTOMATION_RANGE_OPTIONS.map(opt => (
<button
key={opt.mode}
type="button"
style={{
padding: '6px 14px',
borderRadius: '16px',
fontSize: '13px',
cursor: 'pointer',
border: rangeMode === opt.mode ? '1px solid var(--primary)' : '1px solid color-mix(in srgb, var(--border-color) 60%, transparent)',
background: rangeMode === opt.mode ? 'color-mix(in srgb, var(--primary) 10%, transparent)' : 'var(--bg-surface, #fff)',
color: rangeMode === opt.mode ? 'var(--primary)' : 'var(--text-primary)',
fontWeight: rangeMode === opt.mode ? 500 : 400
}}
onClick={() => setRangeMode(opt.mode)}
>
{opt.label}
</button>
))}
</div>
{rangeMode === 'lastNDays' && (
<div className="form-row">
<label>天数</label>
<div className="number-input-group">
<input
type="number"
min="1"
max="3650"
value={lastNDays}
onChange={e => setLastNDays(Number(e.target.value))}
/>
</div>
</div>
)}
<div style={{ fontSize: '12px', color: 'var(--text-secondary, #888)', marginTop: '4px' }}>
{rangeLabelMap[rangeMode]}
</div>
</div>
{/* 终止条件 */}
<div className="form-section">
<h4>终止条件(可选)</h4>
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={stopAtEnabled}
onChange={e => setStopAtEnabled(e.target.checked)}
/>
到指定时间后自动停止
</label>
</div>
{stopAtEnabled && (
<div className="form-row">
<input
type="datetime-local"
value={stopAtValue}
onChange={e => setStopAtValue(e.target.value)}
/>
</div>
)}
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={maxRunsEnabled}
onChange={e => setMaxRunsEnabled(e.target.checked)}
/>
成功执行指定次数后自动停止
</label>
</div>
{maxRunsEnabled && (
<div className="form-row">
<div className="number-input-group">
<input
type="number"
min="1"
value={maxRuns}
onChange={e => setMaxRuns(Number(e.target.value))}
/> 次
</div>
</div>
)}
</div>
{/* 导出目录 */}
<div className="form-section">
<h4>导出目录</h4>
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={useGlobalOutputDir}
onChange={e => setUseGlobalOutputDir(e.target.checked)}
/>
使用全局导出目录
</label>
</div>
{!useGlobalOutputDir && (
<div className="form-row">
<button type="button" className="secondary-btn" onClick={handleChooseDir}>选择目录</button>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
{outputDir || '未选择'}
</span>
</div>
)}
</div>
{/* 启用 */}
<div className="form-section">
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
/>
创建后立即启用
</label>
</div>
</div>
{/* Summary */}
<div className="form-summary">
会话:{sessionIds.length} 个 ·
间隔:{normalizeAutomationIntervalDays(intervalDays)} 天 {normalizeAutomationIntervalHours(intervalHours)} 小时 ·
首次:{firstTriggerEnabled && firstTriggerValue ? new Date(firstTriggerValue).toLocaleString('zh-CN') : '默认按创建时间+间隔'} ·
时间:{rangeLabelMap[rangeMode]} ·
条件:有新消息才导出
</div>
</div>
<div className="form-footer">
<button type="button" className="secondary-btn" onClick={onCancel}>取消</button>
<button type="button" className="primary-btn" onClick={handleSave}>保存任务</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,130 @@
import React from 'react'
import { Play, Pause, Edit, Trash2, Clock, CheckCircle2, AlertCircle } from 'lucide-react'
import type { ExportAutomationTask } from '../../../../types/exportAutomation'
import {
formatAutomationScheduleLabel,
formatAutomationCurrentState,
formatAutomationStopCondition,
formatAutomationLastRunSummary
} from '../../utils/automation'
interface AutomationTaskListProps {
tasks: ExportAutomationTask[]
onEdit: (task: ExportAutomationTask) => void
onDelete: (task: ExportAutomationTask) => void
onToggleEnable: (task: ExportAutomationTask, enabled: boolean) => void
onRunNow: (task: ExportAutomationTask) => void
}
export const AutomationTaskList: React.FC<AutomationTaskListProps> = ({
tasks,
onEdit,
onDelete,
onToggleEnable,
onRunNow
}) => {
if (tasks.length === 0) {
return (
<div className="automation-task-list">
<div className="empty-state">
<Clock size={48} className="empty-icon" />
<p>暂无自动化任务</p>
<span style={{ fontSize: '12px', opacity: 0.6 }}>在导出配置时,选择“保存为自动化任务”即可创建</span>
</div>
</div>
)
}
const nowMs = Date.now()
return (
<div className="automation-task-list">
{tasks.map(task => {
const isEnabled = task.enabled
// For queueState, we can determine from lastRunStatus if it's currently running/queued
const queueState = task.runState?.lastRunStatus === 'running'
? 'running'
: task.runState?.lastRunStatus === 'queued' ? 'queued' : null
return (
<div key={task.id} className="task-item">
<div className="task-info">
<div className="task-header">
<span className={`status-badge ${isEnabled ? 'enabled' : ''}`}>
{isEnabled ? '运行中' : '已停用'}
</span>
<h4 title={task.name}>{task.name}</h4>
</div>
<div className="task-details">
<div className="detail-row">
<Clock size={14} />
<span>调度规则:{formatAutomationScheduleLabel(task.schedule)}</span>
</div>
<div className="detail-row">
<AlertCircle size={14} />
<span>停止条件:{formatAutomationStopCondition(task)}</span>
</div>
<div className="detail-row">
<CheckCircle2 size={14} />
<span>最近执行:{formatAutomationLastRunSummary(task)}</span>
</div>
<div className="detail-row" style={{ color: isEnabled ? 'var(--primary)' : 'inherit', marginTop: '4px' }}>
<span>状态:{formatAutomationCurrentState(task, queueState, nowMs)}</span>
</div>
</div>
</div>
<div className="task-actions">
<div className="action-row">
{isEnabled ? (
<button
className="secondary-btn"
title="停用任务"
onClick={() => onToggleEnable(task, false)}
>
<Pause size={14} style={{ marginRight: '4px' }} /> 停用
</button>
) : (
<button
className="primary-btn"
title="启用任务"
onClick={() => onToggleEnable(task, true)}
>
<Play size={14} style={{ marginRight: '4px' }} /> 启用
</button>
)}
<button
className="secondary-btn"
title="立即手动触发一次"
onClick={() => onRunNow(task)}
>
<Play size={14} />
</button>
</div>
<div className="action-row">
<button
className="icon-btn"
title="编辑"
onClick={() => onEdit(task)}
>
<Edit size={16} />
</button>
<button
className="icon-btn danger"
title="删除"
onClick={() => {
if (window.confirm(`确认删除自动化任务「${task.name}」吗?`)) {
onDelete(task)
}
}}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,454 @@
.export-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: color-mix(in srgb, var(--bg-primary) 60%, transparent);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
.export-dialog-container {
background: var(--bg-primary);
border-radius: 16px;
width: 100%;
max-width: 680px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 12px 48px color-mix(in srgb, var(--shadow-color) 40%, transparent),
0 0 0 1px color-mix(in srgb, var(--border-color) 80%, transparent);
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
// Header
.dialog-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--border-color);
.title-area {
.title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.subtitle {
font-size: 13px;
color: var(--text-tertiary);
}
}
.close-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
padding: 4px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
// Body
.dialog-body {
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 32px;
.config-section {
display: flex;
flex-direction: column;
gap: 12px;
.config-row-group {
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-surface, var(--bg-primary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
.config-row {
display: flex;
align-items: center;
gap: 16px;
.row-label {
width: 72px;
flex-shrink: 0;
font-size: 13px;
color: var(--text-secondary);
}
.row-value-btn {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-main, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
gap: 8px;
.icon {
color: var(--primary);
}
.text {
flex: 1;
text-align: left;
font-size: 13px;
color: var(--text-primary);
}
.arrow {
color: var(--text-tertiary);
font-size: 12px;
}
&:hover {
border-color: var(--primary);
background: var(--bg-surface, var(--bg-primary));
}
}
}
}
.export-path-row {
flex: 1;
display: flex;
gap: 8px;
align-items: center;
.path-display-group {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
.path-display-btn {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-tertiary, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 5px 12px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
min-width: 0;
.path-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
.open-folder-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex-shrink: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
}
.change-path-btn {
display: flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 16px;
background: var(--bg-surface, var(--bg-primary));
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-main, var(--bg-secondary));
border-color: var(--text-tertiary);
}
}
}
.error-text {
color: #ef4444;
font-size: 12px;
margin-top: -4px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16px;
svg {
color: var(--primary);
}
}
.format-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.format-card {
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 16px;
text-align: left;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
.format-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.format-desc {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
&:hover {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
background: var(--bg-hover);
}
&.active {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 15%, transparent);
.format-name {
color: var(--primary);
}
}
}
}
.media-options, .advanced-options {
display: flex;
flex-wrap: wrap;
gap: 16px;
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
}
input[type="checkbox"] {
accent-color: var(--primary);
width: 16px;
height: 16px;
cursor: pointer;
}
svg {
color: var(--text-tertiary);
}
&:has(input:checked) {
border-color: color-mix(in srgb, var(--primary) 50%, transparent);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
svg {
color: var(--primary);
}
}
}
}
.file-size-limit, .concurrency-control, .path-style-control {
margin-top: 16px;
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text-tertiary);
input, select {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color 0.2s ease;
&:focus {
border-color: var(--primary);
}
}
input[type="number"] {
width: 80px;
}
}
.path-style-control {
flex: 1 1 100%;
margin-top: 4px;
> span {
flex: 0 0 auto;
}
.path-style-segmented {
display: inline-flex;
min-height: 34px;
padding: 3px;
gap: 3px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
button {
min-width: 82px;
border: 0;
border-radius: 6px;
padding: 6px 10px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 28%, transparent);
}
}
}
}
}
}
// Footer
.dialog-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
background: var(--bg-secondary);
button {
padding: 8px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.cancel-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
&.confirm-btn {
background: var(--primary);
border: 1px solid var(--primary);
color: white;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent);
&:hover {
background: color-mix(in srgb, var(--primary) 90%, black);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 40%, transparent);
}
}
}
}
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@@ -0,0 +1,327 @@
import React, { memo, useState, useEffect } from 'react'
import {
X,
FileText,
Image as ImageIcon,
Video,
Mic,
FileBox,
Smile,
Calendar,
Settings,
HardDrive,
ExternalLink,
FolderOpen
} from 'lucide-react'
import { ExportDateRangeDialog } from '../../../../components/Export/ExportDateRangeDialog'
import {
getExportDateRangeLabel,
resolveExportDateRangeConfig,
serializeExportDateRangeConfig,
type ExportDefaultDateRangeConfig
} from '../../../../utils/exportDateRange'
import type { ExportDialogState, ExportOptions, TextExportFormat } from '../../types'
import { formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportDialog.scss'
interface ExportDialogProps {
dialogState: ExportDialogState
onClose: () => void
options: ExportOptions
onOptionsChange: (patch: Partial<ExportOptions>) => void
exportPath: string
onSelectPath: () => void
rawDateRangeConfig: ExportDefaultDateRangeConfig | string | null
onDateRangeConfigChange: (config: ExportDefaultDateRangeConfig | string | null) => void
onConfirm: (finalOptions: ExportOptions) => void
onAutomationCreate: (finalOptions: ExportOptions) => void
}
const ExportDialog: React.FC<ExportDialogProps> = ({
dialogState,
onClose,
options,
onOptionsChange,
exportPath,
onSelectPath,
rawDateRangeConfig,
onDateRangeConfigChange,
onConfirm,
onAutomationCreate
}) => {
const [isDateRangeOpen, setIsDateRangeOpen] = useState(false)
const currentSelection = React.useMemo(() => {
return resolveExportDateRangeConfig(rawDateRangeConfig)
}, [rawDateRangeConfig])
const dateRangeLabel = React.useMemo(() => {
return getExportDateRangeLabel(currentSelection)
}, [currentSelection])
if (!dialogState.open) return null
// Local state for UI toggles that might not strictly map 1:1 to config immediately
// But we'll just use the global options for now
const { intent, scope, sessionNames, title } = dialogState
const isAutomation = intent === 'automation-create'
const handleFormatSelect = (format: TextExportFormat) => {
onOptionsChange({ format })
}
const handleConfirm = () => {
if (isAutomation) {
onAutomationCreate(options)
} else {
onConfirm(options)
}
}
return (
<div className="export-dialog-overlay">
<div className="export-dialog-container">
{/* Header */}
<div className="dialog-header">
<div className="title-area">
<h2 className="title">{title}</h2>
<div className="subtitle">
{scope === 'single'
? `准备导出包含 ${sessionNames[0]} 的会话内容`
: `已选择 ${sessionNames.length} 个会话`}
{isAutomation && ' (自动化任务模板配置)'}
</div>
</div>
<button className="close-btn" onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Body */}
<div className="dialog-body">
{/* Export Path */}
<div className="config-section">
<div className="section-title">
<HardDrive size={16} /> 导出配置
</div>
<div className="config-row-group">
<div className="config-row">
<span className="row-label">时间范围</span>
<button
className="row-value-btn"
onClick={() => setIsDateRangeOpen(true)}
>
<Calendar size={14} className="icon" />
<span className="text">{dateRangeLabel}</span>
<span className="arrow">&gt;</span>
</button>
</div>
<div className="config-row">
<span className="row-label">保存路径</span>
<div className="export-path-row">
<div className="path-display-group">
<button
className="path-display-btn"
onClick={onSelectPath}
title={exportPath}
>
<FolderOpen size={16} />
<span className="path-text">
{exportPath ? formatPathBrief(exportPath, 46) : '请选择导出目录...'}
</span>
</button>
{exportPath && (
<button
className="open-folder-btn"
title="在文件管理器中打开"
onClick={() => window.electronAPI.shell.openPath(exportPath)}
>
<ExternalLink size={16} />
</button>
)}
</div>
<button className="change-path-btn" onClick={onSelectPath}>
更改
</button>
</div>
</div>
</div>
{!exportPath && <div className="error-text">请先选择一个保存路径</div>}
</div>
{/* Format Selection */}
<div className="config-section">
<div className="section-title">
<FileText size={16} /> 导出格式
</div>
<div className="format-grid">
{formatOptions.map(fmt => (
<button
key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
onClick={() => handleFormatSelect(fmt.value)}
>
<div className="format-name">{fmt.label}</div>
<div className="format-desc">{fmt.desc}</div>
</button>
))}
</div>
</div>
{/* Media Configuration */}
<div className="config-section">
<div className="section-title">
<HardDrive size={16} /> 媒体与文件附件
</div>
<div className="media-options">
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportImages}
onChange={e => onOptionsChange({ exportImages: e.target.checked })}
/>
<ImageIcon size={16} /> 导出图片
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVideos}
onChange={e => onOptionsChange({ exportVideos: e.target.checked })}
/>
<Video size={16} /> 导出视频
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVoices}
onChange={e => onOptionsChange({ exportVoices: e.target.checked })}
/>
<Mic size={16} /> 导出语音
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportFiles}
onChange={e => onOptionsChange({ exportFiles: e.target.checked })}
/>
<FileBox size={16} /> 导出文件
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportEmojis}
onChange={e => onOptionsChange({ exportEmojis: e.target.checked })}
/>
<Smile size={16} /> 导出表情包
</label>
</div>
{(options.exportVideos || options.exportFiles) && (
<div className="file-size-limit">
<span>最大文件限制 (MB):</span>
<input
type="number"
min={1}
max={MAX_EXPORT_FILE_SIZE_MB_LIMIT}
value={options.maxFileSizeMb}
onChange={e => onOptionsChange({ maxFileSizeMb: Number(e.target.value) })}
/>
</div>
)}
</div>
{/* Advanced Options */}
<div className="config-section">
<div className="section-title">
<Settings size={16} /> 高级选项
</div>
<div className="advanced-options">
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportAvatars}
onChange={e => onOptionsChange({ exportAvatars: e.target.checked })}
/>
包含联系人头像
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVoiceAsText}
onChange={e => onOptionsChange({ exportVoiceAsText: e.target.checked })}
/>
语音转文字 (若已转换)
</label>
<div className="path-style-control">
<span>媒体路径:</span>
<div className="path-style-segmented" title="控制 TXT/JSON/Excel/CSV 中写入的媒体相对路径分隔符">
{[
{ value: 'auto', label: '当前系统' },
{ value: 'posix', label: 'macOS/Linux' },
{ value: 'windows', label: 'Windows' }
].map(item => (
<button
key={item.value}
type="button"
className={options.exportPathStyle === item.value ? 'active' : ''}
onClick={() => onOptionsChange({ exportPathStyle: item.value as ExportOptions['exportPathStyle'] })}
>
{item.label}
</button>
))}
</div>
</div>
<div className="concurrency-control">
<span>导出并发数:</span>
<select
value={options.exportConcurrency}
onChange={e => onOptionsChange({ exportConcurrency: Number(e.target.value) })}
>
<option value={1}>1 (最稳定)</option>
<option value={3}>3 (推荐)</option>
<option value={5}>5 (较快)</option>
<option value={10}>10 (最快, 易卡顿)</option>
</select>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="dialog-footer">
<button className="cancel-btn" onClick={onClose}>取消</button>
<button
className="confirm-btn"
onClick={handleConfirm}
disabled={!exportPath}
>
{isAutomation ? '创建自动化任务' : '开始导出'}
</button>
</div>
</div>
<ExportDateRangeDialog
open={isDateRangeOpen}
value={currentSelection}
onClose={() => setIsDateRangeOpen(false)}
onConfirm={(nextSelection) => {
onDateRangeConfigChange(serializeExportDateRangeConfig(nextSelection))
setIsDateRangeOpen(false)
}}
/>
</div>
)
}
export default memo(ExportDialog)
@@ -0,0 +1,145 @@
.export-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-primary);
border-radius: 10px;
padding: 8px 16px;
box-shadow: 0 4px 12px color-mix(in srgb, var(--shadow-color) 40%, transparent);
border: 1px solid var(--border-color);
.top-bar-left {
display: flex;
align-items: center;
gap: 20px;
.label {
font-size: 13px;
font-weight: 600;
color: var(--text-tertiary);
margin-right: 10px;
white-space: nowrap;
}
.path-selector {
display: flex;
align-items: center;
.path-display-group {
display: flex;
align-items: center;
gap: 6px;
}
.path-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
padding: 5px 12px;
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
max-width: 240px;
.path-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
.open-folder-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
}
.layout-selector {
display: flex;
align-items: center;
.layout-pills {
display: flex;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 3px;
border: 1px solid var(--border-color);
.pill-btn {
background: transparent;
border: none;
padding: 5px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-tertiary) 15%, transparent);
}
&.active {
background: var(--primary);
color: white;
box-shadow: 0 2px 4px color-mix(in srgb, var(--primary) 40%, transparent);
}
}
}
}
}
.top-bar-right {
display: flex;
align-items: center;
.global-settings-btn {
display: flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
padding: 6px 14px;
border-radius: 6px;
color: var(--primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
background: color-mix(in srgb, var(--primary) 8%, transparent);
border-color: var(--primary);
}
}
}
}
@@ -0,0 +1,74 @@
import React, { memo } from 'react'
import { FolderOpen, Settings2, ExternalLink, Bot } from 'lucide-react'
import type { ExportWriteLayout } from '../../../../services/config'
import { writeLayoutOptions } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportTopBar.scss'
interface ExportTopBarProps {
exportPath: string
writeLayout: ExportWriteLayout
onSelectPath: () => void
onWriteLayoutChange: (layout: ExportWriteLayout) => void
onGlobalSettingsClick: () => void
}
const ExportTopBar: React.FC<ExportTopBarProps> = ({
exportPath,
writeLayout,
onSelectPath,
onWriteLayoutChange,
onGlobalSettingsClick
}) => {
return (
<div className="export-top-bar">
<div className="top-bar-left">
<div className="path-selector">
<span className="label">导出路径</span>
<div className="path-display-group">
<button className="path-btn" onClick={onSelectPath} title={exportPath || '请选择导出路径'}>
<FolderOpen size={16} />
<span className="path-text">
{exportPath ? formatPathBrief(exportPath, 40) : '请选择...'}
</span>
</button>
{exportPath && (
<button
className="open-folder-btn"
title="在文件管理器中打开"
onClick={() => window.electronAPI.shell.openPath(exportPath)}
>
<ExternalLink size={16} />
</button>
)}
</div>
</div>
<div className="layout-selector">
<span className="label">目录结构</span>
<div className="layout-pills">
{writeLayoutOptions.map(option => (
<button
key={option.value}
className={`pill-btn ${writeLayout === option.value ? 'active' : ''}`}
onClick={() => onWriteLayoutChange(option.value)}
title={option.desc}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div className="top-bar-right">
<button className="global-settings-btn" onClick={onGlobalSettingsClick}>
<Settings2 size={18} />
<span>全局配置</span>
</button>
</div>
</div>
)
}
export default memo(ExportTopBar)
@@ -0,0 +1,112 @@
.session-detail {
background: var(--bg-primary);
border-radius: 16px;
border: 1px solid var(--border-color);
padding: 20px;
box-shadow: 0 4px 24px color-mix(in srgb, var(--shadow-color) 20%, transparent);
.sd-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
.sd-avatar {
width: 64px;
height: 64px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.sd-info {
display: flex;
flex-direction: column;
gap: 4px;
.sd-name {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.sd-alias {
font-size: 13px;
color: var(--text-secondary);
}
.sd-time {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
}
.sd-stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.stat-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
cursor: pointer;
&:hover {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary));
transform: translateY(-2px);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);
}
.stat-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.message { background: #E3F2FD; color: #1976D2; }
&.image { background: #E8F5E9; color: #388E3C; }
&.voice { background: #FFF3E0; color: #F57C00; }
&.video { background: #FCE4EC; color: #C2185B; }
&.file { background: #F3E5F5; color: #7B1FA2; }
&.emoji { background: #FFF8E1; color: #FBC02D; }
}
.stat-content {
display: flex;
flex-direction: column;
gap: 2px;
.stat-label {
font-size: 12px;
color: var(--text-secondary);
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
}
}
}
}
@@ -0,0 +1,87 @@
import React, { memo, useEffect } from 'react'
import type { SessionRow } from '../../types'
import { useSessionMetrics } from '../../hooks/useSessionMetrics'
import { formatAbsoluteDate } from '../../utils/format'
import { MessageSquare, Image, Mic, Video, FileText, Smile } from 'lucide-react'
import './SessionDetail.scss'
interface SessionDetailProps {
session: SessionRow
onExportContent: (contentType: 'text' | 'image' | 'voice' | 'video' | 'file' | 'emoji') => void
}
const SessionDetail: React.FC<SessionDetailProps> = ({ session, onExportContent }) => {
const { metricsMap, fetchMetrics, isLoading } = useSessionMetrics()
useEffect(() => {
fetchMetrics([session.username])
}, [session.username, fetchMetrics])
const metrics = metricsMap[session.username]
return (
<div className="session-detail">
<div className="sd-header">
<div className="sd-avatar">
<img src={session.avatarUrl || 'file://./assets/default_avatar.png'} alt="avatar" />
</div>
<div className="sd-info">
<h2 className="sd-name">{session.displayName}</h2>
{session.remark && session.nickname && session.remark !== session.nickname && (
<span className="sd-alias">昵称: {session.nickname}</span>
)}
<span className="sd-time">
最后活跃: {session.lastTimestamp ? formatAbsoluteDate(session.lastTimestamp) : '未知'}
</span>
</div>
</div>
<div className="sd-stats-grid">
<div className="stat-card" onClick={() => onExportContent('text')}>
<div className="stat-icon message"><MessageSquare size={18} /></div>
<div className="stat-content">
<span className="stat-label">文本/总消息</span>
<span className="stat-value">{metrics?.totalMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('image')}>
<div className="stat-icon image"><Image size={18} /></div>
<div className="stat-content">
<span className="stat-label">图片</span>
<span className="stat-value">{metrics?.imageMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('voice')}>
<div className="stat-icon voice"><Mic size={18} /></div>
<div className="stat-content">
<span className="stat-label">语音</span>
<span className="stat-value">{metrics?.voiceMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('video')}>
<div className="stat-icon video"><Video size={18} /></div>
<div className="stat-content">
<span className="stat-label">视频</span>
<span className="stat-value">{metrics?.videoMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('file')}>
<div className="stat-icon file"><FileText size={18} /></div>
<div className="stat-content">
<span className="stat-label">文件</span>
<span className="stat-value">{metrics?.fileMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('emoji')}>
<div className="stat-icon emoji"><Smile size={18} /></div>
<div className="stat-content">
<span className="stat-label">表情</span>
<span className="stat-value">{metrics?.emojiMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
</div>
</div>
)
}
export default memo(SessionDetail)
@@ -0,0 +1,290 @@
.session-table-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-surface, var(--bg-primary)); // Fallback
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px color-mix(in srgb, var(--shadow-color) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
// ── Top Filter (Search + Tabs inline) ──────────────────────────────────
.st-top-filter {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
flex-shrink: 0;
box-sizing: border-box;
padding: 0 24px;
background: var(--bg-surface, var(--bg-primary));
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
gap: 24px;
-webkit-app-region: no-drag;
.st-tabs {
display: flex;
gap: 8px;
.tab-btn {
background: transparent;
border: none;
padding: 6px 16px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-radius: 20px;
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover, color-mix(in srgb, var(--primary) 10%, transparent));
color: var(--text-primary);
}
&.active {
background: var(--bg-active, color-mix(in srgb, var(--primary) 15%, transparent));
color: var(--primary);
font-weight: 600;
}
}
}
.st-search {
display: flex;
align-items: center;
background: var(--bg-main, var(--bg-secondary));
border-radius: 20px;
padding: 6px 14px;
width: 280px;
transition: all 0.2s ease;
border: 1px solid transparent;
&:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
background: var(--bg-surface, var(--bg-primary));
}
.search-icon {
color: var(--text-tertiary);
margin-right: 8px;
flex-shrink: 0;
}
input {
flex: 1;
border: none;
background: transparent;
outline: none;
color: var(--text-primary);
font-size: 13px;
min-width: 0;
&::placeholder {
color: var(--text-tertiary);
}
}
.clear-search-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 50%;
margin-left: 4px;
&:hover {
color: var(--text-secondary);
}
}
}
}
// ── Grid Layout Definition ───────────────────────────────────────────
.st-header-row, .st-row {
display: grid;
grid-template-columns: 48px minmax(200px, 1fr) 80px 120px 60px 60px 60px 60px 60px 80px;
align-items: center;
gap: 8px;
padding: 0 24px;
}
// ── Header Row ──────────────────────────────────────────────────────────
.st-header-row {
height: 44px;
background: var(--bg-main, var(--bg-secondary));
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
.col-checkbox {
justify-content: flex-start;
}
> div {
display: flex;
align-items: center;
}
.col-stat, .col-count {
justify-content: flex-end;
text-align: right;
}
.col-action {
justify-content: center;
}
}
// ── List Body ───────────────────────────────────────────────────────────
.table-body {
flex: 1;
overflow: hidden; // virtuoso handles scrolling
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: var(--text-tertiary);
font-size: 14px;
}
}
// ── Data Row ────────────────────────────────────────────────────────────
.st-row {
height: 64px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 20%, transparent);
transition: background 0.15s ease;
cursor: default;
&:hover {
background: var(--bg-hover, color-mix(in srgb, var(--text-primary) 3%, transparent));
}
> div {
display: flex;
align-items: center;
font-size: 13px;
color: var(--text-primary);
}
.col-stat, .col-count {
justify-content: flex-end;
font-family: 'Inter', system-ui, sans-serif;
color: var(--text-secondary);
}
.col-count {
font-weight: 500;
color: var(--text-primary);
}
.col-time {
color: var(--text-tertiary);
font-size: 12px;
}
.col-action {
justify-content: center;
.st-action-btn {
background: transparent;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
border-color: var(--primary);
}
}
}
// Avatar and info
.col-info {
gap: 12px;
overflow: hidden;
.st-avatar {
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary) 10%, transparent);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-letter {
font-size: 18px;
color: var(--primary);
font-weight: 600;
}
}
.st-text {
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
flex: 1;
.st-name {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.st-id {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
}
}
}
// ── Checkbox ──────────────────────────────────────────────────────────
.checkbox-btn {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
&:hover {
color: var(--text-secondary);
}
.checked {
color: var(--primary);
}
}
}
@@ -0,0 +1,231 @@
import React, { memo, useRef, useCallback } from 'react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { Search, Hash, Clock, MoreVertical, X, CheckSquare, Square } from 'lucide-react'
import type { SessionRow, ConversationTab, ContactsSortConfig } from '../../types'
import type { SessionContentMetric } from '../../hooks/useSessionMetrics'
import { conversationTabLabels, exportKindPriority } from '../../constants'
import { getAvatarLetter } from '../../utils/format'
import { formatLatestMessageTimeFromSeconds } from '../../utils/format'
import './SessionTable.scss'
interface SessionTableProps {
sessions: SessionRow[]
activeTab: ConversationTab
searchQuery: string
sortConfig: ContactsSortConfig
selectedSessionIds: Set<string>
onTabChange: (tab: ConversationTab) => void
onSearchChange: (query: string) => void
onSortChange: (config: ContactsSortConfig) => void
onSelectionChange: (selectedIds: Set<string>) => void
onSingleExport?: (sessionId: string) => void
onBatchExport?: () => void
onAutomationExport?: () => void
isLoading?: boolean
metricsMap?: Record<string, SessionContentMetric>
}
const SessionTable: React.FC<SessionTableProps> = ({
sessions,
activeTab,
searchQuery,
sortConfig,
selectedSessionIds,
onTabChange,
onSearchChange,
onSortChange,
onSelectionChange,
onSingleExport,
onBatchExport,
onAutomationExport,
isLoading,
metricsMap
}) => {
const virtuosoRef = useRef<VirtuosoHandle>(null)
const handleSearchClear = () => onSearchChange('')
const handleSortClick = (key: ContactsSortConfig['key']) => {
if (sortConfig.key === key) {
if (sortConfig.order === 'desc') {
onSortChange({ key, order: 'asc' })
} else {
onSortChange({ key, order: 'desc' })
}
} else {
onSortChange({ key, order: 'desc' })
}
}
const handleSelectAll = () => {
if (selectedSessionIds.size === sessions.length && sessions.length > 0) {
onSelectionChange(new Set())
} else {
onSelectionChange(new Set(sessions.map(s => s.username)))
}
}
const toggleSelection = useCallback((username: string) => {
const next = new Set(selectedSessionIds)
if (next.has(username)) {
next.delete(username)
} else {
next.add(username)
}
onSelectionChange(next)
}, [selectedSessionIds, onSelectionChange])
// Tabs layout
const tabs: ConversationTab[] = ['private', 'group', 'official', 'former_friend']
return (
<div className="session-table-container">
{/* ─── Search & Tabs (Top Filter) ─────────────────────────────────────────── */}
<div className="st-top-filter">
<div className="st-tabs">
{tabs.map((tab) => (
<button
key={tab}
className={`tab-btn ${activeTab === tab ? 'active' : ''}`}
onClick={() => onTabChange(tab)}
>
{conversationTabLabels[tab]}
</button>
))}
</div>
{selectedSessionIds.size > 0 && (
<div className="st-selection-actions" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px' }}>
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>已选 {selectedSessionIds.size} 项</span>
{onBatchExport && (
<button className="primary-btn" onClick={onBatchExport} style={{ height: '28px', padding: '0 16px', borderRadius: '14px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '13px' }}>
批量导出
</button>
)}
{onAutomationExport && (
<button className="secondary-btn" onClick={onAutomationExport} style={{ height: '28px', padding: '0 16px', borderRadius: '14px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '13px' }}>
创建自动化
</button>
)}
</div>
)}
<div className="st-search" style={{ marginLeft: selectedSessionIds.size > 0 ? '0' : 'auto' }}>
<Search size={16} className="search-icon" />
<input
type="text"
placeholder="搜索联系人、群组、微信号或备注..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchQuery && (
<button className="clear-search-btn" onClick={handleSearchClear}>
<X size={16} />
</button>
)}
</div>
</div>
{/* ─── Table Columns Header ─────────────────────────────────────── */}
<div className="st-header-row">
<div className="col-checkbox">
<button className="checkbox-btn" onClick={handleSelectAll}>
{selectedSessionIds.size === sessions.length && sessions.length > 0 ? (
<CheckSquare size={18} className="checked" />
) : (
<Square size={18} />
)}
</button>
</div>
<div className="col-info">会话 / 联系人</div>
<div className="col-count sortable" onClick={() => handleSortClick('messageCount')} style={{ cursor: 'pointer', display: 'flex', gap: '4px', userSelect: 'none' }}>
总消息数
{sortConfig.key === 'messageCount' && (
<span className="sort-icon">{sortConfig.order === 'desc' ? '↓' : '↑'}</span>
)}
</div>
<div className="col-time sortable" onClick={() => handleSortClick('latestMessageTime')} style={{ cursor: 'pointer', display: 'flex', gap: '4px', userSelect: 'none' }}>
最新消息时间
{sortConfig.key === 'latestMessageTime' && (
<span className="sort-icon">{sortConfig.order === 'desc' ? '↓' : '↑'}</span>
)}
</div>
<div className="col-stat">表情包</div>
<div className="col-stat">语音</div>
<div className="col-stat">图片</div>
<div className="col-stat">视频</div>
<div className="col-stat">文件</div>
<div className="col-action">操作</div>
</div>
{/* ─── Virtualized List ─────────────────────────────────────────── */}
<div className="table-body">
{sessions.length === 0 ? (
<div className="empty-state">
{isLoading ? '加载中...' : (searchQuery ? '没有匹配的会话' : '此分类下没有会话')}
</div>
) : (
<Virtuoso
ref={virtuosoRef}
data={sessions}
itemContent={(index, session) => {
if (!session) return null
const metrics = metricsMap?.[session.username]
const totalCount = metrics?.totalMessages ?? session.messageCountHint ?? 0
return (
<div className="st-row" key={session.username}>
<div className="col-checkbox">
<button className="checkbox-btn" onClick={() => toggleSelection(session.username)}>
{selectedSessionIds.has(session.username) ? (
<CheckSquare size={18} className="checked" />
) : (
<Square size={18} />
)}
</button>
</div>
<div className="col-info">
<div className="st-avatar">
{session.avatarUrl ? (
<img src={session.avatarUrl} alt="avatar" />
) : (
<span className="avatar-letter">{getAvatarLetter(session.displayName || session.username)}</span>
)}
</div>
<div className="st-text">
<span className="st-name" title={session.displayName || session.username}>{session.displayName || session.username}</span>
<span className="st-id" title={session.username}>{session.username}</span>
</div>
</div>
<div className="col-count">{totalCount.toLocaleString()}</div>
<div className="col-time">
{session.lastTimestamp ? formatLatestMessageTimeFromSeconds(session.lastTimestamp).text : '-'}
</div>
<div className="col-stat">{metrics?.emojiMessages ?? '-'}</div>
<div className="col-stat">{metrics?.voiceMessages ?? '-'}</div>
<div className="col-stat">{metrics?.imageMessages ?? '-'}</div>
<div className="col-stat">{metrics?.videoMessages ?? '-'}</div>
<div className="col-stat">{metrics?.fileMessages ?? '-'}</div>
<div className="col-action">
<button
className="st-action-btn"
onClick={(e) => {
e.stopPropagation()
onSingleExport?.(session.username)
}}
>
导出
</button>
</div>
</div>
)
}}
/>
)}
</div>
</div>
)
}
export default memo(SessionTable)
@@ -0,0 +1,243 @@
.task-center {
background: var(--bg-surface, var(--bg-primary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
.task-center-header {
display: flex;
justify-content: space-between;
align-items: center;
.title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.clear-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-tertiary);
background: transparent;
border: 1px solid transparent;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
}
}
}
.task-list {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding-right: 4px;
// Custom scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
}
.task-card {
background: var(--bg-main, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
transition: all 0.2s ease;
&:hover {
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.task-card-header {
display: flex;
justify-content: space-between;
align-items: center;
.task-info {
display: flex;
align-items: center;
gap: 10px;
.task-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.task-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
color: var(--text-secondary);
.spin {
animation: spin 1s linear infinite;
}
}
}
.task-actions {
display: flex;
align-items: center;
gap: 6px;
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: transparent;
border: 1px solid transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-surface, var(--bg-primary));
color: var(--text-primary);
border-color: var(--border-color);
}
&.cancel {
&:hover {
color: #ef4444;
border-color: color-mix(in srgb, #ef4444 30%, var(--border-color));
background: color-mix(in srgb, #ef4444 10%, transparent);
}
}
}
}
}
.task-progress-area {
display: flex;
align-items: center;
gap: 12px;
.progress-bar-bg {
flex: 1;
height: 4px;
background: color-mix(in srgb, var(--border-color) 60%, var(--bg-surface));
border-radius: 2px;
overflow: hidden;
.progress-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 2px;
transition: width 0.3s ease;
}
}
.progress-stats {
display: flex;
align-items: center;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
min-width: 80px;
justify-content: flex-end;
}
}
.task-error-msg {
font-size: 11px;
color: #ef4444;
background: color-mix(in srgb, #ef4444 10%, transparent);
padding: 6px 10px;
border-radius: 4px;
margin-top: 4px;
word-break: break-all;
}
/* Status specific styles */
&.status-running {
.task-status-badge {
background: color-mix(in srgb, var(--primary) 15%, transparent);
color: var(--primary);
}
}
&.status-success {
border-color: color-mix(in srgb, #10b981 30%, var(--border-color));
.task-status-badge {
background: color-mix(in srgb, #10b981 15%, transparent);
color: #10b981;
}
.progress-bar-fill {
background: #10b981;
}
}
&.status-error {
border-color: color-mix(in srgb, #ef4444 30%, var(--border-color));
.task-status-badge {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #ef4444;
}
.progress-bar-fill {
background: #ef4444;
}
}
}
.bg-tasks-section {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.bg-task {
.task-progress-text {
font-size: 12px;
color: var(--text-secondary);
}
}
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -0,0 +1,148 @@
import React, { memo } from 'react'
import { CheckCircle2, XCircle, Loader2, PlayCircle, PauseCircle, Trash2, StopCircle } from 'lucide-react'
import type { ExportTask } from '../../types'
import type { BackgroundTaskRecord } from '../../../../types/backgroundTask'
import {
backgroundTaskSourceLabels,
backgroundTaskStatusLabels
} from '../../constants'
import { formatDurationMs } from '../../utils/format'
import './TaskCenter.scss'
interface TaskCenterProps {
exportTasks: ExportTask[]
onCancelExportTask: (taskId: string) => void
onClearCompletedExportTasks: () => void
backgroundTasks: BackgroundTaskRecord[]
onPauseBackgroundTask: (taskId: string) => void
onResumeBackgroundTask: (taskId: string) => void
onCancelBackgroundTask: (taskId: string) => void
}
const TaskCenter: React.FC<TaskCenterProps> = ({
exportTasks,
onCancelExportTask,
onClearCompletedExportTasks,
backgroundTasks,
onPauseBackgroundTask,
onResumeBackgroundTask,
onCancelBackgroundTask
}) => {
const hasTasks = exportTasks.length > 0 || backgroundTasks.length > 0
if (!hasTasks) return null
const activeExportTasks = exportTasks.filter(t => t.status === 'running' || t.status === 'cancel_requested')
const completedExportTasks = exportTasks.filter(t => t.status === 'success' || t.status === 'error')
return (
<div className="task-center">
<div className="task-center-header">
<h2 className="title">任务中心</h2>
{completedExportTasks.length > 0 && (
<button className="clear-btn" onClick={onClearCompletedExportTasks}>
<Trash2 size={14} /> 清理已完成
</button>
)}
</div>
<div className="task-list">
{/* Export Tasks */}
{exportTasks.map(task => {
const hasMessageProgress = Number(task.progress.phaseTotal) > 0
const phaseLabel = task.progress.phaseLabel || task.progress.phase
const labelAlreadyHasCount = hasMessageProgress && /\d[\d,]*\s*\/\s*\d[\d,]*\s*条/.test(phaseLabel)
const countText = hasMessageProgress
? `${Math.max(0, Math.floor(Number(task.progress.phaseProgress) || 0)).toLocaleString()} / ${Math.max(0, Math.floor(Number(task.progress.phaseTotal) || 0)).toLocaleString()} 条`
: `${Math.max(0, Math.floor(Number(task.progress.current) || 0))} / ${Math.max(0, Math.floor(Number(task.progress.total) || 0))}`
return (
<div key={task.id} className={`task-card export-task status-${task.status}`}>
<div className="task-card-header">
<div className="task-info">
<span className="task-title">{task.title}</span>
<span className="task-status-badge">
{task.status === 'running' && <Loader2 className="spin" size={12} />}
{task.status === 'success' && <CheckCircle2 size={12} />}
{task.status === 'error' && <XCircle size={12} />}
{task.status === 'cancel_requested' && <Loader2 className="spin" size={12} />}
{task.status === 'running' ? '导出中' : task.status === 'success' ? '已完成' : task.status === 'cancel_requested' ? '取消中' : '失败'}
</span>
</div>
<div className="task-actions">
{task.status === 'running' && (
<button className="action-btn cancel" onClick={() => onCancelExportTask(task.id)} title="取消任务">
<StopCircle size={16} />
</button>
)}
</div>
</div>
<div className="task-progress-area">
<div className="progress-bar-bg">
<div
className="progress-bar-fill"
style={{ width: `${Math.max(0, Math.min(100, (task.progress.current / Math.max(1, task.progress.total)) * 100))}%` }}
/>
</div>
<div className="progress-stats">
<span className="phase">{phaseLabel}</span>
{!labelAlreadyHasCount && <span className="count">{countText}</span>}
</div>
{task.error && <div className="task-error-msg">{task.error}</div>}
</div>
</div>
)
})}
{/* Global Background Tasks */}
{backgroundTasks.length > 0 && (
<div className="bg-tasks-section">
<h3 className="section-title">全局后台任务</h3>
{backgroundTasks.map(bgTask => {
const isPausable = bgTask.status === 'running'
const isResumable = bgTask.status === 'paused'
const isCancelable = bgTask.status !== 'completed' && bgTask.status !== 'canceled' && bgTask.status !== 'failed'
return (
<div key={bgTask.id} className={`task-card bg-task status-${bgTask.status}`}>
<div className="task-card-header">
<div className="task-info">
<span className="task-title">{backgroundTaskSourceLabels[bgTask.sourcePage] || '后台任务'}</span>
<span className="task-status-badge">{backgroundTaskStatusLabels[bgTask.status]}</span>
</div>
<div className="task-actions">
{isPausable && (
<button className="action-btn" onClick={() => onPauseBackgroundTask(bgTask.id)} title="暂停">
<PauseCircle size={16} />
</button>
)}
{isResumable && (
<button className="action-btn" onClick={() => onResumeBackgroundTask(bgTask.id)} title="恢复">
<PlayCircle size={16} />
</button>
)}
{isCancelable && (
<button className="action-btn cancel" onClick={() => onCancelBackgroundTask(bgTask.id)} title="取消">
<StopCircle size={16} />
</button>
)}
</div>
</div>
{bgTask.progressText && (
<div className="task-progress-text">
{bgTask.progressText}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
}
export default memo(TaskCenter)
+192
View File
@@ -0,0 +1,192 @@
/**
* ExportV2 — Constants and configuration
* Extracted from ExportPage.tsx, cleaned and organized.
*/
import type {
ContentType,
ConversationTab,
DisplayNamePreference,
TextExportFormat,
TaskProgress,
ContactsSortConfig
} from './types'
import type { BackgroundTaskRecord } from '../../types/backgroundTask'
import type { ExportWriteLayout } from '../../services/config'
// ─── Content type labels ─────────────────────────────────────
export const contentTypeLabels: Record<ContentType, string> = {
text: '聊天文本',
voice: '语音',
image: '图片',
video: '视频',
emoji: '表情包',
file: '文件'
}
export const getContentTypeLabel = (type: ContentType): string =>
contentTypeLabels[type] || type
// ─── Conversation tab labels ─────────────────────────────────
export const conversationTabLabels: Record<ConversationTab, string> = {
private: '私聊',
group: '群聊',
official: '公众号',
former_friend: '曾经的好友'
}
// ─── Export format options ────────────────────────────────────
export const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
]
// ─── Display name options ────────────────────────────────────
export const displayNameOptions: Array<{ value: DisplayNamePreference; label: string; desc: string }> = [
{ value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' },
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' },
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
]
// ─── Write layout options ────────────────────────────────────
export const writeLayoutOptions: Array<{ value: ExportWriteLayout; label: string; desc: string }> = [
{
value: 'A',
label: 'A(类型分目录)',
desc: '聊天文本、语音、视频、表情包、图片分别创建文件夹'
},
{
value: 'B',
label: 'B(文本根目录+媒体按会话)',
desc: '聊天文本在根目录;媒体按类型目录后再按会话分目录'
},
{
value: 'C',
label: 'C(按会话分目录)',
desc: '每个会话一个目录,目录内包含文本与媒体文件'
}
]
// ─── File size presets ───────────────────────────────────────
export const FILE_SIZE_PRESETS_MB = [0, 100, 200, 500, 1024] as const
export const MAX_EXPORT_FILE_SIZE_MB_LIMIT = 4096
// ─── Default txt export columns ──────────────────────────────
export const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
// ─── Background task labels ──────────────────────────────────
export const backgroundTaskSourceLabels: Record<string, string> = {
export: '导出页',
chat: '聊天页',
analytics: '分析页',
sns: '朋友圈页',
groupAnalytics: '群分析页',
annualReport: '年度报告',
other: '其他页面'
}
export const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
running: '运行中',
pause_requested: '中断中',
paused: '已中断',
cancel_requested: '停止中',
completed: '已完成',
failed: '失败',
canceled: '已停止'
}
// ─── Conversation tab sort priority ──────────────────────────
export const exportKindPriority: Record<ConversationTab, number> = {
private: 0,
group: 1,
former_friend: 2,
official: 3
}
// ─── Default sort config ─────────────────────────────────────
export const DEFAULT_CONTACTS_SORT_CONFIG: ContactsSortConfig = { key: null, order: null }
// ─── Timing constants ────────────────────────────────────────
export const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
export const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
export const EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS = 320
export const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
export const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
export const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
export const SESSION_MEDIA_METRIC_VISIBLE_REFRESH_LIMIT = 24
export const SESSION_MEDIA_METRIC_TAB_REFRESH_LIMIT = 96
export const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200
export const SESSION_DETAIL_BACKGROUND_METRIC_LIMIT_PER_TAB = 96
export const INLINE_AVATAR_CACHE_MAX_LENGTH = 4096
export const CONTACT_ENRICH_TIMEOUT_MS = 7000
export const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
export const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
export const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
export const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
export const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
// ─── Default export options ──────────────────────────────────
export const createDefaultExportOptions = (): import('./types').ExportOptions => ({
format: 'json',
dateRange: {
start: new Date(new Date().setHours(0, 0, 0, 0)),
end: new Date()
},
useAllTime: false,
exportAvatars: true,
exportMedia: true,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportFiles: true,
maxFileSizeMb: 200,
exportVoiceAsText: false,
exportPathStyle: 'auto',
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
exportConcurrency: 2
})
// ─── Empty progress factory ──────────────────────────────────
export const createEmptyProgress = (): TaskProgress => ({
current: 0,
total: 0,
currentName: '',
phase: '',
phaseLabel: '',
phaseProgress: 0,
phaseTotal: 0,
exportedMessages: 0,
estimatedTotalMessages: 0,
collectedMessages: 0,
writtenFiles: 0,
mediaDoneFiles: 0,
mediaCacheHitFiles: 0,
mediaCacheMissFiles: 0,
mediaCacheFillFiles: 0,
mediaDedupReuseFiles: 0,
mediaBytesWritten: 0
})
+316
View File
@@ -0,0 +1,316 @@
import { create } from 'zustand'
import { useEffect, useRef, useCallback } from 'react'
import * as configService from '../../../services/config'
import type { ExportAutomationTask } from '../../../types/exportAutomation'
import type { ExportTaskPayload } from '../types'
import {
resolveAutomationDueScheduleKey,
} from '../utils/automation'
// ─── Core Logic Helpers ─────────────────────────────────────
export const resolveAutomationScopeKey = async (): Promise<string> => {
const [myWxid, dbPath] = await Promise.all([
configService.getMyWxid(),
configService.getDbPath()
])
return dbPath || myWxid ? `${dbPath || ''}::${myWxid || ''}` : 'default'
}
const resolveAutomationHasNewMessages = async (task: ExportAutomationTask): Promise<{ shouldRun: boolean; reason?: string }> => {
const lastSuccessAt = Number(task.runState?.lastSuccessAt || 0)
if (!lastSuccessAt) return { shouldRun: true }
const stats = await window.electronAPI.chat.getExportSessionStats(task.sessionIds, {
includeRelations: false,
allowStaleCache: true
})
if (!stats.success || !stats.data) {
return { shouldRun: false, reason: stats.error || '会话统计失败,已跳过' }
}
let latestTimestamp = 0
for (const sessionId of task.sessionIds) {
const raw = Number(stats.data?.[sessionId]?.lastTimestamp || 0)
if (Number.isFinite(raw) && raw > latestTimestamp) {
latestTimestamp = Math.max(0, Math.floor(raw))
}
}
if (latestTimestamp <= 0) {
return { shouldRun: false, reason: '未检测到可用会话时间戳,已跳过' }
}
const lastSuccessSeconds = Math.floor(lastSuccessAt / 1000)
if (latestTimestamp > lastSuccessSeconds) {
return { shouldRun: true }
} else {
return { shouldRun: false, reason: '无新消息,本次触发已跳过' }
}
}
// ─── Zustand Store ──────────────────────────────────────────
interface AutomationState {
isReady: boolean
tasks: ExportAutomationTask[]
loadTasks: () => Promise<void>
addTask: (task: ExportAutomationTask) => Promise<void>
updateTask: (taskId: string, updater: (prev: ExportAutomationTask) => ExportAutomationTask) => Promise<void>
deleteTask: (taskId: string) => Promise<void>
}
export const useAutomationStore = create<AutomationState>((set, get) => ({
isReady: false,
tasks: [],
loadTasks: async () => {
try {
const scopeKey = await resolveAutomationScopeKey()
const item = await configService.getExportAutomationTasks(scopeKey)
set({ tasks: item?.tasks || [], isReady: true })
} catch (error) {
console.error('加载自动化导出任务失败', error)
set({ isReady: true })
}
},
addTask: async (task) => {
const { tasks } = get()
const nextTasks = [task, ...tasks]
set({ tasks: nextTasks })
const scopeKey = await resolveAutomationScopeKey()
await configService.setExportAutomationTasks(scopeKey, nextTasks)
},
updateTask: async (taskId, updater) => {
const { tasks } = get()
const nextTasks = tasks.map(t => t.id === taskId ? updater(t) : t)
set({ tasks: nextTasks })
const scopeKey = await resolveAutomationScopeKey()
await configService.setExportAutomationTasks(scopeKey, nextTasks)
},
deleteTask: async (taskId) => {
const { tasks } = get()
const nextTasks = tasks.filter(t => t.id !== taskId)
set({ tasks: nextTasks })
const scopeKey = await resolveAutomationScopeKey()
await configService.setExportAutomationTasks(scopeKey, nextTasks)
}
}))
// ─── Runner Hook ────────────────────────────────────────────
export function useAutomationRunner(startTask: (payload: ExportTaskPayload) => void) {
const { isReady, tasks, loadTasks, updateTask } = useAutomationStore()
const isRunningRef = useRef(false)
const startTaskRef = useRef(startTask)
const tasksRef = useRef(tasks)
// Keep refs in sync
useEffect(() => { startTaskRef.current = startTask }, [startTask])
useEffect(() => { tasksRef.current = tasks }, [tasks])
// Initialize store
useEffect(() => {
if (!isReady) {
void loadTasks()
}
}, [isReady, loadTasks])
// Stable evaluate function that reads from refs + Zustand.getState()
const evaluate = useCallback(async () => {
const store = useAutomationStore.getState()
if (!store.isReady) return
if (isRunningRef.current) return
isRunningRef.current = true
const patchTask = (taskId: string, updater: (prev: ExportAutomationTask) => ExportAutomationTask) => {
void store.updateTask(taskId, updater)
}
const markSkipped = (taskId: string, reason: string, scheduleKey?: string) => {
patchTask(taskId, (current) => ({
...current,
updatedAt: Date.now(),
runState: {
...(current.runState || {}),
lastRunStatus: 'skipped',
lastSkipAt: Date.now(),
lastSkipReason: reason,
lastScheduleKey: scheduleKey || current.runState?.lastScheduleKey
}
}))
}
try {
const now = new Date()
const currentTasks = useAutomationStore.getState().tasks
const enabledTasks = currentTasks.filter((task) => task.enabled)
for (const task of enabledTasks) {
const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0)))
const maxRuns = Math.max(0, Math.floor(Number(task.stopCondition?.maxRuns || 0)))
// Check max runs
if (maxRuns > 0 && successCount >= maxRuns) {
patchTask(task.id, (current) => ({
...current,
enabled: false,
updatedAt: Date.now(),
runState: {
...(current.runState || {}),
lastRunStatus: 'skipped',
lastSkipAt: Date.now(),
lastSkipReason: `已达到最大执行次数(${maxRuns} 次),任务已自动停用`,
successCount
}
}))
continue
}
// Check end date
const endAt = Number(task.stopCondition?.endAt || 0)
if (endAt > 0 && now.getTime() > endAt) {
patchTask(task.id, (current) => ({
...current,
enabled: false,
updatedAt: Date.now(),
runState: {
...(current.runState || {}),
lastRunStatus: 'skipped',
lastSkipAt: Date.now(),
lastSkipReason: '已超过终止时间,任务已自动停用'
}
}))
continue
}
const scheduleKey = resolveAutomationDueScheduleKey(task, now)
if (!scheduleKey) continue
if (task.runState?.lastScheduleKey === scheduleKey) continue
// Check conditions
if (task.condition?.type === 'new-message-since-last-success') {
const checkResult = await resolveAutomationHasNewMessages(task)
if (!checkResult.shouldRun) {
markSkipped(task.id, checkResult.reason || '无新消息,本次触发已跳过', scheduleKey)
continue
}
}
// Enqueue
const outputDir = String(task.outputDir || '').trim() || await configService.getExportPath()
if (!outputDir) {
markSkipped(task.id, '导出目录未设置', scheduleKey)
continue
}
const exportOptions: any = {
...task.template.optionTemplate,
exportPathStyle: task.template.optionTemplate?.exportPathStyle || 'auto',
dateRange: typeof task.template.dateRangeConfig === 'string'
? JSON.parse(task.template.dateRangeConfig)
: task.template.dateRangeConfig
}
startTaskRef.current({
sessionIds: task.sessionIds,
sessionNames: task.sessionNames,
outputDir,
options: exportOptions,
scope: task.template.scope,
source: 'automation',
automationTaskId: task.id,
contentType: task.template.contentType
})
patchTask(task.id, (prev) => ({
...prev,
updatedAt: Date.now(),
runState: {
...(prev.runState || {}),
lastRunStatus: 'queued',
lastTriggeredAt: Date.now(),
lastSkipReason: undefined,
lastError: undefined,
lastScheduleKey: scheduleKey
}
}))
}
} catch (error) {
console.error('Automation Schedule Evaluation Failed', error)
} finally {
isRunningRef.current = false
}
}, []) // No reactive deps — reads from refs/getState() directly
// Stable 30-second timer that never resets
useEffect(() => {
if (!isReady) return
let cancelled = false
const run = () => {
if (cancelled) return
void evaluate()
}
// Run once immediately
run()
const timer = setInterval(run, 30_000)
return () => {
cancelled = true
clearInterval(timer)
}
}, [isReady, evaluate])
// Manual trigger helper
const enqueueAutomationTask = useCallback(async (
task: ExportAutomationTask,
_options?: { scheduleKey?: string }
): Promise<{ queued: boolean; reason?: string }> => {
const outputDir = String(task.outputDir || '').trim() || await configService.getExportPath()
if (!outputDir) {
return { queued: false, reason: '导出目录未设置' }
}
const exportOptions: any = {
...task.template.optionTemplate,
exportPathStyle: task.template.optionTemplate?.exportPathStyle || 'auto',
dateRange: typeof task.template.dateRangeConfig === 'string'
? JSON.parse(task.template.dateRangeConfig)
: task.template.dateRangeConfig
}
startTaskRef.current({
sessionIds: task.sessionIds,
sessionNames: task.sessionNames,
outputDir,
options: exportOptions,
scope: task.template.scope,
source: 'automation',
automationTaskId: task.id,
contentType: task.template.contentType
})
void updateTask(task.id, (prev) => ({
...prev,
updatedAt: Date.now(),
runState: {
...(prev.runState || {}),
lastRunStatus: 'queued',
lastTriggeredAt: Date.now(),
lastSkipReason: undefined,
lastError: undefined,
lastScheduleKey: _options?.scheduleKey || prev.runState?.lastScheduleKey
}
}))
return { queued: true }
}, [updateTask])
return { enqueueAutomationTask }
}
@@ -0,0 +1,81 @@
/**
* ExportV2 — useBackgroundTasks hook
*
* Subscribes to global background tasks (like wechat-backup, data-processing)
* to display them in the Export Page if there are any active ones.
*/
import { useState, useEffect, useCallback, useMemo } from 'react'
import type { BackgroundTaskRecord } from '../../../types/backgroundTask'
import {
requestCancelBackgroundTask,
requestCancelBackgroundTasks,
requestPauseBackgroundTask,
requestResumeBackgroundTask,
subscribeBackgroundTasks
} from '../../../services/backgroundTaskMonitor'
export interface BackgroundTasksResult {
allTasks: BackgroundTaskRecord[]
chatTasks: BackgroundTaskRecord[]
nonExportTasks: BackgroundTaskRecord[]
nonExportTasksUpdatedAt: number
pauseTask: (taskId: string) => void
resumeTask: (taskId: string) => void
cancelTask: (taskId: string) => void
cancelChatTasks: () => void
}
export function useBackgroundTasks(): BackgroundTasksResult {
const [tasks, setTasks] = useState<BackgroundTaskRecord[]>([])
useEffect(() => {
// Subscribe to global background tasks
const unsubscribe = subscribeBackgroundTasks((newTasks) => {
setTasks(newTasks)
})
return () => unsubscribe()
}, [])
const chatTasks = useMemo(() => {
return tasks.filter(task => task.sourcePage === 'chat')
}, [tasks])
const nonExportTasks = useMemo(() => {
// Tasks that are neither from export nor chat
return tasks.filter(task => task.sourcePage !== 'export' && task.sourcePage !== 'chat')
}, [tasks])
const nonExportTasksUpdatedAt = useMemo(() => {
return nonExportTasks.reduce((latest, task) => Math.max(latest, task.updatedAt || 0), 0)
}, [nonExportTasks])
const pauseTask = useCallback((taskId: string) => {
requestPauseBackgroundTask(taskId)
}, [])
const resumeTask = useCallback((taskId: string) => {
requestResumeBackgroundTask(taskId)
}, [])
const cancelTask = useCallback((taskId: string) => {
requestCancelBackgroundTask(taskId)
}, [])
const cancelChatTasks = useCallback(() => {
requestCancelBackgroundTasks(task => task.sourcePage === 'chat' && task.status !== 'completed' && task.status !== 'canceled' && task.status !== 'failed')
}, [])
return {
allTasks: tasks,
chatTasks,
nonExportTasks,
nonExportTasksUpdatedAt,
pauseTask,
resumeTask,
cancelTask,
cancelChatTasks
}
}
+255
View File
@@ -0,0 +1,255 @@
/**
* ExportV2 — useContactsLoader hook
*
* Encapsulates the logic for loading contacts from config/cache and network,
* and performing background avatar enrichment via electron APIs.
*/
import { useState, useRef, useCallback } from 'react'
import type { ContactInfo } from '../../../types/models'
import type { ContactsAvatarCacheEntry } from '../../../services/config'
import * as configService from '../../../services/config'
import {
toContactMapFromCaches,
mergeAvatarCacheIntoContacts,
upsertAvatarCacheFromContacts
} from '../utils/avatar'
import {
CONTACT_ENRICH_TIMEOUT_MS,
EXPORT_AVATAR_ENRICH_BATCH_SIZE
} from '../constants'
export interface ContactsLoaderResult {
contactMap: Record<string, ContactInfo>
avatarEntries: Record<string, ContactsAvatarCacheEntry>
isLoading: boolean
isEnriching: boolean
dataSource: 'cache' | 'network' | null
lastUpdatedAt: number
loadContacts: (scopeKey: string, candidateUsernames?: string[]) => Promise<void>
abort: () => void
}
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> => {
let timer: any
const timeoutPromise = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error('timeout')), timeoutMs)
})
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer))
}
export function useContactsLoader(): ContactsLoaderResult {
const [contactMap, setContactMap] = useState<Record<string, ContactInfo>>({})
const [avatarEntries, setAvatarEntries] = useState<Record<string, ContactsAvatarCacheEntry>>({})
const [isLoading, setIsLoading] = useState(false)
const [isEnriching, setIsEnriching] = useState(false)
const [dataSource, setDataSource] = useState<'cache' | 'network' | null>(null)
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(0)
const abortControllerRef = useRef<AbortController | null>(null)
const abort = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
}, [])
const loadContacts = useCallback(async (scopeKey: string, candidateUsernames?: string[]) => {
abort()
const controller = new AbortController()
abortControllerRef.current = controller
const signal = controller.signal
setIsLoading(true)
try {
// 1. Initial async cache load
const [contactsCache, avatarCache] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
const cachedContacts = contactsCache?.contacts || []
const cachedAvatarEntries = avatarCache?.avatars || {}
let currentContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries)
let currentAvatarEntries = { ...cachedAvatarEntries }
setContactMap(currentContactMap)
setAvatarEntries(currentAvatarEntries)
setDataSource(cachedContacts.length > 0 ? 'cache' : 'network')
if (cachedContacts.length === 0) {
setLastUpdatedAt(Date.now())
}
setIsLoading(false)
if (signal.aborted) return
// 2. Background fetch & enrich
setIsEnriching(true)
try {
const contactsResult = await withTimeout(
window.electronAPI.chat.getContacts({ lite: true }),
CONTACT_ENRICH_TIMEOUT_MS
).catch(() => null)
if (signal.aborted) return
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts
? contactsResult.contacts
: []
if (contactsFromNetwork.length > 0) {
const contactsWithCachedAvatar = mergeAvatarCacheIntoContacts(contactsFromNetwork, currentAvatarEntries)
const nextContactMap = contactsWithCachedAvatar.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, {})
for (const [username, cachedContact] of Object.entries(currentContactMap)) {
if (!nextContactMap[username]) {
nextContactMap[username] = cachedContact
}
}
currentContactMap = nextContactMap
setContactMap(currentContactMap)
setLastUpdatedAt(Date.now())
// Save to config
await configService.setContactsListCache(scopeKey, Object.values(currentContactMap).map(c => ({
username: c.username,
displayName: c.displayName || '',
type: c.type || 'other',
remark: c.remark,
nickname: c.nickname,
alias: c.alias,
labels: c.labels,
detailDescription: c.detailDescription,
region: c.region
})))
const upsertResult = upsertAvatarCacheFromContacts(currentAvatarEntries, Object.values(currentContactMap), {
prune: true,
now: Date.now()
})
currentAvatarEntries = upsertResult.avatarEntries
setAvatarEntries(currentAvatarEntries)
if (upsertResult.changed) {
await configService.setContactsAvatarCache(scopeKey, currentAvatarEntries)
}
}
if (signal.aborted) return
// 3. Avatar enrich for missing
const sourceContacts = Object.values(currentContactMap)
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
if (!contact?.username) continue
sourceByUsername.set(contact.username, contact)
}
const candidatesToEnrich = candidateUsernames?.length
? candidateUsernames
: sourceContacts.map(c => c.username)
const needsEnrichment = candidatesToEnrich
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
return !currentContact?.avatarUrl
})
if (needsEnrichment.length > 0) {
let hasEnrichChanges = false
let enrichContactMap: Record<string, ContactInfo> = { ...currentContactMap }
let enrichAvatarEntries = { ...currentAvatarEntries }
for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
if (signal.aborted) break
const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
const enrichResult = await withTimeout(
window.electronAPI.chat.enrichSessionsContactInfo(batch, {
skipDisplayName: true,
onlyMissingAvatar: true
}),
CONTACT_ENRICH_TIMEOUT_MS
).catch(() => null)
if (signal.aborted) break
if (enrichResult?.success && enrichResult.contacts) {
const enrichedBatch = enrichResult.contacts as Record<string, { displayName?: string; avatarUrl?: string }>
let batchChanged = false
for (const [username, enriched] of Object.entries(enrichedBatch)) {
if (!enriched.avatarUrl) continue
enrichContactMap[username] = {
...(enrichContactMap[username] || { username, displayName: username, type: 'other' }),
avatarUrl: enriched.avatarUrl
}
hasEnrichChanges = true
batchChanged = true
}
if (batchChanged) {
setContactMap(enrichContactMap)
const upsertResult = upsertAvatarCacheFromContacts(enrichAvatarEntries, Object.values(enrichContactMap), {
now: Date.now()
})
if (upsertResult.changed) {
enrichAvatarEntries = upsertResult.avatarEntries
setAvatarEntries(enrichAvatarEntries)
await configService.setContactsAvatarCache(scopeKey, enrichAvatarEntries)
}
}
}
}
if (hasEnrichChanges && !signal.aborted) {
await configService.setContactsListCache(scopeKey, Object.values(enrichContactMap).map(c => ({
username: c.username,
displayName: c.displayName || '',
type: c.type || 'other',
remark: c.remark,
nickname: c.nickname,
alias: c.alias,
labels: c.labels,
detailDescription: c.detailDescription,
region: c.region
})))
setLastUpdatedAt(Date.now())
}
}
} finally {
if (!signal.aborted) {
setIsEnriching(false)
}
}
} catch (err) {
console.error('[useContactsLoader] Error loading contacts:', err)
if (!signal.aborted) {
setIsLoading(false)
setIsEnriching(false)
}
}
}, [abort])
return {
contactMap,
avatarEntries,
isLoading,
isEnriching,
dataSource,
lastUpdatedAt,
loadContacts,
abort
}
}
+234
View File
@@ -0,0 +1,234 @@
/**
* ExportV2 — useExportConfig hook
*
* Manages global export configurations: output path, write layout, and export options.
* Synchronizes state with configService for persistence.
*/
import { useState, useCallback, useEffect, useRef } from 'react'
import type { ExportOptions, TextExportFormat, DisplayNamePreference } from '../types'
import { createDefaultExportOptions, defaultTxtColumns } from '../constants'
import type { ExportWriteLayout } from '../../../services/config'
import * as configService from '../../../services/config'
import {
resolveExportDateRangeConfig,
type ExportDefaultDateRangeConfig
} from '../../../utils/exportDateRange'
export interface ExportConfigResult {
isLoaded: boolean
// Base settings
exportPath: string
setExportPath: (path: string) => void
writeLayout: ExportWriteLayout
setWriteLayout: (layout: ExportWriteLayout) => void
// Export options
options: ExportOptions
updateOptions: (patch: Partial<ExportOptions>) => void
// Full raw date range config (for the date range picker)
rawDateRangeConfig: ExportDefaultDateRangeConfig | string | null
setRawDateRangeConfig: (config: ExportDefaultDateRangeConfig | string | null) => void
}
export function useExportConfig(): ExportConfigResult {
const [isLoaded, setIsLoaded] = useState(false)
const [exportPath, setExportPathState] = useState<string>('')
const [writeLayout, setWriteLayoutState] = useState<ExportWriteLayout>('C')
const [options, setOptions] = useState<ExportOptions>(createDefaultExportOptions())
const [rawDateRangeConfig, setRawDateRangeConfigState] = useState<ExportDefaultDateRangeConfig | string | null>(null)
// Use a ref to avoid infinite loops if we need to check current state in effects
const optionsRef = useRef(options)
optionsRef.current = options
// 1. Initial Load
useEffect(() => {
let isMounted = true
const loadConfigs = async () => {
try {
const [
path,
layout,
format,
avatars,
media,
dateRange,
voiceAsText,
pathStyle,
excelCompact,
txtCols,
concurrency
] = await Promise.all([
configService.getExportPath(),
configService.getExportWriteLayout(),
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
configService.getExportDefaultMedia(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultPathStyle(),
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency()
])
if (!isMounted) return
// Base settings
if (path) {
setExportPathState(path)
} else {
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportPathState(downloadsPath)
} catch (e) {
setExportPathState('')
}
}
setWriteLayoutState(layout || 'C')
// Build ExportOptions from multiple configs
const newOptions = createDefaultExportOptions()
if (format) newOptions.format = format as TextExportFormat
if (typeof avatars === 'boolean') newOptions.exportAvatars = avatars
if (media) {
newOptions.exportImages = media.images !== false
newOptions.exportVideos = media.videos !== false
newOptions.exportVoices = media.voices !== false
newOptions.exportEmojis = media.emojis !== false
newOptions.exportFiles = media.files !== false
// The old page had `exportMedia: true` derived from whether any media was checked
newOptions.exportMedia = (
newOptions.exportImages ||
newOptions.exportVideos ||
newOptions.exportVoices ||
newOptions.exportEmojis ||
newOptions.exportFiles
)
}
if (typeof voiceAsText === 'boolean') newOptions.exportVoiceAsText = voiceAsText
if (pathStyle === 'auto' || pathStyle === 'posix' || pathStyle === 'windows') newOptions.exportPathStyle = pathStyle
if (typeof excelCompact === 'boolean') newOptions.excelCompactColumns = excelCompact
if (Array.isArray(txtCols)) newOptions.txtColumns = txtCols
if (typeof concurrency === 'number' && concurrency > 0) newOptions.exportConcurrency = concurrency
// Date range
setRawDateRangeConfigState(dateRange)
if (dateRange) {
const resolvedDateRange = resolveExportDateRangeConfig(dateRange as any, new Date())
newOptions.useAllTime = resolvedDateRange.useAllTime
newOptions.dateRange = resolvedDateRange.dateRange
}
setOptions(newOptions)
} catch (err) {
console.error('[useExportConfig] Error loading configs:', err)
} finally {
if (isMounted) setIsLoaded(true)
}
}
void loadConfigs()
return () => { isMounted = false }
}, [])
// 2. Setters (State + ConfigService)
const setExportPath = useCallback((path: string) => {
setExportPathState(path)
void configService.setExportPath(path)
}, [])
const setWriteLayout = useCallback((layout: ExportWriteLayout) => {
setWriteLayoutState(layout)
void configService.setExportWriteLayout(layout)
}, [])
const updateOptions = useCallback((patch: Partial<ExportOptions>) => {
setOptions(prev => {
const next = { ...prev, ...patch }
// Persist individual keys based on the patch
if (patch.format !== undefined) {
void configService.setExportDefaultFormat(patch.format)
}
if (patch.exportAvatars !== undefined) {
void configService.setExportDefaultAvatars(patch.exportAvatars)
}
if (
patch.exportImages !== undefined ||
patch.exportVideos !== undefined ||
patch.exportVoices !== undefined ||
patch.exportEmojis !== undefined ||
patch.exportFiles !== undefined ||
patch.maxFileSizeMb !== undefined
) {
void configService.setExportDefaultMedia({
images: next.exportImages,
videos: next.exportVideos,
voices: next.exportVoices,
emojis: next.exportEmojis,
files: next.exportFiles
})
}
if (patch.exportVoiceAsText !== undefined) {
void configService.setExportDefaultVoiceAsText(patch.exportVoiceAsText)
}
if (patch.exportPathStyle !== undefined) {
void configService.setExportDefaultPathStyle(patch.exportPathStyle)
}
if (patch.excelCompactColumns !== undefined) {
void configService.setExportDefaultExcelCompactColumns(patch.excelCompactColumns)
}
if (patch.txtColumns !== undefined) {
void configService.setExportDefaultTxtColumns(patch.txtColumns)
}
if (patch.exportConcurrency !== undefined) {
void configService.setExportDefaultConcurrency(patch.exportConcurrency)
}
// Auto-derive exportMedia
next.exportMedia = (
next.exportImages ||
next.exportVideos ||
next.exportVoices ||
next.exportEmojis ||
next.exportFiles
)
return next
})
}, [])
const setRawDateRangeConfig = useCallback((config: ExportDefaultDateRangeConfig | string | null) => {
setRawDateRangeConfigState(config)
if (config) {
void configService.setExportDefaultDateRange(config as any)
// Update options as well
const resolvedDateRange = resolveExportDateRangeConfig(config as any, new Date())
updateOptions({
useAllTime: resolvedDateRange.useAllTime,
dateRange: resolvedDateRange.dateRange
})
}
}, [updateOptions])
return {
isLoaded,
exportPath,
setExportPath,
writeLayout,
setWriteLayout,
options,
updateOptions,
rawDateRangeConfig,
setRawDateRangeConfig
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* ExportV2 — useExportDialog hook
*
* Manages the visibility and state of the Export Configuration dialog.
* This dialog handles multi-session, single-session, content-specific, and automation-creation intents.
*/
import { useState, useCallback } from 'react'
import type { ExportDialogState, TaskScope, ContentType } from '../types'
export interface ExportDialogResult {
dialogState: ExportDialogState
openDialog: (params: Omit<ExportDialogState, 'open'>) => void
closeDialog: () => void
openMultiExport: (sessionIds: string[], sessionNames: string[]) => void
openSingleExport: (sessionId: string, sessionName: string) => void
openContentExport: (sessionIds: string[], sessionNames: string[], contentType: ContentType) => void
openAutomationCreate: (sessionIds: string[], sessionNames: string[]) => void
}
export function useExportDialog(): ExportDialogResult {
const [dialogState, setDialogState] = useState<ExportDialogState>({
open: false,
intent: 'manual',
scope: 'single',
sessionIds: [],
sessionNames: [],
title: ''
})
const openDialog = useCallback((params: Omit<ExportDialogState, 'open'>) => {
setDialogState({
open: true,
...params
})
}, [])
const closeDialog = useCallback(() => {
setDialogState(prev => ({ ...prev, open: false }))
}, [])
const openMultiExport = useCallback((sessionIds: string[], sessionNames: string[]) => {
openDialog({
intent: 'manual',
scope: 'multi',
sessionIds,
sessionNames,
title: `批量导出 (${sessionIds.length} 个会话)`
})
}, [openDialog])
const openSingleExport = useCallback((sessionId: string, sessionName: string) => {
openDialog({
intent: 'manual',
scope: 'single',
sessionIds: [sessionId],
sessionNames: [sessionName],
title: `导出: ${sessionName}`
})
}, [openDialog])
const openContentExport = useCallback((sessionIds: string[], sessionNames: string[], contentType: ContentType) => {
openDialog({
intent: 'manual',
scope: 'content',
contentType,
sessionIds,
sessionNames,
title: `批量导出特定内容 (${sessionIds.length} 个会话)`
})
}, [openDialog])
const openAutomationCreate = useCallback((sessionIds: string[], sessionNames: string[]) => {
openDialog({
intent: 'automation-create',
scope: sessionIds.length === 1 ? 'single' : 'multi',
sessionIds,
sessionNames,
title: `创建自动化导出任务`
})
}, [openDialog])
return {
dialogState,
openDialog,
closeDialog,
openMultiExport,
openSingleExport,
openContentExport,
openAutomationCreate
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* ExportV2 — useExportSessions hook
*
* Handles fetching chat sessions from the database and merging them with contact data.
* Also provides client-side filtering (by tab/kind, search query) and sorting.
*/
import { useState, useCallback, useMemo, useRef } from 'react'
import type { ChatSession, ContactInfo } from '../../../types/models'
import type { SessionRow, ConversationTab, ContactsSortConfig } from '../types'
import { toSessionRowsWithContacts } from '../utils/session'
export interface ExportSessionsResult {
rawSessions: ChatSession[]
sessions: SessionRow[] // Unfiltered, merged with contacts
filteredSessions: SessionRow[] // Filtered & sorted
isLoading: boolean
error: string | null
searchQuery: string
activeTab: ConversationTab
sortConfig: ContactsSortConfig
setSearchQuery: (query: string) => void
setActiveTab: (tab: ConversationTab) => void
setSortConfig: (config: ContactsSortConfig) => void
loadSessions: () => Promise<void>
abort: () => void
}
export function useExportSessions(
contactMap: Record<string, ContactInfo>,
initialTab: ConversationTab = 'private',
metricsMap?: Record<string, any>
): ExportSessionsResult {
const [rawSessions, setRawSessions] = useState<ChatSession[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<ConversationTab>(initialTab)
const [sortConfig, setSortConfig] = useState<ContactsSortConfig>({ key: 'messageCount', order: 'desc' })
const abortControllerRef = useRef<AbortController | null>(null)
const abort = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
}, [])
const loadSessions = useCallback(async () => {
abort()
const controller = new AbortController()
abortControllerRef.current = controller
const signal = controller.signal
setIsLoading(true)
setError(null)
try {
// Connect first to ensure DB is ready, just like old ExportPage did
const connectResult = await window.electronAPI.chat.connect()
if (signal.aborted) return
if (!connectResult?.success) {
throw new Error(connectResult?.error || '无法连接到数据库')
}
const sessionsResult = await window.electronAPI.chat.getSessions()
if (signal.aborted) return
if (sessionsResult?.success && sessionsResult.sessions) {
setRawSessions(sessionsResult.sessions)
} else {
throw new Error(sessionsResult?.error || '获取会话列表失败')
}
} catch (err: any) {
if (!signal.aborted) {
console.error('[useExportSessions] Error loading sessions:', err)
setError(err.message || '加载会话失败')
}
} finally {
if (!signal.aborted) {
setIsLoading(false)
}
}
}, [abort])
// Merge raw sessions with contacts
const sessions = useMemo(() => {
return toSessionRowsWithContacts(rawSessions, contactMap)
}, [rawSessions, contactMap])
// Filter and sort
const filteredSessions = useMemo(() => {
let result = sessions.filter((s) => s.kind === activeTab)
const query = searchQuery.trim().toLowerCase()
if (query) {
result = result.filter((s) => {
const nameMatch = (s.displayName || s.wechatId || '').toLowerCase().includes(query)
const wxidMatch = (s.wechatId || '').toLowerCase().includes(query)
const aliasMatch = (contactMap[s.wechatId || '']?.alias || '').toLowerCase().includes(query)
const remarkMatch = (contactMap[s.wechatId || '']?.remark || '').toLowerCase().includes(query)
return nameMatch || wxidMatch || aliasMatch || remarkMatch
})
}
if (sortConfig.key) {
result.sort((a, b) => {
let valA = 0
let valB = 0
if (sortConfig.key === 'messageCount') {
// Temporarily use unreadCount or 0 if we don't have messageCount yet.
// In the real app, we need to load messageCounts asynchronously.
valA = metricsMap?.[a.username]?.totalMessages ?? a.messageCountHint ?? a.unreadCount ?? 0
valB = metricsMap?.[b.username]?.totalMessages ?? b.messageCountHint ?? b.unreadCount ?? 0
} else if (sortConfig.key === 'latestMessageTime') {
valA = a.sortTimestamp || a.lastTimestamp || 0
valB = b.sortTimestamp || b.lastTimestamp || 0
}
if (valA === valB) return 0
const diff = valA - valB
return sortConfig.order === 'asc' ? diff : -diff
})
}
return result
}, [sessions, activeTab, searchQuery, sortConfig, contactMap, metricsMap])
return {
rawSessions,
sessions,
filteredSessions,
isLoading,
error,
searchQuery,
activeTab,
sortConfig,
setSearchQuery,
setActiveTab,
setSortConfig,
loadSessions,
abort
}
}
+170
View File
@@ -0,0 +1,170 @@
/**
* ExportV2 — useExportTasks hook
*
* Manages the queue and state of manual export tasks.
* Includes starting tasks via electronAPI, tracking progress, and aborting.
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { ExportTask, ExportTaskPayload, TaskStatus } from '../types'
import { createEmptyProgress } from '../constants'
import { useExportTaskStore } from '../../../stores/exportTaskStore'
import { buildProgressPayloadSignature } from '../utils/progress'
import { resolvePerfStageByPhase, applyProgressToTaskPerformance } from '../utils/performance'
import type { ExportProgress } from '../types'
export interface ExportTasksResult {
tasks: ExportTask[]
activeTasks: ExportTask[]
completedTasks: ExportTask[]
startTask: (payload: ExportTaskPayload) => void
cancelTask: (taskId: string) => void
clearCompletedTasks: () => void
}
const generateTaskId = () => `task-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`
export function useExportTasks(): ExportTasksResult {
const [tasks, setTasks] = useState<ExportTask[]>([])
const tasksRef = useRef<ExportTask[]>([])
tasksRef.current = tasks
const { setSessionStatus } = useExportTaskStore()
// Track the ongoing tasks to update the global zustand store badge
useEffect(() => {
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({
activeTaskCount: activeTasks.length,
inProgressSessionIds: Array.from(inProgressSessionIds)
})
}, [tasks, setSessionStatus])
const updateTask = useCallback((taskId: string, updater: (prev: ExportTask) => ExportTask) => {
setTasks(prev => prev.map(t => t.id === taskId ? updater(t) : t))
}, [])
const startTask = useCallback((payload: ExportTaskPayload) => {
const taskId = generateTaskId()
const title = payload.sessionIds.length > 1
? `批量导出 ${payload.sessionIds.length} 个会话`
: `导出 ${payload.sessionNames[0] || '会话'}`
const newTask: ExportTask = {
id: taskId,
title,
status: 'running',
createdAt: Date.now(),
startedAt: Date.now(),
payload,
progress: createEmptyProgress()
}
setTasks(prev => [newTask, ...prev])
// Kick off via electron API
const run = async () => {
let progressUnsubscribe: (() => void) | null = null
let lastProgressSig = ''
try {
// Subscribe to progress
progressUnsubscribe = window.electronAPI.export.onProgress((progressPayload: ExportProgress) => {
if ((progressPayload as any).taskId && (progressPayload as any).taskId !== taskId) return
const sig = buildProgressPayloadSignature(progressPayload)
if (sig === lastProgressSig) return
lastProgressSig = sig
updateTask(taskId, (task) => {
if (task.status !== 'running') return task
const nextProgress = { ...task.progress, ...progressPayload }
// The original expected performance from task or undefined.
// The signature is applyProgressToTaskPerformance(task: ExportTask, payload: ExportProgress, now: number)
const nextPerf = applyProgressToTaskPerformance(task, progressPayload, Date.now())
return {
...task,
progress: nextProgress,
performance: nextPerf
}
})
})
const frontendOptions = payload.options as any
const electronOptions = { ...frontendOptions }
if (frontendOptions?.useAllTime) {
electronOptions.dateRange = null
} else if (frontendOptions?.dateRange) {
electronOptions.dateRange = {
start: frontendOptions.dateRange.start.getTime(),
end: frontendOptions.dateRange.end.getTime()
}
}
const result = await window.electronAPI.export.exportSessions(
payload.sessionIds,
payload.outputDir,
electronOptions,
{ taskId }
)
updateTask(taskId, (task) => ({
...task,
status: result?.success ? 'success' : 'error',
finishedAt: Date.now(),
error: result?.error || undefined,
sessionOutputPaths: result?.sessionOutputPaths
}))
} catch (err: any) {
console.error('[useExportTasks] Task failed:', err)
updateTask(taskId, (task) => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: err.message || '未知错误'
}))
} finally {
if (progressUnsubscribe) {
progressUnsubscribe()
}
}
}
void run()
}, [updateTask])
const cancelTask = useCallback((taskId: string) => {
updateTask(taskId, (task) => {
if (task.status === 'running') {
window.electronAPI.export.cancelTask(taskId)
return { ...task, status: 'cancel_requested' }
}
return task
})
}, [updateTask])
const clearCompletedTasks = useCallback(() => {
setTasks(prev => prev.filter(t => t.status === 'running' || t.status === 'cancel_requested'))
}, [])
const activeTasks = tasks.filter(t => t.status === 'running' || t.status === 'cancel_requested')
const completedTasks = tasks.filter(t => t.status === 'success' || t.status === 'error')
return {
tasks,
activeTasks,
completedTasks,
startTask,
cancelTask,
clearCompletedTasks
}
}
@@ -0,0 +1,78 @@
/**
* ExportV2 — useSessionMetrics hook
*
* Fetches and caches session content metrics (total messages, voice, image, video, etc.)
* from the backend SQLite database.
*/
import { create } from 'zustand'
export interface SessionContentMetric {
totalMessages?: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
fileMessages?: number
systemMessages?: number
appMessages?: number
}
interface SessionMetricsState {
metricsMap: Record<string, SessionContentMetric>
isLoading: boolean
error: Error | null
loadingRefs: Set<string>
fetchMetrics: (sessionIds: string[]) => Promise<void>
}
export const useSessionMetrics = create<SessionMetricsState>((set, get) => ({
metricsMap: {},
isLoading: false,
error: null,
loadingRefs: new Set(),
fetchMetrics: async (sessionIds: string[]) => {
if (sessionIds.length === 0) return
const { metricsMap, loadingRefs } = get()
const missingIds = sessionIds.filter(id => !metricsMap[id] && !loadingRefs.has(id))
if (missingIds.length === 0) return
set({ isLoading: true, error: null })
const newLoadingRefs = new Set(loadingRefs)
missingIds.forEach(id => newLoadingRefs.add(id))
set({ loadingRefs: newLoadingRefs })
try {
const stats = await window.electronAPI.chat.getExportSessionStats(missingIds, { includeRelations: false })
const newMetrics: Record<string, SessionContentMetric> = {}
if (stats.success && stats.data) {
for (const [sessionId, sessionStat] of Object.entries(stats.data)) {
newMetrics[sessionId] = {
totalMessages: sessionStat.totalMessages,
voiceMessages: sessionStat.voiceMessages,
imageMessages: sessionStat.imageMessages,
videoMessages: sessionStat.videoMessages,
emojiMessages: sessionStat.emojiMessages,
fileMessages: 0 // Will need specific metric if available
}
}
}
set(state => ({
metricsMap: { ...state.metricsMap, ...newMetrics }
}))
} catch (err) {
console.error('Failed to fetch session metrics:', err)
set({ error: err instanceof Error ? err : new Error(String(err)) })
} finally {
set(state => {
const nextLoadingRefs = new Set(state.loadingRefs)
missingIds.forEach(id => nextLoadingRefs.delete(id))
return { loadingRefs: nextLoadingRefs, isLoading: false }
})
}
}
}))
+324
View File
@@ -0,0 +1,324 @@
/**
* ExportV2 — Type definitions
* Extracted and cleaned from the original ExportPage.tsx monolith.
*/
import type { ChatSession as AppChatSession, ContactInfo } from '../../types/models'
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../../types/electron'
import type { BackgroundTaskRecord } from '../../types/backgroundTask'
// ─── Tabs & Enums ────────────────────────────────────────────
export type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
export type TaskStatus =
| 'queued'
| 'running'
| 'pause_requested'
| 'paused'
| 'cancel_requested'
| 'success'
| 'error'
export type TaskScope = 'single' | 'multi' | 'content' | 'sns'
export type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
export type ContentCardType = ContentType | 'sns'
export type SessionLayout = 'shared' | 'per-session'
export type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
export type TextExportFormat =
| 'chatlab'
| 'chatlab-jsonl'
| 'json'
| 'arkme-json'
| 'html'
| 'txt'
| 'excel'
| 'weclone'
| 'sql'
export type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
export type ContactsSortKey = 'messageCount' | 'latestMessageTime'
export type ContactsSortOrder = 'desc' | 'asc'
export interface ContactsSortConfig {
key: ContactsSortKey | null
order: ContactsSortOrder | null
}
// ─── Export Options ──────────────────────────────────────────
export interface ExportOptions {
format: TextExportFormat
dateRange: { start: Date; end: Date } | null
useAllTime: boolean
exportAvatars: boolean
exportMedia: boolean
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
exportFiles: boolean
maxFileSizeMb: number
exportVoiceAsText: boolean
exportPathStyle: 'auto' | 'posix' | 'windows'
excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: DisplayNamePreference
exportConcurrency: number
}
// ─── Session ─────────────────────────────────────────────────
export interface SessionRow extends AppChatSession {
kind: ConversationTab
wechatId?: string
hasSession: boolean
remark?: string
nickname?: string
displayName?: string
avatarUrl?: string
}
// ─── Task Progress ───────────────────────────────────────────
export interface TaskProgress {
current: number
total: number
currentName: string
phase: ExportProgress['phase'] | ''
phaseLabel: string
phaseProgress: number
phaseTotal: number
exportedMessages: number
estimatedTotalMessages: number
collectedMessages: number
writtenFiles: number
mediaDoneFiles: number
mediaCacheHitFiles: number
mediaCacheMissFiles: number
mediaCacheFillFiles: number
mediaDedupReuseFiles: number
mediaBytesWritten: number
}
export type TaskPerfStage = 'collect' | 'build' | 'write' | 'other'
export interface TaskSessionPerformance {
sessionId: string
sessionName: string
startedAt: number
finishedAt?: number
elapsedMs: number
lastPhase?: ExportProgress['phase']
lastPhaseStartedAt?: number
}
export interface TaskPerformance {
stages: Record<TaskPerfStage, number>
sessions: Record<string, TaskSessionPerformance>
}
// ─── Export Task ──────────────────────────────────────────────
export interface ExportTaskPayload {
sessionIds: string[]
outputDir: string
options?: ElectronExportOptions
scope: TaskScope
source: 'manual' | 'automation'
automationTaskId?: string
contentType?: ContentType
sessionNames: string[]
snsOptions?: {
format: SnsTimelineExportFormat
exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number
endTime?: number
}
}
export interface ExportTask {
id: string
title: string
status: TaskStatus
settledSessionIds?: string[]
sessionOutputPaths?: Record<string, string>
createdAt: number
startedAt?: number
finishedAt?: number
error?: string
payload: ExportTaskPayload
progress: TaskProgress
performance?: TaskPerformance
}
// ─── Export Dialog ────────────────────────────────────────────
export interface ExportDialogState {
open: boolean
intent: 'manual' | 'automation-create'
scope: TaskScope
contentType?: ContentType
sessionIds: string[]
sessionNames: string[]
title: string
}
// ─── Session Detail ──────────────────────────────────────────
export interface SessionDetail {
wxid: string
displayName: string
remark?: string
nickName?: string
alias?: string
avatarUrl?: string
messageCount: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
transferMessages?: number
redPacketMessages?: number
callMessages?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
relationStatsLoaded?: boolean
statsUpdatedAt?: number
statsStale?: boolean
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
// ─── Session Metrics ─────────────────────────────────────────
export interface SessionExportMetric {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
transferMessages: number
redPacketMessages: number
callMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
export interface SessionContentMetric {
totalMessages?: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
transferMessages?: number
redPacketMessages?: number
callMessages?: number
firstTimestamp?: number
lastTimestamp?: number
}
export interface TimeRangeBounds {
minDate: Date
maxDate: Date
}
export interface SessionExportCacheMeta {
updatedAt: number
stale: boolean
includeRelations: boolean
source: 'memory' | 'disk' | 'fresh'
rangeFiltered?: boolean
}
// ─── Session Load Trace ──────────────────────────────────────
export type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed'
export interface SessionLoadStageState {
status: SessionLoadStageStatus
startedAt?: number
finishedAt?: number
error?: string
}
export interface SessionLoadTraceState {
messageCount: SessionLoadStageState
mediaMetrics: SessionLoadStageState
}
// ─── Contacts Loading ────────────────────────────────────────
export type SessionDataSource = 'cache' | 'network' | null
export type ContactsDataSource = 'cache' | 'network' | null
export interface ContactsLoadSession {
requestId: string
startedAt: number
attempt: number
timeoutMs: number
}
export interface ContactsLoadIssue {
kind: 'timeout' | 'error'
title: string
message: string
reason: string
errorDetail?: string
occurredAt: number
elapsedMs: number
}
// ─── Automation Draft (for the create/edit form) ─────────────
export interface AutomationTaskDraft {
mode: 'create' | 'edit'
id?: string
name: string
enabled: boolean
sessionIds: string[]
sessionNames: string[]
outputDir: string
useGlobalOutputDir: boolean
scope: Exclude<TaskScope, 'sns'>
contentType?: ContentType
optionTemplate: Omit<ElectronExportOptions, 'dateRange'>
dateRangeConfig: import('../../types/exportAutomation').ExportAutomationDateRangeConfig | string | null
intervalDays: number
intervalHours: number
firstTriggerAtEnabled: boolean
firstTriggerAtValue: string
stopAtEnabled: boolean
stopAtValue: string
maxRunsEnabled: boolean
maxRuns: number
}
// ─── Re-exports for convenience ──────────────────────────────
export type { AppChatSession, ContactInfo }
export type { ElectronExportOptions, ExportProgress }
export type { BackgroundTaskRecord }
export type {
ExportAutomationCondition,
ExportAutomationDateRangeConfig,
ExportAutomationSchedule,
ExportAutomationTask
} from '../../types/exportAutomation'
+293
View File
@@ -0,0 +1,293 @@
/**
* ExportV2 — Automation utility functions
* Pure functions for automation task scheduling, date range resolution, and formatting.
*/
import type {
ExportAutomationTask,
ExportAutomationSchedule,
ExportAutomationDateRangeConfig
} from '../../../types/exportAutomation'
import type { ExportDateRangeSelection, ExportDateRangePreset } from '../../../utils/exportDateRange'
import {
resolveExportDateRangeConfig,
getExportDateRangeLabel,
createDateRangeByLastNDays
} from '../../../utils/exportDateRange'
import { formatDurationMs } from './format'
// ─── Interval normalization ──────────────────────────────────
export const normalizeAutomationIntervalDays = (value: unknown): number =>
Math.max(0, Math.floor(Number(value) || 0))
export const normalizeAutomationIntervalHours = (value: unknown): number =>
Math.max(0, Math.min(23, Math.floor(Number(value) || 0)))
export const normalizeAutomationFirstTriggerAt = (value: unknown): number => {
const numeric = Math.floor(Number(value) || 0)
if (!Number.isFinite(numeric) || numeric <= 0) return 0
return numeric
}
// ─── Interval calculation ────────────────────────────────────
export const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
const totalHours = (days * 24) + hours
if (totalHours <= 0) return 0
return totalHours * 60 * 60 * 1000
}
// ─── Trigger timing ──────────────────────────────────────────
export const resolveAutomationInitialTriggerAt = (task: ExportAutomationTask): number | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
if (firstTriggerAt > 0) return firstTriggerAt
const createdAt = Math.max(0, Math.floor(Number(task.createdAt || 0)))
if (!createdAt) return null
return createdAt + intervalMs
}
export const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
if (lastTriggeredAt > 0) return lastTriggeredAt + intervalMs
return resolveAutomationInitialTriggerAt(task)
}
export const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date): string | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const nowMs = now.getTime()
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
if (lastTriggeredAt > 0) {
if (nowMs < lastTriggeredAt + intervalMs) return null
return `interval:${lastTriggeredAt}:${Math.floor((nowMs - lastTriggeredAt) / intervalMs)}`
}
const initialTriggerAt = resolveAutomationInitialTriggerAt(task)
if (!initialTriggerAt) return null
if (nowMs < initialTriggerAt) return null
return `first:${initialTriggerAt}`
}
// ─── Schedule formatting ─────────────────────────────────────
export const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
const parts: string[] = []
if (days > 0) parts.push(`${days} 天`)
if (hours > 0) parts.push(`${hours} 小时`)
return `每间隔 ${parts.length > 0 ? parts.join(' ') : '0 小时'} 执行一次`
}
export const resolveAutomationFirstTriggerSummary = (task: ExportAutomationTask): string => {
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
if (firstTriggerAt <= 0) return '未指定(默认按创建时间+间隔)'
return new Date(firstTriggerAt).toLocaleString('zh-CN')
}
export const formatAutomationCurrentState = (
task: ExportAutomationTask,
queueState: 'queued' | 'running' | null,
nowMs: number
): string => {
if (!task.enabled) return '已停用'
if (queueState === 'running') return '执行中'
if (queueState === 'queued') return '排队中'
const nextTriggerAt = resolveAutomationNextTriggerAt(task)
if (!nextTriggerAt) return '等待触发'
const diff = nextTriggerAt - nowMs
if (diff <= 0) return '即将触发'
return `等待触发 · 下次 ${new Date(nextTriggerAt).toLocaleString('zh-CN')}(约 ${formatDurationMs(diff)} 后)`
}
export const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
const endAt = Number(task.stopCondition?.endAt || 0)
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
const labels: string[] = []
if (endAt > 0) {
labels.push(`截止到 ${new Date(endAt).toLocaleString('zh-CN')}`)
}
if (maxRuns > 0) {
const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0)))
labels.push(`成功 ${successCount}/${maxRuns} 次后停止`)
}
return labels.length > 0 ? labels.join(' · ') : '无'
}
export const formatAutomationLastRunSummary = (task: ExportAutomationTask): string => {
const status = task.runState?.lastRunStatus || 'idle'
const label = (
status === 'idle' ? '尚未执行' :
status === 'queued' ? '已入队' :
status === 'running' ? '执行中' :
status === 'success' ? '执行成功' :
status === 'error' ? '执行失败' :
status === 'skipped' ? '已跳过' :
status
)
const parts: string[] = [label]
if (task.runState?.lastSuccessAt) {
parts.push(`最近成功于 ${new Date(task.runState.lastSuccessAt).toLocaleString('zh-CN')}`)
}
if (task.runState?.lastSkipReason) parts.push(task.runState.lastSkipReason)
if (task.runState?.lastError) parts.push(task.runState.lastError)
return parts.join(' · ')
}
// ─── Schedule builder ────────────────────────────────────────
export const buildAutomationSchedule = (
intervalDays: number,
intervalHours: number,
firstTriggerAt: number
): ExportAutomationSchedule => ({
type: 'interval',
intervalDays,
intervalHours,
firstTriggerAt: firstTriggerAt > 0 ? firstTriggerAt : undefined
})
// ─── Date part helpers ───────────────────────────────────────
export const buildAutomationDatePart = (timestamp: number): string => {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
export const buildAutomationTodayDatePart = (): string => buildAutomationDatePart(Date.now())
export const normalizeAutomationDatePart = (value: string): string => {
const text = String(value || '').trim()
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
}
export const normalizeAutomationTimePart = (value: string): string => {
const text = String(value || '').trim()
if (!/^\d{2}:\d{2}$/.test(text)) return '00:00'
const [hoursText, minutesText] = text.split(':')
const hours = Math.floor(Number(hoursText))
const minutes = Math.floor(Number(minutesText))
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return '00:00'
const safeHours = Math.min(23, Math.max(0, hours))
const safeMinutes = Math.min(59, Math.max(0, minutes))
return `${`${safeHours}`.padStart(2, '0')}:${`${safeMinutes}`.padStart(2, '0')}`
}
// ─── Date range for automation ───────────────────────────────
export type AutomationRangeMode = 'all' | 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last1year' | 'lastNDays' | 'custom'
export const AUTOMATION_RANGE_OPTIONS: Array<{ mode: AutomationRangeMode; label: string }> = [
{ mode: 'all', label: '全部时间' },
{ mode: 'yesterday', label: '往前1天' },
{ mode: 'last7days', label: '往前7天' },
{ mode: 'last30days', label: '往前30天' },
{ mode: 'last1year', label: '往前1年' },
{ mode: 'lastNDays', label: '往前N天' },
{ mode: 'custom', label: '完整时间' }
]
export const AUTOMATION_LAST_N_DAYS_MIN = 1
export const AUTOMATION_LAST_N_DAYS_MAX = 3650
export const AUTOMATION_LAST_N_DAYS_DEFAULT = 3
export const normalizeAutomationLastNDays = (value: unknown): number => {
const parsed = Math.floor(Number(value) || 0)
if (!Number.isFinite(parsed) || parsed <= 0) return AUTOMATION_LAST_N_DAYS_DEFAULT
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, parsed))
}
export const readAutomationLastNDays = (
config: ExportAutomationDateRangeConfig | string | null | undefined
): number | null => {
if (!config || typeof config !== 'object') return null
const raw = config as Record<string, unknown>
const mode = String(raw.relativeMode || '').trim()
if (mode !== 'last-n-days') return null
const days = Math.floor(Number(raw.relativeDays) || 0)
if (!Number.isFinite(days) || days <= 0) return null
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, days))
}
export const buildAutomationLastNDaysConfig = (days: number): ExportAutomationDateRangeConfig => ({
version: 1,
preset: 'custom',
useAllTime: false,
relativeMode: 'last-n-days',
relativeDays: normalizeAutomationLastNDays(days)
})
export const resolveAutomationDateRangeSelection = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
now = new Date()
): ExportDateRangeSelection => {
const relativeDays = readAutomationLastNDays(config)
if (relativeDays) {
return {
preset: 'custom',
useAllTime: false,
dateRange: createDateRangeByLastNDays(relativeDays, now)
}
}
return resolveExportDateRangeConfig(config as any, now)
}
export const resolveAutomationRangeMode = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
selection: ExportDateRangeSelection
): AutomationRangeMode => {
if (readAutomationLastNDays(config)) return 'lastNDays'
if (selection.useAllTime) return 'all'
if (selection.preset === 'today') return 'today'
if (selection.preset === 'yesterday') return 'yesterday'
if (selection.preset === 'last7days') return 'last7days'
if (selection.preset === 'last30days') return 'last30days'
if (selection.preset === 'last1year') return 'last1year'
return 'custom'
}
export const createAutomationSelectionByMode = (
mode: Exclude<AutomationRangeMode, 'custom' | 'lastNDays'>,
now = new Date()
): ExportDateRangeSelection => {
const preset: ExportDateRangePreset = mode
return resolveExportDateRangeConfig({
version: 1,
preset,
useAllTime: mode === 'all'
}, now)
}
export const formatAutomationRangeLabel = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
selection?: ExportDateRangeSelection
): string => {
const resolved = selection || resolveAutomationDateRangeSelection(config, new Date())
const mode = resolveAutomationRangeMode(config, resolved)
if (mode === 'all') return '每次触发导出全部历史消息'
if (mode === 'today') return '每次触发导出当天'
if (mode === 'yesterday') return '每次触发导出前1天(昨日)'
if (mode === 'last7days') return '每次触发导出前7天'
if (mode === 'last30days') return '每次触发导出前30天'
if (mode === 'last1year') return '每次触发导出前1年'
if (mode === 'lastNDays') {
return `每次触发导出前 ${readAutomationLastNDays(config) || AUTOMATION_LAST_N_DAYS_DEFAULT} 天`
}
return `完整时间:${getExportDateRangeLabel(resolved)}`
}
// ─── Task ID factories ───────────────────────────────────────
export const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
export const createAutomationTaskId = (): string => `auto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+160
View File
@@ -0,0 +1,160 @@
/**
* ExportV2 — Avatar utility functions
* Pure functions for avatar URL normalization, caching logic, and merge operations.
*/
import type { ContactInfo } from '../../../types/models'
import type * as configService from '../../../services/config'
import { INLINE_AVATAR_CACHE_MAX_LENGTH } from '../constants'
// ─── URL normalization ───────────────────────────────────────
export const normalizeExportAvatarUrl = (value?: string | null): string | undefined => {
const normalized = String(value || '').trim()
if (!normalized) return undefined
const lower = normalized.toLowerCase()
if (lower === 'null' || lower === 'undefined') return undefined
return normalized
}
export const shouldPersistExportAvatarUrl = (value?: string | null): value is string => {
const normalized = normalizeExportAvatarUrl(value)
if (!normalized) return false
if (!normalized.startsWith('data:')) return true
return normalized.length <= INLINE_AVATAR_CACHE_MAX_LENGTH
}
// ─── Contact map from caches ─────────────────────────────────
export const toContactMapFromCaches = (
contacts: configService.ContactsListCacheContact[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): Record<string, ContactInfo> => {
const map: Record<string, ContactInfo> = {}
for (const contact of contacts || []) {
if (!contact?.username) continue
const cachedAvatarUrl = avatarEntries[contact.username]?.avatarUrl
map[contact.username] = {
...contact,
avatarUrl: shouldPersistExportAvatarUrl(cachedAvatarUrl) ? cachedAvatarUrl : undefined
}
}
return map
}
// ─── Avatar cache compaction ─────────────────────────────────
export const compactExportAvatarEntries = (
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): {
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
changed: boolean
} => {
const nextCache: Record<string, configService.ContactsAvatarCacheEntry> = {}
let changed = false
for (const [username, entry] of Object.entries(avatarEntries || {})) {
const normalizedUsername = String(username || '').trim()
const avatarUrl = normalizeExportAvatarUrl(entry?.avatarUrl)
if (!normalizedUsername || !shouldPersistExportAvatarUrl(avatarUrl)) {
changed = true
continue
}
nextCache[normalizedUsername] = {
avatarUrl,
updatedAt: Number(entry?.updatedAt || 0) || Date.now(),
checkedAt: Number(entry?.checkedAt || 0) || Date.now()
}
if (
normalizedUsername !== username ||
avatarUrl !== entry?.avatarUrl ||
nextCache[normalizedUsername].updatedAt !== entry?.updatedAt ||
nextCache[normalizedUsername].checkedAt !== entry?.checkedAt
) {
changed = true
}
}
return { avatarEntries: nextCache, changed }
}
// ─── Merge avatar cache into contacts ────────────────────────
export const mergeAvatarCacheIntoContacts = (
sourceContacts: ContactInfo[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): ContactInfo[] => {
if (!sourceContacts.length || Object.keys(avatarEntries).length === 0) {
return sourceContacts
}
let changed = false
const merged = sourceContacts.map((contact) => {
const cachedAvatar = avatarEntries[contact.username]?.avatarUrl
if (!shouldPersistExportAvatarUrl(cachedAvatar) || contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
avatarUrl: cachedAvatar
}
})
return changed ? merged : sourceContacts
}
// ─── Upsert avatar cache from contacts ───────────────────────
export const upsertAvatarCacheFromContacts = (
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>,
sourceContacts: ContactInfo[],
options?: { prune?: boolean; markCheckedUsernames?: string[]; now?: number }
): {
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
changed: boolean
updatedAt: number | null
} => {
const compactedCache = compactExportAvatarEntries(avatarEntries)
const nextCache = { ...compactedCache.avatarEntries }
const now = options?.now || Date.now()
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
const usernamesInSource = new Set<string>()
let changed = compactedCache.changed
for (const contact of sourceContacts) {
const username = String(contact.username || '').trim()
if (!username) continue
usernamesInSource.add(username)
const prev = nextCache[username]
const avatarUrl = normalizeExportAvatarUrl(contact.avatarUrl)
if (!shouldPersistExportAvatarUrl(avatarUrl)) continue
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
nextCache[username] = { avatarUrl, updatedAt, checkedAt }
changed = true
}
}
for (const username of markCheckedSet) {
const prev = nextCache[username]
if (!prev) continue
if (prev.checkedAt !== now) {
nextCache[username] = { ...prev, checkedAt: now }
changed = true
}
}
if (options?.prune) {
for (const username of Object.keys(nextCache)) {
if (usernamesInSource.has(username)) continue
delete nextCache[username]
changed = true
}
}
return {
avatarEntries: nextCache,
changed,
updatedAt: changed ? now : null
}
}
+209
View File
@@ -0,0 +1,209 @@
/**
* ExportV2 — Formatting utilities
* Pure functions for date, time, duration, and path formatting.
*/
// ─── Duration ────────────────────────────────────────────────
export const formatDurationMs = (ms: number): string => {
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) return `${hours}小时${minutes}分${seconds}秒`
if (minutes > 0) return `${minutes}分${seconds}秒`
return `${seconds}秒`
}
// ─── Absolute dates ──────────────────────────────────────────
export const formatAbsoluteDate = (timestamp: number): string => {
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
}
export const formatYmdDateFromSeconds = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp * 1000)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
}
export const formatYmdHmDateTime = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
const h = `${d.getHours()}`.padStart(2, '0')
const min = `${d.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${day} ${h}:${min}`
}
// ─── Relative time ───────────────────────────────────────────
export const formatLatestMessageTimeFromSeconds = (
timestamp?: number,
now: number = Date.now()
): { text: string; title: string } => {
if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) {
return { text: '--', title: '' }
}
const ms = timestamp * 1000
const absolute = formatYmdHmDateTime(ms)
const diff = Math.max(0, now - ms)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return { text: '刚刚', title: absolute }
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return { text: `${minutes} 分钟前`, title: absolute }
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return { text: `${hours} 小时前`, title: absolute }
}
return { text: absolute, title: absolute }
}
export const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return ''
const diff = Math.max(0, now - timestamp)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return `${minutes} 分钟前`
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return `${hours} 小时前`
}
return formatAbsoluteDate(timestamp)
}
// ─── Path formatting ─────────────────────────────────────────
export const formatPathBrief = (value: string, maxLength = 52): string => {
const normalized = String(value || '')
if (normalized.length <= maxLength) return normalized
// Try to split by path separator
const separator = normalized.includes('\\') ? '\\' : '/'
const parts = normalized.split(separator).filter(Boolean) // Handle trailing/leading slashes
if (parts.length <= 2) {
// Cannot shorten by directory levels, fallback to string slice
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)}`
}
// Handle Windows paths like C:\ keeping the root
let first = parts[0]
if (normalized.startsWith(separator)) {
first = separator + first
} else if (normalized.includes(':\\') || normalized.includes(':/')) {
// Keep root drive like C:
first = first + separator
} else {
first = first + separator
}
const last = parts[parts.length - 1]
let result = `${first}…${separator}${last}`
let leftIdx = 1
let rightIdx = parts.length - 2
while (leftIdx <= rightIdx) {
const nextLeft = `${parts[leftIdx]}${separator}`
const nextRight = leftIdx < rightIdx ? `${separator}${parts[rightIdx]}` : ''
if (result.length + nextLeft.length + nextRight.length <= maxLength) {
result = result.replace('…', `${nextLeft}…${nextRight}`)
leftIdx++
rightIdx--
} else {
break
}
}
if (result.length > maxLength) {
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)}`
}
return result
}
export const resolveParentDir = (value: string): string => {
const normalized = String(value || '').trim()
if (!normalized) return ''
const noTrailing = normalized.replace(/[\\/]+$/, '')
if (!noTrailing) return normalized
const lastSlash = Math.max(noTrailing.lastIndexOf('/'), noTrailing.lastIndexOf('\\'))
if (lastSlash < 0) return normalized
if (lastSlash === 0) return noTrailing.slice(0, 1)
if (/^[A-Za-z]:$/.test(noTrailing.slice(0, lastSlash))) {
return `${noTrailing.slice(0, lastSlash)}\\`
}
return noTrailing.slice(0, lastSlash)
}
// ─── Avatar ──────────────────────────────────────────────────
export const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
// ─── DateTime local value (for <input type="datetime-local">) ─
export const toDateTimeLocalValue = (timestamp: number): string => {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hours = `${date.getHours()}`.padStart(2, '0')
const minutes = `${date.getMinutes()}`.padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
export const parseDateTimeLocalValue = (value: string): number | null => {
const text = String(value || '').trim()
if (!text) return null
const parsed = new Date(text)
const timestamp = parsed.getTime()
if (!Number.isFinite(timestamp)) return null
return Math.floor(timestamp)
}
// ─── Number normalization ────────────────────────────────────
export const normalizeMessageCount = (value: unknown): number | undefined => {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0) return undefined
return Math.floor(parsed)
}
export const normalizeTimestampSeconds = (value: unknown): number | undefined => {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
return Math.floor(parsed)
}
export const mergeStableCount = (incoming: number | undefined, previous: number | undefined): number | undefined => {
if (typeof incoming !== 'number') return previous
if (incoming === 0 && typeof previous === 'number' && previous > 0) return previous
return incoming
}
+162
View File
@@ -0,0 +1,162 @@
/**
* ExportV2 — Performance tracking utilities
* Pure functions for task performance measurement (per-session timing, stage breakdown).
*/
import type { ExportTask, TaskPerformance, TaskSessionPerformance, TaskPerfStage, ExportProgress } from '../types'
import { TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS } from '../constants'
import { isTextBatchTask } from './progress'
// ─── Stage resolution ────────────────────────────────────────
export const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
if (phase === 'preparing') return 'collect'
if (phase === 'writing') return 'write'
if (phase === 'exporting' || phase === 'exporting-media' || phase === 'exporting-voice') return 'build'
return 'other'
}
// ─── Performance object factories ────────────────────────────
export const createEmptyTaskPerformance = (): TaskPerformance => ({
stages: { collect: 0, build: 0, write: 0, other: 0 },
sessions: {}
})
export const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => ({
stages: {
collect: performance?.stages.collect || 0,
build: performance?.stages.build || 0,
write: performance?.stages.write || 0,
other: performance?.stages.other || 0
},
sessions: { ...(performance?.sessions || {}) }
})
// ─── Session name resolution ─────────────────────────────────
export const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
const idx = task.payload.sessionIds.indexOf(sessionId)
if (idx >= 0) {
return task.payload.sessionNames[idx] || fallback || sessionId
}
return fallback || sessionId
}
// ─── Apply progress to performance ──────────────────────────
export const applyProgressToTaskPerformance = (
task: ExportTask,
payload: ExportProgress,
now: number
): TaskPerformance | undefined => {
if (!isTextBatchTask(task)) return task.performance
const sessionId = String(payload.currentSessionId || '').trim()
if (!sessionId) return task.performance || createEmptyTaskPerformance()
const currentPerformance = task.performance
const currentSession = currentPerformance?.sessions?.[sessionId]
if (
payload.phase !== 'complete' &&
currentSession &&
currentSession.lastPhase === payload.phase &&
typeof currentSession.lastPhaseStartedAt === 'number' &&
now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS
) {
return currentPerformance
}
const performance = cloneTaskPerformance(task.performance)
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
const existing = performance.sessions[sessionId]
const session: TaskSessionPerformance = existing
? { ...existing, sessionName: existing.sessionName || sessionName }
: { sessionId, sessionName, startedAt: now, elapsedMs: 0 }
if (!session.finishedAt && session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
const delta = Math.max(0, now - session.lastPhaseStartedAt)
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
}
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
if (payload.phase === 'complete') {
session.finishedAt = now
session.lastPhase = undefined
session.lastPhaseStartedAt = undefined
} else {
session.lastPhase = payload.phase
session.lastPhaseStartedAt = now
}
performance.sessions[sessionId] = session
return performance
}
// ─── Finalize performance on task completion ─────────────────
export const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
if (!isTextBatchTask(task) || !task.performance) return task.performance
const performance = cloneTaskPerformance(task.performance)
const nextSessions: Record<string, TaskSessionPerformance> = {}
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
const session: TaskSessionPerformance = { ...sourceSession }
if (session.finishedAt) continue
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
const delta = Math.max(0, now - session.lastPhaseStartedAt)
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
}
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
session.finishedAt = now
session.lastPhase = undefined
session.lastPhaseStartedAt = undefined
nextSessions[sessionId] = session
}
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
if (nextSessions[sessionId]) continue
nextSessions[sessionId] = { ...sourceSession }
}
performance.sessions = nextSessions
return performance
}
// ─── Stage totals (live, accounts for in-progress sessions) ──
export const getTaskPerformanceStageTotals = (
performance: TaskPerformance | undefined,
now: number
): Record<TaskPerfStage, number> => {
const totals: Record<TaskPerfStage, number> = {
collect: performance?.stages.collect || 0,
build: performance?.stages.build || 0,
write: performance?.stages.write || 0,
other: performance?.stages.other || 0
}
if (!performance) return totals
for (const session of Object.values(performance.sessions)) {
if (session.finishedAt) continue
if (!session.lastPhase || typeof session.lastPhaseStartedAt !== 'number') continue
const delta = Math.max(0, now - session.lastPhaseStartedAt)
totals[resolvePerfStageByPhase(session.lastPhase)] += delta
}
return totals
}
// ─── Top N slowest sessions ──────────────────────────────────
export const getTaskPerformanceTopSessions = (
performance: TaskPerformance | undefined,
now: number,
limit = 5
): Array<TaskSessionPerformance & { liveElapsedMs: number }> => {
if (!performance) return []
return Object.values(performance.sessions)
.map((session) => {
const liveElapsedMs = session.finishedAt
? session.elapsedMs
: Math.max(session.elapsedMs, now - session.startedAt)
return { ...session, liveElapsedMs }
})
.sort((a, b) => b.liveElapsedMs - a.liveElapsedMs)
.slice(0, limit)
}
+155
View File
@@ -0,0 +1,155 @@
/**
* ExportV2 — Progress utility functions
* Pure functions for progress normalization, comparison, and formatting.
*/
import type { TaskProgress, ExportTask, ExportProgress } from '../types'
// ─── Progress comparison ─────────────────────────────────────
export const areTaskProgressEqual = (left: TaskProgress, right: TaskProgress): boolean => (
left.current === right.current &&
left.total === right.total &&
left.currentName === right.currentName &&
left.phase === right.phase &&
left.phaseLabel === right.phaseLabel &&
left.phaseProgress === right.phaseProgress &&
left.phaseTotal === right.phaseTotal &&
left.exportedMessages === right.exportedMessages &&
left.estimatedTotalMessages === right.estimatedTotalMessages &&
left.collectedMessages === right.collectedMessages &&
left.writtenFiles === right.writtenFiles &&
left.mediaDoneFiles === right.mediaDoneFiles &&
left.mediaCacheHitFiles === right.mediaCacheHitFiles &&
left.mediaCacheMissFiles === right.mediaCacheMissFiles &&
left.mediaCacheFillFiles === right.mediaCacheFillFiles &&
left.mediaDedupReuseFiles === right.mediaDedupReuseFiles &&
left.mediaBytesWritten === right.mediaBytesWritten
)
// ─── Progress normalization ──────────────────────────────────
export const normalizeProgressFloat = (value: unknown, digits = 3): number => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
const factor = 10 ** digits
return Math.round(parsed * factor) / factor
}
export const normalizeProgressInt = (value: unknown): number => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
return Math.max(0, Math.floor(parsed))
}
// ─── Progress payload signature (for dedup) ──────────────────
export const buildProgressPayloadSignature = (payload: ExportProgress): string => ([
String(payload.phase || ''),
String(payload.currentSessionId || ''),
String(payload.currentSession || ''),
String(payload.phaseLabel || ''),
normalizeProgressFloat(payload.current, 4),
normalizeProgressFloat(payload.total, 4),
normalizeProgressFloat(payload.phaseProgress, 2),
normalizeProgressFloat(payload.phaseTotal, 2),
normalizeProgressInt(payload.collectedMessages),
normalizeProgressInt(payload.exportedMessages),
normalizeProgressInt(payload.estimatedTotalMessages),
normalizeProgressInt(payload.writtenFiles),
normalizeProgressInt(payload.mediaDoneFiles),
normalizeProgressInt(payload.mediaCacheHitFiles),
normalizeProgressInt(payload.mediaCacheMissFiles),
normalizeProgressInt(payload.mediaCacheFillFiles),
normalizeProgressInt(payload.mediaDedupReuseFiles),
normalizeProgressInt(payload.mediaBytesWritten)
].join('|'))
// ─── Task status helpers ─────────────────────────────────────
export const isExportTaskActiveStatus = (status: import('../types').TaskStatus): boolean => (
status === 'queued' ||
status === 'running' ||
status === 'pause_requested' ||
status === 'paused' ||
status === 'cancel_requested'
)
export const getTaskStatusLabel = (task: ExportTask): string => {
if (task.status === 'queued') return '排队中'
if (task.status === 'running') return '进行中'
if (task.status === 'pause_requested') return '暂停中'
if (task.status === 'paused') return '已暂停'
if (task.status === 'cancel_requested') return '取消中'
if (task.status === 'success') return '已完成'
return '失败'
}
export const resolveExportTaskCardClass = (
status: import('../types').TaskStatus
): 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
if (status === 'pause_requested' || status === 'paused') return 'paused'
if (status === 'cancel_requested') return 'stopped'
return status
}
export const resolveBackgroundTaskCardClass = (
status: import('../../../types/backgroundTask').BackgroundTaskRecord['status']
): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
if (status === 'running') return 'running'
if (status === 'pause_requested' || status === 'paused') return 'paused'
if (status === 'cancel_requested' || status === 'canceled') return 'stopped'
if (status === 'completed') return 'success'
return 'error'
}
// ─── Background task progress parsing ────────────────────────
export const parseBackgroundTaskProgress = (progressText?: string): {
current: number
total: number
ratio: number | null
} => {
const normalized = String(progressText || '').trim()
if (!normalized) return { current: 0, total: 0, ratio: null }
const match = normalized.match(/(\d+)\s*\/\s*(\d+)/)
if (!match) return { current: 0, total: 0, ratio: null }
const current = Math.max(0, Math.floor(Number(match[1]) || 0))
const total = Math.max(0, Math.floor(Number(match[2]) || 0))
if (total <= 0) return { current, total, ratio: null }
return {
current,
total,
ratio: Math.max(0, Math.min(1, current / total))
}
}
// ─── Task scope helpers ──────────────────────────────────────
export const isTextBatchTask = (task: ExportTask): boolean =>
task.payload.scope === 'content' && task.payload.contentType === 'text'
export const isImageExportTask = (task: ExportTask): boolean => {
if (task.payload.scope === 'sns') {
return Boolean(task.payload.snsOptions?.exportImages)
}
if (task.payload.scope !== 'content') return false
if (task.payload.contentType === 'image') return true
return Boolean(task.payload.options?.exportImages)
}
// ─── Task open directory resolution ──────────────────────────
import { resolveParentDir } from './format'
export const resolveTaskOpenDir = (task: ExportTask): string => {
const sessionIds = Array.isArray(task.payload.sessionIds) ? task.payload.sessionIds : []
if (sessionIds.length === 1) {
const onlySessionId = String(sessionIds[0] || '').trim()
const outputPath = onlySessionId ? String(task.sessionOutputPaths?.[onlySessionId] || '').trim() : ''
if (outputPath) {
return resolveParentDir(outputPath) || task.payload.outputDir
}
}
return task.payload.outputDir
}
+152
View File
@@ -0,0 +1,152 @@
/**
* ExportV2 — Session utility functions
* Pure functions for session classification, sorting, and merging.
*/
import type { ChatSession as AppChatSession, ContactInfo } from '../../../types/models'
import type { ConversationTab, SessionRow } from '../types'
// ─── Session type classification ─────────────────────────────
export const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
if (session.username.endsWith('@chatroom')) return 'group'
if (session.username.startsWith('gh_')) return 'official'
if (contact?.type === 'official') return 'official'
if (contact?.type === 'former_friend') return 'former_friend'
return 'private'
}
export const toKindByContact = (contact: ContactInfo): ConversationTab => {
if (contact.type === 'group') return 'group'
if (contact.type === 'official') return 'official'
if (contact.type === 'former_friend') return 'former_friend'
return 'private'
}
// ─── Session filtering predicates ────────────────────────────
export const isContentScopeSession = (session: SessionRow): boolean =>
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
export const isExportConversationSession = (session: SessionRow): boolean =>
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
export const isSingleContactSession = (sessionId: string): boolean => {
const normalized = String(sessionId || '').trim()
if (!normalized) return false
if (normalized.includes('@chatroom')) return false
if (normalized.startsWith('gh_')) return false
return true
}
export const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => {
if (tab === 'private') return contact.type === 'friend'
if (tab === 'group') return contact.type === 'group'
if (tab === 'official') return contact.type === 'official'
return contact.type === 'former_friend'
}
// ─── Build session rows from sessions + contacts ─────────────
export const toSessionRowsWithContacts = (
sessions: AppChatSession[],
contactMap: Record<string, ContactInfo>
): SessionRow[] => {
const sessionMap = new Map<string, AppChatSession>()
for (const session of sessions || []) {
sessionMap.set(session.username, session)
}
const contacts = Object.values(contactMap)
.filter((contact) => (
contact.type === 'friend' ||
contact.type === 'group' ||
contact.type === 'official' ||
contact.type === 'former_friend'
))
if (contacts.length > 0) {
return contacts
.map((contact) => {
const session = sessionMap.get(contact.username)
const latestTs = session?.sortTimestamp || session?.lastTimestamp || 0
return {
...(session || {
username: contact.username,
type: 0,
unreadCount: 0,
summary: '',
sortTimestamp: latestTs,
lastTimestamp: latestTs,
lastMsgType: 0
}),
username: contact.username,
kind: toKindByContact(contact),
wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username,
avatarUrl: session?.avatarUrl || contact.avatarUrl,
remark: contact.remark,
nickname: contact.nickname,
hasSession: Boolean(session)
} as SessionRow
})
.sort((a, b) => {
const latestA = a.sortTimestamp || a.lastTimestamp || 0
const latestB = b.sortTimestamp || b.lastTimestamp || 0
if (latestA !== latestB) return latestB - latestA
return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')
})
}
return sessions
.map((session) => {
const contact = contactMap[session.username]
return {
...session,
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: session.avatarUrl || contact?.avatarUrl,
remark: contact?.remark,
nickname: contact?.nickname,
hasSession: true
} as SessionRow
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
}
// ─── Array equality check ────────────────────────────────────
export const areStringArraysEqual = (left: string[], right: string[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
export const getSelectionScopeFromRows = (rows: SessionRow[]): import('../types').TaskScope => {
if (rows.length === 0) return 'single'
if (rows.length === 1) return 'single'
return 'multi'
}
export const resolveScopeDisplayNames = (
rows: SessionRow[],
pref: import('../types').DisplayNamePreference
): string[] => {
return rows.map(r => r.remark || r.nickname || r.displayName || r.username)
}
// ─── Name comparison helper ──────────────────────────────────
export const toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
const set = new Set<string>()
for (const value of values) {
const normalized = String(value || '').trim()
if (!normalized) continue
set.add(normalized)
}
return set
}