交互细节修复与代码修复

This commit is contained in:
cc
2026-04-05 10:57:49 +08:00
parent 4fc0a92651
commit f00525d21a
18 changed files with 1611 additions and 439 deletions

View File

@@ -12,6 +12,7 @@ import {
Database,
Download,
ExternalLink,
File as FileIcon,
FolderOpen,
Hash,
Image as ImageIcon,
@@ -201,6 +202,7 @@ const contentTypeLabels: Record<ContentType, string> = {
emoji: '表情包',
file: '文件'
}
const FILE_SIZE_PRESETS_MB = [0, 100, 200, 500, 1024] as const
const backgroundTaskSourceLabels: Record<string, string> = {
export: '导出页',
@@ -1210,16 +1212,18 @@ const WriteLayoutSelector = memo(function WriteLayoutSelector({
const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A类型分目录'
return (
<div className="write-layout-control" ref={containerRef}>
<div className={`write-layout-control ${isOpen ? 'open' : ''}`} ref={containerRef}>
<span className="control-label"></span>
<button
className={`layout-trigger ${isOpen ? 'active' : ''}`}
type="button"
onClick={() => setIsOpen(prev => !prev)}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
{writeLayoutLabel}
</button>
<div className={`layout-dropdown ${isOpen ? 'open' : ''}`}>
<div className={`layout-dropdown ${isOpen ? 'open' : ''}`} role="listbox" aria-label="写入目录方式">
{writeLayoutOptions.map(option => (
<button
key={option.value}
@@ -1336,7 +1340,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
}: TaskCenterModalProps) {
if (!isOpen) return null
return (
return createPortal(
<div
className="task-center-modal-overlay"
onClick={onClose}
@@ -1533,7 +1537,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
)}
</div>
</div>
</div>
</div>,
document.body
)
})
@@ -6491,6 +6496,10 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog
const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
isSessionScopeDialog ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
@@ -6500,6 +6509,80 @@ function ExportPage() {
const activeDialogFormatLabel = exportDialog.scope === 'sns'
? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat)
: (formatOptions.find(option => option.value === options.format)?.label ?? options.format)
const sessionMediaOptions = [
{
key: 'images',
label: '图片',
desc: '聊天图片与缩略图',
icon: ImageIcon,
checked: options.exportImages,
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportImages: checked }))
},
{
key: 'voices',
label: '语音',
desc: '语音消息文件',
icon: Mic,
checked: options.exportVoices,
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportVoices: checked }))
},
{
key: 'videos',
label: '视频',
desc: '聊天视频与封面',
icon: Video,
checked: options.exportVideos,
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportVideos: checked }))
},
{
key: 'emojis',
label: '表情包',
desc: '静态与动态表情',
icon: MessageSquare,
checked: options.exportEmojis,
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportEmojis: checked }))
},
{
key: 'files',
label: '文件',
desc: '文档与附件',
icon: FileIcon,
checked: options.exportFiles,
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportFiles: checked }))
}
]
const snsMediaOptions = [
{
key: 'images',
label: '图片',
desc: '朋友圈图片',
icon: ImageIcon,
checked: snsExportImages,
onToggle: (checked: boolean) => setSnsExportImages(checked)
},
{
key: 'live-photos',
label: '实况图',
desc: 'Live Photo',
icon: Aperture,
checked: snsExportLivePhotos,
onToggle: (checked: boolean) => setSnsExportLivePhotos(checked)
},
{
key: 'videos',
label: '视频',
desc: '朋友圈视频',
icon: Video,
checked: snsExportVideos,
onToggle: (checked: boolean) => setSnsExportVideos(checked)
}
]
const dialogMediaOptions = exportDialog.scope === 'sns' ? snsMediaOptions : sessionMediaOptions
const mediaSelectionSummaryLabel = `已选择 ${dialogMediaOptions.filter(option => option.checked).length}/${dialogMediaOptions.length}`
const voiceAsTextStatusLabel = options.exportVoices
? '已勾选导出语音:会同时导出语音文件,并在文本中追加语音转写结果。'
: '未勾选导出语音时,仅在文本里追加语音转写结果,不导出语音文件。'
const fileSizeLimitLabel = options.maxFileSizeMb <= 0 ? '不限' : `${options.maxFileSizeMb} MB`
const shouldShowDisplayNameSection = !(
exportDialog.scope === 'sns' ||
(
@@ -6518,8 +6601,9 @@ function ExportPage() {
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
const hasFilteredContacts = filteredContacts.length > 0
const CONTACTS_ACTION_STICKY_WIDTH = 184
const contactsTableMinWidth = useMemo(() => {
const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12)
const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + CONTACTS_ACTION_STICKY_WIDTH + (8 * 12)
const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0
const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0
return baseWidth + snsWidth + mutualFriendsWidth
@@ -6710,7 +6794,7 @@ function ExportPage() {
const toggleTaskPerfDetail = useCallback((taskId: string) => {
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
}, [])
const renderContactRow = useCallback((_: number, contact: ContactInfo) => {
const renderContactRow = useCallback((index: number, contact: ContactInfo) => {
const matchedSession = sessionRowByUsername.get(contact.username)
const canExport = Boolean(matchedSession?.hasSession)
const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching)
@@ -6776,8 +6860,20 @@ function ExportPage() {
: contact.type === 'group'
? '打开群聊'
: '打开对话'
const previousContact = index > 0 ? filteredContacts[index - 1] : null
const nextContact = index < filteredContacts.length - 1 ? filteredContacts[index + 1] : null
const previousCanExport = Boolean(previousContact && sessionRowByUsername.get(previousContact.username)?.hasSession)
const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession)
const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username))
const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username))
const rowClassName = [
'contact-row',
checked ? 'selected' : '',
checked && previousSelected ? 'selected-contiguous-top' : '',
checked && nextSelected ? 'selected-contiguous-bottom' : ''
].filter(Boolean).join(' ')
return (
<div className={`contact-row ${checked ? 'selected' : ''}`}>
<div className={rowClassName}>
<div className="contact-item">
<div className="row-left-sticky">
<div className="row-select-cell">
@@ -6926,6 +7022,7 @@ function ExportPage() {
</div>
)
}, [
filteredContacts,
lastExportBySession,
navigate,
nowTick,
@@ -7095,7 +7192,7 @@ function ExportPage() {
onTogglePerfTask={toggleTaskPerfDetail}
/>
{isExportDefaultsModalOpen && (
{isExportDefaultsModalOpen && createPortal(
<div
className="export-defaults-modal-overlay"
onClick={() => setIsExportDefaultsModalOpen(false)}
@@ -7133,7 +7230,8 @@ function ExportPage() {
</button>
</div>
</div>
</div>
</div>,
document.body
)}
<div className="export-section-title-row">
@@ -7218,7 +7316,7 @@ function ExportPage() {
]}
/>
<button
className={`session-load-detail-entry ${isSessionLoadDetailActive ? 'active' : ''}`}
className={`session-load-detail-entry ${showSessionLoadDetailModal ? 'open' : ''} ${isSessionLoadDetailActive && !showSessionLoadDetailModal ? 'active' : ''}`.trim()}
type="button"
onClick={() => setShowSessionLoadDetailModal(true)}
>
@@ -7428,7 +7526,7 @@ function ExportPage() {
)}
</div>
{showSessionLoadDetailModal && (
{showSessionLoadDetailModal && createPortal(
<div
className="session-load-detail-overlay"
onClick={() => setShowSessionLoadDetailModal(false)}
@@ -7663,10 +7761,11 @@ function ExportPage() {
</section>
</div>
</div>
</div>
</div>,
document.body
)}
{sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && (
{sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && createPortal(
<div
className="session-mutual-friends-overlay"
onClick={closeSessionMutualFriendsDialog}
@@ -7749,10 +7848,11 @@ function ExportPage() {
)}
</div>
</div>
</div>
</div>,
document.body
)}
{showSessionDetailPanel && (
{showSessionDetailPanel && createPortal(
<div
className="export-session-detail-overlay"
onClick={closeSessionDetailPanel}
@@ -7854,19 +7954,15 @@ function ExportPage() {
<div className="detail-record-list">
{currentSessionExportRecords.map((record, index) => (
<div className="detail-record-item" key={`${record.exportTime}-${record.content}-${index}`}>
<div className="record-row">
<span className="label"></span>
<span className="value">{formatYmdHmDateTime(record.exportTime)}</span>
<div className="detail-record-head">
<span className="record-export-time">{formatYmdHmDateTime(record.exportTime)}</span>
<span className="record-content-pill" title={record.content}>{record.content}</span>
</div>
<div className="record-row">
<span className="label"></span>
<span className="value">{record.content}</span>
</div>
<div className="record-row">
<span className="label"></span>
<span className="value path" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span>
<div className="detail-record-path-row">
<span className="path-label"></span>
<span className="path-value" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span>
<button
className="detail-inline-btn"
className="detail-inline-btn detail-record-open-btn"
type="button"
onClick={() => void window.electronAPI.shell.openPath(record.outputDir)}
>
@@ -7882,7 +7978,7 @@ function ExportPage() {
<div className="detail-section">
<div className="section-title">
<MessageSquare size={14} />
<span></span>
<span></span>
</div>
<div className="detail-stats-meta">
{isRefreshingSessionDetailStats
@@ -8065,7 +8161,8 @@ function ExportPage() {
<div className="detail-empty"></div>
)}
</aside>
</div>
</div>,
document.body
)}
<ContactSnsTimelineDialog
@@ -8192,66 +8289,103 @@ function ExportPage() {
{shouldShowMediaSection && (
<div className="dialog-section">
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4>
<div className="media-check-grid">
{exportDialog.scope === 'sns' ? (
<>
<label><input type="checkbox" checked={snsExportImages} onChange={event => setSnsExportImages(event.target.checked)} /> </label>
<label><input type="checkbox" checked={snsExportLivePhotos} onChange={event => setSnsExportLivePhotos(event.target.checked)} /> </label>
<label><input type="checkbox" checked={snsExportVideos} onChange={event => setSnsExportVideos(event.target.checked)} /> </label>
</>
) : (
<>
<label><input type="checkbox" checked={options.exportImages} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportFiles} onChange={event => setOptions(prev => ({ ...prev, exportFiles: event.target.checked }))} /> </label>
</>
)}
<div className="section-header-action media-section-header">
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4>
<span className="media-selection-pill">{mediaSelectionSummaryLabel}</span>
</div>
{exportDialog.scope !== 'sns' && options.exportFiles && (
<div className="format-note">使 MD5 </div>
<div className="media-option-grid">
{dialogMediaOptions.map(option => {
const Icon = option.icon
return (
<label key={option.key} className={`media-option-card ${option.checked ? 'active' : ''}`}>
<input
className="media-option-input"
type="checkbox"
checked={option.checked}
onChange={event => option.onToggle(event.target.checked)}
/>
<span className="media-option-main">
<span className="media-option-icon">
<Icon size={16} />
</span>
<span className="media-option-text">
<span className="media-option-label">{option.label}</span>
<span className="media-option-desc">{option.desc}</span>
</span>
</span>
<span className={`media-option-check ${option.checked ? 'active' : ''}`}>
<Check size={12} />
</span>
</label>
)
})}
</div>
{exportDialog.scope !== 'sns' && (
<div
className={`dialog-collapse-slot ${options.exportFiles ? 'open' : ''}`}
aria-hidden={!options.exportFiles}
>
<div className="dialog-collapse-inner">
<div className="file-size-subsection">
<div className="file-size-subsection-header">
<div className="file-size-heading"></div>
<div className="file-size-current">{fileSizeLimitLabel}</div>
</div>
<div className="file-size-note">
使 MD5
</div>
<div className="file-size-preset-row">
{FILE_SIZE_PRESETS_MB.map(preset => (
<button
key={preset}
type="button"
className={`file-size-preset-btn ${options.maxFileSizeMb === preset ? 'active' : ''}`}
onClick={() => setOptions(prev => ({ ...prev, maxFileSizeMb: preset }))}
>
{preset === 0 ? '不限' : `${preset}MB`}
</button>
))}
</div>
<div className="dialog-input-row">
<input
type="number"
min={0}
step={10}
value={options.maxFileSizeMb}
onChange={event => {
const raw = Number(event.target.value)
setOptions(prev => ({ ...prev, maxFileSizeMb: Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0 }))
}}
/>
<span>MB</span>
</div>
</div>
</div>
</div>
)}
</div>
)}
{shouldShowMediaSection && exportDialog.scope !== 'sns' && options.exportFiles && (
<div className="dialog-section">
<h4></h4>
<div className="format-note">0 </div>
<div className="dialog-input-row">
<input
type="number"
min={0}
step={10}
value={options.maxFileSizeMb}
onChange={event => {
const raw = Number(event.target.value)
setOptions(prev => ({ ...prev, maxFileSizeMb: Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0 }))
}}
/>
<span>MB</span>
</div>
</div>
)}
{shouldShowImageDeepSearchToggle && (
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"> hardlink </div>
{shouldRenderImageDeepSearchToggle && (
<div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}>
<div className="dialog-collapse-inner">
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"> hardlink </div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
)}
@@ -8262,6 +8396,7 @@ function ExportPage() {
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"></div>
<div className="format-note">{voiceAsTextStatusLabel}</div>
</div>
<button
type="button"