mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
@@ -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
@@ -405,8 +405,22 @@ function App() {
|
|||||||
|
|
||||||
// 独立会话聊天窗口(仅显示聊天内容区域)
|
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||||
if (isStandaloneChatWindow) {
|
if (isStandaloneChatWindow) {
|
||||||
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
|
const params = new URLSearchParams(location.search)
|
||||||
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
|
const sessionId = params.get('sessionId') || ''
|
||||||
|
const standaloneSource = params.get('source')
|
||||||
|
const standaloneInitialDisplayName = params.get('initialDisplayName')
|
||||||
|
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
|
||||||
|
const standaloneInitialContactType = params.get('initialContactType')
|
||||||
|
return (
|
||||||
|
<ChatPage
|
||||||
|
standaloneSessionWindow
|
||||||
|
initialSessionId={sessionId}
|
||||||
|
standaloneSource={standaloneSource}
|
||||||
|
standaloneInitialDisplayName={standaloneInitialDisplayName}
|
||||||
|
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
|
||||||
|
standaloneInitialContactType={standaloneInitialContactType}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 独立通知窗口
|
// 独立通知窗口
|
||||||
|
|||||||
254
src/components/Export/ExportDateRangeDialog.scss
Normal file
254
src/components/Export/ExportDateRangeDialog.scss
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
.export-date-range-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 2400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog {
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-close-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-preset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-preset-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-mode-banner {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&.range {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.4);
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-panel {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-date-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-date-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
&.invalid {
|
||||||
|
border-color: #e84d4d;
|
||||||
|
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-nav {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-weekdays {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-days {
|
||||||
|
margin-top: 4px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-day {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 20px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&.outside {
|
||||||
|
color: var(--text-quaternary);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.14);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-btn {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.export-date-range-calendar-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
340
src/components/Export/ExportDateRangeDialog.tsx
Normal file
340
src/components/Export/ExportDateRangeDialog.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Check, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
EXPORT_DATE_RANGE_PRESETS,
|
||||||
|
WEEKDAY_SHORT_LABELS,
|
||||||
|
addMonths,
|
||||||
|
buildCalendarCells,
|
||||||
|
cloneExportDateRangeSelection,
|
||||||
|
createDateRangeByPreset,
|
||||||
|
createDefaultDateRange,
|
||||||
|
formatCalendarMonthTitle,
|
||||||
|
formatDateInputValue,
|
||||||
|
isSameDay,
|
||||||
|
parseDateInputValue,
|
||||||
|
startOfDay,
|
||||||
|
endOfDay,
|
||||||
|
toMonthStart,
|
||||||
|
type ExportDateRangePreset,
|
||||||
|
type ExportDateRangeSelection
|
||||||
|
} from '../../utils/exportDateRange'
|
||||||
|
import './ExportDateRangeDialog.scss'
|
||||||
|
|
||||||
|
interface ExportDateRangeDialogProps {
|
||||||
|
open: boolean
|
||||||
|
value: ExportDateRangeSelection
|
||||||
|
title?: string
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: (value: ExportDateRangeSelection) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
||||||
|
startPanelMonth: Date
|
||||||
|
endPanelMonth: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
|
||||||
|
...cloneExportDateRangeSelection(value),
|
||||||
|
startPanelMonth: toMonthStart(value.dateRange.start),
|
||||||
|
endPanelMonth: toMonthStart(value.dateRange.end)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ExportDateRangeDialog({
|
||||||
|
open,
|
||||||
|
value,
|
||||||
|
title = '时间范围设置',
|
||||||
|
onClose,
|
||||||
|
onConfirm
|
||||||
|
}: ExportDateRangeDialogProps) {
|
||||||
|
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
|
||||||
|
const [dateInput, setDateInput] = useState({
|
||||||
|
start: formatDateInputValue(value.dateRange.start),
|
||||||
|
end: formatDateInputValue(value.dateRange.end)
|
||||||
|
})
|
||||||
|
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const nextDraft = buildDialogDraft(value)
|
||||||
|
setDraft(nextDraft)
|
||||||
|
setDateInput({
|
||||||
|
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||||
|
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||||
|
})
|
||||||
|
setDateInputError({ start: false, end: false })
|
||||||
|
}, [open, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setDateInput({
|
||||||
|
start: formatDateInputValue(draft.dateRange.start),
|
||||||
|
end: formatDateInputValue(draft.dateRange.end)
|
||||||
|
})
|
||||||
|
setDateInputError({ start: false, end: false })
|
||||||
|
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||||
|
|
||||||
|
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
|
||||||
|
if (preset === 'all') {
|
||||||
|
const previewRange = createDefaultDateRange()
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
preset,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: previewRange,
|
||||||
|
startPanelMonth: toMonthStart(previewRange.start),
|
||||||
|
endPanelMonth: toMonthStart(previewRange.end)
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = createDateRangeByPreset(preset)
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
preset,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: range,
|
||||||
|
startPanelMonth: toMonthStart(range.start),
|
||||||
|
endPanelMonth: toMonthStart(range.end)
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateDraftStart = useCallback((targetDate: Date) => {
|
||||||
|
const start = startOfDay(targetDate)
|
||||||
|
setDraft(prev => {
|
||||||
|
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start,
|
||||||
|
end: nextEnd
|
||||||
|
},
|
||||||
|
startPanelMonth: toMonthStart(start),
|
||||||
|
endPanelMonth: toMonthStart(nextEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateDraftEnd = useCallback((targetDate: Date) => {
|
||||||
|
const end = endOfDay(targetDate)
|
||||||
|
setDraft(prev => {
|
||||||
|
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||||
|
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
},
|
||||||
|
startPanelMonth: toMonthStart(nextStart),
|
||||||
|
endPanelMonth: toMonthStart(nextEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commitStartFromInput = useCallback(() => {
|
||||||
|
const parsed = parseDateInputValue(dateInput.start)
|
||||||
|
if (!parsed) {
|
||||||
|
setDateInputError(prev => ({ ...prev, start: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
|
updateDraftStart(parsed)
|
||||||
|
}, [dateInput.start, updateDraftStart])
|
||||||
|
|
||||||
|
const commitEndFromInput = useCallback(() => {
|
||||||
|
const parsed = parseDateInputValue(dateInput.end)
|
||||||
|
if (!parsed) {
|
||||||
|
setDateInputError(prev => ({ ...prev, end: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
|
updateDraftEnd(parsed)
|
||||||
|
}, [dateInput.end, updateDraftEnd])
|
||||||
|
|
||||||
|
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
|
||||||
|
setDraft(prev => (
|
||||||
|
panel === 'start'
|
||||||
|
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
|
||||||
|
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
|
||||||
|
))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isRangeModeActive = !draft.useAllTime
|
||||||
|
const modeText = isRangeModeActive
|
||||||
|
? '当前导出模式:按时间范围导出'
|
||||||
|
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
|
||||||
|
|
||||||
|
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
|
||||||
|
if (preset === 'all') return draft.useAllTime
|
||||||
|
return !draft.useAllTime && draft.preset === preset
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
|
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
|
||||||
|
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="export-date-range-dialog-overlay" onClick={onClose}>
|
||||||
|
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="export-date-range-dialog-header">
|
||||||
|
<h4>{title}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-date-range-dialog-close-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭时间范围设置"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-preset-list">
|
||||||
|
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
|
||||||
|
const active = isPresetActive(preset.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-preset-item ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => applyPreset(preset.value)}
|
||||||
|
>
|
||||||
|
<span>{preset.label}</span>
|
||||||
|
{active && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`export-date-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
|
||||||
|
{modeText}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-calendar-grid">
|
||||||
|
<section className="export-date-range-calendar-panel">
|
||||||
|
<div className="export-date-range-calendar-panel-header">
|
||||||
|
<div className="export-date-range-calendar-date-label">
|
||||||
|
<span>起始日期</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
|
||||||
|
value={dateInput.start}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value
|
||||||
|
setDateInput(prev => ({ ...prev, start: nextValue }))
|
||||||
|
if (dateInputError.start) {
|
||||||
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
commitStartFromInput()
|
||||||
|
}}
|
||||||
|
onBlur={commitStartFromInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-nav">
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月">‹</button>
|
||||||
|
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-weekdays">
|
||||||
|
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||||
|
<span key={`start-weekday-${label}`}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-days">
|
||||||
|
{startPanelCells.map((cell) => {
|
||||||
|
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`start-${cell.date.getTime()}`}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
||||||
|
onClick={() => updateDraftStart(cell.date)}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="export-date-range-calendar-panel">
|
||||||
|
<div className="export-date-range-calendar-panel-header">
|
||||||
|
<div className="export-date-range-calendar-date-label">
|
||||||
|
<span>截止日期</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
|
||||||
|
value={dateInput.end}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value
|
||||||
|
setDateInput(prev => ({ ...prev, end: nextValue }))
|
||||||
|
if (dateInputError.end) {
|
||||||
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
commitEndFromInput()
|
||||||
|
}}
|
||||||
|
onBlur={commitEndFromInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-nav">
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月">‹</button>
|
||||||
|
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-weekdays">
|
||||||
|
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||||
|
<span key={`end-weekday-${label}`}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-days">
|
||||||
|
{endPanelCells.map((cell) => {
|
||||||
|
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`end-${cell.date.getTime()}`}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
||||||
|
onClick={() => updateDraftEnd(cell.date)}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-dialog-actions">
|
||||||
|
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-date-range-dialog-btn primary"
|
||||||
|
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
459
src/components/Export/ExportDefaultsSettingsForm.scss
Normal file
459
src/components/Export/ExportDefaultsSettingsForm.scss
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
.export-defaults-settings-form {
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 120;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-card {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-desc {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option.active .option-desc {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.45);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-arrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-default-grid {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concurrency-inline-options {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concurrency-option {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 48px;
|
||||||
|
height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
&:checked + .switch-slider {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked + .switch-slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus + .switch-slider {
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-split {
|
||||||
|
.form-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field,
|
||||||
|
.settings-time-range-field {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle-line {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-default-grid {
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concurrency-inline-options {
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-setting-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-setting-group .form-control {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-grid {
|
||||||
|
max-width: none;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.export-defaults-settings-form.layout-split {
|
||||||
|
.media-setting-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-setting-group .form-control {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-default-grid {
|
||||||
|
max-width: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.export-defaults-settings-form.layout-split {
|
||||||
|
.form-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field,
|
||||||
|
.settings-time-range-field,
|
||||||
|
.log-toggle-line,
|
||||||
|
.media-default-grid,
|
||||||
|
.concurrency-inline-options,
|
||||||
|
.format-grid {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-default-grid {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
389
src/components/Export/ExportDefaultsSettingsForm.tsx
Normal file
389
src/components/Export/ExportDefaultsSettingsForm.tsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import * as configService from '../../services/config'
|
||||||
|
import { ExportDateRangeDialog } from './ExportDateRangeDialog'
|
||||||
|
import {
|
||||||
|
createDefaultExportDateRangeSelection,
|
||||||
|
getExportDateRangeLabel,
|
||||||
|
resolveExportDateRangeConfig,
|
||||||
|
serializeExportDateRangeConfig,
|
||||||
|
type ExportDateRangeSelection
|
||||||
|
} from '../../utils/exportDateRange'
|
||||||
|
import './ExportDefaultsSettingsForm.scss'
|
||||||
|
|
||||||
|
export interface ExportDefaultsSettingsPatch {
|
||||||
|
format?: string
|
||||||
|
avatars?: boolean
|
||||||
|
dateRange?: ExportDateRangeSelection
|
||||||
|
media?: configService.ExportDefaultMediaConfig
|
||||||
|
voiceAsText?: boolean
|
||||||
|
excelCompactColumns?: boolean
|
||||||
|
concurrency?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportDefaultsSettingsFormProps {
|
||||||
|
onNotify?: (text: string, success: boolean) => void
|
||||||
|
onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void
|
||||||
|
layout?: 'stacked' | 'split'
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFormatOptions = [
|
||||||
|
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||||
|
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||||
|
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||||
|
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||||
|
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||||
|
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||||
|
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||||
|
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
|
||||||
|
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const exportExcelColumnOptions = [
|
||||||
|
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
||||||
|
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
||||||
|
|
||||||
|
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportDefaultsSettingsForm({
|
||||||
|
onNotify,
|
||||||
|
onDefaultsChanged,
|
||||||
|
layout = 'stacked'
|
||||||
|
}: ExportDefaultsSettingsFormProps) {
|
||||||
|
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||||
|
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||||
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||||
|
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||||
|
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
|
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||||
|
images: true,
|
||||||
|
videos: true,
|
||||||
|
voices: true,
|
||||||
|
emojis: true
|
||||||
|
})
|
||||||
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
|
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||||
|
configService.getExportDefaultFormat(),
|
||||||
|
configService.getExportDefaultAvatars(),
|
||||||
|
configService.getExportDefaultDateRange(),
|
||||||
|
configService.getExportDefaultMedia(),
|
||||||
|
configService.getExportDefaultVoiceAsText(),
|
||||||
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
|
configService.getExportDefaultConcurrency()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
setExportDefaultFormat(savedFormat || 'excel')
|
||||||
|
setExportDefaultAvatars(savedAvatars ?? true)
|
||||||
|
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
||||||
|
setExportDefaultMedia(savedMedia ?? {
|
||||||
|
images: true,
|
||||||
|
videos: true,
|
||||||
|
voices: true,
|
||||||
|
emojis: true
|
||||||
|
})
|
||||||
|
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||||
|
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||||
|
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showExportExcelColumnsSelect])
|
||||||
|
|
||||||
|
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||||
|
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||||
|
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||||
|
|
||||||
|
const notify = (text: string, success = true) => {
|
||||||
|
onNotify?.(text, success)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`export-defaults-settings-form ${layout === 'split' ? 'layout-split' : 'layout-stacked'}`}>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>导出并发数</label>
|
||||||
|
<span className="form-hint">导出多个会话时的最大并发(1~6)</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="concurrency-inline-options" role="radiogroup" aria-label="导出并发数">
|
||||||
|
{exportConcurrencyOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={`concurrency-option ${exportDefaultConcurrency === option ? 'active' : ''}`}
|
||||||
|
aria-pressed={exportDefaultConcurrency === option}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultConcurrency(option)
|
||||||
|
await configService.setExportDefaultConcurrency(option)
|
||||||
|
onDefaultsChanged?.({ concurrency: option })
|
||||||
|
notify(`已将导出并发数设为 ${option}`, true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group format-setting-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>聊天消息默认导出格式</label>
|
||||||
|
<span className="form-hint">导出页面默认选中的格式</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="format-grid">
|
||||||
|
{exportFormatOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`format-card ${exportDefaultFormat === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultFormat(option.value)
|
||||||
|
await configService.setExportDefaultFormat(option.value)
|
||||||
|
onDefaultsChanged?.({ format: option.value })
|
||||||
|
notify('已更新导出格式默认值', true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="format-label">{option.label}</span>
|
||||||
|
<span className="format-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>聊天消息导出带头像</label>
|
||||||
|
<span className="form-hint">开启后导出的聊天消息对应的文件中会带头像信息。</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{exportDefaultAvatars ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch" htmlFor="shared-export-default-avatars">
|
||||||
|
<input
|
||||||
|
id="shared-export-default-avatars"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultAvatars}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setExportDefaultAvatars(enabled)
|
||||||
|
await configService.setExportDefaultAvatars(enabled)
|
||||||
|
onDefaultsChanged?.({ avatars: enabled })
|
||||||
|
notify(enabled ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像', true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>默认导出时间范围</label>
|
||||||
|
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="settings-time-range-field">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
setIsExportDateRangeDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="settings-time-range-value">{exportDateRangeLabel}</span>
|
||||||
|
<span className="settings-time-range-arrow">></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExportDateRangeDialog
|
||||||
|
open={isExportDateRangeDialogOpen}
|
||||||
|
value={exportDefaultDateRange}
|
||||||
|
onClose={() => setIsExportDateRangeDialogOpen(false)}
|
||||||
|
onConfirm={async (nextSelection) => {
|
||||||
|
setExportDefaultDateRange(nextSelection)
|
||||||
|
await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection))
|
||||||
|
onDefaultsChanged?.({ dateRange: nextSelection })
|
||||||
|
notify('已更新默认导出时间范围', true)
|
||||||
|
setIsExportDateRangeDialogOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>Excel 列显示</label>
|
||||||
|
<span className="form-hint">控制 Excel 导出的列字段</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||||
|
setIsExportDateRangeDialogOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showExportExcelColumnsSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportExcelColumnOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
const compact = option.value === 'compact'
|
||||||
|
setExportDefaultExcelCompactColumns(compact)
|
||||||
|
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||||
|
onDefaultsChanged?.({ excelCompactColumns: compact })
|
||||||
|
notify(compact ? '已启用精简列' : '已启用完整列', true)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group media-setting-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>默认导出媒体内容</label>
|
||||||
|
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="media-default-grid">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.images}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, images: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出图片`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
图片
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.voices}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, voices: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出语音`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
语音
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.videos}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, videos: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出视频`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
视频
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.emojis}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, emojis: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出表情包`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
表情包
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>默认语音转文字</label>
|
||||||
|
<span className="form-hint">导出时默认将语音转写为文字</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch" htmlFor="shared-export-default-voice-as-text">
|
||||||
|
<input
|
||||||
|
id="shared-export-default-voice-as-text"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultVoiceAsText}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setExportDefaultVoiceAsText(enabled)
|
||||||
|
await configService.setExportDefaultVoiceAsText(enabled)
|
||||||
|
onDefaultsChanged?.({ voiceAsText: enabled })
|
||||||
|
notify(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ interface JumpToDatePopoverProps {
|
|||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSelect: (date: Date) => void
|
onSelect: (date: Date) => void
|
||||||
|
onMonthChange?: (date: Date) => void
|
||||||
className?: string
|
className?: string
|
||||||
style?: React.CSSProperties
|
style?: React.CSSProperties
|
||||||
currentDate?: Date
|
currentDate?: Date
|
||||||
@@ -20,6 +21,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onMonthChange,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
currentDate = new Date(),
|
currentDate = new Date(),
|
||||||
@@ -112,13 +114,17 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
const days = generateCalendar()
|
const days = generateCalendar()
|
||||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||||
|
const updateCalendarDate = (nextDate: Date) => {
|
||||||
|
setCalendarDate(nextDate)
|
||||||
|
onMonthChange?.(nextDate)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||||
<div className="calendar-nav">
|
<div className="calendar-nav">
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||||
aria-label="上一月"
|
aria-label="上一月"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
@@ -126,7 +132,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||||
aria-label="下一月"
|
aria-label="下一月"
|
||||||
>
|
>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||||
@@ -64,6 +64,7 @@ const normalizeAccountId = (value?: string | null): string => {
|
|||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [authEnabled, setAuthEnabled] = useState(false)
|
const [authEnabled, setAuthEnabled] = useState(false)
|
||||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||||
@@ -465,16 +466,20 @@ function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authEnabled && (
|
<button
|
||||||
<button
|
className="nav-item"
|
||||||
className="nav-item"
|
onClick={() => {
|
||||||
onClick={() => setLocked(true)}
|
if (authEnabled) {
|
||||||
title={collapsed ? '锁定' : undefined}
|
setLocked(true)
|
||||||
>
|
return
|
||||||
<span className="nav-icon"><Lock size={20} /></span>
|
}
|
||||||
<span className="nav-label">锁定</span>
|
navigate('/settings', { state: { initialTab: 'security' } })
|
||||||
</button>
|
}}
|
||||||
)}
|
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
||||||
|
>
|
||||||
|
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
||||||
|
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
|
|||||||
329
src/components/Sns/ContactSnsTimelineDialog.scss
Normal file
329
src/components/Sns/ContactSnsTimelineDialog.scss
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
.contact-sns-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
max-height: min(86vh, 860px);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary-solid, #ffffff);
|
||||||
|
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: contactSnsDialogSpin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-meta {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-username {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-stats {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
width: 248px;
|
||||||
|
max-height: calc((28px * 15) + 16px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 7px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-index {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-close-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-tip {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-posts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-posts-list .post-header-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-status {
|
||||||
|
padding: 20px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.contact-sns-dialog-overlay {
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog {
|
||||||
|
width: min(100vw - 16px, 760px);
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
|
||||||
|
.contact-sns-dialog-header {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-header-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-btn {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-panel {
|
||||||
|
width: min(78vw, 232px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-tip {
|
||||||
|
padding: 10px 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-body {
|
||||||
|
padding: 10px 10px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contactSnsDialogSpin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
593
src/components/Sns/ContactSnsTimelineDialog.tsx
Normal file
593
src/components/Sns/ContactSnsTimelineDialog.tsx
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Loader2, X } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { SnsPostItem } from './SnsPostItem'
|
||||||
|
import type { SnsPost } from '../../types/sns'
|
||||||
|
import {
|
||||||
|
type ContactSnsRankItem,
|
||||||
|
type ContactSnsRankMode,
|
||||||
|
type ContactSnsTimelineTarget,
|
||||||
|
getAvatarLetter
|
||||||
|
} from './contactSnsTimeline'
|
||||||
|
import './ContactSnsTimelineDialog.scss'
|
||||||
|
|
||||||
|
const TIMELINE_PAGE_SIZE = 20
|
||||||
|
const SNS_RANK_PAGE_SIZE = 50
|
||||||
|
const SNS_RANK_DISPLAY_LIMIT = 15
|
||||||
|
|
||||||
|
interface ContactSnsRankCacheEntry {
|
||||||
|
likes: ContactSnsRankItem[]
|
||||||
|
comments: ContactSnsRankItem[]
|
||||||
|
totalPosts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactSnsTimelineDialogProps {
|
||||||
|
target: ContactSnsTimelineTarget | null
|
||||||
|
onClose: () => void
|
||||||
|
initialTotalPosts?: number | null
|
||||||
|
initialTotalPostsLoading?: boolean
|
||||||
|
isProtected?: boolean
|
||||||
|
onDeletePost?: (postId: string, username: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTotalPosts = (value?: number | null): number | null => {
|
||||||
|
if (!Number.isFinite(value)) return null
|
||||||
|
return Math.max(0, Math.floor(Number(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatYmdDateFromSeconds = (timestamp?: number): string => {
|
||||||
|
if (!timestamp || !Number.isFinite(timestamp)) return '—'
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => {
|
||||||
|
const likeMap = new Map<string, ContactSnsRankItem>()
|
||||||
|
const commentMap = new Map<string, ContactSnsRankItem>()
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
const createTime = Number(post?.createTime) || 0
|
||||||
|
const likes = Array.isArray(post?.likes) ? post.likes : []
|
||||||
|
const comments = Array.isArray(post?.comments) ? post.comments : []
|
||||||
|
|
||||||
|
for (const likeNameRaw of likes) {
|
||||||
|
const name = String(likeNameRaw || '').trim() || '未知用户'
|
||||||
|
const current = likeMap.get(name)
|
||||||
|
if (current) {
|
||||||
|
current.count += 1
|
||||||
|
if (createTime > current.latestTime) current.latestTime = createTime
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
likeMap.set(name, { name, count: 1, latestTime: createTime })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const comment of comments) {
|
||||||
|
const name = String(comment?.nickname || '').trim() || '未知用户'
|
||||||
|
const current = commentMap.get(name)
|
||||||
|
if (current) {
|
||||||
|
current.count += 1
|
||||||
|
if (createTime > current.latestTime) current.latestTime = createTime
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
commentMap.set(name, { name, count: 1, latestTime: createTime })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => {
|
||||||
|
if (right.count !== left.count) return right.count - left.count
|
||||||
|
if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime
|
||||||
|
return left.name.localeCompare(right.name, 'zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
likes: [...likeMap.values()].sort(sorter),
|
||||||
|
comments: [...commentMap.values()].sort(sorter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactSnsTimelineDialog({
|
||||||
|
target,
|
||||||
|
onClose,
|
||||||
|
initialTotalPosts = null,
|
||||||
|
initialTotalPostsLoading = false,
|
||||||
|
isProtected = false,
|
||||||
|
onDeletePost
|
||||||
|
}: ContactSnsTimelineDialogProps) {
|
||||||
|
const [timelinePosts, setTimelinePosts] = useState<SnsPost[]>([])
|
||||||
|
const [timelineLoading, setTimelineLoading] = useState(false)
|
||||||
|
const [timelineLoadingMore, setTimelineLoadingMore] = useState(false)
|
||||||
|
const [timelineHasMore, setTimelineHasMore] = useState(false)
|
||||||
|
const [timelineTotalPosts, setTimelineTotalPosts] = useState<number | null>(null)
|
||||||
|
const [timelineStatsLoading, setTimelineStatsLoading] = useState(false)
|
||||||
|
const [rankMode, setRankMode] = useState<ContactSnsRankMode | null>(null)
|
||||||
|
const [likeRankings, setLikeRankings] = useState<ContactSnsRankItem[]>([])
|
||||||
|
const [commentRankings, setCommentRankings] = useState<ContactSnsRankItem[]>([])
|
||||||
|
const [rankLoading, setRankLoading] = useState(false)
|
||||||
|
const [rankError, setRankError] = useState<string | null>(null)
|
||||||
|
const [rankLoadedPosts, setRankLoadedPosts] = useState(0)
|
||||||
|
const [rankTotalPosts, setRankTotalPosts] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const timelinePostsRef = useRef<SnsPost[]>([])
|
||||||
|
const timelineLoadingRef = useRef(false)
|
||||||
|
const timelineRequestTokenRef = useRef(0)
|
||||||
|
const totalPostsRequestTokenRef = useRef(0)
|
||||||
|
const rankRequestTokenRef = useRef(0)
|
||||||
|
const rankLoadingRef = useRef(false)
|
||||||
|
const rankCacheRef = useRef<Record<string, ContactSnsRankCacheEntry>>({})
|
||||||
|
|
||||||
|
const targetUsername = String(target?.username || '').trim()
|
||||||
|
const targetDisplayName = target?.displayName || targetUsername
|
||||||
|
const targetAvatarUrl = target?.avatarUrl
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timelinePostsRef.current = timelinePosts
|
||||||
|
}, [timelinePosts])
|
||||||
|
|
||||||
|
const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => {
|
||||||
|
const reset = Boolean(options?.reset)
|
||||||
|
if (timelineLoadingRef.current) return
|
||||||
|
|
||||||
|
timelineLoadingRef.current = true
|
||||||
|
if (reset) {
|
||||||
|
setTimelineLoading(true)
|
||||||
|
setTimelineLoadingMore(false)
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
} else {
|
||||||
|
setTimelineLoadingMore(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestToken = ++timelineRequestTokenRef.current
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endTime: number | undefined
|
||||||
|
if (!reset && timelinePostsRef.current.length > 0) {
|
||||||
|
endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
|
TIMELINE_PAGE_SIZE,
|
||||||
|
0,
|
||||||
|
[nextTarget.username],
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
endTime
|
||||||
|
)
|
||||||
|
if (requestToken !== timelineRequestTokenRef.current) return
|
||||||
|
|
||||||
|
if (!result.success || !Array.isArray(result.timeline)) {
|
||||||
|
if (reset) {
|
||||||
|
setTimelinePosts([])
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
|
||||||
|
if (reset) {
|
||||||
|
setTimelinePosts(timeline)
|
||||||
|
setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIds = new Set(timelinePostsRef.current.map((post) => post.id))
|
||||||
|
const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id))
|
||||||
|
if (uniqueOlder.length > 0) {
|
||||||
|
const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime)
|
||||||
|
setTimelinePosts(merged)
|
||||||
|
}
|
||||||
|
if (timeline.length < TIMELINE_PAGE_SIZE) {
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载联系人朋友圈失败:', error)
|
||||||
|
if (requestToken === timelineRequestTokenRef.current && reset) {
|
||||||
|
setTimelinePosts([])
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestToken === timelineRequestTokenRef.current) {
|
||||||
|
timelineLoadingRef.current = false
|
||||||
|
setTimelineLoading(false)
|
||||||
|
setTimelineLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
|
||||||
|
const requestToken = ++totalPostsRequestTokenRef.current
|
||||||
|
setTimelineStatsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||||
|
if (requestToken !== totalPostsRequestTokenRef.current) return
|
||||||
|
|
||||||
|
if (!result.success || !result.counts) {
|
||||||
|
setTimelineTotalPosts(null)
|
||||||
|
setRankTotalPosts(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawCount = Number(result.counts[nextTarget.username] || 0)
|
||||||
|
const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
|
||||||
|
setTimelineTotalPosts(normalized)
|
||||||
|
setRankTotalPosts(normalized)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载联系人朋友圈条数失败:', error)
|
||||||
|
if (requestToken !== totalPostsRequestTokenRef.current) return
|
||||||
|
setTimelineTotalPosts(null)
|
||||||
|
setRankTotalPosts(null)
|
||||||
|
} finally {
|
||||||
|
if (requestToken === totalPostsRequestTokenRef.current) {
|
||||||
|
setTimelineStatsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
|
||||||
|
const normalizedUsername = String(nextTarget?.username || '').trim()
|
||||||
|
if (!normalizedUsername || rankLoadingRef.current) return
|
||||||
|
|
||||||
|
const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts)
|
||||||
|
const cached = rankCacheRef.current[normalizedUsername]
|
||||||
|
|
||||||
|
if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) {
|
||||||
|
setLikeRankings(cached.likes)
|
||||||
|
setCommentRankings(cached.comments)
|
||||||
|
setRankLoadedPosts(cached.totalPosts)
|
||||||
|
setRankTotalPosts(cached.totalPosts)
|
||||||
|
setRankError(null)
|
||||||
|
setRankLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rankLoadingRef.current = true
|
||||||
|
const requestToken = ++rankRequestTokenRef.current
|
||||||
|
setRankLoading(true)
|
||||||
|
setRankError(null)
|
||||||
|
setRankLoadedPosts(0)
|
||||||
|
setRankTotalPosts(normalizedKnownTotal)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allPosts: SnsPost[] = []
|
||||||
|
let endTime: number | undefined
|
||||||
|
let hasMore = true
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
|
SNS_RANK_PAGE_SIZE,
|
||||||
|
0,
|
||||||
|
[normalizedUsername],
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
endTime
|
||||||
|
)
|
||||||
|
if (requestToken !== rankRequestTokenRef.current) return
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '加载朋友圈排行失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagePosts = Array.isArray(result.timeline)
|
||||||
|
? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
|
||||||
|
: []
|
||||||
|
if (pagePosts.length === 0) {
|
||||||
|
hasMore = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allPosts.push(...pagePosts)
|
||||||
|
setRankLoadedPosts(allPosts.length)
|
||||||
|
if (normalizedKnownTotal === null) {
|
||||||
|
setRankTotalPosts(allPosts.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
endTime = pagePosts[pagePosts.length - 1].createTime - 1
|
||||||
|
hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestToken !== rankRequestTokenRef.current) return
|
||||||
|
|
||||||
|
const rankings = buildContactSnsRankings(allPosts)
|
||||||
|
const totalPosts = allPosts.length
|
||||||
|
rankCacheRef.current[normalizedUsername] = {
|
||||||
|
likes: rankings.likes,
|
||||||
|
comments: rankings.comments,
|
||||||
|
totalPosts
|
||||||
|
}
|
||||||
|
setLikeRankings(rankings.likes)
|
||||||
|
setCommentRankings(rankings.comments)
|
||||||
|
setRankLoadedPosts(totalPosts)
|
||||||
|
setRankTotalPosts(totalPosts)
|
||||||
|
setRankError(null)
|
||||||
|
} catch (error) {
|
||||||
|
if (requestToken !== rankRequestTokenRef.current) return
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
setLikeRankings([])
|
||||||
|
setCommentRankings([])
|
||||||
|
setRankError(message || '加载朋友圈排行失败')
|
||||||
|
} finally {
|
||||||
|
if (requestToken === rankRequestTokenRef.current) {
|
||||||
|
rankLoadingRef.current = false
|
||||||
|
setRankLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [timelineTotalPosts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetUsername) return
|
||||||
|
|
||||||
|
totalPostsRequestTokenRef.current += 1
|
||||||
|
rankRequestTokenRef.current += 1
|
||||||
|
rankLoadingRef.current = false
|
||||||
|
setRankMode(null)
|
||||||
|
setLikeRankings([])
|
||||||
|
setCommentRankings([])
|
||||||
|
setRankLoading(false)
|
||||||
|
setRankError(null)
|
||||||
|
setRankLoadedPosts(0)
|
||||||
|
setRankTotalPosts(null)
|
||||||
|
setTimelinePosts([])
|
||||||
|
setTimelineTotalPosts(null)
|
||||||
|
setTimelineStatsLoading(false)
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
setTimelineLoadingMore(false)
|
||||||
|
setTimelineLoading(false)
|
||||||
|
|
||||||
|
void loadTimelinePosts({
|
||||||
|
username: targetUsername,
|
||||||
|
displayName: targetDisplayName,
|
||||||
|
avatarUrl: targetAvatarUrl
|
||||||
|
}, { reset: true })
|
||||||
|
}, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetUsername) return
|
||||||
|
|
||||||
|
const normalizedTotal = normalizeTotalPosts(initialTotalPosts)
|
||||||
|
if (normalizedTotal !== null) {
|
||||||
|
setTimelineTotalPosts(normalizedTotal)
|
||||||
|
setRankTotalPosts(normalizedTotal)
|
||||||
|
setTimelineStatsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialTotalPostsLoading) {
|
||||||
|
setTimelineTotalPosts(null)
|
||||||
|
setRankTotalPosts(null)
|
||||||
|
setTimelineStatsLoading(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadTimelineTotalPosts({
|
||||||
|
username: targetUsername,
|
||||||
|
displayName: targetDisplayName,
|
||||||
|
avatarUrl: targetAvatarUrl
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
initialTotalPosts,
|
||||||
|
initialTotalPostsLoading,
|
||||||
|
loadTimelineTotalPosts,
|
||||||
|
targetAvatarUrl,
|
||||||
|
targetDisplayName,
|
||||||
|
targetUsername
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timelineTotalPosts === null) return
|
||||||
|
if (timelinePosts.length >= timelineTotalPosts) {
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
}, [timelinePosts.length, timelineTotalPosts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rankMode || !targetUsername) return
|
||||||
|
void loadRankings({
|
||||||
|
username: targetUsername,
|
||||||
|
displayName: targetDisplayName,
|
||||||
|
avatarUrl: targetAvatarUrl
|
||||||
|
})
|
||||||
|
}, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetUsername) return
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose, targetUsername])
|
||||||
|
|
||||||
|
const timelineStatsText = useMemo(() => {
|
||||||
|
const loadedCount = timelinePosts.length
|
||||||
|
const loadPart = timelineStatsLoading
|
||||||
|
? `已加载 ${loadedCount} / 总数统计中...`
|
||||||
|
: timelineTotalPosts === null
|
||||||
|
? `已加载 ${loadedCount} 条`
|
||||||
|
: `已加载 ${loadedCount} / 共 ${timelineTotalPosts} 条`
|
||||||
|
|
||||||
|
if (timelineLoading && loadedCount === 0) return `${loadPart} | 加载中...`
|
||||||
|
if (loadedCount === 0) return loadPart
|
||||||
|
|
||||||
|
const latest = timelinePosts[0]?.createTime
|
||||||
|
const earliest = timelinePosts[timelinePosts.length - 1]?.createTime
|
||||||
|
return `${loadPart} | ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}`
|
||||||
|
}, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts])
|
||||||
|
|
||||||
|
const activeRankings = useMemo(() => {
|
||||||
|
if (rankMode === 'likes') return likeRankings
|
||||||
|
if (rankMode === 'comments') return commentRankings
|
||||||
|
return []
|
||||||
|
}, [commentRankings, likeRankings, rankMode])
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return
|
||||||
|
void loadTimelinePosts({
|
||||||
|
username: targetUsername,
|
||||||
|
displayName: targetDisplayName,
|
||||||
|
avatarUrl: targetAvatarUrl
|
||||||
|
}, { reset: false })
|
||||||
|
}, [
|
||||||
|
loadTimelinePosts,
|
||||||
|
targetAvatarUrl,
|
||||||
|
targetDisplayName,
|
||||||
|
targetUsername,
|
||||||
|
timelineHasMore,
|
||||||
|
timelineLoading,
|
||||||
|
timelineLoadingMore
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleBodyScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const element = event.currentTarget
|
||||||
|
const remaining = element.scrollHeight - element.scrollTop - element.clientHeight
|
||||||
|
if (remaining <= 160) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
}, [loadMore])
|
||||||
|
|
||||||
|
const toggleRankMode = useCallback((mode: ContactSnsRankMode) => {
|
||||||
|
setRankMode((previous) => (previous === mode ? null : mode))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!target) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="contact-sns-dialog-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="contact-sns-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="联系人朋友圈"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="contact-sns-dialog-header">
|
||||||
|
<div className="contact-sns-dialog-header-main">
|
||||||
|
<div className="contact-sns-dialog-avatar">
|
||||||
|
{targetAvatarUrl ? (
|
||||||
|
<img src={targetAvatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{getAvatarLetter(targetDisplayName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="contact-sns-dialog-meta">
|
||||||
|
<h4>{targetDisplayName}</h4>
|
||||||
|
<div className="contact-sns-dialog-username">@{targetUsername}</div>
|
||||||
|
<div className="contact-sns-dialog-stats">{timelineStatsText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="contact-sns-dialog-header-actions">
|
||||||
|
<div className="contact-sns-dialog-rank-switch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`contact-sns-dialog-rank-btn ${rankMode === 'likes' ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleRankMode('likes')}
|
||||||
|
>
|
||||||
|
点赞排行
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`contact-sns-dialog-rank-btn ${rankMode === 'comments' ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleRankMode('comments')}
|
||||||
|
>
|
||||||
|
评论排行
|
||||||
|
</button>
|
||||||
|
{rankMode && (
|
||||||
|
<div
|
||||||
|
className="contact-sns-dialog-rank-panel"
|
||||||
|
role="region"
|
||||||
|
aria-label={rankMode === 'likes' ? '点赞排行' : '评论排行'}
|
||||||
|
>
|
||||||
|
{rankLoading && (
|
||||||
|
<div className="contact-sns-dialog-rank-loading">
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
<span>
|
||||||
|
{rankTotalPosts !== null && rankTotalPosts > 0
|
||||||
|
? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts} 条`
|
||||||
|
: `统计中,已加载 ${rankLoadedPosts} 条`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!rankLoading && rankError ? (
|
||||||
|
<div className="contact-sns-dialog-rank-empty">{rankError}</div>
|
||||||
|
) : !rankLoading && activeRankings.length === 0 ? (
|
||||||
|
<div className="contact-sns-dialog-rank-empty">
|
||||||
|
{rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
|
||||||
|
<div className="contact-sns-dialog-rank-row" key={`${rankMode}-${item.name}`}>
|
||||||
|
<span className="contact-sns-dialog-rank-index">{index + 1}</span>
|
||||||
|
<span className="contact-sns-dialog-rank-name" title={item.name}>{item.name}</span>
|
||||||
|
<span className="contact-sns-dialog-rank-count">
|
||||||
|
{item.count.toLocaleString('zh-CN')}
|
||||||
|
{rankMode === 'likes' ? '次' : '条'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="contact-sns-dialog-close-btn" type="button" onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-sns-dialog-tip">
|
||||||
|
在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="contact-sns-dialog-body"
|
||||||
|
onScroll={handleBodyScroll}
|
||||||
|
>
|
||||||
|
{timelinePosts.length > 0 && (
|
||||||
|
<div className="contact-sns-dialog-posts-list">
|
||||||
|
{timelinePosts.map((post) => (
|
||||||
|
<SnsPostItem
|
||||||
|
key={post.id}
|
||||||
|
post={{ ...post, isProtected }}
|
||||||
|
onPreview={(src, isVideo, liveVideoPath) => {
|
||||||
|
if (isVideo) {
|
||||||
|
void window.electronAPI.window.openVideoPlayerWindow(src)
|
||||||
|
} else {
|
||||||
|
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDebug={() => {}}
|
||||||
|
onDelete={onDeletePost}
|
||||||
|
hideAuthorMeta
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{timelineLoading && (
|
||||||
|
<div className="contact-sns-dialog-status">正在加载该联系人的朋友圈...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!timelineLoading && timelinePosts.length === 0 && (
|
||||||
|
<div className="contact-sns-dialog-status empty">该联系人暂无朋友圈</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!timelineLoading && timelineHasMore && (
|
||||||
|
<button
|
||||||
|
className="contact-sns-dialog-load-more"
|
||||||
|
type="button"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={timelineLoadingMore}
|
||||||
|
>
|
||||||
|
{timelineLoadingMore ? '正在加载...' : '加载更多'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,60 +1,52 @@
|
|||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
|
import { Search, User, X, Loader2 } from 'lucide-react'
|
||||||
import { Avatar } from '../Avatar'
|
import { Avatar } from '../Avatar'
|
||||||
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
|
|
||||||
|
|
||||||
interface Contact {
|
interface Contact {
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
postCount?: number
|
||||||
|
postCountStatus?: 'idle' | 'loading' | 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactsCountProgress {
|
||||||
|
resolved: number
|
||||||
|
total: number
|
||||||
|
running: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SnsFilterPanelProps {
|
interface SnsFilterPanelProps {
|
||||||
searchKeyword: string
|
searchKeyword: string
|
||||||
setSearchKeyword: (val: string) => void
|
setSearchKeyword: (val: string) => void
|
||||||
jumpTargetDate?: Date
|
totalFriendsLabel?: string
|
||||||
setJumpTargetDate: (date?: Date) => void
|
|
||||||
onOpenJumpDialog: () => void
|
|
||||||
selectedUsernames: string[]
|
|
||||||
setSelectedUsernames: (val: string[]) => void
|
|
||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
contactSearch: string
|
contactSearch: string
|
||||||
setContactSearch: (val: string) => void
|
setContactSearch: (val: string) => void
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
contactsCountProgress?: ContactsCountProgress
|
||||||
|
onOpenContactTimeline: (contact: Contact) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||||
searchKeyword,
|
searchKeyword,
|
||||||
setSearchKeyword,
|
setSearchKeyword,
|
||||||
jumpTargetDate,
|
totalFriendsLabel,
|
||||||
setJumpTargetDate,
|
|
||||||
onOpenJumpDialog,
|
|
||||||
selectedUsernames,
|
|
||||||
setSelectedUsernames,
|
|
||||||
contacts,
|
contacts,
|
||||||
contactSearch,
|
contactSearch,
|
||||||
setContactSearch,
|
setContactSearch,
|
||||||
loading
|
loading,
|
||||||
|
contactsCountProgress,
|
||||||
|
onOpenContactTimeline
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const filteredContacts = contacts.filter(c =>
|
const filteredContacts = contacts.filter(c =>
|
||||||
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleUserSelection = (username: string) => {
|
|
||||||
if (selectedUsernames.includes(username)) {
|
|
||||||
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
|
|
||||||
} else {
|
|
||||||
setJumpTargetDate(undefined) // Reset date jump when selecting user
|
|
||||||
setSelectedUsernames([...selectedUsernames, username])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchKeyword('')
|
setSearchKeyword('')
|
||||||
setSelectedUsernames([])
|
setContactSearch('')
|
||||||
setJumpTargetDate(undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEmptyStateText = () => {
|
const getEmptyStateText = () => {
|
||||||
@@ -71,7 +63,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
<aside className="sns-filter-panel">
|
<aside className="sns-filter-panel">
|
||||||
<div className="filter-header">
|
<div className="filter-header">
|
||||||
<h3>筛选条件</h3>
|
<h3>筛选条件</h3>
|
||||||
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
|
{(searchKeyword || contactSearch) && (
|
||||||
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -99,43 +91,13 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date Widget */}
|
|
||||||
<div className="filter-widget date-widget">
|
|
||||||
<div className="widget-header">
|
|
||||||
<Calendar size={14} />
|
|
||||||
<span>时间跳转</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
|
|
||||||
onClick={onOpenJumpDialog}
|
|
||||||
>
|
|
||||||
<span className="date-text">
|
|
||||||
{jumpTargetDate
|
|
||||||
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
||||||
: '选择日期...'}
|
|
||||||
</span>
|
|
||||||
{jumpTargetDate && (
|
|
||||||
<div
|
|
||||||
className="clear-date-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setJumpTargetDate(undefined)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Widget */}
|
{/* Contact Widget */}
|
||||||
<div className="filter-widget contact-widget">
|
<div className="filter-widget contact-widget">
|
||||||
<div className="widget-header">
|
<div className="widget-header">
|
||||||
<User size={14} />
|
<User size={14} />
|
||||||
<span>联系人</span>
|
<span>联系人</span>
|
||||||
{selectedUsernames.length > 0 && (
|
{totalFriendsLabel && (
|
||||||
<span className="badge">{selectedUsernames.length}</span>
|
<span className="widget-header-summary">{totalFriendsLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,18 +114,36 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contactsCountProgress && contactsCountProgress.total > 0 && (
|
||||||
|
<div className="contact-count-progress">
|
||||||
|
{contactsCountProgress.running
|
||||||
|
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
|
||||||
|
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="contact-list-scroll">
|
<div className="contact-list-scroll">
|
||||||
{filteredContacts.map(contact => {
|
{filteredContacts.map(contact => {
|
||||||
|
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={contact.username}
|
key={contact.username}
|
||||||
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
|
className="contact-row"
|
||||||
onClick={() => toggleUserSelection(contact.username)}
|
onClick={() => onOpenContactTimeline(contact)}
|
||||||
>
|
>
|
||||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||||
<div className="contact-meta">
|
<div className="contact-meta">
|
||||||
<span className="contact-name">{contact.displayName}</span>
|
<span className="contact-name">{contact.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="contact-post-count-wrap">
|
||||||
|
{isPostCountReady ? (
|
||||||
|
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
|
||||||
|
) : (
|
||||||
|
<span className="contact-post-count-loading" title="统计中">
|
||||||
|
<Loader2 size={13} className="spinning" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
26
src/components/Sns/contactSnsTimeline.ts
Normal file
26
src/components/Sns/contactSnsTimeline.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export interface ContactSnsTimelineTarget {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSnsRankItem {
|
||||||
|
name: string
|
||||||
|
count: number
|
||||||
|
latestTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContactSnsRankMode = 'likes' | 'comments'
|
||||||
|
|
||||||
|
export const isSingleContactSession = (sessionId: string): boolean => {
|
||||||
|
const normalized = String(sessionId || '').trim()
|
||||||
|
if (!normalized) return false
|
||||||
|
if (normalized.includes('@chatroom')) return false
|
||||||
|
if (normalized.startsWith('gh_')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAvatarLetter = (name: string): string => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return [...name][0] || '?'
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
@@ -11,6 +11,8 @@ import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
|||||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||||
import JumpToDatePopover from '../components/JumpToDatePopover'
|
import JumpToDatePopover from '../components/JumpToDatePopover'
|
||||||
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
|
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import {
|
import {
|
||||||
emitOpenSingleExport,
|
emitOpenSingleExport,
|
||||||
@@ -204,8 +206,13 @@ function formatYmdHmDateTime(timestamp?: number): string {
|
|||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
standaloneSessionWindow?: boolean
|
standaloneSessionWindow?: boolean
|
||||||
initialSessionId?: string | null
|
initialSessionId?: string | null
|
||||||
|
standaloneSource?: string | null
|
||||||
|
standaloneInitialDisplayName?: string | null
|
||||||
|
standaloneInitialAvatarUrl?: string | null
|
||||||
|
standaloneInitialContactType?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready'
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
wxid: string
|
wxid: string
|
||||||
@@ -408,8 +415,20 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
|
|
||||||
|
|
||||||
function ChatPage(props: ChatPageProps) {
|
function ChatPage(props: ChatPageProps) {
|
||||||
const { standaloneSessionWindow = false, initialSessionId = null } = props
|
const {
|
||||||
|
standaloneSessionWindow = false,
|
||||||
|
initialSessionId = null,
|
||||||
|
standaloneSource = null,
|
||||||
|
standaloneInitialDisplayName = null,
|
||||||
|
standaloneInitialAvatarUrl = null,
|
||||||
|
standaloneInitialContactType = null
|
||||||
|
} = props
|
||||||
const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
|
const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
|
||||||
|
const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource])
|
||||||
|
const normalizedStandaloneInitialDisplayName = useMemo(() => String(standaloneInitialDisplayName || '').trim(), [standaloneInitialDisplayName])
|
||||||
|
const normalizedStandaloneInitialAvatarUrl = useMemo(() => String(standaloneInitialAvatarUrl || '').trim(), [standaloneInitialAvatarUrl])
|
||||||
|
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
|
||||||
|
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -493,11 +512,17 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
||||||
const [noMessageTable, setNoMessageTable] = useState(false)
|
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||||
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
|
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(normalizedStandaloneInitialDisplayName || null)
|
||||||
|
const [fallbackAvatarUrl, setFallbackAvatarUrl] = useState<string | null>(normalizedStandaloneInitialAvatarUrl || null)
|
||||||
|
const [standaloneLoadStage, setStandaloneLoadStage] = useState<StandaloneLoadStage>(
|
||||||
|
standaloneSessionWindow && normalizedInitialSessionId ? 'connecting' : 'idle'
|
||||||
|
)
|
||||||
|
const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false)
|
||||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||||
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
|
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
|
||||||
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
|
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
|
||||||
|
const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
|
||||||
const [exportPrepareHint, setExportPrepareHint] = useState('')
|
const [exportPrepareHint, setExportPrepareHint] = useState('')
|
||||||
|
|
||||||
// 消息右键菜单
|
// 消息右键菜单
|
||||||
@@ -2408,9 +2433,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [appendMessages, getMessageKey])
|
}, [appendMessages, getMessageKey])
|
||||||
|
|
||||||
// 选择会话
|
// 选择会话
|
||||||
const selectSessionById = useCallback((sessionId: string) => {
|
const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
if (!normalizedSessionId || normalizedSessionId === currentSessionId) return
|
if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
|
||||||
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
|
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
|
||||||
sessionSwitchRequestSeqRef.current = switchRequestSeq
|
sessionSwitchRequestSeqRef.current = switchRequestSeq
|
||||||
|
|
||||||
@@ -2734,7 +2759,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [currentSessionId, messages.length, isLoadingMessages])
|
}, [currentSessionId, messages.length, isLoadingMessages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
|
if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
|
||||||
if (pendingSessionLoadRef.current === currentSessionId) return
|
if (pendingSessionLoadRef.current === currentSessionId) return
|
||||||
if (initialLoadRequestedSessionRef.current === currentSessionId) return
|
if (initialLoadRequestedSessionRef.current === currentSessionId) return
|
||||||
initialLoadRequestedSessionRef.current = currentSessionId
|
initialLoadRequestedSessionRef.current = currentSessionId
|
||||||
@@ -2745,7 +2770,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
forceInitialLimit: 30
|
forceInitialLimit: 30
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
}, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -2906,7 +2931,21 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback)
|
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback)
|
||||||
const currentSession = (() => {
|
const currentSession = (() => {
|
||||||
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||||
if (found || !currentSessionId) return found
|
if (found) {
|
||||||
|
if (
|
||||||
|
standaloneSessionWindow &&
|
||||||
|
normalizedInitialSessionId &&
|
||||||
|
found.username === normalizedInitialSessionId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...found,
|
||||||
|
displayName: found.displayName || fallbackDisplayName || found.username,
|
||||||
|
avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
if (!currentSessionId) return found
|
||||||
return {
|
return {
|
||||||
username: currentSessionId,
|
username: currentSessionId,
|
||||||
type: 0,
|
type: 0,
|
||||||
@@ -2916,6 +2955,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
lastTimestamp: 0,
|
lastTimestamp: 0,
|
||||||
lastMsgType: 0,
|
lastMsgType: 0,
|
||||||
displayName: fallbackDisplayName || currentSessionId,
|
displayName: fallbackDisplayName || currentSessionId,
|
||||||
|
avatarUrl: fallbackAvatarUrl || undefined,
|
||||||
} as ChatSession
|
} as ChatSession
|
||||||
})()
|
})()
|
||||||
const filteredGroupPanelMembers = useMemo(() => {
|
const filteredGroupPanelMembers = useMemo(() => {
|
||||||
@@ -2935,33 +2975,135 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}, [groupMemberSearchKeyword, groupPanelMembers])
|
}, [groupMemberSearchKeyword, groupPanelMembers])
|
||||||
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
|
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
|
||||||
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
|
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
|
||||||
|
const isCurrentSessionGroup = Boolean(
|
||||||
|
currentSession && (
|
||||||
|
isGroupChatSession(currentSession.username) ||
|
||||||
|
(
|
||||||
|
standaloneSessionWindow &&
|
||||||
|
currentSession.username === normalizedInitialSessionId &&
|
||||||
|
normalizedStandaloneInitialContactType === 'group'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const isCurrentSessionPrivateSnsSupported = Boolean(
|
||||||
|
currentSession &&
|
||||||
|
isSingleContactSession(currentSession.username) &&
|
||||||
|
!isCurrentSessionGroup
|
||||||
|
)
|
||||||
|
|
||||||
|
const openCurrentSessionSnsTimeline = useCallback(() => {
|
||||||
|
if (!currentSession || !isCurrentSessionPrivateSnsSupported) return
|
||||||
|
setChatSnsTimelineTarget({
|
||||||
|
username: currentSession.username,
|
||||||
|
displayName: currentSession.displayName || currentSession.username,
|
||||||
|
avatarUrl: currentSession.avatarUrl
|
||||||
|
})
|
||||||
|
}, [currentSession, isCurrentSessionPrivateSnsSupported])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!standaloneSessionWindow) return
|
||||||
|
setStandaloneInitialLoadRequested(false)
|
||||||
|
setStandaloneLoadStage(normalizedInitialSessionId ? 'connecting' : 'idle')
|
||||||
|
setFallbackDisplayName(normalizedStandaloneInitialDisplayName || null)
|
||||||
|
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl || null)
|
||||||
|
}, [
|
||||||
|
standaloneSessionWindow,
|
||||||
|
normalizedInitialSessionId,
|
||||||
|
normalizedStandaloneInitialDisplayName,
|
||||||
|
normalizedStandaloneInitialAvatarUrl
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!standaloneSessionWindow) return
|
||||||
|
if (!normalizedInitialSessionId) return
|
||||||
|
|
||||||
|
if (normalizedStandaloneInitialDisplayName) {
|
||||||
|
setFallbackDisplayName(normalizedStandaloneInitialDisplayName)
|
||||||
|
}
|
||||||
|
if (normalizedStandaloneInitialAvatarUrl) {
|
||||||
|
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSessionId) {
|
||||||
|
setCurrentSession(normalizedInitialSessionId, { preserveMessages: false })
|
||||||
|
}
|
||||||
|
if (!isConnected || isConnecting) {
|
||||||
|
setStandaloneLoadStage('connecting')
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
standaloneSessionWindow,
|
||||||
|
normalizedInitialSessionId,
|
||||||
|
normalizedStandaloneInitialDisplayName,
|
||||||
|
normalizedStandaloneInitialAvatarUrl,
|
||||||
|
currentSessionId,
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
setCurrentSession
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!standaloneSessionWindow) return
|
if (!standaloneSessionWindow) return
|
||||||
if (!normalizedInitialSessionId) return
|
if (!normalizedInitialSessionId) return
|
||||||
if (!isConnected || isConnecting) return
|
if (!isConnected || isConnecting) return
|
||||||
if (currentSessionId === normalizedInitialSessionId) return
|
if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return
|
||||||
selectSessionById(normalizedInitialSessionId)
|
setStandaloneInitialLoadRequested(true)
|
||||||
|
setStandaloneLoadStage('loading')
|
||||||
|
selectSessionById(normalizedInitialSessionId, {
|
||||||
|
force: currentSessionId === normalizedInitialSessionId
|
||||||
|
})
|
||||||
}, [
|
}, [
|
||||||
standaloneSessionWindow,
|
standaloneSessionWindow,
|
||||||
normalizedInitialSessionId,
|
normalizedInitialSessionId,
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
standaloneInitialLoadRequested,
|
||||||
selectSessionById
|
selectSessionById
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
|
||||||
|
if (!isConnected || isConnecting) {
|
||||||
|
setStandaloneLoadStage('connecting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!standaloneInitialLoadRequested) {
|
||||||
|
setStandaloneLoadStage('loading')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentSessionId !== normalizedInitialSessionId) {
|
||||||
|
setStandaloneLoadStage('loading')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isLoadingMessages || isSessionSwitching) {
|
||||||
|
setStandaloneLoadStage('loading')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStandaloneLoadStage('ready')
|
||||||
|
}, [
|
||||||
|
standaloneSessionWindow,
|
||||||
|
normalizedInitialSessionId,
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
standaloneInitialLoadRequested,
|
||||||
|
currentSessionId,
|
||||||
|
isLoadingMessages,
|
||||||
|
isSessionSwitching
|
||||||
|
])
|
||||||
|
|
||||||
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
|
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSessionId) return
|
if (!currentSessionId) return
|
||||||
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||||
if (found) {
|
if (found) {
|
||||||
setFallbackDisplayName(null)
|
if (found.displayName) setFallbackDisplayName(found.displayName)
|
||||||
|
if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadContactInfoBatch([currentSessionId]).then(() => {
|
loadContactInfoBatch([currentSessionId]).then(() => {
|
||||||
const cached = senderAvatarCache.get(currentSessionId)
|
const cached = senderAvatarCache.get(currentSessionId)
|
||||||
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
|
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
|
||||||
|
if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl)
|
||||||
})
|
})
|
||||||
}, [currentSessionId, sessions])
|
}, [currentSessionId, sessions])
|
||||||
|
|
||||||
@@ -3738,16 +3880,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
src={currentSession.avatarUrl}
|
src={currentSession.avatarUrl}
|
||||||
name={currentSession.displayName || currentSession.username}
|
name={currentSession.displayName || currentSession.username}
|
||||||
size={40}
|
size={40}
|
||||||
className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
|
className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'}
|
||||||
/>
|
/>
|
||||||
<div className="header-info">
|
<div className="header-info">
|
||||||
<h3>{currentSession.displayName || currentSession.username}</h3>
|
<h3>{currentSession.displayName || currentSession.username}</h3>
|
||||||
{isGroupChatSession(currentSession.username) && (
|
{isCurrentSessionGroup && (
|
||||||
<div className="header-subtitle">群聊</div>
|
<div className="header-subtitle">群聊</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
{!standaloneSessionWindow && isGroupChatSession(currentSession.username) && (
|
{!standaloneSessionWindow && isCurrentSessionGroup && (
|
||||||
<button
|
<button
|
||||||
className="icon-btn group-analytics-btn"
|
className="icon-btn group-analytics-btn"
|
||||||
onClick={handleGroupAnalytics}
|
onClick={handleGroupAnalytics}
|
||||||
@@ -3756,7 +3898,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<BarChart3 size={18} />
|
<BarChart3 size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isGroupChatSession(currentSession.username) && (
|
{isCurrentSessionGroup && (
|
||||||
<button
|
<button
|
||||||
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
|
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
|
||||||
onClick={toggleGroupMembersPanel}
|
onClick={toggleGroupMembersPanel}
|
||||||
@@ -3779,6 +3921,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!standaloneSessionWindow && isCurrentSessionPrivateSnsSupported && (
|
||||||
|
<button
|
||||||
|
className="icon-btn chat-sns-timeline-btn"
|
||||||
|
onClick={openCurrentSessionSnsTimeline}
|
||||||
|
disabled={!currentSessionId}
|
||||||
|
title="查看对方朋友圈"
|
||||||
|
>
|
||||||
|
<Aperture size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!standaloneSessionWindow && (
|
{!standaloneSessionWindow && (
|
||||||
<button
|
<button
|
||||||
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
||||||
@@ -3863,13 +4015,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>
|
||||||
|
|
||||||
@@ -3880,7 +4034,19 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ContactSnsTimelineDialog
|
||||||
|
target={chatSnsTimelineTarget}
|
||||||
|
onClose={() => setChatSnsTimelineTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
|
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
|
||||||
|
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
|
||||||
|
<div className="standalone-phase-overlay" role="status" aria-live="polite">
|
||||||
|
<Loader2 size={22} className="spin" />
|
||||||
|
<span>{standaloneLoadStage === 'connecting' ? '正在建立连接...' : '正在加载最近消息...'}</span>
|
||||||
|
{connectionError && <small>{connectionError}</small>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
|
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
|
||||||
<div className="loading-messages loading-overlay">
|
<div className="loading-messages loading-overlay">
|
||||||
<Loader2 size={24} />
|
<Loader2 size={24} />
|
||||||
@@ -3937,7 +4103,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
session={currentSession}
|
session={currentSession}
|
||||||
showTime={!showDateDivider && showTime}
|
showTime={!showDateDivider && showTime}
|
||||||
myAvatarUrl={myAvatarUrl}
|
myAvatarUrl={myAvatarUrl}
|
||||||
isGroupChat={isGroupChatSession(currentSession.username)}
|
isGroupChat={isCurrentSessionGroup}
|
||||||
onRequireModelDownload={handleRequireModelDownload}
|
onRequireModelDownload={handleRequireModelDownload}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
@@ -3969,7 +4135,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 群成员面板 */}
|
{/* 群成员面板 */}
|
||||||
{showGroupMembersPanel && isGroupChatSession(currentSession.username) && (
|
{showGroupMembersPanel && isCurrentSessionGroup && (
|
||||||
<div className="detail-panel group-members-panel">
|
<div className="detail-panel group-members-panel">
|
||||||
<div className="detail-header">
|
<div className="detail-header">
|
||||||
<h4>群成员</h4>
|
<h4>群成员</h4>
|
||||||
|
|||||||
@@ -535,6 +535,28 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-entry-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.goto-chat-btn {
|
.goto-chat-btn {
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react'
|
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList, Aperture } from 'lucide-react'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import type { ContactInfo } from '../types/models'
|
||||||
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
|
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||||||
import './ContactsPage.scss'
|
import './ContactsPage.scss'
|
||||||
|
|
||||||
interface ContactInfo {
|
|
||||||
username: string
|
|
||||||
displayName: string
|
|
||||||
remark?: string
|
|
||||||
nickname?: string
|
|
||||||
avatarUrl?: string
|
|
||||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContactEnrichInfo {
|
interface ContactEnrichInfo {
|
||||||
displayName?: string
|
displayName?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
@@ -62,6 +56,9 @@ function ContactsPage() {
|
|||||||
// 导出模式与查看详情
|
// 导出模式与查看详情
|
||||||
const [exportMode, setExportMode] = useState(false)
|
const [exportMode, setExportMode] = useState(false)
|
||||||
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
|
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
|
||||||
|
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
|
||||||
|
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||||
|
const [snsTimelineTarget, setSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { setCurrentSession } = useChatStore()
|
const { setCurrentSession } = useChatStore()
|
||||||
|
|
||||||
@@ -509,6 +506,41 @@ function ContactsPage() {
|
|||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [searchKeyword])
|
}, [searchKeyword])
|
||||||
|
|
||||||
|
const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => {
|
||||||
|
if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnsUserPostCountsStatus('loading')
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||||
|
if (!result.success || !result.counts) {
|
||||||
|
setSnsUserPostCountsStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCounts: Record<string, number> = {}
|
||||||
|
for (const [rawUsername, rawCount] of Object.entries(result.counts)) {
|
||||||
|
const username = String(rawUsername || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const value = Number(rawCount)
|
||||||
|
normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnsUserPostCounts(normalizedCounts)
|
||||||
|
setSnsUserPostCountsStatus('ready')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载通讯录联系人朋友圈条数失败:', error)
|
||||||
|
setSnsUserPostCountsStatus('error')
|
||||||
|
}
|
||||||
|
}, [snsUserPostCountsStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedContact || !isSingleContactSession(selectedContact.username)) return
|
||||||
|
if (snsUserPostCountsStatus !== 'idle') return
|
||||||
|
void loadSnsUserPostCounts()
|
||||||
|
}, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus])
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
let filtered = contacts.filter(contact => {
|
let filtered = contacts.filter(contact => {
|
||||||
if (contact.type === 'friend' && !contactTypes.friends) return false
|
if (contact.type === 'friend' && !contactTypes.friends) return false
|
||||||
@@ -579,6 +611,38 @@ function ContactsPage() {
|
|||||||
}, [filteredContacts, selectedUsernames])
|
}, [filteredContacts, selectedUsernames])
|
||||||
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
|
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
|
||||||
|
|
||||||
|
const selectedContactSupportsSns = useMemo(() => {
|
||||||
|
return Boolean(selectedContact && isSingleContactSession(selectedContact.username))
|
||||||
|
}, [selectedContact])
|
||||||
|
|
||||||
|
const selectedContactSnsCount = useMemo(() => {
|
||||||
|
if (!selectedContactSupportsSns || !selectedContact) return null
|
||||||
|
if (snsUserPostCountsStatus !== 'ready') return null
|
||||||
|
const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0)
|
||||||
|
return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
|
||||||
|
}, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus])
|
||||||
|
|
||||||
|
const selectedContactSnsEntryLabel = useMemo(() => {
|
||||||
|
if (!selectedContactSupportsSns) return ''
|
||||||
|
if (selectedContactSnsCount !== null) {
|
||||||
|
return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}条`
|
||||||
|
}
|
||||||
|
if (snsUserPostCountsStatus === 'error') return '朋友圈:查看'
|
||||||
|
return '朋友圈:统计中...'
|
||||||
|
}, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus])
|
||||||
|
|
||||||
|
const openSelectedContactSnsTimeline = useCallback(() => {
|
||||||
|
if (!selectedContact || !selectedContactSupportsSns) return
|
||||||
|
if (snsUserPostCountsStatus === 'idle') {
|
||||||
|
void loadSnsUserPostCounts()
|
||||||
|
}
|
||||||
|
setSnsTimelineTarget({
|
||||||
|
username: selectedContact.username,
|
||||||
|
displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username,
|
||||||
|
avatarUrl: selectedContact.avatarUrl
|
||||||
|
})
|
||||||
|
}, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus])
|
||||||
|
|
||||||
const { startIndex, endIndex } = useMemo(() => {
|
const { startIndex, endIndex } = useMemo(() => {
|
||||||
if (filteredContacts.length === 0) {
|
if (filteredContacts.length === 0) {
|
||||||
return { startIndex: 0, endIndex: 0 }
|
return { startIndex: 0, endIndex: 0 }
|
||||||
@@ -1069,6 +1133,19 @@ function ContactsPage() {
|
|||||||
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
||||||
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
||||||
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
||||||
|
{selectedContactSupportsSns && (
|
||||||
|
<div className="detail-row">
|
||||||
|
<span className="detail-label">朋友圈</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="detail-entry-btn"
|
||||||
|
onClick={openSelectedContactSnsTimeline}
|
||||||
|
>
|
||||||
|
<Aperture size={14} />
|
||||||
|
<span>{selectedContactSnsEntryLabel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -1091,6 +1168,14 @@ function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ContactSnsTimelineDialog
|
||||||
|
target={snsTimelineTarget}
|
||||||
|
onClose={() => setSnsTimelineTarget(null)}
|
||||||
|
initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null}
|
||||||
|
initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username
|
||||||
|
? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading'
|
||||||
|
: false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -348,6 +348,51 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-time-range-field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.45);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-arrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.select-trigger {
|
.select-trigger {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
@@ -2239,4 +2284,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useThemeStore, themes } from '../stores/themeStore'
|
import { useThemeStore, themes } from '../stores/themeStore'
|
||||||
@@ -8,20 +9,19 @@ import * as configService from '../services/config'
|
|||||||
import {
|
import {
|
||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
|
||||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
{ id: 'notification', label: '通知', icon: Bell },
|
{ id: 'notification', label: '通知', icon: Bell },
|
||||||
{ id: 'database', label: '数据库连接', icon: Database },
|
{ id: 'database', label: '数据库连接', icon: Database },
|
||||||
{ id: 'models', label: '模型管理', icon: Mic },
|
{ id: 'models', label: '模型管理', icon: Mic },
|
||||||
{ id: 'export', label: '导出', icon: Download },
|
|
||||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ interface WxidOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
|
const location = useLocation()
|
||||||
const {
|
const {
|
||||||
isDbConnected,
|
isDbConnected,
|
||||||
setDbConnected,
|
setDbConnected,
|
||||||
@@ -73,14 +74,6 @@ function SettingsPage() {
|
|||||||
const [wxid, setWxid] = useState('')
|
const [wxid, setWxid] = useState('')
|
||||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||||
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
|
||||||
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
|
||||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
|
||||||
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
|
|
||||||
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
const [imageKeyProgress, setImageKeyProgress] = useState(0)
|
const [imageKeyProgress, setImageKeyProgress] = useState(0)
|
||||||
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||||
@@ -103,12 +96,6 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
|
||||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
|
||||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
|
||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
|
||||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
|
||||||
|
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(true)
|
const [notificationEnabled, setNotificationEnabled] = useState(true)
|
||||||
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
|
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
|
||||||
@@ -202,26 +189,11 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 点击外部关闭下拉框
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const initialTab = (location.state as { initialTab?: SettingsTab } | null)?.initialTab
|
||||||
const target = e.target as Node
|
if (!initialTab) return
|
||||||
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
setActiveTab(initialTab)
|
||||||
setShowExportFormatSelect(false)
|
}, [location.state])
|
||||||
}
|
|
||||||
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
|
|
||||||
setShowExportDateRangeSelect(false)
|
|
||||||
}
|
|
||||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
|
||||||
setShowExportExcelColumnsSelect(false)
|
|
||||||
}
|
|
||||||
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
|
|
||||||
setShowExportConcurrencySelect(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
@@ -289,13 +261,6 @@ function SettingsPage() {
|
|||||||
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
||||||
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||||
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
|
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
|
||||||
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
|
|
||||||
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
|
|
||||||
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
|
||||||
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
|
||||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
|
||||||
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
|
|
||||||
|
|
||||||
const savedNotificationEnabled = await configService.getNotificationEnabled()
|
const savedNotificationEnabled = await configService.getNotificationEnabled()
|
||||||
const savedNotificationPosition = await configService.getNotificationPosition()
|
const savedNotificationPosition = await configService.getNotificationPosition()
|
||||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||||
@@ -330,12 +295,6 @@ function SettingsPage() {
|
|||||||
setLogEnabled(savedLogEnabled)
|
setLogEnabled(savedLogEnabled)
|
||||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||||
setTranscribeLanguages(savedTranscribeLanguages)
|
setTranscribeLanguages(savedTranscribeLanguages)
|
||||||
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
|
||||||
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
|
||||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
|
||||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
|
|
||||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
|
||||||
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
|
||||||
|
|
||||||
setNotificationEnabled(savedNotificationEnabled)
|
setNotificationEnabled(savedNotificationEnabled)
|
||||||
setNotificationPosition(savedNotificationPosition)
|
setNotificationPosition(savedNotificationPosition)
|
||||||
@@ -1547,258 +1506,6 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const exportFormatOptions = [
|
|
||||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
|
||||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
|
||||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
|
||||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
|
||||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
|
||||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
|
||||||
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
|
|
||||||
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
|
||||||
]
|
|
||||||
const exportDateRangeOptions = [
|
|
||||||
{ value: 'today', label: '今天' },
|
|
||||||
{ value: '7d', label: '最近7天' },
|
|
||||||
{ value: '30d', label: '最近30天' },
|
|
||||||
{ value: '90d', label: '最近90天' },
|
|
||||||
{ value: 'all', label: '全部时间' }
|
|
||||||
]
|
|
||||||
const exportExcelColumnOptions = [
|
|
||||||
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
|
||||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const exportConcurrencyOptions = [
|
|
||||||
{ value: 1, label: '1' },
|
|
||||||
{ value: 2, label: '2' },
|
|
||||||
{ value: 3, label: '3' },
|
|
||||||
{ value: 4, label: '4' },
|
|
||||||
{ value: 5, label: '5' },
|
|
||||||
{ value: 6, label: '6' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
|
||||||
return options.find((option) => option.value === value)?.label ?? value
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderExportTab = () => {
|
|
||||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
|
||||||
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
|
||||||
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
|
||||||
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
|
||||||
const exportConcurrencyLabel = String(exportDefaultConcurrency)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tab-content">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>默认导出格式</label>
|
|
||||||
<span className="form-hint">导出页面默认选中的格式</span>
|
|
||||||
<div className="select-field" ref={exportFormatDropdownRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setShowExportFormatSelect(!showExportFormatSelect)
|
|
||||||
setShowExportDateRangeSelect(false)
|
|
||||||
setShowExportExcelColumnsSelect(false)
|
|
||||||
setShowExportConcurrencySelect(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="select-value">{exportFormatLabel}</span>
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</button>
|
|
||||||
{showExportFormatSelect && (
|
|
||||||
<div className="select-dropdown">
|
|
||||||
{exportFormatOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
|
|
||||||
onClick={async () => {
|
|
||||||
setExportDefaultFormat(option.value)
|
|
||||||
await configService.setExportDefaultFormat(option.value)
|
|
||||||
showMessage('已更新导出格式默认值', true)
|
|
||||||
setShowExportFormatSelect(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="option-label">{option.label}</span>
|
|
||||||
{option.desc && <span className="option-desc">{option.desc}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>默认导出时间范围</label>
|
|
||||||
<span className="form-hint">控制导出页面的默认时间选择</span>
|
|
||||||
<div className="select-field" ref={exportDateRangeDropdownRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
|
||||||
setShowExportFormatSelect(false)
|
|
||||||
setShowExportExcelColumnsSelect(false)
|
|
||||||
setShowExportConcurrencySelect(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="select-value">{exportDateRangeLabel}</span>
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</button>
|
|
||||||
{showExportDateRangeSelect && (
|
|
||||||
<div className="select-dropdown">
|
|
||||||
{exportDateRangeOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
|
|
||||||
onClick={async () => {
|
|
||||||
setExportDefaultDateRange(option.value)
|
|
||||||
await configService.setExportDefaultDateRange(option.value)
|
|
||||||
showMessage('已更新默认导出时间范围', true)
|
|
||||||
setShowExportDateRangeSelect(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="option-label">{option.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>默认导出媒体文件</label>
|
|
||||||
<span className="form-hint">控制图片/语音/表情的默认导出开关</span>
|
|
||||||
<div className="log-toggle-line">
|
|
||||||
<span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span>
|
|
||||||
<label className="switch" htmlFor="export-default-media">
|
|
||||||
<input
|
|
||||||
id="export-default-media"
|
|
||||||
className="switch-input"
|
|
||||||
type="checkbox"
|
|
||||||
checked={exportDefaultMedia}
|
|
||||||
onChange={async (e) => {
|
|
||||||
const enabled = e.target.checked
|
|
||||||
setExportDefaultMedia(enabled)
|
|
||||||
await configService.setExportDefaultMedia(enabled)
|
|
||||||
showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="switch-slider" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>默认语音转文字</label>
|
|
||||||
<span className="form-hint">导出时默认将语音转写为文字</span>
|
|
||||||
<div className="log-toggle-line">
|
|
||||||
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
|
|
||||||
<label className="switch" htmlFor="export-default-voice-as-text">
|
|
||||||
<input
|
|
||||||
id="export-default-voice-as-text"
|
|
||||||
className="switch-input"
|
|
||||||
type="checkbox"
|
|
||||||
checked={exportDefaultVoiceAsText}
|
|
||||||
onChange={async (e) => {
|
|
||||||
const enabled = e.target.checked
|
|
||||||
setExportDefaultVoiceAsText(enabled)
|
|
||||||
await configService.setExportDefaultVoiceAsText(enabled)
|
|
||||||
showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="switch-slider" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Excel 列显示</label>
|
|
||||||
<span className="form-hint">控制 Excel 导出的列字段</span>
|
|
||||||
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
|
||||||
setShowExportFormatSelect(false)
|
|
||||||
setShowExportDateRangeSelect(false)
|
|
||||||
setShowExportConcurrencySelect(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</button>
|
|
||||||
{showExportExcelColumnsSelect && (
|
|
||||||
<div className="select-dropdown">
|
|
||||||
{exportExcelColumnOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
|
||||||
onClick={async () => {
|
|
||||||
const compact = option.value === 'compact'
|
|
||||||
setExportDefaultExcelCompactColumns(compact)
|
|
||||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
|
||||||
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
|
|
||||||
setShowExportExcelColumnsSelect(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="option-label">{option.label}</span>
|
|
||||||
{option.desc && <span className="option-desc">{option.desc}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>导出并发数</label>
|
|
||||||
<span className="form-hint">导出多个会话时的最大并发(1~6)</span>
|
|
||||||
<div className="select-field" ref={exportConcurrencyDropdownRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setShowExportConcurrencySelect(!showExportConcurrencySelect)
|
|
||||||
setShowExportFormatSelect(false)
|
|
||||||
setShowExportDateRangeSelect(false)
|
|
||||||
setShowExportExcelColumnsSelect(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="select-value">{exportConcurrencyLabel}</span>
|
|
||||||
<ChevronDown size={16} />
|
|
||||||
</button>
|
|
||||||
{showExportConcurrencySelect && (
|
|
||||||
<div className="select-dropdown">
|
|
||||||
{exportConcurrencyOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`select-option ${exportDefaultConcurrency === option.value ? 'active' : ''}`}
|
|
||||||
onClick={async () => {
|
|
||||||
setExportDefaultConcurrency(option.value)
|
|
||||||
await configService.setExportDefaultConcurrency(option.value)
|
|
||||||
showMessage(`已将导出并发数设为 ${option.value}`, true)
|
|
||||||
setShowExportConcurrencySelect(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="option-label">{option.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const renderCacheTab = () => (
|
const renderCacheTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<p className="section-desc">管理应用缓存数据</p>
|
<p className="section-desc">管理应用缓存数据</p>
|
||||||
@@ -2395,7 +2102,6 @@ function SettingsPage() {
|
|||||||
{activeTab === 'notification' && renderNotificationTab()}
|
{activeTab === 'notification' && renderNotificationTab()}
|
||||||
{activeTab === 'database' && renderDatabaseTab()}
|
{activeTab === 'database' && renderDatabaseTab()}
|
||||||
{activeTab === 'models' && renderModelsTab()}
|
{activeTab === 'models' && renderModelsTab()}
|
||||||
{activeTab === 'export' && renderExportTab()}
|
|
||||||
{activeTab === 'cache' && renderCacheTab()}
|
{activeTab === 'cache' && renderCacheTab()}
|
||||||
{activeTab === 'api' && renderApiTab()}
|
{activeTab === 'api' && renderApiTab()}
|
||||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
.sns-page-layout {
|
.sns-page-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: calc(100% + 48px);
|
||||||
|
margin: -24px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--sns-bg-color);
|
background: var(--sns-bg-color);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
.sns-feed-container {
|
.sns-feed-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--sns-max-width);
|
max-width: var(--sns-max-width);
|
||||||
padding: 20px 24px 60px 24px;
|
padding: 10px 24px 12px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -44,13 +45,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 4px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: var(--sns-bg-color);
|
background: var(--sns-bg-color);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding-top: 10px;
|
padding-top: 4px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 6px;
|
||||||
|
|
||||||
.feed-header-main {
|
.feed-header-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -67,6 +68,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feed-stats-line {
|
.feed-stats-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -80,6 +85,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-stats-range {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-overview-total {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-stats-divider {
|
||||||
|
color: color-mix(in srgb, var(--text-secondary) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-my-timeline-entry {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: default;
|
||||||
|
transition: color 0.2s ease, opacity 0.2s ease;
|
||||||
|
|
||||||
|
.feed-my-timeline-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-my-timeline-count {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ready {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .feed-my-timeline-count {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.feed-stats-retry {
|
.feed-stats-retry {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -98,6 +173,18 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jump-calendar-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
isolation: isolate;
|
||||||
|
z-index: 20;
|
||||||
|
|
||||||
|
.jump-date-popover {
|
||||||
|
z-index: 2600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -123,6 +210,50 @@
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jump-date-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--sns-border-radius-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-chip-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-chip-clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sns-posts-scroll {
|
.sns-posts-scroll {
|
||||||
@@ -179,6 +310,30 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author-trigger-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-trigger {
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.post-content-col {
|
.post-content-col {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -206,6 +361,30 @@
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author-name-trigger {
|
||||||
|
align-self: flex-start;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
transition: color 0.15s ease, text-decoration-color 0.15s ease;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .author-name {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.post-time {
|
.post-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
@@ -219,6 +398,13 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-time-standalone {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.2;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.debug-btn {
|
.debug-btn {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
@@ -909,9 +1095,21 @@
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widget-header-summary {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-widget .widget-header .badge + .widget-header-summary {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Search Widget */
|
/* Search Widget */
|
||||||
.input-group {
|
.input-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -950,44 +1148,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Date Widget */
|
|
||||||
.date-picker-trigger {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--sns-border-radius-sm);
|
|
||||||
padding: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: rgba(var(--primary-rgb), 0.08);
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-date-btn {
|
|
||||||
padding: 4px;
|
|
||||||
display: flex;
|
|
||||||
color: var(--primary);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Contact Widget - Refactored */
|
/* Contact Widget - Refactored */
|
||||||
.contact-widget {
|
.contact-widget {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1043,6 +1203,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-count-progress {
|
||||||
|
padding: 8px 16px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-list-scroll {
|
.contact-list-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1060,9 +1228,8 @@
|
|||||||
border-radius: var(--sns-border-radius-md);
|
border-radius: var(--sns-border-radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease, transform 0.2s ease;
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
border: 2px solid transparent;
|
border: 1px solid transparent;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
/* Separation for unselected items */
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
@@ -1070,41 +1237,6 @@
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background: rgba(var(--primary-rgb), 0.1);
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: none;
|
|
||||||
z-index: 5;
|
|
||||||
margin-bottom: 0;
|
|
||||||
/* Remove margin to merge */
|
|
||||||
|
|
||||||
.contact-meta {
|
|
||||||
.contact-name {
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* If the NEXT item is also selected */
|
|
||||||
&:has(+ .contact-row.selected) {
|
|
||||||
border-bottom: none;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
/* Compensate for missing border (+2px) */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* If the PREVIOUS item is selected */
|
|
||||||
&.selected+.contact-row.selected {
|
|
||||||
border-top: none;
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
margin-top: 0;
|
|
||||||
padding-top: 12px;
|
|
||||||
/* Compensate for missing border */
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-meta {
|
.contact-meta {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1120,6 +1252,33 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-post-count-wrap {
|
||||||
|
margin-left: 8px;
|
||||||
|
min-width: 46px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-post-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-post-count-loading {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,6 +1476,116 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.author-timeline-dialog {
|
||||||
|
background: var(--sns-card-bg);
|
||||||
|
border-radius: var(--sns-border-radius-lg);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
width: min(860px, 94vw);
|
||||||
|
max-height: 86vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-header {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-meta-text {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-username {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-stats {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-body {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: calc(86vh - 96px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-posts-list {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-loading {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-empty {
|
||||||
|
padding: 42px 10px 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-timeline-load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto 2px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slide-up-fade {
|
@keyframes slide-up-fade {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1436,6 +1705,44 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-trigger.sns-export-time-range-trigger {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.45);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-range-arrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.export-format-options {
|
.export-format-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
// 配置服务 - 封装 Electron Store
|
// 配置服务 - 封装 Electron Store
|
||||||
import { config } from './ipc'
|
import { config } from './ipc'
|
||||||
|
import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange'
|
||||||
|
|
||||||
// 配置键名
|
// 配置键名
|
||||||
export const CONFIG_KEYS = {
|
export const CONFIG_KEYS = {
|
||||||
@@ -26,6 +27,7 @@ export const CONFIG_KEYS = {
|
|||||||
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
||||||
TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
|
TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
|
||||||
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
||||||
|
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
|
||||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||||
@@ -41,6 +43,7 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
||||||
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||||
|
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||||
@@ -75,6 +78,20 @@ export interface WxidConfig {
|
|||||||
updatedAt?: number
|
updatedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExportDefaultMediaConfig {
|
||||||
|
images: boolean
|
||||||
|
videos: boolean
|
||||||
|
voices: boolean
|
||||||
|
emojis: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||||
|
images: true,
|
||||||
|
videos: true,
|
||||||
|
voices: true,
|
||||||
|
emojis: true
|
||||||
|
}
|
||||||
|
|
||||||
// 获取解密密钥
|
// 获取解密密钥
|
||||||
export async function getDecryptKey(): Promise<string | null> {
|
export async function getDecryptKey(): Promise<string | null> {
|
||||||
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
|
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
|
||||||
@@ -333,27 +350,64 @@ export async function setExportDefaultFormat(format: string): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取导出默认时间范围
|
// 获取导出默认头像设置
|
||||||
export async function getExportDefaultDateRange(): Promise<string | null> {
|
export async function getExportDefaultAvatars(): Promise<boolean | null> {
|
||||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS)
|
||||||
return (value as string) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置导出默认时间范围
|
|
||||||
export async function setExportDefaultDateRange(range: string): Promise<void> {
|
|
||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取导出默认媒体设置
|
|
||||||
export async function getExportDefaultMedia(): Promise<boolean | null> {
|
|
||||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
|
||||||
if (typeof value === 'boolean') return value
|
if (typeof value === 'boolean') return value
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置导出默认头像设置
|
||||||
|
export async function setExportDefaultAvatars(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取导出默认时间范围
|
||||||
|
export async function getExportDefaultDateRange(): Promise<ExportDefaultDateRangeConfig | string | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return value as ExportDefaultDateRangeConfig
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认时间范围
|
||||||
|
export async function setExportDefaultDateRange(range: ExportDefaultDateRangeConfig | string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取导出默认媒体设置
|
||||||
|
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return {
|
||||||
|
images: value,
|
||||||
|
videos: value,
|
||||||
|
voices: value,
|
||||||
|
emojis: value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const raw = value as Partial<Record<keyof ExportDefaultMediaConfig, unknown>>
|
||||||
|
return {
|
||||||
|
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
|
||||||
|
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
|
||||||
|
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
|
||||||
|
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// 设置导出默认媒体设置
|
// 设置导出默认媒体设置
|
||||||
export async function setExportDefaultMedia(enabled: boolean): Promise<void> {
|
export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled)
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, {
|
||||||
|
images: media.images,
|
||||||
|
videos: media.videos,
|
||||||
|
voices: media.voices,
|
||||||
|
emojis: media.emojis
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取导出默认语音转文字
|
// 获取导出默认语音转文字
|
||||||
@@ -534,6 +588,11 @@ export interface ExportSnsStatsCacheItem {
|
|||||||
totalFriends: number
|
totalFriends: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExportSnsUserPostCountsCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
counts: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnsPageOverviewCache {
|
export interface SnsPageOverviewCache {
|
||||||
totalPosts: number
|
totalPosts: number
|
||||||
totalFriends: number
|
totalFriends: number
|
||||||
@@ -741,6 +800,58 @@ export async function setExportSnsStatsCache(
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
|
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getExportSnsUserPostCountsCache(scopeKey: string): Promise<ExportSnsUserPostCountsCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const raw = rawItem as Record<string, unknown>
|
||||||
|
const rawCounts = raw.counts
|
||||||
|
if (!rawCounts || typeof rawCounts !== 'object') return null
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const [rawUsername, rawCount] of Object.entries(rawCounts as Record<string, unknown>)) {
|
||||||
|
const username = String(rawUsername || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const valueNum = Number(rawCount)
|
||||||
|
counts[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
|
||||||
|
? raw.updatedAt
|
||||||
|
: 0
|
||||||
|
return { updatedAt, counts }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSnsUserPostCountsCache(
|
||||||
|
scopeKey: string,
|
||||||
|
counts: Record<string, number>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, number> = {}
|
||||||
|
for (const [rawUsername, rawCount] of Object.entries(counts || {})) {
|
||||||
|
const username = String(rawUsername || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const valueNum = Number(rawCount)
|
||||||
|
normalized[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
counts: normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
||||||
if (!scopeKey) return null
|
if (!scopeKey) return null
|
||||||
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||||
|
|||||||
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 }>
|
||||||
|
|||||||
341
src/utils/exportDateRange.ts
Normal file
341
src/utils/exportDateRange.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
export type ExportDateRangePreset =
|
||||||
|
| 'all'
|
||||||
|
| 'today'
|
||||||
|
| 'yesterday'
|
||||||
|
| 'last3days'
|
||||||
|
| 'last7days'
|
||||||
|
| 'last30days'
|
||||||
|
| 'last1year'
|
||||||
|
| 'last2years'
|
||||||
|
| 'custom'
|
||||||
|
|
||||||
|
export type CalendarCell = { date: Date; inCurrentMonth: boolean }
|
||||||
|
|
||||||
|
export interface ExportDateRange {
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportDateRangeSelection {
|
||||||
|
preset: ExportDateRangePreset
|
||||||
|
useAllTime: boolean
|
||||||
|
dateRange: ExportDateRange
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportDefaultDateRangeConfig {
|
||||||
|
version?: 1
|
||||||
|
preset?: ExportDateRangePreset | string
|
||||||
|
useAllTime?: boolean
|
||||||
|
start?: string | number | Date | null
|
||||||
|
end?: string | number | Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPORT_DATE_RANGE_PRESETS: Array<{
|
||||||
|
value: Exclude<ExportDateRangePreset, 'custom'>
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: 'all', label: '全部时间' },
|
||||||
|
{ value: 'today', label: '今天' },
|
||||||
|
{ value: 'yesterday', label: '昨天' },
|
||||||
|
{ value: 'last3days', label: '最近3天' },
|
||||||
|
{ value: 'last7days', label: '最近一周' },
|
||||||
|
{ value: 'last30days', label: '最近30天' },
|
||||||
|
{ value: 'last1year', label: '最近一年' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRESET_LABELS: Record<Exclude<ExportDateRangePreset, 'custom'>, string> = {
|
||||||
|
all: '全部时间',
|
||||||
|
today: '今天',
|
||||||
|
yesterday: '昨天',
|
||||||
|
last3days: '最近3天',
|
||||||
|
last7days: '最近一周',
|
||||||
|
last30days: '最近30天',
|
||||||
|
last1year: '最近一年',
|
||||||
|
last2years: '最近两年'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_PRESET_MAP: Record<string, Exclude<ExportDateRangePreset, 'custom'> | 'legacy90days'> = {
|
||||||
|
all: 'all',
|
||||||
|
today: 'today',
|
||||||
|
yesterday: 'yesterday',
|
||||||
|
last3days: 'last3days',
|
||||||
|
last7days: 'last7days',
|
||||||
|
last30days: 'last30days',
|
||||||
|
last1year: 'last1year',
|
||||||
|
last2years: 'last2years',
|
||||||
|
'7d': 'last7days',
|
||||||
|
'30d': 'last30days',
|
||||||
|
'90d': 'legacy90days'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEEKDAY_SHORT_LABELS = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
|
||||||
|
export const startOfDay = (date: Date): Date => {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setHours(0, 0, 0, 0)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const endOfDay = (date: Date): Date => {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setHours(23, 59, 59, 999)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDefaultDateRange = (): ExportDateRange => {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
start: startOfDay(now),
|
||||||
|
end: now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDateRangeByPreset = (
|
||||||
|
preset: Exclude<ExportDateRangePreset, 'all' | 'custom'>,
|
||||||
|
now = new Date()
|
||||||
|
): ExportDateRange => {
|
||||||
|
const end = new Date(now)
|
||||||
|
const baseStart = startOfDay(now)
|
||||||
|
|
||||||
|
if (preset === 'today') {
|
||||||
|
return { start: baseStart, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'yesterday') {
|
||||||
|
const yesterday = new Date(baseStart)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
return {
|
||||||
|
start: yesterday,
|
||||||
|
end: endOfDay(yesterday)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'last1year' || preset === 'last2years') {
|
||||||
|
const yearsBack = preset === 'last1year' ? 1 : 2
|
||||||
|
const start = new Date(baseStart)
|
||||||
|
const expectedMonth = start.getMonth()
|
||||||
|
start.setFullYear(start.getFullYear() - yearsBack)
|
||||||
|
if (start.getMonth() !== expectedMonth) {
|
||||||
|
start.setDate(0)
|
||||||
|
}
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysBack = preset === 'last3days' ? 2 : preset === 'last7days' ? 6 : 29
|
||||||
|
const start = new Date(baseStart)
|
||||||
|
start.setDate(start.getDate() - daysBack)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDateRangeByLastNDays = (days: number, now = new Date()): ExportDateRange => {
|
||||||
|
const end = new Date(now)
|
||||||
|
const start = startOfDay(now)
|
||||||
|
start.setDate(start.getDate() - Math.max(0, days - 1))
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateInputValue = (date: Date): string => {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const d = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseDateInputValue = (raw: string): Date | null => {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
|
||||||
|
if (!matched) return null
|
||||||
|
const year = Number(matched[1])
|
||||||
|
const month = Number(matched[2])
|
||||||
|
const day = Number(matched[3])
|
||||||
|
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
|
||||||
|
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||||
|
const parsed = new Date(year, month - 1, day)
|
||||||
|
if (
|
||||||
|
parsed.getFullYear() !== year ||
|
||||||
|
parsed.getMonth() !== month - 1 ||
|
||||||
|
parsed.getDate() !== day
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toMonthStart = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), 1)
|
||||||
|
|
||||||
|
export const addMonths = (date: Date, delta: number): Date => {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setMonth(next.getMonth() + delta)
|
||||||
|
return toMonthStart(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSameDay = (left: Date, right: Date): boolean => (
|
||||||
|
left.getFullYear() === right.getFullYear() &&
|
||||||
|
left.getMonth() === right.getMonth() &&
|
||||||
|
left.getDate() === right.getDate()
|
||||||
|
)
|
||||||
|
|
||||||
|
export const buildCalendarCells = (monthStart: Date): CalendarCell[] => {
|
||||||
|
const firstDay = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1)
|
||||||
|
const startOffset = firstDay.getDay()
|
||||||
|
const gridStart = new Date(firstDay)
|
||||||
|
gridStart.setDate(gridStart.getDate() - startOffset)
|
||||||
|
const cells: CalendarCell[] = []
|
||||||
|
for (let index = 0; index < 42; index += 1) {
|
||||||
|
const current = new Date(gridStart)
|
||||||
|
current.setDate(gridStart.getDate() + index)
|
||||||
|
cells.push({
|
||||||
|
date: current,
|
||||||
|
inCurrentMonth: current.getMonth() === monthStart.getMonth()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatCalendarMonthTitle = (date: Date): string => `${date.getFullYear()}年${date.getMonth() + 1}月`
|
||||||
|
|
||||||
|
export const cloneExportDateRange = (range: ExportDateRange): ExportDateRange => ({
|
||||||
|
start: new Date(range.start),
|
||||||
|
end: new Date(range.end)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const cloneExportDateRangeSelection = (selection: ExportDateRangeSelection): ExportDateRangeSelection => ({
|
||||||
|
preset: selection.preset,
|
||||||
|
useAllTime: selection.useAllTime,
|
||||||
|
dateRange: cloneExportDateRange(selection.dateRange)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createExportDateRangeSelectionFromPreset = (
|
||||||
|
preset: Exclude<ExportDateRangePreset, 'custom'>,
|
||||||
|
now = new Date()
|
||||||
|
): ExportDateRangeSelection => {
|
||||||
|
if (preset === 'all') {
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: createDefaultDateRange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: createDateRangeByPreset(preset, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDefaultExportDateRangeSelection = (): ExportDateRangeSelection => (
|
||||||
|
createExportDateRangeSelectionFromPreset('today')
|
||||||
|
)
|
||||||
|
|
||||||
|
const parseStoredDate = (value: unknown): Date | null => {
|
||||||
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||||
|
return new Date(value)
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
const parsed = new Date(value)
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = parseDateInputValue(value)
|
||||||
|
if (normalized) return normalized
|
||||||
|
const parsed = new Date(value)
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizePreset = (raw: unknown): Exclude<ExportDateRangePreset, 'custom'> | 'legacy90days' | null => {
|
||||||
|
if (typeof raw !== 'string') return null
|
||||||
|
const normalized = LEGACY_PRESET_MAP[raw]
|
||||||
|
return normalized ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveExportDateRangeConfig = (
|
||||||
|
raw: ExportDefaultDateRangeConfig | string | null | undefined,
|
||||||
|
now = new Date()
|
||||||
|
): ExportDateRangeSelection => {
|
||||||
|
if (!raw) {
|
||||||
|
return createDefaultExportDateRangeSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const preset = normalizePreset(raw)
|
||||||
|
if (!preset) return createDefaultExportDateRangeSelection()
|
||||||
|
if (preset === 'legacy90days') {
|
||||||
|
return {
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: createDateRangeByLastNDays(90, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createExportDateRangeSelectionFromPreset(preset, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = normalizePreset(raw.preset)
|
||||||
|
if (raw.useAllTime || preset === 'all') {
|
||||||
|
return createExportDateRangeSelectionFromPreset('all', now)
|
||||||
|
}
|
||||||
|
if (preset && preset !== 'legacy90days') {
|
||||||
|
return createExportDateRangeSelectionFromPreset(preset, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset === 'legacy90days') {
|
||||||
|
return {
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: createDateRangeByLastNDays(90, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedStart = parseStoredDate(raw.start)
|
||||||
|
const parsedEnd = parseStoredDate(raw.end)
|
||||||
|
if (parsedStart && parsedEnd) {
|
||||||
|
const start = startOfDay(parsedStart)
|
||||||
|
const end = endOfDay(parsedEnd)
|
||||||
|
return {
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start,
|
||||||
|
end: end < start ? endOfDay(start) : end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDefaultExportDateRangeSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeExportDateRangeConfig = (
|
||||||
|
selection: ExportDateRangeSelection
|
||||||
|
): ExportDefaultDateRangeConfig => {
|
||||||
|
if (selection.useAllTime) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
preset: 'all',
|
||||||
|
useAllTime: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.preset === 'custom') {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
start: formatDateInputValue(selection.dateRange.start),
|
||||||
|
end: formatDateInputValue(selection.dateRange.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
preset: selection.preset,
|
||||||
|
useAllTime: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getExportDateRangeLabel = (selection: ExportDateRangeSelection): string => {
|
||||||
|
if (selection.useAllTime) return PRESET_LABELS.all
|
||||||
|
if (selection.preset !== 'custom') return PRESET_LABELS[selection.preset]
|
||||||
|
return `${formatDateInputValue(selection.dateRange.start)} 至 ${formatDateInputValue(selection.dateRange.end)}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user