mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-05 23:15:59 +00:00
交互细节修复与代码修复
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user