Merge pull request #373 from aits2026/codex/ts0305-01-export-module-upgrade

导出模块继续优化+朋友圈体验增强+显性化应用锁入口+其他一些小优化
This commit is contained in:
xuncha
2026-03-06 17:21:59 +08:00
committed by GitHub
30 changed files with 9206 additions and 1684 deletions

View File

@@ -24,7 +24,7 @@ import { windowsHelloService } from './services/windowsHelloService'
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
import { cloudControlService } from './services/cloudControlService' import { cloudControlService } from './services/cloudControlService'
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { httpService } from './services/httpService' import { httpService } from './services/httpService'
@@ -87,10 +87,12 @@ let onboardingWindow: BrowserWindow | null = null
// Splash 启动窗口 // Splash 启动窗口
let splashWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null
const sessionChatWindows = new Map<string, BrowserWindow>() const sessionChatWindows = new Map<string, BrowserWindow>()
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
const keyService = new KeyService() const keyService = new KeyService()
let mainWindowReady = false let mainWindowReady = false
let shouldShowMain = true let shouldShowMain = true
let isAppQuitting = false
// 更新下载状态管理Issue #294 修复) // 更新下载状态管理Issue #294 修复)
let isDownloadInProgress = false let isDownloadInProgress = false
@@ -123,6 +125,47 @@ interface AnnualReportYearsTaskState {
updatedAt: number updatedAt: number
} }
interface OpenSessionChatWindowOptions {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat'
}
const normalizeSessionChatWindowOptionString = (value: unknown): string => {
return String(value || '').trim()
}
const loadSessionChatWindowContent = (
win: BrowserWindow,
sessionId: string,
source: 'chat' | 'export',
options?: OpenSessionChatWindowOptions
) => {
const queryParams = new URLSearchParams({
sessionId,
source
})
const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName)
const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl)
const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType)
if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName)
if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl)
if (initialContactType) queryParams.set('initialContactType', initialContactType)
const query = queryParams.toString()
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`)
return
}
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-window?${query}`
})
}
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>() const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
const annualReportYearsTaskByCacheKey = new Map<string, string>() const annualReportYearsTaskByCacheKey = new Map<string, string>()
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>() const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
@@ -290,6 +333,21 @@ function createWindow(options: { autoShow?: boolean } = {}) {
callback(false) callback(false)
}) })
win.on('closed', () => {
if (mainWindow !== win) return
mainWindow = null
mainWindowReady = false
if (process.platform !== 'darwin' && !isAppQuitting) {
// 隐藏通知窗也是 BrowserWindow必须销毁否则会阻止应用退出。
destroyNotificationWindow()
if (BrowserWindow.getAllWindows().length === 0) {
app.quit()
}
}
})
return win return win
} }
@@ -688,12 +746,18 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
/** /**
* 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域) * 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
*/ */
function createSessionChatWindow(sessionId: string) { function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return null if (!normalizedSessionId) return null
const normalizedSource = normalizeSessionChatWindowSource(options?.source)
const existing = sessionChatWindows.get(normalizedSessionId) const existing = sessionChatWindows.get(normalizedSessionId)
if (existing && !existing.isDestroyed()) { if (existing && !existing.isDestroyed()) {
const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
if (trackedSource !== normalizedSource) {
loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
}
if (existing.isMinimized()) { if (existing.isMinimized()) {
existing.restore() existing.restore()
} }
@@ -730,10 +794,9 @@ function createSessionChatWindow(sessionId: string) {
autoHideMenuBar: true autoHideMenuBar: true
}) })
const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}` loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`)
if (process.env.VITE_DEV_SERVER_URL) {
win.webContents.on('before-input-event', (event, input) => { win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) { if (win.webContents.isDevToolsOpened()) {
@@ -744,10 +807,6 @@ function createSessionChatWindow(sessionId: string) {
event.preventDefault() event.preventDefault()
} }
}) })
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-window?${sessionParam}`
})
} }
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
@@ -759,10 +818,12 @@ function createSessionChatWindow(sessionId: string) {
const tracked = sessionChatWindows.get(normalizedSessionId) const tracked = sessionChatWindows.get(normalizedSessionId)
if (tracked === win) { if (tracked === win) {
sessionChatWindows.delete(normalizedSessionId) sessionChatWindows.delete(normalizedSessionId)
sessionChatWindowSources.delete(normalizedSessionId)
} }
}) })
sessionChatWindows.set(normalizedSessionId, win) sessionChatWindows.set(normalizedSessionId, win)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
return win return win
} }
@@ -1071,8 +1132,8 @@ function registerIpcHandlers() {
}) })
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => { ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
const win = createSessionChatWindow(sessionId) const win = createSessionChatWindow(sessionId, options)
return Boolean(win) return Boolean(win)
}) })
@@ -1410,6 +1471,7 @@ function registerIpcHandlers() {
forceRefresh?: boolean forceRefresh?: boolean
allowStaleCache?: boolean allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}) => { }) => {
return chatService.getExportSessionStats(sessionIds, options) return chatService.getExportSessionStats(sessionIds, options)
}) })
@@ -1463,6 +1525,10 @@ function registerIpcHandlers() {
return snsService.getSnsUsernames() return snsService.getSnsUsernames()
}) })
ipcMain.handle('sns:getUserPostCounts', async () => {
return snsService.getUserPostCounts()
})
ipcMain.handle('sns:getExportStats', async () => { ipcMain.handle('sns:getExportStats', async () => {
return snsService.getExportStats() return snsService.getExportStats()
}) })
@@ -1471,6 +1537,10 @@ function registerIpcHandlers() {
return snsService.getExportStatsFast() return snsService.getExportStatsFast()
}) })
ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
return snsService.getUserPostStats(username)
})
ipcMain.handle('sns:debugResource', async (_, url: string) => { ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url) return snsService.debugResource(url)
}) })
@@ -2373,6 +2443,9 @@ app.whenReady().then(async () => {
}) })
app.on('before-quit', async () => { app.on('before-quit', async () => {
isAppQuitting = true
// 通知窗使用 hide 而非 close退出时主动销毁避免残留窗口阻塞进程退出。
destroyNotificationWindow()
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
try { await httpService.stop() } catch {} try { await httpService.stop() } catch {}
// 终止 wcdb Worker 线程,避免线程阻止进程退出 // 终止 wcdb Worker 线程,避免线程阻止进程退出

View File

@@ -99,8 +99,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) => openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (sessionId: string) => openSessionChatWindow: (
ipcRenderer.invoke('window:openSessionChatWindow', sessionId) sessionId: string,
options?: {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
}, },
// 数据库路径 // 数据库路径
@@ -174,7 +182,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
getExportSessionStats: ( getExportSessionStats: (
sessionIds: string[], sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getGroupMyMessageCountHint: (chatroomId: string) => getGroupMyMessageCountHint: (chatroomId: string) =>
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
@@ -339,8 +353,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),

View File

@@ -164,6 +164,7 @@ interface ExportSessionStatsOptions {
forceRefresh?: boolean forceRefresh?: boolean
allowStaleCache?: boolean allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
} }
interface ExportSessionStatsCacheMeta { interface ExportSessionStatsCacheMeta {
@@ -5209,39 +5210,36 @@ class ChatService {
return { success: true, detail: cachedDetail.detail } return { success: true, detail: cachedDetail.detail }
} }
const [tableStatsResult, statsResult] = await Promise.allSettled([ const tableStatsResult = await wcdbService.getMessageTableStats(normalizedSessionId)
wcdbService.getMessageTableStats(normalizedSessionId),
(async (): Promise<ExportSessionStats | null> => {
const cachedStats = this.getSessionStatsCacheEntry(normalizedSessionId)
if (cachedStats && this.supportsRequestedRelation(cachedStats.entry, false)) {
return this.fromSessionStatsCacheStats(cachedStats.entry.stats)
}
const myWxid = this.configService.get('myWxid') || ''
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
const stats = await this.getOrComputeSessionExportStats(normalizedSessionId, false, selfIdentitySet)
this.setSessionStatsCacheEntry(normalizedSessionId, stats, false)
return stats
})()
])
const statsSnapshot = statsResult.status === 'fulfilled'
? statsResult.value
: null
const firstMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.firstTimestamp)
? Math.max(0, Math.floor(statsSnapshot.firstTimestamp as number))
: undefined
const latestMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.lastTimestamp)
? Math.max(0, Math.floor(statsSnapshot.lastTimestamp as number))
: undefined
const messageTables: { dbName: string; tableName: string; count: number }[] = [] const messageTables: { dbName: string; tableName: string; count: number }[] = []
if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) { let firstMessageTime: number | undefined
for (const row of tableStatsResult.value.tables) { let latestMessageTime: number | undefined
if (tableStatsResult.success && tableStatsResult.tables) {
for (const row of tableStatsResult.tables) {
messageTables.push({ messageTables.push({
dbName: basename(row.db_path || ''), dbName: basename(row.db_path || ''),
tableName: row.table_name || '', tableName: row.table_name || '',
count: parseInt(row.count || '0', 10) count: parseInt(row.count || '0', 10)
}) })
const firstTs = this.getRowInt(
row,
['first_timestamp', 'firstTimestamp', 'first_time', 'firstTime', 'min_create_time', 'minCreateTime'],
0
)
if (firstTs > 0 && (firstMessageTime === undefined || firstTs < firstMessageTime)) {
firstMessageTime = firstTs
}
const lastTs = this.getRowInt(
row,
['last_timestamp', 'lastTimestamp', 'last_time', 'lastTime', 'max_create_time', 'maxCreateTime'],
0
)
if (lastTs > 0 && (latestMessageTime === undefined || lastTs > latestMessageTime)) {
latestMessageTime = lastTs
}
} }
} }
@@ -5357,6 +5355,7 @@ class ChatService {
const forceRefresh = options.forceRefresh === true const forceRefresh = options.forceRefresh === true
const allowStaleCache = options.allowStaleCache === true const allowStaleCache = options.allowStaleCache === true
const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true
const cacheOnly = options.cacheOnly === true
const normalizedSessionIds = Array.from( const normalizedSessionIds = Array.from(
new Set( new Set(
@@ -5380,10 +5379,10 @@ class ChatService {
? this.getGroupMyMessageCountHintEntry(sessionId) ? this.getGroupMyMessageCountHintEntry(sessionId)
: null : null
const cachedResult = this.getSessionStatsCacheEntry(sessionId) const cachedResult = this.getSessionStatsCacheEntry(sessionId)
if (!forceRefresh && !preferAccurateSpecialTypes) { const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes)
if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
if (!stale || allowStaleCache) { if (!stale || allowStaleCache || cacheOnly) {
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
@@ -5400,11 +5399,13 @@ class ChatService {
continue continue
} }
} }
// allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程 // allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询
if (allowStaleCache && cachedResult) { if (canUseCache && allowStaleCache && cachedResult) {
needsRefreshSet.add(sessionId) needsRefreshSet.add(sessionId)
continue continue
} }
if (cacheOnly) {
continue
} }
pendingSessionIds.push(sessionId) pendingSessionIds.push(sessionId)
} }

View File

@@ -292,7 +292,9 @@ class SnsService {
private contactCache: ContactCacheService private contactCache: ContactCacheService
private imageCache = new Map<string, string>() private imageCache = new Map<string, string>()
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000
private lastTimelineFallbackAt = 0 private lastTimelineFallbackAt = 0
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
@@ -864,6 +866,84 @@ class SnsService {
}) })
} }
private async getUserPostCountsFromTimeline(): Promise<Record<string, number>> {
const pageSize = 500
const counts: Record<string, number> = {}
let offset = 0
for (let round = 0; round < 2000; round++) {
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
if (!result.success || !Array.isArray(result.timeline)) {
throw new Error(result.error || '获取朋友圈用户总条数失败')
}
const rows = result.timeline
if (rows.length === 0) break
for (const row of rows) {
const username = this.pickTimelineUsername(row)
if (!username) continue
counts[username] = (counts[username] || 0) + 1
}
if (rows.length < pageSize) break
offset += rows.length
}
return counts
}
async getUserPostCounts(options?: {
preferCache?: boolean
}): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
const preferCache = options?.preferCache ?? true
const now = Date.now()
try {
if (
preferCache &&
this.userPostCountsCache &&
now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs
) {
return { success: true, counts: this.userPostCountsCache.counts }
}
const counts = await this.getUserPostCountsFromTimeline()
this.userPostCountsCache = {
counts,
updatedAt: Date.now()
}
return { success: true, counts }
} catch (error) {
console.error('[SnsService] getUserPostCounts failed:', error)
if (this.userPostCountsCache) {
return { success: true, counts: this.userPostCountsCache.counts }
}
return { success: false, error: String(error) }
}
}
async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> {
const normalizedUsername = this.toOptionalString(username)
if (!normalizedUsername) {
return { success: false, error: '用户名不能为空' }
}
const countsResult = await this.getUserPostCounts({ preferCache: true })
if (countsResult.success) {
const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0
return {
success: true,
data: {
username: normalizedUsername,
totalPosts: Math.max(0, Number(totalPosts || 0))
}
}
}
return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' }
}
// 安装朋友圈删除拦截 // 安装朋友圈删除拦截
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
return wcdbService.installSnsBlockDeleteTrigger() return wcdbService.installSnsBlockDeleteTrigger()
@@ -881,7 +961,12 @@ class SnsService {
// 从数据库直接删除朋友圈记录 // 从数据库直接删除朋友圈记录
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
return wcdbService.deleteSnsPost(postId) const result = await wcdbService.deleteSnsPost(postId)
if (result.success) {
this.userPostCountsCache = null
this.exportStatsCache = null
}
return result
} }
/** /**

View File

@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
let notificationWindow: BrowserWindow | null = null let notificationWindow: BrowserWindow | null = null
let closeTimer: NodeJS.Timeout | null = null let closeTimer: NodeJS.Timeout | null = null
export function destroyNotificationWindow() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
lastNotificationData = null
if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null
return
}
const win = notificationWindow
notificationWindow = null
try {
win.destroy()
} catch (error) {
console.warn('[NotificationWindow] Failed to destroy window:', error)
}
}
export function createNotificationWindow() { export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) { if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow return notificationWindow

View File

@@ -405,8 +405,22 @@ function App() {
// 独立会话聊天窗口(仅显示聊天内容区域) // 独立会话聊天窗口(仅显示聊天内容区域)
if (isStandaloneChatWindow) { if (isStandaloneChatWindow) {
const sessionId = new URLSearchParams(location.search).get('sessionId') || '' const params = new URLSearchParams(location.search)
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} /> const sessionId = params.get('sessionId') || ''
const standaloneSource = params.get('source')
const standaloneInitialDisplayName = params.get('initialDisplayName')
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
const standaloneInitialContactType = params.get('initialContactType')
return (
<ChatPage
standaloneSessionWindow
initialSessionId={sessionId}
standaloneSource={standaloneSource}
standaloneInitialDisplayName={standaloneInitialDisplayName}
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
standaloneInitialContactType={standaloneInitialContactType}
/>
)
} }
// 独立通知窗口 // 独立通知窗口

View File

@@ -0,0 +1,254 @@
.export-date-range-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 2400;
}
.export-date-range-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.export-date-range-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
}
}
.export-date-range-dialog-close-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
}
.export-date-range-preset-list {
display: flex;
flex-wrap: nowrap;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
&::-webkit-scrollbar {
height: 4px;
}
}
.export-date-range-preset-item {
flex: 0 0 auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 30px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.export-date-range-mode-banner {
border-radius: 8px;
padding: 6px 8px;
font-size: 11px;
line-height: 1.4;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
&.range {
border-color: rgba(var(--primary-rgb), 0.4);
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
}
.export-date-range-calendar-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-date-range-calendar-panel {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 7px;
}
.export-date-range-calendar-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.export-date-range-calendar-date-label {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 11px;
color: var(--text-secondary);
}
}
.export-date-range-date-input {
width: 100%;
min-width: 0;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 24px;
padding: 0 7px;
font-size: 11px;
&.invalid {
border-color: #e84d4d;
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
}
}
.export-date-range-calendar-nav {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-primary);
button {
width: 20px;
height: 20px;
border-radius: 5px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
padding: 0;
line-height: 1;
}
}
.export-date-range-calendar-weekdays {
margin-top: 6px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
span {
text-align: center;
font-size: 10px;
color: var(--text-tertiary);
}
}
.export-date-range-calendar-days {
margin-top: 4px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.export-date-range-calendar-day {
border: 1px solid transparent;
border-radius: 6px;
min-height: 20px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 10px;
cursor: pointer;
padding: 0;
&.outside {
color: var(--text-quaternary);
opacity: 0.75;
}
&.selected {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary);
font-weight: 600;
}
}
.export-date-range-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.export-date-range-dialog-btn {
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border-color);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
&.primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
&:hover {
background: var(--primary-hover);
}
}
&.secondary {
background: var(--bg-secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
}
@media (max-width: 860px) {
.export-date-range-calendar-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,340 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, X } from 'lucide-react'
import {
EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS,
addMonths,
buildCalendarCells,
cloneExportDateRangeSelection,
createDateRangeByPreset,
createDefaultDateRange,
formatCalendarMonthTitle,
formatDateInputValue,
isSameDay,
parseDateInputValue,
startOfDay,
endOfDay,
toMonthStart,
type ExportDateRangePreset,
type ExportDateRangeSelection
} from '../../utils/exportDateRange'
import './ExportDateRangeDialog.scss'
interface ExportDateRangeDialogProps {
open: boolean
value: ExportDateRangeSelection
title?: string
onClose: () => void
onConfirm: (value: ExportDateRangeSelection) => void
}
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
startPanelMonth: Date
endPanelMonth: Date
}
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
...cloneExportDateRangeSelection(value),
startPanelMonth: toMonthStart(value.dateRange.start),
endPanelMonth: toMonthStart(value.dateRange.end)
})
export function ExportDateRangeDialog({
open,
value,
title = '时间范围设置',
onClose,
onConfirm
}: ExportDateRangeDialogProps) {
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
const [dateInput, setDateInput] = useState({
start: formatDateInputValue(value.dateRange.start),
end: formatDateInputValue(value.dateRange.end)
})
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
useEffect(() => {
if (!open) return
const nextDraft = buildDialogDraft(value)
setDraft(nextDraft)
setDateInput({
start: formatDateInputValue(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end)
})
setDateInputError({ start: false, end: false })
}, [open, value])
useEffect(() => {
if (!open) return
setDateInput({
start: formatDateInputValue(draft.dateRange.start),
end: formatDateInputValue(draft.dateRange.end)
})
setDateInputError({ start: false, end: false })
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') {
const previewRange = createDefaultDateRange()
setDraft(prev => ({
...prev,
preset,
useAllTime: true,
dateRange: previewRange,
startPanelMonth: toMonthStart(previewRange.start),
endPanelMonth: toMonthStart(previewRange.end)
}))
return
}
const range = createDateRangeByPreset(preset)
setDraft(prev => ({
...prev,
preset,
useAllTime: false,
dateRange: range,
startPanelMonth: toMonthStart(range.start),
endPanelMonth: toMonthStart(range.end)
}))
}, [])
const updateDraftStart = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate)
setDraft(prev => {
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start,
end: nextEnd
},
startPanelMonth: toMonthStart(start),
endPanelMonth: toMonthStart(nextEnd)
}
})
}, [])
const updateDraftEnd = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
setDraft(prev => {
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
},
startPanelMonth: toMonthStart(nextStart),
endPanelMonth: toMonthStart(nextEnd)
}
})
}, [])
const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start)
if (!parsed) {
setDateInputError(prev => ({ ...prev, start: true }))
return
}
setDateInputError(prev => ({ ...prev, start: false }))
updateDraftStart(parsed)
}, [dateInput.start, updateDraftStart])
const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end)
if (!parsed) {
setDateInputError(prev => ({ ...prev, end: true }))
return
}
setDateInputError(prev => ({ ...prev, end: false }))
updateDraftEnd(parsed)
}, [dateInput.end, updateDraftEnd])
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
setDraft(prev => (
panel === 'start'
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
))
}, [])
const isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive
? '当前导出模式:按时间范围导出'
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
if (preset === 'all') return draft.useAllTime
return !draft.useAllTime && draft.preset === preset
}, [draft])
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
if (!open) return null
return createPortal(
<div className="export-date-range-dialog-overlay" onClick={onClose}>
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="export-date-range-dialog-header">
<h4>{title}</h4>
<button
type="button"
className="export-date-range-dialog-close-btn"
onClick={onClose}
aria-label="关闭时间范围设置"
>
<X size={14} />
</button>
</div>
<div className="export-date-range-preset-list">
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
const active = isPresetActive(preset.value)
return (
<button
key={preset.value}
type="button"
className={`export-date-range-preset-item ${active ? 'active' : ''}`}
onClick={() => applyPreset(preset.value)}
>
<span>{preset.label}</span>
{active && <Check size={14} />}
</button>
)
})}
</div>
<div className={`export-date-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
{modeText}
</div>
<div className="export-date-range-calendar-grid">
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
value={dateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, start: nextValue }))
if (dateInputError.start) {
setDateInputError(prev => ({ ...prev, start: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitStartFromInput()
}}
onBlur={commitStartFromInput}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`start-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{startPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
return (
<button
key={`start-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftStart(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
value={dateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, end: nextValue }))
if (dateInputError.end) {
setDateInputError(prev => ({ ...prev, end: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitEndFromInput()
}}
onBlur={commitEndFromInput}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`end-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{endPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
return (
<button
key={`end-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftEnd(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
</div>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
</button>
<button
type="button"
className="export-date-range-dialog-btn primary"
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
>
</button>
</div>
</div>
</div>,
document.body
)
}

View File

@@ -0,0 +1,459 @@
.export-defaults-settings-form {
.form-group {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.form-hint {
display: block;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.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: 120;
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);
}
.format-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
gap: 6px;
width: 100%;
margin-bottom: 10px;
}
.format-card {
width: 100%;
min-height: 0;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 10px;
text-align: left;
background: var(--bg-primary);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
transition: border-color 0.2s ease, background 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
}
.format-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.35;
}
.format-desc {
margin-top: 1px;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.35;
}
.select-option.active .option-desc {
color: var(--primary);
}
.settings-time-range-field {
margin-bottom: 10px;
}
.settings-time-range-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: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.settings-time-range-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-time-range-arrow {
color: var(--text-tertiary);
font-weight: 700;
line-height: 1;
}
.log-toggle-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-primary);
}
.media-default-grid {
width: 100%;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 12px;
margin-bottom: 10px;
label {
display: inline-flex;
align-items: center;
gap: 5px;
margin-bottom: 0;
font-size: 13px;
line-height: 1;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
}
input[type='checkbox'] {
margin: 0;
accent-color: var(--primary);
}
}
.log-status {
font-size: 13px;
color: var(--text-secondary);
}
.concurrency-inline-options {
width: 100%;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.concurrency-option {
border: 1px solid var(--border-color);
border-radius: 10px;
min-height: 38px;
padding: 0;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.switch {
position: relative;
display: inline-flex;
width: 48px;
height: 28px;
cursor: pointer;
flex-shrink: 0;
}
.switch-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
&:checked + .switch-slider {
background: var(--primary);
}
&:checked + .switch-slider::before {
transform: translateX(20px);
}
&:focus + .switch-slider {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
}
.switch-slider {
position: absolute;
inset: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 999px;
transition: all 0.2s ease;
&::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 3px;
top: 3px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease;
}
}
&.layout-split {
.form-group {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 18px;
align-items: center;
padding: 14px 0;
margin-bottom: 0;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
}
.form-group:last-child {
border-bottom: none;
padding-bottom: 0;
}
.form-group:first-child {
padding-top: 0;
}
.form-copy {
min-width: 0;
}
.form-control {
min-width: 0;
display: flex;
justify-content: flex-end;
}
.form-hint {
margin-bottom: 0;
line-height: 1.5;
}
.select-field,
.settings-time-range-field {
width: 100%;
max-width: 360px;
margin-bottom: 0;
}
.log-toggle-line {
width: 100%;
max-width: 360px;
margin-bottom: 0;
}
.media-default-grid {
max-width: 360px;
margin-bottom: 0;
}
.concurrency-inline-options {
max-width: 360px;
margin-bottom: 0;
}
.format-setting-group {
grid-template-columns: 1fr;
gap: 10px;
align-items: stretch;
}
.format-setting-group .form-control {
justify-content: flex-start;
}
.format-grid {
max-width: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 0;
}
}
}
@media (max-width: 1024px) {
.export-defaults-settings-form.layout-split {
.media-setting-group {
grid-template-columns: 1fr;
gap: 10px;
align-items: stretch;
}
.media-setting-group .form-control {
justify-content: flex-start;
}
.media-default-grid {
max-width: none;
flex-wrap: wrap;
}
}
}
@media (max-width: 760px) {
.export-defaults-settings-form.layout-split {
.form-group {
grid-template-columns: 1fr;
gap: 10px;
}
.form-control {
justify-content: flex-start;
}
.select-field,
.settings-time-range-field,
.log-toggle-line,
.media-default-grid,
.concurrency-inline-options,
.format-grid {
max-width: none;
}
.media-default-grid {
flex-wrap: wrap;
}
.format-grid {
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
}
}
}

