mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-27 15:07:55 +00:00
Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af530a15e | ||
|
|
6eae60ba54 | ||
|
|
2d711cca80 | ||
|
|
b274c99b91 | ||
|
|
4e66074603 | ||
|
|
42fbc479c9 | ||
|
|
f47610b98a | ||
|
|
cda45ce64c | ||
|
|
009a0d64b8 | ||
|
|
3afb0da017 | ||
|
|
bdc7f8a8a8 | ||
|
|
69a72f24ed | ||
|
|
ee0e71d50e | ||
|
|
39ba175651 | ||
|
|
731f022669 | ||
|
|
8d5527990b | ||
|
|
1ff536c2f7 | ||
|
|
27a18f1fc6 | ||
|
|
8921b90392 | ||
|
|
6cd925b062 | ||
|
|
28a344c63c | ||
|
|
a9b5fa0fae | ||
|
|
65212201ad | ||
|
|
d8c3ba34a8 | ||
|
|
63be8a35ad | ||
|
|
53ef4e11f9 | ||
|
|
c9a6451407 | ||
|
|
9d07a3a7bd | ||
|
|
bd4296199a | ||
|
|
b9e0535f63 | ||
|
|
6e371d75c8 | ||
|
|
7697f382ef | ||
|
|
4c551a8c91 | ||
|
|
56227c69f7 | ||
|
|
5acd3d86c8 | ||
|
|
d7f7139f36 | ||
|
|
1c5cacf1ce | ||
|
|
0a603116ef | ||
|
|
809b28a994 | ||
|
|
f7610a3570 | ||
|
|
bff9e87096 | ||
|
|
d872a8af20 | ||
|
|
4966cdbfac | ||
|
|
cb3eb83eac | ||
|
|
5daa7bce73 | ||
|
|
4e80f93b30 | ||
|
|
2776a1a5ce | ||
|
|
4f402d6a6a | ||
|
|
d544da6e4d | ||
|
|
0e42c19d3b | ||
|
|
0a2bd3d46a | ||
|
|
86d2dade11 | ||
|
|
19ab4409a3 | ||
|
|
3af90bd6e9 | ||
|
|
cfb0cff1a3 | ||
|
|
c08d6cd668 | ||
|
|
a53bebefd7 | ||
|
|
8e0c3306e8 | ||
|
|
f4364b3bd3 | ||
|
|
5b5757a1d7 | ||
|
|
f165f4911b | ||
|
|
b81b538d9a | ||
|
|
2f32c8e092 | ||
|
|
d101a79bf8 | ||
|
|
caea10a190 | ||
|
|
1445202a0d | ||
|
|
6f62ac4ffb | ||
|
|
e87bbe7223 | ||
|
|
e7e2c40c68 | ||
|
|
78b6d445fa | ||
|
|
c212355860 | ||
|
|
c223c20b38 | ||
|
|
524a9cda35 | ||
|
|
8bee66d404 | ||
|
|
142b00499b | ||
|
|
b0ea6c0ea2 | ||
|
|
67fd53a503 | ||
|
|
29529271fb | ||
|
|
4489a0f702 | ||
|
|
0d9fcc731a | ||
|
|
fe1c8862e6 | ||
|
|
092450e4f8 | ||
|
|
da054de708 | ||
|
|
dfac3c57cc | ||
|
|
0f3ecdc4ee | ||
|
|
24c47c3aa3 | ||
|
|
f53de9fe0b | ||
|
|
ee4d1f5689 | ||
|
|
122ad73c2e | ||
|
|
6ad1e6c3f3 | ||
|
|
c899fa72b8 | ||
|
|
e209bd68d4 | ||
|
|
96ac655d92 | ||
|
|
1d97b19774 | ||
|
|
11c7de3568 | ||
|
|
38d899fa94 | ||
|
|
37796c98c9 | ||
|
|
5b2e48badd | ||
|
|
627aa35f88 | ||
|
|
74e974177c | ||
|
|
6911132c95 | ||
|
|
f1affc7d63 | ||
|
|
bea824aee9 | ||
|
|
cbdd5b3a24 | ||
|
|
c02bc753fd | ||
|
|
d4915e1a62 | ||
|
|
2d4a5fc62f | ||
|
|
94a010c9b2 | ||
|
|
a6a202f6ff | ||
|
|
2127fdd443 | ||
|
|
3b3fd8b35c | ||
|
|
95d0937015 | ||
|
|
b070b4f659 | ||
|
|
a8c05fd26c | ||
|
|
ecd64f62bc | ||
|
|
5affd4e57b | ||
|
|
76d69ab7dd | ||
|
|
1d1b38210a | ||
|
|
836032d93e | ||
|
|
dc3e285917 | ||
|
|
e54eb8fea2 | ||
|
|
177dbaa5ff | ||
|
|
1d08ab945d | ||
|
|
10ce7d772c | ||
|
|
e1a23ac606 | ||
|
|
439259ec57 | ||
|
|
a0dda0b866 | ||
|
|
6913defc12 | ||
|
|
f3e2fdd4fc | ||
|
|
5c44b35045 | ||
|
|
cebb6426f8 | ||
|
|
f05e50e63e | ||
|
|
f8ef3f18ff | ||
|
|
47dbc540ac | ||
|
|
766d5ed2af | ||
|
|
783b408611 | ||
|
|
24c91269a0 |
61
.github/workflows/release.yml
vendored
61
.github/workflows/release.yml
vendored
@@ -8,20 +8,75 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
release-mac-arm64:
|
||||||
|
runs-on: macos-14
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish macOS arm64 (unsigned DMG)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
run: |
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish always
|
||||||
|
|
||||||
|
- name: Update Release Notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cat <<EOF > release_notes.md
|
||||||
|
## 更新日志
|
||||||
|
修复了一些已知问题
|
||||||
|
|
||||||
|
## 查看更多日志/获取最新动态
|
||||||
|
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22.12
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -63,6 +63,8 @@ chatlab-format.md
|
|||||||
*.bak
|
*.bak
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
.agents/
|
.agents/
|
||||||
resources/wx_send
|
resources/wx_send
|
||||||
概述.md
|
概述.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|||||||
14
electron/entitlements.mac.plist
Normal file
14
electron/entitlements.mac.plist
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.debugger</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -10,7 +10,7 @@ type WorkerPayload = {
|
|||||||
thumbOnly: boolean
|
thumbOnly: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean }
|
type Candidate = { score: number; path: string; isThumb: boolean }
|
||||||
|
|
||||||
const payload = workerData as WorkerPayload
|
const payload = workerData as WorkerPayload
|
||||||
|
|
||||||
@@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean {
|
|||||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripDatVariantSuffix(base: string): string {
|
||||||
|
const lower = base.toLowerCase()
|
||||||
|
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||||
|
for (const suffix of suffixes) {
|
||||||
|
if (lower.endsWith(suffix)) {
|
||||||
|
return lower.slice(0, -suffix.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/[._][a-z]$/.test(lower)) {
|
||||||
|
return lower.slice(0, -2)
|
||||||
|
}
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
|
||||||
function hasXVariant(baseLower: string): boolean {
|
function hasXVariant(baseLower: string): boolean {
|
||||||
return /[._][a-z]$/.test(baseLower)
|
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasImageVariantSuffix(baseLower: string): boolean {
|
function hasImageVariantSuffix(baseLower: string): boolean {
|
||||||
return /[._][a-z]$/.test(baseLower)
|
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyImageDatBase(baseLower: string): boolean {
|
|
||||||
return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDatBase(name: string): string {
|
function normalizeDatBase(name: string): string {
|
||||||
@@ -35,11 +45,18 @@ function normalizeDatBase(name: string): string {
|
|||||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||||
base = base.slice(0, -4)
|
base = base.slice(0, -4)
|
||||||
}
|
}
|
||||||
while (/[._][a-z]$/.test(base)) {
|
while (true) {
|
||||||
base = base.slice(0, -2)
|
const stripped = stripDatVariantSuffix(base)
|
||||||
}
|
if (stripped === base) {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
base = stripped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyImageDatBase(baseLower: string): boolean {
|
||||||
|
return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower))
|
||||||
|
}
|
||||||
|
|
||||||
function matchesDatName(fileName: string, datName: string): boolean {
|
function matchesDatName(fileName: string, datName: string): boolean {
|
||||||
const lower = fileName.toLowerCase()
|
const lower = fileName.toLowerCase()
|
||||||
@@ -47,25 +64,23 @@ function matchesDatName(fileName: string, datName: string): boolean {
|
|||||||
const normalizedBase = normalizeDatBase(base)
|
const normalizedBase = normalizeDatBase(base)
|
||||||
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
|
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
|
||||||
if (normalizedBase === normalizedTarget) return true
|
if (normalizedBase === normalizedTarget) return true
|
||||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`)
|
return lower.endsWith('.dat') && lower.includes(normalizedTarget)
|
||||||
if (pattern.test(lower)) return true
|
|
||||||
return lower.endsWith('.dat') && lower.includes(datName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreDatName(fileName: string): number {
|
function scoreDatName(fileName: string): number {
|
||||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
const lower = fileName.toLowerCase()
|
||||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||||
return 2
|
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||||
|
if (!hasXVariant(baseLower)) return 500
|
||||||
|
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||||
|
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||||
|
if (isThumbnailDat(lower)) return 100
|
||||||
|
return 350
|
||||||
}
|
}
|
||||||
|
|
||||||
function isThumbnailDat(fileName: string): boolean {
|
function isThumbnailDat(fileName: string): boolean {
|
||||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHdDat(fileName: string): boolean {
|
|
||||||
const lower = fileName.toLowerCase()
|
const lower = fileName.toLowerCase()
|
||||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat')
|
||||||
return base.endsWith('_hd') || base.endsWith('_h')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkForDat(
|
function walkForDat(
|
||||||
@@ -105,20 +120,15 @@ function walkForDat(
|
|||||||
if (!lower.endsWith('.dat')) continue
|
if (!lower.endsWith('.dat')) continue
|
||||||
const baseLower = lower.slice(0, -4)
|
const baseLower = lower.slice(0, -4)
|
||||||
if (!isLikelyImageDatBase(baseLower)) continue
|
if (!isLikelyImageDatBase(baseLower)) continue
|
||||||
if (!hasXVariant(baseLower)) continue
|
|
||||||
if (!matchesDatName(lower, datName)) continue
|
if (!matchesDatName(lower, datName)) continue
|
||||||
// 排除高清图片格式 (_hd, _h)
|
|
||||||
if (isHdDat(lower)) continue
|
|
||||||
matchedBases.add(baseLower)
|
matchedBases.add(baseLower)
|
||||||
const isThumb = isThumbnailDat(lower)
|
const isThumb = isThumbnailDat(lower)
|
||||||
if (!allowThumbnail && isThumb) continue
|
if (!allowThumbnail && isThumb) continue
|
||||||
if (thumbOnly && !isThumb) continue
|
if (thumbOnly && !isThumb) continue
|
||||||
const score = scoreDatName(lower)
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
score,
|
score: scoreDatName(lower),
|
||||||
path: entryPath,
|
path: entryPath,
|
||||||
isThumb,
|
isThumb
|
||||||
hasX: hasXVariant(baseLower)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,10 +136,8 @@ function walkForDat(
|
|||||||
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
|
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const withX = candidates.filter((item) => item.hasX)
|
const nonThumb = candidates.filter((item) => !item.isThumb)
|
||||||
const basePool = withX.length ? withX : candidates
|
const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates)
|
||||||
const nonThumb = basePool.filter((item) => !item.isThumb)
|
|
||||||
const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool)
|
|
||||||
|
|
||||||
let best: { score: number; path: string } | null = null
|
let best: { score: number; path: string } | null = null
|
||||||
for (const item of finalPool) {
|
for (const item of finalPool) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
|
|||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
|
import { KeyServiceMac } from './services/keyServiceMac'
|
||||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
import { videoService } from './services/videoService'
|
import { videoService } from './services/videoService'
|
||||||
import { snsService, isVideoUrl } from './services/snsService'
|
import { snsService, isVideoUrl } from './services/snsService'
|
||||||
@@ -88,7 +89,9 @@ let onboardingWindow: BrowserWindow | null = null
|
|||||||
let splashWindow: BrowserWindow | null = null
|
let splashWindow: BrowserWindow | null = null
|
||||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||||
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||||
const keyService = new KeyService()
|
const keyService = process.platform === 'darwin'
|
||||||
|
? new KeyServiceMac() as any
|
||||||
|
: new KeyService()
|
||||||
|
|
||||||
let mainWindowReady = false
|
let mainWindowReady = false
|
||||||
let shouldShowMain = true
|
let shouldShowMain = true
|
||||||
@@ -232,13 +235,32 @@ const isYearsLoadCanceled = (taskId: string): boolean => {
|
|||||||
return task?.canceled === true
|
return task?.canceled === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
win.setWindowButtonVisibility(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitMaximizeState = () => {
|
||||||
|
if (win.isDestroyed()) return
|
||||||
|
win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen())
|
||||||
|
}
|
||||||
|
|
||||||
|
win.on('maximize', emitMaximizeState)
|
||||||
|
win.on('unmaximize', emitMaximizeState)
|
||||||
|
win.on('enter-full-screen', emitMaximizeState)
|
||||||
|
win.on('leave-full-screen', emitMaximizeState)
|
||||||
|
win.webContents.on('did-finish-load', emitMaximizeState)
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(options: { autoShow?: boolean } = {}) {
|
function createWindow(options: { autoShow?: boolean } = {}) {
|
||||||
// 获取图标路径 - 打包后在 resources 目录
|
// 获取图标路径 - 打包后在 resources 目录
|
||||||
const { autoShow = true } = options
|
const { autoShow = true } = options
|
||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: (process.platform === 'darwin'
|
||||||
|
? join(process.resourcesPath, 'icon.icns')
|
||||||
|
: join(process.resourcesPath, 'icon.ico'))
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
@@ -253,13 +275,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
webSecurity: false // Allow loading local files (video playback)
|
webSecurity: false // Allow loading local files (video playback)
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden',
|
||||||
titleBarOverlay: {
|
titleBarOverlay: false,
|
||||||
color: '#00000000',
|
|
||||||
symbolColor: '#1a1a1a',
|
|
||||||
height: 40
|
|
||||||
},
|
|
||||||
show: false
|
show: false
|
||||||
})
|
})
|
||||||
|
setupCustomTitleBarWindow(win)
|
||||||
|
|
||||||
// 窗口准备好后显示
|
// 窗口准备好后显示
|
||||||
// Splash 模式下不在这里 show,由启动流程统一控制
|
// Splash 模式下不在这里 show,由启动流程统一控制
|
||||||
@@ -364,7 +383,9 @@ function createAgreementWindow() {
|
|||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: (process.platform === 'darwin'
|
||||||
|
? join(process.resourcesPath, 'icon.icns')
|
||||||
|
: join(process.resourcesPath, 'icon.ico'))
|
||||||
|
|
||||||
const isDark = nativeTheme.shouldUseDarkColors
|
const isDark = nativeTheme.shouldUseDarkColors
|
||||||
|
|
||||||
@@ -414,7 +435,9 @@ function createSplashWindow(): BrowserWindow {
|
|||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: (process.platform === 'darwin'
|
||||||
|
? join(process.resourcesPath, 'icon.icns')
|
||||||
|
: join(process.resourcesPath, 'icon.ico'))
|
||||||
|
|
||||||
splashWindow = new BrowserWindow({
|
splashWindow = new BrowserWindow({
|
||||||
width: 760,
|
width: 760,
|
||||||
@@ -485,7 +508,9 @@ function createOnboardingWindow() {
|
|||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: (process.platform === 'darwin'
|
||||||
|
? join(process.resourcesPath, 'icon.icns')
|
||||||
|
: join(process.resourcesPath, 'icon.ico'))
|
||||||
|
|
||||||
onboardingWindow = new BrowserWindow({
|
onboardingWindow = new BrowserWindow({
|
||||||
width: 960,
|
width: 960,
|
||||||
@@ -531,7 +556,9 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
|
|||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: (process.platform === 'darwin'
|
||||||
|
? join(process.resourcesPath, 'icon.icns')
|
||||||
|
: join(process.resourcesPath, 'icon.ico'))
|
||||||
|
|
||||||
// 获取屏幕尺寸
|
// 获取屏幕尺寸
|
||||||
const { screen } = require('electron')
|
const { screen } = require('electron')
|
||||||
@@ -629,7 +656,9 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
|||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: (process.platform === 'darwin'
|
||||||
|
? join(process.resourcesPath, 'icon.icns')
|
||||||
|
: join(process.resourcesPath, 'icon.ico'))
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 900,
|
width: 900,
|
||||||
@@ -643,17 +672,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
|||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
webSecurity: false // 允许加载本地文件
|
webSecurity: false // 允许加载本地文件
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hidden',
|
frame: false,
|
||||||
titleBarOverlay: {
|
|
||||||
color: '#00000000',
|
|
||||||
symbolColor: '#ffffff',
|
|
||||||
height: 40
|
|
||||||
},
|
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: '#000000',
|
backgroundColor: '#000000',
|
||||||
autoHideMenuBar: true
|
autoHideMenuBar: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setupCustomTitleBarWindow(win)
|
||||||
|
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
win.show()
|
win.show()
|
||||||
})
|
})
|
||||||
@@ -690,7 +716,9 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
|||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: (process.platform === 'darwin'
|
||||||
|
? join(process.resourcesPath, 'icon.icns')
|
||||||
|
: join(process.resourcesPath, 'icon.ico'))
|
||||||
|
|
||||||
// 根据系统主题设置窗口背景色
|
// 根据系统主题设置窗口背景色
|
||||||
const isDark = nativeTheme.shouldUseDarkColors
|
const isDark = nativeTheme.shouldUseDarkColors
|
||||||
@@ -707,15 +735,12 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
|||||||
nodeIntegration: false
|
nodeIntegration: false
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden',
|
||||||
titleBarOverlay: {
|
titleBarOverlay: false,
|
||||||
color: '#00000000',
|
|
||||||
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
|
|
||||||
height: 32
|
|
||||||
},
|
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
|
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
|
||||||
autoHideMenuBar: true
|
autoHideMenuBar: true
|
||||||
})
|
})
|
||||||
|
setupCustomTitleBarWindow(win)
|
||||||
|
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
win.show()
|
win.show()
|
||||||
@@ -768,7 +793,9 @@ function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWin
|
|||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: (process.platform === 'darwin'
|
||||||
|
? join(process.resourcesPath, 'icon.icns')
|
||||||
|
: join(process.resourcesPath, 'icon.ico'))
|
||||||
|
|
||||||
const isDark = nativeTheme.shouldUseDarkColors
|
const isDark = nativeTheme.shouldUseDarkColors
|
||||||
|
|
||||||
@@ -961,6 +988,17 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('log:clear', async () => {
|
||||||
|
try {
|
||||||
|
const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
|
||||||
|
await mkdir(dirname(logPath), { recursive: true })
|
||||||
|
await writeFile(logPath, '', 'utf8')
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
|
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
|
||||||
return exportCardDiagnosticsService.snapshot(options?.limit)
|
return exportCardDiagnosticsService.snapshot(options?.limit)
|
||||||
})
|
})
|
||||||
@@ -1100,6 +1138,11 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('window:isMaximized', (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
return Boolean(win?.isMaximized() || win?.isFullScreen())
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.on('window:close', (event) => {
|
ipcMain.on('window:close', (event) => {
|
||||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||||
})
|
})
|
||||||
@@ -1999,7 +2042,6 @@ function registerIpcHandlers() {
|
|||||||
dbPath,
|
dbPath,
|
||||||
decryptKey,
|
decryptKey,
|
||||||
wxid,
|
wxid,
|
||||||
nativeTimeoutMs: 5000,
|
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
if (isYearsLoadCanceled(taskId)) return
|
if (isYearsLoadCanceled(taskId)) return
|
||||||
const snapshot = updateTaskSnapshot({
|
const snapshot = updateTaskSnapshot({
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
log: {
|
log: {
|
||||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
getPath: () => ipcRenderer.invoke('log:getPath'),
|
||||||
read: () => ipcRenderer.invoke('log:read'),
|
read: () => ipcRenderer.invoke('log:read'),
|
||||||
|
clear: () => ipcRenderer.invoke('log:clear'),
|
||||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -86,6 +87,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
window: {
|
window: {
|
||||||
minimize: () => ipcRenderer.send('window:minimize'),
|
minimize: () => ipcRenderer.send('window:minimize'),
|
||||||
maximize: () => ipcRenderer.send('window:maximize'),
|
maximize: () => ipcRenderer.send('window:maximize'),
|
||||||
|
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||||
|
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
|
||||||
|
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
|
||||||
|
ipcRenderer.on('window:maximizeStateChanged', listener)
|
||||||
|
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||||
|
},
|
||||||
close: () => ipcRenderer.send('window:close'),
|
close: () => ipcRenderer.send('window:close'),
|
||||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import * as https from 'https'
|
|||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
import * as fzstd from 'fzstd'
|
import * as fzstd from 'fzstd'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import Database from 'better-sqlite3'
|
|
||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
@@ -16,14 +15,9 @@ import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStat
|
|||||||
import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService'
|
import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService'
|
||||||
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
|
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
|
||||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||||
|
import { ImageDecryptService } from './imageDecryptService'
|
||||||
import { LRUCache } from '../utils/LRUCache.js'
|
import { LRUCache } from '../utils/LRUCache.js'
|
||||||
|
|
||||||
type HardlinkState = {
|
|
||||||
db: Database.Database
|
|
||||||
imageTable?: string
|
|
||||||
dirTable?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatSession {
|
export interface ChatSession {
|
||||||
username: string
|
username: string
|
||||||
type: number
|
type: number
|
||||||
@@ -213,11 +207,11 @@ class ChatService {
|
|||||||
private avatarCache: Map<string, ContactCacheEntry>
|
private avatarCache: Map<string, ContactCacheEntry>
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||||
private hardlinkCache = new Map<string, HardlinkState>()
|
|
||||||
private readonly contactCacheService: ContactCacheService
|
private readonly contactCacheService: ContactCacheService
|
||||||
private readonly messageCacheService: MessageCacheService
|
private readonly messageCacheService: MessageCacheService
|
||||||
private readonly sessionStatsCacheService: SessionStatsCacheService
|
private readonly sessionStatsCacheService: SessionStatsCacheService
|
||||||
private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService
|
private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService
|
||||||
|
private readonly imageDecryptService: ImageDecryptService
|
||||||
private voiceWavCache: LRUCache<string, Buffer>
|
private voiceWavCache: LRUCache<string, Buffer>
|
||||||
private voiceTranscriptCache: LRUCache<string, string>
|
private voiceTranscriptCache: LRUCache<string, string>
|
||||||
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
||||||
@@ -276,6 +270,7 @@ class ChatService {
|
|||||||
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
|
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
|
||||||
this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath())
|
this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath())
|
||||||
this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath())
|
this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath())
|
||||||
|
this.imageDecryptService = new ImageDecryptService()
|
||||||
// 初始化LRU缓存,限制大小防止内存泄漏
|
// 初始化LRU缓存,限制大小防止内存泄漏
|
||||||
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
|
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
|
||||||
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
|
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
|
||||||
@@ -364,8 +359,9 @@ class ChatService {
|
|||||||
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
||||||
wcdbService.setMonitor((type, json) => {
|
wcdbService.setMonitor((type, json) => {
|
||||||
this.handleSessionStatsMonitorChange(type, json)
|
this.handleSessionStatsMonitorChange(type, json)
|
||||||
|
const windows = BrowserWindow.getAllWindows()
|
||||||
// 广播给所有渲染进程窗口
|
// 广播给所有渲染进程窗口
|
||||||
BrowserWindow.getAllWindows().forEach((win) => {
|
windows.forEach((win) => {
|
||||||
if (!win.isDestroyed()) {
|
if (!win.isDestroyed()) {
|
||||||
win.webContents.send('wcdb-change', { type, json })
|
win.webContents.send('wcdb-change', { type, json })
|
||||||
}
|
}
|
||||||
@@ -2979,7 +2975,9 @@ class ChatService {
|
|||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
||||||
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
||||||
let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
||||||
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null
|
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||||
|
|| this.extractSenderUsernameFromContent(content)
|
||||||
|
|| null
|
||||||
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
||||||
|
|
||||||
if (senderUsername && (myWxidLower || cleanedWxidLower)) {
|
if (senderUsername && (myWxidLower || cleanedWxidLower)) {
|
||||||
@@ -4390,7 +4388,18 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private stripSenderPrefix(content: string): string {
|
private stripSenderPrefix(content: string): string {
|
||||||
return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)\s*/, '')
|
return content.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|<br\s*\/?>)\s*|\s*)/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSenderUsernameFromContent(content: string): string | null {
|
||||||
|
if (!content) return null
|
||||||
|
|
||||||
|
const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content)))
|
||||||
|
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(normalized)
|
||||||
|
if (!match?.[1]) return null
|
||||||
|
|
||||||
|
const candidate = match[1].trim()
|
||||||
|
return candidate || null
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeHtmlEntities(content: string): string {
|
private decodeHtmlEntities(content: string): string {
|
||||||
@@ -4852,13 +4861,6 @@ class ChatService {
|
|||||||
this.groupMyMessageCountCacheService.clearAll()
|
this.groupMyMessageCountCacheService.clearAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const state of this.hardlinkCache.values()) {
|
|
||||||
try {
|
|
||||||
state.db?.close()
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
this.hardlinkCache.clear()
|
|
||||||
|
|
||||||
if (includeEmojis) {
|
if (includeEmojis) {
|
||||||
emojiCache.clear()
|
emojiCache.clear()
|
||||||
emojiDownloading.clear()
|
emojiDownloading.clear()
|
||||||
@@ -5502,59 +5504,33 @@ class ChatService {
|
|||||||
const localId = parseInt(msgId, 10)
|
const localId = parseInt(msgId, 10)
|
||||||
if (!this.connected) await this.connect()
|
if (!this.connected) await this.connect()
|
||||||
|
|
||||||
// 1. 获取消息详情以拿到 MD5 和 AES Key
|
// 1. 获取消息详情
|
||||||
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
||||||
if (!msgResult.success || !msgResult.message) {
|
if (!msgResult.success || !msgResult.message) {
|
||||||
return { success: false, error: '未找到消息' }
|
return { success: false, error: '未找到消息' }
|
||||||
}
|
}
|
||||||
const msg = msgResult.message
|
const msg = msgResult.message
|
||||||
|
|
||||||
// 2. 确定搜索的基础名
|
// 2. 使用 imageDecryptService 解密图片
|
||||||
const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId)
|
const result = await this.imageDecryptService.decryptImage({
|
||||||
|
sessionId,
|
||||||
|
imageMd5: msg.imageMd5,
|
||||||
|
imageDatName: msg.imageDatName || String(msg.localId),
|
||||||
|
force: false
|
||||||
|
})
|
||||||
|
|
||||||
// 3. 查找 .dat 文件
|
if (!result.success || !result.localPath) {
|
||||||
const myWxid = this.configService.get('myWxid')
|
return { success: false, error: result.error || '图片解密失败' }
|
||||||
const dbPath = this.configService.get('dbPath')
|
|
||||||
if (!myWxid || !dbPath) return { success: false, error: '配置缺失' }
|
|
||||||
|
|
||||||
const accountDir = dirname(dirname(dbPath)) // dbPath 是 db_storage 里面的路径或同级
|
|
||||||
// 实际上 dbPath 指向 db_storage,accountDir 应该是其父目录
|
|
||||||
const actualAccountDir = this.resolveAccountDir(dbPath, myWxid)
|
|
||||||
if (!actualAccountDir) return { success: false, error: '无法定位账号目录' }
|
|
||||||
|
|
||||||
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
|
|
||||||
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
|
|
||||||
|
|
||||||
// 4. 获取解密密钥(优先使用当前 wxid 对应的密钥)
|
|
||||||
const imageKeys = this.configService.getImageKeysForCurrentWxid()
|
|
||||||
const xorKeyRaw = imageKeys.xorKey
|
|
||||||
const aesKeyRaw = imageKeys.aesKey || msg.aesKey
|
|
||||||
|
|
||||||
if (!xorKeyRaw) return { success: false, error: '未配置图片 XOR 密钥,请在设置中自动获取' }
|
|
||||||
|
|
||||||
const xorKey = this.parseXorKey(xorKeyRaw)
|
|
||||||
const data = readFileSync(datPath)
|
|
||||||
|
|
||||||
// 5. 解密
|
|
||||||
let decrypted: Buffer
|
|
||||||
const version = this.getDatVersion(data)
|
|
||||||
|
|
||||||
if (version === 0) {
|
|
||||||
decrypted = this.decryptDatV3(data, xorKey)
|
|
||||||
} else if (version === 1) {
|
|
||||||
const aesKey = this.asciiKey16(this.defaultV1AesKey)
|
|
||||||
decrypted = this.decryptDatV4(data, xorKey, aesKey)
|
|
||||||
} else {
|
|
||||||
const trimmed = String(aesKeyRaw ?? '').trim()
|
|
||||||
if (!trimmed || trimmed.length < 16) {
|
|
||||||
return { success: false, error: 'V4版本需要16字节AES密钥' }
|
|
||||||
}
|
|
||||||
const aesKey = this.asciiKey16(trimmed)
|
|
||||||
decrypted = this.decryptDatV4(data, xorKey, aesKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回 base64
|
// 3. 读取解密后的文件并转成 base64
|
||||||
return { success: true, data: decrypted.toString('base64') }
|
// localPath 是 file:// URL,需要转换成文件路径
|
||||||
|
const filePath = result.localPath.startsWith('file://')
|
||||||
|
? result.localPath.replace(/^file:\/\//, '')
|
||||||
|
: result.localPath
|
||||||
|
|
||||||
|
const imageData = readFileSync(filePath)
|
||||||
|
return { success: true, data: imageData.toString('base64') }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: getImageData 失败:', e)
|
console.error('ChatService: getImageData 失败:', e)
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
@@ -6632,7 +6608,9 @@ class ChatService {
|
|||||||
createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0),
|
createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0),
|
||||||
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)),
|
sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)),
|
||||||
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
||||||
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null,
|
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
||||||
|
|| this.extractSenderUsernameFromContent(rawContent)
|
||||||
|
|| null,
|
||||||
rawContent: rawContent,
|
rawContent: rawContent,
|
||||||
content: rawContent, // 添加原始内容供视频MD5解析使用
|
content: rawContent, // 添加原始内容供视频MD5解析使用
|
||||||
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
|
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
|
||||||
@@ -6707,10 +6685,6 @@ class ChatService {
|
|||||||
|
|
||||||
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
||||||
const normalized = this.normalizeDatBase(baseName)
|
const normalized = this.normalizeDatBase(baseName)
|
||||||
if (this.looksLikeMd5(normalized)) {
|
|
||||||
const hardlinkPath = this.resolveHardlinkPath(accountDir, normalized, sessionId)
|
|
||||||
if (hardlinkPath) return hardlinkPath
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchPaths = [
|
const searchPaths = [
|
||||||
join(accountDir, 'FileStorage', 'Image'),
|
join(accountDir, 'FileStorage', 'Image'),
|
||||||
@@ -6776,68 +6750,6 @@ class ChatService {
|
|||||||
return /[._][a-z]$/.test(baseLower)
|
return /[._][a-z]$/.test(baseLower)
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveHardlinkPath(accountDir: string, md5: string, sessionId?: string): string | null {
|
|
||||||
try {
|
|
||||||
const hardlinkPath = join(accountDir, 'hardlink.db')
|
|
||||||
if (!existsSync(hardlinkPath)) return null
|
|
||||||
|
|
||||||
const state = this.getHardlinkState(accountDir, hardlinkPath)
|
|
||||||
if (!state.imageTable) return null
|
|
||||||
|
|
||||||
const row = state.db
|
|
||||||
.prepare(`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE md5 = ? LIMIT 1`)
|
|
||||||
.get(md5) as { dir1?: string; dir2?: string; file_name?: string } | undefined
|
|
||||||
|
|
||||||
if (!row) return null
|
|
||||||
const dir1 = row.dir1 as string | undefined
|
|
||||||
const dir2 = row.dir2 as string | undefined
|
|
||||||
const fileName = row.file_name as string | undefined
|
|
||||||
if (!dir1 || !dir2 || !fileName) return null
|
|
||||||
const lowerFileName = fileName.toLowerCase()
|
|
||||||
if (lowerFileName.endsWith('.dat')) {
|
|
||||||
const baseLower = lowerFileName.slice(0, -4)
|
|
||||||
if (!this.hasXVariant(baseLower)) return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let dirName = dir2
|
|
||||||
if (state.dirTable && sessionId) {
|
|
||||||
try {
|
|
||||||
const dirRow = state.db
|
|
||||||
.prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`)
|
|
||||||
.get(dir2, sessionId) as { dir_name?: string } | undefined
|
|
||||||
if (dirRow?.dir_name) dirName = dirRow.dir_name as string
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = join(accountDir, dir1, dirName, fileName)
|
|
||||||
if (existsSync(fullPath)) return fullPath
|
|
||||||
|
|
||||||
const withDat = `${fullPath}.dat`
|
|
||||||
if (existsSync(withDat)) return withDat
|
|
||||||
} catch { }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private getHardlinkState(accountDir: string, hardlinkPath: string): HardlinkState {
|
|
||||||
const cached = this.hardlinkCache.get(accountDir)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const db = new Database(hardlinkPath, { readonly: true, fileMustExist: true })
|
|
||||||
const imageRow = db
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1")
|
|
||||||
.get() as { name?: string } | undefined
|
|
||||||
const dirRow = db
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1")
|
|
||||||
.get() as { name?: string } | undefined
|
|
||||||
const state: HardlinkState = {
|
|
||||||
db,
|
|
||||||
imageTable: imageRow?.name as string | undefined,
|
|
||||||
dirTable: dirRow?.name as string | undefined
|
|
||||||
}
|
|
||||||
this.hardlinkCache.set(accountDir, state)
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDatVersion(data: Buffer): number {
|
private getDatVersion(data: Buffer): number {
|
||||||
if (data.length < 6) return 0
|
if (data.length < 6) return 0
|
||||||
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
|
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ class CloudControlService {
|
|||||||
return `Windows ${release}`
|
return `Windows ${release}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
||||||
|
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
||||||
|
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
||||||
|
return `macOS ${macVersion}`
|
||||||
|
}
|
||||||
|
|
||||||
return platform
|
return platform
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,4 +95,3 @@ class CloudControlService {
|
|||||||
|
|
||||||
export const cloudControlService = new CloudControlService()
|
export const cloudControlService = new CloudControlService()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ export class DbPathService {
|
|||||||
const possiblePaths: string[] = []
|
const possiblePaths: string[] = []
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
|
||||||
// 微信4.x 数据目录
|
// macOS 微信路径(固定)
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||||
|
} else {
|
||||||
|
// Windows 微信4.x 数据目录
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
for (const path of possiblePaths) {
|
||||||
@@ -193,6 +198,9 @@ export class DbPathService {
|
|||||||
*/
|
*/
|
||||||
getDefaultPath(): string {
|
getDefaultPath(): string {
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||||
|
}
|
||||||
return join(home, 'Documents', 'xwechat_files')
|
return join(home, 'Documents', 'xwechat_files')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,15 @@ interface MediaExportItem {
|
|||||||
posterDataUrl?: string
|
posterDataUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExportDisplayProfile {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
remark: string
|
||||||
|
alias: string
|
||||||
|
groupNickname: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
||||||
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
|
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
|
||||||
|
|
||||||
@@ -860,6 +869,50 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveExportDisplayProfile(
|
||||||
|
wxid: string,
|
||||||
|
preference: ExportOptions['displayNamePreference'],
|
||||||
|
getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>,
|
||||||
|
groupNicknamesMap: Map<string, string>,
|
||||||
|
fallbackDisplayName = '',
|
||||||
|
extraGroupNicknameCandidates: Array<string | undefined | null> = []
|
||||||
|
): Promise<ExportDisplayProfile> {
|
||||||
|
const resolvedWxid = String(wxid || '').trim() || String(fallbackDisplayName || '').trim() || 'unknown'
|
||||||
|
const contactResult = resolvedWxid ? await getContact(resolvedWxid) : { success: false as const }
|
||||||
|
const contact = contactResult.success ? contactResult.contact : null
|
||||||
|
const nickname = String(contact?.nickName || contact?.nick_name || fallbackDisplayName || resolvedWxid)
|
||||||
|
const remark = String(contact?.remark || '')
|
||||||
|
const alias = String(contact?.alias || '')
|
||||||
|
const groupNickname = this.resolveGroupNicknameByCandidates(
|
||||||
|
groupNicknamesMap,
|
||||||
|
[
|
||||||
|
resolvedWxid,
|
||||||
|
contact?.username,
|
||||||
|
contact?.userName,
|
||||||
|
contact?.encryptUsername,
|
||||||
|
contact?.encryptUserName,
|
||||||
|
alias,
|
||||||
|
...extraGroupNicknameCandidates
|
||||||
|
]
|
||||||
|
) || ''
|
||||||
|
const displayName = this.getPreferredDisplayName(
|
||||||
|
resolvedWxid,
|
||||||
|
nickname,
|
||||||
|
remark,
|
||||||
|
groupNickname,
|
||||||
|
preference || 'remark'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
wxid: resolvedWxid,
|
||||||
|
nickname,
|
||||||
|
remark,
|
||||||
|
alias,
|
||||||
|
groupNickname,
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从转账消息 XML 中提取并解析 "谁转账给谁" 描述
|
* 从转账消息 XML 中提取并解析 "谁转账给谁" 描述
|
||||||
* @param content 原始消息内容 XML
|
* @param content 原始消息内容 XML
|
||||||
@@ -1800,6 +1853,26 @@ class ExportService {
|
|||||||
else if (appMsgKind === 'quote') meta.appMsgType = '57'
|
else if (appMsgKind === 'quote') meta.appMsgType = '57'
|
||||||
if (appMsgKind) meta.appMsgKind = appMsgKind
|
if (appMsgKind) meta.appMsgKind = appMsgKind
|
||||||
|
|
||||||
|
const appMsgDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc')
|
||||||
|
const appMsgAppName = this.extractXmlValue(normalized, 'appname')
|
||||||
|
const appMsgSourceName =
|
||||||
|
this.extractXmlValue(normalized, 'sourcename') ||
|
||||||
|
this.extractXmlValue(normalized, 'sourcedisplayname')
|
||||||
|
const appMsgSourceUsername = this.extractXmlValue(normalized, 'sourceusername')
|
||||||
|
const appMsgThumbUrl =
|
||||||
|
this.extractXmlValue(normalized, 'thumburl') ||
|
||||||
|
this.extractXmlValue(normalized, 'cdnthumburl') ||
|
||||||
|
this.extractXmlValue(normalized, 'cover') ||
|
||||||
|
this.extractXmlValue(normalized, 'coverurl') ||
|
||||||
|
this.extractXmlValue(normalized, 'thumbUrl') ||
|
||||||
|
this.extractXmlValue(normalized, 'coverUrl')
|
||||||
|
|
||||||
|
if (appMsgDesc) meta.appMsgDesc = appMsgDesc
|
||||||
|
if (appMsgAppName) meta.appMsgAppName = appMsgAppName
|
||||||
|
if (appMsgSourceName) meta.appMsgSourceName = appMsgSourceName
|
||||||
|
if (appMsgSourceUsername) meta.appMsgSourceUsername = appMsgSourceUsername
|
||||||
|
if (appMsgThumbUrl) meta.appMsgThumbUrl = appMsgThumbUrl
|
||||||
|
|
||||||
if (appMsgKind === 'quote') {
|
if (appMsgKind === 'quote') {
|
||||||
const quoteInfo = this.parseQuoteMessage(normalized)
|
const quoteInfo = this.parseQuoteMessage(normalized)
|
||||||
if (quoteInfo.content) meta.quotedContent = quoteInfo.content
|
if (quoteInfo.content) meta.quotedContent = quoteInfo.content
|
||||||
@@ -1807,6 +1880,18 @@ class ExportService {
|
|||||||
if (quoteInfo.type) meta.quotedType = quoteInfo.type
|
if (quoteInfo.type) meta.quotedType = quoteInfo.type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appMsgKind === 'link') {
|
||||||
|
const linkCard = this.extractHtmlLinkCard(normalized, localType)
|
||||||
|
const linkUrl = linkCard?.url || this.normalizeHtmlLinkUrl(
|
||||||
|
this.extractXmlValue(normalized, 'shareurl') ||
|
||||||
|
this.extractXmlValue(normalized, 'shorturl') ||
|
||||||
|
this.extractXmlValue(normalized, 'dataurl')
|
||||||
|
)
|
||||||
|
if (linkCard?.title) meta.linkTitle = linkCard.title
|
||||||
|
if (linkUrl) meta.linkUrl = linkUrl
|
||||||
|
if (appMsgThumbUrl) meta.linkThumb = appMsgThumbUrl
|
||||||
|
}
|
||||||
|
|
||||||
if (isMusic) {
|
if (isMusic) {
|
||||||
const musicTitle =
|
const musicTitle =
|
||||||
this.extractXmlValue(normalized, 'songname') ||
|
this.extractXmlValue(normalized, 'songname') ||
|
||||||
@@ -2125,12 +2210,22 @@ class ExportService {
|
|||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName
|
imageDatName
|
||||||
})
|
})
|
||||||
if (!thumbResult.success || !thumbResult.localPath) {
|
if (thumbResult.success && thumbResult.localPath) {
|
||||||
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||||
result.localPath = thumbResult.localPath
|
result.localPath = thumbResult.localPath
|
||||||
|
} else {
|
||||||
|
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`)
|
||||||
|
// 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL
|
||||||
|
const { imageStore } = await import('../main')
|
||||||
|
const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName)
|
||||||
|
if (cachedThumb) {
|
||||||
|
console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`)
|
||||||
|
result.localPath = cachedThumb
|
||||||
|
} else {
|
||||||
|
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖
|
// 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖
|
||||||
@@ -3240,8 +3335,19 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
|
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
|
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
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?.({
|
onProgress?.({
|
||||||
current: 0,
|
current: 0,
|
||||||
@@ -3277,6 +3383,18 @@ class ExportService {
|
|||||||
await this.ensureVoiceModel(onProgress)
|
await this.ensureVoiceModel(onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const senderUsernames = new Set<string>()
|
||||||
|
let senderScanIndex = 0
|
||||||
|
for (const msg of allMessages) {
|
||||||
|
if ((senderScanIndex++ & 0x7f) === 0) {
|
||||||
|
this.throwIfStopRequested(control)
|
||||||
|
}
|
||||||
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
|
}
|
||||||
|
senderUsernames.add(sessionId)
|
||||||
|
senderUsernames.add(cleanedMyWxid)
|
||||||
|
await this.preloadContacts(senderUsernames, contactCache)
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
@@ -3407,6 +3525,7 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const chatLabMessages: ChatLabMessage[] = []
|
const chatLabMessages: ChatLabMessage[] = []
|
||||||
|
const senderProfileMap = new Map<string, ExportDisplayProfile>()
|
||||||
let messageIndex = 0
|
let messageIndex = 0
|
||||||
for (const msg of allMessages) {
|
for (const msg of allMessages) {
|
||||||
if ((messageIndex++ & 0x7f) === 0) {
|
if ((messageIndex++ & 0x7f) === 0) {
|
||||||
@@ -3422,12 +3541,36 @@ class ExportService {
|
|||||||
const groupNickname = memberInfo.groupNickname
|
const groupNickname = memberInfo.groupNickname
|
||||||
|| (isGroup ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '')
|
|| (isGroup ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '')
|
||||||
|| ''
|
|| ''
|
||||||
|
const senderProfile = isGroup
|
||||||
|
? await this.resolveExportDisplayProfile(
|
||||||
|
msg.senderUsername || cleanedMyWxid,
|
||||||
|
options.displayNamePreference,
|
||||||
|
getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (memberInfo.accountName || msg.senderUsername || ''),
|
||||||
|
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||||
|
)
|
||||||
|
: {
|
||||||
|
wxid: msg.senderUsername || cleanedMyWxid,
|
||||||
|
nickname: memberInfo.accountName || msg.senderUsername || '',
|
||||||
|
remark: '',
|
||||||
|
alias: '',
|
||||||
|
groupNickname,
|
||||||
|
displayName: memberInfo.accountName || msg.senderUsername || ''
|
||||||
|
}
|
||||||
|
if (senderProfile.wxid && !senderProfileMap.has(senderProfile.wxid)) {
|
||||||
|
senderProfileMap.set(senderProfile.wxid, senderProfile)
|
||||||
|
}
|
||||||
|
|
||||||
// 确定消息内容
|
// 确定消息内容
|
||||||
let content: string | null
|
let content: string | null
|
||||||
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
|
const mediaItem = mediaCache.get(mediaKey)
|
||||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||||
// 使用预先转写的文字
|
// 使用预先转写的文字
|
||||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
|
} else if (mediaItem && msg.localType === 3) {
|
||||||
|
content = mediaItem.relativePath
|
||||||
} else {
|
} else {
|
||||||
content = this.parseMessageContent(
|
content = this.parseMessageContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
@@ -3458,8 +3601,8 @@ class ExportService {
|
|||||||
|
|
||||||
const message: ChatLabMessage = {
|
const message: ChatLabMessage = {
|
||||||
sender: msg.senderUsername,
|
sender: msg.senderUsername,
|
||||||
accountName: memberInfo.accountName,
|
accountName: senderProfile.displayName || memberInfo.accountName,
|
||||||
groupNickname: groupNickname || undefined,
|
groupNickname: (senderProfile.groupNickname || groupNickname) || undefined,
|
||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.convertMessageType(msg.localType, msg.content),
|
type: this.convertMessageType(msg.localType, msg.content),
|
||||||
content: content
|
content: content
|
||||||
@@ -3575,10 +3718,27 @@ class ExportService {
|
|||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
const sessionAvatar = avatarMap.get(sessionId)
|
const sessionAvatar = avatarMap.get(sessionId)
|
||||||
const members = Array.from(collected.memberSet.values()).map((info) => {
|
const members = await Promise.all(Array.from(collected.memberSet.values()).map(async (info) => {
|
||||||
|
const profile = isGroup
|
||||||
|
? (senderProfileMap.get(info.member.platformId) || await this.resolveExportDisplayProfile(
|
||||||
|
info.member.platformId,
|
||||||
|
options.displayNamePreference,
|
||||||
|
getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
info.member.accountName || info.member.platformId,
|
||||||
|
this.isSameWxid(info.member.platformId, cleanedMyWxid) ? [rawMyWxid, cleanedMyWxid] : []
|
||||||
|
))
|
||||||
|
: null
|
||||||
|
const member = profile
|
||||||
|
? {
|
||||||
|
...info.member,
|
||||||
|
accountName: profile.displayName || info.member.accountName,
|
||||||
|
groupNickname: profile.groupNickname || info.member.groupNickname
|
||||||
|
}
|
||||||
|
: info.member
|
||||||
const avatar = avatarMap.get(info.member.platformId)
|
const avatar = avatarMap.get(info.member.platformId)
|
||||||
return avatar ? { ...info.member, avatar } : info.member
|
return avatar ? { ...member, avatar } : member
|
||||||
})
|
}))
|
||||||
|
|
||||||
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar)
|
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar)
|
||||||
|
|
||||||
@@ -3651,6 +3811,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
|
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
|
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
@@ -3906,9 +4067,10 @@ class ExportService {
|
|||||||
|
|
||||||
const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType)
|
const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType)
|
||||||
if (appMsgMeta) {
|
if (appMsgMeta) {
|
||||||
if (options.format === 'arkme-json') {
|
if (
|
||||||
Object.assign(msgObj, appMsgMeta)
|
options.format === 'arkme-json' ||
|
||||||
} else if (options.format === 'json' && appMsgMeta.appMsgKind === 'quote') {
|
(options.format === 'json' && (appMsgMeta.appMsgKind === 'quote' || appMsgMeta.appMsgKind === 'link'))
|
||||||
|
) {
|
||||||
Object.assign(msgObj, appMsgMeta)
|
Object.assign(msgObj, appMsgMeta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4100,9 +4262,17 @@ class ExportService {
|
|||||||
if (message.locationLabel) compactMessage.locationLabel = message.locationLabel
|
if (message.locationLabel) compactMessage.locationLabel = message.locationLabel
|
||||||
if (message.appMsgType) compactMessage.appMsgType = message.appMsgType
|
if (message.appMsgType) compactMessage.appMsgType = message.appMsgType
|
||||||
if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind
|
if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind
|
||||||
|
if (message.appMsgDesc) compactMessage.appMsgDesc = message.appMsgDesc
|
||||||
|
if (message.appMsgAppName) compactMessage.appMsgAppName = message.appMsgAppName
|
||||||
|
if (message.appMsgSourceName) compactMessage.appMsgSourceName = message.appMsgSourceName
|
||||||
|
if (message.appMsgSourceUsername) compactMessage.appMsgSourceUsername = message.appMsgSourceUsername
|
||||||
|
if (message.appMsgThumbUrl) compactMessage.appMsgThumbUrl = message.appMsgThumbUrl
|
||||||
if (message.quotedContent) compactMessage.quotedContent = message.quotedContent
|
if (message.quotedContent) compactMessage.quotedContent = message.quotedContent
|
||||||
if (message.quotedSender) compactMessage.quotedSender = message.quotedSender
|
if (message.quotedSender) compactMessage.quotedSender = message.quotedSender
|
||||||
if (message.quotedType) compactMessage.quotedType = message.quotedType
|
if (message.quotedType) compactMessage.quotedType = message.quotedType
|
||||||
|
if (message.linkTitle) compactMessage.linkTitle = message.linkTitle
|
||||||
|
if (message.linkUrl) compactMessage.linkUrl = message.linkUrl
|
||||||
|
if (message.linkThumb) compactMessage.linkThumb = message.linkThumb
|
||||||
if (message.finderTitle) compactMessage.finderTitle = message.finderTitle
|
if (message.finderTitle) compactMessage.finderTitle = message.finderTitle
|
||||||
if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
|
if (message.finderDesc) compactMessage.finderDesc = message.finderDesc
|
||||||
if (message.finderUsername) compactMessage.finderUsername = message.finderUsername
|
if (message.finderUsername) compactMessage.finderUsername = message.finderUsername
|
||||||
@@ -4457,13 +4627,14 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 预加载群昵称 (仅群聊且完整列模式)
|
// 预加载群昵称 (仅群聊且完整列模式)
|
||||||
const groupNicknameCandidates = (isGroup && !useCompactColumns)
|
const groupNicknameCandidates = isGroup
|
||||||
? this.buildGroupNicknameIdCandidates([
|
? this.buildGroupNicknameIdCandidates([
|
||||||
...collected.rows.map(msg => msg.senderUsername),
|
...collected.rows.map(msg => msg.senderUsername),
|
||||||
cleanedMyWxid
|
cleanedMyWxid,
|
||||||
|
rawMyWxid
|
||||||
])
|
])
|
||||||
: []
|
: []
|
||||||
const groupNicknamesMap = (isGroup && !useCompactColumns)
|
const groupNicknamesMap = isGroup
|
||||||
? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
|
? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
@@ -4582,30 +4753,26 @@ class ExportService {
|
|||||||
let senderRemark: string = ''
|
let senderRemark: string = ''
|
||||||
let senderGroupNickname: string = '' // 群昵称
|
let senderGroupNickname: string = '' // 群昵称
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
if (msg.isSend) {
|
const senderProfile = await this.resolveExportDisplayProfile(
|
||||||
|
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||||
|
options.displayNamePreference,
|
||||||
|
getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
|
||||||
|
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||||
|
)
|
||||||
|
senderWxid = senderProfile.wxid
|
||||||
|
senderNickname = senderProfile.nickname
|
||||||
|
senderRemark = senderProfile.remark
|
||||||
|
senderGroupNickname = senderProfile.groupNickname
|
||||||
|
senderRole = senderProfile.displayName
|
||||||
|
} else if (msg.isSend) {
|
||||||
// 我发送的消息
|
// 我发送的消息
|
||||||
senderRole = '我'
|
senderRole = '我'
|
||||||
senderWxid = cleanedMyWxid
|
senderWxid = cleanedMyWxid
|
||||||
senderNickname = myInfo.displayName || cleanedMyWxid
|
senderNickname = myInfo.displayName || cleanedMyWxid
|
||||||
senderRemark = ''
|
senderRemark = ''
|
||||||
} else if (isGroup && msg.senderUsername) {
|
|
||||||
// 群消息
|
|
||||||
senderWxid = msg.senderUsername
|
|
||||||
|
|
||||||
// 用 getContact 获取联系人详情,分别取昵称和备注
|
|
||||||
const contactDetail = await getContactCached(msg.senderUsername)
|
|
||||||
if (contactDetail.success && contactDetail.contact) {
|
|
||||||
// nickName 才是真正的昵称
|
|
||||||
senderNickname = contactDetail.contact.nickName || msg.senderUsername
|
|
||||||
senderRemark = contactDetail.contact.remark || ''
|
|
||||||
// 身份:有备注显示备注,没有显示昵称
|
|
||||||
senderRole = senderRemark || senderNickname
|
|
||||||
} else {
|
|
||||||
senderNickname = msg.senderUsername
|
|
||||||
senderRemark = ''
|
|
||||||
senderRole = msg.senderUsername
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 单聊对方消息 - 用 getContact 获取联系人详情
|
// 单聊对方消息 - 用 getContact 获取联系人详情
|
||||||
senderWxid = sessionId
|
senderWxid = sessionId
|
||||||
@@ -4621,12 +4788,6 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取群昵称 (仅群聊且完整列模式)
|
|
||||||
if (isGroup && !useCompactColumns && senderWxid) {
|
|
||||||
senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid])
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const row = worksheet.getRow(currentRow)
|
const row = worksheet.getRow(currentRow)
|
||||||
row.height = 24
|
row.height = 24
|
||||||
|
|
||||||
@@ -4802,6 +4963,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
|
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
|
||||||
@@ -4864,7 +5026,8 @@ class ExportService {
|
|||||||
? this.buildGroupNicknameIdCandidates([
|
? this.buildGroupNicknameIdCandidates([
|
||||||
...Array.from(senderUsernames.values()),
|
...Array.from(senderUsernames.values()),
|
||||||
...collected.rows.map(msg => msg.senderUsername),
|
...collected.rows.map(msg => msg.senderUsername),
|
||||||
cleanedMyWxid
|
cleanedMyWxid,
|
||||||
|
rawMyWxid
|
||||||
])
|
])
|
||||||
: []
|
: []
|
||||||
const groupNicknamesMap = isGroup
|
const groupNicknamesMap = isGroup
|
||||||
@@ -5022,21 +5185,23 @@ class ExportService {
|
|||||||
let senderNickname: string
|
let senderNickname: string
|
||||||
let senderRemark = ''
|
let senderRemark = ''
|
||||||
|
|
||||||
if (msg.isSend) {
|
if (isGroup) {
|
||||||
|
const senderProfile = await this.resolveExportDisplayProfile(
|
||||||
|
msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||||
|
options.displayNamePreference,
|
||||||
|
getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''),
|
||||||
|
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||||
|
)
|
||||||
|
senderWxid = senderProfile.wxid
|
||||||
|
senderNickname = senderProfile.nickname
|
||||||
|
senderRemark = senderProfile.remark
|
||||||
|
senderRole = senderProfile.displayName
|
||||||
|
} else if (msg.isSend) {
|
||||||
senderRole = '我'
|
senderRole = '我'
|
||||||
senderWxid = cleanedMyWxid
|
senderWxid = cleanedMyWxid
|
||||||
senderNickname = myInfo.displayName || cleanedMyWxid
|
senderNickname = myInfo.displayName || cleanedMyWxid
|
||||||
} else if (isGroup && msg.senderUsername) {
|
|
||||||
senderWxid = msg.senderUsername
|
|
||||||
const contactDetail = await getContactCached(msg.senderUsername)
|
|
||||||
if (contactDetail.success && contactDetail.contact) {
|
|
||||||
senderNickname = contactDetail.contact.nickName || msg.senderUsername
|
|
||||||
senderRemark = contactDetail.contact.remark || ''
|
|
||||||
senderRole = senderRemark || senderNickname
|
|
||||||
} else {
|
|
||||||
senderNickname = msg.senderUsername
|
|
||||||
senderRole = msg.senderUsername
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
senderWxid = sessionId
|
senderWxid = sessionId
|
||||||
const contactDetail = await getContactCached(sessionId)
|
const contactDetail = await getContactCached(sessionId)
|
||||||
@@ -5108,6 +5273,7 @@ class ExportService {
|
|||||||
|
|
||||||
const cleanedMyWxid = conn.cleanedWxid
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
|
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
|
||||||
@@ -5159,7 +5325,8 @@ class ExportService {
|
|||||||
? this.buildGroupNicknameIdCandidates([
|
? this.buildGroupNicknameIdCandidates([
|
||||||
...Array.from(senderUsernames.values()),
|
...Array.from(senderUsernames.values()),
|
||||||
...collected.rows.map(msg => msg.senderUsername),
|
...collected.rows.map(msg => msg.senderUsername),
|
||||||
cleanedMyWxid
|
cleanedMyWxid,
|
||||||
|
rawMyWxid
|
||||||
])
|
])
|
||||||
: []
|
: []
|
||||||
const groupNicknamesMap = isGroup
|
const groupNicknamesMap = isGroup
|
||||||
@@ -5289,7 +5456,17 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let talker = myInfo.displayName || '我'
|
let talker = myInfo.displayName || '我'
|
||||||
if (!msg.isSend) {
|
if (isGroup) {
|
||||||
|
const senderProfile = await this.resolveExportDisplayProfile(
|
||||||
|
msg.isSend ? cleanedMyWxid : senderWxid,
|
||||||
|
options.displayNamePreference,
|
||||||
|
getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid,
|
||||||
|
msg.isSend ? [rawMyWxid, cleanedMyWxid] : []
|
||||||
|
)
|
||||||
|
talker = senderProfile.displayName
|
||||||
|
} else if (!msg.isSend) {
|
||||||
const contactDetail = await getContactCached(senderWxid)
|
const contactDetail = await getContactCached(senderWxid)
|
||||||
const senderNickname = contactDetail.success && contactDetail.contact
|
const senderNickname = contactDetail.success && contactDetail.contact
|
||||||
? (contactDetail.contact.nickName || senderWxid)
|
? (contactDetail.contact.nickName || senderWxid)
|
||||||
@@ -5529,7 +5706,8 @@ class ExportService {
|
|||||||
? this.buildGroupNicknameIdCandidates([
|
? this.buildGroupNicknameIdCandidates([
|
||||||
...Array.from(senderUsernames.values()),
|
...Array.from(senderUsernames.values()),
|
||||||
...collected.rows.map(msg => msg.senderUsername),
|
...collected.rows.map(msg => msg.senderUsername),
|
||||||
cleanedMyWxid
|
cleanedMyWxid,
|
||||||
|
rawMyWxid
|
||||||
])
|
])
|
||||||
: []
|
: []
|
||||||
const groupNicknamesMap = isGroup
|
const groupNicknamesMap = isGroup
|
||||||
@@ -5737,11 +5915,16 @@ class ExportService {
|
|||||||
|
|
||||||
const isSenderMe = msg.isSend
|
const isSenderMe = msg.isSend
|
||||||
const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
|
const senderInfo = collected.memberSet.get(msg.senderUsername)?.member
|
||||||
const senderName = isSenderMe
|
const senderName = isGroup
|
||||||
? (myInfo.displayName || '我')
|
? (await this.resolveExportDisplayProfile(
|
||||||
: (isGroup
|
isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid),
|
||||||
? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername)
|
options.displayNamePreference,
|
||||||
: (sessionInfo.displayName || sessionId))
|
getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''),
|
||||||
|
isSenderMe ? [rawMyWxid, cleanedMyWxid] : []
|
||||||
|
)).displayName
|
||||||
|
: (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId))
|
||||||
|
|
||||||
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName)
|
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName)
|
||||||
|
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ class HttpService {
|
|||||||
const trimmedRows = allRows.slice(0, limit)
|
const trimmedRows = allRows.slice(0, limit)
|
||||||
const finalHasMore = hasMore || allRows.length > limit
|
const finalHasMore = hasMore || allRows.length > limit
|
||||||
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||||
|
await this.backfillMissingSenderUsernames(talker, messages)
|
||||||
return { success: true, messages, hasMore: finalHasMore }
|
return { success: true, messages, hasMore: finalHasMore }
|
||||||
} finally {
|
} finally {
|
||||||
await wcdbService.closeMessageCursor(cursor)
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
@@ -359,6 +360,41 @@ class HttpService {
|
|||||||
return Math.min(Math.max(parsed, min), max)
|
return Math.min(Math.max(parsed, min), max)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise<void> {
|
||||||
|
if (!talker.endsWith('@chatroom')) return
|
||||||
|
|
||||||
|
const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim())
|
||||||
|
if (targets.length === 0) return
|
||||||
|
|
||||||
|
const myWxid = (this.configService.get('myWxid') || '').trim()
|
||||||
|
for (const msg of targets) {
|
||||||
|
const localId = Number(msg.localId || 0)
|
||||||
|
if (Number.isFinite(localId) && localId > 0) {
|
||||||
|
try {
|
||||||
|
const detail = await wcdbService.getMessageById(talker, localId)
|
||||||
|
if (detail.success && detail.message) {
|
||||||
|
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
|
||||||
|
if (hydrated?.senderUsername) {
|
||||||
|
msg.senderUsername = hydrated.senderUsername
|
||||||
|
}
|
||||||
|
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
|
||||||
|
msg.isSend = hydrated.isSend
|
||||||
|
}
|
||||||
|
if (!msg.rawContent && hydrated?.rawContent) {
|
||||||
|
msg.rawContent = hydrated.rawContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[HttpService] backfill sender failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||||
|
msg.senderUsername = myWxid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const raw = url.searchParams.get(key)
|
const raw = url.searchParams.get(key)
|
||||||
@@ -778,6 +814,49 @@ class HttpService {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
||||||
|
if (!sender) return ''
|
||||||
|
return groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveChatLabSenderInfo(
|
||||||
|
msg: Message,
|
||||||
|
talkerId: string,
|
||||||
|
talkerName: string,
|
||||||
|
myWxid: string,
|
||||||
|
isGroup: boolean,
|
||||||
|
senderNames: Record<string, string>,
|
||||||
|
groupNicknamesMap: Map<string, string>
|
||||||
|
): { sender: string; accountName: string; groupNickname?: string } {
|
||||||
|
let sender = String(msg.senderUsername || '').trim()
|
||||||
|
let usedUnknownPlaceholder = false
|
||||||
|
const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase()
|
||||||
|
const isSelf = msg.isSend === 1 || sameAsMe
|
||||||
|
|
||||||
|
if (!sender && isSelf && myWxid) {
|
||||||
|
sender = myWxid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sender) {
|
||||||
|
if (msg.localType === 10000 || msg.localType === 266287972401) {
|
||||||
|
sender = talkerId
|
||||||
|
} else {
|
||||||
|
sender = `unknown_sender_${msg.localId || msg.createTime || 0}`
|
||||||
|
usedUnknownPlaceholder = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : ''
|
||||||
|
const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender)
|
||||||
|
const accountName = isSelf ? '我' : (displayName || '未知发送者')
|
||||||
|
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
accountName,
|
||||||
|
groupNickname: groupNickname || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换为 ChatLab 格式
|
* 转换为 ChatLab 格式
|
||||||
*/
|
*/
|
||||||
@@ -817,36 +896,24 @@ class HttpService {
|
|||||||
// 构建成员列表
|
// 构建成员列表
|
||||||
const memberMap = new Map<string, ChatLabMember>()
|
const memberMap = new Map<string, ChatLabMember>()
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const sender = msg.senderUsername || ''
|
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
||||||
if (sender && !memberMap.has(sender)) {
|
if (!memberMap.has(senderInfo.sender)) {
|
||||||
const displayName = senderNames[sender] || sender
|
memberMap.set(senderInfo.sender, {
|
||||||
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
|
platformId: senderInfo.sender,
|
||||||
// 获取群昵称(尝试多种方式)
|
accountName: senderInfo.accountName,
|
||||||
const groupNickname = isGroup
|
groupNickname: senderInfo.groupNickname
|
||||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
|
||||||
: ''
|
|
||||||
memberMap.set(sender, {
|
|
||||||
platformId: sender,
|
|
||||||
accountName: isSelf ? '我' : displayName,
|
|
||||||
groupNickname: groupNickname || undefined
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换消息
|
// 转换消息
|
||||||
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
||||||
const sender = msg.senderUsername || ''
|
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
||||||
const isSelf = msg.isSend === 1 || sender === myWxid
|
|
||||||
const accountName = isSelf ? '我' : (senderNames[sender] || sender)
|
|
||||||
// 获取该发送者的群昵称
|
|
||||||
const groupNickname = isGroup
|
|
||||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
|
||||||
: ''
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sender,
|
sender: senderInfo.sender,
|
||||||
accountName,
|
accountName: senderInfo.accountName,
|
||||||
groupNickname: groupNickname || undefined,
|
groupNickname: senderInfo.groupNickname,
|
||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg),
|
||||||
|
|||||||
@@ -414,23 +414,33 @@ export class ImageDecryptService {
|
|||||||
if (!skipResolvedCache) {
|
if (!skipResolvedCache) {
|
||||||
if (imageMd5) {
|
if (imageMd5) {
|
||||||
const cached = this.resolvedCache.get(imageMd5)
|
const cached = this.resolvedCache.get(imageMd5)
|
||||||
if (cached && existsSync(cached)) return cached
|
if (cached && existsSync(cached)) {
|
||||||
|
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||||
|
this.cacheDatPath(accountDir, imageMd5, preferred)
|
||||||
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred)
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (imageDatName) {
|
if (imageDatName) {
|
||||||
const cached = this.resolvedCache.get(imageDatName)
|
const cached = this.resolvedCache.get(imageDatName)
|
||||||
if (cached && existsSync(cached)) return cached
|
if (cached && existsSync(cached)) {
|
||||||
|
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||||
|
this.cacheDatPath(accountDir, imageDatName, preferred)
|
||||||
|
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred)
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
||||||
if (imageMd5) {
|
if (imageMd5) {
|
||||||
const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail)
|
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||||
if (res) return res
|
if (res) return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||||
const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail)
|
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||||
if (res) return res
|
if (res) return res
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,16 +449,17 @@ export class ImageDecryptService {
|
|||||||
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
||||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
|
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
|
||||||
if (hardlinkPath) {
|
if (hardlinkPath) {
|
||||||
const isThumb = this.isThumbnailPath(hardlinkPath)
|
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
|
||||||
|
const isThumb = this.isThumbnailPath(preferredPath)
|
||||||
if (allowThumbnail || !isThumb) {
|
if (allowThumbnail || !isThumb) {
|
||||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath })
|
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath })
|
||||||
this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
|
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||||
return hardlinkPath
|
return preferredPath
|
||||||
}
|
}
|
||||||
// hardlink 找到的是缩略图,但要求高清图
|
// hardlink 找到的是缩略图,但要求高清图
|
||||||
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
||||||
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||||
if (hdPath) {
|
if (hdPath) {
|
||||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
@@ -462,16 +473,19 @@ export class ImageDecryptService {
|
|||||||
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
|
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
|
||||||
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||||
if (fallbackPath) {
|
if (fallbackPath) {
|
||||||
const isThumb = this.isThumbnailPath(fallbackPath)
|
const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail)
|
||||||
|
const isThumb = this.isThumbnailPath(preferredPath)
|
||||||
if (allowThumbnail || !isThumb) {
|
if (allowThumbnail || !isThumb) {
|
||||||
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath })
|
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
|
||||||
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
|
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||||
return fallbackPath
|
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||||
|
return preferredPath
|
||||||
}
|
}
|
||||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||||
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||||
if (hdPath) {
|
if (hdPath) {
|
||||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
|
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||||
return hdPath
|
return hdPath
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -484,14 +498,15 @@ export class ImageDecryptService {
|
|||||||
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
|
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
|
||||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||||
if (hardlinkPath) {
|
if (hardlinkPath) {
|
||||||
const isThumb = this.isThumbnailPath(hardlinkPath)
|
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
|
||||||
|
const isThumb = this.isThumbnailPath(preferredPath)
|
||||||
if (allowThumbnail || !isThumb) {
|
if (allowThumbnail || !isThumb) {
|
||||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath })
|
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath })
|
||||||
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||||
return hardlinkPath
|
return preferredPath
|
||||||
}
|
}
|
||||||
// hardlink 找到的是缩略图,但要求高清图
|
// hardlink 找到的是缩略图,但要求高清图
|
||||||
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||||
if (hdPath) {
|
if (hdPath) {
|
||||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
return hdPath
|
return hdPath
|
||||||
@@ -510,9 +525,10 @@ export class ImageDecryptService {
|
|||||||
if (!skipResolvedCache) {
|
if (!skipResolvedCache) {
|
||||||
const cached = this.resolvedCache.get(imageDatName)
|
const cached = this.resolvedCache.get(imageDatName)
|
||||||
if (cached && existsSync(cached)) {
|
if (cached && existsSync(cached)) {
|
||||||
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||||
|
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||||
// 缓存的是缩略图,尝试找高清图
|
// 缓存的是缩略图,尝试找高清图
|
||||||
const hdPath = this.findHdVariantInSameDir(cached)
|
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||||
if (hdPath) return hdPath
|
if (hdPath) return hdPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -801,7 +817,8 @@ export class ImageDecryptService {
|
|||||||
const key = `${accountDir}|${datName}`
|
const key = `${accountDir}|${datName}`
|
||||||
const cached = this.resolvedCache.get(key)
|
const cached = this.resolvedCache.get(key)
|
||||||
if (cached && existsSync(cached)) {
|
if (cached && existsSync(cached)) {
|
||||||
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||||
|
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = join(accountDir, 'msg', 'attach')
|
const root = join(accountDir, 'msg', 'attach')
|
||||||
@@ -810,7 +827,7 @@ export class ImageDecryptService {
|
|||||||
// 优化1:快速概率性查找
|
// 优化1:快速概率性查找
|
||||||
// 包含:1. 基于文件名的前缀猜测 (旧版)
|
// 包含:1. 基于文件名的前缀猜测 (旧版)
|
||||||
// 2. 基于日期的最近月份扫描 (新版无索引时)
|
// 2. 基于日期的最近月份扫描 (新版无索引时)
|
||||||
const fastHit = await this.fastProbabilisticSearch(root, datName)
|
const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail)
|
||||||
if (fastHit) {
|
if (fastHit) {
|
||||||
this.resolvedCache.set(key, fastHit)
|
this.resolvedCache.set(key, fastHit)
|
||||||
return fastHit
|
return fastHit
|
||||||
@@ -830,34 +847,29 @@ export class ImageDecryptService {
|
|||||||
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||||
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||||
*/
|
*/
|
||||||
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
|
private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise<string | null> {
|
||||||
const { promises: fs } = require('fs')
|
const { promises: fs } = require('fs')
|
||||||
const { join } = require('path')
|
const { join } = require('path')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
|
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
|
||||||
const lowerName = datName.toLowerCase()
|
const lowerName = datName.toLowerCase()
|
||||||
let baseName = lowerName
|
const baseName = this.normalizeDatBase(lowerName)
|
||||||
if (baseName.endsWith('.dat')) {
|
const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail)
|
||||||
baseName = baseName.slice(0, -4)
|
|
||||||
if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) {
|
|
||||||
baseName = baseName.slice(0, -3)
|
|
||||||
} else if (baseName.endsWith('_thumb')) {
|
|
||||||
baseName = baseName.slice(0, -6)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
if (/^[a-f0-9]{32}$/.test(baseName)) {
|
if (/^[a-f0-9]{32}$/.test(baseName)) {
|
||||||
const dir1 = baseName.substring(0, 2)
|
const dir1 = baseName.substring(0, 2)
|
||||||
const dir2 = baseName.substring(2, 4)
|
const dir2 = baseName.substring(2, 4)
|
||||||
|
for (const targetName of targetNames) {
|
||||||
candidates.push(
|
candidates.push(
|
||||||
join(root, dir1, dir2, datName),
|
join(root, dir1, dir2, targetName),
|
||||||
join(root, dir1, dir2, 'Img', datName),
|
join(root, dir1, dir2, 'Img', targetName),
|
||||||
join(root, dir1, dir2, 'mg', datName),
|
join(root, dir1, dir2, 'mg', targetName),
|
||||||
join(root, dir1, dir2, 'Image', datName)
|
join(root, dir1, dir2, 'Image', targetName)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
try {
|
try {
|
||||||
@@ -883,13 +895,6 @@ export class ImageDecryptService {
|
|||||||
months.push(mStr)
|
months.push(mStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetNames = [datName]
|
|
||||||
if (baseName !== lowerName) {
|
|
||||||
targetNames.push(`${baseName}.dat`)
|
|
||||||
targetNames.push(`${baseName}_t.dat`)
|
|
||||||
targetNames.push(`${baseName}_thumb.dat`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchSize = 20
|
const batchSize = 20
|
||||||
for (let i = 0; i < sessionDirs.length; i += batchSize) {
|
for (let i = 0; i < sessionDirs.length; i += batchSize) {
|
||||||
const batch = sessionDirs.slice(i, i + batchSize)
|
const batch = sessionDirs.slice(i, i + batchSize)
|
||||||
@@ -919,36 +924,13 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 在同一目录下查找高清图变体
|
* 在同一目录下查找高清图变体
|
||||||
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
|
* 优先 `_h`,再回退其他非缩略图变体
|
||||||
*/
|
*/
|
||||||
private findHdVariantInSameDir(thumbPath: string): string | null {
|
private findHdVariantInSameDir(thumbPath: string): string | null {
|
||||||
try {
|
try {
|
||||||
const dir = dirname(thumbPath)
|
const dir = dirname(thumbPath)
|
||||||
const fileName = basename(thumbPath).toLowerCase()
|
const fileName = basename(thumbPath)
|
||||||
|
return this.findPreferredDatVariantInDir(dir, fileName, false)
|
||||||
// 提取基础名称(去掉 _t.dat 或 .t.dat)
|
|
||||||
let baseName = fileName
|
|
||||||
if (baseName.endsWith('_t.dat')) {
|
|
||||||
baseName = baseName.slice(0, -6)
|
|
||||||
} else if (baseName.endsWith('.t.dat')) {
|
|
||||||
baseName = baseName.slice(0, -6)
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试查找高清图变体
|
|
||||||
const variants = [
|
|
||||||
`${baseName}_h.dat`,
|
|
||||||
`${baseName}.h.dat`,
|
|
||||||
`${baseName}.dat`
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const variant of variants) {
|
|
||||||
const variantPath = join(dir, variant)
|
|
||||||
if (existsSync(variantPath)) {
|
|
||||||
return variantPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { }
|
} catch { }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1001,23 +983,105 @@ export class ImageDecryptService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stripDatVariantSuffix(base: string): string {
|
||||||
|
const lower = base.toLowerCase()
|
||||||
|
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||||
|
for (const suffix of suffixes) {
|
||||||
|
if (lower.endsWith(suffix)) {
|
||||||
|
return lower.slice(0, -suffix.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/[._][a-z]$/.test(lower)) {
|
||||||
|
return lower.slice(0, -2)
|
||||||
|
}
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDatVariantPriority(name: string): number {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||||
|
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||||
|
if (!this.hasXVariant(baseLower)) return 500
|
||||||
|
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||||
|
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||||
|
if (this.isThumbnailDat(lower)) return 100
|
||||||
|
return 350
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] {
|
||||||
|
if (!baseName) return []
|
||||||
|
const names = [
|
||||||
|
`${baseName}_h.dat`,
|
||||||
|
`${baseName}.h.dat`,
|
||||||
|
`${baseName}.dat`,
|
||||||
|
`${baseName}_hd.dat`,
|
||||||
|
`${baseName}.hd.dat`,
|
||||||
|
`${baseName}_c.dat`,
|
||||||
|
`${baseName}.c.dat`
|
||||||
|
]
|
||||||
|
if (allowThumbnail) {
|
||||||
|
names.push(
|
||||||
|
`${baseName}_thumb.dat`,
|
||||||
|
`${baseName}.thumb.dat`,
|
||||||
|
`${baseName}_t.dat`,
|
||||||
|
`${baseName}.t.dat`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Array.from(new Set(names))
|
||||||
|
}
|
||||||
|
|
||||||
|
private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null {
|
||||||
|
let entries: string[]
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dirPath)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const target = this.normalizeDatBase(baseName.toLowerCase())
|
||||||
|
let bestPath: string | null = null
|
||||||
|
let bestScore = Number.NEGATIVE_INFINITY
|
||||||
|
for (const entry of entries) {
|
||||||
|
const lower = entry.toLowerCase()
|
||||||
|
if (!lower.endsWith('.dat')) continue
|
||||||
|
if (!allowThumbnail && this.isThumbnailDat(lower)) continue
|
||||||
|
const baseLower = lower.slice(0, -4)
|
||||||
|
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||||
|
const score = this.getDatVariantPriority(lower)
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestPath = join(dirPath, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string {
|
||||||
|
const lower = datPath.toLowerCase()
|
||||||
|
if (!lower.endsWith('.dat')) return datPath
|
||||||
|
const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail)
|
||||||
|
return preferred || datPath
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeDatBase(name: string): string {
|
private normalizeDatBase(name: string): string {
|
||||||
let base = name.toLowerCase()
|
let base = name.toLowerCase()
|
||||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||||
base = base.slice(0, -4)
|
base = base.slice(0, -4)
|
||||||
}
|
}
|
||||||
while (/[._][a-z]$/.test(base)) {
|
for (;;) {
|
||||||
base = base.slice(0, -2)
|
const stripped = this.stripDatVariantSuffix(base)
|
||||||
}
|
if (stripped === base) {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
base = stripped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||||
return /[._][a-z]$/.test(baseLower)
|
return this.stripDatVariantSuffix(baseLower) !== baseLower
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1206,24 +1270,7 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
|
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
|
||||||
let entries: string[]
|
return this.findPreferredDatVariantInDir(dirPath, baseName, false)
|
||||||
try {
|
|
||||||
entries = readdirSync(dirPath)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const target = this.normalizeDatBase(baseName.toLowerCase())
|
|
||||||
for (const entry of entries) {
|
|
||||||
const lower = entry.toLowerCase()
|
|
||||||
if (!lower.endsWith('.dat')) continue
|
|
||||||
if (this.isThumbnailDat(lower)) continue
|
|
||||||
const baseLower = lower.slice(0, -4)
|
|
||||||
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
|
|
||||||
if (!this.hasXVariant(baseLower)) continue
|
|
||||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
|
||||||
return join(dirPath, entry)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isNonThumbnailVariantDat(datPath: string): boolean {
|
private isNonThumbnailVariantDat(datPath: string): boolean {
|
||||||
@@ -1231,8 +1278,7 @@ export class ImageDecryptService {
|
|||||||
if (!lower.endsWith('.dat')) return false
|
if (!lower.endsWith('.dat')) return false
|
||||||
if (this.isThumbnailDat(lower)) return false
|
if (this.isThumbnailDat(lower)) return false
|
||||||
const baseLower = lower.slice(0, -4)
|
const baseLower = lower.slice(0, -4)
|
||||||
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
|
return this.isLikelyImageDatBase(baseLower)
|
||||||
return this.hasXVariant(baseLower)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
|
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
|
||||||
@@ -1858,7 +1904,7 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
private hasXVariant(base: string): boolean {
|
private hasXVariant(base: string): boolean {
|
||||||
const lower = base.toLowerCase()
|
const lower = base.toLowerCase()
|
||||||
return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t')
|
return this.stripDatVariantSuffix(lower) !== lower
|
||||||
}
|
}
|
||||||
|
|
||||||
private isHdPath(p: string): boolean {
|
private isHdPath(p: string): boolean {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: stri
|
|||||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||||
|
|
||||||
export class KeyService {
|
export class KeyService {
|
||||||
|
private readonly isMac = process.platform === 'darwin'
|
||||||
private koffi: any = null
|
private koffi: any = null
|
||||||
private lib: any = null
|
private lib: any = null
|
||||||
private initialized = false
|
private initialized = false
|
||||||
@@ -714,6 +715,68 @@ export class KeyService {
|
|||||||
return wxid.substring(0, second)
|
return wxid.substring(0, second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
const xorKey = code & 0xFF
|
||||||
|
const dataToHash = code.toString() + cleanedWxid
|
||||||
|
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
|
||||||
|
const aesKey = md5Full.substring(0, 16)
|
||||||
|
return { xorKey, aesKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||||
|
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||||
|
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||||
|
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||||
|
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
|
||||||
|
const candidates: string[] = []
|
||||||
|
const pushUnique = (value: string) => {
|
||||||
|
const v = String(value || '').trim()
|
||||||
|
if (!v || candidates.includes(v)) return
|
||||||
|
candidates.push(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
||||||
|
|
||||||
|
if (manualDir) {
|
||||||
|
const normalized = manualDir.replace(/[\\/]+$/, '')
|
||||||
|
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
||||||
|
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||||
|
|
||||||
|
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
|
||||||
|
if (marker) {
|
||||||
|
const root = normalized.slice(0, marker.index! + marker[0].length)
|
||||||
|
try {
|
||||||
|
const { readdirSync, statSync } = await import('fs')
|
||||||
|
const { join } = await import('path')
|
||||||
|
for (const entry of readdirSync(root)) {
|
||||||
|
if (!entry.startsWith('wxid_')) continue
|
||||||
|
const full = join(root, entry)
|
||||||
|
try {
|
||||||
|
if (statSync(full).isDirectory()) pushUnique(entry)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUnique('unknown')
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
async autoGetImageKey(
|
async autoGetImageKey(
|
||||||
manualDir?: string,
|
manualDir?: string,
|
||||||
onProgress?: (message: string) => void,
|
onProgress?: (message: string) => void,
|
||||||
@@ -749,52 +812,34 @@ export class KeyService {
|
|||||||
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
|
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
|
||||||
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
|
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
|
||||||
|
|
||||||
// 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid(可能是unknown)
|
const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam)
|
||||||
let targetWxid = ''
|
let verifyCiphertext: Buffer | null = null
|
||||||
|
if (manualDir && existsSync(manualDir)) {
|
||||||
// 方案1: 直接使用传入的wxidParam(最优先)
|
const template = await this._findTemplateData(manualDir, 32)
|
||||||
if (wxidParam && wxidParam.startsWith('wxid_')) {
|
verifyCiphertext = template.ciphertext
|
||||||
targetWxid = wxidParam
|
|
||||||
console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方案2: 从 manualDir 提取前端已配置好的正确 wxid
|
if (verifyCiphertext) {
|
||||||
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
|
onProgress?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
||||||
if (!targetWxid && manualDir) {
|
for (const candidateWxid of wxidCandidates) {
|
||||||
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
|
for (const code of codes) {
|
||||||
if (dirName.startsWith('wxid_')) {
|
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||||
targetWxid = dirName
|
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||||
console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid)
|
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||||
|
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
|
||||||
|
return { success: true, xorKey, aesKey }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||||
// 方案3: 回退到 DLL 发现的第一个(可能是 unknown)
|
|
||||||
if (!targetWxid) {
|
|
||||||
targetWxid = accounts[0].wxid
|
|
||||||
console.log('[ImageKey] 无法获取 wxid,使用 DLL 发现的:', targetWxid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanWxid: 截断到第二个下划线,与 xkey 算法一致
|
// 无模板密文可验真时回退旧策略
|
||||||
const cleanedWxid = this.cleanWxid(targetWxid)
|
const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown'
|
||||||
console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid)
|
const fallbackCode = codes[0]
|
||||||
|
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||||
// 用 cleanedWxid + code 本地计算密钥
|
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||||
// xorKey = code & 0xFF
|
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
|
||||||
// aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16)
|
return { success: true, xorKey, aesKey }
|
||||||
const code = codes[0]
|
|
||||||
const xorKey = code & 0xFF
|
|
||||||
const dataToHash = code.toString() + cleanedWxid
|
|
||||||
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
|
|
||||||
const aesKey = md5Full.substring(0, 16)
|
|
||||||
|
|
||||||
onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`)
|
|
||||||
console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
xorKey,
|
|
||||||
aesKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||||
@@ -810,10 +855,20 @@ export class KeyService {
|
|||||||
try {
|
try {
|
||||||
// 1. 查找模板文件获取密文和 XOR 密钥
|
// 1. 查找模板文件获取密文和 XOR 密钥
|
||||||
onProgress?.('正在查找模板文件...')
|
onProgress?.('正在查找模板文件...')
|
||||||
const { ciphertext, xorKey } = await this._findTemplateData(userDir)
|
let result = await this._findTemplateData(userDir, 32)
|
||||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
let { ciphertext, xorKey } = result
|
||||||
|
|
||||||
onProgress?.(`XOR 密钥: 0x${(xorKey ?? 0).toString(16).padStart(2, '0')},正在查找微信进程...`)
|
// 如果找不到密钥,尝试扫描更多文件
|
||||||
|
if (ciphertext && xorKey === null) {
|
||||||
|
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||||
|
result = await this._findTemplateData(userDir, 100)
|
||||||
|
xorKey = result.xorKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||||
|
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
||||||
|
|
||||||
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
// 2. 找微信 PID
|
// 2. 找微信 PID
|
||||||
const pid = await this.findWeChatPid()
|
const pid = await this.findWeChatPid()
|
||||||
@@ -830,7 +885,7 @@ export class KeyService {
|
|||||||
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||||
if (aesKey) {
|
if (aesKey) {
|
||||||
onProgress?.('密钥获取成功')
|
onProgress?.('密钥获取成功')
|
||||||
return { success: true, xorKey: xorKey ?? 0, aesKey }
|
return { success: true, xorKey, aesKey }
|
||||||
}
|
}
|
||||||
// 等 5 秒再试
|
// 等 5 秒再试
|
||||||
await new Promise(r => setTimeout(r, 5000))
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
@@ -845,26 +900,26 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _findTemplateData(userDir: string): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||||
const { readdirSync, readFileSync, statSync } = await import('fs')
|
const { readdirSync, readFileSync, statSync } = await import('fs')
|
||||||
const { join } = await import('path')
|
const { join } = await import('path')
|
||||||
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||||
|
|
||||||
// 递归收集 *_t.dat 文件
|
// 递归收集 *_t.dat 文件
|
||||||
const collect = (dir: string, results: string[], limit = 32) => {
|
const collect = (dir: string, results: string[], maxFiles: number) => {
|
||||||
if (results.length >= limit) return
|
if (results.length >= maxFiles) return
|
||||||
try {
|
try {
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
if (results.length >= limit) break
|
if (results.length >= maxFiles) break
|
||||||
const full = join(dir, entry.name)
|
const full = join(dir, entry.name)
|
||||||
if (entry.isDirectory()) collect(full, results, limit)
|
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||||
}
|
}
|
||||||
} catch { /* 忽略无权限目录 */ }
|
} catch { /* 忽略无权限目录 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const files: string[] = []
|
const files: string[] = []
|
||||||
collect(userDir, files)
|
collect(userDir, files, limit)
|
||||||
|
|
||||||
// 按修改时间降序
|
// 按修改时间降序
|
||||||
files.sort((a, b) => {
|
files.sort((a, b) => {
|
||||||
|
|||||||
1046
electron/services/keyServiceMac.ts
Normal file
1046
electron/services/keyServiceMac.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
|||||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import Database from 'better-sqlite3'
|
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
@@ -71,58 +70,21 @@ class VideoService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
* 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
|
||||||
*/
|
*/
|
||||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||||
const cachePath = this.getCachePath()
|
|
||||||
const dbPath = this.getDbPath()
|
const dbPath = this.getDbPath()
|
||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
|
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
|
||||||
|
|
||||||
if (!wxid) {
|
if (!wxid) {
|
||||||
this.log('queryVideoFileName: wxid 为空')
|
this.log('queryVideoFileName: wxid 为空')
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||||
if (cachePath) {
|
|
||||||
const cacheDbPaths = [
|
|
||||||
join(cachePath, cleanedWxid, 'hardlink.db'),
|
|
||||||
join(cachePath, wxid, 'hardlink.db'),
|
|
||||||
join(cachePath, 'hardlink.db'),
|
|
||||||
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
|
|
||||||
join(cachePath, 'databases', wxid, 'hardlink.db')
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const p of cacheDbPaths) {
|
|
||||||
if (existsSync(p)) {
|
|
||||||
try {
|
|
||||||
this.log('尝试缓存 hardlink.db', { path: p })
|
|
||||||
const db = new Database(p, { readonly: true })
|
|
||||||
const row = db.prepare(`
|
|
||||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
|
||||||
WHERE md5 = ?
|
|
||||||
LIMIT 1
|
|
||||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if (row?.file_name) {
|
|
||||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
|
||||||
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
|
||||||
return realMd5
|
|
||||||
}
|
|
||||||
this.log('缓存 hardlink.db 未命中', { path: p })
|
|
||||||
} catch (e) {
|
|
||||||
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
|
||||||
if (dbPath) {
|
if (dbPath) {
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
const wxidLower = wxid.toLowerCase()
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ export class VoiceTranscribeService {
|
|||||||
private recognizer: OfflineRecognizer | null = null
|
private recognizer: OfflineRecognizer | null = null
|
||||||
private isInitializing = false
|
private isInitializing = false
|
||||||
|
|
||||||
|
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env }
|
||||||
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||||
|
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||||
|
const candidates = [
|
||||||
|
join(__dirname, '..', 'node_modules', platformPkg),
|
||||||
|
join(__dirname, 'node_modules', platformPkg),
|
||||||
|
join(process.cwd(), 'node_modules', platformPkg),
|
||||||
|
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||||
|
].filter((item): item is string => Boolean(item) && existsSync(item))
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const key = 'DYLD_LIBRARY_PATH'
|
||||||
|
const existing = env[key] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||||
|
env[key] = Array.from(new Set(merged)).join(':')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'linux') {
|
||||||
|
const key = 'LD_LIBRARY_PATH'
|
||||||
|
const existing = env[key] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||||
|
env[key] = Array.from(new Set(merged)).join(':')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
private resolveModelDir(): string {
|
private resolveModelDir(): string {
|
||||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||||
if (configured) return configured
|
if (configured) return configured
|
||||||
@@ -206,17 +238,20 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Worker } = require('worker_threads')
|
const { fork } = require('child_process')
|
||||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||||
|
|
||||||
const worker = new Worker(workerPath, {
|
const worker = fork(workerPath, [], {
|
||||||
workerData: {
|
env: this.buildTranscribeWorkerEnv(),
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||||
|
serialization: 'advanced'
|
||||||
|
})
|
||||||
|
worker.send({
|
||||||
modelPath,
|
modelPath,
|
||||||
tokensPath,
|
tokensPath,
|
||||||
wavData,
|
wavData,
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
languages: supportedLanguages
|
languages: supportedLanguages
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let finalTranscript = ''
|
let finalTranscript = ''
|
||||||
@@ -227,11 +262,13 @@ export class VoiceTranscribeService {
|
|||||||
} else if (msg.type === 'final') {
|
} else if (msg.type === 'final') {
|
||||||
finalTranscript = msg.text
|
finalTranscript = msg.text
|
||||||
resolve({ success: true, transcript: finalTranscript })
|
resolve({ success: true, transcript: finalTranscript })
|
||||||
worker.terminate()
|
worker.disconnect()
|
||||||
|
worker.kill()
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||||
resolve({ success: false, error: msg.error })
|
resolve({ success: false, error: msg.error })
|
||||||
worker.terminate()
|
worker.disconnect()
|
||||||
|
worker.kill()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { join, dirname, basename } from 'path'
|
import { join, dirname, basename } from 'path'
|
||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
|
||||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
@@ -60,6 +61,7 @@ export class WcdbCore {
|
|||||||
private currentPath: string | null = null
|
private currentPath: string | null = null
|
||||||
private currentKey: string | null = null
|
private currentKey: string | null = null
|
||||||
private currentWxid: string | null = null
|
private currentWxid: string | null = null
|
||||||
|
private currentDbStoragePath: string | null = null
|
||||||
|
|
||||||
// 函数引用
|
// 函数引用
|
||||||
private wcdbInitProtection: any = null
|
private wcdbInitProtection: any = null
|
||||||
@@ -128,14 +130,17 @@ export class WcdbCore {
|
|||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
private lastLogTail: string | null = null
|
private lastLogTail: string | null = null
|
||||||
|
private lastResolvedLogPath: string | null = null
|
||||||
|
|
||||||
setPaths(resourcesPath: string, userDataPath: string): void {
|
setPaths(resourcesPath: string, userDataPath: string): void {
|
||||||
this.resourcesPath = resourcesPath
|
this.resourcesPath = resourcesPath
|
||||||
this.userDataPath = userDataPath
|
this.userDataPath = userDataPath
|
||||||
|
this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogEnabled(enabled: boolean): void {
|
setLogEnabled(enabled: boolean): void {
|
||||||
this.logEnabled = enabled
|
this.logEnabled = enabled
|
||||||
|
this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true)
|
||||||
if (this.isLogEnabled() && this.initialized) {
|
if (this.isLogEnabled() && this.initialized) {
|
||||||
this.startLogPolling()
|
this.startLogPolling()
|
||||||
} else {
|
} else {
|
||||||
@@ -143,7 +148,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用命名管道 IPC
|
// 使用命名管道/socket IPC (Windows: Named Pipe, macOS: Unix Socket)
|
||||||
startMonitor(callback: (type: string, json: string) => void): boolean {
|
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
if (!this.wcdbStartMonitorPipe) {
|
if (!this.wcdbStartMonitorPipe) {
|
||||||
return false
|
return false
|
||||||
@@ -168,7 +173,6 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connectMonitorPipe(pipePath)
|
this.connectMonitorPipe(pipePath)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -185,13 +189,18 @@ export class WcdbCore {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.monitorCallback) return
|
if (!this.monitorCallback) return
|
||||||
|
|
||||||
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
|
||||||
})
|
|
||||||
|
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
this.monitorPipeClient.on('data', (data: Buffer) => {
|
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||||
buffer += data.toString('utf8')
|
const rawChunk = data.toString('utf8')
|
||||||
const lines = buffer.split('\n')
|
// macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包
|
||||||
|
const normalizedChunk = rawChunk
|
||||||
|
.replace(/\u0000/g, '\n')
|
||||||
|
.replace(/}\s*{/g, '}\n{')
|
||||||
|
|
||||||
|
buffer += normalizedChunk
|
||||||
|
const lines = buffer.split(/\r?\n/)
|
||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || ''
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
@@ -203,9 +212,22 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兜底:如果没有分隔符但已形成完整 JSON,则直接上报
|
||||||
|
const tail = buffer.trim()
|
||||||
|
if (tail.startsWith('{') && tail.endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(tail)
|
||||||
|
this.monitorCallback?.(parsed.action || 'update', tail)
|
||||||
|
buffer = ''
|
||||||
|
} catch {
|
||||||
|
// 不可解析则继续等待下一块数据
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.monitorPipeClient.on('error', () => {
|
this.monitorPipeClient.on('error', () => {
|
||||||
|
// 保持静默,与现有错误处理策略一致
|
||||||
})
|
})
|
||||||
|
|
||||||
this.monitorPipeClient.on('close', () => {
|
this.monitorPipeClient.on('close', () => {
|
||||||
@@ -251,9 +273,13 @@ export class WcdbCore {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 路径
|
* 获取库文件路径(跨平台)
|
||||||
*/
|
*/
|
||||||
private getDllPath(): string {
|
private getDllPath(): string {
|
||||||
|
const isMac = process.platform === 'darwin'
|
||||||
|
const libName = isMac ? 'libwcdb_api.dylib' : 'wcdb_api.dll'
|
||||||
|
const subDir = isMac ? 'macos' : ''
|
||||||
|
|
||||||
const envDllPath = process.env.WCDB_DLL_PATH
|
const envDllPath = process.env.WCDB_DLL_PATH
|
||||||
if (envDllPath && envDllPath.length > 0) {
|
if (envDllPath && envDllPath.length > 0) {
|
||||||
return envDllPath
|
return envDllPath
|
||||||
@@ -265,22 +291,22 @@ export class WcdbCore {
|
|||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
// 环境变量指定 resource 目录
|
// 环境变量指定 resource 目录
|
||||||
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, 'wcdb_api.dll') : null,
|
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null,
|
||||||
// 显式 setPaths 设置的路径
|
// 显式 setPaths 设置的路径
|
||||||
this.resourcesPath ? join(this.resourcesPath, 'wcdb_api.dll') : null,
|
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null,
|
||||||
// text/resources/wcdb_api.dll (打包常见结构)
|
// resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
|
||||||
join(resourcesPath, 'resources', 'wcdb_api.dll'),
|
join(resourcesPath, 'resources', subDir, libName),
|
||||||
// items/resourcesPath/wcdb_api.dll (扁平结构)
|
// resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构)
|
||||||
join(resourcesPath, 'wcdb_api.dll'),
|
join(resourcesPath, subDir, libName),
|
||||||
// CWD fallback
|
// CWD fallback
|
||||||
join(process.cwd(), 'resources', 'wcdb_api.dll')
|
join(process.cwd(), 'resources', subDir, libName)
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
for (const path of candidates) {
|
for (const path of candidates) {
|
||||||
if (existsSync(path)) return path
|
if (existsSync(path)) return path
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates[0] || 'wcdb_api.dll'
|
return candidates[0] || libName
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLogEnabled(): boolean {
|
private isLogEnabled(): boolean {
|
||||||
@@ -292,14 +318,97 @@ export class WcdbCore {
|
|||||||
private writeLog(message: string, force = false): void {
|
private writeLog(message: string, force = false): void {
|
||||||
if (!force && !this.isLogEnabled()) return
|
if (!force && !this.isLogEnabled()) return
|
||||||
const line = `[${new Date().toISOString()}] ${message}`
|
const line = `[${new Date().toISOString()}] ${message}`
|
||||||
// 同时输出到控制台和文件
|
|
||||||
|
|
||||||
|
const candidates: string[] = []
|
||||||
|
if (this.userDataPath) candidates.push(join(this.userDataPath, 'logs', 'wcdb.log'))
|
||||||
|
if (process.env.WCDB_LOG_DIR) candidates.push(join(process.env.WCDB_LOG_DIR, 'logs', 'wcdb.log'))
|
||||||
|
candidates.push(join(process.cwd(), 'logs', 'wcdb.log'))
|
||||||
|
candidates.push(join(tmpdir(), 'weflow-wcdb.log'))
|
||||||
|
|
||||||
|
const uniq = Array.from(new Set(candidates))
|
||||||
|
for (const filePath of uniq) {
|
||||||
try {
|
try {
|
||||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
const dir = dirname(filePath)
|
||||||
const dir = join(base, 'logs')
|
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
|
appendFileSync(filePath, line + '\n', { encoding: 'utf8' })
|
||||||
} catch { }
|
this.lastResolvedLogPath = filePath
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[wcdbCore] writeLog failed path=${filePath}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[wcdbCore] writeLog failed for all candidates:', uniq.join(' | '))
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatSqlForLog(sql: string, maxLen = 240): string {
|
||||||
|
const compact = String(sql || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (compact.length <= maxLen) return compact
|
||||||
|
return compact.slice(0, maxLen) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dumpDbStatus(tag: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status skipped: not connected`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.wcdbGetDbStatus) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status skipped: api not supported`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const rc = this.wcdbGetDbStatus(this.handle, outPtr)
|
||||||
|
if (rc !== 0 || !outPtr[0]) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status failed rc=${rc} outPtr=${outPtr[0] ? 'set' : 'null'}`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status decode failed`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.writeLog(`[diag:${tag}] db_status=${jsonStr}`, true)
|
||||||
|
} catch (e) {
|
||||||
|
this.writeLog(`[diag:${tag}] db_status exception: ${String(e)}`, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runPostOpenDiagnostics(dbPath: string, dbStoragePath: string | null, sessionDbPath: string | null, wxid: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.writeLog(`[diag:open] input dbPath=${dbPath} wxid=${wxid}`, true)
|
||||||
|
this.writeLog(`[diag:open] resolved dbStorage=${dbStoragePath || 'null'}`, true)
|
||||||
|
this.writeLog(`[diag:open] resolved sessionDb=${sessionDbPath || 'null'}`, true)
|
||||||
|
if (!dbStoragePath) return
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dbStoragePath)
|
||||||
|
const sample = entries.slice(0, 20).join(',')
|
||||||
|
this.writeLog(`[diag:open] dbStorage entries(${entries.length}) sample=${sample}`, true)
|
||||||
|
} catch (e) {
|
||||||
|
this.writeLog(`[diag:open] list dbStorage failed: ${String(e)}`, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactProbe = await this.execQuery(
|
||||||
|
'contact',
|
||||||
|
null,
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name LIMIT 50"
|
||||||
|
)
|
||||||
|
if (contactProbe.success) {
|
||||||
|
const names = (contactProbe.rows || []).map((r: any) => String(r?.name || '')).filter(Boolean)
|
||||||
|
this.writeLog(`[diag:open] contact sqlite_master rows=${names.length} names=${names.join(',')}`, true)
|
||||||
|
} else {
|
||||||
|
this.writeLog(`[diag:open] contact sqlite_master failed: ${contactProbe.error || 'unknown'}`, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactCount = await this.execQuery('contact', null, 'SELECT COUNT(1) AS cnt FROM contact')
|
||||||
|
if (contactCount.success && Array.isArray(contactCount.rows) && contactCount.rows.length > 0) {
|
||||||
|
this.writeLog(`[diag:open] contact count=${String((contactCount.rows[0] as any)?.cnt ?? '')}`, true)
|
||||||
|
} else {
|
||||||
|
this.writeLog(`[diag:open] contact count failed: ${contactCount.error || 'unknown'}`, true)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.writeLog(`[diag:open] post-open diagnostics exception: ${String(e)}`, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -376,6 +485,51 @@ export class WcdbCore {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isRealDbFileName(name: string): boolean {
|
||||||
|
const lower = String(name || '').toLowerCase()
|
||||||
|
if (!lower.endsWith('.db')) return false
|
||||||
|
if (lower.endsWith('.db-shm')) return false
|
||||||
|
if (lower.endsWith('.db-wal')) return false
|
||||||
|
if (lower.endsWith('.db-journal')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContactDbPath(): string | null {
|
||||||
|
const dbStorage = this.currentDbStoragePath || this.resolveDbStoragePath(this.currentPath || '', this.currentWxid || '')
|
||||||
|
if (!dbStorage) return null
|
||||||
|
const contactDir = join(dbStorage, 'Contact')
|
||||||
|
if (!existsSync(contactDir)) return null
|
||||||
|
|
||||||
|
const preferred = [
|
||||||
|
join(contactDir, 'contact.db'),
|
||||||
|
join(contactDir, 'Contact.db')
|
||||||
|
]
|
||||||
|
for (const p of preferred) {
|
||||||
|
if (existsSync(p)) return p
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(contactDir)
|
||||||
|
const cands = entries
|
||||||
|
.filter((name) => this.isRealDbFileName(name))
|
||||||
|
.map((name) => join(contactDir, name))
|
||||||
|
if (cands.length > 0) return cands[0]
|
||||||
|
} catch { }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickFirstStringField(row: Record<string, any>, candidates: string[]): string {
|
||||||
|
for (const key of candidates) {
|
||||||
|
const v = row[key]
|
||||||
|
if (typeof v === 'string' && v.trim()) return v
|
||||||
|
if (v !== null && v !== undefined) {
|
||||||
|
const s = String(v).trim()
|
||||||
|
if (s) return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 WCDB
|
* 初始化 WCDB
|
||||||
*/
|
*/
|
||||||
@@ -385,13 +539,30 @@ export class WcdbCore {
|
|||||||
try {
|
try {
|
||||||
this.koffi = require('koffi')
|
this.koffi = require('koffi')
|
||||||
const dllPath = this.getDllPath()
|
const dllPath = this.getDllPath()
|
||||||
|
this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true)
|
||||||
|
|
||||||
if (!existsSync(dllPath)) {
|
if (!existsSync(dllPath)) {
|
||||||
console.error('WCDB DLL 不存在:', dllPath)
|
console.error('WCDB DLL 不存在:', dllPath)
|
||||||
|
this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const dllDir = dirname(dllPath)
|
const dllDir = dirname(dllPath)
|
||||||
|
const isMac = process.platform === 'darwin'
|
||||||
|
|
||||||
|
// 预加载依赖库
|
||||||
|
if (isMac) {
|
||||||
|
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
|
||||||
|
if (existsSync(wcdbCorePath)) {
|
||||||
|
try {
|
||||||
|
this.koffi.load(wcdbCorePath)
|
||||||
|
this.writeLog('预加载 libWCDB.dylib 成功')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('预加载 libWCDB.dylib 失败(可能不是致命的):', e)
|
||||||
|
this.writeLog(`预加载 libWCDB.dylib 失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
||||||
if (existsSync(wcdbCorePath)) {
|
if (existsSync(wcdbCorePath)) {
|
||||||
try {
|
try {
|
||||||
@@ -412,6 +583,7 @@ export class WcdbCore {
|
|||||||
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
|
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.lib = this.koffi.load(dllPath)
|
this.lib = this.koffi.load(dllPath)
|
||||||
|
|
||||||
@@ -423,6 +595,8 @@ export class WcdbCore {
|
|||||||
const resourcePaths = [
|
const resourcePaths = [
|
||||||
dllDir, // DLL 所在目录
|
dllDir, // DLL 所在目录
|
||||||
dirname(dllDir), // 上级目录
|
dirname(dllDir), // 上级目录
|
||||||
|
process.resourcesPath, // 打包后 Contents/Resources
|
||||||
|
process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources
|
||||||
this.resourcesPath, // 配置的资源路径
|
this.resourcesPath, // 配置的资源路径
|
||||||
join(process.cwd(), 'resources') // 开发环境
|
join(process.cwd(), 'resources') // 开发环境
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
@@ -731,6 +905,7 @@ export class WcdbCore {
|
|||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
if (initResult !== 0) {
|
if (initResult !== 0) {
|
||||||
console.error('WCDB 初始化失败:', initResult)
|
console.error('WCDB 初始化失败:', initResult)
|
||||||
|
lastDllInitError = `初始化失败(错误码: ${initResult})`
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,7 +1156,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
||||||
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
|
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true)
|
||||||
|
|
||||||
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
||||||
console.error('数据库目录不存在:', dbPath)
|
console.error('数据库目录不存在:', dbPath)
|
||||||
@@ -990,7 +1165,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionDbPath = this.findSessionDb(dbStoragePath)
|
const sessionDbPath = this.findSessionDb(dbStoragePath)
|
||||||
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`)
|
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`, true)
|
||||||
if (!sessionDbPath) {
|
if (!sessionDbPath) {
|
||||||
console.error('未找到 session.db 文件')
|
console.error('未找到 session.db 文件')
|
||||||
this.writeLog('open failed: session.db not found')
|
this.writeLog('open failed: session.db not found')
|
||||||
@@ -1016,6 +1191,7 @@ export class WcdbCore {
|
|||||||
this.currentPath = dbPath
|
this.currentPath = dbPath
|
||||||
this.currentKey = hexKey
|
this.currentKey = hexKey
|
||||||
this.currentWxid = wxid
|
this.currentWxid = wxid
|
||||||
|
this.currentDbStoragePath = dbStoragePath
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
if (this.wcdbSetMyWxid && wxid) {
|
if (this.wcdbSetMyWxid && wxid) {
|
||||||
try {
|
try {
|
||||||
@@ -1027,7 +1203,9 @@ export class WcdbCore {
|
|||||||
if (this.isLogEnabled()) {
|
if (this.isLogEnabled()) {
|
||||||
this.startLogPolling()
|
this.startLogPolling()
|
||||||
}
|
}
|
||||||
this.writeLog(`open ok handle=${handle}`)
|
this.writeLog(`open ok handle=${handle}`, true)
|
||||||
|
await this.dumpDbStatus('open')
|
||||||
|
await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('打开数据库异常:', e)
|
console.error('打开数据库异常:', e)
|
||||||
@@ -1052,6 +1230,7 @@ export class WcdbCore {
|
|||||||
this.currentPath = null
|
this.currentPath = null
|
||||||
this.currentKey = null
|
this.currentKey = null
|
||||||
this.currentWxid = null
|
this.currentWxid = null
|
||||||
|
this.currentDbStoragePath = null
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
this.stopLogPolling()
|
this.stopLogPolling()
|
||||||
}
|
}
|
||||||
@@ -1207,6 +1386,31 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
if (usernames.length === 0) return { success: true, map: {} }
|
if (usernames.length === 0) return { success: true, map: {} }
|
||||||
try {
|
try {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const uniq = Array.from(new Set(usernames.map((x) => String(x || '').trim()).filter(Boolean)))
|
||||||
|
if (uniq.length === 0) return { success: true, map: {} }
|
||||||
|
const inList = uniq.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
|
||||||
|
const q = await this.execQuery('contact', null, sql)
|
||||||
|
if (!q.success) return { success: false, error: q.error || '获取昵称失败' }
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const row of (q.rows || []) as Array<Record<string, any>>) {
|
||||||
|
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
|
||||||
|
if (!username) continue
|
||||||
|
const display = this.pickFirstStringField(row, [
|
||||||
|
'remark', 'Remark',
|
||||||
|
'nick_name', 'nickName', 'nickname', 'NickName',
|
||||||
|
'alias', 'Alias'
|
||||||
|
]) || username
|
||||||
|
map[username] = display
|
||||||
|
}
|
||||||
|
// 保证每个请求用户名至少有回退值
|
||||||
|
for (const u of uniq) {
|
||||||
|
if (!map[u]) map[u] = u
|
||||||
|
}
|
||||||
|
return { success: true, map }
|
||||||
|
}
|
||||||
|
|
||||||
// 让出控制权,避免阻塞事件循环
|
// 让出控制权,避免阻塞事件循环
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
@@ -1255,6 +1459,34 @@ export class WcdbCore {
|
|||||||
return { success: true, map: resultMap }
|
return { success: true, map: resultMap }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
|
||||||
|
const q = await this.execQuery('contact', null, sql)
|
||||||
|
if (!q.success) {
|
||||||
|
if (Object.keys(resultMap).length > 0) {
|
||||||
|
return { success: true, map: resultMap, error: q.error || '获取头像失败' }
|
||||||
|
}
|
||||||
|
return { success: false, error: q.error || '获取头像失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of (q.rows || []) as Array<Record<string, any>>) {
|
||||||
|
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
|
||||||
|
if (!username) continue
|
||||||
|
const url = this.pickFirstStringField(row, [
|
||||||
|
'big_head_img_url', 'bigHeadImgUrl', 'bigHeadUrl', 'big_head_url',
|
||||||
|
'small_head_img_url', 'smallHeadImgUrl', 'smallHeadUrl', 'small_head_url',
|
||||||
|
'head_img_url', 'headImgUrl',
|
||||||
|
'avatar_url', 'avatarUrl'
|
||||||
|
])
|
||||||
|
if (url) {
|
||||||
|
resultMap[username] = url
|
||||||
|
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, map: resultMap }
|
||||||
|
}
|
||||||
|
|
||||||
// 让出控制权,避免阻塞事件循环
|
// 让出控制权,避免阻塞事件循环
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
@@ -1463,10 +1695,42 @@ export class WcdbCore {
|
|||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const safe = String(username || '').replace(/'/g, "''")
|
||||||
|
const sql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1`
|
||||||
|
const q = await this.execQuery('contact', null, sql)
|
||||||
|
if (!q.success) {
|
||||||
|
return { success: false, error: q.error || '获取联系人失败' }
|
||||||
|
}
|
||||||
|
const row = Array.isArray(q.rows) && q.rows.length > 0 ? q.rows[0] : null
|
||||||
|
if (!row) {
|
||||||
|
return { success: false, error: `联系人不存在: ${username}` }
|
||||||
|
}
|
||||||
|
return { success: true, contact: row }
|
||||||
|
}
|
||||||
|
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbGetContact(this.handle, username, outPtr)
|
const result = this.wcdbGetContact(this.handle, username, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
return { success: false, error: `获取联系人失败: ${result}` }
|
this.writeLog(`[diag:getContact] primary api failed username=${username} code=${result} outPtr=${outPtr[0] ? 'set' : 'null'}`, true)
|
||||||
|
await this.dumpDbStatus('getContact-primary-fail')
|
||||||
|
await this.printLogs(true)
|
||||||
|
|
||||||
|
// Fallback: 直接查询 contact 表,便于区分是接口失败还是 contact 库本身不可读。
|
||||||
|
const safe = String(username || '').replace(/'/g, "''")
|
||||||
|
const fallbackSql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1`
|
||||||
|
const fallback = await this.execQuery('contact', null, fallbackSql)
|
||||||
|
if (fallback.success) {
|
||||||
|
const row = Array.isArray(fallback.rows) ? fallback.rows[0] : null
|
||||||
|
if (row) {
|
||||||
|
this.writeLog(`[diag:getContact] fallback sql hit username=${username}`, true)
|
||||||
|
return { success: true, contact: row }
|
||||||
|
}
|
||||||
|
this.writeLog(`[diag:getContact] fallback sql no row username=${username}`, true)
|
||||||
|
return { success: false, error: `联系人不存在: ${username}` }
|
||||||
|
}
|
||||||
|
this.writeLog(`[diag:getContact] fallback sql failed username=${username} err=${fallback.error || 'unknown'}`, true)
|
||||||
|
return { success: false, error: `获取联系人失败: ${result}; fallback=${fallback.error || 'unknown'}` }
|
||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析联系人失败' }
|
if (!jsonStr) return { success: false, error: '解析联系人失败' }
|
||||||
@@ -1803,16 +2067,43 @@ export class WcdbCore {
|
|||||||
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedKind = String(kind || '').toLowerCase()
|
||||||
|
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||||
|
let effectivePath = path || ''
|
||||||
|
if (normalizedKind === 'contact' && !effectivePath) {
|
||||||
|
const resolvedContactDb = this.resolveContactDbPath()
|
||||||
|
if (resolvedContactDb) {
|
||||||
|
effectivePath = resolvedContactDb
|
||||||
|
this.writeLog(`[diag:execQuery] contact path override -> ${effectivePath}`, true)
|
||||||
|
} else {
|
||||||
|
this.writeLog('[diag:execQuery] contact path override miss: Contact/contact.db not found', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
const result = this.wcdbExecQuery(this.handle, kind, effectivePath, sql, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
if (isContactQuery) {
|
||||||
|
this.writeLog(`[diag:execQuery] contact query failed code=${result} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
|
||||||
|
await this.dumpDbStatus('execQuery-contact-fail')
|
||||||
|
await this.printLogs(true)
|
||||||
|
}
|
||||||
return { success: false, error: `执行查询失败: ${result}` }
|
return { success: false, error: `执行查询失败: ${result}` }
|
||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
|
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
|
||||||
const rows = JSON.parse(jsonStr)
|
const rows = JSON.parse(jsonStr)
|
||||||
|
if (isContactQuery) {
|
||||||
|
const count = Array.isArray(rows) ? rows.length : -1
|
||||||
|
this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
|
||||||
|
}
|
||||||
return { success: true, rows }
|
return { success: true, rows }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||||
|
if (isContactQuery) {
|
||||||
|
this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true)
|
||||||
|
await this.dumpDbStatus('execQuery-contact-exception')
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setMonitor(callback: (type: string, json: string) => void): void {
|
setMonitor(callback: (type: string, json: string) => void): void {
|
||||||
this.monitorListener = callback;
|
this.monitorListener = callback;
|
||||||
this.callWorker('setMonitor').catch(() => { });
|
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
import { parentPort, workerData } from 'worker_threads'
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
interface WorkerParams {
|
interface WorkerParams {
|
||||||
modelPath: string
|
modelPath: string
|
||||||
tokensPath: string
|
tokensPath: string
|
||||||
wavData: Buffer
|
wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
languages?: string[]
|
languages?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendLibrarySearchPath(libDir: string): void {
|
||||||
|
if (!existsSync(libDir)) return
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const current = process.env.DYLD_LIBRARY_PATH || ''
|
||||||
|
const paths = current.split(':').filter(Boolean)
|
||||||
|
if (!paths.includes(libDir)) {
|
||||||
|
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const current = process.env.LD_LIBRARY_PATH || ''
|
||||||
|
const paths = current.split(':').filter(Boolean)
|
||||||
|
if (!paths.includes(libDir)) {
|
||||||
|
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareSherpaRuntimeEnv(): void {
|
||||||
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||||
|
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||||
|
const resourcesPath = (process as any).resourcesPath as string | undefined
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
// Dev: /project/dist-electron -> /project/node_modules/...
|
||||||
|
join(__dirname, '..', 'node_modules', platformPkg),
|
||||||
|
// Fallback for alternate layouts
|
||||||
|
join(__dirname, 'node_modules', platformPkg),
|
||||||
|
join(process.cwd(), 'node_modules', platformPkg),
|
||||||
|
// Packaged app: Resources/app.asar.unpacked/node_modules/...
|
||||||
|
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const dir of candidates) {
|
||||||
|
appendLibrarySearchPath(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 语言标记映射
|
// 语言标记映射
|
||||||
const LANGUAGE_TAGS: Record<string, string> = {
|
const LANGUAGE_TAGS: Record<string, string> = {
|
||||||
'zh': '<|zh|>',
|
'zh': '<|zh|>',
|
||||||
@@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!parentPort) {
|
const isForkProcess = !parentPort
|
||||||
return;
|
const emit = (msg: any) => {
|
||||||
|
if (parentPort) {
|
||||||
|
parentPort.postMessage(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof process.send === 'function') {
|
||||||
|
process.send(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
|
||||||
|
if (Buffer.isBuffer(data)) return data
|
||||||
|
if (data instanceof Uint8Array) return Buffer.from(data)
|
||||||
|
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
|
||||||
|
return Buffer.from((data as any).data)
|
||||||
|
}
|
||||||
|
return Buffer.alloc(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readParams = async (): Promise<WorkerParams | null> => {
|
||||||
|
if (parentPort) {
|
||||||
|
return workerData as WorkerParams
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (value: WorkerParams | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
process.once('message', (msg) => finish(msg as WorkerParams))
|
||||||
|
process.once('disconnect', () => finish(null))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
prepareSherpaRuntimeEnv()
|
||||||
|
const params = await readParams()
|
||||||
|
if (!params) return
|
||||||
|
|
||||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||||
let sherpa: any;
|
let sherpa: any;
|
||||||
try {
|
try {
|
||||||
sherpa = require('sherpa-onnx-node');
|
sherpa = require('sherpa-onnx-node');
|
||||||
} catch (requireError) {
|
} catch (requireError) {
|
||||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||||
|
if (isForkProcess) process.exit(1)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
|
||||||
const wavData = Buffer.from(rawWavData);
|
const wavData = normalizeBuffer(rawWavData);
|
||||||
// 确保有有效的语言列表,默认只允许中文
|
// 确保有有效的语言列表,默认只允许中文
|
||||||
let allowedLanguages = languages || ['zh']
|
let allowedLanguages = languages || ['zh']
|
||||||
if (allowedLanguages.length === 0) {
|
if (allowedLanguages.length === 0) {
|
||||||
@@ -151,16 +232,18 @@ async function run() {
|
|||||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||||
const processedText = richTranscribePostProcess(result.text)
|
const processedText = richTranscribePostProcess(result.text)
|
||||||
|
|
||||||
parentPort.postMessage({ type: 'final', text: processedText })
|
emit({ type: 'final', text: processedText })
|
||||||
|
if (isForkProcess) process.exit(0)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
parentPort.postMessage({ type: 'final', text: '' })
|
emit({ type: 'final', text: '' })
|
||||||
|
if (isForkProcess) process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
emit({ type: 'error', error: String(error) })
|
||||||
|
if (isForkProcess) process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,17 @@ if (parentPort) {
|
|||||||
result = { success: true }
|
result = { success: true }
|
||||||
break
|
break
|
||||||
case 'setMonitor':
|
case 'setMonitor':
|
||||||
core.setMonitor((type, json) => {
|
{
|
||||||
|
const monitorOk = core.setMonitor((type, json) => {
|
||||||
parentPort!.postMessage({
|
parentPort!.postMessage({
|
||||||
id: -1,
|
id: -1,
|
||||||
type: 'monitor',
|
type: 'monitor',
|
||||||
payload: { type, json }
|
payload: { type, json }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
result = { success: true }
|
result = { success: monitorOk }
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'testConnection':
|
case 'testConnection':
|
||||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
break
|
break
|
||||||
|
|||||||
227
package-lock.json
generated
227
package-lock.json
generated
@@ -9,7 +9,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
@@ -35,7 +34,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^4.0.2",
|
"@electron/rebuild": "^4.0.2",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -2784,16 +2782,6 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/better-sqlite3": {
|
|
||||||
"version": "7.6.13",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
|
||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/cacheable-request": {
|
"node_modules/@types/cacheable-request": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||||
@@ -3868,20 +3856,6 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/better-sqlite3": {
|
|
||||||
"version": "12.5.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.5.0.tgz",
|
|
||||||
"integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bindings": "^1.5.0",
|
|
||||||
"prebuild-install": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/big-integer": {
|
"node_modules/big-integer": {
|
||||||
"version": "1.6.52",
|
"version": "1.6.52",
|
||||||
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
|
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
|
||||||
@@ -3904,15 +3878,6 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bindings": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"file-uri-to-path": "1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
|
||||||
@@ -4924,6 +4889,7 @@
|
|||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mimic-response": "^3.1.0"
|
"mimic-response": "^3.1.0"
|
||||||
@@ -4939,6 +4905,7 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -4947,15 +4914,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/deep-extend": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/defaults": {
|
"node_modules/defaults": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz",
|
||||||
@@ -5047,6 +5005,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5817,15 +5776,6 @@
|
|||||||
"node": ">=8.3.0"
|
"node": ">=8.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expand-template": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
|
||||||
"license": "(MIT OR WTFPL)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -5964,12 +5914,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/filelist": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz",
|
||||||
@@ -6272,12 +6216,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/github-from-package": {
|
|
||||||
"version": "0.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz",
|
|
||||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "7.2.3",
|
"version": "7.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
|
||||||
@@ -6744,12 +6682,6 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ini": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/inline-style-parser": {
|
"node_modules/inline-style-parser": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
"resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||||
@@ -8503,12 +8435,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mkdirp-classic": {
|
|
||||||
"version": "0.5.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
|
||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -8534,12 +8460,6 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/napi-build-utils": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
@@ -9003,44 +8923,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prebuild-install": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.0",
|
|
||||||
"expand-template": "^2.0.3",
|
|
||||||
"github-from-package": "0.0.0",
|
|
||||||
"minimist": "^1.2.3",
|
|
||||||
"mkdirp-classic": "^0.5.3",
|
|
||||||
"napi-build-utils": "^2.0.0",
|
|
||||||
"node-abi": "^3.3.0",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"rc": "^1.2.7",
|
|
||||||
"simple-get": "^4.0.0",
|
|
||||||
"tar-fs": "^2.0.0",
|
|
||||||
"tunnel-agent": "^0.6.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"prebuild-install": "bin.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prebuild-install/node_modules/node-abi": {
|
|
||||||
"version": "3.85.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.85.0.tgz",
|
|
||||||
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"semver": "^7.3.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/proc-log": {
|
"node_modules/proc-log": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-5.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-5.0.0.tgz",
|
||||||
@@ -9101,6 +8983,7 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz",
|
||||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"end-of-stream": "^1.1.0",
|
"end-of-stream": "^1.1.0",
|
||||||
@@ -9130,21 +9013,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
|
||||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
|
||||||
"dependencies": {
|
|
||||||
"deep-extend": "^0.6.0",
|
|
||||||
"ini": "~1.3.0",
|
|
||||||
"minimist": "^1.2.0",
|
|
||||||
"strip-json-comments": "~2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"rc": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
|
||||||
@@ -9823,6 +9691,9 @@
|
|||||||
"sherpa-onnx-win-x64": "^1.12.23"
|
"sherpa-onnx-win-x64": "^1.12.23"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/sherpa-onnx-win-ia32": {
|
"node_modules/sherpa-onnx-win-ia32": {
|
||||||
"version": "1.12.23",
|
"version": "1.12.23",
|
||||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
||||||
@@ -9865,51 +9736,6 @@
|
|||||||
"node": ">=16.11.0"
|
"node": ">=16.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-concat": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/simple-get": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"decompress-response": "^6.0.0",
|
|
||||||
"once": "^1.3.1",
|
|
||||||
"simple-concat": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||||
@@ -10139,15 +9965,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-json-comments": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stubborn-fs": {
|
"node_modules/stubborn-fs": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
|
||||||
@@ -10225,24 +10042,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar-fs": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chownr": "^1.1.1",
|
|
||||||
"mkdirp-classic": "^0.5.2",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"tar-stream": "^2.1.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar-fs/node_modules/chownr": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/tar-stream": {
|
"node_modules/tar-stream": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
@@ -10519,18 +10318,6 @@
|
|||||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tunnel-agent": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "4.41.0",
|
"version": "4.41.0",
|
||||||
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz",
|
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -20,7 +20,6 @@
|
|||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
@@ -46,7 +45,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^4.0.2",
|
"@electron/rebuild": "^4.0.2",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -72,6 +70,18 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"category": "public.app-category.utilities",
|
||||||
|
"hardenedRuntime": false,
|
||||||
|
"gatekeeperAssess": false,
|
||||||
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||||
|
"icon": "resources/icon.icns"
|
||||||
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
@@ -120,6 +130,8 @@
|
|||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/silk-wasm/**/*",
|
"node_modules/silk-wasm/**/*",
|
||||||
"node_modules/sherpa-onnx-node/**/*",
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
|
"node_modules/sherpa-onnx-*/*",
|
||||||
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
@@ -139,6 +151,7 @@
|
|||||||
"from": "resources/vcruntime140_1.dll",
|
"from": "resources/vcruntime140_1.dll",
|
||||||
"to": "."
|
"to": "."
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"icon": "resources/icon.icns"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
resources/icon.icns
Normal file
BIN
resources/icon.icns
Normal file
Binary file not shown.
10
resources/image_scan_entitlements.plist
Normal file
10
resources/image_scan_entitlements.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.debugger</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
resources/image_scan_helper
Executable file
BIN
resources/image_scan_helper
Executable file
Binary file not shown.
77
resources/image_scan_helper.c
Normal file
77
resources/image_scan_helper.c
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* image_scan_helper - 轻量包装程序
|
||||||
|
* 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey
|
||||||
|
* 用法: image_scan_helper <pid> <ciphertext_hex>
|
||||||
|
* 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."}
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <libgen.h>
|
||||||
|
#include <mach-o/dyld.h>
|
||||||
|
|
||||||
|
typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext);
|
||||||
|
typedef void (*FreeStringFn)(const char* str);
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
if (argc != 3) {
|
||||||
|
fprintf(stderr, "Usage: %s <pid> <ciphertext_hex>\n", argv[0]);
|
||||||
|
printf("{\"success\":false,\"error\":\"invalid arguments\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pid = atoi(argv[1]);
|
||||||
|
const char* ciphertext_hex = argv[2];
|
||||||
|
|
||||||
|
if (pid <= 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"invalid pid\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定位 dylib: 与自身同目录下的 libwx_key.dylib */
|
||||||
|
char exe_path[4096];
|
||||||
|
uint32_t size = sizeof(exe_path);
|
||||||
|
if (_NSGetExecutablePath(exe_path, &size) != 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
char* dir = dirname(exe_path);
|
||||||
|
char dylib_path[4096];
|
||||||
|
snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir);
|
||||||
|
|
||||||
|
void* handle = dlopen(dylib_path, RTLD_LAZY);
|
||||||
|
if (!handle) {
|
||||||
|
printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey");
|
||||||
|
if (!scan_fn) {
|
||||||
|
printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n");
|
||||||
|
dlclose(handle);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString");
|
||||||
|
|
||||||
|
fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex);
|
||||||
|
|
||||||
|
const char* result = scan_fn(pid, ciphertext_hex);
|
||||||
|
|
||||||
|
if (result && strlen(result) > 0) {
|
||||||
|
/* 检查是否是错误 */
|
||||||
|
if (strncmp(result, "ERROR", 5) == 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"%s\"}\n", result);
|
||||||
|
} else {
|
||||||
|
printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result);
|
||||||
|
}
|
||||||
|
if (free_fn) free_fn(result);
|
||||||
|
} else {
|
||||||
|
printf("{\"success\":false,\"error\":\"no key found\"}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
dlclose(handle);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
BIN
resources/libwx_key.dylib
Executable file
BIN
resources/libwx_key.dylib
Executable file
Binary file not shown.
BIN
resources/macos/libWCDB.dylib
Executable file
BIN
resources/macos/libWCDB.dylib
Executable file
Binary file not shown.
BIN
resources/macos/libwcdb_api.dylib
Executable file
BIN
resources/macos/libwcdb_api.dylib
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/xkey_helper
Executable file
BIN
resources/xkey_helper
Executable file
Binary file not shown.
78
src/App.tsx
78
src/App.tsx
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
|
||||||
import TitleBar from './components/TitleBar'
|
import TitleBar from './components/TitleBar'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
import RouteGuard from './components/RouteGuard'
|
import RouteGuard from './components/RouteGuard'
|
||||||
@@ -8,6 +8,7 @@ import HomePage from './pages/HomePage'
|
|||||||
import ChatPage from './pages/ChatPage'
|
import ChatPage from './pages/ChatPage'
|
||||||
import AnalyticsPage from './pages/AnalyticsPage'
|
import AnalyticsPage from './pages/AnalyticsPage'
|
||||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||||
|
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
|
||||||
import AnnualReportPage from './pages/AnnualReportPage'
|
import AnnualReportPage from './pages/AnnualReportPage'
|
||||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||||
import DualReportPage from './pages/DualReportPage'
|
import DualReportPage from './pages/DualReportPage'
|
||||||
@@ -37,9 +38,22 @@ import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
|||||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||||
|
|
||||||
|
function RouteStateRedirect({ to }: { to: string }) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return <Navigate to={to} replace state={location.state} />
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const settingsBackgroundRef = useRef<Location>({
|
||||||
|
pathname: '/home',
|
||||||
|
search: '',
|
||||||
|
hash: '',
|
||||||
|
state: null,
|
||||||
|
key: 'settings-fallback'
|
||||||
|
} as Location)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setDbConnected,
|
setDbConnected,
|
||||||
@@ -63,8 +77,14 @@ function App() {
|
|||||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||||
const isNotificationWindow = location.pathname === '/notification-window'
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
const isExportRoute = location.pathname === '/export'
|
const isSettingsRoute = location.pathname === '/settings'
|
||||||
|
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||||
|
const routeLocation = isSettingsRoute
|
||||||
|
? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||||
|
: location
|
||||||
|
const isExportRoute = routeLocation.pathname === '/export'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
|
||||||
// 锁定状态
|
// 锁定状态
|
||||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||||
@@ -81,6 +101,12 @@ function App() {
|
|||||||
// 数据收集同意状态
|
// 数据收集同意状态
|
||||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname !== '/settings') {
|
||||||
|
settingsBackgroundRef.current = location
|
||||||
|
}
|
||||||
|
}, [location])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const body = document.body
|
const body = document.body
|
||||||
@@ -112,10 +138,6 @@ function App() {
|
|||||||
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||||
document.documentElement.setAttribute('data-mode', effectiveMode)
|
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||||
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
|
||||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
|
||||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMode(themeMode)
|
applyMode(themeMode)
|
||||||
@@ -429,6 +451,25 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
|
const handleCloseSettings = () => {
|
||||||
|
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||||
|
if (backgroundLocation.pathname === '/settings') {
|
||||||
|
navigate('/home', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: backgroundLocation.pathname,
|
||||||
|
search: backgroundLocation.search,
|
||||||
|
hash: backgroundLocation.hash
|
||||||
|
},
|
||||||
|
{
|
||||||
|
replace: true,
|
||||||
|
state: backgroundLocation.state
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<div className="window-drag-region" aria-hidden="true" />
|
<div className="window-drag-region" aria-hidden="true" />
|
||||||
@@ -439,7 +480,10 @@ function App() {
|
|||||||
useHello={lockUseHello}
|
useHello={lockUseHello}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TitleBar />
|
<TitleBar
|
||||||
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
|
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||||
<UpdateProgressCapsule />
|
<UpdateProgressCapsule />
|
||||||
@@ -550,27 +594,29 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="main-layout">
|
<div className="main-layout">
|
||||||
<Sidebar />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<RouteGuard>
|
<RouteGuard>
|
||||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||||
<ExportPage />
|
<ExportPage />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Routes>
|
<Routes location={routeLocation}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/home" element={<HomePage />} />
|
<Route path="/home" element={<HomePage />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
|
|
||||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
|
||||||
|
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
|
||||||
|
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
|
||||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||||
<Route path="/dual-report" element={<DualReportPage />} />
|
<Route path="/dual-report" element={<DualReportPage />} />
|
||||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||||
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
|
||||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||||
<Route path="/sns" element={<SnsPage />} />
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
@@ -579,6 +625,10 @@ function App() {
|
|||||||
</RouteGuard>
|
</RouteGuard>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isSettingsRoute && (
|
||||||
|
<SettingsPage onClose={handleCloseSettings} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/components/ChatAnalysisHeader.scss
Normal file
136
src/components/ChatAnalysisHeader.scss
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
.chat-analysis-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.chat-analysis-breadcrumb-separator {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-current-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
.current {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chat-analysis-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-breadcrumb {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/components/ChatAnalysisHeader.tsx
Normal file
105
src/components/ChatAnalysisHeader.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { ChevronDown, ChevronLeft } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import './ChatAnalysisHeader.scss'
|
||||||
|
|
||||||
|
export type ChatAnalysisMode = 'private' | 'group'
|
||||||
|
|
||||||
|
interface ChatAnalysisHeaderProps {
|
||||||
|
currentMode: ChatAnalysisMode
|
||||||
|
actions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_CONFIG: Record<ChatAnalysisMode, { label: string; path: string }> = {
|
||||||
|
private: {
|
||||||
|
label: '私聊分析',
|
||||||
|
path: '/analytics/private'
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
label: '群聊分析',
|
||||||
|
path: '/analytics/group'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatAnalysisHeader({ currentMode, actions }: ChatAnalysisHeaderProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const currentLabel = MODE_CONFIG[currentMode].label
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const alternateMode = useMemo(
|
||||||
|
() => (currentMode === 'private' ? 'group' : 'private'),
|
||||||
|
[currentMode]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!dropdownRef.current?.contains(event.target as Node)) {
|
||||||
|
setMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
}
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-analysis-header">
|
||||||
|
<div className="chat-analysis-breadcrumb">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-analysis-back"
|
||||||
|
onClick={() => navigate('/analytics')}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
<span>聊天分析</span>
|
||||||
|
</button>
|
||||||
|
<span className="chat-analysis-breadcrumb-separator">/</span>
|
||||||
|
<div className="chat-analysis-dropdown" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chat-analysis-current-trigger ${menuOpen ? 'open' : ''}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
onClick={() => setMenuOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<span className="current">{currentLabel}</span>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="chat-analysis-menu" role="menu" aria-label="切换聊天分析类型">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="chat-analysis-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
navigate(MODE_CONFIG[alternateMode].path)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MODE_CONFIG[alternateMode].label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions ? <div className="chat-analysis-actions">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatAnalysisHeader
|
||||||
41
src/components/ErrorBoundary.tsx
Normal file
41
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Component, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: any) {
|
||||||
|
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback || (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||||
|
<p>消息渲染出错</p>
|
||||||
|
<p style={{ fontSize: '12px', marginTop: '8px' }}>
|
||||||
|
{this.state.error?.message || '未知错误'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,6 @@ export function GlobalSessionMonitor() {
|
|||||||
return () => {
|
return () => {
|
||||||
removeListener()
|
removeListener()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
return () => { }
|
return () => { }
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -43,31 +43,62 @@
|
|||||||
.sidebar-user-card-wrap {
|
.sidebar-user-card-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 0 12px 10px;
|
margin: 0 12px 10px;
|
||||||
|
--sidebar-user-menu-width: 172px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-user-clear-trigger {
|
.sidebar-user-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: auto;
|
||||||
bottom: calc(100% + 8px);
|
bottom: calc(100% + 8px);
|
||||||
|
width: max(100%, var(--sidebar-user-menu-width));
|
||||||
z-index: 12;
|
z-index: 12;
|
||||||
border: 1px solid rgba(255, 59, 48, 0.28);
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.95);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--bg-secondary);
|
background: transparent;
|
||||||
color: #d93025;
|
color: var(--text-primary);
|
||||||
padding: 8px 10px;
|
padding: 9px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
text-align: left;
|
||||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: #d93025;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 59, 48, 0.08);
|
background: rgba(255, 59, 48, 0.08);
|
||||||
border-color: rgba(255, 59, 48, 0.46);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,24 +275,183 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-btn {
|
.sidebar-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.3);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
z-index: 1100;
|
||||||
padding: 8px;
|
padding: 20px;
|
||||||
border: none;
|
animation: fadeIn 0.2s ease;
|
||||||
background: transparent;
|
}
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 9999px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
&:hover {
|
@keyframes fadeIn {
|
||||||
background: var(--bg-tertiary);
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dialog {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
animation: slideUp 0.25s ease;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-wxid-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-wxid-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: rgba(99, 102, 241, 0.32);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
border-color: rgba(99, 102, 241, 0.5);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-id {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dialog-actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-clear-dialog-overlay {
|
.sidebar-clear-dialog-overlay {
|
||||||
@@ -273,6 +463,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-clear-dialog {
|
.sidebar-clear-dialog {
|
||||||
@@ -282,6 +473,7 @@
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||||
padding: 18px 18px 16px;
|
padding: 18px 18px 16px;
|
||||||
|
animation: slideUp 0.25s ease;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
|
import { useChatStore } from '../stores/chatStore'
|
||||||
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||||
|
|
||||||
@@ -15,11 +17,28 @@ interface SidebarUserProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||||
|
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||||
|
|
||||||
interface SidebarUserProfileCache extends SidebarUserProfile {
|
interface SidebarUserProfileCache extends SidebarUserProfile {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccountProfilesCache {
|
||||||
|
[wxid: string]: {
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
alias?: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WxidOption {
|
||||||
|
wxid: string
|
||||||
|
modifiedTime: number
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||||
@@ -46,11 +65,32 @@ const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
|||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
|
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
|
||||||
|
|
||||||
|
// 同时写入账号缓存池
|
||||||
|
const accountsCache = readAccountProfilesCache()
|
||||||
|
accountsCache[profile.wxid] = {
|
||||||
|
displayName: profile.displayName,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
alias: profile.alias,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略本地缓存失败,不影响主流程
|
// 忽略本地缓存失败,不影响主流程
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readAccountProfilesCache = (): AccountProfilesCache => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return typeof parsed === 'object' && parsed ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeAccountId = (value?: string | null): string => {
|
const normalizeAccountId = (value?: string | null): string => {
|
||||||
const trimmed = String(value || '').trim()
|
const trimmed = String(value || '').trim()
|
||||||
if (!trimmed) return ''
|
if (!trimmed) return ''
|
||||||
@@ -62,10 +102,13 @@ const normalizeAccountId = (value?: string | null): string => {
|
|||||||
return suffixMatch ? suffixMatch[1] : trimmed
|
return suffixMatch ? suffixMatch[1] : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar() {
|
interface SidebarProps {
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ collapsed }: SidebarProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
|
||||||
const [authEnabled, setAuthEnabled] = useState(false)
|
const [authEnabled, setAuthEnabled] = useState(false)
|
||||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||||
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||||
@@ -73,12 +116,14 @@ function Sidebar() {
|
|||||||
displayName: '未识别用户'
|
displayName: '未识别用户'
|
||||||
})
|
})
|
||||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||||
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false)
|
const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
|
||||||
const [shouldClearCacheData, setShouldClearCacheData] = useState(false)
|
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||||
const [shouldClearExportData, setShouldClearExportData] = useState(false)
|
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
|
||||||
const [isClearingAccountData, setIsClearingAccountData] = useState(false)
|
|
||||||
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
const setLocked = useAppStore(state => state.setLocked)
|
const setLocked = useAppStore(state => state.setLocked)
|
||||||
|
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||||
|
const resetChatStore = useChatStore(state => state.reset)
|
||||||
|
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||||
@@ -140,6 +185,9 @@ function Sidebar() {
|
|||||||
const resolvedWxidRaw = String(wxid || '').trim()
|
const resolvedWxidRaw = String(wxid || '').trim()
|
||||||
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||||
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||||
|
|
||||||
|
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||||
|
|
||||||
const wxidCandidates = new Set<string>([
|
const wxidCandidates = new Set<string>([
|
||||||
resolvedWxidRaw.toLowerCase(),
|
resolvedWxidRaw.toLowerCase(),
|
||||||
resolvedWxid.trim().toLowerCase(),
|
resolvedWxid.trim().toLowerCase(),
|
||||||
@@ -165,77 +213,36 @@ function Sidebar() {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackDisplayName = resolvedWxid || '未识别用户'
|
// 并行获取名称和头像
|
||||||
|
const [contactResult, avatarResult] = await Promise.allSettled([
|
||||||
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
|
(async () => {
|
||||||
patchUserProfile({
|
const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
|
||||||
wxid: resolvedWxid,
|
for (const candidate of candidates) {
|
||||||
displayName: fallbackDisplayName
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!resolvedWxidRaw && !resolvedWxid) return
|
|
||||||
|
|
||||||
// 第二阶段:后台补齐名称(不会阻塞首屏)。
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
|
|
||||||
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
|
|
||||||
const contact = await window.electronAPI.chat.getContact(candidate)
|
const contact = await window.electronAPI.chat.getContact(candidate)
|
||||||
if (!contact) continue
|
if (contact?.remark || contact?.nickName || contact?.alias) {
|
||||||
if (!myContact) myContact = contact
|
return contact
|
||||||
if (contact.remark || contact.nickName || contact.alias) {
|
|
||||||
myContact = contact
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fromContact = pickFirstValidName(
|
return null
|
||||||
|
})(),
|
||||||
|
window.electronAPI.chat.getMyAvatarUrl()
|
||||||
|
])
|
||||||
|
|
||||||
|
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
|
||||||
|
const displayName = pickFirstValidName(
|
||||||
myContact?.remark,
|
myContact?.remark,
|
||||||
myContact?.nickName,
|
myContact?.nickName,
|
||||||
myContact?.alias
|
myContact?.alias
|
||||||
)
|
) || resolvedWxid || '未识别用户'
|
||||||
|
|
||||||
if (fromContact) {
|
patchUserProfile({
|
||||||
patchUserProfile({ displayName: fromContact }, resolvedWxid)
|
wxid: resolvedWxid,
|
||||||
// 同步补充微信号(alias)
|
displayName,
|
||||||
if (myContact?.alias) {
|
alias: myContact?.alias,
|
||||||
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
|
||||||
}
|
? avatarResult.value.avatarUrl
|
||||||
return
|
: undefined
|
||||||
}
|
})
|
||||||
|
|
||||||
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
|
|
||||||
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
|
|
||||||
const enrichedDisplayName = pickFirstValidName(
|
|
||||||
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
|
|
||||||
enrichedResult.contacts?.[resolvedWxid]?.displayName,
|
|
||||||
enrichedResult.contacts?.[cleanedWxid]?.displayName,
|
|
||||||
enrichedResult.contacts?.self?.displayName,
|
|
||||||
myContact?.alias
|
|
||||||
)
|
|
||||||
const bestName = enrichedDisplayName
|
|
||||||
if (bestName) {
|
|
||||||
patchUserProfile({ displayName: bestName }, resolvedWxid)
|
|
||||||
}
|
|
||||||
// 降级分支也补充微信号
|
|
||||||
if (myContact?.alias) {
|
|
||||||
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
|
||||||
}
|
|
||||||
} catch (nameError) {
|
|
||||||
console.error('加载侧边栏用户昵称失败:', nameError)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
// 第二阶段:后台补齐头像(不会阻塞首屏)。
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
|
|
||||||
if (avatarResult.success && avatarResult.avatarUrl) {
|
|
||||||
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
|
|
||||||
}
|
|
||||||
} catch (avatarError) {
|
|
||||||
console.error('加载侧边栏用户头像失败:', avatarError)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载侧边栏用户信息失败:', error)
|
console.error('加载侧边栏用户信息失败:', error)
|
||||||
}
|
}
|
||||||
@@ -243,10 +250,7 @@ function Sidebar() {
|
|||||||
|
|
||||||
const cachedProfile = readSidebarUserProfileCache()
|
const cachedProfile = readSidebarUserProfileCache()
|
||||||
if (cachedProfile) {
|
if (cachedProfile) {
|
||||||
setUserProfile(prev => ({
|
setUserProfile(cachedProfile)
|
||||||
...prev,
|
|
||||||
...cachedProfile
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadCurrentUser()
|
void loadCurrentUser()
|
||||||
@@ -260,79 +264,125 @@ function Sidebar() {
|
|||||||
return [...name][0] || '?'
|
return [...name][0] || '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openSwitchAccountDialog = async () => {
|
||||||
|
setIsAccountMenuOpen(false)
|
||||||
|
if (!isDbConnected) {
|
||||||
|
window.alert('数据库未连接,无法切换账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dbPath = await configService.getDbPath()
|
||||||
|
if (!dbPath) {
|
||||||
|
window.alert('请先在设置中配置数据库路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||||
|
const accountsCache = readAccountProfilesCache()
|
||||||
|
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||||
|
|
||||||
|
const enrichedWxids = wxids.map(option => {
|
||||||
|
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||||
|
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
||||||
|
|
||||||
|
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
displayName: userProfile.displayName,
|
||||||
|
avatarUrl: userProfile.avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cached) {
|
||||||
|
console.log('[切换账号] 使用缓存:', option.wxid, cached)
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
displayName: cached.displayName,
|
||||||
|
avatarUrl: cached.avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...option, displayName: option.wxid }
|
||||||
|
})
|
||||||
|
|
||||||
|
setWxidOptions(enrichedWxids)
|
||||||
|
setShowSwitchAccountDialog(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('扫描账号失败:', error)
|
||||||
|
window.alert('扫描账号失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSwitchAccount = async (selectedWxid: string) => {
|
||||||
|
if (!selectedWxid || isSwitchingAccount) return
|
||||||
|
setIsSwitchingAccount(true)
|
||||||
|
try {
|
||||||
|
console.log('[切换账号] 开始切换到:', selectedWxid)
|
||||||
|
const currentWxid = userProfile.wxid
|
||||||
|
if (currentWxid === selectedWxid) {
|
||||||
|
console.log('[切换账号] 已经是当前账号,跳过')
|
||||||
|
setShowSwitchAccountDialog(false)
|
||||||
|
setIsSwitchingAccount(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[切换账号] 设置新 wxid')
|
||||||
|
await configService.setMyWxid(selectedWxid)
|
||||||
|
|
||||||
|
console.log('[切换账号] 获取账号配置')
|
||||||
|
const wxidConfig = await configService.getWxidConfig(selectedWxid)
|
||||||
|
console.log('[切换账号] 配置内容:', wxidConfig)
|
||||||
|
if (wxidConfig?.decryptKey) {
|
||||||
|
console.log('[切换账号] 设置 decryptKey')
|
||||||
|
await configService.setDecryptKey(wxidConfig.decryptKey)
|
||||||
|
}
|
||||||
|
if (typeof wxidConfig?.imageXorKey === 'number') {
|
||||||
|
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
|
||||||
|
await configService.setImageXorKey(wxidConfig.imageXorKey)
|
||||||
|
}
|
||||||
|
if (wxidConfig?.imageAesKey) {
|
||||||
|
console.log('[切换账号] 设置 imageAesKey')
|
||||||
|
await configService.setImageAesKey(wxidConfig.imageAesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[切换账号] 检查数据库连接状态')
|
||||||
|
console.log('[切换账号] 数据库连接状态:', isDbConnected)
|
||||||
|
if (isDbConnected) {
|
||||||
|
console.log('[切换账号] 关闭数据库连接')
|
||||||
|
await window.electronAPI.chat.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[切换账号] 清除缓存')
|
||||||
|
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||||
|
clearAnalyticsStoreCache()
|
||||||
|
resetChatStore()
|
||||||
|
|
||||||
|
console.log('[切换账号] 触发 wxid-changed 事件')
|
||||||
|
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
|
||||||
|
|
||||||
|
console.log('[切换账号] 切换成功')
|
||||||
|
setShowSwitchAccountDialog(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[切换账号] 失败:', error)
|
||||||
|
window.alert('切换账号失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setIsSwitchingAccount(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSettingsFromAccountMenu = () => {
|
||||||
|
setIsAccountMenuOpen(false)
|
||||||
|
navigate('/settings', {
|
||||||
|
state: {
|
||||||
|
backgroundLocation: location
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||||
}
|
}
|
||||||
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
||||||
const canConfirmClear = shouldClearCacheData || shouldClearExportData
|
|
||||||
|
|
||||||
const resetClearDialogState = () => {
|
|
||||||
setShouldClearCacheData(false)
|
|
||||||
setShouldClearExportData(false)
|
|
||||||
setShowClearAccountDialog(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openClearAccountDialog = () => {
|
|
||||||
setIsAccountMenuOpen(false)
|
|
||||||
setShouldClearCacheData(false)
|
|
||||||
setShouldClearExportData(false)
|
|
||||||
setShowClearAccountDialog(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirmClearAccountData = async () => {
|
|
||||||
if (!canConfirmClear || isClearingAccountData) return
|
|
||||||
setIsClearingAccountData(true)
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.chat.clearCurrentAccountData({
|
|
||||||
clearCache: shouldClearCacheData,
|
|
||||||
clearExports: shouldClearExportData
|
|
||||||
})
|
|
||||||
if (!result.success) {
|
|
||||||
window.alert(result.error || '清理失败,请稍后重试。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
|
||||||
setUserProfile({ wxid: '', displayName: '未识别用户' })
|
|
||||||
window.dispatchEvent(new Event('wxid-changed'))
|
|
||||||
|
|
||||||
const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : []
|
|
||||||
const selectedScopes = [
|
|
||||||
shouldClearCacheData ? '缓存数据' : '',
|
|
||||||
shouldClearExportData ? '导出数据' : ''
|
|
||||||
].filter(Boolean)
|
|
||||||
const detailLines: string[] = [
|
|
||||||
`清理范围:${selectedScopes.join('、') || '未选择'}`,
|
|
||||||
`已清理项目:${removedPaths.length} 项`
|
|
||||||
]
|
|
||||||
if (removedPaths.length > 0) {
|
|
||||||
detailLines.push('', '清理明细(最多显示 8 项):')
|
|
||||||
for (const [index, path] of removedPaths.slice(0, 8).entries()) {
|
|
||||||
detailLines.push(`${index + 1}. ${path}`)
|
|
||||||
}
|
|
||||||
if (removedPaths.length > 8) {
|
|
||||||
detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (result.warning) {
|
|
||||||
detailLines.push('', `注意:${result.warning}`)
|
|
||||||
}
|
|
||||||
const followupHint = shouldClearCacheData
|
|
||||||
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
|
|
||||||
: '你可以继续使用当前登录状态,无需重新登录。'
|
|
||||||
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全,WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
|
|
||||||
resetClearDialogState()
|
|
||||||
if (shouldClearCacheData) {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清理账号数据失败:', error)
|
|
||||||
window.alert('清理失败,请稍后重试。')
|
|
||||||
} finally {
|
|
||||||
setIsClearingAccountData(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||||
<nav className="nav-menu">
|
<nav className="nav-menu">
|
||||||
{/* 首页 */}
|
{/* 首页 */}
|
||||||
@@ -375,24 +425,14 @@ function Sidebar() {
|
|||||||
<span className="nav-label">通讯录</span>
|
<span className="nav-label">通讯录</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 私聊分析 */}
|
{/* 聊天分析 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/analytics"
|
to="/analytics"
|
||||||
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
||||||
title={collapsed ? '私聊分析' : undefined}
|
title={collapsed ? '聊天分析' : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><BarChart3 size={20} /></span>
|
<span className="nav-icon"><BarChart3 size={20} /></span>
|
||||||
<span className="nav-label">私聊分析</span>
|
<span className="nav-label">聊天分析</span>
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
{/* 群聊分析 */}
|
|
||||||
<NavLink
|
|
||||||
to="/group-analytics"
|
|
||||||
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
|
|
||||||
title={collapsed ? '群聊分析' : undefined}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><Users size={20} /></span>
|
|
||||||
<span className="nav-label">群聊分析</span>
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 年度报告 */}
|
{/* 年度报告 */}
|
||||||
@@ -427,17 +467,47 @@ function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
|
||||||
{isAccountMenuOpen && (
|
|
||||||
<button
|
<button
|
||||||
className="sidebar-user-clear-trigger"
|
className="nav-item"
|
||||||
onClick={openClearAccountDialog}
|
onClick={() => {
|
||||||
type="button"
|
if (authEnabled) {
|
||||||
|
setLocked(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate('/settings', {
|
||||||
|
state: {
|
||||||
|
initialTab: 'security',
|
||||||
|
backgroundLocation: location
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
||||||
<span>清除此账号所有数据</span>
|
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||||
|
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
|
||||||
|
<button
|
||||||
|
className="sidebar-user-menu-item"
|
||||||
|
onClick={openSwitchAccountDialog}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
<span>切换账号</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sidebar-user-menu-item"
|
||||||
|
onClick={openSettingsFromAccountMenu}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Settings size={14} />
|
||||||
|
<span>设置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||||
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||||
@@ -465,85 +535,41 @@ function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
className="nav-item"
|
|
||||||
onClick={() => {
|
|
||||||
if (authEnabled) {
|
|
||||||
setLocked(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
navigate('/settings', { state: { initialTab: 'security' } })
|
|
||||||
}}
|
|
||||||
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
|
||||||
>
|
|
||||||
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
|
||||||
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<NavLink
|
|
||||||
to="/settings"
|
|
||||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
|
||||||
title={collapsed ? '设置' : undefined}
|
|
||||||
>
|
|
||||||
<span className="nav-icon">
|
|
||||||
<Settings size={20} />
|
|
||||||
</span>
|
|
||||||
<span className="nav-label">设置</span>
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="collapse-btn"
|
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
title={collapsed ? '展开菜单' : '收起菜单'}
|
|
||||||
>
|
|
||||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
{showClearAccountDialog && (
|
{showSwitchAccountDialog && (
|
||||||
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}>
|
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
|
||||||
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||||
<h3>清除此账号所有数据</h3>
|
<h3>切换账号</h3>
|
||||||
<p>
|
<p>选择要切换的微信账号</p>
|
||||||
操作后可将该账户在 weflow 下产生的所有缓存文件、导出文件等彻底清除。
|
<div className="sidebar-wxid-list">
|
||||||
清除后必须手动登录微信客户端 weflow 才能再次获取,保障你的数据安全。
|
{wxidOptions.map((option) => (
|
||||||
</p>
|
|
||||||
<div className="sidebar-clear-options">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={shouldClearCacheData}
|
|
||||||
onChange={(event) => setShouldClearCacheData(event.target.checked)}
|
|
||||||
disabled={isClearingAccountData}
|
|
||||||
/>
|
|
||||||
缓存数据
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={shouldClearExportData}
|
|
||||||
onChange={(event) => setShouldClearExportData(event.target.checked)}
|
|
||||||
disabled={isClearingAccountData}
|
|
||||||
/>
|
|
||||||
导出数据
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-clear-actions">
|
|
||||||
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}>取消</button>
|
|
||||||
<button
|
<button
|
||||||
|
key={option.wxid}
|
||||||
|
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
|
||||||
|
onClick={() => handleSwitchAccount(option.wxid)}
|
||||||
|
disabled={isSwitchingAccount}
|
||||||
type="button"
|
type="button"
|
||||||
className="danger"
|
|
||||||
disabled={!canConfirmClear || isClearingAccountData}
|
|
||||||
onClick={handleConfirmClearAccountData}
|
|
||||||
>
|
>
|
||||||
{isClearingAccountData ? '清除中...' : '确认清除'}
|
<div className="wxid-avatar">
|
||||||
|
{option.avatarUrl ? <img src={option.avatarUrl} alt="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="wxid-info">
|
||||||
|
<div className="wxid-name">{option.displayName || option.wxid}</div>
|
||||||
|
<div className="wxid-id">{option.wxid}</div>
|
||||||
|
</div>
|
||||||
|
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-dialog-actions">
|
||||||
|
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}>取消</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Search, User, X, Loader2 } from 'lucide-react'
|
import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
|
||||||
import { Avatar } from '../Avatar'
|
import { Avatar } from '../Avatar'
|
||||||
|
|
||||||
interface Contact {
|
interface Contact {
|
||||||
@@ -25,7 +25,12 @@ interface SnsFilterPanelProps {
|
|||||||
setContactSearch: (val: string) => void
|
setContactSearch: (val: string) => void
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
contactsCountProgress?: ContactsCountProgress
|
contactsCountProgress?: ContactsCountProgress
|
||||||
|
selectedContactUsernames: string[]
|
||||||
|
activeContactUsername?: string
|
||||||
onOpenContactTimeline: (contact: Contact) => void
|
onOpenContactTimeline: (contact: Contact) => void
|
||||||
|
onToggleContactSelected: (contact: Contact) => void
|
||||||
|
onClearSelectedContacts: () => void
|
||||||
|
onExportSelectedContacts: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||||
@@ -37,12 +42,21 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
setContactSearch,
|
setContactSearch,
|
||||||
loading,
|
loading,
|
||||||
contactsCountProgress,
|
contactsCountProgress,
|
||||||
onOpenContactTimeline
|
selectedContactUsernames,
|
||||||
|
activeContactUsername,
|
||||||
|
onOpenContactTimeline,
|
||||||
|
onToggleContactSelected,
|
||||||
|
onClearSelectedContacts,
|
||||||
|
onExportSelectedContacts
|
||||||
}) => {
|
}) => {
|
||||||
const filteredContacts = contacts.filter(c =>
|
const filteredContacts = contacts.filter(c =>
|
||||||
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
|
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||||
)
|
)
|
||||||
|
const selectedContactLookup = React.useMemo(
|
||||||
|
() => new Set(selectedContactUsernames),
|
||||||
|
[selectedContactUsernames]
|
||||||
|
)
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchKeyword('')
|
setSearchKeyword('')
|
||||||
@@ -122,14 +136,34 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="contact-interaction-hint">
|
||||||
|
点左侧可多选下载,点右侧可查看单人详情
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="contact-list-scroll">
|
<div className="contact-list-scroll">
|
||||||
{filteredContacts.map(contact => {
|
{filteredContacts.map(contact => {
|
||||||
const isPostCountReady = contact.postCountStatus === 'ready'
|
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||||
|
const isSelected = selectedContactLookup.has(contact.username)
|
||||||
|
const isActive = activeContactUsername === contact.username
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={contact.username}
|
key={contact.username}
|
||||||
className="contact-row"
|
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
|
||||||
|
onClick={() => onToggleContactSelected(contact)}
|
||||||
|
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="contact-main-btn"
|
||||||
onClick={() => onOpenContactTimeline(contact)}
|
onClick={() => onOpenContactTimeline(contact)}
|
||||||
|
title={`查看 ${contact.displayName} 的朋友圈`}
|
||||||
>
|
>
|
||||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||||
<div className="contact-meta">
|
<div className="contact-meta">
|
||||||
@@ -144,6 +178,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -151,6 +186,19 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
|||||||
<div className="empty-state">{getEmptyStateText()}</div>
|
<div className="empty-state">{getEmptyStateText()}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedContactUsernames.length > 0 && (
|
||||||
|
<div className="contact-batch-bar">
|
||||||
|
<span className="contact-batch-summary">已选 {selectedContactUsernames.length} 人</span>
|
||||||
|
<button type="button" className="contact-batch-btn" onClick={onClearSelectedContacts}>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button type="button" className="contact-batch-btn primary" onClick={onExportSelectedContacts}>
|
||||||
|
<Download size={14} />
|
||||||
|
<span>下载所选</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2101;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 繁花如梦:标题栏毛玻璃
|
// 繁花如梦:标题栏毛玻璃
|
||||||
@@ -16,6 +20,12 @@
|
|||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.title-logo {
|
.title-logo {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -26,4 +36,111 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-sidebar-toggle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-window-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-window-control-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-close:hover {
|
||||||
|
background: #e5484d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 16px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.live-play-btn.active {
|
||||||
|
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
|
||||||
|
color: var(--primary, #4c84ff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-text {
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,87 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Copy, Minus, PanelLeftClose, PanelLeftOpen, Square, X } from 'lucide-react'
|
||||||
import './TitleBar.scss'
|
import './TitleBar.scss'
|
||||||
|
|
||||||
interface TitleBarProps {
|
interface TitleBarProps {
|
||||||
title?: string
|
title?: string
|
||||||
|
sidebarCollapsed?: boolean
|
||||||
|
onToggleSidebar?: () => void
|
||||||
|
showWindowControls?: boolean
|
||||||
|
customControls?: React.ReactNode
|
||||||
|
showLogo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function TitleBar({ title }: TitleBarProps = {}) {
|
function TitleBar({
|
||||||
|
title,
|
||||||
|
sidebarCollapsed = false,
|
||||||
|
onToggleSidebar,
|
||||||
|
showWindowControls = true,
|
||||||
|
customControls,
|
||||||
|
showLogo = true
|
||||||
|
}: TitleBarProps = {}) {
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showWindowControls) return
|
||||||
|
|
||||||
|
void window.electronAPI.window.isMaximized().then(setIsMaximized).catch(() => {
|
||||||
|
setIsMaximized(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return window.electronAPI.window.onMaximizeStateChanged((maximized) => {
|
||||||
|
setIsMaximized(maximized)
|
||||||
|
})
|
||||||
|
}, [showWindowControls])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="title-bar">
|
<div className="title-bar">
|
||||||
<img src="./logo.png" alt="WeFlow" className="title-logo" />
|
<div className="title-brand">
|
||||||
|
{showLogo && <img src="./logo.png" alt="WeFlow" className="title-logo" />}
|
||||||
<span className="titles">{title || 'WeFlow'}</span>
|
<span className="titles">{title || 'WeFlow'}</span>
|
||||||
|
{onToggleSidebar ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="title-sidebar-toggle"
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
title={sidebarCollapsed ? '展开菜单' : '收起菜单'}
|
||||||
|
aria-label={sidebarCollapsed ? '展开菜单' : '收起菜单'}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{customControls}
|
||||||
|
{showWindowControls ? (
|
||||||
|
<div className="title-window-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="title-window-control-btn"
|
||||||
|
aria-label="最小化"
|
||||||
|
title="最小化"
|
||||||
|
onClick={() => window.electronAPI.window.minimize()}
|
||||||
|
>
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="title-window-control-btn"
|
||||||
|
aria-label={isMaximized ? '还原' : '最大化'}
|
||||||
|
title={isMaximized ? '还原' : '最大化'}
|
||||||
|
onClick={() => window.electronAPI.window.maximize()}
|
||||||
|
>
|
||||||
|
{isMaximized ? <Copy size={12} /> : <Square size={12} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="title-window-control-btn is-close"
|
||||||
|
aria-label="关闭"
|
||||||
|
title="关闭"
|
||||||
|
onClick={() => window.electronAPI.window.close()}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
.update-dialog {
|
.update-dialog {
|
||||||
width: 680px;
|
width: 680px;
|
||||||
background: #f5f5f5;
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
/* Top Section (White/Gradient) */
|
/* Top Section (White/Gradient) */
|
||||||
.dialog-header {
|
.dialog-header {
|
||||||
background: #ffffff;
|
background: var(--bg-primary, #ffffff);
|
||||||
padding: 40px 20px 30px;
|
padding: 40px 20px 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -41,14 +41,14 @@
|
|||||||
left: -50px;
|
left: -50px;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%);
|
background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%);
|
||||||
opacity: 0.8;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-tag {
|
.version-tag {
|
||||||
background: #f0eee9;
|
background: var(--bg-tertiary, #f0eee9);
|
||||||
color: #8c7b6e;
|
color: var(--text-tertiary, #8c7b6e);
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -60,21 +60,21 @@
|
|||||||
h2 {
|
h2 {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #333333;
|
color: var(--text-primary, #333333);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #999999;
|
color: var(--text-secondary, #999999);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content Section (Light Gray) */
|
/* Content Section (Light Gray) */
|
||||||
.dialog-content {
|
.dialog-content {
|
||||||
background: #f2f2f2;
|
background: var(--bg-tertiary, #f2f2f2);
|
||||||
padding: 24px 40px 40px;
|
padding: 24px 40px 40px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
|
||||||
.icon-box {
|
.icon-box {
|
||||||
background: #fbfbfb; // Beige-ish white
|
background: var(--bg-primary, #fbfbfb);
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #8c7b6e;
|
color: var(--text-tertiary, #8c7b6e);
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@@ -107,27 +107,38 @@
|
|||||||
.text-box {
|
.text-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
h3 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-size: 18px;
|
color: var(--text-primary, #333333);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #333333;
|
margin: 16px 0 8px;
|
||||||
margin: 0 0 8px;
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666666;
|
color: var(--text-secondary, #666666);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin: 8px 0 0 18px;
|
margin: 4px 0 0 18px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666666;
|
color: var(--text-secondary, #666666);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,19 +153,19 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: var(--text-secondary, #888);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-bg {
|
.progress-bar-bg {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: #e0e0e0;
|
background: var(--border-color, #e0e0e0);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #000000;
|
background: var(--text-primary, #000000);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -164,7 +175,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #666;
|
color: var(--text-secondary, #666);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +186,8 @@
|
|||||||
|
|
||||||
.btn-ignore {
|
.btn-ignore {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #666666;
|
color: var(--text-secondary, #666666);
|
||||||
border: 1px solid #d0d0d0;
|
border: 1px solid var(--border-color, #d0d0d0);
|
||||||
padding: 16px 32px;
|
padding: 16px 32px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -185,9 +196,9 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f5f5f5;
|
background: var(--bg-hover, #f5f5f5);
|
||||||
border-color: #999999;
|
border-color: var(--text-secondary, #999999);
|
||||||
color: #333333;
|
color: var(--text-primary, #333333);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@@ -196,11 +207,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-update {
|
.btn-update {
|
||||||
background: #000000;
|
background: var(--text-primary, #000000);
|
||||||
color: #ffffff;
|
color: var(--bg-primary, #ffffff);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 16px 48px;
|
padding: 16px 48px;
|
||||||
border-radius: 20px; // Pill shape
|
border-radius: 20px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -231,7 +242,7 @@
|
|||||||
right: 16px;
|
right: 16px;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
border: none;
|
border: none;
|
||||||
color: #999;
|
color: var(--text-secondary, #999);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -244,7 +255,7 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
color: #333;
|
color: var(--text-primary, #333);
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
<Quote size={20} />
|
<Quote size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-box">
|
<div className="text-box">
|
||||||
<h3>优化</h3>
|
|
||||||
{updateInfo.releaseNotes ? (
|
{updateInfo.releaseNotes ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
|
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
.analytics-page-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 100%;
|
||||||
|
|
||||||
|
.loading-container,
|
||||||
|
.error-container {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载和错误状态
|
// 加载和错误状态
|
||||||
.loading-container,
|
.loading-container,
|
||||||
.error-container {
|
.error-container {
|
||||||
@@ -53,24 +65,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } 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 ReactECharts from 'echarts-for-react'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './AnalyticsPage.scss'
|
import './AnalyticsPage.scss'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
|
||||||
|
|
||||||
interface ExcludeCandidate {
|
interface ExcludeCandidate {
|
||||||
username: string
|
username: string
|
||||||
@@ -48,6 +55,13 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
const loadData = useCallback(async (forceRefresh = false) => {
|
const loadData = useCallback(async (forceRefresh = false) => {
|
||||||
if (isLoaded && !forceRefresh) return
|
if (isLoaded && !forceRefresh) return
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'analytics',
|
||||||
|
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
|
||||||
|
detail: '准备读取整体统计数据',
|
||||||
|
progressText: '整体统计',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
@@ -60,27 +74,70 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingStatus('正在统计消息数据...')
|
setLoadingStatus('正在统计消息数据...')
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在统计消息数据',
|
||||||
|
progressText: '整体统计'
|
||||||
|
})
|
||||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前页面分析流程已结束'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (statsResult.success && statsResult.data) {
|
if (statsResult.success && statsResult.data) {
|
||||||
setStatistics(statsResult.data)
|
setStatistics(statsResult.data)
|
||||||
} else {
|
} else {
|
||||||
setError(statsResult.error || '加载统计数据失败')
|
setError(statsResult.error || '加载统计数据失败')
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: statsResult.error || '加载统计数据失败'
|
||||||
|
})
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoadingStatus('正在分析联系人排名...')
|
setLoadingStatus('正在分析联系人排名...')
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在分析联系人排名',
|
||||||
|
progressText: '联系人排名'
|
||||||
|
})
|
||||||
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
|
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,联系人排名后续步骤未继续'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (rankingsResult.success && rankingsResult.data) {
|
if (rankingsResult.success && rankingsResult.data) {
|
||||||
setRankings(rankingsResult.data)
|
setRankings(rankingsResult.data)
|
||||||
}
|
}
|
||||||
setLoadingStatus('正在计算时间分布...')
|
setLoadingStatus('正在计算时间分布...')
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在计算时间分布',
|
||||||
|
progressText: '时间分布'
|
||||||
|
})
|
||||||
const timeResult = await window.electronAPI.analytics.getTimeDistribution()
|
const timeResult = await window.electronAPI.analytics.getTimeDistribution()
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,时间分布结果未继续写入'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (timeResult.success && timeResult.data) {
|
if (timeResult.success && timeResult.data) {
|
||||||
setTimeDistribution(timeResult.data)
|
setTimeDistribution(timeResult.data)
|
||||||
}
|
}
|
||||||
markLoaded()
|
markLoaded()
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '分析看板数据加载完成',
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e))
|
setError(String(e))
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
if (removeListener) removeListener()
|
if (removeListener) removeListener()
|
||||||
@@ -360,8 +417,28 @@ function AnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderPageShell = (content: ReactNode) => (
|
||||||
|
<div className="analytics-page-shell">
|
||||||
|
<ChatAnalysisHeader currentMode="private" />
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const analyticsHeaderActions = (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading && !isLoaded) {
|
if (isLoading && !isLoaded) {
|
||||||
return (
|
return renderPageShell(
|
||||||
<div className="loading-container">
|
<div className="loading-container">
|
||||||
<Loader2 size={48} className="spin" />
|
<Loader2 size={48} className="spin" />
|
||||||
<p className="loading-status">{loadingStatus}</p>
|
<p className="loading-status">{loadingStatus}</p>
|
||||||
@@ -374,7 +451,7 @@ function AnalyticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
|
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
|
||||||
return (
|
return renderPageShell(
|
||||||
<div className="error-container">
|
<div className="error-container">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<div className="error-actions">
|
<div className="error-actions">
|
||||||
@@ -390,25 +467,18 @@ function AnalyticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error && !isLoaded) {
|
if (error && !isLoaded) {
|
||||||
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
return renderPageShell(
|
||||||
|
<div className="error-container">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => loadData(true)}>重试</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="analytics-page-shell">
|
||||||
<div className="page-header">
|
<ChatAnalysisHeader currentMode="private" actions={analyticsHeaderActions} />
|
||||||
<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">
|
<div className="page-scroll">
|
||||||
<section className="page-section">
|
<section className="page-section">
|
||||||
<div className="stats-overview">
|
<div className="stats-overview">
|
||||||
@@ -556,7 +626,7 @@ function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
|
.analytics-entry-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.analytics-welcome-container {
|
.analytics-welcome-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
min-height: 0;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
animation: fadeIn 0.4s ease-out;
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
&.analytics-welcome-container--mode {
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(7, 193, 96, 0.06), transparent 48%),
|
||||||
|
var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-content {
|
.welcome-content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -106,6 +123,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.analytics-welcome-container {
|
||||||
|
padding: 28px 18px;
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
.action-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { BarChart2, History, RefreshCcw } from 'lucide-react'
|
import { BarChart2, History, RefreshCcw } from 'lucide-react'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
|
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
|
||||||
import './AnalyticsWelcomePage.scss'
|
import './AnalyticsWelcomePage.scss'
|
||||||
|
|
||||||
function AnalyticsWelcomePage() {
|
function AnalyticsWelcomePage() {
|
||||||
@@ -14,11 +15,11 @@ function AnalyticsWelcomePage() {
|
|||||||
const { lastLoadTime } = useAnalyticsStore()
|
const { lastLoadTime } = useAnalyticsStore()
|
||||||
|
|
||||||
const handleLoadCache = () => {
|
const handleLoadCache = () => {
|
||||||
navigate('/analytics/view')
|
navigate('/analytics/private/view')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewAnalysis = () => {
|
const handleNewAnalysis = () => {
|
||||||
navigate('/analytics/view', { state: { forceRefresh: true } })
|
navigate('/analytics/private/view', { state: { forceRefresh: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatLastTime = (ts: number | null) => {
|
const formatLastTime = (ts: number | null) => {
|
||||||
@@ -27,15 +28,18 @@ function AnalyticsWelcomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="analytics-welcome-container">
|
<div className="analytics-entry-page">
|
||||||
|
<ChatAnalysisHeader currentMode="private" />
|
||||||
|
|
||||||
|
<div className="analytics-welcome-container analytics-welcome-container--mode">
|
||||||
<div className="welcome-content">
|
<div className="welcome-content">
|
||||||
<div className="icon-wrapper">
|
<div className="icon-wrapper">
|
||||||
<BarChart2 size={40} />
|
<BarChart2 size={40} />
|
||||||
</div>
|
</div>
|
||||||
<h1>私聊数据分析</h1>
|
<h1>私聊数据分析</h1>
|
||||||
<p>
|
<p>
|
||||||
WeFlow 可以分析你的聊天记录,生成详细的统计报表。<br />
|
WeFlow 可以分析你的好友聊天记录,生成详细的统计报表。<br />
|
||||||
你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
你可以选择加载上次的分析结果,或者重新开始一次新的私聊分析。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="action-cards">
|
<div className="action-cards">
|
||||||
@@ -57,6 +61,7 @@ function AnalyticsWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './AnnualReportPage.scss'
|
import './AnnualReportPage.scss'
|
||||||
|
|
||||||
type YearOption = number | 'all'
|
type YearOption = number | 'all'
|
||||||
@@ -49,8 +55,17 @@ function AnnualReportPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false
|
let disposed = false
|
||||||
let taskId = ''
|
let taskId = ''
|
||||||
|
let uiTaskId = ''
|
||||||
|
|
||||||
const applyLoadPayload = (payload: YearsLoadPayload) => {
|
const applyLoadPayload = (payload: YearsLoadPayload) => {
|
||||||
|
if (uiTaskId) {
|
||||||
|
updateBackgroundTask(uiTaskId, {
|
||||||
|
detail: payload.statusText || '正在加载可用年份',
|
||||||
|
progressText: payload.done
|
||||||
|
? '已完成'
|
||||||
|
: `${Array.isArray(payload.years) ? payload.years.length : 0} 个年份`
|
||||||
|
})
|
||||||
|
}
|
||||||
if (payload.strategy) setLoadStrategy(payload.strategy)
|
if (payload.strategy) setLoadStrategy(payload.strategy)
|
||||||
if (payload.phase) setLoadPhase(payload.phase)
|
if (payload.phase) setLoadPhase(payload.phase)
|
||||||
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
||||||
@@ -91,6 +106,14 @@ function AnnualReportPage() {
|
|||||||
setIsLoadingMoreYears(false)
|
setIsLoadingMoreYears(false)
|
||||||
setHasYearsLoadFinished(true)
|
setHasYearsLoadFinished(true)
|
||||||
setLoadPhase('done')
|
setLoadPhase('done')
|
||||||
|
if (uiTaskId) {
|
||||||
|
finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', {
|
||||||
|
detail: payload.canceled
|
||||||
|
? '年度报告年份加载已停止'
|
||||||
|
: `年度报告年份加载完成,共 ${years.length} 个年份`,
|
||||||
|
progressText: payload.canceled ? '已停止' : `${years.length} 个年份`
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsLoadingMoreYears(true)
|
setIsLoadingMoreYears(true)
|
||||||
setHasYearsLoadFinished(false)
|
setHasYearsLoadFinished(false)
|
||||||
@@ -105,6 +128,18 @@ function AnnualReportPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const startLoad = async () => {
|
const startLoad = async () => {
|
||||||
|
uiTaskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'annualReport',
|
||||||
|
title: '年度报告年份加载',
|
||||||
|
detail: '准备使用原生快速模式加载年份',
|
||||||
|
progressText: '初始化',
|
||||||
|
cancelable: true,
|
||||||
|
onCancel: async () => {
|
||||||
|
if (taskId) {
|
||||||
|
await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setIsLoadingMoreYears(true)
|
setIsLoadingMoreYears(true)
|
||||||
setHasYearsLoadFinished(false)
|
setHasYearsLoadFinished(false)
|
||||||
@@ -120,6 +155,9 @@ function AnnualReportPage() {
|
|||||||
try {
|
try {
|
||||||
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
||||||
if (!startResult.success || !startResult.taskId) {
|
if (!startResult.success || !startResult.taskId) {
|
||||||
|
finishBackgroundTask(uiTaskId, 'failed', {
|
||||||
|
detail: startResult.error || '加载年度数据失败'
|
||||||
|
})
|
||||||
setLoadError(startResult.error || '加载年度数据失败')
|
setLoadError(startResult.error || '加载年度数据失败')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setIsLoadingMoreYears(false)
|
setIsLoadingMoreYears(false)
|
||||||
@@ -131,6 +169,9 @@ function AnnualReportPage() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
finishBackgroundTask(uiTaskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
setLoadError(String(e))
|
setLoadError(String(e))
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setIsLoadingMoreYears(false)
|
setIsLoadingMoreYears(false)
|
||||||
@@ -168,16 +209,7 @@ function AnnualReportPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据(首批)...</p>
|
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在准备年度报告...</p>
|
||||||
<div className="load-telemetry compact">
|
|
||||||
<p><span className="label">加载方式:</span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
|
|
||||||
<p><span className="label">状态:</span>{loadStatusText || '正在加载年份数据...'}</p>
|
|
||||||
<p>
|
|
||||||
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
|
||||||
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
|
||||||
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -223,30 +255,6 @@ function AnnualReportPage() {
|
|||||||
<Sparkles size={32} className="header-icon" />
|
<Sparkles size={32} className="header-icon" />
|
||||||
<h1 className="page-title">年度报告</h1>
|
<h1 className="page-title">年度报告</h1>
|
||||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||||
{loadedYearCount > 0 && (
|
|
||||||
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
|
||||||
{isYearStatusComplete ? (
|
|
||||||
<>已显示 {loadedYearCount} 个年份,年份数据已全部加载完毕。总耗时 {formatLoadElapsed(totalElapsedMs)}</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
已显示 {loadedYearCount} 个年份,正在补充更多年份<span className="dot-ellipsis" aria-hidden="true">...</span>
|
|
||||||
(已耗时 {formatLoadElapsed(totalElapsedMs)})
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
|
||||||
<p><span className="label">加载方式:</span>{strategyLabel}</p>
|
|
||||||
<p>
|
|
||||||
<span className="label">状态:</span>
|
|
||||||
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="label">原生耗时:</span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} |{' '}
|
|
||||||
<span className="label">扫表耗时:</span>{formatLoadElapsed(scanElapsedMs)} |{' '}
|
|
||||||
<span className="label">总耗时:</span>{formatLoadElapsed(totalElapsedMs)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="report-sections">
|
<div className="report-sections">
|
||||||
<section className="report-section">
|
<section className="report-section">
|
||||||
@@ -270,7 +278,6 @@ function AnnualReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{renderYearLoadStatus()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -317,7 +324,6 @@ function AnnualReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{renderYearLoadStatus()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
|
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './AnnualReportWindow.scss'
|
import './AnnualReportWindow.scss'
|
||||||
|
|
||||||
// SVG 背景图案 (用于导出)
|
// SVG 背景图案 (用于导出)
|
||||||
@@ -127,12 +133,6 @@ function AnnualReportWindow() {
|
|||||||
|
|
||||||
const { currentTheme, themeMode } = useThemeStore()
|
const { currentTheme, themeMode } = useThemeStore()
|
||||||
|
|
||||||
// 应用主题到独立窗口
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
|
||||||
document.documentElement.setAttribute('data-mode', themeMode)
|
|
||||||
}, [currentTheme, themeMode])
|
|
||||||
|
|
||||||
// Section refs
|
// Section refs
|
||||||
const sectionRefs = {
|
const sectionRefs = {
|
||||||
cover: useRef<HTMLElement>(null),
|
cover: useRef<HTMLElement>(null),
|
||||||
@@ -164,6 +164,13 @@ function AnnualReportWindow() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const generateReport = async (year: number) => {
|
const generateReport = async (year: number) => {
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'annualReport',
|
||||||
|
title: '年度报告生成',
|
||||||
|
detail: `正在生成 ${formatYearLabel(year)} 年度报告`,
|
||||||
|
progressText: '初始化',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoadingProgress(0)
|
setLoadingProgress(0)
|
||||||
@@ -171,25 +178,46 @@ function AnnualReportWindow() {
|
|||||||
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
||||||
setLoadingProgress(payload.progress)
|
setLoadingProgress(payload.progress)
|
||||||
setLoadingStage(payload.status)
|
setLoadingStage(payload.status)
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: payload.status || '正在生成年度报告',
|
||||||
|
progressText: `${Math.max(0, Math.round(payload.progress || 0))}%`
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.annualReport.generateReport(year)
|
const result = await window.electronAPI.annualReport.generateReport(year)
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前报告结果未继续写入页面'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setLoadingProgress(100)
|
setLoadingProgress(100)
|
||||||
setLoadingStage('完成')
|
setLoadingStage('完成')
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '年度报告生成完成',
|
||||||
|
progressText: '100%'
|
||||||
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setReportData(result.data!)
|
setReportData(result.data!)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, 300)
|
}, 300)
|
||||||
} else {
|
} else {
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: result.error || '生成年度报告失败'
|
||||||
|
})
|
||||||
setError(result.error || '生成报告失败')
|
setError(result.error || '生成报告失败')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
setError(String(e))
|
setError(String(e))
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
123
src/pages/ChatAnalyticsHubPage.scss
Normal file
123
src/pages/ChatAnalyticsHubPage.scss
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
.chat-analytics-hub-page {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analytics-hub-content {
|
||||||
|
width: min(860px, 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analytics-hub-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analytics-hub-content h1 {
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analytics-hub-desc {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 0 0 32px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analytics-hub-grid {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analytics-entry-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 260px;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(7, 193, 96, 0.08) 0%, rgba(7, 193, 96, 0.02) 100%),
|
||||||
|
var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: rgba(7, 193, 96, 0.35);
|
||||||
|
box-shadow: 0 20px 36px rgba(7, 193, 96, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(7, 193, 96, 0.12);
|
||||||
|
color: #07c160;
|
||||||
|
|
||||||
|
&.group {
|
||||||
|
background: rgba(24, 119, 242, 0.12);
|
||||||
|
color: #1877f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card-header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card-cta {
|
||||||
|
margin-top: auto;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.chat-analytics-hub-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/pages/ChatAnalyticsHubPage.tsx
Normal file
59
src/pages/ChatAnalyticsHubPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ArrowRight, BarChart3, MessageSquare, Users } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import './ChatAnalyticsHubPage.scss'
|
||||||
|
|
||||||
|
function ChatAnalyticsHubPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-analytics-hub-page">
|
||||||
|
<div className="chat-analytics-hub-content">
|
||||||
|
<div className="chat-analytics-hub-badge">
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
<span>聊天分析</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>选择你要进入的分析视角</h1>
|
||||||
|
<p className="chat-analytics-hub-desc">
|
||||||
|
私聊分析更适合看好友聊天统计和趋势,群聊分析则用于查看群成员、发言排行和活跃时段。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="chat-analytics-hub-grid">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-analytics-entry-card"
|
||||||
|
onClick={() => navigate('/analytics/private')}
|
||||||
|
>
|
||||||
|
<div className="entry-card-icon">
|
||||||
|
<MessageSquare size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="entry-card-header">
|
||||||
|
<h2>私聊分析</h2>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</div>
|
||||||
|
<p>查看好友聊天统计、消息趋势、活跃时段与联系人排名。</p>
|
||||||
|
<span className="entry-card-cta">进入私聊分析</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-analytics-entry-card"
|
||||||
|
onClick={() => navigate('/analytics/group')}
|
||||||
|
>
|
||||||
|
<div className="entry-card-icon group">
|
||||||
|
<Users size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="entry-card-header">
|
||||||
|
<h2>群聊分析</h2>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</div>
|
||||||
|
<p>查看群成员信息、发言排行、活跃时段和媒体内容统计。</p>
|
||||||
|
<span className="entry-card-cta">进入群聊分析</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatAnalyticsHubPage
|
||||||
@@ -33,6 +33,16 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&.error-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useParams, useLocation } from 'react-router-dom'
|
import { useParams, useLocation } from 'react-router-dom'
|
||||||
import { ChatRecordItem } from '../types/models'
|
import { ChatRecordItem } from '../types/models'
|
||||||
import TitleBar from '../components/TitleBar'
|
import TitleBar from '../components/TitleBar'
|
||||||
|
import { ErrorBoundary } from '../components/ErrorBoundary'
|
||||||
import './ChatHistoryPage.scss'
|
import './ChatHistoryPage.scss'
|
||||||
|
|
||||||
export default function ChatHistoryPage() {
|
export default function ChatHistoryPage() {
|
||||||
@@ -166,7 +167,9 @@ export default function ChatHistoryPage() {
|
|||||||
<div className="status-msg empty">暂无可显示的聊天记录</div>
|
<div className="status-msg empty">暂无可显示的聊天记录</div>
|
||||||
) : (
|
) : (
|
||||||
recordList.map((item, i) => (
|
recordList.map((item, i) => (
|
||||||
<HistoryItem key={i} item={item} />
|
<ErrorBoundary key={i} fallback={<div className="history-item error-item">消息解析失败</div>}>
|
||||||
|
<HistoryItem item={item} />
|
||||||
|
</ErrorBoundary>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +178,8 @@ export default function ChatHistoryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||||
|
const [imageError, setImageError] = useState(false)
|
||||||
|
|
||||||
// sourcetime 在合并转发里有两种格式:
|
// sourcetime 在合并转发里有两种格式:
|
||||||
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||||
let time = ''
|
let time = ''
|
||||||
@@ -197,19 +202,16 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
|||||||
if (src) {
|
if (src) {
|
||||||
return (
|
return (
|
||||||
<div className="media-content">
|
<div className="media-content">
|
||||||
|
{imageError ? (
|
||||||
|
<div className="media-tip">图片无法加载</div>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt="图片"
|
alt="图片"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
onError={(e) => {
|
onError={() => setImageError(true)}
|
||||||
const target = e.target as HTMLImageElement
|
|
||||||
target.style.display = 'none'
|
|
||||||
const placeholder = document.createElement('div')
|
|
||||||
placeholder.className = 'media-tip'
|
|
||||||
placeholder.textContent = '图片无法加载'
|
|
||||||
target.parentElement?.appendChild(placeholder)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1605,6 +1605,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
|
||||||
.session-avatar {
|
.session-avatar {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@@ -1638,6 +1639,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
.jump-calendar-anchor {
|
.jump-calendar-anchor {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -4440,18 +4442,23 @@
|
|||||||
|
|
||||||
// 折叠群入口样式
|
// 折叠群入口样式
|
||||||
.session-item.fold-entry {
|
.session-item.fold-entry {
|
||||||
background: var(--card-inner-bg, rgba(0,0,0,0.03));
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg, rgba(0,0,0,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
.fold-entry-avatar {
|
.fold-entry-avatar {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--primary-color, #07c160);
|
background: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #fff;
|
color: #fa9d3b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-name {
|
.session-name {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
|
|||||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import {
|
import {
|
||||||
emitOpenSingleExport,
|
emitOpenSingleExport,
|
||||||
onExportSessionStatus,
|
onExportSessionStatus,
|
||||||
@@ -350,18 +356,19 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
if (isFoldEntry) {
|
if (isFoldEntry) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`session-item fold-entry`}
|
className={`session-item fold-entry ${isActive ? 'active' : ''}`}
|
||||||
onClick={() => onSelect(session)}
|
onClick={() => onSelect(session)}
|
||||||
>
|
>
|
||||||
<div className="fold-entry-avatar">
|
<div className="fold-entry-avatar">
|
||||||
<FolderClosed size={22} />
|
<MessageSquare size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div className="session-info">
|
<div className="session-info">
|
||||||
<div className="session-top">
|
<div className="session-top">
|
||||||
<span className="session-name">折叠的群聊</span>
|
<span className="session-name">折叠的聊天</span>
|
||||||
|
<span className="session-time">{timeText}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="session-bottom">
|
<div className="session-bottom">
|
||||||
<span className="session-summary">{session.summary || ''}</span>
|
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1067,6 +1074,13 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
if (!normalizedSessionId) return
|
if (!normalizedSessionId) return
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'chat',
|
||||||
|
title: '聊天页会话详情统计',
|
||||||
|
detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`,
|
||||||
|
progressText: '基础信息',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
|
||||||
const requestSeq = ++detailRequestSeqRef.current
|
const requestSeq = ++detailRequestSeqRef.current
|
||||||
const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId)
|
const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId)
|
||||||
@@ -1130,8 +1144,23 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取会话基础详情',
|
||||||
|
progressText: '基础信息'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
|
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前基础查询结束后未继续补充统计'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '会话已切换,旧详情任务已停止'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.detail) {
|
if (result.success && result.detail) {
|
||||||
setSessionDetail((prev) => ({
|
setSessionDetail((prev) => ({
|
||||||
wxid: normalizedSessionId,
|
wxid: normalizedSessionId,
|
||||||
@@ -1170,6 +1199,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取补充信息与导出统计',
|
||||||
|
progressText: '补充统计'
|
||||||
|
})
|
||||||
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
||||||
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
||||||
window.electronAPI.chat.getExportSessionStats(
|
window.electronAPI.chat.getExportSessionStats(
|
||||||
@@ -1178,7 +1211,18 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,补充统计结果未继续写入'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '会话已切换,旧补充统计任务已停止'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
|
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
|
||||||
const detail = extraResultSettled.value.detail
|
const detail = extraResultSettled.value.detail
|
||||||
@@ -1214,8 +1258,15 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '聊天页会话详情统计完成',
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载会话详情补充统计失败:', e)
|
console.error('加载会话详情补充统计失败:', e)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (requestSeq === detailRequestSeqRef.current) {
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
setIsLoadingDetailExtra(false)
|
setIsLoadingDetailExtra(false)
|
||||||
@@ -1228,13 +1279,31 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (!normalizedSessionId || isLoadingRelationStats) return
|
if (!normalizedSessionId || isLoadingRelationStats) return
|
||||||
|
|
||||||
const requestSeq = detailRequestSeqRef.current
|
const requestSeq = detailRequestSeqRef.current
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'chat',
|
||||||
|
title: '聊天页关系统计补算',
|
||||||
|
detail: `正在补算 ${normalizedSessionId} 的共同好友与关联数据`,
|
||||||
|
progressText: '关系统计',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoadingRelationStats(true)
|
setIsLoadingRelationStats(true)
|
||||||
try {
|
try {
|
||||||
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前关系统计查询结束后未继续刷新'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '会话已切换,旧关系统计任务已停止'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const metric = relationResult.success && relationResult.data
|
const metric = relationResult.success && relationResult.data
|
||||||
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
@@ -1254,11 +1323,26 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setIsRefreshingDetailStats(true)
|
setIsRefreshingDetailStats(true)
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在刷新关系统计结果',
|
||||||
|
progressText: '关系统计刷新'
|
||||||
|
})
|
||||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||||
)
|
)
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,刷新结果未继续写入'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '会话已切换,旧关系统计刷新任务已停止'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (freshResult.success && freshResult.data) {
|
if (freshResult.success && freshResult.data) {
|
||||||
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||||
@@ -1266,17 +1350,32 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
|
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '聊天页关系统计补算完成',
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('刷新会话关系统计失败:', error)
|
console.error('刷新会话关系统计失败:', error)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(error)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (requestSeq === detailRequestSeqRef.current) {
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
setIsRefreshingDetailStats(false)
|
setIsRefreshingDetailStats(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
} else {
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '聊天页关系统计补算完成',
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载会话关系统计失败:', error)
|
console.error('加载会话关系统计失败:', error)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(error)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (requestSeq === detailRequestSeqRef.current) {
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
setIsLoadingRelationStats(false)
|
setIsLoadingRelationStats(false)
|
||||||
@@ -2868,10 +2967,51 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setFilteredSessions([])
|
setFilteredSessions([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const visible = sessions.filter(s => {
|
|
||||||
|
// 检查是否有折叠的群聊
|
||||||
|
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||||
|
const hasFoldedGroups = foldedGroups.length > 0
|
||||||
|
|
||||||
|
let visible = sessions.filter(s => {
|
||||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 如果有折叠的群聊,但列表中没有入口,则插入入口
|
||||||
|
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
||||||
|
// 找到最新的折叠消息
|
||||||
|
const latestFolded = foldedGroups.reduce((latest, current) => {
|
||||||
|
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||||
|
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||||
|
return currentTime > latestTime ? current : latest
|
||||||
|
})
|
||||||
|
|
||||||
|
const foldEntry: ChatSession = {
|
||||||
|
username: 'placeholder_foldgroup',
|
||||||
|
displayName: '折叠的聊天',
|
||||||
|
summary: `${latestFolded.displayName || latestFolded.username}: ${latestFolded.summary}`,
|
||||||
|
type: 0,
|
||||||
|
sortTimestamp: latestFolded.sortTimestamp || latestFolded.lastTimestamp,
|
||||||
|
lastTimestamp: latestFolded.lastTimestamp || latestFolded.sortTimestamp,
|
||||||
|
lastMsgType: 0,
|
||||||
|
unreadCount: foldedGroups.reduce((sum, s) => sum + (s.unreadCount || 0), 0),
|
||||||
|
isMuted: false,
|
||||||
|
isFolded: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按时间戳插入到正确位置
|
||||||
|
const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp
|
||||||
|
const insertIndex = visible.findIndex(s => {
|
||||||
|
const sTime = s.sortTimestamp || s.lastTimestamp
|
||||||
|
return sTime < foldTime
|
||||||
|
})
|
||||||
|
if (insertIndex === -1) {
|
||||||
|
visible.push(foldEntry)
|
||||||
|
} else {
|
||||||
|
visible.splice(insertIndex, 0, foldEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
setFilteredSessions(visible)
|
setFilteredSessions(visible)
|
||||||
return
|
return
|
||||||
@@ -3225,7 +3365,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
const handleGroupAnalytics = useCallback(() => {
|
const handleGroupAnalytics = useCallback(() => {
|
||||||
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
|
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
|
||||||
navigate('/group-analytics', {
|
navigate('/analytics/group', {
|
||||||
state: {
|
state: {
|
||||||
preselectGroupIds: [currentSessionId]
|
preselectGroupIds: [currentSessionId]
|
||||||
}
|
}
|
||||||
@@ -3387,20 +3527,17 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 并发池:同时跑 concurrency 个任务
|
// 并发池:同时跑 concurrency 个任务
|
||||||
const pool: Promise<void>[] = []
|
const pool = new Set<Promise<void>>()
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
const p = decryptOne(img)
|
const p = decryptOne(img).then(() => { pool.delete(p) })
|
||||||
pool.push(p)
|
pool.add(p)
|
||||||
if (pool.length >= concurrency) {
|
if (pool.size >= concurrency) {
|
||||||
await Promise.race(pool)
|
await Promise.race(pool)
|
||||||
// 移除已完成的
|
|
||||||
for (let j = pool.length - 1; j >= 0; j--) {
|
|
||||||
const settled = await Promise.race([pool[j].then(() => true), Promise.resolve(false)])
|
|
||||||
if (settled) pool.splice(j, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (pool.size > 0) {
|
||||||
await Promise.all(pool)
|
await Promise.all(pool)
|
||||||
|
}
|
||||||
|
|
||||||
finishDecrypt(successCount, failCount)
|
finishDecrypt(successCount, failCount)
|
||||||
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
||||||
@@ -3759,12 +3896,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
||||||
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
{isSessionListSyncing && (
|
|
||||||
<div className="session-sync-indicator">
|
|
||||||
<Loader2 size={12} className="spin" />
|
|
||||||
<span>同步中</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 折叠群 header */}
|
{/* 折叠群 header */}
|
||||||
@@ -5375,8 +5506,9 @@ function MessageBubble({
|
|||||||
let finalImagePath = imageLocalPath
|
let finalImagePath = imageLocalPath
|
||||||
let finalLiveVideoPath = imageLiveVideoPath || undefined
|
let finalLiveVideoPath = imageLiveVideoPath || undefined
|
||||||
|
|
||||||
// If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer.
|
// Every explicit preview click re-runs the forced HD search/decrypt path so
|
||||||
if (imageHasUpdate) {
|
// users don't need to re-enter the session after WeChat materializes a new original image.
|
||||||
|
if (message.imageMd5 || message.imageDatName) {
|
||||||
try {
|
try {
|
||||||
const upgraded = await requestImageDecrypt(true, true)
|
const upgraded = await requestImageDecrypt(true, true)
|
||||||
if (upgraded?.success && upgraded.localPath) {
|
if (upgraded?.success && upgraded.localPath) {
|
||||||
@@ -5408,7 +5540,6 @@ function MessageBubble({
|
|||||||
|
|
||||||
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
|
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
|
||||||
}, [
|
}, [
|
||||||
imageHasUpdate,
|
|
||||||
imageLiveVideoPath,
|
imageLiveVideoPath,
|
||||||
imageLocalPath,
|
imageLocalPath,
|
||||||
imageCacheKey,
|
imageCacheKey,
|
||||||
|
|||||||
@@ -891,28 +891,6 @@ function ContactsPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="contacts-count">
|
|
||||||
共 {filteredContacts.length} / {contacts.length} 个联系人
|
|
||||||
{contactsUpdatedAt && (
|
|
||||||
<span className="contacts-cache-meta">
|
|
||||||
{contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{contacts.length > 0 && (
|
|
||||||
<span className="contacts-cache-meta">
|
|
||||||
头像缓存 {avatarCachedCount}/{contacts.length}
|
|
||||||
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isLoading && contacts.length > 0 && (
|
|
||||||
<span className="contacts-cache-meta syncing">后台同步中...</span>
|
|
||||||
)}
|
|
||||||
{avatarEnrichProgress.running && (
|
|
||||||
<span className="avatar-enrich-progress">
|
|
||||||
头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{exportMode && (
|
{exportMode && (
|
||||||
<div className="selection-toolbar">
|
<div className="selection-toolbar">
|
||||||
|
|||||||
@@ -254,6 +254,168 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-load-detail-summary {
|
||||||
|
padding: 12px 12px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-summary-text {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-note {
|
||||||
|
margin: 8px 12px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-stop-btn,
|
||||||
|
.session-load-detail-task-stop-btn {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary));
|
||||||
|
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-stop-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-task-list {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-task-item {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&.status-cancel_requested {
|
||||||
|
border-color: color-mix(in srgb, var(--warning, #f59e0b) 36%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-failed {
|
||||||
|
border-color: color-mix(in srgb, var(--danger, #ef4444) 36%, var(--border-color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-task-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-task-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-task-source {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-task-status {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&.status-running {
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-cancel_requested {
|
||||||
|
color: #b45309;
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-completed {
|
||||||
|
color: #166534;
|
||||||
|
background: rgba(34, 197, 94, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-failed {
|
||||||
|
color: #b91c1c;
|
||||||
|
background: rgba(239, 68, 68, 0.14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-task-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-task-stop-btn {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-load-detail-empty {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.session-load-detail-table {
|
.session-load-detail-table {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -744,6 +906,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -765,6 +928,7 @@
|
|||||||
.layout-option-label {
|
.layout-option-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-option-desc {
|
.layout-option-desc {
|
||||||
@@ -1399,15 +1563,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-table-section {
|
.session-table-section {
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
padding: 12px;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,20 +1617,31 @@
|
|||||||
.table-tabs {
|
.table-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: auto;
|
||||||
|
max-width: max-content;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 7px 12px;
|
padding: 7px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.tab-btn-content {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
@@ -1547,14 +1717,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
|
--contacts-native-scrollbar-compensation: 18px;
|
||||||
--contacts-row-height: 76px;
|
--contacts-row-height: 76px;
|
||||||
--contacts-default-visible-rows: 10;
|
--contacts-default-visible-rows: 10;
|
||||||
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
|
--contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows));
|
||||||
--contacts-select-col-width: 34px;
|
--contacts-select-col-width: 34px;
|
||||||
|
--contacts-avatar-col-width: 44px;
|
||||||
--contacts-inline-padding: 12px;
|
--contacts-inline-padding: 12px;
|
||||||
|
--contacts-column-gap: 12px;
|
||||||
|
--contacts-name-text-width: 10em;
|
||||||
|
--contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width));
|
||||||
|
--contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap));
|
||||||
--contacts-message-col-width: 120px;
|
--contacts-message-col-width: 120px;
|
||||||
--contacts-media-col-width: 72px;
|
--contacts-media-col-width: 72px;
|
||||||
--contacts-action-col-width: 140px;
|
--contacts-action-col-width: 140px;
|
||||||
|
--contacts-table-min-width: 1200px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -1563,24 +1740,51 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
|
.table-scroll-shell {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-viewport {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding-bottom: var(--contacts-native-scrollbar-compensation);
|
||||||
|
margin-bottom: calc(-1 * var(--contacts-native-scrollbar-compensation));
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll-content {
|
||||||
|
min-width: max(100%, var(--contacts-table-min-width));
|
||||||
|
}
|
||||||
|
|
||||||
.session-table-sticky {
|
.session-table-sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
background: var(--card-bg);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state,
|
.loading-state,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
width: 100%;
|
||||||
|
min-width: max(100%, var(--contacts-table-min-width));
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
@@ -1590,14 +1794,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.load-issue-state {
|
.load-issue-state {
|
||||||
|
width: 100%;
|
||||||
|
min-width: max(100%, var(--contacts-table-min-width));
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.issue-card {
|
.issue-card {
|
||||||
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
|
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
|
||||||
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
|
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1671,7 +1878,7 @@
|
|||||||
.issue-diagnostics {
|
.issue-diagnostics {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--bg-primary);
|
background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary));
|
||||||
border: 1px dashed var(--border-color);
|
border: 1px dashed var(--border-color);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1682,17 +1889,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.contacts-list-header {
|
.contacts-list-header {
|
||||||
|
--contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--contacts-column-gap);
|
||||||
padding: 10px var(--contacts-inline-padding) 8px;
|
padding: 10px var(--contacts-inline-padding) 8px;
|
||||||
|
min-width: max(100%, var(--contacts-table-min-width));
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
|
||||||
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
|
background: var(--contacts-header-bg);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.is-draggable {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-list-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--contacts-column-gap);
|
||||||
|
width: var(--contacts-left-sticky-width);
|
||||||
|
min-width: var(--contacts-left-sticky-width);
|
||||||
|
max-width: var(--contacts-left-sticky-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contacts-list-header-select {
|
.contacts-list-header-select {
|
||||||
@@ -1705,8 +1932,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.contacts-list-header-main {
|
.contacts-list-header-main {
|
||||||
flex: 1;
|
flex: 0 0 var(--contacts-main-col-width);
|
||||||
min-width: 0;
|
width: var(--contacts-main-col-width);
|
||||||
|
min-width: var(--contacts-main-col-width);
|
||||||
|
max-width: var(--contacts-main-col-width);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1721,21 +1950,30 @@
|
|||||||
|
|
||||||
.contacts-list-header-count {
|
.contacts-list-header-count {
|
||||||
width: var(--contacts-message-col-width);
|
width: var(--contacts-message-col-width);
|
||||||
|
min-width: var(--contacts-message-col-width);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contacts-list-header-media {
|
.contacts-list-header-media {
|
||||||
width: var(--contacts-media-col-width);
|
width: var(--contacts-media-col-width);
|
||||||
min-width: var(--contacts-media-col-width);
|
min-width: var(--contacts-media-col-width);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contacts-list-header-actions {
|
.contacts-list-header-actions {
|
||||||
@@ -1749,8 +1987,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 8;
|
z-index: 13;
|
||||||
background: var(--bg-primary);
|
background: var(--contacts-header-bg);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@@ -1761,30 +1999,70 @@
|
|||||||
left: -8px;
|
left: -8px;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: linear-gradient(to right, transparent, var(--bg-primary));
|
background: linear-gradient(to right, transparent, var(--contacts-header-bg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contacts-list {
|
.contacts-list {
|
||||||
|
width: 100%;
|
||||||
|
min-width: max(100%, var(--contacts-table-min-width));
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: var(--contacts-default-list-height);
|
min-height: var(--contacts-default-list-height);
|
||||||
height: var(--contacts-default-list-height);
|
height: var(--contacts-default-list-height);
|
||||||
overflow: hidden;
|
position: relative;
|
||||||
|
overflow-x: clip;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding: 0 0 12px;
|
padding: 0 0 12px;
|
||||||
}
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
.contacts-virtuoso {
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-track {
|
||||||
background: var(--text-tertiary);
|
background: transparent;
|
||||||
border-radius: 3px;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--text-tertiary) 72%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-virtuoso {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bottom-scrollbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--text-tertiary) 70%, transparent) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bottom-scrollbar-inner {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-bottom-scrollbar {
|
||||||
|
height: 16px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-clear-btn {
|
.selection-clear-btn {
|
||||||
@@ -1848,30 +2126,50 @@
|
|||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
|
|
||||||
&.selected .contact-item {
|
&.selected .contact-item {
|
||||||
background: rgba(var(--primary-rgb), 0.08);
|
background: var(--contacts-row-bg);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 52%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected .row-action-cell {
|
&.selected .contact-item:hover {
|
||||||
background: rgba(var(--primary-rgb), 0.08);
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-item {
|
.contact-item {
|
||||||
|
--contacts-row-bg: var(--bg-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--contacts-column-gap);
|
||||||
padding: 12px var(--contacts-inline-padding);
|
padding: 12px 6px 12px var(--contacts-inline-padding);
|
||||||
|
min-width: max(100%, var(--contacts-table-min-width));
|
||||||
height: 72px;
|
height: 72px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
background: var(--contacts-row-bg);
|
||||||
|
box-shadow: inset 0 0 0 1px transparent;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--contacts-row-bg);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-left-sticky {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 11;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: var(--contacts-column-gap);
|
||||||
|
width: var(--contacts-left-sticky-width);
|
||||||
|
min-width: var(--contacts-left-sticky-width);
|
||||||
|
max-width: var(--contacts-left-sticky-width);
|
||||||
|
background: var(--contacts-row-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.row-select-cell {
|
.row-select-cell {
|
||||||
width: var(--contacts-select-col-width);
|
width: var(--contacts-select-col-width);
|
||||||
min-width: var(--contacts-select-col-width);
|
min-width: var(--contacts-select-col-width);
|
||||||
@@ -1905,8 +2203,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.contact-info {
|
.contact-info {
|
||||||
flex: 1;
|
flex: 0 0 var(--contacts-name-text-width);
|
||||||
min-width: 0;
|
width: var(--contacts-name-text-width);
|
||||||
|
min-width: var(--contacts-name-text-width);
|
||||||
|
max-width: var(--contacts-name-text-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-name {
|
.contact-name {
|
||||||
@@ -1949,6 +2249,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-media-metric {
|
.row-media-metric {
|
||||||
@@ -1959,6 +2260,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-media-metric-value {
|
.row-media-metric-value {
|
||||||
@@ -1982,6 +2284,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
min-height: 14px;
|
min-height: 14px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2104,11 +2407,12 @@
|
|||||||
min-width: 1300px;
|
min-width: 1300px;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
thead th {
|
thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: color-mix(in srgb, var(--bg-primary) 75%, var(--bg-secondary));
|
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -2231,24 +2535,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: stretch;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: var(--contacts-action-col-width);
|
width: var(--contacts-action-col-width);
|
||||||
|
min-width: var(--contacts-action-col-width);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 6;
|
z-index: 10;
|
||||||
background: var(--bg-primary);
|
background: var(--contacts-row-bg);
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -12px;
|
|
||||||
bottom: -12px;
|
|
||||||
left: -8px;
|
|
||||||
width: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(to right, transparent, var(--bg-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-action-main {
|
.row-action-main {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -3929,6 +4225,8 @@
|
|||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
--contacts-inline-padding: 10px;
|
--contacts-inline-padding: 10px;
|
||||||
|
--contacts-name-text-width: 10em;
|
||||||
|
--contacts-main-col-width: calc(44px + 10px + var(--contacts-name-text-width));
|
||||||
--contacts-message-col-width: 104px;
|
--contacts-message-col-width: 104px;
|
||||||
--contacts-media-col-width: 62px;
|
--contacts-media-col-width: 62px;
|
||||||
--contacts-action-col-width: 140px;
|
--contacts-action-col-width: 140px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
|
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
|
||||||
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
|
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
|
||||||
|
import type { BackgroundTaskRecord } from '../types/backgroundTask'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import {
|
import {
|
||||||
emitExportSessionStatus,
|
emitExportSessionStatus,
|
||||||
@@ -37,6 +38,11 @@ import {
|
|||||||
onExportSessionStatusRequest,
|
onExportSessionStatusRequest,
|
||||||
onOpenSingleExport
|
onOpenSingleExport
|
||||||
} from '../services/exportBridge'
|
} from '../services/exportBridge'
|
||||||
|
import {
|
||||||
|
requestCancelBackgroundTask,
|
||||||
|
requestCancelBackgroundTasks,
|
||||||
|
subscribeBackgroundTasks
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
@@ -176,6 +182,24 @@ const contentTypeLabels: Record<ContentType, string> = {
|
|||||||
emoji: '表情包'
|
emoji: '表情包'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backgroundTaskSourceLabels: Record<string, string> = {
|
||||||
|
export: '导出页',
|
||||||
|
chat: '聊天页',
|
||||||
|
analytics: '分析页',
|
||||||
|
sns: '朋友圈页',
|
||||||
|
groupAnalytics: '群分析页',
|
||||||
|
annualReport: '年度报告',
|
||||||
|
other: '其他页面'
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
|
||||||
|
running: '运行中',
|
||||||
|
cancel_requested: '停止中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
canceled: '已停止'
|
||||||
|
}
|
||||||
|
|
||||||
const conversationTabLabels: Record<ConversationTab, string> = {
|
const conversationTabLabels: Record<ConversationTab, string> = {
|
||||||
private: '私聊',
|
private: '私聊',
|
||||||
group: '群聊',
|
group: '群聊',
|
||||||
@@ -1422,6 +1446,7 @@ function ExportPage() {
|
|||||||
const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState<Record<string, SessionMutualFriendsMetric>>({})
|
const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState<Record<string, SessionMutualFriendsMetric>>({})
|
||||||
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
||||||
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
|
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
|
||||||
|
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
|
||||||
|
|
||||||
const [exportFolder, setExportFolder] = useState('')
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
||||||
@@ -1487,6 +1512,12 @@ function ExportPage() {
|
|||||||
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
|
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
|
||||||
const [nowTick, setNowTick] = useState(Date.now())
|
const [nowTick, setNowTick] = useState(Date.now())
|
||||||
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
|
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
|
||||||
|
const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false)
|
||||||
|
const [contactsListScrollParent, setContactsListScrollParent] = useState<HTMLDivElement | null>(null)
|
||||||
|
const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({
|
||||||
|
viewportWidth: 0,
|
||||||
|
contentWidth: 0
|
||||||
|
})
|
||||||
const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
|
const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
|
||||||
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
|
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
|
||||||
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
|
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
|
||||||
@@ -1508,6 +1539,16 @@ function ExportPage() {
|
|||||||
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
|
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
|
||||||
const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null)
|
const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null)
|
||||||
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
|
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const contactsHorizontalViewportRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const contactsHorizontalContentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const contactsBottomScrollbarRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const contactsScrollSyncSourceRef = useRef<'viewport' | 'bottom' | null>(null)
|
||||||
|
const contactsHeaderDragStateRef = useRef({
|
||||||
|
pointerId: -1,
|
||||||
|
startClientX: 0,
|
||||||
|
startScrollLeft: 0,
|
||||||
|
didDrag: false
|
||||||
|
})
|
||||||
const sessionFormatDropdownRef = useRef<HTMLDivElement | null>(null)
|
const sessionFormatDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
const detailRequestSeqRef = useRef(0)
|
const detailRequestSeqRef = useRef(0)
|
||||||
const sessionsRef = useRef<SessionRow[]>([])
|
const sessionsRef = useRef<SessionRow[]>([])
|
||||||
@@ -1560,6 +1601,10 @@ function ExportPage() {
|
|||||||
endIndex: -1
|
endIndex: -1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleContactsListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
setContactsListScrollParent(prev => (prev === node ? prev : node))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
||||||
if (exportCacheScopeReadyRef.current) {
|
if (exportCacheScopeReadyRef.current) {
|
||||||
return exportCacheScopeRef.current
|
return exportCacheScopeRef.current
|
||||||
@@ -1903,6 +1948,10 @@ function ExportPage() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [contactsList.length, isContactsListLoading, contactsLoadIssue])
|
}, [contactsList.length, isContactsListLoading, contactsLoadIssue])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeBackgroundTasks(setBackgroundTasks)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tasksRef.current = tasks
|
tasksRef.current = tasks
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
@@ -3843,11 +3892,9 @@ function ExportPage() {
|
|||||||
|
|
||||||
if (scope === 'content' && contentType) {
|
if (scope === 'content' && contentType) {
|
||||||
if (contentType === 'text') {
|
if (contentType === 'text') {
|
||||||
const fastTextFormat: TextExportFormat = options.format === 'excel' ? 'arkme-json' : options.format
|
|
||||||
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency))
|
const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency))
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
format: fastTextFormat,
|
|
||||||
contentType,
|
contentType,
|
||||||
exportConcurrency: textExportConcurrency,
|
exportConcurrency: textExportConcurrency,
|
||||||
exportAvatars: base.exportAvatars,
|
exportAvatars: base.exportAvatars,
|
||||||
@@ -5491,6 +5538,16 @@ function ExportPage() {
|
|||||||
alert('复制失败,请手动复制诊断信息')
|
alert('复制失败,请手动复制诊断信息')
|
||||||
}
|
}
|
||||||
}, [contactsDiagnosticsText])
|
}, [contactsDiagnosticsText])
|
||||||
|
const handleCancelBackgroundTask = useCallback((taskId: string) => {
|
||||||
|
requestCancelBackgroundTask(taskId)
|
||||||
|
}, [])
|
||||||
|
const handleCancelAllNonExportTasks = useCallback(() => {
|
||||||
|
requestCancelBackgroundTasks(task => (
|
||||||
|
task.sourcePage !== 'export' &&
|
||||||
|
task.cancelable &&
|
||||||
|
(task.status === 'running' || task.status === 'cancel_requested')
|
||||||
|
))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const sessionContactsUpdatedAtLabel = useMemo(() => {
|
const sessionContactsUpdatedAtLabel = useMemo(() => {
|
||||||
if (!sessionContactsUpdatedAt) return ''
|
if (!sessionContactsUpdatedAt) return ''
|
||||||
@@ -5563,6 +5620,36 @@ function ExportPage() {
|
|||||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
||||||
const hasFilteredContacts = filteredContacts.length > 0
|
const hasFilteredContacts = filteredContacts.length > 0
|
||||||
|
const contactsTableMinWidth = useMemo(() => {
|
||||||
|
const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12)
|
||||||
|
const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0
|
||||||
|
const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0
|
||||||
|
return baseWidth + snsWidth + mutualFriendsWidth
|
||||||
|
}, [shouldShowMutualFriendsColumn, shouldShowSnsColumn])
|
||||||
|
const contactsTableStyle = useMemo(() => (
|
||||||
|
{
|
||||||
|
['--contacts-table-min-width' as const]: `${contactsTableMinWidth}px`
|
||||||
|
} as CSSProperties
|
||||||
|
), [contactsTableMinWidth])
|
||||||
|
const hasContactsHorizontalOverflow = contactsHorizontalScrollMetrics.contentWidth - contactsHorizontalScrollMetrics.viewportWidth > 1
|
||||||
|
const contactsBottomScrollbarInnerStyle = useMemo<CSSProperties>(() => ({
|
||||||
|
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
|
||||||
|
}), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
|
||||||
|
const nonExportBackgroundTasks = useMemo(() => (
|
||||||
|
backgroundTasks.filter(task => task.sourcePage !== 'export')
|
||||||
|
), [backgroundTasks])
|
||||||
|
const runningNonExportTaskCount = useMemo(() => (
|
||||||
|
nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length
|
||||||
|
), [nonExportBackgroundTasks])
|
||||||
|
const cancelableNonExportTaskCount = useMemo(() => (
|
||||||
|
nonExportBackgroundTasks.filter(task => (
|
||||||
|
task.cancelable &&
|
||||||
|
(task.status === 'running' || task.status === 'cancel_requested')
|
||||||
|
)).length
|
||||||
|
), [nonExportBackgroundTasks])
|
||||||
|
const nonExportBackgroundTasksUpdatedAt = useMemo(() => (
|
||||||
|
nonExportBackgroundTasks.reduce((latest, task) => Math.max(latest, task.updatedAt || 0), 0)
|
||||||
|
), [nonExportBackgroundTasks])
|
||||||
const sessionLoadDetailUpdatedAt = useMemo(() => {
|
const sessionLoadDetailUpdatedAt = useMemo(() => {
|
||||||
let latest = 0
|
let latest = 0
|
||||||
for (const row of sessionLoadDetailRows) {
|
for (const row of sessionLoadDetailRows) {
|
||||||
@@ -5588,6 +5675,136 @@ function ExportPage() {
|
|||||||
row.mutualFriends.statusLabel.startsWith('加载中')
|
row.mutualFriends.statusLabel.startsWith('加载中')
|
||||||
))
|
))
|
||||||
), [sessionLoadDetailRows])
|
), [sessionLoadDetailRows])
|
||||||
|
const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'bottom', scrollLeft: number) => {
|
||||||
|
if (contactsScrollSyncSourceRef.current && contactsScrollSyncSourceRef.current !== source) return
|
||||||
|
|
||||||
|
contactsScrollSyncSourceRef.current = source
|
||||||
|
const viewport = contactsHorizontalViewportRef.current
|
||||||
|
const bottomScrollbar = contactsBottomScrollbarRef.current
|
||||||
|
|
||||||
|
if (source !== 'viewport' && viewport && Math.abs(viewport.scrollLeft - scrollLeft) > 1) {
|
||||||
|
viewport.scrollLeft = scrollLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source !== 'bottom' && bottomScrollbar && Math.abs(bottomScrollbar.scrollLeft - scrollLeft) > 1) {
|
||||||
|
bottomScrollbar.scrollLeft = scrollLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (contactsScrollSyncSourceRef.current === source) {
|
||||||
|
contactsScrollSyncSourceRef.current = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
const handleContactsHorizontalViewportScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||||
|
syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft)
|
||||||
|
}, [syncContactsHorizontalScroll])
|
||||||
|
const handleContactsBottomScrollbarScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||||
|
syncContactsHorizontalScroll('bottom', event.currentTarget.scrollLeft)
|
||||||
|
}, [syncContactsHorizontalScroll])
|
||||||
|
const resetContactsHeaderDrag = useCallback((currentTarget?: HTMLDivElement | null) => {
|
||||||
|
const dragState = contactsHeaderDragStateRef.current
|
||||||
|
if (currentTarget && dragState.pointerId >= 0 && currentTarget.hasPointerCapture(dragState.pointerId)) {
|
||||||
|
currentTarget.releasePointerCapture(dragState.pointerId)
|
||||||
|
}
|
||||||
|
dragState.pointerId = -1
|
||||||
|
dragState.startClientX = 0
|
||||||
|
dragState.startScrollLeft = 0
|
||||||
|
dragState.didDrag = false
|
||||||
|
setIsContactsHeaderDragging(false)
|
||||||
|
}, [])
|
||||||
|
const handleContactsHeaderPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!hasContactsHorizontalOverflow || event.pointerType === 'touch') return
|
||||||
|
if (event.button !== 0) return
|
||||||
|
if (event.target instanceof Element && event.target.closest('button, a, input, textarea, select, label, [role="button"]')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contactsHeaderDragStateRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startClientX: event.clientX,
|
||||||
|
startScrollLeft: contactsHorizontalViewportRef.current?.scrollLeft ?? 0,
|
||||||
|
didDrag: false
|
||||||
|
}
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
setIsContactsHeaderDragging(true)
|
||||||
|
}, [hasContactsHorizontalOverflow])
|
||||||
|
const handleContactsHeaderPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
const dragState = contactsHeaderDragStateRef.current
|
||||||
|
if (dragState.pointerId !== event.pointerId) return
|
||||||
|
|
||||||
|
const viewport = contactsHorizontalViewportRef.current
|
||||||
|
const content = contactsHorizontalContentRef.current
|
||||||
|
if (!viewport || !content) return
|
||||||
|
|
||||||
|
const deltaX = event.clientX - dragState.startClientX
|
||||||
|
if (!dragState.didDrag && Math.abs(deltaX) < 4) return
|
||||||
|
|
||||||
|
dragState.didDrag = true
|
||||||
|
const maxScrollLeft = Math.max(0, content.scrollWidth - viewport.clientWidth)
|
||||||
|
const nextScrollLeft = Math.max(0, Math.min(dragState.startScrollLeft - deltaX, maxScrollLeft))
|
||||||
|
|
||||||
|
viewport.scrollLeft = nextScrollLeft
|
||||||
|
syncContactsHorizontalScroll('viewport', nextScrollLeft)
|
||||||
|
event.preventDefault()
|
||||||
|
}, [syncContactsHorizontalScroll])
|
||||||
|
const handleContactsHeaderPointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return
|
||||||
|
resetContactsHeaderDrag(event.currentTarget)
|
||||||
|
}, [resetContactsHeaderDrag])
|
||||||
|
const handleContactsHeaderPointerCancel = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return
|
||||||
|
resetContactsHeaderDrag(event.currentTarget)
|
||||||
|
}, [resetContactsHeaderDrag])
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = contactsHorizontalViewportRef.current
|
||||||
|
const content = contactsHorizontalContentRef.current
|
||||||
|
if (!viewport || !content) return
|
||||||
|
|
||||||
|
const syncMetrics = () => {
|
||||||
|
const viewportWidth = Math.round(viewport.clientWidth)
|
||||||
|
const contentWidth = Math.round(content.scrollWidth)
|
||||||
|
|
||||||
|
setContactsHorizontalScrollMetrics((prev) => (
|
||||||
|
prev.viewportWidth === viewportWidth && prev.contentWidth === contentWidth
|
||||||
|
? prev
|
||||||
|
: { viewportWidth, contentWidth }
|
||||||
|
))
|
||||||
|
|
||||||
|
const maxScrollLeft = Math.max(0, contentWidth - viewportWidth)
|
||||||
|
const clampedScrollLeft = Math.min(viewport.scrollLeft, maxScrollLeft)
|
||||||
|
|
||||||
|
if (Math.abs(viewport.scrollLeft - clampedScrollLeft) > 1) {
|
||||||
|
viewport.scrollLeft = clampedScrollLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottomScrollbar = contactsBottomScrollbarRef.current
|
||||||
|
if (bottomScrollbar) {
|
||||||
|
const nextScrollLeft = Math.min(bottomScrollbar.scrollLeft, maxScrollLeft)
|
||||||
|
if (Math.abs(bottomScrollbar.scrollLeft - nextScrollLeft) > 1) {
|
||||||
|
bottomScrollbar.scrollLeft = nextScrollLeft
|
||||||
|
}
|
||||||
|
if (Math.abs(nextScrollLeft - clampedScrollLeft) > 1) {
|
||||||
|
bottomScrollbar.scrollLeft = clampedScrollLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncMetrics()
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', syncMetrics)
|
||||||
|
return () => window.removeEventListener('resize', syncMetrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(syncMetrics)
|
||||||
|
resizeObserver.observe(viewport)
|
||||||
|
resizeObserver.observe(content)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
const closeTaskCenter = useCallback(() => {
|
const closeTaskCenter = useCallback(() => {
|
||||||
setIsTaskCenterOpen(false)
|
setIsTaskCenterOpen(false)
|
||||||
setExpandedPerfTaskId(null)
|
setExpandedPerfTaskId(null)
|
||||||
@@ -5664,6 +5881,7 @@ function ExportPage() {
|
|||||||
return (
|
return (
|
||||||
<div className={`contact-row ${checked ? 'selected' : ''}`}>
|
<div className={`contact-row ${checked ? 'selected' : ''}`}>
|
||||||
<div className="contact-item">
|
<div className="contact-item">
|
||||||
|
<div className="row-left-sticky">
|
||||||
<div className="row-select-cell">
|
<div className="row-select-cell">
|
||||||
<button
|
<button
|
||||||
className={`select-icon-btn ${checked ? 'checked' : ''}`}
|
className={`select-icon-btn ${checked ? 'checked' : ''}`}
|
||||||
@@ -5686,6 +5904,7 @@ function ExportPage() {
|
|||||||
<div className="contact-name">{contact.displayName}</div>
|
<div className="contact-name">{contact.displayName}</div>
|
||||||
<div className="contact-remark">{contact.alias || contact.username}</div>
|
<div className="contact-remark">{contact.alias || contact.username}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="row-message-count">
|
<div className="row-message-count">
|
||||||
<div className="row-message-stats">
|
<div className="row-message-stats">
|
||||||
<strong className={`row-message-count-value ${messageCountState.state === 'value' ? '' : 'muted'}`}>
|
<strong className={`row-message-count-value ${messageCountState.state === 'value' ? '' : 'muted'}`}>
|
||||||
@@ -5796,7 +6015,7 @@ function ExportPage() {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'}
|
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'}
|
||||||
</button>
|
</button>
|
||||||
{hasRecentExport && <span className="row-export-time">{recentExportTime}</span>}
|
{hasRecentExport && <span className="row-export-time">{recentExportTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -6115,18 +6334,26 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="session-table-section" ref={sessionTableSectionRef}>
|
<div className="session-table-section" ref={sessionTableSectionRef}>
|
||||||
<div className="session-table-layout">
|
<div className="session-table-layout">
|
||||||
<div className="table-wrap">
|
<div className="table-wrap" style={contactsTableStyle}>
|
||||||
<div className="session-table-sticky">
|
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<div className="table-tabs" role="tablist" aria-label="会话类型">
|
<div className="table-tabs" role="tablist" aria-label="会话类型">
|
||||||
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
|
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
|
||||||
私聊 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private}
|
<span className="tab-btn-content">
|
||||||
|
<span>私聊</span>
|
||||||
|
<span>{isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
|
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
|
||||||
群聊 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}
|
<span className="tab-btn-content">
|
||||||
|
<span>群聊</span>
|
||||||
|
<span>{isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
|
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
|
||||||
曾经的好友 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}
|
<span className="tab-btn-content">
|
||||||
|
<span>曾经的好友</span>
|
||||||
|
<span>{isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -6151,6 +6378,14 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="table-scroll-shell">
|
||||||
|
<div
|
||||||
|
ref={contactsHorizontalViewportRef}
|
||||||
|
className="table-scroll-viewport"
|
||||||
|
onScroll={handleContactsHorizontalViewportScroll}
|
||||||
|
>
|
||||||
|
<div ref={contactsHorizontalContentRef} className="table-scroll-content">
|
||||||
|
<div className="session-table-sticky">
|
||||||
{contactsList.length > 0 && isContactsListLoading && (
|
{contactsList.length > 0 && isContactsListLoading && (
|
||||||
<div className="table-stage-hint">
|
<div className="table-stage-hint">
|
||||||
<Loader2 size={14} className="spin" />
|
<Loader2 size={14} className="spin" />
|
||||||
@@ -6159,7 +6394,14 @@ function ExportPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasFilteredContacts && (
|
{hasFilteredContacts && (
|
||||||
<div className="contacts-list-header">
|
<div
|
||||||
|
className={`contacts-list-header ${hasContactsHorizontalOverflow ? 'is-draggable' : ''} ${isContactsHeaderDragging ? 'is-dragging' : ''}`}
|
||||||
|
onPointerDown={handleContactsHeaderPointerDown}
|
||||||
|
onPointerMove={handleContactsHeaderPointerMove}
|
||||||
|
onPointerUp={handleContactsHeaderPointerUp}
|
||||||
|
onPointerCancel={handleContactsHeaderPointerCancel}
|
||||||
|
>
|
||||||
|
<span className="contacts-list-header-left">
|
||||||
<span className="contacts-list-header-select">
|
<span className="contacts-list-header-select">
|
||||||
<button
|
<button
|
||||||
className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`}
|
className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`}
|
||||||
@@ -6174,6 +6416,7 @@ function ExportPage() {
|
|||||||
<span className="contacts-list-header-main">
|
<span className="contacts-list-header-main">
|
||||||
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
|
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
<span className="contacts-list-header-count">总消息数</span>
|
<span className="contacts-list-header-count">总消息数</span>
|
||||||
<span className="contacts-list-header-media">表情包</span>
|
<span className="contacts-list-header-media">表情包</span>
|
||||||
<span className="contacts-list-header-media">语音</span>
|
<span className="contacts-list-header-media">语音</span>
|
||||||
@@ -6254,13 +6497,16 @@ function ExportPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="contacts-list"
|
className="contacts-list"
|
||||||
|
ref={handleContactsListScrollParentRef}
|
||||||
onWheelCapture={handleContactsListWheelCapture}
|
onWheelCapture={handleContactsListWheelCapture}
|
||||||
>
|
>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
ref={contactsVirtuosoRef}
|
ref={contactsVirtuosoRef}
|
||||||
className="contacts-virtuoso"
|
className="contacts-virtuoso"
|
||||||
|
customScrollParent={contactsListScrollParent ?? undefined}
|
||||||
data={filteredContacts}
|
data={filteredContacts}
|
||||||
computeItemKey={(_, contact) => contact.username}
|
computeItemKey={(_, contact) => contact.username}
|
||||||
|
fixedItemHeight={76}
|
||||||
itemContent={renderContactRow}
|
itemContent={renderContactRow}
|
||||||
rangeChanged={handleContactsRangeChanged}
|
rangeChanged={handleContactsRangeChanged}
|
||||||
atTopStateChange={setIsContactsListAtTop}
|
atTopStateChange={setIsContactsListAtTop}
|
||||||
@@ -6269,6 +6515,20 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFilteredContacts && hasContactsHorizontalOverflow && (
|
||||||
|
<div
|
||||||
|
ref={contactsBottomScrollbarRef}
|
||||||
|
className="table-bottom-scrollbar"
|
||||||
|
onScroll={handleContactsBottomScrollbarScroll}
|
||||||
|
aria-label="会话列表横向滚动条"
|
||||||
|
>
|
||||||
|
<div className="table-bottom-scrollbar-inner" style={contactsBottomScrollbarInnerStyle} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{showSessionLoadDetailModal && (
|
{showSessionLoadDetailModal && (
|
||||||
<div
|
<div
|
||||||
@@ -6303,6 +6563,67 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="session-load-detail-body">
|
<div className="session-load-detail-body">
|
||||||
|
<section className="session-load-detail-block">
|
||||||
|
<h5>其他页面后台任务</h5>
|
||||||
|
<div className="session-load-detail-summary">
|
||||||
|
<div className="session-load-detail-summary-text">
|
||||||
|
<strong>{runningNonExportTaskCount}</strong>
|
||||||
|
<span>个任务正在占用后台读取资源</span>
|
||||||
|
{nonExportBackgroundTasksUpdatedAt > 0 && (
|
||||||
|
<em>最近更新时间 {new Date(nonExportBackgroundTasksUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false })}</em>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="session-load-detail-stop-btn"
|
||||||
|
onClick={handleCancelAllNonExportTasks}
|
||||||
|
disabled={cancelableNonExportTaskCount === 0}
|
||||||
|
>
|
||||||
|
中断其他页面加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="session-load-detail-note">
|
||||||
|
停止请求会阻止其他页面继续发起后续统计或补算;当前已经发出的单次查询,会在返回后结束。
|
||||||
|
</p>
|
||||||
|
{nonExportBackgroundTasks.length > 0 ? (
|
||||||
|
<div className="session-load-detail-task-list">
|
||||||
|
{nonExportBackgroundTasks.map((task) => (
|
||||||
|
<div key={task.id} className={`session-load-detail-task-item status-${task.status}`}>
|
||||||
|
<div className="session-load-detail-task-main">
|
||||||
|
<div className="session-load-detail-task-title-row">
|
||||||
|
<span className="session-load-detail-task-source">
|
||||||
|
{backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other}
|
||||||
|
</span>
|
||||||
|
<strong>{task.title}</strong>
|
||||||
|
<span className={`session-load-detail-task-status status-${task.status}`}>
|
||||||
|
{backgroundTaskStatusLabels[task.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p>{task.detail || '暂无详细说明'}</p>
|
||||||
|
<div className="session-load-detail-task-meta">
|
||||||
|
<span>开始:{formatLoadDetailTime(task.startedAt)}</span>
|
||||||
|
<span>更新:{formatLoadDetailTime(task.updatedAt)}</span>
|
||||||
|
{task.progressText && <span>进度:{task.progressText}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="session-load-detail-task-stop-btn"
|
||||||
|
onClick={() => handleCancelBackgroundTask(task.id)}
|
||||||
|
disabled={!task.cancelable || (task.status !== 'running' && task.status !== 'cancel_requested')}
|
||||||
|
>
|
||||||
|
停止
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="session-load-detail-empty">
|
||||||
|
当前没有检测到其他页面后台任务
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="session-load-detail-block">
|
<section className="session-load-detail-block">
|
||||||
<h5>总消息数</h5>
|
<h5>总消息数</h5>
|
||||||
<div className="session-load-detail-table">
|
<div className="session-load-detail-table">
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
.group-analytics-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.group-analytics-page {
|
.group-analytics-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
&.standalone {
|
&.standalone {
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, C
|
|||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import DateRangePicker from '../components/DateRangePicker'
|
import DateRangePicker from '../components/DateRangePicker'
|
||||||
|
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './GroupAnalyticsPage.scss'
|
import './GroupAnalyticsPage.scss'
|
||||||
|
|
||||||
interface GroupChatInfo {
|
interface GroupChatInfo {
|
||||||
@@ -176,15 +183,39 @@ function GroupAnalyticsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadGroups = useCallback(async () => {
|
const loadGroups = useCallback(async () => {
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'groupAnalytics',
|
||||||
|
title: '群列表加载',
|
||||||
|
detail: '正在读取群聊列表',
|
||||||
|
progressText: '群聊列表',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,群聊列表结果未继续写入'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setGroups(result.data)
|
setGroups(result.data)
|
||||||
setFilteredGroups(result.data)
|
setFilteredGroups(result.data)
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: `群聊列表加载完成,共 ${result.data.length} 个群`,
|
||||||
|
progressText: `${result.data.length} 个群`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: result.error || '加载群聊列表失败'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -314,6 +345,13 @@ function GroupAnalyticsPage() {
|
|||||||
|
|
||||||
const loadFunctionData = async (func: AnalysisFunction) => {
|
const loadFunctionData = async (func: AnalysisFunction) => {
|
||||||
if (!selectedGroup) return
|
if (!selectedGroup) return
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'groupAnalytics',
|
||||||
|
title: `群分析:${func}`,
|
||||||
|
detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`,
|
||||||
|
progressText: func,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setFunctionLoading(true)
|
setFunctionLoading(true)
|
||||||
|
|
||||||
// 计算时间戳
|
// 计算时间戳
|
||||||
@@ -323,33 +361,96 @@ function GroupAnalyticsPage() {
|
|||||||
try {
|
try {
|
||||||
switch (func) {
|
switch (func) {
|
||||||
case 'members': {
|
case 'members': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取群成员列表',
|
||||||
|
progressText: '成员列表'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.data) setMembers(result.data)
|
if (result.success && result.data) setMembers(result.data)
|
||||||
|
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||||
|
detail: result.success ? `群成员列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取群成员列表失败'),
|
||||||
|
progressText: result.success ? `${result.data?.length || 0} 人` : '失败'
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'memberExport': {
|
case 'memberExport': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取导出成员列表',
|
||||||
|
progressText: '成员导出'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.data) setMembers(result.data)
|
if (result.success && result.data) setMembers(result.data)
|
||||||
|
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||||
|
detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取成员导出列表失败'),
|
||||||
|
progressText: result.success ? `${result.data?.length || 0} 人` : '失败'
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'ranking': {
|
case 'ranking': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在计算群消息排行',
|
||||||
|
progressText: '消息排行'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.data) setRankings(result.data)
|
if (result.success && result.data) setRankings(result.data)
|
||||||
|
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||||
|
detail: result.success ? `群消息排行加载完成,共 ${result.data?.length || 0} 条` : (result.error || '读取群消息排行失败'),
|
||||||
|
progressText: result.success ? `${result.data?.length || 0} 条` : '失败'
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'activeHours': {
|
case 'activeHours': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在计算群活跃时段',
|
||||||
|
progressText: '活跃时段'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime)
|
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.data) setActiveHours(result.data.hourlyDistribution)
|
if (result.success && result.data) setActiveHours(result.data.hourlyDistribution)
|
||||||
|
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||||
|
detail: result.success ? '群活跃时段加载完成' : (result.error || '读取群活跃时段失败'),
|
||||||
|
progressText: result.success ? '24 小时分布' : '失败'
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'mediaStats': {
|
case 'mediaStats': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在统计群消息类型',
|
||||||
|
progressText: '消息类型'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime)
|
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.data) setMediaStats(result.data)
|
if (result.success && result.data) setMediaStats(result.data)
|
||||||
|
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
|
||||||
|
detail: result.success ? `群消息类型统计完成,共 ${result.data?.total || 0} 条` : (result.error || '读取群消息类型统计失败'),
|
||||||
|
progressText: result.success ? `${result.data?.total || 0} 条` : '失败'
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setFunctionLoading(false)
|
setFunctionLoading(false)
|
||||||
}
|
}
|
||||||
@@ -1085,12 +1186,15 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="group-analytics-shell">
|
||||||
|
<ChatAnalysisHeader currentMode="group" />
|
||||||
<div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}>
|
<div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}>
|
||||||
{renderGroupList()}
|
{renderGroupList()}
|
||||||
<div className="resize-handle" onMouseDown={() => setIsResizing(true)} />
|
<div className="resize-handle" onMouseDown={() => setIsResizing(true)} />
|
||||||
<div className="detail-area">
|
<div className="detail-area">
|
||||||
{renderDetailPanel()}
|
{renderDetailPanel()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{renderMemberModal()}
|
{renderMemberModal()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,76 +7,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
.title-bar {
|
|
||||||
height: 40px;
|
|
||||||
min-height: 40px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
padding-right: 140px; // 为原生窗口控件留出空间
|
|
||||||
|
|
||||||
.window-drag-area {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
margin-right: 16px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.live-play-btn {
|
|
||||||
&.active {
|
|
||||||
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
|
|
||||||
color: var(--primary, #4c84ff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scale-text {
|
|
||||||
min-width: 50px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 14px;
|
|
||||||
background: var(--border-color);
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-viewport {
|
.image-viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
||||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||||
|
import TitleBar from '../components/TitleBar'
|
||||||
import './ImageWindow.scss'
|
import './ImageWindow.scss'
|
||||||
|
|
||||||
export default function ImageWindow() {
|
export default function ImageWindow() {
|
||||||
@@ -207,9 +208,12 @@ export default function ImageWindow() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="image-window-container">
|
<div className="image-window-container">
|
||||||
<div className="title-bar">
|
<TitleBar
|
||||||
<div className="window-drag-area"></div>
|
title="图片查看"
|
||||||
<div className="title-bar-controls">
|
showWindowControls={true}
|
||||||
|
showLogo={false}
|
||||||
|
customControls={
|
||||||
|
<div className="image-controls">
|
||||||
{hasLiveVideo && (
|
{hasLiveVideo && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -231,7 +235,8 @@ export default function ImageWindow() {
|
|||||||
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
|
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
|
||||||
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
|
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="image-viewport"
|
className="image-viewport"
|
||||||
|
|||||||
@@ -1,17 +1,92 @@
|
|||||||
|
.settings-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 41px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2050;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px 32px;
|
||||||
|
background: rgba(15, 23, 42, 0.28);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
animation: settingsFadeIn 0.2s ease;
|
||||||
|
|
||||||
|
&.closing {
|
||||||
|
animation: settingsFadeOut 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settingsFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settingsFadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
backdrop-filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.settings-page {
|
.settings-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
width: min(1160px, calc(100vw - 96px));
|
||||||
margin: -24px;
|
height: min(820px, calc(100vh - 120px));
|
||||||
|
max-height: 100%;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
animation: settingsSlideUp 0.3s ease;
|
||||||
|
|
||||||
|
&.closing {
|
||||||
|
animation: settingsSlideDown 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settingsSlideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settingsSlideDown {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.settings-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 20px;
|
gap: 20px;
|
||||||
|
margin-bottom: 14px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -22,29 +97,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-title-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-actions {
|
.settings-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-close-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: rgba(139, 115, 85, 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
flex-direction: column;
|
||||||
padding: 4px;
|
gap: 6px;
|
||||||
background: var(--bg-tertiary);
|
padding: 12px;
|
||||||
border-radius: 12px;
|
width: 220px;
|
||||||
margin-bottom: 20px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: fit-content;
|
background: var(--bg-secondary);
|
||||||
}
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 10px 18px;
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 11px 14px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -63,10 +176,12 @@
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.settings-body {
|
.settings-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
min-width: 0;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
@@ -85,8 +200,10 @@
|
|||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 16px;
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
min-height: 100%;
|
||||||
|
|
||||||
.section-desc {
|
.section-desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -932,7 +1049,7 @@
|
|||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
z-index: 100;
|
z-index: 2200;
|
||||||
animation: slideDown 0.3s ease;
|
animation: slideDown 0.3s ease;
|
||||||
|
|
||||||
&.success {
|
&.success {
|
||||||
@@ -946,6 +1063,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.settings-modal-overlay {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page {
|
||||||
|
width: min(100%, calc(100vw - 40px));
|
||||||
|
height: min(100%, calc(100vh - 82px));
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-tabs {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slideDown {
|
@keyframes slideDown {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -1784,54 +1922,106 @@
|
|||||||
|
|
||||||
.model-status-card {
|
.model-status-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-info {
|
.model-info {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
.model-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.model-name {
|
.model-name {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-path {
|
.model-size {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
|
||||||
&.success {
|
&.success {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
color: #10b981;
|
color: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.warning {
|
&.warning {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
color: #f59e0b;
|
color: #f59e0b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-path-block {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.path-text {
|
.path-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
word-break: break-all;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-actions {
|
.model-actions {
|
||||||
flex-shrink: 0;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
|
||||||
.btn-download {
|
.btn-download {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1866,16 +2056,18 @@
|
|||||||
.download-status {
|
.download-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
width: 280px;
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
|
||||||
.status-header,
|
.status-header,
|
||||||
.progress-info {
|
.progress-info {
|
||||||
// specific layout class
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center; // Align vertically
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.percent {
|
.percent {
|
||||||
@@ -1889,6 +2081,7 @@
|
|||||||
.details {
|
.details {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -1963,10 +2156,12 @@
|
|||||||
.path-selector {
|
.path-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||||
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
|
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
|
||||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
@@ -36,7 +36,11 @@ interface WxidOption {
|
|||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
interface SettingsPageProps {
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const {
|
const {
|
||||||
isDbConnected,
|
isDbConnected,
|
||||||
@@ -130,6 +134,7 @@ function SettingsPage() {
|
|||||||
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||||
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
||||||
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
||||||
|
const [isClosing, setIsClosing] = useState(false)
|
||||||
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||||
|
|
||||||
// 安全设置 state
|
// 安全设置 state
|
||||||
@@ -195,6 +200,17 @@ function SettingsPage() {
|
|||||||
setActiveTab(initialTab)
|
setActiveTab(initialTab)
|
||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onClose) return
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
|
||||||
setDbKeyStatus(payload.message)
|
setDbKeyStatus(payload.message)
|
||||||
@@ -430,6 +446,14 @@ function SettingsPage() {
|
|||||||
setTimeout(() => setMessage(null), 3000)
|
setTimeout(() => setMessage(null), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!onClose) return
|
||||||
|
setIsClosing(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose()
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
type WxidKeys = {
|
type WxidKeys = {
|
||||||
decryptKey: string
|
decryptKey: string
|
||||||
imageXorKey: number | null
|
imageXorKey: number | null
|
||||||
@@ -873,6 +897,21 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClearLog = async () => {
|
||||||
|
const confirmed = window.confirm('确定清空 wcdb.log 吗?')
|
||||||
|
if (!confirmed) return
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.log.clear()
|
||||||
|
if (!result.success) {
|
||||||
|
showMessage(result.error || '清空日志失败', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showMessage('日志已清空', true)
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`清空日志失败: ${e}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClearAnalyticsCache = async () => {
|
const handleClearAnalyticsCache = async () => {
|
||||||
if (isClearingCache) return
|
if (isClearingCache) return
|
||||||
setIsClearingAnalyticsCache(true)
|
setIsClearingAnalyticsCache(true)
|
||||||
@@ -1355,15 +1394,12 @@ function SettingsPage() {
|
|||||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="form-hint" style={{ color: '#f59e0b', margin: '6px 0' }}>
|
|
||||||
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用「内存扫描」方案。
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
<button className="btn btn-primary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
|
||||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高">
|
<button className="btn btn-secondary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
|
||||||
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
{isFetchingImageKey ? '扫描中...' : '内存扫描'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isFetchingImageKey ? (
|
{isFetchingImageKey ? (
|
||||||
@@ -1375,7 +1411,7 @@ function SettingsPage() {
|
|||||||
) : (
|
) : (
|
||||||
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
||||||
)}
|
)}
|
||||||
<span className="form-hint">内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</span>
|
<span className="form-hint">优先推荐缓存计算方案。若图片无法解密,可使用内存扫描(需微信运行并打开 2-3 张图片大图)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1406,10 +1442,15 @@ function SettingsPage() {
|
|||||||
<button className="btn btn-secondary" onClick={handleCopyLog}>
|
<button className="btn btn-secondary" onClick={handleCopyLog}>
|
||||||
<Copy size={16} /> 复制日志内容
|
<Copy size={16} /> 复制日志内容
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={handleClearLog}>
|
||||||
|
<Trash2 size={16} /> 清空日志
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || ''
|
||||||
|
|
||||||
const renderModelsTab = () => (
|
const renderModelsTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1424,16 +1465,25 @@ function SettingsPage() {
|
|||||||
<div className="setting-control vertical has-border">
|
<div className="setting-control vertical has-border">
|
||||||
<div className="model-status-card">
|
<div className="model-status-card">
|
||||||
<div className="model-info">
|
<div className="model-info">
|
||||||
<div className="model-name">SenseVoiceSmall (245 MB)</div>
|
<div className="model-name-row">
|
||||||
<div className="model-path">
|
<div className="model-name">SenseVoiceSmall</div>
|
||||||
|
<span className="model-size">245 MB</span>
|
||||||
|
</div>
|
||||||
|
<div className="model-meta">
|
||||||
{whisperModelStatus?.exists ? (
|
{whisperModelStatus?.exists ? (
|
||||||
<span className="status-indicator success"><Check size={14} /> 已安装</span>
|
<span className="status-indicator success"><Check size={14} /> 已安装</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="status-indicator warning">未安装</span>
|
<span className="status-indicator warning">未安装</span>
|
||||||
)}
|
)}
|
||||||
{whisperModelDir && <div className="path-text" title={whisperModelDir}>{whisperModelDir}</div>}
|
{resolvedWhisperModelPath && (
|
||||||
|
<div className="model-path-block">
|
||||||
|
<span className="path-label">模型目录</span>
|
||||||
|
<div className="path-text" title={resolvedWhisperModelPath}>{resolvedWhisperModelPath}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{(!whisperModelStatus?.exists || isWhisperDownloading) && (
|
||||||
<div className="model-actions">
|
<div className="model-actions">
|
||||||
{!whisperModelStatus?.exists && !isWhisperDownloading && (
|
{!whisperModelStatus?.exists && !isWhisperDownloading && (
|
||||||
<button
|
<button
|
||||||
@@ -1460,6 +1510,7 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sub-setting">
|
<div className="sub-setting">
|
||||||
@@ -2010,7 +2061,6 @@ function SettingsPage() {
|
|||||||
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
||||||
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2049,7 +2099,8 @@ function SettingsPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-page">
|
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
|
||||||
|
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
|
||||||
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
|
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
|
||||||
|
|
||||||
{/* 多账号选择对话框 */}
|
{/* 多账号选择对话框 */}
|
||||||
@@ -2080,17 +2131,29 @@ function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="settings-header">
|
<div className="settings-header">
|
||||||
|
<div className="settings-title-block">
|
||||||
<h1>设置</h1>
|
<h1>设置</h1>
|
||||||
|
</div>
|
||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
||||||
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
||||||
</button>
|
</button>
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-tabs">
|
<div className="settings-layout">
|
||||||
|
<div className="settings-tabs" role="tablist" aria-label="设置项">
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<button key={tab.id} className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
>
|
||||||
<tab.icon size={16} />
|
<tab.icon size={16} />
|
||||||
<span>{tab.label}</span>
|
<span>{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2108,7 +2171,8 @@ function SettingsPage() {
|
|||||||
{activeTab === 'security' && renderSecurityTab()}
|
{activeTab === 'security' && renderSecurityTab()}
|
||||||
{activeTab === 'about' && renderAboutTab()}
|
{activeTab === 'about' && renderAboutTab()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* Global Variables */
|
/* Global Variables */
|
||||||
:root {
|
:root {
|
||||||
--sns-max-width: 800px;
|
--sns-max-width: 800px;
|
||||||
--sns-panel-width: 320px;
|
--sns-panel-width: 380px;
|
||||||
--sns-bg-color: var(--bg-primary);
|
--sns-bg-color: var(--bg-primary);
|
||||||
--sns-card-bg: var(--bg-secondary);
|
--sns-card-bg: var(--bg-secondary);
|
||||||
--sns-border-radius-lg: 16px;
|
--sns-border-radius-lg: 16px;
|
||||||
@@ -263,6 +263,48 @@
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-contact-filter-bar {
|
||||||
|
margin: 10px 4px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border-color));
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.feed-contact-filter-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-contact-filter-summary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-contact-filter-clear {
|
||||||
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.posts-list {
|
.posts-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1211,6 +1253,13 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-interaction-hint {
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.contact-list-scroll {
|
.contact-list-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1218,23 +1267,75 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
/* Remove gap to allow borders to merge */
|
|
||||||
|
|
||||||
.contact-row {
|
.contact-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 10px;
|
|
||||||
border-radius: var(--sns-border-radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease, transform 0.2s ease;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
border-radius: var(--sns-border-radius-md);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected .contact-main-btn {
|
||||||
|
background: rgba(var(--primary-rgb), 0.06);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 20%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active .contact-main-btn {
|
||||||
|
background: rgba(var(--primary-rgb), 0.12);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 48%, var(--border-color));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active .contact-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-select-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checked {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-main-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--sns-border-radius-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
transform: translateX(2px);
|
}
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-meta {
|
.contact-meta {
|
||||||
@@ -1282,6 +1383,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-batch-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px 14px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-batch-summary {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-batch-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--sns-border-radius-md);
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||||
|
background: rgba(var(--primary-rgb), 0.12);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimel
|
|||||||
import JumpToDatePopover from '../components/JumpToDatePopover'
|
import JumpToDatePopover from '../components/JumpToDatePopover'
|
||||||
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import {
|
import {
|
||||||
createExportDateRangeSelectionFromPreset,
|
createExportDateRangeSelectionFromPreset,
|
||||||
getExportDateRangeLabel,
|
getExportDateRangeLabel,
|
||||||
@@ -57,6 +63,7 @@ interface SnsOverviewStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||||
|
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
|
||||||
|
|
||||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||||
|
|
||||||
@@ -117,6 +124,7 @@ export default function SnsPage() {
|
|||||||
total: 0,
|
total: 0,
|
||||||
running: false
|
running: false
|
||||||
})
|
})
|
||||||
|
const [selectedContactUsernames, setSelectedContactUsernames] = useState<string[]>([])
|
||||||
const [currentUserProfile, setCurrentUserProfile] = useState<SidebarUserProfile>(() => readSidebarUserProfileCache() || {
|
const [currentUserProfile, setCurrentUserProfile] = useState<SidebarUserProfile>(() => readSidebarUserProfileCache() || {
|
||||||
wxid: '',
|
wxid: '',
|
||||||
displayName: ''
|
displayName: ''
|
||||||
@@ -134,6 +142,7 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
// 导出相关状态
|
// 导出相关状态
|
||||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||||
|
const [exportScope, setExportScope] = useState<SnsExportScope>({ kind: 'all' })
|
||||||
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
|
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
|
||||||
const [exportFolder, setExportFolder] = useState('')
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
const [exportImages, setExportImages] = useState(false)
|
const [exportImages, setExportImages] = useState(false)
|
||||||
@@ -164,9 +173,11 @@ export default function SnsPage() {
|
|||||||
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
|
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
|
||||||
const searchKeywordRef = useRef(searchKeyword)
|
const searchKeywordRef = useRef(searchKeyword)
|
||||||
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
|
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
|
||||||
|
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
|
||||||
const cacheScopeKeyRef = useRef('')
|
const cacheScopeKeyRef = useRef('')
|
||||||
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
||||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||||
|
const pendingResetFeedRef = useRef(false)
|
||||||
const contactsLoadTokenRef = useRef(0)
|
const contactsLoadTokenRef = useRef(0)
|
||||||
const contactsCountHydrationTokenRef = useRef(0)
|
const contactsCountHydrationTokenRef = useRef(0)
|
||||||
const contactsCountBatchTimerRef = useRef<number | null>(null)
|
const contactsCountBatchTimerRef = useRef<number | null>(null)
|
||||||
@@ -180,6 +191,13 @@ export default function SnsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contactsRef.current = contacts
|
contactsRef.current = contacts
|
||||||
}, [contacts])
|
}, [contacts])
|
||||||
|
useEffect(() => {
|
||||||
|
const contactLookup = new Set(contacts.map((contact) => contact.username))
|
||||||
|
setSelectedContactUsernames((prev) => {
|
||||||
|
const next = prev.filter((username) => contactLookup.has(username))
|
||||||
|
return next.length === prev.length ? prev : next
|
||||||
|
})
|
||||||
|
}, [contacts])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
overviewStatsRef.current = overviewStats
|
overviewStatsRef.current = overviewStats
|
||||||
}, [overviewStats])
|
}, [overviewStats])
|
||||||
@@ -192,6 +210,9 @@ export default function SnsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
jumpTargetDateRef.current = jumpTargetDate
|
jumpTargetDateRef.current = jumpTargetDate
|
||||||
}, [jumpTargetDate])
|
}, [jumpTargetDate])
|
||||||
|
useEffect(() => {
|
||||||
|
selectedContactUsernamesRef.current = selectedContactUsernames
|
||||||
|
}, [selectedContactUsernames])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showJumpPopover) {
|
if (!showJumpPopover) {
|
||||||
setJumpPopoverDate(jumpTargetDate || new Date())
|
setJumpPopoverDate(jumpTargetDate || new Date())
|
||||||
@@ -370,6 +391,31 @@ export default function SnsPage() {
|
|||||||
return contacts.find((contact) => contact.username === normalizedTargetUsername) || null
|
return contacts.find((contact) => contact.username === normalizedTargetUsername) || null
|
||||||
}, [authorTimelineTarget, contacts])
|
}, [authorTimelineTarget, contacts])
|
||||||
|
|
||||||
|
const exportSelectedContactsSummary = useMemo(() => {
|
||||||
|
if (exportScope.kind !== 'selected' || exportScope.usernames.length === 0) return ''
|
||||||
|
const contactMap = new Map(contacts.map((contact) => [contact.username, contact]))
|
||||||
|
const names = exportScope.usernames.map((username) => contactMap.get(username)?.displayName || username)
|
||||||
|
if (names.length <= 2) return names.join('、')
|
||||||
|
return `${names.slice(0, 2).join('、')} 等 ${names.length} 位联系人`
|
||||||
|
}, [contacts, exportScope])
|
||||||
|
|
||||||
|
const selectedFeedContactsSummary = useMemo(() => {
|
||||||
|
if (selectedContactUsernames.length === 0) return ''
|
||||||
|
const contactMap = new Map(contacts.map((contact) => [contact.username, contact]))
|
||||||
|
const names = selectedContactUsernames.map((username) => contactMap.get(username)?.displayName || username)
|
||||||
|
if (names.length <= 2) return names.join('、')
|
||||||
|
return `${names.slice(0, 2).join('、')} 等 ${names.length} 人`
|
||||||
|
}, [contacts, selectedContactUsernames])
|
||||||
|
|
||||||
|
const selectedContactUsernameSet = useMemo(() => (
|
||||||
|
new Set(selectedContactUsernames.map((username) => normalizeAccountId(username)))
|
||||||
|
), [selectedContactUsernames])
|
||||||
|
|
||||||
|
const visiblePosts = useMemo(() => {
|
||||||
|
if (selectedContactUsernameSet.size === 0) return posts
|
||||||
|
return posts.filter((post) => selectedContactUsernameSet.has(normalizeAccountId(post.username)))
|
||||||
|
}, [posts, selectedContactUsernameSet])
|
||||||
|
|
||||||
const myTimelineCount = useMemo(() => {
|
const myTimelineCount = useMemo(() => {
|
||||||
if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') {
|
if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') {
|
||||||
return normalizePostCount(resolvedCurrentUserContact.postCount)
|
return normalizePostCount(resolvedCurrentUserContact.postCount)
|
||||||
@@ -383,6 +429,10 @@ export default function SnsPage() {
|
|||||||
: overviewStatsStatus === 'loading' || contactsLoading
|
: overviewStatsStatus === 'loading' || contactsLoading
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const canStartExport = Boolean(exportFolder) && !isExporting && (
|
||||||
|
exportScope.kind === 'all' || exportScope.usernames.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
const openCurrentUserTimeline = useCallback(() => {
|
const openCurrentUserTimeline = useCallback(() => {
|
||||||
if (!resolvedCurrentUserContact) return
|
if (!resolvedCurrentUserContact) return
|
||||||
setAuthorTimelineTarget({
|
setAuthorTimelineTarget({
|
||||||
@@ -393,7 +443,11 @@ export default function SnsPage() {
|
|||||||
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
|
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
|
||||||
|
|
||||||
const isDefaultViewNow = useCallback(() => {
|
const isDefaultViewNow = useCallback(() => {
|
||||||
return !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
|
return (
|
||||||
|
!searchKeywordRef.current.trim() &&
|
||||||
|
!jumpTargetDateRef.current &&
|
||||||
|
selectedContactUsernamesRef.current.length === 0
|
||||||
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const ensureSnsCacheScopeKey = useCallback(async () => {
|
const ensureSnsCacheScopeKey = useCallback(async () => {
|
||||||
@@ -555,9 +609,23 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
|
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
|
||||||
|
|
||||||
|
const openExportDialog = useCallback((scope: SnsExportScope) => {
|
||||||
|
setExportScope(scope)
|
||||||
|
setExportResult(null)
|
||||||
|
setExportProgress(null)
|
||||||
|
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
|
||||||
|
setIsExportDateRangeDialogOpen(false)
|
||||||
|
setShowExportDialog(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
|
||||||
const { reset = false, direction = 'older' } = options
|
const { reset = false, direction = 'older' } = options
|
||||||
if (loadingRef.current) return
|
if (loadingRef.current) {
|
||||||
|
if (reset) {
|
||||||
|
pendingResetFeedRef.current = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loadingRef.current = true
|
loadingRef.current = true
|
||||||
if (direction === 'newer') setLoadingNewer(true)
|
if (direction === 'newer') setLoadingNewer(true)
|
||||||
@@ -565,13 +633,19 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
const currentSearchKeyword = searchKeywordRef.current
|
||||||
|
const currentJumpTargetDate = jumpTargetDateRef.current
|
||||||
|
const currentSelectedContactUsernames = selectedContactUsernamesRef.current
|
||||||
|
const selectedUsernames = currentSelectedContactUsernames.length > 0
|
||||||
|
? [...currentSelectedContactUsernames]
|
||||||
|
: undefined
|
||||||
let startTs: number | undefined = undefined
|
let startTs: number | undefined = undefined
|
||||||
let endTs: number | undefined = undefined
|
let endTs: number | undefined = undefined
|
||||||
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
// If jumping to date, set endTs to end of that day
|
// If jumping to date, set endTs to end of that day
|
||||||
if (jumpTargetDate) {
|
if (currentJumpTargetDate) {
|
||||||
endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399
|
endTs = Math.floor(currentJumpTargetDate.getTime() / 1000) + 86399
|
||||||
}
|
}
|
||||||
} else if (direction === 'newer') {
|
} else if (direction === 'newer') {
|
||||||
const currentPosts = postsRef.current
|
const currentPosts = postsRef.current
|
||||||
@@ -581,8 +655,8 @@ export default function SnsPage() {
|
|||||||
const result = await window.electronAPI.sns.getTimeline(
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
limit,
|
limit,
|
||||||
0,
|
0,
|
||||||
undefined,
|
selectedUsernames,
|
||||||
searchKeyword,
|
currentSearchKeyword,
|
||||||
topTs + 1,
|
topTs + 1,
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
@@ -622,8 +696,8 @@ export default function SnsPage() {
|
|||||||
const result = await window.electronAPI.sns.getTimeline(
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
limit,
|
limit,
|
||||||
0,
|
0,
|
||||||
undefined,
|
selectedUsernames,
|
||||||
searchKeyword,
|
currentSearchKeyword,
|
||||||
startTs, // default undefined
|
startTs, // default undefined
|
||||||
endTs
|
endTs
|
||||||
)
|
)
|
||||||
@@ -637,7 +711,7 @@ export default function SnsPage() {
|
|||||||
// Check for newer items above topTs
|
// Check for newer items above topTs
|
||||||
const topTs = result.timeline[0]?.createTime || 0;
|
const topTs = result.timeline[0]?.createTime || 0;
|
||||||
if (topTs > 0) {
|
if (topTs > 0) {
|
||||||
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, undefined, searchKeyword, topTs + 1, undefined);
|
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, currentSearchKeyword, topTs + 1, undefined);
|
||||||
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
|
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
|
||||||
} else {
|
} else {
|
||||||
setHasNewer(false);
|
setHasNewer(false);
|
||||||
@@ -663,8 +737,12 @@ export default function SnsPage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
setLoadingNewer(false)
|
setLoadingNewer(false)
|
||||||
loadingRef.current = false
|
loadingRef.current = false
|
||||||
|
if (pendingResetFeedRef.current) {
|
||||||
|
pendingResetFeedRef.current = false
|
||||||
|
void loadPosts({ reset: true })
|
||||||
}
|
}
|
||||||
}, [jumpTargetDate, persistSnsPageCache, searchKeyword])
|
}
|
||||||
|
}, [persistSnsPageCache])
|
||||||
|
|
||||||
const stopContactsCountHydration = useCallback((resetProgress = false) => {
|
const stopContactsCountHydration = useCallback((resetProgress = false) => {
|
||||||
contactsCountHydrationTokenRef.current += 1
|
contactsCountHydrationTokenRef.current += 1
|
||||||
@@ -728,9 +806,23 @@ export default function SnsPage() {
|
|||||||
})
|
})
|
||||||
if (pendingTargets.length === 0) return
|
if (pendingTargets.length === 0) return
|
||||||
|
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'sns',
|
||||||
|
title: '朋友圈联系人计数补算',
|
||||||
|
detail: `正在补算 ${pendingTargets.length} 个联系人朋友圈条数`,
|
||||||
|
progressText: `${preResolved}/${totalTargets}`,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
|
||||||
let normalizedCounts: Record<string, number> = {}
|
let normalizedCounts: Record<string, number> = {}
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.sns.getUserPostCounts()
|
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
if (runToken !== contactsCountHydrationTokenRef.current) return
|
||||||
if (result.success && result.counts) {
|
if (result.success && result.counts) {
|
||||||
normalizedCounts = Object.fromEntries(
|
normalizedCounts = Object.fromEntries(
|
||||||
@@ -747,12 +839,28 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load contact post counts:', error)
|
console.error('Failed to load contact post counts:', error)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(error)
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved = preResolved
|
let resolved = preResolved
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
const applyBatch = () => {
|
const applyBatch = () => {
|
||||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
if (runToken !== contactsCountHydrationTokenRef.current) return
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}`
|
||||||
|
})
|
||||||
|
contactsCountBatchTimerRef.current = null
|
||||||
|
setContactsCountProgress({
|
||||||
|
resolved,
|
||||||
|
total: totalTargets,
|
||||||
|
running: false
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE)
|
const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE)
|
||||||
if (batch.length === 0) {
|
if (batch.length === 0) {
|
||||||
@@ -762,6 +870,10 @@ export default function SnsPage() {
|
|||||||
running: false
|
running: false
|
||||||
})
|
})
|
||||||
contactsCountBatchTimerRef.current = null
|
contactsCountBatchTimerRef.current = null
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '联系人朋友圈条数补算完成',
|
||||||
|
progressText: `${totalTargets}/${totalTargets}`
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +901,10 @@ export default function SnsPage() {
|
|||||||
total: totalTargets,
|
total: totalTargets,
|
||||||
running: resolved < totalTargets
|
running: resolved < totalTargets
|
||||||
})
|
})
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: `已完成 ${resolved}/${totalTargets} 个联系人朋友圈条数补算`,
|
||||||
|
progressText: `${resolved}/${totalTargets}`
|
||||||
|
})
|
||||||
|
|
||||||
if (cursor < totalTargets) {
|
if (cursor < totalTargets) {
|
||||||
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
|
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
|
||||||
@@ -803,6 +919,13 @@ export default function SnsPage() {
|
|||||||
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
|
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
|
||||||
const loadContacts = useCallback(async () => {
|
const loadContacts = useCallback(async () => {
|
||||||
const requestToken = ++contactsLoadTokenRef.current
|
const requestToken = ++contactsLoadTokenRef.current
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'sns',
|
||||||
|
title: '朋友圈联系人列表加载',
|
||||||
|
detail: '准备读取联系人缓存与最近会话',
|
||||||
|
progressText: '初始化',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
stopContactsCountHydration(true)
|
stopContactsCountHydration(true)
|
||||||
setContactsLoading(true)
|
setContactsLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -845,10 +968,20 @@ export default function SnsPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取联系人与最近会话数据',
|
||||||
|
progressText: '联系人快照'
|
||||||
|
})
|
||||||
const [contactsResult, sessionsResult] = await Promise.all([
|
const [contactsResult, sessionsResult] = await Promise.all([
|
||||||
window.electronAPI.chat.getContacts(),
|
window.electronAPI.chat.getContacts(),
|
||||||
window.electronAPI.chat.getSessions()
|
window.electronAPI.chat.getSessions()
|
||||||
])
|
])
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
const contactMap = new Map<string, Contact>()
|
const contactMap = new Map<string, Contact>()
|
||||||
const sessionTimestampMap = new Map<string, number>()
|
const sessionTimestampMap = new Map<string, number>()
|
||||||
|
|
||||||
@@ -904,7 +1037,17 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
// 用 enrichSessionsContactInfo 统一补充头像和显示名
|
// 用 enrichSessionsContactInfo 统一补充头像和显示名
|
||||||
if (allUsernames.length > 0) {
|
if (allUsernames.length > 0) {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在补齐联系人显示名与头像',
|
||||||
|
progressText: '联系人补齐'
|
||||||
|
})
|
||||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,联系人补齐未继续写入'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (enriched.success && enriched.contacts) {
|
if (enriched.success && enriched.contacts) {
|
||||||
contactsList = contactsList.map((contact) => {
|
contactsList = contactsList.map((contact) => {
|
||||||
const extra = enriched.contacts?.[contact.username]
|
const extra = enriched.contacts?.[contact.username]
|
||||||
@@ -931,10 +1074,17 @@ export default function SnsPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`,
|
||||||
|
progressText: `${contactsList.length} 人`
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestToken !== contactsLoadTokenRef.current) return
|
if (requestToken !== contactsLoadTokenRef.current) return
|
||||||
console.error('Failed to load contacts:', error)
|
console.error('Failed to load contacts:', error)
|
||||||
stopContactsCountHydration(true)
|
stopContactsCountHydration(true)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(error)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (requestToken === contactsLoadTokenRef.current) {
|
if (requestToken === contactsLoadTokenRef.current) {
|
||||||
setContactsLoading(false)
|
setContactsLoading(false)
|
||||||
@@ -962,6 +1112,23 @@ export default function SnsPage() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const toggleContactSelected = useCallback((contact: Contact) => {
|
||||||
|
setSelectedContactUsernames((prev) => (
|
||||||
|
prev.includes(contact.username)
|
||||||
|
? prev.filter((username) => username !== contact.username)
|
||||||
|
: [...prev, contact.username]
|
||||||
|
))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearSelectedContacts = useCallback(() => {
|
||||||
|
setSelectedContactUsernames([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openSelectedContactsExport = useCallback(() => {
|
||||||
|
if (selectedContactUsernames.length === 0) return
|
||||||
|
openExportDialog({ kind: 'selected', usernames: [...selectedContactUsernames] })
|
||||||
|
}, [openExportDialog, selectedContactUsernames])
|
||||||
|
|
||||||
const handlePostDelete = useCallback((postId: string, username: string) => {
|
const handlePostDelete = useCallback((postId: string, username: string) => {
|
||||||
setPosts(prev => {
|
setPosts(prev => {
|
||||||
const next = prev.filter(p => p.id !== postId)
|
const next = prev.filter(p => p.id !== postId)
|
||||||
@@ -1029,6 +1196,7 @@ export default function SnsPage() {
|
|||||||
stopContactsCountHydration(true)
|
stopContactsCountHydration(true)
|
||||||
setContacts([])
|
setContacts([])
|
||||||
setPosts([]); setHasMore(true); setHasNewer(false);
|
setPosts([]); setHasMore(true); setHasNewer(false);
|
||||||
|
setSelectedContactUsernames([])
|
||||||
setSearchKeyword(''); setJumpTargetDate(undefined);
|
setSearchKeyword(''); setJumpTargetDate(undefined);
|
||||||
void hydrateSnsPageCache()
|
void hydrateSnsPageCache()
|
||||||
loadContacts();
|
loadContacts();
|
||||||
@@ -1046,6 +1214,21 @@ export default function SnsPage() {
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [searchKeyword, jumpTargetDate, loadPosts])
|
}, [searchKeyword, jumpTargetDate, loadPosts])
|
||||||
|
|
||||||
|
const selectedContactUsernamesKey = useMemo(
|
||||||
|
() => selectedContactUsernames.join('||'),
|
||||||
|
[selectedContactUsernames]
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasInitializedSelectedFeedFilterRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitializedSelectedFeedFilterRef.current) {
|
||||||
|
hasInitializedSelectedFeedFilterRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadPosts({ reset: true })
|
||||||
|
}, [loadPosts, selectedContactUsernamesKey])
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
||||||
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
|
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
|
||||||
@@ -1186,13 +1369,7 @@ export default function SnsPage() {
|
|||||||
<Shield size={20} />
|
<Shield size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => openExportDialog({ kind: 'all' })}
|
||||||
setExportResult(null)
|
|
||||||
setExportProgress(null)
|
|
||||||
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
|
|
||||||
setIsExportDateRangeDialogOpen(false)
|
|
||||||
setShowExportDialog(true)
|
|
||||||
}}
|
|
||||||
className="icon-btn export-btn"
|
className="icon-btn export-btn"
|
||||||
title="导出朋友圈"
|
title="导出朋友圈"
|
||||||
>
|
>
|
||||||
@@ -1214,6 +1391,20 @@ export default function SnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedContactUsernames.length > 0 && (
|
||||||
|
<div className="feed-contact-filter-bar">
|
||||||
|
<span className="feed-contact-filter-label">仅显示</span>
|
||||||
|
<span className="feed-contact-filter-summary">{selectedFeedContactsSummary} 的动态</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="feed-contact-filter-clear"
|
||||||
|
onClick={clearSelectedContacts}
|
||||||
|
>
|
||||||
|
清空筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
|
<div className="sns-posts-scroll" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
|
||||||
{loadingNewer && (
|
{loadingNewer && (
|
||||||
<div className="status-indicator loading-newer">
|
<div className="status-indicator loading-newer">
|
||||||
@@ -1229,7 +1420,7 @@ export default function SnsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="posts-list">
|
<div className="posts-list">
|
||||||
{posts.map(post => (
|
{visiblePosts.map(post => (
|
||||||
<SnsPostItem
|
<SnsPostItem
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={{ ...post, isProtected: triggerInstalled === true }}
|
post={{ ...post, isProtected: triggerInstalled === true }}
|
||||||
@@ -1247,7 +1438,7 @@ export default function SnsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && posts.length === 0 && (
|
{loading && visiblePosts.length === 0 && (
|
||||||
<div className="initial-loading">
|
<div className="initial-loading">
|
||||||
<div className="loading-pulse">
|
<div className="loading-pulse">
|
||||||
<div className="pulse-circle"></div>
|
<div className="pulse-circle"></div>
|
||||||
@@ -1256,24 +1447,26 @@ export default function SnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && posts.length > 0 && (
|
{loading && visiblePosts.length > 0 && (
|
||||||
<div className="status-indicator loading-more">
|
<div className="status-indicator loading-more">
|
||||||
<RefreshCw size={16} className="spinning" />
|
<RefreshCw size={16} className="spinning" />
|
||||||
<span>正在加载更多...</span>
|
<span>正在加载更多...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasMore && posts.length > 0 && (
|
{!hasMore && visiblePosts.length > 0 && (
|
||||||
<div className="status-indicator no-more">或许过往已无可溯洄,但好在还有可以与你相遇的明天</div>
|
<div className="status-indicator no-more">或许过往已无可溯洄,但好在还有可以与你相遇的明天</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && posts.length === 0 && (
|
{!loading && visiblePosts.length === 0 && (
|
||||||
<div className="no-results">
|
<div className="no-results">
|
||||||
<div className="no-results-icon"><Search size={48} /></div>
|
<div className="no-results-icon"><Search size={48} /></div>
|
||||||
<p>未找到相关动态</p>
|
<p>未找到相关动态</p>
|
||||||
{(searchKeyword || jumpTargetDate) && (
|
{(searchKeyword || jumpTargetDate || selectedContactUsernames.length > 0) && (
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
setSearchKeyword(''); setJumpTargetDate(undefined);
|
setSearchKeyword('')
|
||||||
|
setJumpTargetDate(undefined)
|
||||||
|
clearSelectedContacts()
|
||||||
}} className="reset-inline">
|
}} className="reset-inline">
|
||||||
重置筛选条件
|
重置筛选条件
|
||||||
</button>
|
</button>
|
||||||
@@ -1299,7 +1492,12 @@ export default function SnsPage() {
|
|||||||
setContactSearch={setContactSearch}
|
setContactSearch={setContactSearch}
|
||||||
loading={contactsLoading}
|
loading={contactsLoading}
|
||||||
contactsCountProgress={contactsCountProgress}
|
contactsCountProgress={contactsCountProgress}
|
||||||
|
selectedContactUsernames={selectedContactUsernames}
|
||||||
|
activeContactUsername={authorTimelineTarget?.username}
|
||||||
onOpenContactTimeline={openContactTimeline}
|
onOpenContactTimeline={openContactTimeline}
|
||||||
|
onToggleContactSelected={toggleContactSelected}
|
||||||
|
onClearSelectedContacts={clearSelectedContacts}
|
||||||
|
onExportSelectedContacts={openSelectedContactsExport}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialogs and Overlays */}
|
{/* Dialogs and Overlays */}
|
||||||
@@ -1444,9 +1642,12 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
<div className="export-dialog-body">
|
<div className="export-dialog-body">
|
||||||
{/* 筛选条件提示 */}
|
{/* 筛选条件提示 */}
|
||||||
{searchKeyword && (
|
{(searchKeyword || exportScope.kind === 'selected') && (
|
||||||
<div className="export-filter-info">
|
<div className="export-filter-info">
|
||||||
<span className="filter-badge">筛选导出</span>
|
<span className="filter-badge">导出范围</span>
|
||||||
|
{exportScope.kind === 'selected' && (
|
||||||
|
<span className="filter-tag">联系人: {exportSelectedContactsSummary}</span>
|
||||||
|
)}
|
||||||
{searchKeyword && <span className="filter-tag">关键词: "{searchKeyword}"</span>}
|
{searchKeyword && <span className="filter-tag">关键词: "{searchKeyword}"</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1572,7 +1773,7 @@ export default function SnsPage() {
|
|||||||
{/* 同步提示 */}
|
{/* 同步提示 */}
|
||||||
<div className="export-sync-hint">
|
<div className="export-sync-hint">
|
||||||
<Info size={14} />
|
<Info size={14} />
|
||||||
<span>将同步主页面的关键词搜索</span>
|
<span>{exportScope.kind === 'selected' ? '将同步主页面的关键词搜索,并仅导出所选联系人' : '将同步主页面的关键词搜索'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 进度条 */}
|
{/* 进度条 */}
|
||||||
@@ -1599,7 +1800,7 @@ export default function SnsPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="export-start-btn"
|
className="export-start-btn"
|
||||||
disabled={!exportFolder || isExporting}
|
disabled={!canStartExport}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
||||||
@@ -1614,6 +1815,7 @@ export default function SnsPage() {
|
|||||||
const result = await window.electronAPI.sns.exportTimeline({
|
const result = await window.electronAPI.sns.exportTimeline({
|
||||||
outputDir: exportFolder,
|
outputDir: exportFolder,
|
||||||
format: exportFormat,
|
format: exportFormat,
|
||||||
|
usernames: exportScope.kind === 'selected' ? exportScope.usernames : undefined,
|
||||||
keyword: searchKeyword || undefined,
|
keyword: searchKeyword || undefined,
|
||||||
exportImages,
|
exportImages,
|
||||||
exportLivePhotos,
|
exportLivePhotos,
|
||||||
|
|||||||
@@ -780,9 +780,6 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
{currentStep.id === 'image' && (
|
{currentStep.id === 'image' && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="field-hint" style={{ color: '#f59e0b', marginBottom: '12px' }}>
|
|
||||||
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用下方「内存扫描」方案。
|
|
||||||
</div>
|
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">图片 XOR 密钥</label>
|
<label className="field-label">图片 XOR 密钥</label>
|
||||||
@@ -795,11 +792,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||||
<button className="btn btn-secondary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
<button className="btn btn-primary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
|
||||||
{isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
{isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高,需要微信正在运行">
|
<button className="btn btn-secondary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
|
||||||
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
{isFetchingImageKey ? '扫描中...' : '内存扫描'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -813,7 +810,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="field-hint" style={{ marginTop: '8px' }}>内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</div>
|
<div className="field-hint" style={{ marginTop: '8px' }}>优先推荐缓存计算方案。若图片无法解密,可使用内存扫描(需微信运行并打开 2-3 张图片大图)</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
149
src/services/backgroundTaskMonitor.ts
Normal file
149
src/services/backgroundTaskMonitor.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type {
|
||||||
|
BackgroundTaskInput,
|
||||||
|
BackgroundTaskRecord,
|
||||||
|
BackgroundTaskStatus,
|
||||||
|
BackgroundTaskUpdate
|
||||||
|
} from '../types/backgroundTask'
|
||||||
|
|
||||||
|
type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
|
||||||
|
|
||||||
|
const tasks = new Map<string, BackgroundTaskRecord>()
|
||||||
|
const cancelHandlers = new Map<string, () => void | Promise<void>>()
|
||||||
|
const listeners = new Set<BackgroundTaskListener>()
|
||||||
|
let taskSequence = 0
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'cancel_requested'])
|
||||||
|
const MAX_SETTLED_TASKS = 24
|
||||||
|
|
||||||
|
const buildTaskId = (): string => {
|
||||||
|
taskSequence += 1
|
||||||
|
return `bg-task-${Date.now()}-${taskSequence}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyListeners = () => {
|
||||||
|
const snapshot = getBackgroundTaskSnapshot()
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruneSettledTasks = () => {
|
||||||
|
const settledTasks = [...tasks.values()]
|
||||||
|
.filter(task => !ACTIVE_STATUSES.has(task.status))
|
||||||
|
.sort((a, b) => (b.finishedAt || b.updatedAt) - (a.finishedAt || a.updatedAt))
|
||||||
|
|
||||||
|
for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
|
||||||
|
tasks.delete(staleTask.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBackgroundTaskSnapshot = (): BackgroundTaskRecord[] => (
|
||||||
|
[...tasks.values()].sort((a, b) => {
|
||||||
|
const aActive = ACTIVE_STATUSES.has(a.status) ? 1 : 0
|
||||||
|
const bActive = ACTIVE_STATUSES.has(b.status) ? 1 : 0
|
||||||
|
if (aActive !== bActive) return bActive - aActive
|
||||||
|
return b.updatedAt - a.updatedAt
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const subscribeBackgroundTasks = (listener: BackgroundTaskListener): (() => void) => {
|
||||||
|
listeners.add(listener)
|
||||||
|
listener(getBackgroundTaskSnapshot())
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
|
||||||
|
const now = Date.now()
|
||||||
|
const taskId = buildTaskId()
|
||||||
|
tasks.set(taskId, {
|
||||||
|
id: taskId,
|
||||||
|
sourcePage: input.sourcePage,
|
||||||
|
title: input.title,
|
||||||
|
detail: input.detail,
|
||||||
|
progressText: input.progressText,
|
||||||
|
cancelable: input.cancelable !== false,
|
||||||
|
cancelRequested: false,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
if (input.onCancel) {
|
||||||
|
cancelHandlers.set(taskId, input.onCancel)
|
||||||
|
}
|
||||||
|
pruneSettledTasks()
|
||||||
|
notifyListeners()
|
||||||
|
return taskId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate): void => {
|
||||||
|
const existing = tasks.get(taskId)
|
||||||
|
if (!existing) return
|
||||||
|
const nextStatus = patch.status || existing.status
|
||||||
|
const nextUpdatedAt = Date.now()
|
||||||
|
tasks.set(taskId, {
|
||||||
|
...existing,
|
||||||
|
...patch,
|
||||||
|
status: nextStatus,
|
||||||
|
updatedAt: nextUpdatedAt,
|
||||||
|
finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
|
||||||
|
})
|
||||||
|
pruneSettledTasks()
|
||||||
|
notifyListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const finishBackgroundTask = (
|
||||||
|
taskId: string,
|
||||||
|
status: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>,
|
||||||
|
patch?: Omit<BackgroundTaskUpdate, 'status'>
|
||||||
|
): void => {
|
||||||
|
const existing = tasks.get(taskId)
|
||||||
|
if (!existing) return
|
||||||
|
const now = Date.now()
|
||||||
|
tasks.set(taskId, {
|
||||||
|
...existing,
|
||||||
|
...patch,
|
||||||
|
status,
|
||||||
|
updatedAt: now,
|
||||||
|
finishedAt: now,
|
||||||
|
cancelRequested: status === 'canceled' ? true : existing.cancelRequested
|
||||||
|
})
|
||||||
|
cancelHandlers.delete(taskId)
|
||||||
|
pruneSettledTasks()
|
||||||
|
notifyListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestCancelBackgroundTask = (taskId: string): boolean => {
|
||||||
|
const existing = tasks.get(taskId)
|
||||||
|
if (!existing || !existing.cancelable || !ACTIVE_STATUSES.has(existing.status)) return false
|
||||||
|
tasks.set(taskId, {
|
||||||
|
...existing,
|
||||||
|
status: 'cancel_requested',
|
||||||
|
cancelRequested: true,
|
||||||
|
detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
const cancelHandler = cancelHandlers.get(taskId)
|
||||||
|
if (cancelHandler) {
|
||||||
|
void Promise.resolve(cancelHandler()).catch(() => {})
|
||||||
|
}
|
||||||
|
notifyListeners()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => {
|
||||||
|
let canceledCount = 0
|
||||||
|
for (const task of tasks.values()) {
|
||||||
|
if (!predicate(task)) continue
|
||||||
|
if (requestCancelBackgroundTask(task.id)) {
|
||||||
|
canceledCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return canceledCount
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isBackgroundTaskCancelRequested = (taskId: string): boolean => {
|
||||||
|
const task = tasks.get(taskId)
|
||||||
|
return Boolean(task?.cancelRequested)
|
||||||
|
}
|
||||||
46
src/types/backgroundTask.ts
Normal file
46
src/types/backgroundTask.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export type BackgroundTaskSourcePage =
|
||||||
|
| 'export'
|
||||||
|
| 'chat'
|
||||||
|
| 'analytics'
|
||||||
|
| 'sns'
|
||||||
|
| 'groupAnalytics'
|
||||||
|
| 'annualReport'
|
||||||
|
| 'other'
|
||||||
|
|
||||||
|
export type BackgroundTaskStatus =
|
||||||
|
| 'running'
|
||||||
|
| 'cancel_requested'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'canceled'
|
||||||
|
|
||||||
|
export interface BackgroundTaskRecord {
|
||||||
|
id: string
|
||||||
|
sourcePage: BackgroundTaskSourcePage
|
||||||
|
title: string
|
||||||
|
detail?: string
|
||||||
|
progressText?: string
|
||||||
|
cancelable: boolean
|
||||||
|
cancelRequested: boolean
|
||||||
|
status: BackgroundTaskStatus
|
||||||
|
startedAt: number
|
||||||
|
updatedAt: number
|
||||||
|
finishedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundTaskInput {
|
||||||
|
sourcePage: BackgroundTaskSourcePage
|
||||||
|
title: string
|
||||||
|
detail?: string
|
||||||
|
progressText?: string
|
||||||
|
cancelable?: boolean
|
||||||
|
onCancel?: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundTaskUpdate {
|
||||||
|
title?: string
|
||||||
|
detail?: string
|
||||||
|
progressText?: string
|
||||||
|
status?: BackgroundTaskStatus
|
||||||
|
cancelable?: boolean
|
||||||
|
}
|
||||||
3
src/types/electron.d.ts
vendored
3
src/types/electron.d.ts
vendored
@@ -11,6 +11,8 @@ export interface ElectronAPI {
|
|||||||
window: {
|
window: {
|
||||||
minimize: () => void
|
minimize: () => void
|
||||||
maximize: () => void
|
maximize: () => void
|
||||||
|
isMaximized: () => Promise<boolean>
|
||||||
|
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => () => void
|
||||||
close: () => void
|
close: () => void
|
||||||
openAgreementWindow: () => Promise<boolean>
|
openAgreementWindow: () => Promise<boolean>
|
||||||
completeOnboarding: () => Promise<boolean>
|
completeOnboarding: () => Promise<boolean>
|
||||||
@@ -67,6 +69,7 @@ export interface ElectronAPI {
|
|||||||
log: {
|
log: {
|
||||||
getPath: () => Promise<string>
|
getPath: () => Promise<string>
|
||||||
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
||||||
|
clear: () => Promise<{ success: boolean; error?: string }>
|
||||||
debug: (data: any) => void
|
debug: (data: any) => void
|
||||||
}
|
}
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
|
|||||||
Reference in New Issue
Block a user