全新出发

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

No files matched your search

@@ -0,0 +1,451 @@
// Automation.scss — Matching WeFlow design system
// Uses CSS custom properties consistent with ExportV2Page, ExportDialog, etc.
// Design language: no hard borders, subtle color-mix transparency, rounded-2xl modals, 8px controls
// ─── Modal Overlay & Container ────────────────────────────────
.automation-modal-overlay {
position: fixed;
inset: 0;
z-index: 7700;
background: color-mix(in srgb, black 40%, transparent);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
backdrop-filter: blur(2px);
animation: fadeIn 0.15s ease;
.automation-modal {
width: 100%;
max-width: 720px;
height: 80vh;
max-height: 780px;
background: var(--bg-surface, var(--bg-primary));
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 48px color-mix(in srgb, black 30%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
animation: modalScaleUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
.header-title {
h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--text-tertiary);
}
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 0;
background: var(--bg-main, var(--bg-secondary));
}
}
}
// ─── Task List ────────────────────────────────────────────────
.automation-task-list {
display: flex;
flex-direction: column;
.empty-state {
padding: 60px 24px;
text-align: center;
color: var(--text-tertiary);
.empty-icon {
margin-bottom: 16px;
opacity: 0.3;
}
p {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--text-secondary);
}
span {
display: block;
font-size: 12px;
color: var(--text-tertiary);
}
}
.task-item {
padding: 16px 24px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
background: var(--bg-surface, var(--bg-primary));
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
transition: background 0.15s;
&:hover {
background: var(--bg-hover, color-mix(in srgb, var(--bg-surface) 92%, var(--text-primary)));
}
&:last-child {
border-bottom: none;
}
.task-info {
flex: 1;
min-width: 0;
.task-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
flex-shrink: 0;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 500;
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
color: var(--text-tertiary);
&.enabled {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
}
.task-details {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
color: var(--text-tertiary);
.detail-row {
display: flex;
align-items: center;
gap: 5px;
line-height: 1.5;
svg {
opacity: 0.45;
flex-shrink: 0;
}
}
}
}
.task-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
.action-row {
display: flex;
gap: 6px;
}
}
}
}
// ─── Shared Button Styles ─────────────────────────────────────
.automation-modal-overlay,
.automation-form-overlay,
.automation-task-list {
.primary-btn {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1.2;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
background: var(--primary);
color: #fff;
transition: opacity 0.15s, box-shadow 0.15s;
&:hover {
opacity: 0.88;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent);
}
&:active { opacity: 0.75; }
}
.secondary-btn {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1.2;
gap: 4px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
transition: background 0.15s;
&:hover {
background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-hover));
}
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 6px;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.danger:hover {
color: #e53935;
background: color-mix(in srgb, #e53935 8%, transparent);
}
}
.close-icon-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
// ─── Form Overlay ─────────────────────────────────────────────
.automation-form-overlay {
position: fixed;
inset: 0;
z-index: 7800;
background: color-mix(in srgb, black 40%, transparent);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
backdrop-filter: blur(2px);
animation: fadeIn 0.15s ease;
.automation-form-modal {
width: 100%;
max-width: 560px;
max-height: 85vh;
background: var(--bg-surface, var(--bg-primary));
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 48px color-mix(in srgb, black 30%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
animation: modalScaleUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
.form-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
}
.form-body {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 20px;
.form-section {
display: flex;
flex-direction: column;
gap: 10px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.02em;
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
&.vertical {
flex-direction: column;
align-items: flex-start;
}
> label {
font-size: 13px;
color: var(--text-tertiary);
flex-shrink: 0;
}
input[type="text"],
input[type="number"],
input[type="datetime-local"] {
flex: 1;
min-width: 0;
padding: 8px 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
border-radius: 8px;
font-size: 13px;
outline: none;
background: var(--bg-main, var(--bg-secondary));
color: var(--text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 12%, transparent);
}
&[disabled] {
background: var(--bg-hover);
color: var(--text-tertiary);
cursor: not-allowed;
}
}
.number-input-group {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
input[type="number"] {
width: 72px;
flex: none;
text-align: center;
}
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
user-select: none;
input[type="checkbox"],
input[type="radio"] {
cursor: pointer;
accent-color: var(--primary);
}
}
}
}
.form-summary {
padding: 12px 14px;
background: var(--bg-main, var(--bg-secondary));
border-radius: 8px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.7;
border: 1px solid color-mix(in srgb, var(--border-color) 30%, transparent);
}
}
.form-footer {
padding: 14px 24px;
border-top: 1px solid color-mix(in srgb, var(--border-color) 50%, transparent);
display: flex;
justify-content: flex-end;
gap: 10px;
background: var(--bg-main, var(--bg-secondary));
}
}
}
// ─── Reuse existing animations from ExportV2Page ──────────────
// fadeIn & modalScaleUp are defined in ExportV2Page.scss
// If not available, define local fallbacks:
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modalScaleUp {
from { opacity: 0; transform: scale(0.96) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@@ -0,0 +1,75 @@
import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { ExportAutomationTask } from '../../../../types/exportAutomation'
import { useAutomationStore } from '../../hooks/useAutomation'
import { AutomationTaskList } from './AutomationTaskList'
import { AutomationTaskForm } from './AutomationTaskForm'
import './Automation.scss'
interface AutomationModalProps {
onClose: () => void
}
export const AutomationModal: React.FC<AutomationModalProps> = ({ onClose }) => {
const { tasks, updateTask, deleteTask } = useAutomationStore()
const [editingTask, setEditingTask] = useState<ExportAutomationTask | null>(null)
const handleToggleEnable = (task: ExportAutomationTask, enabled: boolean) => {
void updateTask(task.id, prev => ({ ...prev, enabled, updatedAt: Date.now() }))
}
const handleRunNow = (_task: ExportAutomationTask) => {
// Manual trigger is handled by the automation runner's evaluateSchedules
// For now, just show a hint
alert('手动触发功能需要自动化运行器的支持,任务将在下次调度周期中执行。')
}
const handleDelete = (task: ExportAutomationTask) => {
void deleteTask(task.id)
}
const handleEditSave = (task: ExportAutomationTask) => {
void updateTask(task.id, () => task)
setEditingTask(null)
}
return createPortal(
<>
<div className="automation-modal-overlay" onClick={onClose}>
<div className="automation-modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="header-title">
<h3>自动化导出</h3>
<p>管理定时增量备份任务,仅在应用运行期间生效</p>
</div>
<div className="header-actions">
<button className="close-icon-btn" onClick={onClose}>
<X size={18} />
</button>
</div>
</div>
<div className="modal-content">
<AutomationTaskList
tasks={tasks}
onEdit={setEditingTask}
onDelete={handleDelete}
onToggleEnable={handleToggleEnable}
onRunNow={handleRunNow}
/>
</div>
</div>
</div>
{editingTask && (
<AutomationTaskForm
initialTask={editingTask}
onSave={handleEditSave}
onCancel={() => setEditingTask(null)}
/>
)}
</>,
document.body
)
}
@@ -0,0 +1,416 @@
import React, { useState } from 'react'
import { X } from 'lucide-react'
import type { ExportAutomationTask } from '../../../../types/exportAutomation'
import type { ExportTaskPayload } from '../../types'
import {
buildAutomationSchedule,
createAutomationTaskId,
normalizeAutomationIntervalDays,
normalizeAutomationIntervalHours,
AUTOMATION_RANGE_OPTIONS,
type AutomationRangeMode,
normalizeAutomationLastNDays,
AUTOMATION_LAST_N_DAYS_DEFAULT,
buildAutomationLastNDaysConfig,
resolveAutomationRangeMode,
resolveAutomationDateRangeSelection
} from '../../utils/automation'
import type { ExportAutomationDateRangeConfig } from '../../../../types/exportAutomation'
import './Automation.scss'
interface AutomationTaskFormProps {
initialTask?: ExportAutomationTask
basePayload?: Omit<ExportTaskPayload, 'source' | 'automationTaskId'>
onSave: (task: ExportAutomationTask) => void
onCancel: () => void
}
export const AutomationTaskForm: React.FC<AutomationTaskFormProps> = ({
initialTask,
basePayload,
onSave,
onCancel
}) => {
// ─── Derive a sensible default task name ───────────────────
const defaultName = (() => {
const names = initialTask?.sessionNames || basePayload?.sessionNames || []
if (names.length === 0) return '每日增量备份'
if (names.length === 1) return `${names[0]} 自动化导出`
return `${names[0]} 等 ${names.length} 个会话 自动化导出`
})()
const [name, setName] = useState(initialTask?.name || defaultName)
const [enabled, setEnabled] = useState(initialTask?.enabled ?? true)
const [useGlobalOutputDir, setUseGlobalOutputDir] = useState(!initialTask?.outputDir)
const [outputDir, setOutputDir] = useState(initialTask?.outputDir || '')
const schedule = initialTask?.schedule
const [intervalDays, setIntervalDays] = useState(schedule?.intervalDays ?? 1)
const [intervalHours, setIntervalHours] = useState(schedule?.intervalHours ?? 0)
const firstTriggerAt = schedule?.firstTriggerAt
const [firstTriggerEnabled, setFirstTriggerEnabled] = useState(!!firstTriggerAt)
const [firstTriggerValue, setFirstTriggerValue] = useState(() => {
if (firstTriggerAt) {
const d = new Date(firstTriggerAt)
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
return d.toISOString().slice(0, 16)
}
return ''
})
// ─── Date Range Mode ───────────────────────────────────────
const initDateRangeConfig = initialTask?.template?.dateRangeConfig
const initSelection = initDateRangeConfig
? resolveAutomationDateRangeSelection(initDateRangeConfig)
: undefined
const initMode = initDateRangeConfig && initSelection
? resolveAutomationRangeMode(initDateRangeConfig, initSelection)
: 'all'
const [rangeMode, setRangeMode] = useState<AutomationRangeMode>(initMode)
const [lastNDays, setLastNDays] = useState(() => {
if (initDateRangeConfig && typeof initDateRangeConfig === 'object' && (initDateRangeConfig as any).relativeDays) {
return (initDateRangeConfig as any).relativeDays
}
return AUTOMATION_LAST_N_DAYS_DEFAULT
})
// ─── Stop Conditions ──────────────────────────────────────
const stopCondition = initialTask?.stopCondition
const [stopAtEnabled, setStopAtEnabled] = useState(!!stopCondition?.endAt)
const [stopAtValue, setStopAtValue] = useState(() => {
if (stopCondition?.endAt) {
const d = new Date(stopCondition.endAt)
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
return d.toISOString().slice(0, 16)
}
return ''
})
const [maxRunsEnabled, setMaxRunsEnabled] = useState(!!stopCondition?.maxRuns)
const [maxRuns, setMaxRuns] = useState(stopCondition?.maxRuns || 0)
const handleChooseDir = async () => {
const result = await window.electronAPI.dialog.openFile({
properties: ['openDirectory', 'createDirectory'] as any
})
if (!result.canceled && result.filePaths.length > 0) {
setOutputDir(result.filePaths[0])
setUseGlobalOutputDir(false)
}
}
const buildDateRangeConfig = (): ExportAutomationDateRangeConfig | null => {
if (rangeMode === 'all') {
return { version: 1, preset: 'all', useAllTime: true } as any
}
if (rangeMode === 'lastNDays') {
return buildAutomationLastNDaysConfig(normalizeAutomationLastNDays(lastNDays)) as any
}
if (['yesterday', 'last7days', 'last30days', 'last1year', 'today'].includes(rangeMode)) {
return { version: 1, preset: rangeMode, useAllTime: false } as any
}
return null
}
const handleSave = () => {
const trimmedName = name.trim()
if (!trimmedName) {
alert('请输入任务名称')
return
}
const days = normalizeAutomationIntervalDays(intervalDays)
const hours = normalizeAutomationIntervalHours(intervalHours)
if (days <= 0 && hours <= 0) {
alert('执行间隔不能为0,请至少设置天数或小时')
return
}
let firstTriggerTimestamp: number | undefined
if (firstTriggerEnabled) {
if (!firstTriggerValue) {
alert('请填写有效的首次触发时间')
return
}
firstTriggerTimestamp = new Date(firstTriggerValue).getTime()
}
let endAtTimestamp: number | undefined
if (stopAtEnabled) {
if (!stopAtValue) {
alert('请填写有效的终止时间')
return
}
endAtTimestamp = new Date(stopAtValue).getTime()
}
const maxRunsValue = maxRunsEnabled ? Math.max(0, Math.floor(maxRuns)) : undefined
const sessionIds = initialTask?.sessionIds || basePayload?.sessionIds || []
const sessionNames = initialTask?.sessionNames || basePayload?.sessionNames || []
const templateOptions = initialTask?.template.optionTemplate || basePayload?.options || {}
const scope = initialTask?.template.scope || basePayload?.scope || 'multi'
const contentType = initialTask?.template.contentType || basePayload?.contentType
const task: ExportAutomationTask = {
id: initialTask?.id || createAutomationTaskId(),
name: trimmedName,
enabled,
sessionIds,
sessionNames,
outputDir: useGlobalOutputDir ? undefined : outputDir,
schedule: buildAutomationSchedule(days, hours, firstTriggerTimestamp || 0),
condition: {
type: 'new-message-since-last-success'
},
stopCondition: {
endAt: endAtTimestamp,
maxRuns: maxRunsValue
},
template: {
scope: scope as any,
contentType,
optionTemplate: templateOptions as any,
dateRangeConfig: buildDateRangeConfig() as any
},
runState: initialTask?.runState,
createdAt: initialTask?.createdAt || Date.now(),
updatedAt: Date.now()
}
onSave(task)
}
// ─── Summary ───────────────────────────────────────────────
const sessionIds = initialTask?.sessionIds || basePayload?.sessionIds || []
const sessionNamesForSummary = initialTask?.sessionNames || basePayload?.sessionNames || []
const rangeLabelMap: Record<AutomationRangeMode, string> = {
all: '每次触发导出全部历史消息',
today: '每次触发导出当天',
yesterday: '每次触发导出前1天',
last7days: '每次触发导出前7天',
last30days: '每次触发导出前30天',
last1year: '每次触发导出前1年',
lastNDays: `每次触发导出前 ${normalizeAutomationLastNDays(lastNDays)} 天`,
custom: '完整时间'
}
return (
<div className="automation-form-overlay" onClick={onCancel}>
<div className="automation-form-modal" onClick={e => e.stopPropagation()}>
<div className="form-header">
<h3>{initialTask ? '编辑自动化任务' : '创建自动化任务'}</h3>
<button className="close-icon-btn" onClick={onCancel}>
<X size={18} />
</button>
</div>
<div className="form-body">
{/* 基本信息 */}
<div className="form-section">
<h4>任务名称</h4>
<div className="form-row">
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="例如:每天凌晨备份"
/>
</div>
</div>
{/* 执行频率 */}
<div className="form-section">
<h4>执行频率</h4>
<div className="form-row">
<label>间隔天数</label>
<div className="number-input-group">
<input
type="number"
min="0"
value={intervalDays}
onChange={e => setIntervalDays(Number(e.target.value))}
/>
</div>
<label>间隔小时</label>
<div className="number-input-group">
<input
type="number"
min="0"
max="23"
value={intervalHours}
onChange={e => setIntervalHours(Number(e.target.value))}
/>
</div>
</div>
</div>
{/* 首次触发 */}
<div className="form-section">
<h4>首次触发时间(可选)</h4>
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={firstTriggerEnabled}
onChange={e => setFirstTriggerEnabled(e.target.checked)}
/>
指定第一次触发时间
</label>
</div>
{firstTriggerEnabled && (
<div className="form-row">
<input
type="datetime-local"
value={firstTriggerValue}
onChange={e => setFirstTriggerValue(e.target.value)}
/>
</div>
)}
</div>
{/* 导出时间范围 */}
<div className="form-section">
<h4>导出时间范围(按触发时间动态计算)</h4>
<div className="form-row" style={{ flexWrap: 'wrap', gap: '8px' }}>
{AUTOMATION_RANGE_OPTIONS.map(opt => (
<button
key={opt.mode}
type="button"
style={{
padding: '6px 14px',
borderRadius: '16px',
fontSize: '13px',
cursor: 'pointer',
border: rangeMode === opt.mode ? '1px solid var(--primary)' : '1px solid color-mix(in srgb, var(--border-color) 60%, transparent)',
background: rangeMode === opt.mode ? 'color-mix(in srgb, var(--primary) 10%, transparent)' : 'var(--bg-surface, #fff)',
color: rangeMode === opt.mode ? 'var(--primary)' : 'var(--text-primary)',
fontWeight: rangeMode === opt.mode ? 500 : 400
}}
onClick={() => setRangeMode(opt.mode)}
>
{opt.label}
</button>
))}
</div>
{rangeMode === 'lastNDays' && (
<div className="form-row">
<label>天数</label>
<div className="number-input-group">
<input
type="number"
min="1"
max="3650"
value={lastNDays}
onChange={e => setLastNDays(Number(e.target.value))}
/>
</div>
</div>
)}
<div style={{ fontSize: '12px', color: 'var(--text-secondary, #888)', marginTop: '4px' }}>
{rangeLabelMap[rangeMode]}
</div>
</div>
{/* 终止条件 */}
<div className="form-section">
<h4>终止条件(可选)</h4>
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={stopAtEnabled}
onChange={e => setStopAtEnabled(e.target.checked)}
/>
到指定时间后自动停止
</label>
</div>
{stopAtEnabled && (
<div className="form-row">
<input
type="datetime-local"
value={stopAtValue}
onChange={e => setStopAtValue(e.target.value)}
/>
</div>
)}
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={maxRunsEnabled}
onChange={e => setMaxRunsEnabled(e.target.checked)}
/>
成功执行指定次数后自动停止
</label>
</div>
{maxRunsEnabled && (
<div className="form-row">
<div className="number-input-group">
<input
type="number"
min="1"
value={maxRuns}
onChange={e => setMaxRuns(Number(e.target.value))}
/> 次
</div>
</div>
)}
</div>
{/* 导出目录 */}
<div className="form-section">
<h4>导出目录</h4>
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={useGlobalOutputDir}
onChange={e => setUseGlobalOutputDir(e.target.checked)}
/>
使用全局导出目录
</label>
</div>
{!useGlobalOutputDir && (
<div className="form-row">
<button type="button" className="secondary-btn" onClick={handleChooseDir}>选择目录</button>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
{outputDir || '未选择'}
</span>
</div>
)}
</div>
{/* 启用 */}
<div className="form-section">
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
/>
创建后立即启用
</label>
</div>
</div>
{/* Summary */}
<div className="form-summary">
会话:{sessionIds.length} 个 ·
间隔:{normalizeAutomationIntervalDays(intervalDays)} 天 {normalizeAutomationIntervalHours(intervalHours)} 小时 ·
首次:{firstTriggerEnabled && firstTriggerValue ? new Date(firstTriggerValue).toLocaleString('zh-CN') : '默认按创建时间+间隔'} ·
时间:{rangeLabelMap[rangeMode]} ·
条件:有新消息才导出
</div>
</div>
<div className="form-footer">
<button type="button" className="secondary-btn" onClick={onCancel}>取消</button>
<button type="button" className="primary-btn" onClick={handleSave}>保存任务</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,130 @@
import React from 'react'
import { Play, Pause, Edit, Trash2, Clock, CheckCircle2, AlertCircle } from 'lucide-react'
import type { ExportAutomationTask } from '../../../../types/exportAutomation'
import {
formatAutomationScheduleLabel,
formatAutomationCurrentState,
formatAutomationStopCondition,
formatAutomationLastRunSummary
} from '../../utils/automation'
interface AutomationTaskListProps {
tasks: ExportAutomationTask[]
onEdit: (task: ExportAutomationTask) => void
onDelete: (task: ExportAutomationTask) => void
onToggleEnable: (task: ExportAutomationTask, enabled: boolean) => void
onRunNow: (task: ExportAutomationTask) => void
}
export const AutomationTaskList: React.FC<AutomationTaskListProps> = ({
tasks,
onEdit,
onDelete,
onToggleEnable,
onRunNow
}) => {
if (tasks.length === 0) {
return (
<div className="automation-task-list">
<div className="empty-state">
<Clock size={48} className="empty-icon" />
<p>暂无自动化任务</p>
<span style={{ fontSize: '12px', opacity: 0.6 }}>在导出配置时,选择“保存为自动化任务”即可创建</span>
</div>
</div>
)
}
const nowMs = Date.now()
return (
<div className="automation-task-list">
{tasks.map(task => {
const isEnabled = task.enabled
// For queueState, we can determine from lastRunStatus if it's currently running/queued
const queueState = task.runState?.lastRunStatus === 'running'
? 'running'
: task.runState?.lastRunStatus === 'queued' ? 'queued' : null
return (
<div key={task.id} className="task-item">
<div className="task-info">
<div className="task-header">
<span className={`status-badge ${isEnabled ? 'enabled' : ''}`}>
{isEnabled ? '运行中' : '已停用'}
</span>
<h4 title={task.name}>{task.name}</h4>
</div>
<div className="task-details">
<div className="detail-row">
<Clock size={14} />
<span>调度规则:{formatAutomationScheduleLabel(task.schedule)}</span>
</div>
<div className="detail-row">
<AlertCircle size={14} />
<span>停止条件:{formatAutomationStopCondition(task)}</span>
</div>
<div className="detail-row">
<CheckCircle2 size={14} />
<span>最近执行:{formatAutomationLastRunSummary(task)}</span>
</div>
<div className="detail-row" style={{ color: isEnabled ? 'var(--primary)' : 'inherit', marginTop: '4px' }}>
<span>状态:{formatAutomationCurrentState(task, queueState, nowMs)}</span>
</div>
</div>
</div>
<div className="task-actions">
<div className="action-row">
{isEnabled ? (
<button
className="secondary-btn"
title="停用任务"
onClick={() => onToggleEnable(task, false)}
>
<Pause size={14} style={{ marginRight: '4px' }} /> 停用
</button>
) : (
<button
className="primary-btn"
title="启用任务"
onClick={() => onToggleEnable(task, true)}
>
<Play size={14} style={{ marginRight: '4px' }} /> 启用
</button>
)}
<button
className="secondary-btn"
title="立即手动触发一次"
onClick={() => onRunNow(task)}
>
<Play size={14} />
</button>
</div>
<div className="action-row">
<button
className="icon-btn"
title="编辑"
onClick={() => onEdit(task)}
>
<Edit size={16} />
</button>
<button
className="icon-btn danger"
title="删除"
onClick={() => {
if (window.confirm(`确认删除自动化任务「${task.name}」吗?`)) {
onDelete(task)
}
}}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,454 @@
.export-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: color-mix(in srgb, var(--bg-primary) 60%, transparent);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
.export-dialog-container {
background: var(--bg-primary);
border-radius: 16px;
width: 100%;
max-width: 680px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 12px 48px color-mix(in srgb, var(--shadow-color) 40%, transparent),
0 0 0 1px color-mix(in srgb, var(--border-color) 80%, transparent);
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
// Header
.dialog-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--border-color);
.title-area {
.title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.subtitle {
font-size: 13px;
color: var(--text-tertiary);
}
}
.close-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
padding: 4px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
// Body
.dialog-body {
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 32px;
.config-section {
display: flex;
flex-direction: column;
gap: 12px;
.config-row-group {
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-surface, var(--bg-primary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
.config-row {
display: flex;
align-items: center;
gap: 16px;
.row-label {
width: 72px;
flex-shrink: 0;
font-size: 13px;
color: var(--text-secondary);
}
.row-value-btn {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-main, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
gap: 8px;
.icon {
color: var(--primary);
}
.text {
flex: 1;
text-align: left;
font-size: 13px;
color: var(--text-primary);
}
.arrow {
color: var(--text-tertiary);
font-size: 12px;
}
&:hover {
border-color: var(--primary);
background: var(--bg-surface, var(--bg-primary));
}
}
}
}
.export-path-row {
flex: 1;
display: flex;
gap: 8px;
align-items: center;
.path-display-group {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
.path-display-btn {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-tertiary, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 5px 12px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
min-width: 0;
.path-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
.open-folder-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex-shrink: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
}
.change-path-btn {
display: flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 16px;
background: var(--bg-surface, var(--bg-primary));
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-main, var(--bg-secondary));
border-color: var(--text-tertiary);
}
}
}
.error-text {
color: #ef4444;
font-size: 12px;
margin-top: -4px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16px;
svg {
color: var(--primary);
}
}
.format-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.format-card {
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 16px;
text-align: left;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
.format-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.format-desc {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
&:hover {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
background: var(--bg-hover);
}
&.active {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 15%, transparent);
.format-name {
color: var(--primary);
}
}
}
}
.media-options, .advanced-options {
display: flex;
flex-wrap: wrap;
gap: 16px;
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
}
input[type="checkbox"] {
accent-color: var(--primary);
width: 16px;
height: 16px;
cursor: pointer;
}
svg {
color: var(--text-tertiary);
}
&:has(input:checked) {
border-color: color-mix(in srgb, var(--primary) 50%, transparent);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
svg {
color: var(--primary);
}
}
}
}
.file-size-limit, .concurrency-control, .path-style-control {
margin-top: 16px;
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text-tertiary);
input, select {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color 0.2s ease;
&:focus {
border-color: var(--primary);
}
}
input[type="number"] {
width: 80px;
}
}
.path-style-control {
flex: 1 1 100%;
margin-top: 4px;
> span {
flex: 0 0 auto;
}
.path-style-segmented {
display: inline-flex;
min-height: 34px;
padding: 3px;
gap: 3px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
button {
min-width: 82px;
border: 0;
border-radius: 6px;
padding: 6px 10px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 28%, transparent);
}
}
}
}
}
}
// Footer
.dialog-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
background: var(--bg-secondary);
button {
padding: 8px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.cancel-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
&.confirm-btn {
background: var(--primary);
border: 1px solid var(--primary);
color: white;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent);
&:hover {
background: color-mix(in srgb, var(--primary) 90%, black);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 40%, transparent);
}
}
}
}
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@@ -0,0 +1,327 @@
import React, { memo, useState, useEffect } from 'react'
import {
X,
FileText,
Image as ImageIcon,
Video,
Mic,
FileBox,
Smile,
Calendar,
Settings,
HardDrive,
ExternalLink,
FolderOpen
} from 'lucide-react'
import { ExportDateRangeDialog } from '../../../../components/Export/ExportDateRangeDialog'
import {
getExportDateRangeLabel,
resolveExportDateRangeConfig,
serializeExportDateRangeConfig,
type ExportDefaultDateRangeConfig
} from '../../../../utils/exportDateRange'
import type { ExportDialogState, ExportOptions, TextExportFormat } from '../../types'
import { formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportDialog.scss'
interface ExportDialogProps {
dialogState: ExportDialogState
onClose: () => void
options: ExportOptions
onOptionsChange: (patch: Partial<ExportOptions>) => void
exportPath: string
onSelectPath: () => void
rawDateRangeConfig: ExportDefaultDateRangeConfig | string | null
onDateRangeConfigChange: (config: ExportDefaultDateRangeConfig | string | null) => void
onConfirm: (finalOptions: ExportOptions) => void
onAutomationCreate: (finalOptions: ExportOptions) => void
}
const ExportDialog: React.FC<ExportDialogProps> = ({
dialogState,
onClose,
options,
onOptionsChange,
exportPath,
onSelectPath,
rawDateRangeConfig,
onDateRangeConfigChange,
onConfirm,
onAutomationCreate
}) => {
const [isDateRangeOpen, setIsDateRangeOpen] = useState(false)
const currentSelection = React.useMemo(() => {
return resolveExportDateRangeConfig(rawDateRangeConfig)
}, [rawDateRangeConfig])
const dateRangeLabel = React.useMemo(() => {
return getExportDateRangeLabel(currentSelection)
}, [currentSelection])
if (!dialogState.open) return null
// Local state for UI toggles that might not strictly map 1:1 to config immediately
// But we'll just use the global options for now
const { intent, scope, sessionNames, title } = dialogState
const isAutomation = intent === 'automation-create'
const handleFormatSelect = (format: TextExportFormat) => {
onOptionsChange({ format })
}
const handleConfirm = () => {
if (isAutomation) {
onAutomationCreate(options)
} else {
onConfirm(options)
}
}
return (
<div className="export-dialog-overlay">
<div className="export-dialog-container">
{/* Header */}
<div className="dialog-header">
<div className="title-area">
<h2 className="title">{title}</h2>
<div className="subtitle">
{scope === 'single'
? `准备导出包含 ${sessionNames[0]} 的会话内容`
: `已选择 ${sessionNames.length} 个会话`}
{isAutomation && ' (自动化任务模板配置)'}
</div>
</div>
<button className="close-btn" onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Body */}
<div className="dialog-body">
{/* Export Path */}
<div className="config-section">
<div className="section-title">
<HardDrive size={16} /> 导出配置
</div>
<div className="config-row-group">
<div className="config-row">
<span className="row-label">时间范围</span>
<button
className="row-value-btn"
onClick={() => setIsDateRangeOpen(true)}
>
<Calendar size={14} className="icon" />
<span className="text">{dateRangeLabel}</span>
<span className="arrow">&gt;</span>
</button>
</div>
<div className="config-row">
<span className="row-label">保存路径</span>
<div className="export-path-row">
<div className="path-display-group">
<button
className="path-display-btn"
onClick={onSelectPath}
title={exportPath}
>
<FolderOpen size={16} />
<span className="path-text">
{exportPath ? formatPathBrief(exportPath, 46) : '请选择导出目录...'}
</span>
</button>
{exportPath && (
<button
className="open-folder-btn"
title="在文件管理器中打开"
onClick={() => window.electronAPI.shell.openPath(exportPath)}
>
<ExternalLink size={16} />
</button>
)}
</div>
<button className="change-path-btn" onClick={onSelectPath}>
更改
</button>
</div>
</div>
</div>
{!exportPath && <div className="error-text">请先选择一个保存路径</div>}
</div>
{/* Format Selection */}
<div className="config-section">
<div className="section-title">
<FileText size={16} /> 导出格式
</div>
<div className="format-grid">
{formatOptions.map(fmt => (
<button
key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
onClick={() => handleFormatSelect(fmt.value)}
>
<div className="format-name">{fmt.label}</div>
<div className="format-desc">{fmt.desc}</div>
</button>
))}
</div>
</div>
{/* Media Configuration */}
<div className="config-section">
<div className="section-title">
<HardDrive size={16} /> 媒体与文件附件
</div>
<div className="media-options">
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportImages}
onChange={e => onOptionsChange({ exportImages: e.target.checked })}
/>
<ImageIcon size={16} /> 导出图片
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVideos}
onChange={e => onOptionsChange({ exportVideos: e.target.checked })}
/>
<Video size={16} /> 导出视频
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVoices}
onChange={e => onOptionsChange({ exportVoices: e.target.checked })}
/>
<Mic size={16} /> 导出语音
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportFiles}
onChange={e => onOptionsChange({ exportFiles: e.target.checked })}
/>
<FileBox size={16} /> 导出文件
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportEmojis}
onChange={e => onOptionsChange({ exportEmojis: e.target.checked })}
/>
<Smile size={16} /> 导出表情包
</label>
</div>
{(options.exportVideos || options.exportFiles) && (
<div className="file-size-limit">
<span>最大文件限制 (MB):</span>
<input
type="number"
min={1}
max={MAX_EXPORT_FILE_SIZE_MB_LIMIT}
value={options.maxFileSizeMb}
onChange={e => onOptionsChange({ maxFileSizeMb: Number(e.target.value) })}
/>
</div>
)}
</div>
{/* Advanced Options */}
<div className="config-section">
<div className="section-title">
<Settings size={16} /> 高级选项
</div>
<div className="advanced-options">
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportAvatars}
onChange={e => onOptionsChange({ exportAvatars: e.target.checked })}
/>
包含联系人头像
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVoiceAsText}
onChange={e => onOptionsChange({ exportVoiceAsText: e.target.checked })}
/>
语音转文字 (若已转换)
</label>
<div className="path-style-control">
<span>媒体路径:</span>
<div className="path-style-segmented" title="控制 TXT/JSON/Excel/CSV 中写入的媒体相对路径分隔符">
{[
{ value: 'auto', label: '当前系统' },
{ value: 'posix', label: 'macOS/Linux' },
{ value: 'windows', label: 'Windows' }
].map(item => (
<button
key={item.value}
type="button"
className={options.exportPathStyle === item.value ? 'active' : ''}
onClick={() => onOptionsChange({ exportPathStyle: item.value as ExportOptions['exportPathStyle'] })}
>
{item.label}
</button>
))}
</div>
</div>
<div className="concurrency-control">
<span>导出并发数:</span>
<select
value={options.exportConcurrency}
onChange={e => onOptionsChange({ exportConcurrency: Number(e.target.value) })}
>
<option value={1}>1 (最稳定)</option>
<option value={3}>3 (推荐)</option>
<option value={5}>5 (较快)</option>
<option value={10}>10 (最快, 易卡顿)</option>
</select>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="dialog-footer">
<button className="cancel-btn" onClick={onClose}>取消</button>
<button
className="confirm-btn"
onClick={handleConfirm}
disabled={!exportPath}
>
{isAutomation ? '创建自动化任务' : '开始导出'}
</button>
</div>
</div>
<ExportDateRangeDialog
open={isDateRangeOpen}
value={currentSelection}
onClose={() => setIsDateRangeOpen(false)}
onConfirm={(nextSelection) => {
onDateRangeConfigChange(serializeExportDateRangeConfig(nextSelection))
setIsDateRangeOpen(false)
}}
/>
</div>
)
}
export default memo(ExportDialog)
@@ -0,0 +1,145 @@
.export-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-primary);
border-radius: 10px;
padding: 8px 16px;
box-shadow: 0 4px 12px color-mix(in srgb, var(--shadow-color) 40%, transparent);
border: 1px solid var(--border-color);
.top-bar-left {
display: flex;
align-items: center;
gap: 20px;
.label {
font-size: 13px;
font-weight: 600;
color: var(--text-tertiary);
margin-right: 10px;
white-space: nowrap;
}
.path-selector {
display: flex;
align-items: center;
.path-display-group {
display: flex;
align-items: center;
gap: 6px;
}
.path-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
padding: 5px 12px;
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
max-width: 240px;
.path-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
.open-folder-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
}
.layout-selector {
display: flex;
align-items: center;
.layout-pills {
display: flex;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 3px;
border: 1px solid var(--border-color);
.pill-btn {
background: transparent;
border: none;
padding: 5px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-tertiary) 15%, transparent);
}
&.active {
background: var(--primary);
color: white;
box-shadow: 0 2px 4px color-mix(in srgb, var(--primary) 40%, transparent);
}
}
}
}
}
.top-bar-right {
display: flex;
align-items: center;
.global-settings-btn {
display: flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
padding: 6px 14px;
border-radius: 6px;
color: var(--primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
background: color-mix(in srgb, var(--primary) 8%, transparent);
border-color: var(--primary);
}
}
}
}
@@ -0,0 +1,74 @@
import React, { memo } from 'react'
import { FolderOpen, Settings2, ExternalLink, Bot } from 'lucide-react'
import type { ExportWriteLayout } from '../../../../services/config'
import { writeLayoutOptions } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportTopBar.scss'
interface ExportTopBarProps {
exportPath: string
writeLayout: ExportWriteLayout
onSelectPath: () => void
onWriteLayoutChange: (layout: ExportWriteLayout) => void
onGlobalSettingsClick: () => void
}
const ExportTopBar: React.FC<ExportTopBarProps> = ({
exportPath,
writeLayout,
onSelectPath,
onWriteLayoutChange,
onGlobalSettingsClick
}) => {
return (
<div className="export-top-bar">
<div className="top-bar-left">
<div className="path-selector">
<span className="label">导出路径</span>
<div className="path-display-group">
<button className="path-btn" onClick={onSelectPath} title={exportPath || '请选择导出路径'}>
<FolderOpen size={16} />
<span className="path-text">
{exportPath ? formatPathBrief(exportPath, 40) : '请选择...'}
</span>
</button>
{exportPath && (
<button
className="open-folder-btn"
title="在文件管理器中打开"
onClick={() => window.electronAPI.shell.openPath(exportPath)}
>
<ExternalLink size={16} />
</button>
)}
</div>
</div>
<div className="layout-selector">
<span className="label">目录结构</span>
<div className="layout-pills">
{writeLayoutOptions.map(option => (
<button
key={option.value}
className={`pill-btn ${writeLayout === option.value ? 'active' : ''}`}
onClick={() => onWriteLayoutChange(option.value)}
title={option.desc}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div className="top-bar-right">
<button className="global-settings-btn" onClick={onGlobalSettingsClick}>
<Settings2 size={18} />
<span>全局配置</span>
</button>
</div>
</div>
)
}
export default memo(ExportTopBar)
@@ -0,0 +1,112 @@
.session-detail {
background: var(--bg-primary);
border-radius: 16px;
border: 1px solid var(--border-color);
padding: 20px;
box-shadow: 0 4px 24px color-mix(in srgb, var(--shadow-color) 20%, transparent);
.sd-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
.sd-avatar {
width: 64px;
height: 64px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.sd-info {
display: flex;
flex-direction: column;
gap: 4px;
.sd-name {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.sd-alias {
font-size: 13px;
color: var(--text-secondary);
}
.sd-time {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
}
.sd-stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.stat-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
cursor: pointer;
&:hover {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary));
transform: translateY(-2px);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);
}
.stat-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.message { background: #E3F2FD; color: #1976D2; }
&.image { background: #E8F5E9; color: #388E3C; }
&.voice { background: #FFF3E0; color: #F57C00; }
&.video { background: #FCE4EC; color: #C2185B; }
&.file { background: #F3E5F5; color: #7B1FA2; }
&.emoji { background: #FFF8E1; color: #FBC02D; }
}
.stat-content {
display: flex;
flex-direction: column;
gap: 2px;
.stat-label {
font-size: 12px;
color: var(--text-secondary);
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
}
}
}
}
@@ -0,0 +1,87 @@
import React, { memo, useEffect } from 'react'
import type { SessionRow } from '../../types'
import { useSessionMetrics } from '../../hooks/useSessionMetrics'
import { formatAbsoluteDate } from '../../utils/format'
import { MessageSquare, Image, Mic, Video, FileText, Smile } from 'lucide-react'
import './SessionDetail.scss'
interface SessionDetailProps {
session: SessionRow
onExportContent: (contentType: 'text' | 'image' | 'voice' | 'video' | 'file' | 'emoji') => void
}
const SessionDetail: React.FC<SessionDetailProps> = ({ session, onExportContent }) => {
const { metricsMap, fetchMetrics, isLoading } = useSessionMetrics()
useEffect(() => {
fetchMetrics([session.username])
}, [session.username, fetchMetrics])
const metrics = metricsMap[session.username]
return (
<div className="session-detail">
<div className="sd-header">
<div className="sd-avatar">
<img src={session.avatarUrl || 'file://./assets/default_avatar.png'} alt="avatar" />
</div>
<div className="sd-info">
<h2 className="sd-name">{session.displayName}</h2>
{session.remark && session.nickname && session.remark !== session.nickname && (
<span className="sd-alias">昵称: {session.nickname}</span>
)}
<span className="sd-time">
最后活跃: {session.lastTimestamp ? formatAbsoluteDate(session.lastTimestamp) : '未知'}
</span>
</div>
</div>
<div className="sd-stats-grid">
<div className="stat-card" onClick={() => onExportContent('text')}>
<div className="stat-icon message"><MessageSquare size={18} /></div>
<div className="stat-content">
<span className="stat-label">文本/总消息</span>
<span className="stat-value">{metrics?.totalMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('image')}>
<div className="stat-icon image"><Image size={18} /></div>
<div className="stat-content">
<span className="stat-label">图片</span>
<span className="stat-value">{metrics?.imageMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('voice')}>
<div className="stat-icon voice"><Mic size={18} /></div>
<div className="stat-content">
<span className="stat-label">语音</span>
<span className="stat-value">{metrics?.voiceMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('video')}>
<div className="stat-icon video"><Video size={18} /></div>
<div className="stat-content">
<span className="stat-label">视频</span>
<span className="stat-value">{metrics?.videoMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('file')}>
<div className="stat-icon file"><FileText size={18} /></div>
<div className="stat-content">
<span className="stat-label">文件</span>
<span className="stat-value">{metrics?.fileMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
<div className="stat-card" onClick={() => onExportContent('emoji')}>
<div className="stat-icon emoji"><Smile size={18} /></div>
<div className="stat-content">
<span className="stat-label">表情</span>
<span className="stat-value">{metrics?.emojiMessages ?? (isLoading ? '...' : 0)}</span>
</div>
</div>
</div>
</div>
)
}
export default memo(SessionDetail)
@@ -0,0 +1,290 @@
.session-table-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-surface, var(--bg-primary)); // Fallback
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px color-mix(in srgb, var(--shadow-color) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
// ── Top Filter (Search + Tabs inline) ──────────────────────────────────
.st-top-filter {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
flex-shrink: 0;
box-sizing: border-box;
padding: 0 24px;
background: var(--bg-surface, var(--bg-primary));
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
gap: 24px;
-webkit-app-region: no-drag;
.st-tabs {
display: flex;
gap: 8px;
.tab-btn {
background: transparent;
border: none;
padding: 6px 16px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-radius: 20px;
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover, color-mix(in srgb, var(--primary) 10%, transparent));
color: var(--text-primary);
}
&.active {
background: var(--bg-active, color-mix(in srgb, var(--primary) 15%, transparent));
color: var(--primary);
font-weight: 600;
}
}
}
.st-search {
display: flex;
align-items: center;
background: var(--bg-main, var(--bg-secondary));
border-radius: 20px;
padding: 6px 14px;
width: 280px;
transition: all 0.2s ease;
border: 1px solid transparent;
&:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
background: var(--bg-surface, var(--bg-primary));
}
.search-icon {
color: var(--text-tertiary);
margin-right: 8px;
flex-shrink: 0;
}
input {
flex: 1;
border: none;
background: transparent;
outline: none;
color: var(--text-primary);
font-size: 13px;
min-width: 0;
&::placeholder {
color: var(--text-tertiary);
}
}
.clear-search-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 50%;
margin-left: 4px;
&:hover {
color: var(--text-secondary);
}
}
}
}
// ── Grid Layout Definition ───────────────────────────────────────────
.st-header-row, .st-row {
display: grid;
grid-template-columns: 48px minmax(200px, 1fr) 80px 120px 60px 60px 60px 60px 60px 80px;
align-items: center;
gap: 8px;
padding: 0 24px;
}
// ── Header Row ──────────────────────────────────────────────────────────
.st-header-row {
height: 44px;
background: var(--bg-main, var(--bg-secondary));
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
.col-checkbox {
justify-content: flex-start;
}
> div {
display: flex;
align-items: center;
}
.col-stat, .col-count {
justify-content: flex-end;
text-align: right;
}
.col-action {
justify-content: center;
}
}
// ── List Body ───────────────────────────────────────────────────────────
.table-body {
flex: 1;
overflow: hidden; // virtuoso handles scrolling
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: var(--text-tertiary);
font-size: 14px;
}
}
// ── Data Row ────────────────────────────────────────────────────────────
.st-row {
height: 64px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 20%, transparent);
transition: background 0.15s ease;
cursor: default;
&:hover {
background: var(--bg-hover, color-mix(in srgb, var(--text-primary) 3%, transparent));
}
> div {
display: flex;
align-items: center;
font-size: 13px;
color: var(--text-primary);
}
.col-stat, .col-count {
justify-content: flex-end;
font-family: 'Inter', system-ui, sans-serif;
color: var(--text-secondary);
}
.col-count {
font-weight: 500;
color: var(--text-primary);
}
.col-time {
color: var(--text-tertiary);
font-size: 12px;
}
.col-action {
justify-content: center;
.st-action-btn {
background: transparent;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
border-color: var(--primary);
}
}
}
// Avatar and info
.col-info {
gap: 12px;
overflow: hidden;
.st-avatar {
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary) 10%, transparent);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-letter {
font-size: 18px;
color: var(--primary);
font-weight: 600;
}
}
.st-text {
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
flex: 1;
.st-name {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.st-id {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
}
}
}
// ── Checkbox ──────────────────────────────────────────────────────────
.checkbox-btn {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
&:hover {
color: var(--text-secondary);
}
.checked {
color: var(--primary);
}
}
}
@@ -0,0 +1,231 @@
import React, { memo, useRef, useCallback } from 'react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { Search, Hash, Clock, MoreVertical, X, CheckSquare, Square } from 'lucide-react'
import type { SessionRow, ConversationTab, ContactsSortConfig } from '../../types'
import type { SessionContentMetric } from '../../hooks/useSessionMetrics'
import { conversationTabLabels, exportKindPriority } from '../../constants'
import { getAvatarLetter } from '../../utils/format'
import { formatLatestMessageTimeFromSeconds } from '../../utils/format'
import './SessionTable.scss'
interface SessionTableProps {
sessions: SessionRow[]
activeTab: ConversationTab
searchQuery: string
sortConfig: ContactsSortConfig
selectedSessionIds: Set<string>
onTabChange: (tab: ConversationTab) => void
onSearchChange: (query: string) => void
onSortChange: (config: ContactsSortConfig) => void
onSelectionChange: (selectedIds: Set<string>) => void
onSingleExport?: (sessionId: string) => void
onBatchExport?: () => void
onAutomationExport?: () => void
isLoading?: boolean
metricsMap?: Record<string, SessionContentMetric>
}
const SessionTable: React.FC<SessionTableProps> = ({
sessions,
activeTab,
searchQuery,
sortConfig,
selectedSessionIds,
onTabChange,
onSearchChange,
onSortChange,
onSelectionChange,
onSingleExport,
onBatchExport,
onAutomationExport,
isLoading,
metricsMap
}) => {
const virtuosoRef = useRef<VirtuosoHandle>(null)
const handleSearchClear = () => onSearchChange('')
const handleSortClick = (key: ContactsSortConfig['key']) => {
if (sortConfig.key === key) {
if (sortConfig.order === 'desc') {
onSortChange({ key, order: 'asc' })
} else {
onSortChange({ key, order: 'desc' })
}
} else {
onSortChange({ key, order: 'desc' })
}
}
const handleSelectAll = () => {
if (selectedSessionIds.size === sessions.length && sessions.length > 0) {
onSelectionChange(new Set())
} else {
onSelectionChange(new Set(sessions.map(s => s.username)))
}
}
const toggleSelection = useCallback((username: string) => {
const next = new Set(selectedSessionIds)
if (next.has(username)) {
next.delete(username)
} else {
next.add(username)
}
onSelectionChange(next)
}, [selectedSessionIds, onSelectionChange])
// Tabs layout
const tabs: ConversationTab[] = ['private', 'group', 'official', 'former_friend']
return (
<div className="session-table-container">
{/* ─── Search & Tabs (Top Filter) ─────────────────────────────────────────── */}
<div className="st-top-filter">
<div className="st-tabs">
{tabs.map((tab) => (
<button
key={tab}
className={`tab-btn ${activeTab === tab ? 'active' : ''}`}
onClick={() => onTabChange(tab)}
>
{conversationTabLabels[tab]}
</button>
))}
</div>
{selectedSessionIds.size > 0 && (
<div className="st-selection-actions" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px' }}>
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>已选 {selectedSessionIds.size} 项</span>
{onBatchExport && (
<button className="primary-btn" onClick={onBatchExport} style={{ height: '28px', padding: '0 16px', borderRadius: '14px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '13px' }}>
批量导出
</button>
)}
{onAutomationExport && (
<button className="secondary-btn" onClick={onAutomationExport} style={{ height: '28px', padding: '0 16px', borderRadius: '14px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '13px' }}>
创建自动化
</button>
)}
</div>
)}
<div className="st-search" style={{ marginLeft: selectedSessionIds.size > 0 ? '0' : 'auto' }}>
<Search size={16} className="search-icon" />
<input
type="text"
placeholder="搜索联系人、群组、微信号或备注..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchQuery && (
<button className="clear-search-btn" onClick={handleSearchClear}>
<X size={16} />
</button>
)}
</div>
</div>
{/* ─── Table Columns Header ─────────────────────────────────────── */}
<div className="st-header-row">
<div className="col-checkbox">
<button className="checkbox-btn" onClick={handleSelectAll}>
{selectedSessionIds.size === sessions.length && sessions.length > 0 ? (
<CheckSquare size={18} className="checked" />
) : (
<Square size={18} />
)}
</button>
</div>
<div className="col-info">会话 / 联系人</div>
<div className="col-count sortable" onClick={() => handleSortClick('messageCount')} style={{ cursor: 'pointer', display: 'flex', gap: '4px', userSelect: 'none' }}>
总消息数
{sortConfig.key === 'messageCount' && (
<span className="sort-icon">{sortConfig.order === 'desc' ? '↓' : '↑'}</span>
)}
</div>
<div className="col-time sortable" onClick={() => handleSortClick('latestMessageTime')} style={{ cursor: 'pointer', display: 'flex', gap: '4px', userSelect: 'none' }}>
最新消息时间
{sortConfig.key === 'latestMessageTime' && (
<span className="sort-icon">{sortConfig.order === 'desc' ? '↓' : '↑'}</span>
)}
</div>
<div className="col-stat">表情包</div>
<div className="col-stat">语音</div>
<div className="col-stat">图片</div>
<div className="col-stat">视频</div>
<div className="col-stat">文件</div>
<div className="col-action">操作</div>
</div>
{/* ─── Virtualized List ─────────────────────────────────────────── */}
<div className="table-body">
{sessions.length === 0 ? (
<div className="empty-state">
{isLoading ? '加载中...' : (searchQuery ? '没有匹配的会话' : '此分类下没有会话')}
</div>
) : (
<Virtuoso
ref={virtuosoRef}
data={sessions}
itemContent={(index, session) => {
if (!session) return null
const metrics = metricsMap?.[session.username]
const totalCount = metrics?.totalMessages ?? session.messageCountHint ?? 0
return (
<div className="st-row" key={session.username}>
<div className="col-checkbox">
<button className="checkbox-btn" onClick={() => toggleSelection(session.username)}>
{selectedSessionIds.has(session.username) ? (
<CheckSquare size={18} className="checked" />
) : (
<Square size={18} />
)}
</button>
</div>
<div className="col-info">
<div className="st-avatar">
{session.avatarUrl ? (
<img src={session.avatarUrl} alt="avatar" />
) : (
<span className="avatar-letter">{getAvatarLetter(session.displayName || session.username)}</span>
)}
</div>
<div className="st-text">
<span className="st-name" title={session.displayName || session.username}>{session.displayName || session.username}</span>
<span className="st-id" title={session.username}>{session.username}</span>
</div>
</div>
<div className="col-count">{totalCount.toLocaleString()}</div>
<div className="col-time">
{session.lastTimestamp ? formatLatestMessageTimeFromSeconds(session.lastTimestamp).text : '-'}
</div>
<div className="col-stat">{metrics?.emojiMessages ?? '-'}</div>
<div className="col-stat">{metrics?.voiceMessages ?? '-'}</div>
<div className="col-stat">{metrics?.imageMessages ?? '-'}</div>
<div className="col-stat">{metrics?.videoMessages ?? '-'}</div>
<div className="col-stat">{metrics?.fileMessages ?? '-'}</div>
<div className="col-action">
<button
className="st-action-btn"
onClick={(e) => {
e.stopPropagation()
onSingleExport?.(session.username)
}}
>
导出
</button>
</div>
</div>
)
}}
/>
)}
</div>
</div>
)
}
export default memo(SessionTable)
@@ -0,0 +1,243 @@
.task-center {
background: var(--bg-surface, var(--bg-primary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
.task-center-header {
display: flex;
justify-content: space-between;
align-items: center;
.title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.clear-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-tertiary);
background: transparent;
border: 1px solid transparent;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
}
}
}
.task-list {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding-right: 4px;
// Custom scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
}
.task-card {
background: var(--bg-main, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
transition: all 0.2s ease;
&:hover {
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.task-card-header {
display: flex;
justify-content: space-between;
align-items: center;
.task-info {
display: flex;
align-items: center;
gap: 10px;
.task-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.task-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
color: var(--text-secondary);
.spin {
animation: spin 1s linear infinite;
}
}
}
.task-actions {
display: flex;
align-items: center;
gap: 6px;
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: transparent;
border: 1px solid transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-surface, var(--bg-primary));
color: var(--text-primary);
border-color: var(--border-color);
}
&.cancel {
&:hover {
color: #ef4444;
border-color: color-mix(in srgb, #ef4444 30%, var(--border-color));
background: color-mix(in srgb, #ef4444 10%, transparent);
}
}
}
}
}
.task-progress-area {
display: flex;
align-items: center;
gap: 12px;
.progress-bar-bg {
flex: 1;
height: 4px;
background: color-mix(in srgb, var(--border-color) 60%, var(--bg-surface));
border-radius: 2px;
overflow: hidden;
.progress-bar-fill {
height: 100%;
background: var(--primary);
border-radius: 2px;
transition: width 0.3s ease;
}
}
.progress-stats {
display: flex;
align-items: center;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
min-width: 80px;
justify-content: flex-end;
}
}
.task-error-msg {
font-size: 11px;
color: #ef4444;
background: color-mix(in srgb, #ef4444 10%, transparent);
padding: 6px 10px;
border-radius: 4px;
margin-top: 4px;
word-break: break-all;
}
/* Status specific styles */
&.status-running {
.task-status-badge {
background: color-mix(in srgb, var(--primary) 15%, transparent);
color: var(--primary);
}
}
&.status-success {
border-color: color-mix(in srgb, #10b981 30%, var(--border-color));
.task-status-badge {
background: color-mix(in srgb, #10b981 15%, transparent);
color: #10b981;
}
.progress-bar-fill {
background: #10b981;
}
}
&.status-error {
border-color: color-mix(in srgb, #ef4444 30%, var(--border-color));
.task-status-badge {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #ef4444;
}
.progress-bar-fill {
background: #ef4444;
}
}
}
.bg-tasks-section {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.bg-task {
.task-progress-text {
font-size: 12px;
color: var(--text-secondary);
}
}
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -0,0 +1,148 @@
import React, { memo } from 'react'
import { CheckCircle2, XCircle, Loader2, PlayCircle, PauseCircle, Trash2, StopCircle } from 'lucide-react'
import type { ExportTask } from '../../types'
import type { BackgroundTaskRecord } from '../../../../types/backgroundTask'
import {
backgroundTaskSourceLabels,
backgroundTaskStatusLabels
} from '../../constants'
import { formatDurationMs } from '../../utils/format'
import './TaskCenter.scss'
interface TaskCenterProps {
exportTasks: ExportTask[]
onCancelExportTask: (taskId: string) => void
onClearCompletedExportTasks: () => void
backgroundTasks: BackgroundTaskRecord[]
onPauseBackgroundTask: (taskId: string) => void
onResumeBackgroundTask: (taskId: string) => void
onCancelBackgroundTask: (taskId: string) => void
}
const TaskCenter: React.FC<TaskCenterProps> = ({
exportTasks,
onCancelExportTask,
onClearCompletedExportTasks,
backgroundTasks,
onPauseBackgroundTask,
onResumeBackgroundTask,
onCancelBackgroundTask
}) => {
const hasTasks = exportTasks.length > 0 || backgroundTasks.length > 0
if (!hasTasks) return null
const activeExportTasks = exportTasks.filter(t => t.status === 'running' || t.status === 'cancel_requested')
const completedExportTasks = exportTasks.filter(t => t.status === 'success' || t.status === 'error')
return (
<div className="task-center">
<div className="task-center-header">
<h2 className="title">任务中心</h2>
{completedExportTasks.length > 0 && (
<button className="clear-btn" onClick={onClearCompletedExportTasks}>
<Trash2 size={14} /> 清理已完成
</button>
)}
</div>
<div className="task-list">
{/* Export Tasks */}
{exportTasks.map(task => {
const hasMessageProgress = Number(task.progress.phaseTotal) > 0
const phaseLabel = task.progress.phaseLabel || task.progress.phase
const labelAlreadyHasCount = hasMessageProgress && /\d[\d,]*\s*\/\s*\d[\d,]*\s*条/.test(phaseLabel)
const countText = hasMessageProgress
? `${Math.max(0, Math.floor(Number(task.progress.phaseProgress) || 0)).toLocaleString()} / ${Math.max(0, Math.floor(Number(task.progress.phaseTotal) || 0)).toLocaleString()} 条`
: `${Math.max(0, Math.floor(Number(task.progress.current) || 0))} / ${Math.max(0, Math.floor(Number(task.progress.total) || 0))}`
return (
<div key={task.id} className={`task-card export-task status-${task.status}`}>
<div className="task-card-header">
<div className="task-info">
<span className="task-title">{task.title}</span>
<span className="task-status-badge">
{task.status === 'running' && <Loader2 className="spin" size={12} />}
{task.status === 'success' && <CheckCircle2 size={12} />}
{task.status === 'error' && <XCircle size={12} />}
{task.status === 'cancel_requested' && <Loader2 className="spin" size={12} />}
{task.status === 'running' ? '导出中' : task.status === 'success' ? '已完成' : task.status === 'cancel_requested' ? '取消中' : '失败'}
</span>
</div>
<div className="task-actions">
{task.status === 'running' && (
<button className="action-btn cancel" onClick={() => onCancelExportTask(task.id)} title="取消任务">
<StopCircle size={16} />
</button>
)}
</div>
</div>
<div className="task-progress-area">
<div className="progress-bar-bg">
<div
className="progress-bar-fill"
style={{ width: `${Math.max(0, Math.min(100, (task.progress.current / Math.max(1, task.progress.total)) * 100))}%` }}
/>
</div>
<div className="progress-stats">
<span className="phase">{phaseLabel}</span>
{!labelAlreadyHasCount && <span className="count">{countText}</span>}
</div>
{task.error && <div className="task-error-msg">{task.error}</div>}
</div>
</div>
)
})}
{/* Global Background Tasks */}
{backgroundTasks.length > 0 && (
<div className="bg-tasks-section">
<h3 className="section-title">全局后台任务</h3>
{backgroundTasks.map(bgTask => {
const isPausable = bgTask.status === 'running'
const isResumable = bgTask.status === 'paused'
const isCancelable = bgTask.status !== 'completed' && bgTask.status !== 'canceled' && bgTask.status !== 'failed'
return (
<div key={bgTask.id} className={`task-card bg-task status-${bgTask.status}`}>
<div className="task-card-header">
<div className="task-info">
<span className="task-title">{backgroundTaskSourceLabels[bgTask.sourcePage] || '后台任务'}</span>
<span className="task-status-badge">{backgroundTaskStatusLabels[bgTask.status]}</span>
</div>
<div className="task-actions">
{isPausable && (
<button className="action-btn" onClick={() => onPauseBackgroundTask(bgTask.id)} title="暂停">
<PauseCircle size={16} />
</button>
)}
{isResumable && (
<button className="action-btn" onClick={() => onResumeBackgroundTask(bgTask.id)} title="恢复">
<PlayCircle size={16} />
</button>
)}
{isCancelable && (
<button className="action-btn cancel" onClick={() => onCancelBackgroundTask(bgTask.id)} title="取消">
<StopCircle size={16} />
</button>
)}
</div>
</div>
{bgTask.progressText && (
<div className="task-progress-text">
{bgTask.progressText}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
}
export default memo(TaskCenter)