View File

@@ -0,0 +1,389 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import * as configService from '../../services/config'
import { ExportDateRangeDialog } from './ExportDateRangeDialog'
import {
createDefaultExportDateRangeSelection,
getExportDateRangeLabel,
resolveExportDateRangeConfig,
serializeExportDateRangeConfig,
type ExportDateRangeSelection
} from '../../utils/exportDateRange'
import './ExportDefaultsSettingsForm.scss'
export interface ExportDefaultsSettingsPatch {
format?: string
avatars?: boolean
dateRange?: ExportDateRangeSelection
media?: configService.ExportDefaultMediaConfig
voiceAsText?: boolean
excelCompactColumns?: boolean
concurrency?: number
}
interface ExportDefaultsSettingsFormProps {
onNotify?: (text: string, success: boolean) => void
onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void
layout?: 'stacked' | 'split'
}
const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式CSV' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
] as const
const exportExcelColumnOptions = [
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] as const
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
return options.find((option) => option.value === value)?.label ?? value
}
export function ExportDefaultsSettingsForm({
onNotify,
onDefaultsChanged,
layout = 'stacked'
}: ExportDefaultsSettingsFormProps) {
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true,
videos: true,
voices: true,
emojis: true
})
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
useEffect(() => {
let cancelled = false
void (async () => {
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultConcurrency()
])
if (cancelled) return
setExportDefaultFormat(savedFormat || 'excel')
setExportDefaultAvatars(savedAvatars ?? true)
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
setExportDefaultMedia(savedMedia ?? {
images: true,
videos: true,
voices: true,
emojis: true
})
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2)
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportExcelColumnsSelect])
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
const notify = (text: string, success = true) => {
onNotify?.(text, success)
}
return (
<div className={`export-defaults-settings-form ${layout === 'split' ? 'layout-split' : 'layout-stacked'}`}>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint">1~6</span>
</div>
<div className="form-control">
<div className="concurrency-inline-options" role="radiogroup" aria-label="导出并发数">
{exportConcurrencyOptions.map((option) => (
<button
key={option}
type="button"
className={`concurrency-option ${exportDefaultConcurrency === option ? 'active' : ''}`}
aria-pressed={exportDefaultConcurrency === option}
onClick={async () => {
setExportDefaultConcurrency(option)
await configService.setExportDefaultConcurrency(option)
onDefaultsChanged?.({ concurrency: option })
notify(`已将导出并发数设为 ${option}`, true)
}}
>
{option}
</button>
))}
</div>
</div>
</div>
<div className="form-group format-setting-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="format-grid">
{exportFormatOptions.map((option) => (
<button
key={option.value}
type="button"
className={`format-card ${exportDefaultFormat === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFormat(option.value)
await configService.setExportDefaultFormat(option.value)
onDefaultsChanged?.({ format: option.value })
notify('已更新导出格式默认值', true)
}}
>
<span className="format-label">{option.label}</span>
<span className="format-desc">{option.desc}</span>
</button>
))}
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="log-toggle-line">
<span className="log-status">{exportDefaultAvatars ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="shared-export-default-avatars">
<input
id="shared-export-default-avatars"
className="switch-input"
type="checkbox"
checked={exportDefaultAvatars}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultAvatars(enabled)
await configService.setExportDefaultAvatars(enabled)
onDefaultsChanged?.({ avatars: enabled })
notify(enabled ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="settings-time-range-field">
<button
type="button"
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(false)
setIsExportDateRangeDialogOpen(true)
}}
>
<span className="settings-time-range-value">{exportDateRangeLabel}</span>
<span className="settings-time-range-arrow">&gt;</span>
</button>
</div>
</div>
</div>
<ExportDateRangeDialog
open={isExportDateRangeDialogOpen}
value={exportDefaultDateRange}
onClose={() => setIsExportDateRangeDialogOpen(false)}
onConfirm={async (nextSelection) => {
setExportDefaultDateRange(nextSelection)
await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection))
onDefaultsChanged?.({ dateRange: nextSelection })
notify('已更新默认导出时间范围', true)
setIsExportDateRangeDialogOpen(false)
}}
/>
<div className="form-group">
<div className="form-copy">
<label>Excel </label>
<span className="form-hint"> Excel </span>
</div>
<div className="form-control">
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setIsExportDateRangeDialogOpen(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
<ChevronDown size={16} />
</button>
{showExportExcelColumnsSelect && (
<div className="select-dropdown">
{exportExcelColumnOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
onClick={async () => {
const compact = option.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
onDefaultsChanged?.({ excelCompactColumns: compact })
notify(compact ? '已启用精简列' : '已启用完整列', true)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="form-group media-setting-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="media-default-grid">
<label>
<input
type="checkbox"
checked={exportDefaultMedia.images}
onChange={async (e) => {
const next = { ...exportDefaultMedia, images: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出图片`, true)
}}
/>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.voices}
onChange={async (e) => {
const next = { ...exportDefaultMedia, voices: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出语音`, true)
}}
/>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.videos}
onChange={async (e) => {
const next = { ...exportDefaultMedia, videos: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出视频`, true)
}}
/>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.emojis}
onChange={async (e) => {
const next = { ...exportDefaultMedia, emojis: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出表情包`, true)
}}
/>
</label>
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="log-toggle-line">
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="shared-export-default-voice-as-text">
<input
id="shared-export-default-voice-as-text"
className="switch-input"
type="checkbox"
checked={exportDefaultVoiceAsText}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultVoiceAsText(enabled)
await configService.setExportDefaultVoiceAsText(enabled)
onDefaultsChanged?.({ voiceAsText: enabled })
notify(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -6,6 +6,7 @@ interface JumpToDatePopoverProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onSelect: (date: Date) => void onSelect: (date: Date) => void
onMonthChange?: (date: Date) => void
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
currentDate?: Date currentDate?: Date
@@ -20,6 +21,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
isOpen, isOpen,
onClose, onClose,
onSelect, onSelect,
onMonthChange,
className, className,
style, style,
currentDate = new Date(), currentDate = new Date(),
@@ -112,13 +114,17 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
const weekdays = ['日', '一', '二', '三', '四', '五', '六'] const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar() const days = generateCalendar()
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim() const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
const updateCalendarDate = (nextDate: Date) => {
setCalendarDate(nextDate)
onMonthChange?.(nextDate)
}
return ( return (
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期"> <div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
<div className="calendar-nav"> <div className="calendar-nav">
<button <button
className="nav-btn" className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))} onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
aria-label="上一月" aria-label="上一月"
> >
<ChevronLeft size={16} /> <ChevronLeft size={16} />
@@ -126,7 +132,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
<span className="current-month">{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}</span> <span className="current-month">{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}</span>
<button <button
className="nav-btn" className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))} onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
aria-label="下一月" aria-label="下一月"
> >
<ChevronRight size={16} /> <ChevronRight size={16} />

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react' import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config' import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
@@ -64,6 +64,7 @@ const normalizeAccountId = (value?: string | null): string => {
function Sidebar() { function Sidebar() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0) const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
@@ -465,16 +466,20 @@ function Sidebar() {
</div> </div>
</div> </div>
{authEnabled && (
<button <button
className="nav-item" className="nav-item"
onClick={() => setLocked(true)} onClick={() => {
title={collapsed ? '锁定' : undefined} if (authEnabled) {
setLocked(true)
return
}
navigate('/settings', { state: { initialTab: 'security' } })
}}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
> >
<span className="nav-icon"><Lock size={20} /></span> <span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
<span className="nav-label"></span> <span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
</button> </button>
)}
<NavLink <NavLink
to="/settings" to="/settings"

View File

@@ -0,0 +1,329 @@
.contact-sns-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: rgba(15, 23, 42, 0.38);
}
.contact-sns-dialog {
width: min(760px, 100%);
max-height: min(86vh, 860px);
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, #ffffff);
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
display: flex;
flex-direction: column;
overflow: hidden;
.spin {
animation: contactSnsDialogSpin 1s linear infinite;
}
.contact-sns-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
}
.contact-sns-dialog-header-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.contact-sns-dialog-avatar {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 14px;
font-weight: 600;
}
}
.contact-sns-dialog-meta {
min-width: 0;
h4 {
margin: 0;
font-size: 15px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.contact-sns-dialog-username {
margin-top: 2px;
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-sns-dialog-stats {
margin-top: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.contact-sns-dialog-header-actions {
display: flex;
align-items: flex-start;
gap: 8px;
flex-shrink: 0;
}
.contact-sns-dialog-rank-switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
}
.contact-sns-dialog-rank-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
height: 28px;
padding: 0 10px;
font-size: 12px;
line-height: 1;
cursor: pointer;
white-space: nowrap;
&:hover {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
}
&.active {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
}
}
.contact-sns-dialog-rank-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 248px;
max-height: calc((28px * 15) + 16px);
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
border-radius: 10px;
background: var(--bg-primary);
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18);
padding: 8px;
z-index: 12;
}
.contact-sns-dialog-rank-empty {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
text-align: center;
padding: 6px 0;
}
.contact-sns-dialog-rank-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 4px 0 8px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.contact-sns-dialog-rank-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 4px;
border-radius: 7px;
&:hover {
background: var(--bg-hover);
}
}
.contact-sns-dialog-rank-index {
font-size: 12px;
color: var(--text-tertiary);
text-align: right;
font-variant-numeric: tabular-nums;
}
.contact-sns-dialog-rank-name {
font-size: 12px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-sns-dialog-rank-count {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.contact-sns-dialog-close-btn {
border: none;
background: transparent;
color: var(--text-secondary);
width: 28px;
height: 28px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.contact-sns-dialog-tip {
padding: 10px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
word-break: break-word;
}
.contact-sns-dialog-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 16px 14px;
}
.contact-sns-dialog-posts-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.contact-sns-dialog-posts-list .post-header-actions {
display: none;
}
.contact-sns-dialog-status {
padding: 20px 12px;
text-align: center;
font-size: 13px;
color: var(--text-secondary);
&.empty {
color: var(--text-tertiary);
}
}
.contact-sns-dialog-load-more {
display: block;
margin: 12px auto 0;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 10px;
padding: 9px 18px;
font-size: 13px;
cursor: pointer;
&:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.72;
}
}
}
@media (max-width: 768px) {
.contact-sns-dialog-overlay {
padding: 12px 8px;
}
.contact-sns-dialog {
width: min(100vw - 16px, 760px);
max-height: calc(100vh - 24px);
.contact-sns-dialog-header {
padding: 12px;
}
.contact-sns-dialog-header-actions {
gap: 6px;
}
.contact-sns-dialog-rank-btn {
height: 26px;
padding: 0 8px;
font-size: 11px;
}
.contact-sns-dialog-rank-panel {
width: min(78vw, 232px);
}
.contact-sns-dialog-tip {
padding: 10px 12px;
line-height: 1.55;
}
.contact-sns-dialog-body {
padding: 10px 10px 12px;
}
}
}
@keyframes contactSnsDialogSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,593 @@
import { createPortal } from 'react-dom'
import { Loader2, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SnsPostItem } from './SnsPostItem'
import type { SnsPost } from '../../types/sns'
import {
type ContactSnsRankItem,
type ContactSnsRankMode,
type ContactSnsTimelineTarget,
getAvatarLetter
} from './contactSnsTimeline'
import './ContactSnsTimelineDialog.scss'
const TIMELINE_PAGE_SIZE = 20
const SNS_RANK_PAGE_SIZE = 50
const SNS_RANK_DISPLAY_LIMIT = 15
interface ContactSnsRankCacheEntry {
likes: ContactSnsRankItem[]
comments: ContactSnsRankItem[]
totalPosts: number
}
interface ContactSnsTimelineDialogProps {
target: ContactSnsTimelineTarget | null
onClose: () => void
initialTotalPosts?: number | null
initialTotalPostsLoading?: boolean
isProtected?: boolean
onDeletePost?: (postId: string, username: string) => void
}
const normalizeTotalPosts = (value?: number | null): number | null => {
if (!Number.isFinite(value)) return null
return Math.max(0, Math.floor(Number(value)))
}
const formatYmdDateFromSeconds = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => {
const likeMap = new Map<string, ContactSnsRankItem>()
const commentMap = new Map<string, ContactSnsRankItem>()
for (const post of posts) {
const createTime = Number(post?.createTime) || 0
const likes = Array.isArray(post?.likes) ? post.likes : []
const comments = Array.isArray(post?.comments) ? post.comments : []
for (const likeNameRaw of likes) {
const name = String(likeNameRaw || '').trim() || '未知用户'
const current = likeMap.get(name)
if (current) {
current.count += 1
if (createTime > current.latestTime) current.latestTime = createTime
continue
}
likeMap.set(name, { name, count: 1, latestTime: createTime })
}
for (const comment of comments) {
const name = String(comment?.nickname || '').trim() || '未知用户'
const current = commentMap.get(name)
if (current) {
current.count += 1
if (createTime > current.latestTime) current.latestTime = createTime
continue
}
commentMap.set(name, { name, count: 1, latestTime: createTime })
}
}
const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => {
if (right.count !== left.count) return right.count - left.count
if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime
return left.name.localeCompare(right.name, 'zh-CN')
}
return {
likes: [...likeMap.values()].sort(sorter),
comments: [...commentMap.values()].sort(sorter)
}
}
export function ContactSnsTimelineDialog({
target,
onClose,
initialTotalPosts = null,
initialTotalPostsLoading = false,
isProtected = false,
onDeletePost
}: ContactSnsTimelineDialogProps) {
const [timelinePosts, setTimelinePosts] = useState<SnsPost[]>([])
const [timelineLoading, setTimelineLoading] = useState(false)
const [timelineLoadingMore, setTimelineLoadingMore] = useState(false)
const [timelineHasMore, setTimelineHasMore] = useState(false)
const [timelineTotalPosts, setTimelineTotalPosts] = useState<number | null>(null)
const [timelineStatsLoading, setTimelineStatsLoading] = useState(false)
const [rankMode, setRankMode] = useState<ContactSnsRankMode | null>(null)
const [likeRankings, setLikeRankings] = useState<ContactSnsRankItem[]>([])
const [commentRankings, setCommentRankings] = useState<ContactSnsRankItem[]>([])
const [rankLoading, setRankLoading] = useState(false)
const [rankError, setRankError] = useState<string | null>(null)
const [rankLoadedPosts, setRankLoadedPosts] = useState(0)
const [rankTotalPosts, setRankTotalPosts] = useState<number | null>(null)
const timelinePostsRef = useRef<SnsPost[]>([])
const timelineLoadingRef = useRef(false)
const timelineRequestTokenRef = useRef(0)
const totalPostsRequestTokenRef = useRef(0)
const rankRequestTokenRef = useRef(0)
const rankLoadingRef = useRef(false)
const rankCacheRef = useRef<Record<string, ContactSnsRankCacheEntry>>({})
const targetUsername = String(target?.username || '').trim()
const targetDisplayName = target?.displayName || targetUsername
const targetAvatarUrl = target?.avatarUrl
useEffect(() => {
timelinePostsRef.current = timelinePosts
}, [timelinePosts])
const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => {
const reset = Boolean(options?.reset)
if (timelineLoadingRef.current) return
timelineLoadingRef.current = true
if (reset) {
setTimelineLoading(true)
setTimelineLoadingMore(false)
setTimelineHasMore(false)
} else {
setTimelineLoadingMore(true)
}
const requestToken = ++timelineRequestTokenRef.current
try {
let endTime: number | undefined
if (!reset && timelinePostsRef.current.length > 0) {
endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1
}
const result = await window.electronAPI.sns.getTimeline(
TIMELINE_PAGE_SIZE,
0,
[nextTarget.username],
'',
undefined,
endTime
)
if (requestToken !== timelineRequestTokenRef.current) return
if (!result.success || !Array.isArray(result.timeline)) {
if (reset) {
setTimelinePosts([])
setTimelineHasMore(false)
}
return
}
const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
if (reset) {
setTimelinePosts(timeline)
setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE)
return
}
const existingIds = new Set(timelinePostsRef.current.map((post) => post.id))
const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id))
if (uniqueOlder.length > 0) {
const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime)
setTimelinePosts(merged)
}
if (timeline.length < TIMELINE_PAGE_SIZE) {
setTimelineHasMore(false)
}
} catch (error) {
console.error('加载联系人朋友圈失败:', error)
if (requestToken === timelineRequestTokenRef.current && reset) {
setTimelinePosts([])
setTimelineHasMore(false)
}
} finally {
if (requestToken === timelineRequestTokenRef.current) {
timelineLoadingRef.current = false
setTimelineLoading(false)
setTimelineLoadingMore(false)
}
}
}, [])
const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
const requestToken = ++totalPostsRequestTokenRef.current
setTimelineStatsLoading(true)
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (requestToken !== totalPostsRequestTokenRef.current) return
if (!result.success || !result.counts) {
setTimelineTotalPosts(null)
setRankTotalPosts(null)
return
}
const rawCount = Number(result.counts[nextTarget.username] || 0)
const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
setTimelineTotalPosts(normalized)
setRankTotalPosts(normalized)
} catch (error) {
console.error('加载联系人朋友圈条数失败:', error)
if (requestToken !== totalPostsRequestTokenRef.current) return
setTimelineTotalPosts(null)
setRankTotalPosts(null)
} finally {
if (requestToken === totalPostsRequestTokenRef.current) {
setTimelineStatsLoading(false)
}
}
}, [])
const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
const normalizedUsername = String(nextTarget?.username || '').trim()
if (!normalizedUsername || rankLoadingRef.current) return
const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts)
const cached = rankCacheRef.current[normalizedUsername]
if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) {
setLikeRankings(cached.likes)
setCommentRankings(cached.comments)
setRankLoadedPosts(cached.totalPosts)
setRankTotalPosts(cached.totalPosts)
setRankError(null)
setRankLoading(false)
return
}
rankLoadingRef.current = true
const requestToken = ++rankRequestTokenRef.current
setRankLoading(true)
setRankError(null)
setRankLoadedPosts(0)
setRankTotalPosts(normalizedKnownTotal)
try {
const allPosts: SnsPost[] = []
let endTime: number | undefined
let hasMore = true
while (hasMore) {
const result = await window.electronAPI.sns.getTimeline(
SNS_RANK_PAGE_SIZE,
0,
[normalizedUsername],
'',
undefined,
endTime
)
if (requestToken !== rankRequestTokenRef.current) return
if (!result.success) {
throw new Error(result.error || '加载朋友圈排行失败')
}
const pagePosts = Array.isArray(result.timeline)
? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
: []
if (pagePosts.length === 0) {
hasMore = false
break
}
allPosts.push(...pagePosts)
setRankLoadedPosts(allPosts.length)
if (normalizedKnownTotal === null) {
setRankTotalPosts(allPosts.length)
}
endTime = pagePosts[pagePosts.length - 1].createTime - 1
hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE
}
if (requestToken !== rankRequestTokenRef.current) return
const rankings = buildContactSnsRankings(allPosts)
const totalPosts = allPosts.length
rankCacheRef.current[normalizedUsername] = {
likes: rankings.likes,
comments: rankings.comments,
totalPosts
}
setLikeRankings(rankings.likes)
setCommentRankings(rankings.comments)
setRankLoadedPosts(totalPosts)
setRankTotalPosts(totalPosts)
setRankError(null)
} catch (error) {
if (requestToken !== rankRequestTokenRef.current) return
const message = error instanceof Error ? error.message : String(error)
setLikeRankings([])
setCommentRankings([])
setRankError(message || '加载朋友圈排行失败')
} finally {
if (requestToken === rankRequestTokenRef.current) {
rankLoadingRef.current = false
setRankLoading(false)
}
}
}, [timelineTotalPosts])
useEffect(() => {
if (!targetUsername) return
totalPostsRequestTokenRef.current += 1
rankRequestTokenRef.current += 1
rankLoadingRef.current = false
setRankMode(null)
setLikeRankings([])
setCommentRankings([])
setRankLoading(false)
setRankError(null)
setRankLoadedPosts(0)
setRankTotalPosts(null)
setTimelinePosts([])
setTimelineTotalPosts(null)
setTimelineStatsLoading(false)
setTimelineHasMore(false)
setTimelineLoadingMore(false)
setTimelineLoading(false)
void loadTimelinePosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
}, { reset: true })
}, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername])
useEffect(() => {
if (!targetUsername) return
const normalizedTotal = normalizeTotalPosts(initialTotalPosts)
if (normalizedTotal !== null) {
setTimelineTotalPosts(normalizedTotal)
setRankTotalPosts(normalizedTotal)
setTimelineStatsLoading(false)
return
}
if (initialTotalPostsLoading) {
setTimelineTotalPosts(null)
setRankTotalPosts(null)
setTimelineStatsLoading(true)
return
}
void loadTimelineTotalPosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
})
}, [
initialTotalPosts,
initialTotalPostsLoading,
loadTimelineTotalPosts,
targetAvatarUrl,
targetDisplayName,
targetUsername
])
useEffect(() => {
if (timelineTotalPosts === null) return
if (timelinePosts.length >= timelineTotalPosts) {
setTimelineHasMore(false)
}
}, [timelinePosts.length, timelineTotalPosts])
useEffect(() => {
if (!rankMode || !targetUsername) return
void loadRankings({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
})
}, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername])
useEffect(() => {
if (!targetUsername) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, targetUsername])
const timelineStatsText = useMemo(() => {
const loadedCount = timelinePosts.length
const loadPart = timelineStatsLoading
? `已加载 ${loadedCount} / 总数统计中...`
: timelineTotalPosts === null
? `已加载 ${loadedCount}`
: `已加载 ${loadedCount} / 共 ${timelineTotalPosts}`
if (timelineLoading && loadedCount === 0) return `${loadPart} 加载中...`
if (loadedCount === 0) return loadPart
const latest = timelinePosts[0]?.createTime
const earliest = timelinePosts[timelinePosts.length - 1]?.createTime
return `${loadPart} ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}`
}, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts])
const activeRankings = useMemo(() => {
if (rankMode === 'likes') return likeRankings
if (rankMode === 'comments') return commentRankings
return []
}, [commentRankings, likeRankings, rankMode])
const loadMore = useCallback(() => {
if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return
void loadTimelinePosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
}, { reset: false })
}, [
loadTimelinePosts,
targetAvatarUrl,
targetDisplayName,
targetUsername,
timelineHasMore,
timelineLoading,
timelineLoadingMore
])
const handleBodyScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget
const remaining = element.scrollHeight - element.scrollTop - element.clientHeight
if (remaining <= 160) {
loadMore()
}
}, [loadMore])
const toggleRankMode = useCallback((mode: ContactSnsRankMode) => {
setRankMode((previous) => (previous === mode ? null : mode))
}, [])
if (!target) return null
return createPortal(
<div className="contact-sns-dialog-overlay" onClick={onClose}>
<div
className="contact-sns-dialog"
role="dialog"
aria-modal="true"
aria-label="联系人朋友圈"
onClick={(event) => event.stopPropagation()}
>
<div className="contact-sns-dialog-header">
<div className="contact-sns-dialog-header-main">
<div className="contact-sns-dialog-avatar">
{targetAvatarUrl ? (
<img src={targetAvatarUrl} alt="" />
) : (
<span>{getAvatarLetter(targetDisplayName)}</span>
)}
</div>
<div className="contact-sns-dialog-meta">
<h4>{targetDisplayName}</h4>
<div className="contact-sns-dialog-username">@{targetUsername}</div>
<div className="contact-sns-dialog-stats">{timelineStatsText}</div>
</div>
</div>
<div className="contact-sns-dialog-header-actions">
<div className="contact-sns-dialog-rank-switch">
<button
type="button"
className={`contact-sns-dialog-rank-btn ${rankMode === 'likes' ? 'active' : ''}`}
onClick={() => toggleRankMode('likes')}
>
</button>
<button
type="button"
className={`contact-sns-dialog-rank-btn ${rankMode === 'comments' ? 'active' : ''}`}
onClick={() => toggleRankMode('comments')}
>
</button>
{rankMode && (
<div
className="contact-sns-dialog-rank-panel"
role="region"
aria-label={rankMode === 'likes' ? '点赞排行' : '评论排行'}
>
{rankLoading && (
<div className="contact-sns-dialog-rank-loading">
<Loader2 size={12} className="spin" />
<span>
{rankTotalPosts !== null && rankTotalPosts > 0
? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts}`
: `统计中,已加载 ${rankLoadedPosts}`}
</span>
</div>
)}
{!rankLoading && rankError ? (
<div className="contact-sns-dialog-rank-empty">{rankError}</div>
) : !rankLoading && activeRankings.length === 0 ? (
<div className="contact-sns-dialog-rank-empty">
{rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
</div>
) : (
activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
<div className="contact-sns-dialog-rank-row" key={`${rankMode}-${item.name}`}>
<span className="contact-sns-dialog-rank-index">{index + 1}</span>
<span className="contact-sns-dialog-rank-name" title={item.name}>{item.name}</span>
<span className="contact-sns-dialog-rank-count">
{item.count.toLocaleString('zh-CN')}
{rankMode === 'likes' ? '次' : '条'}
</span>
</div>
))
)}
</div>
)}
</div>
<button className="contact-sns-dialog-close-btn" type="button" onClick={onClose}>
<X size={16} />
</button>
</div>
</div>
<div className="contact-sns-dialog-tip">
</div>
<div
className="contact-sns-dialog-body"
onScroll={handleBodyScroll}
>
{timelinePosts.length > 0 && (
<div className="contact-sns-dialog-posts-list">
{timelinePosts.map((post) => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={() => {}}
onDelete={onDeletePost}
hideAuthorMeta
/>
))}
</div>
)}
{timelineLoading && (
<div className="contact-sns-dialog-status">...</div>
)}
{!timelineLoading && timelinePosts.length === 0 && (
<div className="contact-sns-dialog-status empty"></div>
)}
{!timelineLoading && timelineHasMore && (
<button
className="contact-sns-dialog-load-more"
type="button"
onClick={loadMore}
disabled={timelineLoadingMore}
>
{timelineLoadingMore ? '正在加载...' : '加载更多'}
</button>
)}
</div>
</div>
</div>,
document.body
)
}

