全新出发

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

No files matched your search

@@ -0,0 +1,454 @@
.export-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: color-mix(in srgb, var(--bg-primary) 60%, transparent);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
.export-dialog-container {
background: var(--bg-primary);
border-radius: 16px;
width: 100%;
max-width: 680px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 12px 48px color-mix(in srgb, var(--shadow-color) 40%, transparent),
0 0 0 1px color-mix(in srgb, var(--border-color) 80%, transparent);
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
// Header
.dialog-header {
padding: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--border-color);
.title-area {
.title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.subtitle {
font-size: 13px;
color: var(--text-tertiary);
}
}
.close-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
padding: 4px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
// Body
.dialog-body {
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 32px;
.config-section {
display: flex;
flex-direction: column;
gap: 12px;
.config-row-group {
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-surface, var(--bg-primary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
.config-row {
display: flex;
align-items: center;
gap: 16px;
.row-label {
width: 72px;
flex-shrink: 0;
font-size: 13px;
color: var(--text-secondary);
}
.row-value-btn {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-main, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
gap: 8px;
.icon {
color: var(--primary);
}
.text {
flex: 1;
text-align: left;
font-size: 13px;
color: var(--text-primary);
}
.arrow {
color: var(--text-tertiary);
font-size: 12px;
}
&:hover {
border-color: var(--primary);
background: var(--bg-surface, var(--bg-primary));
}
}
}
}
.export-path-row {
flex: 1;
display: flex;
gap: 8px;
align-items: center;
.path-display-group {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
.path-display-btn {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-tertiary, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 5px 12px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
min-width: 0;
.path-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
.open-folder-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex-shrink: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
}
}
}
.change-path-btn {
display: flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 16px;
background: var(--bg-surface, var(--bg-primary));
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--bg-main, var(--bg-secondary));
border-color: var(--text-tertiary);
}
}
}
.error-text {
color: #ef4444;
font-size: 12px;
margin-top: -4px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 16px;
svg {
color: var(--primary);
}
}
.format-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.format-card {
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 16px;
text-align: left;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
.format-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.format-desc {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
&:hover {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
background: var(--bg-hover);
}
&.active {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, transparent);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 15%, transparent);
.format-name {
color: var(--primary);
}
}
}
}
.media-options, .advanced-options {
display: flex;
flex-wrap: wrap;
gap: 16px;
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
transition: all 0.2s ease;
&:hover {
background: var(--bg-hover);
}
input[type="checkbox"] {
accent-color: var(--primary);
width: 16px;
height: 16px;
cursor: pointer;
}
svg {
color: var(--text-tertiary);
}
&:has(input:checked) {
border-color: color-mix(in srgb, var(--primary) 50%, transparent);
background: color-mix(in srgb, var(--primary) 4%, transparent);
color: var(--primary);
svg {
color: var(--primary);
}
}
}
}
.file-size-limit, .concurrency-control, .path-style-control {
margin-top: 16px;
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text-tertiary);
input, select {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color 0.2s ease;
&:focus {
border-color: var(--primary);
}
}
input[type="number"] {
width: 80px;
}
}
.path-style-control {
flex: 1 1 100%;
margin-top: 4px;
> span {
flex: 0 0 auto;
}
.path-style-segmented {
display: inline-flex;
min-height: 34px;
padding: 3px;
gap: 3px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
button {
min-width: 82px;
border: 0;
border-radius: 6px;
padding: 6px 10px;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 28%, transparent);
}
}
}
}
}
}
// Footer
.dialog-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
background: var(--bg-secondary);
button {
padding: 8px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.cancel-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
&.confirm-btn {
background: var(--primary);
border: 1px solid var(--primary);
color: white;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent);
&:hover {
background: color-mix(in srgb, var(--primary) 90%, black);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 40%, transparent);
}
}
}
}
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@@ -0,0 +1,327 @@
import React, { memo, useState, useEffect } from 'react'
import {
X,
FileText,
Image as ImageIcon,
Video,
Mic,
FileBox,
Smile,
Calendar,
Settings,
HardDrive,
ExternalLink,
FolderOpen
} from 'lucide-react'
import { ExportDateRangeDialog } from '../../../../components/Export/ExportDateRangeDialog'
import {
getExportDateRangeLabel,
resolveExportDateRangeConfig,
serializeExportDateRangeConfig,
type ExportDefaultDateRangeConfig
} from '../../../../utils/exportDateRange'
import type { ExportDialogState, ExportOptions, TextExportFormat } from '../../types'
import { formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportDialog.scss'
interface ExportDialogProps {
dialogState: ExportDialogState
onClose: () => void
options: ExportOptions
onOptionsChange: (patch: Partial<ExportOptions>) => void
exportPath: string
onSelectPath: () => void
rawDateRangeConfig: ExportDefaultDateRangeConfig | string | null
onDateRangeConfigChange: (config: ExportDefaultDateRangeConfig | string | null) => void
onConfirm: (finalOptions: ExportOptions) => void
onAutomationCreate: (finalOptions: ExportOptions) => void
}
const ExportDialog: React.FC<ExportDialogProps> = ({
dialogState,
onClose,
options,
onOptionsChange,
exportPath,
onSelectPath,
rawDateRangeConfig,
onDateRangeConfigChange,
onConfirm,
onAutomationCreate
}) => {
const [isDateRangeOpen, setIsDateRangeOpen] = useState(false)
const currentSelection = React.useMemo(() => {
return resolveExportDateRangeConfig(rawDateRangeConfig)
}, [rawDateRangeConfig])
const dateRangeLabel = React.useMemo(() => {
return getExportDateRangeLabel(currentSelection)
}, [currentSelection])
if (!dialogState.open) return null
// Local state for UI toggles that might not strictly map 1:1 to config immediately
// But we'll just use the global options for now
const { intent, scope, sessionNames, title } = dialogState
const isAutomation = intent === 'automation-create'
const handleFormatSelect = (format: TextExportFormat) => {
onOptionsChange({ format })
}
const handleConfirm = () => {
if (isAutomation) {
onAutomationCreate(options)
} else {
onConfirm(options)
}
}
return (
<div className="export-dialog-overlay">
<div className="export-dialog-container">
{/* Header */}
<div className="dialog-header">
<div className="title-area">
<h2 className="title">{title}</h2>
<div className="subtitle">
{scope === 'single'
? `准备导出包含 ${sessionNames[0]} 的会话内容`
: `已选择 ${sessionNames.length} 个会话`}
{isAutomation && ' (自动化任务模板配置)'}
</div>
</div>
<button className="close-btn" onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Body */}
<div className="dialog-body">
{/* Export Path */}
<div className="config-section">
<div className="section-title">
<HardDrive size={16} /> 导出配置
</div>
<div className="config-row-group">
<div className="config-row">
<span className="row-label">时间范围</span>
<button
className="row-value-btn"
onClick={() => setIsDateRangeOpen(true)}
>
<Calendar size={14} className="icon" />
<span className="text">{dateRangeLabel}</span>
<span className="arrow">&gt;</span>
</button>
</div>
<div className="config-row">
<span className="row-label">保存路径</span>
<div className="export-path-row">
<div className="path-display-group">
<button
className="path-display-btn"
onClick={onSelectPath}
title={exportPath}
>
<FolderOpen size={16} />
<span className="path-text">
{exportPath ? formatPathBrief(exportPath, 46) : '请选择导出目录...'}
</span>
</button>
{exportPath && (
<button
className="open-folder-btn"
title="在文件管理器中打开"
onClick={() => window.electronAPI.shell.openPath(exportPath)}
>
<ExternalLink size={16} />
</button>
)}
</div>
<button className="change-path-btn" onClick={onSelectPath}>
更改
</button>
</div>
</div>
</div>
{!exportPath && <div className="error-text">请先选择一个保存路径</div>}
</div>
{/* Format Selection */}
<div className="config-section">
<div className="section-title">
<FileText size={16} /> 导出格式
</div>
<div className="format-grid">
{formatOptions.map(fmt => (
<button
key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
onClick={() => handleFormatSelect(fmt.value)}
>
<div className="format-name">{fmt.label}</div>
<div className="format-desc">{fmt.desc}</div>
</button>
))}
</div>
</div>
{/* Media Configuration */}
<div className="config-section">
<div className="section-title">
<HardDrive size={16} /> 媒体与文件附件
</div>
<div className="media-options">
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportImages}
onChange={e => onOptionsChange({ exportImages: e.target.checked })}
/>
<ImageIcon size={16} /> 导出图片
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVideos}
onChange={e => onOptionsChange({ exportVideos: e.target.checked })}
/>
<Video size={16} /> 导出视频
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVoices}
onChange={e => onOptionsChange({ exportVoices: e.target.checked })}
/>
<Mic size={16} /> 导出语音
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportFiles}
onChange={e => onOptionsChange({ exportFiles: e.target.checked })}
/>
<FileBox size={16} /> 导出文件
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportEmojis}
onChange={e => onOptionsChange({ exportEmojis: e.target.checked })}
/>
<Smile size={16} /> 导出表情包
</label>
</div>
{(options.exportVideos || options.exportFiles) && (
<div className="file-size-limit">
<span>最大文件限制 (MB):</span>
<input
type="number"
min={1}
max={MAX_EXPORT_FILE_SIZE_MB_LIMIT}
value={options.maxFileSizeMb}
onChange={e => onOptionsChange({ maxFileSizeMb: Number(e.target.value) })}
/>
</div>
)}
</div>
{/* Advanced Options */}
<div className="config-section">
<div className="section-title">
<Settings size={16} /> 高级选项
</div>
<div className="advanced-options">
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportAvatars}
onChange={e => onOptionsChange({ exportAvatars: e.target.checked })}
/>
包含联系人头像
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={options.exportVoiceAsText}
onChange={e => onOptionsChange({ exportVoiceAsText: e.target.checked })}
/>
语音转文字 (若已转换)
</label>
<div className="path-style-control">
<span>媒体路径:</span>
<div className="path-style-segmented" title="控制 TXT/JSON/Excel/CSV 中写入的媒体相对路径分隔符">
{[
{ value: 'auto', label: '当前系统' },
{ value: 'posix', label: 'macOS/Linux' },
{ value: 'windows', label: 'Windows' }
].map(item => (
<button
key={item.value}
type="button"
className={options.exportPathStyle === item.value ? 'active' : ''}
onClick={() => onOptionsChange({ exportPathStyle: item.value as ExportOptions['exportPathStyle'] })}
>
{item.label}
</button>
))}
</div>
</div>
<div className="concurrency-control">
<span>导出并发数:</span>
<select
value={options.exportConcurrency}
onChange={e => onOptionsChange({ exportConcurrency: Number(e.target.value) })}
>
<option value={1}>1 (最稳定)</option>
<option value={3}>3 (推荐)</option>
<option value={5}>5 (较快)</option>
<option value={10}>10 (最快, 易卡顿)</option>
</select>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="dialog-footer">
<button className="cancel-btn" onClick={onClose}>取消</button>
<button
className="confirm-btn"
onClick={handleConfirm}
disabled={!exportPath}
>
{isAutomation ? '创建自动化任务' : '开始导出'}
</button>
</div>
</div>
<ExportDateRangeDialog
open={isDateRangeOpen}
value={currentSelection}
onClose={() => setIsDateRangeOpen(false)}
onConfirm={(nextSelection) => {
onDateRangeConfigChange(serializeExportDateRangeConfig(nextSelection))
setIsDateRangeOpen(false)
}}
/>
</div>
)
}
export default memo(ExportDialog)