全新出发
This commit is contained in:
1 parent
b06c117b1e
commit
fa4edebed6
463 files changed
+181610
-125
No files matched your search
@@ -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); }
|
||||
}
|
||||
@@ -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">></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)
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -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'
|
||||
@@ -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)}`
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user