View File

@@ -1,60 +1,52 @@
import React, { useState } from 'react' import React from 'react'
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react' import { Search, User, X, Loader2 } from 'lucide-react'
import { Avatar } from '../Avatar' import { Avatar } from '../Avatar'
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
interface Contact { interface Contact {
username: string username: string
displayName: string displayName: string
avatarUrl?: string avatarUrl?: string
postCount?: number
postCountStatus?: 'idle' | 'loading' | 'ready'
}
interface ContactsCountProgress {
resolved: number
total: number
running: boolean
} }
interface SnsFilterPanelProps { interface SnsFilterPanelProps {
searchKeyword: string searchKeyword: string
setSearchKeyword: (val: string) => void setSearchKeyword: (val: string) => void
jumpTargetDate?: Date totalFriendsLabel?: string
setJumpTargetDate: (date?: Date) => void
onOpenJumpDialog: () => void
selectedUsernames: string[]
setSelectedUsernames: (val: string[]) => void
contacts: Contact[] contacts: Contact[]
contactSearch: string contactSearch: string
setContactSearch: (val: string) => void setContactSearch: (val: string) => void
loading?: boolean loading?: boolean
contactsCountProgress?: ContactsCountProgress
onOpenContactTimeline: (contact: Contact) => void
} }
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword, searchKeyword,
setSearchKeyword, setSearchKeyword,
jumpTargetDate, totalFriendsLabel,
setJumpTargetDate,
onOpenJumpDialog,
selectedUsernames,
setSelectedUsernames,
contacts, contacts,
contactSearch, contactSearch,
setContactSearch, setContactSearch,
loading loading,
contactsCountProgress,
onOpenContactTimeline
}) => { }) => {
const filteredContacts = contacts.filter(c => const filteredContacts = contacts.filter(c =>
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase()) c.username.toLowerCase().includes(contactSearch.toLowerCase())
) )
const toggleUserSelection = (username: string) => {
if (selectedUsernames.includes(username)) {
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
} else {
setJumpTargetDate(undefined) // Reset date jump when selecting user
setSelectedUsernames([...selectedUsernames, username])
}
}
const clearFilters = () => { const clearFilters = () => {
setSearchKeyword('') setSearchKeyword('')
setSelectedUsernames([]) setContactSearch('')
setJumpTargetDate(undefined)
} }
const getEmptyStateText = () => { const getEmptyStateText = () => {
@@ -71,7 +63,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<aside className="sns-filter-panel"> <aside className="sns-filter-panel">
<div className="filter-header"> <div className="filter-header">
<h3></h3> <h3></h3>
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && ( {(searchKeyword || contactSearch) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选"> <button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
@@ -99,43 +91,13 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
)} )}
</div> </div>
</div> </div>
{/* Date Widget */}
<div className="filter-widget date-widget">
<div className="widget-header">
<Calendar size={14} />
<span></span>
</div>
<button
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
onClick={onOpenJumpDialog}
>
<span className="date-text">
{jumpTargetDate
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
: '选择日期...'}
</span>
{jumpTargetDate && (
<div
className="clear-date-btn"
onClick={(e) => {
e.stopPropagation()
setJumpTargetDate(undefined)
}}
>
<X size={12} />
</div>
)}
</button>
</div>
{/* Contact Widget */} {/* Contact Widget */}
<div className="filter-widget contact-widget"> <div className="filter-widget contact-widget">
<div className="widget-header"> <div className="widget-header">
<User size={14} /> <User size={14} />
<span></span> <span></span>
{selectedUsernames.length > 0 && ( {totalFriendsLabel && (
<span className="badge">{selectedUsernames.length}</span> <span className="widget-header-summary">{totalFriendsLabel}</span>
)} )}
</div> </div>
@@ -152,18 +114,36 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
)} )}
</div> </div>
{contactsCountProgress && contactsCountProgress.total > 0 && (
<div className="contact-count-progress">
{contactsCountProgress.running
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
</div>
)}
<div className="contact-list-scroll"> <div className="contact-list-scroll">
{filteredContacts.map(contact => { {filteredContacts.map(contact => {
const isPostCountReady = contact.postCountStatus === 'ready'
return ( return (
<div <div
key={contact.username} key={contact.username}
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`} className="contact-row"
onClick={() => toggleUserSelection(contact.username)} onClick={() => onOpenContactTimeline(contact)}
> >
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" /> <Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta"> <div className="contact-meta">
<span className="contact-name">{contact.displayName}</span> <span className="contact-name">{contact.displayName}</span>
</div> </div>
<div className="contact-post-count-wrap">
{isPostCountReady ? (
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}</span>
) : (
<span className="contact-post-count-loading" title="统计中">
<Loader2 size={13} className="spinning" />
</span>
)}
</div>
</div> </div>
) )
})} })}

View File

@@ -243,10 +243,12 @@ interface SnsPostItemProps {
post: SnsPost post: SnsPost
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onDebug: (post: SnsPost) => void onDebug: (post: SnsPost) => void
onDelete?: (postId: string) => void onDelete?: (postId: string, username: string) => void
onOpenAuthorPosts?: (post: SnsPost) => void
hideAuthorMeta?: boolean
} }
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => { export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
const [mediaDeleted, setMediaDeleted] = useState(false) const [mediaDeleted, setMediaDeleted] = useState(false)
const [dbDeleted, setDbDeleted] = useState(false) const [dbDeleted, setDbDeleted] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -299,31 +301,56 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
if (r.success) { if (r.success) {
setDbDeleted(true) setDbDeleted(true)
onDelete?.(post.id) onDelete?.(post.id, post.username)
} }
} finally { } finally {
setDeleting(false) setDeleting(false)
} }
} }
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
e.stopPropagation()
onOpenAuthorPosts?.(post)
}
return ( return (
<> <>
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}> <div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
{!hideAuthorMeta && (
<div className="post-avatar-col"> <div className="post-avatar-col">
<button
type="button"
className="author-trigger-btn avatar-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<Avatar <Avatar
src={post.avatarUrl} src={post.avatarUrl}
name={post.nickname} name={post.nickname}
size={48} size={48}
shape="rounded" shape="rounded"
/> />
</button>
</div> </div>
)}
<div className="post-content-col"> <div className="post-content-col">
<div className="post-header-row"> <div className="post-header-row">
{hideAuthorMeta ? (
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
) : (
<div className="post-author-info"> <div className="post-author-info">
<button
type="button"
className="author-trigger-btn author-name-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span> <span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
</button>
<span className="post-time">{formatTime(post.createTime)}</span> <span className="post-time">{formatTime(post.createTime)}</span>
</div> </div>
)}
<div className="post-header-actions"> <div className="post-header-actions">
{(mediaDeleted || dbDeleted) && ( {(mediaDeleted || dbDeleted) && (
<span className="post-deleted-badge"> <span className="post-deleted-badge">

View File

@@ -0,0 +1,26 @@
export interface ContactSnsTimelineTarget {
username: string
displayName: string
avatarUrl?: string
}
export interface ContactSnsRankItem {
name: string
count: number
latestTime: number
}
export type ContactSnsRankMode = 'likes' | 'comments'
export const isSingleContactSession = (sessionId: string): boolean => {
const normalized = String(sessionId || '').trim()
if (!normalized) return false
if (normalized.includes('@chatroom')) return false
if (normalized.startsWith('gh_')) return false
return true
}
export const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}

View File

@@ -1783,6 +1783,30 @@
z-index: 2; z-index: 2;
} }
.standalone-phase-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
color: var(--text-secondary);
font-size: 14px;
pointer-events: none;
.spin {
animation: spin 1s linear infinite;
}
small {
color: var(--text-tertiary);
font-size: 12px;
}
}
.empty-chat-inline { .empty-chat-inline {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown } from 'lucide-react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
@@ -11,6 +11,8 @@ import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon' import { LivePhotoIcon } from '../components/LivePhotoIcon'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDatePopover from '../components/JumpToDatePopover' import JumpToDatePopover from '../components/JumpToDatePopover'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import * as configService from '../services/config' import * as configService from '../services/config'
import { import {
emitOpenSingleExport, emitOpenSingleExport,
@@ -204,8 +206,13 @@ function formatYmdHmDateTime(timestamp?: number): string {
interface ChatPageProps { interface ChatPageProps {
standaloneSessionWindow?: boolean standaloneSessionWindow?: boolean
initialSessionId?: string | null initialSessionId?: string | null
standaloneSource?: string | null
standaloneInitialDisplayName?: string | null
standaloneInitialAvatarUrl?: string | null
standaloneInitialContactType?: string | null
} }
type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready'
interface SessionDetail { interface SessionDetail {
wxid: string wxid: string
@@ -408,8 +415,20 @@ const SessionItem = React.memo(function SessionItem({
function ChatPage(props: ChatPageProps) { function ChatPage(props: ChatPageProps) {
const { standaloneSessionWindow = false, initialSessionId = null } = props const {
standaloneSessionWindow = false,
initialSessionId = null,
standaloneSource = null,
standaloneInitialDisplayName = null,
standaloneInitialAvatarUrl = null,
standaloneInitialContactType = null
} = props
const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource])
const normalizedStandaloneInitialDisplayName = useMemo(() => String(standaloneInitialDisplayName || '').trim(), [standaloneInitialDisplayName])
const normalizedStandaloneInitialAvatarUrl = useMemo(() => String(standaloneInitialAvatarUrl || '').trim(), [standaloneInitialAvatarUrl])
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
const navigate = useNavigate() const navigate = useNavigate()
const { const {
@@ -493,11 +512,17 @@ function ChatPage(props: ChatPageProps) {
const [hasInitialMessages, setHasInitialMessages] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [isSessionSwitching, setIsSessionSwitching] = useState(false) const [isSessionSwitching, setIsSessionSwitching] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false)
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null) const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(normalizedStandaloneInitialDisplayName || null)
const [fallbackAvatarUrl, setFallbackAvatarUrl] = useState<string | null>(normalizedStandaloneInitialAvatarUrl || null)
const [standaloneLoadStage, setStandaloneLoadStage] = useState<StandaloneLoadStage>(
standaloneSessionWindow && normalizedInitialSessionId ? 'connecting' : 'idle'
)
const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set()) const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
const [exportPrepareHint, setExportPrepareHint] = useState('') const [exportPrepareHint, setExportPrepareHint] = useState('')
// 消息右键菜单 // 消息右键菜单
@@ -2408,9 +2433,9 @@ function ChatPage(props: ChatPageProps) {
}, [appendMessages, getMessageKey]) }, [appendMessages, getMessageKey])
// 选择会话 // 选择会话
const selectSessionById = useCallback((sessionId: string) => { const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId || normalizedSessionId === currentSessionId) return if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1 const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
sessionSwitchRequestSeqRef.current = switchRequestSeq sessionSwitchRequestSeqRef.current = switchRequestSeq
@@ -2734,7 +2759,7 @@ function ChatPage(props: ChatPageProps) {
}, [currentSessionId, messages.length, isLoadingMessages]) }, [currentSessionId, messages.length, isLoadingMessages])
useEffect(() => { useEffect(() => {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) { if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
if (pendingSessionLoadRef.current === currentSessionId) return if (pendingSessionLoadRef.current === currentSessionId) return
if (initialLoadRequestedSessionRef.current === currentSessionId) return if (initialLoadRequestedSessionRef.current === currentSessionId) return
initialLoadRequestedSessionRef.current = currentSessionId initialLoadRequestedSessionRef.current = currentSessionId
@@ -2745,7 +2770,7 @@ function ChatPage(props: ChatPageProps) {
forceInitialLimit: 30 forceInitialLimit: 30
}) })
} }
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) }, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -2906,7 +2931,21 @@ function ChatPage(props: ChatPageProps) {
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback
const currentSession = (() => { const currentSession = (() => {
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found || !currentSessionId) return found if (found) {
if (
standaloneSessionWindow &&
normalizedInitialSessionId &&
found.username === normalizedInitialSessionId
) {
return {
...found,
displayName: found.displayName || fallbackDisplayName || found.username,
avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined
}
}
return found
}
if (!currentSessionId) return found
return { return {
username: currentSessionId, username: currentSessionId,
type: 0, type: 0,
@@ -2916,6 +2955,7 @@ function ChatPage(props: ChatPageProps) {
lastTimestamp: 0, lastTimestamp: 0,
lastMsgType: 0, lastMsgType: 0,
displayName: fallbackDisplayName || currentSessionId, displayName: fallbackDisplayName || currentSessionId,
avatarUrl: fallbackAvatarUrl || undefined,
} as ChatSession } as ChatSession
})() })()
const filteredGroupPanelMembers = useMemo(() => { const filteredGroupPanelMembers = useMemo(() => {
@@ -2935,33 +2975,135 @@ function ChatPage(props: ChatPageProps) {
}, [groupMemberSearchKeyword, groupPanelMembers]) }, [groupMemberSearchKeyword, groupPanelMembers])
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId)) const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
const isCurrentSessionGroup = Boolean(
currentSession && (
isGroupChatSession(currentSession.username) ||
(
standaloneSessionWindow &&
currentSession.username === normalizedInitialSessionId &&
normalizedStandaloneInitialContactType === 'group'
)
)
)
const isCurrentSessionPrivateSnsSupported = Boolean(
currentSession &&
isSingleContactSession(currentSession.username) &&
!isCurrentSessionGroup
)
const openCurrentSessionSnsTimeline = useCallback(() => {
if (!currentSession || !isCurrentSessionPrivateSnsSupported) return
setChatSnsTimelineTarget({
username: currentSession.username,
displayName: currentSession.displayName || currentSession.username,
avatarUrl: currentSession.avatarUrl
})
}, [currentSession, isCurrentSessionPrivateSnsSupported])
useEffect(() => {
if (!standaloneSessionWindow) return
setStandaloneInitialLoadRequested(false)
setStandaloneLoadStage(normalizedInitialSessionId ? 'connecting' : 'idle')
setFallbackDisplayName(normalizedStandaloneInitialDisplayName || null)
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl || null)
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
normalizedStandaloneInitialDisplayName,
normalizedStandaloneInitialAvatarUrl
])
useEffect(() => {
if (!standaloneSessionWindow) return
if (!normalizedInitialSessionId) return
if (normalizedStandaloneInitialDisplayName) {
setFallbackDisplayName(normalizedStandaloneInitialDisplayName)
}
if (normalizedStandaloneInitialAvatarUrl) {
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl)
}
if (!currentSessionId) {
setCurrentSession(normalizedInitialSessionId, { preserveMessages: false })
}
if (!isConnected || isConnecting) {
setStandaloneLoadStage('connecting')
}
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
normalizedStandaloneInitialDisplayName,
normalizedStandaloneInitialAvatarUrl,
currentSessionId,
isConnected,
isConnecting,
setCurrentSession
])
useEffect(() => { useEffect(() => {
if (!standaloneSessionWindow) return if (!standaloneSessionWindow) return
if (!normalizedInitialSessionId) return if (!normalizedInitialSessionId) return
if (!isConnected || isConnecting) return if (!isConnected || isConnecting) return
if (currentSessionId === normalizedInitialSessionId) return if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return
selectSessionById(normalizedInitialSessionId) setStandaloneInitialLoadRequested(true)
setStandaloneLoadStage('loading')
selectSessionById(normalizedInitialSessionId, {
force: currentSessionId === normalizedInitialSessionId
})
}, [ }, [
standaloneSessionWindow, standaloneSessionWindow,
normalizedInitialSessionId, normalizedInitialSessionId,
isConnected, isConnected,
isConnecting, isConnecting,
currentSessionId, currentSessionId,
standaloneInitialLoadRequested,
selectSessionById selectSessionById
]) ])
useEffect(() => {
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
if (!isConnected || isConnecting) {
setStandaloneLoadStage('connecting')
return
}
if (!standaloneInitialLoadRequested) {
setStandaloneLoadStage('loading')
return
}
if (currentSessionId !== normalizedInitialSessionId) {
setStandaloneLoadStage('loading')
return
}
if (isLoadingMessages || isSessionSwitching) {
setStandaloneLoadStage('loading')
return
}
setStandaloneLoadStage('ready')
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
isConnected,
isConnecting,
standaloneInitialLoadRequested,
currentSessionId,
isLoadingMessages,
isSessionSwitching
])
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称 // 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
useEffect(() => { useEffect(() => {
if (!currentSessionId) return if (!currentSessionId) return
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found) { if (found) {
setFallbackDisplayName(null) if (found.displayName) setFallbackDisplayName(found.displayName)
if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl)
return return
} }
loadContactInfoBatch([currentSessionId]).then(() => { loadContactInfoBatch([currentSessionId]).then(() => {
const cached = senderAvatarCache.get(currentSessionId) const cached = senderAvatarCache.get(currentSessionId)
if (cached?.displayName) setFallbackDisplayName(cached.displayName) if (cached?.displayName) setFallbackDisplayName(cached.displayName)
if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl)
}) })
}, [currentSessionId, sessions]) }, [currentSessionId, sessions])
@@ -3738,16 +3880,16 @@ function ChatPage(props: ChatPageProps) {
src={currentSession.avatarUrl} src={currentSession.avatarUrl}
name={currentSession.displayName || currentSession.username} name={currentSession.displayName || currentSession.username}
size={40} size={40}
className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'} className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'}
/> />
<div className="header-info"> <div className="header-info">
<h3>{currentSession.displayName || currentSession.username}</h3> <h3>{currentSession.displayName || currentSession.username}</h3>
{isGroupChatSession(currentSession.username) && ( {isCurrentSessionGroup && (
<div className="header-subtitle"></div> <div className="header-subtitle"></div>
)} )}
</div> </div>
<div className="header-actions"> <div className="header-actions">
{!standaloneSessionWindow && isGroupChatSession(currentSession.username) && ( {!standaloneSessionWindow && isCurrentSessionGroup && (
<button <button
className="icon-btn group-analytics-btn" className="icon-btn group-analytics-btn"
onClick={handleGroupAnalytics} onClick={handleGroupAnalytics}
@@ -3756,7 +3898,7 @@ function ChatPage(props: ChatPageProps) {
<BarChart3 size={18} /> <BarChart3 size={18} />
</button> </button>
)} )}
{isGroupChatSession(currentSession.username) && ( {isCurrentSessionGroup && (
<button <button
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`} className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
onClick={toggleGroupMembersPanel} onClick={toggleGroupMembersPanel}
@@ -3779,6 +3921,16 @@ function ChatPage(props: ChatPageProps) {
)} )}
</button> </button>
)} )}
{!standaloneSessionWindow && isCurrentSessionPrivateSnsSupported && (
<button
className="icon-btn chat-sns-timeline-btn"
onClick={openCurrentSessionSnsTimeline}
disabled={!currentSessionId}
title="查看对方朋友圈"
>
<Aperture size={18} />
</button>
)}
{!standaloneSessionWindow && ( {!standaloneSessionWindow && (
<button <button
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`} className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
@@ -3863,6 +4015,7 @@ function ChatPage(props: ChatPageProps) {
> >
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} /> <RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} />
</button> </button>
{!shouldHideStandaloneDetailButton && (
<button <button
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`} className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
onClick={toggleDetailPanel} onClick={toggleDetailPanel}
@@ -3870,6 +4023,7 @@ function ChatPage(props: ChatPageProps) {
> >
<Info size={18} /> <Info size={18} />
</button> </button>
)}
</div> </div>
</div> </div>
@@ -3880,7 +4034,19 @@ function ChatPage(props: ChatPageProps) {
</div> </div>
)} )}
<ContactSnsTimelineDialog
target={chatSnsTimelineTarget}
onClose={() => setChatSnsTimelineTarget(null)}
/>
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}> <div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
<div className="standalone-phase-overlay" role="status" aria-live="polite">
<Loader2 size={22} className="spin" />
<span>{standaloneLoadStage === 'connecting' ? '正在建立连接...' : '正在加载最近消息...'}</span>
{connectionError && <small>{connectionError}</small>}
</div>
)}
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && ( {isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
<div className="loading-messages loading-overlay"> <div className="loading-messages loading-overlay">
<Loader2 size={24} /> <Loader2 size={24} />
@@ -3937,7 +4103,7 @@ function ChatPage(props: ChatPageProps) {
session={currentSession} session={currentSession}
showTime={!showDateDivider && showTime} showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl} myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChatSession(currentSession.username)} isGroupChat={isCurrentSessionGroup}
onRequireModelDownload={handleRequireModelDownload} onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
@@ -3969,7 +4135,7 @@ function ChatPage(props: ChatPageProps) {
</div> </div>
{/* 群成员面板 */} {/* 群成员面板 */}
{showGroupMembersPanel && isGroupChatSession(currentSession.username) && ( {showGroupMembersPanel && isCurrentSessionGroup && (
<div className="detail-panel group-members-panel"> <div className="detail-panel group-members-panel">
<div className="detail-header"> <div className="detail-header">
<h4></h4> <h4></h4>

View File

@@ -535,6 +535,28 @@
word-break: break-all; word-break: break-all;
user-select: text; user-select: text;
} }
.detail-entry-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
padding: 6px 10px;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
&:hover {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
}
}
} }
.goto-chat-btn { .goto-chat-btn {

View File

@@ -1,20 +1,14 @@
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react' import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList, Aperture } from 'lucide-react'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import * as configService from '../services/config' import * as configService from '../services/config'
import type { ContactInfo } from '../types/models'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import './ContactsPage.scss' import './ContactsPage.scss'
interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
interface ContactEnrichInfo { interface ContactEnrichInfo {
displayName?: string displayName?: string
avatarUrl?: string avatarUrl?: string
@@ -62,6 +56,9 @@ function ContactsPage() {
// 导出模式与查看详情 // 导出模式与查看详情
const [exportMode, setExportMode] = useState(false) const [exportMode, setExportMode] = useState(false)
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null) const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [snsTimelineTarget, setSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
const { setCurrentSession } = useChatStore() const { setCurrentSession } = useChatStore()
@@ -509,6 +506,41 @@ function ContactsPage() {
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [searchKeyword]) }, [searchKeyword])
const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => {
if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) {
return
}
setSnsUserPostCountsStatus('loading')
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (!result.success || !result.counts) {
setSnsUserPostCountsStatus('error')
return
}
const normalizedCounts: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(result.counts)) {
const username = String(rawUsername || '').trim()
if (!username) continue
const value = Number(rawCount)
normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
}
setSnsUserPostCounts(normalizedCounts)
setSnsUserPostCountsStatus('ready')
} catch (error) {
console.error('加载通讯录联系人朋友圈条数失败:', error)
setSnsUserPostCountsStatus('error')
}
}, [snsUserPostCountsStatus])
useEffect(() => {
if (!selectedContact || !isSingleContactSession(selectedContact.username)) return
if (snsUserPostCountsStatus !== 'idle') return
void loadSnsUserPostCounts()
}, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus])
const filteredContacts = useMemo(() => { const filteredContacts = useMemo(() => {
let filtered = contacts.filter(contact => { let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false if (contact.type === 'friend' && !contactTypes.friends) return false
@@ -579,6 +611,38 @@ function ContactsPage() {
}, [filteredContacts, selectedUsernames]) }, [filteredContacts, selectedUsernames])
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
const selectedContactSupportsSns = useMemo(() => {
return Boolean(selectedContact && isSingleContactSession(selectedContact.username))
}, [selectedContact])
const selectedContactSnsCount = useMemo(() => {
if (!selectedContactSupportsSns || !selectedContact) return null
if (snsUserPostCountsStatus !== 'ready') return null
const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0)
return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
}, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus])
const selectedContactSnsEntryLabel = useMemo(() => {
if (!selectedContactSupportsSns) return ''
if (selectedContactSnsCount !== null) {
return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}`
}
if (snsUserPostCountsStatus === 'error') return '朋友圈:查看'
return '朋友圈:统计中...'
}, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus])
const openSelectedContactSnsTimeline = useCallback(() => {
if (!selectedContact || !selectedContactSupportsSns) return
if (snsUserPostCountsStatus === 'idle') {
void loadSnsUserPostCounts()
}
setSnsTimelineTarget({
username: selectedContact.username,
displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username,
avatarUrl: selectedContact.avatarUrl
})
}, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus])
const { startIndex, endIndex } = useMemo(() => { const { startIndex, endIndex } = useMemo(() => {
if (filteredContacts.length === 0) { if (filteredContacts.length === 0) {
return { startIndex: 0, endIndex: 0 } return { startIndex: 0, endIndex: 0 }
@@ -1069,6 +1133,19 @@ function ContactsPage() {
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>} {selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div> <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
{selectedContactSupportsSns && (
<div className="detail-row">
<span className="detail-label"></span>
<button
type="button"
className="detail-entry-btn"
onClick={openSelectedContactSnsTimeline}
>
<Aperture size={14} />
<span>{selectedContactSnsEntryLabel}</span>
</button>
</div>
)}
</div> </div>
<button <button
@@ -1091,6 +1168,14 @@ function ContactsPage() {
</div> </div>
</div> </div>
)} )}
<ContactSnsTimelineDialog
target={snsTimelineTarget}
onClose={() => setSnsTimelineTarget(null)}
initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null}
initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username
? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading'
: false}
/>
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -348,6 +348,51 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.settings-time-range-field {
margin-bottom: 10px;
}
.settings-time-range-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: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.settings-time-range-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-time-range-arrow {
color: var(--text-tertiary);
font-weight: 700;
line-height: 1;
}
.select-trigger { .select-trigger {
width: 100%; width: 100%;
padding: 10px 16px; padding: 10px 16px;

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
@@ -8,20 +9,19 @@ import * as configService from '../services/config'
import { import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic, Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2 ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
} from 'lucide-react' } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics' type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'appearance', label: '外观', icon: Palette },
{ id: 'notification', label: '通知', icon: Bell }, { id: 'notification', label: '通知', icon: Bell },
{ id: 'database', label: '数据库连接', icon: Database }, { id: 'database', label: '数据库连接', icon: Database },
{ id: 'models', label: '模型管理', icon: Mic }, { id: 'models', label: '模型管理', icon: Mic },
{ id: 'export', label: '导出', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe }, { id: 'api', label: 'API 服务', icon: Globe },
@@ -37,6 +37,7 @@ interface WxidOption {
} }
function SettingsPage() { function SettingsPage() {
const location = useLocation()
const { const {
isDbConnected, isDbConnected,
setDbConnected, setDbConnected,
@@ -73,14 +74,6 @@ function SettingsPage() {
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([]) const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false) const [showWxidSelect, setShowWxidSelect] = useState(false)
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [imageKeyProgress, setImageKeyProgress] = useState(0) const [imageKeyProgress, setImageKeyProgress] = useState(0)
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null) const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
@@ -103,12 +96,6 @@ function SettingsPage() {
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false) const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh']) const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [notificationEnabled, setNotificationEnabled] = useState(true) const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right') const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
@@ -202,26 +189,11 @@ function SettingsPage() {
} }
}, []) }, [])
// 点击外部关闭下拉框
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const initialTab = (location.state as { initialTab?: SettingsTab } | null)?.initialTab
const target = e.target as Node if (!initialTab) return
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { setActiveTab(initialTab)
setShowExportFormatSelect(false) }, [location.state])
}
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
setShowExportDateRangeSelect(false)
}
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
setShowExportConcurrencySelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
@@ -289,13 +261,6 @@ function SettingsPage() {
const savedWhisperModelDir = await configService.getWhisperModelDir() const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedAutoTranscribe = await configService.getAutoTranscribeVoice() const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages() const savedTranscribeLanguages = await configService.getTranscribeLanguages()
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
const savedNotificationEnabled = await configService.getNotificationEnabled() const savedNotificationEnabled = await configService.getNotificationEnabled()
const savedNotificationPosition = await configService.getNotificationPosition() const savedNotificationPosition = await configService.getNotificationPosition()
const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterMode = await configService.getNotificationFilterMode()
@@ -330,12 +295,6 @@ function SettingsPage() {
setLogEnabled(savedLogEnabled) setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe) setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages) setTranscribeLanguages(savedTranscribeLanguages)
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
setNotificationEnabled(savedNotificationEnabled) setNotificationEnabled(savedNotificationEnabled)
setNotificationPosition(savedNotificationPosition) setNotificationPosition(savedNotificationPosition)
@@ -1547,258 +1506,6 @@ function SettingsPage() {
</div> </div>
) )
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: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式CSV' },
{ 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 exportConcurrencyOptions = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 4, label: '4' },
{ value: 5, label: '5' },
{ value: 6, label: '6' }
]
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)
const exportConcurrencyLabel = String(exportDefaultConcurrency)
return (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="select-field" ref={exportFormatDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
onClick={() => {
setShowExportFormatSelect(!showExportFormatSelect)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportFormatLabel}</span>
<ChevronDown size={16} />
</button>
{showExportFormatSelect && (
<div className="select-dropdown">
{exportFormatOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFormat(option.value)
await configService.setExportDefaultFormat(option.value)
showMessage('已更新导出格式默认值', true)
setShowExportFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="select-field" ref={exportDateRangeDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
onClick={() => {
setShowExportDateRangeSelect(!showExportDateRangeSelect)
setShowExportFormatSelect(false)
setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportDateRangeLabel}</span>
<ChevronDown size={16} />
</button>
{showExportDateRangeSelect && (
<div className="select-dropdown">
{exportDateRangeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultDateRange(option.value)
await configService.setExportDefaultDateRange(option.value)
showMessage('已更新默认导出时间范围', true)
setShowExportDateRangeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">//</span>
<div className="log-toggle-line">
<span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="export-default-media">
<input
id="export-default-media"
className="switch-input"
type="checkbox"
checked={exportDefaultMedia}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultMedia(enabled)
await configService.setExportDefaultMedia(enabled)
showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="export-default-voice-as-text">
<input
id="export-default-voice-as-text"
className="switch-input"
type="checkbox"
checked={exportDefaultVoiceAsText}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultVoiceAsText(enabled)
await configService.setExportDefaultVoiceAsText(enabled)
showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label>Excel </label>
<span className="form-hint"> Excel </span>
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
<ChevronDown size={16} />
</button>
{showExportExcelColumnsSelect && (
<div className="select-dropdown">
{exportExcelColumnOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
onClick={async () => {
const compact = option.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">1~6</span>
<div className="select-field" ref={exportConcurrencyDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`}
onClick={() => {
setShowExportConcurrencySelect(!showExportConcurrencySelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportConcurrencyLabel}</span>
<ChevronDown size={16} />
</button>
{showExportConcurrencySelect && (
<div className="select-dropdown">
{exportConcurrencyOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultConcurrency === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultConcurrency(option.value)
await configService.setExportDefaultConcurrency(option.value)
showMessage(`已将导出并发数设为 ${option.value}`, true)
setShowExportConcurrencySelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}
const renderCacheTab = () => ( const renderCacheTab = () => (
<div className="tab-content"> <div className="tab-content">
<p className="section-desc"></p> <p className="section-desc"></p>
@@ -2395,7 +2102,6 @@ function SettingsPage() {
{activeTab === 'notification' && renderNotificationTab()} {activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'database' && renderDatabaseTab()} {activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'models' && renderModelsTab()} {activeTab === 'models' && renderModelsTab()}
{activeTab === 'export' && renderExportTab()}
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()} {activeTab === 'api' && renderApiTab()}
{activeTab === 'analytics' && renderAnalyticsTab()} {activeTab === 'analytics' && renderAnalyticsTab()}

View File

@@ -11,7 +11,8 @@
.sns-page-layout { .sns-page-layout {
display: flex; display: flex;
height: 100%; height: calc(100% + 48px);
margin: -24px;
overflow: hidden; overflow: hidden;
background: var(--sns-bg-color); background: var(--sns-bg-color);
position: relative; position: relative;
@@ -32,7 +33,7 @@
.sns-feed-container { .sns-feed-container {
width: 100%; width: 100%;
max-width: var(--sns-max-width); max-width: var(--sns-max-width);
padding: 20px 24px 60px 24px; padding: 10px 24px 12px 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
@@ -44,13 +45,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 8px; margin-bottom: 4px;
padding: 0 4px; padding: 0 4px;
z-index: 2; z-index: 2;
background: var(--sns-bg-color); background: var(--sns-bg-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
padding-top: 10px; padding-top: 4px;
padding-bottom: 10px; padding-bottom: 6px;
.feed-header-main { .feed-header-main {
display: flex; display: flex;
@@ -67,6 +68,10 @@
} }
.feed-stats-line { .feed-stats-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.4; line-height: 1.4;
@@ -80,6 +85,76 @@
} }
} }
.feed-stats-range {
gap: 0;
}
.feed-overview-total {
font-size: inherit;
color: inherit;
white-space: nowrap;
}
.feed-stats-divider {
color: color-mix(in srgb, var(--text-secondary) 78%, transparent);
}
.feed-my-timeline-entry {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 0;
border: none;
background: transparent;
font-size: 13px;
line-height: 1.4;
color: var(--text-secondary);
cursor: default;
transition: color 0.2s ease, opacity 0.2s ease;
.feed-my-timeline-label {
font-weight: 500;
}
.feed-my-timeline-count {
color: var(--text-primary);
font-weight: 600;
display: inline-flex;
align-items: center;
.spin {
animation: spin 0.8s linear infinite;
}
}
&.ready {
cursor: pointer;
&:hover {
color: var(--primary);
}
&:hover .feed-my-timeline-count {
color: var(--primary);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 3px;
border-radius: 6px;
}
}
&.loading {
opacity: 0.72;
}
&:disabled {
opacity: 0.68;
}
}
.feed-stats-retry { .feed-stats-retry {
border: none; border: none;
background: transparent; background: transparent;
@@ -98,6 +173,18 @@
gap: 10px; gap: 10px;
} }
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
z-index: 20;
.jump-date-popover {
z-index: 2600;
}
}
.icon-btn { .icon-btn {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -123,6 +210,50 @@
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
} }
.jump-date-chip {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border-color);
border-radius: var(--sns-border-radius-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 8px 10px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--hover-bg);
color: var(--primary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.jump-date-chip-label {
font-size: 13px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
.jump-date-chip-clear {
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
border-radius: 999px;
padding: 1px;
&:hover {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
}
} }
.sns-posts-scroll { .sns-posts-scroll {
@@ -179,6 +310,30 @@
flex-shrink: 0; flex-shrink: 0;
} }
.author-trigger-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
color: inherit;
cursor: pointer;
}
.avatar-trigger {
border-radius: 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
}
.post-content-col { .post-content-col {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -206,6 +361,30 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
.author-name-trigger {
align-self: flex-start;
border-radius: 6px;
margin-bottom: 2px;
.author-name {
transition: color 0.15s ease, text-decoration-color 0.15s ease;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 2px;
margin-bottom: 0;
}
&:hover .author-name {
color: var(--primary);
text-decoration-color: currentColor;
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
}
.post-time { .post-time {
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
@@ -219,6 +398,13 @@
flex-shrink: 0; flex-shrink: 0;
} }
.post-time-standalone {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.2;
padding-top: 2px;
}
.debug-btn { .debug-btn {
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
@@ -909,8 +1095,20 @@
padding: 2px 6px; padding: 2px 6px;
border-radius: 10px; border-radius: 10px;
} }
.widget-header-summary {
margin-left: auto;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
} }
} }
}
.contact-widget .widget-header .badge + .widget-header-summary {
margin-left: 8px;
}
/* Search Widget */ /* Search Widget */
.input-group { .input-group {
@@ -950,44 +1148,6 @@
} }
} }
/* Date Widget */
.date-picker-trigger {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: var(--sns-border-radius-sm);
padding: 12px;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
color: var(--text-secondary);
&:hover {
background: var(--bg-primary);
border-color: var(--primary);
}
&.active {
background: rgba(var(--primary-rgb), 0.08);
border-color: var(--primary);
color: var(--primary);
font-weight: 500;
}
.clear-date-btn {
padding: 4px;
display: flex;
color: var(--primary);
&:hover {
transform: scale(1.1);
}
}
}
/* Contact Widget - Refactored */ /* Contact Widget - Refactored */
.contact-widget { .contact-widget {
display: flex; display: flex;
@@ -1043,6 +1203,14 @@
} }
} }
.contact-count-progress {
padding: 8px 16px 10px;
font-size: 11px;
color: var(--text-tertiary);
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent);
font-variant-numeric: tabular-nums;
}
.contact-list-scroll { .contact-list-scroll {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -1060,9 +1228,8 @@
border-radius: var(--sns-border-radius-md); border-radius: var(--sns-border-radius-md);
cursor: pointer; cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease; transition: background 0.2s ease, transform 0.2s ease;
border: 2px solid transparent; border: 1px solid transparent;
margin-bottom: 4px; margin-bottom: 4px;
/* Separation for unselected items */
&:hover { &:hover {
background: var(--hover-bg); background: var(--hover-bg);
@@ -1070,41 +1237,6 @@
z-index: 10; z-index: 10;
} }
&.selected {
background: rgba(var(--primary-rgb), 0.1);
border-color: var(--primary);
box-shadow: none;
z-index: 5;
margin-bottom: 0;
/* Remove margin to merge */
.contact-meta {
.contact-name {
color: var(--primary);
font-weight: 600;
}
}
/* If the NEXT item is also selected */
&:has(+ .contact-row.selected) {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 12px;
/* Compensate for missing border (+2px) */
}
}
/* If the PREVIOUS item is selected */
&.selected+.contact-row.selected {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
margin-top: 0;
padding-top: 12px;
/* Compensate for missing border */
}
.contact-meta { .contact-meta {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1120,6 +1252,33 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
} }
.contact-post-count-wrap {
margin-left: 8px;
min-width: 46px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
}
.contact-post-count {
font-size: 12px;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.contact-post-count-loading {
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
.spinning {
animation: spin 0.8s linear infinite;
}
}
} }
} }
@@ -1317,6 +1476,116 @@
} }
} }
.author-timeline-dialog {
background: var(--sns-card-bg);
border-radius: var(--sns-border-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
width: min(860px, 94vw);
max-height: 86vh;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
overflow: hidden;
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.author-timeline-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
display: flex;
align-items: flex-start;
justify-content: space-between;
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
&:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
}
}
.author-timeline-meta {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.author-timeline-meta-text {
min-width: 0;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
}
.author-timeline-username {
margin-top: 2px;
font-size: 12px;
color: var(--text-secondary);
}
.author-timeline-stats {
margin-top: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.author-timeline-body {
padding: 16px;
overflow-y: auto;
min-height: 180px;
max-height: calc(86vh - 96px);
}
.author-timeline-posts-list {
gap: 16px;
}
.author-timeline-loading {
margin-top: 12px;
}
.author-timeline-empty {
padding: 42px 10px 30px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.author-timeline-load-more {
display: block;
margin: 12px auto 2px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 999px;
padding: 7px 16px;
font-size: 13px;
cursor: pointer;
&:hover:not(:disabled) {
color: var(--primary);
border-color: var(--primary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
@keyframes slide-up-fade { @keyframes slide-up-fade {
from { from {
opacity: 0; opacity: 0;
@@ -1436,6 +1705,44 @@
gap: 8px; gap: 8px;
} }
.export-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.time-range-trigger.sns-export-time-range-trigger {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: 999px;
color: var(--text-primary);
font-size: 12px;
min-height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
&:hover:not(:disabled) {
border-color: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.time-range-arrow {
color: var(--text-tertiary);
font-weight: 700;
line-height: 1;
}
}
.export-format-options { .export-format-options {
display: grid; display: grid;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
// 配置服务 - 封装 Electron Store // 配置服务 - 封装 Electron Store
import { config } from './ipc' import { config } from './ipc'
import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange'
// 配置键名 // 配置键名
export const CONFIG_KEYS = { export const CONFIG_KEYS = {
@@ -26,6 +27,7 @@ export const CONFIG_KEYS = {
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice', AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
TRANSCRIBE_LANGUAGES: 'transcribeLanguages', TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat', EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
@@ -41,6 +43,7 @@ export const CONFIG_KEYS = {
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap', EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap', SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
@@ -75,6 +78,20 @@ export interface WxidConfig {
updatedAt?: number updatedAt?: number
} }
export interface ExportDefaultMediaConfig {
images: boolean
videos: boolean
voices: boolean
emojis: boolean
}
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
images: true,
videos: true,
voices: true,
emojis: true
}
// 获取解密密钥 // 获取解密密钥
export async function getDecryptKey(): Promise<string | null> { export async function getDecryptKey(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY) const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
@@ -333,27 +350,64 @@ export async function setExportDefaultFormat(format: string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
} }
// 获取导出默认时间范围 // 获取导出默认头像设置
export async function getExportDefaultDateRange(): Promise<string | null> { export async function getExportDefaultAvatars(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE) const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS)
return (value as string) || null
}
// 设置导出默认时间范围
export async function setExportDefaultDateRange(range: string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
}
// 获取导出默认媒体设置
export async function getExportDefaultMedia(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
if (typeof value === 'boolean') return value if (typeof value === 'boolean') return value
return null return null
} }
// 设置导出默认头像设置
export async function setExportDefaultAvatars(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS, enabled)
}
// 获取导出默认时间范围
export async function getExportDefaultDateRange(): Promise<ExportDefaultDateRangeConfig | string | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
if (typeof value === 'string') return value
if (value && typeof value === 'object') {
return value as ExportDefaultDateRangeConfig
}
return null
}
// 设置导出默认时间范围
export async function setExportDefaultDateRange(range: ExportDefaultDateRangeConfig | string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
}
// 获取导出默认媒体设置
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
if (typeof value === 'boolean') {
return {
images: value,
videos: value,
voices: value,
emojis: value
}
}
if (value && typeof value === 'object') {
const raw = value as Partial<Record<keyof ExportDefaultMediaConfig, unknown>>
return {
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis
}
}
return null
}
// 设置导出默认媒体设置 // 设置导出默认媒体设置
export async function setExportDefaultMedia(enabled: boolean): Promise<void> { export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, {
images: media.images,
videos: media.videos,
voices: media.voices,
emojis: media.emojis
})
} }
// 获取导出默认语音转文字 // 获取导出默认语音转文字
@@ -534,6 +588,11 @@ export interface ExportSnsStatsCacheItem {
totalFriends: number totalFriends: number
} }
export interface ExportSnsUserPostCountsCacheItem {
updatedAt: number
counts: Record<string, number>
}
export interface SnsPageOverviewCache { export interface SnsPageOverviewCache {
totalPosts: number totalPosts: number
totalFriends: number totalFriends: number
@@ -741,6 +800,58 @@ export async function setExportSnsStatsCache(
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
} }
export async function getExportSnsUserPostCountsCache(scopeKey: string): Promise<ExportSnsUserPostCountsCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const rawCounts = raw.counts
if (!rawCounts || typeof rawCounts !== 'object') return null
const counts: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(rawCounts as Record<string, unknown>)) {
const username = String(rawUsername || '').trim()
if (!username) continue
const valueNum = Number(rawCount)
counts[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
}
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
? raw.updatedAt
: 0
return { updatedAt, counts }
}
export async function setExportSnsUserPostCountsCache(
scopeKey: string,
counts: Record<string, number>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(counts || {})) {
const username = String(rawUsername || '').trim()
if (!username) continue
const valueNum = Number(rawCount)
normalized[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
}
map[scopeKey] = {
updatedAt: Date.now(),
counts: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
}
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> { export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
if (!scopeKey) return null if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP) const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)

View File

@@ -1,5 +1,12 @@
import type { ChatSession, Message, Contact, ContactInfo } from './models' import type { ChatSession, Message, Contact, ContactInfo } from './models'
export interface SessionChatWindowOpenOptions {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: ContactInfo['type']
}
export interface ElectronAPI { export interface ElectronAPI {
window: { window: {
minimize: () => void minimize: () => void
@@ -13,7 +20,7 @@ export interface ElectronAPI {
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void> resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void> openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean> openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
openSessionChatWindow: (sessionId: string) => Promise<boolean> openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
} }
config: { config: {
get: (key: string) => Promise<unknown> get: (key: string) => Promise<unknown>
@@ -250,7 +257,13 @@ export interface ElectronAPI {
}> }>
getExportSessionStats: ( getExportSessionStats: (
sessionIds: string[], sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}
) => Promise<{ ) => Promise<{
success: boolean success: boolean
data?: Record<string, { data?: Record<string, {
@@ -776,8 +789,10 @@ export interface ElectronAPI {
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
getUserPostCounts: () => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }>
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>

View File

@@ -0,0 +1,341 @@
export type ExportDateRangePreset =
| 'all'
| 'today'
| 'yesterday'
| 'last3days'
| 'last7days'
| 'last30days'
| 'last1year'
| 'last2years'
| 'custom'
export type CalendarCell = { date: Date; inCurrentMonth: boolean }
export interface ExportDateRange {
start: Date
end: Date
}
export interface ExportDateRangeSelection {
preset: ExportDateRangePreset
useAllTime: boolean
dateRange: ExportDateRange
}
export interface ExportDefaultDateRangeConfig {
version?: 1
preset?: ExportDateRangePreset | string
useAllTime?: boolean
start?: string | number | Date | null
end?: string | number | Date | null
}
export const EXPORT_DATE_RANGE_PRESETS: Array<{
value: Exclude<ExportDateRangePreset, 'custom'>
label: string
}> = [
{ value: 'all', label: '全部时间' },
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨天' },
{ value: 'last3days', label: '最近3天' },
{ value: 'last7days', label: '最近一周' },
{ value: 'last30days', label: '最近30天' },
{ value: 'last1year', label: '最近一年' }
]
const PRESET_LABELS: Record<Exclude<ExportDateRangePreset, 'custom'>, string> = {
all: '全部时间',
today: '今天',
yesterday: '昨天',
last3days: '最近3天',
last7days: '最近一周',
last30days: '最近30天',
last1year: '最近一年',
last2years: '最近两年'
}
const LEGACY_PRESET_MAP: Record<string, Exclude<ExportDateRangePreset, 'custom'> | 'legacy90days'> = {
all: 'all',
today: 'today',
yesterday: 'yesterday',
last3days: 'last3days',
last7days: 'last7days',
last30days: 'last30days',
last1year: 'last1year',
last2years: 'last2years',
'7d': 'last7days',
'30d': 'last30days',
'90d': 'legacy90days'
}
export const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六']
export const startOfDay = (date: Date): Date => {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return next
}
export const endOfDay = (date: Date): Date => {
const next = new Date(date)
next.setHours(23, 59, 59, 999)
return next
}
export const createDefaultDateRange = (): ExportDateRange => {
const now = new Date()
return {
start: startOfDay(now),
end: now
}
}
export const createDateRangeByPreset = (
preset: Exclude<ExportDateRangePreset, 'all' | 'custom'>,
now = new Date()
): ExportDateRange => {
const end = new Date(now)
const baseStart = startOfDay(now)
if (preset === 'today') {
return { start: baseStart, end }
}
if (preset === 'yesterday') {
const yesterday = new Date(baseStart)
yesterday.setDate(yesterday.getDate() - 1)
return {
start: yesterday,
end: endOfDay(yesterday)
}
}
if (preset === 'last1year' || preset === 'last2years') {
const yearsBack = preset === 'last1year' ? 1 : 2
const start = new Date(baseStart)
const expectedMonth = start.getMonth()
start.setFullYear(start.getFullYear() - yearsBack)
if (start.getMonth() !== expectedMonth) {
start.setDate(0)
}
return { start, end }
}
const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29
const start = new Date(baseStart)
start.setDate(start.getDate() - daysBack)
return { start, end }
}
export const createDateRangeByLastNDays = (days: number, now = new Date()): ExportDateRange => {
const end = new Date(now)
const start = startOfDay(now)
start.setDate(start.getDate() - Math.max(0, days - 1))
return { start, end }
}
export const formatDateInputValue = (date: Date): string => {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
export const parseDateInputValue = (raw: string): Date | null => {
const text = String(raw || '').trim()
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (!matched) return null
const year = Number(matched[1])
const month = Number(matched[2])
const day = Number(matched[3])
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
if (month < 1 || month > 12 || day < 1 || day > 31) return null
const parsed = new Date(year, month - 1, day)
if (
parsed.getFullYear() !== year ||
parsed.getMonth() !== month - 1 ||
parsed.getDate() !== day
) {
return null
}
return parsed
}
export const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1)
export const addMonths = (date: Date, delta: number): Date => {
const next = new Date(date)
next.setMonth(next.getMonth() + delta)
return toMonthStart(next)
}
export const isSameDay = (left: Date, right: Date): boolean => (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
)
export const buildCalendarCells = (monthStart: Date): CalendarCell[] => {
const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1)
const startOffset = firstDay.getDay()
const gridStart = new Date(firstDay)
gridStart.setDate(gridStart.getDate() - startOffset)
const cells: CalendarCell[] = []
for (let index = 0; index < 42; index += 1) {
const current = new Date(gridStart)
current.setDate(gridStart.getDate() + index)
cells.push({
date: current,
inCurrentMonth: current.getMonth() === monthStart.getMonth()
})
}
return cells
}
export const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}${date.getMonth() + 1}`
export const cloneExportDateRange = (range: ExportDateRange): ExportDateRange => ({
start: new Date(range.start),
end: new Date(range.end)
})
export const cloneExportDateRangeSelection = (selection: ExportDateRangeSelection): ExportDateRangeSelection => ({
preset: selection.preset,
useAllTime: selection.useAllTime,
dateRange: cloneExportDateRange(selection.dateRange)
})
export const createExportDateRangeSelectionFromPreset = (
preset: Exclude<ExportDateRangePreset, 'custom'>,
now = new Date()
): ExportDateRangeSelection => {
if (preset === 'all') {
return {
preset,
useAllTime: true,
dateRange: createDefaultDateRange()
}
}
return {
preset,
useAllTime: false,
dateRange: createDateRangeByPreset(preset, now)
}
}
export const createDefaultExportDateRangeSelection = (): ExportDateRangeSelection => (
createExportDateRangeSelectionFromPreset('today')
)
const parseStoredDate = (value: unknown): Date | null => {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return new Date(value)
}
if (typeof value === 'number' && Number.isFinite(value)) {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
if (typeof value === 'string') {
const normalized = parseDateInputValue(value)
if (normalized) return normalized
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
return null
}
const normalizePreset = (raw: unknown): Exclude<ExportDateRangePreset, 'custom'> | 'legacy90days' | null => {
if (typeof raw !== 'string') return null
const normalized = LEGACY_PRESET_MAP[raw]
return normalized ?? null
}
export const resolveExportDateRangeConfig = (
raw: ExportDefaultDateRangeConfig | string | null | undefined,
now = new Date()
): ExportDateRangeSelection => {
if (!raw) {
return createDefaultExportDateRangeSelection()
}
if (typeof raw === 'string') {
const preset = normalizePreset(raw)
if (!preset) return createDefaultExportDateRangeSelection()
if (preset === 'legacy90days') {
return {
preset: 'custom',
useAllTime: false,
dateRange: createDateRangeByLastNDays(90, now)
}
}
return createExportDateRangeSelectionFromPreset(preset, now)
}
const preset = normalizePreset(raw.preset)
if (raw.useAllTime || preset === 'all') {
return createExportDateRangeSelectionFromPreset('all', now)
}
if (preset && preset !== 'legacy90days') {
return createExportDateRangeSelectionFromPreset(preset, now)
}
if (preset === 'legacy90days') {
return {
preset: 'custom',
useAllTime: false,
dateRange: createDateRangeByLastNDays(90, now)
}
}
const parsedStart = parseStoredDate(raw.start)
const parsedEnd = parseStoredDate(raw.end)
if (parsedStart && parsedEnd) {
const start = startOfDay(parsedStart)
const end = endOfDay(parsedEnd)
return {
preset: 'custom',
useAllTime: false,
dateRange: {
start,
end: end < start ? endOfDay(start) : end
}
}
}
return createDefaultExportDateRangeSelection()
}
export const serializeExportDateRangeConfig = (
selection: ExportDateRangeSelection
): ExportDefaultDateRangeConfig => {
if (selection.useAllTime) {
return {
version: 1,
preset: 'all',
useAllTime: true
}
}
if (selection.preset === 'custom') {
return {
version: 1,
preset: 'custom',
useAllTime: false,
start: formatDateInputValue(selection.dateRange.start),
end: formatDateInputValue(selection.dateRange.end)
}
}
return {
version: 1,
preset: selection.preset,
useAllTime: false
}
}
export const getExportDateRangeLabel = (selection: ExportDateRangeSelection): string => {
if (selection.useAllTime) return PRESET_LABELS.all
if (selection.preset !== 'custom') return PRESET_LABELS[selection.preset]
return `${formatDateInputValue(selection.dateRange.start)}${formatDateInputValue(selection.dateRange.end)}`
}