mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 23:55:50 +00:00
Compare commits
291 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e2fdd4fc | ||
|
|
5c44b35045 | ||
|
|
cebb6426f8 | ||
|
|
f05e50e63e | ||
|
|
f8ef3f18ff | ||
|
|
47dbc540ac | ||
|
|
766d5ed2af | ||
|
|
783b408611 | ||
|
|
24c91269a0 | ||
|
|
e786026049 | ||
|
|
566b0cf6e5 | ||
|
|
b17844e837 | ||
|
|
5c93c4db57 | ||
|
|
57e8a96a4a | ||
|
|
438581834e | ||
|
|
58cfd49859 | ||
|
|
4a1933e924 | ||
|
|
6ded8c5ab5 | ||
|
|
edf38aad48 | ||
|
|
f4caa51da5 | ||
|
|
9575ba2a9f | ||
|
|
af2fe91f81 | ||
|
|
c641c86598 | ||
|
|
0599de372a | ||
|
|
1c89ee2797 | ||
|
|
5fd846bfc8 | ||
|
|
02aefcf155 | ||
|
|
e92983dd80 | ||
|
|
03f65317a9 | ||
|
|
21cb09fbde | ||
|
|
6c1e7f6f12 | ||
|
|
344dd3343b | ||
|
|
cacb9e449c | ||
|
|
18313141f4 | ||
|
|
ecd73ae0d6 | ||
|
|
7ad754df03 | ||
|
|
cfc601e19a | ||
|
|
9984f9c206 | ||
|
|
39e59a4077 | ||
|
|
d735ed19cb | ||
|
|
f4037a1ccf | ||
|
|
3e917e2062 | ||
|
|
919357a374 | ||
|
|
5b6be864fd | ||
|
|
98a3b06e56 | ||
|
|
6253def76c | ||
|
|
450e5f7e61 | ||
|
|
d2ec9c680d | ||
|
|
56d7ad6999 | ||
|
|
97024395c1 | ||
|
|
10342be2be | ||
|
|
51a3ee4a9b | ||
|
|
8779bbc532 | ||
|
|
90b33ef444 | ||
|
|
3fa0b36426 | ||
|
|
60a64cd777 | ||
|
|
c543fabdf4 | ||
|
|
64b96f00f7 | ||
|
|
86b372de68 | ||
|
|
c108070696 | ||
|
|
80a193a394 | ||
|
|
b9c16dbee4 | ||
|
|
6e870ef300 | ||
|
|
cf45ae30ac | ||
|
|
38a0453cbb | ||
|
|
92d37abbc5 | ||
|
|
39662038f7 | ||
|
|
75b58d0423 | ||
|
|
1814808df1 | ||
|
|
fe57d80a00 | ||
|
|
8cb855328d | ||
|
|
a62ba8e167 | ||
|
|
4f40b4af49 | ||
|
|
8d9a042489 | ||
|
|
ef05466d6d | ||
|
|
0a5cf005a1 | ||
|
|
f6c365bdf1 | ||
|
|
bc2ab60c59 | ||
|
|
ad217d4a3b | ||
|
|
61cc3e6f58 | ||
|
|
a3ab06509e | ||
|
|
54684ea3c9 | ||
|
|
3de4951c96 | ||
|
|
05c551d7ac | ||
|
|
7cea8b4fb3 | ||
|
|
ba2cdbf8cf | ||
|
|
3e004867be | ||
|
|
edaef53712 | ||
|
|
933842f6af | ||
|
|
2eff82891e | ||
|
|
c625756ab4 | ||
|
|
2140a220e2 | ||
|
|
7ead55d801 | ||
|
|
4e0038c813 | ||
|
|
d07e4c8ecd | ||
|
|
63fd42ff05 | ||
|
|
d5dbcd3f80 | ||
|
|
c301f36912 | ||
|
|
9dd5ee2365 | ||
|
|
3388b7a122 | ||
|
|
38af8de469 | ||
|
|
db0ebc6c33 | ||
|
|
7cc2961538 | ||
|
|
835ec4782c | ||
|
|
e6942bc201 | ||
|
|
ebabe1560f | ||
|
|
4da697f507 | ||
|
|
f18fb83a92 | ||
|
|
e050402787 | ||
|
|
b3dd0e25fa | ||
|
|
a5358b82f6 | ||
|
|
2a9f0f24fd | ||
|
|
5945942acd | ||
|
|
bcdb983b98 | ||
|
|
7836c611b7 | ||
|
|
2797d571e4 | ||
|
|
389fd0b1b0 | ||
|
|
25630da1ce | ||
|
|
ca972d3e28 | ||
|
|
80420302c1 | ||
|
|
9759d5f64f | ||
|
|
17a9b6102e | ||
|
|
7e7503035a | ||
|
|
02a6b24517 | ||
|
|
b3fee5b56d | ||
|
|
26d38acddb | ||
|
|
8a30e9b663 | ||
|
|
46a2d04528 | ||
|
|
6a85b82643 | ||
|
|
b436bb63da | ||
|
|
b5cb4051ab | ||
|
|
01f774db54 | ||
|
|
c5a6d765ee | ||
|
|
459f23bbd6 | ||
|
|
360754737f | ||
|
|
36f1476782 | ||
|
|
ecae83f659 | ||
|
|
fbe5109ed9 | ||
|
|
4adedad0de | ||
|
|
28257ba66f | ||
|
|
3062295069 | ||
|
|
3c231a7fde | ||
|
|
0247b02f6e | ||
|
|
8aaad71784 | ||
|
|
e795474917 | ||
|
|
49f99f57c9 | ||
|
|
53398707aa | ||
|
|
1d8a7d2e63 | ||
|
|
313e2bc080 | ||
|
|
0037935280 | ||
|
|
7858b40ce4 | ||
|
|
ab6db27ea7 | ||
|
|
4568795081 | ||
|
|
43643d1a83 | ||
|
|
28e7de6ceb | ||
|
|
c204855a71 | ||
|
|
dab33c4e60 | ||
|
|
47f9c0a502 | ||
|
|
d9a6fd2a42 | ||
|
|
dcb91905ad | ||
|
|
b6fd842d4e | ||
|
|
4b57e3e350 | ||
|
|
1652ebc4ad | ||
|
|
924ff1b6fc | ||
|
|
926ca72331 | ||
|
|
cf7190aaec | ||
|
|
54d6cded53 | ||
|
|
7a7e54ea5b | ||
|
|
7b4aa23f35 | ||
|
|
ac4482bc8b | ||
|
|
0a7f2b15f1 | ||
|
|
95e0b83537 | ||
|
|
bb602af750 | ||
|
|
580242b9d2 | ||
|
|
2cc1b55cbf | ||
|
|
e1944783d0 | ||
|
|
423d760f36 | ||
|
|
16e237b698 | ||
|
|
28d68d8a8e | ||
|
|
d476fbbdae | ||
|
|
64542f2902 | ||
|
|
56a59a5355 | ||
|
|
285ddeb62e | ||
|
|
84ef51f16b | ||
|
|
fb1125136c | ||
|
|
55f7ff1842 | ||
|
|
ac1d2210da | ||
|
|
ff92f355e2 | ||
|
|
4b8c8155fa | ||
|
|
756a83191d | ||
|
|
b5eb8be15e | ||
|
|
38a023d0b6 | ||
|
|
3a878dd019 | ||
|
|
6314c0f1d6 | ||
|
|
c5eed25f06 | ||
|
|
e1243522b0 | ||
|
|
d9108ac6ed | ||
|
|
302abe3e40 | ||
|
|
b6a2191e38 | ||
|
|
84b54e43aa | ||
|
|
e9971aa6c4 | ||
|
|
91f630209c | ||
|
|
b6878aefd6 | ||
|
|
f0f70def8c | ||
|
|
81bc5aefff | ||
|
|
698d2c96d7 | ||
|
|
ce683a539d | ||
|
|
ac481c6b18 | ||
|
|
750d6ad7eb | ||
|
|
7bd801cd01 | ||
|
|
5cb364f754 | ||
|
|
04d1b0c694 | ||
|
|
35028df817 | ||
|
|
2e8f55d7a8 | ||
|
|
815a440082 | ||
|
|
2afcd528dc | ||
|
|
8d68a59799 | ||
|
|
51bc60776d | ||
|
|
43f4c966f9 | ||
|
|
98a0233c4d | ||
|
|
0545be3244 | ||
|
|
4a67b22d8d | ||
|
|
5840bf710c | ||
|
|
1b8e1c2aab | ||
|
|
60aa949cca | ||
|
|
5b05b8927c | ||
|
|
d65d6d2396 | ||
|
|
086ac8fdc9 | ||
|
|
c6c7f128a9 | ||
|
|
36ec12fd0f | ||
|
|
e9fd751578 | ||
|
|
21a97b8871 | ||
|
|
b8ede4cfd0 | ||
|
|
f47eba5764 | ||
|
|
1347136b54 | ||
|
|
89f0758fbb | ||
|
|
b5507b9f5d | ||
|
|
204baa52ab | ||
|
|
bc739dc4a0 | ||
|
|
64616b9136 | ||
|
|
983783ea95 | ||
|
|
1414a4a9cf | ||
|
|
af7639aa73 | ||
|
|
dabc6a2d0a | ||
|
|
d1ef159e87 | ||
|
|
cc5c323ccb | ||
|
|
d18a871429 | ||
|
|
0a1f55f6a6 | ||
|
|
faeda030e9 | ||
|
|
b3700c3a4c | ||
|
|
01a221831f | ||
|
|
9cb41e01e2 | ||
|
|
abdb4f62de | ||
|
|
da7d354436 | ||
|
|
794a306f89 | ||
|
|
ac61ee1833 | ||
|
|
a87d419868 | ||
|
|
abbb7a0cb1 | ||
|
|
a5ae22d2a5 | ||
|
|
22b6a07749 | ||
|
|
dbdb2e2959 | ||
|
|
5147b3f0e4 | ||
|
|
a8eb0057e3 | ||
|
|
7604ff2ae4 | ||
|
|
bf9b5ba593 | ||
|
|
d12c111684 | ||
|
|
dffd3c9138 | ||
|
|
c34f7af6de | ||
|
|
22c7048ef6 | ||
|
|
96aa9d0813 | ||
|
|
d99c0ff8b2 | ||
|
|
c6e8bde078 | ||
|
|
adff7b9e1e | ||
|
|
b62c18fd84 | ||
|
|
de7cbdf494 | ||
|
|
0444ca143e | ||
|
|
596baad296 | ||
|
|
e686bb6247 | ||
|
|
06d6f15e38 | ||
|
|
d3adae42fe | ||
|
|
39b38119c1 | ||
|
|
eace3e9467 | ||
|
|
366da8d38e | ||
|
|
a965890916 | ||
|
|
b07bbd68d7 | ||
|
|
3d4a79aac6 | ||
|
|
e30c4cc644 | ||
|
|
d317be3ad3 | ||
|
|
1b078bd2fd | ||
|
|
1d84ed1614 | ||
|
|
114476d74c |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -57,11 +57,12 @@ Thumbs.db
|
||||
|
||||
wcdb/
|
||||
xkey/
|
||||
server/
|
||||
*info
|
||||
概述.md
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
.claude/
|
||||
.agents/
|
||||
resources/wx_send
|
||||
resources/wx_send
|
||||
概述.mdpnpm-lock.yaml
|
||||
|
||||
27
README.md
27
README.md
@@ -41,7 +41,28 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
- 年度报告与可视化概览
|
||||
- 导出聊天记录为 HTML 等格式
|
||||
- HTTP API 接口(供开发者集成)
|
||||
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||
|
||||
## 详细功能清单
|
||||
|
||||
当前版本已支持以下能力:
|
||||
|
||||
| 功能模块 | 说明 |
|
||||
|---------|------|
|
||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
|
||||
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
|
||||
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
|
||||
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
|
||||
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
|
||||
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
|
||||
|
||||
## HTTP API
|
||||
|
||||
@@ -55,13 +76,9 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
||||
- **访问地址**:`http://127.0.0.1:5031`
|
||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||
|
||||
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||
|
||||
## 面向开发者
|
||||
|
||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||
|
||||
766
electron/main.ts
766
electron/main.ts
@@ -3,7 +3,7 @@ import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { join, dirname } from 'path'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { ConfigService } from './services/config'
|
||||
import { dbPathService } from './services/dbPathService'
|
||||
@@ -21,8 +21,10 @@ import { videoService } from './services/videoService'
|
||||
import { snsService, isVideoUrl } from './services/snsService'
|
||||
import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
|
||||
import { cloudControlService } from './services/cloudControlService'
|
||||
|
||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
|
||||
|
||||
@@ -84,16 +86,152 @@ let agreementWindow: BrowserWindow | null = null
|
||||
let onboardingWindow: BrowserWindow | null = null
|
||||
// Splash 启动窗口
|
||||
let splashWindow: BrowserWindow | null = null
|
||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||
const keyService = new KeyService()
|
||||
|
||||
let mainWindowReady = false
|
||||
let shouldShowMain = true
|
||||
let isAppQuitting = false
|
||||
|
||||
// 更新下载状态管理(Issue #294 修复)
|
||||
let isDownloadInProgress = false
|
||||
let downloadProgressHandler: ((progress: any) => void) | null = null
|
||||
let downloadedHandler: (() => void) | null = null
|
||||
|
||||
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
|
||||
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
|
||||
|
||||
interface AnnualReportYearsProgressPayload {
|
||||
years?: number[]
|
||||
done: boolean
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
strategy?: AnnualReportYearsLoadStrategy
|
||||
phase?: AnnualReportYearsLoadPhase
|
||||
statusText?: string
|
||||
nativeElapsedMs?: number
|
||||
scanElapsedMs?: number
|
||||
totalElapsedMs?: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
interface AnnualReportYearsTaskState {
|
||||
cacheKey: string
|
||||
canceled: boolean
|
||||
done: boolean
|
||||
snapshot: AnnualReportYearsProgressPayload
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface OpenSessionChatWindowOptions {
|
||||
source?: 'chat' | 'export'
|
||||
initialDisplayName?: string
|
||||
initialAvatarUrl?: string
|
||||
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
|
||||
return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat'
|
||||
}
|
||||
|
||||
const normalizeSessionChatWindowOptionString = (value: unknown): string => {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
const loadSessionChatWindowContent = (
|
||||
win: BrowserWindow,
|
||||
sessionId: string,
|
||||
source: 'chat' | 'export',
|
||||
options?: OpenSessionChatWindowOptions
|
||||
) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
sessionId,
|
||||
source
|
||||
})
|
||||
const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName)
|
||||
const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl)
|
||||
const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType)
|
||||
if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName)
|
||||
if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl)
|
||||
if (initialContactType) queryParams.set('initialContactType', initialContactType)
|
||||
const query = queryParams.toString()
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`)
|
||||
return
|
||||
}
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-window?${query}`
|
||||
})
|
||||
}
|
||||
|
||||
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
|
||||
const annualReportYearsTaskByCacheKey = new Map<string, string>()
|
||||
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
|
||||
const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000
|
||||
|
||||
const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => {
|
||||
const years = Array.isArray(snapshot.years) ? [...snapshot.years] : []
|
||||
return { ...snapshot, years }
|
||||
}
|
||||
|
||||
const buildAnnualReportYearsCacheKey = (dbPath: string, wxid: string): string => {
|
||||
return `${String(dbPath || '').trim()}\u0001${String(wxid || '').trim()}`
|
||||
}
|
||||
|
||||
const pruneAnnualReportYearsSnapshotCache = (): void => {
|
||||
const now = Date.now()
|
||||
for (const [cacheKey, entry] of annualReportYearsSnapshotCache.entries()) {
|
||||
if (now - entry.updatedAt > annualReportYearsSnapshotTtlMs) {
|
||||
annualReportYearsSnapshotCache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persistAnnualReportYearsSnapshot = (
|
||||
cacheKey: string,
|
||||
taskId: string,
|
||||
snapshot: AnnualReportYearsProgressPayload
|
||||
): void => {
|
||||
annualReportYearsSnapshotCache.set(cacheKey, {
|
||||
taskId,
|
||||
snapshot: normalizeAnnualReportYearsSnapshot(snapshot),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
pruneAnnualReportYearsSnapshotCache()
|
||||
}
|
||||
|
||||
const getAnnualReportYearsSnapshot = (
|
||||
cacheKey: string
|
||||
): { taskId: string; snapshot: AnnualReportYearsProgressPayload } | null => {
|
||||
pruneAnnualReportYearsSnapshotCache()
|
||||
const entry = annualReportYearsSnapshotCache.get(cacheKey)
|
||||
if (!entry) return null
|
||||
return {
|
||||
taskId: entry.taskId,
|
||||
snapshot: normalizeAnnualReportYearsSnapshot(entry.snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
const broadcastAnnualReportYearsProgress = (
|
||||
taskId: string,
|
||||
payload: AnnualReportYearsProgressPayload
|
||||
): void => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed()) continue
|
||||
win.webContents.send('annualReport:availableYearsProgress', {
|
||||
taskId,
|
||||
...payload
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isYearsLoadCanceled = (taskId: string): boolean => {
|
||||
const task = annualReportYearsLoadTasks.get(taskId)
|
||||
return task?.canceled === true
|
||||
}
|
||||
|
||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
// 获取图标路径 - 打包后在 resources 目录
|
||||
const { autoShow = true } = options
|
||||
@@ -195,6 +333,21 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
||||
callback(false)
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
if (mainWindow !== win) return
|
||||
|
||||
mainWindow = null
|
||||
mainWindowReady = false
|
||||
|
||||
if (process.platform !== 'darwin' && !isAppQuitting) {
|
||||
// 隐藏通知窗也是 BrowserWindow,必须销毁,否则会阻止应用退出。
|
||||
destroyNotificationWindow()
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
@@ -590,6 +743,90 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
return win
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
|
||||
*/
|
||||
function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return null
|
||||
const normalizedSource = normalizeSessionChatWindowSource(options?.source)
|
||||
|
||||
const existing = sessionChatWindows.get(normalizedSessionId)
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
|
||||
if (trackedSource !== normalizedSource) {
|
||||
loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options)
|
||||
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
|
||||
}
|
||||
if (existing.isMinimized()) {
|
||||
existing.restore()
|
||||
}
|
||||
existing.focus()
|
||||
return existing
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
const isDark = nativeTheme.shouldUseDarkColors
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 600,
|
||||
height: 820,
|
||||
minWidth: 420,
|
||||
minHeight: 560,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
|
||||
height: 40
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
if (win.webContents.isDevToolsOpened()) {
|
||||
win.webContents.closeDevTools()
|
||||
} else {
|
||||
win.webContents.openDevTools()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
win.focus()
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
const tracked = sessionChatWindows.get(normalizedSessionId)
|
||||
if (tracked === win) {
|
||||
sessionChatWindows.delete(normalizedSessionId)
|
||||
sessionChatWindowSources.delete(normalizedSessionId)
|
||||
}
|
||||
})
|
||||
|
||||
sessionChatWindows.set(normalizedSessionId, win)
|
||||
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
|
||||
return win
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
shouldShowMain = true
|
||||
if (mainWindowReady) {
|
||||
@@ -597,6 +834,65 @@ function showMainWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeAccountId = (value: string): string => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
const buildAccountNameMatcher = (wxidCandidates: string[]) => {
|
||||
const loweredCandidates = wxidCandidates
|
||||
.map((item) => String(item || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return (name: string): boolean => {
|
||||
const loweredName = String(name || '').trim().toLowerCase()
|
||||
if (!loweredName) return false
|
||||
return loweredCandidates.some((candidate) => (
|
||||
loweredName === candidate ||
|
||||
loweredName.startsWith(`${candidate}_`) ||
|
||||
loweredName.includes(candidate)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
const removePathIfExists = async (
|
||||
targetPath: string,
|
||||
removedPaths: string[],
|
||||
warnings: string[]
|
||||
): Promise<void> => {
|
||||
if (!targetPath || !existsSync(targetPath)) return
|
||||
try {
|
||||
await rm(targetPath, { recursive: true, force: true })
|
||||
removedPaths.push(targetPath)
|
||||
} catch (error) {
|
||||
warnings.push(`${targetPath}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const removeMatchedEntriesInDir = async (
|
||||
rootDir: string,
|
||||
shouldRemove: (name: string) => boolean,
|
||||
removedPaths: string[],
|
||||
warnings: string[]
|
||||
): Promise<void> => {
|
||||
if (!rootDir || !existsSync(rootDir)) return
|
||||
try {
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!shouldRemove(entry.name)) continue
|
||||
const targetPath = join(rootDir, entry.name)
|
||||
await removePathIfExists(targetPath, removedPaths, warnings)
|
||||
}
|
||||
} catch (error) {
|
||||
warnings.push(`${rootDir}: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 IPC 处理器
|
||||
function registerIpcHandlers() {
|
||||
registerNotificationHandlers()
|
||||
@@ -665,6 +961,39 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
|
||||
return exportCardDiagnosticsService.snapshot(options?.limit)
|
||||
})
|
||||
|
||||
ipcMain.handle('diagnostics:clearExportCardLogs', async () => {
|
||||
exportCardDiagnosticsService.clear()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('diagnostics:exportExportCardLogs', async (_, payload?: {
|
||||
filePath?: string
|
||||
frontendLogs?: unknown[]
|
||||
}) => {
|
||||
const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : ''
|
||||
if (!filePath) {
|
||||
return { success: false, error: '导出路径不能为空' }
|
||||
}
|
||||
return exportCardDiagnosticsService.exportCombinedLogs(filePath, payload?.frontendLogs || [])
|
||||
})
|
||||
|
||||
// 数据收集服务
|
||||
ipcMain.handle('cloud:init', async () => {
|
||||
await cloudControlService.init()
|
||||
})
|
||||
|
||||
ipcMain.handle('cloud:recordPage', (_, pageName: string) => {
|
||||
cloudControlService.recordPage(pageName)
|
||||
})
|
||||
|
||||
ipcMain.handle('cloud:getLogs', async () => {
|
||||
return cloudControlService.getLogs()
|
||||
})
|
||||
|
||||
ipcMain.handle('app:checkForUpdates', async () => {
|
||||
if (!AUTO_UPDATE_ENABLED) {
|
||||
return { hasUpdate: false }
|
||||
@@ -802,6 +1131,12 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
|
||||
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
|
||||
const win = createSessionChatWindow(sessionId, options)
|
||||
return Boolean(win)
|
||||
})
|
||||
|
||||
// 根据视频尺寸调整窗口大小
|
||||
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
@@ -912,8 +1247,27 @@ function registerIpcHandlers() {
|
||||
return chatService.getSessions()
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
|
||||
return chatService.enrichSessionsContactInfo(usernames)
|
||||
ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => {
|
||||
return chatService.getSessionStatuses(usernames)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getExportTabCounts', async () => {
|
||||
return chatService.getExportTabCounts()
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getContactTypeCounts', async () => {
|
||||
return chatService.getContactTypeCounts()
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => {
|
||||
return chatService.getSessionMessageCounts(sessionIds)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: {
|
||||
skipDisplayName?: boolean
|
||||
onlyMissingAvatar?: boolean
|
||||
}) => {
|
||||
return chatService.enrichSessionsContactInfo(usernames, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
||||
@@ -970,10 +1324,162 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:clearCurrentAccountData', async (_, options?: { clearCache?: boolean; clearExports?: boolean }) => {
|
||||
const cfg = configService
|
||||
if (!cfg) return { success: false, error: '配置服务未初始化' }
|
||||
|
||||
const clearCache = options?.clearCache === true
|
||||
const clearExports = options?.clearExports === true
|
||||
if (!clearCache && !clearExports) {
|
||||
return { success: false, error: '请至少选择一项清理范围' }
|
||||
}
|
||||
|
||||
const rawWxid = String(cfg.get('myWxid') || '').trim()
|
||||
if (!rawWxid) {
|
||||
return { success: false, error: '当前账号未登录或未识别,无法清理' }
|
||||
}
|
||||
const normalizedWxid = normalizeAccountId(rawWxid)
|
||||
const wxidCandidates = Array.from(new Set([rawWxid, normalizedWxid].filter(Boolean)))
|
||||
const isMatchedAccountName = buildAccountNameMatcher(wxidCandidates)
|
||||
const removedPaths: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
try {
|
||||
wcdbService.close()
|
||||
chatService.close()
|
||||
} catch (error) {
|
||||
warnings.push(`关闭数据库连接失败: ${String(error)}`)
|
||||
}
|
||||
|
||||
if (clearCache) {
|
||||
const [analyticsResult, imageResult] = await Promise.all([
|
||||
analyticsService.clearCache(),
|
||||
imageDecryptService.clearCache()
|
||||
])
|
||||
const chatResult = chatService.clearCaches()
|
||||
const cleanupResults = [analyticsResult, imageResult, chatResult]
|
||||
for (const result of cleanupResults) {
|
||||
if (!result.success && result.error) warnings.push(result.error)
|
||||
}
|
||||
|
||||
const configuredCachePath = String(cfg.get('cachePath') || '').trim()
|
||||
const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow')
|
||||
const userDataCacheDir = join(app.getPath('userData'), 'cache')
|
||||
const cacheRootCandidates = [
|
||||
configuredCachePath,
|
||||
join(documentsWeFlowDir, 'Images'),
|
||||
join(documentsWeFlowDir, 'Voices'),
|
||||
join(documentsWeFlowDir, 'Emojis'),
|
||||
userDataCacheDir
|
||||
].filter(Boolean)
|
||||
|
||||
for (const wxid of wxidCandidates) {
|
||||
if (configuredCachePath) {
|
||||
await removePathIfExists(join(configuredCachePath, wxid), removedPaths, warnings)
|
||||
await removePathIfExists(join(configuredCachePath, 'Images', wxid), removedPaths, warnings)
|
||||
await removePathIfExists(join(configuredCachePath, 'Voices', wxid), removedPaths, warnings)
|
||||
await removePathIfExists(join(configuredCachePath, 'Emojis', wxid), removedPaths, warnings)
|
||||
}
|
||||
await removePathIfExists(join(documentsWeFlowDir, 'Images', wxid), removedPaths, warnings)
|
||||
await removePathIfExists(join(documentsWeFlowDir, 'Voices', wxid), removedPaths, warnings)
|
||||
await removePathIfExists(join(documentsWeFlowDir, 'Emojis', wxid), removedPaths, warnings)
|
||||
await removePathIfExists(join(userDataCacheDir, wxid), removedPaths, warnings)
|
||||
}
|
||||
|
||||
for (const cacheRoot of cacheRootCandidates) {
|
||||
await removeMatchedEntriesInDir(cacheRoot, isMatchedAccountName, removedPaths, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
if (clearExports) {
|
||||
const configuredExportPath = String(cfg.get('exportPath') || '').trim()
|
||||
const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow')
|
||||
const exportRootCandidates = [
|
||||
configuredExportPath,
|
||||
join(documentsWeFlowDir, 'exports'),
|
||||
join(documentsWeFlowDir, 'Exports')
|
||||
].filter(Boolean)
|
||||
|
||||
for (const exportRoot of exportRootCandidates) {
|
||||
await removeMatchedEntriesInDir(exportRoot, isMatchedAccountName, removedPaths, warnings)
|
||||
}
|
||||
|
||||
const resetConfigKeys = [
|
||||
'exportSessionRecordMap',
|
||||
'exportLastSessionRunMap',
|
||||
'exportLastContentRunMap',
|
||||
'exportSessionMessageCountCacheMap',
|
||||
'exportSessionContentMetricCacheMap',
|
||||
'exportSnsStatsCacheMap',
|
||||
'snsPageCacheMap',
|
||||
'contactsListCacheMap',
|
||||
'contactsAvatarCacheMap',
|
||||
'lastSession'
|
||||
]
|
||||
for (const key of resetConfigKeys) {
|
||||
const defaultValue = key === 'lastSession' ? '' : {}
|
||||
cfg.set(key as any, defaultValue as any)
|
||||
}
|
||||
}
|
||||
|
||||
if (clearCache) {
|
||||
try {
|
||||
const wxidConfigsRaw = cfg.get('wxidConfigs') as Record<string, any> | undefined
|
||||
if (wxidConfigsRaw && typeof wxidConfigsRaw === 'object') {
|
||||
const nextConfigs: Record<string, any> = { ...wxidConfigsRaw }
|
||||
for (const key of Object.keys(nextConfigs)) {
|
||||
if (isMatchedAccountName(key) || normalizeAccountId(key) === normalizedWxid) {
|
||||
delete nextConfigs[key]
|
||||
}
|
||||
}
|
||||
cfg.set('wxidConfigs' as any, nextConfigs as any)
|
||||
}
|
||||
cfg.set('myWxid' as any, '')
|
||||
cfg.set('decryptKey' as any, '')
|
||||
cfg.set('imageXorKey' as any, 0)
|
||||
cfg.set('imageAesKey' as any, '')
|
||||
cfg.set('dbPath' as any, '')
|
||||
cfg.set('lastOpenedDb' as any, '')
|
||||
cfg.set('onboardingDone' as any, false)
|
||||
cfg.set('lastSession' as any, '')
|
||||
} catch (error) {
|
||||
warnings.push(`清理账号配置失败: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
removedPaths,
|
||||
warning: warnings.length > 0 ? warnings.join('; ') : undefined
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => {
|
||||
return chatService.getSessionDetail(sessionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => {
|
||||
return chatService.getSessionDetailFast(sessionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => {
|
||||
return chatService.getSessionDetailExtra(sessionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: {
|
||||
includeRelations?: boolean
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}) => {
|
||||
return chatService.getExportSessionStats(sessionIds, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getGroupMyMessageCountHint', async (_, chatroomId: string) => {
|
||||
return chatService.getGroupMyMessageCountHint(chatroomId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
|
||||
return chatService.getImageData(sessionId, msgId)
|
||||
})
|
||||
@@ -990,6 +1496,9 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
|
||||
return chatService.getMessageDates(sessionId)
|
||||
})
|
||||
ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => {
|
||||
return chatService.getMessageDateCounts(sessionId)
|
||||
})
|
||||
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||
})
|
||||
@@ -1016,6 +1525,22 @@ function registerIpcHandlers() {
|
||||
return snsService.getSnsUsernames()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getUserPostCounts', async () => {
|
||||
return snsService.getUserPostCounts()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getExportStats', async () => {
|
||||
return snsService.getExportStats()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getExportStatsFast', async () => {
|
||||
return snsService.getExportStatsFast()
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
|
||||
return snsService.getUserPostStats(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||
return snsService.debugResource(url)
|
||||
})
|
||||
@@ -1063,11 +1588,17 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
||||
return snsService.exportTimeline(options, (progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('sns:exportProgress', progress)
|
||||
const exportOptions = { ...(options || {}) }
|
||||
delete exportOptions.taskId
|
||||
|
||||
return snsService.exportTimeline(
|
||||
exportOptions,
|
||||
(progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('sns:exportProgress', progress)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:selectExportDir', async () => {
|
||||
@@ -1196,6 +1727,7 @@ function registerIpcHandlers() {
|
||||
event.sender.send('export:progress', progress)
|
||||
}
|
||||
}
|
||||
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
})
|
||||
|
||||
@@ -1285,6 +1817,16 @@ function registerIpcHandlers() {
|
||||
return groupAnalyticsService.getGroupMembers(chatroomId)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'groupAnalytics:getGroupMembersPanelData',
|
||||
async (_, chatroomId: string, options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } | boolean) => {
|
||||
const normalizedOptions = typeof options === 'boolean'
|
||||
? { forceRefresh: options }
|
||||
: options
|
||||
return groupAnalyticsService.getGroupMembersPanelData(chatroomId, normalizedOptions)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
|
||||
return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime)
|
||||
})
|
||||
@@ -1365,6 +1907,194 @@ function registerIpcHandlers() {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('annualReport:startAvailableYearsLoad', async (event) => {
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
|
||||
const dbPath = cfg.get('dbPath')
|
||||
const decryptKey = cfg.get('decryptKey')
|
||||
const wxid = cfg.get('myWxid')
|
||||
const cacheKey = buildAnnualReportYearsCacheKey(dbPath, wxid)
|
||||
|
||||
const runningTaskId = annualReportYearsTaskByCacheKey.get(cacheKey)
|
||||
if (runningTaskId) {
|
||||
const runningTask = annualReportYearsLoadTasks.get(runningTaskId)
|
||||
if (runningTask && !runningTask.done) {
|
||||
return {
|
||||
success: true,
|
||||
taskId: runningTaskId,
|
||||
reused: true,
|
||||
snapshot: normalizeAnnualReportYearsSnapshot(runningTask.snapshot)
|
||||
}
|
||||
}
|
||||
annualReportYearsTaskByCacheKey.delete(cacheKey)
|
||||
}
|
||||
|
||||
const cachedSnapshot = getAnnualReportYearsSnapshot(cacheKey)
|
||||
if (cachedSnapshot && cachedSnapshot.snapshot.done) {
|
||||
return {
|
||||
success: true,
|
||||
taskId: cachedSnapshot.taskId,
|
||||
reused: true,
|
||||
snapshot: normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const initialSnapshot: AnnualReportYearsProgressPayload = cachedSnapshot?.snapshot && !cachedSnapshot.snapshot.done
|
||||
? {
|
||||
...normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot),
|
||||
done: false,
|
||||
canceled: false,
|
||||
error: undefined
|
||||
}
|
||||
: {
|
||||
years: [],
|
||||
done: false,
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '准备使用原生快速模式加载年份...',
|
||||
nativeElapsedMs: 0,
|
||||
scanElapsedMs: 0,
|
||||
totalElapsedMs: 0,
|
||||
switched: false,
|
||||
nativeTimedOut: false
|
||||
}
|
||||
|
||||
const updateTaskSnapshot = (payload: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload | null => {
|
||||
const task = annualReportYearsLoadTasks.get(taskId)
|
||||
if (!task) return null
|
||||
|
||||
const hasPayloadYears = Array.isArray(payload.years)
|
||||
const nextYears = (hasPayloadYears && (payload.done || (payload.years || []).length > 0))
|
||||
? [...(payload.years || [])]
|
||||
: Array.isArray(task.snapshot.years) ? [...task.snapshot.years] : []
|
||||
|
||||
const nextSnapshot: AnnualReportYearsProgressPayload = normalizeAnnualReportYearsSnapshot({
|
||||
...task.snapshot,
|
||||
...payload,
|
||||
years: nextYears
|
||||
})
|
||||
task.snapshot = nextSnapshot
|
||||
task.done = nextSnapshot.done === true
|
||||
task.updatedAt = Date.now()
|
||||
annualReportYearsLoadTasks.set(taskId, task)
|
||||
persistAnnualReportYearsSnapshot(task.cacheKey, taskId, nextSnapshot)
|
||||
return nextSnapshot
|
||||
}
|
||||
|
||||
annualReportYearsLoadTasks.set(taskId, {
|
||||
cacheKey,
|
||||
canceled: false,
|
||||
done: false,
|
||||
snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
annualReportYearsTaskByCacheKey.set(cacheKey, taskId)
|
||||
persistAnnualReportYearsSnapshot(cacheKey, taskId, initialSnapshot)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await annualReportService.getAvailableYears({
|
||||
dbPath,
|
||||
decryptKey,
|
||||
wxid,
|
||||
nativeTimeoutMs: 5000,
|
||||
onProgress: (progress) => {
|
||||
if (isYearsLoadCanceled(taskId)) return
|
||||
const snapshot = updateTaskSnapshot({
|
||||
...progress,
|
||||
done: false
|
||||
})
|
||||
if (!snapshot) return
|
||||
broadcastAnnualReportYearsProgress(taskId, snapshot)
|
||||
},
|
||||
shouldCancel: () => isYearsLoadCanceled(taskId)
|
||||
})
|
||||
|
||||
const canceled = isYearsLoadCanceled(taskId)
|
||||
if (canceled) {
|
||||
const snapshot = updateTaskSnapshot({
|
||||
done: true,
|
||||
canceled: true,
|
||||
phase: 'done',
|
||||
statusText: '已取消年份加载'
|
||||
})
|
||||
if (snapshot) {
|
||||
broadcastAnnualReportYearsProgress(taskId, snapshot)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const completionPayload: AnnualReportYearsProgressPayload = result.success
|
||||
? {
|
||||
years: result.data || [],
|
||||
done: true,
|
||||
strategy: result.meta?.strategy,
|
||||
phase: 'done',
|
||||
statusText: result.meta?.statusText || '年份数据加载完成',
|
||||
nativeElapsedMs: result.meta?.nativeElapsedMs,
|
||||
scanElapsedMs: result.meta?.scanElapsedMs,
|
||||
totalElapsedMs: result.meta?.totalElapsedMs,
|
||||
switched: result.meta?.switched,
|
||||
nativeTimedOut: result.meta?.nativeTimedOut
|
||||
}
|
||||
: {
|
||||
years: result.data || [],
|
||||
done: true,
|
||||
error: result.error || '加载年度数据失败',
|
||||
strategy: result.meta?.strategy,
|
||||
phase: 'done',
|
||||
statusText: result.meta?.statusText || '年份数据加载失败',
|
||||
nativeElapsedMs: result.meta?.nativeElapsedMs,
|
||||
scanElapsedMs: result.meta?.scanElapsedMs,
|
||||
totalElapsedMs: result.meta?.totalElapsedMs,
|
||||
switched: result.meta?.switched,
|
||||
nativeTimedOut: result.meta?.nativeTimedOut
|
||||
}
|
||||
|
||||
const snapshot = updateTaskSnapshot(completionPayload)
|
||||
if (snapshot) {
|
||||
broadcastAnnualReportYearsProgress(taskId, snapshot)
|
||||
}
|
||||
} catch (e) {
|
||||
const snapshot = updateTaskSnapshot({
|
||||
done: true,
|
||||
error: String(e),
|
||||
phase: 'done',
|
||||
statusText: '年份数据加载失败',
|
||||
strategy: 'hybrid'
|
||||
})
|
||||
if (snapshot) {
|
||||
broadcastAnnualReportYearsProgress(taskId, snapshot)
|
||||
}
|
||||
} finally {
|
||||
const task = annualReportYearsLoadTasks.get(taskId)
|
||||
if (task) {
|
||||
annualReportYearsTaskByCacheKey.delete(task.cacheKey)
|
||||
}
|
||||
annualReportYearsLoadTasks.delete(taskId)
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
reused: false,
|
||||
snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => {
|
||||
const key = String(taskId || '').trim()
|
||||
if (!key) return { success: false, error: '任务ID不能为空' }
|
||||
const task = annualReportYearsLoadTasks.get(key)
|
||||
if (!task) return { success: true }
|
||||
task.canceled = true
|
||||
annualReportYearsLoadTasks.set(key, task)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('annualReport:generateReport', async (_, year: number) => {
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
@@ -1528,14 +2258,20 @@ function registerIpcHandlers() {
|
||||
|
||||
// 密钥获取
|
||||
ipcMain.handle('key:autoGetDbKey', async (event) => {
|
||||
return keyService.autoGetDbKey(60_000, (message, level) => {
|
||||
return keyService.autoGetDbKey(180_000, (message, level) => {
|
||||
event.sender.send('key:dbKeyStatus', { message, level })
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string) => {
|
||||
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => {
|
||||
return keyService.autoGetImageKey(manualDir, (message) => {
|
||||
event.sender.send('key:imageKeyStatus', { message })
|
||||
}, wxid)
|
||||
})
|
||||
|
||||
ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => {
|
||||
return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => {
|
||||
event.sender.send('key:imageKeyStatus', { message })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1706,6 +2442,16 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
isAppQuitting = true
|
||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||
destroyNotificationWindow()
|
||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||
try { await httpService.stop() } catch {}
|
||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||
try { wcdbService.shutdown() } catch {}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
|
||||
@@ -73,6 +73,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||
},
|
||||
|
||||
diagnostics: {
|
||||
getExportCardLogs: (options?: { limit?: number }) =>
|
||||
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
|
||||
clearExportCardLogs: () =>
|
||||
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
|
||||
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
|
||||
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
|
||||
},
|
||||
|
||||
// 窗口控制
|
||||
window: {
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
@@ -89,7 +98,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||
openSessionChatWindow: (
|
||||
sessionId: string,
|
||||
options?: {
|
||||
source?: 'chat' | 'export'
|
||||
initialDisplayName?: string
|
||||
initialAvatarUrl?: string
|
||||
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
) =>
|
||||
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
|
||||
},
|
||||
|
||||
// 数据库路径
|
||||
@@ -113,7 +132,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 密钥获取
|
||||
key: {
|
||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||
autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir),
|
||||
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
|
||||
scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
|
||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
||||
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
||||
@@ -129,8 +149,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
chat: {
|
||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
|
||||
enrichSessionsContactInfo: (
|
||||
usernames: string[],
|
||||
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||
@@ -148,14 +174,31 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
|
||||
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
|
||||
close: () => ipcRenderer.invoke('chat:close'),
|
||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
|
||||
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
||||
getExportSessionStats: (
|
||||
sessionIds: string[],
|
||||
options?: {
|
||||
includeRelations?: boolean
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}
|
||||
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
|
||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||
@@ -226,6 +269,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
groupAnalytics: {
|
||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||
getGroupMembersPanelData: (
|
||||
chatroomId: string,
|
||||
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
|
||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||
@@ -237,9 +284,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 年度报告
|
||||
annualReport: {
|
||||
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
|
||||
startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
|
||||
cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
|
||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
done: boolean
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
strategy?: 'cache' | 'native' | 'hybrid'
|
||||
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||
statusText?: string
|
||||
nativeElapsedMs?: number
|
||||
scanElapsedMs?: number
|
||||
totalElapsedMs?: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}) => void) => {
|
||||
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
|
||||
},
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
||||
@@ -264,7 +331,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
exportContacts: (outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
@@ -286,6 +353,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
|
||||
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
|
||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||
@@ -302,6 +373,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||
},
|
||||
|
||||
|
||||
// 数据收集
|
||||
cloud: {
|
||||
init: () => ipcRenderer.invoke('cloud:init'),
|
||||
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
|
||||
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
|
||||
},
|
||||
|
||||
// HTTP API 服务
|
||||
http: {
|
||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||
|
||||
@@ -85,7 +85,34 @@ export interface AnnualReportData {
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AvailableYearsLoadProgress {
|
||||
years: number[]
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
phase: 'cache' | 'native' | 'scan'
|
||||
statusText: string
|
||||
nativeElapsedMs: number
|
||||
scanElapsedMs: number
|
||||
totalElapsedMs: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
interface AvailableYearsLoadMeta {
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
nativeElapsedMs: number
|
||||
scanElapsedMs: number
|
||||
totalElapsedMs: number
|
||||
switched: boolean
|
||||
nativeTimedOut: boolean
|
||||
statusText: string
|
||||
}
|
||||
|
||||
class AnnualReportService {
|
||||
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly availableYearsScanConcurrency = 4
|
||||
private readonly availableYearsColumnCache = new Map<string, string>()
|
||||
private readonly availableYearsCache = new Map<string, { years: number[]; updatedAt: number }>()
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
@@ -181,6 +208,234 @@ class AnnualReportService {
|
||||
}
|
||||
}
|
||||
|
||||
private quoteSqlIdentifier(identifier: string): string {
|
||||
return `"${String(identifier || '').replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
private toUnixTimestamp(value: any): number {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n) || n <= 0) return 0
|
||||
// 兼容毫秒级时间戳
|
||||
const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
|
||||
return seconds > 0 ? seconds : 0
|
||||
}
|
||||
|
||||
private addYearsFromRange(years: Set<number>, firstTs: number, lastTs: number): boolean {
|
||||
let changed = false
|
||||
const currentYear = new Date().getFullYear()
|
||||
const minTs = firstTs > 0 ? firstTs : lastTs
|
||||
const maxTs = lastTs > 0 ? lastTs : firstTs
|
||||
if (minTs <= 0 || maxTs <= 0) return changed
|
||||
|
||||
const minYear = new Date(minTs * 1000).getFullYear()
|
||||
const maxYear = new Date(maxTs * 1000).getFullYear()
|
||||
for (let y = minYear; y <= maxYear; y++) {
|
||||
if (y >= 2010 && y <= currentYear && !years.has(y)) {
|
||||
years.add(y)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private normalizeAvailableYears(years: Iterable<number>): number[] {
|
||||
return Array.from(new Set(Array.from(years)))
|
||||
.filter((y) => Number.isFinite(y))
|
||||
.map((y) => Math.floor(y))
|
||||
.sort((a, b) => b - a)
|
||||
}
|
||||
|
||||
private async forEachWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
handler: (item: T, index: number) => Promise<void>,
|
||||
shouldStop?: () => boolean
|
||||
): Promise<void> {
|
||||
if (!items.length) return
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
||||
let nextIndex = 0
|
||||
const workers: Promise<void>[] = []
|
||||
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
workers.push((async () => {
|
||||
while (true) {
|
||||
if (shouldStop?.()) break
|
||||
const current = nextIndex
|
||||
nextIndex += 1
|
||||
if (current >= items.length) break
|
||||
await handler(items[current], current)
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
private async detectTimeColumn(dbPath: string, tableName: string): Promise<string | null> {
|
||||
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||
if (this.availableYearsColumnCache.has(cacheKey)) {
|
||||
const cached = this.availableYearsColumnCache.get(cacheKey) || ''
|
||||
return cached || null
|
||||
}
|
||||
|
||||
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
|
||||
this.availableYearsColumnCache.set(cacheKey, '')
|
||||
return null
|
||||
}
|
||||
|
||||
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
|
||||
const columns = new Set<string>()
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
|
||||
if (name) columns.add(name)
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (columns.has(candidate)) {
|
||||
this.availableYearsColumnCache.set(cacheKey, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
this.availableYearsColumnCache.set(cacheKey, '')
|
||||
return null
|
||||
}
|
||||
|
||||
private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
|
||||
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||
const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
|
||||
const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
|
||||
const tried = new Set<string>()
|
||||
|
||||
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
||||
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
|
||||
const row = result.rows[0] as Record<string, any>
|
||||
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
|
||||
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
|
||||
return { first, last }
|
||||
}
|
||||
|
||||
tried.add(initialColumn)
|
||||
const quick = await queryByColumn(initialColumn)
|
||||
if (quick) {
|
||||
if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
|
||||
return quick
|
||||
}
|
||||
|
||||
const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
|
||||
if (!detectedColumn || tried.has(detectedColumn)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return queryByColumn(detectedColumn)
|
||||
}
|
||||
|
||||
private async getAvailableYearsByTableScan(
|
||||
sessionIds: string[],
|
||||
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||
): Promise<number[]> {
|
||||
const years = new Set<number>()
|
||||
let lastEmittedSize = 0
|
||||
|
||||
const emitIfChanged = (force = false) => {
|
||||
if (!options?.onProgress) return
|
||||
const next = this.normalizeAvailableYears(years)
|
||||
if (!force && next.length === lastEmittedSize) return
|
||||
options.onProgress(next)
|
||||
lastEmittedSize = next.length
|
||||
}
|
||||
|
||||
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||
|
||||
await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
|
||||
if (shouldCancel()) return
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const table of tableStats.tables as Record<string, any>[]) {
|
||||
if (shouldCancel()) return
|
||||
const tableName = String(table.table_name || table.name || '').trim()
|
||||
const dbPath = String(table.db_path || table.dbPath || '').trim()
|
||||
if (!tableName || !dbPath) continue
|
||||
|
||||
const range = await this.getTableTimeRange(dbPath, tableName)
|
||||
if (!range) continue
|
||||
const changed = this.addYearsFromRange(years, range.first, range.last)
|
||||
if (changed) emitIfChanged()
|
||||
}
|
||||
}, shouldCancel)
|
||||
|
||||
emitIfChanged(true)
|
||||
return this.normalizeAvailableYears(years)
|
||||
}
|
||||
|
||||
private async getAvailableYearsByEdgeScan(
|
||||
sessionIds: string[],
|
||||
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||
): Promise<number[]> {
|
||||
const years = new Set<number>()
|
||||
let lastEmittedSize = 0
|
||||
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||
|
||||
const emitIfChanged = (force = false) => {
|
||||
if (!options?.onProgress) return
|
||||
const next = this.normalizeAvailableYears(years)
|
||||
if (!force && next.length === lastEmittedSize) return
|
||||
options.onProgress(next)
|
||||
lastEmittedSize = next.length
|
||||
}
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
if (shouldCancel()) break
|
||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
||||
const changed = this.addYearsFromRange(years, first || 0, last || 0)
|
||||
if (changed) emitIfChanged()
|
||||
}
|
||||
emitIfChanged(true)
|
||||
return this.normalizeAvailableYears(years)
|
||||
}
|
||||
|
||||
private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
|
||||
return `${dbPath}\u0001${cleanedWxid}`
|
||||
}
|
||||
|
||||
private getCachedAvailableYears(cacheKey: string): number[] | null {
|
||||
const cached = this.availableYearsCache.get(cacheKey)
|
||||
if (!cached) return null
|
||||
if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
|
||||
this.availableYearsCache.delete(cacheKey)
|
||||
return null
|
||||
}
|
||||
return [...cached.years]
|
||||
}
|
||||
|
||||
private setCachedAvailableYears(cacheKey: string, years: number[]): void {
|
||||
const normalized = this.normalizeAvailableYears(years)
|
||||
|
||||
this.availableYearsCache.set(cacheKey, {
|
||||
years: normalized,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
|
||||
if (this.availableYearsCache.size > 8) {
|
||||
let oldestKey = ''
|
||||
let oldestTime = Number.POSITIVE_INFINITY
|
||||
for (const [key, val] of this.availableYearsCache) {
|
||||
if (val.updatedAt < oldestTime) {
|
||||
oldestTime = val.updatedAt
|
||||
oldestKey = key
|
||||
}
|
||||
}
|
||||
if (oldestKey) this.availableYearsCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||
let content = this.decodeMaybeCompressed(compressContent)
|
||||
if (!content || content.length === 0) {
|
||||
@@ -359,38 +614,226 @@ class AnnualReportService {
|
||||
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
|
||||
}
|
||||
|
||||
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
|
||||
async getAvailableYears(params: {
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
wxid: string
|
||||
onProgress?: (payload: AvailableYearsLoadProgress) => void
|
||||
shouldCancel?: () => boolean
|
||||
nativeTimeoutMs?: number
|
||||
}): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
|
||||
try {
|
||||
const isCancelled = () => params.shouldCancel?.() === true
|
||||
const totalStartedAt = Date.now()
|
||||
let nativeElapsedMs = 0
|
||||
let scanElapsedMs = 0
|
||||
let switched = false
|
||||
let nativeTimedOut = false
|
||||
let latestYears: number[] = []
|
||||
|
||||
const emitProgress = (payload: {
|
||||
years?: number[]
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
phase: 'cache' | 'native' | 'scan'
|
||||
statusText: string
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}) => {
|
||||
if (!params.onProgress) return
|
||||
if (Array.isArray(payload.years)) latestYears = payload.years
|
||||
params.onProgress({
|
||||
years: latestYears,
|
||||
strategy: payload.strategy,
|
||||
phase: payload.phase,
|
||||
statusText: payload.statusText,
|
||||
nativeElapsedMs,
|
||||
scanElapsedMs,
|
||||
totalElapsedMs: Date.now() - totalStartedAt,
|
||||
switched: payload.switched ?? switched,
|
||||
nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
|
||||
})
|
||||
}
|
||||
|
||||
const buildMeta = (
|
||||
strategy: 'cache' | 'native' | 'hybrid',
|
||||
statusText: string
|
||||
): AvailableYearsLoadMeta => ({
|
||||
strategy,
|
||||
nativeElapsedMs,
|
||||
scanElapsedMs,
|
||||
totalElapsedMs: Date.now() - totalStartedAt,
|
||||
switched,
|
||||
nativeTimedOut,
|
||||
statusText
|
||||
})
|
||||
|
||||
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
|
||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||
if (sessionIds.length === 0) {
|
||||
return { success: false, error: '未找到消息会话' }
|
||||
}
|
||||
|
||||
const fastYears = await wcdbService.getAvailableYears(sessionIds)
|
||||
if (fastYears.success && fastYears.data) {
|
||||
return { success: true, data: fastYears.data }
|
||||
}
|
||||
|
||||
const years = new Set<number>()
|
||||
for (const sessionId of sessionIds) {
|
||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
||||
if (!first && !last) continue
|
||||
|
||||
const minYear = new Date((first || last || 0) * 1000).getFullYear()
|
||||
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
|
||||
for (let y = minYear; y <= maxYear; y++) {
|
||||
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
|
||||
const cached = this.getCachedAvailableYears(cacheKey)
|
||||
if (cached) {
|
||||
latestYears = cached
|
||||
emitProgress({
|
||||
years: cached,
|
||||
strategy: 'cache',
|
||||
phase: 'cache',
|
||||
statusText: '命中缓存,已快速加载年份数据'
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: cached,
|
||||
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedYears = Array.from(years).sort((a, b) => b - a)
|
||||
return { success: true, data: sortedYears }
|
||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||
if (sessionIds.length === 0) {
|
||||
return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
|
||||
}
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
|
||||
const nativeStartedAt = Date.now()
|
||||
let nativeTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
emitProgress({
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '正在使用原生快速模式加载年份...'
|
||||
})
|
||||
nativeTicker = setInterval(() => {
|
||||
nativeElapsedMs = Date.now() - nativeStartedAt
|
||||
emitProgress({
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '正在使用原生快速模式加载年份...'
|
||||
})
|
||||
}, 120)
|
||||
|
||||
const nativeRace = await Promise.race([
|
||||
wcdbService.getAvailableYears(sessionIds)
|
||||
.then((result) => ({ kind: 'result' as const, result }))
|
||||
.catch((error) => ({ kind: 'error' as const, error: String(error) })),
|
||||
new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
|
||||
])
|
||||
|
||||
if (nativeTicker) {
|
||||
clearInterval(nativeTicker)
|
||||
nativeTicker = null
|
||||
}
|
||||
nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
|
||||
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
|
||||
const years = this.normalizeAvailableYears(nativeRace.result.data)
|
||||
latestYears = years
|
||||
this.setCachedAvailableYears(cacheKey, years)
|
||||
emitProgress({
|
||||
years,
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '原生快速模式加载完成'
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: years,
|
||||
meta: buildMeta('native', '原生快速模式加载完成')
|
||||
}
|
||||
}
|
||||
|
||||
switched = true
|
||||
nativeTimedOut = nativeRace.kind === 'timeout'
|
||||
emitProgress({
|
||||
strategy: 'hybrid',
|
||||
phase: 'native',
|
||||
statusText: nativeTimedOut
|
||||
? '原生快速模式超时,已自动切换到扫表兼容模式...'
|
||||
: '原生快速模式不可用,已自动切换到扫表兼容模式...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
|
||||
const scanStartedAt = Date.now()
|
||||
let scanTicker: ReturnType<typeof setInterval> | null = null
|
||||
scanTicker = setInterval(() => {
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: nativeTimedOut
|
||||
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||
: '正在使用扫表兼容模式加载年份...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
}, 120)
|
||||
|
||||
let years = await this.getAvailableYearsByTableScan(sessionIds, {
|
||||
onProgress: (items) => {
|
||||
latestYears = items
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
years: items,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: nativeTimedOut
|
||||
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||
: '正在使用扫表兼容模式加载年份...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
},
|
||||
shouldCancel: params.shouldCancel
|
||||
})
|
||||
|
||||
if (isCancelled()) {
|
||||
if (scanTicker) clearInterval(scanTicker)
|
||||
return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
}
|
||||
if (years.length === 0) {
|
||||
years = await this.getAvailableYearsByEdgeScan(sessionIds, {
|
||||
onProgress: (items) => {
|
||||
latestYears = items
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
years: items,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: '扫表结果为空,正在执行游标兜底扫描...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
},
|
||||
shouldCancel: params.shouldCancel
|
||||
})
|
||||
}
|
||||
if (scanTicker) {
|
||||
clearInterval(scanTicker)
|
||||
scanTicker = null
|
||||
}
|
||||
scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
|
||||
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
this.setCachedAvailableYears(cacheKey, years)
|
||||
latestYears = years
|
||||
emitProgress({
|
||||
years,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: '扫表兼容模式加载完成',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: years,
|
||||
meta: buildMeta('hybrid', '扫表兼容模式加载完成')
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
91
electron/services/cloudControlService.ts
Normal file
91
electron/services/cloudControlService.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { app } from 'electron'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
interface UsageStats {
|
||||
appVersion: string
|
||||
platform: string
|
||||
deviceId: string
|
||||
timestamp: number
|
||||
online: boolean
|
||||
pages: string[]
|
||||
}
|
||||
|
||||
class CloudControlService {
|
||||
private deviceId: string = ''
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private pages: Set<string> = new Set()
|
||||
|
||||
async init() {
|
||||
this.deviceId = this.getDeviceId()
|
||||
await wcdbService.cloudInit(300)
|
||||
await this.reportOnline()
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.reportOnline()
|
||||
}, 300000)
|
||||
}
|
||||
|
||||
private getDeviceId(): string {
|
||||
const crypto = require('crypto')
|
||||
const os = require('os')
|
||||
const machineId = os.hostname() + os.platform() + os.arch()
|
||||
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||
}
|
||||
|
||||
private async reportOnline() {
|
||||
const data: UsageStats = {
|
||||
appVersion: app.getVersion(),
|
||||
platform: this.getPlatformVersion(),
|
||||
deviceId: this.deviceId,
|
||||
timestamp: Date.now(),
|
||||
online: true,
|
||||
pages: Array.from(this.pages)
|
||||
}
|
||||
|
||||
await wcdbService.cloudReport(JSON.stringify(data))
|
||||
this.pages.clear()
|
||||
}
|
||||
|
||||
private getPlatformVersion(): string {
|
||||
const os = require('os')
|
||||
const platform = process.platform
|
||||
|
||||
if (platform === 'win32') {
|
||||
const release = os.release()
|
||||
const parts = release.split('.')
|
||||
const major = parseInt(parts[0])
|
||||
const minor = parseInt(parts[1] || '0')
|
||||
const build = parseInt(parts[2] || '0')
|
||||
|
||||
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||
if (major === 10 && minor === 0 && build >= 22000) {
|
||||
return 'Windows 11'
|
||||
} else if (major === 10) {
|
||||
return 'Windows 10'
|
||||
}
|
||||
return `Windows ${release}`
|
||||
}
|
||||
|
||||
return platform
|
||||
}
|
||||
|
||||
recordPage(pageName: string) {
|
||||
this.pages.add(pageName)
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
wcdbService.cloudStop()
|
||||
}
|
||||
|
||||
async getLogs() {
|
||||
return wcdbService.getLogs()
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudControlService = new CloudControlService()
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ export class ConfigService {
|
||||
whisperDownloadSource: 'tsinghua',
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 2,
|
||||
exportDefaultConcurrency: 4,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
@@ -671,4 +671,4 @@ export class ConfigService {
|
||||
this.unlockedKeys.clear()
|
||||
this.unlockPassword = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
354
electron/services/exportCardDiagnosticsService.ts
Normal file
354
electron/services/exportCardDiagnosticsService.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
|
||||
export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
|
||||
export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout'
|
||||
|
||||
export interface ExportCardDiagLogEntry {
|
||||
id: string
|
||||
ts: number
|
||||
source: ExportCardDiagSource
|
||||
level: ExportCardDiagLevel
|
||||
message: string
|
||||
traceId?: string
|
||||
stepId?: string
|
||||
stepName?: string
|
||||
status?: ExportCardDiagStatus
|
||||
durationMs?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ActiveStepState {
|
||||
key: string
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
startedAt: number
|
||||
lastUpdatedAt: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface StepStartInput {
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
level?: ExportCardDiagLevel
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface StepEndInput {
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
|
||||
level?: ExportCardDiagLevel
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface LogInput {
|
||||
ts?: number
|
||||
source: ExportCardDiagSource
|
||||
level?: ExportCardDiagLevel
|
||||
message: string
|
||||
traceId?: string
|
||||
stepId?: string
|
||||
stepName?: string
|
||||
status?: ExportCardDiagStatus
|
||||
durationMs?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ExportCardDiagSnapshot {
|
||||
logs: ExportCardDiagLogEntry[]
|
||||
activeSteps: Array<{
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
elapsedMs: number
|
||||
stallMs: number
|
||||
startedAt: number
|
||||
lastUpdatedAt: number
|
||||
message?: string
|
||||
}>
|
||||
summary: {
|
||||
totalLogs: number
|
||||
activeStepCount: number
|
||||
errorCount: number
|
||||
warnCount: number
|
||||
timeoutCount: number
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
export class ExportCardDiagnosticsService {
|
||||
private readonly maxLogs = 6000
|
||||
private logs: ExportCardDiagLogEntry[] = []
|
||||
private activeSteps = new Map<string, ActiveStepState>()
|
||||
private seq = 0
|
||||
|
||||
private nextId(ts: number): string {
|
||||
this.seq += 1
|
||||
return `export-card-diag-${ts}-${this.seq}`
|
||||
}
|
||||
|
||||
private trimLogs() {
|
||||
if (this.logs.length <= this.maxLogs) return
|
||||
const drop = this.logs.length - this.maxLogs
|
||||
this.logs.splice(0, drop)
|
||||
}
|
||||
|
||||
log(input: LogInput): ExportCardDiagLogEntry {
|
||||
const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now()
|
||||
const entry: ExportCardDiagLogEntry = {
|
||||
id: this.nextId(ts),
|
||||
ts,
|
||||
source: input.source,
|
||||
level: input.level || 'info',
|
||||
message: input.message,
|
||||
traceId: input.traceId,
|
||||
stepId: input.stepId,
|
||||
stepName: input.stepName,
|
||||
status: input.status,
|
||||
durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined,
|
||||
data: input.data
|
||||
}
|
||||
|
||||
this.logs.push(entry)
|
||||
this.trimLogs()
|
||||
|
||||
if (entry.traceId && entry.stepId && entry.stepName) {
|
||||
const key = `${entry.traceId}::${entry.stepId}`
|
||||
if (entry.status === 'running') {
|
||||
const previous = this.activeSteps.get(key)
|
||||
this.activeSteps.set(key, {
|
||||
key,
|
||||
traceId: entry.traceId,
|
||||
stepId: entry.stepId,
|
||||
stepName: entry.stepName,
|
||||
source: entry.source,
|
||||
startedAt: previous?.startedAt || entry.ts,
|
||||
lastUpdatedAt: entry.ts,
|
||||
message: entry.message
|
||||
})
|
||||
} else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') {
|
||||
this.activeSteps.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
stepStart(input: StepStartInput): ExportCardDiagLogEntry {
|
||||
return this.log({
|
||||
source: input.source,
|
||||
level: input.level || 'info',
|
||||
message: input.message || `${input.stepName} 开始`,
|
||||
traceId: input.traceId,
|
||||
stepId: input.stepId,
|
||||
stepName: input.stepName,
|
||||
status: 'running',
|
||||
data: input.data
|
||||
})
|
||||
}
|
||||
|
||||
stepEnd(input: StepEndInput): ExportCardDiagLogEntry {
|
||||
return this.log({
|
||||
source: input.source,
|
||||
level: input.level || (input.status === 'done' ? 'info' : 'warn'),
|
||||
message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`,
|
||||
traceId: input.traceId,
|
||||
stepId: input.stepId,
|
||||
stepName: input.stepName,
|
||||
status: input.status || 'done',
|
||||
durationMs: input.durationMs,
|
||||
data: input.data
|
||||
})
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.logs = []
|
||||
this.activeSteps.clear()
|
||||
}
|
||||
|
||||
snapshot(limit = 1200): ExportCardDiagSnapshot {
|
||||
const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200
|
||||
const logs = this.logs.slice(-capped)
|
||||
const now = Date.now()
|
||||
|
||||
const activeSteps = Array.from(this.activeSteps.values())
|
||||
.map(step => ({
|
||||
traceId: step.traceId,
|
||||
stepId: step.stepId,
|
||||
stepName: step.stepName,
|
||||
source: step.source,
|
||||
startedAt: step.startedAt,
|
||||
lastUpdatedAt: step.lastUpdatedAt,
|
||||
elapsedMs: Math.max(0, now - step.startedAt),
|
||||
stallMs: Math.max(0, now - step.lastUpdatedAt),
|
||||
message: step.message
|
||||
}))
|
||||
.sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
|
||||
|
||||
let errorCount = 0
|
||||
let warnCount = 0
|
||||
let timeoutCount = 0
|
||||
for (const item of logs) {
|
||||
if (item.level === 'error') errorCount += 1
|
||||
if (item.level === 'warn') warnCount += 1
|
||||
if (item.status === 'timeout') timeoutCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
activeSteps,
|
||||
summary: {
|
||||
totalLogs: this.logs.length,
|
||||
activeStepCount: activeSteps.length,
|
||||
errorCount,
|
||||
warnCount,
|
||||
timeoutCount,
|
||||
lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] {
|
||||
const result: ExportCardDiagLogEntry[] = []
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const row = item as Record<string, unknown>
|
||||
const tsRaw = row.ts ?? row.timestamp
|
||||
const tsNum = Number(tsRaw)
|
||||
const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now()
|
||||
|
||||
const sourceRaw = String(row.source || 'frontend')
|
||||
const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker'
|
||||
? sourceRaw
|
||||
: 'frontend'
|
||||
const levelRaw = String(row.level || 'info')
|
||||
const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error'
|
||||
? levelRaw
|
||||
: 'info'
|
||||
|
||||
const statusRaw = String(row.status || '')
|
||||
const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout'
|
||||
? statusRaw
|
||||
: undefined
|
||||
|
||||
const durationRaw = Number(row.durationMs)
|
||||
result.push({
|
||||
id: String(row.id || this.nextId(ts)),
|
||||
ts,
|
||||
source,
|
||||
level,
|
||||
message: String(row.message || ''),
|
||||
traceId: typeof row.traceId === 'string' ? row.traceId : undefined,
|
||||
stepId: typeof row.stepId === 'string' ? row.stepId : undefined,
|
||||
stepName: typeof row.stepName === 'string' ? row.stepName : undefined,
|
||||
status,
|
||||
durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined,
|
||||
data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private serializeLogEntry(log: ExportCardDiagLogEntry): string {
|
||||
return JSON.stringify(log)
|
||||
}
|
||||
|
||||
private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string {
|
||||
const total = logs.length
|
||||
let errorCount = 0
|
||||
let warnCount = 0
|
||||
let timeoutCount = 0
|
||||
let frontendCount = 0
|
||||
let backendCount = 0
|
||||
let mainCount = 0
|
||||
let workerCount = 0
|
||||
|
||||
for (const item of logs) {
|
||||
if (item.level === 'error') errorCount += 1
|
||||
if (item.level === 'warn') warnCount += 1
|
||||
if (item.status === 'timeout') timeoutCount += 1
|
||||
if (item.source === 'frontend') frontendCount += 1
|
||||
if (item.source === 'backend') backendCount += 1
|
||||
if (item.source === 'main') mainCount += 1
|
||||
if (item.source === 'worker') workerCount += 1
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('WeFlow 导出卡片诊断摘要')
|
||||
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`)
|
||||
lines.push(`日志总数: ${total}`)
|
||||
lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`)
|
||||
lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`)
|
||||
lines.push(`当前活跃步骤: ${activeSteps.length}`)
|
||||
|
||||
if (activeSteps.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('活跃步骤:')
|
||||
for (const step of activeSteps.slice(0, 12)) {
|
||||
lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12)
|
||||
if (latestErrors.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('最近异常:')
|
||||
for (const item of latestErrors) {
|
||||
lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{
|
||||
success: boolean
|
||||
filePath?: string
|
||||
summaryPath?: string
|
||||
count?: number
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : [])
|
||||
const merged = [...this.logs, ...normalizedFrontend]
|
||||
.sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id))
|
||||
|
||||
const lines = merged.map(item => this.serializeLogEntry(item)).join('\n')
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8')
|
||||
|
||||
const ext = extname(filePath)
|
||||
const baseName = ext ? basename(filePath, ext) : basename(filePath)
|
||||
const summaryPath = join(dirname(filePath), `${baseName}.txt`)
|
||||
const snapshot = this.snapshot(1500)
|
||||
const summaryText = this.buildSummaryText(merged, snapshot.activeSteps)
|
||||
await writeFile(summaryPath, summaryText, 'utf8')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
summaryPath,
|
||||
count: merged.length
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const exportCardDiagnosticsService = new ExportCardDiagnosticsService()
|
||||
229
electron/services/exportContentStatsCacheService.ts
Normal file
229
electron/services/exportContentStatsCacheService.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
const MAX_SESSION_ENTRIES_PER_SCOPE = 6000
|
||||
|
||||
export interface ExportContentSessionStatsEntry {
|
||||
updatedAt: number
|
||||
hasAny: boolean
|
||||
hasVoice: boolean
|
||||
hasImage: boolean
|
||||
hasVideo: boolean
|
||||
hasEmoji: boolean
|
||||
mediaReady: boolean
|
||||
}
|
||||
|
||||
export interface ExportContentScopeStatsEntry {
|
||||
updatedAt: number
|
||||
sessions: Record<string, ExportContentSessionStatsEntry>
|
||||
}
|
||||
|
||||
interface ExportContentStatsStore {
|
||||
version: number
|
||||
scopes: Record<string, ExportContentScopeStatsEntry>
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return Math.max(0, Math.floor(value))
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
if (updatedAt === undefined) return null
|
||||
return {
|
||||
updatedAt,
|
||||
hasAny: toBoolean(source.hasAny, false),
|
||||
hasVoice: toBoolean(source.hasVoice, false),
|
||||
hasImage: toBoolean(source.hasImage, false),
|
||||
hasVideo: toBoolean(source.hasVideo, false),
|
||||
hasEmoji: toBoolean(source.hasEmoji, false),
|
||||
mediaReady: toBoolean(source.mediaReady, false)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
if (updatedAt === undefined) return null
|
||||
|
||||
const sessionsRaw = source.sessions
|
||||
if (!sessionsRaw || typeof sessionsRaw !== 'object') {
|
||||
return {
|
||||
updatedAt,
|
||||
sessions: {}
|
||||
}
|
||||
}
|
||||
|
||||
const sessions: Record<string, ExportContentSessionStatsEntry> = {}
|
||||
for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record<string, unknown>)) {
|
||||
const normalized = normalizeSessionStatsEntry(entryRaw)
|
||||
if (!normalized) continue
|
||||
sessions[sessionId] = normalized
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt,
|
||||
sessions
|
||||
}
|
||||
}
|
||||
|
||||
function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry {
|
||||
return {
|
||||
updatedAt: scope.updatedAt,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ExportContentStatsCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private store: ExportContentStatsStore = {
|
||||
version: CACHE_VERSION,
|
||||
scopes: {}
|
||||
}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'export-content-stats.json')
|
||||
this.ensureCacheDir()
|
||||
this.load()
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const payload = parsed as Record<string, unknown>
|
||||
const scopesRaw = payload.scopes
|
||||
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const scopes: Record<string, ExportContentScopeStatsEntry> = {}
|
||||
for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||
const normalizedScope = normalizeScopeStatsEntry(scopeRaw)
|
||||
if (!normalizedScope) continue
|
||||
scopes[scopeKey] = normalizedScope
|
||||
}
|
||||
|
||||
this.store = {
|
||||
version: CACHE_VERSION,
|
||||
scopes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ExportContentStatsCacheService: 载入缓存失败', error)
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
}
|
||||
}
|
||||
|
||||
getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined {
|
||||
if (!scopeKey) return undefined
|
||||
const rawScope = this.store.scopes[scopeKey]
|
||||
if (!rawScope) return undefined
|
||||
const normalizedScope = normalizeScopeStatsEntry(rawScope)
|
||||
if (!normalizedScope) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
return undefined
|
||||
}
|
||||
this.store.scopes[scopeKey] = normalizedScope
|
||||
return cloneScope(normalizedScope)
|
||||
}
|
||||
|
||||
setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void {
|
||||
if (!scopeKey) return
|
||||
const normalized = normalizeScopeStatsEntry(scope)
|
||||
if (!normalized) return
|
||||
this.store.scopes[scopeKey] = normalized
|
||||
this.trimScope(scopeKey)
|
||||
this.trimScopes()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
deleteSession(scopeKey: string, sessionId: string): void {
|
||||
if (!scopeKey || !sessionId) return
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
if (!(sessionId in scope.sessions)) return
|
||||
delete scope.sessions[sessionId]
|
||||
if (Object.keys(scope.sessions).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
} else {
|
||||
scope.updatedAt = Date.now()
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearScope(scopeKey: string): void {
|
||||
if (!scopeKey) return
|
||||
if (!this.store.scopes[scopeKey]) return
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('ExportContentStatsCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
private trimScope(scopeKey: string): void {
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
|
||||
const entries = Object.entries(scope.sessions)
|
||||
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||
|
||||
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE))
|
||||
}
|
||||
|
||||
private trimScopes(): void {
|
||||
const scopeEntries = Object.entries(this.store.scopes)
|
||||
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||
|
||||
scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES))
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
this.ensureCacheDir()
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('ExportContentStatsCacheService: 持久化缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
electron/services/exportRecordService.ts
Normal file
95
electron/services/exportRecordService.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface ExportRecord {
|
||||
exportTime: number
|
||||
format: string
|
||||
messageCount: number
|
||||
sourceLatestMessageTimestamp?: number
|
||||
outputPath?: string
|
||||
}
|
||||
|
||||
type RecordStore = Record<string, ExportRecord[]>
|
||||
|
||||
class ExportRecordService {
|
||||
private filePath: string | null = null
|
||||
private loaded = false
|
||||
private store: RecordStore = {}
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
const userDataPath = app.getPath('userData')
|
||||
fs.mkdirSync(userDataPath, { recursive: true })
|
||||
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
const filePath = this.resolveFilePath()
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.store = parsed as RecordStore
|
||||
}
|
||||
} catch {
|
||||
this.store = {}
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.resolveFilePath()
|
||||
fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8')
|
||||
} catch {
|
||||
// ignore persist errors to avoid blocking export flow
|
||||
}
|
||||
}
|
||||
|
||||
getLatestRecord(sessionId: string, format: string): ExportRecord | null {
|
||||
this.ensureLoaded()
|
||||
const records = this.store[sessionId]
|
||||
if (!records || records.length === 0) return null
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
const record = records[i]
|
||||
if (record && record.format === format) return record
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
saveRecord(
|
||||
sessionId: string,
|
||||
format: string,
|
||||
messageCount: number,
|
||||
extra?: {
|
||||
sourceLatestMessageTimestamp?: number
|
||||
outputPath?: string
|
||||
}
|
||||
): void {
|
||||
this.ensureLoaded()
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return
|
||||
if (!this.store[normalizedSessionId]) {
|
||||
this.store[normalizedSessionId] = []
|
||||
}
|
||||
const list = this.store[normalizedSessionId]
|
||||
list.push({
|
||||
exportTime: Date.now(),
|
||||
format,
|
||||
messageCount,
|
||||
sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp,
|
||||
outputPath: extra?.outputPath
|
||||
})
|
||||
// keep the latest 30 records per session
|
||||
if (list.length > 30) {
|
||||
this.store[normalizedSessionId] = list.slice(-30)
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
|
||||
export const exportRecordService = new ExportRecordService()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,12 @@ export interface GroupMember {
|
||||
alias?: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
export interface GroupMembersPanelEntry extends GroupMember {
|
||||
isFriend: boolean
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface GroupMessageRank {
|
||||
@@ -43,8 +49,28 @@ export interface GroupMediaStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
interface GroupMemberContactInfo {
|
||||
remark: string
|
||||
nickName: string
|
||||
alias: string
|
||||
username: string
|
||||
userName: string
|
||||
encryptUsername: string
|
||||
encryptUserName: string
|
||||
localType: number
|
||||
}
|
||||
|
||||
class GroupAnalyticsService {
|
||||
private configService: ConfigService
|
||||
private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000
|
||||
private readonly groupMembersPanelFullTimeoutMs = 25 * 1000
|
||||
private readonly groupMembersPanelCache = new Map<string, { updatedAt: number; data: GroupMembersPanelEntry[] }>()
|
||||
private readonly groupMembersPanelInFlight = new Map<
|
||||
string,
|
||||
Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }>
|
||||
>()
|
||||
private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
@@ -89,6 +115,128 @@ class GroupAnalyticsService {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private resolveMemberUsername(
|
||||
candidate: unknown,
|
||||
memberLookup: Map<string, string>
|
||||
): string | null {
|
||||
if (typeof candidate !== 'string') return null
|
||||
const raw = candidate.trim()
|
||||
if (!raw) return null
|
||||
if (memberLookup.has(raw)) return memberLookup.get(raw) || null
|
||||
const cleaned = this.cleanAccountDirName(raw)
|
||||
if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null
|
||||
|
||||
const parts = raw.split(/[,\s;|]+/).filter(Boolean)
|
||||
for (const part of parts) {
|
||||
if (memberLookup.has(part)) return memberLookup.get(part) || null
|
||||
const normalizedPart = this.cleanAccountDirName(part)
|
||||
if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null
|
||||
}
|
||||
|
||||
if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return this.extractOwnerUsername(parsed, memberLookup, 0)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private extractOwnerUsername(
|
||||
value: unknown,
|
||||
memberLookup: Map<string, string>,
|
||||
depth: number
|
||||
): string | null {
|
||||
if (depth > 4 || value == null) return null
|
||||
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return this.resolveMemberUsername(value, memberLookup)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const owner = this.extractOwnerUsername(item, memberLookup, depth + 1)
|
||||
if (owner) return owner
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') return null
|
||||
const row = value as Record<string, unknown>
|
||||
|
||||
for (const [key, entry] of Object.entries(row)) {
|
||||
const keyLower = key.toLowerCase()
|
||||
if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof entry === 'boolean') {
|
||||
if (entry && typeof row.username === 'string') {
|
||||
const owner = this.resolveMemberUsername(row.username, memberLookup)
|
||||
if (owner) return owner
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1)
|
||||
if (owner) return owner
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async detectGroupOwnerUsername(
|
||||
chatroomId: string,
|
||||
members: Array<{ username: string; [key: string]: unknown }>
|
||||
): Promise<string | undefined> {
|
||||
const memberLookup = new Map<string, string>()
|
||||
for (const member of members) {
|
||||
const username = String(member.username || '').trim()
|
||||
if (!username) continue
|
||||
const cleaned = this.cleanAccountDirName(username)
|
||||
memberLookup.set(username, username)
|
||||
memberLookup.set(cleaned, username)
|
||||
}
|
||||
if (memberLookup.size === 0) return undefined
|
||||
|
||||
const tryResolve = (candidate: unknown): string | undefined => {
|
||||
const owner = this.extractOwnerUsername(candidate, memberLookup, 0)
|
||||
return owner || undefined
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const owner = tryResolve(member)
|
||||
if (owner) return owner
|
||||
}
|
||||
|
||||
try {
|
||||
const groupContact = await wcdbService.getContact(chatroomId)
|
||||
if (groupContact.success && groupContact.contact) {
|
||||
const owner = tryResolve(groupContact.contact)
|
||||
if (owner) return owner
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
|
||||
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
|
||||
const owner = tryResolve(roomResult.rows[0])
|
||||
if (owner) return owner
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
@@ -296,6 +444,203 @@ class GroupAnalyticsService {
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
private toNonNegativeInteger(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return Math.max(0, Math.floor(parsed))
|
||||
}
|
||||
|
||||
private pickStringField(row: Record<string, unknown>, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = row[key]
|
||||
if (value == null) continue
|
||||
const text = String(value).trim()
|
||||
if (text) return text
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private pickIntegerField(row: Record<string, unknown>, keys: string[], fallback: number = 0): number {
|
||||
for (const key of keys) {
|
||||
const value = row[key]
|
||||
if (value == null || value === '') continue
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return Math.floor(parsed)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
|
||||
const mode = includeMessageCounts ? 'full' : 'members'
|
||||
return `${dbPath}::${wxid}::${chatroomId}::${mode}`
|
||||
}
|
||||
|
||||
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
|
||||
if (this.groupMembersPanelCache.size <= maxEntries) return
|
||||
const entries = Array.from(this.groupMembersPanelCache.entries())
|
||||
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)
|
||||
const removeCount = this.groupMembersPanelCache.size - maxEntries
|
||||
for (let i = 0; i < removeCount; i += 1) {
|
||||
this.groupMembersPanelCache.delete(entries[i][0])
|
||||
}
|
||||
}
|
||||
|
||||
private async withPromiseTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutResult: T
|
||||
): Promise<T> {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
return promise
|
||||
}
|
||||
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
resolve(timeoutResult)
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async buildGroupMemberContactLookup(usernames: string[]): Promise<Map<string, GroupMemberContactInfo>> {
|
||||
const lookup = new Map<string, GroupMemberContactInfo>()
|
||||
const candidates = this.buildIdCandidates(usernames)
|
||||
if (candidates.length === 0) return lookup
|
||||
|
||||
const appendContactsToLookup = (rows: Record<string, unknown>[]) => {
|
||||
for (const row of rows) {
|
||||
const contact: GroupMemberContactInfo = {
|
||||
remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']),
|
||||
nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']),
|
||||
alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']),
|
||||
username: this.pickStringField(row, ['username', 'WCDB_CT_username']),
|
||||
userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']),
|
||||
encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']),
|
||||
encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']),
|
||||
localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
||||
}
|
||||
const lookupKeys = this.buildIdCandidates([
|
||||
contact.username,
|
||||
contact.userName,
|
||||
contact.encryptUsername,
|
||||
contact.encryptUserName,
|
||||
contact.alias
|
||||
])
|
||||
for (const key of lookupKeys) {
|
||||
const normalized = key.toLowerCase()
|
||||
if (!lookup.has(normalized)) {
|
||||
lookup.set(normalized, contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batchSize = 200
|
||||
for (let i = 0; i < candidates.length; i += batchSize) {
|
||||
const batch = candidates.slice(i, i + batchSize)
|
||||
if (batch.length === 0) continue
|
||||
|
||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||
const lightweightSql = `
|
||||
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
`
|
||||
let result = await wcdbService.execQuery('contact', null, lightweightSql)
|
||||
if (!result.success || !result.rows) {
|
||||
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
|
||||
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
|
||||
}
|
||||
if (!result.success || !result.rows) continue
|
||||
appendContactsToLookup(result.rows as Record<string, unknown>[])
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
private resolveContactByCandidates(
|
||||
lookup: Map<string, GroupMemberContactInfo>,
|
||||
candidates: Array<string | undefined | null>
|
||||
): GroupMemberContactInfo | undefined {
|
||||
const ids = this.buildIdCandidates(candidates)
|
||||
for (const id of ids) {
|
||||
const hit = lookup.get(id.toLowerCase())
|
||||
if (hit) return hit
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async buildGroupMessageCountLookup(chatroomId: string): Promise<Map<string, number>> {
|
||||
const lookup = new Map<string, number>()
|
||||
const result = await wcdbService.getGroupStats(chatroomId, 0, 0)
|
||||
if (!result.success || !result.data) return lookup
|
||||
|
||||
const sessionData = result.data?.sessions?.[chatroomId]
|
||||
if (!sessionData || !sessionData.senders) return lookup
|
||||
|
||||
const idMap = result.data.idMap || {}
|
||||
for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record<string, number>)) {
|
||||
const username = String(idMap[senderId] || senderId || '').trim()
|
||||
if (!username) continue
|
||||
const count = this.toNonNegativeInteger(rawCount)
|
||||
const keys = this.buildIdCandidates([username])
|
||||
for (const key of keys) {
|
||||
const normalized = key.toLowerCase()
|
||||
const prev = lookup.get(normalized) || 0
|
||||
if (count > prev) {
|
||||
lookup.set(normalized, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
private resolveMessageCountByCandidates(
|
||||
lookup: Map<string, number>,
|
||||
candidates: Array<string | undefined | null>
|
||||
): number {
|
||||
let maxCount = 0
|
||||
const ids = this.buildIdCandidates(candidates)
|
||||
for (const id of ids) {
|
||||
const count = lookup.get(id.toLowerCase())
|
||||
if (typeof count === 'number' && count > maxCount) {
|
||||
maxCount = count
|
||||
}
|
||||
}
|
||||
return maxCount
|
||||
}
|
||||
|
||||
private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean {
|
||||
const normalizedWxid = String(wxid || '').trim().toLowerCase()
|
||||
if (!normalizedWxid) return false
|
||||
if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false
|
||||
if (this.friendExcludeNames.has(normalizedWxid)) return false
|
||||
if (!contact) return false
|
||||
return contact.localType === 1
|
||||
}
|
||||
|
||||
private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] {
|
||||
return members.sort((a, b) => {
|
||||
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
||||
if (ownerDiff !== 0) return ownerDiff
|
||||
|
||||
const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend))
|
||||
if (friendDiff !== 0) return friendDiff
|
||||
|
||||
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
|
||||
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
|
||||
})
|
||||
}
|
||||
|
||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||
const idCandidates = this.buildIdCandidates(candidates)
|
||||
if (idCandidates.length === 0) return ''
|
||||
@@ -483,6 +828,167 @@ class GroupAnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadGroupMembersPanelDataFresh(
|
||||
chatroomId: string,
|
||||
includeMessageCounts: boolean
|
||||
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
|
||||
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
||||
if (!membersResult.success || !membersResult.members) {
|
||||
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||
}
|
||||
|
||||
const members = membersResult.members as Array<{
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
originalName?: string
|
||||
[key: string]: unknown
|
||||
}>
|
||||
if (members.length === 0) return { success: true, data: [] }
|
||||
|
||||
const usernames = members
|
||||
.map((member) => String(member.username || '').trim())
|
||||
.filter(Boolean)
|
||||
if (usernames.length === 0) return { success: true, data: [] }
|
||||
|
||||
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
|
||||
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
|
||||
const messageCountLookupPromise = includeMessageCounts
|
||||
? this.buildGroupMessageCountLookup(chatroomId)
|
||||
: Promise.resolve(new Map<string, number>())
|
||||
|
||||
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
|
||||
displayNamesPromise,
|
||||
contactLookupPromise,
|
||||
ownerPromise,
|
||||
messageCountLookupPromise
|
||||
])
|
||||
|
||||
const nicknameCandidates = this.buildIdCandidates([
|
||||
...members.map((member) => member.username),
|
||||
...members.map((member) => member.originalName),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.username),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.userName),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName),
|
||||
...Array.from(contactLookup.values()).map((contact) => contact?.alias)
|
||||
])
|
||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
let myGroupMessageCountHint: number | undefined
|
||||
|
||||
const data: GroupMembersPanelEntry[] = members
|
||||
.map((member) => {
|
||||
const wxid = String(member.username || '').trim()
|
||||
if (!wxid) return null
|
||||
|
||||
const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName])
|
||||
const nickname = contact?.nickName || ''
|
||||
const remark = contact?.remark || ''
|
||||
const alias = contact?.alias || ''
|
||||
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||
const lookupCandidates = this.buildIdCandidates([
|
||||
wxid,
|
||||
member.originalName as string | undefined,
|
||||
contact?.username,
|
||||
contact?.userName,
|
||||
contact?.encryptUsername,
|
||||
contact?.encryptUserName,
|
||||
alias
|
||||
])
|
||||
if (normalizedWxid === myWxid) {
|
||||
lookupCandidates.push(myWxid)
|
||||
}
|
||||
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||
|
||||
return {
|
||||
username: wxid,
|
||||
displayName,
|
||||
nickname,
|
||||
alias,
|
||||
remark,
|
||||
groupNickname,
|
||||
avatarUrl: member.avatarUrl,
|
||||
isOwner: Boolean(ownerUsername && ownerUsername === wxid),
|
||||
isFriend: this.isFriendMember(wxid, contact),
|
||||
messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates)
|
||||
}
|
||||
})
|
||||
.filter((member): member is GroupMembersPanelEntry => Boolean(member))
|
||||
|
||||
if (includeMessageCounts && myWxid) {
|
||||
const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid)
|
||||
if (selfEntry && Number.isFinite(selfEntry.messageCount)) {
|
||||
myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount))
|
||||
}
|
||||
}
|
||||
|
||||
if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) {
|
||||
void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number)
|
||||
}
|
||||
|
||||
return { success: true, data: this.sortGroupMembersPanelEntries(data) }
|
||||
}
|
||||
|
||||
async getGroupMembersPanelData(
|
||||
chatroomId: string,
|
||||
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
|
||||
try {
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||
|
||||
const forceRefresh = Boolean(options?.forceRefresh)
|
||||
const includeMessageCounts = options?.includeMessageCounts !== false
|
||||
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts)
|
||||
const now = Date.now()
|
||||
const cached = this.groupMembersPanelCache.get(cacheKey)
|
||||
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
|
||||
return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt }
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
const pending = this.groupMembersPanelInFlight.get(cacheKey)
|
||||
if (pending) return pending
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const timeoutMs = includeMessageCounts
|
||||
? this.groupMembersPanelFullTimeoutMs
|
||||
: this.groupMembersPanelMembersTimeoutMs
|
||||
const fresh = await this.withPromiseTimeout(
|
||||
this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts),
|
||||
timeoutMs,
|
||||
{
|
||||
success: false,
|
||||
error: includeMessageCounts
|
||||
? '群成员发言统计加载超时,请稍后重试'
|
||||
: '群成员列表加载超时,请稍后重试'
|
||||
}
|
||||
)
|
||||
if (!fresh.success || !fresh.data) {
|
||||
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
|
||||
}
|
||||
|
||||
const updatedAt = Date.now()
|
||||
this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data })
|
||||
this.pruneGroupMembersPanelCache()
|
||||
return { success: true, data: fresh.data, fromCache: false, updatedAt }
|
||||
})().finally(() => {
|
||||
this.groupMembersPanelInFlight.delete(cacheKey)
|
||||
})
|
||||
|
||||
this.groupMembersPanelInFlight.set(cacheKey, requestPromise)
|
||||
return await requestPromise
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
@@ -497,6 +1003,7 @@ class GroupAnalyticsService {
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
originalName?: string
|
||||
[key: string]: unknown
|
||||
}>
|
||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||
|
||||
@@ -543,6 +1050,7 @@ class GroupAnalyticsService {
|
||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members)
|
||||
const data: GroupMember[] = members.map((m) => {
|
||||
const wxid = m.username || ''
|
||||
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||
@@ -572,7 +1080,8 @@ class GroupAnalyticsService {
|
||||
alias,
|
||||
remark,
|
||||
groupNickname,
|
||||
avatarUrl: m.avatarUrl
|
||||
avatarUrl: m.avatarUrl,
|
||||
isOwner: Boolean(ownerUsername && ownerUsername === wxid)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_GROUP_ENTRIES_PER_SCOPE = 3000
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
|
||||
export interface GroupMyMessageCountCacheEntry {
|
||||
updatedAt: number
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
interface GroupMyMessageCountScopeMap {
|
||||
[chatroomId: string]: GroupMyMessageCountCacheEntry
|
||||
}
|
||||
|
||||
interface GroupMyMessageCountCacheStore {
|
||||
version: number
|
||||
scopes: Record<string, GroupMyMessageCountScopeMap>
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return Math.max(0, Math.floor(value))
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
const messageCount = toNonNegativeInt(source.messageCount)
|
||||
if (updatedAt === undefined || messageCount === undefined) return null
|
||||
return {
|
||||
updatedAt,
|
||||
messageCount
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupMyMessageCountCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private store: GroupMyMessageCountCacheStore = {
|
||||
version: CACHE_VERSION,
|
||||
scopes: {}
|
||||
}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
|
||||
this.ensureCacheDir()
|
||||
this.load()
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const payload = parsed as Record<string, unknown>
|
||||
const scopesRaw = payload.scopes
|
||||
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||
const normalizedScope: GroupMyMessageCountScopeMap = {}
|
||||
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||
const entry = normalizeEntry(entryRaw)
|
||||
if (!entry) continue
|
||||
normalizedScope[chatroomId] = entry
|
||||
}
|
||||
if (Object.keys(normalizedScope).length > 0) {
|
||||
scopes[scopeKey] = normalizedScope
|
||||
}
|
||||
}
|
||||
|
||||
this.store = {
|
||||
version: CACHE_VERSION,
|
||||
scopes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 载入缓存失败', error)
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
}
|
||||
}
|
||||
|
||||
get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined {
|
||||
if (!scopeKey || !chatroomId) return undefined
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return undefined
|
||||
const entry = normalizeEntry(scope[chatroomId])
|
||||
if (!entry) {
|
||||
delete scope[chatroomId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
return undefined
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void {
|
||||
if (!scopeKey || !chatroomId) return
|
||||
const normalized = normalizeEntry(entry)
|
||||
if (!normalized) return
|
||||
|
||||
if (!this.store.scopes[scopeKey]) {
|
||||
this.store.scopes[scopeKey] = {}
|
||||
}
|
||||
|
||||
const existing = this.store.scopes[scopeKey][chatroomId]
|
||||
if (existing && existing.updatedAt > normalized.updatedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
this.store.scopes[scopeKey][chatroomId] = normalized
|
||||
this.trimScope(scopeKey)
|
||||
this.trimScopes()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
delete(scopeKey: string, chatroomId: string): void {
|
||||
if (!scopeKey || !chatroomId) return
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
if (!(chatroomId in scope)) return
|
||||
delete scope[chatroomId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearScope(scopeKey: string): void {
|
||||
if (!scopeKey) return
|
||||
if (!this.store.scopes[scopeKey]) return
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
private trimScope(scopeKey: string): void {
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
const entries = Object.entries(scope)
|
||||
if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return
|
||||
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
const trimmed: GroupMyMessageCountScopeMap = {}
|
||||
for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) {
|
||||
trimmed[chatroomId] = entry
|
||||
}
|
||||
this.store.scopes[scopeKey] = trimmed
|
||||
}
|
||||
|
||||
private trimScopes(): void {
|
||||
const scopeEntries = Object.entries(this.store.scopes)
|
||||
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||
scopeEntries.sort((a, b) => {
|
||||
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||
return bUpdatedAt - aUpdatedAt
|
||||
})
|
||||
|
||||
const trimmedScopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||
trimmedScopes[scopeKey] = scopeMap
|
||||
}
|
||||
this.store.scopes = trimmedScopes
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,16 @@ function getStaticFfmpegPath(): string | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
|
||||
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
||||
return ffmpegStatic
|
||||
if (typeof ffmpegStatic === 'string') {
|
||||
// 修复:如果路径包含 app.asar(打包后),自动替换为 app.asar.unpacked
|
||||
let fixedPath = ffmpegStatic
|
||||
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
|
||||
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
|
||||
}
|
||||
|
||||
if (existsSync(fixedPath)) {
|
||||
return fixedPath
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2: 手动构建路径(开发环境)
|
||||
|
||||
@@ -509,6 +509,58 @@ export class KeyService {
|
||||
return false
|
||||
}
|
||||
|
||||
private isLoginRelatedText(value: string): boolean {
|
||||
const normalized = String(value || '').replace(/\s+/g, '').toLowerCase()
|
||||
if (!normalized) return false
|
||||
const keywords = [
|
||||
'登录',
|
||||
'扫码',
|
||||
'二维码',
|
||||
'请在手机上确认',
|
||||
'手机确认',
|
||||
'切换账号',
|
||||
'wechatlogin',
|
||||
'qrcode',
|
||||
'scan'
|
||||
]
|
||||
return keywords.some((keyword) => normalized.includes(keyword))
|
||||
}
|
||||
|
||||
private async detectWeChatLoginRequired(pid: number): Promise<boolean> {
|
||||
if (!this.ensureUser32()) return false
|
||||
let loginRequired = false
|
||||
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
if (!this.isWeChatWindowTitle(title)) return true
|
||||
|
||||
const pidBuf = Buffer.alloc(4)
|
||||
this.GetWindowThreadProcessId(hWnd, pidBuf)
|
||||
const windowPid = pidBuf.readUInt32LE(0)
|
||||
if (windowPid !== pid) return true
|
||||
|
||||
if (this.isLoginRelatedText(title)) {
|
||||
loginRequired = true
|
||||
return false
|
||||
}
|
||||
|
||||
const children = this.collectChildWindowInfos(hWnd)
|
||||
for (const child of children) {
|
||||
if (this.isLoginRelatedText(child.title) || this.isLoginRelatedText(child.className)) {
|
||||
loginRequired = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, this.WNDENUMPROC_PTR)
|
||||
|
||||
this.EnumWindows(enumWindowsCallback, 0)
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
return loginRequired
|
||||
}
|
||||
|
||||
private async waitForWeChatWindowComponents(pid: number, timeoutMs = 15000): Promise<boolean> {
|
||||
if (!this.ensureUser32()) return true
|
||||
const startTime = Date.now()
|
||||
@@ -605,6 +657,7 @@ export class KeyService {
|
||||
|
||||
const keyBuffer = Buffer.alloc(128)
|
||||
const start = Date.now()
|
||||
let loginRequiredDetected = false
|
||||
|
||||
try {
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
@@ -624,6 +677,9 @@ export class KeyService {
|
||||
const level = levelOut[0] ?? 0
|
||||
if (msg) {
|
||||
logs.push(msg)
|
||||
if (this.isLoginRelatedText(msg)) {
|
||||
loginRequiredDetected = true
|
||||
}
|
||||
onStatus?.(msg, level)
|
||||
}
|
||||
}
|
||||
@@ -635,6 +691,15 @@ export class KeyService {
|
||||
} catch { }
|
||||
}
|
||||
|
||||
const loginRequired = loginRequiredDetected || await this.detectWeChatLoginRequired(pid)
|
||||
if (loginRequired) {
|
||||
return {
|
||||
success: false,
|
||||
error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。',
|
||||
logs
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: '获取密钥超时', logs }
|
||||
}
|
||||
|
||||
@@ -651,7 +716,8 @@ export class KeyService {
|
||||
|
||||
async autoGetImageKey(
|
||||
manualDir?: string,
|
||||
onProgress?: (message: string) => void
|
||||
onProgress?: (message: string) => void,
|
||||
wxidParam?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
|
||||
@@ -683,20 +749,29 @@ export class KeyService {
|
||||
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
|
||||
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
|
||||
|
||||
// 从 manualDir 提取前端已配置好的正确 wxid
|
||||
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
|
||||
// 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid(可能是unknown)
|
||||
let targetWxid = ''
|
||||
if (manualDir) {
|
||||
|
||||
// 方案1: 直接使用传入的wxidParam(最优先)
|
||||
if (wxidParam && wxidParam.startsWith('wxid_')) {
|
||||
targetWxid = wxidParam
|
||||
console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid)
|
||||
}
|
||||
|
||||
// 方案2: 从 manualDir 提取前端已配置好的正确 wxid
|
||||
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
|
||||
if (!targetWxid && manualDir) {
|
||||
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
|
||||
if (dirName.startsWith('wxid_')) {
|
||||
targetWxid = dirName
|
||||
console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid)
|
||||
}
|
||||
}
|
||||
|
||||
// 方案3: 回退到 DLL 发现的第一个(可能是 unknown)
|
||||
if (!targetWxid) {
|
||||
// 无法从 manualDir 提取 wxid,回退到 DLL 发现的第一个
|
||||
targetWxid = accounts[0].wxid
|
||||
console.log('[ImageKey] 无法从 manualDir 提取 wxid,使用 DLL 发现的:', targetWxid)
|
||||
console.log('[ImageKey] 无法获取 wxid,使用 DLL 发现的:', targetWxid)
|
||||
}
|
||||
|
||||
// CleanWxid: 截断到第二个下划线,与 xkey 算法一致
|
||||
@@ -721,4 +796,266 @@ export class KeyService {
|
||||
aesKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||
// 只扫 RW 可写区域(更快),同时支持 ASCII 和 UTF-16LE 两种密钥格式
|
||||
// 验证支持 JPEG/PNG/WEBP/WXGF/GIF 多种格式
|
||||
|
||||
async autoGetImageKeyByMemoryScan(
|
||||
userDir: string,
|
||||
onProgress?: (message: string) => void
|
||||
): Promise<ImageKeyResult> {
|
||||
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
|
||||
|
||||
try {
|
||||
// 1. 查找模板文件获取密文和 XOR 密钥
|
||||
onProgress?.('正在查找模板文件...')
|
||||
let result = await this._findTemplateData(userDir, 32)
|
||||
let { ciphertext, xorKey } = result
|
||||
|
||||
// 如果找不到密钥,尝试扫描更多文件
|
||||
if (ciphertext && xorKey === null) {
|
||||
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||
result = await this._findTemplateData(userDir, 100)
|
||||
xorKey = result.xorKey
|
||||
}
|
||||
|
||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
// 2. 找微信 PID
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
||||
|
||||
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
||||
|
||||
// 3. 持续轮询内存扫描,最多 60 秒
|
||||
const deadline = Date.now() + 60_000
|
||||
let scanCount = 0
|
||||
while (Date.now() < deadline) {
|
||||
scanCount++
|
||||
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
||||
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||
if (aesKey) {
|
||||
onProgress?.('密钥获取成功')
|
||||
return { success: true, xorKey, aesKey }
|
||||
}
|
||||
// 等 5 秒再试
|
||||
await new Promise(r => setTimeout(r, 5000))
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: '60 秒内未找到 AES 密钥。\n请确保已在微信中打开 2-3 张图片大图后再试。'
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: `内存扫描失败: ${e}` }
|
||||
}
|
||||
}
|
||||
|
||||
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
const { readdirSync, readFileSync, statSync } = await import('fs')
|
||||
const { join } = await import('path')
|
||||
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
|
||||
// 递归收集 *_t.dat 文件
|
||||
const collect = (dir: string, results: string[], maxFiles: number) => {
|
||||
if (results.length >= maxFiles) return
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (results.length >= maxFiles) break
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||
}
|
||||
} catch { /* 忽略无权限目录 */ }
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
collect(userDir, files, limit)
|
||||
|
||||
// 按修改时间降序
|
||||
files.sort((a, b) => {
|
||||
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||
})
|
||||
|
||||
let ciphertext: Buffer | null = null
|
||||
const tailCounts: Record<string, number> = {}
|
||||
|
||||
for (const f of files.slice(0, 32)) {
|
||||
try {
|
||||
const data = readFileSync(f)
|
||||
if (data.length < 8) continue
|
||||
|
||||
// 统计末尾两字节用于 XOR 密钥
|
||||
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
// 提取密文(取第一个有效的)
|
||||
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
||||
ciphertext = data.subarray(0xF, 0x1F)
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
// 计算 XOR 密钥
|
||||
let xorKey: number | null = null
|
||||
let maxCount = 0
|
||||
for (const [key, count] of Object.entries(tailCounts)) {
|
||||
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
|
||||
}
|
||||
|
||||
return { ciphertext, xorKey }
|
||||
}
|
||||
|
||||
private async _scanMemoryForAesKey(
|
||||
pid: number,
|
||||
ciphertext: Buffer,
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
|
||||
// 直接用已加载的 kernel32 实例,用 uintptr 传地址
|
||||
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t'])
|
||||
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')])
|
||||
|
||||
// RW 保护标志(只扫可写区域,速度更快)
|
||||
const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||
const MEM_COMMIT = 0x1000
|
||||
const PAGE_NOACCESS = 0x01
|
||||
const PAGE_GUARD = 0x100
|
||||
const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64
|
||||
|
||||
const hProcess = this.OpenProcess(0x1F0FFF, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
// 枚举 RW 内存区域
|
||||
const regions: Array<[number, number]> = []
|
||||
let addr = 0
|
||||
const mbi = Buffer.alloc(MBI_SIZE)
|
||||
|
||||
while (addr < 0x7FFFFFFFFFFF) {
|
||||
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
|
||||
if (ret === 0) break
|
||||
// MEMORY_BASIC_INFORMATION x64 布局:
|
||||
// 0: BaseAddress (8)
|
||||
// 8: AllocationBase (8)
|
||||
// 16: AllocationProtect (4) + 4 padding
|
||||
// 24: RegionSize (8)
|
||||
// 32: State (4)
|
||||
// 36: Protect (4)
|
||||
// 40: Type (4) + 4 padding = 48 total
|
||||
const base = Number(mbi.readBigUInt64LE(0))
|
||||
const size = Number(mbi.readBigUInt64LE(24))
|
||||
const state = mbi.readUInt32LE(32)
|
||||
const protect = mbi.readUInt32LE(36)
|
||||
|
||||
if (state === MEM_COMMIT &&
|
||||
protect !== PAGE_NOACCESS &&
|
||||
(protect & PAGE_GUARD) === 0 &&
|
||||
(protect & RW_FLAGS) !== 0 &&
|
||||
size <= 50 * 1024 * 1024) {
|
||||
regions.push([base, size])
|
||||
}
|
||||
const next = base + size
|
||||
if (next <= addr) break
|
||||
addr = next
|
||||
}
|
||||
|
||||
const totalMB = regions.reduce((s, [, sz]) => s + sz, 0) / 1024 / 1024
|
||||
onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`)
|
||||
|
||||
const CHUNK = 4 * 1024 * 1024
|
||||
const OVERLAP = 65
|
||||
|
||||
for (let i = 0; i < regions.length; i++) {
|
||||
const [base, size] = regions[i]
|
||||
if (i % 20 === 0) {
|
||||
onProgress?.(`扫描进度 ${i}/${regions.length}...`)
|
||||
await new Promise(r => setTimeout(r, 1)) // 让出事件循环
|
||||
}
|
||||
|
||||
let offset = 0
|
||||
let trailing: Buffer | null = null
|
||||
|
||||
while (offset < size) {
|
||||
const chunkSize = Math.min(CHUNK, size - offset)
|
||||
const buf = Buffer.alloc(chunkSize)
|
||||
const bytesReadOut = [0]
|
||||
const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut)
|
||||
if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue }
|
||||
|
||||
const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0])
|
||||
|
||||
// 搜索 ASCII 32字节密钥
|
||||
const key = this._searchAsciiKey(data, ciphertext)
|
||||
if (key) { this.CloseHandle(hProcess); return key }
|
||||
|
||||
// 搜索 UTF-16LE 32字节密钥
|
||||
const key16 = this._searchUtf16Key(data, ciphertext)
|
||||
if (key16) { this.CloseHandle(hProcess); return key16 }
|
||||
|
||||
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
|
||||
offset += chunkSize
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} finally {
|
||||
this.CloseHandle(hProcess)
|
||||
}
|
||||
}
|
||||
|
||||
private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null {
|
||||
for (let i = 0; i < data.length - 34; i++) {
|
||||
if (this._isAlphaNum(data[i])) continue
|
||||
let valid = true
|
||||
for (let j = 1; j <= 32; j++) {
|
||||
if (!this._isAlphaNum(data[i + j])) { valid = false; break }
|
||||
}
|
||||
if (!valid) continue
|
||||
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
|
||||
const keyBytes = data.subarray(i + 1, i + 33)
|
||||
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null {
|
||||
for (let i = 0; i < data.length - 65; i++) {
|
||||
let valid = true
|
||||
for (let j = 0; j < 32; j++) {
|
||||
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break }
|
||||
}
|
||||
if (!valid) continue
|
||||
const keyBytes = Buffer.alloc(32)
|
||||
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
|
||||
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private _isAlphaNum(b: number): boolean {
|
||||
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39)
|
||||
}
|
||||
|
||||
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null)
|
||||
decipher.setAutoPadding(false)
|
||||
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
// 支持 JPEG / PNG / WEBP / WXGF / GIF
|
||||
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||
return false
|
||||
} catch { return false }
|
||||
}
|
||||
}
|
||||
|
||||
293
electron/services/sessionStatsCacheService.ts
Normal file
293
electron/services/sessionStatsCacheService.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
const CACHE_VERSION = 2
|
||||
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
|
||||
export interface SessionStatsCacheStats {
|
||||
totalMessages: number
|
||||
voiceMessages: number
|
||||
imageMessages: number
|
||||
videoMessages: number
|
||||
emojiMessages: number
|
||||
transferMessages: number
|
||||
redPacketMessages: number
|
||||
callMessages: number
|
||||
firstTimestamp?: number
|
||||
lastTimestamp?: number
|
||||
privateMutualGroups?: number
|
||||
groupMemberCount?: number
|
||||
groupMyMessages?: number
|
||||
groupActiveSpeakers?: number
|
||||
groupMutualFriends?: number
|
||||
}
|
||||
|
||||
export interface SessionStatsCacheEntry {
|
||||
updatedAt: number
|
||||
includeRelations: boolean
|
||||
stats: SessionStatsCacheStats
|
||||
}
|
||||
|
||||
interface SessionStatsScopeMap {
|
||||
[sessionId: string]: SessionStatsCacheEntry
|
||||
}
|
||||
|
||||
interface SessionStatsCacheStore {
|
||||
version: number
|
||||
scopes: Record<string, SessionStatsScopeMap>
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return Math.max(0, Math.floor(value))
|
||||
}
|
||||
|
||||
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
|
||||
const totalMessages = toNonNegativeInt(source.totalMessages)
|
||||
const voiceMessages = toNonNegativeInt(source.voiceMessages)
|
||||
const imageMessages = toNonNegativeInt(source.imageMessages)
|
||||
const videoMessages = toNonNegativeInt(source.videoMessages)
|
||||
const emojiMessages = toNonNegativeInt(source.emojiMessages)
|
||||
const transferMessages = toNonNegativeInt(source.transferMessages)
|
||||
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
|
||||
const callMessages = toNonNegativeInt(source.callMessages)
|
||||
|
||||
if (
|
||||
totalMessages === undefined ||
|
||||
voiceMessages === undefined ||
|
||||
imageMessages === undefined ||
|
||||
videoMessages === undefined ||
|
||||
emojiMessages === undefined ||
|
||||
transferMessages === undefined ||
|
||||
redPacketMessages === undefined ||
|
||||
callMessages === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized: SessionStatsCacheStats = {
|
||||
totalMessages,
|
||||
voiceMessages,
|
||||
imageMessages,
|
||||
videoMessages,
|
||||
emojiMessages,
|
||||
transferMessages,
|
||||
redPacketMessages,
|
||||
callMessages
|
||||
}
|
||||
|
||||
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
|
||||
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
|
||||
|
||||
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
|
||||
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
|
||||
|
||||
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
|
||||
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
|
||||
|
||||
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
|
||||
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
|
||||
|
||||
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
|
||||
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
|
||||
|
||||
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
|
||||
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
|
||||
|
||||
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
|
||||
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
|
||||
const stats = normalizeStats(source.stats)
|
||||
|
||||
if (updatedAt === undefined || !stats) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt,
|
||||
includeRelations,
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionStatsCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private store: SessionStatsCacheStore = {
|
||||
version: CACHE_VERSION,
|
||||
scopes: {}
|
||||
}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'session-stats.json')
|
||||
this.ensureCacheDir()
|
||||
this.load()
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const payload = parsed as Record<string, unknown>
|
||||
const version = Number(payload.version)
|
||||
if (!Number.isFinite(version) || version !== CACHE_VERSION) {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
const scopesRaw = payload.scopes
|
||||
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const scopes: Record<string, SessionStatsScopeMap> = {}
|
||||
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||
const normalizedScope: SessionStatsScopeMap = {}
|
||||
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||
const entry = normalizeEntry(entryRaw)
|
||||
if (!entry) continue
|
||||
normalizedScope[sessionId] = entry
|
||||
}
|
||||
if (Object.keys(normalizedScope).length > 0) {
|
||||
scopes[scopeKey] = normalizedScope
|
||||
}
|
||||
}
|
||||
|
||||
this.store = {
|
||||
version: CACHE_VERSION,
|
||||
scopes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SessionStatsCacheService: 载入缓存失败', error)
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
}
|
||||
}
|
||||
|
||||
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
|
||||
if (!scopeKey || !sessionId) return undefined
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return undefined
|
||||
const entry = normalizeEntry(scope[sessionId])
|
||||
if (!entry) {
|
||||
delete scope[sessionId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
return undefined
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
|
||||
if (!scopeKey || !sessionId) return
|
||||
const normalized = normalizeEntry(entry)
|
||||
if (!normalized) return
|
||||
|
||||
if (!this.store.scopes[scopeKey]) {
|
||||
this.store.scopes[scopeKey] = {}
|
||||
}
|
||||
this.store.scopes[scopeKey][sessionId] = normalized
|
||||
|
||||
this.trimScope(scopeKey)
|
||||
this.trimScopes()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
delete(scopeKey: string, sessionId: string): void {
|
||||
if (!scopeKey || !sessionId) return
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
if (!(sessionId in scope)) return
|
||||
|
||||
delete scope[sessionId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearScope(scopeKey: string): void {
|
||||
if (!scopeKey) return
|
||||
if (!this.store.scopes[scopeKey]) return
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('SessionStatsCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
private trimScope(scopeKey: string): void {
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
const entries = Object.entries(scope)
|
||||
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||
|
||||
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
const trimmed: SessionStatsScopeMap = {}
|
||||
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
|
||||
trimmed[sessionId] = entry
|
||||
}
|
||||
this.store.scopes[scopeKey] = trimmed
|
||||
}
|
||||
|
||||
private trimScopes(): void {
|
||||
const scopeEntries = Object.entries(this.store.scopes)
|
||||
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||
|
||||
scopeEntries.sort((a, b) => {
|
||||
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||
return bUpdatedAt - aUpdatedAt
|
||||
})
|
||||
|
||||
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
|
||||
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||
trimmedScopes[scopeKey] = scopeMap
|
||||
}
|
||||
this.store.scopes = trimmedScopes
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('SessionStatsCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,68 @@ export interface SnsPost {
|
||||
linkUrl?: string
|
||||
}
|
||||
|
||||
interface SnsContactIdentity {
|
||||
username: string
|
||||
wxid: string
|
||||
alias?: string
|
||||
wechatId?: string
|
||||
remark?: string
|
||||
nickName?: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
interface ParsedLikeUser {
|
||||
username?: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
interface ParsedCommentItem {
|
||||
id: string
|
||||
nickname: string
|
||||
username?: string
|
||||
content: string
|
||||
refCommentId: string
|
||||
refUsername?: string
|
||||
refNickname?: string
|
||||
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
|
||||
}
|
||||
|
||||
interface ArkmeLikeDetail {
|
||||
nickname: string
|
||||
username?: string
|
||||
wxid?: string
|
||||
alias?: string
|
||||
wechatId?: string
|
||||
remark?: string
|
||||
nickName?: string
|
||||
displayName: string
|
||||
source: 'xml' | 'legacy'
|
||||
}
|
||||
|
||||
interface ArkmeCommentDetail {
|
||||
id: string
|
||||
nickname: string
|
||||
username?: string
|
||||
wxid?: string
|
||||
alias?: string
|
||||
wechatId?: string
|
||||
remark?: string
|
||||
nickName?: string
|
||||
displayName: string
|
||||
content: string
|
||||
refCommentId: string
|
||||
refNickname?: string
|
||||
refUsername?: string
|
||||
refWxid?: string
|
||||
refAlias?: string
|
||||
refWechatId?: string
|
||||
refRemark?: string
|
||||
refNickName?: string
|
||||
refDisplayName?: string
|
||||
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
|
||||
source: 'xml' | 'legacy'
|
||||
}
|
||||
|
||||
|
||||
|
||||
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
|
||||
@@ -127,7 +189,7 @@ const extractVideoKey = (xml: string): string | undefined => {
|
||||
/**
|
||||
* 从 XML 中解析评论信息(含表情包、回复关系)
|
||||
*/
|
||||
function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] {
|
||||
function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
|
||||
if (!xml) return []
|
||||
|
||||
type CommentItem = {
|
||||
@@ -229,12 +291,264 @@ class SnsService {
|
||||
private configService: ConfigService
|
||||
private contactCache: ContactCacheService
|
||||
private imageCache = new Map<string, string>()
|
||||
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
||||
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
||||
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||||
private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000
|
||||
private lastTimelineFallbackAt = 0
|
||||
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
||||
}
|
||||
|
||||
private toOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
private async resolveContactIdentity(
|
||||
username: string,
|
||||
identityCache: Map<string, Promise<SnsContactIdentity | null>>
|
||||
): Promise<SnsContactIdentity | null> {
|
||||
const normalized = String(username || '').trim()
|
||||
if (!normalized) return null
|
||||
|
||||
let pending = identityCache.get(normalized)
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const cached = this.contactCache.get(normalized)
|
||||
let alias: string | undefined
|
||||
let remark: string | undefined
|
||||
let nickName: string | undefined
|
||||
|
||||
try {
|
||||
const contactResult = await wcdbService.getContact(normalized)
|
||||
if (contactResult.success && contactResult.contact) {
|
||||
const contact = contactResult.contact
|
||||
alias = this.toOptionalString(contact.alias ?? contact.Alias)
|
||||
remark = this.toOptionalString(contact.remark ?? contact.Remark)
|
||||
nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName)
|
||||
}
|
||||
} catch {
|
||||
// 联系人补全失败不影响导出
|
||||
}
|
||||
|
||||
const displayName = remark || nickName || alias || cached?.displayName || normalized
|
||||
return {
|
||||
username: normalized,
|
||||
wxid: normalized,
|
||||
alias,
|
||||
wechatId: alias,
|
||||
remark,
|
||||
nickName,
|
||||
displayName
|
||||
}
|
||||
})()
|
||||
identityCache.set(normalized, pending)
|
||||
}
|
||||
|
||||
return pending
|
||||
}
|
||||
|
||||
private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] {
|
||||
if (!xml) return []
|
||||
const likes: ParsedLikeUser[] = []
|
||||
try {
|
||||
let likeListMatch = xml.match(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
|
||||
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
|
||||
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
|
||||
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\s\S]*?)<\/like_user_list>/i)
|
||||
if (!likeListMatch) return likes
|
||||
|
||||
const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) {
|
||||
const block = m[1]
|
||||
const username = this.toOptionalString(block.match(/<username>([^<]*)<\/username>/i)?.[1])
|
||||
const nickname = this.toOptionalString(
|
||||
block.match(/<nickname>([^<]*)<\/nickname>/i)?.[1]
|
||||
|| block.match(/<nickName>([^<]*)<\/nickName>/i)?.[1]
|
||||
)
|
||||
if (username || nickname) {
|
||||
likes.push({ username, nickname })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SnsService] 解析点赞用户失败:', e)
|
||||
}
|
||||
return likes
|
||||
}
|
||||
|
||||
private async buildArkmeInteractionDetails(
|
||||
post: SnsPost,
|
||||
identityCache: Map<string, Promise<SnsContactIdentity | null>>
|
||||
): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> {
|
||||
const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '')
|
||||
const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0
|
||||
? xmlLikes
|
||||
: (post.likes || []).map((nickname) => ({ nickname }))
|
||||
const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy'
|
||||
const likesDetail: ArkmeLikeDetail[] = []
|
||||
const likeSeen = new Set<string>()
|
||||
|
||||
for (const like of likeCandidates) {
|
||||
const identity = like.username
|
||||
? await this.resolveContactIdentity(like.username, identityCache)
|
||||
: null
|
||||
const nickname = like.nickname || identity?.displayName || like.username || ''
|
||||
const username = identity?.username || like.username
|
||||
const key = `${username || ''}|${nickname}`
|
||||
if (likeSeen.has(key)) continue
|
||||
likeSeen.add(key)
|
||||
likesDetail.push({
|
||||
nickname,
|
||||
username,
|
||||
wxid: username,
|
||||
alias: identity?.alias,
|
||||
wechatId: identity?.wechatId,
|
||||
remark: identity?.remark,
|
||||
nickName: identity?.nickName,
|
||||
displayName: identity?.displayName || nickname || username || '',
|
||||
source: likeSource
|
||||
})
|
||||
}
|
||||
|
||||
const xmlComments = parseCommentsFromXml(post.rawXml || '')
|
||||
const commentMap = new Map<string, SnsPost['comments'][number]>()
|
||||
for (const comment of post.comments || []) {
|
||||
if (comment.id) commentMap.set(comment.id, comment)
|
||||
}
|
||||
|
||||
const commentsBase: ParsedCommentItem[] = xmlComments.length > 0
|
||||
? xmlComments.map((comment) => {
|
||||
const fallback = comment.id ? commentMap.get(comment.id) : undefined
|
||||
return {
|
||||
id: comment.id || fallback?.id || '',
|
||||
nickname: comment.nickname || fallback?.nickname || '',
|
||||
username: comment.username,
|
||||
content: comment.content || fallback?.content || '',
|
||||
refCommentId: comment.refCommentId || fallback?.refCommentId || '',
|
||||
refUsername: comment.refUsername,
|
||||
refNickname: comment.refNickname || fallback?.refNickname,
|
||||
emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis
|
||||
}
|
||||
})
|
||||
: (post.comments || []).map((comment) => ({
|
||||
id: comment.id || '',
|
||||
nickname: comment.nickname || '',
|
||||
content: comment.content || '',
|
||||
refCommentId: comment.refCommentId || '',
|
||||
refNickname: comment.refNickname,
|
||||
emojis: comment.emojis
|
||||
}))
|
||||
|
||||
if (xmlComments.length > 0) {
|
||||
const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean))
|
||||
for (const comment of post.comments || []) {
|
||||
if (comment.id && mappedIds.has(comment.id)) continue
|
||||
commentsBase.push({
|
||||
id: comment.id || '',
|
||||
nickname: comment.nickname || '',
|
||||
content: comment.content || '',
|
||||
refCommentId: comment.refCommentId || '',
|
||||
refNickname: comment.refNickname,
|
||||
emojis: comment.emojis
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy'
|
||||
const commentsDetail: ArkmeCommentDetail[] = []
|
||||
|
||||
for (const comment of commentsBase) {
|
||||
const actor = comment.username
|
||||
? await this.resolveContactIdentity(comment.username, identityCache)
|
||||
: null
|
||||
const refActor = comment.refUsername
|
||||
? await this.resolveContactIdentity(comment.refUsername, identityCache)
|
||||
: null
|
||||
const nickname = comment.nickname || actor?.displayName || comment.username || ''
|
||||
const username = actor?.username || comment.username
|
||||
const refUsername = refActor?.username || comment.refUsername
|
||||
commentsDetail.push({
|
||||
id: comment.id || '',
|
||||
nickname,
|
||||
username,
|
||||
wxid: username,
|
||||
alias: actor?.alias,
|
||||
wechatId: actor?.wechatId,
|
||||
remark: actor?.remark,
|
||||
nickName: actor?.nickName,
|
||||
displayName: actor?.displayName || nickname || username || '',
|
||||
content: comment.content || '',
|
||||
refCommentId: comment.refCommentId || '',
|
||||
refNickname: comment.refNickname || refActor?.displayName,
|
||||
refUsername,
|
||||
refWxid: refUsername,
|
||||
refAlias: refActor?.alias,
|
||||
refWechatId: refActor?.wechatId,
|
||||
refRemark: refActor?.remark,
|
||||
refNickName: refActor?.nickName,
|
||||
refDisplayName: refActor?.displayName,
|
||||
emojis: comment.emojis,
|
||||
source: commentSource
|
||||
})
|
||||
}
|
||||
|
||||
return { likesDetail, commentsDetail }
|
||||
}
|
||||
|
||||
private parseCountValue(row: any): number {
|
||||
if (!row || typeof row !== 'object') return 0
|
||||
const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0]
|
||||
const num = Number(raw)
|
||||
return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0
|
||||
}
|
||||
|
||||
private pickTimelineUsername(post: any): string {
|
||||
const raw = post?.username ?? post?.user_name ?? post?.userName ?? ''
|
||||
if (typeof raw !== 'string') return ''
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
let totalPosts = 0
|
||||
let myPosts = 0
|
||||
let offset = 0
|
||||
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||||
|
||||
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
|
||||
|
||||
totalPosts += rows.length
|
||||
for (const row of rows) {
|
||||
const username = this.pickTimelineUsername(row)
|
||||
if (username) uniqueUsers.add(username)
|
||||
if (normalizedMyWxid && username === normalizedMyWxid) myPosts += 1
|
||||
}
|
||||
|
||||
if (rows.length < pageSize) break
|
||||
offset += rows.length
|
||||
}
|
||||
|
||||
return {
|
||||
totalPosts,
|
||||
totalFriends: uniqueUsers.size,
|
||||
myPosts: normalizedMyWxid ? myPosts : null
|
||||
}
|
||||
}
|
||||
|
||||
private parseLikesFromXml(xml: string): string[] {
|
||||
if (!xml) return []
|
||||
const likes: string[] = []
|
||||
@@ -349,14 +663,285 @@ class SnsService {
|
||||
}
|
||||
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
|
||||
if (!result.success || !result.rows) {
|
||||
// 尝试 userName 列名
|
||||
const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine')
|
||||
if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error }
|
||||
return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) }
|
||||
const collect = (rows?: any[]): string[] => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
const usernames: string[] = []
|
||||
for (const row of rows) {
|
||||
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
|
||||
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
|
||||
if (username) usernames.push(username)
|
||||
}
|
||||
return usernames
|
||||
}
|
||||
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
|
||||
|
||||
const primary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
const fallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
|
||||
const merged = Array.from(new Set([
|
||||
...collect(primary.rows),
|
||||
...collect(fallback.rows)
|
||||
]))
|
||||
|
||||
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
|
||||
if (merged.length > 0) {
|
||||
return { success: true, usernames: merged }
|
||||
}
|
||||
|
||||
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
|
||||
if (primary.success || fallback.success) {
|
||||
return { success: true, usernames: [] }
|
||||
}
|
||||
|
||||
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
|
||||
}
|
||||
|
||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
let totalPosts = 0
|
||||
let totalFriends = 0
|
||||
let myPosts: number | null = null
|
||||
|
||||
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
|
||||
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
|
||||
totalPosts = this.parseCountValue(postCountResult.rows[0])
|
||||
}
|
||||
|
||||
if (totalPosts > 0) {
|
||||
const friendCountPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
|
||||
} else {
|
||||
const friendCountFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||||
if (normalizedMyWxid) {
|
||||
const myPostPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostPrimary.rows[0])
|
||||
} else {
|
||||
const myPostFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostFallback.rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { totalPosts, totalFriends, myPosts }
|
||||
}
|
||||
|
||||
async getExportStats(options?: {
|
||||
allowTimelineFallback?: boolean
|
||||
preferCache?: boolean
|
||||
}): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||||
const allowTimelineFallback = options?.allowTimelineFallback ?? true
|
||||
const preferCache = options?.preferCache ?? false
|
||||
const now = Date.now()
|
||||
const myWxid = this.toOptionalString(this.configService.get('myWxid'))
|
||||
|
||||
try {
|
||||
if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalPosts: this.exportStatsCache.totalPosts,
|
||||
totalFriends: this.exportStatsCache.totalFriends,
|
||||
myPosts: this.exportStatsCache.myPosts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let { totalPosts, totalFriends, myPosts } = await this.getExportStatsFromTableCount(myWxid)
|
||||
let fallbackAttempted = false
|
||||
let fallbackError = ''
|
||||
|
||||
// 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。
|
||||
if (
|
||||
allowTimelineFallback &&
|
||||
(totalPosts <= 0 || totalFriends <= 0) &&
|
||||
now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs
|
||||
) {
|
||||
fallbackAttempted = true
|
||||
try {
|
||||
const timelineStats = await this.getExportStatsFromTimeline(myWxid)
|
||||
this.lastTimelineFallbackAt = Date.now()
|
||||
if (timelineStats.totalPosts > 0) {
|
||||
totalPosts = timelineStats.totalPosts
|
||||
}
|
||||
if (timelineStats.totalFriends > 0) {
|
||||
totalFriends = timelineStats.totalFriends
|
||||
}
|
||||
if (timelineStats.myPosts !== null) {
|
||||
myPosts = timelineStats.myPosts
|
||||
}
|
||||
} catch (error) {
|
||||
fallbackError = String(error)
|
||||
console.error('[SnsService] getExportStats timeline fallback failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedStats = {
|
||||
totalPosts: Math.max(0, Number(totalPosts || 0)),
|
||||
totalFriends: Math.max(0, Number(totalFriends || 0)),
|
||||
myPosts: myWxid
|
||||
? (myPosts === null ? null : Math.max(0, Number(myPosts || 0)))
|
||||
: null
|
||||
}
|
||||
const computedHasData = normalizedStats.totalPosts > 0 || normalizedStats.totalFriends > 0
|
||||
const cacheHasData = !!this.exportStatsCache && (this.exportStatsCache.totalPosts > 0 || this.exportStatsCache.totalFriends > 0)
|
||||
|
||||
// 计算结果全 0 时,优先使用已有非零缓存,避免瞬时异常覆盖有效统计。
|
||||
if (!computedHasData && cacheHasData && this.exportStatsCache) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalPosts: this.exportStatsCache.totalPosts,
|
||||
totalFriends: this.exportStatsCache.totalFriends,
|
||||
myPosts: this.exportStatsCache.myPosts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当主查询结果全 0 且回退统计执行失败时,返回失败给前端显示明确状态(而非错误地展示 0)。
|
||||
if (!computedHasData && fallbackAttempted && fallbackError) {
|
||||
return { success: false, error: fallbackError }
|
||||
}
|
||||
|
||||
this.exportStatsCache = {
|
||||
totalPosts: normalizedStats.totalPosts,
|
||||
totalFriends: normalizedStats.totalFriends,
|
||||
myPosts: normalizedStats.myPosts,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
return { success: true, data: normalizedStats }
|
||||
} catch (e) {
|
||||
if (this.exportStatsCache) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalPosts: this.exportStatsCache.totalPosts,
|
||||
totalFriends: this.exportStatsCache.totalFriends,
|
||||
myPosts: this.exportStatsCache.myPosts
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||||
return this.getExportStats({
|
||||
allowTimelineFallback: false,
|
||||
preferCache: true
|
||||
})
|
||||
}
|
||||
|
||||
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 || '统计单个好友朋友圈失败' }
|
||||
}
|
||||
|
||||
// 安装朋友圈删除拦截
|
||||
@@ -376,7 +961,12 @@ class SnsService {
|
||||
|
||||
// 从数据库直接删除朋友圈记录
|
||||
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||
return wcdbService.deleteSnsPost(postId)
|
||||
const result = await wcdbService.deleteSnsPost(postId)
|
||||
if (result.success) {
|
||||
this.userPostCountsCache = null
|
||||
this.exportStatsCache = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -431,20 +1021,6 @@ class SnsService {
|
||||
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
if (!result.success || !result.timeline || result.timeline.length === 0) return result
|
||||
|
||||
// 诊断:测试 execQuery 查 content 字段
|
||||
try {
|
||||
const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1')
|
||||
if (testResult.success && testResult.rows?.[0]) {
|
||||
const r = testResult.rows[0]
|
||||
console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200))
|
||||
console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList'))
|
||||
} else {
|
||||
console.log('[SnsService] execQuery 诊断失败:', testResult.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[SnsService] execQuery 诊断异常:', e)
|
||||
}
|
||||
|
||||
const enrichedTimeline = result.timeline.map((post: any) => {
|
||||
const contact = this.contactCache.get(post.username)
|
||||
const isVideoPost = post.type === 15
|
||||
@@ -577,14 +1153,44 @@ class SnsService {
|
||||
*/
|
||||
async exportTimeline(options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html'
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportMedia?: boolean
|
||||
exportImages?: boolean
|
||||
exportLivePhotos?: boolean
|
||||
exportVideos?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> {
|
||||
const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options
|
||||
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
|
||||
shouldPause?: () => boolean
|
||||
shouldStop?: () => boolean
|
||||
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
|
||||
const { outputDir, format, usernames, keyword, startTime, endTime } = options
|
||||
const hasExplicitMediaSelection =
|
||||
typeof options.exportImages === 'boolean' ||
|
||||
typeof options.exportLivePhotos === 'boolean' ||
|
||||
typeof options.exportVideos === 'boolean'
|
||||
const shouldExportImages = hasExplicitMediaSelection
|
||||
? options.exportImages === true
|
||||
: options.exportMedia === true
|
||||
const shouldExportLivePhotos = hasExplicitMediaSelection
|
||||
? options.exportLivePhotos === true
|
||||
: options.exportMedia === true
|
||||
const shouldExportVideos = hasExplicitMediaSelection
|
||||
? options.exportVideos === true
|
||||
: options.exportMedia === true
|
||||
const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos
|
||||
const getControlState = (): 'paused' | 'stopped' | null => {
|
||||
if (control?.shouldStop?.()) return 'stopped'
|
||||
if (control?.shouldPause?.()) return 'paused'
|
||||
return null
|
||||
}
|
||||
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
|
||||
state === 'stopped'
|
||||
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
|
||||
: { success: true, paused: true, filePath: '', postCount, mediaCount }
|
||||
)
|
||||
|
||||
try {
|
||||
// 确保输出目录存在
|
||||
@@ -601,6 +1207,10 @@ class SnsService {
|
||||
progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' })
|
||||
|
||||
while (hasMore) {
|
||||
const controlState = getControlState()
|
||||
if (controlState) {
|
||||
return buildInterruptedResult(controlState, allPosts.length, 0)
|
||||
}
|
||||
const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs)
|
||||
if (result.success && result.timeline && result.timeline.length > 0) {
|
||||
allPosts.push(...result.timeline)
|
||||
@@ -628,15 +1238,54 @@ class SnsService {
|
||||
let mediaCount = 0
|
||||
const mediaDir = join(outputDir, 'media')
|
||||
|
||||
if (exportMedia) {
|
||||
if (shouldExportMedia) {
|
||||
if (!existsSync(mediaDir)) {
|
||||
mkdirSync(mediaDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 收集所有媒体下载任务
|
||||
const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = []
|
||||
const mediaTasks: Array<{
|
||||
kind: 'image' | 'video' | 'livephoto'
|
||||
media: SnsMedia
|
||||
url: string
|
||||
key?: string
|
||||
postId: string
|
||||
mi: number
|
||||
}> = []
|
||||
for (const post of allPosts) {
|
||||
post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi }))
|
||||
post.media.forEach((media, mi) => {
|
||||
const isVideo = isVideoUrl(media.url)
|
||||
if (shouldExportImages && !isVideo && media.url) {
|
||||
mediaTasks.push({
|
||||
kind: 'image',
|
||||
media,
|
||||
url: media.url,
|
||||
key: media.key,
|
||||
postId: post.id,
|
||||
mi
|
||||
})
|
||||
}
|
||||
if (shouldExportVideos && isVideo && media.url) {
|
||||
mediaTasks.push({
|
||||
kind: 'video',
|
||||
media,
|
||||
url: media.url,
|
||||
key: media.key,
|
||||
postId: post.id,
|
||||
mi
|
||||
})
|
||||
}
|
||||
if (shouldExportLivePhotos && media.livePhoto?.url) {
|
||||
mediaTasks.push({
|
||||
kind: 'livephoto',
|
||||
media,
|
||||
url: media.livePhoto.url,
|
||||
key: media.livePhoto.key || media.key,
|
||||
postId: post.id,
|
||||
mi
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 并发下载(5路)
|
||||
@@ -645,29 +1294,42 @@ class SnsService {
|
||||
const runTask = async (task: typeof mediaTasks[0]) => {
|
||||
const { media, postId, mi } = task
|
||||
try {
|
||||
const isVideo = isVideoUrl(media.url)
|
||||
const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url)
|
||||
const ext = isVideo ? 'mp4' : 'jpg'
|
||||
const fileName = `${postId}_${mi}.${ext}`
|
||||
const suffix = task.kind === 'livephoto' ? '_live' : ''
|
||||
const fileName = `${postId}_${mi}${suffix}.${ext}`
|
||||
const filePath = join(mediaDir, fileName)
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
if (task.kind === 'livephoto') {
|
||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||
} else {
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
}
|
||||
mediaCount++
|
||||
} else {
|
||||
const result = await this.fetchAndDecryptImage(media.url, media.key)
|
||||
const result = await this.fetchAndDecryptImage(task.url, task.key)
|
||||
if (result.success && result.data) {
|
||||
await writeFile(filePath, result.data)
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
if (task.kind === 'livephoto') {
|
||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||
} else {
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
}
|
||||
mediaCount++
|
||||
} else if (result.success && result.cachePath) {
|
||||
const cachedData = await readFile(result.cachePath)
|
||||
await writeFile(filePath, cachedData)
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
if (task.kind === 'livephoto') {
|
||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||
} else {
|
||||
;(media as any).localPath = `media/${fileName}`
|
||||
}
|
||||
mediaCount++
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e)
|
||||
console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e)
|
||||
}
|
||||
done++
|
||||
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
|
||||
@@ -677,11 +1339,18 @@ class SnsService {
|
||||
const queue = [...mediaTasks]
|
||||
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const controlState = getControlState()
|
||||
if (controlState) return controlState
|
||||
const task = queue.shift()!
|
||||
await runTask(task)
|
||||
}
|
||||
return null
|
||||
})
|
||||
await Promise.all(workers)
|
||||
const workerResults = await Promise.all(workers)
|
||||
const interruptedState = workerResults.find(state => state === 'paused' || state === 'stopped')
|
||||
if (interruptedState) {
|
||||
return buildInterruptedResult(interruptedState, allPosts.length, mediaCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 2.5 下载头像
|
||||
@@ -693,6 +1362,8 @@ class SnsService {
|
||||
const avatarQueue = [...uniqueUsers]
|
||||
const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => {
|
||||
while (avatarQueue.length > 0) {
|
||||
const controlState = getControlState()
|
||||
if (controlState) return controlState
|
||||
const post = avatarQueue.shift()!
|
||||
try {
|
||||
const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg`
|
||||
@@ -710,11 +1381,20 @@ class SnsService {
|
||||
avatarDone++
|
||||
progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` })
|
||||
}
|
||||
return null
|
||||
})
|
||||
await Promise.all(avatarWorkers)
|
||||
const avatarWorkerResults = await Promise.all(avatarWorkers)
|
||||
const interruptedState = avatarWorkerResults.find(state => state === 'paused' || state === 'stopped')
|
||||
if (interruptedState) {
|
||||
return buildInterruptedResult(interruptedState, allPosts.length, mediaCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 生成输出文件
|
||||
const finalControlState = getControlState()
|
||||
if (finalControlState) {
|
||||
return buildInterruptedResult(finalControlState, allPosts.length, mediaCount)
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
let outputFilePath: string
|
||||
|
||||
@@ -747,6 +1427,92 @@ class SnsService {
|
||||
}))
|
||||
}
|
||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||
} else if (format === 'arkmejson') {
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||||
progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' })
|
||||
|
||||
const identityCache = new Map<string, Promise<SnsContactIdentity | null>>()
|
||||
const posts: any[] = []
|
||||
let built = 0
|
||||
|
||||
for (const post of allPosts) {
|
||||
const controlState = getControlState()
|
||||
if (controlState) {
|
||||
return buildInterruptedResult(controlState, allPosts.length, mediaCount)
|
||||
}
|
||||
|
||||
const authorIdentity = await this.resolveContactIdentity(post.username, identityCache)
|
||||
const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache)
|
||||
|
||||
posts.push({
|
||||
id: post.id,
|
||||
username: post.username,
|
||||
nickname: post.nickname,
|
||||
author: authorIdentity
|
||||
? {
|
||||
...authorIdentity
|
||||
}
|
||||
: {
|
||||
username: post.username,
|
||||
wxid: post.username,
|
||||
displayName: post.nickname || post.username
|
||||
},
|
||||
createTime: post.createTime,
|
||||
createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'),
|
||||
contentDesc: post.contentDesc,
|
||||
type: post.type,
|
||||
media: post.media.map(m => ({
|
||||
url: m.url,
|
||||
thumb: m.thumb,
|
||||
localPath: (m as any).localPath || undefined,
|
||||
livePhoto: m.livePhoto ? {
|
||||
url: m.livePhoto.url,
|
||||
thumb: m.livePhoto.thumb,
|
||||
localPath: (m.livePhoto as any).localPath || undefined
|
||||
} : undefined
|
||||
})),
|
||||
likes: post.likes,
|
||||
comments: post.comments,
|
||||
likesDetail,
|
||||
commentsDetail,
|
||||
linkTitle: (post as any).linkTitle,
|
||||
linkUrl: (post as any).linkUrl
|
||||
})
|
||||
|
||||
built++
|
||||
if (built % 20 === 0 || built === allPosts.length) {
|
||||
progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` })
|
||||
}
|
||||
}
|
||||
|
||||
const ownerWxid = this.toOptionalString(this.configService.get('myWxid'))
|
||||
const ownerIdentity = ownerWxid
|
||||
? await this.resolveContactIdentity(ownerWxid, identityCache)
|
||||
: null
|
||||
const recordOwner = ownerIdentity
|
||||
? { ...ownerIdentity }
|
||||
: ownerWxid
|
||||
? { username: ownerWxid, wxid: ownerWxid, displayName: ownerWxid }
|
||||
: { username: '', wxid: '', displayName: '' }
|
||||
|
||||
const exportData = {
|
||||
exportTime: new Date().toISOString(),
|
||||
format: 'arkmejson',
|
||||
schemaVersion: '1.0.0',
|
||||
recordOwner,
|
||||
mediaSelection: {
|
||||
images: shouldExportImages,
|
||||
livePhotos: shouldExportLivePhotos,
|
||||
videos: shouldExportVideos
|
||||
},
|
||||
totalPosts: allPosts.length,
|
||||
filters: {
|
||||
usernames: usernames || [],
|
||||
keyword: keyword || ''
|
||||
},
|
||||
posts
|
||||
}
|
||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||
} else {
|
||||
// HTML 格式
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import Database from 'better-sqlite3'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
export interface VideoInfo {
|
||||
@@ -71,58 +70,21 @@ class VideoService {
|
||||
|
||||
/**
|
||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
* 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
*/
|
||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||
const cachePath = this.getCachePath()
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
|
||||
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
|
||||
|
||||
if (!wxid) {
|
||||
this.log('queryVideoFileName: wxid 为空')
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||
if (cachePath) {
|
||||
const cacheDbPaths = [
|
||||
join(cachePath, cleanedWxid, 'hardlink.db'),
|
||||
join(cachePath, wxid, 'hardlink.db'),
|
||||
join(cachePath, 'hardlink.db'),
|
||||
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
|
||||
join(cachePath, 'databases', wxid, 'hardlink.db')
|
||||
]
|
||||
|
||||
for (const p of cacheDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
this.log('尝试缓存 hardlink.db', { path: p })
|
||||
const db = new Database(p, { readonly: true })
|
||||
const row = db.prepare(`
|
||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||
WHERE md5 = ?
|
||||
LIMIT 1
|
||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
||||
db.close()
|
||||
|
||||
if (row?.file_name) {
|
||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
||||
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||
return realMd5
|
||||
}
|
||||
this.log('缓存 hardlink.db 未命中', { path: p })
|
||||
} catch (e) {
|
||||
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
if (dbPath) {
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
|
||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||
@@ -114,6 +114,9 @@ export class WcdbCore {
|
||||
private wcdbStartMonitorPipe: any = null
|
||||
private wcdbStopMonitorPipe: any = null
|
||||
private wcdbGetMonitorPipeName: any = null
|
||||
private wcdbCloudInit: any = null
|
||||
private wcdbCloudReport: any = null
|
||||
private wcdbCloudStop: any = null
|
||||
|
||||
private monitorPipeClient: any = null
|
||||
private monitorCallback: ((type: string, json: string) => void) | null = null
|
||||
@@ -702,12 +705,33 @@ export class WcdbCore {
|
||||
this.wcdbVerifyUser = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_cloud_init(int32_t interval_seconds)
|
||||
try {
|
||||
this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)')
|
||||
} catch {
|
||||
this.wcdbCloudInit = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_cloud_report(const char* stats_json)
|
||||
try {
|
||||
this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)')
|
||||
} catch {
|
||||
this.wcdbCloudReport = null
|
||||
}
|
||||
|
||||
// void wcdb_cloud_stop()
|
||||
try {
|
||||
this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()')
|
||||
} catch {
|
||||
this.wcdbCloudStop = null
|
||||
}
|
||||
|
||||
|
||||
// 初始化
|
||||
const initResult = this.wcdbInit()
|
||||
if (initResult !== 0) {
|
||||
console.error('WCDB 初始化失败:', initResult)
|
||||
lastDllInitError = `初始化失败(错误码: ${initResult})`
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1144,6 +1168,40 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
|
||||
const normalizedSessionIds = Array.from(
|
||||
new Set(
|
||||
(sessionIds || [])
|
||||
.map((id) => String(id || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
)
|
||||
if (normalizedSessionIds.length === 0) {
|
||||
return { success: true, counts: {} }
|
||||
}
|
||||
|
||||
try {
|
||||
const counts: Record<string, number> = {}
|
||||
for (let i = 0; i < normalizedSessionIds.length; i += 1) {
|
||||
const sessionId = normalizedSessionIds[i]
|
||||
const outCount = [0]
|
||||
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
|
||||
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
|
||||
|
||||
if (i > 0 && i % 160 === 0) {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
}
|
||||
return { success: true, counts }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
@@ -1841,8 +1899,57 @@ export class WcdbCore {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Windows Hello
|
||||
* 数据收集初始化
|
||||
*/
|
||||
async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
if (!initOk) return { success: false, error: 'WCDB init failed' }
|
||||
}
|
||||
if (!this.wcdbCloudInit) {
|
||||
return { success: false, error: 'Cloud init API not supported by DLL' }
|
||||
}
|
||||
try {
|
||||
const result = this.wcdbCloudInit(intervalSeconds)
|
||||
if (result !== 0) {
|
||||
return { success: false, error: `Cloud init failed: ${result}` }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
if (!initOk) return { success: false, error: 'WCDB init failed' }
|
||||
}
|
||||
if (!this.wcdbCloudReport) {
|
||||
return { success: false, error: 'Cloud report API not supported by DLL' }
|
||||
}
|
||||
try {
|
||||
const result = this.wcdbCloudReport(statsJson || '')
|
||||
if (result !== 0) {
|
||||
return { success: false, error: `Cloud report failed: ${result}` }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
cloudStop(): { success: boolean; error?: string } {
|
||||
if (!this.wcdbCloudStop) {
|
||||
return { success: false, error: 'Cloud stop API not supported by DLL' }
|
||||
}
|
||||
try {
|
||||
this.wcdbCloudStop()
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
@@ -2093,4 +2200,3 @@ export class WcdbCore {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,10 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageCount', { sessionId })
|
||||
}
|
||||
|
||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
@@ -479,6 +483,27 @@ export class WcdbService {
|
||||
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:初始化
|
||||
*/
|
||||
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudInit', { intervalSeconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:上报数据
|
||||
*/
|
||||
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudReport', { statsJson })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:停止
|
||||
*/
|
||||
cloudStop(): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudStop', {})
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ if (parentPort) {
|
||||
case 'getMessageCount':
|
||||
result = await core.getMessageCount(payload.sessionId)
|
||||
break
|
||||
case 'getMessageCounts':
|
||||
result = await core.getMessageCounts(payload.sessionIds)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
@@ -171,7 +174,15 @@ if (parentPort) {
|
||||
case 'deleteMessage':
|
||||
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||
break
|
||||
|
||||
case 'cloudInit':
|
||||
result = await core.cloudInit(payload.intervalSeconds)
|
||||
break
|
||||
case 'cloudReport':
|
||||
result = await core.cloudReport(payload.statsJson)
|
||||
break
|
||||
case 'cloudStop':
|
||||
result = core.cloudStop()
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
|
||||
let notificationWindow: BrowserWindow | null = null
|
||||
let closeTimer: NodeJS.Timeout | null = null
|
||||
|
||||
export function destroyNotificationWindow() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
lastNotificationData = null
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null
|
||||
return
|
||||
}
|
||||
|
||||
const win = notificationWindow
|
||||
notificationWindow = null
|
||||
|
||||
try {
|
||||
win.destroy()
|
||||
} catch (error) {
|
||||
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"echarts": "^5.5.1",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
@@ -45,7 +45,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
13
src/App.scss
13
src/App.scss
@@ -69,6 +69,19 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.export-keepalive-page {
|
||||
height: 100%;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.export-route-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes appFadeIn {
|
||||
|
||||
105
src/App.tsx
105
src/App.tsx
@@ -26,6 +26,7 @@ import NotificationWindow from './pages/NotificationWindow'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||
import * as configService from './services/config'
|
||||
import * as cloudControl from './services/cloudControl'
|
||||
import { Download, X, Shield } from 'lucide-react'
|
||||
import './App.scss'
|
||||
|
||||
@@ -60,7 +61,9 @@ function App() {
|
||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isExportRoute = location.pathname === '/export'
|
||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||
|
||||
// 锁定状态
|
||||
@@ -75,6 +78,9 @@ function App() {
|
||||
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||
const [agreementLoading, setAgreementLoading] = useState(true)
|
||||
|
||||
// 数据收集同意状态
|
||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const body = document.body
|
||||
@@ -170,6 +176,14 @@ function App() {
|
||||
const agreed = await configService.getAgreementAccepted()
|
||||
if (!agreed) {
|
||||
setShowAgreement(true)
|
||||
} else {
|
||||
// 协议已同意,检查数据收集同意状态
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||
if (consent === null && denyCount < 2) {
|
||||
setShowAnalyticsConsent(true)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查协议状态失败:', e)
|
||||
@@ -180,16 +194,45 @@ function App() {
|
||||
checkAgreement()
|
||||
}, [])
|
||||
|
||||
// 初始化数据收集
|
||||
useEffect(() => {
|
||||
cloudControl.initCloudControl()
|
||||
}, [])
|
||||
|
||||
// 记录页面访问
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
if (path && path !== '/') {
|
||||
cloudControl.recordPage(path)
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
const handleAgree = async () => {
|
||||
if (!agreementChecked) return
|
||||
await configService.setAgreementAccepted(true)
|
||||
setShowAgreement(false)
|
||||
// 协议同意后,检查数据收集同意
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
if (consent === null) {
|
||||
setShowAnalyticsConsent(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisagree = () => {
|
||||
window.electronAPI.window.close()
|
||||
}
|
||||
|
||||
const handleAnalyticsAllow = async () => {
|
||||
await configService.setAnalyticsConsent(true)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
const handleAnalyticsDeny = async () => {
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
await configService.setAnalyticsDenyCount(denyCount + 1)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
// 监听启动时的更新通知
|
||||
useEffect(() => {
|
||||
if (isNotificationWindow) return // Skip updates in notification window
|
||||
@@ -360,6 +403,26 @@ function App() {
|
||||
return <ChatHistoryPage />
|
||||
}
|
||||
|
||||
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||
if (isStandaloneChatWindow) {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const sessionId = params.get('sessionId') || ''
|
||||
const standaloneSource = params.get('source')
|
||||
const standaloneInitialDisplayName = params.get('initialDisplayName')
|
||||
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
|
||||
const standaloneInitialContactType = params.get('initialContactType')
|
||||
return (
|
||||
<ChatPage
|
||||
standaloneSessionWindow
|
||||
initialSessionId={sessionId}
|
||||
standaloneSource={standaloneSource}
|
||||
standaloneInitialDisplayName={standaloneInitialDisplayName}
|
||||
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
|
||||
standaloneInitialContactType={standaloneInitialContactType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立通知窗口
|
||||
if (isNotificationWindow) {
|
||||
return <NotificationWindow />
|
||||
@@ -439,6 +502,42 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据收集同意弹窗 */}
|
||||
{showAnalyticsConsent && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
<div className="agreement-modal">
|
||||
<div className="agreement-header">
|
||||
<Shield size={32} />
|
||||
<h2>使用数据收集说明</h2>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<p>为了持续改进 WeFlow 并提供更好的用户体验,我们希望收集一些匿名的使用数据。</p>
|
||||
|
||||
<h4>我们会收集什么?</h4>
|
||||
<p>• 功能使用情况(如哪些功能被使用、使用频率)</p>
|
||||
<p>• 应用性能数据(如加载时间、错误日志)</p>
|
||||
<p>• 设备基本信息(如操作系统版本、应用版本)</p>
|
||||
|
||||
<h4>我们不会收集什么?</h4>
|
||||
<p>• 你的聊天记录内容</p>
|
||||
<p>• 个人身份信息</p>
|
||||
<p>• 联系人信息</p>
|
||||
<p>• 任何可以识别你身份的数据</p>
|
||||
<p>• 一切你担心会涉及隐藏的数据</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
<div className="agreement-actions">
|
||||
<button className="btn btn-secondary" onClick={handleAnalyticsDeny}>不允许</button>
|
||||
<button className="btn btn-primary" onClick={handleAnalyticsAllow}>允许</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更新提示对话框 */}
|
||||
<UpdateDialog
|
||||
open={showUpdateDialog}
|
||||
@@ -454,6 +553,10 @@ function App() {
|
||||
<Sidebar />
|
||||
<main className="content">
|
||||
<RouteGuard>
|
||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||
<ExportPage />
|
||||
</div>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
@@ -468,7 +571,7 @@ function App() {
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -198,11 +198,12 @@ export function GlobalSessionMonitor() {
|
||||
// 尝试丰富或获取联系人详情
|
||||
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (contact) {
|
||||
if (contact.remark || contact.nickname) {
|
||||
title = contact.remark || contact.nickname
|
||||
if (contact.remark || contact.nickName) {
|
||||
title = contact.remark || contact.nickName
|
||||
}
|
||||
if (contact.avatarUrl) {
|
||||
avatarUrl = contact.avatarUrl
|
||||
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||
if (avatarResult?.avatarUrl) {
|
||||
avatarUrl = avatarResult.avatarUrl
|
||||
}
|
||||
} else {
|
||||
// 如果不在缓存/数据库中
|
||||
@@ -222,8 +223,11 @@ export function GlobalSessionMonitor() {
|
||||
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (retried) {
|
||||
title = retried.remark || retried.nickname || title
|
||||
avatarUrl = retried.avatarUrl || avatarUrl
|
||||
title = retried.remark || retried.nickName || title
|
||||
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||
if (retriedAvatar?.avatarUrl) {
|
||||
avatarUrl = retriedAvatar.avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
166
src/components/JumpToDatePopover.scss
Normal file
166
src/components/JumpToDatePopover.scss
Normal file
@@ -0,0 +1,166 @@
|
||||
.jump-date-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 312px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: none;
|
||||
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||
opacity: 1;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
mix-blend-mode: normal;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
padding: 12px;
|
||||
z-index: 1600;
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.jump-date-popover .current-month {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.jump-date-popover .nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
background-color: var(--bg-secondary-solid, #ffffff) !important;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .nav-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.jump-date-popover .status-line {
|
||||
min-height: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.jump-date-popover .status-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-grid .weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-grid .weekday {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.jump-date-popover .calendar-grid .days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: repeat(6, 36px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell {
|
||||
position: relative;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell .day-number {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.empty {
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell:not(.empty):not(.no-message):hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.today {
|
||||
border-color: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.no-message {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-count {
|
||||
position: static;
|
||||
margin-top: 1px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #16a34a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected .day-count {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-count-loading {
|
||||
position: static;
|
||||
margin-top: 1px;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.jump-date-popover .spin {
|
||||
animation: jump-date-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes jump-date-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
191
src/components/JumpToDatePopover.tsx
Normal file
191
src/components/JumpToDatePopover.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import './JumpToDatePopover.scss'
|
||||
|
||||
interface JumpToDatePopoverProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (date: Date) => void
|
||||
onMonthChange?: (date: Date) => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
currentDate?: Date
|
||||
messageDates?: Set<string>
|
||||
hasLoadedMessageDates?: boolean
|
||||
messageDateCounts?: Record<string, number>
|
||||
loadingDates?: boolean
|
||||
loadingDateCounts?: boolean
|
||||
}
|
||||
|
||||
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
onMonthChange,
|
||||
className,
|
||||
style,
|
||||
currentDate = new Date(),
|
||||
messageDates,
|
||||
hasLoadedMessageDates = false,
|
||||
messageDateCounts,
|
||||
loadingDates = false,
|
||||
loadingDateCounts = false
|
||||
}) => {
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const normalized = new Date(currentDate)
|
||||
setCalendarDate(normalized)
|
||||
setSelectedDate(normalized)
|
||||
}, [isOpen, currentDate])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const getDaysInMonth = (date: Date): number => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (date: Date): number => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const toDateKey = (day: number): string => {
|
||||
const year = calendarDate.getFullYear()
|
||||
const month = calendarDate.getMonth() + 1
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const hasMessage = (day: number): boolean => {
|
||||
if (!hasLoadedMessageDates) return true
|
||||
if (!messageDates || messageDates.size === 0) return false
|
||||
return messageDates.has(toDateKey(day))
|
||||
}
|
||||
|
||||
const isToday = (day: number): boolean => {
|
||||
const today = new Date()
|
||||
return day === today.getDate()
|
||||
&& calendarDate.getMonth() === today.getMonth()
|
||||
&& calendarDate.getFullYear() === today.getFullYear()
|
||||
}
|
||||
|
||||
const isSelected = (day: number): boolean => {
|
||||
return day === selectedDate.getDate()
|
||||
&& calendarDate.getMonth() === selectedDate.getMonth()
|
||||
&& calendarDate.getFullYear() === selectedDate.getFullYear()
|
||||
}
|
||||
|
||||
const generateCalendar = (): Array<number | null> => {
|
||||
const daysInMonth = getDaysInMonth(calendarDate)
|
||||
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||
const days: Array<number | null> = []
|
||||
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null)
|
||||
}
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
if (hasLoadedMessageDates && !hasMessage(day)) return
|
||||
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||
setSelectedDate(targetDate)
|
||||
onSelect(targetDate)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const getDayClassName = (day: number | null): string => {
|
||||
if (day === null) return 'day-cell empty'
|
||||
const classes = ['day-cell']
|
||||
if (isToday(day)) classes.push('today')
|
||||
if (isSelected(day)) classes.push('selected')
|
||||
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
onMonthChange?.(nextDate)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
aria-label="上一月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
aria-label="下一月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="status-line">
|
||||
{loadingDates && (
|
||||
<span className="status-item">
|
||||
<Loader2 size={12} className="spin" />
|
||||
<span>日期加载中</span>
|
||||
</span>
|
||||
)}
|
||||
{!loadingDates && loadingDateCounts && (
|
||||
<span className="status-item">
|
||||
<Loader2 size={12} className="spin" />
|
||||
<span>条数加载中</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="calendar-grid">
|
||||
<div className="weekdays">
|
||||
{weekdays.map(day => (
|
||||
<div key={day} className="weekday">{day}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="days">
|
||||
{days.map((day, index) => {
|
||||
if (day === null) return <div key={index} className="day-cell empty" />
|
||||
const dateKey = toDateKey(day)
|
||||
const hasMessageOnDay = hasMessage(day)
|
||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||
const showCount = count > 0
|
||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={getDayClassName(day)}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||
type="button"
|
||||
>
|
||||
<span className="day-number">{day}</span>
|
||||
{showCount && <span className="day-count">{count}</span>}
|
||||
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default JumpToDatePopover
|
||||
@@ -10,6 +10,19 @@
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
|
||||
.sidebar-user-card-wrap {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
.sidebar-user-card {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
|
||||
.user-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu,
|
||||
.sidebar-footer {
|
||||
padding: 0 8px;
|
||||
@@ -27,6 +40,119 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-card-wrap {
|
||||
position: relative;
|
||||
margin: 0 12px 10px;
|
||||
}
|
||||
|
||||
.sidebar-user-clear-trigger {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 12;
|
||||
border: 1px solid rgba(255, 59, 48, 0.28);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: #d93025;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
border-color: rgba(255, 59, 48, 0.46);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-card {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(99, 102, 241, 0.32);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.menu-open {
|
||||
border-color: rgba(99, 102, 241, 0.44);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-wxid {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-menu-caret {
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -57,7 +183,7 @@
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +196,44 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-icon-with-badge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
background: #ff3b30;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
|
||||
}
|
||||
|
||||
.nav-badge.icon-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -10px;
|
||||
margin-left: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
@@ -105,6 +264,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog {
|
||||
width: min(460px, 100%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-options {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||
[data-theme="blossom-dream"] .sidebar {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
@@ -130,4 +365,4 @@
|
||||
background: rgba(209, 158, 187, 0.15);
|
||||
color: #D19EBB;
|
||||
border: 1px solid rgba(209, 158, 187, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,336 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
interface SidebarUserProfile {
|
||||
wxid: string
|
||||
displayName: string
|
||||
alias?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
|
||||
interface SidebarUserProfileCache extends SidebarUserProfile {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as SidebarUserProfileCache
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
if (!parsed.wxid || !parsed.displayName) return null
|
||||
return {
|
||||
wxid: parsed.wxid,
|
||||
displayName: parsed.displayName,
|
||||
alias: parsed.alias,
|
||||
avatarUrl: parsed.avatarUrl
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
||||
if (!profile.wxid || !profile.displayName) return
|
||||
try {
|
||||
const payload: SidebarUserProfileCache = {
|
||||
...profile,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
// 忽略本地缓存失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeAccountId = (value?: string | null): string => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||
wxid: '',
|
||||
displayName: '未识别用户'
|
||||
})
|
||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false)
|
||||
const [shouldClearCacheData, setShouldClearCacheData] = useState(false)
|
||||
const [shouldClearExportData, setShouldClearExportData] = useState(false)
|
||||
const [isClearingAccountData, setIsClearingAccountData] = useState(false)
|
||||
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!isAccountMenuOpen) return
|
||||
const target = event.target as Node | null
|
||||
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
|
||||
setIsAccountMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isAccountMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onExportSessionStatus((payload) => {
|
||||
const countFromPayload = typeof payload?.activeTaskCount === 'number'
|
||||
? payload.activeTaskCount
|
||||
: Array.isArray(payload?.inProgressSessionIds)
|
||||
? payload.inProgressSessionIds.length
|
||||
: 0
|
||||
const normalized = Math.max(0, Math.floor(countFromPayload))
|
||||
setActiveExportTaskCount(normalized)
|
||||
})
|
||||
|
||||
requestExportSessionStatus()
|
||||
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadCurrentUser = async () => {
|
||||
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
||||
setUserProfile(prev => {
|
||||
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
|
||||
return prev
|
||||
}
|
||||
const next: SidebarUserProfile = {
|
||||
...prev,
|
||||
...patch
|
||||
}
|
||||
if (!next.displayName) {
|
||||
next.displayName = next.wxid || '未识别用户'
|
||||
}
|
||||
writeSidebarUserProfileCache(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const wxid = await configService.getMyWxid()
|
||||
const resolvedWxidRaw = String(wxid || '').trim()
|
||||
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||
const wxidCandidates = new Set<string>([
|
||||
resolvedWxidRaw.toLowerCase(),
|
||||
resolvedWxid.trim().toLowerCase(),
|
||||
cleanedWxid.trim().toLowerCase()
|
||||
].filter(Boolean))
|
||||
|
||||
const normalizeName = (value?: string | null): string | undefined => {
|
||||
if (!value) return undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const lowered = trimmed.toLowerCase()
|
||||
if (lowered === 'self') return undefined
|
||||
if (lowered.startsWith('wxid_')) return undefined
|
||||
if (wxidCandidates.has(lowered)) return undefined
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeName(candidate)
|
||||
if (normalized) return normalized
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fallbackDisplayName = resolvedWxid || '未识别用户'
|
||||
|
||||
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
|
||||
patchUserProfile({
|
||||
wxid: resolvedWxid,
|
||||
displayName: fallbackDisplayName
|
||||
})
|
||||
|
||||
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||
|
||||
// 第二阶段:后台补齐名称(不会阻塞首屏)。
|
||||
void (async () => {
|
||||
try {
|
||||
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
|
||||
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
|
||||
const contact = await window.electronAPI.chat.getContact(candidate)
|
||||
if (!contact) continue
|
||||
if (!myContact) myContact = contact
|
||||
if (contact.remark || contact.nickName || contact.alias) {
|
||||
myContact = contact
|
||||
break
|
||||
}
|
||||
}
|
||||
const fromContact = pickFirstValidName(
|
||||
myContact?.remark,
|
||||
myContact?.nickName,
|
||||
myContact?.alias
|
||||
)
|
||||
|
||||
if (fromContact) {
|
||||
patchUserProfile({ displayName: fromContact }, resolvedWxid)
|
||||
// 同步补充微信号(alias)
|
||||
if (myContact?.alias) {
|
||||
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
|
||||
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
|
||||
const enrichedDisplayName = pickFirstValidName(
|
||||
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
|
||||
enrichedResult.contacts?.[resolvedWxid]?.displayName,
|
||||
enrichedResult.contacts?.[cleanedWxid]?.displayName,
|
||||
enrichedResult.contacts?.self?.displayName,
|
||||
myContact?.alias
|
||||
)
|
||||
const bestName = enrichedDisplayName
|
||||
if (bestName) {
|
||||
patchUserProfile({ displayName: bestName }, resolvedWxid)
|
||||
}
|
||||
// 降级分支也补充微信号
|
||||
if (myContact?.alias) {
|
||||
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||
}
|
||||
} catch (nameError) {
|
||||
console.error('加载侧边栏用户昵称失败:', nameError)
|
||||
}
|
||||
})()
|
||||
|
||||
// 第二阶段:后台补齐头像(不会阻塞首屏)。
|
||||
void (async () => {
|
||||
try {
|
||||
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
|
||||
if (avatarResult.success && avatarResult.avatarUrl) {
|
||||
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
|
||||
}
|
||||
} catch (avatarError) {
|
||||
console.error('加载侧边栏用户头像失败:', avatarError)
|
||||
}
|
||||
})()
|
||||
} catch (error) {
|
||||
console.error('加载侧边栏用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const cachedProfile = readSidebarUserProfileCache()
|
||||
if (cachedProfile) {
|
||||
setUserProfile(prev => ({
|
||||
...prev,
|
||||
...cachedProfile
|
||||
}))
|
||||
}
|
||||
|
||||
void loadCurrentUser()
|
||||
const onWxidChanged = () => { void loadCurrentUser() }
|
||||
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
}, [])
|
||||
|
||||
const getAvatarLetter = (name: string): string => {
|
||||
if (!name) return '?'
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
}
|
||||
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
||||
const canConfirmClear = shouldClearCacheData || shouldClearExportData
|
||||
|
||||
const resetClearDialogState = () => {
|
||||
setShouldClearCacheData(false)
|
||||
setShouldClearExportData(false)
|
||||
setShowClearAccountDialog(false)
|
||||
}
|
||||
|
||||
const openClearAccountDialog = () => {
|
||||
setIsAccountMenuOpen(false)
|
||||
setShouldClearCacheData(false)
|
||||
setShouldClearExportData(false)
|
||||
setShowClearAccountDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmClearAccountData = async () => {
|
||||
if (!canConfirmClear || isClearingAccountData) return
|
||||
setIsClearingAccountData(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.clearCurrentAccountData({
|
||||
clearCache: shouldClearCacheData,
|
||||
clearExports: shouldClearExportData
|
||||
})
|
||||
if (!result.success) {
|
||||
window.alert(result.error || '清理失败,请稍后重试。')
|
||||
return
|
||||
}
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
setUserProfile({ wxid: '', displayName: '未识别用户' })
|
||||
window.dispatchEvent(new Event('wxid-changed'))
|
||||
|
||||
const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : []
|
||||
const selectedScopes = [
|
||||
shouldClearCacheData ? '缓存数据' : '',
|
||||
shouldClearExportData ? '导出数据' : ''
|
||||
].filter(Boolean)
|
||||
const detailLines: string[] = [
|
||||
`清理范围:${selectedScopes.join('、') || '未选择'}`,
|
||||
`已清理项目:${removedPaths.length} 项`
|
||||
]
|
||||
if (removedPaths.length > 0) {
|
||||
detailLines.push('', '清理明细(最多显示 8 项):')
|
||||
for (const [index, path] of removedPaths.slice(0, 8).entries()) {
|
||||
detailLines.push(`${index + 1}. ${path}`)
|
||||
}
|
||||
if (removedPaths.length > 8) {
|
||||
detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`)
|
||||
}
|
||||
}
|
||||
if (result.warning) {
|
||||
detailLines.push('', `注意:${result.warning}`)
|
||||
}
|
||||
const followupHint = shouldClearCacheData
|
||||
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
|
||||
: '你可以继续使用当前登录状态,无需重新登录。'
|
||||
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全,WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
|
||||
resetClearDialogState()
|
||||
if (shouldClearCacheData) {
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理账号数据失败:', error)
|
||||
window.alert('清理失败,请稍后重试。')
|
||||
} finally {
|
||||
setIsClearingAccountData(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
@@ -98,24 +411,75 @@ function Sidebar() {
|
||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||
title={collapsed ? '导出' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Download size={20} /></span>
|
||||
<span className="nav-icon nav-icon-with-badge">
|
||||
<Download size={20} />
|
||||
{collapsed && activeExportTaskCount > 0 && (
|
||||
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="nav-label">导出</span>
|
||||
{!collapsed && activeExportTaskCount > 0 && (
|
||||
<span className="nav-badge">{exportTaskBadge}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{authEnabled && (
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => setLocked(true)}
|
||||
title={collapsed ? '锁定' : undefined}
|
||||
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||
{isAccountMenuOpen && (
|
||||
<button
|
||||
className="sidebar-user-clear-trigger"
|
||||
onClick={openClearAccountDialog}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>清除此账号所有数据</span>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setIsAccountMenuOpen(prev => !prev)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="nav-icon"><Lock size={20} /></span>
|
||||
<span className="nav-label">锁定</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="user-avatar">
|
||||
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{userProfile.displayName}</div>
|
||||
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
||||
<ChevronUp size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => {
|
||||
if (authEnabled) {
|
||||
setLocked(true)
|
||||
return
|
||||
}
|
||||
navigate('/settings', { state: { initialTab: 'security' } })
|
||||
}}
|
||||
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
||||
>
|
||||
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
||||
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
||||
</button>
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
@@ -136,6 +500,49 @@ function Sidebar() {
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showClearAccountDialog && (
|
||||
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}>
|
||||
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>清除此账号所有数据</h3>
|
||||
<p>
|
||||
操作后可将该账户在 weflow 下产生的所有缓存文件、导出文件等彻底清除。
|
||||
清除后必须手动登录微信客户端 weflow 才能再次获取,保障你的数据安全。
|
||||
</p>
|
||||
<div className="sidebar-clear-options">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shouldClearCacheData}
|
||||
onChange={(event) => setShouldClearCacheData(event.target.checked)}
|
||||
disabled={isClearingAccountData}
|
||||
/>
|
||||
缓存数据
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shouldClearExportData}
|
||||
onChange={(event) => setShouldClearExportData(event.target.checked)}
|
||||
disabled={isClearingAccountData}
|
||||
/>
|
||||
导出数据
|
||||
</label>
|
||||
</div>
|
||||
<div className="sidebar-clear-actions">
|
||||
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={!canConfirmClear || isClearingAccountData}
|
||||
onClick={handleConfirmClearAccountData}
|
||||
>
|
||||
{isClearingAccountData ? '清除中...' : '确认清除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
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,67 +1,69 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { Search, User, X, Loader2 } from 'lucide-react'
|
||||
import { Avatar } from '../Avatar'
|
||||
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
|
||||
|
||||
interface Contact {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
postCount?: number
|
||||
postCountStatus?: 'idle' | 'loading' | 'ready'
|
||||
}
|
||||
|
||||
interface ContactsCountProgress {
|
||||
resolved: number
|
||||
total: number
|
||||
running: boolean
|
||||
}
|
||||
|
||||
interface SnsFilterPanelProps {
|
||||
searchKeyword: string
|
||||
setSearchKeyword: (val: string) => void
|
||||
jumpTargetDate?: Date
|
||||
setJumpTargetDate: (date?: Date) => void
|
||||
onOpenJumpDialog: () => void
|
||||
selectedUsernames: string[]
|
||||
setSelectedUsernames: (val: string[]) => void
|
||||
totalFriendsLabel?: string
|
||||
contacts: Contact[]
|
||||
contactSearch: string
|
||||
setContactSearch: (val: string) => void
|
||||
loading?: boolean
|
||||
contactsCountProgress?: ContactsCountProgress
|
||||
onOpenContactTimeline: (contact: Contact) => void
|
||||
}
|
||||
|
||||
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
searchKeyword,
|
||||
setSearchKeyword,
|
||||
jumpTargetDate,
|
||||
setJumpTargetDate,
|
||||
onOpenJumpDialog,
|
||||
selectedUsernames,
|
||||
setSelectedUsernames,
|
||||
totalFriendsLabel,
|
||||
contacts,
|
||||
contactSearch,
|
||||
setContactSearch,
|
||||
loading
|
||||
loading,
|
||||
contactsCountProgress,
|
||||
onOpenContactTimeline
|
||||
}) => {
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||
)
|
||||
|
||||
const toggleUserSelection = (username: string) => {
|
||||
if (selectedUsernames.includes(username)) {
|
||||
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
|
||||
} else {
|
||||
setJumpTargetDate(undefined) // Reset date jump when selecting user
|
||||
setSelectedUsernames([...selectedUsernames, username])
|
||||
}
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchKeyword('')
|
||||
setSelectedUsernames([])
|
||||
setJumpTargetDate(undefined)
|
||||
setContactSearch('')
|
||||
}
|
||||
|
||||
const getEmptyStateText = () => {
|
||||
if (loading && contacts.length === 0) {
|
||||
return '正在加载联系人...'
|
||||
}
|
||||
if (contacts.length === 0) {
|
||||
return '暂无好友或曾经的好友'
|
||||
}
|
||||
return '没有找到联系人'
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sns-filter-panel">
|
||||
<div className="filter-header">
|
||||
<h3>筛选条件</h3>
|
||||
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
|
||||
{(searchKeyword || contactSearch) && (
|
||||
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
@@ -89,43 +91,13 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Widget */}
|
||||
<div className="filter-widget date-widget">
|
||||
<div className="widget-header">
|
||||
<Calendar size={14} />
|
||||
<span>时间跳转</span>
|
||||
</div>
|
||||
<button
|
||||
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
|
||||
onClick={onOpenJumpDialog}
|
||||
>
|
||||
<span className="date-text">
|
||||
{jumpTargetDate
|
||||
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: '选择日期...'}
|
||||
</span>
|
||||
{jumpTargetDate && (
|
||||
<div
|
||||
className="clear-date-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setJumpTargetDate(undefined)
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Contact Widget */}
|
||||
<div className="filter-widget contact-widget">
|
||||
<div className="widget-header">
|
||||
<User size={14} />
|
||||
<span>联系人</span>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="badge">{selectedUsernames.length}</span>
|
||||
{totalFriendsLabel && (
|
||||
<span className="widget-header-summary">{totalFriendsLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -142,19 +114,41 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contactsCountProgress && contactsCountProgress.total > 0 && (
|
||||
<div className="contact-count-progress">
|
||||
{contactsCountProgress.running
|
||||
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
|
||||
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="contact-list-scroll">
|
||||
{filteredContacts.map(contact => (
|
||||
{filteredContacts.map(contact => {
|
||||
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||
return (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
|
||||
onClick={() => toggleUserSelection(contact.username)}
|
||||
className="contact-row"
|
||||
onClick={() => onOpenContactTimeline(contact)}
|
||||
>
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
<div className="contact-meta">
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
</div>
|
||||
<div className="contact-post-count-wrap">
|
||||
{isPostCountReady ? (
|
||||
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
|
||||
) : (
|
||||
<span className="contact-post-count-loading" title="统计中">
|
||||
<Loader2 size={13} className="spinning" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{filteredContacts.length === 0 && (
|
||||
<div className="empty-state">没有找到联系人</div>
|
||||
<div className="empty-state">{getEmptyStateText()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -243,10 +243,12 @@ interface SnsPostItemProps {
|
||||
post: SnsPost
|
||||
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
|
||||
onDebug: (post: SnsPost) => void
|
||||
onDelete?: (postId: string) => void
|
||||
onDelete?: (postId: string, username: string) => void
|
||||
onOpenAuthorPosts?: (post: SnsPost) => void
|
||||
hideAuthorMeta?: boolean
|
||||
}
|
||||
|
||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => {
|
||||
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
|
||||
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||
const [dbDeleted, setDbDeleted] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
@@ -299,31 +301,56 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
|
||||
if (r.success) {
|
||||
setDbDeleted(true)
|
||||
onDelete?.(post.id)
|
||||
onDelete?.(post.id, post.username)
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onOpenAuthorPosts?.(post)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
|
||||
<div className="post-avatar-col">
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={48}
|
||||
shape="rounded"
|
||||
/>
|
||||
</div>
|
||||
{!hideAuthorMeta && (
|
||||
<div className="post-avatar-col">
|
||||
<button
|
||||
type="button"
|
||||
className="author-trigger-btn avatar-trigger"
|
||||
onClick={handleOpenAuthorPosts}
|
||||
title="查看该发布者的全部朋友圈"
|
||||
>
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={48}
|
||||
shape="rounded"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="post-content-col">
|
||||
<div className="post-header-row">
|
||||
<div className="post-author-info">
|
||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||
</div>
|
||||
{hideAuthorMeta ? (
|
||||
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
|
||||
) : (
|
||||
<div className="post-author-info">
|
||||
<button
|
||||
type="button"
|
||||
className="author-trigger-btn author-name-trigger"
|
||||
onClick={handleOpenAuthorPosts}
|
||||
title="查看该发布者的全部朋友圈"
|
||||
>
|
||||
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||
</button>
|
||||
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="post-header-actions">
|
||||
{(mediaDeleted || dbDeleted) && (
|
||||
<span className="post-deleted-badge">
|
||||
|
||||
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] || '?'
|
||||
}
|
||||
@@ -26,6 +26,48 @@
|
||||
margin: 0 0 48px;
|
||||
}
|
||||
|
||||
.page-desc.load-summary {
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.page-desc.load-summary.complete {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.load-telemetry {
|
||||
width: min(760px, 100%);
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 28px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.load-telemetry.loading {
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
}
|
||||
|
||||
.load-telemetry.complete {
|
||||
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
|
||||
}
|
||||
|
||||
.load-telemetry.compact {
|
||||
margin: 12px 0 0;
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.report-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -83,6 +125,14 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.year-grid-with-status {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -95,7 +145,39 @@
|
||||
.report-section .year-grid {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.year-grid-with-status .year-grid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.year-load-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.year-load-status.complete {
|
||||
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
|
||||
}
|
||||
|
||||
.dot-ellipsis {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
vertical-align: bottom;
|
||||
animation: dot-ellipsis 1.2s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
.year-load-status.complete .dot-ellipsis,
|
||||
.page-desc.load-summary.complete .dot-ellipsis {
|
||||
animation: none;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.year-card {
|
||||
@@ -185,3 +267,7 @@
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes dot-ellipsis {
|
||||
to { width: 1.4em; }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,28 @@ import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||
import './AnnualReportPage.scss'
|
||||
|
||||
type YearOption = number | 'all'
|
||||
type YearsLoadPayload = {
|
||||
years?: number[]
|
||||
done: boolean
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
strategy?: 'cache' | 'native' | 'hybrid'
|
||||
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||
statusText?: string
|
||||
nativeElapsedMs?: number
|
||||
scanElapsedMs?: number
|
||||
totalElapsedMs?: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
const formatLoadElapsed = (ms: number) => {
|
||||
const totalSeconds = Math.max(0, ms) / 1000
|
||||
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
|
||||
}
|
||||
|
||||
function AnnualReportPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -11,32 +33,117 @@ function AnnualReportPage() {
|
||||
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||||
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
|
||||
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
|
||||
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
|
||||
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
|
||||
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
|
||||
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
|
||||
const [scanElapsedMs, setScanElapsedMs] = useState(0)
|
||||
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
|
||||
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
|
||||
const [nativeTimedOut, setNativeTimedOut] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAvailableYears()
|
||||
}, [])
|
||||
let disposed = false
|
||||
let taskId = ''
|
||||
|
||||
const loadAvailableYears = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
setAvailableYears(result.data)
|
||||
setSelectedYear((prev) => prev ?? result.data[0])
|
||||
setSelectedPairYear((prev) => prev ?? result.data[0])
|
||||
} else if (!result.success) {
|
||||
setLoadError(result.error || '加载年度数据失败')
|
||||
const applyLoadPayload = (payload: YearsLoadPayload) => {
|
||||
if (payload.strategy) setLoadStrategy(payload.strategy)
|
||||
if (payload.phase) setLoadPhase(payload.phase)
|
||||
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
||||
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
|
||||
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
|
||||
}
|
||||
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
|
||||
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
|
||||
}
|
||||
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
|
||||
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
|
||||
}
|
||||
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
|
||||
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
|
||||
|
||||
const years = Array.isArray(payload.years) ? payload.years : []
|
||||
if (years.length > 0) {
|
||||
setAvailableYears(years)
|
||||
setSelectedYear((prev) => {
|
||||
if (prev === 'all') return prev
|
||||
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||||
return years[0]
|
||||
})
|
||||
setSelectedPairYear((prev) => {
|
||||
if (prev === 'all') return prev
|
||||
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||||
return years[0]
|
||||
})
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
if (payload.error && !payload.canceled) {
|
||||
setLoadError(payload.error || '加载年度数据失败')
|
||||
}
|
||||
|
||||
if (payload.done) {
|
||||
setIsLoading(false)
|
||||
setIsLoadingMoreYears(false)
|
||||
setHasYearsLoadFinished(true)
|
||||
setLoadPhase('done')
|
||||
} else {
|
||||
setIsLoadingMoreYears(true)
|
||||
setHasYearsLoadFinished(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setLoadError(String(e))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
|
||||
if (disposed) return
|
||||
if (taskId && payload.taskId !== taskId) return
|
||||
if (!taskId) taskId = payload.taskId
|
||||
applyLoadPayload(payload)
|
||||
})
|
||||
|
||||
const startLoad = async () => {
|
||||
setIsLoading(true)
|
||||
setIsLoadingMoreYears(true)
|
||||
setHasYearsLoadFinished(false)
|
||||
setLoadStrategy('native')
|
||||
setLoadPhase('native')
|
||||
setLoadStatusText('准备使用原生快速模式加载年份...')
|
||||
setNativeElapsedMs(0)
|
||||
setScanElapsedMs(0)
|
||||
setTotalElapsedMs(0)
|
||||
setHasSwitchedStrategy(false)
|
||||
setNativeTimedOut(false)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
||||
if (!startResult.success || !startResult.taskId) {
|
||||
setLoadError(startResult.error || '加载年度数据失败')
|
||||
setIsLoading(false)
|
||||
setIsLoadingMoreYears(false)
|
||||
return
|
||||
}
|
||||
taskId = startResult.taskId
|
||||
if (startResult.snapshot) {
|
||||
applyLoadPayload(startResult.snapshot)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setLoadError(String(e))
|
||||
setIsLoading(false)
|
||||
setIsLoadingMoreYears(false)
|
||||
}
|
||||
}
|
||||
|
||||
void startLoad()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
stopListen()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (selectedYear === null) return
|
||||
@@ -57,16 +164,25 @@ function AnnualReportPage() {
|
||||
navigate(`/dual-report?year=${yearParam}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && availableYears.length === 0) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据...</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据(首批)...</p>
|
||||
<div className="load-telemetry compact">
|
||||
<p><span className="label">加载方式:</span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
|
||||
<p><span className="label">状态:</span>{loadStatusText || '正在加载年份数据...'}</p>
|
||||
<p>
|
||||
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
||||
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
||||
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (availableYears.length === 0) {
|
||||
if (availableYears.length === 0 && !isLoadingMoreYears) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
|
||||
@@ -87,11 +203,50 @@ function AnnualReportPage() {
|
||||
return value === 'all' ? '全部时间' : `${value} 年`
|
||||
}
|
||||
|
||||
const loadedYearCount = availableYears.length
|
||||
const isYearStatusComplete = hasYearsLoadFinished
|
||||
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
|
||||
const renderYearLoadStatus = () => (
|
||||
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||
{isYearStatusComplete ? (
|
||||
<>全部年份已加载完毕</>
|
||||
) : (
|
||||
<>
|
||||
更多年份加载中<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||
{loadedYearCount > 0 && (
|
||||
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||
{isYearStatusComplete ? (
|
||||
<>已显示 {loadedYearCount} 个年份,年份数据已全部加载完毕。总耗时 {formatLoadElapsed(totalElapsedMs)}</>
|
||||
) : (
|
||||
<>
|
||||
已显示 {loadedYearCount} 个年份,正在补充更多年份<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||
(已耗时 {formatLoadElapsed(totalElapsedMs)})
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||
<p><span className="label">加载方式:</span>{strategyLabel}</p>
|
||||
<p>
|
||||
<span className="label">状态:</span>
|
||||
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
|
||||
</p>
|
||||
<p>
|
||||
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
||||
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
||||
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="report-sections">
|
||||
<section className="report-section">
|
||||
@@ -102,17 +257,20 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="year-grid">
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="year-grid-with-status">
|
||||
<div className="year-grid">
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderYearLoadStatus()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -146,17 +304,20 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="year-grid">
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={`pair-${option}`}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPairYear(option)}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="year-grid-with-status">
|
||||
<div className="year-grid">
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={`pair-${option}`}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPairYear(option)}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderYearLoadStatus()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -174,4 +335,23 @@ function AnnualReportPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function getStrategyLabel(params: {
|
||||
loadStrategy: 'cache' | 'native' | 'hybrid'
|
||||
loadPhase: 'cache' | 'native' | 'scan' | 'done'
|
||||
hasYearsLoadFinished: boolean
|
||||
hasSwitchedStrategy: boolean
|
||||
nativeTimedOut: boolean
|
||||
}): string {
|
||||
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
|
||||
if (loadStrategy === 'cache') return '缓存模式(快速)'
|
||||
if (hasYearsLoadFinished) {
|
||||
if (loadStrategy === 'native') return '原生快速模式'
|
||||
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
|
||||
return '扫表兼容模式'
|
||||
}
|
||||
if (loadPhase === 'native') return '原生快速模式(优先)'
|
||||
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
|
||||
return '混合策略'
|
||||
}
|
||||
|
||||
export default AnnualReportPage
|
||||
|
||||
@@ -490,6 +490,18 @@
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.jump-calendar-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
z-index: 20;
|
||||
|
||||
.jump-date-popover {
|
||||
z-index: 2600;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -534,6 +546,22 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.export-prepare-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 24px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
background: var(--chat-pattern);
|
||||
@@ -815,6 +843,24 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-sync-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -1592,6 +1638,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.jump-calendar-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@@ -1624,6 +1677,10 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1651,6 +1708,33 @@
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.switching .message-list {
|
||||
opacity: 0.42;
|
||||
transform: scale(0.995);
|
||||
filter: saturate(0.72) blur(1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.switching .loading-overlay {
|
||||
background: rgba(127, 127, 127, 0.18);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.export-prepare-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list {
|
||||
@@ -1666,7 +1750,7 @@
|
||||
background-color: var(--bg-tertiary);
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag !important;
|
||||
transition: opacity 240ms ease, transform 240ms ease;
|
||||
transition: opacity 240ms ease, transform 240ms ease, filter 220ms ease;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
@@ -1699,6 +1783,30 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.standalone-phase-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-chat-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2662,6 +2770,13 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-stats-meta {
|
||||
margin-top: -6px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
@@ -2699,6 +2814,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.detail-inline-btn {
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--primary);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2736,6 +2871,14 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-table-placeholder {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2757,6 +2900,188 @@
|
||||
}
|
||||
}
|
||||
|
||||
.group-members-panel {
|
||||
.group-members-toolbar {
|
||||
padding: 12px 16px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-members-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.group-members-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-members-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: #b45309;
|
||||
background: color-mix(in srgb, #f59e0b 10%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.group-members-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.group-member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.group-member-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.group-member-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-member-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.group-member-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-member-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-member-id {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-member-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-flag {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
&.owner {
|
||||
color: #f59e0b;
|
||||
background: color-mix(in srgb, #f59e0b 16%, transparent);
|
||||
border-color: color-mix(in srgb, #f59e0b 35%, var(--border-color));
|
||||
}
|
||||
|
||||
&.friend {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||
}
|
||||
}
|
||||
|
||||
.group-member-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.loading {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
color: #b45309;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -4132,4 +4457,169 @@
|
||||
.session-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 消息信息弹窗
|
||||
.message-info-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-info-modal {
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--text-tertiary); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
svg { opacity: 0.7; }
|
||||
|
||||
.copy-btn {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
&:hover { background: var(--bg-secondary); color: var(--text-primary); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
svg { color: var(--text-tertiary); flex-shrink: 0; }
|
||||
|
||||
.label { color: var(--text-secondary); flex-shrink: 0; }
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
|
||||
&.highlight { color: var(--primary); font-weight: 600; }
|
||||
&.mono { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; }
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
|
||||
&:hover { background: var(--bg-secondary); color: var(--text-primary); }
|
||||
svg { color: inherit; }
|
||||
}
|
||||
|
||||
&:hover .copy-btn { opacity: 1; }
|
||||
}
|
||||
|
||||
.raw-content-box {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,6 +148,17 @@
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -177,6 +188,22 @@
|
||||
padding: 0 20px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.contacts-cache-meta {
|
||||
margin-left: 10px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
|
||||
&.syncing {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-enrich-progress {
|
||||
margin-left: 10px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-toolbar {
|
||||
@@ -213,10 +240,103 @@
|
||||
}
|
||||
}
|
||||
|
||||
.load-issue-state {
|
||||
flex: 1;
|
||||
padding: 14px 14px 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
.issue-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.issue-message {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.issue-reason {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.issue-hints {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.issue-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.issue-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.issue-diagnostics {
|
||||
margin-top: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px dashed var(--border-color);
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 12px;
|
||||
position: relative;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -229,15 +349,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list-virtual {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.contact-row {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 76px;
|
||||
padding-bottom: 4px;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
height: 72px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -399,6 +535,28 @@
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.detail-entry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goto-chat-btn {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,16 @@ function DualReportWindow() {
|
||||
setLoadingStage('完成')
|
||||
|
||||
if (result.success && result.data) {
|
||||
setReportData(result.data)
|
||||
const normalizedResponse = result.data.response
|
||||
? {
|
||||
...result.data.response,
|
||||
slowest: result.data.response.slowest ?? result.data.response.avg
|
||||
}
|
||||
: undefined
|
||||
setReportData({
|
||||
...result.data,
|
||||
response: normalizedResponse
|
||||
})
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setError(result.error || '生成报告失败')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ interface GroupMessageRank {
|
||||
}
|
||||
|
||||
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||
|
||||
interface MemberMessageExportOptions {
|
||||
format: MemberExportFormat
|
||||
@@ -119,6 +119,7 @@ function GroupAnalyticsPage() {
|
||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||
|
||||
@@ -348,6 +348,51 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-time-range-field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-time-range-trigger {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--primary-rgb), 0.45);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-time-range-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.settings-time-range-arrow {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
@@ -2239,4 +2284,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
@@ -8,20 +9,19 @@ import * as configService from '../services/config'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
||||
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
|
||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
||||
} from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'notification', label: '通知', icon: Bell },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||
|
||||
@@ -37,6 +37,7 @@ interface WxidOption {
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const location = useLocation()
|
||||
const {
|
||||
isDbConnected,
|
||||
setDbConnected,
|
||||
@@ -73,14 +74,6 @@ function SettingsPage() {
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
||||
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
|
||||
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [imageKeyProgress, setImageKeyProgress] = useState(0)
|
||||
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||
@@ -103,12 +96,6 @@ function SettingsPage() {
|
||||
|
||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(true)
|
||||
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
|
||||
@@ -121,6 +108,9 @@ function SettingsPage() {
|
||||
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
||||
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
||||
|
||||
// 数据收集同意状态
|
||||
const [analyticsConsent, setAnalyticsConsent] = useState<boolean>(false)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -199,26 +189,11 @@ function SettingsPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
||||
setShowExportFormatSelect(false)
|
||||
}
|
||||
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
|
||||
setShowExportDateRangeSelect(false)
|
||||
}
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
|
||||
setShowExportConcurrencySelect(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
|
||||
const initialTab = (location.state as { initialTab?: SettingsTab } | null)?.initialTab
|
||||
if (!initialTab) return
|
||||
setActiveTab(initialTab)
|
||||
}, [location.state])
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||
@@ -286,13 +261,6 @@ function SettingsPage() {
|
||||
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
||||
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
|
||||
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
|
||||
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
|
||||
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
||||
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
|
||||
|
||||
const savedNotificationEnabled = await configService.getNotificationEnabled()
|
||||
const savedNotificationPosition = await configService.getNotificationPosition()
|
||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||
@@ -327,12 +295,6 @@ function SettingsPage() {
|
||||
setLogEnabled(savedLogEnabled)
|
||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||
setTranscribeLanguages(savedTranscribeLanguages)
|
||||
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
||||
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
||||
|
||||
setNotificationEnabled(savedNotificationEnabled)
|
||||
setNotificationPosition(savedNotificationPosition)
|
||||
@@ -343,6 +305,9 @@ function SettingsPage() {
|
||||
setWordCloudExcludeWords(savedExcludeWords)
|
||||
setExcludeWordsInput(savedExcludeWords.join('\n'))
|
||||
|
||||
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
|
||||
setAnalyticsConsent(savedAnalyticsConsent ?? false)
|
||||
|
||||
// 如果语言列表为空,保存默认值
|
||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||
const defaultLanguages = ['zh']
|
||||
@@ -768,42 +733,25 @@ function SettingsPage() {
|
||||
|
||||
const handleAutoGetImageKey = async () => {
|
||||
if (isFetchingImageKey) return;
|
||||
if (!dbPath) {
|
||||
showMessage('请先选择数据库目录', false);
|
||||
return;
|
||||
}
|
||||
if (!dbPath) { showMessage('请先选择数据库目录', false); return; }
|
||||
setIsFetchingImageKey(true);
|
||||
setImageKeyPercent(0)
|
||||
setImageKeyStatus('正在初始化...');
|
||||
setImageKeyProgress(0); // 重置进度
|
||||
setImageKeyProgress(0);
|
||||
|
||||
try {
|
||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
|
||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
|
||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') {
|
||||
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('已获取图片密钥')
|
||||
showMessage('已自动获取图片密钥', true)
|
||||
|
||||
// Auto-save after fetching keys
|
||||
// We need to use the values directly because state updates are async
|
||||
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
||||
const newAesKey = result.aesKey
|
||||
|
||||
await configService.setImageXorKey(newXorKey)
|
||||
await configService.setImageAesKey(newAesKey)
|
||||
|
||||
if (wxid) {
|
||||
await configService.setWxidConfig(wxid, {
|
||||
decryptKey: decryptKey, // use current state as it hasn't changed here
|
||||
imageXorKey: newXorKey,
|
||||
imageAesKey: newAesKey
|
||||
})
|
||||
}
|
||||
|
||||
if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey })
|
||||
} else {
|
||||
showMessage(result.error || '自动获取图片密钥失败', false)
|
||||
}
|
||||
@@ -814,6 +762,36 @@ function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanImageKeyFromMemory = async () => {
|
||||
if (isFetchingImageKey) return;
|
||||
if (!dbPath) { showMessage('请先选择数据库目录', false); return; }
|
||||
setIsFetchingImageKey(true);
|
||||
setImageKeyPercent(0)
|
||||
setImageKeyStatus('正在扫描内存...');
|
||||
|
||||
try {
|
||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
|
||||
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('内存扫描成功,已获取图片密钥')
|
||||
showMessage('内存扫描成功,已获取图片密钥', true)
|
||||
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
||||
const newAesKey = result.aesKey
|
||||
await configService.setImageXorKey(newXorKey)
|
||||
await configService.setImageAesKey(newAesKey)
|
||||
if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey })
|
||||
} else {
|
||||
showMessage(result.error || '内存扫描获取图片密钥失败', false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage(`内存扫描失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsFetchingImageKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
@@ -966,8 +944,12 @@ function SettingsPage() {
|
||||
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
||||
<div className="theme-preview" style={{
|
||||
background: effectiveMode === 'dark'
|
||||
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
|
||||
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)` : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
|
||||
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)'
|
||||
: theme.id === 'geist' ? 'linear-gradient(135deg, #1a1a1a 0%, #222222 100%)'
|
||||
: 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
|
||||
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)`
|
||||
: theme.id === 'geist' ? 'linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%)'
|
||||
: `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
|
||||
}}>
|
||||
<div className="theme-accent" style={{
|
||||
background: theme.accentColor
|
||||
@@ -1373,24 +1355,27 @@ function SettingsPage() {
|
||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
<div className="form-hint" style={{ color: '#f59e0b', margin: '6px 0' }}>
|
||||
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用「内存扫描」方案。
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高">
|
||||
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
||||
</button>
|
||||
</div>
|
||||
{isFetchingImageKey ? (
|
||||
<div className="brute-force-progress">
|
||||
<div className="status-header">
|
||||
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
|
||||
</div>
|
||||
{imageKeyPercent !== null && (
|
||||
<div className="progress-bar-container">
|
||||
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
||||
)}
|
||||
<span className="form-hint">内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</span>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -1521,258 +1506,6 @@ function SettingsPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
const exportFormatOptions = [
|
||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
|
||||
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
||||
]
|
||||
const exportDateRangeOptions = [
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: '7d', label: '最近7天' },
|
||||
{ value: '30d', label: '最近30天' },
|
||||
{ value: '90d', label: '最近90天' },
|
||||
{ value: 'all', label: '全部时间' }
|
||||
]
|
||||
const exportExcelColumnOptions = [
|
||||
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||
]
|
||||
|
||||
const exportConcurrencyOptions = [
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 3, label: '3' },
|
||||
{ value: 4, label: '4' },
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 6, label: '6' }
|
||||
]
|
||||
|
||||
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
const renderExportTab = () => {
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
||||
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
||||
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
||||
const exportConcurrencyLabel = String(exportDefaultConcurrency)
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>默认导出格式</label>
|
||||
<span className="form-hint">导出页面默认选中的格式</span>
|
||||
<div className="select-field" ref={exportFormatDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportFormatSelect(!showExportFormatSelect)
|
||||
setShowExportDateRangeSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setShowExportConcurrencySelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportFormatLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFormatOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFormat(option.value)
|
||||
await configService.setExportDefaultFormat(option.value)
|
||||
showMessage('已更新导出格式默认值', true)
|
||||
setShowExportFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认导出时间范围</label>
|
||||
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||
<div className="select-field" ref={exportDateRangeDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setShowExportConcurrencySelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportDateRangeLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportDateRangeSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportDateRangeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultDateRange(option.value)
|
||||
await configService.setExportDefaultDateRange(option.value)
|
||||
showMessage('已更新默认导出时间范围', true)
|
||||
setShowExportDateRangeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认导出媒体文件</label>
|
||||
<span className="form-hint">控制图片/语音/表情的默认导出开关</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="export-default-media">
|
||||
<input
|
||||
id="export-default-media"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setExportDefaultMedia(enabled)
|
||||
await configService.setExportDefaultMedia(enabled)
|
||||
showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认语音转文字</label>
|
||||
<span className="form-hint">导出时默认将语音转写为文字</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="export-default-voice-as-text">
|
||||
<input
|
||||
id="export-default-voice-as-text"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={exportDefaultVoiceAsText}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setExportDefaultVoiceAsText(enabled)
|
||||
await configService.setExportDefaultVoiceAsText(enabled)
|
||||
showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Excel 列显示</label>
|
||||
<span className="form-hint">控制 Excel 导出的列字段</span>
|
||||
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportDateRangeSelect(false)
|
||||
setShowExportConcurrencySelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportExcelColumnsSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportExcelColumnOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
const compact = option.value === 'compact'
|
||||
setExportDefaultExcelCompactColumns(compact)
|
||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>导出并发数</label>
|
||||
<span className="form-hint">导出多个会话时的最大并发(1~6)</span>
|
||||
<div className="select-field" ref={exportConcurrencyDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportConcurrencySelect(!showExportConcurrencySelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportDateRangeSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportConcurrencyLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportConcurrencySelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportConcurrencyOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultConcurrency === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultConcurrency(option.value)
|
||||
await configService.setExportDefaultConcurrency(option.value)
|
||||
showMessage(`已将导出并发数设为 ${option.value}`, true)
|
||||
setShowExportConcurrencySelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderCacheTab = () => (
|
||||
<div className="tab-content">
|
||||
<p className="section-desc">管理应用缓存数据</p>
|
||||
@@ -2293,6 +2026,24 @@ function SettingsPage() {
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}>用户协议</a>
|
||||
</div>
|
||||
<p className="copyright">© 2025 WeFlow. All rights reserved.</p>
|
||||
|
||||
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
|
||||
<span style={{ fontSize: '13px', opacity: 0.7 }}>匿名数据收集</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="switch-input"
|
||||
checked={analyticsConsent}
|
||||
onChange={async (e) => {
|
||||
const consent = e.target.checked
|
||||
setAnalyticsConsent(consent)
|
||||
await configService.setAnalyticsConsent(consent)
|
||||
showMessage(consent ? '已允许数据收集' : '已拒绝数据收集', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -2351,7 +2102,6 @@ function SettingsPage() {
|
||||
{activeTab === 'notification' && renderNotificationTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'api' && renderApiTab()}
|
||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
|
||||
.sns-page-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
height: calc(100% + 48px);
|
||||
margin: -24px;
|
||||
overflow: hidden;
|
||||
background: var(--sns-bg-color);
|
||||
position: relative;
|
||||
@@ -23,7 +24,7 @@
|
||||
========================================= */
|
||||
.sns-main-viewport {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -32,24 +33,138 @@
|
||||
.sns-feed-container {
|
||||
width: 100%;
|
||||
max-width: var(--sns-max-width);
|
||||
padding: 20px 24px 60px 24px;
|
||||
padding: 10px 24px 12px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 4px;
|
||||
z-index: 2;
|
||||
background: var(--sns-bg-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-top: 4px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
.feed-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.feed-stats-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
|
||||
&.loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: #d94f45;
|
||||
}
|
||||
}
|
||||
|
||||
.feed-stats-range {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.feed-overview-total {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feed-stats-divider {
|
||||
color: color-mix(in srgb, var(--text-secondary) 78%, transparent);
|
||||
}
|
||||
|
||||
.feed-my-timeline-entry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
cursor: default;
|
||||
transition: color 0.2s ease, opacity 0.2s ease;
|
||||
|
||||
.feed-my-timeline-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feed-my-timeline-count {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.ready {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:hover .feed-my-timeline-count {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 3px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.68;
|
||||
}
|
||||
}
|
||||
|
||||
.feed-stats-retry {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -58,6 +173,18 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.jump-calendar-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
z-index: 20;
|
||||
|
||||
.jump-date-popover {
|
||||
z-index: 2600;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -83,6 +210,57 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.posts-list {
|
||||
@@ -132,6 +310,30 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.author-trigger-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-trigger {
|
||||
border-radius: 12px;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-content-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -159,6 +361,30 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.author-name-trigger {
|
||||
align-self: flex-start;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.author-name {
|
||||
transition: color 0.15s ease, text-decoration-color 0.15s ease;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
text-underline-offset: 2px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover .author-name {
|
||||
color: var(--primary);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
@@ -172,6 +398,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.post-time-standalone {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.2;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.debug-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
@@ -862,9 +1095,21 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.widget-header-summary {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-widget .widget-header .badge + .widget-header-summary {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Search Widget */
|
||||
.input-group {
|
||||
position: relative;
|
||||
@@ -903,44 +1148,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Date Widget */
|
||||
.date-picker-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--sns-border-radius-sm);
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clear-date-btn {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
color: var(--primary);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Contact Widget - Refactored */
|
||||
.contact-widget {
|
||||
display: flex;
|
||||
@@ -996,6 +1203,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.contact-count-progress {
|
||||
padding: 8px 16px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.contact-list-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -1013,9 +1228,8 @@
|
||||
border-radius: var(--sns-border-radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
border: 1px solid transparent;
|
||||
margin-bottom: 4px;
|
||||
/* Separation for unselected items */
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
@@ -1023,46 +1237,47 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.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) */
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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-post-count-wrap {
|
||||
margin-left: 8px;
|
||||
min-width: 46px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
.contact-post-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contact-post-count-loading {
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1261,6 +1476,116 @@
|
||||
}
|
||||
}
|
||||
|
||||
.author-timeline-dialog {
|
||||
background: var(--sns-card-bg);
|
||||
border-radius: var(--sns-border-radius-lg);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
width: min(860px, 94vw);
|
||||
max-height: 86vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.author-timeline-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.author-timeline-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.author-timeline-meta-text {
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.author-timeline-username {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.author-timeline-stats {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.author-timeline-body {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
min-height: 180px;
|
||||
max-height: calc(86vh - 96px);
|
||||
}
|
||||
|
||||
.author-timeline-posts-list {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.author-timeline-loading {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.author-timeline-empty {
|
||||
padding: 42px 10px 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.author-timeline-load-more {
|
||||
display: block;
|
||||
margin: 12px auto 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -1380,6 +1705,44 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.time-range-trigger.sns-export-time-range-trigger {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border-radius: 999px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(var(--primary-rgb), 0.45);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.time-range-arrow {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.export-format-options {
|
||||
display: grid;
|
||||
@@ -1909,10 +2272,31 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.export-media-check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.export-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2091,4 +2475,4 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,18 @@ interface WelcomePageProps {
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
|
||||
const base = String(error || '自动获取密钥失败').trim()
|
||||
const tailLogs = Array.isArray(logs)
|
||||
? logs
|
||||
.map(item => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(-6)
|
||||
: []
|
||||
if (tailLogs.length === 0) return base
|
||||
return `${base};最近状态:${tailLogs.join(' | ')}`
|
||||
}
|
||||
|
||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const navigate = useNavigate()
|
||||
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
|
||||
@@ -292,7 +304,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setIsManualStartPrompt(true)
|
||||
setDbKeyStatus('需要手动启动微信')
|
||||
} else {
|
||||
setError(result.error || '自动获取密钥失败')
|
||||
if (result.error?.includes('尚未完成登录')) {
|
||||
setDbKeyStatus('请先在微信完成登录后重试')
|
||||
}
|
||||
setError(formatDbKeyFailureMessage(result.error, result.logs))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -309,22 +324,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
const handleAutoGetImageKey = async () => {
|
||||
if (isFetchingImageKey) return
|
||||
if (!dbPath) {
|
||||
setError('请先选择数据库目录')
|
||||
return
|
||||
}
|
||||
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||
setIsFetchingImageKey(true)
|
||||
setError('')
|
||||
setImageKeyPercent(0)
|
||||
setImageKeyStatus('正在准备获取图片密钥...')
|
||||
try {
|
||||
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
|
||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
|
||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') {
|
||||
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('已获取图片密钥')
|
||||
} else {
|
||||
@@ -337,6 +346,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanImageKeyFromMemory = async () => {
|
||||
if (isFetchingImageKey) return
|
||||
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||
setIsFetchingImageKey(true)
|
||||
setError('')
|
||||
setImageKeyPercent(0)
|
||||
setImageKeyStatus('正在扫描内存...')
|
||||
try {
|
||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('内存扫描成功,已获取图片密钥')
|
||||
} else {
|
||||
setError(result.error || '内存扫描获取图片密钥失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`内存扫描失败: ${e}`)
|
||||
} finally {
|
||||
setIsFetchingImageKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canGoNext = () => {
|
||||
if (currentStep.id === 'intro') return true
|
||||
if (currentStep.id === 'db') return Boolean(dbPath)
|
||||
@@ -747,50 +780,40 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{currentStep.id === 'image' && (
|
||||
<div className="form-group">
|
||||
<div className="field-hint" style={{ color: '#f59e0b', marginBottom: '12px' }}>
|
||||
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用下方「内存扫描」方案。
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="field-label">图片 XOR 密钥</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="0x..."
|
||||
value={imageXorKey}
|
||||
onChange={(e) => setImageXorKey(e.target.value)}
|
||||
/>
|
||||
<input type="text" className="field-input" placeholder="0x..." value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">图片 AES 密钥</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="16位密钥"
|
||||
value={imageAesKey}
|
||||
onChange={(e) => setImageAesKey(e.target.value)}
|
||||
/>
|
||||
<input type="text" className="field-input" placeholder="16位密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button className="btn btn-secondary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
||||
{isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高,需要微信正在运行">
|
||||
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isFetchingImageKey ? (
|
||||
<div className="brute-force-progress">
|
||||
<div className="status-header">
|
||||
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
|
||||
</div>
|
||||
{imageKeyPercent !== null && (
|
||||
<div className="progress-bar-container">
|
||||
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
||||
)}
|
||||
|
||||
<div className="field-hint">请在微信中打开几张图片后再点击获取</div>
|
||||
<div className="field-hint" style={{ marginTop: '8px' }}>内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
9
src/services/cloudControl.ts
Normal file
9
src/services/cloudControl.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// 数据收集服务前端接口
|
||||
|
||||
export async function initCloudControl() {
|
||||
return window.electronAPI.cloud.init()
|
||||
}
|
||||
|
||||
export function recordPage(pageName: string) {
|
||||
window.electronAPI.cloud.recordPage(pageName)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 配置服务 - 封装 Electron Store
|
||||
import { config } from './ipc'
|
||||
import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange'
|
||||
|
||||
// 配置键名
|
||||
export const CONFIG_KEYS = {
|
||||
@@ -26,12 +27,27 @@ export const CONFIG_KEYS = {
|
||||
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
||||
TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
|
||||
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
||||
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
|
||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
||||
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
||||
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
||||
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
|
||||
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
||||
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
|
||||
EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap',
|
||||
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
|
||||
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
||||
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
|
||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||
CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap',
|
||||
|
||||
// 安全
|
||||
AUTH_ENABLED: 'authEnabled',
|
||||
@@ -48,7 +64,11 @@ export const CONFIG_KEYS = {
|
||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||
|
||||
// 词云
|
||||
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords'
|
||||
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
|
||||
|
||||
// 数据收集
|
||||
ANALYTICS_CONSENT: 'analyticsConsent',
|
||||
ANALYTICS_DENY_COUNT: 'analyticsDenyCount'
|
||||
} as const
|
||||
|
||||
export interface WxidConfig {
|
||||
@@ -58,6 +78,20 @@ export interface WxidConfig {
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
export interface ExportDefaultMediaConfig {
|
||||
images: boolean
|
||||
videos: boolean
|
||||
voices: boolean
|
||||
emojis: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
}
|
||||
|
||||
// 获取解密密钥
|
||||
export async function getDecryptKey(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
|
||||
@@ -316,27 +350,64 @@ export async function setExportDefaultFormat(format: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
|
||||
}
|
||||
|
||||
// 获取导出默认时间范围
|
||||
export async function getExportDefaultDateRange(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置导出默认时间范围
|
||||
export async function setExportDefaultDateRange(range: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||
}
|
||||
|
||||
// 获取导出默认媒体设置
|
||||
export async function getExportDefaultMedia(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
||||
// 获取导出默认头像设置
|
||||
export async function getExportDefaultAvatars(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认头像设置
|
||||
export async function setExportDefaultAvatars(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS, enabled)
|
||||
}
|
||||
|
||||
// 获取导出默认时间范围
|
||||
export async function getExportDefaultDateRange(): Promise<ExportDefaultDateRangeConfig | string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
||||
if (typeof value === 'string') return value
|
||||
if (value && typeof value === 'object') {
|
||||
return value as ExportDefaultDateRangeConfig
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认时间范围
|
||||
export async function setExportDefaultDateRange(range: ExportDefaultDateRangeConfig | string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||
}
|
||||
|
||||
// 获取导出默认媒体设置
|
||||
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
||||
if (typeof value === 'boolean') {
|
||||
return {
|
||||
images: value,
|
||||
videos: value,
|
||||
voices: value,
|
||||
emojis: value
|
||||
}
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const raw = value as Partial<Record<keyof ExportDefaultMediaConfig, unknown>>
|
||||
return {
|
||||
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
|
||||
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
|
||||
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
|
||||
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认媒体设置
|
||||
export async function setExportDefaultMedia(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled)
|
||||
export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, {
|
||||
images: media.images,
|
||||
videos: media.videos,
|
||||
voices: media.voices,
|
||||
emojis: media.emojis
|
||||
})
|
||||
}
|
||||
|
||||
// 获取导出默认语音转文字
|
||||
@@ -386,6 +457,651 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
|
||||
}
|
||||
|
||||
export type ExportWriteLayout = 'A' | 'B' | 'C'
|
||||
|
||||
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_WRITE_LAYOUT)
|
||||
if (value === 'A' || value === 'B' || value === 'C') return value
|
||||
return 'B'
|
||||
}
|
||||
|
||||
export async function setExportWriteLayout(layout: ExportWriteLayout): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_WRITE_LAYOUT, layout)
|
||||
}
|
||||
|
||||
export async function getExportSessionNamePrefixEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED)
|
||||
if (typeof value === 'boolean') return value
|
||||
return true
|
||||
}
|
||||
|
||||
export async function setExportSessionNamePrefixEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getExportLastSessionRunMap(): Promise<Record<string, number>> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP)
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
const map: Record<string, number> = {}
|
||||
for (const [sessionId, raw] of entries) {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
map[sessionId] = raw
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export async function setExportLastSessionRunMap(map: Record<string, number>): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP, map)
|
||||
}
|
||||
|
||||
export async function getExportLastContentRunMap(): Promise<Record<string, number>> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP)
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
const map: Record<string, number> = {}
|
||||
for (const [key, raw] of entries) {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
map[key] = raw
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export async function setExportLastContentRunMap(map: Record<string, number>): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map)
|
||||
}
|
||||
|
||||
export interface ExportSessionRecordEntry {
|
||||
exportTime: number
|
||||
content: string
|
||||
outputDir: string
|
||||
}
|
||||
|
||||
export async function getExportSessionRecordMap(): Promise<Record<string, ExportSessionRecordEntry[]>> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP)
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
const map: Record<string, ExportSessionRecordEntry[]> = {}
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
for (const [sessionId, rawList] of entries) {
|
||||
if (!Array.isArray(rawList)) continue
|
||||
const normalizedList: ExportSessionRecordEntry[] = []
|
||||
for (const rawItem of rawList) {
|
||||
if (!rawItem || typeof rawItem !== 'object') continue
|
||||
const exportTime = Number((rawItem as Record<string, unknown>).exportTime)
|
||||
const content = String((rawItem as Record<string, unknown>).content || '').trim()
|
||||
const outputDir = String((rawItem as Record<string, unknown>).outputDir || '').trim()
|
||||
if (!Number.isFinite(exportTime) || exportTime <= 0) continue
|
||||
if (!content || !outputDir) continue
|
||||
normalizedList.push({
|
||||
exportTime: Math.floor(exportTime),
|
||||
content,
|
||||
outputDir
|
||||
})
|
||||
}
|
||||
if (normalizedList.length > 0) {
|
||||
map[sessionId] = normalizedList
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export async function setExportSessionRecordMap(map: Record<string, ExportSessionRecordEntry[]>): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP, map)
|
||||
}
|
||||
|
||||
export async function getExportLastSnsPostCount(): Promise<number> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT)
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
return Math.floor(value)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function setExportLastSnsPostCount(count: number): Promise<void> {
|
||||
const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
|
||||
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
|
||||
}
|
||||
|
||||
export interface ExportSessionMessageCountCacheItem {
|
||||
updatedAt: number
|
||||
counts: Record<string, number>
|
||||
}
|
||||
|
||||
export interface ExportSessionContentMetricCacheEntry {
|
||||
totalMessages?: number
|
||||
voiceMessages?: number
|
||||
imageMessages?: number
|
||||
videoMessages?: number
|
||||
emojiMessages?: number
|
||||
}
|
||||
|
||||
export interface ExportSessionContentMetricCacheItem {
|
||||
updatedAt: number
|
||||
metrics: Record<string, ExportSessionContentMetricCacheEntry>
|
||||
}
|
||||
|
||||
export interface ExportSnsStatsCacheItem {
|
||||
updatedAt: number
|
||||
totalPosts: number
|
||||
totalFriends: number
|
||||
}
|
||||
|
||||
export interface ExportSnsUserPostCountsCacheItem {
|
||||
updatedAt: number
|
||||
counts: Record<string, number>
|
||||
}
|
||||
|
||||
export interface SnsPageOverviewCache {
|
||||
totalPosts: number
|
||||
totalFriends: number
|
||||
myPosts: number | null
|
||||
earliestTime: number | null
|
||||
latestTime: number | null
|
||||
}
|
||||
|
||||
export interface SnsPageCacheItem {
|
||||
updatedAt: number
|
||||
overviewStats: SnsPageOverviewCache
|
||||
posts: unknown[]
|
||||
}
|
||||
|
||||
export interface ContactsListCacheContact {
|
||||
username: string
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
export interface ContactsListCacheItem {
|
||||
updatedAt: number
|
||||
contacts: ContactsListCacheContact[]
|
||||
}
|
||||
|
||||
export interface ContactsAvatarCacheEntry {
|
||||
avatarUrl: string
|
||||
updatedAt: number
|
||||
checkedAt: number
|
||||
}
|
||||
|
||||
export interface ContactsAvatarCacheItem {
|
||||
updatedAt: number
|
||||
avatars: Record<string, ContactsAvatarCacheEntry>
|
||||
}
|
||||
|
||||
export async function getExportSessionMessageCountCache(scopeKey: string): Promise<ExportSessionMessageCountCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_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 rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||
const rawCounts = (rawItem as Record<string, unknown>).counts
|
||||
if (!rawCounts || typeof rawCounts !== 'object') return null
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
for (const [sessionId, countRaw] of Object.entries(rawCounts as Record<string, unknown>)) {
|
||||
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
|
||||
counts[sessionId] = Math.floor(countRaw)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||
counts
|
||||
}
|
||||
}
|
||||
|
||||
export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record<string, number>): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [sessionId, countRaw] of Object.entries(counts || {})) {
|
||||
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
|
||||
normalized[sessionId] = Math.floor(countRaw)
|
||||
}
|
||||
}
|
||||
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
counts: normalized
|
||||
}
|
||||
await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
export async function getExportSessionContentMetricCache(scopeKey: string): Promise<ExportSessionContentMetricCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_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 rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||
const rawMetrics = (rawItem as Record<string, unknown>).metrics
|
||||
if (!rawMetrics || typeof rawMetrics !== 'object') return null
|
||||
|
||||
const metrics: Record<string, ExportSessionContentMetricCacheEntry> = {}
|
||||
for (const [sessionId, rawMetric] of Object.entries(rawMetrics as Record<string, unknown>)) {
|
||||
if (!rawMetric || typeof rawMetric !== 'object') continue
|
||||
const source = rawMetric as Record<string, unknown>
|
||||
const metric: ExportSessionContentMetricCacheEntry = {}
|
||||
if (typeof source.totalMessages === 'number' && Number.isFinite(source.totalMessages) && source.totalMessages >= 0) {
|
||||
metric.totalMessages = Math.floor(source.totalMessages)
|
||||
}
|
||||
if (typeof source.voiceMessages === 'number' && Number.isFinite(source.voiceMessages) && source.voiceMessages >= 0) {
|
||||
metric.voiceMessages = Math.floor(source.voiceMessages)
|
||||
}
|
||||
if (typeof source.imageMessages === 'number' && Number.isFinite(source.imageMessages) && source.imageMessages >= 0) {
|
||||
metric.imageMessages = Math.floor(source.imageMessages)
|
||||
}
|
||||
if (typeof source.videoMessages === 'number' && Number.isFinite(source.videoMessages) && source.videoMessages >= 0) {
|
||||
metric.videoMessages = Math.floor(source.videoMessages)
|
||||
}
|
||||
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
|
||||
metric.emojiMessages = Math.floor(source.emojiMessages)
|
||||
}
|
||||
if (Object.keys(metric).length === 0) continue
|
||||
metrics[sessionId] = metric
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||
metrics
|
||||
}
|
||||
}
|
||||
|
||||
export async function setExportSessionContentMetricCache(
|
||||
scopeKey: string,
|
||||
metrics: Record<string, ExportSessionContentMetricCacheEntry>
|
||||
): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
|
||||
const normalized: Record<string, ExportSessionContentMetricCacheEntry> = {}
|
||||
for (const [sessionId, rawMetric] of Object.entries(metrics || {})) {
|
||||
if (!rawMetric || typeof rawMetric !== 'object') continue
|
||||
const metric: ExportSessionContentMetricCacheEntry = {}
|
||||
if (typeof rawMetric.totalMessages === 'number' && Number.isFinite(rawMetric.totalMessages) && rawMetric.totalMessages >= 0) {
|
||||
metric.totalMessages = Math.floor(rawMetric.totalMessages)
|
||||
}
|
||||
if (typeof rawMetric.voiceMessages === 'number' && Number.isFinite(rawMetric.voiceMessages) && rawMetric.voiceMessages >= 0) {
|
||||
metric.voiceMessages = Math.floor(rawMetric.voiceMessages)
|
||||
}
|
||||
if (typeof rawMetric.imageMessages === 'number' && Number.isFinite(rawMetric.imageMessages) && rawMetric.imageMessages >= 0) {
|
||||
metric.imageMessages = Math.floor(rawMetric.imageMessages)
|
||||
}
|
||||
if (typeof rawMetric.videoMessages === 'number' && Number.isFinite(rawMetric.videoMessages) && rawMetric.videoMessages >= 0) {
|
||||
metric.videoMessages = Math.floor(rawMetric.videoMessages)
|
||||
}
|
||||
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
|
||||
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
|
||||
}
|
||||
if (Object.keys(metric).length === 0) continue
|
||||
normalized[sessionId] = metric
|
||||
}
|
||||
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
metrics: normalized
|
||||
}
|
||||
await config.set(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
export async function getExportSnsStatsCache(scopeKey: string): Promise<ExportSnsStatsCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_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 totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0
|
||||
? Math.floor(raw.totalPosts)
|
||||
: 0
|
||||
const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0
|
||||
? Math.floor(raw.totalFriends)
|
||||
: 0
|
||||
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
|
||||
? raw.updatedAt
|
||||
: 0
|
||||
|
||||
return { updatedAt, totalPosts, totalFriends }
|
||||
}
|
||||
|
||||
export async function setExportSnsStatsCache(
|
||||
scopeKey: string,
|
||||
stats: { totalPosts: number; totalFriends: number }
|
||||
): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0,
|
||||
totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0
|
||||
}
|
||||
|
||||
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
export async function getExportSnsUserPostCountsCache(scopeKey: string): Promise<ExportSnsUserPostCountsCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const rawMap = value as Record<string, unknown>
|
||||
const rawItem = rawMap[scopeKey]
|
||||
if (!rawItem || typeof rawItem !== 'object') return null
|
||||
|
||||
const raw = rawItem as Record<string, unknown>
|
||||
const rawCounts = raw.counts
|
||||
if (!rawCounts || typeof rawCounts !== 'object') return null
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
for (const [rawUsername, rawCount] of Object.entries(rawCounts as Record<string, unknown>)) {
|
||||
const username = String(rawUsername || '').trim()
|
||||
if (!username) continue
|
||||
const valueNum = Number(rawCount)
|
||||
counts[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
|
||||
}
|
||||
|
||||
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
|
||||
? raw.updatedAt
|
||||
: 0
|
||||
return { updatedAt, counts }
|
||||
}
|
||||
|
||||
export async function setExportSnsUserPostCountsCache(
|
||||
scopeKey: string,
|
||||
counts: Record<string, number>
|
||||
): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
|
||||
const normalized: Record<string, number> = {}
|
||||
for (const [rawUsername, rawCount] of Object.entries(counts || {})) {
|
||||
const username = String(rawUsername || '').trim()
|
||||
if (!username) continue
|
||||
const valueNum = Number(rawCount)
|
||||
normalized[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
|
||||
}
|
||||
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
counts: normalized
|
||||
}
|
||||
|
||||
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||
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 rawOverview = raw.overviewStats
|
||||
const rawPosts = raw.posts
|
||||
if (!rawOverview || typeof rawOverview !== 'object' || !Array.isArray(rawPosts)) return null
|
||||
|
||||
const overviewObj = rawOverview as Record<string, unknown>
|
||||
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : 0)
|
||||
const normalizeNullableTimestamp = (v: unknown) => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
|
||||
return null
|
||||
}
|
||||
const normalizeNullableCount = (v: unknown) => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0,
|
||||
overviewStats: {
|
||||
totalPosts: Math.max(0, normalizeNumber(overviewObj.totalPosts)),
|
||||
totalFriends: Math.max(0, normalizeNumber(overviewObj.totalFriends)),
|
||||
myPosts: normalizeNullableCount(overviewObj.myPosts),
|
||||
earliestTime: normalizeNullableTimestamp(overviewObj.earliestTime),
|
||||
latestTime: normalizeNullableTimestamp(overviewObj.latestTime)
|
||||
},
|
||||
posts: rawPosts
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSnsPageCache(
|
||||
scopeKey: string,
|
||||
payload: { overviewStats: SnsPageOverviewCache; posts: unknown[] }
|
||||
): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
|
||||
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0)
|
||||
const normalizeNullableTimestamp = (v: unknown) => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
|
||||
return null
|
||||
}
|
||||
const normalizeNullableCount = (v: unknown) => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
|
||||
return null
|
||||
}
|
||||
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
overviewStats: {
|
||||
totalPosts: normalizeNumber(payload?.overviewStats?.totalPosts),
|
||||
totalFriends: normalizeNumber(payload?.overviewStats?.totalFriends),
|
||||
myPosts: normalizeNullableCount(payload?.overviewStats?.myPosts),
|
||||
earliestTime: normalizeNullableTimestamp(payload?.overviewStats?.earliestTime),
|
||||
latestTime: normalizeNullableTimestamp(payload?.overviewStats?.latestTime)
|
||||
},
|
||||
posts: Array.isArray(payload?.posts) ? payload.posts : []
|
||||
}
|
||||
|
||||
await config.set(CONFIG_KEYS.SNS_PAGE_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
// 获取通讯录加载超时阈值(毫秒)
|
||||
export async function getContactsLoadTimeoutMs(): Promise<number> {
|
||||
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
|
||||
return Math.floor(value)
|
||||
}
|
||||
return 3000
|
||||
}
|
||||
|
||||
// 设置通讯录加载超时阈值(毫秒)
|
||||
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
|
||||
const normalized = Number.isFinite(timeoutMs)
|
||||
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
|
||||
: 3000
|
||||
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
|
||||
}
|
||||
|
||||
export async function getContactsListCache(scopeKey: string): Promise<ContactsListCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.CONTACTS_LIST_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 rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||
const rawContacts = (rawItem as Record<string, unknown>).contacts
|
||||
if (!Array.isArray(rawContacts)) return null
|
||||
|
||||
const contacts: ContactsListCacheContact[] = []
|
||||
for (const raw of rawContacts) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const item = raw as Record<string, unknown>
|
||||
const username = typeof item.username === 'string' ? item.username.trim() : ''
|
||||
if (!username) continue
|
||||
const displayName = typeof item.displayName === 'string' ? item.displayName : username
|
||||
const type = typeof item.type === 'string' ? item.type : 'other'
|
||||
contacts.push({
|
||||
username,
|
||||
displayName,
|
||||
remark: typeof item.remark === 'string' ? item.remark : undefined,
|
||||
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
|
||||
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
|
||||
? type
|
||||
: 'other'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||
contacts
|
||||
}
|
||||
}
|
||||
|
||||
export async function setContactsListCache(scopeKey: string, contacts: ContactsListCacheContact[]): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
|
||||
const normalized: ContactsListCacheContact[] = []
|
||||
for (const contact of contacts || []) {
|
||||
const username = String(contact?.username || '').trim()
|
||||
if (!username) continue
|
||||
const displayName = String(contact?.displayName || username)
|
||||
const type = contact?.type || 'other'
|
||||
if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') {
|
||||
continue
|
||||
}
|
||||
normalized.push({
|
||||
username,
|
||||
displayName,
|
||||
remark: contact?.remark ? String(contact.remark) : undefined,
|
||||
nickname: contact?.nickname ? String(contact.nickname) : undefined,
|
||||
type
|
||||
})
|
||||
}
|
||||
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
contacts: normalized
|
||||
}
|
||||
await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
export async function getContactsAvatarCache(scopeKey: string): Promise<ContactsAvatarCacheItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_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 rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||
const rawAvatars = (rawItem as Record<string, unknown>).avatars
|
||||
if (!rawAvatars || typeof rawAvatars !== 'object') return null
|
||||
|
||||
const avatars: Record<string, ContactsAvatarCacheEntry> = {}
|
||||
for (const [rawUsername, rawEntry] of Object.entries(rawAvatars as Record<string, unknown>)) {
|
||||
const username = rawUsername.trim()
|
||||
if (!username) continue
|
||||
|
||||
if (typeof rawEntry === 'string') {
|
||||
const avatarUrl = rawEntry.trim()
|
||||
if (!avatarUrl) continue
|
||||
avatars[username] = {
|
||||
avatarUrl,
|
||||
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||
checkedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!rawEntry || typeof rawEntry !== 'object') continue
|
||||
const entry = rawEntry as Record<string, unknown>
|
||||
const avatarUrl = typeof entry.avatarUrl === 'string' ? entry.avatarUrl.trim() : ''
|
||||
if (!avatarUrl) continue
|
||||
const updatedAt = typeof entry.updatedAt === 'number' && Number.isFinite(entry.updatedAt)
|
||||
? entry.updatedAt
|
||||
: 0
|
||||
const checkedAt = typeof entry.checkedAt === 'number' && Number.isFinite(entry.checkedAt)
|
||||
? entry.checkedAt
|
||||
: updatedAt
|
||||
|
||||
avatars[username] = {
|
||||
avatarUrl,
|
||||
updatedAt,
|
||||
checkedAt
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||
avatars
|
||||
}
|
||||
}
|
||||
|
||||
export async function setContactsAvatarCache(
|
||||
scopeKey: string,
|
||||
avatars: Record<string, ContactsAvatarCacheEntry>
|
||||
): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
|
||||
const normalized: Record<string, ContactsAvatarCacheEntry> = {}
|
||||
for (const [rawUsername, rawEntry] of Object.entries(avatars || {})) {
|
||||
const username = String(rawUsername || '').trim()
|
||||
if (!username || !rawEntry || typeof rawEntry !== 'object') continue
|
||||
const avatarUrl = String(rawEntry.avatarUrl || '').trim()
|
||||
if (!avatarUrl) continue
|
||||
const updatedAt = Number.isFinite(rawEntry.updatedAt)
|
||||
? Math.max(0, Math.floor(rawEntry.updatedAt))
|
||||
: Date.now()
|
||||
const checkedAt = Number.isFinite(rawEntry.checkedAt)
|
||||
? Math.max(0, Math.floor(rawEntry.checkedAt))
|
||||
: updatedAt
|
||||
normalized[username] = {
|
||||
avatarUrl,
|
||||
updatedAt,
|
||||
checkedAt
|
||||
}
|
||||
}
|
||||
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
avatars: normalized
|
||||
}
|
||||
await config.set(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP, map)
|
||||
}
|
||||
|
||||
// === 安全相关 ===
|
||||
|
||||
export async function getAuthEnabled(): Promise<boolean> {
|
||||
@@ -482,3 +1198,26 @@ export async function getWordCloudExcludeWords(): Promise<string[]> {
|
||||
export async function setWordCloudExcludeWords(words: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS, words)
|
||||
}
|
||||
|
||||
// 获取数据收集同意状态
|
||||
export async function getAnalyticsConsent(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.ANALYTICS_CONSENT)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置数据收集同意状态
|
||||
export async function setAnalyticsConsent(consent: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.ANALYTICS_CONSENT, consent)
|
||||
}
|
||||
|
||||
// 获取数据收集拒绝次数
|
||||
export async function getAnalyticsDenyCount(): Promise<number> {
|
||||
const value = await config.get(CONFIG_KEYS.ANALYTICS_DENY_COUNT)
|
||||
return typeof value === 'number' ? value : 0
|
||||
}
|
||||
|
||||
// 设置数据收集拒绝次数
|
||||
export async function setAnalyticsDenyCount(count: number): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
|
||||
}
|
||||
|
||||
85
src/services/exportBridge.ts
Normal file
85
src/services/exportBridge.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export interface OpenSingleExportPayload {
|
||||
sessionId: string
|
||||
sessionName?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface ExportSessionStatusPayload {
|
||||
inProgressSessionIds: string[]
|
||||
activeTaskCount: number
|
||||
}
|
||||
|
||||
export interface SingleExportDialogStatusPayload {
|
||||
requestId: string
|
||||
status: 'initializing' | 'opened' | 'failed'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const OPEN_SINGLE_EXPORT_EVENT = 'weflow:open-single-export'
|
||||
const EXPORT_SESSION_STATUS_EVENT = 'weflow:export-session-status'
|
||||
const EXPORT_SESSION_STATUS_REQUEST_EVENT = 'weflow:export-session-status-request'
|
||||
const SINGLE_EXPORT_DIALOG_STATUS_EVENT = 'weflow:single-export-dialog-status'
|
||||
|
||||
export const emitOpenSingleExport = (payload: OpenSingleExportPayload) => {
|
||||
window.dispatchEvent(new CustomEvent<OpenSingleExportPayload>(OPEN_SINGLE_EXPORT_EVENT, {
|
||||
detail: payload
|
||||
}))
|
||||
}
|
||||
|
||||
export const onOpenSingleExport = (
|
||||
listener: (payload: OpenSingleExportPayload) => void
|
||||
): (() => void) => {
|
||||
const handler = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<OpenSingleExportPayload>
|
||||
listener(customEvent.detail)
|
||||
}
|
||||
|
||||
window.addEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener)
|
||||
return () => window.removeEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener)
|
||||
}
|
||||
|
||||
export const emitExportSessionStatus = (payload: ExportSessionStatusPayload) => {
|
||||
window.dispatchEvent(new CustomEvent<ExportSessionStatusPayload>(EXPORT_SESSION_STATUS_EVENT, {
|
||||
detail: payload
|
||||
}))
|
||||
}
|
||||
|
||||
export const onExportSessionStatus = (
|
||||
listener: (payload: ExportSessionStatusPayload) => void
|
||||
): (() => void) => {
|
||||
const handler = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<ExportSessionStatusPayload>
|
||||
listener(customEvent.detail)
|
||||
}
|
||||
|
||||
window.addEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener)
|
||||
return () => window.removeEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener)
|
||||
}
|
||||
|
||||
export const requestExportSessionStatus = () => {
|
||||
window.dispatchEvent(new CustomEvent(EXPORT_SESSION_STATUS_REQUEST_EVENT))
|
||||
}
|
||||
|
||||
export const onExportSessionStatusRequest = (listener: () => void): (() => void) => {
|
||||
const handler = () => listener()
|
||||
window.addEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler)
|
||||
return () => window.removeEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler)
|
||||
}
|
||||
|
||||
export const emitSingleExportDialogStatus = (payload: SingleExportDialogStatusPayload) => {
|
||||
window.dispatchEvent(new CustomEvent<SingleExportDialogStatusPayload>(SINGLE_EXPORT_DIALOG_STATUS_EVENT, {
|
||||
detail: payload
|
||||
}))
|
||||
}
|
||||
|
||||
export const onSingleExportDialogStatus = (
|
||||
listener: (payload: SingleExportDialogStatusPayload) => void
|
||||
): (() => void) => {
|
||||
const handler = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<SingleExportDialogStatusPayload>
|
||||
listener(customEvent.detail)
|
||||
}
|
||||
|
||||
window.addEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener)
|
||||
return () => window.removeEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export interface ChatState {
|
||||
setConnectionError: (error: string | null) => void
|
||||
setSessions: (sessions: ChatSession[]) => void
|
||||
setFilteredSessions: (sessions: ChatSession[]) => void
|
||||
setCurrentSession: (sessionId: string | null) => void
|
||||
setCurrentSession: (sessionId: string | null, options?: { preserveMessages?: boolean }) => void
|
||||
setLoadingSessions: (loading: boolean) => void
|
||||
setMessages: (messages: Message[]) => void
|
||||
appendMessages: (messages: Message[], prepend?: boolean) => void
|
||||
@@ -69,12 +69,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
||||
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
||||
|
||||
setCurrentSession: (sessionId) => set({
|
||||
setCurrentSession: (sessionId, options) => set((state) => ({
|
||||
currentSessionId: sessionId,
|
||||
messages: [],
|
||||
messages: options?.preserveMessages ? state.messages : [],
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false
|
||||
}),
|
||||
})),
|
||||
|
||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||
|
||||
|
||||
115
src/stores/contactTypeCountsStore.ts
Normal file
115
src/stores/contactTypeCountsStore.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ContactInfo } from '../types/models'
|
||||
|
||||
export interface ContactTypeTabCounts {
|
||||
private: number
|
||||
group: number
|
||||
official: number
|
||||
former_friend: number
|
||||
}
|
||||
|
||||
export interface ContactTypeCardCounts {
|
||||
friends: number
|
||||
groups: number
|
||||
officials: number
|
||||
deletedFriends: number
|
||||
}
|
||||
|
||||
const emptyTabCounts: ContactTypeTabCounts = {
|
||||
private: 0,
|
||||
group: 0,
|
||||
official: 0,
|
||||
former_friend: 0
|
||||
}
|
||||
|
||||
let inflightPromise: Promise<ContactTypeTabCounts> | null = null
|
||||
|
||||
const normalizeCounts = (counts?: Partial<ContactTypeTabCounts> | null): ContactTypeTabCounts => {
|
||||
return {
|
||||
private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0,
|
||||
group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0,
|
||||
official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0,
|
||||
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0
|
||||
}
|
||||
}
|
||||
|
||||
export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => {
|
||||
const next = { ...emptyTabCounts }
|
||||
for (const contact of contacts || []) {
|
||||
if (contact.type === 'friend') next.private += 1
|
||||
if (contact.type === 'group') next.group += 1
|
||||
if (contact.type === 'official') next.official += 1
|
||||
if (contact.type === 'former_friend') next.former_friend += 1
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => {
|
||||
return {
|
||||
friends: counts.private,
|
||||
groups: counts.group,
|
||||
officials: counts.official,
|
||||
deletedFriends: counts.former_friend
|
||||
}
|
||||
}
|
||||
|
||||
interface ContactTypeCountsState {
|
||||
tabCounts: ContactTypeTabCounts
|
||||
isLoading: boolean
|
||||
isReady: boolean
|
||||
updatedAt: number
|
||||
setTabCounts: (counts: ContactTypeTabCounts) => void
|
||||
syncFromContacts: (contacts: ContactInfo[]) => void
|
||||
ensureLoaded: (options?: { force?: boolean }) => Promise<ContactTypeTabCounts>
|
||||
}
|
||||
|
||||
export const useContactTypeCountsStore = create<ContactTypeCountsState>((set, get) => ({
|
||||
tabCounts: { ...emptyTabCounts },
|
||||
isLoading: false,
|
||||
isReady: false,
|
||||
updatedAt: 0,
|
||||
setTabCounts: (counts) => {
|
||||
const normalized = normalizeCounts(counts)
|
||||
set({
|
||||
tabCounts: normalized,
|
||||
isReady: true,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
},
|
||||
syncFromContacts: (contacts) => {
|
||||
const fromContacts = toContactTypeTabCountsFromContacts(contacts || [])
|
||||
get().setTabCounts(fromContacts)
|
||||
},
|
||||
ensureLoaded: async (options) => {
|
||||
if (!options?.force && get().isReady) {
|
||||
return get().tabCounts
|
||||
}
|
||||
if (inflightPromise) {
|
||||
return inflightPromise
|
||||
}
|
||||
|
||||
set({ isLoading: true })
|
||||
inflightPromise = (async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getContactTypeCounts()
|
||||
if (result?.success && result.counts) {
|
||||
const normalized = normalizeCounts(result.counts)
|
||||
set({
|
||||
tabCounts: normalized,
|
||||
isReady: true,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
return normalized
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载联系人类型计数失败:', error)
|
||||
}
|
||||
return get().tabCounts
|
||||
})().finally(() => {
|
||||
inflightPromise = null
|
||||
set({ isLoading: false })
|
||||
})
|
||||
|
||||
return inflightPromise
|
||||
}
|
||||
}))
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream'
|
||||
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream' | 'geist'
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
export interface ThemeInfo {
|
||||
@@ -57,6 +57,13 @@ export const themes: ThemeInfo[] = [
|
||||
description: 'RAL 180 80 10',
|
||||
primaryColor: '#5A8A8A',
|
||||
bgColor: '#E4F0F0'
|
||||
},
|
||||
{
|
||||
id: 'geist',
|
||||
name: 'Geist',
|
||||
description: 'Vercel · 极简黑白',
|
||||
primaryColor: '#000000',
|
||||
bgColor: '#ffffff'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
--card-bg: rgba(255, 255, 255, 0.7);
|
||||
--card-inner-bg: #FAFAF7;
|
||||
--sent-card-bg: var(--primary);
|
||||
|
||||
// primary 色上方的前景文字色(大多数主题为白色)
|
||||
--on-primary: white;
|
||||
}
|
||||
|
||||
// ==================== 浅色主题 ====================
|
||||
@@ -190,6 +193,31 @@
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// Geist · 极简黑白 - 浅色
|
||||
[data-theme="geist"][data-mode="light"],
|
||||
[data-theme="geist"]:not([data-mode]) {
|
||||
--primary: #444444;
|
||||
--primary-rgb: 68, 68, 68;
|
||||
--primary-hover: #333333;
|
||||
--primary-light: rgba(68, 68, 68, 0.08);
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: rgba(250, 250, 250, 0.95);
|
||||
--bg-tertiary: rgba(0, 0, 0, 0.03);
|
||||
--bg-hover: rgba(0, 0, 0, 0.05);
|
||||
--text-primary: #111111;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--border-color: #eaeaea;
|
||||
--border-radius: 6px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
--bg-gradient: linear-gradient(135deg, #ffffff 0%, #fafafa 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #444444 0%, #666666 100%);
|
||||
--card-bg: rgba(250, 250, 250, 0.95);
|
||||
--card-inner-bg: #f5f5f5;
|
||||
--sent-card-bg: #444444;
|
||||
}
|
||||
|
||||
// ==================== 深色主题 ====================
|
||||
|
||||
// 云上舞白 - 深色
|
||||
@@ -339,6 +367,33 @@
|
||||
--sent-card-bg: var(--primary);
|
||||
}
|
||||
|
||||
// Geist · 极简黑白 - 深色
|
||||
[data-theme="geist"][data-mode="dark"] {
|
||||
--primary: #ededed;
|
||||
--primary-rgb: 237, 237, 237;
|
||||
--primary-hover: #d5d5d5;
|
||||
--primary-light: rgba(237, 237, 237, 0.1);
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: rgba(34, 34, 34, 0.95);
|
||||
--bg-secondary-solid: #222222;
|
||||
--bg-tertiary: rgba(255, 255, 255, 0.04);
|
||||
--bg-hover: rgba(255, 255, 255, 0.07);
|
||||
--text-primary: #ededed;
|
||||
--text-secondary: #999999;
|
||||
--text-tertiary: #666666;
|
||||
--border-color: #2e2e2e;
|
||||
--border-radius: 6px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
--bg-gradient: linear-gradient(135deg, #1a1a1a 0%, #222222 100%);
|
||||
--primary-gradient: linear-gradient(135deg, #ededed 0%, #cccccc 100%);
|
||||
--card-bg: rgba(34, 34, 34, 0.95);
|
||||
--card-inner-bg: #2a2a2a;
|
||||
--sent-card-bg: #3a3a3a;
|
||||
// primary 是浅灰色,上方文字需要用深色
|
||||
--on-primary: #111111;
|
||||
}
|
||||
|
||||
// 重置样式
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -395,7 +450,7 @@ body {
|
||||
|
||||
&-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color: var(--on-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
|
||||
297
src/types/electron.d.ts
vendored
297
src/types/electron.d.ts
vendored
@@ -1,5 +1,12 @@
|
||||
import type { ChatSession, Message, Contact, ContactInfo } from './models'
|
||||
|
||||
export interface SessionChatWindowOpenOptions {
|
||||
source?: 'chat' | 'export'
|
||||
initialDisplayName?: string
|
||||
initialAvatarUrl?: string
|
||||
initialContactType?: ContactInfo['type']
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
minimize: () => void
|
||||
@@ -13,6 +20,7 @@ export interface ElectronAPI {
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||
openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
|
||||
}
|
||||
config: {
|
||||
get: (key: string) => Promise<unknown>
|
||||
@@ -48,9 +56,65 @@ export interface ElectronAPI {
|
||||
onDownloadProgress: (callback: (progress: number) => void) => () => void
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
|
||||
}
|
||||
notification: {
|
||||
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
|
||||
close: () => Promise<void>
|
||||
click: (sessionId: string) => void
|
||||
ready: () => void
|
||||
resize: (width: number, height: number) => void
|
||||
onShow: (callback: (event: any, data: any) => void) => () => void
|
||||
}
|
||||
log: {
|
||||
getPath: () => Promise<string>
|
||||
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
||||
debug: (data: any) => void
|
||||
}
|
||||
diagnostics: {
|
||||
getExportCardLogs: (options?: { limit?: number }) => Promise<{
|
||||
logs: Array<{
|
||||
id: string
|
||||
ts: number
|
||||
source: 'frontend' | 'main' | 'backend' | 'worker'
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
message: string
|
||||
traceId?: string
|
||||
stepId?: string
|
||||
stepName?: string
|
||||
status?: 'running' | 'done' | 'failed' | 'timeout'
|
||||
durationMs?: number
|
||||
data?: Record<string, unknown>
|
||||
}>
|
||||
activeSteps: Array<{
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: 'frontend' | 'main' | 'backend' | 'worker'
|
||||
elapsedMs: number
|
||||
stallMs: number
|
||||
startedAt: number
|
||||
lastUpdatedAt: number
|
||||
message?: string
|
||||
}>
|
||||
summary: {
|
||||
totalLogs: number
|
||||
activeStepCount: number
|
||||
errorCount: number
|
||||
warnCount: number
|
||||
timeoutCount: number
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
}>
|
||||
clearExportCardLogs: () => Promise<{ success: boolean }>
|
||||
exportExportCardLogs: (payload: {
|
||||
filePath: string
|
||||
frontendLogs?: unknown[]
|
||||
}) => Promise<{
|
||||
success: boolean
|
||||
filePath?: string
|
||||
summaryPath?: string
|
||||
count?: number
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
dbPath: {
|
||||
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
@@ -66,14 +130,48 @@ export interface ElectronAPI {
|
||||
}
|
||||
key: {
|
||||
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
||||
autoGetImageKey: (manualDir?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||
scanImageKeyFromMemory: (userDir: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
|
||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
}
|
||||
chat: {
|
||||
connect: () => Promise<{ success: boolean; error?: string }>
|
||||
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
||||
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
|
||||
getSessionStatuses: (usernames: string[]) => Promise<{
|
||||
success: boolean
|
||||
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||
error?: string
|
||||
}>
|
||||
getExportTabCounts: () => Promise<{
|
||||
success: boolean
|
||||
counts?: {
|
||||
private: number
|
||||
group: number
|
||||
official: number
|
||||
former_friend: number
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getContactTypeCounts: () => Promise<{
|
||||
success: boolean
|
||||
counts?: {
|
||||
private: number
|
||||
group: number
|
||||
official: number
|
||||
former_friend: number
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getSessionMessageCounts: (sessionIds: string[]) => Promise<{
|
||||
success: boolean
|
||||
counts?: Record<string, number>
|
||||
error?: string
|
||||
}>
|
||||
enrichSessionsContactInfo: (
|
||||
usernames: string[],
|
||||
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||
error?: string
|
||||
@@ -87,6 +185,7 @@ export interface ElectronAPI {
|
||||
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
|
||||
success: boolean
|
||||
messages?: Message[]
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
}>
|
||||
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
|
||||
@@ -94,6 +193,17 @@ export interface ElectronAPI {
|
||||
messages?: Message[]
|
||||
error?: string
|
||||
}>
|
||||
getCachedMessages: (sessionId: string) => Promise<{
|
||||
success: boolean
|
||||
messages?: Message[]
|
||||
error?: string
|
||||
}>
|
||||
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) => Promise<{
|
||||
success: boolean
|
||||
removedPaths?: string[]
|
||||
warning?: string
|
||||
error?: string
|
||||
}>
|
||||
getContact: (username: string) => Promise<Contact | null>
|
||||
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
||||
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
||||
@@ -123,6 +233,72 @@ export interface ElectronAPI {
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getSessionDetailFast: (sessionId: string) => Promise<{
|
||||
success: boolean
|
||||
detail?: {
|
||||
wxid: string
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickName?: string
|
||||
alias?: string
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getSessionDetailExtra: (sessionId: string) => Promise<{
|
||||
success: boolean
|
||||
detail?: {
|
||||
firstMessageTime?: number
|
||||
latestMessageTime?: number
|
||||
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getExportSessionStats: (
|
||||
sessionIds: string[],
|
||||
options?: {
|
||||
includeRelations?: boolean
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: Record<string, {
|
||||
totalMessages: number
|
||||
voiceMessages: number
|
||||
imageMessages: number
|
||||
videoMessages: number
|
||||
emojiMessages: number
|
||||
transferMessages: number
|
||||
redPacketMessages: number
|
||||
callMessages: number
|
||||
firstTimestamp?: number
|
||||
lastTimestamp?: number
|
||||
privateMutualGroups?: number
|
||||
groupMemberCount?: number
|
||||
groupMyMessages?: number
|
||||
groupActiveSpeakers?: number
|
||||
groupMutualFriends?: number
|
||||
}>
|
||||
cache?: Record<string, {
|
||||
updatedAt: number
|
||||
stale: boolean
|
||||
includeRelations: boolean
|
||||
source: 'memory' | 'disk' | 'fresh'
|
||||
}>
|
||||
needsRefresh?: string[]
|
||||
error?: string
|
||||
}>
|
||||
getGroupMyMessageCountHint: (chatroomId: string) => Promise<{
|
||||
success: boolean
|
||||
count?: number
|
||||
updatedAt?: number
|
||||
source?: 'memory' | 'disk'
|
||||
error?: string
|
||||
}>
|
||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
|
||||
@@ -131,6 +307,8 @@ export interface ElectronAPI {
|
||||
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
|
||||
error?: string
|
||||
}>
|
||||
getMessageDates: (sessionId: string) => Promise<{ success: boolean; dates?: string[]; error?: string }>
|
||||
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
@@ -140,7 +318,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||
@@ -252,9 +430,31 @@ export interface ElectronAPI {
|
||||
alias?: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
isOwner?: boolean
|
||||
}>
|
||||
error?: string
|
||||
}>
|
||||
getGroupMembersPanelData: (
|
||||
chatroomId: string,
|
||||
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: Array<{
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
nickname?: string
|
||||
alias?: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
isOwner?: boolean
|
||||
isFriend: boolean
|
||||
messageCount: number
|
||||
}>
|
||||
fromCache?: boolean
|
||||
updatedAt?: number
|
||||
error?: string
|
||||
}>
|
||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{
|
||||
success: boolean
|
||||
data?: Array<{
|
||||
@@ -309,6 +509,30 @@ export interface ElectronAPI {
|
||||
data?: number[]
|
||||
error?: string
|
||||
}>
|
||||
startAvailableYearsLoad: () => Promise<{
|
||||
success: boolean
|
||||
taskId?: string
|
||||
reused?: boolean
|
||||
snapshot?: {
|
||||
years?: number[]
|
||||
done: boolean
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
strategy?: 'cache' | 'native' | 'hybrid'
|
||||
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||
statusText?: string
|
||||
nativeElapsedMs?: number
|
||||
scanElapsedMs?: number
|
||||
totalElapsedMs?: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
cancelAvailableYearsLoad: (taskId: string) => Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}>
|
||||
generateReport: (year: number) => Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
@@ -371,6 +595,20 @@ export interface ElectronAPI {
|
||||
phrase: string
|
||||
count: number
|
||||
}>
|
||||
snsStats?: {
|
||||
totalPosts: number
|
||||
typeCounts?: Record<string, number>
|
||||
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
}
|
||||
lostFriend: {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
earlyCount: number
|
||||
lateCount: number
|
||||
periodDesc: string
|
||||
} | null
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
@@ -379,6 +617,21 @@ export interface ElectronAPI {
|
||||
dir?: string
|
||||
error?: string
|
||||
}>
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
done: boolean
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
strategy?: 'cache' | 'native' | 'hybrid'
|
||||
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||
statusText?: string
|
||||
nativeElapsedMs?: number
|
||||
scanElapsedMs?: number
|
||||
totalElapsedMs?: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}) => void) => () => void
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||
}
|
||||
dualReport: {
|
||||
@@ -426,15 +679,26 @@ export interface ElectronAPI {
|
||||
myTopEmojiMd5?: string
|
||||
friendTopEmojiMd5?: string
|
||||
myTopEmojiUrl?: string
|
||||
friendTopEmojiUrl?: string
|
||||
myTopEmojiCount?: number
|
||||
friendTopEmojiCount?: number
|
||||
topPhrases: Array<{ phrase: string; count: number }>
|
||||
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||
heatmap?: number[][]
|
||||
initiative?: { initiated: number; received: number }
|
||||
response?: { avg: number; fastest: number; count: number }
|
||||
response?: { avg: number; fastest: number; slowest?: number; count: number }
|
||||
monthly?: Record<string, number>
|
||||
streak?: { days: number; startDate: string; endDate: string }
|
||||
}
|
||||
topPhrases: Array<{ phrase: string; count: number }>
|
||||
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||
heatmap?: number[][]
|
||||
initiative?: { initiated: number; received: number }
|
||||
response?: { avg: number; fastest: number; slowest?: number; count: number }
|
||||
monthly?: Record<string, number>
|
||||
streak?: { days: number; startDate: string; endDate: string }
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
@@ -454,6 +718,9 @@ export interface ElectronAPI {
|
||||
success: boolean
|
||||
successCount?: number
|
||||
failCount?: number
|
||||
pendingSessionIds?: string[]
|
||||
successSessionIds?: string[]
|
||||
failedSessionIds?: string[]
|
||||
error?: string
|
||||
}>
|
||||
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
||||
@@ -506,26 +773,37 @@ export interface ElectronAPI {
|
||||
error?: string
|
||||
}>
|
||||
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
||||
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
||||
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }>
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
|
||||
exportTimeline: (options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html'
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportMedia?: boolean
|
||||
exportImages?: boolean
|
||||
exportLivePhotos?: boolean
|
||||
exportVideos?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
|
||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||
getUserPostCounts: () => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
|
||||
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
|
||||
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }>
|
||||
getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }>
|
||||
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
||||
checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>
|
||||
deleteSnsPost: (postId: string) => Promise<{ success: boolean; error?: string }>
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||
}
|
||||
cloud: {
|
||||
init: () => Promise<void>
|
||||
recordPage: (pageName: string) => Promise<void>
|
||||
getLogs: () => Promise<string[]>
|
||||
}
|
||||
http: {
|
||||
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||
stop: () => Promise<{ success: boolean }>
|
||||
@@ -534,7 +812,8 @@ export interface ElectronAPI {
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
@@ -548,6 +827,7 @@ export interface ExportOptions {
|
||||
excelCompactColumns?: boolean
|
||||
txtColumns?: string[]
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
}
|
||||
@@ -556,6 +836,7 @@ export interface ExportProgress {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
currentSessionId?: string
|
||||
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
||||
phaseProgress?: number
|
||||
phaseTotal?: number
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ChatSession {
|
||||
sortTimestamp: number // 用于排序
|
||||
lastTimestamp: number // 用于显示时间
|
||||
lastMsgType: number
|
||||
messageCountHint?: number // 会话总消息数提示(若底层直接可取)
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
lastMsgSender?: string
|
||||
@@ -33,6 +34,7 @@ export interface ContactInfo {
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
alias?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
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)}`
|
||||
}
|
||||
21
src/vite-env.d.ts
vendored
21
src/vite-env.d.ts
vendored
@@ -1,22 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
// ... other methods ...
|
||||
auth: {
|
||||
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
||||
verifyEnabled: () => Promise<boolean>
|
||||
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
||||
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
|
||||
setHelloSecret: (password: string) => Promise<{ success: boolean }>
|
||||
clearHelloSecret: () => Promise<{ success: boolean }>
|
||||
isLockMode: () => Promise<boolean>
|
||||
}
|
||||
// For brevity, using 'any' for other parts or properly importing types if available.
|
||||
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
|
||||
// or import a shared type definition.
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user