交互细节修复与代码修复

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

@@ -2712,43 +2712,76 @@
// 会话详情面板
.detail-panel {
width: 280px;
width: clamp(280px, 25vw, 360px);
min-width: 280px;
background: var(--card-bg);
border-left: 1px solid var(--border-color);
max-width: 360px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--card-bg) 94%, #fff 6%) 0%,
var(--card-bg) 100%
);
border-left: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
box-shadow: -14px 0 28px rgba(0, 0, 0, 0.07);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInRight 0.2s ease;
animation: slideInRight 0.28s cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform, opacity;
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
gap: 8px;
padding: 14px 14px 12px;
background: color-mix(in srgb, var(--card-bg) 92%, #fff 8%);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 2;
backdrop-filter: blur(6px);
.detail-title-wrap {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
h4 {
font-size: 15px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.detail-title-sub {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.close-btn {
width: 28px;
height: 28px;
background: none;
border: none;
padding: 4px;
padding: 0;
cursor: pointer;
color: var(--text-secondary);
border-radius: 6px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.18s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: rotate(90deg);
}
}
}
@@ -2780,69 +2813,135 @@
.detail-content {
flex: 1;
overflow-y: auto;
padding: 16px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
&::-webkit-scrollbar {
width: 4px;
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 2px;
background: color-mix(in srgb, var(--text-tertiary) 68%, transparent);
border-radius: 999px;
}
}
.detail-overview-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
animation: detailCardEnter 0.24s ease both;
.detail-overview-avatar {
flex-shrink: 0;
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
.detail-overview-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.detail-overview-name {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail-overview-sub {
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.detail-section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
margin: 0;
padding: 12px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 86%, transparent);
animation: detailCardEnter 0.24s ease both;
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
letter-spacing: 0.3px;
svg {
opacity: 0.7;
color: var(--primary);
opacity: 0.9;
}
}
.detail-stats-meta {
margin-top: -6px;
margin-top: -2px;
margin-bottom: 10px;
padding: 6px 8px;
border-radius: 8px;
font-size: 12px;
color: var(--text-tertiary);
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
}
}
.detail-section:nth-child(2) {
animation-delay: 0.03s;
}
.detail-section:nth-child(3) {
animation-delay: 0.06s;
}
.detail-section:nth-child(4) {
animation-delay: 0.09s;
}
.detail-item {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 76%, transparent);
font-size: 13px;
&:last-child {
border-bottom: none;
}
svg {
> svg {
color: var(--text-tertiary);
flex-shrink: 0;
width: 14px;
height: 14px;
}
.label {
color: var(--text-secondary);
flex-shrink: 0;
width: 88px;
line-height: 1.3;
}
.value {
@@ -2851,22 +2950,27 @@
color: var(--text-primary);
word-break: break-all;
user-select: text;
line-height: 1.35;
&.highlight {
color: var(--primary);
font-weight: 600;
font-size: 21px;
letter-spacing: 0.2px;
}
}
.detail-inline-btn {
border: none;
background: var(--bg-secondary);
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
color: var(--primary);
border-radius: 6px;
padding: 4px 8px;
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
line-height: 1;
font-weight: 500;
cursor: pointer;
transition: all 0.16s ease;
&:disabled {
cursor: not-allowed;
@@ -2874,6 +2978,7 @@
}
&:hover:not(:disabled) {
transform: translateY(-1px);
background: var(--bg-hover);
}
}
@@ -2886,12 +2991,12 @@
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
opacity: 0.2;
transition: opacity 0.15s, color 0.15s, background 0.15s;
&:hover {
@@ -2907,18 +3012,27 @@
&:hover .copy-btn {
opacity: 1;
}
&:focus-within .copy-btn {
opacity: 1;
}
}
.detail-basic-section .label {
width: 70px;
}
.table-list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
}
.detail-table-placeholder {
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 8px;
padding: 11px 12px;
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
border: 1px dashed color-mix(in srgb, var(--border-color) 76%, transparent);
border-radius: 10px;
font-size: 12px;
color: var(--text-secondary);
}
@@ -2928,18 +3042,64 @@
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
border-radius: 10px;
font-size: 12px;
transition: transform 0.16s ease, border-color 0.16s ease;
&:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--primary) 26%, var(--border-color));
}
.db-name {
color: var(--text-primary);
font-weight: 500;
max-width: 62%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-count {
color: var(--primary);
font-weight: 500;
font-weight: 600;
}
}
}
.session-detail-panel {
.detail-content {
padding-top: 10px;
}
.detail-overview-card {
gap: 10px;
.detail-overview-meta {
flex: 1;
}
}
.detail-overview-close-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: all 0.16s ease;
&:hover {
color: var(--text-primary);
background: var(--bg-hover);
transform: rotate(90deg);
}
}
}
@@ -3140,6 +3300,18 @@
}
}
@keyframes detailCardEnter {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 语音转文字按钮样式 */
.voice-transcribe-btn {
width: 28px;

View File

@@ -68,6 +68,21 @@ const MESSAGE_LIST_SCROLL_IDLE_MS = 160
const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
type RequestIdleCallbackCompat = (callback: () => void, options?: { timeout?: number }) => number
function scheduleWhenIdle(task: () => void, options?: { timeout?: number; fallbackDelay?: number }): void {
const requestIdleCallbackFn = (
globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat }
).requestIdleCallback
if (typeof requestIdleCallbackFn === 'function') {
requestIdleCallbackFn(task, options?.timeout !== undefined ? { timeout: options.timeout } : undefined)
return
}
window.setTimeout(task, options?.fallbackDelay ?? 0)
}
function isGlobalMsgSearchCanceled(error: unknown): boolean {
return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR
}
@@ -2959,15 +2974,9 @@ function ChatPage(props: ChatPageProps) {
await loadContactInfoBatch(usernames)
} else {
await new Promise<void>((resolve) => {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
void loadContactInfoBatch(usernames).finally(resolve)
}, { timeout: 700 })
} else {
setTimeout(() => {
void loadContactInfoBatch(usernames).finally(resolve)
}, 80)
}
scheduleWhenIdle(() => {
void loadContactInfoBatch(usernames).finally(resolve)
}, { timeout: 700, fallbackDelay: 80 })
})
}
processedBatchCount += 1
@@ -3066,7 +3075,7 @@ function ChatPage(props: ChatPageProps) {
const loadContactInfoBatch = async (usernames: string[]) => {
const startTime = performance.now()
try {
// 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate
// 在数据服务调用前让出控制权(使用 setTimeout 0 代替 setImmediate
await new Promise(resolve => setTimeout(resolve, 0))
const dllStart = performance.now()
@@ -3077,7 +3086,7 @@ function ChatPage(props: ChatPageProps) {
}
const dllTime = performance.now() - dllStart
// DLL 调用后再次让出控制权
//数据服务调用后再次让出控制权
await new Promise(resolve => setTimeout(resolve, 0))
const totalTime = performance.now() - startTime
@@ -3259,13 +3268,7 @@ function ChatPage(props: ChatPageProps) {
}
if (defer) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
runWarmup()
}, { timeout: 1200 })
} else {
globalThis.setTimeout(runWarmup, 120)
}
scheduleWhenIdle(runWarmup, { timeout: 1200, fallbackDelay: 120 })
return
}
@@ -3288,11 +3291,7 @@ function ChatPage(props: ChatPageProps) {
run()
}
if ('requestIdleCallback' in window) {
window.requestIdleCallback(runWhenIdle, { timeout: 1200 })
} else {
window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS)
}
scheduleWhenIdle(runWhenIdle, { timeout: 1200, fallbackDelay: MESSAGE_LIST_SCROLL_IDLE_MS })
}, [warmupGroupSenderProfiles])
// 加载消息
@@ -6796,13 +6795,7 @@ function ChatPage(props: ChatPageProps) {
{/* 会话详情面板 */}
{showDetailPanel && (
<div className="detail-panel">
<div className="detail-header">
<h4></h4>
<button className="close-btn" onClick={() => setShowDetailPanel(false)}>
<X size={16} />
</button>
</div>
<div className="detail-panel session-detail-panel">
{isLoadingDetail && !sessionDetail ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
@@ -6810,7 +6803,27 @@ function ChatPage(props: ChatPageProps) {
</div>
) : sessionDetail ? (
<div className="detail-content">
<div className="detail-section">
<div className="detail-overview-card">
<Avatar
src={currentSession?.avatarUrl}
name={sessionDetail.remark || sessionDetail.nickName || currentSession?.displayName || sessionDetail.wxid}
size={42}
className="detail-overview-avatar"
/>
<div className="detail-overview-meta">
<span className="detail-overview-name">
{sessionDetail.remark || sessionDetail.nickName || currentSession?.displayName || sessionDetail.alias || sessionDetail.wxid}
</span>
<span className="detail-overview-sub">
{sessionDetail.alias || sessionDetail.wxid}
</span>
</div>
<button className="detail-overview-close-btn" onClick={() => setShowDetailPanel(false)} title="关闭详情">
<X size={16} />
</button>
</div>
<div className="detail-section detail-basic-section">
<div className="detail-item">
<Hash size={14} />
<span className="label">ID</span>
@@ -6848,10 +6861,10 @@ function ChatPage(props: ChatPageProps) {
)}
</div>
<div className="detail-section">
<div className="detail-section detail-stats-section">
<div className="section-title">
<MessageSquare size={14} />
<span></span>
<span></span>
</div>
<div className="detail-stats-meta">
{isRefreshingDetailStats
@@ -7009,7 +7022,7 @@ function ChatPage(props: ChatPageProps) {
</div>
</div>
<div className="detail-section">
<div className="detail-section detail-db-section">
<div className="section-title">
<Database size={14} />
<span></span>

File diff suppressed because it is too large Load Diff

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"

View File

@@ -1867,8 +1867,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="tab-content anti-revoke-tab">
<div className="anti-revoke-hero">
<div className="anti-revoke-hero-main">
<h3></h3>
<p></p>
<h3></h3>
<p> WeFlow </p>
</div>
<div className="anti-revoke-metrics">
<div className="anti-revoke-metric is-total">