Merge pull request #373 from aits2026/codex/ts0305-01-export-module-upgrade
导出模块继续优化+朋友圈体验增强+显性化应用锁入口+其他一些小优化
This commit is contained in:
30 files changed
+9205
-1683
No files matched your search
+84
-11
@@ -24,7 +24,7 @@ import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
||||
import { cloudControlService } from './services/cloudControlService'
|
||||
|
||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
|
||||
|
||||
@@ -87,10 +87,12 @@ let onboardingWindow: BrowserWindow | null = null
|
||||
// Splash 启动窗口
|
||||
let splashWindow: BrowserWindow | null = null
|
||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||
const keyService = new KeyService()
|
||||
|
||||
let mainWindowReady = false
|
||||
let shouldShowMain = true
|
||||
let isAppQuitting = false
|
||||
|
||||
// 更新下载状态管理(Issue #294 修复)
|
||||
let isDownloadInProgress = false
|
||||
@@ -123,6 +125,47 @@ interface AnnualReportYearsTaskState {
|
||||
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 annualReportYearsTaskByCacheKey = new Map<string, string>()
|
||||
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
|
||||
@@ -290,6 +333,21 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
if (!normalizedSessionId) return null
|
||||
const normalizedSource = normalizeSessionChatWindowSource(options?.source)
|
||||
|
||||
const existing = sessionChatWindows.get(normalizedSessionId)
|
||||
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()) {
|
||||
existing.restore()
|
||||
}
|
||||
@@ -730,10 +794,9 @@ function createSessionChatWindow(sessionId: string) {
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}`
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`)
|
||||
loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
if (win.webContents.isDevToolsOpened()) {
|
||||
@@ -744,10 +807,6 @@ function createSessionChatWindow(sessionId: string) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-window?${sessionParam}`
|
||||
})
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
@@ -759,10 +818,12 @@ function createSessionChatWindow(sessionId: string) {
|
||||
const tracked = sessionChatWindows.get(normalizedSessionId)
|
||||
if (tracked === win) {
|
||||
sessionChatWindows.delete(normalizedSessionId)
|
||||
sessionChatWindowSources.delete(normalizedSessionId)
|
||||
}
|
||||
})
|
||||
|
||||
sessionChatWindows.set(normalizedSessionId, win)
|
||||
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
|
||||
return win
|
||||
}
|
||||
|
||||
@@ -1071,8 +1132,8 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
||||
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => {
|
||||
const win = createSessionChatWindow(sessionId)
|
||||
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
|
||||
const win = createSessionChatWindow(sessionId, options)
|
||||
return Boolean(win)
|
||||
})
|
||||
|
||||
@@ -1410,6 +1471,7 @@ function registerIpcHandlers() {
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}) => {
|
||||
return chatService.getExportSessionStats(sessionIds, options)
|
||||
})
|
||||
@@ -1463,6 +1525,10 @@ function registerIpcHandlers() {
|
||||
return snsService.getSnsUsernames()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getUserPostCounts', async () => {
|
||||
return snsService.getUserPostCounts()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getExportStats', async () => {
|
||||
return snsService.getExportStats()
|
||||
})
|
||||
@@ -1471,6 +1537,10 @@ function registerIpcHandlers() {
|
||||
return snsService.getExportStatsFast()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
|
||||
return snsService.getUserPostStats(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||
return snsService.debugResource(url)
|
||||
})
|
||||
@@ -2373,6 +2443,9 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
isAppQuitting = true
|
||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||
destroyNotificationWindow()
|
||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||
try { await httpService.stop() } catch {}
|
||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||
|
||||
+19
-3
@@ -99,8 +99,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||
openSessionChatWindow: (sessionId: string) =>
|
||||
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
|
||||
openSessionChatWindow: (
|
||||
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),
|
||||
getExportSessionStats: (
|
||||
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),
|
||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||
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) =>
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
|
||||
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
|
||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||
|
||||
@@ -164,6 +164,7 @@ interface ExportSessionStatsOptions {
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}
|
||||
|
||||
interface ExportSessionStatsCacheMeta {
|
||||
@@ -5209,39 +5210,36 @@ class ChatService {
|
||||
return { success: true, detail: cachedDetail.detail }
|
||||
}
|
||||
|
||||
const [tableStatsResult, statsResult] = await Promise.allSettled([
|
||||
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 tableStatsResult = await wcdbService.getMessageTableStats(normalizedSessionId)
|
||||
|
||||
const messageTables: { dbName: string; tableName: string; count: number }[] = []
|
||||
if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) {
|
||||
for (const row of tableStatsResult.value.tables) {
|
||||
let firstMessageTime: number | undefined
|
||||
let latestMessageTime: number | undefined
|
||||
if (tableStatsResult.success && tableStatsResult.tables) {
|
||||
for (const row of tableStatsResult.tables) {
|
||||
messageTables.push({
|
||||
dbName: basename(row.db_path || ''),
|
||||
tableName: row.table_name || '',
|
||||
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 allowStaleCache = options.allowStaleCache === true
|
||||
const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true
|
||||
const cacheOnly = options.cacheOnly === true
|
||||
|
||||
const normalizedSessionIds = Array.from(
|
||||
new Set(
|
||||
@@ -5380,32 +5379,34 @@ class ChatService {
|
||||
? this.getGroupMyMessageCountHintEntry(sessionId)
|
||||
: null
|
||||
const cachedResult = this.getSessionStatsCacheEntry(sessionId)
|
||||
if (!forceRefresh && !preferAccurateSpecialTypes) {
|
||||
if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
|
||||
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
|
||||
if (!stale || allowStaleCache) {
|
||||
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
|
||||
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
|
||||
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
|
||||
}
|
||||
cacheMeta[sessionId] = {
|
||||
updatedAt: cachedResult.entry.updatedAt,
|
||||
stale,
|
||||
includeRelations: cachedResult.entry.includeRelations,
|
||||
source: cachedResult.source
|
||||
}
|
||||
if (stale) {
|
||||
needsRefreshSet.add(sessionId)
|
||||
}
|
||||
continue
|
||||
const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes)
|
||||
if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
|
||||
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
|
||||
if (!stale || allowStaleCache || cacheOnly) {
|
||||
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
|
||||
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
|
||||
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
|
||||
}
|
||||
cacheMeta[sessionId] = {
|
||||
updatedAt: cachedResult.entry.updatedAt,
|
||||
stale,
|
||||
includeRelations: cachedResult.entry.includeRelations,
|
||||
source: cachedResult.source
|
||||
}
|
||||
if (stale) {
|
||||
needsRefreshSet.add(sessionId)
|
||||
}
|
||||
}
|
||||
// allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程。
|
||||
if (allowStaleCache && cachedResult) {
|
||||
needsRefreshSet.add(sessionId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。
|
||||
if (canUseCache && allowStaleCache && cachedResult) {
|
||||
needsRefreshSet.add(sessionId)
|
||||
continue
|
||||
}
|
||||
if (cacheOnly) {
|
||||
continue
|
||||
}
|
||||
pendingSessionIds.push(sessionId)
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +292,9 @@ class SnsService {
|
||||
private contactCache: ContactCacheService
|
||||
private imageCache = new Map<string, string>()
|
||||
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 userPostCountsCacheTtlMs = 5 * 60 * 1000
|
||||
private lastTimelineFallbackAt = 0
|
||||
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 }> {
|
||||
return wcdbService.installSnsBlockDeleteTrigger()
|
||||
@@ -881,7 +961,12 @@ class SnsService {
|
||||
|
||||
// 从数据库直接删除朋友圈记录
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
|
||||
let notificationWindow: BrowserWindow | 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() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
|
||||
+16
-2
@@ -405,8 +405,22 @@ function App() {
|
||||
|
||||
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||
if (isStandaloneChatWindow) {
|
||||
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
|
||||
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
|
||||
const params = new URLSearchParams(location.search)
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立通知窗口
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">></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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ interface JumpToDatePopoverProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (date: Date) => void
|
||||
onMonthChange?: (date: Date) => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
currentDate?: Date
|
||||
@@ -20,6 +21,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
onMonthChange,
|
||||
className,
|
||||
style,
|
||||
currentDate = new Date(),
|
||||
@@ -112,13 +114,17 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
onMonthChange?.(nextDate)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
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="上一月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
@@ -126,7 +132,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||
<button
|
||||
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="下一月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
|
||||
+17
-12
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
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 * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
@@ -64,6 +64,7 @@ const normalizeAccountId = (value?: string | null): string => {
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||
@@ -465,16 +466,20 @@ function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authEnabled && (
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => setLocked(true)}
|
||||
title={collapsed ? '锁定' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Lock size={20} /></span>
|
||||
<span className="nav-label">锁定</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => {
|
||||
if (authEnabled) {
|
||||
setLocked(true)
|
||||
return
|
||||
}
|
||||
navigate('/settings', { state: { initialTab: 'security' } })
|
||||
}}
|
||||
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
||||
>
|
||||
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
||||
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
||||
</button>
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -1,60 +1,52 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { Search, User, X, Loader2 } from 'lucide-react'
|
||||
import { Avatar } from '../Avatar'
|
||||
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
|
||||
|
||||
interface Contact {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
postCount?: number
|
||||
postCountStatus?: 'idle' | 'loading' | 'ready'
|
||||
}
|
||||
|
||||
interface ContactsCountProgress {
|
||||
resolved: number
|
||||
total: number
|
||||
running: boolean
|
||||
}
|
||||
|
||||
interface SnsFilterPanelProps {
|
||||
searchKeyword: string
|
||||
setSearchKeyword: (val: string) => void
|
||||
jumpTargetDate?: Date
|
||||
setJumpTargetDate: (date?: Date) => void
|
||||
onOpenJumpDialog: () => void
|
||||
selectedUsernames: string[]
|
||||
setSelectedUsernames: (val: string[]) => void
|
||||
totalFriendsLabel?: string
|
||||
contacts: Contact[]
|
||||
contactSearch: string
|
||||
setContactSearch: (val: string) => void
|
||||
loading?: boolean
|
||||
contactsCountProgress?: ContactsCountProgress
|
||||
onOpenContactTimeline: (contact: Contact) => void
|
||||
}
|
||||
|
||||
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
searchKeyword,
|
||||
setSearchKeyword,
|
||||
jumpTargetDate,
|
||||
setJumpTargetDate,
|
||||
onOpenJumpDialog,
|
||||
selectedUsernames,
|
||||
setSelectedUsernames,
|
||||
totalFriendsLabel,
|
||||
contacts,
|
||||
contactSearch,
|
||||
setContactSearch,
|
||||
loading
|
||||
loading,
|
||||
contactsCountProgress,
|
||||
onOpenContactTimeline
|
||||
}) => {
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||
(c.displayName || '').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 = () => {
|
||||
setSearchKeyword('')
|
||||
setSelectedUsernames([])
|
||||
setJumpTargetDate(undefined)
|
||||
setContactSearch('')
|
||||
}
|
||||
|
||||
const getEmptyStateText = () => {
|
||||
@@ -71,7 +63,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
<aside className="sns-filter-panel">
|
||||
<div className="filter-header">
|
||||
<h3>筛选条件</h3>
|
||||
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
|
||||
{(searchKeyword || contactSearch) && (
|
||||
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
@@ -99,43 +91,13 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
)}
|
||||
</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 */}
|
||||
<div className="filter-widget contact-widget">
|
||||
<div className="widget-header">
|
||||
<User size={14} />
|
||||
<span>联系人</span>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="badge">{selectedUsernames.length}</span>
|
||||
{totalFriendsLabel && (
|
||||
<span className="widget-header-summary">{totalFriendsLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -152,18 +114,36 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
)}
|
||||
</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">
|
||||
{filteredContacts.map(contact => {
|
||||
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||
return (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
|
||||
onClick={() => toggleUserSelection(contact.username)}
|
||||
className="contact-row"
|
||||
onClick={() => onOpenContactTimeline(contact)}
|
||||
>
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||
<div className="contact-meta">
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -243,10 +243,12 @@ interface SnsPostItemProps {
|
||||
post: SnsPost
|
||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => 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 [dbDeleted, setDbDeleted] = 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)
|
||||
if (r.success) {
|
||||
setDbDeleted(true)
|
||||
onDelete?.(post.id)
|
||||
onDelete?.(post.id, post.username)
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onOpenAuthorPosts?.(post)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
|
||||
<div className="post-avatar-col">
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={48}
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
{!hideAuthorMeta && (
|
||||
<div className="post-avatar-col">
|
||||
<button
|
||||
type="button"
|
||||
className="author-trigger-btn avatar-trigger"
|
||||
onClick={handleOpenAuthorPosts}
|
||||
title="查看该发布者的全部朋友圈"
|
||||
>
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={48}
|
||||
shape="rounded"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="post-content-col">
|
||||
<div className="post-header-row">
|
||||
<div className="post-author-info">
|
||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||
</div>
|
||||
{hideAuthorMeta ? (
|
||||
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
|
||||
) : (
|
||||
<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>
|
||||
</button>
|
||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="post-header-actions">
|
||||
{(mediaDeleted || dbDeleted) && (
|
||||
<span className="post-deleted-badge">
|
||||
|
||||
@@ -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] || '?'
|
||||
}
|
||||
@@ -1783,6 +1783,30 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+190
-24
@@ -1,5 +1,5 @@
|
||||
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 { createPortal } from 'react-dom'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
@@ -11,6 +11,8 @@ import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||
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 {
|
||||
emitOpenSingleExport,
|
||||
@@ -204,8 +206,13 @@ function formatYmdHmDateTime(timestamp?: number): string {
|
||||
interface ChatPageProps {
|
||||
standaloneSessionWindow?: boolean
|
||||
initialSessionId?: string | null
|
||||
standaloneSource?: string | null
|
||||
standaloneInitialDisplayName?: string | null
|
||||
standaloneInitialAvatarUrl?: string | null
|
||||
standaloneInitialContactType?: string | null
|
||||
}
|
||||
|
||||
type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready'
|
||||
|
||||
interface SessionDetail {
|
||||
wxid: string
|
||||
@@ -408,8 +415,20 @@ const SessionItem = React.memo(function SessionItem({
|
||||
|
||||
|
||||
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 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 {
|
||||
@@ -493,11 +512,17 @@ function ChatPage(props: ChatPageProps) {
|
||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||
const [isSessionSwitching, setIsSessionSwitching] = 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 [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
|
||||
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
|
||||
const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
|
||||
const [exportPrepareHint, setExportPrepareHint] = useState('')
|
||||
|
||||
// 消息右键菜单
|
||||
@@ -2408,9 +2433,9 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, [appendMessages, getMessageKey])
|
||||
|
||||
// 选择会话
|
||||
const selectSessionById = useCallback((sessionId: string) => {
|
||||
const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId || normalizedSessionId === currentSessionId) return
|
||||
if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
|
||||
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
|
||||
sessionSwitchRequestSeqRef.current = switchRequestSeq
|
||||
|
||||
@@ -2734,7 +2759,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, [currentSessionId, messages.length, isLoadingMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
|
||||
if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
|
||||
if (pendingSessionLoadRef.current === currentSessionId) return
|
||||
if (initialLoadRequestedSessionRef.current === currentSessionId) return
|
||||
initialLoadRequestedSessionRef.current = currentSessionId
|
||||
@@ -2745,7 +2770,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
forceInitialLimit: 30
|
||||
})
|
||||
}
|
||||
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -2906,7 +2931,21 @@ function ChatPage(props: ChatPageProps) {
|
||||
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback)
|
||||
const currentSession = (() => {
|
||||
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 {
|
||||
username: currentSessionId,
|
||||
type: 0,
|
||||
@@ -2916,6 +2955,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
lastTimestamp: 0,
|
||||
lastMsgType: 0,
|
||||
displayName: fallbackDisplayName || currentSessionId,
|
||||
avatarUrl: fallbackAvatarUrl || undefined,
|
||||
} as ChatSession
|
||||
})()
|
||||
const filteredGroupPanelMembers = useMemo(() => {
|
||||
@@ -2935,33 +2975,135 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, [groupMemberSearchKeyword, groupPanelMembers])
|
||||
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
|
||||
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(() => {
|
||||
if (!standaloneSessionWindow) return
|
||||
if (!normalizedInitialSessionId) return
|
||||
if (!isConnected || isConnecting) return
|
||||
if (currentSessionId === normalizedInitialSessionId) return
|
||||
selectSessionById(normalizedInitialSessionId)
|
||||
if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return
|
||||
setStandaloneInitialLoadRequested(true)
|
||||
setStandaloneLoadStage('loading')
|
||||
selectSessionById(normalizedInitialSessionId, {
|
||||
force: currentSessionId === normalizedInitialSessionId
|
||||
})
|
||||
}, [
|
||||
standaloneSessionWindow,
|
||||
normalizedInitialSessionId,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
currentSessionId,
|
||||
standaloneInitialLoadRequested,
|
||||
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(() => {
|
||||
if (!currentSessionId) return
|
||||
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||
if (found) {
|
||||
setFallbackDisplayName(null)
|
||||
if (found.displayName) setFallbackDisplayName(found.displayName)
|
||||
if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl)
|
||||
return
|
||||
}
|
||||
loadContactInfoBatch([currentSessionId]).then(() => {
|
||||
const cached = senderAvatarCache.get(currentSessionId)
|
||||
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
|
||||
if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl)
|
||||
})
|
||||
}, [currentSessionId, sessions])
|
||||
|
||||
@@ -3738,16 +3880,16 @@ function ChatPage(props: ChatPageProps) {
|
||||
src={currentSession.avatarUrl}
|
||||
name={currentSession.displayName || currentSession.username}
|
||||
size={40}
|
||||
className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
|
||||
className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'}
|
||||
/>
|
||||
<div className="header-info">
|
||||
<h3>{currentSession.displayName || currentSession.username}</h3>
|
||||
{isGroupChatSession(currentSession.username) && (
|
||||
{isCurrentSessionGroup && (
|
||||
<div className="header-subtitle">群聊</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{!standaloneSessionWindow && isGroupChatSession(currentSession.username) && (
|
||||
{!standaloneSessionWindow && isCurrentSessionGroup && (
|
||||
<button
|
||||
className="icon-btn group-analytics-btn"
|
||||
onClick={handleGroupAnalytics}
|
||||
@@ -3756,7 +3898,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
<BarChart3 size={18} />
|
||||
</button>
|
||||
)}
|
||||
{isGroupChatSession(currentSession.username) && (
|
||||
{isCurrentSessionGroup && (
|
||||
<button
|
||||
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
|
||||
onClick={toggleGroupMembersPanel}
|
||||
@@ -3779,6 +3921,16 @@ function ChatPage(props: ChatPageProps) {
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!standaloneSessionWindow && isCurrentSessionPrivateSnsSupported && (
|
||||
<button
|
||||
className="icon-btn chat-sns-timeline-btn"
|
||||
onClick={openCurrentSessionSnsTimeline}
|
||||
disabled={!currentSessionId}
|
||||
title="查看对方朋友圈"
|
||||
>
|
||||
<Aperture size={18} />
|
||||
</button>
|
||||
)}
|
||||
{!standaloneSessionWindow && (
|
||||
<button
|
||||
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
||||
@@ -3863,13 +4015,15 @@ function ChatPage(props: ChatPageProps) {
|
||||
>
|
||||
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
|
||||
onClick={toggleDetailPanel}
|
||||
title="会话详情"
|
||||
>
|
||||
<Info size={18} />
|
||||
</button>
|
||||
{!shouldHideStandaloneDetailButton && (
|
||||
<button
|
||||
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
|
||||
onClick={toggleDetailPanel}
|
||||
title="会话详情"
|
||||
>
|
||||
<Info size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3880,7 +4034,19 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContactSnsTimelineDialog
|
||||
target={chatSnsTimelineTarget}
|
||||
onClose={() => setChatSnsTimelineTarget(null)}
|
||||
/>
|
||||
|
||||
<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) && (
|
||||
<div className="loading-messages loading-overlay">
|
||||
<Loader2 size={24} />
|
||||
@@ -3937,7 +4103,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
session={currentSession}
|
||||
showTime={!showDateDivider && showTime}
|
||||
myAvatarUrl={myAvatarUrl}
|
||||
isGroupChat={isGroupChatSession(currentSession.username)}
|
||||
isGroupChat={isCurrentSessionGroup}
|
||||
onRequireModelDownload={handleRequireModelDownload}
|
||||
onContextMenu={handleContextMenu}
|
||||
isSelectionMode={isSelectionMode}
|
||||
@@ -3969,7 +4135,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
|
||||
{/* 群成员面板 */}
|
||||
{showGroupMembersPanel && isGroupChatSession(currentSession.username) && (
|
||||
{showGroupMembersPanel && isCurrentSessionGroup && (
|
||||
<div className="detail-panel group-members-panel">
|
||||
<div className="detail-header">
|
||||
<h4>群成员</h4>
|
||||
|
||||
@@ -535,6 +535,28 @@
|
||||
word-break: break-all;
|
||||
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 {
|
||||
|
||||
+95
-10
@@ -1,20 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
|
||||
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 { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||
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'
|
||||
|
||||
interface ContactInfo {
|
||||
username: string
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
interface ContactEnrichInfo {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
@@ -62,6 +56,9 @@ function ContactsPage() {
|
||||
// 导出模式与查看详情
|
||||
const [exportMode, setExportMode] = useState(false)
|
||||
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 { setCurrentSession } = useChatStore()
|
||||
|
||||
@@ -509,6 +506,41 @@ function ContactsPage() {
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [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(() => {
|
||||
let filtered = contacts.filter(contact => {
|
||||
if (contact.type === 'friend' && !contactTypes.friends) return false
|
||||
@@ -579,6 +611,38 @@ function ContactsPage() {
|
||||
}, [filteredContacts, selectedUsernames])
|
||||
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(() => {
|
||||
if (filteredContacts.length === 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>
|
||||
{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>
|
||||
{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>
|
||||
|
||||
<button
|
||||
@@ -1091,6 +1168,14 @@ function ContactsPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+1469
-150
@@ -20,6 +20,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.export-top-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.export-section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -27,6 +33,83 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-load-detail-entry {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-load-detail-entry-icon {
|
||||
width: 16px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-load-detail-entry-bar {
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 78%, var(--text-tertiary));
|
||||
opacity: 0.82;
|
||||
animation: sessionLoadDetailBars 1.8s ease-in-out infinite;
|
||||
|
||||
&:nth-child(1) {
|
||||
height: 7px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
height: 11px;
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
height: 9px;
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
}
|
||||
|
||||
.session-load-detail-entry.active .session-load-detail-entry-bar {
|
||||
animation-duration: 1.1s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes sessionLoadDetailBars {
|
||||
0%, 100% {
|
||||
transform: scaleY(0.72);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(1.18);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.export-section-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
@@ -87,13 +170,396 @@
|
||||
}
|
||||
}
|
||||
|
||||
.session-load-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2200;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.session-load-detail-modal {
|
||||
width: min(820px, 100%);
|
||||
max-height: min(78vh, 860px);
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-load-detail-header {
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-load-detail-close {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-load-detail-body {
|
||||
padding: 12px 16px 16px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.session-load-detail-block {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--card-bg);
|
||||
|
||||
h5 {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-load-detail-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.session-load-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(76px, 0.78fr) minmax(260px, 1.55fr) minmax(84px, 0.74fr) minmax(84px, 0.74fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 9px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent);
|
||||
min-width: 620px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.header {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 75%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.session-load-detail-status-cell {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.session-load-detail-status-icon {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-load-detail-progress-pulse {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
letter-spacing: 0.1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-mutual-friends-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2250;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.session-mutual-friends-modal {
|
||||
width: min(760px, 100%);
|
||||
max-height: min(82vh, 900px);
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-mutual-friends-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-mutual-friends-header-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-mutual-friends-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.session-mutual-friends-meta {
|
||||
min-width: 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-mutual-friends-stats {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-mutual-friends-close {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.session-mutual-friends-tip {
|
||||
margin: 14px 16px 0;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.session-mutual-friends-toolbar {
|
||||
padding: 12px 16px 0;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--primary) 58%, var(--border-color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-mutual-friends-body {
|
||||
padding: 14px 16px 16px;
|
||||
overflow: auto;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.session-mutual-friends-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-mutual-friends-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(120px, 0.82fr) max-content 56px 96px minmax(0, 1.28fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-height: 42px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.session-mutual-friends-rank,
|
||||
.session-mutual-friends-count,
|
||||
.session-mutual-friends-latest {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.session-mutual-friends-rank {
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.session-mutual-friends-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-mutual-friends-source {
|
||||
justify-self: start;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
&.incoming {
|
||||
color: #065f46;
|
||||
border-color: color-mix(in srgb, #10b981 38%, var(--border-color));
|
||||
background: color-mix(in srgb, #10b981 10%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&.outgoing {
|
||||
color: #92400e;
|
||||
border-color: color-mix(in srgb, #d97706 38%, var(--border-color));
|
||||
background: color-mix(in srgb, #f59e0b 12%, var(--bg-secondary));
|
||||
}
|
||||
|
||||
&.bidirectional {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
.session-mutual-friends-desc {
|
||||
min-width: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-mutual-friends-count,
|
||||
.session-mutual-friends-latest {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-mutual-friends-empty {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.global-export-controls {
|
||||
--top-inline-control-height: 34px;
|
||||
flex: 0 1 980px;
|
||||
width: min(980px, 100%);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(240px, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -102,13 +568,22 @@
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
width: 78px;
|
||||
flex: 0 0 78px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.path-control {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.control-label {
|
||||
width: 48px;
|
||||
flex-basis: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.path-inline-row {
|
||||
@@ -173,16 +648,44 @@
|
||||
.write-layout-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.more-export-settings-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.more-export-settings-btn {
|
||||
min-height: var(--top-inline-control-height);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 0 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
.layout-trigger {
|
||||
width: 100%;
|
||||
min-height: var(--top-inline-control-height);
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -332,81 +835,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.task-center-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-center-inline {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.task-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-open-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border-radius: 7px;
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.active-running {
|
||||
border-color: rgba(255, 77, 79, 0.45);
|
||||
color: #ff4d4f;
|
||||
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.task-running-badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2);
|
||||
animation: exportTaskBadgePulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
border-radius: 7px;
|
||||
padding: 6px 9px;
|
||||
@@ -416,6 +844,116 @@
|
||||
}
|
||||
}
|
||||
|
||||
.task-center-card {
|
||||
min-width: 92px;
|
||||
min-height: 42px;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.has-alert {
|
||||
border-color: rgba(255, 77, 79, 0.28);
|
||||
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.export-defaults-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2300;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.export-defaults-modal {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(80vh, 860px);
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.export-defaults-modal-header {
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-defaults-modal-body {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.export-defaults-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.task-center-card-label {
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-center-card-badge {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.16);
|
||||
animation: exportTaskBadgePulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.content-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
@@ -482,11 +1020,9 @@
|
||||
|
||||
.card-export-btn {
|
||||
margin-top: auto;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
padding: 7px 9px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -495,19 +1031,40 @@
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
&.primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&.primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
|
||||
color: var(--text-secondary);
|
||||
border-color: color-mix(in srgb, var(--border-color) 85%, transparent);
|
||||
}
|
||||
|
||||
&.secondary:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 28%, transparent);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 94%, var(--primary) 6%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
&.running {
|
||||
background: var(--primary-hover);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
&.primary.running {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.skeleton-card {
|
||||
@@ -584,7 +1141,7 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
padding: 12px 14px 14px;
|
||||
background: var(--bg-secondary-solid, #ffffff);
|
||||
}
|
||||
|
||||
@@ -994,8 +1551,10 @@
|
||||
--contacts-default-visible-rows: 10;
|
||||
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
|
||||
--contacts-select-col-width: 34px;
|
||||
--contacts-inline-padding: 12px;
|
||||
--contacts-message-col-width: 120px;
|
||||
--contacts-action-col-width: 280px;
|
||||
--contacts-media-col-width: 72px;
|
||||
--contacts-action-col-width: 140px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
@@ -1126,7 +1685,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px 8px;
|
||||
padding: 10px var(--contacts-inline-padding) 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
|
||||
font-size: 12px;
|
||||
@@ -1139,7 +1698,9 @@
|
||||
.contacts-list-header-select {
|
||||
width: var(--contacts-select-col-width);
|
||||
min-width: var(--contacts-select-col-width);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1167,13 +1728,41 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contacts-list-header-media {
|
||||
width: var(--contacts-media-col-width);
|
||||
min-width: var(--contacts-media-col-width);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contacts-list-header-actions {
|
||||
width: var(--contacts-action-col-width);
|
||||
width: max(var(--contacts-action-col-width), 184px);
|
||||
min-width: max(var(--contacts-action-col-width), 184px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 8;
|
||||
background: var(--bg-primary);
|
||||
white-space: nowrap;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -8px;
|
||||
width: 8px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list {
|
||||
@@ -1181,7 +1770,7 @@
|
||||
min-height: var(--contacts-default-list-height);
|
||||
height: var(--contacts-default-list-height);
|
||||
overflow: hidden;
|
||||
padding: 0 12px 12px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.contacts-virtuoso {
|
||||
@@ -1206,6 +1795,7 @@
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--text-tertiary);
|
||||
@@ -1229,6 +1819,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
@@ -1258,13 +1850,17 @@
|
||||
&.selected .contact-item {
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
&.selected .row-action-cell {
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
padding: 12px var(--contacts-inline-padding);
|
||||
height: 72px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
@@ -1347,12 +1943,87 @@
|
||||
width: var(--contacts-message-col-width);
|
||||
min-width: var(--contacts-message-col-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row-media-metric {
|
||||
width: var(--contacts-media-col-width);
|
||||
min-width: var(--contacts-media-col-width);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row-media-metric-value {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.row-media-metric-icon {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.row-sns-metric-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 48%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--text-secondary);
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
.row-mutual-friends-btn.ready {
|
||||
color: #0f766e;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #115e59;
|
||||
}
|
||||
}
|
||||
|
||||
.row-message-stats {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -1397,6 +2068,30 @@
|
||||
.row-message-stat.total .row-message-count-value {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row-open-chat-link {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-virtuoso {
|
||||
@@ -1532,44 +2227,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
.row-action-cell {
|
||||
.row-action-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
width: var(--contacts-action-col-width);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 6;
|
||||
background: var(--bg-primary);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
bottom: -12px;
|
||||
left: -8px;
|
||||
width: 8px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to right, transparent, var(--bg-primary));
|
||||
}
|
||||
|
||||
.row-action-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.row-open-chat-btn {
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 38%, var(--border-color));
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary));
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary) 18%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary) 55%, var(--border-color));
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
color: var(--text-tertiary);
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
&.single-line {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,47 +2285,81 @@
|
||||
}
|
||||
}
|
||||
|
||||
.row-export-btn {
|
||||
.row-export-action-stack {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 84px;
|
||||
|
||||
&.single-line {
|
||||
min-height: 28px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.row-export-link {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.75;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.running {
|
||||
background: color-mix(in srgb, var(--primary) 80%, #000);
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.paused {
|
||||
background: rgba(250, 173, 20, 0.16);
|
||||
color: #d48806;
|
||||
border: 1px solid rgba(250, 173, 20, 0.38);
|
||||
&.state-running {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
&.no-session {
|
||||
background: var(--bg-secondary);
|
||||
&.state-disabled {
|
||||
color: var(--text-tertiary);
|
||||
border: 1px dashed var(--border-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row-export-time {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row-export-link.state-running + .row-export-time {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row-export-link.state-running:hover:not(:disabled),
|
||||
.row-export-link.state-running:focus-visible {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row-export-link.state-disabled:hover:not(:disabled),
|
||||
.row-export-link.state-disabled:focus-visible {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1840,6 +2563,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.detail-sns-entry-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1951,6 +2678,345 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-session-sns-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);
|
||||
}
|
||||
|
||||
.export-session-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;
|
||||
|
||||
.sns-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sns-dialog-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.sns-dialog-meta {
|
||||
min-width: 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sns-dialog-username {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sns-dialog-stats {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sns-dialog-header-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sns-dialog-rank-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.sns-dialog-rank-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.sns-dialog-rank-index {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sns-dialog-rank-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sns-dialog-rank-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.sns-dialog-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
|
||||
.export-session-sns-posts-list {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.post-header-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sns-post-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sns-post-card {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border-radius: 10px;
|
||||
padding: 10px 11px;
|
||||
}
|
||||
|
||||
.sns-post-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sns-post-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sns-post-media-grid {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sns-post-media-item {
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sns-post-media-video-tag {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
background: rgba(0, 0, 0, 0.64);
|
||||
color: #fff;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.sns-dialog-status {
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
|
||||
&.empty {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2045,7 +3111,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-right: 2px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
@@ -2061,6 +3127,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-header-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dialog-header-note {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.close-icon-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
@@ -2125,6 +3204,74 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-format-select {
|
||||
position: relative;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dialog-format-trigger {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-format-trigger-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dialog-format-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: min(360px, calc(100vw - 64px));
|
||||
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);
|
||||
}
|
||||
|
||||
.dialog-format-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);
|
||||
}
|
||||
|
||||
&.active .option-desc {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.scope-tag-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2244,16 +3391,18 @@
|
||||
|
||||
.media-check-grid {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
@@ -2261,6 +3410,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-switch-row {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dialog-switch-copy {
|
||||
min-width: 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.format-note {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-switch {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 45%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&.on {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-switch-thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-switch.on .dialog-switch-thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.display-name-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
@@ -2627,9 +3829,16 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.export-top-bar {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.global-export-controls {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
flex-basis: 920px;
|
||||
width: min(920px, 100%);
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(220px, 1fr) auto;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
@@ -2641,17 +3850,47 @@
|
||||
}
|
||||
|
||||
.media-check-grid {
|
||||
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.global-export-controls {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.export-top-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-center-control {
|
||||
grid-column: 1 / -1;
|
||||
.global-export-controls {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.global-export-controls .path-control,
|
||||
.global-export-controls .write-layout-control {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.global-export-controls .control-label {
|
||||
width: auto;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.task-center-card {
|
||||
align-self: auto;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dialog-switch-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.export-defaults-modal {
|
||||
width: min(92vw, 720px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2660,14 +3899,44 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.session-load-detail-entry {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.session-load-detail-modal {
|
||||
width: min(94vw, 820px);
|
||||
}
|
||||
|
||||
.session-mutual-friends-modal {
|
||||
width: min(94vw, 760px);
|
||||
max-height: 86vh;
|
||||
}
|
||||
|
||||
.session-mutual-friends-row {
|
||||
grid-template-columns: 30px minmax(88px, 0.9fr) max-content 44px 74px;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.session-mutual-friends-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.session-load-detail-row {
|
||||
grid-template-columns: minmax(68px, 0.72fr) minmax(232px, 1.6fr) minmax(80px, 0.72fr) minmax(80px, 0.72fr);
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
--contacts-inline-padding: 10px;
|
||||
--contacts-message-col-width: 104px;
|
||||
--contacts-action-col-width: 236px;
|
||||
--contacts-media-col-width: 62px;
|
||||
--contacts-action-col-width: 140px;
|
||||
}
|
||||
|
||||
.table-wrap .contacts-list-header {
|
||||
gap: 8px;
|
||||
padding: 8px 10px 6px;
|
||||
padding: 8px var(--contacts-inline-padding) 6px;
|
||||
}
|
||||
|
||||
.table-wrap .contacts-list-header-main {
|
||||
@@ -2679,7 +3948,7 @@
|
||||
}
|
||||
|
||||
.table-wrap .contacts-list {
|
||||
padding: 0 10px 10px;
|
||||
padding: 0 0 10px;
|
||||
min-height: 300px;
|
||||
height: min(56vh, 560px);
|
||||
}
|
||||
@@ -2688,6 +3957,10 @@
|
||||
min-width: var(--contacts-message-col-width);
|
||||
}
|
||||
|
||||
.table-wrap .row-media-metric {
|
||||
min-width: var(--contacts-media-col-width);
|
||||
}
|
||||
|
||||
.table-wrap .row-message-stats {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -2700,10 +3973,19 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table-wrap .row-media-metric-value {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.table-wrap .row-message-stat.total .row-message-count-value {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table-wrap .row-open-chat-link,
|
||||
.table-wrap .row-export-link {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.export-dialog-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -2746,4 +4028,41 @@
|
||||
.export-session-detail-panel {
|
||||
width: calc(100vw - 12px);
|
||||
}
|
||||
|
||||
.export-session-sns-overlay {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.export-session-sns-dialog {
|
||||
width: min(100vw - 16px, 760px);
|
||||
max-height: calc(100vh - 24px);
|
||||
|
||||
.sns-dialog-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sns-dialog-header-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sns-dialog-rank-btn {
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sns-dialog-rank-panel {
|
||||
width: min(78vw, 232px);
|
||||
max-height: calc((28px * 15) + 16px);
|
||||
}
|
||||
|
||||
.sns-dialog-tip {
|
||||
padding: 10px 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sns-dialog-body {
|
||||
padding: 10px 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2952
-731
@@ -38,6 +38,19 @@ import {
|
||||
onOpenSingleExport
|
||||
} from '../services/exportBridge'
|
||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
||||
import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm'
|
||||
import type { SnsPost } from '../types/sns'
|
||||
import {
|
||||
cloneExportDateRange,
|
||||
createDefaultDateRange,
|
||||
createDefaultExportDateRangeSelection,
|
||||
getExportDateRangeLabel,
|
||||
resolveExportDateRangeConfig,
|
||||
type ExportDateRangeSelection
|
||||
} from '../utils/exportDateRange'
|
||||
import './ExportPage.scss'
|
||||
|
||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||
@@ -45,21 +58,11 @@ type TaskStatus = 'queued' | 'running' | 'success' | 'error'
|
||||
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
||||
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
||||
type ContentCardType = ContentType | 'sns'
|
||||
type SnsRankMode = 'likes' | 'comments'
|
||||
|
||||
type SessionLayout = 'shared' | 'per-session'
|
||||
|
||||
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
|
||||
type DateRangePreset =
|
||||
| 'all'
|
||||
| 'today'
|
||||
| 'yesterday'
|
||||
| 'last3days'
|
||||
| 'last7days'
|
||||
| 'last30days'
|
||||
| 'last1year'
|
||||
| 'last2years'
|
||||
| 'custom'
|
||||
type CalendarCell = { date: Date; inCurrentMonth: boolean }
|
||||
|
||||
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
|
||||
@@ -154,15 +157,17 @@ interface ExportDialogState {
|
||||
title: string
|
||||
}
|
||||
|
||||
interface TimeRangeDialogDraft {
|
||||
preset: DateRangePreset
|
||||
useAllTime: boolean
|
||||
dateRange: { start: Date; end: Date }
|
||||
startPanelMonth: Date
|
||||
endPanelMonth: Date
|
||||
}
|
||||
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
||||
const SESSION_MEDIA_METRIC_BATCH_SIZE = 12
|
||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120
|
||||
const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200
|
||||
const SNS_USER_POST_COUNT_BATCH_SIZE = 12
|
||||
const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120
|
||||
const SNS_RANK_PAGE_SIZE = 50
|
||||
const SNS_RANK_DISPLAY_LIMIT = 15
|
||||
const contentTypeLabels: Record<ContentType, string> = {
|
||||
text: '聊天文本',
|
||||
voice: '语音',
|
||||
@@ -171,6 +176,13 @@ const contentTypeLabels: Record<ContentType, string> = {
|
||||
emoji: '表情包'
|
||||
}
|
||||
|
||||
const conversationTabLabels: Record<ConversationTab, string> = {
|
||||
private: '私聊',
|
||||
group: '群聊',
|
||||
official: '公众号',
|
||||
former_friend: '曾经的好友'
|
||||
}
|
||||
|
||||
const getContentTypeLabel = (type: ContentType): string => {
|
||||
return contentTypeLabels[type] || type
|
||||
}
|
||||
@@ -409,6 +421,14 @@ const formatYmdHmDateTime = (timestamp?: number): string => {
|
||||
return `${y}-${m}-${day} ${h}:${min}`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const formatPathBrief = (value: string, maxLength = 52): string => {
|
||||
const normalized = String(value || '')
|
||||
if (normalized.length <= maxLength) return normalized
|
||||
@@ -434,126 +454,6 @@ const formatRecentExportTime = (timestamp?: number, now = Date.now()): string =>
|
||||
return formatAbsoluteDate(timestamp)
|
||||
}
|
||||
|
||||
const startOfDay = (date: Date): Date => {
|
||||
const next = new Date(date)
|
||||
next.setHours(0, 0, 0, 0)
|
||||
return next
|
||||
}
|
||||
|
||||
const endOfDay = (date: Date): Date => {
|
||||
const next = new Date(date)
|
||||
next.setHours(23, 59, 59, 999)
|
||||
return next
|
||||
}
|
||||
|
||||
const createDefaultDateRange = (): { start: Date; end: Date } => {
|
||||
const now = new Date()
|
||||
return {
|
||||
start: startOfDay(now),
|
||||
end: now
|
||||
}
|
||||
}
|
||||
|
||||
const createDateRangeByPreset = (
|
||||
preset: Exclude<DateRangePreset, 'all' | 'custom'>,
|
||||
now = new Date()
|
||||
): { start: Date; end: Date } => {
|
||||
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)
|
||||
// Handle leap-year fallback (e.g. Feb 29 -> Feb 28).
|
||||
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 }
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
|
||||
const addMonths = (date: Date, delta: number): Date => {
|
||||
const next = new Date(date)
|
||||
next.setMonth(next.getMonth() + delta)
|
||||
return toMonthStart(next)
|
||||
}
|
||||
|
||||
const isSameDay = (left: Date, right: Date): boolean => (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate()
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月`
|
||||
|
||||
const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六']
|
||||
|
||||
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
|
||||
if (session.username.endsWith('@chatroom')) return 'group'
|
||||
if (session.username.startsWith('gh_')) return 'official'
|
||||
@@ -573,6 +473,10 @@ const isContentScopeSession = (session: SessionRow): boolean => (
|
||||
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
|
||||
)
|
||||
|
||||
const isExportConversationSession = (session: SessionRow): boolean => (
|
||||
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
|
||||
)
|
||||
|
||||
const exportKindPriority: Record<ConversationTab, number> = {
|
||||
private: 0,
|
||||
group: 1,
|
||||
@@ -585,6 +489,16 @@ const getAvatarLetter = (name: string): string => {
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
|
||||
const toComparableNameSet = (values: Array<string | undefined | null>): Set<string> => {
|
||||
const set = new Set<string>()
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) continue
|
||||
set.add(normalized)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => {
|
||||
if (tab === 'private') return contact.type === 'friend'
|
||||
if (tab === 'group') return contact.type === 'group'
|
||||
@@ -648,6 +562,202 @@ interface SessionDetail {
|
||||
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||
}
|
||||
|
||||
interface SessionSnsTimelineTarget {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface SessionSnsRankItem {
|
||||
name: string
|
||||
count: number
|
||||
latestTime: number
|
||||
}
|
||||
|
||||
type SessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional'
|
||||
type SessionMutualFriendBehavior = 'likes' | 'comments' | 'both'
|
||||
|
||||
interface SessionMutualFriendItem {
|
||||
name: string
|
||||
incomingLikeCount: number
|
||||
incomingCommentCount: number
|
||||
outgoingLikeCount: number
|
||||
outgoingCommentCount: number
|
||||
totalCount: number
|
||||
latestTime: number
|
||||
direction: SessionMutualFriendDirection
|
||||
behavior: SessionMutualFriendBehavior
|
||||
}
|
||||
|
||||
interface SessionMutualFriendsMetric {
|
||||
count: number
|
||||
items: SessionMutualFriendItem[]
|
||||
loadedPosts: number
|
||||
totalPosts: number | null
|
||||
computedAt: number
|
||||
}
|
||||
|
||||
interface SessionSnsRankCacheEntry {
|
||||
likes: SessionSnsRankItem[]
|
||||
comments: SessionSnsRankItem[]
|
||||
totalPosts: number
|
||||
computedAt: number
|
||||
}
|
||||
|
||||
const buildSessionSnsRankings = (posts: SnsPost[]): { likes: SessionSnsRankItem[]; comments: SessionSnsRankItem[] } => {
|
||||
const likeMap = new Map<string, SessionSnsRankItem>()
|
||||
const commentMap = new Map<string, SessionSnsRankItem>()
|
||||
|
||||
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 = (a: SessionSnsRankItem, b: SessionSnsRankItem): number => {
|
||||
if (b.count !== a.count) return b.count - a.count
|
||||
if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime
|
||||
return a.name.localeCompare(b.name, 'zh-CN')
|
||||
}
|
||||
|
||||
return {
|
||||
likes: [...likeMap.values()].sort(sorter),
|
||||
comments: [...commentMap.values()].sort(sorter)
|
||||
}
|
||||
}
|
||||
|
||||
const buildSessionMutualFriendsMetric = (
|
||||
posts: SnsPost[],
|
||||
totalPosts: number | null
|
||||
): SessionMutualFriendsMetric => {
|
||||
const friendMap = new Map<string, SessionMutualFriendItem>()
|
||||
|
||||
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 existing = friendMap.get(name)
|
||||
if (existing) {
|
||||
existing.incomingLikeCount += 1
|
||||
existing.totalCount += 1
|
||||
existing.behavior = existing.incomingCommentCount > 0 ? 'both' : 'likes'
|
||||
if (createTime > existing.latestTime) existing.latestTime = createTime
|
||||
continue
|
||||
}
|
||||
friendMap.set(name, {
|
||||
name,
|
||||
incomingLikeCount: 1,
|
||||
incomingCommentCount: 0,
|
||||
outgoingLikeCount: 0,
|
||||
outgoingCommentCount: 0,
|
||||
totalCount: 1,
|
||||
latestTime: createTime,
|
||||
direction: 'incoming',
|
||||
behavior: 'likes'
|
||||
})
|
||||
}
|
||||
|
||||
for (const comment of comments) {
|
||||
const name = String(comment?.nickname || '').trim() || '未知用户'
|
||||
const existing = friendMap.get(name)
|
||||
if (existing) {
|
||||
existing.incomingCommentCount += 1
|
||||
existing.totalCount += 1
|
||||
existing.behavior = existing.incomingLikeCount > 0 ? 'both' : 'comments'
|
||||
if (createTime > existing.latestTime) existing.latestTime = createTime
|
||||
continue
|
||||
}
|
||||
friendMap.set(name, {
|
||||
name,
|
||||
incomingLikeCount: 0,
|
||||
incomingCommentCount: 1,
|
||||
outgoingLikeCount: 0,
|
||||
outgoingCommentCount: 0,
|
||||
totalCount: 1,
|
||||
latestTime: createTime,
|
||||
direction: 'incoming',
|
||||
behavior: 'comments'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const items = [...friendMap.values()].sort((a, b) => {
|
||||
if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount
|
||||
if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime
|
||||
return a.name.localeCompare(b.name, 'zh-CN')
|
||||
})
|
||||
|
||||
return {
|
||||
count: items.length,
|
||||
items,
|
||||
loadedPosts: posts.length,
|
||||
totalPosts,
|
||||
computedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const getSessionMutualFriendDirectionLabel = (direction: SessionMutualFriendDirection): string => {
|
||||
if (direction === 'incoming') return '对方赞/评TA'
|
||||
if (direction === 'outgoing') return 'TA赞/评对方'
|
||||
return '双方有互动'
|
||||
}
|
||||
|
||||
const getSessionMutualFriendBehaviorLabel = (behavior: SessionMutualFriendBehavior): string => {
|
||||
if (behavior === 'likes') return '赞'
|
||||
if (behavior === 'comments') return '评'
|
||||
return '赞/评'
|
||||
}
|
||||
|
||||
const summarizeMutualFriendBehavior = (likeCount: number, commentCount: number): SessionMutualFriendBehavior => {
|
||||
if (likeCount > 0 && commentCount > 0) return 'both'
|
||||
if (likeCount > 0) return 'likes'
|
||||
return 'comments'
|
||||
}
|
||||
|
||||
const describeSessionMutualFriendRelation = (
|
||||
item: SessionMutualFriendItem,
|
||||
targetDisplayName: string
|
||||
): string => {
|
||||
if (item.direction === 'incoming') {
|
||||
if (item.behavior === 'likes') return `${item.name} 给 ${targetDisplayName} 点过赞`
|
||||
if (item.behavior === 'comments') return `${item.name} 给 ${targetDisplayName} 评论过`
|
||||
return `${item.name} 给 ${targetDisplayName} 点过赞、评论过`
|
||||
}
|
||||
if (item.direction === 'outgoing') {
|
||||
if (item.behavior === 'likes') return `${targetDisplayName} 给 ${item.name} 点过赞`
|
||||
if (item.behavior === 'comments') return `${targetDisplayName} 给 ${item.name} 评论过`
|
||||
return `${targetDisplayName} 给 ${item.name} 点过赞、评论过`
|
||||
}
|
||||
if (item.behavior === 'likes') return `${targetDisplayName} 和 ${item.name} 双方都有点赞互动`
|
||||
if (item.behavior === 'comments') return `${targetDisplayName} 和 ${item.name} 双方都有评论互动`
|
||||
return `${targetDisplayName} 和 ${item.name} 双方都有点赞或评论互动`
|
||||
}
|
||||
|
||||
interface SessionExportMetric {
|
||||
totalMessages: number
|
||||
voiceMessages: number
|
||||
@@ -684,6 +794,31 @@ interface SessionExportCacheMeta {
|
||||
source: 'memory' | 'disk' | 'fresh'
|
||||
}
|
||||
|
||||
type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed'
|
||||
|
||||
interface SessionLoadStageState {
|
||||
status: SessionLoadStageStatus
|
||||
startedAt?: number
|
||||
finishedAt?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface SessionLoadTraceState {
|
||||
messageCount: SessionLoadStageState
|
||||
mediaMetrics: SessionLoadStageState
|
||||
snsPostCounts: SessionLoadStageState
|
||||
mutualFriends: SessionLoadStageState
|
||||
}
|
||||
|
||||
interface SessionLoadStageSummary {
|
||||
total: number
|
||||
loaded: number
|
||||
statusLabel: string
|
||||
startedAt?: number
|
||||
finishedAt?: number
|
||||
latestProgressAt?: number
|
||||
}
|
||||
|
||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
@@ -869,6 +1004,49 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
const pickSessionMediaMetric = (
|
||||
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
||||
): SessionContentMetric | null => {
|
||||
if (!metricRaw) return null
|
||||
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
|
||||
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
|
||||
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
|
||||
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
|
||||
if (
|
||||
typeof voiceMessages !== 'number' &&
|
||||
typeof imageMessages !== 'number' &&
|
||||
typeof videoMessages !== 'number' &&
|
||||
typeof emojiMessages !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
voiceMessages,
|
||||
imageMessages,
|
||||
videoMessages,
|
||||
emojiMessages
|
||||
}
|
||||
}
|
||||
|
||||
const hasCompleteSessionMediaMetric = (metricRaw: SessionContentMetric | undefined): boolean => {
|
||||
if (!metricRaw) return false
|
||||
return (
|
||||
typeof normalizeMessageCount(metricRaw.voiceMessages) === 'number' &&
|
||||
typeof normalizeMessageCount(metricRaw.imageMessages) === 'number' &&
|
||||
typeof normalizeMessageCount(metricRaw.videoMessages) === 'number' &&
|
||||
typeof normalizeMessageCount(metricRaw.emojiMessages) === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'pending' })
|
||||
|
||||
const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({
|
||||
messageCount: createDefaultSessionLoadStage(),
|
||||
mediaMetrics: createDefaultSessionLoadStage(),
|
||||
snsPostCounts: createDefaultSessionLoadStage(),
|
||||
mutualFriends: createDefaultSessionLoadStage()
|
||||
})
|
||||
|
||||
const WriteLayoutSelector = memo(function WriteLayoutSelector({
|
||||
writeLayout,
|
||||
onChange,
|
||||
@@ -1208,19 +1386,42 @@ function ExportPage() {
|
||||
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
|
||||
const [sessionMessageCounts, setSessionMessageCounts] = useState<Record<string, number>>({})
|
||||
const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false)
|
||||
const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false)
|
||||
const [sessionContentMetrics, setSessionContentMetrics] = useState<Record<string, SessionContentMetric>>({})
|
||||
const [sessionLoadTraceMap, setSessionLoadTraceMap] = useState<Record<string, SessionLoadTraceState>>({})
|
||||
const [sessionLoadProgressPulseMap, setSessionLoadProgressPulseMap] = useState<Record<string, { at: number; delta: number }>>({})
|
||||
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||||
const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null)
|
||||
const [contactsLoadIssue, setContactsLoadIssue] = useState<ContactsLoadIssue | null>(null)
|
||||
const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false)
|
||||
const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now())
|
||||
const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false)
|
||||
const [showSessionLoadDetailModal, setShowSessionLoadDetailModal] = useState(false)
|
||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||
const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false)
|
||||
const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false)
|
||||
const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false)
|
||||
const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false)
|
||||
const [copiedDetailField, setCopiedDetailField] = useState<string | null>(null)
|
||||
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
|
||||
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const [sessionSnsTimelineTarget, setSessionSnsTimelineTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
||||
const [sessionSnsTimelinePosts, setSessionSnsTimelinePosts] = useState<SnsPost[]>([])
|
||||
const [sessionSnsTimelineLoading, setSessionSnsTimelineLoading] = useState(false)
|
||||
const [sessionSnsTimelineLoadingMore, setSessionSnsTimelineLoadingMore] = useState(false)
|
||||
const [sessionSnsTimelineHasMore, setSessionSnsTimelineHasMore] = useState(false)
|
||||
const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState<number | null>(null)
|
||||
const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false)
|
||||
const [sessionSnsRankMode, setSessionSnsRankMode] = useState<SnsRankMode | null>(null)
|
||||
const [sessionSnsLikeRankings, setSessionSnsLikeRankings] = useState<SessionSnsRankItem[]>([])
|
||||
const [sessionSnsCommentRankings, setSessionSnsCommentRankings] = useState<SessionSnsRankItem[]>([])
|
||||
const [sessionSnsRankLoading, setSessionSnsRankLoading] = useState(false)
|
||||
const [sessionSnsRankError, setSessionSnsRankError] = useState<string | null>(null)
|
||||
const [sessionSnsRankLoadedPosts, setSessionSnsRankLoadedPosts] = useState(0)
|
||||
const [sessionSnsRankTotalPosts, setSessionSnsRankTotalPosts] = useState<number | null>(null)
|
||||
const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState<Record<string, SessionMutualFriendsMetric>>({})
|
||||
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
||||
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
|
||||
|
||||
const [exportFolder, setExportFolder] = useState('')
|
||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
||||
@@ -1230,10 +1431,20 @@ function ExportPage() {
|
||||
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
|
||||
const [snsExportVideos, setSnsExportVideos] = useState(false)
|
||||
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
|
||||
const [timeRangePreset, setTimeRangePreset] = useState<DateRangePreset>('all')
|
||||
const [timeRangeDialogDraft, setTimeRangeDialogDraft] = useState<TimeRangeDialogDraft | null>(null)
|
||||
const [timeRangeDateInput, setTimeRangeDateInput] = useState<{ start: string; end: string }>({ start: '', end: '' })
|
||||
const [timeRangeDateInputError, setTimeRangeDateInputError] = useState<{ start: boolean; end: boolean }>({ start: false, end: false })
|
||||
const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false)
|
||||
const [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||
const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = 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)
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'json',
|
||||
@@ -1243,11 +1454,11 @@ function ExportPage() {
|
||||
},
|
||||
useAllTime: false,
|
||||
exportAvatars: true,
|
||||
exportMedia: false,
|
||||
exportMedia: true,
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportVideos: true,
|
||||
exportEmojis: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: false,
|
||||
excelCompactColumns: true,
|
||||
txtColumns: defaultTxtColumns,
|
||||
@@ -1262,6 +1473,7 @@ function ExportPage() {
|
||||
sessionNames: [],
|
||||
title: ''
|
||||
})
|
||||
const [showSessionFormatSelect, setShowSessionFormatSelect] = useState(false)
|
||||
|
||||
const [tasks, setTasks] = useState<ExportTask[]>([])
|
||||
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
|
||||
@@ -1296,8 +1508,10 @@ function ExportPage() {
|
||||
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
|
||||
const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null)
|
||||
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
|
||||
const sessionFormatDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||
const detailRequestSeqRef = useRef(0)
|
||||
const sessionsRef = useRef<SessionRow[]>([])
|
||||
const sessionContentMetricsRef = useRef<Record<string, SessionContentMetric>>({})
|
||||
const contactsListSizeRef = useRef(0)
|
||||
const contactsUpdatedAtRef = useRef<number | null>(null)
|
||||
const sessionsHydratedAtRef = useRef(0)
|
||||
@@ -1306,7 +1520,45 @@ function ExportPage() {
|
||||
const activeTaskCountRef = useRef(0)
|
||||
const hasBaseConfigReadyRef = useRef(false)
|
||||
const sessionCountRequestIdRef = useRef(0)
|
||||
const isLoadingSessionCountsRef = useRef(false)
|
||||
const activeTabRef = useRef<ConversationTab>('private')
|
||||
const detailStatsPriorityRef = useRef(false)
|
||||
const sessionSnsTimelinePostsRef = useRef<SnsPost[]>([])
|
||||
const sessionSnsTimelineLoadingRef = useRef(false)
|
||||
const sessionSnsTimelineRequestTokenRef = useRef(0)
|
||||
const sessionSnsRankRequestTokenRef = useRef(0)
|
||||
const sessionSnsRankLoadingRef = useRef(false)
|
||||
const sessionSnsRankCacheRef = useRef<Record<string, SessionSnsRankCacheEntry>>({})
|
||||
const snsUserPostCountsHydrationTokenRef = useRef(0)
|
||||
const snsUserPostCountsBatchTimerRef = useRef<number | null>(null)
|
||||
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
|
||||
const sessionLoadProgressSnapshotRef = useRef<Record<string, { loaded: number; total: number }>>({})
|
||||
const sessionMediaMetricQueueRef = useRef<string[]>([])
|
||||
const sessionMediaMetricQueuedSetRef = useRef<Set<string>>(new Set())
|
||||
const sessionMediaMetricLoadingSetRef = useRef<Set<string>>(new Set())
|
||||
const sessionMediaMetricReadySetRef = useRef<Set<string>>(new Set())
|
||||
const sessionMediaMetricRunIdRef = useRef(0)
|
||||
const sessionMediaMetricWorkerRunningRef = useRef(false)
|
||||
const sessionMediaMetricBackgroundFeedTimerRef = useRef<number | null>(null)
|
||||
const sessionMediaMetricPersistTimerRef = useRef<number | null>(null)
|
||||
const sessionMediaMetricPendingPersistRef = useRef<Record<string, configService.ExportSessionContentMetricCacheEntry>>({})
|
||||
const sessionMediaMetricVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
|
||||
startIndex: 0,
|
||||
endIndex: -1
|
||||
})
|
||||
const sessionMutualFriendsMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({})
|
||||
const sessionMutualFriendsDirectMetricsRef = useRef<Record<string, SessionMutualFriendsMetric>>({})
|
||||
const sessionMutualFriendsQueueRef = useRef<string[]>([])
|
||||
const sessionMutualFriendsQueuedSetRef = useRef<Set<string>>(new Set())
|
||||
const sessionMutualFriendsLoadingSetRef = useRef<Set<string>>(new Set())
|
||||
const sessionMutualFriendsReadySetRef = useRef<Set<string>>(new Set())
|
||||
const sessionMutualFriendsRunIdRef = useRef(0)
|
||||
const sessionMutualFriendsWorkerRunningRef = useRef(false)
|
||||
const sessionMutualFriendsBackgroundFeedTimerRef = useRef<number | null>(null)
|
||||
const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({
|
||||
startIndex: 0,
|
||||
endIndex: -1
|
||||
})
|
||||
|
||||
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
||||
if (exportCacheScopeReadyRef.current) {
|
||||
@@ -1356,6 +1608,108 @@ function ExportPage() {
|
||||
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
|
||||
}, [contactsLoadTimeoutMs])
|
||||
|
||||
useEffect(() => {
|
||||
isLoadingSessionCountsRef.current = isLoadingSessionCounts
|
||||
}, [isLoadingSessionCounts])
|
||||
|
||||
useEffect(() => {
|
||||
sessionContentMetricsRef.current = sessionContentMetrics
|
||||
}, [sessionContentMetrics])
|
||||
|
||||
useEffect(() => {
|
||||
sessionMutualFriendsMetricsRef.current = sessionMutualFriendsMetrics
|
||||
}, [sessionMutualFriendsMetrics])
|
||||
|
||||
const patchSessionLoadTraceStage = useCallback((
|
||||
sessionIds: string[],
|
||||
stageKey: keyof SessionLoadTraceState,
|
||||
status: SessionLoadStageStatus,
|
||||
options?: { force?: boolean; error?: string }
|
||||
) => {
|
||||
if (sessionIds.length === 0) return
|
||||
const now = Date.now()
|
||||
setSessionLoadTraceMap(prev => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
for (const sessionIdRaw of sessionIds) {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!sessionId) continue
|
||||
const prevTrace = next[sessionId] || createDefaultSessionLoadTrace()
|
||||
const prevStage = prevTrace[stageKey] || createDefaultSessionLoadStage()
|
||||
if (!options?.force && prevStage.status === 'done' && status !== 'done') {
|
||||
continue
|
||||
}
|
||||
let stageChanged = false
|
||||
const nextStage: SessionLoadStageState = { ...prevStage }
|
||||
if (nextStage.status !== status) {
|
||||
nextStage.status = status
|
||||
stageChanged = true
|
||||
}
|
||||
if (status === 'loading') {
|
||||
if (!nextStage.startedAt) {
|
||||
nextStage.startedAt = now
|
||||
stageChanged = true
|
||||
}
|
||||
if (nextStage.finishedAt) {
|
||||
nextStage.finishedAt = undefined
|
||||
stageChanged = true
|
||||
}
|
||||
if (nextStage.error) {
|
||||
nextStage.error = undefined
|
||||
stageChanged = true
|
||||
}
|
||||
} else if (status === 'done') {
|
||||
if (!nextStage.startedAt) {
|
||||
nextStage.startedAt = now
|
||||
stageChanged = true
|
||||
}
|
||||
if (!nextStage.finishedAt) {
|
||||
nextStage.finishedAt = now
|
||||
stageChanged = true
|
||||
}
|
||||
if (nextStage.error) {
|
||||
nextStage.error = undefined
|
||||
stageChanged = true
|
||||
}
|
||||
} else if (status === 'failed') {
|
||||
if (!nextStage.startedAt) {
|
||||
nextStage.startedAt = now
|
||||
stageChanged = true
|
||||
}
|
||||
if (!nextStage.finishedAt) {
|
||||
nextStage.finishedAt = now
|
||||
stageChanged = true
|
||||
}
|
||||
const nextError = options?.error || '加载失败'
|
||||
if (nextStage.error !== nextError) {
|
||||
nextStage.error = nextError
|
||||
stageChanged = true
|
||||
}
|
||||
} else if (status === 'pending') {
|
||||
if (nextStage.startedAt !== undefined) {
|
||||
nextStage.startedAt = undefined
|
||||
stageChanged = true
|
||||
}
|
||||
if (nextStage.finishedAt !== undefined) {
|
||||
nextStage.finishedAt = undefined
|
||||
stageChanged = true
|
||||
}
|
||||
if (nextStage.error !== undefined) {
|
||||
nextStage.error = undefined
|
||||
stageChanged = true
|
||||
}
|
||||
}
|
||||
if (!stageChanged) continue
|
||||
next[sessionId] = {
|
||||
...prevTrace,
|
||||
[stageKey]: nextStage
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
|
||||
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
|
||||
const loadVersion = contactsLoadVersionRef.current + 1
|
||||
@@ -1577,6 +1931,10 @@ function ExportPage() {
|
||||
hasSeededSnsStatsRef.current = hasSeededSnsStats
|
||||
}, [hasSeededSnsStats])
|
||||
|
||||
useEffect(() => {
|
||||
sessionSnsTimelinePostsRef.current = sessionSnsTimelinePosts
|
||||
}, [sessionSnsTimelinePosts])
|
||||
|
||||
const preselectSessionIds = useMemo(() => {
|
||||
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
||||
const rawList = Array.isArray(state?.preselectSessionIds)
|
||||
@@ -1607,8 +1965,10 @@ function ExportPage() {
|
||||
setIsBaseConfigLoading(true)
|
||||
let isReady = true
|
||||
try {
|
||||
const [savedPath, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, exportCacheScope] = await Promise.all([
|
||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
|
||||
configService.getExportPath(),
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultAvatars(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
@@ -1620,6 +1980,7 @@ function ExportPage() {
|
||||
configService.getExportLastSnsPostCount(),
|
||||
configService.getExportWriteLayout(),
|
||||
configService.getExportSessionNamePrefixEnabled(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
ensureExportCacheScope()
|
||||
])
|
||||
|
||||
@@ -1638,7 +1999,20 @@ function ExportPage() {
|
||||
setLastExportByContent(savedContentMap)
|
||||
setExportRecordsBySession(savedSessionRecordMap)
|
||||
setLastSnsExportPostCount(savedSnsPostCount)
|
||||
await configService.setExportDefaultFormat('json')
|
||||
setExportDefaultFormat((savedFormat as TextExportFormat) || 'excel')
|
||||
setExportDefaultAvatars(savedAvatars ?? true)
|
||||
setExportDefaultMedia(savedMedia ?? {
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
})
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
|
||||
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
|
||||
setTimeRangeSelection(resolvedDefaultDateRange)
|
||||
|
||||
if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) {
|
||||
setSnsStats({
|
||||
@@ -1653,8 +2027,18 @@ function ExportPage() {
|
||||
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
||||
setOptions(prev => ({
|
||||
...prev,
|
||||
format: 'json',
|
||||
exportMedia: savedMedia ?? prev.exportMedia,
|
||||
format: ((savedFormat as TextExportFormat) || 'excel'),
|
||||
exportAvatars: savedAvatars ?? true,
|
||||
exportMedia: Boolean(
|
||||
(savedMedia?.images ?? prev.exportImages) ||
|
||||
(savedMedia?.voices ?? prev.exportVoices) ||
|
||||
(savedMedia?.videos ?? prev.exportVideos) ||
|
||||
(savedMedia?.emojis ?? prev.exportEmojis)
|
||||
),
|
||||
exportImages: savedMedia?.images ?? prev.exportImages,
|
||||
exportVoices: savedMedia?.voices ?? prev.exportVoices,
|
||||
exportVideos: savedMedia?.videos ?? prev.exportVideos,
|
||||
exportEmojis: savedMedia?.emojis ?? prev.exportEmojis,
|
||||
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
||||
txtColumns,
|
||||
@@ -1722,6 +2106,445 @@ function ExportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => {
|
||||
if (snsUserPostCountsStatus === 'loading') return
|
||||
if (!options?.force && snsUserPostCountsStatus === 'ready') return
|
||||
|
||||
const targetSessionIds = sessionsRef.current
|
||||
.filter((session) => session.hasSession && isSingleContactSession(session.username))
|
||||
.map((session) => session.username)
|
||||
|
||||
snsUserPostCountsHydrationTokenRef.current += 1
|
||||
const runToken = snsUserPostCountsHydrationTokenRef.current
|
||||
if (snsUserPostCountsBatchTimerRef.current) {
|
||||
window.clearTimeout(snsUserPostCountsBatchTimerRef.current)
|
||||
snsUserPostCountsBatchTimerRef.current = null
|
||||
}
|
||||
|
||||
if (targetSessionIds.length === 0) {
|
||||
setSnsUserPostCountsStatus('ready')
|
||||
return
|
||||
}
|
||||
|
||||
const scopeKey = exportCacheScopeReadyRef.current
|
||||
? exportCacheScopeRef.current
|
||||
: await ensureExportCacheScope()
|
||||
const targetSet = new Set(targetSessionIds)
|
||||
let cachedCounts: Record<string, number> = {}
|
||||
try {
|
||||
const cached = await configService.getExportSnsUserPostCountsCache(scopeKey)
|
||||
cachedCounts = cached?.counts || {}
|
||||
} catch (cacheError) {
|
||||
console.error('读取导出页朋友圈条数缓存失败:', cacheError)
|
||||
}
|
||||
|
||||
const cachedTargetCounts = Object.entries(cachedCounts).reduce<Record<string, number>>((acc, [sessionId, countRaw]) => {
|
||||
if (!targetSet.has(sessionId)) return acc
|
||||
const nextCount = Number(countRaw)
|
||||
acc[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0
|
||||
return acc
|
||||
}, {})
|
||||
const cachedReadySessionIds = Object.keys(cachedTargetCounts)
|
||||
if (cachedReadySessionIds.length > 0) {
|
||||
setSnsUserPostCounts(prev => ({ ...prev, ...cachedTargetCounts }))
|
||||
patchSessionLoadTraceStage(cachedReadySessionIds, 'snsPostCounts', 'done')
|
||||
}
|
||||
|
||||
const pendingSessionIds = options?.force
|
||||
? targetSessionIds
|
||||
: targetSessionIds.filter((sessionId) => !(sessionId in cachedTargetCounts))
|
||||
if (pendingSessionIds.length === 0) {
|
||||
setSnsUserPostCountsStatus('ready')
|
||||
return
|
||||
}
|
||||
|
||||
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'pending', { force: true })
|
||||
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'loading')
|
||||
setSnsUserPostCountsStatus('loading')
|
||||
|
||||
let normalizedCounts: Record<string, number> = {}
|
||||
try {
|
||||
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||
if (runToken !== snsUserPostCountsHydrationTokenRef.current) return
|
||||
|
||||
if (!result.success || !result.counts) {
|
||||
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', {
|
||||
error: result.error || '朋友圈条数统计失败'
|
||||
})
|
||||
setSnsUserPostCountsStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
|
||||
} catch (cacheError) {
|
||||
console.error('写入导出页朋友圈条数缓存失败:', cacheError)
|
||||
}
|
||||
})()
|
||||
} catch (error) {
|
||||
console.error('加载朋友圈用户条数失败:', error)
|
||||
if (runToken !== snsUserPostCountsHydrationTokenRef.current) return
|
||||
patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', {
|
||||
error: String(error)
|
||||
})
|
||||
setSnsUserPostCountsStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
let cursor = 0
|
||||
const applyBatch = () => {
|
||||
if (runToken !== snsUserPostCountsHydrationTokenRef.current) return
|
||||
|
||||
const batchSessionIds = pendingSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE)
|
||||
if (batchSessionIds.length === 0) {
|
||||
setSnsUserPostCountsStatus('ready')
|
||||
snsUserPostCountsBatchTimerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const batchCounts: Record<string, number> = {}
|
||||
for (const sessionId of batchSessionIds) {
|
||||
const nextCount = normalizedCounts[sessionId]
|
||||
batchCounts[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0
|
||||
}
|
||||
|
||||
setSnsUserPostCounts(prev => ({ ...prev, ...batchCounts }))
|
||||
patchSessionLoadTraceStage(batchSessionIds, 'snsPostCounts', 'done')
|
||||
|
||||
cursor += batchSessionIds.length
|
||||
if (cursor < targetSessionIds.length) {
|
||||
snsUserPostCountsBatchTimerRef.current = window.setTimeout(applyBatch, SNS_USER_POST_COUNT_BATCH_INTERVAL_MS)
|
||||
} else {
|
||||
setSnsUserPostCountsStatus('ready')
|
||||
snsUserPostCountsBatchTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
applyBatch()
|
||||
}, [ensureExportCacheScope, patchSessionLoadTraceStage, snsUserPostCountsStatus])
|
||||
|
||||
const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => {
|
||||
const reset = Boolean(options?.reset)
|
||||
if (sessionSnsTimelineLoadingRef.current) return
|
||||
|
||||
sessionSnsTimelineLoadingRef.current = true
|
||||
if (reset) {
|
||||
setSessionSnsTimelineLoading(true)
|
||||
setSessionSnsTimelineLoadingMore(false)
|
||||
setSessionSnsTimelineHasMore(false)
|
||||
} else {
|
||||
setSessionSnsTimelineLoadingMore(true)
|
||||
}
|
||||
|
||||
const requestToken = ++sessionSnsTimelineRequestTokenRef.current
|
||||
|
||||
try {
|
||||
const limit = 20
|
||||
let endTime: number | undefined
|
||||
if (!reset && sessionSnsTimelinePostsRef.current.length > 0) {
|
||||
endTime = sessionSnsTimelinePostsRef.current[sessionSnsTimelinePostsRef.current.length - 1].createTime - 1
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.sns.getTimeline(limit, 0, [target.username], '', undefined, endTime)
|
||||
if (requestToken !== sessionSnsTimelineRequestTokenRef.current) return
|
||||
|
||||
if (!result.success || !Array.isArray(result.timeline)) {
|
||||
if (reset) {
|
||||
setSessionSnsTimelinePosts([])
|
||||
setSessionSnsTimelineHasMore(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const timeline = [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime)
|
||||
if (reset) {
|
||||
setSessionSnsTimelinePosts(timeline)
|
||||
setSessionSnsTimelineHasMore(timeline.length >= limit)
|
||||
return
|
||||
}
|
||||
|
||||
const existingIds = new Set(sessionSnsTimelinePostsRef.current.map((post) => post.id))
|
||||
const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id))
|
||||
if (uniqueOlder.length > 0) {
|
||||
const merged = [...sessionSnsTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime)
|
||||
setSessionSnsTimelinePosts(merged)
|
||||
}
|
||||
if (timeline.length < limit) {
|
||||
setSessionSnsTimelineHasMore(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载联系人朋友圈失败:', error)
|
||||
if (requestToken === sessionSnsTimelineRequestTokenRef.current && reset) {
|
||||
setSessionSnsTimelinePosts([])
|
||||
setSessionSnsTimelineHasMore(false)
|
||||
}
|
||||
} finally {
|
||||
if (requestToken === sessionSnsTimelineRequestTokenRef.current) {
|
||||
sessionSnsTimelineLoadingRef.current = false
|
||||
setSessionSnsTimelineLoading(false)
|
||||
setSessionSnsTimelineLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeSessionSnsTimeline = useCallback(() => {
|
||||
sessionSnsTimelineRequestTokenRef.current += 1
|
||||
sessionSnsTimelineLoadingRef.current = false
|
||||
sessionSnsRankRequestTokenRef.current += 1
|
||||
sessionSnsRankLoadingRef.current = false
|
||||
setSessionSnsRankMode(null)
|
||||
setSessionSnsLikeRankings([])
|
||||
setSessionSnsCommentRankings([])
|
||||
setSessionSnsRankLoading(false)
|
||||
setSessionSnsRankError(null)
|
||||
setSessionSnsRankLoadedPosts(0)
|
||||
setSessionSnsRankTotalPosts(null)
|
||||
setSessionSnsTimelineTarget(null)
|
||||
setSessionSnsTimelinePosts([])
|
||||
setSessionSnsTimelineLoading(false)
|
||||
setSessionSnsTimelineLoadingMore(false)
|
||||
setSessionSnsTimelineHasMore(false)
|
||||
setSessionSnsTimelineTotalPosts(null)
|
||||
setSessionSnsTimelineStatsLoading(false)
|
||||
}, [])
|
||||
|
||||
const sessionSnsTimelineInitialTotalPosts = useMemo(() => {
|
||||
const username = String(sessionSnsTimelineTarget?.username || '').trim()
|
||||
if (!username) return null
|
||||
if (!Object.prototype.hasOwnProperty.call(snsUserPostCounts, username)) return null
|
||||
const count = Number(snsUserPostCounts[username] || 0)
|
||||
return Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
|
||||
}, [sessionSnsTimelineTarget, snsUserPostCounts])
|
||||
|
||||
const sessionSnsTimelineInitialTotalPostsLoading = useMemo(() => {
|
||||
const username = String(sessionSnsTimelineTarget?.username || '').trim()
|
||||
if (!username) return false
|
||||
if (Object.prototype.hasOwnProperty.call(snsUserPostCounts, username)) return false
|
||||
return snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle'
|
||||
}, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus])
|
||||
|
||||
const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => {
|
||||
sessionSnsRankRequestTokenRef.current += 1
|
||||
sessionSnsRankLoadingRef.current = false
|
||||
setSessionSnsRankMode(null)
|
||||
setSessionSnsLikeRankings([])
|
||||
setSessionSnsCommentRankings([])
|
||||
setSessionSnsRankLoading(false)
|
||||
setSessionSnsRankError(null)
|
||||
setSessionSnsRankLoadedPosts(0)
|
||||
setSessionSnsTimelineTarget(target)
|
||||
setSessionSnsTimelinePosts([])
|
||||
setSessionSnsTimelineHasMore(false)
|
||||
setSessionSnsTimelineLoadingMore(false)
|
||||
setSessionSnsTimelineLoading(false)
|
||||
const hasKnownCount = Object.prototype.hasOwnProperty.call(snsUserPostCounts, target.username)
|
||||
if (hasKnownCount) {
|
||||
const count = Number(snsUserPostCounts[target.username] || 0)
|
||||
const normalizedCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
|
||||
setSessionSnsTimelineTotalPosts(normalizedCount)
|
||||
setSessionSnsTimelineStatsLoading(false)
|
||||
setSessionSnsRankTotalPosts(normalizedCount)
|
||||
} else {
|
||||
setSessionSnsTimelineTotalPosts(null)
|
||||
setSessionSnsTimelineStatsLoading(snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle')
|
||||
setSessionSnsRankTotalPosts(null)
|
||||
}
|
||||
|
||||
void loadSnsUserPostCounts()
|
||||
}, [
|
||||
loadSnsUserPostCounts,
|
||||
snsUserPostCounts,
|
||||
snsUserPostCountsStatus
|
||||
])
|
||||
|
||||
const openSessionSnsTimeline = useCallback(() => {
|
||||
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
||||
if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return
|
||||
|
||||
const target: SessionSnsTimelineTarget = {
|
||||
username: normalizedSessionId,
|
||||
displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId,
|
||||
avatarUrl: sessionDetail.avatarUrl
|
||||
}
|
||||
|
||||
openSessionSnsTimelineByTarget(target)
|
||||
}, [openSessionSnsTimelineByTarget, sessionDetail])
|
||||
|
||||
const openContactSnsTimeline = useCallback((contact: ContactInfo) => {
|
||||
const normalizedSessionId = String(contact?.username || '').trim()
|
||||
if (!isSingleContactSession(normalizedSessionId)) return
|
||||
openSessionSnsTimelineByTarget({
|
||||
username: normalizedSessionId,
|
||||
displayName: contact.displayName || contact.remark || contact.nickname || normalizedSessionId,
|
||||
avatarUrl: contact.avatarUrl
|
||||
})
|
||||
}, [openSessionSnsTimelineByTarget])
|
||||
|
||||
const openSessionMutualFriendsDialog = useCallback((contact: ContactInfo) => {
|
||||
const normalizedSessionId = String(contact?.username || '').trim()
|
||||
if (!normalizedSessionId || !isSingleContactSession(normalizedSessionId)) return
|
||||
const metric = sessionMutualFriendsMetricsRef.current[normalizedSessionId]
|
||||
if (!metric) return
|
||||
setSessionMutualFriendsSearch('')
|
||||
setSessionMutualFriendsDialogTarget({
|
||||
username: normalizedSessionId,
|
||||
displayName: contact.displayName || contact.remark || contact.nickname || normalizedSessionId,
|
||||
avatarUrl: contact.avatarUrl
|
||||
})
|
||||
}, [])
|
||||
|
||||
const closeSessionMutualFriendsDialog = useCallback(() => {
|
||||
setSessionMutualFriendsDialogTarget(null)
|
||||
setSessionMutualFriendsSearch('')
|
||||
}, [])
|
||||
|
||||
const loadMoreSessionSnsTimeline = useCallback(() => {
|
||||
if (!sessionSnsTimelineTarget || sessionSnsTimelineLoading || sessionSnsTimelineLoadingMore || !sessionSnsTimelineHasMore) return
|
||||
void loadSessionSnsTimelinePosts(sessionSnsTimelineTarget, { reset: false })
|
||||
}, [
|
||||
loadSessionSnsTimelinePosts,
|
||||
sessionSnsTimelineHasMore,
|
||||
sessionSnsTimelineLoading,
|
||||
sessionSnsTimelineLoadingMore,
|
||||
sessionSnsTimelineTarget
|
||||
])
|
||||
|
||||
const loadSessionSnsRankings = useCallback(async (target: SessionSnsTimelineTarget) => {
|
||||
const normalizedUsername = String(target?.username || '').trim()
|
||||
if (!normalizedUsername || sessionSnsRankLoadingRef.current) return
|
||||
|
||||
const knownTotal = snsUserPostCountsStatus === 'ready'
|
||||
? Number(snsUserPostCounts[normalizedUsername] || 0)
|
||||
: null
|
||||
const normalizedKnownTotal = knownTotal !== null && Number.isFinite(knownTotal)
|
||||
? Math.max(0, Math.floor(knownTotal))
|
||||
: null
|
||||
const cached = sessionSnsRankCacheRef.current[normalizedUsername]
|
||||
|
||||
if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) {
|
||||
setSessionSnsLikeRankings(cached.likes)
|
||||
setSessionSnsCommentRankings(cached.comments)
|
||||
setSessionSnsRankLoadedPosts(cached.totalPosts)
|
||||
setSessionSnsRankTotalPosts(cached.totalPosts)
|
||||
setSessionSnsRankError(null)
|
||||
setSessionSnsRankLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
sessionSnsRankLoadingRef.current = true
|
||||
const requestToken = ++sessionSnsRankRequestTokenRef.current
|
||||
setSessionSnsRankLoading(true)
|
||||
setSessionSnsRankError(null)
|
||||
setSessionSnsRankLoadedPosts(0)
|
||||
setSessionSnsRankTotalPosts(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 !== sessionSnsRankRequestTokenRef.current) return
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '加载朋友圈排行失败')
|
||||
}
|
||||
|
||||
const pagePosts = Array.isArray(result.timeline)
|
||||
? [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime)
|
||||
: []
|
||||
if (pagePosts.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
allPosts.push(...pagePosts)
|
||||
setSessionSnsRankLoadedPosts(allPosts.length)
|
||||
if (normalizedKnownTotal === null) {
|
||||
setSessionSnsRankTotalPosts(allPosts.length)
|
||||
}
|
||||
|
||||
endTime = pagePosts[pagePosts.length - 1].createTime - 1
|
||||
hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE
|
||||
}
|
||||
|
||||
if (requestToken !== sessionSnsRankRequestTokenRef.current) return
|
||||
|
||||
const rankings = buildSessionSnsRankings(allPosts)
|
||||
const totalPosts = allPosts.length
|
||||
sessionSnsRankCacheRef.current[normalizedUsername] = {
|
||||
likes: rankings.likes,
|
||||
comments: rankings.comments,
|
||||
totalPosts,
|
||||
computedAt: Date.now()
|
||||
}
|
||||
setSessionSnsLikeRankings(rankings.likes)
|
||||
setSessionSnsCommentRankings(rankings.comments)
|
||||
setSessionSnsRankLoadedPosts(totalPosts)
|
||||
setSessionSnsRankTotalPosts(totalPosts)
|
||||
setSessionSnsRankError(null)
|
||||
} catch (error) {
|
||||
if (requestToken !== sessionSnsRankRequestTokenRef.current) return
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setSessionSnsLikeRankings([])
|
||||
setSessionSnsCommentRankings([])
|
||||
setSessionSnsRankError(message || '加载朋友圈排行失败')
|
||||
} finally {
|
||||
if (requestToken === sessionSnsRankRequestTokenRef.current) {
|
||||
sessionSnsRankLoadingRef.current = false
|
||||
setSessionSnsRankLoading(false)
|
||||
}
|
||||
}
|
||||
}, [snsUserPostCounts, snsUserPostCountsStatus])
|
||||
|
||||
const renderSessionSnsTimelineStats = useCallback((): string => {
|
||||
const loadedCount = sessionSnsTimelinePosts.length
|
||||
const loadPart = sessionSnsTimelineStatsLoading
|
||||
? `已加载 ${loadedCount} / 总数统计中...`
|
||||
: sessionSnsTimelineTotalPosts === null
|
||||
? `已加载 ${loadedCount} 条`
|
||||
: `已加载 ${loadedCount} / 共 ${sessionSnsTimelineTotalPosts} 条`
|
||||
|
||||
if (sessionSnsTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...`
|
||||
if (loadedCount === 0) return loadPart
|
||||
|
||||
const latest = sessionSnsTimelinePosts[0]?.createTime
|
||||
const earliest = sessionSnsTimelinePosts[sessionSnsTimelinePosts.length - 1]?.createTime
|
||||
const rangeText = `${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}`
|
||||
return `${loadPart} | ${rangeText}`
|
||||
}, [
|
||||
sessionSnsTimelineLoading,
|
||||
sessionSnsTimelinePosts,
|
||||
sessionSnsTimelineStatsLoading,
|
||||
sessionSnsTimelineTotalPosts
|
||||
])
|
||||
|
||||
const toggleSessionSnsRankMode = useCallback((mode: SnsRankMode) => {
|
||||
setSessionSnsRankMode((prev) => (prev === mode ? null : mode))
|
||||
}, [])
|
||||
|
||||
const sessionSnsActiveRankings = useMemo(() => {
|
||||
if (sessionSnsRankMode === 'likes') return sessionSnsLikeRankings
|
||||
if (sessionSnsRankMode === 'comments') return sessionSnsCommentRankings
|
||||
return []
|
||||
}, [sessionSnsCommentRankings, sessionSnsLikeRankings, sessionSnsRankMode])
|
||||
|
||||
const mergeSessionContentMetrics = useCallback((input: Record<string, SessionExportMetric | SessionContentMetric | undefined>) => {
|
||||
const entries = Object.entries(input)
|
||||
if (entries.length === 0) return
|
||||
@@ -1818,6 +2641,469 @@ function ExportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetSessionMediaMetricLoader = useCallback(() => {
|
||||
sessionMediaMetricRunIdRef.current += 1
|
||||
sessionMediaMetricQueueRef.current = []
|
||||
sessionMediaMetricQueuedSetRef.current.clear()
|
||||
sessionMediaMetricLoadingSetRef.current.clear()
|
||||
sessionMediaMetricReadySetRef.current.clear()
|
||||
sessionMediaMetricWorkerRunningRef.current = false
|
||||
sessionMediaMetricPendingPersistRef.current = {}
|
||||
sessionMediaMetricVisibleRangeRef.current = { startIndex: 0, endIndex: -1 }
|
||||
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
|
||||
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
|
||||
sessionMediaMetricBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
if (sessionMediaMetricPersistTimerRef.current) {
|
||||
window.clearTimeout(sessionMediaMetricPersistTimerRef.current)
|
||||
sessionMediaMetricPersistTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const flushSessionMediaMetricCache = useCallback(async () => {
|
||||
const pendingMetrics = sessionMediaMetricPendingPersistRef.current
|
||||
sessionMediaMetricPendingPersistRef.current = {}
|
||||
if (Object.keys(pendingMetrics).length === 0) return
|
||||
|
||||
try {
|
||||
const scopeKey = await ensureExportCacheScope()
|
||||
const existing = await configService.getExportSessionContentMetricCache(scopeKey)
|
||||
const nextMetrics = {
|
||||
...(existing?.metrics || {}),
|
||||
...pendingMetrics
|
||||
}
|
||||
await configService.setExportSessionContentMetricCache(scopeKey, nextMetrics)
|
||||
} catch (error) {
|
||||
console.error('写入导出页会话内容统计缓存失败:', error)
|
||||
}
|
||||
}, [ensureExportCacheScope])
|
||||
|
||||
const scheduleFlushSessionMediaMetricCache = useCallback(() => {
|
||||
if (sessionMediaMetricPersistTimerRef.current) return
|
||||
sessionMediaMetricPersistTimerRef.current = window.setTimeout(() => {
|
||||
sessionMediaMetricPersistTimerRef.current = null
|
||||
void flushSessionMediaMetricCache()
|
||||
}, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS)
|
||||
}, [flushSessionMediaMetricCache])
|
||||
|
||||
const resetSessionMutualFriendsLoader = useCallback(() => {
|
||||
sessionMutualFriendsRunIdRef.current += 1
|
||||
sessionMutualFriendsDirectMetricsRef.current = {}
|
||||
sessionMutualFriendsQueueRef.current = []
|
||||
sessionMutualFriendsQueuedSetRef.current.clear()
|
||||
sessionMutualFriendsLoadingSetRef.current.clear()
|
||||
sessionMutualFriendsReadySetRef.current.clear()
|
||||
sessionMutualFriendsWorkerRunningRef.current = false
|
||||
sessionMutualFriendsVisibleRangeRef.current = { startIndex: 0, endIndex: -1 }
|
||||
if (sessionMutualFriendsBackgroundFeedTimerRef.current) {
|
||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => {
|
||||
if (!sessionId) return true
|
||||
if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true
|
||||
const existing = sessionMutualFriendsMetricsRef.current[sessionId]
|
||||
if (existing && typeof existing.count === 'number' && Array.isArray(existing.items)) {
|
||||
sessionMutualFriendsReadySetRef.current.add(sessionId)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
|
||||
const front = options?.front === true
|
||||
const incoming: string[] = []
|
||||
for (const sessionIdRaw of sessionIds) {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!sessionId) continue
|
||||
if (sessionMutualFriendsQueuedSetRef.current.has(sessionId)) continue
|
||||
if (sessionMutualFriendsLoadingSetRef.current.has(sessionId)) continue
|
||||
if (isSessionMutualFriendsReady(sessionId)) continue
|
||||
sessionMutualFriendsQueuedSetRef.current.add(sessionId)
|
||||
incoming.push(sessionId)
|
||||
}
|
||||
if (incoming.length === 0) return
|
||||
patchSessionLoadTraceStage(incoming, 'mutualFriends', 'pending')
|
||||
if (front) {
|
||||
sessionMutualFriendsQueueRef.current = [...incoming, ...sessionMutualFriendsQueueRef.current]
|
||||
} else {
|
||||
sessionMutualFriendsQueueRef.current.push(...incoming)
|
||||
}
|
||||
}, [isSessionMutualFriendsReady, patchSessionLoadTraceStage])
|
||||
|
||||
const hasPendingMetricLoads = useCallback((): boolean => (
|
||||
isLoadingSessionCountsRef.current ||
|
||||
sessionMediaMetricQueuedSetRef.current.size > 0 ||
|
||||
sessionMediaMetricLoadingSetRef.current.size > 0 ||
|
||||
sessionMediaMetricWorkerRunningRef.current ||
|
||||
snsUserPostCountsStatus === 'loading' ||
|
||||
snsUserPostCountsStatus === 'idle'
|
||||
), [snsUserPostCountsStatus])
|
||||
|
||||
const getSessionMutualFriendProfile = useCallback((sessionId: string): {
|
||||
displayName: string
|
||||
candidateNames: Set<string>
|
||||
} => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const contact = contactsList.find(item => item.username === normalizedSessionId)
|
||||
const session = sessionsRef.current.find(item => item.username === normalizedSessionId)
|
||||
const displayName = contact?.displayName || contact?.remark || contact?.nickname || session?.displayName || normalizedSessionId
|
||||
return {
|
||||
displayName,
|
||||
candidateNames: toComparableNameSet([
|
||||
displayName,
|
||||
contact?.displayName,
|
||||
contact?.remark,
|
||||
contact?.nickname,
|
||||
contact?.alias
|
||||
])
|
||||
}
|
||||
}, [contactsList])
|
||||
|
||||
const rebuildSessionMutualFriendsMetric = useCallback((targetSessionId: string): SessionMutualFriendsMetric | null => {
|
||||
const normalizedTargetSessionId = String(targetSessionId || '').trim()
|
||||
if (!normalizedTargetSessionId) return null
|
||||
|
||||
const directMetrics = sessionMutualFriendsDirectMetricsRef.current
|
||||
const directMetric = directMetrics[normalizedTargetSessionId]
|
||||
if (!directMetric) return null
|
||||
|
||||
const { candidateNames } = getSessionMutualFriendProfile(normalizedTargetSessionId)
|
||||
const mergedMap = new Map<string, SessionMutualFriendItem>()
|
||||
for (const item of directMetric.items) {
|
||||
mergedMap.set(item.name, { ...item })
|
||||
}
|
||||
|
||||
for (const [sourceSessionId, sourceMetric] of Object.entries(directMetrics)) {
|
||||
if (!sourceMetric || sourceSessionId === normalizedTargetSessionId) continue
|
||||
const sourceProfile = getSessionMutualFriendProfile(sourceSessionId)
|
||||
if (!sourceProfile.displayName) continue
|
||||
if (mergedMap.has(sourceProfile.displayName)) continue
|
||||
|
||||
const reverseMatches = sourceMetric.items.filter(item => candidateNames.has(item.name))
|
||||
if (reverseMatches.length === 0) continue
|
||||
|
||||
const reverseCount = reverseMatches.reduce((sum, item) => sum + item.totalCount, 0)
|
||||
const reverseLikeCount = reverseMatches.reduce((sum, item) => sum + item.incomingLikeCount, 0)
|
||||
const reverseCommentCount = reverseMatches.reduce((sum, item) => sum + item.incomingCommentCount, 0)
|
||||
const reverseLatestTime = reverseMatches.reduce((latest, item) => Math.max(latest, item.latestTime), 0)
|
||||
const existing = mergedMap.get(sourceProfile.displayName)
|
||||
if (existing) {
|
||||
existing.outgoingLikeCount += reverseLikeCount
|
||||
existing.outgoingCommentCount += reverseCommentCount
|
||||
existing.totalCount += reverseCount
|
||||
existing.latestTime = Math.max(existing.latestTime, reverseLatestTime)
|
||||
existing.direction = (existing.incomingLikeCount + existing.incomingCommentCount) > 0
|
||||
? 'bidirectional'
|
||||
: 'outgoing'
|
||||
existing.behavior = summarizeMutualFriendBehavior(
|
||||
existing.incomingLikeCount + existing.outgoingLikeCount,
|
||||
existing.incomingCommentCount + existing.outgoingCommentCount
|
||||
)
|
||||
} else {
|
||||
mergedMap.set(sourceProfile.displayName, {
|
||||
name: sourceProfile.displayName,
|
||||
incomingLikeCount: 0,
|
||||
incomingCommentCount: 0,
|
||||
outgoingLikeCount: reverseLikeCount,
|
||||
outgoingCommentCount: reverseCommentCount,
|
||||
totalCount: reverseCount,
|
||||
latestTime: reverseLatestTime,
|
||||
direction: 'outgoing',
|
||||
behavior: summarizeMutualFriendBehavior(reverseLikeCount, reverseCommentCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const items = [...mergedMap.values()].sort((a, b) => {
|
||||
if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount
|
||||
if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime
|
||||
return a.name.localeCompare(b.name, 'zh-CN')
|
||||
})
|
||||
|
||||
return {
|
||||
...directMetric,
|
||||
count: items.length,
|
||||
items
|
||||
}
|
||||
}, [getSessionMutualFriendProfile])
|
||||
|
||||
const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return
|
||||
sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric
|
||||
|
||||
const impactedSessionIds = new Set<string>([normalizedSessionId])
|
||||
const allSessionIds = sessionsRef.current
|
||||
.filter(session => session.hasSession && isSingleContactSession(session.username))
|
||||
.map(session => session.username)
|
||||
|
||||
for (const targetSessionId of allSessionIds) {
|
||||
if (targetSessionId === normalizedSessionId) continue
|
||||
const targetProfile = getSessionMutualFriendProfile(targetSessionId)
|
||||
if (directMetric.items.some(item => targetProfile.candidateNames.has(item.name))) {
|
||||
impactedSessionIds.add(targetSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
setSessionMutualFriendsMetrics(prev => {
|
||||
const next = { ...prev }
|
||||
let changed = false
|
||||
for (const targetSessionId of impactedSessionIds) {
|
||||
const rebuiltMetric = rebuildSessionMutualFriendsMetric(targetSessionId)
|
||||
if (!rebuiltMetric) continue
|
||||
const previousMetric = prev[targetSessionId]
|
||||
const previousSerialized = previousMetric ? JSON.stringify(previousMetric) : ''
|
||||
const nextSerialized = JSON.stringify(rebuiltMetric)
|
||||
if (previousSerialized === nextSerialized) continue
|
||||
next[targetSessionId] = rebuiltMetric
|
||||
changed = true
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric])
|
||||
|
||||
const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => {
|
||||
if (!sessionId) return true
|
||||
if (sessionMediaMetricReadySetRef.current.has(sessionId)) return true
|
||||
const existing = sessionContentMetricsRef.current[sessionId]
|
||||
if (hasCompleteSessionMediaMetric(existing)) {
|
||||
sessionMediaMetricReadySetRef.current.add(sessionId)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => {
|
||||
const front = options?.front === true
|
||||
const incoming: string[] = []
|
||||
for (const sessionIdRaw of sessionIds) {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!sessionId) continue
|
||||
if (sessionMediaMetricQueuedSetRef.current.has(sessionId)) continue
|
||||
if (sessionMediaMetricLoadingSetRef.current.has(sessionId)) continue
|
||||
if (isSessionMediaMetricReady(sessionId)) continue
|
||||
sessionMediaMetricQueuedSetRef.current.add(sessionId)
|
||||
incoming.push(sessionId)
|
||||
}
|
||||
if (incoming.length === 0) return
|
||||
patchSessionLoadTraceStage(incoming, 'mediaMetrics', 'pending')
|
||||
if (front) {
|
||||
sessionMediaMetricQueueRef.current = [...incoming, ...sessionMediaMetricQueueRef.current]
|
||||
} else {
|
||||
sessionMediaMetricQueueRef.current.push(...incoming)
|
||||
}
|
||||
}, [isSessionMediaMetricReady, patchSessionLoadTraceStage])
|
||||
|
||||
const applySessionMediaMetricsFromStats = useCallback((data?: Record<string, SessionExportMetric>) => {
|
||||
if (!data) return
|
||||
const nextMetrics: Record<string, SessionContentMetric> = {}
|
||||
let hasPatch = false
|
||||
for (const [sessionIdRaw, metricRaw] of Object.entries(data)) {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!sessionId) continue
|
||||
const metric = pickSessionMediaMetric(metricRaw)
|
||||
if (!metric) continue
|
||||
nextMetrics[sessionId] = metric
|
||||
hasPatch = true
|
||||
sessionMediaMetricPendingPersistRef.current[sessionId] = {
|
||||
...sessionMediaMetricPendingPersistRef.current[sessionId],
|
||||
...metric
|
||||
}
|
||||
if (hasCompleteSessionMediaMetric(metric)) {
|
||||
sessionMediaMetricReadySetRef.current.add(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPatch) {
|
||||
mergeSessionContentMetrics(nextMetrics)
|
||||
scheduleFlushSessionMediaMetricCache()
|
||||
}
|
||||
}, [mergeSessionContentMetrics, scheduleFlushSessionMediaMetricCache])
|
||||
|
||||
const runSessionMediaMetricWorker = useCallback(async (runId: number) => {
|
||||
if (sessionMediaMetricWorkerRunningRef.current) return
|
||||
sessionMediaMetricWorkerRunningRef.current = true
|
||||
try {
|
||||
while (runId === sessionMediaMetricRunIdRef.current) {
|
||||
if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 80))
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionMediaMetricQueueRef.current.length === 0) break
|
||||
|
||||
const batchSessionIds: string[] = []
|
||||
while (batchSessionIds.length < SESSION_MEDIA_METRIC_BATCH_SIZE && sessionMediaMetricQueueRef.current.length > 0) {
|
||||
const nextId = sessionMediaMetricQueueRef.current.shift()
|
||||
if (!nextId) continue
|
||||
sessionMediaMetricQueuedSetRef.current.delete(nextId)
|
||||
if (sessionMediaMetricLoadingSetRef.current.has(nextId)) continue
|
||||
if (isSessionMediaMetricReady(nextId)) continue
|
||||
sessionMediaMetricLoadingSetRef.current.add(nextId)
|
||||
batchSessionIds.push(nextId)
|
||||
}
|
||||
if (batchSessionIds.length === 0) {
|
||||
continue
|
||||
}
|
||||
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading')
|
||||
|
||||
try {
|
||||
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
batchSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||
if (cacheResult.success && cacheResult.data) {
|
||||
applySessionMediaMetricsFromStats(cacheResult.data as Record<string, SessionExportMetric>)
|
||||
}
|
||||
|
||||
const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
|
||||
if (missingSessionIds.length > 0) {
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
missingSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true }
|
||||
)
|
||||
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
applySessionMediaMetricsFromStats(freshResult.data as Record<string, SessionExportMetric>)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出页加载会话媒体统计失败:', error)
|
||||
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', {
|
||||
error: String(error)
|
||||
})
|
||||
} finally {
|
||||
const completedSessionIds: string[] = []
|
||||
for (const sessionId of batchSessionIds) {
|
||||
sessionMediaMetricLoadingSetRef.current.delete(sessionId)
|
||||
if (isSessionMediaMetricReady(sessionId)) {
|
||||
sessionMediaMetricReadySetRef.current.add(sessionId)
|
||||
completedSessionIds.push(sessionId)
|
||||
}
|
||||
}
|
||||
if (completedSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(completedSessionIds, 'mediaMetrics', 'done')
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||
}
|
||||
} finally {
|
||||
sessionMediaMetricWorkerRunningRef.current = false
|
||||
if (runId === sessionMediaMetricRunIdRef.current && sessionMediaMetricQueueRef.current.length > 0) {
|
||||
void runSessionMediaMetricWorker(runId)
|
||||
}
|
||||
}
|
||||
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
|
||||
|
||||
const scheduleSessionMediaMetricWorker = useCallback(() => {
|
||||
if (!isSessionCountStageReady) return
|
||||
if (isLoadingSessionCountsRef.current) return
|
||||
if (sessionMediaMetricWorkerRunningRef.current) return
|
||||
const runId = sessionMediaMetricRunIdRef.current
|
||||
void runSessionMediaMetricWorker(runId)
|
||||
}, [isSessionCountStageReady, runSessionMediaMetricWorker])
|
||||
|
||||
const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise<SessionMutualFriendsMetric> => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const hasKnownTotal = Object.prototype.hasOwnProperty.call(snsUserPostCounts, normalizedSessionId)
|
||||
const knownTotalRaw = hasKnownTotal ? Number(snsUserPostCounts[normalizedSessionId] || 0) : NaN
|
||||
const knownTotal = Number.isFinite(knownTotalRaw) ? Math.max(0, Math.floor(knownTotalRaw)) : null
|
||||
const allPosts: SnsPost[] = []
|
||||
let endTime: number | undefined
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const result = await window.electronAPI.sns.getTimeline(
|
||||
SNS_RANK_PAGE_SIZE,
|
||||
0,
|
||||
[normalizedSessionId],
|
||||
'',
|
||||
undefined,
|
||||
endTime
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '共同好友统计失败')
|
||||
}
|
||||
|
||||
const pagePosts = Array.isArray(result.timeline)
|
||||
? [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime)
|
||||
: []
|
||||
if (pagePosts.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
allPosts.push(...pagePosts)
|
||||
endTime = pagePosts[pagePosts.length - 1].createTime - 1
|
||||
hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE
|
||||
}
|
||||
|
||||
return buildSessionMutualFriendsMetric(allPosts, knownTotal)
|
||||
}, [snsUserPostCounts])
|
||||
|
||||
const runSessionMutualFriendsWorker = useCallback(async (runId: number) => {
|
||||
if (sessionMutualFriendsWorkerRunningRef.current) return
|
||||
sessionMutualFriendsWorkerRunningRef.current = true
|
||||
try {
|
||||
while (runId === sessionMutualFriendsRunIdRef.current) {
|
||||
if (hasPendingMetricLoads()) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 120))
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionId = sessionMutualFriendsQueueRef.current.shift()
|
||||
if (!sessionId) break
|
||||
sessionMutualFriendsQueuedSetRef.current.delete(sessionId)
|
||||
if (sessionMutualFriendsLoadingSetRef.current.has(sessionId)) continue
|
||||
if (isSessionMutualFriendsReady(sessionId)) continue
|
||||
|
||||
sessionMutualFriendsLoadingSetRef.current.add(sessionId)
|
||||
patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'loading')
|
||||
|
||||
try {
|
||||
const metric = await loadSessionMutualFriendsMetric(sessionId)
|
||||
if (runId !== sessionMutualFriendsRunIdRef.current) return
|
||||
applySessionMutualFriendsMetric(sessionId, metric)
|
||||
sessionMutualFriendsReadySetRef.current.add(sessionId)
|
||||
patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'done')
|
||||
} catch (error) {
|
||||
console.error('导出页加载共同好友统计失败:', error)
|
||||
patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
sessionMutualFriendsLoadingSetRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||
}
|
||||
} finally {
|
||||
sessionMutualFriendsWorkerRunningRef.current = false
|
||||
if (runId === sessionMutualFriendsRunIdRef.current && sessionMutualFriendsQueueRef.current.length > 0) {
|
||||
void runSessionMutualFriendsWorker(runId)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
applySessionMutualFriendsMetric,
|
||||
hasPendingMetricLoads,
|
||||
isSessionMutualFriendsReady,
|
||||
loadSessionMutualFriendsMetric,
|
||||
patchSessionLoadTraceStage
|
||||
])
|
||||
|
||||
const scheduleSessionMutualFriendsWorker = useCallback(() => {
|
||||
if (!isSessionCountStageReady) return
|
||||
if (hasPendingMetricLoads()) return
|
||||
if (sessionMutualFriendsWorkerRunningRef.current) return
|
||||
const runId = sessionMutualFriendsRunIdRef.current
|
||||
void runSessionMutualFriendsWorker(runId)
|
||||
}, [hasPendingMetricLoads, isSessionCountStageReady, runSessionMutualFriendsWorker])
|
||||
|
||||
const loadSessionMessageCounts = useCallback(async (
|
||||
sourceSessions: SessionRow[],
|
||||
priorityTab: ConversationTab,
|
||||
@@ -1829,8 +3115,12 @@ function ExportPage() {
|
||||
const requestId = sessionCountRequestIdRef.current + 1
|
||||
sessionCountRequestIdRef.current = requestId
|
||||
const isStale = () => sessionCountRequestIdRef.current !== requestId
|
||||
setIsSessionCountStageReady(false)
|
||||
|
||||
const exportableSessions = sourceSessions.filter(session => session.hasSession)
|
||||
const exportableSessionIds = exportableSessions.map(session => session.username)
|
||||
const exportableSessionIdSet = new Set(exportableSessionIds)
|
||||
patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'pending', { force: true })
|
||||
const seededHintCounts = exportableSessions.reduce<Record<string, number>>((acc, session) => {
|
||||
const nextCount = normalizeMessageCount(session.messageCountHint)
|
||||
if (typeof nextCount === 'number') {
|
||||
@@ -1839,12 +3129,17 @@ function ExportPage() {
|
||||
return acc
|
||||
}, {})
|
||||
const seededPersistentCounts = Object.entries(options?.seededCounts || {}).reduce<Record<string, number>>((acc, [sessionId, countRaw]) => {
|
||||
if (!exportableSessionIdSet.has(sessionId)) return acc
|
||||
const nextCount = normalizeMessageCount(countRaw)
|
||||
if (typeof nextCount === 'number') {
|
||||
acc[sessionId] = nextCount
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
const seededPersistentSessionIds = Object.keys(seededPersistentCounts)
|
||||
if (seededPersistentSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(seededPersistentSessionIds, 'messageCount', 'done')
|
||||
}
|
||||
const seededCounts = { ...seededHintCounts, ...seededPersistentCounts }
|
||||
const accumulatedCounts: Record<string, number> = { ...seededCounts }
|
||||
setSessionMessageCounts(seededCounts)
|
||||
@@ -1859,6 +3154,9 @@ function ExportPage() {
|
||||
|
||||
if (exportableSessions.length === 0) {
|
||||
setIsLoadingSessionCounts(false)
|
||||
if (!isStale()) {
|
||||
setIsSessionCountStageReady(true)
|
||||
}
|
||||
return { ...accumulatedCounts }
|
||||
}
|
||||
|
||||
@@ -1894,26 +3192,54 @@ function ExportPage() {
|
||||
|
||||
setIsLoadingSessionCounts(true)
|
||||
try {
|
||||
if (detailStatsPriorityRef.current) {
|
||||
return { ...accumulatedCounts }
|
||||
}
|
||||
if (prioritizedSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
|
||||
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
|
||||
if (isStale()) return { ...accumulatedCounts }
|
||||
if (priorityResult.success) {
|
||||
applyCounts(priorityResult.counts)
|
||||
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'done')
|
||||
} else {
|
||||
patchSessionLoadTraceStage(
|
||||
prioritizedSessionIds,
|
||||
'messageCount',
|
||||
'failed',
|
||||
{ error: priorityResult.error || '总消息数加载失败' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (detailStatsPriorityRef.current) {
|
||||
return { ...accumulatedCounts }
|
||||
}
|
||||
if (remainingSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
|
||||
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
|
||||
if (isStale()) return { ...accumulatedCounts }
|
||||
if (remainingResult.success) {
|
||||
applyCounts(remainingResult.counts)
|
||||
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'done')
|
||||
} else {
|
||||
patchSessionLoadTraceStage(
|
||||
remainingSessionIds,
|
||||
'messageCount',
|
||||
'failed',
|
||||
{ error: remainingResult.error || '总消息数加载失败' }
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出页加载会话消息总数失败:', error)
|
||||
patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'failed', {
|
||||
error: String(error)
|
||||
})
|
||||
} finally {
|
||||
if (!isStale()) {
|
||||
setIsLoadingSessionCounts(false)
|
||||
setIsSessionCountStageReady(true)
|
||||
if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) {
|
||||
try {
|
||||
await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts)
|
||||
@@ -1924,18 +3250,36 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
return { ...accumulatedCounts }
|
||||
}, [mergeSessionContentMetrics])
|
||||
}, [mergeSessionContentMetrics, patchSessionLoadTraceStage])
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
const loadToken = Date.now()
|
||||
sessionLoadTokenRef.current = loadToken
|
||||
sessionsHydratedAtRef.current = 0
|
||||
sessionPreciseRefreshAtRef.current = {}
|
||||
resetSessionMediaMetricLoader()
|
||||
resetSessionMutualFriendsLoader()
|
||||
setIsLoading(true)
|
||||
setIsSessionEnriching(false)
|
||||
sessionCountRequestIdRef.current += 1
|
||||
setSessionMessageCounts({})
|
||||
setSessionContentMetrics({})
|
||||
setSessionMutualFriendsMetrics({})
|
||||
sessionMutualFriendsMetricsRef.current = {}
|
||||
setSessionMutualFriendsDialogTarget(null)
|
||||
setSessionMutualFriendsSearch('')
|
||||
setSessionLoadTraceMap({})
|
||||
setSessionLoadProgressPulseMap({})
|
||||
sessionLoadProgressSnapshotRef.current = {}
|
||||
snsUserPostCountsHydrationTokenRef.current += 1
|
||||
if (snsUserPostCountsBatchTimerRef.current) {
|
||||
window.clearTimeout(snsUserPostCountsBatchTimerRef.current)
|
||||
snsUserPostCountsBatchTimerRef.current = null
|
||||
}
|
||||
setSnsUserPostCounts({})
|
||||
setSnsUserPostCountsStatus('idle')
|
||||
setIsLoadingSessionCounts(false)
|
||||
setIsSessionCountStageReady(false)
|
||||
|
||||
const isStale = () => sessionLoadTokenRef.current !== loadToken
|
||||
|
||||
@@ -1945,10 +3289,12 @@ function ExportPage() {
|
||||
|
||||
const [
|
||||
cachedContactsPayload,
|
||||
cachedMessageCountsPayload
|
||||
cachedMessageCountsPayload,
|
||||
cachedContentMetricsPayload
|
||||
] = await Promise.all([
|
||||
loadContactsCaches(scopeKey),
|
||||
configService.getExportSessionMessageCountCache(scopeKey)
|
||||
configService.getExportSessionMessageCountCache(scopeKey),
|
||||
configService.getExportSessionContentMetricCache(scopeKey)
|
||||
])
|
||||
if (isStale()) return
|
||||
|
||||
@@ -1962,7 +3308,7 @@ function ExportPage() {
|
||||
const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries)
|
||||
if (cachedContacts.length > 0) {
|
||||
syncContactTypeCounts(Object.values(cachedContactMap))
|
||||
setSessions(toSessionRowsWithContacts([], cachedContactMap))
|
||||
setSessions(toSessionRowsWithContacts([], cachedContactMap).filter(isExportConversationSession))
|
||||
setSessionDataSource('cache')
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -1981,7 +3327,7 @@ function ExportPage() {
|
||||
|
||||
if (sessionsResult.success && sessionsResult.sessions) {
|
||||
const rawSessions = sessionsResult.sessions
|
||||
const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap)
|
||||
const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap).filter(isExportConversationSession)
|
||||
const exportableSessionIds = baseSessions
|
||||
.filter((session) => session.hasSession)
|
||||
.map((session) => session.username)
|
||||
@@ -2000,6 +3346,22 @@ function ExportPage() {
|
||||
acc[sessionId] = { totalMessages: count }
|
||||
return acc
|
||||
}, {})
|
||||
const cachedContentMetrics = Object.entries(cachedContentMetricsPayload?.metrics || {}).reduce<Record<string, SessionContentMetric>>((acc, [sessionId, rawMetric]) => {
|
||||
if (!exportableSessionIdSet.has(sessionId)) return acc
|
||||
const metric = pickSessionMediaMetric(rawMetric)
|
||||
if (!metric) return acc
|
||||
acc[sessionId] = metric
|
||||
if (hasCompleteSessionMediaMetric(metric)) {
|
||||
sessionMediaMetricReadySetRef.current.add(sessionId)
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
const cachedContentMetricReadySessionIds = Object.entries(cachedContentMetrics)
|
||||
.filter(([, metric]) => hasCompleteSessionMediaMetric(metric))
|
||||
.map(([sessionId]) => sessionId)
|
||||
if (cachedContentMetricReadySessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done')
|
||||
}
|
||||
|
||||
if (isStale()) return
|
||||
if (Object.keys(cachedMessageCounts).length > 0) {
|
||||
@@ -2008,6 +3370,9 @@ function ExportPage() {
|
||||
if (Object.keys(cachedCountAsMetrics).length > 0) {
|
||||
mergeSessionContentMetrics(cachedCountAsMetrics)
|
||||
}
|
||||
if (Object.keys(cachedContentMetrics).length > 0) {
|
||||
mergeSessionContentMetrics(cachedContentMetrics)
|
||||
}
|
||||
setSessions(baseSessions)
|
||||
sessionsHydratedAtRef.current = Date.now()
|
||||
void (async () => {
|
||||
@@ -2027,12 +3392,14 @@ function ExportPage() {
|
||||
setIsSessionEnriching(true)
|
||||
void (async () => {
|
||||
try {
|
||||
if (detailStatsPriorityRef.current) return
|
||||
let contactMap = { ...cachedContactMap }
|
||||
let avatarEntries = { ...cachedAvatarEntries }
|
||||
let hasFreshNetworkData = false
|
||||
let hasNetworkContactsSnapshot = false
|
||||
|
||||
if (isStale()) return
|
||||
if (detailStatsPriorityRef.current) return
|
||||
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
|
||||
if (isStale()) return
|
||||
|
||||
@@ -2091,6 +3458,7 @@ function ExportPage() {
|
||||
if (needsEnrichment.length > 0) {
|
||||
for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
|
||||
if (isStale()) return
|
||||
if (detailStatsPriorityRef.current) return
|
||||
const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
|
||||
if (batch.length === 0) continue
|
||||
try {
|
||||
@@ -2152,7 +3520,7 @@ function ExportPage() {
|
||||
}, contactMap)
|
||||
|
||||
if (isStale()) return
|
||||
const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap)
|
||||
const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap).filter(isExportConversationSession)
|
||||
.map((session) => {
|
||||
const extra = extraContactMap[session.username]
|
||||
const displayName = extra?.displayName || session.displayName || session.username
|
||||
@@ -2205,7 +3573,7 @@ function ExportPage() {
|
||||
} finally {
|
||||
if (!isStale()) setIsLoading(false)
|
||||
}
|
||||
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, syncContactTypeCounts])
|
||||
}, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExportRoute) return
|
||||
@@ -2237,9 +3605,22 @@ function ExportPage() {
|
||||
// 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。
|
||||
sessionLoadTokenRef.current = Date.now()
|
||||
sessionCountRequestIdRef.current += 1
|
||||
snsUserPostCountsHydrationTokenRef.current += 1
|
||||
if (snsUserPostCountsBatchTimerRef.current) {
|
||||
window.clearTimeout(snsUserPostCountsBatchTimerRef.current)
|
||||
snsUserPostCountsBatchTimerRef.current = null
|
||||
}
|
||||
resetSessionMutualFriendsLoader()
|
||||
setIsSessionEnriching(false)
|
||||
setIsLoadingSessionCounts(false)
|
||||
}, [isExportRoute])
|
||||
setSnsUserPostCountsStatus(prev => (prev === 'loading' ? 'idle' : prev))
|
||||
}, [isExportRoute, resetSessionMutualFriendsLoader])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'official') {
|
||||
setActiveTab('private')
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
activeTabRef.current = activeTab
|
||||
@@ -2305,15 +3686,30 @@ function ExportPage() {
|
||||
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
|
||||
setExportDialog({ open: true, ...payload })
|
||||
setIsTimeRangeDialogOpen(false)
|
||||
setTimeRangePreset('all')
|
||||
setTimeRangeSelection(exportDefaultDateRangeSelection)
|
||||
|
||||
setOptions(prev => {
|
||||
const nextDateRange = prev.dateRange ?? createDefaultDateRange()
|
||||
const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange)
|
||||
|
||||
const next: ExportOptions = {
|
||||
...prev,
|
||||
useAllTime: true,
|
||||
dateRange: nextDateRange
|
||||
format: exportDefaultFormat,
|
||||
exportAvatars: exportDefaultAvatars,
|
||||
useAllTime: exportDefaultDateRangeSelection.useAllTime,
|
||||
dateRange: nextDateRange,
|
||||
exportMedia: Boolean(
|
||||
exportDefaultMedia.images ||
|
||||
exportDefaultMedia.voices ||
|
||||
exportDefaultMedia.videos ||
|
||||
exportDefaultMedia.emojis
|
||||
),
|
||||
exportImages: exportDefaultMedia.images,
|
||||
exportVoices: exportDefaultMedia.voices,
|
||||
exportVideos: exportDefaultMedia.videos,
|
||||
exportEmojis: exportDefaultMedia.emojis,
|
||||
exportVoiceAsText: exportDefaultVoiceAsText,
|
||||
excelCompactColumns: exportDefaultExcelCompactColumns,
|
||||
exportConcurrency: exportDefaultConcurrency
|
||||
}
|
||||
|
||||
if (payload.scope === 'sns') {
|
||||
@@ -2327,7 +3723,6 @@ function ExportPage() {
|
||||
next.exportVoices = false
|
||||
next.exportVideos = false
|
||||
next.exportEmojis = false
|
||||
next.exportAvatars = true
|
||||
} else {
|
||||
next.exportMedia = true
|
||||
next.exportImages = payload.contentType === 'image'
|
||||
@@ -2340,219 +3735,30 @@ function ExportPage() {
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
}, [
|
||||
exportDefaultDateRangeSelection,
|
||||
exportDefaultExcelCompactColumns,
|
||||
exportDefaultFormat,
|
||||
exportDefaultAvatars,
|
||||
exportDefaultMedia,
|
||||
exportDefaultVoiceAsText,
|
||||
exportDefaultConcurrency
|
||||
])
|
||||
|
||||
const closeExportDialog = useCallback(() => {
|
||||
setExportDialog(prev => ({ ...prev, open: false }))
|
||||
setIsTimeRangeDialogOpen(false)
|
||||
setTimeRangeDialogDraft(null)
|
||||
setTimeRangeDateInput({ start: '', end: '' })
|
||||
setTimeRangeDateInputError({ start: false, end: false })
|
||||
}, [])
|
||||
|
||||
const buildTimeRangeDialogDraft = useCallback((): TimeRangeDialogDraft => {
|
||||
const dateRange = options.dateRange ?? createDefaultDateRange()
|
||||
return {
|
||||
preset: timeRangePreset,
|
||||
useAllTime: options.useAllTime,
|
||||
dateRange: {
|
||||
start: new Date(dateRange.start),
|
||||
end: new Date(dateRange.end)
|
||||
},
|
||||
startPanelMonth: toMonthStart(dateRange.start),
|
||||
endPanelMonth: toMonthStart(dateRange.end)
|
||||
}
|
||||
}, [options.dateRange, options.useAllTime, timeRangePreset])
|
||||
|
||||
const openTimeRangeDialog = useCallback(() => {
|
||||
const draft = buildTimeRangeDialogDraft()
|
||||
setTimeRangeDialogDraft(draft)
|
||||
setIsTimeRangeDialogOpen(true)
|
||||
}, [buildTimeRangeDialogDraft])
|
||||
}, [])
|
||||
|
||||
const closeTimeRangeDialog = useCallback(() => {
|
||||
setIsTimeRangeDialogOpen(false)
|
||||
setTimeRangeDialogDraft(null)
|
||||
setTimeRangeDateInput({ start: '', end: '' })
|
||||
setTimeRangeDateInputError({ start: false, end: false })
|
||||
}, [])
|
||||
|
||||
const applyTimeRangePresetToDraft = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
|
||||
setTimeRangeDialogDraft(prev => {
|
||||
const base = prev ?? buildTimeRangeDialogDraft()
|
||||
if (preset === 'all') {
|
||||
const previewRange = createDefaultDateRange()
|
||||
return {
|
||||
...base,
|
||||
preset,
|
||||
useAllTime: true,
|
||||
dateRange: {
|
||||
start: previewRange.start,
|
||||
end: previewRange.end
|
||||
},
|
||||
startPanelMonth: toMonthStart(previewRange.start),
|
||||
endPanelMonth: toMonthStart(previewRange.end)
|
||||
}
|
||||
}
|
||||
const range = createDateRangeByPreset(preset)
|
||||
return {
|
||||
...base,
|
||||
preset,
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: range.start,
|
||||
end: range.end
|
||||
},
|
||||
startPanelMonth: toMonthStart(range.start),
|
||||
endPanelMonth: toMonthStart(range.end)
|
||||
}
|
||||
})
|
||||
}, [buildTimeRangeDialogDraft])
|
||||
|
||||
const handleTimeRangePresetClick = useCallback((preset: Exclude<DateRangePreset, 'custom'>) => {
|
||||
applyTimeRangePresetToDraft(preset)
|
||||
}, [applyTimeRangePresetToDraft])
|
||||
|
||||
const updateTimeRangeDraftStart = useCallback((targetDate: Date) => {
|
||||
const start = startOfDay(targetDate)
|
||||
setTimeRangeDialogDraft(prev => {
|
||||
const base = prev ?? buildTimeRangeDialogDraft()
|
||||
const nextEnd = base.dateRange.end < start ? endOfDay(start) : base.dateRange.end
|
||||
return {
|
||||
...base,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: nextEnd
|
||||
},
|
||||
startPanelMonth: toMonthStart(start),
|
||||
endPanelMonth: toMonthStart(nextEnd)
|
||||
}
|
||||
})
|
||||
}, [buildTimeRangeDialogDraft])
|
||||
|
||||
const updateTimeRangeDraftEnd = useCallback((targetDate: Date) => {
|
||||
const end = endOfDay(targetDate)
|
||||
setTimeRangeDialogDraft(prev => {
|
||||
const base = prev ?? buildTimeRangeDialogDraft()
|
||||
const isAllTimeMode = base.useAllTime
|
||||
const nextStart = isAllTimeMode
|
||||
? startOfDay(targetDate)
|
||||
: base.dateRange.start
|
||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||
return {
|
||||
...base,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
},
|
||||
startPanelMonth: toMonthStart(nextStart),
|
||||
endPanelMonth: toMonthStart(nextEnd)
|
||||
}
|
||||
})
|
||||
}, [buildTimeRangeDialogDraft])
|
||||
|
||||
const commitTimeRangeStartFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(timeRangeDateInput.start)
|
||||
if (!parsed) {
|
||||
setTimeRangeDateInputError(prev => ({ ...prev, start: true }))
|
||||
return
|
||||
}
|
||||
setTimeRangeDateInputError(prev => ({ ...prev, start: false }))
|
||||
updateTimeRangeDraftStart(parsed)
|
||||
}, [timeRangeDateInput.start, updateTimeRangeDraftStart])
|
||||
|
||||
const commitTimeRangeEndFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(timeRangeDateInput.end)
|
||||
if (!parsed) {
|
||||
setTimeRangeDateInputError(prev => ({ ...prev, end: true }))
|
||||
return
|
||||
}
|
||||
setTimeRangeDateInputError(prev => ({ ...prev, end: false }))
|
||||
updateTimeRangeDraftEnd(parsed)
|
||||
}, [timeRangeDateInput.end, updateTimeRangeDraftEnd])
|
||||
|
||||
const shiftTimeRangePanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
|
||||
setTimeRangeDialogDraft(prev => {
|
||||
const base = prev ?? buildTimeRangeDialogDraft()
|
||||
if (panel === 'start') {
|
||||
return {
|
||||
...base,
|
||||
startPanelMonth: addMonths(base.startPanelMonth, delta)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
endPanelMonth: addMonths(base.endPanelMonth, delta)
|
||||
}
|
||||
})
|
||||
}, [buildTimeRangeDialogDraft])
|
||||
|
||||
const commitTimeRangeDialogDraft = useCallback(() => {
|
||||
const draft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft()
|
||||
setTimeRangePreset(draft.preset)
|
||||
setOptions(prev => ({
|
||||
...prev,
|
||||
useAllTime: draft.useAllTime,
|
||||
dateRange: {
|
||||
start: new Date(draft.dateRange.start),
|
||||
end: new Date(draft.dateRange.end)
|
||||
}
|
||||
}))
|
||||
closeTimeRangeDialog()
|
||||
}, [buildTimeRangeDialogDraft, closeTimeRangeDialog, timeRangeDialogDraft])
|
||||
|
||||
const timeRangeSummaryLabel = useMemo(() => {
|
||||
if (options.useAllTime) return '默认导出全部时间'
|
||||
if (timeRangePreset === 'today') return '今天'
|
||||
if (timeRangePreset === 'yesterday') return '昨天'
|
||||
if (timeRangePreset === 'last3days') return '最近3天'
|
||||
if (timeRangePreset === 'last7days') return '最近一周'
|
||||
if (timeRangePreset === 'last30days') return '最近30 天'
|
||||
if (timeRangePreset === 'last1year') return '最近一年'
|
||||
if (timeRangePreset === 'last2years') return '最近两年'
|
||||
if (options.dateRange) {
|
||||
return `${formatDateInputValue(options.dateRange.start)} 至 ${formatDateInputValue(options.dateRange.end)}`
|
||||
}
|
||||
return '自定义时间范围'
|
||||
}, [options.useAllTime, options.dateRange, timeRangePreset])
|
||||
|
||||
const activeTimeRangeDialogDraft = timeRangeDialogDraft ?? buildTimeRangeDialogDraft()
|
||||
const isRangeModeActive = !activeTimeRangeDialogDraft.useAllTime
|
||||
const timeRangeModeText = isRangeModeActive
|
||||
? '当前导出模式:按时间范围导出'
|
||||
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimeRangeDialogOpen) return
|
||||
setTimeRangeDateInput({
|
||||
start: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.start),
|
||||
end: formatDateInputValue(activeTimeRangeDialogDraft.dateRange.end)
|
||||
})
|
||||
setTimeRangeDateInputError({ start: false, end: false })
|
||||
}, [
|
||||
isTimeRangeDialogOpen,
|
||||
activeTimeRangeDialogDraft.dateRange.start.getTime(),
|
||||
activeTimeRangeDialogDraft.dateRange.end.getTime()
|
||||
])
|
||||
|
||||
const isTimeRangePresetActive = useCallback((preset: DateRangePreset): boolean => {
|
||||
if (preset === 'all') return activeTimeRangeDialogDraft.useAllTime
|
||||
return !activeTimeRangeDialogDraft.useAllTime && activeTimeRangeDialogDraft.preset === preset
|
||||
}, [activeTimeRangeDialogDraft])
|
||||
|
||||
const startPanelCells = useMemo(
|
||||
() => buildCalendarCells(activeTimeRangeDialogDraft.startPanelMonth),
|
||||
[activeTimeRangeDialogDraft.startPanelMonth]
|
||||
)
|
||||
|
||||
const endPanelCells = useMemo(
|
||||
() => buildCalendarCells(activeTimeRangeDialogDraft.endPanelMonth),
|
||||
[activeTimeRangeDialogDraft.endPanelMonth]
|
||||
)
|
||||
const timeRangeSummaryLabel = useMemo(() => getExportDateRangeLabel(timeRangeSelection), [timeRangeSelection])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onOpenSingleExport((payload) => {
|
||||
@@ -2644,7 +3850,7 @@ function ExportPage() {
|
||||
format: fastTextFormat,
|
||||
contentType,
|
||||
exportConcurrency: textExportConcurrency,
|
||||
exportAvatars: true,
|
||||
exportAvatars: base.exportAvatars,
|
||||
exportMedia: false,
|
||||
exportImages: false,
|
||||
exportVoices: false,
|
||||
@@ -3057,7 +4263,13 @@ function ExportPage() {
|
||||
closeExportDialog()
|
||||
|
||||
await configService.setExportDefaultFormat(options.format)
|
||||
await configService.setExportDefaultMedia(Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis))
|
||||
await configService.setExportDefaultAvatars(options.exportAvatars)
|
||||
await configService.setExportDefaultMedia({
|
||||
images: options.exportImages,
|
||||
voices: options.exportVoices,
|
||||
videos: options.exportVideos,
|
||||
emojis: options.exportEmojis
|
||||
})
|
||||
await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText)
|
||||
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
||||
await configService.setExportDefaultTxtColumns(options.txtColumns)
|
||||
@@ -3280,9 +4492,17 @@ function ExportPage() {
|
||||
const activeTabLabel = useMemo(() => {
|
||||
if (activeTab === 'private') return '私聊'
|
||||
if (activeTab === 'group') return '群聊'
|
||||
if (activeTab === 'former_friend') return '曾经的好友'
|
||||
return '公众号'
|
||||
return '曾经的好友'
|
||||
}, [activeTab])
|
||||
const contactsHeaderMainLabel = useMemo(() => {
|
||||
if (activeTab === 'group') return '群聊名称'
|
||||
if (activeTab === 'private' || activeTab === 'former_friend') return '联系人'
|
||||
return '联系人(头像/名称/微信号)'
|
||||
}, [activeTab])
|
||||
const shouldShowSnsColumn = useMemo(() => (
|
||||
activeTab === 'private' || activeTab === 'former_friend'
|
||||
), [activeTab])
|
||||
const shouldShowMutualFriendsColumn = shouldShowSnsColumn
|
||||
|
||||
const sessionRowByUsername = useMemo(() => {
|
||||
const map = new Map<string, SessionRow>()
|
||||
@@ -3336,11 +4556,395 @@ function ExportPage() {
|
||||
return indexedContacts.map(item => item.contact)
|
||||
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
|
||||
|
||||
const keywordMatchedContactUsernameSet = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
const matched = new Set<string>()
|
||||
for (const contact of contactsList) {
|
||||
if (!contact?.username) continue
|
||||
if (!keyword) {
|
||||
matched.add(contact.username)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
(contact.displayName || '').toLowerCase().includes(keyword) ||
|
||||
(contact.remark || '').toLowerCase().includes(keyword) ||
|
||||
(contact.nickname || '').toLowerCase().includes(keyword) ||
|
||||
(contact.alias || '').toLowerCase().includes(keyword) ||
|
||||
contact.username.toLowerCase().includes(keyword)
|
||||
) {
|
||||
matched.add(contact.username)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}, [contactsList, searchKeyword])
|
||||
|
||||
const loadDetailTargetsByTab = useMemo(() => {
|
||||
const targets: Record<ConversationTab, string[]> = {
|
||||
private: [],
|
||||
group: [],
|
||||
official: [],
|
||||
former_friend: []
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (!session.hasSession) continue
|
||||
if (!keywordMatchedContactUsernameSet.has(session.username)) continue
|
||||
targets[session.kind].push(session.username)
|
||||
}
|
||||
return targets
|
||||
}, [keywordMatchedContactUsernameSet, sessions])
|
||||
|
||||
const formatLoadDetailTime = useCallback((value?: number): string => {
|
||||
if (!value || !Number.isFinite(value)) return '--'
|
||||
return new Date(value).toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}, [])
|
||||
|
||||
const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => {
|
||||
if (total <= 0) return '待加载'
|
||||
if (loaded >= total) return `已完成 ${total}`
|
||||
if (hasStarted) return `加载中 ${loaded}/${total}`
|
||||
return '待加载'
|
||||
}, [])
|
||||
|
||||
const summarizeLoadTraceForTab = useCallback((
|
||||
sessionIds: string[],
|
||||
stageKey: keyof SessionLoadTraceState
|
||||
): SessionLoadStageSummary => {
|
||||
const total = sessionIds.length
|
||||
let loaded = 0
|
||||
let hasStarted = false
|
||||
let earliestStart: number | undefined
|
||||
let latestFinish: number | undefined
|
||||
let latestProgressAt: number | undefined
|
||||
for (const sessionId of sessionIds) {
|
||||
const stage = sessionLoadTraceMap[sessionId]?.[stageKey]
|
||||
if (stage?.status === 'done') {
|
||||
loaded += 1
|
||||
if (typeof stage.finishedAt === 'number') {
|
||||
latestProgressAt = latestProgressAt === undefined
|
||||
? stage.finishedAt
|
||||
: Math.max(latestProgressAt, stage.finishedAt)
|
||||
}
|
||||
}
|
||||
if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') {
|
||||
hasStarted = true
|
||||
}
|
||||
if (typeof stage?.startedAt === 'number') {
|
||||
earliestStart = earliestStart === undefined
|
||||
? stage.startedAt
|
||||
: Math.min(earliestStart, stage.startedAt)
|
||||
}
|
||||
if (typeof stage?.finishedAt === 'number') {
|
||||
latestFinish = latestFinish === undefined
|
||||
? stage.finishedAt
|
||||
: Math.max(latestFinish, stage.finishedAt)
|
||||
}
|
||||
}
|
||||
return {
|
||||
total,
|
||||
loaded,
|
||||
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted),
|
||||
startedAt: earliestStart,
|
||||
finishedAt: loaded >= total ? latestFinish : undefined,
|
||||
latestProgressAt
|
||||
}
|
||||
}, [getLoadDetailStatusLabel, sessionLoadTraceMap])
|
||||
|
||||
const createNotApplicableLoadSummary = useCallback((): SessionLoadStageSummary => {
|
||||
return {
|
||||
total: 0,
|
||||
loaded: 0,
|
||||
statusLabel: '不适用'
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sessionLoadDetailRows = useMemo(() => {
|
||||
const tabOrder: ConversationTab[] = ['private', 'group', 'former_friend']
|
||||
return tabOrder.map((tab) => {
|
||||
const sessionIds = loadDetailTargetsByTab[tab] || []
|
||||
const snsSessionIds = sessionIds.filter((sessionId) => isSingleContactSession(sessionId))
|
||||
const snsPostCounts = tab === 'private' || tab === 'former_friend'
|
||||
? summarizeLoadTraceForTab(snsSessionIds, 'snsPostCounts')
|
||||
: createNotApplicableLoadSummary()
|
||||
const mutualFriends = tab === 'private' || tab === 'former_friend'
|
||||
? summarizeLoadTraceForTab(snsSessionIds, 'mutualFriends')
|
||||
: createNotApplicableLoadSummary()
|
||||
return {
|
||||
tab,
|
||||
label: conversationTabLabels[tab],
|
||||
messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'),
|
||||
mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics'),
|
||||
snsPostCounts,
|
||||
mutualFriends
|
||||
}
|
||||
})
|
||||
}, [createNotApplicableLoadSummary, loadDetailTargetsByTab, summarizeLoadTraceForTab])
|
||||
|
||||
const formatLoadDetailPulseTime = useCallback((value?: number): string => {
|
||||
if (!value || !Number.isFinite(value)) return '--'
|
||||
return new Date(value).toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const previousSnapshot = sessionLoadProgressSnapshotRef.current
|
||||
const nextSnapshot: Record<string, { loaded: number; total: number }> = {}
|
||||
const resetKeys: string[] = []
|
||||
const updates: Array<{ key: string; at: number; delta: number }> = []
|
||||
const stageKeys: Array<keyof SessionLoadTraceState> = ['messageCount', 'mediaMetrics', 'snsPostCounts', 'mutualFriends']
|
||||
|
||||
for (const row of sessionLoadDetailRows) {
|
||||
for (const stageKey of stageKeys) {
|
||||
const summary = row[stageKey]
|
||||
const key = `${stageKey}:${row.tab}`
|
||||
const loaded = Number.isFinite(summary.loaded) ? Math.max(0, Math.floor(summary.loaded)) : 0
|
||||
const total = Number.isFinite(summary.total) ? Math.max(0, Math.floor(summary.total)) : 0
|
||||
nextSnapshot[key] = { loaded, total }
|
||||
|
||||
const previous = previousSnapshot[key]
|
||||
if (!previous || previous.total !== total || loaded < previous.loaded) {
|
||||
resetKeys.push(key)
|
||||
continue
|
||||
}
|
||||
if (loaded > previous.loaded) {
|
||||
updates.push({
|
||||
key,
|
||||
at: summary.latestProgressAt || Date.now(),
|
||||
delta: loaded - previous.loaded
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionLoadProgressSnapshotRef.current = nextSnapshot
|
||||
if (resetKeys.length === 0 && updates.length === 0) return
|
||||
|
||||
setSessionLoadProgressPulseMap(prev => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
for (const key of resetKeys) {
|
||||
if (!(key in next)) continue
|
||||
delete next[key]
|
||||
changed = true
|
||||
}
|
||||
for (const update of updates) {
|
||||
const previous = next[update.key]
|
||||
if (previous && previous.at === update.at && previous.delta === update.delta) continue
|
||||
next[update.key] = { at: update.at, delta: update.delta }
|
||||
changed = true
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [sessionLoadDetailRows])
|
||||
|
||||
useEffect(() => {
|
||||
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
|
||||
setIsContactsListAtTop(true)
|
||||
}, [activeTab, searchKeyword])
|
||||
|
||||
const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
|
||||
if (sourceContacts.length === 0) return []
|
||||
const startCandidate = sessionMediaMetricVisibleRangeRef.current.startIndex
|
||||
const endCandidate = sessionMediaMetricVisibleRangeRef.current.endIndex
|
||||
const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0))
|
||||
const visibleEnd = endCandidate >= startIndex
|
||||
? endCandidate
|
||||
: Math.min(sourceContacts.length - 1, startIndex + 9)
|
||||
const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS))
|
||||
const sessionIds: string[] = []
|
||||
for (let index = startIndex; index <= endIndex; index += 1) {
|
||||
const contact = sourceContacts[index]
|
||||
if (!contact?.username) continue
|
||||
const mappedSession = sessionRowByUsername.get(contact.username)
|
||||
if (!mappedSession?.hasSession) continue
|
||||
sessionIds.push(contact.username)
|
||||
}
|
||||
return sessionIds
|
||||
}, [sessionRowByUsername])
|
||||
|
||||
const collectVisibleSessionMutualFriendsTargets = useCallback((sourceContacts: ContactInfo[]): string[] => {
|
||||
if (sourceContacts.length === 0) return []
|
||||
const startCandidate = sessionMutualFriendsVisibleRangeRef.current.startIndex
|
||||
const endCandidate = sessionMutualFriendsVisibleRangeRef.current.endIndex
|
||||
const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0))
|
||||
const visibleEnd = endCandidate >= startIndex
|
||||
? endCandidate
|
||||
: Math.min(sourceContacts.length - 1, startIndex + 9)
|
||||
const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS))
|
||||
const sessionIds: string[] = []
|
||||
for (let index = startIndex; index <= endIndex; index += 1) {
|
||||
const contact = sourceContacts[index]
|
||||
if (!contact?.username || !isSingleContactSession(contact.username)) continue
|
||||
const mappedSession = sessionRowByUsername.get(contact.username)
|
||||
if (!mappedSession?.hasSession) continue
|
||||
sessionIds.push(contact.username)
|
||||
}
|
||||
return sessionIds
|
||||
}, [sessionRowByUsername])
|
||||
|
||||
const handleContactsRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
|
||||
const startIndex = Number.isFinite(range?.startIndex) ? Math.max(0, Math.floor(range.startIndex)) : 0
|
||||
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
|
||||
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
|
||||
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
|
||||
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
|
||||
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||
if (visibleTargets.length === 0) return
|
||||
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
|
||||
scheduleSessionMediaMetricWorker()
|
||||
const visibleMutualFriendsTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts)
|
||||
if (visibleMutualFriendsTargets.length > 0) {
|
||||
enqueueSessionMutualFriendsRequests(visibleMutualFriendsTargets, { front: true })
|
||||
scheduleSessionMutualFriendsWorker()
|
||||
}
|
||||
}, [
|
||||
collectVisibleSessionMetricTargets,
|
||||
collectVisibleSessionMutualFriendsTargets,
|
||||
enqueueSessionMediaMetricRequests,
|
||||
enqueueSessionMutualFriendsRequests,
|
||||
filteredContacts,
|
||||
isSessionCountStageReady,
|
||||
scheduleSessionMediaMetricWorker,
|
||||
scheduleSessionMutualFriendsWorker
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionCountStageReady || filteredContacts.length === 0) return
|
||||
const runId = sessionMediaMetricRunIdRef.current
|
||||
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||
if (visibleTargets.length > 0) {
|
||||
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
|
||||
scheduleSessionMediaMetricWorker()
|
||||
}
|
||||
|
||||
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
|
||||
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
|
||||
sessionMediaMetricBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
|
||||
const visibleTargetSet = new Set(visibleTargets)
|
||||
let cursor = 0
|
||||
const feedNext = () => {
|
||||
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||
if (isLoadingSessionCountsRef.current) return
|
||||
const batchIds: string[] = []
|
||||
while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
|
||||
const contact = filteredContacts[cursor]
|
||||
cursor += 1
|
||||
if (!contact?.username) continue
|
||||
if (visibleTargetSet.has(contact.username)) continue
|
||||
const mappedSession = sessionRowByUsername.get(contact.username)
|
||||
if (!mappedSession?.hasSession) continue
|
||||
batchIds.push(contact.username)
|
||||
}
|
||||
|
||||
if (batchIds.length > 0) {
|
||||
enqueueSessionMediaMetricRequests(batchIds)
|
||||
scheduleSessionMediaMetricWorker()
|
||||
}
|
||||
|
||||
if (cursor < filteredContacts.length) {
|
||||
sessionMediaMetricBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
feedNext()
|
||||
return () => {
|
||||
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
|
||||
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
|
||||
sessionMediaMetricBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [
|
||||
collectVisibleSessionMetricTargets,
|
||||
enqueueSessionMediaMetricRequests,
|
||||
filteredContacts,
|
||||
isSessionCountStageReady,
|
||||
scheduleSessionMediaMetricWorker,
|
||||
sessionRowByUsername
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionCountStageReady || filteredContacts.length === 0) return
|
||||
const runId = sessionMutualFriendsRunIdRef.current
|
||||
const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts)
|
||||
if (visibleTargets.length > 0) {
|
||||
enqueueSessionMutualFriendsRequests(visibleTargets, { front: true })
|
||||
scheduleSessionMutualFriendsWorker()
|
||||
}
|
||||
|
||||
if (sessionMutualFriendsBackgroundFeedTimerRef.current) {
|
||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
|
||||
const visibleTargetSet = new Set(visibleTargets)
|
||||
let cursor = 0
|
||||
const feedNext = () => {
|
||||
if (runId !== sessionMutualFriendsRunIdRef.current) return
|
||||
const batchIds: string[] = []
|
||||
while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
|
||||
const contact = filteredContacts[cursor]
|
||||
cursor += 1
|
||||
if (!contact?.username || !isSingleContactSession(contact.username)) continue
|
||||
if (visibleTargetSet.has(contact.username)) continue
|
||||
const mappedSession = sessionRowByUsername.get(contact.username)
|
||||
if (!mappedSession?.hasSession) continue
|
||||
batchIds.push(contact.username)
|
||||
}
|
||||
|
||||
if (batchIds.length > 0) {
|
||||
enqueueSessionMutualFriendsRequests(batchIds)
|
||||
scheduleSessionMutualFriendsWorker()
|
||||
}
|
||||
|
||||
if (cursor < filteredContacts.length) {
|
||||
sessionMutualFriendsBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
feedNext()
|
||||
return () => {
|
||||
if (sessionMutualFriendsBackgroundFeedTimerRef.current) {
|
||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [
|
||||
collectVisibleSessionMutualFriendsTargets,
|
||||
enqueueSessionMutualFriendsRequests,
|
||||
filteredContacts,
|
||||
isSessionCountStageReady,
|
||||
scheduleSessionMutualFriendsWorker,
|
||||
sessionRowByUsername
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
snsUserPostCountsHydrationTokenRef.current += 1
|
||||
if (snsUserPostCountsBatchTimerRef.current) {
|
||||
window.clearTimeout(snsUserPostCountsBatchTimerRef.current)
|
||||
snsUserPostCountsBatchTimerRef.current = null
|
||||
}
|
||||
if (sessionMediaMetricBackgroundFeedTimerRef.current) {
|
||||
window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current)
|
||||
sessionMediaMetricBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
if (sessionMediaMetricPersistTimerRef.current) {
|
||||
window.clearTimeout(sessionMediaMetricPersistTimerRef.current)
|
||||
sessionMediaMetricPersistTimerRef.current = null
|
||||
}
|
||||
if (sessionMutualFriendsBackgroundFeedTimerRef.current) {
|
||||
window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current)
|
||||
sessionMutualFriendsBackgroundFeedTimerRef.current = null
|
||||
}
|
||||
void flushSessionMediaMetricCache()
|
||||
}
|
||||
}, [flushSessionMediaMetricCache])
|
||||
|
||||
const contactByUsername = useMemo(() => {
|
||||
const map = new Map<string, ContactInfo>()
|
||||
for (const contact of contactsList) {
|
||||
@@ -3358,6 +4962,40 @@ function ExportPage() {
|
||||
.slice(0, 20)
|
||||
}, [sessionDetail?.wxid, exportRecordsBySession])
|
||||
|
||||
const sessionDetailSupportsSnsTimeline = useMemo(() => {
|
||||
const sessionId = String(sessionDetail?.wxid || '').trim()
|
||||
return isSingleContactSession(sessionId)
|
||||
}, [sessionDetail?.wxid])
|
||||
|
||||
const sessionDetailSnsCountLabel = useMemo(() => {
|
||||
const sessionId = String(sessionDetail?.wxid || '').trim()
|
||||
if (!sessionId || !sessionDetailSupportsSnsTimeline) return '朋友圈:0条'
|
||||
|
||||
if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') {
|
||||
return '朋友圈:统计中...'
|
||||
}
|
||||
if (snsUserPostCountsStatus === 'error') {
|
||||
return '朋友圈:统计失败'
|
||||
}
|
||||
|
||||
const count = Number(snsUserPostCounts[sessionId] || 0)
|
||||
const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
|
||||
return `朋友圈:${normalized}条`
|
||||
}, [sessionDetail?.wxid, sessionDetailSupportsSnsTimeline, snsUserPostCounts, snsUserPostCountsStatus])
|
||||
|
||||
const sessionMutualFriendsDialogMetric = useMemo(() => {
|
||||
const sessionId = String(sessionMutualFriendsDialogTarget?.username || '').trim()
|
||||
if (!sessionId) return null
|
||||
return sessionMutualFriendsMetrics[sessionId] || null
|
||||
}, [sessionMutualFriendsDialogTarget, sessionMutualFriendsMetrics])
|
||||
|
||||
const filteredSessionMutualFriendsDialogItems = useMemo(() => {
|
||||
const items = sessionMutualFriendsDialogMetric?.items || []
|
||||
const keyword = sessionMutualFriendsSearch.trim().toLowerCase()
|
||||
if (!keyword) return items
|
||||
return items.filter(item => item.name.toLowerCase().includes(keyword))
|
||||
}, [sessionMutualFriendsDialogMetric, sessionMutualFriendsSearch])
|
||||
|
||||
const applySessionDetailStats = useCallback((
|
||||
sessionId: string,
|
||||
metric: SessionExportMetric,
|
||||
@@ -3399,6 +5037,11 @@ function ExportPage() {
|
||||
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return
|
||||
const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}`
|
||||
|
||||
detailStatsPriorityRef.current = true
|
||||
sessionCountRequestIdRef.current += 1
|
||||
setIsLoadingSessionCounts(false)
|
||||
|
||||
const requestSeq = ++detailRequestSeqRef.current
|
||||
const mappedSession = sessionRowByUsername.get(normalizedSessionId)
|
||||
@@ -3510,19 +5153,13 @@ function ExportPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
||||
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
||||
window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||
)
|
||||
])
|
||||
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
|
||||
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
|
||||
const detail = extraResultSettled.value.detail
|
||||
if (detail) {
|
||||
const extraPromise = window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId)
|
||||
void (async () => {
|
||||
try {
|
||||
const extraResult = await extraPromise
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (!extraResult.success || !extraResult.detail) return
|
||||
const detail = extraResult.detail
|
||||
setSessionDetail((prev) => {
|
||||
if (!prev || prev.wxid !== normalizedSessionId) return prev
|
||||
return {
|
||||
@@ -3532,51 +5169,73 @@ function ExportPage() {
|
||||
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : []
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('导出页加载会话详情补充信息失败:', error)
|
||||
} finally {
|
||||
if (requestSeq === detailRequestSeqRef.current) {
|
||||
setIsLoadingSessionDetailExtra(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
let quickMetric: SessionExportMetric | undefined
|
||||
let quickCacheMeta: SessionExportCacheMeta | undefined
|
||||
try {
|
||||
const quickStatsResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (quickStatsResult.success) {
|
||||
quickMetric = quickStatsResult.data?.[normalizedSessionId] as SessionExportMetric | undefined
|
||||
quickCacheMeta = quickStatsResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||
if (quickMetric) {
|
||||
applySessionDetailStats(normalizedSessionId, quickMetric, quickCacheMeta, false)
|
||||
} else if (quickCacheMeta) {
|
||||
const cacheMeta = quickCacheMeta
|
||||
setSessionDetail((prev) => {
|
||||
if (!prev || prev.wxid !== normalizedSessionId) return prev
|
||||
return {
|
||||
...prev,
|
||||
statsUpdatedAt: cacheMeta.updatedAt,
|
||||
statsStale: cacheMeta.stale
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出页读取会话统计缓存失败:', error)
|
||||
}
|
||||
|
||||
let refreshIncludeRelations = false
|
||||
let shouldRefreshStats = false
|
||||
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) {
|
||||
const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined
|
||||
const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||
refreshIncludeRelations = Boolean(cacheMeta?.includeRelations)
|
||||
if (metric) {
|
||||
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations)
|
||||
} else if (cacheMeta) {
|
||||
setSessionDetail((prev) => {
|
||||
if (!prev || prev.wxid !== normalizedSessionId) return prev
|
||||
return {
|
||||
...prev,
|
||||
relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded,
|
||||
statsUpdatedAt: cacheMeta.updatedAt,
|
||||
statsStale: cacheMeta.stale
|
||||
}
|
||||
})
|
||||
}
|
||||
shouldRefreshStats = Array.isArray(statsResultSettled.value.needsRefresh) &&
|
||||
statsResultSettled.value.needsRefresh.includes(normalizedSessionId)
|
||||
}
|
||||
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
|
||||
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
|
||||
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
||||
|
||||
if (shouldRefreshStats) {
|
||||
if (shouldRunPreciseRefresh) {
|
||||
setIsRefreshingSessionDetailStats(true)
|
||||
void (async () => {
|
||||
try {
|
||||
// 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: refreshIncludeRelations, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||
const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||
if (metric) {
|
||||
applySessionDetailStats(
|
||||
normalizedSessionId,
|
||||
metric,
|
||||
cacheMeta,
|
||||
refreshIncludeRelations ? true : undefined
|
||||
)
|
||||
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false)
|
||||
sessionPreciseRefreshAtRef.current[preciseCacheKey] = Date.now()
|
||||
} else if (cacheMeta) {
|
||||
setSessionDetail((prev) => {
|
||||
if (!prev || prev.wxid !== normalizedSessionId) return prev
|
||||
return {
|
||||
...prev,
|
||||
statsUpdatedAt: cacheMeta.updatedAt,
|
||||
statsStale: cacheMeta.stale
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -3590,7 +5249,6 @@ function ExportPage() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出页加载会话详情补充统计失败:', error)
|
||||
} finally {
|
||||
if (requestSeq === detailRequestSeqRef.current) {
|
||||
setIsLoadingSessionDetailExtra(false)
|
||||
}
|
||||
@@ -3619,36 +5277,6 @@ function ExportPage() {
|
||||
if (metric) {
|
||||
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true)
|
||||
}
|
||||
|
||||
const needRefresh = relationResult.success &&
|
||||
Array.isArray(relationResult.needsRefresh) &&
|
||||
relationResult.needsRefresh.includes(normalizedSessionId)
|
||||
|
||||
if (needRefresh) {
|
||||
setIsRefreshingSessionDetailStats(true)
|
||||
void (async () => {
|
||||
try {
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||
if (freshMetric) {
|
||||
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出页刷新会话关系统计失败:', error)
|
||||
} finally {
|
||||
if (requestSeq === detailRequestSeqRef.current) {
|
||||
setIsRefreshingSessionDetailStats(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出页加载会话关系统计失败:', error)
|
||||
} finally {
|
||||
@@ -3658,20 +5286,95 @@ function ExportPage() {
|
||||
}
|
||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return
|
||||
if (snsUserPostCountsStatus === 'idle') {
|
||||
void loadSnsUserPostCounts()
|
||||
}
|
||||
}, [
|
||||
loadSnsUserPostCounts,
|
||||
sessionDetailSupportsSnsTimeline,
|
||||
showSessionDetailPanel,
|
||||
snsUserPostCountsStatus
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExportRoute || !isSessionCountStageReady) return
|
||||
if (snsUserPostCountsStatus !== 'idle') return
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadSnsUserPostCounts()
|
||||
}, 260)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isExportRoute, isSessionCountStageReady, loadSnsUserPostCounts, snsUserPostCountsStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionSnsTimelineTarget) return
|
||||
if (Object.prototype.hasOwnProperty.call(snsUserPostCounts, sessionSnsTimelineTarget.username)) {
|
||||
const total = Number(snsUserPostCounts[sessionSnsTimelineTarget.username] || 0)
|
||||
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
|
||||
setSessionSnsTimelineTotalPosts(normalizedTotal)
|
||||
setSessionSnsRankTotalPosts(normalizedTotal)
|
||||
setSessionSnsTimelineStatsLoading(false)
|
||||
return
|
||||
}
|
||||
if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') {
|
||||
setSessionSnsTimelineStatsLoading(true)
|
||||
return
|
||||
}
|
||||
setSessionSnsTimelineTotalPosts(null)
|
||||
setSessionSnsRankTotalPosts(null)
|
||||
setSessionSnsTimelineStatsLoading(false)
|
||||
}, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionSnsTimelineTotalPosts === null) return
|
||||
if (sessionSnsTimelinePosts.length >= sessionSnsTimelineTotalPosts) {
|
||||
setSessionSnsTimelineHasMore(false)
|
||||
}
|
||||
}, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionSnsRankMode || !sessionSnsTimelineTarget) return
|
||||
void loadSessionSnsRankings(sessionSnsTimelineTarget)
|
||||
}, [loadSessionSnsRankings, sessionSnsRankMode, sessionSnsTimelineTarget])
|
||||
|
||||
const closeSessionDetailPanel = useCallback(() => {
|
||||
detailRequestSeqRef.current += 1
|
||||
detailStatsPriorityRef.current = false
|
||||
sessionSnsTimelineRequestTokenRef.current += 1
|
||||
sessionSnsTimelineLoadingRef.current = false
|
||||
sessionSnsRankRequestTokenRef.current += 1
|
||||
sessionSnsRankLoadingRef.current = false
|
||||
setShowSessionDetailPanel(false)
|
||||
setIsLoadingSessionDetail(false)
|
||||
setIsLoadingSessionDetailExtra(false)
|
||||
setIsRefreshingSessionDetailStats(false)
|
||||
setIsLoadingSessionRelationStats(false)
|
||||
setSessionSnsRankMode(null)
|
||||
setSessionSnsLikeRankings([])
|
||||
setSessionSnsCommentRankings([])
|
||||
setSessionSnsRankLoading(false)
|
||||
setSessionSnsRankError(null)
|
||||
setSessionSnsRankLoadedPosts(0)
|
||||
setSessionSnsRankTotalPosts(null)
|
||||
setSessionSnsTimelineTarget(null)
|
||||
setSessionSnsTimelinePosts([])
|
||||
setSessionSnsTimelineLoading(false)
|
||||
setSessionSnsTimelineLoadingMore(false)
|
||||
setSessionSnsTimelineHasMore(false)
|
||||
setSessionSnsTimelineTotalPosts(null)
|
||||
setSessionSnsTimelineStatsLoading(false)
|
||||
}, [])
|
||||
|
||||
const openSessionDetail = useCallback((sessionId: string) => {
|
||||
if (!sessionId) return
|
||||
detailStatsPriorityRef.current = true
|
||||
setShowSessionDetailPanel(true)
|
||||
if (isSingleContactSession(sessionId)) {
|
||||
void loadSnsUserPostCounts()
|
||||
}
|
||||
void loadSessionDetail(sessionId)
|
||||
}, [loadSessionDetail])
|
||||
}, [loadSessionDetail, loadSnsUserPostCounts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSessionDetailPanel) return
|
||||
@@ -3684,6 +5387,60 @@ function ExportPage() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [closeSessionDetailPanel, showSessionDetailPanel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSessionLoadDetailModal) return
|
||||
if (snsUserPostCountsStatus === 'idle') {
|
||||
void loadSnsUserPostCounts()
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setShowSessionLoadDetailModal(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [loadSnsUserPostCounts, showSessionLoadDetailModal, snsUserPostCountsStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionSnsTimelineTarget) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeSessionSnsTimeline()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [closeSessionSnsTimeline, sessionSnsTimelineTarget])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionMutualFriendsDialogTarget) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeSessionMutualFriendsDialog()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [closeSessionMutualFriendsDialog, sessionMutualFriendsDialogTarget])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSessionFormatSelect) return
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (sessionFormatDropdownRef.current && !sessionFormatDropdownRef.current.contains(target)) {
|
||||
setShowSessionFormatSelect(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [showSessionFormatSelect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!exportDialog.open) {
|
||||
setShowSessionFormatSelect(false)
|
||||
}
|
||||
}, [exportDialog.open])
|
||||
|
||||
const handleCopyDetailField = useCallback(async (text: string, field: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -3777,10 +5534,17 @@ function ExportPage() {
|
||||
const formatCandidateOptions = exportDialog.scope === 'sns'
|
||||
? snsFormatOptions
|
||||
: formatOptions
|
||||
const isSessionScopeDialog = exportDialog.scope === 'single' || exportDialog.scope === 'multi'
|
||||
const isContentScopeDialog = exportDialog.scope === 'content'
|
||||
const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text'
|
||||
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
|
||||
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
||||
const shouldShowMediaSection = !isContentScopeDialog
|
||||
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
|
||||
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
|
||||
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
||||
? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat)
|
||||
: (formatOptions.find(option => option.value === options.format)?.label ?? options.format)
|
||||
const shouldShowDisplayNameSection = !(
|
||||
exportDialog.scope === 'sns' ||
|
||||
(
|
||||
@@ -3797,7 +5561,33 @@ function ExportPage() {
|
||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
||||
const hasFilteredContacts = filteredContacts.length > 0
|
||||
const sessionLoadDetailUpdatedAt = useMemo(() => {
|
||||
let latest = 0
|
||||
for (const row of sessionLoadDetailRows) {
|
||||
const candidateTimes = [
|
||||
row.messageCount.finishedAt || row.messageCount.startedAt || 0,
|
||||
row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0,
|
||||
row.snsPostCounts.finishedAt || row.snsPostCounts.startedAt || 0,
|
||||
row.mutualFriends.finishedAt || row.mutualFriends.startedAt || 0
|
||||
]
|
||||
for (const candidate of candidateTimes) {
|
||||
if (candidate > latest) {
|
||||
latest = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}, [sessionLoadDetailRows])
|
||||
const isSessionLoadDetailActive = useMemo(() => (
|
||||
sessionLoadDetailRows.some(row => (
|
||||
row.messageCount.statusLabel.startsWith('加载中') ||
|
||||
row.mediaMetrics.statusLabel.startsWith('加载中') ||
|
||||
row.snsPostCounts.statusLabel.startsWith('加载中') ||
|
||||
row.mutualFriends.statusLabel.startsWith('加载中')
|
||||
))
|
||||
), [sessionLoadDetailRows])
|
||||
const closeTaskCenter = useCallback(() => {
|
||||
setIsTaskCenterOpen(false)
|
||||
setExpandedPerfTaskId(null)
|
||||
@@ -3808,18 +5598,69 @@ function ExportPage() {
|
||||
const renderContactRow = useCallback((_: number, contact: ContactInfo) => {
|
||||
const matchedSession = sessionRowByUsername.get(contact.username)
|
||||
const canExport = Boolean(matchedSession?.hasSession)
|
||||
const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching)
|
||||
const checked = canExport && selectedSessions.has(contact.username)
|
||||
const isRunning = canExport && runningSessionIds.has(contact.username)
|
||||
const isQueued = canExport && queuedSessionIds.has(contact.username)
|
||||
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
|
||||
const recentExportTimestamp = lastExportBySession[contact.username]
|
||||
const hasRecentExport = canExport && Boolean(recentExportTimestamp)
|
||||
const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : ''
|
||||
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
||||
const displayedMessageCount = countedMessages ?? hintedMessages
|
||||
const messageCountLabel = !canExport
|
||||
? '--'
|
||||
: typeof displayedMessageCount === 'number'
|
||||
? displayedMessageCount.toLocaleString('zh-CN')
|
||||
: '获取中'
|
||||
const mediaMetric = sessionContentMetrics[contact.username]
|
||||
const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } =
|
||||
!canExport
|
||||
? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' })
|
||||
: typeof displayedMessageCount === 'number'
|
||||
? { state: 'value', text: displayedMessageCount.toLocaleString('zh-CN') }
|
||||
: { state: 'loading' }
|
||||
const metricToDisplay = (value: unknown): { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } => {
|
||||
const normalized = normalizeMessageCount(value)
|
||||
if (!canExport) {
|
||||
return isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' }
|
||||
}
|
||||
if (typeof normalized === 'number') {
|
||||
return { state: 'value', text: normalized.toLocaleString('zh-CN') }
|
||||
}
|
||||
return { state: 'loading' }
|
||||
}
|
||||
const emojiMetric = metricToDisplay(mediaMetric?.emojiMessages)
|
||||
const voiceMetric = metricToDisplay(mediaMetric?.voiceMessages)
|
||||
const imageMetric = metricToDisplay(mediaMetric?.imageMessages)
|
||||
const videoMetric = metricToDisplay(mediaMetric?.videoMessages)
|
||||
const supportsSnsTimeline = isSingleContactSession(contact.username)
|
||||
const hasSnsCount = Object.prototype.hasOwnProperty.call(snsUserPostCounts, contact.username)
|
||||
const snsStageStatus = sessionLoadTraceMap[contact.username]?.snsPostCounts?.status
|
||||
const isSnsCountLoading = (
|
||||
supportsSnsTimeline &&
|
||||
!hasSnsCount &&
|
||||
(
|
||||
snsStageStatus === 'pending' ||
|
||||
snsStageStatus === 'loading' ||
|
||||
snsUserPostCountsStatus === 'loading' ||
|
||||
snsUserPostCountsStatus === 'idle'
|
||||
)
|
||||
)
|
||||
const snsRawCount = Number(snsUserPostCounts[contact.username] || 0)
|
||||
const snsCount = Number.isFinite(snsRawCount) ? Math.max(0, Math.floor(snsRawCount)) : 0
|
||||
const mutualFriendsMetric = sessionMutualFriendsMetrics[contact.username]
|
||||
const hasMutualFriendsMetric = Boolean(mutualFriendsMetric)
|
||||
const mutualFriendsStageStatus = sessionLoadTraceMap[contact.username]?.mutualFriends?.status
|
||||
const isMutualFriendsLoading = (
|
||||
supportsSnsTimeline &&
|
||||
canExport &&
|
||||
!hasMutualFriendsMetric &&
|
||||
(
|
||||
mutualFriendsStageStatus === 'pending' ||
|
||||
mutualFriendsStageStatus === 'loading'
|
||||
)
|
||||
)
|
||||
const openChatLabel = contact.type === 'friend'
|
||||
? '打开私聊'
|
||||
: contact.type === 'group'
|
||||
? '打开群聊'
|
||||
: '打开对话'
|
||||
return (
|
||||
<div className={`contact-row ${checked ? 'selected' : ''}`}>
|
||||
<div className="contact-item">
|
||||
@@ -3847,51 +5688,125 @@ function ExportPage() {
|
||||
</div>
|
||||
<div className="row-message-count">
|
||||
<div className="row-message-stats">
|
||||
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
|
||||
{messageCountLabel}
|
||||
<strong className={`row-message-count-value ${messageCountState.state === 'value' ? '' : 'muted'}`}>
|
||||
{messageCountState.state === 'loading'
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
|
||||
: messageCountState.text}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-action-cell">
|
||||
<div className="row-action-main">
|
||||
{canExport && (
|
||||
<button
|
||||
className="row-open-chat-btn"
|
||||
disabled={!canExport}
|
||||
title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'}
|
||||
type="button"
|
||||
className="row-open-chat-link"
|
||||
title="在新窗口打开该会话"
|
||||
onClick={() => {
|
||||
if (!canExport) return
|
||||
void window.electronAPI.window.openSessionChatWindow(contact.username)
|
||||
void window.electronAPI.window.openSessionChatWindow(contact.username, {
|
||||
source: 'export',
|
||||
initialDisplayName: contact.displayName || contact.username,
|
||||
initialAvatarUrl: contact.avatarUrl,
|
||||
initialContactType: contact.type
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
打开对话
|
||||
{openChatLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-media-metric">
|
||||
<strong className="row-media-metric-value">
|
||||
{emojiMetric.state === 'loading'
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
|
||||
: emojiMetric.text}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="row-media-metric">
|
||||
<strong className="row-media-metric-value">
|
||||
{voiceMetric.state === 'loading'
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
|
||||
: voiceMetric.text}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="row-media-metric">
|
||||
<strong className="row-media-metric-value">
|
||||
{imageMetric.state === 'loading'
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
|
||||
: imageMetric.text}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="row-media-metric">
|
||||
<strong className="row-media-metric-value">
|
||||
{videoMetric.state === 'loading'
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="统计加载中" />
|
||||
: videoMetric.text}
|
||||
</strong>
|
||||
</div>
|
||||
{shouldShowSnsColumn && (
|
||||
<div className="row-media-metric">
|
||||
{supportsSnsTimeline ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`row-sns-metric-btn ${isSnsCountLoading ? 'loading' : ''}`}
|
||||
title={`查看 ${contact.displayName || contact.username} 的朋友圈`}
|
||||
onClick={() => openContactSnsTimeline(contact)}
|
||||
>
|
||||
{isSnsCountLoading
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="朋友圈统计加载中" />
|
||||
: hasSnsCount
|
||||
? `${snsCount.toLocaleString('zh-CN')} 条`
|
||||
: '--'}
|
||||
</button>
|
||||
) : (
|
||||
<strong className="row-media-metric-value">--</strong>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldShowMutualFriendsColumn && (
|
||||
<div className="row-media-metric">
|
||||
{supportsSnsTimeline ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`row-sns-metric-btn row-mutual-friends-btn ${isMutualFriendsLoading ? 'loading' : ''} ${hasMutualFriendsMetric ? 'ready' : ''}`}
|
||||
title={`查看 ${contact.displayName || contact.username} 的共同好友`}
|
||||
onClick={() => openSessionMutualFriendsDialog(contact)}
|
||||
disabled={!hasMutualFriendsMetric}
|
||||
>
|
||||
{isMutualFriendsLoading
|
||||
? <Loader2 size={12} className="spin row-media-metric-icon" aria-label="共同好友统计加载中" />
|
||||
: hasMutualFriendsMetric
|
||||
? mutualFriendsMetric.count.toLocaleString('zh-CN')
|
||||
: '--'}
|
||||
</button>
|
||||
) : (
|
||||
<strong className="row-media-metric-value">--</strong>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="row-action-cell">
|
||||
<div className={`row-action-main ${hasRecentExport ? '' : 'single-line'}`.trim()}>
|
||||
<div className={`row-export-action-stack ${hasRecentExport ? '' : 'single-line'}`.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`row-export-link ${isRunning ? 'state-running' : ''} ${!canExport ? 'state-disabled' : ''}`}
|
||||
disabled={!canExport || isRunning}
|
||||
onClick={() => {
|
||||
if (!matchedSession || !matchedSession.hasSession) return
|
||||
openSingleExport({
|
||||
...matchedSession,
|
||||
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
|
||||
})
|
||||
}}
|
||||
>
|
||||
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'}
|
||||
</button>
|
||||
{hasRecentExport && <span className="row-export-time">{recentExportTime}</span>}
|
||||
</div>
|
||||
<button
|
||||
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
|
||||
onClick={() => openSessionDetail(contact.username)}
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
|
||||
disabled={!canExport || isRunning}
|
||||
onClick={() => {
|
||||
if (!matchedSession || !matchedSession.hasSession) return
|
||||
openSingleExport({
|
||||
...matchedSession,
|
||||
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Loader2 size={14} className="spin" />
|
||||
导出中
|
||||
</>
|
||||
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '单会话导出'}
|
||||
</button>
|
||||
</div>
|
||||
{recent && <span className="row-export-time">{recent}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3899,15 +5814,26 @@ function ExportPage() {
|
||||
}, [
|
||||
lastExportBySession,
|
||||
nowTick,
|
||||
openContactSnsTimeline,
|
||||
openSessionDetail,
|
||||
openSessionMutualFriendsDialog,
|
||||
openSingleExport,
|
||||
queuedSessionIds,
|
||||
runningSessionIds,
|
||||
selectedSessions,
|
||||
sessionDetail?.wxid,
|
||||
sessionContentMetrics,
|
||||
sessionMutualFriendsMetrics,
|
||||
sessionLoadTraceMap,
|
||||
sessionMessageCounts,
|
||||
sessionRowByUsername,
|
||||
isLoading,
|
||||
isSessionEnriching,
|
||||
showSessionDetailPanel,
|
||||
shouldShowMutualFriendsColumn,
|
||||
shouldShowSnsColumn,
|
||||
snsUserPostCounts,
|
||||
snsUserPostCountsStatus,
|
||||
toggleSelectSession
|
||||
])
|
||||
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
||||
@@ -3943,63 +5869,101 @@ function ExportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => {
|
||||
if (patch.format) {
|
||||
setExportDefaultFormat(patch.format as TextExportFormat)
|
||||
}
|
||||
if (typeof patch.avatars === 'boolean') {
|
||||
setExportDefaultAvatars(patch.avatars)
|
||||
setOptions(prev => ({ ...prev, exportAvatars: patch.avatars! }))
|
||||
}
|
||||
if (patch.dateRange) {
|
||||
setExportDefaultDateRangeSelection(patch.dateRange)
|
||||
}
|
||||
if (patch.media) {
|
||||
const mediaPatch = patch.media
|
||||
setExportDefaultMedia(mediaPatch)
|
||||
setOptions(prev => ({
|
||||
...prev,
|
||||
exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis),
|
||||
exportImages: mediaPatch.images,
|
||||
exportVoices: mediaPatch.voices,
|
||||
exportVideos: mediaPatch.videos,
|
||||
exportEmojis: mediaPatch.emojis
|
||||
}))
|
||||
}
|
||||
if (typeof patch.voiceAsText === 'boolean') {
|
||||
setExportDefaultVoiceAsText(patch.voiceAsText)
|
||||
}
|
||||
if (typeof patch.excelCompactColumns === 'boolean') {
|
||||
setExportDefaultExcelCompactColumns(patch.excelCompactColumns)
|
||||
}
|
||||
if (typeof patch.concurrency === 'number') {
|
||||
setExportDefaultConcurrency(patch.concurrency)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="export-board-page">
|
||||
<div className="export-top-panel">
|
||||
<div className="global-export-controls">
|
||||
<div className="path-control">
|
||||
<span className="control-label">导出位置</span>
|
||||
<div className="path-inline-row">
|
||||
<div className="path-value">
|
||||
<button
|
||||
className="path-link"
|
||||
type="button"
|
||||
title={exportFolder}
|
||||
onClick={() => void chooseExportFolder()}
|
||||
>
|
||||
{exportFolder || '未设置'}
|
||||
</button>
|
||||
<button className="path-change-btn" type="button" onClick={() => void chooseExportFolder()}>
|
||||
更换
|
||||
<div className="export-top-bar">
|
||||
<div className="global-export-controls">
|
||||
<div className="path-control">
|
||||
<span className="control-label">导出位置</span>
|
||||
<div className="path-inline-row">
|
||||
<div className="path-value">
|
||||
<button
|
||||
className="path-link"
|
||||
type="button"
|
||||
title={exportFolder}
|
||||
onClick={() => void chooseExportFolder()}
|
||||
>
|
||||
{exportFolder || '未设置'}
|
||||
</button>
|
||||
<button className="path-change-btn" type="button" onClick={() => void chooseExportFolder()}>
|
||||
更换
|
||||
</button>
|
||||
</div>
|
||||
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
|
||||
<ExternalLink size={14} /> 打开
|
||||
</button>
|
||||
</div>
|
||||
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
|
||||
<ExternalLink size={14} /> 打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WriteLayoutSelector
|
||||
writeLayout={writeLayout}
|
||||
onChange={async (value) => {
|
||||
setWriteLayout(value)
|
||||
await configService.setExportWriteLayout(value)
|
||||
}}
|
||||
sessionNameWithTypePrefix={sessionNameWithTypePrefix}
|
||||
onSessionNameWithTypePrefixChange={async (enabled) => {
|
||||
setSessionNameWithTypePrefix(enabled)
|
||||
await configService.setExportSessionNamePrefixEnabled(enabled)
|
||||
}}
|
||||
/>
|
||||
<WriteLayoutSelector
|
||||
writeLayout={writeLayout}
|
||||
onChange={async (value) => {
|
||||
setWriteLayout(value)
|
||||
await configService.setExportWriteLayout(value)
|
||||
}}
|
||||
sessionNameWithTypePrefix={sessionNameWithTypePrefix}
|
||||
onSessionNameWithTypePrefixChange={async (enabled) => {
|
||||
setSessionNameWithTypePrefix(enabled)
|
||||
await configService.setExportSessionNamePrefixEnabled(enabled)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="task-center-control">
|
||||
<span className="control-label">任务中心</span>
|
||||
<div className="task-center-inline">
|
||||
<div className="task-summary">
|
||||
<span>进行中 {taskRunningCount}</span>
|
||||
<span>排队 {taskQueuedCount}</span>
|
||||
<span>总计 {tasks.length}</span>
|
||||
</div>
|
||||
<div className="more-export-settings-control">
|
||||
<button
|
||||
className={`task-open-btn ${taskRunningCount > 0 ? 'active-running' : ''}`}
|
||||
className="more-export-settings-btn"
|
||||
type="button"
|
||||
onClick={() => setIsTaskCenterOpen(true)}
|
||||
onClick={() => setIsExportDefaultsModalOpen(true)}
|
||||
>
|
||||
任务卡片
|
||||
{taskRunningCount > 0 && <span className="task-running-badge">{taskRunningCount}</span>}
|
||||
更多导出设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`task-center-card ${taskCenterAlertCount > 0 ? 'has-alert' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setIsTaskCenterOpen(true)}
|
||||
>
|
||||
<span className="task-center-card-label">任务中心</span>
|
||||
{taskCenterAlertCount > 0 && (
|
||||
<span className="task-center-card-badge">{taskCenterAlertCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4014,6 +5978,47 @@ function ExportPage() {
|
||||
onTogglePerfTask={toggleTaskPerfDetail}
|
||||
/>
|
||||
|
||||
{isExportDefaultsModalOpen && (
|
||||
<div
|
||||
className="export-defaults-modal-overlay"
|
||||
onClick={() => setIsExportDefaultsModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="export-defaults-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="更多导出设置"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="export-defaults-modal-header">
|
||||
<div>
|
||||
<h3>更多导出设置</h3>
|
||||
</div>
|
||||
<button
|
||||
className="close-icon-btn"
|
||||
type="button"
|
||||
onClick={() => setIsExportDefaultsModalOpen(false)}
|
||||
aria-label="关闭更多导出设置"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="export-defaults-modal-body">
|
||||
<ExportDefaultsSettingsForm layout="split" onDefaultsChanged={handleExportDefaultsChanged} />
|
||||
</div>
|
||||
<div className="export-defaults-modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-btn"
|
||||
onClick={() => setIsExportDefaultsModalOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="export-section-title-row">
|
||||
<h3 className="export-section-title">按类型批量导出</h3>
|
||||
<SectionInfoTooltip
|
||||
@@ -4033,6 +6038,7 @@ function ExportPage() {
|
||||
? isSnsCardStatsLoading
|
||||
: false
|
||||
const isCardRunning = runningCardTypes.has(card.type)
|
||||
const isPrimaryCard = card.type === 'text'
|
||||
return (
|
||||
<div key={card.type} className="content-card">
|
||||
<div className="card-header">
|
||||
@@ -4062,7 +6068,7 @@ function ExportPage() {
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className={`card-export-btn ${isCardRunning ? 'running' : ''}`}
|
||||
className={`card-export-btn ${isPrimaryCard ? 'primary' : 'secondary'} ${isCardRunning ? 'running' : ''}`}
|
||||
disabled={isCardRunning}
|
||||
onClick={() => {
|
||||
if (card.type === 'sns') {
|
||||
@@ -4094,6 +6100,18 @@ function ExportPage() {
|
||||
'你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。'
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
className={`session-load-detail-entry ${isSessionLoadDetailActive ? 'active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setShowSessionLoadDetailModal(true)}
|
||||
>
|
||||
<span className="session-load-detail-entry-icon" aria-hidden="true">
|
||||
<span className="session-load-detail-entry-bar" />
|
||||
<span className="session-load-detail-entry-bar" />
|
||||
<span className="session-load-detail-entry-bar" />
|
||||
</span>
|
||||
<span>数据加载详情</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="session-table-section" ref={sessionTableSectionRef}>
|
||||
<div className="session-table-layout">
|
||||
@@ -4107,9 +6125,6 @@ function ExportPage() {
|
||||
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
|
||||
群聊 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}
|
||||
</button>
|
||||
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>
|
||||
公众号 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.official}
|
||||
</button>
|
||||
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
|
||||
曾经的好友 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}
|
||||
</button>
|
||||
@@ -4157,9 +6172,19 @@ function ExportPage() {
|
||||
</button>
|
||||
</span>
|
||||
<span className="contacts-list-header-main">
|
||||
<span className="contacts-list-header-main-label">联系人(头像/名称/微信号)</span>
|
||||
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
|
||||
</span>
|
||||
<span className="contacts-list-header-count">总消息数</span>
|
||||
<span className="contacts-list-header-media">表情包</span>
|
||||
<span className="contacts-list-header-media">语音</span>
|
||||
<span className="contacts-list-header-media">图片</span>
|
||||
<span className="contacts-list-header-media">视频</span>
|
||||
{shouldShowSnsColumn && (
|
||||
<span className="contacts-list-header-media">朋友圈</span>
|
||||
)}
|
||||
{shouldShowMutualFriendsColumn && (
|
||||
<span className="contacts-list-header-media">共同好友</span>
|
||||
)}
|
||||
<span className="contacts-list-header-actions">
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
@@ -4237,6 +6262,7 @@ function ExportPage() {
|
||||
data={filteredContacts}
|
||||
computeItemKey={(_, contact) => contact.username}
|
||||
itemContent={renderContactRow}
|
||||
rangeChanged={handleContactsRangeChanged}
|
||||
atTopStateChange={setIsContactsListAtTop}
|
||||
overscan={420}
|
||||
/>
|
||||
@@ -4244,6 +6270,268 @@ function ExportPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSessionLoadDetailModal && (
|
||||
<div
|
||||
className="session-load-detail-overlay"
|
||||
onClick={() => setShowSessionLoadDetailModal(false)}
|
||||
>
|
||||
<div
|
||||
className="session-load-detail-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="数据加载详情"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="session-load-detail-header">
|
||||
<div>
|
||||
<h4>数据加载详情</h4>
|
||||
<p>
|
||||
更新时间:
|
||||
{sessionLoadDetailUpdatedAt > 0
|
||||
? new Date(sessionLoadDetailUpdatedAt).toLocaleString('zh-CN')
|
||||
: '暂无'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="session-load-detail-close"
|
||||
type="button"
|
||||
onClick={() => setShowSessionLoadDetailModal(false)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="session-load-detail-body">
|
||||
<section className="session-load-detail-block">
|
||||
<h5>总消息数</h5>
|
||||
<div className="session-load-detail-table">
|
||||
<div className="session-load-detail-row header">
|
||||
<span>会话类型</span>
|
||||
<span>加载状态</span>
|
||||
<span>开始时间</span>
|
||||
<span>完成时间</span>
|
||||
</div>
|
||||
{sessionLoadDetailRows.map((row) => {
|
||||
const pulse = sessionLoadProgressPulseMap[`messageCount:${row.tab}`]
|
||||
const isLoading = row.messageCount.statusLabel.startsWith('加载中')
|
||||
return (
|
||||
<div className="session-load-detail-row" key={`message-${row.tab}`}>
|
||||
<span>{row.label}</span>
|
||||
<span className="session-load-detail-status-cell">
|
||||
<span>{row.messageCount.statusLabel}</span>
|
||||
{isLoading && (
|
||||
<Loader2 size={12} className="spin session-load-detail-status-icon" aria-label="加载中" />
|
||||
)}
|
||||
{isLoading && pulse && pulse.delta > 0 && (
|
||||
<span className="session-load-detail-progress-pulse">
|
||||
{formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatLoadDetailTime(row.messageCount.startedAt)}</span>
|
||||
<span>{formatLoadDetailTime(row.messageCount.finishedAt)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="session-load-detail-block">
|
||||
<h5>多媒体统计(表情包/图片/视频/语音)</h5>
|
||||
<div className="session-load-detail-table">
|
||||
<div className="session-load-detail-row header">
|
||||
<span>会话类型</span>
|
||||
<span>加载状态</span>
|
||||
<span>开始时间</span>
|
||||
<span>完成时间</span>
|
||||
</div>
|
||||
{sessionLoadDetailRows.map((row) => {
|
||||
const pulse = sessionLoadProgressPulseMap[`mediaMetrics:${row.tab}`]
|
||||
const isLoading = row.mediaMetrics.statusLabel.startsWith('加载中')
|
||||
return (
|
||||
<div className="session-load-detail-row" key={`media-${row.tab}`}>
|
||||
<span>{row.label}</span>
|
||||
<span className="session-load-detail-status-cell">
|
||||
<span>{row.mediaMetrics.statusLabel}</span>
|
||||
{isLoading && (
|
||||
<Loader2 size={12} className="spin session-load-detail-status-icon" aria-label="加载中" />
|
||||
)}
|
||||
{isLoading && pulse && pulse.delta > 0 && (
|
||||
<span className="session-load-detail-progress-pulse">
|
||||
{formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatLoadDetailTime(row.mediaMetrics.startedAt)}</span>
|
||||
<span>{formatLoadDetailTime(row.mediaMetrics.finishedAt)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="session-load-detail-block">
|
||||
<h5>朋友圈条数统计</h5>
|
||||
<div className="session-load-detail-table">
|
||||
<div className="session-load-detail-row header">
|
||||
<span>会话类型</span>
|
||||
<span>加载状态</span>
|
||||
<span>开始时间</span>
|
||||
<span>完成时间</span>
|
||||
</div>
|
||||
{sessionLoadDetailRows
|
||||
.filter((row) => row.tab === 'private' || row.tab === 'former_friend')
|
||||
.map((row) => {
|
||||
const pulse = sessionLoadProgressPulseMap[`snsPostCounts:${row.tab}`]
|
||||
const isLoading = row.snsPostCounts.statusLabel.startsWith('加载中')
|
||||
return (
|
||||
<div className="session-load-detail-row" key={`sns-count-${row.tab}`}>
|
||||
<span>{row.label}</span>
|
||||
<span className="session-load-detail-status-cell">
|
||||
<span>{row.snsPostCounts.statusLabel}</span>
|
||||
{isLoading && (
|
||||
<Loader2 size={12} className="spin session-load-detail-status-icon" aria-label="加载中" />
|
||||
)}
|
||||
{isLoading && pulse && pulse.delta > 0 && (
|
||||
<span className="session-load-detail-progress-pulse">
|
||||
{formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatLoadDetailTime(row.snsPostCounts.startedAt)}</span>
|
||||
<span>{formatLoadDetailTime(row.snsPostCounts.finishedAt)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="session-load-detail-block">
|
||||
<h5>共同好友统计</h5>
|
||||
<div className="session-load-detail-table">
|
||||
<div className="session-load-detail-row header">
|
||||
<span>会话类型</span>
|
||||
<span>加载状态</span>
|
||||
<span>开始时间</span>
|
||||
<span>完成时间</span>
|
||||
</div>
|
||||
{sessionLoadDetailRows
|
||||
.filter((row) => row.tab === 'private' || row.tab === 'former_friend')
|
||||
.map((row) => {
|
||||
const pulse = sessionLoadProgressPulseMap[`mutualFriends:${row.tab}`]
|
||||
const isLoading = row.mutualFriends.statusLabel.startsWith('加载中')
|
||||
return (
|
||||
<div className="session-load-detail-row" key={`mutual-friends-${row.tab}`}>
|
||||
<span>{row.label}</span>
|
||||
<span className="session-load-detail-status-cell">
|
||||
<span>{row.mutualFriends.statusLabel}</span>
|
||||
{isLoading && (
|
||||
<Loader2 size={12} className="spin session-load-detail-status-icon" aria-label="加载中" />
|
||||
)}
|
||||
{isLoading && pulse && pulse.delta > 0 && (
|
||||
<span className="session-load-detail-progress-pulse">
|
||||
{formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}个
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatLoadDetailTime(row.mutualFriends.startedAt)}</span>
|
||||
<span>{formatLoadDetailTime(row.mutualFriends.finishedAt)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && (
|
||||
<div
|
||||
className="session-mutual-friends-overlay"
|
||||
onClick={closeSessionMutualFriendsDialog}
|
||||
>
|
||||
<div
|
||||
className="session-mutual-friends-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="共同好友"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="session-mutual-friends-header">
|
||||
<div className="session-mutual-friends-header-main">
|
||||
<div className="session-mutual-friends-avatar">
|
||||
{sessionMutualFriendsDialogTarget.avatarUrl ? (
|
||||
<img src={sessionMutualFriendsDialogTarget.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{getAvatarLetter(sessionMutualFriendsDialogTarget.displayName)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-mutual-friends-meta">
|
||||
<h4>{sessionMutualFriendsDialogTarget.displayName} 的共同好友</h4>
|
||||
<div className="session-mutual-friends-stats">
|
||||
共 {sessionMutualFriendsDialogMetric.count.toLocaleString('zh-CN')} 人
|
||||
{sessionMutualFriendsDialogMetric.totalPosts !== null
|
||||
? ` · 已统计 ${sessionMutualFriendsDialogMetric.loadedPosts.toLocaleString('zh-CN')} / ${sessionMutualFriendsDialogMetric.totalPosts.toLocaleString('zh-CN')} 条朋友圈`
|
||||
: ` · 已统计 ${sessionMutualFriendsDialogMetric.loadedPosts.toLocaleString('zh-CN')} 条朋友圈`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="session-mutual-friends-close"
|
||||
type="button"
|
||||
onClick={closeSessionMutualFriendsDialog}
|
||||
aria-label="关闭共同好友弹窗"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="session-mutual-friends-tip">
|
||||
打开桌面端微信,进入到这个人的朋友圈中,刷ta 的朋友圈,刷的越多这里的数据聚合越多
|
||||
</div>
|
||||
|
||||
<div className="session-mutual-friends-toolbar">
|
||||
<input
|
||||
value={sessionMutualFriendsSearch}
|
||||
onChange={(event) => setSessionMutualFriendsSearch(event.target.value)}
|
||||
placeholder="搜索共同好友"
|
||||
aria-label="搜索共同好友"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="session-mutual-friends-body">
|
||||
{filteredSessionMutualFriendsDialogItems.length === 0 ? (
|
||||
<div className="session-mutual-friends-empty">
|
||||
{sessionMutualFriendsSearch.trim() ? '没有匹配的共同好友' : '暂无共同好友数据'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-mutual-friends-list">
|
||||
{filteredSessionMutualFriendsDialogItems.map((item, index) => (
|
||||
<div className="session-mutual-friends-row" key={`${sessionMutualFriendsDialogTarget.username}-${item.name}`}>
|
||||
<span className="session-mutual-friends-rank">{index + 1}</span>
|
||||
<span className="session-mutual-friends-name" title={item.name}>{item.name}</span>
|
||||
<span className={`session-mutual-friends-source ${item.direction}`}>
|
||||
{getSessionMutualFriendDirectionLabel(item.direction)}
|
||||
</span>
|
||||
<span className="session-mutual-friends-count">{item.totalCount.toLocaleString('zh-CN')}</span>
|
||||
<span className="session-mutual-friends-latest">{formatYmdDateFromSeconds(item.latestTime)}</span>
|
||||
<span
|
||||
className="session-mutual-friends-desc"
|
||||
title={describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)}
|
||||
>
|
||||
{describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSessionDetailPanel && (
|
||||
<div
|
||||
className="export-session-detail-overlay"
|
||||
@@ -4317,6 +6605,21 @@ function ExportPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{sessionDetailSupportsSnsTimeline && (
|
||||
<div className="detail-item">
|
||||
<Aperture size={14} />
|
||||
<span className="label">朋友圈</span>
|
||||
<span className="value">
|
||||
<button
|
||||
className="detail-inline-btn detail-sns-entry-btn"
|
||||
type="button"
|
||||
onClick={openSessionSnsTimeline}
|
||||
>
|
||||
{sessionDetailSnsCountLabel}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
@@ -4543,6 +6846,13 @@ function ExportPage() {
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ContactSnsTimelineDialog
|
||||
target={sessionSnsTimelineTarget}
|
||||
onClose={closeSessionSnsTimeline}
|
||||
initialTotalPosts={sessionSnsTimelineInitialTotalPosts}
|
||||
initialTotalPostsLoading={sessionSnsTimelineInitialTotalPostsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4550,51 +6860,97 @@ function ExportPage() {
|
||||
<div className="export-dialog-overlay" onClick={closeExportDialog}>
|
||||
<div className="export-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>{exportDialog.title}</h3>
|
||||
<div className="dialog-header-copy">
|
||||
<h3>{exportDialog.title}</h3>
|
||||
{isContentTextDialog && (
|
||||
<div className="dialog-header-note">{contentTextDialogSummary}</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="close-icon-btn" onClick={closeExportDialog}><X size={16} /></button>
|
||||
</div>
|
||||
|
||||
<div className="dialog-body">
|
||||
<div className="dialog-section">
|
||||
<h4>导出范围</h4>
|
||||
<div className="scope-tag-row">
|
||||
<span className="scope-tag">{scopeLabel}</span>
|
||||
<span className="scope-count">{scopeCountLabel}</span>
|
||||
{exportDialog.scope !== 'single' && (
|
||||
<div className="dialog-section">
|
||||
<h4>导出范围</h4>
|
||||
<div className="scope-tag-row">
|
||||
<span className="scope-tag">{scopeLabel}</span>
|
||||
<span className="scope-count">{scopeCountLabel}</span>
|
||||
</div>
|
||||
<div className="scope-list">
|
||||
{exportDialog.sessionNames.slice(0, 20).map(name => (
|
||||
<span key={name} className="scope-item">{name}</span>
|
||||
))}
|
||||
{exportDialog.sessionNames.length > 20 && <span className="scope-item">... 还有 {exportDialog.sessionNames.length - 20} 个</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scope-list">
|
||||
{exportDialog.sessionNames.slice(0, 20).map(name => (
|
||||
<span key={name} className="scope-item">{name}</span>
|
||||
))}
|
||||
{exportDialog.sessionNames.length > 20 && <span className="scope-item">... 还有 {exportDialog.sessionNames.length - 20} 个</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowFormatSection && (
|
||||
<div className="dialog-section">
|
||||
<h4>{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}</h4>
|
||||
{useCollapsedSessionFormatSelector ? (
|
||||
<div className="section-header-action">
|
||||
<h4>对话文本导出格式选择</h4>
|
||||
<div className="dialog-format-select" ref={sessionFormatDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`time-range-trigger dialog-format-trigger ${showSessionFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => setShowSessionFormatSelect(prev => !prev)}
|
||||
>
|
||||
<span className="dialog-format-trigger-label">{activeDialogFormatLabel}</span>
|
||||
<span className="time-range-arrow">></span>
|
||||
</button>
|
||||
{showSessionFormatSelect && (
|
||||
<div className="dialog-format-dropdown">
|
||||
{formatOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`dialog-format-option ${options.format === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setOptions(prev => ({ ...prev, format: option.value as TextExportFormat }))
|
||||
setShowSessionFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<h4>{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}</h4>
|
||||
)}
|
||||
{!isContentScopeDialog && exportDialog.scope !== 'sns' && (
|
||||
<div className="format-note">{avatarExportStatusLabel}</div>
|
||||
)}
|
||||
{isContentTextDialog && (
|
||||
<div className="format-note">说明:此模式默认导出头像,不导出图片、语音、视频、表情包等媒体内容。</div>
|
||||
<div className="format-note">{avatarExportStatusLabel}</div>
|
||||
)}
|
||||
{!useCollapsedSessionFormatSelector && (
|
||||
<div className="format-grid">
|
||||
{formatCandidateOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`format-card ${exportDialog.scope === 'sns'
|
||||
? (snsExportFormat === option.value ? 'active' : '')
|
||||
: (options.format === option.value ? 'active' : '')}`}
|
||||
onClick={() => {
|
||||
if (exportDialog.scope === 'sns') {
|
||||
setSnsExportFormat(option.value as SnsTimelineExportFormat)
|
||||
} else {
|
||||
setOptions(prev => ({ ...prev, format: option.value as TextExportFormat }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="format-label">{option.label}</div>
|
||||
<div className="format-desc">{option.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="format-grid">
|
||||
{formatCandidateOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`format-card ${exportDialog.scope === 'sns'
|
||||
? (snsExportFormat === option.value ? 'active' : '')
|
||||
: (options.format === option.value ? 'active' : '')}`}
|
||||
onClick={() => {
|
||||
if (exportDialog.scope === 'sns') {
|
||||
setSnsExportFormat(option.value as SnsTimelineExportFormat)
|
||||
} else {
|
||||
setOptions(prev => ({ ...prev, format: option.value as TextExportFormat }))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="format-label">{option.label}</div>
|
||||
<div className="format-desc">{option.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4614,7 +6970,7 @@ function ExportPage() {
|
||||
|
||||
{shouldShowMediaSection && (
|
||||
<div className="dialog-section">
|
||||
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体与头像'}</h4>
|
||||
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4>
|
||||
<div className="media-check-grid">
|
||||
{exportDialog.scope === 'sns' ? (
|
||||
<>
|
||||
@@ -4628,8 +6984,6 @@ function ExportPage() {
|
||||
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> 语音</label>
|
||||
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> 视频</label>
|
||||
<label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> 表情包</label>
|
||||
<label><input type="checkbox" checked={options.exportVoiceAsText} onChange={event => setOptions(prev => ({ ...prev, exportVoiceAsText: event.target.checked }))} /> 语音转文字</label>
|
||||
<label><input type="checkbox" checked={options.exportAvatars} onChange={event => setOptions(prev => ({ ...prev, exportAvatars: event.target.checked }))} /> 导出头像</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -4639,6 +6993,26 @@ function ExportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSessionScopeDialog && (
|
||||
<div className="dialog-section">
|
||||
<div className="dialog-switch-row">
|
||||
<div className="dialog-switch-copy">
|
||||
<h4>语音转文字</h4>
|
||||
<div className="format-note">默认状态跟随更多导出设置中的语音转文字开关。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`dialog-switch ${options.exportVoiceAsText ? 'on' : ''}`}
|
||||
aria-pressed={options.exportVoiceAsText}
|
||||
aria-label="切换语音转文字"
|
||||
onClick={() => setOptions(prev => ({ ...prev, exportVoiceAsText: !prev.exportVoiceAsText }))}
|
||||
>
|
||||
<span className="dialog-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowDisplayNameSection && (
|
||||
<div className="dialog-section">
|
||||
<h4>发送者名称显示</h4>
|
||||
@@ -4671,173 +7045,20 @@ function ExportPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isTimeRangeDialogOpen && (
|
||||
<div className="time-range-dialog-overlay" onClick={closeTimeRangeDialog}>
|
||||
<div className="time-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="time-range-dialog-header">
|
||||
<h4>时间范围设置</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="close-icon-btn"
|
||||
onClick={closeTimeRangeDialog}
|
||||
aria-label="关闭时间范围设置"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="time-range-preset-list">
|
||||
{([
|
||||
{ value: 'all', label: '全部时间' },
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: 'yesterday', label: '昨天' },
|
||||
{ value: 'last3days', label: '最近3天' },
|
||||
{ value: 'last7days', label: '最近一周' },
|
||||
{ value: 'last30days', label: '最近30 天' },
|
||||
{ value: 'last1year', label: '最近一年' }
|
||||
] as Array<{ value: Exclude<DateRangePreset, 'custom'>; label: string }>).map((preset) => {
|
||||
const isActive = isTimeRangePresetActive(preset.value)
|
||||
return (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
className={`time-range-preset-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleTimeRangePresetClick(preset.value)}
|
||||
>
|
||||
<span>{preset.label}</span>
|
||||
{isActive && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={`time-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
|
||||
{timeRangeModeText}
|
||||
</div>
|
||||
|
||||
<div className="time-range-calendar-grid">
|
||||
<section className="time-range-calendar-panel">
|
||||
<div className="time-range-calendar-panel-header">
|
||||
<div className="time-range-calendar-date-label">
|
||||
<span>起始日期</span>
|
||||
<input
|
||||
type="text"
|
||||
className={`time-range-date-input ${timeRangeDateInputError.start ? 'invalid' : ''}`}
|
||||
value={timeRangeDateInput.start}
|
||||
placeholder="YYYY-MM-DD"
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setTimeRangeDateInput(prev => ({ ...prev, start: nextValue }))
|
||||
if (timeRangeDateInputError.start) {
|
||||
setTimeRangeDateInputError(prev => ({ ...prev, start: false }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
commitTimeRangeStartFromInput()
|
||||
}}
|
||||
onBlur={() => {
|
||||
commitTimeRangeStartFromInput()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="time-range-calendar-nav">
|
||||
<button type="button" onClick={() => shiftTimeRangePanelMonth('start', -1)} aria-label="上个月">‹</button>
|
||||
<span>{formatCalendarMonthTitle(activeTimeRangeDialogDraft.startPanelMonth)}</span>
|
||||
<button type="button" onClick={() => shiftTimeRangePanelMonth('start', 1)} aria-label="下个月">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time-range-calendar-weekdays">
|
||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||
<span key={`start-weekday-${label}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="time-range-calendar-days">
|
||||
{startPanelCells.map((cell) => {
|
||||
const isSelected = !activeTimeRangeDialogDraft.useAllTime &&
|
||||
isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.start)
|
||||
return (
|
||||
<button
|
||||
key={`start-${cell.date.getTime()}`}
|
||||
type="button"
|
||||
className={`time-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => updateTimeRangeDraftStart(cell.date)}
|
||||
>
|
||||
{cell.date.getDate()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="time-range-calendar-panel">
|
||||
<div className="time-range-calendar-panel-header">
|
||||
<div className="time-range-calendar-date-label">
|
||||
<span>截止日期</span>
|
||||
<input
|
||||
type="text"
|
||||
className={`time-range-date-input ${timeRangeDateInputError.end ? 'invalid' : ''}`}
|
||||
value={timeRangeDateInput.end}
|
||||
placeholder="YYYY-MM-DD"
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setTimeRangeDateInput(prev => ({ ...prev, end: nextValue }))
|
||||
if (timeRangeDateInputError.end) {
|
||||
setTimeRangeDateInputError(prev => ({ ...prev, end: false }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
commitTimeRangeEndFromInput()
|
||||
}}
|
||||
onBlur={() => {
|
||||
commitTimeRangeEndFromInput()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="time-range-calendar-nav">
|
||||
<button type="button" onClick={() => shiftTimeRangePanelMonth('end', -1)} aria-label="上个月">‹</button>
|
||||
<span>{formatCalendarMonthTitle(activeTimeRangeDialogDraft.endPanelMonth)}</span>
|
||||
<button type="button" onClick={() => shiftTimeRangePanelMonth('end', 1)} aria-label="下个月">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time-range-calendar-weekdays">
|
||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||
<span key={`end-weekday-${label}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="time-range-calendar-days">
|
||||
{endPanelCells.map((cell) => {
|
||||
const isSelected = !activeTimeRangeDialogDraft.useAllTime &&
|
||||
isSameDay(cell.date, activeTimeRangeDialogDraft.dateRange.end)
|
||||
return (
|
||||
<button
|
||||
key={`end-${cell.date.getTime()}`}
|
||||
type="button"
|
||||
className={`time-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => updateTimeRangeDraftEnd(cell.date)}
|
||||
>
|
||||
{cell.date.getDate()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="time-range-dialog-actions">
|
||||
<button type="button" className="secondary-btn" onClick={closeTimeRangeDialog}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="primary-btn" onClick={commitTimeRangeDialogDraft}>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ExportDateRangeDialog
|
||||
open={isTimeRangeDialogOpen}
|
||||
value={timeRangeSelection}
|
||||
onClose={closeTimeRangeDialog}
|
||||
onConfirm={(nextSelection) => {
|
||||
setTimeRangeSelection(nextSelection)
|
||||
setOptions(prev => ({
|
||||
...prev,
|
||||
useAllTime: nextSelection.useAllTime,
|
||||
dateRange: cloneExportDateRange(nextSelection.dateRange)
|
||||
}))
|
||||
closeTimeRangeDialog()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -348,6 +348,51 @@
|
||||
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 {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
@@ -2239,4 +2284,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-302
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
@@ -8,20 +9,19 @@ import * as configService from '../services/config'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
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
|
||||
} from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
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 }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'notification', label: '通知', icon: Bell },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||
|
||||
@@ -37,6 +37,7 @@ interface WxidOption {
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const location = useLocation()
|
||||
const {
|
||||
isDbConnected,
|
||||
setDbConnected,
|
||||
@@ -73,14 +74,6 @@ function SettingsPage() {
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
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 [imageKeyProgress, setImageKeyProgress] = useState(0)
|
||||
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||
@@ -103,12 +96,6 @@ function SettingsPage() {
|
||||
|
||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||
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 [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
|
||||
@@ -202,26 +189,11 @@ function SettingsPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
||||
setShowExportFormatSelect(false)
|
||||
}
|
||||
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
|
||||
setShowExportDateRangeSelect(false)
|
||||
}
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
|
||||
setShowExportConcurrencySelect(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
|
||||
const initialTab = (location.state as { initialTab?: SettingsTab } | null)?.initialTab
|
||||
if (!initialTab) return
|
||||
setActiveTab(initialTab)
|
||||
}, [location.state])
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||
@@ -289,13 +261,6 @@ function SettingsPage() {
|
||||
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
||||
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||
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 savedNotificationPosition = await configService.getNotificationPosition()
|
||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||
@@ -330,12 +295,6 @@ function SettingsPage() {
|
||||
setLogEnabled(savedLogEnabled)
|
||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||
setTranscribeLanguages(savedTranscribeLanguages)
|
||||
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
||||
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
||||
|
||||
setNotificationEnabled(savedNotificationEnabled)
|
||||
setNotificationPosition(savedNotificationPosition)
|
||||
@@ -1547,258 +1506,6 @@ function SettingsPage() {
|
||||
</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 = () => (
|
||||
<div className="tab-content">
|
||||
<p className="section-desc">管理应用缓存数据</p>
|
||||
@@ -2395,7 +2102,6 @@ function SettingsPage() {
|
||||
{activeTab === 'notification' && renderNotificationTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'api' && renderApiTab()}
|
||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||
|
||||
+387
-80
@@ -11,7 +11,8 @@
|
||||
|
||||
.sns-page-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
height: calc(100% + 48px);
|
||||
margin: -24px;
|
||||
overflow: hidden;
|
||||
background: var(--sns-bg-color);
|
||||
position: relative;
|
||||
@@ -32,7 +33,7 @@
|
||||
.sns-feed-container {
|
||||
width: 100%;
|
||||
max-width: var(--sns-max-width);
|
||||
padding: 20px 24px 60px 24px;
|
||||
padding: 10px 24px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -44,13 +45,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 4px;
|
||||
z-index: 2;
|
||||
background: var(--sns-bg-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
.feed-header-main {
|
||||
display: flex;
|
||||
@@ -67,6 +68,10 @@
|
||||
}
|
||||
|
||||
.feed-stats-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
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 {
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -98,6 +173,18 @@
|
||||
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 {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -123,6 +210,50 @@
|
||||
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 {
|
||||
@@ -179,6 +310,30 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -206,6 +361,30 @@
|
||||
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 {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
@@ -219,6 +398,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-time-standalone {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.2;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.debug-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
@@ -909,9 +1095,21 @@
|
||||
padding: 2px 6px;
|
||||
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 */
|
||||
.input-group {
|
||||
position: relative;
|
||||
@@ -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 {
|
||||
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 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -1060,9 +1228,8 @@
|
||||
border-radius: var(--sns-border-radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 4px;
|
||||
/* Separation for unselected items */
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
@@ -1070,41 +1237,6 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1120,6 +1252,33 @@
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -1436,6 +1705,44 @@
|
||||
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 {
|
||||
display: grid;
|
||||
|
||||
+752
-211
@@ -1,21 +1,51 @@
|
||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2 } from 'lucide-react'
|
||||
import './SnsPage.scss'
|
||||
import { SnsPost } from '../types/sns'
|
||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
|
||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||
import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimeline'
|
||||
import JumpToDatePopover from '../components/JumpToDatePopover'
|
||||
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
||||
import * as configService from '../services/config'
|
||||
import {
|
||||
createExportDateRangeSelectionFromPreset,
|
||||
getExportDateRangeLabel,
|
||||
type ExportDateRangeSelection
|
||||
} from '../utils/exportDateRange'
|
||||
|
||||
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const SNS_PAGE_CACHE_POST_LIMIT = 200
|
||||
const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__'
|
||||
const CONTACT_COUNT_SORT_DEBOUNCE_MS = 200
|
||||
const CONTACT_COUNT_BATCH_SIZE = 10
|
||||
|
||||
type ContactPostCountStatus = 'idle' | 'loading' | 'ready'
|
||||
|
||||
interface Contact {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
type?: 'friend' | 'former_friend' | 'sns_only'
|
||||
lastSessionTimestamp?: number
|
||||
postCount?: number
|
||||
postCountStatus?: ContactPostCountStatus
|
||||
}
|
||||
|
||||
interface SidebarUserProfile {
|
||||
wxid: string
|
||||
displayName: string
|
||||
alias?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface ContactsCountProgress {
|
||||
resolved: number
|
||||
total: number
|
||||
running: boolean
|
||||
}
|
||||
|
||||
interface SnsOverviewStats {
|
||||
@@ -28,6 +58,38 @@ interface SnsOverviewStats {
|
||||
|
||||
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
|
||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as SidebarUserProfile
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
return {
|
||||
wxid: String(parsed.wxid || '').trim(),
|
||||
displayName: String(parsed.displayName || '').trim(),
|
||||
alias: parsed.alias ? String(parsed.alias).trim() : undefined,
|
||||
avatarUrl: parsed.avatarUrl ? String(parsed.avatarUrl).trim() : undefined
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeAccountId = (value?: string | null): string => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return (match?.[1] || trimmed).toLowerCase()
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return (suffixMatch ? suffixMatch[1] : trimmed).toLowerCase()
|
||||
}
|
||||
|
||||
const normalizeNameForCompare = (value?: string | null): string => String(value || '').trim().toLowerCase()
|
||||
|
||||
export default function SnsPage() {
|
||||
const [posts, setPosts] = useState<SnsPost[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -44,17 +106,31 @@ export default function SnsPage() {
|
||||
|
||||
// Filter states
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
|
||||
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
|
||||
|
||||
// Contacts state
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [contactSearch, setContactSearch] = useState('')
|
||||
const [contactsLoading, setContactsLoading] = useState(false)
|
||||
const [contactsCountProgress, setContactsCountProgress] = useState<ContactsCountProgress>({
|
||||
resolved: 0,
|
||||
total: 0,
|
||||
running: false
|
||||
})
|
||||
const [currentUserProfile, setCurrentUserProfile] = useState<SidebarUserProfile>(() => readSidebarUserProfileCache() || {
|
||||
wxid: '',
|
||||
displayName: ''
|
||||
})
|
||||
|
||||
// UI states
|
||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
|
||||
const [authorTimelineTarget, setAuthorTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
|
||||
const [showJumpPopover, setShowJumpPopover] = useState(false)
|
||||
const [jumpPopoverDate, setJumpPopoverDate] = useState<Date>(jumpTargetDate || new Date())
|
||||
const [jumpDateCounts, setJumpDateCounts] = useState<Record<string, number>>({})
|
||||
const [jumpDateMessageDates, setJumpDateMessageDates] = useState<Set<string>>(new Set())
|
||||
const [hasLoadedJumpDateCounts, setHasLoadedJumpDateCounts] = useState(false)
|
||||
const [loadingJumpDateCounts, setLoadingJumpDateCounts] = useState(false)
|
||||
|
||||
// 导出相关状态
|
||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
@@ -63,13 +139,14 @@ export default function SnsPage() {
|
||||
const [exportImages, setExportImages] = useState(false)
|
||||
const [exportLivePhotos, setExportLivePhotos] = useState(false)
|
||||
const [exportVideos, setExportVideos] = useState(false)
|
||||
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
|
||||
const [exportDateRangeSelection, setExportDateRangeSelection] = useState<ExportDateRangeSelection>(
|
||||
() => createExportDateRangeSelectionFromPreset('all')
|
||||
)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
|
||||
const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null)
|
||||
const [refreshSpin, setRefreshSpin] = useState(false)
|
||||
const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null)
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
|
||||
// 触发器相关状态
|
||||
const [showTriggerDialog, setShowTriggerDialog] = useState(false)
|
||||
@@ -78,37 +155,58 @@ export default function SnsPage() {
|
||||
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const postsContainerRef = useRef<HTMLDivElement>(null)
|
||||
const jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const [hasNewer, setHasNewer] = useState(false)
|
||||
const [loadingNewer, setLoadingNewer] = useState(false)
|
||||
const postsRef = useRef<SnsPost[]>([])
|
||||
const contactsRef = useRef<Contact[]>([])
|
||||
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
|
||||
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
|
||||
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
|
||||
const searchKeywordRef = useRef(searchKeyword)
|
||||
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
|
||||
const cacheScopeKeyRef = useRef('')
|
||||
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||
const contactsLoadTokenRef = useRef(0)
|
||||
const contactsCountHydrationTokenRef = useRef(0)
|
||||
const contactsCountBatchTimerRef = useRef<number | null>(null)
|
||||
const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map())
|
||||
const jumpDateRequestSeqRef = useRef(0)
|
||||
|
||||
// Sync posts ref
|
||||
useEffect(() => {
|
||||
postsRef.current = posts
|
||||
}, [posts])
|
||||
useEffect(() => {
|
||||
contactsRef.current = contacts
|
||||
}, [contacts])
|
||||
useEffect(() => {
|
||||
overviewStatsRef.current = overviewStats
|
||||
}, [overviewStats])
|
||||
useEffect(() => {
|
||||
overviewStatsStatusRef.current = overviewStatsStatus
|
||||
}, [overviewStatsStatus])
|
||||
useEffect(() => {
|
||||
selectedUsernamesRef.current = selectedUsernames
|
||||
}, [selectedUsernames])
|
||||
useEffect(() => {
|
||||
searchKeywordRef.current = searchKeyword
|
||||
}, [searchKeyword])
|
||||
useEffect(() => {
|
||||
jumpTargetDateRef.current = jumpTargetDate
|
||||
}, [jumpTargetDate])
|
||||
useEffect(() => {
|
||||
if (!showJumpPopover) {
|
||||
setJumpPopoverDate(jumpTargetDate || new Date())
|
||||
}
|
||||
}, [jumpTargetDate, showJumpPopover])
|
||||
useEffect(() => {
|
||||
if (!showJumpPopover) return
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!jumpCalendarWrapRef.current) return
|
||||
if (jumpCalendarWrapRef.current.contains(event.target as Node)) return
|
||||
setShowJumpPopover(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showJumpPopover])
|
||||
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
|
||||
useLayoutEffect(() => {
|
||||
const snapshot = scrollAdjustmentRef.current;
|
||||
@@ -132,8 +230,170 @@ export default function SnsPage() {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const decodeHtmlEntities = (text: string): string => {
|
||||
if (!text) return ''
|
||||
return text
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.trim()
|
||||
}
|
||||
|
||||
const normalizePostCount = useCallback((value: unknown): number => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return 0
|
||||
return Math.max(0, Math.floor(numeric))
|
||||
}, [])
|
||||
|
||||
const toMonthKey = useCallback((date: Date) => {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}, [])
|
||||
|
||||
const toDateKey = useCallback((timestampSeconds: number) => {
|
||||
const date = new Date(timestampSeconds * 1000)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}, [])
|
||||
|
||||
const applyJumpDateCounts = useCallback((counts: Record<string, number>) => {
|
||||
setJumpDateCounts(counts)
|
||||
setJumpDateMessageDates(new Set(Object.keys(counts)))
|
||||
setHasLoadedJumpDateCounts(true)
|
||||
}, [])
|
||||
|
||||
const loadJumpDateCounts = useCallback(async (monthDate: Date) => {
|
||||
const monthKey = toMonthKey(monthDate)
|
||||
const cached = jumpDateCountsCacheRef.current.get(monthKey)
|
||||
if (cached) {
|
||||
applyJumpDateCounts(cached)
|
||||
setLoadingJumpDateCounts(false)
|
||||
return
|
||||
}
|
||||
|
||||
const requestSeq = ++jumpDateRequestSeqRef.current
|
||||
setLoadingJumpDateCounts(true)
|
||||
setHasLoadedJumpDateCounts(false)
|
||||
|
||||
const year = monthDate.getFullYear()
|
||||
const month = monthDate.getMonth()
|
||||
const monthStart = new Date(year, month, 1, 0, 0, 0, 0)
|
||||
const monthEnd = new Date(year, month + 1, 0, 23, 59, 59, 999)
|
||||
const startTime = Math.floor(monthStart.getTime() / 1000)
|
||||
const endTime = Math.floor(monthEnd.getTime() / 1000)
|
||||
const pageSize = 200
|
||||
let offset = 0
|
||||
const counts: Record<string, number> = {}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const result = await window.electronAPI.sns.getTimeline(pageSize, offset, [], '', startTime, endTime)
|
||||
if (!result?.success || !Array.isArray(result.timeline) || result.timeline.length === 0) {
|
||||
break
|
||||
}
|
||||
result.timeline.forEach((post) => {
|
||||
const key = toDateKey(Number(post.createTime || 0))
|
||||
if (!key) return
|
||||
counts[key] = (counts[key] || 0) + 1
|
||||
})
|
||||
if (result.timeline.length < pageSize) break
|
||||
offset += pageSize
|
||||
}
|
||||
|
||||
if (requestSeq !== jumpDateRequestSeqRef.current) return
|
||||
jumpDateCountsCacheRef.current.set(monthKey, counts)
|
||||
applyJumpDateCounts(counts)
|
||||
} catch (error) {
|
||||
console.error('加载朋友圈按日条数失败:', error)
|
||||
if (requestSeq !== jumpDateRequestSeqRef.current) return
|
||||
setJumpDateCounts({})
|
||||
setJumpDateMessageDates(new Set())
|
||||
setHasLoadedJumpDateCounts(true)
|
||||
} finally {
|
||||
if (requestSeq === jumpDateRequestSeqRef.current) {
|
||||
setLoadingJumpDateCounts(false)
|
||||
}
|
||||
}
|
||||
}, [applyJumpDateCounts, toDateKey, toMonthKey])
|
||||
|
||||
const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => {
|
||||
const aReady = a.postCountStatus === 'ready'
|
||||
const bReady = b.postCountStatus === 'ready'
|
||||
if (aReady && bReady) {
|
||||
const countDiff = normalizePostCount(b.postCount) - normalizePostCount(a.postCount)
|
||||
if (countDiff !== 0) return countDiff
|
||||
} else if (aReady !== bReady) {
|
||||
return aReady ? -1 : 1
|
||||
}
|
||||
|
||||
const tsDiff = Number(b.lastSessionTimestamp || 0) - Number(a.lastSessionTimestamp || 0)
|
||||
if (tsDiff !== 0) return tsDiff
|
||||
return (a.displayName || a.username).localeCompare((b.displayName || b.username), 'zh-Hans-CN')
|
||||
}, [normalizePostCount])
|
||||
|
||||
const sortContactsForRanking = useCallback((input: Contact[]): Contact[] => {
|
||||
return [...input].sort(compareContactsForRanking)
|
||||
}, [compareContactsForRanking])
|
||||
|
||||
const resolvedCurrentUserContact = useMemo(() => {
|
||||
const normalizedWxid = normalizeAccountId(currentUserProfile.wxid)
|
||||
const normalizedAlias = normalizeAccountId(currentUserProfile.alias)
|
||||
const normalizedDisplayName = normalizeNameForCompare(currentUserProfile.displayName)
|
||||
|
||||
if (normalizedWxid) {
|
||||
const exactByUsername = contacts.find((contact) => normalizeAccountId(contact.username) === normalizedWxid)
|
||||
if (exactByUsername) return exactByUsername
|
||||
}
|
||||
|
||||
if (normalizedAlias) {
|
||||
const exactByAliasLikeName = contacts.find((contact) => {
|
||||
const candidates = [contact.displayName, contact.remark, contact.nickname].map(normalizeNameForCompare)
|
||||
return candidates.includes(normalizedAlias)
|
||||
})
|
||||
if (exactByAliasLikeName) return exactByAliasLikeName
|
||||
}
|
||||
|
||||
if (!normalizedDisplayName) return null
|
||||
return contacts.find((contact) => {
|
||||
const candidates = [contact.displayName, contact.remark, contact.nickname].map(normalizeNameForCompare)
|
||||
return candidates.includes(normalizedDisplayName)
|
||||
}) || null
|
||||
}, [contacts, currentUserProfile.alias, currentUserProfile.displayName, currentUserProfile.wxid])
|
||||
|
||||
const currentTimelineTargetContact = useMemo(() => {
|
||||
const normalizedTargetUsername = String(authorTimelineTarget?.username || '').trim()
|
||||
if (!normalizedTargetUsername) return null
|
||||
return contacts.find((contact) => contact.username === normalizedTargetUsername) || null
|
||||
}, [authorTimelineTarget, contacts])
|
||||
|
||||
const myTimelineCount = useMemo(() => {
|
||||
if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') {
|
||||
return normalizePostCount(resolvedCurrentUserContact.postCount)
|
||||
}
|
||||
return null
|
||||
}, [normalizePostCount, resolvedCurrentUserContact])
|
||||
|
||||
const myTimelineCountLoading = Boolean(
|
||||
resolvedCurrentUserContact
|
||||
? resolvedCurrentUserContact.postCountStatus !== 'ready'
|
||||
: overviewStatsStatus === 'loading' || contactsLoading
|
||||
)
|
||||
|
||||
const openCurrentUserTimeline = useCallback(() => {
|
||||
if (!resolvedCurrentUserContact) return
|
||||
setAuthorTimelineTarget({
|
||||
username: resolvedCurrentUserContact.username,
|
||||
displayName: resolvedCurrentUserContact.displayName || currentUserProfile.displayName || resolvedCurrentUserContact.username,
|
||||
avatarUrl: resolvedCurrentUserContact.avatarUrl || currentUserProfile.avatarUrl
|
||||
})
|
||||
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
|
||||
|
||||
const isDefaultViewNow = useCallback(() => {
|
||||
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
|
||||
return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
|
||||
}, [])
|
||||
|
||||
const ensureSnsCacheScopeKey = useCallback(async () => {
|
||||
@@ -144,6 +404,21 @@ export default function SnsPage() {
|
||||
return scopeKey
|
||||
}, [])
|
||||
|
||||
const ensureSnsUserPostCountsCacheScopeKey = useCallback(async () => {
|
||||
if (snsUserPostCountsCacheScopeKeyRef.current) return snsUserPostCountsCacheScopeKeyRef.current
|
||||
const [wxidRaw, dbPathRaw] = await Promise.all([
|
||||
configService.getMyWxid(),
|
||||
configService.getDbPath()
|
||||
])
|
||||
const wxid = String(wxidRaw || '').trim()
|
||||
const dbPath = String(dbPathRaw || '').trim()
|
||||
const scopeKey = (dbPath || wxid)
|
||||
? `${dbPath}::${wxid}`
|
||||
: 'default'
|
||||
snsUserPostCountsCacheScopeKeyRef.current = scopeKey
|
||||
return scopeKey
|
||||
}, [])
|
||||
|
||||
const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => {
|
||||
if (!isDefaultViewNow()) return
|
||||
try {
|
||||
@@ -264,7 +539,7 @@ export default function SnsPage() {
|
||||
}
|
||||
}, [persistSnsPageCache])
|
||||
|
||||
const renderOverviewStats = () => {
|
||||
const renderOverviewRangeText = () => {
|
||||
if (overviewStatsStatus === 'error') {
|
||||
return (
|
||||
<button type="button" className="feed-stats-retry" onClick={() => { void loadOverviewStats() }}>
|
||||
@@ -275,10 +550,11 @@ export default function SnsPage() {
|
||||
if (overviewStatsStatus === 'loading') {
|
||||
return '统计中...'
|
||||
}
|
||||
const myPostsLabel = overviewStats.myPosts === null ? '--' : String(overviewStats.myPosts)
|
||||
return `共 ${overviewStats.totalPosts} 条 | 我的朋友圈 ${myPostsLabel} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友`
|
||||
return `${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)}`
|
||||
}
|
||||
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
|
||||
|
||||
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
||||
const { reset = false, direction = 'older' } = options
|
||||
if (loadingRef.current) return
|
||||
@@ -305,7 +581,7 @@ export default function SnsPage() {
|
||||
const result = await window.electronAPI.sns.getTimeline(
|
||||
limit,
|
||||
0,
|
||||
selectedUsernames,
|
||||
undefined,
|
||||
searchKeyword,
|
||||
topTs + 1,
|
||||
undefined
|
||||
@@ -346,7 +622,7 @@ export default function SnsPage() {
|
||||
const result = await window.electronAPI.sns.getTimeline(
|
||||
limit,
|
||||
0,
|
||||
selectedUsernames,
|
||||
undefined,
|
||||
searchKeyword,
|
||||
startTs, // default undefined
|
||||
endTs
|
||||
@@ -361,7 +637,7 @@ export default function SnsPage() {
|
||||
// Check for newer items above topTs
|
||||
const topTs = result.timeline[0]?.createTime || 0;
|
||||
if (topTs > 0) {
|
||||
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined);
|
||||
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, undefined, searchKeyword, topTs + 1, undefined);
|
||||
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
|
||||
} else {
|
||||
setHasNewer(false);
|
||||
@@ -388,33 +664,241 @@ export default function SnsPage() {
|
||||
setLoadingNewer(false)
|
||||
loadingRef.current = false
|
||||
}
|
||||
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
|
||||
}, [jumpTargetDate, persistSnsPageCache, searchKeyword])
|
||||
|
||||
// Load Contacts(仅加载好友/曾经好友,不再统计朋友圈条数)
|
||||
const stopContactsCountHydration = useCallback((resetProgress = false) => {
|
||||
contactsCountHydrationTokenRef.current += 1
|
||||
if (contactsCountBatchTimerRef.current) {
|
||||
window.clearTimeout(contactsCountBatchTimerRef.current)
|
||||
contactsCountBatchTimerRef.current = null
|
||||
}
|
||||
if (resetProgress) {
|
||||
setContactsCountProgress({
|
||||
resolved: 0,
|
||||
total: 0,
|
||||
running: false
|
||||
})
|
||||
} else {
|
||||
setContactsCountProgress((prev) => ({ ...prev, running: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hydrateContactPostCounts = useCallback(async (
|
||||
usernames: string[],
|
||||
options?: { force?: boolean; readyUsernames?: Set<string> }
|
||||
) => {
|
||||
const force = options?.force === true
|
||||
const targets = usernames
|
||||
.map((username) => String(username || '').trim())
|
||||
.filter(Boolean)
|
||||
stopContactsCountHydration(true)
|
||||
if (targets.length === 0) return
|
||||
|
||||
const readySet = options?.readyUsernames || new Set(
|
||||
contactsRef.current
|
||||
.filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number')
|
||||
.map((contact) => contact.username)
|
||||
)
|
||||
const pendingTargets = force ? targets : targets.filter((username) => !readySet.has(username))
|
||||
const runToken = ++contactsCountHydrationTokenRef.current
|
||||
const totalTargets = targets.length
|
||||
const targetSet = new Set(pendingTargets)
|
||||
|
||||
if (pendingTargets.length > 0) {
|
||||
setContacts((prev) => {
|
||||
let changed = false
|
||||
const next = prev.map((contact) => {
|
||||
if (!targetSet.has(contact.username)) return contact
|
||||
if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact
|
||||
changed = true
|
||||
return {
|
||||
...contact,
|
||||
postCount: force ? undefined : contact.postCount,
|
||||
postCountStatus: 'loading' as ContactPostCountStatus
|
||||
}
|
||||
})
|
||||
return changed ? sortContactsForRanking(next) : prev
|
||||
})
|
||||
}
|
||||
const preResolved = Math.max(0, totalTargets - pendingTargets.length)
|
||||
setContactsCountProgress({
|
||||
resolved: preResolved,
|
||||
total: totalTargets,
|
||||
running: pendingTargets.length > 0
|
||||
})
|
||||
if (pendingTargets.length === 0) return
|
||||
|
||||
let normalizedCounts: Record<string, number> = {}
|
||||
try {
|
||||
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
||||
if (result.success && result.counts) {
|
||||
normalizedCounts = Object.fromEntries(
|
||||
Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)])
|
||||
)
|
||||
void (async () => {
|
||||
try {
|
||||
const scopeKey = await ensureSnsUserPostCountsCacheScopeKey()
|
||||
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
|
||||
} catch (cacheError) {
|
||||
console.error('Failed to persist SNS user post counts cache:', cacheError)
|
||||
}
|
||||
})()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contact post counts:', error)
|
||||
}
|
||||
|
||||
let resolved = preResolved
|
||||
let cursor = 0
|
||||
const applyBatch = () => {
|
||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
||||
|
||||
const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE)
|
||||
if (batch.length === 0) {
|
||||
setContactsCountProgress({
|
||||
resolved: totalTargets,
|
||||
total: totalTargets,
|
||||
running: false
|
||||
})
|
||||
contactsCountBatchTimerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const batchSet = new Set(batch)
|
||||
setContacts((prev) => {
|
||||
let changed = false
|
||||
const next = prev.map((contact) => {
|
||||
if (!batchSet.has(contact.username)) return contact
|
||||
const nextCount = normalizePostCount(normalizedCounts[contact.username])
|
||||
if (contact.postCountStatus === 'ready' && contact.postCount === nextCount) return contact
|
||||
changed = true
|
||||
return {
|
||||
...contact,
|
||||
postCount: nextCount,
|
||||
postCountStatus: 'ready' as ContactPostCountStatus
|
||||
}
|
||||
})
|
||||
return changed ? sortContactsForRanking(next) : prev
|
||||
})
|
||||
|
||||
resolved += batch.length
|
||||
cursor += batch.length
|
||||
setContactsCountProgress({
|
||||
resolved,
|
||||
total: totalTargets,
|
||||
running: resolved < totalTargets
|
||||
})
|
||||
|
||||
if (cursor < totalTargets) {
|
||||
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
|
||||
} else {
|
||||
contactsCountBatchTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
applyBatch()
|
||||
}, [normalizePostCount, sortContactsForRanking, stopContactsCountHydration])
|
||||
|
||||
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
|
||||
const loadContacts = useCallback(async () => {
|
||||
const requestToken = ++contactsLoadTokenRef.current
|
||||
stopContactsCountHydration(true)
|
||||
setContactsLoading(true)
|
||||
try {
|
||||
const contactsResult = await window.electronAPI.chat.getContacts()
|
||||
const snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey()
|
||||
const [cachedPostCountsItem, cachedContactsItem, cachedAvatarItem] = await Promise.all([
|
||||
configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey),
|
||||
configService.getContactsListCache(snsPostCountsScopeKey),
|
||||
configService.getContactsAvatarCache(snsPostCountsScopeKey)
|
||||
])
|
||||
const cachedPostCounts = cachedPostCountsItem?.counts || {}
|
||||
const cachedAvatarMap = cachedAvatarItem?.avatars || {}
|
||||
const cachedContacts = (cachedContactsItem?.contacts || [])
|
||||
.filter((contact) => contact.type === 'friend' || contact.type === 'former_friend')
|
||||
.map((contact) => {
|
||||
const cachedCount = cachedPostCounts[contact.username]
|
||||
const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount)
|
||||
return {
|
||||
username: contact.username,
|
||||
displayName: contact.displayName || contact.username,
|
||||
avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl,
|
||||
remark: contact.remark,
|
||||
nickname: contact.nickname,
|
||||
type: (contact.type === 'former_friend' ? 'former_friend' : 'friend') as 'friend' | 'former_friend',
|
||||
lastSessionTimestamp: 0,
|
||||
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
|
||||
postCountStatus: hasCachedCount ? 'ready' as ContactPostCountStatus : 'idle' as ContactPostCountStatus
|
||||
}
|
||||
})
|
||||
|
||||
if (requestToken !== contactsLoadTokenRef.current) return
|
||||
if (cachedContacts.length > 0) {
|
||||
const cachedContactsSorted = sortContactsForRanking(cachedContacts)
|
||||
setContacts(cachedContactsSorted)
|
||||
setContactsLoading(false)
|
||||
const cachedReadyCount = cachedContactsSorted.filter(contact => contact.postCountStatus === 'ready').length
|
||||
setContactsCountProgress({
|
||||
resolved: cachedReadyCount,
|
||||
total: cachedContactsSorted.length,
|
||||
running: cachedReadyCount < cachedContactsSorted.length
|
||||
})
|
||||
}
|
||||
|
||||
const [contactsResult, sessionsResult] = await Promise.all([
|
||||
window.electronAPI.chat.getContacts(),
|
||||
window.electronAPI.chat.getSessions()
|
||||
])
|
||||
const contactMap = new Map<string, Contact>()
|
||||
const sessionTimestampMap = new Map<string, number>()
|
||||
|
||||
if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) {
|
||||
for (const session of sessionsResult.sessions) {
|
||||
const username = String(session?.username || '').trim()
|
||||
if (!username) continue
|
||||
const ts = Math.max(
|
||||
Number(session?.sortTimestamp || 0),
|
||||
Number(session?.lastTimestamp || 0)
|
||||
)
|
||||
const prevTs = Number(sessionTimestampMap.get(username) || 0)
|
||||
if (ts > prevTs) {
|
||||
sessionTimestampMap.set(username, ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contactsResult.success && contactsResult.contacts) {
|
||||
for (const c of contactsResult.contacts) {
|
||||
if (c.type === 'friend' || c.type === 'former_friend') {
|
||||
const cachedCount = cachedPostCounts[c.username]
|
||||
const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount)
|
||||
contactMap.set(c.username, {
|
||||
username: c.username,
|
||||
displayName: c.displayName,
|
||||
avatarUrl: c.avatarUrl,
|
||||
type: c.type === 'former_friend' ? 'former_friend' : 'friend'
|
||||
remark: c.remark,
|
||||
nickname: c.nickname,
|
||||
type: c.type === 'former_friend' ? 'former_friend' : 'friend',
|
||||
lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0),
|
||||
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
|
||||
postCountStatus: hasCachedCount ? 'ready' : 'idle'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let contactsList = Array.from(contactMap.values())
|
||||
|
||||
let contactsList = sortContactsForRanking(Array.from(contactMap.values()))
|
||||
if (requestToken !== contactsLoadTokenRef.current) return
|
||||
setContacts(contactsList)
|
||||
const readyUsernames = new Set(
|
||||
contactsList
|
||||
.filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number')
|
||||
.map((contact) => contact.username)
|
||||
)
|
||||
void hydrateContactPostCounts(
|
||||
contactsList.map(contact => contact.username),
|
||||
{ readyUsernames }
|
||||
)
|
||||
|
||||
const allUsernames = contactsList.map(c => c.username)
|
||||
|
||||
@@ -422,7 +906,7 @@ export default function SnsPage() {
|
||||
if (allUsernames.length > 0) {
|
||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
||||
if (enriched.success && enriched.contacts) {
|
||||
contactsList = contactsList.map(contact => {
|
||||
contactsList = contactsList.map((contact) => {
|
||||
const extra = enriched.contacts?.[contact.username]
|
||||
if (!extra) return contact
|
||||
return {
|
||||
@@ -432,19 +916,61 @@ export default function SnsPage() {
|
||||
}
|
||||
})
|
||||
if (requestToken !== contactsLoadTokenRef.current) return
|
||||
setContacts(contactsList)
|
||||
setContacts((prev) => {
|
||||
const prevMap = new Map(prev.map((contact) => [contact.username, contact]))
|
||||
const merged = contactsList.map((contact) => {
|
||||
const previous = prevMap.get(contact.username)
|
||||
return {
|
||||
...contact,
|
||||
lastSessionTimestamp: previous?.lastSessionTimestamp ?? contact.lastSessionTimestamp,
|
||||
postCount: previous?.postCount,
|
||||
postCountStatus: previous?.postCountStatus ?? contact.postCountStatus
|
||||
}
|
||||
})
|
||||
return sortContactsForRanking(merged)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestToken !== contactsLoadTokenRef.current) return
|
||||
console.error('Failed to load contacts:', error)
|
||||
stopContactsCountHydration(true)
|
||||
} finally {
|
||||
if (requestToken === contactsLoadTokenRef.current) {
|
||||
setContactsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration])
|
||||
|
||||
const closeAuthorTimeline = useCallback(() => {
|
||||
setAuthorTimelineTarget(null)
|
||||
}, [])
|
||||
|
||||
const openAuthorTimeline = useCallback((post: SnsPost) => {
|
||||
setAuthorTimelineTarget({
|
||||
username: post.username,
|
||||
displayName: decodeHtmlEntities(post.nickname || '') || post.username,
|
||||
avatarUrl: post.avatarUrl
|
||||
})
|
||||
}, [decodeHtmlEntities])
|
||||
|
||||
const openContactTimeline = useCallback((contact: Contact) => {
|
||||
setAuthorTimelineTarget({
|
||||
username: contact.username,
|
||||
displayName: contact.displayName || contact.username,
|
||||
avatarUrl: contact.avatarUrl
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePostDelete = useCallback((postId: string, username: string) => {
|
||||
setPosts(prev => {
|
||||
const next = prev.filter(p => p.id !== postId)
|
||||
void persistSnsPageCache({ posts: next })
|
||||
return next
|
||||
})
|
||||
void loadOverviewStats()
|
||||
}, [loadOverviewStats, persistSnsPageCache])
|
||||
|
||||
// Initial Load & Listeners
|
||||
useEffect(() => {
|
||||
void hydrateSnsPageCache()
|
||||
@@ -452,12 +978,58 @@ export default function SnsPage() {
|
||||
loadOverviewStats()
|
||||
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
||||
|
||||
useEffect(() => {
|
||||
const syncCurrentUserProfile = async () => {
|
||||
const cachedProfile = readSidebarUserProfileCache()
|
||||
if (cachedProfile) {
|
||||
setCurrentUserProfile((prev) => ({
|
||||
wxid: cachedProfile.wxid || prev.wxid,
|
||||
displayName: cachedProfile.displayName || prev.displayName,
|
||||
alias: cachedProfile.alias || prev.alias,
|
||||
avatarUrl: cachedProfile.avatarUrl || prev.avatarUrl
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
const wxidRaw = await configService.getMyWxid()
|
||||
const resolvedWxid = normalizeAccountId(wxidRaw) || String(wxidRaw || '').trim()
|
||||
if (!resolvedWxid && !cachedProfile) return
|
||||
setCurrentUserProfile((prev) => ({
|
||||
wxid: resolvedWxid || prev.wxid,
|
||||
displayName: prev.displayName || cachedProfile?.displayName || resolvedWxid || '未识别用户',
|
||||
alias: prev.alias || cachedProfile?.alias,
|
||||
avatarUrl: prev.avatarUrl || cachedProfile?.avatarUrl
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to sync current sidebar user profile:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void syncCurrentUserProfile()
|
||||
const handleChange = () => { void syncCurrentUserProfile() }
|
||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
contactsCountHydrationTokenRef.current += 1
|
||||
if (contactsCountBatchTimerRef.current) {
|
||||
window.clearTimeout(contactsCountBatchTimerRef.current)
|
||||
contactsCountBatchTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
cacheScopeKeyRef.current = ''
|
||||
snsUserPostCountsCacheScopeKeyRef.current = ''
|
||||
// wxid changed, reset everything
|
||||
stopContactsCountHydration(true)
|
||||
setContacts([])
|
||||
setPosts([]); setHasMore(true); setHasNewer(false);
|
||||
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
|
||||
setSearchKeyword(''); setJumpTargetDate(undefined);
|
||||
void hydrateSnsPageCache()
|
||||
loadContacts();
|
||||
loadOverviewStats();
|
||||
@@ -465,14 +1037,14 @@ export default function SnsPage() {
|
||||
}
|
||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts])
|
||||
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts, stopContactsCountHydration])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadPosts({ reset: true })
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts])
|
||||
}, [searchKeyword, jumpTargetDate, loadPosts])
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
||||
@@ -500,10 +1072,100 @@ export default function SnsPage() {
|
||||
<div className="feed-header-main">
|
||||
<h2>朋友圈</h2>
|
||||
<div className={`feed-stats-line ${overviewStatsStatus}`}>
|
||||
{renderOverviewStats()}
|
||||
<span className="feed-overview-total">
|
||||
{overviewStatsStatus === 'loading'
|
||||
? '共 统计中...'
|
||||
: `共 ${overviewStats.totalPosts.toLocaleString('zh-CN')} 条`}
|
||||
</span>
|
||||
<span className="feed-stats-divider" aria-hidden="true">|</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-my-timeline-entry ${resolvedCurrentUserContact ? 'ready' : ''} ${myTimelineCountLoading ? 'loading' : ''}`}
|
||||
onClick={openCurrentUserTimeline}
|
||||
disabled={!resolvedCurrentUserContact}
|
||||
title={resolvedCurrentUserContact
|
||||
? `打开${resolvedCurrentUserContact.displayName || '我'}的朋友圈详情`
|
||||
: '未在右侧联系人列表中匹配到当前账号'}
|
||||
>
|
||||
<span className="feed-my-timeline-label">我的朋友圈</span>
|
||||
<span className="feed-my-timeline-count">
|
||||
{myTimelineCount !== null
|
||||
? `${myTimelineCount.toLocaleString('zh-CN')} 条`
|
||||
: myTimelineCountLoading
|
||||
? <Loader2 size={14} className="spin" aria-hidden="true" />
|
||||
: '--'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={`feed-stats-line feed-stats-range ${overviewStatsStatus}`}>
|
||||
{renderOverviewRangeText()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="jump-calendar-anchor" ref={jumpCalendarWrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${jumpTargetDate ? 'jump-date-chip' : 'icon-btn'} ${showJumpPopover ? 'active' : ''}`}
|
||||
title={jumpTargetDate
|
||||
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: '时间跳转'}
|
||||
onClick={() => {
|
||||
if (!showJumpPopover) {
|
||||
const nextDate = jumpTargetDate || new Date()
|
||||
setJumpPopoverDate(nextDate)
|
||||
void loadJumpDateCounts(nextDate)
|
||||
}
|
||||
setShowJumpPopover(prev => !prev)
|
||||
}}
|
||||
>
|
||||
{jumpTargetDate ? (
|
||||
<>
|
||||
<span className="jump-date-chip-label">
|
||||
{`${jumpTargetDate.getFullYear()}-${String(jumpTargetDate.getMonth() + 1).padStart(2, '0')}-${String(jumpTargetDate.getDate()).padStart(2, '0')}`}
|
||||
</span>
|
||||
<span
|
||||
className="jump-date-chip-clear"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setJumpTargetDate(undefined)
|
||||
setShowJumpPopover(false)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setJumpTargetDate(undefined)
|
||||
setShowJumpPopover(false)
|
||||
}}
|
||||
aria-label="清除日期跳转"
|
||||
>
|
||||
<X size={14} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Calendar size={20} />
|
||||
)}
|
||||
</button>
|
||||
<JumpToDatePopover
|
||||
isOpen={showJumpPopover}
|
||||
currentDate={jumpPopoverDate}
|
||||
onClose={() => setShowJumpPopover(false)}
|
||||
onMonthChange={(date) => {
|
||||
setJumpPopoverDate(date)
|
||||
void loadJumpDateCounts(date)
|
||||
}}
|
||||
onSelect={(date) => {
|
||||
setJumpPopoverDate(date)
|
||||
setJumpTargetDate(date)
|
||||
}}
|
||||
messageDates={jumpDateMessageDates}
|
||||
hasLoadedMessageDates={hasLoadedJumpDateCounts}
|
||||
messageDateCounts={jumpDateCounts}
|
||||
loadingDateCounts={loadingJumpDateCounts}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setTriggerMessage(null)
|
||||
@@ -527,7 +1189,8 @@ export default function SnsPage() {
|
||||
onClick={() => {
|
||||
setExportResult(null)
|
||||
setExportProgress(null)
|
||||
setExportDateRange({ start: '', end: '' })
|
||||
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
setShowExportDialog(true)
|
||||
}}
|
||||
className="icon-btn export-btn"
|
||||
@@ -578,14 +1241,8 @@ export default function SnsPage() {
|
||||
}
|
||||
}}
|
||||
onDebug={(p) => setDebugPost(p)}
|
||||
onDelete={(postId) => {
|
||||
setPosts(prev => {
|
||||
const next = prev.filter(p => p.id !== postId)
|
||||
void persistSnsPageCache({ posts: next })
|
||||
return next
|
||||
})
|
||||
loadOverviewStats()
|
||||
}}
|
||||
onDelete={handlePostDelete}
|
||||
onOpenAuthorPosts={openAuthorTimeline}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -607,21 +1264,16 @@ export default function SnsPage() {
|
||||
)}
|
||||
|
||||
{!hasMore && posts.length > 0 && (
|
||||
<div className="status-indicator no-more">{
|
||||
selectedUsernames.length === 1 &&
|
||||
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
|
||||
? '在时间的长河里刻舟求剑'
|
||||
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
|
||||
}</div>
|
||||
<div className="status-indicator no-more">或许过往已无可溯洄,但好在还有可以与你相遇的明天</div>
|
||||
)}
|
||||
|
||||
{!loading && posts.length === 0 && (
|
||||
<div className="no-results">
|
||||
<div className="no-results-icon"><Search size={48} /></div>
|
||||
<p>未找到相关动态</p>
|
||||
{(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && (
|
||||
{(searchKeyword || jumpTargetDate) && (
|
||||
<button onClick={() => {
|
||||
setSearchKeyword(''); setSelectedUsernames([]); setJumpTargetDate(undefined);
|
||||
setSearchKeyword(''); setJumpTargetDate(undefined);
|
||||
}} className="reset-inline">
|
||||
重置筛选条件
|
||||
</button>
|
||||
@@ -635,26 +1287,35 @@ export default function SnsPage() {
|
||||
<SnsFilterPanel
|
||||
searchKeyword={searchKeyword}
|
||||
setSearchKeyword={setSearchKeyword}
|
||||
jumpTargetDate={jumpTargetDate}
|
||||
setJumpTargetDate={setJumpTargetDate}
|
||||
onOpenJumpDialog={() => setShowJumpDialog(true)}
|
||||
selectedUsernames={selectedUsernames}
|
||||
setSelectedUsernames={setSelectedUsernames}
|
||||
totalFriendsLabel={
|
||||
overviewStatsStatus === 'loading'
|
||||
? '统计中'
|
||||
: overviewStatsStatus === 'ready'
|
||||
? `${overviewStats.totalFriends} 位好友`
|
||||
: undefined
|
||||
}
|
||||
contacts={contacts}
|
||||
contactSearch={contactSearch}
|
||||
setContactSearch={setContactSearch}
|
||||
loading={contactsLoading}
|
||||
contactsCountProgress={contactsCountProgress}
|
||||
onOpenContactTimeline={openContactTimeline}
|
||||
/>
|
||||
|
||||
{/* Dialogs and Overlays */}
|
||||
<JumpToDateDialog
|
||||
isOpen={showJumpDialog}
|
||||
onClose={() => setShowJumpDialog(false)}
|
||||
onSelect={(date) => {
|
||||
setJumpTargetDate(date)
|
||||
setShowJumpDialog(false)
|
||||
}}
|
||||
currentDate={jumpTargetDate || new Date()}
|
||||
<ContactSnsTimelineDialog
|
||||
target={authorTimelineTarget}
|
||||
onClose={closeAuthorTimeline}
|
||||
initialTotalPosts={authorTimelineTarget?.username === resolvedCurrentUserContact?.username
|
||||
? myTimelineCount
|
||||
: currentTimelineTargetContact?.postCountStatus === 'ready'
|
||||
? normalizePostCount(currentTimelineTargetContact.postCount)
|
||||
: null}
|
||||
initialTotalPostsLoading={Boolean(authorTimelineTarget?.username === resolvedCurrentUserContact?.username
|
||||
? myTimelineCount === null && myTimelineCountLoading
|
||||
: currentTimelineTargetContact?.postCountStatus === 'loading')}
|
||||
isProtected={triggerInstalled === true}
|
||||
onDeletePost={handlePostDelete}
|
||||
/>
|
||||
|
||||
{debugPost && (
|
||||
@@ -783,17 +1444,10 @@ export default function SnsPage() {
|
||||
|
||||
<div className="export-dialog-body">
|
||||
{/* 筛选条件提示 */}
|
||||
{(selectedUsernames.length > 0 || searchKeyword) && (
|
||||
{searchKeyword && (
|
||||
<div className="export-filter-info">
|
||||
<span className="filter-badge">筛选导出</span>
|
||||
{searchKeyword && <span className="filter-tag">关键词: "{searchKeyword}"</span>}
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="filter-tag">
|
||||
<Users size={12} />
|
||||
{selectedUsernames.length} 个联系人
|
||||
<span className="sync-hint">(同步自侧栏筛选)</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -861,31 +1515,19 @@ export default function SnsPage() {
|
||||
|
||||
{/* 时间范围 */}
|
||||
<div className="export-section">
|
||||
<label className="export-label"><Calendar size={14} /> 时间范围(可选)</label>
|
||||
<div className="export-date-row">
|
||||
<div className="date-picker-trigger" onClick={() => {
|
||||
if (!isExporting) setCalendarPicker(prev => prev?.field === 'start' ? null : { field: 'start', month: exportDateRange.start ? new Date(exportDateRange.start) : new Date() })
|
||||
}}>
|
||||
<Calendar size={14} />
|
||||
<span className={exportDateRange.start ? '' : 'placeholder'}>
|
||||
{exportDateRange.start || '开始日期'}
|
||||
</span>
|
||||
{exportDateRange.start && (
|
||||
<X size={12} className="clear-date" onClick={(e) => { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, start: '' })) }} />
|
||||
)}
|
||||
</div>
|
||||
<span className="date-separator">至</span>
|
||||
<div className="date-picker-trigger" onClick={() => {
|
||||
if (!isExporting) setCalendarPicker(prev => prev?.field === 'end' ? null : { field: 'end', month: exportDateRange.end ? new Date(exportDateRange.end) : new Date() })
|
||||
}}>
|
||||
<Calendar size={14} />
|
||||
<span className={exportDateRange.end ? '' : 'placeholder'}>
|
||||
{exportDateRange.end || '结束日期'}
|
||||
</span>
|
||||
{exportDateRange.end && (
|
||||
<X size={12} className="clear-date" onClick={(e) => { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, end: '' })) }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="export-section-header">
|
||||
<label className="export-label"><Calendar size={14} /> 时间范围</label>
|
||||
<button
|
||||
type="button"
|
||||
className="time-range-trigger sns-export-time-range-trigger"
|
||||
onClick={() => {
|
||||
if (!isExporting) setIsExportDateRangeDialogOpen(true)
|
||||
}}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<span>{exportDateRangeLabel}</span>
|
||||
<span className="time-range-arrow">></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -930,7 +1572,7 @@ export default function SnsPage() {
|
||||
{/* 同步提示 */}
|
||||
<div className="export-sync-hint">
|
||||
<Info size={14} />
|
||||
<span>将同步主页面的联系人范围筛选及关键词搜索</span>
|
||||
<span>将同步主页面的关键词搜索</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
@@ -972,13 +1614,16 @@ export default function SnsPage() {
|
||||
const result = await window.electronAPI.sns.exportTimeline({
|
||||
outputDir: exportFolder,
|
||||
format: exportFormat,
|
||||
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
|
||||
keyword: searchKeyword || undefined,
|
||||
exportImages,
|
||||
exportLivePhotos,
|
||||
exportVideos,
|
||||
startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined,
|
||||
endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined
|
||||
startTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
|
||||
endTime: exportDateRangeSelection.useAllTime
|
||||
? undefined
|
||||
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
|
||||
})
|
||||
setExportResult(result)
|
||||
} catch (e: any) {
|
||||
@@ -1045,119 +1690,15 @@ export default function SnsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日期选择弹窗 */}
|
||||
{calendarPicker && (
|
||||
<div className="calendar-overlay" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>
|
||||
<div className="calendar-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="calendar-header">
|
||||
<div className="title-area">
|
||||
<Calendar size={18} />
|
||||
<h3>选择{calendarPicker.field === 'start' ? '开始' : '结束'}日期</h3>
|
||||
</div>
|
||||
<button className="close-btn" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="calendar-view">
|
||||
<div className="calendar-nav">
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() - 1, 1) } : null)}>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月
|
||||
</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() + 1, 1) } : null)}>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{showYearMonthPicker ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear() - 1, prev.month.getMonth(), 1) } : null)}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="year-label">{calendarPicker.month.getFullYear()}年</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear() + 1, prev.month.getMonth(), 1) } : null)}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === calendarPicker.month.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), i, 1) } : null)
|
||||
setShowYearMonthPicker(false)
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="calendar-weekdays">
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map(d => <div key={d} className="weekday">{d}</div>)}
|
||||
</div>
|
||||
<div className="calendar-days">
|
||||
{(() => {
|
||||
const y = calendarPicker.month.getFullYear()
|
||||
const m = calendarPicker.month.getMonth()
|
||||
const firstDay = new Date(y, m, 1).getDay()
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null)
|
||||
for (let i = 1; i <= daysInMonth; i++) cells.push(i)
|
||||
const today = new Date()
|
||||
return cells.map((day, i) => {
|
||||
if (day === null) return <div key={i} className="day-cell empty" />
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
const isToday = day === today.getDate() && m === today.getMonth() && y === today.getFullYear()
|
||||
const currentVal = calendarPicker.field === 'start' ? exportDateRange.start : exportDateRange.end
|
||||
const isSelected = dateStr === currentVal
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`day-cell${isSelected ? ' selected' : ''}${isToday ? ' today' : ''}`}
|
||||
onClick={() => {
|
||||
setExportDateRange(prev => ({ ...prev, [calendarPicker.field]: dateStr }))
|
||||
setCalendarPicker(null)
|
||||
}}
|
||||
>{day}</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="quick-options">
|
||||
<button onClick={() => {
|
||||
if (calendarPicker.field === 'start') {
|
||||
const d = new Date(); d.setMonth(d.getMonth() - 1)
|
||||
setExportDateRange(prev => ({ ...prev, start: d.toISOString().split('T')[0] }))
|
||||
} else {
|
||||
setExportDateRange(prev => ({ ...prev, end: new Date().toISOString().split('T')[0] }))
|
||||
}
|
||||
setCalendarPicker(null)
|
||||
}}>{calendarPicker.field === 'start' ? '一个月前' : '今天'}</button>
|
||||
<button onClick={() => {
|
||||
if (calendarPicker.field === 'start') {
|
||||
const d = new Date(); d.setMonth(d.getMonth() - 3)
|
||||
setExportDateRange(prev => ({ ...prev, start: d.toISOString().split('T')[0] }))
|
||||
} else {
|
||||
const d = new Date(); d.setMonth(d.getMonth() - 1)
|
||||
setExportDateRange(prev => ({ ...prev, end: d.toISOString().split('T')[0] }))
|
||||
}
|
||||
setCalendarPicker(null)
|
||||
}}>{calendarPicker.field === 'start' ? '三个月前' : '一个月前'}</button>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button className="cancel-btn" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ExportDateRangeDialog
|
||||
open={isExportDateRangeDialogOpen}
|
||||
value={exportDateRangeSelection}
|
||||
onClose={() => setIsExportDateRangeDialogOpen(false)}
|
||||
onConfirm={(nextSelection) => {
|
||||
setExportDateRangeSelection(nextSelection)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+127
-16
@@ -1,5 +1,6 @@
|
||||
// 配置服务 - 封装 Electron Store
|
||||
import { config } from './ipc'
|
||||
import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange'
|
||||
|
||||
// 配置键名
|
||||
export const CONFIG_KEYS = {
|
||||
@@ -26,6 +27,7 @@ export const CONFIG_KEYS = {
|
||||
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
||||
TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
|
||||
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
||||
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
|
||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||
@@ -41,6 +43,7 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
||||
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||
@@ -75,6 +78,20 @@ export interface WxidConfig {
|
||||
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> {
|
||||
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)
|
||||
}
|
||||
|
||||
// 获取导出默认时间范围
|
||||
export async function getExportDefaultDateRange(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
||||
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)
|
||||
// 获取导出默认头像设置
|
||||
export async function getExportDefaultAvatars(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS)
|
||||
if (typeof value === 'boolean') return value
|
||||
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> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled)
|
||||
export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
export interface ExportSnsUserPostCountsCacheItem {
|
||||
updatedAt: number
|
||||
counts: Record<string, number>
|
||||
}
|
||||
|
||||
export interface SnsPageOverviewCache {
|
||||
totalPosts: number
|
||||
totalFriends: number
|
||||
@@ -741,6 +800,58 @@ export async function setExportSnsStatsCache(
|
||||
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> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||
|
||||
Vendored
+17
-2
@@ -1,5 +1,12 @@
|
||||
import type { ChatSession, Message, Contact, ContactInfo } from './models'
|
||||
|
||||
export interface SessionChatWindowOpenOptions {
|
||||
source?: 'chat' | 'export'
|
||||
initialDisplayName?: string
|
||||
initialAvatarUrl?: string
|
||||
initialContactType?: ContactInfo['type']
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
minimize: () => void
|
||||
@@ -13,7 +20,7 @@ export interface ElectronAPI {
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||
openSessionChatWindow: (sessionId: string) => Promise<boolean>
|
||||
openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
|
||||
}
|
||||
config: {
|
||||
get: (key: string) => Promise<unknown>
|
||||
@@ -250,7 +257,13 @@ export interface ElectronAPI {
|
||||
}>
|
||||
getExportSessionStats: (
|
||||
sessionIds: string[],
|
||||
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
|
||||
options?: {
|
||||
includeRelations?: boolean
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: Record<string, {
|
||||
@@ -776,8 +789,10 @@ export interface ElectronAPI {
|
||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: 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 }>
|
||||
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 }>
|
||||
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
||||
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in new issue
Block a user