mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
Merge branch 'codex/ts0305-01-export-module-upgrade' into HEAD
# Conflicts: # electron/main.ts
This commit is contained in:
@@ -24,7 +24,7 @@ import { windowsHelloService } from './services/windowsHelloService'
|
|||||||
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
||||||
import { cloudControlService } from './services/cloudControlService'
|
import { cloudControlService } from './services/cloudControlService'
|
||||||
|
|
||||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
import { httpService } from './services/httpService'
|
import { httpService } from './services/httpService'
|
||||||
|
|
||||||
|
|
||||||
@@ -87,10 +87,12 @@ let onboardingWindow: BrowserWindow | null = null
|
|||||||
// Splash 启动窗口
|
// Splash 启动窗口
|
||||||
let splashWindow: BrowserWindow | null = null
|
let splashWindow: BrowserWindow | null = null
|
||||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||||
|
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||||
const keyService = new KeyService()
|
const keyService = new KeyService()
|
||||||
|
|
||||||
let mainWindowReady = false
|
let mainWindowReady = false
|
||||||
let shouldShowMain = true
|
let shouldShowMain = true
|
||||||
|
let isAppQuitting = false
|
||||||
|
|
||||||
// 更新下载状态管理(Issue #294 修复)
|
// 更新下载状态管理(Issue #294 修复)
|
||||||
let isDownloadInProgress = false
|
let isDownloadInProgress = false
|
||||||
@@ -123,6 +125,47 @@ interface AnnualReportYearsTaskState {
|
|||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenSessionChatWindowOptions {
|
||||||
|
source?: 'chat' | 'export'
|
||||||
|
initialDisplayName?: string
|
||||||
|
initialAvatarUrl?: string
|
||||||
|
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
|
||||||
|
return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSessionChatWindowOptionString = (value: unknown): string => {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSessionChatWindowContent = (
|
||||||
|
win: BrowserWindow,
|
||||||
|
sessionId: string,
|
||||||
|
source: 'chat' | 'export',
|
||||||
|
options?: OpenSessionChatWindowOptions
|
||||||
|
) => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
sessionId,
|
||||||
|
source
|
||||||
|
})
|
||||||
|
const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName)
|
||||||
|
const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl)
|
||||||
|
const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType)
|
||||||
|
if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName)
|
||||||
|
if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl)
|
||||||
|
if (initialContactType) queryParams.set('initialContactType', initialContactType)
|
||||||
|
const query = queryParams.toString()
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
|
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||||
|
hash: `/chat-window?${query}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
|
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
|
||||||
const annualReportYearsTaskByCacheKey = new Map<string, string>()
|
const annualReportYearsTaskByCacheKey = new Map<string, string>()
|
||||||
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
|
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
|
||||||
@@ -290,6 +333,21 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
callback(false)
|
callback(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
if (mainWindow !== win) return
|
||||||
|
|
||||||
|
mainWindow = null
|
||||||
|
mainWindowReady = false
|
||||||
|
|
||||||
|
if (process.platform !== 'darwin' && !isAppQuitting) {
|
||||||
|
// 隐藏通知窗也是 BrowserWindow,必须销毁,否则会阻止应用退出。
|
||||||
|
destroyNotificationWindow()
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,12 +746,18 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
|||||||
/**
|
/**
|
||||||
* 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
|
* 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
|
||||||
*/
|
*/
|
||||||
function createSessionChatWindow(sessionId: string) {
|
function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
if (!normalizedSessionId) return null
|
if (!normalizedSessionId) return null
|
||||||
|
const normalizedSource = normalizeSessionChatWindowSource(options?.source)
|
||||||
|
|
||||||
const existing = sessionChatWindows.get(normalizedSessionId)
|
const existing = sessionChatWindows.get(normalizedSessionId)
|
||||||
if (existing && !existing.isDestroyed()) {
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
|
||||||
|
if (trackedSource !== normalizedSource) {
|
||||||
|
loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options)
|
||||||
|
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
|
||||||
|
}
|
||||||
if (existing.isMinimized()) {
|
if (existing.isMinimized()) {
|
||||||
existing.restore()
|
existing.restore()
|
||||||
}
|
}
|
||||||
@@ -730,10 +794,9 @@ function createSessionChatWindow(sessionId: string) {
|
|||||||
autoHideMenuBar: true
|
autoHideMenuBar: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}`
|
loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
|
||||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`)
|
|
||||||
|
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
win.webContents.on('before-input-event', (event, input) => {
|
win.webContents.on('before-input-event', (event, input) => {
|
||||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||||
if (win.webContents.isDevToolsOpened()) {
|
if (win.webContents.isDevToolsOpened()) {
|
||||||
@@ -744,10 +807,6 @@ function createSessionChatWindow(sessionId: string) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
|
||||||
hash: `/chat-window?${sessionParam}`
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
@@ -759,10 +818,12 @@ function createSessionChatWindow(sessionId: string) {
|
|||||||
const tracked = sessionChatWindows.get(normalizedSessionId)
|
const tracked = sessionChatWindows.get(normalizedSessionId)
|
||||||
if (tracked === win) {
|
if (tracked === win) {
|
||||||
sessionChatWindows.delete(normalizedSessionId)
|
sessionChatWindows.delete(normalizedSessionId)
|
||||||
|
sessionChatWindowSources.delete(normalizedSessionId)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sessionChatWindows.set(normalizedSessionId, win)
|
sessionChatWindows.set(normalizedSessionId, win)
|
||||||
|
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
|
||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,8 +1132,8 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
||||||
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => {
|
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
|
||||||
const win = createSessionChatWindow(sessionId)
|
const win = createSessionChatWindow(sessionId, options)
|
||||||
return Boolean(win)
|
return Boolean(win)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1410,6 +1471,7 @@ function registerIpcHandlers() {
|
|||||||
forceRefresh?: boolean
|
forceRefresh?: boolean
|
||||||
allowStaleCache?: boolean
|
allowStaleCache?: boolean
|
||||||
preferAccurateSpecialTypes?: boolean
|
preferAccurateSpecialTypes?: boolean
|
||||||
|
cacheOnly?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
return chatService.getExportSessionStats(sessionIds, options)
|
return chatService.getExportSessionStats(sessionIds, options)
|
||||||
})
|
})
|
||||||
@@ -1463,6 +1525,10 @@ function registerIpcHandlers() {
|
|||||||
return snsService.getSnsUsernames()
|
return snsService.getSnsUsernames()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:getUserPostCounts', async () => {
|
||||||
|
return snsService.getUserPostCounts()
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('sns:getExportStats', async () => {
|
ipcMain.handle('sns:getExportStats', async () => {
|
||||||
return snsService.getExportStats()
|
return snsService.getExportStats()
|
||||||
})
|
})
|
||||||
@@ -1471,6 +1537,10 @@ function registerIpcHandlers() {
|
|||||||
return snsService.getExportStatsFast()
|
return snsService.getExportStatsFast()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
|
||||||
|
return snsService.getUserPostStats(username)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||||
return snsService.debugResource(url)
|
return snsService.debugResource(url)
|
||||||
})
|
})
|
||||||
@@ -2373,6 +2443,9 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
|
isAppQuitting = true
|
||||||
|
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||||
|
destroyNotificationWindow()
|
||||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||||
try { await httpService.stop() } catch {}
|
try { await httpService.stop() } catch {}
|
||||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||||
|
|||||||
@@ -99,8 +99,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||||
openSessionChatWindow: (sessionId: string) =>
|
openSessionChatWindow: (
|
||||||
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
|
sessionId: string,
|
||||||
|
options?: {
|
||||||
|
source?: 'chat' | 'export'
|
||||||
|
initialDisplayName?: string
|
||||||
|
initialAvatarUrl?: string
|
||||||
|
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据库路径
|
// 数据库路径
|
||||||
@@ -174,7 +182,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
||||||
getExportSessionStats: (
|
getExportSessionStats: (
|
||||||
sessionIds: string[],
|
sessionIds: string[],
|
||||||
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
|
options?: {
|
||||||
|
includeRelations?: boolean
|
||||||
|
forceRefresh?: boolean
|
||||||
|
allowStaleCache?: boolean
|
||||||
|
preferAccurateSpecialTypes?: boolean
|
||||||
|
cacheOnly?: boolean
|
||||||
|
}
|
||||||
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||||
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
|
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
|
||||||
@@ -339,8 +353,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||||
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||||
|
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
|
||||||
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||||
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||||
|
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
|
||||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ interface ExportSessionStatsOptions {
|
|||||||
forceRefresh?: boolean
|
forceRefresh?: boolean
|
||||||
allowStaleCache?: boolean
|
allowStaleCache?: boolean
|
||||||
preferAccurateSpecialTypes?: boolean
|
preferAccurateSpecialTypes?: boolean
|
||||||
|
cacheOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportSessionStatsCacheMeta {
|
interface ExportSessionStatsCacheMeta {
|
||||||
@@ -5209,39 +5210,36 @@ class ChatService {
|
|||||||
return { success: true, detail: cachedDetail.detail }
|
return { success: true, detail: cachedDetail.detail }
|
||||||
}
|
}
|
||||||
|
|
||||||
const [tableStatsResult, statsResult] = await Promise.allSettled([
|
const tableStatsResult = await wcdbService.getMessageTableStats(normalizedSessionId)
|
||||||
wcdbService.getMessageTableStats(normalizedSessionId),
|
|
||||||
(async (): Promise<ExportSessionStats | null> => {
|
|
||||||
const cachedStats = this.getSessionStatsCacheEntry(normalizedSessionId)
|
|
||||||
if (cachedStats && this.supportsRequestedRelation(cachedStats.entry, false)) {
|
|
||||||
return this.fromSessionStatsCacheStats(cachedStats.entry.stats)
|
|
||||||
}
|
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
|
||||||
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
|
|
||||||
const stats = await this.getOrComputeSessionExportStats(normalizedSessionId, false, selfIdentitySet)
|
|
||||||
this.setSessionStatsCacheEntry(normalizedSessionId, stats, false)
|
|
||||||
return stats
|
|
||||||
})()
|
|
||||||
])
|
|
||||||
|
|
||||||
const statsSnapshot = statsResult.status === 'fulfilled'
|
|
||||||
? statsResult.value
|
|
||||||
: null
|
|
||||||
const firstMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.firstTimestamp)
|
|
||||||
? Math.max(0, Math.floor(statsSnapshot.firstTimestamp as number))
|
|
||||||
: undefined
|
|
||||||
const latestMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.lastTimestamp)
|
|
||||||
? Math.max(0, Math.floor(statsSnapshot.lastTimestamp as number))
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const messageTables: { dbName: string; tableName: string; count: number }[] = []
|
const messageTables: { dbName: string; tableName: string; count: number }[] = []
|
||||||
if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) {
|
let firstMessageTime: number | undefined
|
||||||
for (const row of tableStatsResult.value.tables) {
|
let latestMessageTime: number | undefined
|
||||||
|
if (tableStatsResult.success && tableStatsResult.tables) {
|
||||||
|
for (const row of tableStatsResult.tables) {
|
||||||
messageTables.push({
|
messageTables.push({
|
||||||
dbName: basename(row.db_path || ''),
|
dbName: basename(row.db_path || ''),
|
||||||
tableName: row.table_name || '',
|
tableName: row.table_name || '',
|
||||||
count: parseInt(row.count || '0', 10)
|
count: parseInt(row.count || '0', 10)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const firstTs = this.getRowInt(
|
||||||
|
row,
|
||||||
|
['first_timestamp', 'firstTimestamp', 'first_time', 'firstTime', 'min_create_time', 'minCreateTime'],
|
||||||
|
0
|
||||||
|
)
|
||||||
|
if (firstTs > 0 && (firstMessageTime === undefined || firstTs < firstMessageTime)) {
|
||||||
|
firstMessageTime = firstTs
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTs = this.getRowInt(
|
||||||
|
row,
|
||||||
|
['last_timestamp', 'lastTimestamp', 'last_time', 'lastTime', 'max_create_time', 'maxCreateTime'],
|
||||||
|
0
|
||||||
|
)
|
||||||
|
if (lastTs > 0 && (latestMessageTime === undefined || lastTs > latestMessageTime)) {
|
||||||
|
latestMessageTime = lastTs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5357,6 +5355,7 @@ class ChatService {
|
|||||||
const forceRefresh = options.forceRefresh === true
|
const forceRefresh = options.forceRefresh === true
|
||||||
const allowStaleCache = options.allowStaleCache === true
|
const allowStaleCache = options.allowStaleCache === true
|
||||||
const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true
|
const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true
|
||||||
|
const cacheOnly = options.cacheOnly === true
|
||||||
|
|
||||||
const normalizedSessionIds = Array.from(
|
const normalizedSessionIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -5380,32 +5379,34 @@ class ChatService {
|
|||||||
? this.getGroupMyMessageCountHintEntry(sessionId)
|
? this.getGroupMyMessageCountHintEntry(sessionId)
|
||||||
: null
|
: null
|
||||||
const cachedResult = this.getSessionStatsCacheEntry(sessionId)
|
const cachedResult = this.getSessionStatsCacheEntry(sessionId)
|
||||||
if (!forceRefresh && !preferAccurateSpecialTypes) {
|
const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes)
|
||||||
if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
|
if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
|
||||||
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
|
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
|
||||||
if (!stale || allowStaleCache) {
|
if (!stale || allowStaleCache || cacheOnly) {
|
||||||
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
|
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
|
||||||
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
|
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
|
||||||
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
|
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
|
||||||
}
|
}
|
||||||
cacheMeta[sessionId] = {
|
cacheMeta[sessionId] = {
|
||||||
updatedAt: cachedResult.entry.updatedAt,
|
updatedAt: cachedResult.entry.updatedAt,
|
||||||
stale,
|
stale,
|
||||||
includeRelations: cachedResult.entry.includeRelations,
|
includeRelations: cachedResult.entry.includeRelations,
|
||||||
source: cachedResult.source
|
source: cachedResult.source
|
||||||
}
|
}
|
||||||
if (stale) {
|
if (stale) {
|
||||||
needsRefreshSet.add(sessionId)
|
needsRefreshSet.add(sessionId)
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程。
|
|
||||||
if (allowStaleCache && cachedResult) {
|
|
||||||
needsRefreshSet.add(sessionId)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。
|
||||||
|
if (canUseCache && allowStaleCache && cachedResult) {
|
||||||
|
needsRefreshSet.add(sessionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (cacheOnly) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
pendingSessionIds.push(sessionId)
|
pendingSessionIds.push(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,9 @@ class SnsService {
|
|||||||
private contactCache: ContactCacheService
|
private contactCache: ContactCacheService
|
||||||
private imageCache = new Map<string, string>()
|
private imageCache = new Map<string, string>()
|
||||||
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
||||||
|
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
||||||
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||||||
|
private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000
|
||||||
private lastTimelineFallbackAt = 0
|
private lastTimelineFallbackAt = 0
|
||||||
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
|
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
|
||||||
|
|
||||||
@@ -864,6 +866,84 @@ class SnsService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getUserPostCountsFromTimeline(): Promise<Record<string, number>> {
|
||||||
|
const pageSize = 500
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (let round = 0; round < 2000; round++) {
|
||||||
|
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||||||
|
if (!result.success || !Array.isArray(result.timeline)) {
|
||||||
|
throw new Error(result.error || '获取朋友圈用户总条数失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = result.timeline
|
||||||
|
if (rows.length === 0) break
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const username = this.pickTimelineUsername(row)
|
||||||
|
if (!username) continue
|
||||||
|
counts[username] = (counts[username] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length < pageSize) break
|
||||||
|
offset += rows.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPostCounts(options?: {
|
||||||
|
preferCache?: boolean
|
||||||
|
}): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
const preferCache = options?.preferCache ?? true
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
preferCache &&
|
||||||
|
this.userPostCountsCache &&
|
||||||
|
now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs
|
||||||
|
) {
|
||||||
|
return { success: true, counts: this.userPostCountsCache.counts }
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = await this.getUserPostCountsFromTimeline()
|
||||||
|
this.userPostCountsCache = {
|
||||||
|
counts,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
return { success: true, counts }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SnsService] getUserPostCounts failed:', error)
|
||||||
|
if (this.userPostCountsCache) {
|
||||||
|
return { success: true, counts: this.userPostCountsCache.counts }
|
||||||
|
}
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> {
|
||||||
|
const normalizedUsername = this.toOptionalString(username)
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
return { success: false, error: '用户名不能为空' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const countsResult = await this.getUserPostCounts({ preferCache: true })
|
||||||
|
if (countsResult.success) {
|
||||||
|
const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
username: normalizedUsername,
|
||||||
|
totalPosts: Math.max(0, Number(totalPosts || 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' }
|
||||||
|
}
|
||||||
|
|
||||||
// 安装朋友圈删除拦截
|
// 安装朋友圈删除拦截
|
||||||
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
return wcdbService.installSnsBlockDeleteTrigger()
|
return wcdbService.installSnsBlockDeleteTrigger()
|
||||||
@@ -881,7 +961,12 @@ class SnsService {
|
|||||||
|
|
||||||
// 从数据库直接删除朋友圈记录
|
// 从数据库直接删除朋友圈记录
|
||||||
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
return wcdbService.deleteSnsPost(postId)
|
const result = await wcdbService.deleteSnsPost(postId)
|
||||||
|
if (result.success) {
|
||||||
|
this.userPostCountsCache = null
|
||||||
|
this.exportStatsCache = null
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
|
|||||||
let notificationWindow: BrowserWindow | null = null
|
let notificationWindow: BrowserWindow | null = null
|
||||||
let closeTimer: NodeJS.Timeout | null = null
|
let closeTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
export function destroyNotificationWindow() {
|
||||||
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer)
|
||||||
|
closeTimer = null
|
||||||
|
}
|
||||||
|
lastNotificationData = null
|
||||||
|
|
||||||
|
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||||
|
notificationWindow = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = notificationWindow
|
||||||
|
notificationWindow = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
win.destroy()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createNotificationWindow() {
|
export function createNotificationWindow() {
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
return notificationWindow
|
return notificationWindow
|
||||||
|
|||||||
18
src/App.tsx
18
src/App.tsx
@@ -402,8 +402,22 @@ function App() {
|
|||||||
|
|
||||||
// 独立会话聊天窗口(仅显示聊天内容区域)
|
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||||
if (isStandaloneChatWindow) {
|
if (isStandaloneChatWindow) {
|
||||||
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
|
const params = new URLSearchParams(location.search)
|
||||||
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
|
const sessionId = params.get('sessionId') || ''
|
||||||
|
const standaloneSource = params.get('source')
|
||||||
|
const standaloneInitialDisplayName = params.get('initialDisplayName')
|
||||||
|
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
|
||||||
|
const standaloneInitialContactType = params.get('initialContactType')
|
||||||
|
return (
|
||||||
|
<ChatPage
|
||||||
|
standaloneSessionWindow
|
||||||
|
initialSessionId={sessionId}
|
||||||
|
standaloneSource={standaloneSource}
|
||||||
|
standaloneInitialDisplayName={standaloneInitialDisplayName}
|
||||||
|
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
|
||||||
|
standaloneInitialContactType={standaloneInitialContactType}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 独立通知窗口
|
// 独立通知窗口
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
|
import { Search, Calendar, User, X, Loader2 } from 'lucide-react'
|
||||||
import { Avatar } from '../Avatar'
|
import { Avatar } from '../Avatar'
|
||||||
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
|
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
|
||||||
|
|
||||||
@@ -7,6 +7,14 @@ interface Contact {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
postCount?: number
|
||||||
|
postCountStatus?: 'idle' | 'loading' | 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactsCountProgress {
|
||||||
|
resolved: number
|
||||||
|
total: number
|
||||||
|
running: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SnsFilterPanelProps {
|
interface SnsFilterPanelProps {
|
||||||
@@ -21,6 +29,7 @@ interface SnsFilterPanelProps {
|
|||||||
contactSearch: string
|
contactSearch: string
|
||||||
setContactSearch: (val: string) => void
|
setContactSearch: (val: string) => void
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
contactsCountProgress?: ContactsCountProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||||
@@ -34,11 +43,12 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
contacts,
|
contacts,
|
||||||
contactSearch,
|
contactSearch,
|
||||||
setContactSearch,
|
setContactSearch,
|
||||||
loading
|
loading,
|
||||||
|
contactsCountProgress
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const filteredContacts = contacts.filter(c =>
|
const filteredContacts = contacts.filter(c =>
|
||||||
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,8 +162,17 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contactsCountProgress && contactsCountProgress.total > 0 && (
|
||||||
|
<div className="contact-count-progress">
|
||||||
|
{contactsCountProgress.running
|
||||||
|
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
|
||||||
|
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="contact-list-scroll">
|
<div className="contact-list-scroll">
|
||||||
{filteredContacts.map(contact => {
|
{filteredContacts.map(contact => {
|
||||||
|
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={contact.username}
|
key={contact.username}
|
||||||
@@ -164,6 +183,15 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
<div className="contact-meta">
|
<div className="contact-meta">
|
||||||
<span className="contact-name">{contact.displayName}</span>
|
<span className="contact-name">{contact.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="contact-post-count-wrap">
|
||||||
|
{isPostCountReady ? (
|
||||||
|
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
|
||||||
|
) : (
|
||||||
|
<span className="contact-post-count-loading" title="统计中">
|
||||||
|
<Loader2 size={13} className="spinning" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -243,10 +243,12 @@ interface SnsPostItemProps {
|
|||||||
post: SnsPost
|
post: SnsPost
|
||||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||||
onDebug: (post: SnsPost) => void
|
onDebug: (post: SnsPost) => void
|
||||||
onDelete?: (postId: string) => void
|
onDelete?: (postId: string, username: string) => void
|
||||||
|
onOpenAuthorPosts?: (post: SnsPost) => void
|
||||||
|
hideAuthorMeta?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => {
|
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
|
||||||
const [mediaDeleted, setMediaDeleted] = useState(false)
|
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||||
const [dbDeleted, setDbDeleted] = useState(false)
|
const [dbDeleted, setDbDeleted] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
@@ -299,31 +301,56 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
|||||||
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
|
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
setDbDeleted(true)
|
setDbDeleted(true)
|
||||||
onDelete?.(post.id)
|
onDelete?.(post.id, post.username)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpenAuthorPosts?.(post)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
|
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
|
||||||
<div className="post-avatar-col">
|
{!hideAuthorMeta && (
|
||||||
<Avatar
|
<div className="post-avatar-col">
|
||||||
src={post.avatarUrl}
|
<button
|
||||||
name={post.nickname}
|
type="button"
|
||||||
size={48}
|
className="author-trigger-btn avatar-trigger"
|
||||||
shape="rounded"
|
onClick={handleOpenAuthorPosts}
|
||||||
/>
|
title="查看该发布者的全部朋友圈"
|
||||||
</div>
|
>
|
||||||
|
<Avatar
|
||||||
|
src={post.avatarUrl}
|
||||||
|
name={post.nickname}
|
||||||
|
size={48}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="post-content-col">
|
<div className="post-content-col">
|
||||||
<div className="post-header-row">
|
<div className="post-header-row">
|
||||||
<div className="post-author-info">
|
{hideAuthorMeta ? (
|
||||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
|
||||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
) : (
|
||||||
</div>
|
<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">
|
<div className="post-header-actions">
|
||||||
{(mediaDeleted || dbDeleted) && (
|
{(mediaDeleted || dbDeleted) && (
|
||||||
<span className="post-deleted-badge">
|
<span className="post-deleted-badge">
|
||||||
|
|||||||
@@ -1783,6 +1783,30 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.standalone-phase-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.empty-chat-inline {
|
.empty-chat-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -204,8 +204,13 @@ function formatYmdHmDateTime(timestamp?: number): string {
|
|||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
standaloneSessionWindow?: boolean
|
standaloneSessionWindow?: boolean
|
||||||
initialSessionId?: string | null
|
initialSessionId?: string | null
|
||||||
|
standaloneSource?: string | null
|
||||||
|
standaloneInitialDisplayName?: string | null
|
||||||
|
standaloneInitialAvatarUrl?: string | null
|
||||||
|
standaloneInitialContactType?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready'
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
wxid: string
|
wxid: string
|
||||||
@@ -408,8 +413,20 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
|
|
||||||
|
|
||||||
function ChatPage(props: ChatPageProps) {
|
function ChatPage(props: ChatPageProps) {
|
||||||
const { standaloneSessionWindow = false, initialSessionId = null } = props
|
const {
|
||||||
|
standaloneSessionWindow = false,
|
||||||
|
initialSessionId = null,
|
||||||
|
standaloneSource = null,
|
||||||
|
standaloneInitialDisplayName = null,
|
||||||
|
standaloneInitialAvatarUrl = null,
|
||||||
|
standaloneInitialContactType = null
|
||||||
|
} = props
|
||||||
const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
|
const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
|
||||||
|
const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource])
|
||||||
|
const normalizedStandaloneInitialDisplayName = useMemo(() => String(standaloneInitialDisplayName || '').trim(), [standaloneInitialDisplayName])
|
||||||
|
const normalizedStandaloneInitialAvatarUrl = useMemo(() => String(standaloneInitialAvatarUrl || '').trim(), [standaloneInitialAvatarUrl])
|
||||||
|
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
|
||||||
|
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -493,7 +510,12 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
||||||
const [noMessageTable, setNoMessageTable] = useState(false)
|
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||||
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
|
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(normalizedStandaloneInitialDisplayName || null)
|
||||||
|
const [fallbackAvatarUrl, setFallbackAvatarUrl] = useState<string | null>(normalizedStandaloneInitialAvatarUrl || null)
|
||||||
|
const [standaloneLoadStage, setStandaloneLoadStage] = useState<StandaloneLoadStage>(
|
||||||
|
standaloneSessionWindow && normalizedInitialSessionId ? 'connecting' : 'idle'
|
||||||
|
)
|
||||||
|
const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false)
|
||||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||||
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
|
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
|
||||||
@@ -2408,9 +2430,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [appendMessages, getMessageKey])
|
}, [appendMessages, getMessageKey])
|
||||||
|
|
||||||
// 选择会话
|
// 选择会话
|
||||||
const selectSessionById = useCallback((sessionId: string) => {
|
const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
if (!normalizedSessionId || normalizedSessionId === currentSessionId) return
|
if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
|
||||||
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
|
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
|
||||||
sessionSwitchRequestSeqRef.current = switchRequestSeq
|
sessionSwitchRequestSeqRef.current = switchRequestSeq
|
||||||
|
|
||||||
@@ -2734,7 +2756,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [currentSessionId, messages.length, isLoadingMessages])
|
}, [currentSessionId, messages.length, isLoadingMessages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
|
if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
|
||||||
if (pendingSessionLoadRef.current === currentSessionId) return
|
if (pendingSessionLoadRef.current === currentSessionId) return
|
||||||
if (initialLoadRequestedSessionRef.current === currentSessionId) return
|
if (initialLoadRequestedSessionRef.current === currentSessionId) return
|
||||||
initialLoadRequestedSessionRef.current = currentSessionId
|
initialLoadRequestedSessionRef.current = currentSessionId
|
||||||
@@ -2745,7 +2767,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
forceInitialLimit: 30
|
forceInitialLimit: 30
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -2906,7 +2928,21 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback)
|
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback)
|
||||||
const currentSession = (() => {
|
const currentSession = (() => {
|
||||||
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||||
if (found || !currentSessionId) return found
|
if (found) {
|
||||||
|
if (
|
||||||
|
standaloneSessionWindow &&
|
||||||
|
normalizedInitialSessionId &&
|
||||||
|
found.username === normalizedInitialSessionId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...found,
|
||||||
|
displayName: found.displayName || fallbackDisplayName || found.username,
|
||||||
|
avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
if (!currentSessionId) return found
|
||||||
return {
|
return {
|
||||||
username: currentSessionId,
|
username: currentSessionId,
|
||||||
type: 0,
|
type: 0,
|
||||||
@@ -2916,6 +2952,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
lastTimestamp: 0,
|
lastTimestamp: 0,
|
||||||
lastMsgType: 0,
|
lastMsgType: 0,
|
||||||
displayName: fallbackDisplayName || currentSessionId,
|
displayName: fallbackDisplayName || currentSessionId,
|
||||||
|
avatarUrl: fallbackAvatarUrl || undefined,
|
||||||
} as ChatSession
|
} as ChatSession
|
||||||
})()
|
})()
|
||||||
const filteredGroupPanelMembers = useMemo(() => {
|
const filteredGroupPanelMembers = useMemo(() => {
|
||||||
@@ -2935,33 +2972,121 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [groupMemberSearchKeyword, groupPanelMembers])
|
}, [groupMemberSearchKeyword, groupPanelMembers])
|
||||||
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
|
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
|
||||||
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
|
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
|
||||||
|
const isCurrentSessionGroup = Boolean(
|
||||||
|
currentSession && (
|
||||||
|
isGroupChatSession(currentSession.username) ||
|
||||||
|
(
|
||||||
|
standaloneSessionWindow &&
|
||||||
|
currentSession.username === normalizedInitialSessionId &&
|
||||||
|
normalizedStandaloneInitialContactType === 'group'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!standaloneSessionWindow) return
|
||||||
|
setStandaloneInitialLoadRequested(false)
|
||||||
|
setStandaloneLoadStage(normalizedInitialSessionId ? 'connecting' : 'idle')
|
||||||
|
setFallbackDisplayName(normalizedStandaloneInitialDisplayName || null)
|
||||||
|
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl || null)
|
||||||
|
}, [
|
||||||
|
standaloneSessionWindow,
|
||||||
|
normalizedInitialSessionId,
|
||||||
|
normalizedStandaloneInitialDisplayName,
|
||||||
|
normalizedStandaloneInitialAvatarUrl
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!standaloneSessionWindow) return
|
||||||
|
if (!normalizedInitialSessionId) return
|
||||||
|
|
||||||
|
if (normalizedStandaloneInitialDisplayName) {
|
||||||
|
setFallbackDisplayName(normalizedStandaloneInitialDisplayName)
|
||||||
|
}
|
||||||
|
if (normalizedStandaloneInitialAvatarUrl) {
|
||||||
|
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSessionId) {
|
||||||
|
setCurrentSession(normalizedInitialSessionId, { preserveMessages: false })
|
||||||
|
}
|
||||||
|
if (!isConnected || isConnecting) {
|
||||||
|
setStandaloneLoadStage('connecting')
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
standaloneSessionWindow,
|
||||||
|
normalizedInitialSessionId,
|
||||||
|
normalizedStandaloneInitialDisplayName,
|
||||||
|
normalizedStandaloneInitialAvatarUrl,
|
||||||
|
currentSessionId,
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
setCurrentSession
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!standaloneSessionWindow) return
|
if (!standaloneSessionWindow) return
|
||||||
if (!normalizedInitialSessionId) return
|
if (!normalizedInitialSessionId) return
|
||||||
if (!isConnected || isConnecting) return
|
if (!isConnected || isConnecting) return
|
||||||
if (currentSessionId === normalizedInitialSessionId) return
|
if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return
|
||||||
selectSessionById(normalizedInitialSessionId)
|
setStandaloneInitialLoadRequested(true)
|
||||||
|
setStandaloneLoadStage('loading')
|
||||||
|
selectSessionById(normalizedInitialSessionId, {
|
||||||
|
force: currentSessionId === normalizedInitialSessionId
|
||||||
|
})
|
||||||
}, [
|
}, [
|
||||||
standaloneSessionWindow,
|
standaloneSessionWindow,
|
||||||
normalizedInitialSessionId,
|
normalizedInitialSessionId,
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
standaloneInitialLoadRequested,
|
||||||
selectSessionById
|
selectSessionById
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
|
||||||
|
if (!isConnected || isConnecting) {
|
||||||
|
setStandaloneLoadStage('connecting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!standaloneInitialLoadRequested) {
|
||||||
|
setStandaloneLoadStage('loading')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentSessionId !== normalizedInitialSessionId) {
|
||||||
|
setStandaloneLoadStage('loading')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isLoadingMessages || isSessionSwitching) {
|
||||||
|
setStandaloneLoadStage('loading')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStandaloneLoadStage('ready')
|
||||||
|
}, [
|
||||||
|
standaloneSessionWindow,
|
||||||
|
normalizedInitialSessionId,
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
standaloneInitialLoadRequested,
|
||||||
|
currentSessionId,
|
||||||
|
isLoadingMessages,
|
||||||
|
isSessionSwitching
|
||||||
|
])
|
||||||
|
|
||||||
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
|
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSessionId) return
|
if (!currentSessionId) return
|
||||||
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||||
if (found) {
|
if (found) {
|
||||||
setFallbackDisplayName(null)
|
if (found.displayName) setFallbackDisplayName(found.displayName)
|
||||||
|
if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadContactInfoBatch([currentSessionId]).then(() => {
|
loadContactInfoBatch([currentSessionId]).then(() => {
|
||||||
const cached = senderAvatarCache.get(currentSessionId)
|
const cached = senderAvatarCache.get(currentSessionId)
|
||||||
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
|
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
|
||||||
|
if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl)
|
||||||
})
|
})
|
||||||
}, [currentSessionId, sessions])
|
}, [currentSessionId, sessions])
|
||||||
|
|
||||||
@@ -3738,16 +3863,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
src={currentSession.avatarUrl}
|
src={currentSession.avatarUrl}
|
||||||
name={currentSession.displayName || currentSession.username}
|
name={currentSession.displayName || currentSession.username}
|
||||||
size={40}
|
size={40}
|
||||||
className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
|
className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'}
|
||||||
/>
|
/>
|
||||||
<div className="header-info">
|
<div className="header-info">
|
||||||
<h3>{currentSession.displayName || currentSession.username}</h3>
|
<h3>{currentSession.displayName || currentSession.username}</h3>
|
||||||
{isGroupChatSession(currentSession.username) && (
|
{isCurrentSessionGroup && (
|
||||||
<div className="header-subtitle">群聊</div>
|
<div className="header-subtitle">群聊</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
{!standaloneSessionWindow && isGroupChatSession(currentSession.username) && (
|
{!standaloneSessionWindow && isCurrentSessionGroup && (
|
||||||
<button
|
<button
|
||||||
className="icon-btn group-analytics-btn"
|
className="icon-btn group-analytics-btn"
|
||||||
onClick={handleGroupAnalytics}
|
onClick={handleGroupAnalytics}
|
||||||
@@ -3756,7 +3881,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<BarChart3 size={18} />
|
<BarChart3 size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isGroupChatSession(currentSession.username) && (
|
{isCurrentSessionGroup && (
|
||||||
<button
|
<button
|
||||||
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
|
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
|
||||||
onClick={toggleGroupMembersPanel}
|
onClick={toggleGroupMembersPanel}
|
||||||
@@ -3863,13 +3988,15 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
>
|
>
|
||||||
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} />
|
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!shouldHideStandaloneDetailButton && (
|
||||||
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
|
<button
|
||||||
onClick={toggleDetailPanel}
|
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
|
||||||
title="会话详情"
|
onClick={toggleDetailPanel}
|
||||||
>
|
title="会话详情"
|
||||||
<Info size={18} />
|
>
|
||||||
</button>
|
<Info size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3881,6 +4008,13 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
|
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
|
||||||
|
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
|
||||||
|
<div className="standalone-phase-overlay" role="status" aria-live="polite">
|
||||||
|
<Loader2 size={22} className="spin" />
|
||||||
|
<span>{standaloneLoadStage === 'connecting' ? '正在建立连接...' : '正在加载最近消息...'}</span>
|
||||||
|
{connectionError && <small>{connectionError}</small>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
|
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
|
||||||
<div className="loading-messages loading-overlay">
|
<div className="loading-messages loading-overlay">
|
||||||
<Loader2 size={24} />
|
<Loader2 size={24} />
|
||||||
@@ -3937,7 +4071,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
session={currentSession}
|
session={currentSession}
|
||||||
showTime={!showDateDivider && showTime}
|
showTime={!showDateDivider && showTime}
|
||||||
myAvatarUrl={myAvatarUrl}
|
myAvatarUrl={myAvatarUrl}
|
||||||
isGroupChat={isGroupChatSession(currentSession.username)}
|
isGroupChat={isCurrentSessionGroup}
|
||||||
onRequireModelDownload={handleRequireModelDownload}
|
onRequireModelDownload={handleRequireModelDownload}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
@@ -3969,7 +4103,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 群成员面板 */}
|
{/* 群成员面板 */}
|
||||||
{showGroupMembersPanel && isGroupChatSession(currentSession.username) && (
|
{showGroupMembersPanel && isCurrentSessionGroup && (
|
||||||
<div className="detail-panel group-members-panel">
|
<div className="detail-panel group-members-panel">
|
||||||
<div className="detail-header">
|
<div className="detail-header">
|
||||||
<h4>群成员</h4>
|
<h4>群成员</h4>
|
||||||
|
|||||||
@@ -27,6 +27,27 @@
|
|||||||
gap: 6px;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.export-section-title {
|
.export-section-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -87,6 +108,152 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.global-export-controls {
|
.global-export-controls {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -995,6 +1162,7 @@
|
|||||||
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
|
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
|
||||||
--contacts-select-col-width: 34px;
|
--contacts-select-col-width: 34px;
|
||||||
--contacts-message-col-width: 120px;
|
--contacts-message-col-width: 120px;
|
||||||
|
--contacts-media-col-width: 72px;
|
||||||
--contacts-action-col-width: 280px;
|
--contacts-action-col-width: 280px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -1167,6 +1335,16 @@
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.contacts-list-header-actions {
|
||||||
width: var(--contacts-action-col-width);
|
width: var(--contacts-action-col-width);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1347,12 +1525,72 @@
|
|||||||
width: var(--contacts-message-col-width);
|
width: var(--contacts-message-col-width);
|
||||||
min-width: var(--contacts-message-col-width);
|
min-width: var(--contacts-message-col-width);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
text-align: center;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.row-message-stats {
|
.row-message-stats {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1397,6 +1635,30 @@
|
|||||||
.row-message-stat.total .row-message-count-value {
|
.row-message-stat.total .row-message-count-value {
|
||||||
font-size: 13px;
|
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 {
|
.table-virtuoso {
|
||||||
@@ -1532,7 +1794,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-action-cell {
|
.row-action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -1542,37 +1804,10 @@
|
|||||||
|
|
||||||
.row-action-main {
|
.row-action-main {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-detail-btn {
|
.row-detail-btn {
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -1596,47 +1831,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-export-btn {
|
.row-export-action-stack {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-export-link {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
padding: 0;
|
||||||
padding: 7px 10px;
|
margin: 0;
|
||||||
background: var(--primary);
|
background: transparent;
|
||||||
color: #fff;
|
color: var(--primary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
line-height: 1.2;
|
||||||
align-items: center;
|
font-weight: 600;
|
||||||
gap: 5px;
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--primary-hover);
|
color: var(--primary-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.75;
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.running {
|
&:focus-visible {
|
||||||
background: color-mix(in srgb, var(--primary) 80%, #000);
|
outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.paused {
|
&.state-running {
|
||||||
background: rgba(250, 173, 20, 0.16);
|
cursor: progress;
|
||||||
color: #d48806;
|
|
||||||
border: 1px solid rgba(250, 173, 20, 0.38);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.no-session {
|
&.state-disabled {
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
border: 1px dashed var(--border-color);
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-export-time {
|
.row-export-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
color: var(--text-tertiary);
|
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 +2104,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-sns-entry-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-btn {
|
.copy-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1951,6 +2219,333 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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-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 14px 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 {
|
.table-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2660,8 +3255,22 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-load-detail-entry {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-modal {
|
||||||
|
width: min(94vw, 820px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.table-wrap {
|
||||||
--contacts-message-col-width: 104px;
|
--contacts-message-col-width: 104px;
|
||||||
|
--contacts-media-col-width: 62px;
|
||||||
--contacts-action-col-width: 236px;
|
--contacts-action-col-width: 236px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2688,6 +3297,10 @@
|
|||||||
min-width: var(--contacts-message-col-width);
|
min-width: var(--contacts-message-col-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-wrap .row-media-metric {
|
||||||
|
min-width: var(--contacts-media-col-width);
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap .row-message-stats {
|
.table-wrap .row-message-stats {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
@@ -2700,10 +3313,19 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-wrap .row-media-metric-value {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap .row-message-stat.total .row-message-count-value {
|
.table-wrap .row-message-stat.total .row-message-count-value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-wrap .row-open-chat-link,
|
||||||
|
.table-wrap .row-export-link {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.export-dialog-overlay {
|
.export-dialog-overlay {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
@@ -2746,4 +3368,41 @@
|
|||||||
.export-session-detail-panel {
|
.export-session-detail-panel {
|
||||||
width: calc(100vw - 12px);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -179,6 +179,30 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author-trigger-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-trigger {
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.post-content-col {
|
.post-content-col {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -206,6 +230,30 @@
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author-name-trigger {
|
||||||
|
align-self: flex-start;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
transition: color 0.15s ease, text-decoration-color 0.15s ease;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .author-name {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.post-time {
|
.post-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
@@ -219,6 +267,13 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-time-standalone {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.2;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.debug-btn {
|
.debug-btn {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
@@ -1043,6 +1098,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-count-progress {
|
||||||
|
padding: 8px 16px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-list-scroll {
|
.contact-list-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1120,6 +1183,40 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-post-count-wrap {
|
||||||
|
margin-left: 8px;
|
||||||
|
min-width: 46px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-post-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-post-count-loading {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
.contact-post-count {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,6 +1414,116 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author-timeline-dialog {
|
||||||
|
background: var(--sns-card-bg);
|
||||||
|
border-radius: var(--sns-border-radius-lg);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
width: min(860px, 94vw);
|
||||||
|
max-height: 86vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-header {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-meta-text {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-username {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-stats {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-body {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: calc(86vh - 96px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-posts-list {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-loading {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-empty {
|
||||||
|
padding: 42px 10px 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto 2px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slide-up-fade {
|
@keyframes slide-up-fade {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -5,17 +5,31 @@ import './SnsPage.scss'
|
|||||||
import { SnsPost } from '../types/sns'
|
import { SnsPost } from '../types/sns'
|
||||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||||
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
|
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
|
||||||
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||||
const SNS_PAGE_CACHE_POST_LIMIT = 200
|
const SNS_PAGE_CACHE_POST_LIMIT = 200
|
||||||
const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__'
|
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 {
|
interface Contact {
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type?: 'friend' | 'former_friend' | 'sns_only'
|
type?: 'friend' | 'former_friend' | 'sns_only'
|
||||||
|
lastSessionTimestamp?: number
|
||||||
|
postCount?: number
|
||||||
|
postCountStatus?: ContactPostCountStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactsCountProgress {
|
||||||
|
resolved: number
|
||||||
|
total: number
|
||||||
|
running: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SnsOverviewStats {
|
interface SnsOverviewStats {
|
||||||
@@ -28,6 +42,12 @@ interface SnsOverviewStats {
|
|||||||
|
|
||||||
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
interface AuthorTimelineTarget {
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function SnsPage() {
|
export default function SnsPage() {
|
||||||
const [posts, setPosts] = useState<SnsPost[]>([])
|
const [posts, setPosts] = useState<SnsPost[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -51,10 +71,22 @@ export default function SnsPage() {
|
|||||||
const [contacts, setContacts] = useState<Contact[]>([])
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
const [contactSearch, setContactSearch] = useState('')
|
const [contactSearch, setContactSearch] = useState('')
|
||||||
const [contactsLoading, setContactsLoading] = useState(false)
|
const [contactsLoading, setContactsLoading] = useState(false)
|
||||||
|
const [contactsCountProgress, setContactsCountProgress] = useState<ContactsCountProgress>({
|
||||||
|
resolved: 0,
|
||||||
|
total: 0,
|
||||||
|
running: false
|
||||||
|
})
|
||||||
|
|
||||||
// UI states
|
// UI states
|
||||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||||
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
|
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
|
||||||
|
const [authorTimelineTarget, setAuthorTimelineTarget] = useState<AuthorTimelineTarget | null>(null)
|
||||||
|
const [authorTimelinePosts, setAuthorTimelinePosts] = useState<SnsPost[]>([])
|
||||||
|
const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false)
|
||||||
|
const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false)
|
||||||
|
const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false)
|
||||||
|
const [authorTimelineTotalPosts, setAuthorTimelineTotalPosts] = useState<number | null>(null)
|
||||||
|
const [authorTimelineStatsLoading, setAuthorTimelineStatsLoading] = useState(false)
|
||||||
|
|
||||||
// 导出相关状态
|
// 导出相关状态
|
||||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||||
@@ -81,19 +113,30 @@ export default function SnsPage() {
|
|||||||
const [hasNewer, setHasNewer] = useState(false)
|
const [hasNewer, setHasNewer] = useState(false)
|
||||||
const [loadingNewer, setLoadingNewer] = useState(false)
|
const [loadingNewer, setLoadingNewer] = useState(false)
|
||||||
const postsRef = useRef<SnsPost[]>([])
|
const postsRef = useRef<SnsPost[]>([])
|
||||||
|
const contactsRef = useRef<Contact[]>([])
|
||||||
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
|
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
|
||||||
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
|
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
|
||||||
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
|
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
|
||||||
const searchKeywordRef = useRef(searchKeyword)
|
const searchKeywordRef = useRef(searchKeyword)
|
||||||
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
|
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
|
||||||
const cacheScopeKeyRef = useRef('')
|
const cacheScopeKeyRef = useRef('')
|
||||||
|
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
||||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||||
const contactsLoadTokenRef = useRef(0)
|
const contactsLoadTokenRef = useRef(0)
|
||||||
|
const contactsCountHydrationTokenRef = useRef(0)
|
||||||
|
const contactsCountBatchTimerRef = useRef<number | null>(null)
|
||||||
|
const authorTimelinePostsRef = useRef<SnsPost[]>([])
|
||||||
|
const authorTimelineLoadingRef = useRef(false)
|
||||||
|
const authorTimelineRequestTokenRef = useRef(0)
|
||||||
|
const authorTimelineStatsTokenRef = useRef(0)
|
||||||
|
|
||||||
// Sync posts ref
|
// Sync posts ref
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
postsRef.current = posts
|
postsRef.current = posts
|
||||||
}, [posts])
|
}, [posts])
|
||||||
|
useEffect(() => {
|
||||||
|
contactsRef.current = contacts
|
||||||
|
}, [contacts])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
overviewStatsRef.current = overviewStats
|
overviewStatsRef.current = overviewStats
|
||||||
}, [overviewStats])
|
}, [overviewStats])
|
||||||
@@ -109,6 +152,9 @@ export default function SnsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
jumpTargetDateRef.current = jumpTargetDate
|
jumpTargetDateRef.current = jumpTargetDate
|
||||||
}, [jumpTargetDate])
|
}, [jumpTargetDate])
|
||||||
|
useEffect(() => {
|
||||||
|
authorTimelinePostsRef.current = authorTimelinePosts
|
||||||
|
}, [authorTimelinePosts])
|
||||||
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
|
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const snapshot = scrollAdjustmentRef.current;
|
const snapshot = scrollAdjustmentRef.current;
|
||||||
@@ -132,6 +178,43 @@ export default function SnsPage() {
|
|||||||
return `${year}-${month}-${day}`
|
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 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 isDefaultViewNow = useCallback(() => {
|
const isDefaultViewNow = useCallback(() => {
|
||||||
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
|
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
|
||||||
}, [])
|
}, [])
|
||||||
@@ -144,6 +227,21 @@ export default function SnsPage() {
|
|||||||
return scopeKey
|
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 }) => {
|
const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => {
|
||||||
if (!isDefaultViewNow()) return
|
if (!isDefaultViewNow()) return
|
||||||
try {
|
try {
|
||||||
@@ -275,8 +373,7 @@ export default function SnsPage() {
|
|||||||
if (overviewStatsStatus === 'loading') {
|
if (overviewStatsStatus === 'loading') {
|
||||||
return '统计中...'
|
return '统计中...'
|
||||||
}
|
}
|
||||||
const myPostsLabel = overviewStats.myPosts === null ? '--' : String(overviewStats.myPosts)
|
return `共 ${overviewStats.totalPosts} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友`
|
||||||
return `共 ${overviewStats.totalPosts} 条 | 我的朋友圈 ${myPostsLabel} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
||||||
@@ -390,31 +487,235 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
|
}, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
|
||||||
|
|
||||||
// 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 loadContacts = useCallback(async () => {
|
||||||
const requestToken = ++contactsLoadTokenRef.current
|
const requestToken = ++contactsLoadTokenRef.current
|
||||||
|
stopContactsCountHydration(true)
|
||||||
setContactsLoading(true)
|
setContactsLoading(true)
|
||||||
try {
|
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,
|
||||||
|
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 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) {
|
if (contactsResult.success && contactsResult.contacts) {
|
||||||
for (const c of contactsResult.contacts) {
|
for (const c of contactsResult.contacts) {
|
||||||
if (c.type === 'friend' || c.type === 'former_friend') {
|
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, {
|
contactMap.set(c.username, {
|
||||||
username: c.username,
|
username: c.username,
|
||||||
displayName: c.displayName,
|
displayName: c.displayName,
|
||||||
avatarUrl: c.avatarUrl,
|
avatarUrl: c.avatarUrl,
|
||||||
type: c.type === 'former_friend' ? 'former_friend' : 'friend'
|
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
|
if (requestToken !== contactsLoadTokenRef.current) return
|
||||||
setContacts(contactsList)
|
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)
|
const allUsernames = contactsList.map(c => c.username)
|
||||||
|
|
||||||
@@ -422,7 +723,7 @@ export default function SnsPage() {
|
|||||||
if (allUsernames.length > 0) {
|
if (allUsernames.length > 0) {
|
||||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
||||||
if (enriched.success && enriched.contacts) {
|
if (enriched.success && enriched.contacts) {
|
||||||
contactsList = contactsList.map(contact => {
|
contactsList = contactsList.map((contact) => {
|
||||||
const extra = enriched.contacts?.[contact.username]
|
const extra = enriched.contacts?.[contact.username]
|
||||||
if (!extra) return contact
|
if (!extra) return contact
|
||||||
return {
|
return {
|
||||||
@@ -432,19 +733,178 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (requestToken !== contactsLoadTokenRef.current) return
|
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) {
|
} catch (error) {
|
||||||
if (requestToken !== contactsLoadTokenRef.current) return
|
if (requestToken !== contactsLoadTokenRef.current) return
|
||||||
console.error('Failed to load contacts:', error)
|
console.error('Failed to load contacts:', error)
|
||||||
|
stopContactsCountHydration(true)
|
||||||
} finally {
|
} finally {
|
||||||
if (requestToken === contactsLoadTokenRef.current) {
|
if (requestToken === contactsLoadTokenRef.current) {
|
||||||
setContactsLoading(false)
|
setContactsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration])
|
||||||
|
|
||||||
|
const closeAuthorTimeline = useCallback(() => {
|
||||||
|
authorTimelineRequestTokenRef.current += 1
|
||||||
|
authorTimelineStatsTokenRef.current += 1
|
||||||
|
authorTimelineLoadingRef.current = false
|
||||||
|
setAuthorTimelineTarget(null)
|
||||||
|
setAuthorTimelinePosts([])
|
||||||
|
setAuthorTimelineLoading(false)
|
||||||
|
setAuthorTimelineLoadingMore(false)
|
||||||
|
setAuthorTimelineHasMore(false)
|
||||||
|
setAuthorTimelineTotalPosts(null)
|
||||||
|
setAuthorTimelineStatsLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadAuthorTimelineTotalPosts = useCallback(async (target: AuthorTimelineTarget) => {
|
||||||
|
const requestToken = ++authorTimelineStatsTokenRef.current
|
||||||
|
setAuthorTimelineStatsLoading(true)
|
||||||
|
setAuthorTimelineTotalPosts(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||||
|
if (requestToken !== authorTimelineStatsTokenRef.current) return
|
||||||
|
|
||||||
|
if (result.success && result.counts) {
|
||||||
|
const totalPosts = result.counts[target.username] ?? 0
|
||||||
|
setAuthorTimelineTotalPosts(Math.max(0, Number(totalPosts || 0)))
|
||||||
|
} else {
|
||||||
|
setAuthorTimelineTotalPosts(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load author timeline total posts:', error)
|
||||||
|
if (requestToken === authorTimelineStatsTokenRef.current) {
|
||||||
|
setAuthorTimelineTotalPosts(null)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestToken === authorTimelineStatsTokenRef.current) {
|
||||||
|
setAuthorTimelineStatsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => {
|
||||||
|
const { reset = false } = options
|
||||||
|
if (authorTimelineLoadingRef.current) return
|
||||||
|
|
||||||
|
authorTimelineLoadingRef.current = true
|
||||||
|
if (reset) {
|
||||||
|
setAuthorTimelineLoading(true)
|
||||||
|
setAuthorTimelineLoadingMore(false)
|
||||||
|
setAuthorTimelineHasMore(false)
|
||||||
|
} else {
|
||||||
|
setAuthorTimelineLoadingMore(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestToken = ++authorTimelineRequestTokenRef.current
|
||||||
|
|
||||||
|
try {
|
||||||
|
const limit = 20
|
||||||
|
let endTs: number | undefined = undefined
|
||||||
|
|
||||||
|
if (!reset && authorTimelinePostsRef.current.length > 0) {
|
||||||
|
endTs = authorTimelinePostsRef.current[authorTimelinePostsRef.current.length - 1].createTime - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
|
limit,
|
||||||
|
0,
|
||||||
|
[target.username],
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
endTs
|
||||||
|
)
|
||||||
|
|
||||||
|
if (requestToken !== authorTimelineRequestTokenRef.current) return
|
||||||
|
if (!result.success || !result.timeline) {
|
||||||
|
if (reset) {
|
||||||
|
setAuthorTimelinePosts([])
|
||||||
|
setAuthorTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
const sorted = [...result.timeline].sort((a, b) => b.createTime - a.createTime)
|
||||||
|
setAuthorTimelinePosts(sorted)
|
||||||
|
setAuthorTimelineHasMore(result.timeline.length >= limit)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIds = new Set(authorTimelinePostsRef.current.map((p) => p.id))
|
||||||
|
const uniqueOlder = result.timeline.filter((p) => !existingIds.has(p.id))
|
||||||
|
if (uniqueOlder.length > 0) {
|
||||||
|
const merged = [...authorTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime)
|
||||||
|
setAuthorTimelinePosts(merged)
|
||||||
|
}
|
||||||
|
if (result.timeline.length < limit) {
|
||||||
|
setAuthorTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load author timeline:', error)
|
||||||
|
if (requestToken === authorTimelineRequestTokenRef.current && reset) {
|
||||||
|
setAuthorTimelinePosts([])
|
||||||
|
setAuthorTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestToken === authorTimelineRequestTokenRef.current) {
|
||||||
|
authorTimelineLoadingRef.current = false
|
||||||
|
setAuthorTimelineLoading(false)
|
||||||
|
setAuthorTimelineLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openAuthorTimeline = useCallback((post: SnsPost) => {
|
||||||
|
authorTimelineRequestTokenRef.current += 1
|
||||||
|
authorTimelineLoadingRef.current = false
|
||||||
|
const target = {
|
||||||
|
username: post.username,
|
||||||
|
nickname: post.nickname,
|
||||||
|
avatarUrl: post.avatarUrl
|
||||||
|
}
|
||||||
|
setAuthorTimelineTarget(target)
|
||||||
|
setAuthorTimelinePosts([])
|
||||||
|
setAuthorTimelineHasMore(false)
|
||||||
|
setAuthorTimelineTotalPosts(null)
|
||||||
|
void loadAuthorTimelinePosts(target, { reset: true })
|
||||||
|
void loadAuthorTimelineTotalPosts(target)
|
||||||
|
}, [loadAuthorTimelinePosts, loadAuthorTimelineTotalPosts])
|
||||||
|
|
||||||
|
const loadMoreAuthorTimeline = useCallback(() => {
|
||||||
|
if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return
|
||||||
|
void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false })
|
||||||
|
}, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts])
|
||||||
|
|
||||||
|
const handlePostDelete = useCallback((postId: string, username: string) => {
|
||||||
|
setPosts(prev => {
|
||||||
|
const next = prev.filter(p => p.id !== postId)
|
||||||
|
void persistSnsPageCache({ posts: next })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId))
|
||||||
|
if (authorTimelineTarget && authorTimelineTarget.username === username) {
|
||||||
|
setAuthorTimelineTotalPosts(prev => prev === null ? null : Math.max(0, prev - 1))
|
||||||
|
}
|
||||||
|
void loadOverviewStats()
|
||||||
|
}, [authorTimelineTarget, loadOverviewStats, persistSnsPageCache])
|
||||||
|
|
||||||
// Initial Load & Listeners
|
// Initial Load & Listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void hydrateSnsPageCache()
|
void hydrateSnsPageCache()
|
||||||
@@ -452,10 +912,23 @@ export default function SnsPage() {
|
|||||||
loadOverviewStats()
|
loadOverviewStats()
|
||||||
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
contactsCountHydrationTokenRef.current += 1
|
||||||
|
if (contactsCountBatchTimerRef.current) {
|
||||||
|
window.clearTimeout(contactsCountBatchTimerRef.current)
|
||||||
|
contactsCountBatchTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
cacheScopeKeyRef.current = ''
|
cacheScopeKeyRef.current = ''
|
||||||
|
snsUserPostCountsCacheScopeKeyRef.current = ''
|
||||||
// wxid changed, reset everything
|
// wxid changed, reset everything
|
||||||
|
stopContactsCountHydration(true)
|
||||||
|
setContacts([])
|
||||||
setPosts([]); setHasMore(true); setHasNewer(false);
|
setPosts([]); setHasMore(true); setHasNewer(false);
|
||||||
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
|
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
|
||||||
void hydrateSnsPageCache()
|
void hydrateSnsPageCache()
|
||||||
@@ -465,7 +938,7 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts])
|
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts, stopContactsCountHydration])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -474,6 +947,24 @@ export default function SnsPage() {
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts])
|
}, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authorTimelineTarget) return
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeAuthorTimeline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [authorTimelineTarget, closeAuthorTimeline])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authorTimelineTotalPosts === null) return
|
||||||
|
if (authorTimelinePosts.length >= authorTimelineTotalPosts) {
|
||||||
|
setAuthorTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
}, [authorTimelinePosts.length, authorTimelineTotalPosts])
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
||||||
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
|
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
|
||||||
@@ -492,6 +983,29 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAuthorTimelineScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
||||||
|
if (scrollHeight - scrollTop - clientHeight < 260) {
|
||||||
|
loadMoreAuthorTimeline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderAuthorTimelineStats = () => {
|
||||||
|
const loadedCount = authorTimelinePosts.length
|
||||||
|
const loadPart = authorTimelineStatsLoading
|
||||||
|
? `已加载 ${loadedCount} / 总数统计中...`
|
||||||
|
: authorTimelineTotalPosts === null
|
||||||
|
? `已加载 ${loadedCount} 条`
|
||||||
|
: `已加载 ${loadedCount} / 共 ${authorTimelineTotalPosts} 条`
|
||||||
|
|
||||||
|
if (authorTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...`
|
||||||
|
if (loadedCount === 0) return loadPart
|
||||||
|
|
||||||
|
const latest = authorTimelinePosts[0]?.createTime ?? null
|
||||||
|
const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null
|
||||||
|
return `${loadPart} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sns-page-layout">
|
<div className="sns-page-layout">
|
||||||
<div className="sns-main-viewport">
|
<div className="sns-main-viewport">
|
||||||
@@ -578,14 +1092,8 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDebug={(p) => setDebugPost(p)}
|
onDebug={(p) => setDebugPost(p)}
|
||||||
onDelete={(postId) => {
|
onDelete={handlePostDelete}
|
||||||
setPosts(prev => {
|
onOpenAuthorPosts={openAuthorTimeline}
|
||||||
const next = prev.filter(p => p.id !== postId)
|
|
||||||
void persistSnsPageCache({ posts: next })
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
loadOverviewStats()
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -644,6 +1152,7 @@ export default function SnsPage() {
|
|||||||
contactSearch={contactSearch}
|
contactSearch={contactSearch}
|
||||||
setContactSearch={setContactSearch}
|
setContactSearch={setContactSearch}
|
||||||
loading={contactsLoading}
|
loading={contactsLoading}
|
||||||
|
contactsCountProgress={contactsCountProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialogs and Overlays */}
|
{/* Dialogs and Overlays */}
|
||||||
@@ -657,6 +1166,77 @@ export default function SnsPage() {
|
|||||||
currentDate={jumpTargetDate || new Date()}
|
currentDate={jumpTargetDate || new Date()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{authorTimelineTarget && (
|
||||||
|
<div className="modal-overlay" onClick={closeAuthorTimeline}>
|
||||||
|
<div className="author-timeline-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="author-timeline-header">
|
||||||
|
<div className="author-timeline-meta">
|
||||||
|
<Avatar
|
||||||
|
src={authorTimelineTarget.avatarUrl}
|
||||||
|
name={authorTimelineTarget.nickname}
|
||||||
|
size={42}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
<div className="author-timeline-meta-text">
|
||||||
|
<h3>{decodeHtmlEntities(authorTimelineTarget.nickname)}</h3>
|
||||||
|
<div className="author-timeline-username">@{authorTimelineTarget.username}</div>
|
||||||
|
<div className="author-timeline-stats">{renderAuthorTimelineStats()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="close-btn" onClick={closeAuthorTimeline}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="author-timeline-body" onScroll={handleAuthorTimelineScroll}>
|
||||||
|
{authorTimelinePosts.length > 0 && (
|
||||||
|
<div className="posts-list author-timeline-posts-list">
|
||||||
|
{authorTimelinePosts.map(post => (
|
||||||
|
<SnsPostItem
|
||||||
|
key={post.id}
|
||||||
|
post={{ ...post, isProtected: triggerInstalled === true }}
|
||||||
|
onPreview={(src, isVideo, liveVideoPath) => {
|
||||||
|
if (isVideo) {
|
||||||
|
void window.electronAPI.window.openVideoPlayerWindow(src)
|
||||||
|
} else {
|
||||||
|
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDebug={(p) => setDebugPost(p)}
|
||||||
|
onDelete={handlePostDelete}
|
||||||
|
onOpenAuthorPosts={openAuthorTimeline}
|
||||||
|
hideAuthorMeta
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authorTimelineLoading && (
|
||||||
|
<div className="status-indicator loading-more author-timeline-loading">
|
||||||
|
<RefreshCw size={16} className="spinning" />
|
||||||
|
<span>正在加载该用户朋友圈...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!authorTimelineLoading && authorTimelinePosts.length === 0 && (
|
||||||
|
<div className="author-timeline-empty">该用户暂无朋友圈</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!authorTimelineLoading && authorTimelineHasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="author-timeline-load-more"
|
||||||
|
onClick={loadMoreAuthorTimeline}
|
||||||
|
disabled={authorTimelineLoadingMore}
|
||||||
|
>
|
||||||
|
{authorTimelineLoadingMore ? '正在加载...' : '加载更多'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{debugPost && (
|
{debugPost && (
|
||||||
<div className="modal-overlay" onClick={() => setDebugPost(null)}>
|
<div className="modal-overlay" onClick={() => setDebugPost(null)}>
|
||||||
<div className="debug-dialog" onClick={(e) => e.stopPropagation()}>
|
<div className="debug-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
||||||
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||||
|
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||||
@@ -533,6 +534,11 @@ export interface ExportSnsStatsCacheItem {
|
|||||||
totalFriends: number
|
totalFriends: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExportSnsUserPostCountsCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
counts: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnsPageOverviewCache {
|
export interface SnsPageOverviewCache {
|
||||||
totalPosts: number
|
totalPosts: number
|
||||||
totalFriends: number
|
totalFriends: number
|
||||||
@@ -740,6 +746,58 @@ export async function setExportSnsStatsCache(
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
|
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getExportSnsUserPostCountsCache(scopeKey: string): Promise<ExportSnsUserPostCountsCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const raw = rawItem as Record<string, unknown>
|
||||||
|
const rawCounts = raw.counts
|
||||||
|
if (!rawCounts || typeof rawCounts !== 'object') return null
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const [rawUsername, rawCount] of Object.entries(rawCounts as Record<string, unknown>)) {
|
||||||
|
const username = String(rawUsername || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const valueNum = Number(rawCount)
|
||||||
|
counts[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
|
||||||
|
? raw.updatedAt
|
||||||
|
: 0
|
||||||
|
return { updatedAt, counts }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSnsUserPostCountsCache(
|
||||||
|
scopeKey: string,
|
||||||
|
counts: Record<string, number>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, number> = {}
|
||||||
|
for (const [rawUsername, rawCount] of Object.entries(counts || {})) {
|
||||||
|
const username = String(rawUsername || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const valueNum = Number(rawCount)
|
||||||
|
normalized[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
counts: normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
||||||
if (!scopeKey) return null
|
if (!scopeKey) return null
|
||||||
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||||
|
|||||||
19
src/types/electron.d.ts
vendored
19
src/types/electron.d.ts
vendored
@@ -1,5 +1,12 @@
|
|||||||
import type { ChatSession, Message, Contact, ContactInfo } from './models'
|
import type { ChatSession, Message, Contact, ContactInfo } from './models'
|
||||||
|
|
||||||
|
export interface SessionChatWindowOpenOptions {
|
||||||
|
source?: 'chat' | 'export'
|
||||||
|
initialDisplayName?: string
|
||||||
|
initialAvatarUrl?: string
|
||||||
|
initialContactType?: ContactInfo['type']
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
window: {
|
window: {
|
||||||
minimize: () => void
|
minimize: () => void
|
||||||
@@ -13,7 +20,7 @@ export interface ElectronAPI {
|
|||||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||||
openSessionChatWindow: (sessionId: string) => Promise<boolean>
|
openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
|
||||||
}
|
}
|
||||||
config: {
|
config: {
|
||||||
get: (key: string) => Promise<unknown>
|
get: (key: string) => Promise<unknown>
|
||||||
@@ -250,7 +257,13 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
getExportSessionStats: (
|
getExportSessionStats: (
|
||||||
sessionIds: string[],
|
sessionIds: string[],
|
||||||
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
|
options?: {
|
||||||
|
includeRelations?: boolean
|
||||||
|
forceRefresh?: boolean
|
||||||
|
allowStaleCache?: boolean
|
||||||
|
preferAccurateSpecialTypes?: boolean
|
||||||
|
cacheOnly?: boolean
|
||||||
|
}
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: Record<string, {
|
data?: Record<string, {
|
||||||
@@ -776,8 +789,10 @@ export interface ElectronAPI {
|
|||||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||||
|
getUserPostCounts: () => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
|
||||||
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
|
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
|
||||||
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
|
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
|
||||||
|
getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }>
|
||||||
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
|
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||||
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
||||||
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
|
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
|
||||||
|
|||||||
Reference in New Issue
Block a user