全新出发
This commit is contained in:
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">></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)
|
||||
Reference in new issue
Block a user