(undefined)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
@@ -477,6 +488,9 @@ function ChatPage(_props: ChatPageProps) {
// 刷新会话列表
const handleRefresh = async () => {
+ setJumpStartTime(0)
+ setJumpEndTime(0)
+ setHasMoreLater(false)
await loadSessions({ silent: true })
}
@@ -484,6 +498,9 @@ function ChatPage(_props: ChatPageProps) {
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
const handleRefreshMessages = async () => {
if (!currentSessionId || isRefreshingMessages) return
+ setJumpStartTime(0)
+ setJumpEndTime(0)
+ setHasMoreLater(false)
setIsRefreshingMessages(true)
try {
// 获取最新消息并增量添加
@@ -518,7 +535,7 @@ function ChatPage(_props: ChatPageProps) {
}
// 加载消息
- const loadMessages = async (sessionId: string, offset = 0) => {
+ const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
const listEl = messageListRef.current
const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0
@@ -535,7 +552,7 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try {
- const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit)
+ const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime)
if (result.success && result.messages) {
if (offset === 0) {
setMessages(result.messages)
@@ -601,6 +618,14 @@ function ChatPage(_props: ChatPageProps) {
}
}
setHasMoreMessages(result.hasMore ?? false)
+ // 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息
+ if (offset === 0) {
+ if (endTime > 0) {
+ setHasMoreLater(true)
+ } else {
+ setHasMoreLater(false)
+ }
+ }
setCurrentOffset(offset + result.messages.length)
} else if (!result.success) {
setConnectionError(result.error || '加载消息失败')
@@ -616,12 +641,41 @@ function ChatPage(_props: ChatPageProps) {
}
}
+ // 加载更晚的消息
+ const loadLaterMessages = useCallback(async () => {
+ if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
+
+ setLoadingMore(true)
+ try {
+ const lastMsg = messages[messages.length - 1]
+ // 从最后一条消息的时间开始往后找
+ const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true)
+
+ if (result.success && result.messages) {
+ // 过滤掉已经在列表中的重复消息
+ const existingKeys = messageKeySetRef.current
+ const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
+
+ if (newMsgs.length > 0) {
+ appendMessages(newMsgs, false)
+ }
+ setHasMoreLater(result.hasMore ?? false)
+ }
+ } catch (e) {
+ console.error('加载后续消息失败:', e)
+ } finally {
+ setLoadingMore(false)
+ }
+ }, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore])
+
// 选择会话
const handleSelectSession = (session: ChatSession) => {
if (session.username === currentSessionId) return
setCurrentSession(session.username)
setCurrentOffset(0)
- loadMessages(session.username, 0)
+ setJumpStartTime(0)
+ setJumpEndTime(0)
+ loadMessages(session.username, 0, 0, 0)
// 重置详情面板
setSessionDetail(null)
if (showDetailPanel) {
@@ -678,16 +732,21 @@ function ChatPage(_props: ChatPageProps) {
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
const threshold = clientHeight * 0.3
if (scrollTop < threshold) {
- loadMessages(currentSessionId, currentOffset)
+ loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
+ }
+ }
+
+ // 预加载更晚的消息
+ if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) {
+ const threshold = clientHeight * 0.3
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight
+ if (distanceFromBottom < threshold) {
+ loadLaterMessages()
}
}
})
- }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages])
+ }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages])
- const getMessageKey = useCallback((msg: Message): string => {
- if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
- return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
- }, [])
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
return (
@@ -1102,6 +1161,25 @@ function ChatPage(_props: ChatPageProps) {
)}
+
+ setShowJumpDialog(false)}
+ onSelect={(date) => {
+ if (!currentSessionId) return
+ const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
+ setCurrentOffset(0)
+ setJumpStartTime(0)
+ setJumpEndTime(end)
+ loadMessages(currentSessionId, 0, 0, end)
+ }}
+ />
+ )}
+
{/* 回到底部按钮 */}
diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss
index 4aa5b35..ddedbd9 100644
--- a/src/pages/ExportPage.scss
+++ b/src/pages/ExportPage.scss
@@ -602,6 +602,87 @@
}
}
+ .export-layout-modal {
+ background: var(--card-bg);
+ padding: 28px 32px;
+ border-radius: 16px;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
+ text-align: center;
+ width: min(520px, 90vw);
+
+ h3 {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 8px;
+ }
+
+ .layout-subtitle {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin: 0 0 20px;
+ }
+
+ .layout-options {
+ display: grid;
+ gap: 12px;
+ }
+
+ .layout-option-btn {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px 18px;
+ border-radius: 12px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ text-align: left;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ border-color: var(--primary);
+ background: rgba(var(--primary-rgb), 0.08);
+ }
+
+ &.primary {
+ border-color: var(--primary);
+ background: rgba(var(--primary-rgb), 0.12);
+ }
+
+ .layout-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ .layout-desc {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ }
+ }
+
+ .layout-actions {
+ margin-top: 18px;
+ display: flex;
+ justify-content: center;
+ }
+
+ .layout-cancel-btn {
+ padding: 8px 20px;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ background: var(--bg-hover);
+ }
+ }
+ }
+
.export-result-modal {
background: var(--card-bg);
padding: 32px 40px;
@@ -1056,4 +1137,4 @@
input:checked + .slider::before {
transform: translateX(20px);
}
-}
\ No newline at end of file
+}
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx
index 19d8605..87f3bfa 100644
--- a/src/pages/ExportPage.tsx
+++ b/src/pages/ExportPage.tsx
@@ -22,6 +22,7 @@ interface ExportOptions {
exportEmojis: boolean
exportVoiceAsText: boolean
excelCompactColumns: boolean
+ txtColumns: string[]
}
interface ExportResult {
@@ -31,7 +32,10 @@ interface ExportResult {
error?: string
}
+type SessionLayout = 'shared' | 'per-session'
+
function ExportPage() {
+ const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const [sessions, setSessions] = useState([])
const [filteredSessions, setFilteredSessions] = useState([])
const [selectedSessions, setSelectedSessions] = useState>(new Set())
@@ -44,6 +48,7 @@ function ExportPage() {
const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
+ const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [options, setOptions] = useState({
format: 'excel',
@@ -58,7 +63,8 @@ function ExportPage() {
exportVoices: true,
exportEmojis: true,
exportVoiceAsText: true,
- excelCompactColumns: true
+ excelCompactColumns: true,
+ txtColumns: defaultTxtColumns
})
const buildDateRangeFromPreset = (preset: string) => {
@@ -122,17 +128,20 @@ function ExportPage() {
savedRange,
savedMedia,
savedVoiceAsText,
- savedExcelCompactColumns
+ savedExcelCompactColumns,
+ savedTxtColumns
] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
- configService.getExportDefaultExcelCompactColumns()
+ configService.getExportDefaultExcelCompactColumns(),
+ configService.getExportDefaultTxtColumns()
])
const preset = savedRange || 'today'
const rangeDefaults = buildDateRangeFromPreset(preset)
+ const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
setOptions((prev) => ({
...prev,
@@ -141,7 +150,8 @@ function ExportPage() {
dateRange: rangeDefaults.dateRange,
exportMedia: savedMedia ?? false,
exportVoiceAsText: savedVoiceAsText ?? true,
- excelCompactColumns: savedExcelCompactColumns ?? true
+ excelCompactColumns: savedExcelCompactColumns ?? true,
+ txtColumns
}))
} catch (e) {
console.error('加载导出默认设置失败:', e)
@@ -154,6 +164,19 @@ function ExportPage() {
loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults])
+ useEffect(() => {
+ const removeListener = window.electronAPI.export.onProgress?.((payload) => {
+ setExportProgress({
+ current: payload.current,
+ total: payload.total,
+ currentName: payload.currentSession
+ })
+ })
+ return () => {
+ removeListener?.()
+ }
+ }, [])
+
useEffect(() => {
if (!searchKeyword.trim()) {
setFilteredSessions(sessions)
@@ -199,7 +222,7 @@ function ExportPage() {
}
}
- const startExport = async () => {
+ const runExport = async (sessionLayout: SessionLayout) => {
if (selectedSessions.size === 0 || !exportFolder) return
setIsExporting(true)
@@ -215,16 +238,18 @@ function ExportPage() {
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportEmojis: options.exportMedia && options.exportEmojis,
- exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia
+ exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns,
+ txtColumns: options.txtColumns,
+ sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
- // 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
+ // 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null
}
- if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') {
+ if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt') {
const result = await window.electronAPI.export.exportSessions(
sessionList,
exportFolder,
@@ -232,16 +257,28 @@ function ExportPage() {
)
setExportResult(result)
} else {
- setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` })
+ setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
}
} catch (e) {
- console.error('导出失败:', e)
+ console.error('导出过程中发生异常:', e)
setExportResult({ success: false, error: String(e) })
} finally {
setIsExporting(false)
}
}
+ const startExport = () => {
+ if (selectedSessions.size === 0 || !exportFolder) return
+
+ if (options.exportMedia && selectedSessions.size > 1) {
+ setShowMediaLayoutPrompt(true)
+ return
+ }
+
+ const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
+ runExport(layout)
+ }
+
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
@@ -600,6 +637,43 @@ function ExportPage() {
+ {/* 媒体导出布局选择弹窗 */}
+ {showMediaLayoutPrompt && (
+ setShowMediaLayoutPrompt(false)}>
+
e.stopPropagation()}>
+
导出文件夹布局
+
检测到同时导出多个会话并包含媒体文件,请选择存放方式:
+
+
+
+
+
+
+
+
+
+ )}
+
{/* 导出进度弹窗 */}
{isExporting && (
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss
index c6bb34b..e5a4bed 100644
--- a/src/pages/SettingsPage.scss
+++ b/src/pages/SettingsPage.scss
@@ -221,6 +221,100 @@
}
}
+ .select-field {
+ position: relative;
+ margin-bottom: 10px;
+ }
+
+ .select-trigger {
+ width: 100%;
+ padding: 10px 16px;
+ border: 1px solid var(--border-color);
+ border-radius: 9999px;
+ font-size: 14px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+
+ &:hover {
+ border-color: var(--text-tertiary);
+ }
+
+ &.open {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
+ }
+ }
+
+ .select-value {
+ flex: 1;
+ min-width: 0;
+ text-align: left;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .select-dropdown {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 6px;
+ box-shadow: var(--shadow-md);
+ z-index: 20;
+ max-height: 320px;
+ overflow-y: auto;
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ }
+
+ .select-option {
+ width: 100%;
+ text-align: left;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 10px 12px;
+ border: none;
+ border-radius: 10px;
+ background: transparent;
+ cursor: pointer;
+ transition: all 0.15s;
+ color: var(--text-primary);
+ font-size: 14px;
+
+ &:hover {
+ background: var(--bg-tertiary);
+ }
+
+ &.active {
+ background: color-mix(in srgb, var(--primary) 12%, transparent);
+ color: var(--primary);
+ }
+ }
+
+ .option-label {
+ font-weight: 500;
+ }
+
+ .option-desc {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ }
+
+ .select-option.active .option-desc {
+ color: var(--primary);
+ }
+
.input-with-toggle {
position: relative;
display: flex;
@@ -1096,13 +1190,15 @@
left: 0;
right: 0;
margin-top: 4px;
- background: var(--bg-secondary);
+ background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-primary);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
max-height: 200px;
overflow-y: auto;
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
}
.wxid-option {
@@ -1216,4 +1312,4 @@
border-top: 1px solid var(--border-primary);
display: flex;
justify-content: flex-end;
-}
\ No newline at end of file
+}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 3f4e930..93c7484 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
@@ -41,6 +41,12 @@ function SettingsPage() {
const [wxidOptions, setWxidOptions] = useState([])
const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef(null)
+ const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
+ const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
+ const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
+ const exportFormatDropdownRef = useRef(null)
+ const exportDateRangeDropdownRef = useRef(null)
+ const exportExcelColumnsDropdownRef = useRef(null)
const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
@@ -55,6 +61,7 @@ function SettingsPage() {
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
+ const [exportDefaultTxtColumns, setExportDefaultTxtColumns] = useState(['index', 'time', 'senderRole', 'messageType', 'content'])
const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -85,13 +92,23 @@ function SettingsPage() {
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
- if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) {
+ const target = e.target as Node
+ if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
setShowWxidSelect(false)
}
+ if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
+ setShowExportFormatSelect(false)
+ }
+ if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
+ setShowExportDateRangeSelect(false)
+ }
+ if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
+ setShowExportExcelColumnsSelect(false)
+ }
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
- }, [showWxidSelect])
+ }, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -125,6 +142,8 @@ function SettingsPage() {
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
+ const savedExportDefaultTxtColumns = await configService.getExportDefaultTxtColumns()
+ const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath)
@@ -142,6 +161,11 @@ function SettingsPage() {
setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
+ setExportDefaultTxtColumns(
+ savedExportDefaultTxtColumns && savedExportDefaultTxtColumns.length > 0
+ ? savedExportDefaultTxtColumns
+ : defaultTxtColumns
+ )
// 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
@@ -150,6 +174,10 @@ function SettingsPage() {
await configService.setTranscribeLanguages(defaultLanguages)
}
+ if (!savedExportDefaultTxtColumns || savedExportDefaultTxtColumns.length === 0) {
+ await configService.setExportDefaultTxtColumns(defaultTxtColumns)
+ }
+
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
} catch (e) {
console.error('加载配置失败:', e)
@@ -484,15 +512,8 @@ function SettingsPage() {
await configService.setTranscribeLanguages(transcribeLanguages)
await configService.setOnboardingDone(true)
- showMessage('配置保存成功,正在测试连接...', true)
- const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
-
- if (result.success) {
- setDbConnected(true, dbPath)
- showMessage('配置保存成功!数据库连接正常', true)
- } else {
- showMessage(result.error || '数据库连接失败,请检查配置', false)
- }
+ // 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
+ showMessage('配置保存成功', true)
} catch (e) {
showMessage(`保存配置失败: ${e}`, false)
} finally {
@@ -870,48 +891,124 @@ function SettingsPage() {
)
- const renderExportTab = () => (
+ const exportFormatOptions = [
+ { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
+ { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
+ { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
+ { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
+ { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
+ { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
+ { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
+ ]
+ const exportDateRangeOptions = [
+ { value: 'today', label: '今天' },
+ { value: '7d', label: '最近7天' },
+ { value: '30d', label: '最近30天' },
+ { value: '90d', label: '最近90天' },
+ { value: 'all', label: '全部时间' }
+ ]
+ const exportExcelColumnOptions = [
+ { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
+ { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
+ ]
+ const exportTxtColumnOptions = [
+ { value: 'index', label: '序号' },
+ { value: 'time', label: '时间' },
+ { value: 'senderRole', label: '发送者身份' },
+ { value: 'messageType', label: '消息类型' },
+ { value: 'content', label: '内容' },
+ { value: 'senderNickname', label: '发送者昵称' },
+ { value: 'senderWxid', label: '发送者微信ID' },
+ { value: 'senderRemark', label: '发送者备注' }
+ ]
+
+ const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
+ return options.find((option) => option.value === value)?.label ?? value
+ }
+
+ const renderExportTab = () => {
+ const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
+ const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
+ const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
+ const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
+
+ return (
导出页面默认选中的格式
-
+
+
+ {showExportFormatSelect && (
+
+ {exportFormatOptions.map((option) => (
+
+ ))}
+
+ )}
+
控制导出页面的默认时间选择
-
+
+
+ {showExportDateRangeSelect && (
+
+ {exportDateRangeOptions.map((option) => (
+
+ ))}
+
+ )}
+
@@ -963,21 +1060,80 @@ function SettingsPage() {
控制 Excel 导出的列字段
-
+
+
+ {showExportExcelColumnsSelect && (
+
+ {exportExcelColumnOptions.map((option) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
默认与 Excel 精简列一致,可多选调整输出字段
+
+ {exportTxtColumnOptions.map((column) => {
+ const checked = exportDefaultTxtColumns.includes(column.value)
+ return (
+
+ )
+ })}
+
- )
+ )
+ }
const renderCacheTab = () => (
管理应用缓存数据
@@ -1126,4 +1282,3 @@ function SettingsPage() {
}
export default SettingsPage
-
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss
new file mode 100644
index 0000000..005e2b4
--- /dev/null
+++ b/src/pages/SnsPage.scss
@@ -0,0 +1,579 @@
+.sns-page {
+ height: 100%;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ overflow: hidden;
+
+ .sns-container {
+ display: flex;
+ height: 100%;
+ }
+
+ .sns-sidebar {
+ width: 280px;
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ flex-shrink: 0;
+
+ &.closed {
+ width: 0;
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ .sidebar-header {
+ padding: 16px 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--border-color);
+
+ h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ }
+
+ .toggle-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+
+ &:hover {
+ background: var(--hover-bg);
+ color: var(--text-primary);
+ }
+ }
+ }
+
+ .filter-content {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+
+ /* 自定义滚动条 */
+ &::-webkit-scrollbar {
+ width: 4px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 10px;
+ }
+ }
+
+ .filter-group {
+ padding-bottom: 4px;
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ .filter-section {
+ padding: 10px 20px;
+
+ label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+ font-weight: 500;
+ }
+
+ input[type="text"] {
+ width: 100%;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 8px 12px;
+ color: var(--text-primary);
+ font-size: 13px;
+ outline: none;
+
+ &:focus {
+ border-color: var(--accent-color);
+ }
+ }
+
+ .date-inputs {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+
+ input[type="date"] {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 6px 10px;
+ color: var(--text-primary);
+ font-size: 12px;
+ outline: none;
+ width: 100%;
+
+ &:focus {
+ border-color: var(--accent-color);
+ }
+ }
+
+ span {
+ font-size: 11px;
+ color: var(--text-tertiary);
+ text-align: center;
+ }
+ }
+ }
+
+ .contact-filter-section {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 200px;
+ padding: 12px 0 0 0;
+
+ .section-header {
+ padding: 0 20px 8px 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ font-weight: 500;
+ }
+
+ .selected-count {
+ font-size: 11px;
+ background: var(--accent-color);
+ color: white;
+ padding: 1px 6px;
+ border-radius: 10px;
+ }
+ }
+
+ .contact-search {
+ margin: 0 20px 10px 20px;
+ position: relative;
+
+ .search-icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-tertiary);
+ }
+
+ input {
+ width: 100%;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 6px 10px 6px 28px;
+ font-size: 12px;
+ color: var(--text-primary);
+ outline: none;
+
+ &:focus {
+ border-color: var(--accent-color);
+ }
+ }
+ }
+
+ .contact-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 10px;
+
+ .contact-item {
+ display: flex;
+ align-items: center;
+ padding: 8px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s;
+ gap: 10px;
+ margin-bottom: 2px;
+ position: relative;
+
+ &:hover {
+ background: var(--hover-bg);
+ }
+
+ &.active {
+ background: rgba(var(--accent-color-rgb), 0.1);
+
+ .contact-name {
+ color: var(--accent-color);
+ font-weight: 600;
+ }
+ }
+
+ .contact-name {
+ flex: 1;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--text-secondary);
+ }
+
+ .check-mark {
+ color: var(--accent-color);
+ font-size: 12px;
+ font-weight: bold;
+ }
+ }
+
+ .empty-contacts {
+ text-align: center;
+ padding: 20px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+ }
+ }
+ }
+
+ .sidebar-footer {
+ padding: 16px 20px;
+ border-top: 1px solid var(--border-color);
+
+ .clear-btn {
+ width: 100%;
+ padding: 8px;
+ background: transparent;
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ transition: all 0.2s;
+
+ &:hover {
+ background: var(--hover-bg);
+ color: var(--text-primary);
+ }
+ }
+ }
+ }
+
+ .sns-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+
+ .sns-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 20px;
+ height: 60px;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+
+ .header-left {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+
+ h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ }
+ }
+
+ .icon-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 8px;
+ border-radius: 4px;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ background: var(--hover-bg);
+ color: var(--text-primary);
+ }
+ }
+
+ .spinning {
+ animation: spin 1s linear infinite;
+ }
+ }
+
+ .sns-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+ scroll-behavior: smooth;
+
+ .active-filters {
+ max-width: 680px;
+ margin: 0 auto 16px auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: rgba(var(--accent-color-rgb), 0.05);
+ border: 1px solid rgba(var(--accent-color-rgb), 0.2);
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-size: 13px;
+ color: var(--accent-color);
+
+ .clear-chip-btn {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ font-size: 12px;
+ text-decoration: underline;
+
+ &:hover {
+ color: var(--text-secondary);
+ }
+ }
+ }
+
+ .sns-post {
+ background: var(--bg-secondary);
+ border-radius: 12px;
+ padding: 20px;
+ margin-bottom: 24px;
+ max-width: 680px;
+ margin-left: auto;
+ margin-right: auto;
+ border: 1px solid var(--border-color);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
+
+ .post-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 14px;
+
+ .post-info {
+ margin-left: 12px;
+
+ .nickname {
+ font-weight: 600;
+ margin-bottom: 2px;
+ color: var(--accent-color);
+ }
+
+ .time {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ }
+ }
+ }
+
+ .post-body {
+ margin-bottom: 16px;
+
+ .post-text {
+ margin-bottom: 12px;
+ white-space: pre-wrap;
+ line-height: 1.6;
+ font-size: 15px;
+ word-break: break-word;
+ }
+
+ .post-media-grid {
+ display: grid;
+ gap: 4px;
+ width: fit-content;
+ max-width: 100%;
+
+ &.media-count-1 {
+ grid-template-columns: 1fr;
+
+ .media-item {
+ max-width: 400px;
+ aspect-ratio: unset;
+ }
+
+ img {
+ height: auto;
+ max-height: 500px;
+ }
+ }
+
+ &.media-count-2,
+ &.media-count-4 {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ &.media-count-3,
+ &.media-count-5,
+ &.media-count-6,
+ &.media-count-7,
+ &.media-count-8,
+ &.media-count-9 {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .media-item {
+ aspect-ratio: 1;
+ background: var(--bg-tertiary);
+ border-radius: 4px;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &.error {
+ background: transparent;
+ border: 1px dashed var(--border-color);
+ color: var(--text-tertiary);
+ font-size: 12px;
+ min-width: 100px;
+ min-height: 100px;
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ cursor: pointer;
+ transition: opacity 0.2s;
+
+ &:hover {
+ opacity: 0.85;
+ }
+ }
+ }
+ }
+
+ .post-video-placeholder {
+ display: inline-flex;
+ align-items: center;
+ background: rgba(var(--accent-color-rgb), 0.1);
+ color: var(--accent-color);
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 8px;
+ }
+ }
+
+ .post-footer {
+ background: var(--bg-tertiary);
+ border-radius: 6px;
+ padding: 10px 12px;
+ font-size: 13.5px;
+
+ .likes-section {
+ display: flex;
+ align-items: flex-start;
+ color: var(--accent-color);
+ padding-bottom: 8px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+ margin-bottom: 8px;
+
+ &:last-child {
+ padding-bottom: 0;
+ border-bottom: none;
+ margin-bottom: 0;
+ }
+
+ .icon {
+ margin-top: 3.5px;
+ margin-right: 8px;
+ flex-shrink: 0;
+ }
+
+ .likes-list {
+ line-height: 1.5;
+ }
+ }
+
+ .comments-section {
+ .comment-item {
+ margin-bottom: 6px;
+ line-height: 1.5;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ .comment-user {
+ color: var(--accent-color);
+ font-weight: 600;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .reply-text {
+ color: var(--text-tertiary);
+ margin: 0 4px;
+ font-size: 13px;
+ }
+
+ .comment-separator {
+ color: var(--text-secondary);
+ margin-left: -2px;
+ margin-right: 4px;
+ }
+
+ .comment-content {
+ color: var(--text-secondary);
+ }
+ }
+ }
+ }
+ }
+
+ .loading-more,
+ .no-more,
+ .no-results {
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--text-tertiary);
+ font-size: 14px;
+
+ .reset-inline {
+ margin-top: 12px;
+ background: var(--accent-color);
+ color: white;
+ border: none;
+ padding: 8px 20px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ box-shadow: 0 2px 6px rgba(var(--accent-color-rgb), 0.3);
+ }
+ }
+ }
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
\ No newline at end of file
diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx
new file mode 100644
index 0000000..04bf2d1
--- /dev/null
+++ b/src/pages/SnsPage.tsx
@@ -0,0 +1,421 @@
+import { useEffect, useState, useRef, useCallback } from 'react'
+import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react'
+import { Avatar } from '../components/Avatar'
+import { ImagePreview } from '../components/ImagePreview'
+import './SnsPage.scss'
+
+interface SnsPost {
+ id: string
+ username: string
+ nickname: string
+ avatarUrl?: string
+ createTime: number
+ contentDesc: string
+ type?: number
+ media: { url: string; thumb: string }[]
+ likes: string[]
+ comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
+}
+
+const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
+ const [error, setError] = useState(false);
+
+ if (error) {
+ return (
+
+ 无法加载
+
+ );
+ }
+
+ return (
+
+

setError(true)}
+ />
+
+ );
+};
+
+interface Contact {
+ username: string
+ displayName: string
+ avatarUrl?: string
+}
+
+export default function SnsPage() {
+ const [posts, setPosts] = useState
([])
+ const [loading, setLoading] = useState(false)
+ const [offset, setOffset] = useState(0)
+ const [hasMore, setHasMore] = useState(true)
+ const loadingRef = useRef(false)
+
+ // 筛选与搜索状态
+ const [searchKeyword, setSearchKeyword] = useState('')
+ const [selectedUsernames, setSelectedUsernames] = useState([])
+ const [startDate, setStartDate] = useState('')
+ const [endDate, setEndDate] = useState('')
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true)
+
+ // 联系人列表状态
+ const [contacts, setContacts] = useState([])
+ const [contactSearch, setContactSearch] = useState('')
+ const [contactsLoading, setContactsLoading] = useState(false)
+ const [previewImage, setPreviewImage] = useState(null)
+
+ const loadPosts = useCallback(async (reset = false) => {
+ if (loadingRef.current) return
+ loadingRef.current = true
+ setLoading(true)
+
+ try {
+ const currentOffset = reset ? 0 : offset
+ const limit = 20
+
+ // 转换日期为秒级时间戳
+ const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined
+ const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天
+
+ const result = await window.electronAPI.sns.getTimeline(
+ limit,
+ currentOffset,
+ selectedUsernames,
+ searchKeyword,
+ startTs,
+ endTs
+ )
+
+ if (result.success && result.timeline) {
+ if (reset) {
+ setPosts(result.timeline)
+ setOffset(limit)
+ setHasMore(result.timeline.length >= limit)
+ } else {
+ setPosts(prev => [...prev, ...result.timeline!])
+ setOffset(prev => prev + limit)
+ if (result.timeline.length < limit) {
+ setHasMore(false)
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load SNS timeline:', error)
+ } finally {
+ setLoading(false)
+ loadingRef.current = false
+ }
+ }, [offset, selectedUsernames, searchKeyword, startDate, endDate])
+
+ // 获取联系人列表
+ const loadContacts = async () => {
+ setContactsLoading(true)
+ try {
+ const result = await window.electronAPI.chat.getSessions()
+ if (result.success && result.sessions) {
+ // 系统账号和特殊前缀
+ const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
+
+ // 初步提取并过滤联系人
+ const initialContacts = result.sessions
+ .filter((s: any) => {
+ if (!s.username) return false;
+ const u = s.username.toLowerCase();
+
+ // 1. 排除群聊 (WeChat 群组以 @chatroom 结尾)
+ if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) {
+ return false;
+ }
+
+ // 2. 排除公众号 (通常以 gh_ 开头)
+ if (u.startsWith('gh_')) {
+ return false;
+ }
+
+ // 3. 排除系统账号
+ if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) {
+ return false;
+ }
+
+ return true;
+ })
+ .map((s: any) => ({
+ username: s.username,
+ displayName: s.displayName || s.username,
+ avatarUrl: s.avatarUrl
+ }))
+ setContacts(initialContacts)
+
+ // 异步进一步富化(获取更多准确的昵称和头像)
+ const usernames = initialContacts.map(c => c.username)
+ const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
+ if (enriched.success && enriched.contacts) {
+ setContacts(prev => prev.map(c => {
+ const extra = enriched.contacts![c.username]
+ if (extra) {
+ return {
+ ...c,
+ displayName: extra.displayName || c.displayName,
+ avatarUrl: extra.avatarUrl || c.avatarUrl
+ }
+ }
+ return c
+ }))
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load contacts:', error)
+ } finally {
+ setContactsLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadContacts()
+ }, [])
+
+ useEffect(() => {
+ loadPosts(true)
+ }, [selectedUsernames, searchKeyword, startDate, endDate])
+
+ const handleScroll = (e: React.UIEvent) => {
+ const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
+ if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) {
+ loadPosts()
+ }
+ }
+
+ const formatTime = (ts: number) => {
+ const date = new Date(ts * 1000)
+ const isCurrentYear = date.getFullYear() === new Date().getFullYear()
+
+ return date.toLocaleString('zh-CN', {
+ year: isCurrentYear ? undefined : 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ })
+ }
+
+ const toggleUserSelection = (username: string) => {
+ setSelectedUsernames(prev => {
+ if (prev.includes(username)) {
+ return prev.filter(u => u !== username)
+ } else {
+ return [...prev, username]
+ }
+ })
+ }
+
+ const clearFilters = () => {
+ setSearchKeyword('')
+ setSelectedUsernames([])
+ setStartDate('')
+ setEndDate('')
+ }
+
+ const filteredContacts = contacts.filter(c =>
+ c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
+ c.username.toLowerCase().includes(contactSearch.toLowerCase())
+ )
+
+ return (
+
+
+ {/* 侧边栏:过滤与搜索 */}
+
+
+
+
+
+ {!isSidebarOpen && (
+
+ )}
+
朋友圈
+
+
+
+
+
+
+
+ {selectedUsernames.length > 0 && (
+
+ 筛选中: {selectedUsernames.length} 位好友
+
+
+ )}
+
+ {posts.map(post => (
+
+
+
+
+
{post.nickname}
+
{formatTime(post.createTime)}
+
+
+
+
+ {post.contentDesc &&
{post.contentDesc}
}
+
+ {post.type === 15 ? (
+
+ [视频]
+
+ ) : post.media.length > 0 && (
+
+ {post.media.map((m, idx) => (
+ setPreviewImage(m.url)} />
+ ))}
+
+ )}
+
+
+ {(post.likes.length > 0 || post.comments.length > 0) && (
+
+ {post.likes.length > 0 && (
+
+
+
+ {post.likes.join('、')}
+
+
+ )}
+
+ {post.comments.length > 0 && (
+
+ {post.comments.map((c, idx) => (
+
+ {c.nickname}
+ {c.refNickname && (
+ <>
+ 回复
+ {c.refNickname}
+ >
+ )}
+ :
+ {c.content}
+
+ ))}
+
+ )}
+
+ )}
+
+ ))}
+
+ {loading &&
加载中...
}
+ {!hasMore && posts.length > 0 &&
没有更多了
}
+ {!loading && posts.length === 0 && (
+
+
没有找到符合条件的朋友圈
+ {selectedUsernames.length > 0 && (
+
+ )}
+
+ )}
+
+
+
+ {previewImage && (
+
setPreviewImage(null)} />
+ )}
+
+ )
+}
diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx
index fb2878c..3f5ac52 100644
--- a/src/pages/WelcomePage.tsx
+++ b/src/pages/WelcomePage.tsx
@@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
浏览选择
-
建议选择包含 xwechat_files 的目录
+
请选择微信-设置-存储位置对应的目录
⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录
)}
@@ -507,7 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus && {dbKeyStatus}
}
获取密钥会自动识别最近登录的账号
- 点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录
+ 点击自动获取后微信将重新启动,当页面提示hook安装成功,现在登录微信后再点击登录
)}
@@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
{imageKeyStatus && {imageKeyStatus}
}
- 如获取失败,请先打开朋友圈图片再重试
+ 请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作
{isFetchingImageKey && 正在扫描内存,请稍候...
}
)}
diff --git a/src/services/config.ts b/src/services/config.ts
index 9bc8a5e..f8361dd 100644
--- a/src/services/config.ts
+++ b/src/services/config.ts
@@ -27,7 +27,8 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
- EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns'
+ EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
+ EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns'
} as const
// 获取解密密钥
@@ -306,3 +307,14 @@ export async function getExportDefaultExcelCompactColumns(): Promise {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
}
+
+// 获取导出默认 TXT 列配置
+export async function getExportDefaultTxtColumns(): Promise {
+ const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS)
+ return Array.isArray(value) ? (value as string[]) : null
+}
+
+// 设置导出默认 TXT 列配置
+export async function setExportDefaultTxtColumns(columns: string[]): Promise {
+ await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
+}
diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts
index c296059..561f568 100644
--- a/src/stores/chatStore.ts
+++ b/src/stores/chatStore.ts
@@ -6,25 +6,26 @@ export interface ChatState {
isConnected: boolean
isConnecting: boolean
connectionError: string | null
-
+
// 会话列表
sessions: ChatSession[]
filteredSessions: ChatSession[]
currentSessionId: string | null
isLoadingSessions: boolean
-
+
// 消息
messages: Message[]
isLoadingMessages: boolean
isLoadingMore: boolean
hasMoreMessages: boolean
-
+ hasMoreLater: boolean
+
// 联系人缓存
contacts: Map
-
+
// 搜索
searchKeyword: string
-
+
// 操作
setConnected: (connected: boolean) => void
setConnecting: (connecting: boolean) => void
@@ -38,6 +39,7 @@ export interface ChatState {
setLoadingMessages: (loading: boolean) => void
setLoadingMore: (loading: boolean) => void
setHasMoreMessages: (hasMore: boolean) => void
+ setHasMoreLater: (hasMore: boolean) => void
setContacts: (contacts: Contact[]) => void
addContact: (contact: Contact) => void
setSearchKeyword: (keyword: string) => void
@@ -56,48 +58,51 @@ export const useChatStore = create((set, get) => ({
isLoadingMessages: false,
isLoadingMore: false,
hasMoreMessages: true,
+ hasMoreLater: false,
contacts: new Map(),
searchKeyword: '',
setConnected: (connected) => set({ isConnected: connected }),
setConnecting: (connecting) => set({ isConnecting: connecting }),
setConnectionError: (error) => set({ connectionError: error }),
-
+
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
-
- setCurrentSession: (sessionId) => set({
+
+ setCurrentSession: (sessionId) => set({
currentSessionId: sessionId,
messages: [],
- hasMoreMessages: true
+ hasMoreMessages: true,
+ hasMoreLater: false
}),
-
+
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
-
+
setMessages: (messages) => set({ messages }),
-
+
appendMessages: (newMessages, prepend = false) => set((state) => ({
- messages: prepend
+ messages: prepend
? [...newMessages, ...state.messages]
: [...state.messages, ...newMessages]
})),
-
+
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
-
- setContacts: (contacts) => set({
- contacts: new Map(contacts.map(c => [c.username, c]))
+ setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }),
+
+ setContacts: (contacts) => set({
+ contacts: new Map(contacts.map(c => [c.username, c]))
}),
-
+
addContact: (contact) => set((state) => {
const newContacts = new Map(state.contacts)
newContacts.set(contact.username, contact)
return { contacts: newContacts }
}),
-
+
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
-
+
reset: () => set({
isConnected: false,
isConnecting: false,
@@ -110,6 +115,7 @@ export const useChatStore = create((set, get) => ({
isLoadingMessages: false,
isLoadingMore: false,
hasMoreMessages: true,
+ hasMoreLater: false,
contacts: new Map(),
searchKeyword: ''
})
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index bacefb3..d489692 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -63,7 +63,7 @@ export interface ElectronAPI {
contacts?: Record
error?: string
}>
- getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
+ getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{
success: boolean;
messages?: Message[];
hasMore?: boolean;
@@ -314,12 +314,31 @@ export interface ElectronAPI {
success: boolean
error?: string
}>
+ onProgress: (callback: (payload: ExportProgress) => void) => () => void
}
whisper: {
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
}
+ sns: {
+ getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{
+ success: boolean
+ timeline?: Array<{
+ id: string
+ username: string
+ nickname: string
+ avatarUrl?: string
+ createTime: number
+ contentDesc: string
+ type?: number
+ media: Array<{ url: string; thumb: string }>
+ likes: Array
+ comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
+ }>
+ error?: string
+ }>
+ }
}
export interface ExportOptions {
@@ -332,6 +351,15 @@ export interface ExportOptions {
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
+ txtColumns?: string[]
+ sessionLayout?: 'shared' | 'per-session'
+}
+
+export interface ExportProgress {
+ current: number
+ total: number
+ currentSession: string
+ phase: 'preparing' | 'exporting' | 'writing' | 'complete'
}
export interface WxidInfo {