mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 15:45:51 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c07ef66324 | ||
|
|
6bc802e77b | ||
|
|
898c86c23f | ||
|
|
7612353389 | ||
|
|
8b37f20b0f | ||
|
|
0054509ef2 | ||
|
|
e0f22f58c8 | ||
|
|
6f41cb34ed | ||
|
|
ddbb0c3b26 | ||
|
|
f40f885af3 | ||
|
|
5413d7e2c8 | ||
|
|
53f0e299e0 | ||
|
|
65365107f5 | ||
|
|
cffeeb26ec | ||
|
|
26d4751e80 | ||
|
|
b8120a5119 | ||
|
|
68a13cefc3 | ||
|
|
cd4b8f3702 | ||
|
|
c5956ba203 | ||
|
|
f456357e01 | ||
|
|
4ef821f45f | ||
|
|
912c78e9e9 | ||
|
|
bfcd154a25 | ||
|
|
a1c8ba48b0 | ||
|
|
f93369489d | ||
|
|
014f57f152 | ||
|
|
3f1eb58af4 | ||
|
|
97f0077e95 | ||
|
|
3d9b1b0f8c | ||
|
|
cf292ca9e2 | ||
|
|
97f14030de | ||
|
|
2cfe0d8ee8 | ||
|
|
a760f45823 | ||
|
|
baa949a301 | ||
|
|
c29bbab25f | ||
|
|
29981e1232 | ||
|
|
2d043cd929 | ||
|
|
d6dca0e5f7 | ||
|
|
d47166e6f9 | ||
|
|
6e3bb9e361 | ||
|
|
b8dbc3caf1 | ||
|
|
c1145c8f89 | ||
|
|
0cba8e6d89 | ||
|
|
f6f468dff3 | ||
|
|
04fc5f9104 | ||
|
|
3c9ab6763c | ||
|
|
f360333ab4 | ||
|
|
834aa6eecb | ||
|
|
2400cc8b55 | ||
|
|
e4ed7faca9 | ||
|
|
8012aa49ee | ||
|
|
7225358b91 | ||
|
|
39688e8e0c | ||
|
|
592ca6128f | ||
|
|
7cd27d8905 | ||
|
|
bca387c54b |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22.12
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -57,3 +57,4 @@ Thumbs.db
|
||||
|
||||
wcdb/
|
||||
*info
|
||||
*.md
|
||||
|
||||
45
electron/dualReportWorker.ts
Normal file
45
electron/dualReportWorker.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import { wcdbService } from './services/wcdbService'
|
||||
import { dualReportService } from './services/dualReportService'
|
||||
|
||||
interface WorkerConfig {
|
||||
year: number
|
||||
friendUsername: string
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
myWxid: string
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
}
|
||||
|
||||
const config = workerData as WorkerConfig
|
||||
process.env.WEFLOW_WORKER = '1'
|
||||
if (config.resourcesPath) {
|
||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||
}
|
||||
|
||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||
|
||||
async function run() {
|
||||
const result = await dualReportService.generateReportWithConfig({
|
||||
year: config.year,
|
||||
friendUsername: config.friendUsername,
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
wxid: config.myWxid,
|
||||
onProgress: (status: string, progress: number) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'dualReport:progress',
|
||||
data: { status, progress }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
parentPort?.postMessage({ type: 'dualReport:result', data: result })
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
parentPort?.postMessage({ type: 'dualReport:error', error: String(err) })
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService } from './services/snsService'
|
||||
import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -673,6 +674,10 @@ function registerIpcHandlers() {
|
||||
return dbPathService.scanWxids(rootPath)
|
||||
})
|
||||
|
||||
ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => {
|
||||
return dbPathService.scanWxidCandidates(rootPath)
|
||||
})
|
||||
|
||||
ipcMain.handle('dbpath:getDefault', async () => {
|
||||
return dbPathService.getDefaultPath()
|
||||
})
|
||||
@@ -798,6 +803,17 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
// Windows Hello
|
||||
ipcMain.handle('auth:hello', async (event, message?: string) => {
|
||||
// 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致
|
||||
// 如果主窗口不存在(极其罕见),则回退到调用者窗口
|
||||
const targetWin = (mainWindow && !mainWindow.isDestroyed())
|
||||
? mainWindow
|
||||
: (BrowserWindow.fromWebContents(event.sender) || undefined)
|
||||
|
||||
return windowsHelloService.verify(message, targetWin)
|
||||
})
|
||||
|
||||
// 导出相关
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
const onProgress = (progress: ExportProgress) => {
|
||||
@@ -829,6 +845,18 @@ function registerIpcHandlers() {
|
||||
return analyticsService.getTimeDistribution()
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getExcludedUsernames', async () => {
|
||||
return analyticsService.getExcludedUsernames()
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:setExcludedUsernames', async (_, usernames: string[]) => {
|
||||
return analyticsService.setExcludedUsernames(usernames)
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getExcludeCandidates', async () => {
|
||||
return analyticsService.getExcludeCandidates()
|
||||
})
|
||||
|
||||
// 缓存管理
|
||||
ipcMain.handle('cache:clearAnalytics', async () => {
|
||||
return analyticsService.clearCache()
|
||||
@@ -894,6 +922,10 @@ function registerIpcHandlers() {
|
||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
||||
})
|
||||
|
||||
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
|
||||
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||
})
|
||||
|
||||
// 打开协议窗口
|
||||
ipcMain.handle('window:openAgreementWindow', async () => {
|
||||
createAgreementWindow()
|
||||
@@ -997,6 +1029,73 @@ function registerIpcHandlers() {
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('dualReport:generateReport', async (_, payload: { friendUsername: string; year: number }) => {
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
|
||||
const dbPath = cfg.get('dbPath')
|
||||
const decryptKey = cfg.get('decryptKey')
|
||||
const wxid = cfg.get('myWxid')
|
||||
const logEnabled = cfg.get('logEnabled')
|
||||
const friendUsername = payload?.friendUsername
|
||||
const year = payload?.year ?? 0
|
||||
|
||||
if (!friendUsername) {
|
||||
return { success: false, error: '缺少好友用户名' }
|
||||
}
|
||||
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const userDataPath = app.getPath('userData')
|
||||
|
||||
const workerPath = join(__dirname, 'dualReportWorker.js')
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled }
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
worker.removeAllListeners()
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'dualReport:progress') {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('dualReport:progress', msg.data)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg && (msg.type === 'dualReport:result' || msg.type === 'done')) {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve(msg.data ?? msg.result)
|
||||
return
|
||||
}
|
||||
if (msg && (msg.type === 'dualReport:error' || msg.type === 'error')) {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve({ success: false, error: msg.error || '双人报告生成失败' })
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
cleanup()
|
||||
resolve({ success: false, error: String(err) })
|
||||
})
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
cleanup()
|
||||
resolve({ success: false, error: `双人报告线程异常退出: ${code}` })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
|
||||
try {
|
||||
const { baseDir, folderName, images } = payload
|
||||
|
||||
24
electron/nodert.d.ts
vendored
Normal file
24
electron/nodert.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module '@nodert-win10-rs4/windows.security.credentials.ui' {
|
||||
export enum UserConsentVerificationResult {
|
||||
Verified = 0,
|
||||
DeviceNotPresent = 1,
|
||||
NotConfiguredForUser = 2,
|
||||
DisabledByPolicy = 3,
|
||||
DeviceBusy = 4,
|
||||
RetriesExhausted = 5,
|
||||
Canceled = 6
|
||||
}
|
||||
|
||||
export enum UserConsentVerifierAvailability {
|
||||
Available = 0,
|
||||
DeviceNotPresent = 1,
|
||||
NotConfiguredForUser = 2,
|
||||
DisabledByPolicy = 3,
|
||||
DeviceBusy = 4
|
||||
}
|
||||
|
||||
export class UserConsentVerifier {
|
||||
static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void;
|
||||
static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
clear: () => ipcRenderer.invoke('config:clear')
|
||||
},
|
||||
|
||||
// 认证
|
||||
auth: {
|
||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
||||
},
|
||||
|
||||
|
||||
// 对话框
|
||||
dialog: {
|
||||
@@ -66,6 +71,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
dbPath: {
|
||||
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
|
||||
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
|
||||
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
|
||||
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
|
||||
},
|
||||
|
||||
@@ -156,9 +162,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 数据分析
|
||||
analytics: {
|
||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
||||
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
|
||||
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
||||
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
||||
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
||||
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
||||
@@ -178,7 +187,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||
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)
|
||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
|
||||
},
|
||||
|
||||
// 年度报告
|
||||
@@ -192,6 +202,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
||||
}
|
||||
},
|
||||
dualReport: {
|
||||
generateReport: (payload: { friendUsername: string; year: number }) =>
|
||||
ipcRenderer.invoke('dualReport:generateReport', payload),
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||
ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('dualReport:progress')
|
||||
}
|
||||
},
|
||||
|
||||
// 导出
|
||||
export: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { wcdbService } from './wcdbService'
|
||||
import { join } from 'path'
|
||||
import { readFile, writeFile, rm } from 'fs/promises'
|
||||
import { app } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export interface ChatStatistics {
|
||||
totalMessages: number
|
||||
@@ -46,6 +47,58 @@ class AnalyticsService {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private normalizeUsername(username: string): string {
|
||||
return username.trim().toLowerCase()
|
||||
}
|
||||
|
||||
private normalizeExcludedUsernames(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const normalized = value
|
||||
.map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '')
|
||||
.filter((item) => item.length > 0)
|
||||
return Array.from(new Set(normalized))
|
||||
}
|
||||
|
||||
private getExcludedUsernamesList(): string[] {
|
||||
return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames'))
|
||||
}
|
||||
|
||||
private getExcludedUsernamesSet(): Set<string> {
|
||||
return new Set(this.getExcludedUsernamesList())
|
||||
}
|
||||
|
||||
private escapeSqlValue(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {}
|
||||
if (usernames.length === 0) return map
|
||||
|
||||
const chunkSize = 200
|
||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||
const chunk = usernames.slice(i, i + chunkSize)
|
||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||
if (!inList) continue
|
||||
const sql = `
|
||||
SELECT username, alias
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows) continue
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const username = row.username || ''
|
||||
const alias = row.alias || ''
|
||||
if (username && alias) {
|
||||
map[username] = alias
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
private cleanAccountDirName(name: string): string {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return trimmed
|
||||
@@ -97,13 +150,15 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
private async getPrivateSessions(
|
||||
cleanedWxid: string
|
||||
cleanedWxid: string,
|
||||
excludedUsernames?: Set<string>
|
||||
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
||||
const sessionResult = await wcdbService.getSessions()
|
||||
if (!sessionResult.success || !sessionResult.sessions) {
|
||||
return { usernames: [], numericIds: [] }
|
||||
}
|
||||
const rows = sessionResult.sessions as Record<string, any>[]
|
||||
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
|
||||
|
||||
const sample = rows[0]
|
||||
void sample
|
||||
@@ -124,7 +179,11 @@ class AnalyticsService {
|
||||
return { username, idValue }
|
||||
})
|
||||
const usernames = sessions.map((s) => s.username)
|
||||
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid))
|
||||
const privateSessions = sessions.filter((s) => {
|
||||
if (!this.isPrivateSession(s.username, cleanedWxid)) return false
|
||||
if (excluded.size === 0) return true
|
||||
return !excluded.has(this.normalizeUsername(s.username))
|
||||
})
|
||||
const privateUsernames = privateSessions.map((s) => s.username)
|
||||
const numericIds = privateSessions
|
||||
.map((s) => s.idValue)
|
||||
@@ -177,8 +236,12 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
||||
const sample = sessionIds.slice(0, 5).join(',')
|
||||
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
|
||||
if (sessionIds.length === 0) {
|
||||
return `${beginTimestamp}-${endTimestamp}-0-empty`
|
||||
}
|
||||
const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort()
|
||||
const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12)
|
||||
return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}`
|
||||
}
|
||||
|
||||
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
|
||||
@@ -369,6 +432,65 @@ class AnalyticsService {
|
||||
void results
|
||||
}
|
||||
|
||||
async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> {
|
||||
try {
|
||||
return { success: true, data: this.getExcludedUsernamesList() }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> {
|
||||
try {
|
||||
const normalized = this.normalizeExcludedUsernames(usernames)
|
||||
this.configService.set('analyticsExcludedUsernames', normalized)
|
||||
await this.clearCache()
|
||||
return { success: true, data: normalized }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
|
||||
const excluded = this.getExcludedUsernamesSet()
|
||||
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set())
|
||||
|
||||
const usernames = new Set<string>(sessionInfo.usernames)
|
||||
for (const name of excluded) usernames.add(name)
|
||||
|
||||
if (usernames.size === 0) {
|
||||
return { success: true, data: [] }
|
||||
}
|
||||
|
||||
const usernameList = Array.from(usernames)
|
||||
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
|
||||
wcdbService.getDisplayNames(usernameList),
|
||||
wcdbService.getAvatarUrls(usernameList),
|
||||
this.getAliasMap(usernameList)
|
||||
])
|
||||
|
||||
const entries = usernameList.map((username) => {
|
||||
const displayName = displayNames.success && displayNames.map
|
||||
? (displayNames.map[username] || username)
|
||||
: username
|
||||
const avatarUrl = avatarUrls.success && avatarUrls.map
|
||||
? avatarUrls.map[username]
|
||||
: undefined
|
||||
const alias = aliasMap[username]
|
||||
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
|
||||
return { username, displayName, avatarUrl, wechatId }
|
||||
})
|
||||
|
||||
return { success: true, data: entries }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
|
||||
@@ -69,6 +69,20 @@ export interface AnnualReportData {
|
||||
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
|
||||
}
|
||||
|
||||
class AnnualReportService {
|
||||
@@ -397,8 +411,15 @@ class AnnualReportService {
|
||||
|
||||
this.reportProgress('加载会话列表...', 15, onProgress)
|
||||
|
||||
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
||||
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||
const isAllTime = year <= 0
|
||||
const reportYear = isAllTime ? 0 : year
|
||||
const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
||||
const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||
|
||||
const now = new Date()
|
||||
// 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd)
|
||||
const actualStartTime = startTime
|
||||
const actualEndTime = endTime
|
||||
|
||||
let totalMessages = 0
|
||||
const contactStats = new Map<string, { sent: number; received: number }>()
|
||||
@@ -420,7 +441,7 @@ class AnnualReportService {
|
||||
const CONVERSATION_GAP = 3600
|
||||
|
||||
this.reportProgress('统计会话消息...', 20, onProgress)
|
||||
const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime)
|
||||
const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime)
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
|
||||
}
|
||||
@@ -474,7 +495,7 @@ class AnnualReportService {
|
||||
}
|
||||
|
||||
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
|
||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd)
|
||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
||||
if (extras.success && extras.data) {
|
||||
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
||||
const extrasData = extras.data as any
|
||||
@@ -554,7 +575,7 @@ class AnnualReportService {
|
||||
// 为保持功能完整,我们进行深度集成的轻量遍历:
|
||||
for (let i = 0; i < sessionIds.length; i++) {
|
||||
const sessionId = sessionIds[i]
|
||||
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime)
|
||||
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime)
|
||||
if (!cursor.success || !cursor.cursor) continue
|
||||
|
||||
let lastDayIndex: number | null = null
|
||||
@@ -689,7 +710,7 @@ class AnnualReportService {
|
||||
|
||||
if (!streakComputedInLoop) {
|
||||
this.reportProgress('计算连续聊天...', 45, onProgress)
|
||||
const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75)
|
||||
const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75)
|
||||
if (streakResult.days > longestStreakDays) {
|
||||
longestStreakDays = streakResult.days
|
||||
longestStreakSessionId = streakResult.sessionId
|
||||
@@ -698,6 +719,42 @@ class AnnualReportService {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取朋友圈统计
|
||||
this.reportProgress('分析朋友圈数据...', 75, onProgress)
|
||||
let snsStatsResult: {
|
||||
totalPosts: number
|
||||
typeCounts?: Record<string, number>
|
||||
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
} | undefined
|
||||
|
||||
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
|
||||
|
||||
if (snsStats.success && snsStats.data) {
|
||||
const d = snsStats.data
|
||||
const usersToFetch = new Set<string>()
|
||||
d.topLikers?.forEach((u: any) => usersToFetch.add(u.username))
|
||||
d.topLiked?.forEach((u: any) => usersToFetch.add(u.username))
|
||||
|
||||
const snsUserIds = Array.from(usersToFetch)
|
||||
const [snsDisplayNames, snsAvatarUrls] = await Promise.all([
|
||||
wcdbService.getDisplayNames(snsUserIds),
|
||||
wcdbService.getAvatarUrls(snsUserIds)
|
||||
])
|
||||
|
||||
const getSnsUserInfo = (username: string) => ({
|
||||
displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username,
|
||||
avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined
|
||||
})
|
||||
|
||||
snsStatsResult = {
|
||||
totalPosts: d.totalPosts || 0,
|
||||
typeCounts: d.typeCounts,
|
||||
topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })),
|
||||
topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) }))
|
||||
}
|
||||
}
|
||||
|
||||
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||
|
||||
const contactIds = Array.from(contactStats.keys())
|
||||
@@ -901,8 +958,130 @@ class AnnualReportService {
|
||||
.slice(0, 32)
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
|
||||
// 曾经的好朋友 (Once Best Friend / Lost Friend)
|
||||
let lostFriend: AnnualReportData['lostFriend'] = null
|
||||
let maxEarlyCount = 80 // 最低门槛
|
||||
let bestEarlyCount = 0
|
||||
let bestLateCount = 0
|
||||
let bestSid = ''
|
||||
let bestPeriodDesc = ''
|
||||
|
||||
const currentMonthIndex = new Date().getMonth() + 1 // 1-12
|
||||
|
||||
const currentYearNum = now.getFullYear()
|
||||
|
||||
if (isAllTime) {
|
||||
const days = Object.keys(d.daily).sort()
|
||||
if (days.length >= 2) {
|
||||
const firstDay = Math.floor(new Date(days[0]).getTime() / 1000)
|
||||
const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000)
|
||||
const midPoint = Math.floor((firstDay + lastDay) / 2)
|
||||
|
||||
this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress)
|
||||
const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint)
|
||||
this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress)
|
||||
const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0)
|
||||
|
||||
if (earlyRes.success && lateRes.success && earlyRes.data) {
|
||||
const earlyData = earlyRes.data.sessions || {}
|
||||
const lateData = (lateRes.data?.sessions) || {}
|
||||
for (const sid of sessionIds) {
|
||||
const e = earlyData[sid] || { sent: 0, received: 0 }
|
||||
const l = lateData[sid] || { sent: 0, received: 0 }
|
||||
const early = (e.sent || 0) + (e.received || 0)
|
||||
const late = (l.sent || 0) + (l.received || 0)
|
||||
if (early > 100 && early > late * 5) {
|
||||
// 选择前期消息量最多的
|
||||
if (early > maxEarlyCount) {
|
||||
maxEarlyCount = early
|
||||
bestEarlyCount = early
|
||||
bestLateCount = late
|
||||
bestSid = sid
|
||||
bestPeriodDesc = '这段时间以来'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (year === currentYearNum) {
|
||||
// 当前年份:独立获取过去12个月的滚动数据
|
||||
this.reportProgress('分析近期好友趋势...', 86, onProgress)
|
||||
// 往前数12个月的起点、中点、终点
|
||||
const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000)
|
||||
const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000)
|
||||
const rollingEnd = Math.floor(now.getTime() / 1000)
|
||||
|
||||
const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1)
|
||||
const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd)
|
||||
|
||||
if (earlyRes.success && lateRes.success && earlyRes.data) {
|
||||
const earlyData = earlyRes.data.sessions || {}
|
||||
const lateData = lateRes.data?.sessions || {}
|
||||
for (const sid of sessionIds) {
|
||||
const e = earlyData[sid] || { sent: 0, received: 0 }
|
||||
const l = lateData[sid] || { sent: 0, received: 0 }
|
||||
const early = (e.sent || 0) + (e.received || 0)
|
||||
const late = (l.sent || 0) + (l.received || 0)
|
||||
if (early > 80 && early > late * 5) {
|
||||
// 选择前期消息量最多的
|
||||
if (early > maxEarlyCount) {
|
||||
maxEarlyCount = early
|
||||
bestEarlyCount = early
|
||||
bestLateCount = late
|
||||
bestSid = sid
|
||||
bestPeriodDesc = '去年的这个时候'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 指定完整年份 (1-6 vs 7-12)
|
||||
for (const [sid, stat] of Object.entries(d.sessions)) {
|
||||
const s = stat as any
|
||||
const mWeights = s.monthly || {}
|
||||
let early = 0
|
||||
let late = 0
|
||||
for (let m = 1; m <= 6; m++) early += mWeights[m] || 0
|
||||
for (let m = 7; m <= 12; m++) late += mWeights[m] || 0
|
||||
|
||||
if (early > 80 && early > late * 5) {
|
||||
// 选择前期消息量最多的
|
||||
if (early > maxEarlyCount) {
|
||||
maxEarlyCount = early
|
||||
bestEarlyCount = early
|
||||
bestLateCount = late
|
||||
bestSid = sid
|
||||
bestPeriodDesc = `${year}年上半年`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSid) {
|
||||
let info = contactInfoMap.get(bestSid)
|
||||
// 如果 contactInfoMap 中没有该联系人,则单独获取
|
||||
if (!info) {
|
||||
const [displayNameRes, avatarUrlRes] = await Promise.all([
|
||||
wcdbService.getDisplayNames([bestSid]),
|
||||
wcdbService.getAvatarUrls([bestSid])
|
||||
])
|
||||
info = {
|
||||
displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid,
|
||||
avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined
|
||||
}
|
||||
}
|
||||
lostFriend = {
|
||||
username: bestSid,
|
||||
displayName: info?.displayName || bestSid,
|
||||
avatarUrl: info?.avatarUrl,
|
||||
earlyCount: bestEarlyCount,
|
||||
lateCount: bestLateCount,
|
||||
periodDesc: bestPeriodDesc
|
||||
}
|
||||
}
|
||||
|
||||
const reportData: AnnualReportData = {
|
||||
year,
|
||||
year: reportYear,
|
||||
totalMessages,
|
||||
totalFriends: contactStats.size,
|
||||
coreFriends,
|
||||
@@ -915,7 +1094,9 @@ class AnnualReportService {
|
||||
mutualFriend,
|
||||
socialInitiative,
|
||||
responseSpeed,
|
||||
topPhrases
|
||||
topPhrases,
|
||||
snsStats: snsStatsResult,
|
||||
lostFriend
|
||||
}
|
||||
|
||||
return { success: true, data: reportData }
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ConfigSchema {
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
authEnabled: boolean
|
||||
@@ -62,6 +63,7 @@ export class ConfigService {
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 2,
|
||||
analyticsExcludedUsernames: [],
|
||||
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
|
||||
@@ -118,6 +118,48 @@ export class DbPathService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||
*/
|
||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||
const wxids: WxidInfo[] = []
|
||||
|
||||
try {
|
||||
if (existsSync(rootPath)) {
|
||||
const entries = readdirSync(rootPath)
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(entryPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!stat.isDirectory()) continue
|
||||
const lower = entry.toLowerCase()
|
||||
if (lower === 'all_users') continue
|
||||
if (!entry.includes('_')) continue
|
||||
|
||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||
}
|
||||
}
|
||||
|
||||
if (wxids.length === 0) {
|
||||
const rootName = basename(rootPath)
|
||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||
const rootStat = statSync(rootPath)
|
||||
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 wxid 列表
|
||||
*/
|
||||
|
||||
456
electron/services/dualReportService.ts
Normal file
456
electron/services/dualReportService.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
export interface DualReportMessage {
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
}
|
||||
|
||||
export interface DualReportFirstChat {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
senderUsername?: string
|
||||
}
|
||||
|
||||
export interface DualReportStats {
|
||||
totalMessages: number
|
||||
totalWords: number
|
||||
imageCount: number
|
||||
voiceCount: number
|
||||
emojiCount: number
|
||||
myTopEmojiMd5?: string
|
||||
friendTopEmojiMd5?: string
|
||||
myTopEmojiUrl?: string
|
||||
friendTopEmojiUrl?: string
|
||||
}
|
||||
|
||||
export interface DualReportData {
|
||||
year: number
|
||||
selfName: string
|
||||
friendUsername: string
|
||||
friendName: string
|
||||
firstChat: DualReportFirstChat | null
|
||||
firstChatMessages?: DualReportMessage[]
|
||||
yearFirstChat?: {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
friendName: string
|
||||
firstThreeMessages: DualReportMessage[]
|
||||
} | null
|
||||
stats: DualReportStats
|
||||
topPhrases: Array<{ phrase: string; count: number }>
|
||||
}
|
||||
|
||||
class DualReportService {
|
||||
private broadcastProgress(status: string, progress: number) {
|
||||
if (parentPort) {
|
||||
parentPort.postMessage({
|
||||
type: 'dualReport:progress',
|
||||
data: { status, progress }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
|
||||
if (onProgress) {
|
||||
onProgress(status, progress)
|
||||
return
|
||||
}
|
||||
this.broadcastProgress(status, progress)
|
||||
}
|
||||
|
||||
private cleanAccountDirName(dirName: string): string {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private async ensureConnectedWithConfig(
|
||||
dbPath: string,
|
||||
decryptKey: string,
|
||||
wxid: string
|
||||
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
|
||||
if (!wxid) return { success: false, error: '未配置微信ID' }
|
||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||
return { success: true, cleanedWxid, rawWxid: wxid }
|
||||
}
|
||||
|
||||
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||
let content = this.decodeMaybeCompressed(compressContent)
|
||||
if (!content || content.length === 0) {
|
||||
content = this.decodeMaybeCompressed(messageContent)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
private decodeMaybeCompressed(raw: any): string {
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length === 0) return ''
|
||||
if (this.looksLikeHex(raw)) {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private decodeBinaryContent(data: Buffer): string {
|
||||
if (data.length === 0) return ''
|
||||
try {
|
||||
if (data.length >= 4) {
|
||||
const magic = data.readUInt32LE(0)
|
||||
if (magic === 0xFD2FB528) {
|
||||
const fzstd = require('fzstd')
|
||||
const decompressed = fzstd.decompress(data)
|
||||
return Buffer.from(decompressed).toString('utf-8')
|
||||
}
|
||||
}
|
||||
const decoded = data.toString('utf-8')
|
||||
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
|
||||
if (replacementCount < decoded.length * 0.2) {
|
||||
return decoded.replace(/\uFFFD/g, '')
|
||||
}
|
||||
return data.toString('latin1')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private looksLikeHex(s: string): boolean {
|
||||
if (s.length % 2 !== 0) return false
|
||||
return /^[0-9a-fA-F]+$/.test(s)
|
||||
}
|
||||
|
||||
private looksLikeBase64(s: string): boolean {
|
||||
if (s.length % 4 !== 0) return false
|
||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||
}
|
||||
|
||||
private formatDateTime(milliseconds: number): string {
|
||||
const dt = new Date(milliseconds)
|
||||
const month = String(dt.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(dt.getDate()).padStart(2, '0')
|
||||
const hour = String(dt.getHours()).padStart(2, '0')
|
||||
const minute = String(dt.getMinutes()).padStart(2, '0')
|
||||
return `${month}/${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
private extractEmojiUrl(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|
||||
if (attrMatch) {
|
||||
let url = attrMatch[1].replace(/&/g, '&')
|
||||
try {
|
||||
if (url.includes('%')) {
|
||||
url = decodeURIComponent(url)
|
||||
}
|
||||
} catch { }
|
||||
return url
|
||||
}
|
||||
const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content)
|
||||
return tagMatch?.[1]
|
||||
}
|
||||
|
||||
private extractEmojiMd5(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
private async getDisplayName(username: string, fallback: string): Promise<string> {
|
||||
const result = await wcdbService.getDisplayNames([username])
|
||||
if (result.success && result.map) {
|
||||
return result.map[username] || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean {
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send
|
||||
if (isSendRaw !== undefined && isSendRaw !== null) {
|
||||
return parseInt(isSendRaw, 10) === 1
|
||||
}
|
||||
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
|
||||
if (!sender) return false
|
||||
const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
|
||||
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
|
||||
return sender === rawLower || sender === cleanedLower
|
||||
}
|
||||
|
||||
private async getFirstMessages(
|
||||
sessionId: string,
|
||||
limit: number,
|
||||
beginTimestamp: number,
|
||||
endTimestamp: number
|
||||
): Promise<any[]> {
|
||||
const safeBegin = Math.max(0, beginTimestamp || 0)
|
||||
const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000)
|
||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd)
|
||||
if (!cursorResult.success || !cursorResult.cursor) return []
|
||||
try {
|
||||
const rows: any[] = []
|
||||
let hasMore = true
|
||||
while (hasMore && rows.length < limit) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||
if (!batch.success || !batch.rows) break
|
||||
for (const row of batch.rows) {
|
||||
rows.push(row)
|
||||
if (rows.length >= limit) break
|
||||
}
|
||||
hasMore = batch.hasMore === true
|
||||
}
|
||||
return rows.slice(0, limit)
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
async generateReportWithConfig(params: {
|
||||
year: number
|
||||
friendUsername: string
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
wxid: string
|
||||
onProgress?: (status: string, progress: number) => void
|
||||
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
|
||||
try {
|
||||
const { year, friendUsername, dbPath, decryptKey, wxid, onProgress } = params
|
||||
this.reportProgress('正在连接数据库...', 5, onProgress)
|
||||
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
|
||||
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
|
||||
|
||||
const cleanedWxid = conn.cleanedWxid
|
||||
const rawWxid = conn.rawWxid
|
||||
|
||||
const reportYear = year <= 0 ? 0 : year
|
||||
const isAllTime = reportYear === 0
|
||||
const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
|
||||
const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||
|
||||
this.reportProgress('加载联系人信息...', 10, onProgress)
|
||||
const friendName = await this.getDisplayName(friendUsername, friendUsername)
|
||||
let myName = await this.getDisplayName(rawWxid, rawWxid)
|
||||
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
|
||||
myName = await this.getDisplayName(cleanedWxid, rawWxid)
|
||||
}
|
||||
|
||||
this.reportProgress('获取首条聊天记录...', 15, onProgress)
|
||||
const firstRows = await this.getFirstMessages(friendUsername, 3, 0, 0)
|
||||
let firstChat: DualReportFirstChat | null = null
|
||||
if (firstRows.length > 0) {
|
||||
const row = firstRows[0]
|
||||
const createTime = parseInt(row.create_time || '0', 10) * 1000
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
firstChat = {
|
||||
createTime,
|
||||
createTimeStr: this.formatDateTime(createTime),
|
||||
content: String(content || ''),
|
||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||
senderUsername: row.sender_username || row.sender
|
||||
}
|
||||
}
|
||||
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
|
||||
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
||||
const msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
return {
|
||||
content: String(msgContent || ''),
|
||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||
createTime: msgTime,
|
||||
createTimeStr: this.formatDateTime(msgTime)
|
||||
}
|
||||
})
|
||||
|
||||
let yearFirstChat: DualReportData['yearFirstChat'] = null
|
||||
if (!isAllTime) {
|
||||
this.reportProgress('获取今年首次聊天...', 20, onProgress)
|
||||
const firstYearRows = await this.getFirstMessages(friendUsername, 3, startTime, endTime)
|
||||
if (firstYearRows.length > 0) {
|
||||
const firstRow = firstYearRows[0]
|
||||
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
|
||||
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
|
||||
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
||||
const msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
return {
|
||||
content: String(msgContent || ''),
|
||||
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||
createTime: msgTime,
|
||||
createTimeStr: this.formatDateTime(msgTime)
|
||||
}
|
||||
})
|
||||
yearFirstChat = {
|
||||
createTime,
|
||||
createTimeStr: this.formatDateTime(createTime),
|
||||
content: String(this.decodeMessageContent(firstRow.message_content, firstRow.compress_content) || ''),
|
||||
isSentByMe: this.resolveIsSent(firstRow, rawWxid, cleanedWxid),
|
||||
friendName,
|
||||
firstThreeMessages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.reportProgress('统计聊天数据...', 30, onProgress)
|
||||
const stats: DualReportStats = {
|
||||
totalMessages: 0,
|
||||
totalWords: 0,
|
||||
imageCount: 0,
|
||||
voiceCount: 0,
|
||||
emojiCount: 0
|
||||
}
|
||||
const wordCountMap = new Map<string, number>()
|
||||
const myEmojiCounts = new Map<string, number>()
|
||||
const friendEmojiCounts = new Map<string, number>()
|
||||
const myEmojiUrlMap = new Map<string, string>()
|
||||
const friendEmojiUrlMap = new Map<string, string>()
|
||||
|
||||
const messageCountResult = await wcdbService.getMessageCount(friendUsername)
|
||||
const totalForProgress = messageCountResult.success && messageCountResult.count
|
||||
? messageCountResult.count
|
||||
: 0
|
||||
let processed = 0
|
||||
let lastProgressAt = 0
|
||||
|
||||
const cursorResult = await wcdbService.openMessageCursor(friendUsername, 1000, true, startTime, endTime)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||
}
|
||||
|
||||
try {
|
||||
let hasMore = true
|
||||
while (hasMore) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||
if (!batch.success || !batch.rows) break
|
||||
for (const row of batch.rows) {
|
||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||
const isSent = this.resolveIsSent(row, rawWxid, cleanedWxid)
|
||||
stats.totalMessages += 1
|
||||
|
||||
if (localType === 3) stats.imageCount += 1
|
||||
if (localType === 34) stats.voiceCount += 1
|
||||
if (localType === 47) {
|
||||
stats.emojiCount += 1
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const md5 = this.extractEmojiMd5(content)
|
||||
const url = this.extractEmojiUrl(content)
|
||||
if (md5) {
|
||||
const targetMap = isSent ? myEmojiCounts : friendEmojiCounts
|
||||
targetMap.set(md5, (targetMap.get(md5) || 0) + 1)
|
||||
if (url) {
|
||||
const urlMap = isSent ? myEmojiUrlMap : friendEmojiUrlMap
|
||||
if (!urlMap.has(md5)) urlMap.set(md5, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (localType === 1 || localType === 244813135921) {
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
const text = String(content || '').trim()
|
||||
if (text.length > 0) {
|
||||
stats.totalWords += text.replace(/\s+/g, '').length
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
if (normalized.length >= 2 &&
|
||||
normalized.length <= 50 &&
|
||||
!normalized.includes('http') &&
|
||||
!normalized.includes('<') &&
|
||||
!normalized.startsWith('[') &&
|
||||
!normalized.startsWith('<?xml')) {
|
||||
wordCountMap.set(normalized, (wordCountMap.get(normalized) || 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalForProgress > 0) {
|
||||
processed++
|
||||
}
|
||||
}
|
||||
hasMore = batch.hasMore === true
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastProgressAt > 200) {
|
||||
if (totalForProgress > 0) {
|
||||
const ratio = Math.min(1, processed / totalForProgress)
|
||||
const progress = 30 + Math.floor(ratio * 50)
|
||||
this.reportProgress('统计聊天数据...', progress, onProgress)
|
||||
}
|
||||
lastProgressAt = now
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||
}
|
||||
|
||||
const pickTop = (map: Map<string, number>): string | undefined => {
|
||||
let topKey: string | undefined
|
||||
let topCount = -1
|
||||
for (const [key, count] of map.entries()) {
|
||||
if (count > topCount) {
|
||||
topCount = count
|
||||
topKey = key
|
||||
}
|
||||
}
|
||||
return topKey
|
||||
}
|
||||
|
||||
const myTopEmojiMd5 = pickTop(myEmojiCounts)
|
||||
const friendTopEmojiMd5 = pickTop(friendEmojiCounts)
|
||||
|
||||
stats.myTopEmojiMd5 = myTopEmojiMd5
|
||||
stats.friendTopEmojiMd5 = friendTopEmojiMd5
|
||||
stats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined
|
||||
stats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined
|
||||
|
||||
this.reportProgress('生成常用语词云...', 85, onProgress)
|
||||
const topPhrases = Array.from(wordCountMap.entries())
|
||||
.filter(([_, count]) => count >= 2)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 50)
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
|
||||
const reportData: DualReportData = {
|
||||
year: reportYear,
|
||||
selfName: myName,
|
||||
friendUsername,
|
||||
friendName,
|
||||
firstChat,
|
||||
firstChatMessages,
|
||||
yearFirstChat,
|
||||
stats,
|
||||
topPhrases
|
||||
}
|
||||
|
||||
this.reportProgress('双人报告生成完成', 100, onProgress)
|
||||
return { success: true, data: reportData }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dualReportService = new DualReportService()
|
||||
@@ -73,6 +73,7 @@ export interface ExportOptions {
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
exportVoices?: boolean
|
||||
exportVideos?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportVoiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
@@ -141,6 +142,12 @@ class ExportService {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
||||
const raw = Math.floor(value)
|
||||
return Math.max(1, Math.min(raw, max))
|
||||
}
|
||||
|
||||
private cleanAccountDirName(dirName: string): string {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
@@ -186,147 +193,34 @@ class ExportService {
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
|
||||
* ext_buffer 包含类似 protobuf 编码的数据,格式示例:
|
||||
* wxid_xxx<binary>群昵称<binary>wxid_yyy<binary>群昵称...
|
||||
*/
|
||||
private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map<string, string> {
|
||||
const nicknameMap = new Map<string, string>()
|
||||
|
||||
try {
|
||||
// 将 buffer 转为字符串,允许部分乱码
|
||||
const raw = buffer.toString('utf8')
|
||||
|
||||
// 提取所有 wxid 格式的字符串: wxid_ 或 wxid_后跟字母数字下划线
|
||||
const wxidPattern = /wxid_[a-z0-9_]+/gi
|
||||
const wxids = raw.match(wxidPattern) || []
|
||||
|
||||
// 对每个 wxid,尝试提取其后的群昵称
|
||||
for (const wxid of wxids) {
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const wxidIndex = raw.toLowerCase().indexOf(wxidLower)
|
||||
|
||||
if (wxidIndex === -1) continue
|
||||
|
||||
// 从 wxid 结束位置开始查找
|
||||
const afterWxid = raw.slice(wxidIndex + wxid.length)
|
||||
|
||||
// 提取紧跟在 wxid 后面的可打印字符(中文、字母、数字等)
|
||||
// 跳过前面的不可打印字符和特定控制字符
|
||||
let nickname = ''
|
||||
let foundStart = false
|
||||
|
||||
for (let i = 0; i < afterWxid.length && i < 100; i++) {
|
||||
const char = afterWxid[i]
|
||||
const code = char.charCodeAt(0)
|
||||
|
||||
// 判断是否为可打印字符(中文、字母、数字、常见符号)
|
||||
const isPrintable = (
|
||||
(code >= 0x4E00 && code <= 0x9FFF) || // 中文
|
||||
(code >= 0x3000 && code <= 0x303F) || // CJK 符号
|
||||
(code >= 0xFF00 && code <= 0xFFEF) || // 全角字符
|
||||
(code >= 0x20 && code <= 0x7E) // ASCII 可打印字符
|
||||
)
|
||||
|
||||
if (isPrintable && code !== 0x01 && code !== 0x18) {
|
||||
foundStart = true
|
||||
nickname += char
|
||||
} else if (foundStart) {
|
||||
// 遇到不可打印字符,停止
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 清理昵称:去除前后空白和特殊字符
|
||||
nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '')
|
||||
|
||||
// 只保存有效的群昵称(长度 > 0 且 < 50)
|
||||
if (nickname && nickname.length > 0 && nickname.length < 50) {
|
||||
nicknameMap.set(wxidLower, nickname)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败时返回空 Map
|
||||
console.error('Failed to parse ext_buffer:', e)
|
||||
}
|
||||
|
||||
return nicknameMap
|
||||
private async preloadContacts(
|
||||
usernames: Iterable<string>,
|
||||
cache: Map<string, { success: boolean; contact?: any; error?: string }>,
|
||||
limit = 8
|
||||
): Promise<void> {
|
||||
const unique = Array.from(new Set(Array.from(usernames).filter(Boolean)))
|
||||
if (unique.length === 0) return
|
||||
await parallelLimit(unique, limit, async (username) => {
|
||||
if (cache.has(username)) return
|
||||
const result = await wcdbService.getContact(username)
|
||||
cache.set(username, result)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 contact.db 的 chat_room 表获取群成员的群昵称
|
||||
* @param chatroomId 群聊ID (如 "xxxxx@chatroom")
|
||||
* @returns Map<wxid, 群昵称>
|
||||
* 从 DLL 获取群成员的群昵称
|
||||
*/
|
||||
async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
||||
console.log('========== getGroupNicknamesForRoom START ==========', chatroomId)
|
||||
try {
|
||||
// 查询 contact.db 的 chat_room 表
|
||||
// path设为null,因为contact.db已经随handle一起打开了
|
||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'`
|
||||
console.log('执行SQL查询:', sql)
|
||||
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
console.log('execQuery结果:', { success: result.success, rowCount: result.rows?.length, error: result.error })
|
||||
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
console.log('❌ 群昵称查询失败或无数据:', chatroomId, result.error)
|
||||
return new Map<string, string>()
|
||||
const result = await wcdbService.getGroupNicknames(chatroomId)
|
||||
if (result.success && result.nicknames) {
|
||||
return new Map(Object.entries(result.nicknames))
|
||||
}
|
||||
|
||||
let extBuffer = result.rows[0].ext_buffer
|
||||
console.log('ext_buffer原始类型:', typeof extBuffer, 'isBuffer:', Buffer.isBuffer(extBuffer))
|
||||
|
||||
// execQuery返回的二进制数据会被编码为字符串(hex或base64)
|
||||
// 需要转换回Buffer
|
||||
if (typeof extBuffer === 'string') {
|
||||
console.log('🔄 ext_buffer是字符串,尝试转换为Buffer...')
|
||||
|
||||
// 尝试判断是hex还是base64
|
||||
if (this.looksLikeHex(extBuffer)) {
|
||||
console.log('✅ 检测到hex编码,使用hex解码')
|
||||
extBuffer = Buffer.from(extBuffer, 'hex')
|
||||
} else if (this.looksLikeBase64(extBuffer)) {
|
||||
console.log('✅ 检测到base64编码,使用base64解码')
|
||||
extBuffer = Buffer.from(extBuffer, 'base64')
|
||||
} else {
|
||||
// 默认尝试hex
|
||||
console.log(' 无法判断编码格式,默认尝试hex')
|
||||
try {
|
||||
extBuffer = Buffer.from(extBuffer, 'hex')
|
||||
return new Map<string, string>()
|
||||
} catch (e) {
|
||||
console.log('❌ hex解码失败,尝试base64')
|
||||
extBuffer = Buffer.from(extBuffer, 'base64')
|
||||
}
|
||||
}
|
||||
console.log('✅ 转换后的Buffer长度:', extBuffer.length)
|
||||
}
|
||||
|
||||
if (!extBuffer || !Buffer.isBuffer(extBuffer)) {
|
||||
console.log('❌ ext_buffer转换失败,不是Buffer类型:', typeof extBuffer)
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
|
||||
console.log('✅ 开始解析ext_buffer, 长度:', extBuffer.length)
|
||||
const nicknamesMap = this.parseGroupNicknamesFromExtBuffer(extBuffer)
|
||||
console.log('✅ 解析完成, 找到', nicknamesMap.size, '个群昵称')
|
||||
|
||||
// 打印前5个群昵称作为示例
|
||||
let count = 0
|
||||
for (const [wxid, nickname] of nicknamesMap.entries()) {
|
||||
if (count++ < 5) {
|
||||
console.log(` - ${wxid}: "${nickname}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return nicknamesMap
|
||||
} catch (e) {
|
||||
console.error('❌ getGroupNicknamesForRoom异常:', e)
|
||||
return new Map<string, string>()
|
||||
} finally {
|
||||
console.log('========== getGroupNicknamesForRoom END ==========')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,6 +305,15 @@ class ExportService {
|
||||
return /^[0-9a-fA-F]+$/.test(s)
|
||||
}
|
||||
|
||||
private normalizeGroupNickname(value: string): string {
|
||||
const trimmed = (value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
const cleaned = trimmed.replace(/[\x00-\x1F\x7F]/g, '')
|
||||
if (!cleaned) return ''
|
||||
if (/^[,"'“”‘’,、]+$/.test(cleaned)) return ''
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户偏好获取显示名称
|
||||
*/
|
||||
@@ -859,10 +762,10 @@ class ExportService {
|
||||
options: {
|
||||
exportImages?: boolean
|
||||
exportVoices?: boolean
|
||||
exportVideos?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportVoiceAsText?: boolean
|
||||
includeVoiceWithTranscript?: boolean
|
||||
exportVideos?: boolean
|
||||
}
|
||||
): Promise<MediaExportItem | null> {
|
||||
const localType = msg.localType
|
||||
@@ -877,8 +780,7 @@ class ExportService {
|
||||
|
||||
// 语音消息
|
||||
if (localType === 34) {
|
||||
const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText
|
||||
if (shouldKeepVoiceFile && options.exportVoices) {
|
||||
if (options.exportVoices) {
|
||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
}
|
||||
if (options.exportVoiceAsText) {
|
||||
@@ -1233,7 +1135,7 @@ class ExportService {
|
||||
mediaRelativePrefix: string
|
||||
} {
|
||||
const exportMediaEnabled = options.exportMedia === true &&
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||
const outputDir = path.dirname(outputPath)
|
||||
const outputBaseName = path.basename(outputPath, path.extname(outputPath))
|
||||
const useSharedMediaLayout = options.sessionLayout === 'shared'
|
||||
@@ -1575,6 +1477,87 @@ class ExportService {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出头像为外部文件(仅用于HTML格式)
|
||||
* 将头像保存到 avatars/ 子目录,返回相对路径
|
||||
*/
|
||||
private async exportAvatarsToFiles(
|
||||
members: Array<{ username: string; avatarUrl?: string }>,
|
||||
outputDir: string
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>()
|
||||
if (members.length === 0) return result
|
||||
|
||||
// 创建 avatars 子目录
|
||||
const avatarsDir = path.join(outputDir, 'avatars')
|
||||
if (!fs.existsSync(avatarsDir)) {
|
||||
fs.mkdirSync(avatarsDir, { recursive: true })
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const fileInfo = this.resolveAvatarFile(member.avatarUrl)
|
||||
if (!fileInfo) continue
|
||||
try {
|
||||
let data: Buffer | null = null
|
||||
let mime = fileInfo.mime
|
||||
if (fileInfo.data) {
|
||||
data = fileInfo.data
|
||||
} else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) {
|
||||
data = await fs.promises.readFile(fileInfo.sourcePath)
|
||||
} else if (fileInfo.sourceUrl) {
|
||||
const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl)
|
||||
if (downloaded) {
|
||||
data = downloaded.data
|
||||
mime = downloaded.mime || mime
|
||||
}
|
||||
}
|
||||
if (!data) continue
|
||||
|
||||
// 优先使用内容检测出的 MIME 类型
|
||||
const detectedMime = this.detectMimeType(data)
|
||||
const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext)
|
||||
|
||||
// 根据 MIME 类型确定文件扩展名
|
||||
const ext = this.getExtensionFromMime(finalMime)
|
||||
|
||||
// 清理用户名作为文件名(移除非法字符,限制长度)
|
||||
const sanitizedUsername = member.username
|
||||
.replace(/[<>:"/\\|?*@]/g, '_')
|
||||
.substring(0, 100)
|
||||
|
||||
const filename = `${sanitizedUsername}${ext}`
|
||||
const avatarPath = path.join(avatarsDir, filename)
|
||||
|
||||
// 保存头像文件
|
||||
await fs.promises.writeFile(avatarPath, data)
|
||||
|
||||
// 返回相对路径
|
||||
result.set(member.username, `avatars/${filename}`)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private getExtensionFromMime(mime: string): string {
|
||||
switch (mime) {
|
||||
case 'image/png':
|
||||
return '.png'
|
||||
case 'image/gif':
|
||||
return '.gif'
|
||||
case 'image/webp':
|
||||
return '.webp'
|
||||
case 'image/bmp':
|
||||
return '.bmp'
|
||||
case 'image/jpeg':
|
||||
default:
|
||||
return '.jpg'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private detectMimeType(buffer: Buffer): string | null {
|
||||
if (buffer.length < 4) return null
|
||||
|
||||
@@ -1681,10 +1664,6 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
if (options.exportVoiceAsText) {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const allMessages = collected.rows
|
||||
|
||||
@@ -1693,6 +1672,14 @@ class ExportService {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? allMessages.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||
}
|
||||
@@ -1707,7 +1694,8 @@ class ExportService {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) || // 图片
|
||||
(t === 47 && options.exportEmojis) || // 表情
|
||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字)
|
||||
(t === 43 && options.exportVideos) || // 视频
|
||||
(t === 34 && options.exportVoices) // 语音文件
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -1721,14 +1709,15 @@ class ExportService {
|
||||
phase: 'exporting-media'
|
||||
})
|
||||
|
||||
// 并行导出媒体,限制 8 个并发
|
||||
const MEDIA_CONCURRENCY = 8
|
||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||
// 并行导出媒体,并发数跟随导出设置
|
||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
@@ -1738,10 +1727,6 @@ class ExportService {
|
||||
}
|
||||
|
||||
// ========== 阶段2:并行语音转文字 ==========
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? allMessages.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
@@ -1895,10 +1880,6 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
if (options.exportVoiceAsText) {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
@@ -1906,6 +1887,21 @@ class ExportService {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? collected.rows.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const senderUsernames = new Set<string>()
|
||||
for (const msg of collected.rows) {
|
||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||
}
|
||||
senderUsernames.add(sessionId)
|
||||
await this.preloadContacts(senderUsernames, contactCache)
|
||||
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
|
||||
// ========== 阶段1:并行导出媒体文件 ==========
|
||||
@@ -1914,7 +1910,8 @@ class ExportService {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices)
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -1928,13 +1925,14 @@ class ExportService {
|
||||
phase: 'exporting-media'
|
||||
})
|
||||
|
||||
const MEDIA_CONCURRENCY = 8
|
||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
@@ -1944,10 +1942,6 @@ class ExportService {
|
||||
}
|
||||
|
||||
// ========== 阶段2:并行语音转文字 ==========
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? collected.rows.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
@@ -1988,10 +1982,10 @@ class ExportService {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
|
||||
if (mediaItem) {
|
||||
content = mediaItem.relativePath
|
||||
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
} else if (mediaItem) {
|
||||
content = mediaItem.relativePath
|
||||
} else {
|
||||
content = this.parseMessageContent(msg.content, msg.localType)
|
||||
}
|
||||
@@ -2003,7 +1997,7 @@ class ExportService {
|
||||
? contact.contact.nickName
|
||||
: (senderInfo.displayName || senderWxid)
|
||||
const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : ''
|
||||
const senderGroupNickname = groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || ''
|
||||
const senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || '')
|
||||
|
||||
// 使用用户偏好的显示名称
|
||||
const senderDisplayName = this.getPreferredDisplayName(
|
||||
@@ -2049,7 +2043,7 @@ class ExportService {
|
||||
? sessionContact.contact.remark
|
||||
: ''
|
||||
const sessionGroupNickname = isGroup
|
||||
? (groupNicknamesMap.get(sessionId.toLowerCase()) || '')
|
||||
? this.normalizeGroupNickname(groupNicknamesMap.get(sessionId.toLowerCase()) || '')
|
||||
: ''
|
||||
|
||||
// 使用用户偏好的显示名称
|
||||
@@ -2156,10 +2150,6 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
if (options.exportVoiceAsText) {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
@@ -2167,6 +2157,21 @@ class ExportService {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? collected.rows.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const senderUsernames = new Set<string>()
|
||||
for (const msg of collected.rows) {
|
||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||
}
|
||||
senderUsernames.add(sessionId)
|
||||
await this.preloadContacts(senderUsernames, contactCache)
|
||||
|
||||
onProgress?.({
|
||||
current: 30,
|
||||
total: 100,
|
||||
@@ -2278,11 +2283,9 @@ class ExportService {
|
||||
}
|
||||
|
||||
// 预加载群昵称 (仅群聊且完整列模式)
|
||||
console.log('预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId)
|
||||
const groupNicknamesMap = (isGroup && !useCompactColumns)
|
||||
? await this.getGroupNicknamesForRoom(sessionId)
|
||||
: new Map<string, string>()
|
||||
console.log('群昵称Map大小:', groupNicknamesMap.size)
|
||||
|
||||
|
||||
// 填充数据
|
||||
@@ -2297,7 +2300,8 @@ class ExportService {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices)
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -2311,13 +2315,14 @@ class ExportService {
|
||||
phase: 'exporting-media'
|
||||
})
|
||||
|
||||
const MEDIA_CONCURRENCY = 8
|
||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
@@ -2327,10 +2332,6 @@ class ExportService {
|
||||
}
|
||||
|
||||
// ========== 并行预处理:语音转文字 ==========
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? sortedMessages.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
@@ -2407,7 +2408,7 @@ class ExportService {
|
||||
|
||||
// 获取群昵称 (仅群聊且完整列模式)
|
||||
if (isGroup && !useCompactColumns && senderWxid) {
|
||||
senderGroupNickname = groupNicknamesMap.get(senderWxid.toLowerCase()) || ''
|
||||
senderGroupNickname = this.normalizeGroupNickname(groupNicknamesMap.get(senderWxid.toLowerCase()) || '')
|
||||
}
|
||||
|
||||
|
||||
@@ -2416,13 +2417,21 @@ class ExportService {
|
||||
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
const contentValue = mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|
||||
const contentValue = shouldUseTranscript
|
||||
? this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
)
|
||||
: (mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
))
|
||||
|
||||
// 调试日志
|
||||
if (msg.localType === 3 || msg.localType === 47) {
|
||||
@@ -2549,6 +2558,16 @@ class ExportService {
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||
|
||||
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||
const getContactCached = async (username: string) => {
|
||||
if (contactCache.has(username)) {
|
||||
return contactCache.get(username)!
|
||||
}
|
||||
const result = await wcdbService.getContact(username)
|
||||
contactCache.set(username, result)
|
||||
return result
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 0,
|
||||
total: 100,
|
||||
@@ -2556,10 +2575,6 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
if (options.exportVoiceAsText) {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
|
||||
// 如果没有消息,不创建文件
|
||||
@@ -2567,6 +2582,21 @@ class ExportService {
|
||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||
}
|
||||
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? collected.rows.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
if (options.exportVoiceAsText && voiceMessages.length > 0) {
|
||||
await this.ensureVoiceModel(onProgress)
|
||||
}
|
||||
|
||||
const senderUsernames = new Set<string>()
|
||||
for (const msg of collected.rows) {
|
||||
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||
}
|
||||
senderUsernames.add(sessionId)
|
||||
await this.preloadContacts(senderUsernames, contactCache)
|
||||
|
||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
@@ -2575,7 +2605,8 @@ class ExportService {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices)
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -2589,13 +2620,14 @@ class ExportService {
|
||||
phase: 'exporting-media'
|
||||
})
|
||||
|
||||
const MEDIA_CONCURRENCY = 8
|
||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||
const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency)
|
||||
await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
@@ -2604,9 +2636,6 @@ class ExportService {
|
||||
})
|
||||
}
|
||||
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? sortedMessages.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
@@ -2637,13 +2666,21 @@ class ExportService {
|
||||
const msg = sortedMessages[i]
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
const contentValue = mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
|
||||
const contentValue = shouldUseTranscript
|
||||
? this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
)
|
||||
: (mediaItem?.relativePath
|
||||
|| this.formatPlainExportContent(
|
||||
msg.content,
|
||||
msg.localType,
|
||||
options,
|
||||
voiceTranscriptMap.get(msg.localId)
|
||||
))
|
||||
|
||||
let senderRole: string
|
||||
let senderWxid: string
|
||||
@@ -2763,7 +2800,7 @@ class ExportService {
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 34 && options.exportVoices) ||
|
||||
t === 43
|
||||
(t === 43 && options.exportVideos)
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -2787,7 +2824,7 @@ class ExportService {
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVoiceWithTranscript: true,
|
||||
exportVideos: true
|
||||
exportVideos: options.exportVideos
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
@@ -2816,7 +2853,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
const avatarMap = options.exportAvatars
|
||||
? await this.exportAvatars(
|
||||
? await this.exportAvatarsToFiles(
|
||||
[
|
||||
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
||||
username,
|
||||
@@ -2824,7 +2861,8 @@ class ExportService {
|
||||
})),
|
||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
||||
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
||||
]
|
||||
],
|
||||
path.dirname(outputPath)
|
||||
)
|
||||
: new Map<string, string>()
|
||||
|
||||
@@ -2841,7 +2879,7 @@ class ExportService {
|
||||
: (sessionInfo.displayName || sessionId))
|
||||
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
|
||||
const avatarHtml = avatarData
|
||||
? `<img src="${this.escapeAttribute(avatarData)}" alt="${this.escapeAttribute(senderName)}" />`
|
||||
? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(senderName)}" />`
|
||||
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
|
||||
|
||||
const timeText = this.formatTimestamp(msg.createTime)
|
||||
@@ -3094,7 +3132,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
const exportMediaEnabled = options.exportMedia === true &&
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||
const sessionLayout = exportMediaEnabled
|
||||
? (options.sessionLayout ?? 'per-session')
|
||||
: 'shared'
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { chatService } from './chatService'
|
||||
|
||||
export interface GroupChatInfo {
|
||||
username: string
|
||||
@@ -12,6 +16,10 @@ export interface GroupMember {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
nickname?: string
|
||||
alias?: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
}
|
||||
|
||||
export interface GroupMessageRank {
|
||||
@@ -41,6 +49,30 @@ class GroupAnalyticsService {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
// 并发控制:限制同时执行的 Promise 数量
|
||||
private async parallelLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let currentIndex = 0
|
||||
|
||||
async function runNext(): Promise<void> {
|
||||
while (currentIndex < items.length) {
|
||||
const index = currentIndex++
|
||||
results[index] = await fn(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array(Math.min(limit, items.length))
|
||||
.fill(null)
|
||||
.map(() => runNext())
|
||||
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
private cleanAccountDirName(name: string): string {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return trimmed
|
||||
@@ -65,6 +97,56 @@ class GroupAnalyticsService {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 DLL 获取群成员的群昵称
|
||||
*/
|
||||
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
||||
try {
|
||||
const result = await wcdbService.getGroupNicknames(chatroomId)
|
||||
if (result.success && result.nicknames) {
|
||||
return new Map(Object.entries(result.nicknames))
|
||||
}
|
||||
return new Map<string, string>()
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
}
|
||||
|
||||
private escapeCsvValue(value: string): string {
|
||||
if (value == null) return ''
|
||||
const str = String(value)
|
||||
if (/[",\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
private normalizeGroupNickname(value: string, wxid: string, fallback: string): string {
|
||||
const trimmed = (value || '').trim()
|
||||
if (!trimmed) return fallback
|
||||
if (/^["'@]+$/.test(trimmed)) return fallback
|
||||
if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private sanitizeWorksheetName(name: string): string {
|
||||
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
|
||||
const limited = cleaned.slice(0, 31)
|
||||
return limited || 'Sheet1'
|
||||
}
|
||||
|
||||
private formatDateTime(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
const month = pad(date.getMonth() + 1)
|
||||
const day = pad(date.getDate())
|
||||
const hour = pad(date.getHours())
|
||||
const minute = pad(date.getMinutes())
|
||||
const second = pad(date.getSeconds())
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||
}
|
||||
|
||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
@@ -80,23 +162,38 @@ class GroupAnalyticsService {
|
||||
.map((row) => row.username || row.user_name || row.userName || '')
|
||||
.filter((username) => username.includes('@chatroom'))
|
||||
|
||||
const [displayNames, avatarUrls, memberCounts] = await Promise.all([
|
||||
wcdbService.getDisplayNames(groupIds),
|
||||
wcdbService.getAvatarUrls(groupIds),
|
||||
wcdbService.getGroupMemberCounts(groupIds)
|
||||
const [memberCounts, contactInfo] = await Promise.all([
|
||||
wcdbService.getGroupMemberCounts(groupIds),
|
||||
chatService.enrichSessionsContactInfo(groupIds)
|
||||
])
|
||||
|
||||
let fallbackNames: { success: boolean; map?: Record<string, string> } | null = null
|
||||
let fallbackAvatars: { success: boolean; map?: Record<string, string> } | null = null
|
||||
if (!contactInfo.success || !contactInfo.contacts) {
|
||||
const [displayNames, avatarUrls] = await Promise.all([
|
||||
wcdbService.getDisplayNames(groupIds),
|
||||
wcdbService.getAvatarUrls(groupIds)
|
||||
])
|
||||
fallbackNames = displayNames
|
||||
fallbackAvatars = avatarUrls
|
||||
}
|
||||
|
||||
const groups: GroupChatInfo[] = []
|
||||
for (const groupId of groupIds) {
|
||||
const contact = contactInfo.success && contactInfo.contacts ? contactInfo.contacts[groupId] : undefined
|
||||
const displayName = contact?.displayName ||
|
||||
(fallbackNames && fallbackNames.success && fallbackNames.map ? (fallbackNames.map[groupId] || '') : '') ||
|
||||
groupId
|
||||
const avatarUrl = contact?.avatarUrl ||
|
||||
(fallbackAvatars && fallbackAvatars.success && fallbackAvatars.map ? fallbackAvatars.map[groupId] : undefined)
|
||||
|
||||
groups.push({
|
||||
username: groupId,
|
||||
displayName: displayNames.success && displayNames.map
|
||||
? (displayNames.map[groupId] || groupId)
|
||||
: groupId,
|
||||
displayName,
|
||||
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
|
||||
? memberCounts.map[groupId]
|
||||
: 0,
|
||||
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined
|
||||
avatarUrl
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,14 +215,55 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
const members = result.members as { username: string; avatarUrl?: string }[]
|
||||
const usernames = members.map((m) => m.username)
|
||||
const displayNames = await wcdbService.getDisplayNames(usernames)
|
||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||
|
||||
const data: GroupMember[] = members.map((m) => ({
|
||||
username: m.username,
|
||||
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username,
|
||||
const [displayNames, groupNicknames] = await Promise.all([
|
||||
wcdbService.getDisplayNames(usernames),
|
||||
this.getGroupNicknamesForRoom(chatroomId)
|
||||
])
|
||||
|
||||
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
||||
const concurrency = 6
|
||||
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||
const contactResult = await wcdbService.getContact(username)
|
||||
if (contactResult.success && contactResult.contact) {
|
||||
const contact = contactResult.contact as any
|
||||
contactMap.set(username, {
|
||||
remark: contact.remark || '',
|
||||
nickName: contact.nickName || contact.nick_name || '',
|
||||
alias: contact.alias || ''
|
||||
})
|
||||
} else {
|
||||
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||
}
|
||||
})
|
||||
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
const data: GroupMember[] = members.map((m) => {
|
||||
const wxid = m.username || ''
|
||||
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||
const contact = contactMap.get(wxid)
|
||||
const nickname = contact?.nickName || ''
|
||||
const remark = contact?.remark || ''
|
||||
const alias = contact?.alias || ''
|
||||
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
|
||||
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||
const groupNickname = this.normalizeGroupNickname(
|
||||
rawGroupNickname,
|
||||
normalizedWxid === myWxid ? myWxid : wxid,
|
||||
''
|
||||
)
|
||||
|
||||
return {
|
||||
username: wxid,
|
||||
displayName,
|
||||
nickname,
|
||||
alias,
|
||||
remark,
|
||||
groupNickname,
|
||||
avatarUrl: m.avatarUrl
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
@@ -248,6 +386,187 @@ class GroupAnalyticsService {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const exportDate = new Date()
|
||||
const exportTime = this.formatDateTime(exportDate)
|
||||
const exportVersion = '0.0.2'
|
||||
const exportGenerator = 'WeFlow'
|
||||
const exportPlatform = 'wechat'
|
||||
|
||||
const groupDisplay = await wcdbService.getDisplayNames([chatroomId])
|
||||
const groupName = groupDisplay.success && groupDisplay.map
|
||||
? (groupDisplay.map[chatroomId] || chatroomId)
|
||||
: chatroomId
|
||||
|
||||
const groupContact = await wcdbService.getContact(chatroomId)
|
||||
const sessionRemark = (groupContact.success && groupContact.contact)
|
||||
? (groupContact.contact.remark || '')
|
||||
: ''
|
||||
|
||||
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
||||
if (!membersResult.success || !membersResult.members) {
|
||||
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||
}
|
||||
|
||||
const members = membersResult.members as { username: string; avatarUrl?: string }[]
|
||||
if (members.length === 0) {
|
||||
return { success: false, error: '群成员为空' }
|
||||
}
|
||||
|
||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||
const [displayNames, groupNicknames] = await Promise.all([
|
||||
wcdbService.getDisplayNames(usernames),
|
||||
this.getGroupNicknamesForRoom(chatroomId)
|
||||
])
|
||||
|
||||
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
||||
const concurrency = 6
|
||||
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||
const result = await wcdbService.getContact(username)
|
||||
if (result.success && result.contact) {
|
||||
const contact = result.contact as any
|
||||
contactMap.set(username, {
|
||||
remark: contact.remark || '',
|
||||
nickName: contact.nickName || contact.nick_name || '',
|
||||
alias: contact.alias || ''
|
||||
})
|
||||
} else {
|
||||
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||
}
|
||||
})
|
||||
|
||||
const infoTitleRow = ['会话信息']
|
||||
const infoRow = ['微信ID', chatroomId, '', '昵称', groupName, '备注', sessionRemark || '', '']
|
||||
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
|
||||
|
||||
const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号']
|
||||
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
|
||||
for (const member of members) {
|
||||
const wxid = member.username
|
||||
const normalizedWxid = this.cleanAccountDirName(wxid || '')
|
||||
const contact = contactMap.get(wxid)
|
||||
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
|
||||
const nickName = contact?.nickName || fallbackName || ''
|
||||
const remark = contact?.remark || ''
|
||||
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
|
||||
const alias = contact?.alias || ''
|
||||
const groupNickname = this.normalizeGroupNickname(
|
||||
rawGroupNickname,
|
||||
normalizedWxid === myWxid ? myWxid : wxid,
|
||||
''
|
||||
)
|
||||
|
||||
rows.push([nickName, remark, groupNickname, wxid, alias])
|
||||
}
|
||||
|
||||
const ext = path.extname(outputPath).toLowerCase()
|
||||
if (ext === '.csv') {
|
||||
const csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
|
||||
const content = '\ufeff' + csvLines.join('\n')
|
||||
fs.writeFileSync(outputPath, content, 'utf8')
|
||||
} else {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const sheet = workbook.addWorksheet(this.sanitizeWorksheetName('群成员列表'))
|
||||
|
||||
let currentRow = 1
|
||||
const titleCell = sheet.getCell(currentRow, 1)
|
||||
titleCell.value = '会话信息'
|
||||
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
|
||||
sheet.getRow(currentRow).height = 25
|
||||
currentRow++
|
||||
|
||||
sheet.getCell(currentRow, 1).value = '微信ID'
|
||||
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.mergeCells(currentRow, 2, currentRow, 3)
|
||||
sheet.getCell(currentRow, 2).value = chatroomId
|
||||
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
|
||||
|
||||
sheet.getCell(currentRow, 4).value = '昵称'
|
||||
sheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 5).value = groupName
|
||||
sheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
|
||||
|
||||
sheet.getCell(currentRow, 6).value = '备注'
|
||||
sheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.mergeCells(currentRow, 7, currentRow, 8)
|
||||
sheet.getCell(currentRow, 7).value = sessionRemark
|
||||
sheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
|
||||
|
||||
sheet.getRow(currentRow).height = 20
|
||||
currentRow++
|
||||
|
||||
sheet.getCell(currentRow, 1).value = '导出工具'
|
||||
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 2).value = exportGenerator
|
||||
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
sheet.getCell(currentRow, 3).value = '导出版本'
|
||||
sheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 4).value = exportVersion
|
||||
sheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
sheet.getCell(currentRow, 5).value = '平台'
|
||||
sheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 6).value = exportPlatform
|
||||
sheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
sheet.getCell(currentRow, 7).value = '导出时间'
|
||||
sheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||
sheet.getCell(currentRow, 8).value = exportTime
|
||||
sheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
|
||||
|
||||
sheet.getRow(currentRow).height = 20
|
||||
currentRow++
|
||||
|
||||
const headerRow = sheet.getRow(currentRow)
|
||||
headerRow.height = 22
|
||||
header.forEach((text, index) => {
|
||||
const cell = headerRow.getCell(index + 1)
|
||||
cell.value = text
|
||||
cell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||
})
|
||||
currentRow++
|
||||
|
||||
sheet.getColumn(1).width = 28
|
||||
sheet.getColumn(2).width = 28
|
||||
sheet.getColumn(3).width = 28
|
||||
sheet.getColumn(4).width = 36
|
||||
sheet.getColumn(5).width = 28
|
||||
sheet.getColumn(6).width = 18
|
||||
sheet.getColumn(7).width = 24
|
||||
sheet.getColumn(8).width = 22
|
||||
|
||||
for (let i = 4; i < rows.length; i++) {
|
||||
const [nickName, remark, groupNickname, wxid, alias] = rows[i]
|
||||
const row = sheet.getRow(currentRow)
|
||||
row.getCell(1).value = nickName
|
||||
row.getCell(2).value = remark
|
||||
row.getCell(3).value = groupNickname
|
||||
row.getCell(4).value = wxid
|
||||
row.getCell(5).value = alias
|
||||
row.alignment = { vertical: 'top', wrapText: true }
|
||||
currentRow++
|
||||
}
|
||||
|
||||
await workbook.xlsx.writeFile(outputPath)
|
||||
}
|
||||
|
||||
return { success: true, count: members.length }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export const groupAnalyticsService = new GroupAnalyticsService()
|
||||
|
||||
@@ -43,6 +43,7 @@ export class KeyService {
|
||||
private GetWindowThreadProcessId: any = null
|
||||
private IsWindowVisible: any = null
|
||||
private EnumChildWindows: any = null
|
||||
private PostMessageW: any = null
|
||||
private WNDENUMPROC_PTR: any = null
|
||||
|
||||
// Advapi32
|
||||
@@ -57,6 +58,7 @@ export class KeyService {
|
||||
private readonly HKEY_LOCAL_MACHINE = 0x80000002
|
||||
private readonly HKEY_CURRENT_USER = 0x80000001
|
||||
private readonly ERROR_SUCCESS = 0
|
||||
private readonly WM_CLOSE = 0x0010
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
@@ -224,6 +226,7 @@ export class KeyService {
|
||||
|
||||
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
|
||||
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
|
||||
|
||||
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
|
||||
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
|
||||
@@ -437,16 +440,60 @@ export class KeyService {
|
||||
return fallbackPid ?? null
|
||||
}
|
||||
|
||||
private async killWeChatProcesses() {
|
||||
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const weixinPid = await this.findPidByImageName('Weixin.exe')
|
||||
const wechatPid = await this.findPidByImageName('WeChat.exe')
|
||||
if (!weixinPid && !wechatPid) return true
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async closeWeChatWindows(): Promise<boolean> {
|
||||
if (!this.ensureUser32()) return false
|
||||
let requested = false
|
||||
|
||||
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
|
||||
if (!this.IsWindowVisible(hWnd)) return true
|
||||
const title = this.getWindowTitle(hWnd)
|
||||
const className = this.getClassName(hWnd)
|
||||
const classLower = (className || '').toLowerCase()
|
||||
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
|
||||
if (!isWeChatWindow) return true
|
||||
|
||||
requested = true
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe'])
|
||||
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe'])
|
||||
this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
|
||||
} catch { }
|
||||
return true
|
||||
}, this.WNDENUMPROC_PTR)
|
||||
|
||||
this.EnumWindows(enumWindowsCallback, 0)
|
||||
this.koffi.unregister(enumWindowsCallback)
|
||||
|
||||
return requested
|
||||
}
|
||||
|
||||
private async killWeChatProcesses(): Promise<boolean> {
|
||||
const requested = await this.closeWeChatWindows()
|
||||
if (requested) {
|
||||
const gracefulOk = await this.waitForWeChatExit(1500)
|
||||
if (gracefulOk) return true
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
|
||||
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
|
||||
} catch (e) {
|
||||
// Ignore if not found
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
return await this.waitForWeChatExit(5000)
|
||||
}
|
||||
|
||||
|
||||
// --- Window Detection ---
|
||||
|
||||
private getWindowTitle(hWnd: any): string {
|
||||
@@ -605,15 +652,24 @@ export class KeyService {
|
||||
}
|
||||
|
||||
// 2. Restart WeChat
|
||||
onStatus?.('正在重启微信以进行获取...', 0)
|
||||
await this.killWeChatProcesses()
|
||||
onStatus?.('正在关闭微信以进行获取...', 0)
|
||||
const closed = await this.killWeChatProcesses()
|
||||
if (!closed) {
|
||||
const err = '无法自动关闭微信,请手动退出后重试'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
// 3. Launch
|
||||
// 3. Launch
|
||||
onStatus?.('正在启动微信...', 0)
|
||||
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' })
|
||||
const sub = spawn(wechatPath, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: dirname(wechatPath)
|
||||
})
|
||||
sub.unref()
|
||||
|
||||
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
|
||||
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
|
||||
onStatus?.('等待微信界面就绪...', 0)
|
||||
const pid = await this.waitForWeChatWindow()
|
||||
if (!pid) {
|
||||
|
||||
@@ -35,6 +35,7 @@ export class WcdbCore {
|
||||
private wcdbGetGroupMemberCount: any = null
|
||||
private wcdbGetGroupMemberCounts: any = null
|
||||
private wcdbGetGroupMembers: any = null
|
||||
private wcdbGetGroupNicknames: any = null
|
||||
private wcdbGetMessageTables: any = null
|
||||
private wcdbGetMessageMeta: any = null
|
||||
private wcdbGetContact: any = null
|
||||
@@ -57,6 +58,8 @@ export class WcdbCore {
|
||||
private wcdbGetDbStatus: any = null
|
||||
private wcdbGetVoiceData: any = null
|
||||
private wcdbGetSnsTimeline: any = null
|
||||
private wcdbGetSnsAnnualStats: any = null
|
||||
private wcdbVerifyUser: any = null
|
||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
private logTimer: NodeJS.Timeout | null = null
|
||||
@@ -259,24 +262,24 @@ export class WcdbCore {
|
||||
let protectionOk = false
|
||||
for (const resPath of resourcePaths) {
|
||||
try {
|
||||
console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
|
||||
// console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
|
||||
protectionOk = this.wcdbInitProtection(resPath)
|
||||
if (protectionOk) {
|
||||
console.log(`[WCDB] InitProtection 成功: ${resPath}`)
|
||||
// console.log(`[WCDB] InitProtection 成功: ${resPath}`)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
|
||||
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!protectionOk) {
|
||||
console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
|
||||
this.writeLog('InitProtection 失败,继续运行')
|
||||
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
|
||||
// this.writeLog('InitProtection 失败,继续运行')
|
||||
// 不返回 false,允许继续运行
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('InitProtection symbol not found:', e)
|
||||
// console.warn('InitProtection symbol not found:', e)
|
||||
}
|
||||
|
||||
// 定义类型
|
||||
@@ -332,6 +335,13 @@ export class WcdbCore {
|
||||
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
||||
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||
|
||||
// wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
||||
try {
|
||||
this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetGroupNicknames = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
|
||||
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||
|
||||
@@ -368,6 +378,13 @@ export class WcdbCore {
|
||||
this.wcdbGetAnnualReportExtras = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_logs(char** out_json)
|
||||
try {
|
||||
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetLogs = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||
try {
|
||||
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
|
||||
@@ -430,6 +447,20 @@ export class WcdbCore {
|
||||
this.wcdbGetSnsTimeline = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||
try {
|
||||
this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSnsAnnualStats = null
|
||||
}
|
||||
|
||||
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
||||
try {
|
||||
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
|
||||
} catch {
|
||||
this.wcdbVerifyUser = null
|
||||
}
|
||||
|
||||
// 初始化
|
||||
const initResult = this.wcdbInit()
|
||||
if (initResult !== 0) {
|
||||
@@ -994,6 +1025,28 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
if (!this.wcdbGetGroupNicknames) {
|
||||
return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' }
|
||||
}
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `获取群昵称失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析群昵称失败' }
|
||||
const nicknames = JSON.parse(jsonStr)
|
||||
return { success: true, nicknames }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
@@ -1335,13 +1388,31 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||
if (!this.lib) return { success: false, error: 'DLL 未加载' }
|
||||
if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetLogs(outPtr)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `获取日志失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析日志失败' }
|
||||
return { success: true, logs: JSON.parse(jsonStr) }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
try {
|
||||
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr)
|
||||
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `执行查询失败: ${result}` }
|
||||
}
|
||||
@@ -1434,6 +1505,39 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Windows Hello
|
||||
*/
|
||||
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
if (!initOk) return { success: false, error: 'WCDB 初始化失败' }
|
||||
}
|
||||
|
||||
if (!this.wcdbVerifyUser) {
|
||||
return { success: false, error: 'Binding not found: VerifyUser' }
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// Allocate buffer for result JSON
|
||||
const maxLen = 1024
|
||||
const outBuf = Buffer.alloc(maxLen)
|
||||
|
||||
// Call native function
|
||||
const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0)
|
||||
this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen)
|
||||
|
||||
// Parse result
|
||||
const jsonStr = this.koffi.decode(outBuf, 'char', -1)
|
||||
const result = JSON.parse(jsonStr)
|
||||
resolve(result)
|
||||
} catch (e) {
|
||||
resolve({ success: false, error: String(e) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||
@@ -1461,4 +1565,29 @@ export class WcdbCore {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
try {
|
||||
if (!this.wcdbGetSnsAnnualStats) {
|
||||
return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' }
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `getSnsAnnualStats failed: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: 'Failed to decode JSON' }
|
||||
return { success: true, data: JSON.parse(jsonStr) }
|
||||
} catch (e) {
|
||||
console.error('getSnsAnnualStats 异常:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,11 @@ export class WcdbService {
|
||||
return this.callWorker('getGroupMembers', { chatroomId })
|
||||
}
|
||||
|
||||
// 获取群成员群名片昵称
|
||||
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
|
||||
return this.callWorker('getGroupNicknames', { chatroomId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息表列表
|
||||
*/
|
||||
@@ -369,6 +374,27 @@ export class WcdbService {
|
||||
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取朋友圈年度统计
|
||||
*/
|
||||
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 DLL 内部日志
|
||||
*/
|
||||
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||
return this.callWorker('getLogs')
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Windows Hello
|
||||
*/
|
||||
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('verifyUser', { message, hwnd })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const wcdbService = new WcdbService()
|
||||
|
||||
32
electron/services/windowsHelloService.ts
Normal file
32
electron/services/windowsHelloService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
export class WindowsHelloService {
|
||||
private verificationPromise: Promise<{ success: boolean; error?: string }> | null = null
|
||||
|
||||
/**
|
||||
* 验证 Windows Hello
|
||||
* @param message 提示信息
|
||||
*/
|
||||
async verify(message: string = '请验证您的身份以解锁 WeFlow', targetWindow?: BrowserWindow): Promise<{ success: boolean; error?: string }> {
|
||||
// Prevent concurrent verification requests
|
||||
if (this.verificationPromise) {
|
||||
return this.verificationPromise
|
||||
}
|
||||
|
||||
// 获取窗口句柄: 优先使用传入的窗口,否则尝试获取焦点窗口,最后兜底主窗口
|
||||
const window = targetWindow || BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||
const hwndBuffer = window?.getNativeWindowHandle()
|
||||
// Convert buffer to int string for transport
|
||||
const hwndStr = hwndBuffer ? BigInt('0x' + hwndBuffer.toString('hex')).toString() : undefined
|
||||
|
||||
this.verificationPromise = wcdbService.verifyUser(message, hwndStr)
|
||||
.finally(() => {
|
||||
this.verificationPromise = null
|
||||
})
|
||||
|
||||
return this.verificationPromise
|
||||
}
|
||||
}
|
||||
|
||||
export const windowsHelloService = new WindowsHelloService()
|
||||
@@ -56,6 +56,9 @@ if (parentPort) {
|
||||
case 'getGroupMembers':
|
||||
result = await core.getGroupMembers(payload.chatroomId)
|
||||
break
|
||||
case 'getGroupNicknames':
|
||||
result = await core.getGroupNicknames(payload.chatroomId)
|
||||
break
|
||||
case 'getMessageTables':
|
||||
result = await core.getMessageTables(payload.sessionId)
|
||||
break
|
||||
@@ -119,6 +122,15 @@ if (parentPort) {
|
||||
case 'getSnsTimeline':
|
||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||
break
|
||||
case 'getSnsAnnualStats':
|
||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getLogs':
|
||||
result = await core.getLogs()
|
||||
break
|
||||
case 'verifyUser':
|
||||
result = await core.verifyUser(payload.message, payload.hwnd)
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "weflow",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
@@ -7380,6 +7380,12 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmmirror.com/nan/-/nan-2.25.0.tgz",
|
||||
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.4.1",
|
||||
"version": "1.5.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hicccc77/WeFlow"
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
"postinstall": "echo 'No native modules to rebuild'",
|
||||
"rebuild": "echo 'No native modules to rebuild'",
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
@@ -55,6 +59,8 @@
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "hicccc77",
|
||||
"repo": "WeFlow",
|
||||
"releaseType": "release"
|
||||
},
|
||||
"productName": "WeFlow",
|
||||
|
||||
Binary file not shown.
24
src/App.tsx
24
src/App.tsx
@@ -10,9 +10,10 @@ import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import AnnualReportPage from './pages/AnnualReportPage'
|
||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||
import DualReportPage from './pages/DualReportPage'
|
||||
import DualReportWindow from './pages/DualReportWindow'
|
||||
import AgreementPage from './pages/AgreementPage'
|
||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import DataManagementPage from './pages/DataManagementPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
@@ -43,7 +44,9 @@ function App() {
|
||||
setDownloadProgress,
|
||||
showUpdateDialog,
|
||||
setShowUpdateDialog,
|
||||
setUpdateError
|
||||
setUpdateError,
|
||||
isLocked,
|
||||
setLocked
|
||||
} = useAppStore()
|
||||
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||
@@ -54,8 +57,10 @@ function App() {
|
||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||
|
||||
// 锁定状态
|
||||
const [isLocked, setIsLocked] = useState(false)
|
||||
const [lockAvatar, setLockAvatar] = useState<string | undefined>(undefined)
|
||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||
const [lockAvatar, setLockAvatar] = useState<string | undefined>(
|
||||
localStorage.getItem('app_lock_avatar') || undefined
|
||||
)
|
||||
const [lockUseHello, setLockUseHello] = useState(false)
|
||||
|
||||
// 协议同意状态
|
||||
@@ -174,7 +179,7 @@ function App() {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
})
|
||||
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
|
||||
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => {
|
||||
setDownloadProgress(progress)
|
||||
})
|
||||
return () => {
|
||||
@@ -271,12 +276,13 @@ function App() {
|
||||
|
||||
if (enabled) {
|
||||
setLockUseHello(useHello)
|
||||
setIsLocked(true)
|
||||
setLocked(true)
|
||||
// 尝试获取头像
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMyAvatarUrl()
|
||||
if (result && result.success && result.avatarUrl) {
|
||||
setLockAvatar(result.avatarUrl)
|
||||
localStorage.setItem('app_lock_avatar', result.avatarUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取锁屏头像失败', e)
|
||||
@@ -310,7 +316,7 @@ function App() {
|
||||
<div className="app-container">
|
||||
{isLocked && (
|
||||
<LockScreen
|
||||
onUnlock={() => setIsLocked(false)}
|
||||
onUnlock={() => setLocked(false)}
|
||||
avatar={lockAvatar}
|
||||
useHello={lockUseHello}
|
||||
/>
|
||||
@@ -394,7 +400,9 @@ function App() {
|
||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/data-management" element={<DataManagementPage />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import * as configService from '../services/config'
|
||||
import { ArrowRight, Fingerprint, Lock, ShieldCheck } from 'lucide-react'
|
||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||
import './LockScreen.scss'
|
||||
|
||||
interface LockScreenProps {
|
||||
@@ -63,18 +63,6 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
setShowHello(true)
|
||||
// 立即执行验证 (0延迟)
|
||||
verifyHello()
|
||||
|
||||
// 后台再次确认可用性,如果其实不可用,再隐藏?
|
||||
// 或者信任用户的配置。为了速度,我们优先信任配置。
|
||||
if (window.PublicKeyCredential) {
|
||||
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
.then(available => {
|
||||
if (!available) {
|
||||
// 如果系统报告不支持,但配置开了,我们可能需要提示?
|
||||
// 暂时保持开启状态,反正 verifyHello 会报错
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Quick start hello failed', e)
|
||||
@@ -84,63 +72,32 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
const verifyHello = async () => {
|
||||
if (isVerifying || isUnlocked) return
|
||||
|
||||
// 取消之前的请求(如果有)
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
setIsVerifying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const challenge = new Uint8Array(32)
|
||||
window.crypto.getRandomValues(challenge)
|
||||
const result = await window.electronAPI.auth.hello()
|
||||
|
||||
const rpId = 'localhost'
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge,
|
||||
rpId,
|
||||
userVerification: 'required',
|
||||
},
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
if (credential) {
|
||||
if (result.success) {
|
||||
handleUnlock()
|
||||
} else {
|
||||
console.error('Hello verification failed:', result.error)
|
||||
setError(result.error || '验证失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
console.log('Hello verification aborted')
|
||||
return
|
||||
}
|
||||
if (e.name === 'NotAllowedError') {
|
||||
console.log('User cancelled Hello verification')
|
||||
} else {
|
||||
console.error('Hello verification error:', e)
|
||||
// 仅在非手动取消时显示错误
|
||||
if (e.name !== 'AbortError') {
|
||||
setError(`验证失败: ${e.message || e.name}`)
|
||||
}
|
||||
}
|
||||
setError(`验证失败: ${e.message || String(e)}`)
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsVerifying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!password || isUnlocked) return
|
||||
|
||||
// 如果正在进行 Hello 验证,取消它
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
// 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消
|
||||
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
|
||||
|
||||
// 不再检查 isVerifying,因为我们允许打断 Hello
|
||||
setIsVerifying(true)
|
||||
|
||||
@@ -6,8 +6,7 @@ interface RouteGuardProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// 不需要数据库连接的页面
|
||||
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management']
|
||||
const PUBLIC_ROUTES = ['/', '/home', '/settings']
|
||||
|
||||
function RouteGuard({ children }: RouteGuardProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 8px;
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import * as configService from '../services/config'
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
const location = useLocation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
|
||||
useEffect(() => {
|
||||
configService.getAuthEnabled().then(setAuthEnabled)
|
||||
}, [])
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
@@ -94,18 +102,21 @@ function Sidebar() {
|
||||
<span className="nav-label">导出</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 数据管理 */}
|
||||
<NavLink
|
||||
to="/data-management"
|
||||
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
|
||||
title={collapsed ? '数据管理' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Database size={20} /></span>
|
||||
<span className="nav-label">数据管理</span>
|
||||
</NavLink>
|
||||
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{authEnabled && (
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => setLocked(true)}
|
||||
title={collapsed ? '锁定' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Lock size={20} /></span>
|
||||
<span className="nav-label">锁定</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => {
|
||||
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
|
||||
if (payload.percent !== undefined) {
|
||||
setDownloadProgress(payload.percent)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@@ -293,3 +311,184 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排除好友弹窗
|
||||
.exclude-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.exclude-modal {
|
||||
width: 560px;
|
||||
max-width: calc(100vw - 48px);
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.exclude-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-modal-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
padding: 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-modal-body {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.exclude-loading,
|
||||
.exclude-error,
|
||||
.exclude-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
padding: 24px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.exclude-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.exclude-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
background: var(--bg-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgba(7, 193, 96, 0.4);
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exclude-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.exclude-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exclude-username {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exclude-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.exclude-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.exclude-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,51 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnalyticsPage.scss'
|
||||
import './DataManagementPage.scss'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
interface ExcludeCandidate {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
wechatId?: string
|
||||
}
|
||||
|
||||
const normalizeUsername = (value: string) => value.trim().toLowerCase()
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingStatus, setLoadingStatus] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false)
|
||||
const [excludeCandidates, setExcludeCandidates] = useState<ExcludeCandidate[]>([])
|
||||
const [excludeQuery, setExcludeQuery] = useState('')
|
||||
const [excludeLoading, setExcludeLoading] = useState(false)
|
||||
const [excludeError, setExcludeError] = useState<string | null>(null)
|
||||
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
|
||||
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
|
||||
|
||||
const themeMode = useThemeStore((state) => state.themeMode)
|
||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
|
||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
|
||||
|
||||
const loadExcludedUsernames = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.getExcludedUsernames()
|
||||
if (result.success && result.data) {
|
||||
setExcludedUsernames(new Set(result.data.map(normalizeUsername)))
|
||||
} else {
|
||||
setExcludedUsernames(new Set())
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载排除名单失败', e)
|
||||
setExcludedUsernames(new Set())
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async (forceRefresh = false) => {
|
||||
if (isLoaded && !forceRefresh) return
|
||||
setIsLoading(true)
|
||||
@@ -66,14 +96,89 @@ function AnalyticsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
loadExcludedUsernames()
|
||||
loadData(true)
|
||||
}
|
||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||
}, [loadData])
|
||||
}, [loadData, loadExcludedUsernames])
|
||||
|
||||
useEffect(() => {
|
||||
loadExcludedUsernames()
|
||||
}, [loadExcludedUsernames])
|
||||
|
||||
const handleRefresh = () => loadData(true)
|
||||
|
||||
const loadExcludeCandidates = useCallback(async () => {
|
||||
setExcludeLoading(true)
|
||||
setExcludeError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.getExcludeCandidates()
|
||||
if (result.success && result.data) {
|
||||
setExcludeCandidates(result.data)
|
||||
} else {
|
||||
setExcludeError(result.error || '加载好友列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setExcludeError(String(e))
|
||||
} finally {
|
||||
setExcludeLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openExcludeDialog = async () => {
|
||||
setExcludeQuery('')
|
||||
setDraftExcluded(new Set(excludedUsernames))
|
||||
setIsExcludeDialogOpen(true)
|
||||
await loadExcludeCandidates()
|
||||
}
|
||||
|
||||
const toggleExcluded = (username: string) => {
|
||||
const key = normalizeUsername(username)
|
||||
setDraftExcluded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleApplyExcluded = async () => {
|
||||
const payload = Array.from(draftExcluded)
|
||||
setIsExcludeDialogOpen(false)
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.setExcludedUsernames(payload)
|
||||
if (!result.success) {
|
||||
alert(result.error || '更新排除名单失败')
|
||||
return
|
||||
}
|
||||
setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername)))
|
||||
clearCache()
|
||||
await window.electronAPI.cache.clearAnalytics()
|
||||
await loadData(true)
|
||||
} catch (e) {
|
||||
alert(`更新排除名单失败:${String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleExcludeCandidates = excludeCandidates
|
||||
.filter((candidate) => {
|
||||
const query = excludeQuery.trim().toLowerCase()
|
||||
if (!query) return true
|
||||
const wechatId = candidate.wechatId || ''
|
||||
const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aSelected = draftExcluded.has(normalizeUsername(a.username))
|
||||
const bSelected = draftExcluded.has(normalizeUsername(b.username))
|
||||
if (aSelected !== bSelected) return aSelected ? -1 : 1
|
||||
return a.displayName.localeCompare(b.displayName, 'zh')
|
||||
})
|
||||
|
||||
const formatDate = (timestamp: number | null) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp * 1000)
|
||||
@@ -248,10 +353,16 @@ function AnalyticsPage() {
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>私聊分析</h1>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
{isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={openExcludeDialog}>
|
||||
<UserMinus size={16} />
|
||||
排除好友{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-scroll">
|
||||
<section className="page-section">
|
||||
@@ -317,6 +428,84 @@ function AnalyticsPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{isExcludeDialogOpen && (
|
||||
<div className="exclude-modal-overlay" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||
<div className="exclude-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="exclude-modal-header">
|
||||
<h3>选择不统计的好友</h3>
|
||||
<button className="modal-close" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="exclude-modal-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索好友"
|
||||
value={excludeQuery}
|
||||
onChange={e => setExcludeQuery(e.target.value)}
|
||||
disabled={excludeLoading}
|
||||
/>
|
||||
{excludeQuery && (
|
||||
<button className="clear-search" onClick={() => setExcludeQuery('')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="exclude-modal-body">
|
||||
{excludeLoading && (
|
||||
<div className="exclude-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>正在加载好友列表...</span>
|
||||
</div>
|
||||
)}
|
||||
{!excludeLoading && excludeError && (
|
||||
<div className="exclude-error">{excludeError}</div>
|
||||
)}
|
||||
{!excludeLoading && !excludeError && (
|
||||
<div className="exclude-list">
|
||||
{visibleExcludeCandidates.map((candidate) => {
|
||||
const isChecked = draftExcluded.has(normalizeUsername(candidate.username))
|
||||
const wechatId = candidate.wechatId?.trim() || candidate.username
|
||||
return (
|
||||
<label key={candidate.username} className={`exclude-item ${isChecked ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleExcluded(candidate.username)}
|
||||
/>
|
||||
<div className="exclude-avatar">
|
||||
<Avatar src={candidate.avatarUrl} name={candidate.displayName} size={32} />
|
||||
</div>
|
||||
<div className="exclude-info">
|
||||
<span className="exclude-name">{candidate.displayName}</span>
|
||||
<span className="exclude-username">{wechatId}</span>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{visibleExcludeCandidates.length === 0 && (
|
||||
<div className="exclude-empty">
|
||||
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="exclude-modal-footer">
|
||||
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
||||
<div className="exclude-actions">
|
||||
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
@@ -25,6 +26,63 @@
|
||||
margin: 0 0 48px;
|
||||
}
|
||||
|
||||
.report-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
|
||||
.report-section {
|
||||
width: 100%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -34,6 +92,12 @@
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.report-section .year-grid {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.year-card {
|
||||
width: 120px;
|
||||
height: 100px;
|
||||
@@ -104,6 +168,13 @@
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Loader2, Sparkles } from 'lucide-react'
|
||||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||
import './AnnualReportPage.scss'
|
||||
|
||||
type YearOption = number | 'all'
|
||||
|
||||
function AnnualReportPage() {
|
||||
const navigate = useNavigate()
|
||||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
||||
const [selectedYear, setSelectedYear] = useState<number | null>(null)
|
||||
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||||
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
@@ -22,7 +25,8 @@ function AnnualReportPage() {
|
||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
setAvailableYears(result.data)
|
||||
setSelectedYear(result.data[0])
|
||||
setSelectedYear((prev) => prev ?? result.data[0])
|
||||
setSelectedPairYear((prev) => prev ?? result.data[0])
|
||||
} else if (!result.success) {
|
||||
setLoadError(result.error || '加载年度数据失败')
|
||||
}
|
||||
@@ -35,10 +39,11 @@ function AnnualReportPage() {
|
||||
}
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!selectedYear) return
|
||||
if (selectedYear === null) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
navigate(`/annual-report/view?year=${selectedYear}`)
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
} finally {
|
||||
@@ -46,6 +51,12 @@ function AnnualReportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateDualReport = () => {
|
||||
if (selectedPairYear === null) return
|
||||
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||||
navigate(`/dual-report?year=${yearParam}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
@@ -67,21 +78,39 @@ function AnnualReportPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const yearOptions: YearOption[] = availableYears.length > 0
|
||||
? ['all', ...availableYears]
|
||||
: []
|
||||
|
||||
const getYearLabel = (value: YearOption | null) => {
|
||||
if (!value) return ''
|
||||
return value === 'all' ? '全部时间' : `${value} 年`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,生成你的微信聊天年度回顾</p>
|
||||
|
||||
<div className="report-sections">
|
||||
<section className="report-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2 className="section-title">总年度报告</h2>
|
||||
<p className="section-desc">包含所有会话与消息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="year-grid">
|
||||
{availableYears.map(year => (
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={year}
|
||||
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(year)}
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
>
|
||||
<span className="year-number">{year}</span>
|
||||
<span className="year-label">年</span>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -99,10 +128,48 @@ function AnnualReportPage() {
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={20} />
|
||||
<span>生成 {selectedYear} 年度报告</span>
|
||||
<span>生成 {getYearLabel(selectedYear)} 年度报告</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="report-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2 className="section-title">双人年度报告</h2>
|
||||
<p className="section-desc">选择一位好友,只看你们的私聊</p>
|
||||
</div>
|
||||
<div className="section-badge">
|
||||
<Users size={16} />
|
||||
<span>私聊</span>
|
||||
</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>
|
||||
|
||||
<button
|
||||
className="generate-btn secondary"
|
||||
onClick={handleGenerateDualReport}
|
||||
disabled={!selectedPairYear}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span>选择好友并生成报告</span>
|
||||
</button>
|
||||
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1279,3 +1279,134 @@
|
||||
color: var(--ar-text-sub) !important;
|
||||
text-align: center;
|
||||
}
|
||||
// 曾经的好朋友 视觉效果
|
||||
.lost-friend-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
margin: 64px auto 48px;
|
||||
position: relative;
|
||||
max-width: 480px;
|
||||
|
||||
.avatar-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 2;
|
||||
|
||||
.avatar-label {
|
||||
font-size: 13px;
|
||||
color: var(--ar-text-sub);
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.sender {
|
||||
animation: fadeInRight 1s ease-out backwards;
|
||||
}
|
||||
|
||||
&.receiver {
|
||||
animation: fadeInLeft 1s ease-out backwards;
|
||||
}
|
||||
}
|
||||
|
||||
.fading-line {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.line-path {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right,
|
||||
var(--ar-primary) 0%,
|
||||
rgba(var(--ar-primary-rgb), 0.4) 50%,
|
||||
rgba(var(--ar-primary-rgb), 0.05) 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.line-glow {
|
||||
position: absolute;
|
||||
inset: -4px 0;
|
||||
background: linear-gradient(to right,
|
||||
rgba(var(--ar-primary-rgb), 0.2) 0%,
|
||||
transparent 100%);
|
||||
filter: blur(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.flow-particle {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
animation: flowAcross 4s infinite linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero-desc.fading {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
font-size: 16px;
|
||||
margin-top: 32px;
|
||||
line-height: 1.8;
|
||||
letter-spacing: 0.05em;
|
||||
animation: fadeIn 1.5s ease-out 0.5s backwards;
|
||||
}
|
||||
|
||||
@keyframes flowAcross {
|
||||
0% {
|
||||
left: -20%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
90% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 120%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,20 @@ interface AnnualReportData {
|
||||
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
||||
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
||||
topPhrases?: { 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
|
||||
}
|
||||
|
||||
interface SectionInfo {
|
||||
@@ -274,6 +288,8 @@ function AnnualReportWindow() {
|
||||
responseSpeed: useRef<HTMLElement>(null),
|
||||
topPhrases: useRef<HTMLElement>(null),
|
||||
ranking: useRef<HTMLElement>(null),
|
||||
sns: useRef<HTMLElement>(null),
|
||||
lostFriend: useRef<HTMLElement>(null),
|
||||
ending: useRef<HTMLElement>(null),
|
||||
}
|
||||
|
||||
@@ -282,7 +298,8 @@ function AnnualReportWindow() {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const yearParam = params.get('year')
|
||||
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
|
||||
const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear()
|
||||
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear
|
||||
generateReport(year)
|
||||
}, [])
|
||||
|
||||
@@ -337,6 +354,11 @@ function AnnualReportWindow() {
|
||||
return `${Math.round(seconds / 3600)}小时`
|
||||
}
|
||||
|
||||
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
|
||||
if (value === 0) return '历史以来'
|
||||
return withSuffix ? `${value}年` : `${value}`
|
||||
}
|
||||
|
||||
// 获取可用的板块列表
|
||||
const getAvailableSections = (): SectionInfo[] => {
|
||||
if (!reportData) return []
|
||||
@@ -367,10 +389,16 @@ function AnnualReportWindow() {
|
||||
if (reportData.responseSpeed) {
|
||||
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
|
||||
}
|
||||
if (reportData.lostFriend) {
|
||||
sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend })
|
||||
}
|
||||
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
||||
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
||||
}
|
||||
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
||||
if (reportData.snsStats && reportData.snsStats.totalPosts > 0) {
|
||||
sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns })
|
||||
}
|
||||
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||
return sections
|
||||
}
|
||||
@@ -595,7 +623,8 @@ function AnnualReportWindow() {
|
||||
|
||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||
link.href = dataUrl
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
@@ -658,11 +687,12 @@ function AnnualReportWindow() {
|
||||
}
|
||||
|
||||
setExportProgress('正在写入文件...')
|
||||
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||
baseDir: dirResult.filePaths[0],
|
||||
folderName: `${reportData?.year}年度报告_分模块`,
|
||||
folderName: `${yearFilePrefix}年度报告_分模块`,
|
||||
images: exportedImages.map((img) => ({
|
||||
name: `${reportData?.year}年度报告_${img.name}.png`,
|
||||
name: `${yearFilePrefix}年度报告_${img.name}.png`,
|
||||
dataUrl: img.data
|
||||
}))
|
||||
})
|
||||
@@ -733,10 +763,14 @@ function AnnualReportWindow() {
|
||||
)
|
||||
}
|
||||
|
||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData
|
||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData
|
||||
const topFriend = coreFriends[0]
|
||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||
const socialStoryName = topFriend?.displayName || '好友'
|
||||
const yearTitle = formatYearLabel(year, true)
|
||||
const yearTitleShort = formatYearLabel(year, false)
|
||||
const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友`
|
||||
const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语`
|
||||
|
||||
return (
|
||||
<div className="annual-report-window">
|
||||
@@ -827,7 +861,7 @@ function AnnualReportWindow() {
|
||||
{/* 封面 */}
|
||||
<section className="section" ref={sectionRefs.cover}>
|
||||
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
||||
<h1 className="hero-title">{year}年<br />微信聊天报告</h1>
|
||||
<h1 className="hero-title">{yearTitle}<br />微信聊天报告</h1>
|
||||
<hr className="divider" />
|
||||
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
||||
</section>
|
||||
@@ -869,7 +903,7 @@ function AnnualReportWindow() {
|
||||
{/* 月度好友 */}
|
||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||
<div className="label-text">月度好友</div>
|
||||
<h2 className="hero-title">{year}年月度好友</h2>
|
||||
<h2 className="hero-title">{monthlyTitle}</h2>
|
||||
<p className="hero-desc">根据12个月的聊天习惯</p>
|
||||
<div className="monthly-orbit">
|
||||
{monthlyTopFriends.map((m, i) => (
|
||||
@@ -1012,11 +1046,46 @@ function AnnualReportWindow() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 曾经的好朋友 */}
|
||||
{lostFriend && (
|
||||
<section className="section" ref={sectionRefs.lostFriend}>
|
||||
<div className="label-text">曾经的好朋友</div>
|
||||
<h2 className="hero-title">{lostFriend.displayName}</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(lostFriend.earlyCount)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
在 <span className="hl">{lostFriend.periodDesc}</span>
|
||||
<br />你们曾有聊不完的话题
|
||||
</p>
|
||||
<div className="lost-friend-visual">
|
||||
<div className="avatar-group sender">
|
||||
<Avatar url={lostFriend.avatarUrl} name={lostFriend.displayName} size="lg" />
|
||||
<span className="avatar-label">TA</span>
|
||||
</div>
|
||||
<div className="fading-line">
|
||||
<div className="line-path" />
|
||||
<div className="line-glow" />
|
||||
<div className="flow-particle" />
|
||||
</div>
|
||||
<div className="avatar-group receiver">
|
||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||
<span className="avatar-label">我</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hero-desc fading">
|
||||
人类发明后悔
|
||||
<br />来证明拥有的珍贵
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 年度常用语 - 词云 */}
|
||||
{topPhrases && topPhrases.length > 0 && (
|
||||
<section className="section" ref={sectionRefs.topPhrases}>
|
||||
<div className="label-text">年度常用语</div>
|
||||
<h2 className="hero-title">你在{year}年的年度常用语</h2>
|
||||
<h2 className="hero-title">{phrasesTitle}</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你说得最多的是:
|
||||
<br />
|
||||
@@ -1029,6 +1098,57 @@ function AnnualReportWindow() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 朋友圈 */}
|
||||
{reportData.snsStats && reportData.snsStats.totalPosts > 0 && (
|
||||
<section className="section" ref={sectionRefs.sns}>
|
||||
<div className="label-text">朋友圈</div>
|
||||
<h2 className="hero-title">记录生活时刻</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你发布了
|
||||
</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{reportData.snsStats.totalPosts}</span>
|
||||
<span className="stat-unit">条朋友圈</span>
|
||||
</div>
|
||||
|
||||
<div className="sns-stats-container" style={{ display: 'flex', gap: '60px', marginTop: '40px', justifyContent: 'center' }}>
|
||||
{reportData.snsStats.topLikers.length > 0 && (
|
||||
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>更关心你的Ta</h3>
|
||||
<div className="mini-ranking">
|
||||
{reportData.snsStats.topLikers.slice(0, 3).map((u, i) => (
|
||||
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||
</div>
|
||||
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reportData.snsStats.topLiked.length > 0 && (
|
||||
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>你最关心的Ta</h3>
|
||||
<div className="mini-ranking">
|
||||
{reportData.snsStats.topLiked.slice(0, 3).map((u, i) => (
|
||||
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||
</div>
|
||||
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 好友排行 */}
|
||||
<section className="section" ref={sectionRefs.ranking}>
|
||||
<div className="label-text">好友排行</div>
|
||||
@@ -1085,7 +1205,7 @@ function AnnualReportWindow() {
|
||||
<br />愿新的一年,
|
||||
<br />所有期待,皆有回声。
|
||||
</p>
|
||||
<div className="ending-year">{year}</div>
|
||||
<div className="ending-year">{yearTitleShort}</div>
|
||||
<div className="ending-brand">WEFLOW</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -491,7 +491,11 @@ function ChatPage(_props: ChatPageProps) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
const dllStart = performance.now()
|
||||
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as {
|
||||
success: boolean
|
||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||
error?: string
|
||||
}
|
||||
const dllTime = performance.now() - dllStart
|
||||
|
||||
// DLL 调用后再次让出控制权
|
||||
@@ -504,7 +508,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
if (result.success && result.contacts) {
|
||||
// 将更新加入队列,用于侧边栏更新
|
||||
for (const [username, contact] of Object.entries(result.contacts)) {
|
||||
const contacts = result.contacts || {}
|
||||
for (const [username, contact] of Object.entries(contacts)) {
|
||||
contactUpdateQueueRef.current.set(username, contact)
|
||||
|
||||
// 如果是自己的信息且当前个人头像为空,同步更新
|
||||
@@ -545,7 +550,11 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setIsRefreshingMessages(true)
|
||||
try {
|
||||
// 获取最新消息并增量添加
|
||||
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50)
|
||||
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as {
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
error?: string
|
||||
}
|
||||
if (!result.success || !result.messages) {
|
||||
return
|
||||
}
|
||||
@@ -593,7 +602,12 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime)
|
||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) as {
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
hasMore?: boolean;
|
||||
error?: string
|
||||
}
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
@@ -690,7 +704,12 @@ function ChatPage(_props: ChatPageProps) {
|
||||
try {
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
// 从最后一条消息的时间开始往后找
|
||||
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true)
|
||||
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) as {
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
hasMore?: boolean;
|
||||
error?: string
|
||||
}
|
||||
|
||||
if (result.success && result.messages) {
|
||||
// 过滤掉已经在列表中的重复消息
|
||||
@@ -1501,6 +1520,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
const imageClickTimerRef = useRef<number | null>(null)
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const imageAutoDecryptTriggered = useRef(false)
|
||||
const imageAutoHdTriggered = useRef<string | null>(null)
|
||||
const [imageInView, setImageInView] = useState(false)
|
||||
const imageForceHdAttempted = useRef<string | null>(null)
|
||||
const imageForceHdPending = useRef(false)
|
||||
const [voiceError, setVoiceError] = useState(false)
|
||||
const [voiceLoading, setVoiceLoading] = useState(false)
|
||||
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
||||
@@ -1551,7 +1574,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
const contentToUse = message.content || (message as any).rawContent || message.parsedContent
|
||||
if (contentToUse) {
|
||||
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
|
||||
window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => {
|
||||
window.electronAPI.video.parseVideoMd5(contentToUse).then((result: { success: boolean; md5?: string; error?: string }) => {
|
||||
console.log('[Video Debug] Parse result:', result)
|
||||
if (result && result.success && result.md5) {
|
||||
console.log('[Video Debug] Parsed MD5:', result.md5)
|
||||
@@ -1559,7 +1582,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
} else {
|
||||
console.error('[Video Debug] Failed to parse MD5:', result)
|
||||
}
|
||||
}).catch((err) => {
|
||||
}).catch((err: unknown) => {
|
||||
console.error('[Video Debug] Parse error:', err)
|
||||
})
|
||||
}
|
||||
@@ -1667,7 +1690,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
}
|
||||
const pending = senderAvatarLoading.get(sender)
|
||||
if (pending) {
|
||||
pending.then((result) => {
|
||||
pending.then((result: { avatarUrl?: string; displayName?: string } | null) => {
|
||||
if (result) {
|
||||
setSenderAvatarUrl(result.avatarUrl)
|
||||
setSenderName(result.displayName)
|
||||
@@ -1697,10 +1720,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
}
|
||||
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
|
||||
|
||||
const requestImageDecrypt = useCallback(async (forceUpdate = false) => {
|
||||
if (!isImage || imageLoading) return
|
||||
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
|
||||
if (!isImage) return
|
||||
if (imageLoading) return
|
||||
if (!silent) {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}
|
||||
try {
|
||||
if (message.imageMd5 || message.imageDatName) {
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
@@ -1726,14 +1752,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
setImageHasUpdate(false)
|
||||
return
|
||||
}
|
||||
setImageError(true)
|
||||
if (!silent) setImageError(true)
|
||||
} catch {
|
||||
setImageError(true)
|
||||
if (!silent) setImageError(true)
|
||||
} finally {
|
||||
setImageLoading(false)
|
||||
if (!silent) setImageLoading(false)
|
||||
}
|
||||
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
||||
|
||||
const triggerForceHd = useCallback(() => {
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
if (imageForceHdAttempted.current === imageCacheKey) return
|
||||
if (imageForceHdPending.current) return
|
||||
imageForceHdAttempted.current = imageCacheKey
|
||||
imageForceHdPending.current = true
|
||||
requestImageDecrypt(true, true).finally(() => {
|
||||
imageForceHdPending.current = false
|
||||
})
|
||||
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
|
||||
|
||||
const handleImageClick = useCallback(() => {
|
||||
if (imageClickTimerRef.current) {
|
||||
window.clearTimeout(imageClickTimerRef.current)
|
||||
@@ -1769,7 +1806,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
}).then((result) => {
|
||||
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
@@ -1787,7 +1824,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload) => {
|
||||
const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => {
|
||||
const matchesCacheKey =
|
||||
payload.cacheKey === message.imageMd5 ||
|
||||
payload.cacheKey === message.imageDatName ||
|
||||
@@ -1804,7 +1841,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => {
|
||||
const unsubscribe = window.electronAPI.image.onCacheResolved((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => {
|
||||
const matchesCacheKey =
|
||||
payload.cacheKey === message.imageMd5 ||
|
||||
payload.cacheKey === message.imageDatName ||
|
||||
@@ -1846,6 +1883,47 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
return () => observer.disconnect()
|
||||
}, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
||||
|
||||
// 进入视野时自动尝试切换高清图
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
const container = imageContainerRef.current
|
||||
if (!container) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
setImageInView(entry.isIntersecting)
|
||||
},
|
||||
{ rootMargin: '120px', threshold: 0 }
|
||||
)
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [isImage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || !imageHasUpdate || !imageInView) return
|
||||
if (imageAutoHdTriggered.current === imageCacheKey) return
|
||||
imageAutoHdTriggered.current = imageCacheKey
|
||||
triggerForceHd()
|
||||
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || !showImagePreview || !imageHasUpdate) return
|
||||
if (imageAutoHdTriggered.current === imageCacheKey) return
|
||||
imageAutoHdTriggered.current = imageCacheKey
|
||||
triggerForceHd()
|
||||
}, [isImage, showImagePreview, imageHasUpdate, imageCacheKey, triggerForceHd])
|
||||
|
||||
// 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清
|
||||
useEffect(() => {
|
||||
if (!isImage || !imageInView) return
|
||||
triggerForceHd()
|
||||
}, [isImage, imageInView, triggerForceHd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || !showImagePreview) return
|
||||
triggerForceHd()
|
||||
}, [isImage, showImagePreview, triggerForceHd])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVoice) return
|
||||
@@ -1933,7 +2011,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
useEffect(() => {
|
||||
if (!isVoice || voiceDataUrl) return
|
||||
window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId))
|
||||
.then(result => {
|
||||
.then((result: { success: boolean; hasCache: boolean; data?: string; error?: string }) => {
|
||||
if (result.success && result.hasCache && result.data) {
|
||||
const url = `data:audio/wav;base64,${result.data}`
|
||||
voiceDataUrlCache.set(voiceCacheKey, url)
|
||||
@@ -2066,7 +2144,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
|
||||
console.log('[Video Debug] Loading video info for MD5:', videoMd5)
|
||||
setVideoLoading(true)
|
||||
window.electronAPI.video.getVideoInfo(videoMd5).then((result) => {
|
||||
window.electronAPI.video.getVideoInfo(videoMd5).then((result: { success: boolean; exists: boolean; videoUrl?: string; coverUrl?: string; thumbUrl?: string; error?: string }) => {
|
||||
console.log('[Video Debug] getVideoInfo result:', result)
|
||||
if (result && result.success) {
|
||||
setVideoInfo({
|
||||
@@ -2079,7 +2157,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
console.error('[Video Debug] Video info failed:', result)
|
||||
setVideoInfo({ exists: false })
|
||||
}
|
||||
}).catch((err) => {
|
||||
}).catch((err: unknown) => {
|
||||
console.error('[Video Debug] getVideoInfo error:', err)
|
||||
setVideoInfo({ exists: false })
|
||||
}).finally(() => {
|
||||
@@ -2092,7 +2170,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.config.get('autoTranscribeVoice').then((value) => {
|
||||
window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => {
|
||||
setAutoTranscribeEnabled(value === true)
|
||||
})
|
||||
}, [])
|
||||
@@ -2196,23 +2274,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className="image-message"
|
||||
onClick={() => setShowImagePreview(true)}
|
||||
onClick={() => {
|
||||
if (imageHasUpdate) {
|
||||
void requestImageDecrypt(true, true)
|
||||
}
|
||||
setShowImagePreview(true)
|
||||
}}
|
||||
onLoad={() => setImageError(false)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{imageHasUpdate && (
|
||||
<button
|
||||
className="image-update-button"
|
||||
type="button"
|
||||
title="发现更高清图片,点击更新"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void requestImageDecrypt(true)
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showImagePreview && (
|
||||
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
|
||||
|
||||
@@ -45,18 +45,18 @@ function ContactsPage() {
|
||||
if (contactsResult.success && contactsResult.contacts) {
|
||||
console.log('📊 总联系人数:', contactsResult.contacts.length)
|
||||
console.log('📊 按类型统计:', {
|
||||
friends: contactsResult.contacts.filter(c => c.type === 'friend').length,
|
||||
groups: contactsResult.contacts.filter(c => c.type === 'group').length,
|
||||
officials: contactsResult.contacts.filter(c => c.type === 'official').length,
|
||||
other: contactsResult.contacts.filter(c => c.type === 'other').length
|
||||
friends: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'friend').length,
|
||||
groups: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'group').length,
|
||||
officials: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'official').length,
|
||||
other: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'other').length
|
||||
})
|
||||
|
||||
// 获取头像URL
|
||||
const usernames = contactsResult.contacts.map(c => c.username)
|
||||
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
|
||||
if (usernames.length > 0) {
|
||||
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||
if (avatarResult.success && avatarResult.contacts) {
|
||||
contactsResult.contacts.forEach(contact => {
|
||||
contactsResult.contacts.forEach((contact: ContactInfo) => {
|
||||
const enriched = avatarResult.contacts?.[contact.username]
|
||||
if (enriched?.avatarUrl) {
|
||||
contact.avatarUrl = enriched.avatarUrl
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.database-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.database-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
|
||||
&.decrypted {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.needs-update {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.db-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.db-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.db-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.db-status {
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.decrypted {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.needs-update {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
|
||||
&.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unavailable-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 20px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.hint {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 24px;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
animation: slideDown 0.3s ease;
|
||||
|
||||
&.success {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.decrypt-progress-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
|
||||
.progress-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
padding: 32px 40px;
|
||||
min-width: 400px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-file {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 图片列表样式
|
||||
.current-dir {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
|
||||
.dir-label {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dir-path {
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
.decrypt-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.decrypted {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.img-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.img-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.img-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.v3 {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.v4 {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.img-size {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.decrypt-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.more-hint {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
|
||||
// 账号选择器
|
||||
.account-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.account-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as configService from '../services/config'
|
||||
import './DataManagementPage.scss'
|
||||
|
||||
function DataManagementPage() {
|
||||
const [dbPath, setDbPath] = useState<string | null>(null)
|
||||
const [wxid, setWxid] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
const [path, id] = await Promise.all([
|
||||
configService.getDbPath(),
|
||||
configService.getMyWxid()
|
||||
])
|
||||
setDbPath(path)
|
||||
setWxid(id)
|
||||
}
|
||||
loadConfig()
|
||||
const handleChange = () => {
|
||||
loadConfig()
|
||||
}
|
||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>数据管理</h1>
|
||||
</div>
|
||||
|
||||
<div className="page-scroll">
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>WCDB 直连模式</h2>
|
||||
<p className="section-desc">
|
||||
当前版本通过 WCDB DLL 直接读取加密数据库,不再需要解密流程。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="database-list">
|
||||
<div className="database-item decrypted">
|
||||
<div className="db-info">
|
||||
<div className="db-name">
|
||||
数据库目录
|
||||
</div>
|
||||
<div className="db-path">{dbPath || '未配置'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="database-item decrypted">
|
||||
<div className="db-info">
|
||||
<div className="db-name">
|
||||
微信ID
|
||||
</div>
|
||||
<div className="db-path">{wxid || '未配置'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataManagementPage
|
||||
171
src/pages/DualReportPage.scss
Normal file
171
src/pages/DualReportPage.scss
Normal file
@@ -0,0 +1,171 @@
|
||||
.dual-report-page {
|
||||
padding: 32px 28px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dual-report-page.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ranking-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
&.top {
|
||||
background: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--primary-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
.count {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
138
src/pages/DualReportPage.tsx
Normal file
138
src/pages/DualReportPage.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2, Search, Users } from 'lucide-react'
|
||||
import './DualReportPage.scss'
|
||||
|
||||
interface ContactRanking {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
sentCount: number
|
||||
receivedCount: number
|
||||
lastMessageTime?: number | null
|
||||
}
|
||||
|
||||
function DualReportPage() {
|
||||
const navigate = useNavigate()
|
||||
const [year, setYear] = useState<number>(0)
|
||||
const [rankings, setRankings] = useState<ContactRanking[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const yearParam = params.get('year')
|
||||
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
|
||||
setYear(Number.isNaN(parsedYear) ? 0 : parsedYear)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadRankings()
|
||||
}, [])
|
||||
|
||||
const loadRankings = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.analytics.getContactRankings(200)
|
||||
if (result.success && result.data) {
|
||||
setRankings(result.data)
|
||||
} else {
|
||||
setLoadError(result.error || '加载好友列表失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setLoadError(String(e))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const yearLabel = year === 0 ? '全部时间' : `${year}年`
|
||||
|
||||
const filteredRankings = useMemo(() => {
|
||||
if (!keyword.trim()) return rankings
|
||||
const q = keyword.trim().toLowerCase()
|
||||
return rankings.filter((item) => {
|
||||
return item.displayName.toLowerCase().includes(q) || item.username.toLowerCase().includes(q)
|
||||
})
|
||||
}, [rankings, keyword])
|
||||
|
||||
const handleSelect = (username: string) => {
|
||||
const yearParam = year === 0 ? 0 : year
|
||||
navigate(`/dual-report/view?username=${encodeURIComponent(username)}&year=${yearParam}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="dual-report-page loading">
|
||||
<Loader2 size={32} className="spin" />
|
||||
<p>正在加载聊天排行...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="dual-report-page loading">
|
||||
<p>加载失败:{loadError}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dual-report-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>双人年度报告</h1>
|
||||
<p>选择一位好友,生成你们的专属聊天报告</p>
|
||||
</div>
|
||||
<div className="year-badge">
|
||||
<Users size={14} />
|
||||
<span>{yearLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
<Search size={16} />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索好友(昵称/备注/wxid)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ranking-list">
|
||||
{filteredRankings.map((item, index) => (
|
||||
<button
|
||||
key={item.username}
|
||||
className="ranking-item"
|
||||
onClick={() => handleSelect(item.username)}
|
||||
>
|
||||
<span className={`rank-badge ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="avatar">
|
||||
{item.avatarUrl
|
||||
? <img src={item.avatarUrl} alt={item.displayName} />
|
||||
: <span>{item.displayName.slice(0, 1) || '?'}</span>
|
||||
}
|
||||
</div>
|
||||
<div className="info">
|
||||
<div className="name">{item.displayName}</div>
|
||||
<div className="sub">{item.username}</div>
|
||||
</div>
|
||||
<div className="meta">
|
||||
<div className="count">{item.messageCount.toLocaleString()} 条</div>
|
||||
<div className="hint">总消息</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredRankings.length === 0 ? (
|
||||
<div className="empty">没有匹配的好友</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DualReportPage
|
||||
253
src/pages/DualReportWindow.scss
Normal file
253
src/pages/DualReportWindow.scss
Normal file
@@ -0,0 +1,253 @@
|
||||
.annual-report-window.dual-report-window {
|
||||
.hero-title {
|
||||
font-size: clamp(22px, 4vw, 34px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dual-cover-title {
|
||||
font-size: clamp(26px, 5vw, 44px);
|
||||
white-space: normal;
|
||||
}
|
||||
.dual-names {
|
||||
font-size: clamp(24px, 4vw, 40px);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 8px 0 16px;
|
||||
color: var(--ar-text-main);
|
||||
|
||||
.amp {
|
||||
color: var(--ar-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dual-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dual-info-card {
|
||||
background: var(--ar-card-bg);
|
||||
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05));
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
|
||||
&.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: var(--ar-text-sub);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
}
|
||||
}
|
||||
|
||||
.dual-message-list {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dual-message {
|
||||
background: var(--ar-card-bg);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
|
||||
&.received {
|
||||
background: var(--ar-card-bg-hover);
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 12px;
|
||||
color: var(--ar-text-sub);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
color: var(--ar-text-main);
|
||||
}
|
||||
}
|
||||
|
||||
.first-chat-scene {
|
||||
background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%);
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px 24px;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.first-chat-scene::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.2), transparent 40%),
|
||||
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.15), transparent 35%),
|
||||
radial-gradient(circle at 50% 80%, rgba(255, 255, 255, 0.12), transparent 45%);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scene-subtitle {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.scene-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.scene-message {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
|
||||
&.sent {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
.scene-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.scene-bubble {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: #5a4d5e;
|
||||
padding: 10px 14px;
|
||||
border-radius: 14px;
|
||||
max-width: 60%;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.scene-message.sent .scene-bubble {
|
||||
background: rgba(255, 224, 168, 0.9);
|
||||
color: #4a3a2f;
|
||||
}
|
||||
|
||||
.scene-meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.scene-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.scene-message.sent .scene-avatar {
|
||||
background: rgba(255, 224, 168, 0.9);
|
||||
color: #4a3a2f;
|
||||
}
|
||||
|
||||
.dual-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(140px, 1fr));
|
||||
gap: 14px;
|
||||
margin: 20px -28px 24px;
|
||||
padding: 0 28px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dual-stat-card {
|
||||
background: var(--ar-card-bg);
|
||||
border-radius: 14px;
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: clamp(20px, 2.8vw, 30px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dual-stat-card.long .stat-num {
|
||||
font-size: clamp(18px, 2.4vw, 26px);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.emoji-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 0 -12px;
|
||||
}
|
||||
|
||||
.emoji-card {
|
||||
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 16px;
|
||||
padding: 18px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--ar-card-bg);
|
||||
|
||||
img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-title {
|
||||
font-size: 12px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
.emoji-placeholder {
|
||||
font-size: 12px;
|
||||
color: var(--ar-text-sub);
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.word-cloud-empty {
|
||||
color: var(--ar-text-sub);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
}
|
||||
472
src/pages/DualReportWindow.tsx
Normal file
472
src/pages/DualReportWindow.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import './AnnualReportWindow.scss'
|
||||
import './DualReportWindow.scss'
|
||||
|
||||
interface DualReportMessage {
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
}
|
||||
|
||||
interface DualReportData {
|
||||
year: number
|
||||
selfName: string
|
||||
friendUsername: string
|
||||
friendName: string
|
||||
firstChat: {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
senderUsername?: string
|
||||
} | null
|
||||
firstChatMessages?: DualReportMessage[]
|
||||
yearFirstChat?: {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
friendName: string
|
||||
firstThreeMessages: DualReportMessage[]
|
||||
} | null
|
||||
stats: {
|
||||
totalMessages: number
|
||||
totalWords: number
|
||||
imageCount: number
|
||||
voiceCount: number
|
||||
emojiCount: number
|
||||
myTopEmojiMd5?: string
|
||||
friendTopEmojiMd5?: string
|
||||
myTopEmojiUrl?: string
|
||||
friendTopEmojiUrl?: string
|
||||
}
|
||||
topPhrases: Array<{ phrase: string; count: number }>
|
||||
}
|
||||
|
||||
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
|
||||
if (!words || words.length === 0) {
|
||||
return <div className="word-cloud-empty">暂无高频语句</div>
|
||||
}
|
||||
const sortedWords = [...words].sort((a, b) => b.count - a.count)
|
||||
const maxCount = sortedWords.length > 0 ? sortedWords[0].count : 1
|
||||
const topWords = sortedWords.slice(0, 32)
|
||||
const baseSize = 520
|
||||
|
||||
const seededRandom = (seed: number) => {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
const placedItems: { x: number; y: number; w: number; h: number }[] = []
|
||||
|
||||
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
|
||||
const halfW = w / 2
|
||||
const halfH = h / 2
|
||||
const dx = x - 50
|
||||
const dy = y - 50
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const maxR = 49 - Math.max(halfW, halfH)
|
||||
if (dist > maxR) return false
|
||||
|
||||
const pad = 1.8
|
||||
for (const p of placedItems) {
|
||||
if ((x - halfW - pad) < (p.x + p.w / 2) &&
|
||||
(x + halfW + pad) > (p.x - p.w / 2) &&
|
||||
(y - halfH - pad) < (p.y + p.h / 2) &&
|
||||
(y + halfH + pad) > (p.y - p.h / 2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const wordItems = topWords.map((item, i) => {
|
||||
const ratio = item.count / maxCount
|
||||
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
|
||||
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
|
||||
const delay = (i * 0.04).toFixed(2)
|
||||
|
||||
const charCount = Math.max(1, item.phrase.length)
|
||||
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
|
||||
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
|
||||
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
|
||||
const widthPx = fontSize * (charCount * widthFactor)
|
||||
const heightPx = fontSize * 1.1
|
||||
const widthPct = (widthPx / baseSize) * 100
|
||||
const heightPct = (heightPx / baseSize) * 100
|
||||
|
||||
let x = 50, y = 50
|
||||
let placedOk = false
|
||||
const tries = i === 0 ? 1 : 420
|
||||
|
||||
for (let t = 0; t < tries; t++) {
|
||||
if (i === 0) {
|
||||
x = 50
|
||||
y = 50
|
||||
} else {
|
||||
const idx = i + t * 0.28
|
||||
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
|
||||
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
|
||||
x = 50 + radius * Math.cos(angle)
|
||||
y = 50 + radius * Math.sin(angle)
|
||||
}
|
||||
if (canPlace(x, y, widthPct, heightPct)) {
|
||||
placedOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!placedOk) return null
|
||||
placedItems.push({ x, y, w: widthPct, h: heightPct })
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="word-tag"
|
||||
style={{
|
||||
'--final-opacity': opacity,
|
||||
left: `${x.toFixed(2)}%`,
|
||||
top: `${y.toFixed(2)}%`,
|
||||
fontSize: `${fontSize}px`,
|
||||
animationDelay: `${delay}s`,
|
||||
} as CSSProperties}
|
||||
title={`${item.phrase} (出现 ${item.count} 次)`}
|
||||
>
|
||||
{item.phrase}
|
||||
</span>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="word-cloud-wrapper">
|
||||
<div className="word-cloud-inner">
|
||||
{wordItems}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DualReportWindow() {
|
||||
const [reportData, setReportData] = useState<DualReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadingStage, setLoadingStage] = useState('准备中')
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
||||
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const username = params.get('username')
|
||||
const yearParam = params.get('year')
|
||||
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
|
||||
const year = Number.isNaN(parsedYear) ? 0 : parsedYear
|
||||
if (!username) {
|
||||
setError('缺少好友信息')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
generateReport(username, year)
|
||||
}, [])
|
||||
|
||||
const generateReport = async (friendUsername: string, year: number) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setLoadingProgress(0)
|
||||
|
||||
const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
||||
setLoadingProgress(payload.progress)
|
||||
setLoadingStage(payload.status)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year })
|
||||
removeProgressListener?.()
|
||||
setLoadingProgress(100)
|
||||
setLoadingStage('完成')
|
||||
|
||||
if (result.success && result.data) {
|
||||
setReportData(result.data)
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setError(result.error || '生成报告失败')
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (e) {
|
||||
removeProgressListener?.()
|
||||
setError(String(e))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadEmojis = async () => {
|
||||
if (!reportData) return
|
||||
const stats = reportData.stats
|
||||
if (stats.myTopEmojiUrl) {
|
||||
const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5)
|
||||
if (res.success && res.localPath) {
|
||||
setMyEmojiUrl(res.localPath)
|
||||
}
|
||||
}
|
||||
if (stats.friendTopEmojiUrl) {
|
||||
const res = await window.electronAPI.chat.downloadEmoji(stats.friendTopEmojiUrl, stats.friendTopEmojiMd5)
|
||||
if (res.success && res.localPath) {
|
||||
setFriendEmojiUrl(res.localPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadEmojis()
|
||||
}, [reportData])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="annual-report-window loading">
|
||||
<div className="loading-ring">
|
||||
<svg viewBox="0 0 100 100">
|
||||
<circle className="ring-bg" cx="50" cy="50" r="42" />
|
||||
<circle
|
||||
className="ring-progress"
|
||||
cx="50" cy="50" r="42"
|
||||
style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ring-text">{loadingProgress}%</span>
|
||||
</div>
|
||||
<p className="loading-stage">{loadingStage}</p>
|
||||
<p className="loading-hint">进行中</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="annual-report-window error">
|
||||
<p>生成报告失败: {error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!reportData) {
|
||||
return (
|
||||
<div className="annual-report-window error">
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}年`
|
||||
const firstChat = reportData.firstChat
|
||||
const firstChatMessages = (reportData.firstChatMessages && reportData.firstChatMessages.length > 0)
|
||||
? reportData.firstChatMessages.slice(0, 3)
|
||||
: firstChat
|
||||
? [{
|
||||
content: firstChat.content,
|
||||
isSentByMe: firstChat.isSentByMe,
|
||||
createTime: firstChat.createTime,
|
||||
createTimeStr: firstChat.createTimeStr
|
||||
}]
|
||||
: []
|
||||
const daysSince = firstChat
|
||||
? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000))
|
||||
: null
|
||||
const yearFirstChat = reportData.yearFirstChat
|
||||
const stats = reportData.stats
|
||||
const statItems = [
|
||||
{ label: '总消息数', value: stats.totalMessages },
|
||||
{ label: '总字数', value: stats.totalWords },
|
||||
{ label: '图片', value: stats.imageCount },
|
||||
{ label: '语音', value: stats.voiceCount },
|
||||
{ label: '表情', value: stats.emojiCount },
|
||||
]
|
||||
|
||||
const decodeEntities = (text: string) => (
|
||||
text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
)
|
||||
|
||||
const stripCdata = (text: string) => text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
|
||||
const extractXmlText = (content: string) => {
|
||||
const titleMatch = content.match(/<title>([\s\S]*?)<\/title>/i)
|
||||
if (titleMatch?.[1]) return titleMatch[1]
|
||||
const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i)
|
||||
if (descMatch?.[1]) return descMatch[1]
|
||||
const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i)
|
||||
if (summaryMatch?.[1]) return summaryMatch[1]
|
||||
const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i)
|
||||
if (contentMatch?.[1]) return contentMatch[1]
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatMessageContent = (content?: string) => {
|
||||
const raw = String(content || '').trim()
|
||||
if (!raw) return '(空)'
|
||||
const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw)
|
||||
const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw)
|
||||
|| hasXmlTag
|
||||
if (!looksLikeXml) return raw
|
||||
const extracted = extractXmlText(raw)
|
||||
if (!extracted) return '(XML消息)'
|
||||
return decodeEntities(stripCdata(extracted).trim()) || '(XML消息)'
|
||||
}
|
||||
const formatFullDate = (timestamp: number) => {
|
||||
const d = new Date(timestamp)
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hour = String(d.getHours()).padStart(2, '0')
|
||||
const minute = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${year}/${month}/${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="annual-report-window dual-report-window">
|
||||
<div className="drag-region" />
|
||||
|
||||
<div className="bg-decoration">
|
||||
<div className="deco-circle c1" />
|
||||
<div className="deco-circle c2" />
|
||||
<div className="deco-circle c3" />
|
||||
<div className="deco-circle c4" />
|
||||
<div className="deco-circle c5" />
|
||||
</div>
|
||||
|
||||
<div className="report-scroll-view">
|
||||
<div className="report-container">
|
||||
<section className="section">
|
||||
<div className="label-text">WEFLOW · DUAL REPORT</div>
|
||||
<h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1>
|
||||
<hr className="divider" />
|
||||
<div className="dual-names">
|
||||
<span>{reportData.selfName}</span>
|
||||
<span className="amp">&</span>
|
||||
<span>{reportData.friendName}</span>
|
||||
</div>
|
||||
<p className="hero-desc">每一次对话都值得被珍藏</p>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="label-text">首次聊天</div>
|
||||
<h2 className="hero-title">故事的开始</h2>
|
||||
{firstChat ? (
|
||||
<>
|
||||
<div className="dual-info-grid">
|
||||
<div className="dual-info-card">
|
||||
<div className="info-label">第一次聊天时间</div>
|
||||
<div className="info-value">{formatFullDate(firstChat.createTime)}</div>
|
||||
</div>
|
||||
<div className="dual-info-card">
|
||||
<div className="info-label">距今天数</div>
|
||||
<div className="info-value">{daysSince} 天</div>
|
||||
</div>
|
||||
</div>
|
||||
{firstChatMessages.length > 0 ? (
|
||||
<div className="dual-message-list">
|
||||
{firstChatMessages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}
|
||||
>
|
||||
<div className="message-meta">
|
||||
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
|
||||
</div>
|
||||
<div className="message-content">{formatMessageContent(msg.content)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="hero-desc">暂无首条消息</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{yearFirstChat ? (
|
||||
<section className="section">
|
||||
<div className="label-text">第一段对话</div>
|
||||
<h2 className="hero-title">
|
||||
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
|
||||
</h2>
|
||||
<div className="dual-info-grid">
|
||||
<div className="dual-info-card">
|
||||
<div className="info-label">第一段对话时间</div>
|
||||
<div className="info-value">{formatFullDate(yearFirstChat.createTime)}</div>
|
||||
</div>
|
||||
<div className="dual-info-card">
|
||||
<div className="info-label">发起者</div>
|
||||
<div className="info-value">{yearFirstChat.isSentByMe ? reportData.selfName : reportData.friendName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dual-message-list">
|
||||
{yearFirstChat.firstThreeMessages.map((msg, idx) => (
|
||||
<div key={idx} className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
|
||||
<div className="message-meta">
|
||||
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
|
||||
</div>
|
||||
<div className="message-content">{formatMessageContent(msg.content)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section">
|
||||
<div className="label-text">常用语</div>
|
||||
<h2 className="hero-title">{yearTitle}常用语</h2>
|
||||
<WordCloud words={reportData.topPhrases} />
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="label-text">年度统计</div>
|
||||
<h2 className="hero-title">{yearTitle}数据概览</h2>
|
||||
<div className="dual-stat-grid">
|
||||
{statItems.map((item) => {
|
||||
const valueText = item.value.toLocaleString()
|
||||
const isLong = valueText.length > 7
|
||||
return (
|
||||
<div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`}>
|
||||
<div className="stat-num">{valueText}</div>
|
||||
<div className="stat-unit">{item.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="emoji-row">
|
||||
<div className="emoji-card">
|
||||
<div className="emoji-title">我常用的表情</div>
|
||||
{myEmojiUrl ? (
|
||||
<img src={myEmojiUrl} alt="my-emoji" />
|
||||
) : (
|
||||
<div className="emoji-placeholder">{stats.myTopEmojiMd5 || '暂无'}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="emoji-card">
|
||||
<div className="emoji-title">{reportData.friendName}常用的表情</div>
|
||||
{friendEmojiUrl ? (
|
||||
<img src={friendEmojiUrl} alt="friend-emoji" />
|
||||
) : (
|
||||
<div className="emoji-placeholder">{stats.friendTopEmojiMd5 || '暂无'}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="label-text">尾声</div>
|
||||
<h2 className="hero-title">谢谢你一直在</h2>
|
||||
<p className="hero-desc">愿我们继续把故事写下去</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DualReportWindow
|
||||
@@ -19,6 +19,7 @@ interface ExportOptions {
|
||||
exportMedia: boolean
|
||||
exportImages: boolean
|
||||
exportVoices: boolean
|
||||
exportVideos: boolean
|
||||
exportEmojis: boolean
|
||||
exportVoiceAsText: boolean
|
||||
excelCompactColumns: boolean
|
||||
@@ -65,6 +66,7 @@ function ExportPage() {
|
||||
exportMedia: false,
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportVideos: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: true,
|
||||
excelCompactColumns: true,
|
||||
@@ -187,7 +189,7 @@ function ExportPage() {
|
||||
}, [loadSessions])
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
|
||||
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
|
||||
setExportProgress({
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
@@ -257,6 +259,7 @@ function ExportPage() {
|
||||
exportMedia: true,
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportVideos: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: true
|
||||
}
|
||||
@@ -286,6 +289,7 @@ function ExportPage() {
|
||||
exportMedia: options.exportMedia,
|
||||
exportImages: options.exportMedia && options.exportImages,
|
||||
exportVoices: options.exportMedia && options.exportVoices,
|
||||
exportVideos: options.exportMedia && options.exportVideos,
|
||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
||||
excelCompactColumns: options.excelCompactColumns,
|
||||
@@ -609,7 +613,7 @@ function ExportPage() {
|
||||
)}
|
||||
<div className="setting-section">
|
||||
<h3>媒体文件</h3>
|
||||
<p className="setting-subtitle">导出图片/语音/表情并在记录内写入相对路径</p>
|
||||
<p className="setting-subtitle">导出图片/语音/视频/表情并在记录内写入相对路径</p>
|
||||
<div className="media-options-card">
|
||||
<div className="media-switch-row">
|
||||
<div className="media-switch-info">
|
||||
@@ -661,7 +665,7 @@ function ExportPage() {
|
||||
<label className="media-checkbox-row">
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">语音转文字</span>
|
||||
<span className="media-checkbox-desc">将语音消息转换为文字导出(不导出语音文件)</span>
|
||||
<span className="media-checkbox-desc">将语音消息转换为文字导出</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -672,6 +676,21 @@ function ExportPage() {
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">视频</span>
|
||||
<span className="media-checkbox-desc">直接复制视频文件到导出目录</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportVideos}
|
||||
disabled={!options.exportMedia}
|
||||
onChange={e => setOptions({ ...options, exportVideos: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">表情</span>
|
||||
|
||||
@@ -333,7 +333,7 @@
|
||||
.group-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -346,11 +346,11 @@
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
.skeleton-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@@ -500,7 +500,7 @@
|
||||
.group-avatar.large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 16px;
|
||||
|
||||
@@ -513,11 +513,11 @@
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,6 +656,32 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
-webkit-app-region: no-drag;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-body {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import DateRangePicker from '../components/DateRangePicker'
|
||||
@@ -16,6 +16,10 @@ interface GroupMember {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
nickname?: string
|
||||
alias?: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
}
|
||||
|
||||
interface GroupMessageRank {
|
||||
@@ -39,6 +43,7 @@ function GroupAnalyticsPage() {
|
||||
const [activeHours, setActiveHours] = useState<Record<number, number>>({})
|
||||
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
|
||||
const [functionLoading, setFunctionLoading] = useState(false)
|
||||
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||
|
||||
// 成员详情弹框
|
||||
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
||||
@@ -181,6 +186,10 @@ function GroupAnalyticsPage() {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
const sanitizeFileName = (name: string) => {
|
||||
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
|
||||
}
|
||||
|
||||
const getHourlyOption = () => {
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const data = hours.map(h => activeHours[h] || 0)
|
||||
@@ -252,6 +261,35 @@ function GroupAnalyticsPage() {
|
||||
setCopiedField(null)
|
||||
}
|
||||
|
||||
const handleExportMembers = async () => {
|
||||
if (!selectedGroup || isExportingMembers) return
|
||||
setIsExportingMembers(true)
|
||||
try {
|
||||
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||
const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_群成员列表`)
|
||||
const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
|
||||
const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx`
|
||||
const saveResult = await window.electronAPI.dialog.saveFile({
|
||||
title: '导出群成员列表',
|
||||
defaultPath,
|
||||
filters: [{ name: 'Excel', extensions: ['xlsx'] }]
|
||||
})
|
||||
if (!saveResult || saveResult.canceled || !saveResult.filePath) return
|
||||
|
||||
const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath)
|
||||
if (result.success) {
|
||||
alert(`导出成功,共 ${result.count ?? members.length} 人`)
|
||||
} else {
|
||||
alert(`导出失败:${result.error || '未知错误'}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导出群成员失败:', e)
|
||||
alert(`导出失败:${String(e)}`)
|
||||
} finally {
|
||||
setIsExportingMembers(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async (text: string, field: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
@@ -264,6 +302,10 @@ function GroupAnalyticsPage() {
|
||||
|
||||
const renderMemberModal = () => {
|
||||
if (!selectedMember) return null
|
||||
const nickname = (selectedMember.nickname || '').trim()
|
||||
const alias = (selectedMember.alias || '').trim()
|
||||
const remark = (selectedMember.remark || '').trim()
|
||||
const groupNickname = (selectedMember.groupNickname || '').trim()
|
||||
|
||||
return (
|
||||
<div className="member-modal-overlay" onClick={() => setSelectedMember(null)}>
|
||||
@@ -286,11 +328,40 @@ function GroupAnalyticsPage() {
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">昵称</span>
|
||||
<span className="detail-value">{selectedMember.displayName}</span>
|
||||
<button className="copy-btn" onClick={() => handleCopy(selectedMember.displayName, 'displayName')}>
|
||||
{copiedField === 'displayName' ? <Check size={14} /> : <Copy size={14} />}
|
||||
<span className="detail-value">{nickname || '未设置'}</span>
|
||||
{nickname && (
|
||||
<button className="copy-btn" onClick={() => handleCopy(nickname, 'nickname')}>
|
||||
{copiedField === 'nickname' ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{alias && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">微信号</span>
|
||||
<span className="detail-value">{alias}</span>
|
||||
<button className="copy-btn" onClick={() => handleCopy(alias, 'alias')}>
|
||||
{copiedField === 'alias' ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{groupNickname && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">群昵称</span>
|
||||
<span className="detail-value">{groupNickname}</span>
|
||||
<button className="copy-btn" onClick={() => handleCopy(groupNickname, 'groupNickname')}>
|
||||
{copiedField === 'groupNickname' ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{remark && (
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">备注</span>
|
||||
<span className="detail-value">{remark}</span>
|
||||
<button className="copy-btn" onClick={() => handleCopy(remark, 'remark')}>
|
||||
{copiedField === 'remark' ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,6 +494,12 @@ function GroupAnalyticsPage() {
|
||||
onRangeComplete={handleDateRangeComplete}
|
||||
/>
|
||||
)}
|
||||
{selectedFunction === 'members' && (
|
||||
<button className="export-btn" onClick={handleExportMembers} disabled={functionLoading || isExportingMembers}>
|
||||
{isExportingMembers ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
|
||||
<span>导出成员</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
|
||||
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
|
||||
@@ -62,9 +62,11 @@ function SettingsPage() {
|
||||
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 [logEnabled, setLogEnabled] = useState(false)
|
||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||
@@ -96,6 +98,7 @@ function SettingsPage() {
|
||||
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
||||
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
||||
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// 安全设置 state
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
@@ -125,6 +128,9 @@ function SettingsPage() {
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
loadAppVersion()
|
||||
return () => {
|
||||
Object.values(saveTimersRef.current).forEach((timer) => clearTimeout(timer))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
@@ -140,16 +146,19 @@ function SettingsPage() {
|
||||
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])
|
||||
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||
setDbKeyStatus(payload.message)
|
||||
})
|
||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => {
|
||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
|
||||
setImageKeyStatus(payload.message)
|
||||
})
|
||||
return () => {
|
||||
@@ -261,7 +270,7 @@ function SettingsPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
|
||||
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
|
||||
if (typeof payload.percent === 'number') {
|
||||
setWhisperDownloadProgress(payload.percent)
|
||||
}
|
||||
@@ -334,6 +343,12 @@ function SettingsPage() {
|
||||
imageAesKey: imageAesKey || ''
|
||||
})
|
||||
|
||||
const buildKeysFromInputs = (overrides?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string }): WxidKeys => ({
|
||||
decryptKey: overrides?.decryptKey ?? decryptKey ?? '',
|
||||
imageXorKey: parseImageXorKey(overrides?.imageXorKey ?? imageXorKey),
|
||||
imageAesKey: overrides?.imageAesKey ?? imageAesKey ?? ''
|
||||
})
|
||||
|
||||
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
|
||||
decryptKey: wxidConfig?.decryptKey || '',
|
||||
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
|
||||
@@ -358,7 +373,7 @@ function SettingsPage() {
|
||||
|
||||
const applyWxidSelection = async (
|
||||
selectedWxid: string,
|
||||
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string }
|
||||
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string; keysOverride?: WxidKeys }
|
||||
) => {
|
||||
if (!selectedWxid) return
|
||||
|
||||
@@ -374,9 +389,9 @@ function SettingsPage() {
|
||||
}
|
||||
|
||||
const preferCurrentKeys = options?.preferCurrentKeys ?? false
|
||||
const keys = preferCurrentKeys
|
||||
const keys = options?.keysOverride ?? (preferCurrentKeys
|
||||
? buildKeysFromState()
|
||||
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))
|
||||
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid)))
|
||||
|
||||
setWxid(selectedWxid)
|
||||
applyKeysToState(keys)
|
||||
@@ -444,7 +459,9 @@ function SettingsPage() {
|
||||
try {
|
||||
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setDbPath(result.filePaths[0])
|
||||
const selectedPath = result.filePaths[0]
|
||||
setDbPath(selectedPath)
|
||||
await configService.setDbPath(selectedPath)
|
||||
showMessage('已选择数据库目录', true)
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -454,7 +471,7 @@ function SettingsPage() {
|
||||
|
||||
const handleScanWxid = async (
|
||||
silent = false,
|
||||
options?: { preferCurrentKeys?: boolean; showDialog?: boolean }
|
||||
options?: { preferCurrentKeys?: boolean; showDialog?: boolean; keysOverride?: WxidKeys }
|
||||
) => {
|
||||
if (!dbPath) {
|
||||
if (!silent) showMessage('请先选择数据库目录', false)
|
||||
@@ -468,7 +485,8 @@ function SettingsPage() {
|
||||
await applyWxidSelection(wxids[0].wxid, {
|
||||
preferCurrentKeys: options?.preferCurrentKeys ?? false,
|
||||
showToast: !silent,
|
||||
toastText: `已检测到账号:${wxids[0].wxid}`
|
||||
toastText: `已检测到账号:${wxids[0].wxid}`,
|
||||
keysOverride: options?.keysOverride
|
||||
})
|
||||
} else if (wxids.length > 1 && allowDialog) {
|
||||
setShowWxidSelect(true)
|
||||
@@ -488,7 +506,9 @@ function SettingsPage() {
|
||||
try {
|
||||
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setCachePath(result.filePaths[0])
|
||||
const selectedPath = result.filePaths[0]
|
||||
setCachePath(selectedPath)
|
||||
await configService.setCachePath(selectedPath)
|
||||
showMessage('已选择缓存目录', true)
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -554,7 +574,9 @@ function SettingsPage() {
|
||||
setDecryptKey(result.key)
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
showMessage('已自动获取解密密钥', true)
|
||||
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false })
|
||||
await syncCurrentKeys({ decryptKey: result.key, wxid })
|
||||
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
|
||||
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride })
|
||||
} else {
|
||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||
setIsManualStartPrompt(true)
|
||||
@@ -575,12 +597,25 @@ function SettingsPage() {
|
||||
handleAutoGetDbKey()
|
||||
}
|
||||
|
||||
// Helper to sync current keys to wxid config
|
||||
const syncCurrentKeys = async () => {
|
||||
const keys = buildKeysFromState()
|
||||
// Debounce config writes to avoid excessive disk IO
|
||||
const scheduleConfigSave = (key: string, task: () => Promise<void> | void, delay = 300) => {
|
||||
const timers = saveTimersRef.current
|
||||
if (timers[key]) {
|
||||
clearTimeout(timers[key])
|
||||
}
|
||||
timers[key] = setTimeout(() => {
|
||||
Promise.resolve(task()).catch((e) => {
|
||||
console.error('保存配置失败:', e)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const syncCurrentKeys = async (options?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string; wxid?: string }) => {
|
||||
const keys = buildKeysFromInputs(options)
|
||||
await syncKeysToConfig(keys)
|
||||
if (wxid) {
|
||||
await configService.setWxidConfig(wxid, {
|
||||
const wxidToUse = options?.wxid ?? wxid
|
||||
if (wxidToUse) {
|
||||
await configService.setWxidConfig(wxidToUse, {
|
||||
decryptKey: keys.decryptKey,
|
||||
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
|
||||
imageAesKey: keys.imageAesKey
|
||||
@@ -804,10 +839,11 @@ function SettingsPage() {
|
||||
type={showDecryptKey ? 'text' : 'password'}
|
||||
placeholder="例如: a1b2c3d4e5f6..."
|
||||
value={decryptKey}
|
||||
onChange={(e) => setDecryptKey(e.target.value)}
|
||||
onBlur={async () => {
|
||||
if (decryptKey && decryptKey.length === 64) {
|
||||
await syncCurrentKeys()
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setDecryptKey(value)
|
||||
if (value && value.length === 64) {
|
||||
scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value, wxid }))
|
||||
// showMessage('解密密钥已保存', true)
|
||||
}
|
||||
}}
|
||||
@@ -839,11 +875,14 @@ function SettingsPage() {
|
||||
type="text"
|
||||
placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
onBlur={async () => {
|
||||
if (dbPath) {
|
||||
await configService.setDbPath(dbPath)
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setDbPath(value)
|
||||
scheduleConfigSave('dbPath', async () => {
|
||||
if (value) {
|
||||
await configService.setDbPath(value)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div className="btn-row">
|
||||
@@ -862,12 +901,43 @@ function SettingsPage() {
|
||||
type="text"
|
||||
placeholder="例如: wxid_xxxxxx"
|
||||
value={wxid}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
onBlur={async () => {
|
||||
if (wxid) {
|
||||
await configService.setMyWxid(wxid)
|
||||
await syncCurrentKeys() // Sync keys to the new wxid entry
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
const previousWxid = wxid
|
||||
setWxid(value)
|
||||
scheduleConfigSave('wxid', async () => {
|
||||
if (previousWxid && previousWxid !== value) {
|
||||
const currentKeys = buildKeysFromState()
|
||||
await configService.setWxidConfig(previousWxid, {
|
||||
decryptKey: currentKeys.decryptKey,
|
||||
imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0,
|
||||
imageAesKey: currentKeys.imageAesKey
|
||||
})
|
||||
}
|
||||
if (value) {
|
||||
await configService.setMyWxid(value)
|
||||
await syncCurrentKeys({ wxid: value }) // Sync keys to the new wxid entry
|
||||
}
|
||||
|
||||
if (value && previousWxid !== value) {
|
||||
if (isDbConnected) {
|
||||
try {
|
||||
await window.electronAPI.chat.close()
|
||||
const result = await window.electronAPI.chat.connect()
|
||||
setDbConnected(result.success, dbPath || undefined)
|
||||
if (!result.success && result.error) {
|
||||
showMessage(result.error, false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage(`切换账号后重新连接失败: ${e}`, false)
|
||||
setDbConnected(false)
|
||||
}
|
||||
}
|
||||
clearAnalyticsStoreCache()
|
||||
resetChatStore()
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: value } }))
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -881,8 +951,14 @@ function SettingsPage() {
|
||||
type="text"
|
||||
placeholder="例如: 0xA4"
|
||||
value={imageXorKey}
|
||||
onChange={(e) => setImageXorKey(e.target.value)}
|
||||
onBlur={syncCurrentKeys}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setImageXorKey(value)
|
||||
const parsed = parseImageXorKey(value)
|
||||
if (value === '' || parsed !== null) {
|
||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value, wxid }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -893,8 +969,11 @@ function SettingsPage() {
|
||||
type="text"
|
||||
placeholder="16 位 AES 密钥"
|
||||
value={imageAesKey}
|
||||
onChange={(e) => setImageAesKey(e.target.value)}
|
||||
onBlur={syncCurrentKeys}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setImageAesKey(value)
|
||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
@@ -1009,8 +1088,11 @@ function SettingsPage() {
|
||||
type="text"
|
||||
placeholder="留空使用默认目录"
|
||||
value={whisperModelDir}
|
||||
onChange={(e) => setWhisperModelDir(e.target.value)}
|
||||
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setWhisperModelDir(value)
|
||||
scheduleConfigSave('whisperModelDir', () => configService.setWhisperModelDir(value))
|
||||
}}
|
||||
/>
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> 选择目录</button>
|
||||
@@ -1064,6 +1146,15 @@ function SettingsPage() {
|
||||
{ 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
|
||||
}
|
||||
@@ -1073,6 +1164,7 @@ function SettingsPage() {
|
||||
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
||||
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
||||
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
||||
const exportConcurrencyLabel = String(exportDefaultConcurrency)
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
@@ -1087,6 +1179,7 @@ function SettingsPage() {
|
||||
setShowExportFormatSelect(!showExportFormatSelect)
|
||||
setShowExportDateRangeSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setShowExportConcurrencySelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportFormatLabel}</span>
|
||||
@@ -1126,6 +1219,7 @@ function SettingsPage() {
|
||||
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setShowExportConcurrencySelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportDateRangeLabel}</span>
|
||||
@@ -1210,6 +1304,7 @@ function SettingsPage() {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportDateRangeSelect(false)
|
||||
setShowExportConcurrencySelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||
@@ -1239,6 +1334,45 @@ function SettingsPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1252,14 +1386,23 @@ function SettingsPage() {
|
||||
type="text"
|
||||
placeholder="留空使用默认目录"
|
||||
value={cachePath}
|
||||
onChange={(e) => setCachePath(e.target.value)}
|
||||
onBlur={async () => {
|
||||
await configService.setCachePath(cachePath)
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setCachePath(value)
|
||||
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
|
||||
}}
|
||||
/>
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={async () => {
|
||||
setCachePath('')
|
||||
await configService.setCachePath('')
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} /> 恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -547,10 +547,41 @@
|
||||
.sns-content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sns-notice-banner {
|
||||
margin: 16px 24px 0 24px;
|
||||
padding: 10px 16px;
|
||||
background: rgba(var(--accent-color-rgb), 0.08);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--accent-color);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
animation: banner-slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes banner-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sns-content {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight } from 'lucide-react'
|
||||
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight, AlertTriangle } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import { ImagePreview } from '../components/ImagePreview'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
@@ -165,8 +165,8 @@ export default function SnsPage() {
|
||||
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
|
||||
}
|
||||
|
||||
const existingIds = new Set(currentPosts.map(p => p.id));
|
||||
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id));
|
||||
const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id));
|
||||
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
|
||||
|
||||
if (uniqueNewer.length > 0) {
|
||||
setPosts(prev => [...uniqueNewer, ...prev]);
|
||||
@@ -253,7 +253,7 @@ export default function SnsPage() {
|
||||
}))
|
||||
setContacts(initialContacts)
|
||||
|
||||
const usernames = initialContacts.map(c => c.username)
|
||||
const usernames = initialContacts.map((c: { username: string }) => c.username)
|
||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||
if (enriched.success && enriched.contacts) {
|
||||
setContacts(prev => prev.map(c => {
|
||||
@@ -412,6 +412,10 @@ export default function SnsPage() {
|
||||
</div>
|
||||
|
||||
<div className="sns-content-wrapper">
|
||||
<div className="sns-notice-banner">
|
||||
<AlertTriangle size={16} />
|
||||
<span>由于技术限制,当前无法解密显示部分图片与视频等加密资源文件</span>
|
||||
</div>
|
||||
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
|
||||
<div className="posts-list">
|
||||
{loadingNewer && (
|
||||
|
||||
@@ -435,6 +435,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wxid-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
z-index: 20;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.wxid-option {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wxid-time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-with-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
@@ -35,6 +35,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
|
||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||
const wxidSelectRef = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [isDetectingPath, setIsDetectingPath] = useState(false)
|
||||
@@ -106,10 +108,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||
setDbKeyStatus(payload.message)
|
||||
})
|
||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => {
|
||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
|
||||
setImageKeyStatus(payload.message)
|
||||
})
|
||||
return () => {
|
||||
@@ -127,8 +129,22 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
useEffect(() => {
|
||||
setWxidOptions([])
|
||||
setWxid('')
|
||||
setShowWxidSelect(false)
|
||||
}, [dbPath])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!showWxidSelect) return
|
||||
const target = event.target as Node
|
||||
if (wxidSelectRef.current && !wxidSelectRef.current.contains(target)) {
|
||||
setShowWxidSelect(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showWxidSelect])
|
||||
|
||||
const currentStep = steps[stepIndex]
|
||||
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
|
||||
const showWindowControls = standalone
|
||||
@@ -217,6 +233,28 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanWxidCandidates = async () => {
|
||||
if (!dbPath) {
|
||||
setError('请先选择数据库目录')
|
||||
return
|
||||
}
|
||||
if (isScanningWxid) return
|
||||
setIsScanningWxid(true)
|
||||
setError('')
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxidCandidates(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
setShowWxidSelect(true)
|
||||
if (!wxids.length) {
|
||||
setError('未检测到可用的账号目录,请检查路径')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`扫描失败: ${e}`)
|
||||
} finally {
|
||||
setIsScanningWxid(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoGetDbKey = async () => {
|
||||
if (isFetchingDbKey) return
|
||||
setIsFetchingDbKey(true)
|
||||
@@ -556,14 +594,35 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
{currentStep.id === 'key' && (
|
||||
<div className="form-group">
|
||||
<label className="field-label">微信账号 (Wxid)</label>
|
||||
<div className="wxid-select" ref={wxidSelectRef}>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="等待获取..."
|
||||
placeholder="点击选择..."
|
||||
value={wxid}
|
||||
readOnly
|
||||
onClick={handleScanWxidCandidates}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
/>
|
||||
{showWxidSelect && wxidOptions.length > 0 && (
|
||||
<div className="wxid-dropdown">
|
||||
{wxidOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.wxid}
|
||||
type="button"
|
||||
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setWxid(opt.wxid)
|
||||
setShowWxidSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="wxid-name">{opt.wxid}</span>
|
||||
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="field-label mt-4">解密密钥</label>
|
||||
<div className="field-with-toggle">
|
||||
@@ -733,4 +792,3 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
|
||||
export default WelcomePage
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface AppState {
|
||||
setShowUpdateDialog: (show: boolean) => void
|
||||
setUpdateError: (error: string | null) => void
|
||||
|
||||
// 锁定状态
|
||||
isLocked: boolean
|
||||
setLocked: (locked: boolean) => void
|
||||
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
@@ -42,6 +46,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
myWxid: null,
|
||||
isLoading: false,
|
||||
loadingText: '',
|
||||
isLocked: false,
|
||||
|
||||
// 更新状态初始化
|
||||
updateInfo: null,
|
||||
@@ -62,6 +67,8 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
loadingText: text ?? ''
|
||||
}),
|
||||
|
||||
setLocked: (locked) => set({ isLocked: locked }),
|
||||
|
||||
setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }),
|
||||
setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }),
|
||||
setDownloadProgress: (progress) => set({ downloadProgress: progress }),
|
||||
@@ -74,6 +81,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
myWxid: null,
|
||||
isLoading: false,
|
||||
loadingText: '',
|
||||
isLocked: false,
|
||||
updateInfo: null,
|
||||
isDownloading: false,
|
||||
downloadProgress: { percent: 0 },
|
||||
|
||||
81
src/types/electron.d.ts
vendored
81
src/types/electron.d.ts
vendored
@@ -42,6 +42,7 @@ export interface ElectronAPI {
|
||||
dbPath: {
|
||||
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
scanWxids: (rootPath: string) => Promise<WxidInfo[]>
|
||||
scanWxidCandidates: (rootPath: string) => Promise<WxidInfo[]>
|
||||
getDefault: () => Promise<string>
|
||||
}
|
||||
wcdb: {
|
||||
@@ -174,6 +175,26 @@ export interface ElectronAPI {
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
getExcludedUsernames: () => Promise<{
|
||||
success: boolean
|
||||
data?: string[]
|
||||
error?: string
|
||||
}>
|
||||
setExcludedUsernames: (usernames: string[]) => Promise<{
|
||||
success: boolean
|
||||
data?: string[]
|
||||
error?: string
|
||||
}>
|
||||
getExcludeCandidates: () => Promise<{
|
||||
success: boolean
|
||||
data?: Array<{
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
wechatId?: string
|
||||
}>
|
||||
error?: string
|
||||
}>
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||
}
|
||||
cache: {
|
||||
@@ -198,6 +219,10 @@ export interface ElectronAPI {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
nickname?: string
|
||||
alias?: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
}>
|
||||
error?: string
|
||||
}>
|
||||
@@ -232,6 +257,11 @@ export interface ElectronAPI {
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
|
||||
success: boolean
|
||||
count?: number
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
annualReport: {
|
||||
getAvailableYears: () => Promise<{
|
||||
@@ -311,6 +341,57 @@ export interface ElectronAPI {
|
||||
}>
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||
}
|
||||
dualReport: {
|
||||
generateReport: (payload: { friendUsername: string; year: number }) => Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
year: number
|
||||
selfName: string
|
||||
friendUsername: string
|
||||
friendName: string
|
||||
firstChat: {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
senderUsername?: string
|
||||
} | null
|
||||
firstChatMessages?: Array<{
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
}>
|
||||
yearFirstChat?: {
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
friendName: string
|
||||
firstThreeMessages: Array<{
|
||||
content: string
|
||||
isSentByMe: boolean
|
||||
createTime: number
|
||||
createTimeStr: string
|
||||
}>
|
||||
} | null
|
||||
stats: {
|
||||
totalMessages: number
|
||||
totalWords: number
|
||||
imageCount: number
|
||||
voiceCount: number
|
||||
emojiCount: number
|
||||
myTopEmojiMd5?: string
|
||||
friendTopEmojiMd5?: string
|
||||
myTopEmojiUrl?: string
|
||||
friendTopEmojiUrl?: string
|
||||
}
|
||||
topPhrases: Array<{ phrase: string; count: number }>
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||
}
|
||||
export: {
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
||||
success: boolean
|
||||
|
||||
13
src/vite-env.d.ts
vendored
13
src/vite-env.d.ts
vendored
@@ -1 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
// ... other methods ...
|
||||
auth: {
|
||||
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,24 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/dualReportWorker.ts',
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'koffi',
|
||||
'fsevents'
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'dualReportWorker.js',
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/imageSearchWorker.ts',
|
||||
vite: {
|
||||
|
||||
Reference in New Issue
Block a user