mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-14 07:25:53 +00:00
42
.github/workflows/release.yml
vendored
42
.github/workflows/release.yml
vendored
@@ -31,28 +31,12 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- 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
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
@@ -114,7 +98,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
@@ -136,7 +120,7 @@ jobs:
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null || true
|
||||
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||
@@ -165,7 +149,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
@@ -187,7 +171,7 @@ jobs:
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null || true
|
||||
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||
@@ -216,7 +200,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
@@ -238,7 +222,7 @@ jobs:
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null || true
|
||||
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||
@@ -327,10 +311,22 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update PKGBUILD version
|
||||
run: |
|
||||
NEW_VER=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||
sed -i "s/^pkgver=.*/pkgver=${NEW_VER}/" resources/installer/linux/PKGBUILD
|
||||
sed -i "s/^pkgrel=.*/pkgrel=1/" resources/installer/linux/PKGBUILD
|
||||
|
||||
- name: Publish AUR package
|
||||
uses: KSXGitHub/github-actions-deploy-aur@master
|
||||
with:
|
||||
pkgname: weflow
|
||||
pkgbuild: resources/installer/linux/PKGBUILD
|
||||
updpkgsums: true
|
||||
assets: |
|
||||
resources/installer/linux/weflow.desktop
|
||||
resources/installer/linux/icon.png
|
||||
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_username: H3CoF6
|
||||
commit_email: h3cof6@gmail.com
|
||||
|
||||
@@ -950,8 +950,17 @@ function closeSplash() {
|
||||
/**
|
||||
* 创建首次引导窗口
|
||||
*/
|
||||
function createOnboardingWindow() {
|
||||
function createOnboardingWindow(mode: 'default' | 'add-account' = 'default') {
|
||||
const onboardingHash = mode === 'add-account'
|
||||
? '/onboarding-window?mode=add-account'
|
||||
: '/onboarding-window'
|
||||
|
||||
if (onboardingWindow && !onboardingWindow.isDestroyed()) {
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`)
|
||||
} else {
|
||||
onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash })
|
||||
}
|
||||
onboardingWindow.focus()
|
||||
return onboardingWindow
|
||||
}
|
||||
@@ -987,9 +996,9 @@ function createOnboardingWindow() {
|
||||
})
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/onboarding-window`)
|
||||
onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`)
|
||||
} else {
|
||||
onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/onboarding-window' })
|
||||
onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash })
|
||||
}
|
||||
|
||||
onboardingWindow.on('closed', () => {
|
||||
@@ -2260,6 +2269,39 @@ function registerIpcHandlers() {
|
||||
const defaultValue = key === 'lastSession' ? '' : {}
|
||||
cfg.set(key as any, defaultValue as any)
|
||||
}
|
||||
|
||||
try {
|
||||
const dbPath = String(cfg.get('dbPath') || '').trim()
|
||||
const automationMapRaw = cfg.get('exportAutomationTaskMap') as Record<string, unknown> | undefined
|
||||
if (automationMapRaw && typeof automationMapRaw === 'object') {
|
||||
const nextAutomationMap: Record<string, unknown> = { ...automationMapRaw }
|
||||
let changed = false
|
||||
for (const scopeKey of Object.keys(automationMapRaw)) {
|
||||
const normalizedScopeKey = String(scopeKey || '').trim()
|
||||
if (!normalizedScopeKey) continue
|
||||
const separatorIndex = normalizedScopeKey.lastIndexOf('::')
|
||||
const scopedDbPath = separatorIndex >= 0
|
||||
? normalizedScopeKey.slice(0, separatorIndex)
|
||||
: ''
|
||||
const scopedWxidRaw = separatorIndex >= 0
|
||||
? normalizedScopeKey.slice(separatorIndex + 2)
|
||||
: normalizedScopeKey
|
||||
const scopedWxid = normalizeAccountId(scopedWxidRaw)
|
||||
const wxidMatched = wxidCandidates.includes(scopedWxidRaw) || scopedWxid === normalizedWxid
|
||||
const dbPathMatched = !dbPath || !scopedDbPath || scopedDbPath === dbPath
|
||||
if (!wxidMatched || !dbPathMatched) continue
|
||||
delete nextAutomationMap[scopeKey]
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
cfg.set('exportAutomationTaskMap' as any, nextAutomationMap as any)
|
||||
} else if (!Object.keys(automationMapRaw).length) {
|
||||
cfg.set('exportAutomationTaskMap' as any, {} as any)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
warnings.push(`清理自动化导出任务失败: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (clearCache) {
|
||||
@@ -3019,12 +3061,13 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 重新打开首次引导窗口,并隐藏主窗口
|
||||
ipcMain.handle('window:openOnboardingWindow', async () => {
|
||||
ipcMain.handle('window:openOnboardingWindow', async (_, options?: { mode?: 'add-account' }) => {
|
||||
shouldShowMain = false
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.hide()
|
||||
}
|
||||
createOnboardingWindow()
|
||||
const mode = options?.mode === 'add-account' ? 'add-account' : 'default'
|
||||
createOnboardingWindow(mode)
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||
openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options),
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||
|
||||
@@ -71,6 +71,7 @@ interface ConfigSchema {
|
||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||
wordCloudExcludeWords: string[]
|
||||
exportWriteLayout: 'A' | 'B' | 'C'
|
||||
exportAutomationTaskMap: Record<string, unknown>
|
||||
|
||||
// AI 见解
|
||||
aiModelApiBaseUrl: string
|
||||
@@ -102,6 +103,8 @@ interface ConfigSchema {
|
||||
// AI 足迹
|
||||
aiFootprintEnabled: boolean
|
||||
aiFootprintSystemPrompt: string
|
||||
/** 是否将 AI 见解调试日志输出到桌面 */
|
||||
aiInsightDebugLogEnabled: boolean
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
@@ -185,6 +188,7 @@ export class ConfigService {
|
||||
quoteLayout: 'quote-top',
|
||||
wordCloudExcludeWords: [],
|
||||
exportWriteLayout: 'A',
|
||||
exportAutomationTaskMap: {},
|
||||
aiModelApiBaseUrl: '',
|
||||
aiModelApiKey: '',
|
||||
aiModelApiModel: 'gpt-4o-mini',
|
||||
@@ -204,7 +208,8 @@ export class ConfigService {
|
||||
aiInsightTelegramToken: '',
|
||||
aiInsightTelegramChatIds: '',
|
||||
aiFootprintEnabled: false,
|
||||
aiFootprintSystemPrompt: ''
|
||||
aiFootprintSystemPrompt: '',
|
||||
aiInsightDebugLogEnabled: false
|
||||
}
|
||||
|
||||
const storeOptions: any = {
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { URL } from 'url'
|
||||
import { Notification } from 'electron'
|
||||
import { app, Notification } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, ChatSession, Message } from './chatService'
|
||||
|
||||
@@ -33,6 +35,8 @@ const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000
|
||||
|
||||
/** 单次 API 请求超时(毫秒) */
|
||||
const API_TIMEOUT_MS = 45_000
|
||||
const API_MAX_TOKENS = 200
|
||||
const API_TEMPERATURE = 0.7
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
@@ -62,15 +66,74 @@ interface SharedAiModelConfig {
|
||||
|
||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||
|
||||
let debugLogWriteQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function formatDebugTimestamp(date: Date = new Date()): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function getInsightDebugLogFilePath(date: Date = new Date()): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return path.join(app.getPath('desktop'), `weflow-ai-insight-debug-${year}-${month}-${day}.log`)
|
||||
}
|
||||
|
||||
function isInsightDebugLogEnabled(): boolean {
|
||||
try {
|
||||
return ConfigService.getInstance().get('aiInsightDebugLogEnabled') === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function appendInsightDebugText(text: string): void {
|
||||
if (!isInsightDebugLogEnabled()) return
|
||||
|
||||
let logFilePath = ''
|
||||
try {
|
||||
logFilePath = getInsightDebugLogFilePath()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogWriteQueue = debugLogWriteQueue
|
||||
.then(() => fs.promises.appendFile(logFilePath, text, 'utf8'))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function insightDebugLine(level: InsightLogLevel, message: string): void {
|
||||
appendInsightDebugText(`[${formatDebugTimestamp()}] [${level}] ${message}\n`)
|
||||
}
|
||||
|
||||
function insightDebugSection(level: InsightLogLevel, title: string, payload: unknown): void {
|
||||
const content = typeof payload === 'string'
|
||||
? payload
|
||||
: JSON.stringify(payload, null, 2)
|
||||
|
||||
appendInsightDebugText(
|
||||
`\n========== [${formatDebugTimestamp()}] [${level}] ${title} ==========\n${content}\n========== END ==========\n`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅输出到 console,不落盘到文件。
|
||||
*/
|
||||
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
|
||||
function insightLog(level: InsightLogLevel, message: string): void {
|
||||
if (level === 'ERROR' || level === 'WARN') {
|
||||
console.warn(`[InsightService] ${message}`)
|
||||
} else {
|
||||
console.log(`[InsightService] ${message}`)
|
||||
}
|
||||
insightDebugLine(level, message)
|
||||
}
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
@@ -127,8 +190,8 @@ function callApi(
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
max_tokens: 200,
|
||||
temperature: 0.7,
|
||||
max_tokens: API_MAX_TOKENS,
|
||||
temperature: API_TEMPERATURE,
|
||||
stream: false
|
||||
})
|
||||
|
||||
@@ -336,15 +399,35 @@ class InsightService {
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }]
|
||||
insightDebugSection(
|
||||
'INFO',
|
||||
'AI 测试连接请求',
|
||||
[
|
||||
`Endpoint: ${endpoint}`,
|
||||
`Model: ${model}`,
|
||||
'',
|
||||
'用户提示词:',
|
||||
requestMessages[0].content
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[{ role: 'user', content: '请回复"连接成功"四个字。' }],
|
||||
requestMessages,
|
||||
15_000
|
||||
)
|
||||
insightDebugSection('INFO', 'AI 测试连接输出原文', result)
|
||||
return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` }
|
||||
} catch (e) {
|
||||
insightDebugSection(
|
||||
'ERROR',
|
||||
'AI 测试连接失败',
|
||||
`错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}`
|
||||
)
|
||||
return { success: false, message: `连接失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
@@ -522,6 +605,105 @@ ${topMentionText}
|
||||
return { apiBaseUrl, apiKey, model }
|
||||
}
|
||||
|
||||
private looksLikeWxid(text: string): boolean {
|
||||
const normalized = String(text || '').trim()
|
||||
if (!normalized) return false
|
||||
return /^wxid_[a-z0-9]+$/i.test(normalized)
|
||||
|| /^[a-z0-9_]+@chatroom$/i.test(normalized)
|
||||
}
|
||||
|
||||
private looksLikeXmlPayload(text: string): boolean {
|
||||
const normalized = String(text || '').trim()
|
||||
if (!normalized) return false
|
||||
return /^(<\?xml|<msg\b|<appmsg\b|<img\b|<emoji\b|<voip\b|<sysmsg\b|<\?xml|<msg\b|<appmsg\b)/i.test(normalized)
|
||||
}
|
||||
|
||||
private normalizeInsightText(text: string): string {
|
||||
return String(text || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\u0000/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
private formatInsightMessageTimestamp(createTime: number): string {
|
||||
const ms = createTime > 1_000_000_000_000 ? createTime : createTime * 1000
|
||||
const date = new Date(ms)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
private async resolveInsightSessionDisplayName(sessionId: string, fallbackDisplayName: string): Promise<string> {
|
||||
const fallback = String(fallbackDisplayName || '').trim()
|
||||
if (fallback && !this.looksLikeWxid(fallback)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await this.getSessionsCached()
|
||||
const matched = sessions.find((session) => String(session.username || '').trim() === sessionId)
|
||||
const cachedDisplayName = String(matched?.displayName || '').trim()
|
||||
if (cachedDisplayName && !this.looksLikeWxid(cachedDisplayName)) {
|
||||
return cachedDisplayName
|
||||
}
|
||||
} catch {
|
||||
// ignore display name lookup failures
|
||||
}
|
||||
|
||||
try {
|
||||
const contact = await chatService.getContactAvatar(sessionId)
|
||||
const contactDisplayName = String(contact?.displayName || '').trim()
|
||||
if (contactDisplayName && !this.looksLikeWxid(contactDisplayName)) {
|
||||
return contactDisplayName
|
||||
}
|
||||
} catch {
|
||||
// ignore display name lookup failures
|
||||
}
|
||||
|
||||
return fallback || sessionId
|
||||
}
|
||||
|
||||
private formatInsightMessageContent(message: Message): string {
|
||||
const parsedContent = this.normalizeInsightText(String(message.parsedContent || ''))
|
||||
const quotedPreview = this.normalizeInsightText(String(message.quotedContent || ''))
|
||||
const quotedSender = this.normalizeInsightText(String(message.quotedSender || ''))
|
||||
|
||||
if (quotedPreview) {
|
||||
const cleanQuotedSender = quotedSender && !this.looksLikeWxid(quotedSender) ? quotedSender : ''
|
||||
const quoteLabel = cleanQuotedSender ? `${cleanQuotedSender}:${quotedPreview}` : quotedPreview
|
||||
const replyText = parsedContent && parsedContent !== '[引用消息]' ? parsedContent : ''
|
||||
return replyText ? `${replyText}[引用 ${quoteLabel}]` : `[引用 ${quoteLabel}]`
|
||||
}
|
||||
|
||||
if (parsedContent) {
|
||||
return parsedContent
|
||||
}
|
||||
|
||||
const rawContent = this.normalizeInsightText(String(message.rawContent || ''))
|
||||
if (rawContent && !this.looksLikeXmlPayload(rawContent)) {
|
||||
return rawContent
|
||||
}
|
||||
|
||||
return '[其他消息]'
|
||||
}
|
||||
|
||||
private buildInsightContextSection(messages: Message[], peerDisplayName: string): string {
|
||||
if (!messages.length) return ''
|
||||
|
||||
const lines = messages.map((message) => {
|
||||
const senderName = message.isSend === 1 ? '我' : peerDisplayName
|
||||
const content = this.formatInsightMessageContent(message)
|
||||
return `${this.formatInsightMessageTimestamp(message.createTime)} '${senderName}'\n${content}`
|
||||
})
|
||||
|
||||
return `近期聊天记录(最近 ${lines.length} 条):\n\n${lines.join('\n\n')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个会话是否允许触发见解。
|
||||
* 若白名单未启用,则所有私聊会话均允许;
|
||||
@@ -817,6 +999,7 @@ ${topMentionText}
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
const allowContext = this.config.get('aiInsightAllowContext') as boolean
|
||||
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
|
||||
const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName)
|
||||
|
||||
insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
|
||||
|
||||
@@ -837,14 +1020,8 @@ ${topMentionText}
|
||||
const msgsResult = await chatService.getLatestMessages(sessionId, contextCount)
|
||||
if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) {
|
||||
const messages: Message[] = msgsResult.messages
|
||||
const msgLines = messages.map((m) => {
|
||||
const sender = m.isSend === 1 ? '我' : (displayName || sessionId)
|
||||
const content = m.rawContent || m.parsedContent || '[非文字消息]'
|
||||
const time = new Date(Number(m.createTime) * 1000).toLocaleString('zh-CN')
|
||||
return `[${time}] ${sender}:${content}`
|
||||
})
|
||||
contextSection = `\n\n近期对话记录(最近 ${msgLines.length} 条):\n${msgLines.join('\n')}`
|
||||
insightLog('INFO', `已加载 ${msgLines.length} 条上下文消息`)
|
||||
contextSection = this.buildInsightContextSection(messages, resolvedDisplayName)
|
||||
insightLog('INFO', `已加载 ${messages.length} 条上下文消息`)
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('WARN', `拉取上下文失败: ${(e as Error).message}`)
|
||||
@@ -868,48 +1045,71 @@ ${topMentionText}
|
||||
// 这样 provider 端(Anthropic/OpenAI)能最大化命中 prompt cache,降低费用
|
||||
const triggerDesc =
|
||||
triggerReason === 'silence'
|
||||
? `你已经 ${silentDays} 天没有和「${displayName}」聊天了。`
|
||||
: `你最近和「${displayName}」有新的聊天动态。`
|
||||
? `你已经 ${silentDays} 天没有和「${resolvedDisplayName}」聊天了。`
|
||||
: `你最近和「${resolvedDisplayName}」有新的聊天动态。`
|
||||
|
||||
const todayStatsDesc =
|
||||
sessionTriggerTimes.length > 1
|
||||
? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
|
||||
: `今天你还没有针对「${displayName}」发出过见解。`
|
||||
? `今天你已经针对「${resolvedDisplayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
|
||||
: `今天你还没有针对「${resolvedDisplayName}」发出过见解。`
|
||||
|
||||
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
|
||||
|
||||
const userPrompt = `触发原因:${triggerDesc}
|
||||
时间统计:${todayStatsDesc} ${globalStatsDesc}${contextSection}
|
||||
|
||||
请给出你的见解(≤80字):`
|
||||
const userPrompt = [
|
||||
`触发原因:${triggerDesc}`,
|
||||
`时间统计:${todayStatsDesc}`,
|
||||
`全局统计:${globalStatsDesc}`,
|
||||
contextSection,
|
||||
'请给出你的见解(≤80字):'
|
||||
].filter(Boolean).join('\n\n')
|
||||
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
const requestMessages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
]
|
||||
|
||||
insightLog('INFO', `准备调用 API: ${endpoint},模型: ${model}`)
|
||||
insightDebugSection(
|
||||
'INFO',
|
||||
`AI 请求 ${resolvedDisplayName} (${sessionId})`,
|
||||
[
|
||||
`接口地址:${endpoint}`,
|
||||
`模型:${model}`,
|
||||
`触发原因:${triggerReason}`,
|
||||
`上下文开关:${allowContext ? '开启' : '关闭'}`,
|
||||
`上下文条数:${contextCount}`,
|
||||
'',
|
||||
'系统提示词:',
|
||||
systemPrompt,
|
||||
'',
|
||||
'用户提示词:',
|
||||
userPrompt
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
]
|
||||
requestMessages
|
||||
)
|
||||
|
||||
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
|
||||
insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result)
|
||||
|
||||
// 模型主动选择跳过
|
||||
if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) {
|
||||
insightLog('INFO', `模型选择跳过 ${displayName}`)
|
||||
insightLog('INFO', `模型选择跳过 ${resolvedDisplayName}`)
|
||||
return
|
||||
}
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${displayName}`
|
||||
const notifTitle = `见解 · ${resolvedDisplayName}`
|
||||
|
||||
insightLog('INFO', `推送通知 → ${displayName}: ${insight}`)
|
||||
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
|
||||
|
||||
// 渠道一:Electron 原生系统通知
|
||||
if (Notification.isSupported()) {
|
||||
@@ -937,9 +1137,14 @@ ${topMentionText}
|
||||
}
|
||||
}
|
||||
|
||||
insightLog('INFO', `已为 ${displayName} 推送见解`)
|
||||
insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`)
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`)
|
||||
insightDebugSection(
|
||||
'ERROR',
|
||||
`AI 请求失败 ${resolvedDisplayName} (${sessionId})`,
|
||||
`错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}`
|
||||
)
|
||||
insightLog('ERROR', `API 调用失败 (${resolvedDisplayName}): ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import crypto from 'crypto'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
|
||||
export class KeyService {
|
||||
private readonly isMac = process.platform === 'darwin'
|
||||
@@ -814,7 +814,7 @@ export class KeyService {
|
||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: true }
|
||||
}
|
||||
}
|
||||
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||
@@ -826,7 +826,7 @@ export class KeyService {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: false }
|
||||
}
|
||||
|
||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { execFile, exec, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -10,7 +11,7 @@ const execFileAsync = promisify(execFile)
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
|
||||
export class KeyServiceLinux {
|
||||
private sudo: any
|
||||
@@ -243,7 +244,14 @@ export class KeyServiceLinux {
|
||||
if (account && account.keys && account.keys.length > 0) {
|
||||
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
||||
const keyObj = account.keys[0]
|
||||
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
|
||||
const aesKey = String(keyObj.aesKey || '')
|
||||
const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey)
|
||||
if (verified === true) {
|
||||
onProgress?.('缓存密钥校验成功,已确认可用')
|
||||
} else if (verified === false) {
|
||||
onProgress?.('已从缓存计算密钥,但未通过本地模板校验')
|
||||
}
|
||||
return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true }
|
||||
}
|
||||
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
||||
} catch (err: any) {
|
||||
@@ -251,6 +259,35 @@ export class KeyServiceLinux {
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise<boolean | null> {
|
||||
const normalizedPath = String(accountPath || '').trim()
|
||||
if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null
|
||||
try {
|
||||
const template = await this._findTemplateData(normalizedPath, 32)
|
||||
if (!template.ciphertext) return null
|
||||
return this.verifyDerivedAesKey(aesKey, template.ciphertext)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
public async autoGetImageKeyByMemoryScan(
|
||||
accountPath: string,
|
||||
onProgress?: (msg: string) => void
|
||||
|
||||
@@ -7,7 +7,7 @@ import crypto from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export class KeyServiceMac {
|
||||
@@ -647,7 +647,7 @@ export class KeyServiceMac {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,7 +662,7 @@ export class KeyServiceMac {
|
||||
const fallbackCode = codes[0]
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: false }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
|
||||
}
|
||||
|
||||
32
package.json
32
package.json
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hicccc77/WeFlow"
|
||||
"url": "https://github.com/Jasonzhu1207/WeFlow"
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
@@ -23,7 +23,6 @@
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -42,8 +41,9 @@
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.12.35",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -54,11 +54,11 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"sass": "^1.99.0",
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -70,16 +70,14 @@
|
||||
"lodash": ">=4.17.21",
|
||||
"brace-expansion": ">=1.1.11",
|
||||
"picomatch": ">=2.3.1",
|
||||
"ajv": ">=8.18.0",
|
||||
"ajv-keywords@3>ajv": "^6.12.6",
|
||||
"@develar/schema-utils>ajv": "^6.12.6"
|
||||
"ajv": ">=8.18.0"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "hicccc77",
|
||||
"owner": "Jasonzhu1207",
|
||||
"repo": "WeFlow",
|
||||
"releaseType": "release"
|
||||
},
|
||||
@@ -98,7 +96,7 @@
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
"icon": "resources/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
@@ -107,19 +105,19 @@
|
||||
"icon": "public/icon.ico",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/runtime/win32/msvcp140.dll",
|
||||
"from": "resources/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
@@ -135,7 +133,7 @@
|
||||
"synopsis": "WeFlow for Linux",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/installer/linux/install.sh",
|
||||
"from": "resources/linux/install.sh",
|
||||
"to": "install.sh"
|
||||
}
|
||||
]
|
||||
@@ -190,7 +188,7 @@
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
"icon": "resources/icon.icns"
|
||||
},
|
||||
"overrides": {
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
30
resources/installer/linux/PKGBUILD
Normal file
30
resources/installer/linux/PKGBUILD
Normal file
@@ -0,0 +1,30 @@
|
||||
# Maintainer: H3CoF6 <h3cof6@gmail.com>
|
||||
pkgname=weflow
|
||||
pkgver=4.3.0
|
||||
pkgrel=1
|
||||
pkgdesc="A local WeChat database decryption and analysis tool"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/hicccc77/weflow"
|
||||
license=('CC-BY-NC-SA-4.0')
|
||||
depends=('alsa-lib' 'gtk3' 'nss' 'glibc')
|
||||
options=('!strip' '!debug')
|
||||
|
||||
source=("WeFlow-${pkgver}-Setup.tar.gz::${url}/releases/download/v${pkgver}/WeFlow-${pkgver}-Setup.tar.gz"
|
||||
"weflow.desktop"
|
||||
"icon.png")
|
||||
|
||||
sha256sums=('2859aca2f57c42f4d1516ed229613623c57d3e78b9cb152fcb2b9c1096ab9340'
|
||||
'2cf03766f5c2f1915ad136f060a66f5788ed32b06defe1956e406c73d7e733b7'
|
||||
'b1c412d9c08ae683e231173c16fe73958ad1063f14c9b3852373385e4fcb6f33')
|
||||
|
||||
package() {
|
||||
install -dm755 "${pkgdir}/opt/${pkgname}"
|
||||
|
||||
cp -a "${srcdir}/WeFlow-${pkgver}-Setup/"* "${pkgdir}/opt/${pkgname}/"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
ln -s "/opt/${pkgname}/weflow" "${pkgdir}/usr/bin/${pkgname}"
|
||||
|
||||
install -Dm644 "${srcdir}/weflow.desktop" -t "${pkgdir}/usr/share/applications/"
|
||||
install -Dm644 "${srcdir}/icon.png" "${pkgdir}/usr/share/pixmaps/${pkgname}.png"
|
||||
}
|
||||
BIN
resources/installer/linux/icon.png
Normal file
BIN
resources/installer/linux/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
9
resources/installer/linux/weflow.desktop
Normal file
9
resources/installer/linux/weflow.desktop
Normal file
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Name=WeFlow
|
||||
Comment=一个本地的微信聊天记录导出和年度报告应用
|
||||
Exec=/usr/bin/weflow %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=weflow
|
||||
StartupWMClass=WeFlow
|
||||
Categories=Utility;
|
||||
@@ -26,6 +26,7 @@ import ContactsPage from './pages/ContactsPage'
|
||||
import ResourcesPage from './pages/ResourcesPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
import AccountManagementPage from './pages/AccountManagementPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||
@@ -678,6 +679,7 @@ function App() {
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/account-management" element={<AccountManagementPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 2400;
|
||||
z-index: 9200;
|
||||
}
|
||||
|
||||
.export-date-range-dialog {
|
||||
|
||||
@@ -275,263 +275,6 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog {
|
||||
width: min(460px, 100%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-options {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||
[data-theme="blossom-dream"] .sidebar {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed, Footprints } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
import { UserRound } from 'lucide-react'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
@@ -19,6 +16,8 @@ interface SidebarUserProfile {
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||
const DEFAULT_DISPLAY_NAME = '微信用户'
|
||||
const DEFAULT_SUBTITLE = '微信账号'
|
||||
|
||||
interface SidebarUserProfileCache extends SidebarUserProfile {
|
||||
updatedAt: number
|
||||
@@ -33,24 +32,16 @@ interface AccountProfilesCache {
|
||||
}
|
||||
}
|
||||
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as SidebarUserProfileCache
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
if (!parsed.wxid || !parsed.displayName) return null
|
||||
if (!parsed.wxid) return null
|
||||
return {
|
||||
wxid: parsed.wxid,
|
||||
displayName: parsed.displayName,
|
||||
displayName: typeof parsed.displayName === 'string' ? parsed.displayName : '',
|
||||
alias: parsed.alias,
|
||||
avatarUrl: parsed.avatarUrl
|
||||
}
|
||||
@@ -60,7 +51,7 @@ const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
}
|
||||
|
||||
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
||||
if (!profile.wxid || !profile.displayName) return
|
||||
if (!profile.wxid) return
|
||||
try {
|
||||
const payload: SidebarUserProfileCache = {
|
||||
...profile,
|
||||
@@ -115,17 +106,11 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||
wxid: '',
|
||||
displayName: '未识别用户'
|
||||
displayName: DEFAULT_DISPLAY_NAME
|
||||
})
|
||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||
const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
|
||||
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
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(() => {
|
||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||
@@ -164,18 +149,20 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let loadSeq = 0
|
||||
|
||||
const loadCurrentUser = async () => {
|
||||
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
||||
const seq = ++loadSeq
|
||||
const patchUserProfile = (patch: Partial<SidebarUserProfile>) => {
|
||||
if (disposed || seq !== loadSeq) return
|
||||
setUserProfile(prev => {
|
||||
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
|
||||
return prev
|
||||
}
|
||||
const next: SidebarUserProfile = {
|
||||
...prev,
|
||||
...patch
|
||||
}
|
||||
if (!next.displayName) {
|
||||
next.displayName = next.wxid || '未识别用户'
|
||||
if (typeof next.displayName !== 'string' || next.displayName.length === 0) {
|
||||
next.displayName = DEFAULT_DISPLAY_NAME
|
||||
}
|
||||
writeSidebarUserProfileCache(next)
|
||||
return next
|
||||
@@ -184,11 +171,33 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
|
||||
try {
|
||||
const wxid = await configService.getMyWxid()
|
||||
if (disposed || seq !== loadSeq) return
|
||||
const resolvedWxidRaw = String(wxid || '').trim()
|
||||
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||
|
||||
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||
if (!resolvedWxidRaw && !resolvedWxid) {
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
patchUserProfile({
|
||||
wxid: '',
|
||||
displayName: DEFAULT_DISPLAY_NAME,
|
||||
alias: undefined,
|
||||
avatarUrl: undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setUserProfile((prev) => {
|
||||
if (prev.wxid === resolvedWxid) return prev
|
||||
const seeded: SidebarUserProfile = {
|
||||
wxid: resolvedWxid,
|
||||
displayName: DEFAULT_DISPLAY_NAME,
|
||||
alias: undefined,
|
||||
avatarUrl: undefined
|
||||
}
|
||||
writeSidebarUserProfileCache(seeded)
|
||||
return seeded
|
||||
})
|
||||
|
||||
const wxidCandidates = new Set<string>([
|
||||
resolvedWxidRaw.toLowerCase(),
|
||||
@@ -197,14 +206,13 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
].filter(Boolean))
|
||||
|
||||
const normalizeName = (value?: string | null): string | undefined => {
|
||||
if (!value) return undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const lowered = trimmed.toLowerCase()
|
||||
if (typeof value !== 'string') return undefined
|
||||
if (value.length === 0) return undefined
|
||||
const lowered = value.trim().toLowerCase()
|
||||
if (lowered === 'self') return undefined
|
||||
if (lowered.startsWith('wxid_')) return undefined
|
||||
if (wxidCandidates.has(lowered)) return undefined
|
||||
return trimmed
|
||||
return value
|
||||
}
|
||||
|
||||
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
|
||||
@@ -229,18 +237,20 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
})(),
|
||||
window.electronAPI.chat.getMyAvatarUrl()
|
||||
])
|
||||
if (disposed || seq !== loadSeq) return
|
||||
|
||||
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
|
||||
const displayName = pickFirstValidName(
|
||||
myContact?.remark,
|
||||
myContact?.nickName,
|
||||
myContact?.alias
|
||||
) || resolvedWxid || '未识别用户'
|
||||
) || DEFAULT_DISPLAY_NAME
|
||||
const alias = normalizeName(myContact?.alias)
|
||||
|
||||
patchUserProfile({
|
||||
wxid: resolvedWxid,
|
||||
displayName,
|
||||
alias: myContact?.alias,
|
||||
alias,
|
||||
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
|
||||
? avatarResult.value.avatarUrl
|
||||
: undefined
|
||||
@@ -257,118 +267,28 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
|
||||
void loadCurrentUser()
|
||||
const onWxidChanged = () => { void loadCurrentUser() }
|
||||
const onWindowFocus = () => { void loadCurrentUser() }
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void loadCurrentUser()
|
||||
}
|
||||
}
|
||||
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.addEventListener('focus', onWindowFocus)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => {
|
||||
disposed = true
|
||||
loadSeq += 1
|
||||
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getAvatarLetter = (name: string): string => {
|
||||
if (!name) return '?'
|
||||
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: WxidOption) => {
|
||||
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
||||
|
||||
let displayName = option.nickname || option.wxid
|
||||
let avatarUrl = option.avatarUrl
|
||||
|
||||
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||
displayName = userProfile.displayName || displayName
|
||||
avatarUrl = userProfile.avatarUrl || avatarUrl
|
||||
}
|
||||
|
||||
else if (cached) {
|
||||
displayName = cached.displayName || displayName
|
||||
avatarUrl = cached.avatarUrl || avatarUrl
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
displayName,
|
||||
avatarUrl
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
if (!name) return '微'
|
||||
const visible = name.trim()
|
||||
return (visible && [...visible][0]) || '微'
|
||||
}
|
||||
|
||||
const openSettingsFromAccountMenu = () => {
|
||||
@@ -380,6 +300,11 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const openAccountManagement = () => {
|
||||
setIsAccountMenuOpen(false)
|
||||
navigate('/account-management')
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
}
|
||||
@@ -515,12 +440,12 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
onClick={openSwitchAccountDialog}
|
||||
onClick={openAccountManagement}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<span>切换账号</span>
|
||||
<Users size={14} />
|
||||
<span>账号管理</span>
|
||||
</button>
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
@@ -534,7 +459,7 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
</div>
|
||||
<div
|
||||
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) ? `\n${userProfile.alias}` : ''}` : undefined}
|
||||
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -549,8 +474,8 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{userProfile.displayName}</div>
|
||||
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
||||
<div className="user-name">{userProfile.displayName || DEFAULT_DISPLAY_NAME}</div>
|
||||
<div className="user-wxid">{userProfile.alias || DEFAULT_SUBTITLE}</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
||||
@@ -561,44 +486,6 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{showSwitchAccountDialog && (
|
||||
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
|
||||
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>切换账号</h3>
|
||||
<p>选择要切换的微信账号</p>
|
||||
<div className="sidebar-wxid-list">
|
||||
{wxidOptions.map((option) => (
|
||||
<button
|
||||
key={option.wxid}
|
||||
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
|
||||
onClick={() => handleSwitchAccount(option.wxid)}
|
||||
disabled={isSwitchingAccount}
|
||||
type="button"
|
||||
>
|
||||
<div className="wxid-avatar">
|
||||
{option.avatarUrl ? (
|
||||
<img src={option.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
|
||||
<UserRound size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wxid-info">
|
||||
<div className="wxid-name">{option.displayName}</div>
|
||||
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
|
||||
</div>
|
||||
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-dialog-actions">
|
||||
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
274
src/pages/AccountManagementPage.scss
Normal file
274
src/pages/AccountManagementPage.scss
Normal file
@@ -0,0 +1,274 @@
|
||||
.account-management-page {
|
||||
padding: 22px 24px;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.account-management-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-management-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-management-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-notice {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notice-action {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.account-notice.success {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #15803d;
|
||||
border-color: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.account-notice.error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.account-notice.info {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.account-empty {
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 18px 14px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
&.is-current {
|
||||
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.account-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.account-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&.current {
|
||||
color: #0f766e;
|
||||
background: rgba(20, 184, 166, 0.14);
|
||||
}
|
||||
|
||||
&.ok {
|
||||
color: #166534;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.meta-tip {
|
||||
margin-left: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.account-card-actions {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
.btn {
|
||||
min-width: 104px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.account-management-footer {
|
||||
margin-top: 2px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-management-page {
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.account-management-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.account-card-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
574
src/pages/AccountManagementPage.tsx
Normal file
574
src/pages/AccountManagementPage.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { RefreshCw, UserPlus, Trash2, ArrowRightLeft, CheckCircle2, Database } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import './AccountManagementPage.scss'
|
||||
|
||||
interface ScannedWxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface ManagedAccountItem {
|
||||
wxid: string
|
||||
normalizedWxid: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
modifiedTime?: number
|
||||
configUpdatedAt?: number
|
||||
hasConfig: boolean
|
||||
isCurrent: boolean
|
||||
fromScan: boolean
|
||||
}
|
||||
|
||||
type AccountProfileCacheEntry = {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
interface DeleteUndoState {
|
||||
targetWxid: string
|
||||
deletedConfigEntries: Array<[string, configService.WxidConfig]>
|
||||
deletedProfileEntries: Array<[string, AccountProfileCacheEntry]>
|
||||
previousCurrentWxid: string
|
||||
shouldRestoreAsCurrent: boolean
|
||||
previousDbConnected: boolean
|
||||
}
|
||||
|
||||
type NoticeState =
|
||||
| { type: 'success' | 'error' | 'info'; text: string }
|
||||
| null
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||
const hiddenDeletedAccountIds = new Set<string>()
|
||||
const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户'
|
||||
|
||||
const normalizeAccountId = (value?: string | null): string => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
const resolveAccountDisplayName = (
|
||||
candidates: Array<unknown>,
|
||||
wxidCandidates: Set<string>
|
||||
): string => {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue
|
||||
if (candidate.length === 0) continue
|
||||
const normalized = candidate.trim().toLowerCase()
|
||||
if (normalized.startsWith('wxid_')) continue
|
||||
if (normalized && wxidCandidates.has(normalized)) continue
|
||||
return candidate
|
||||
}
|
||||
return DEFAULT_ACCOUNT_DISPLAY_NAME
|
||||
}
|
||||
|
||||
const resolveAccountAvatarText = (displayName?: string): string => {
|
||||
if (typeof displayName !== 'string' || displayName.length === 0) return '微'
|
||||
const visible = displayName.trim()
|
||||
return (visible && [...visible][0]) || '微'
|
||||
}
|
||||
|
||||
const readAccountProfilesCache = (): Record<string, AccountProfileCacheEntry> => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, AccountProfileCacheEntry> : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function AccountManagementPage() {
|
||||
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||
const setDbConnected = useAppStore(state => state.setDbConnected)
|
||||
const resetChatStore = useChatStore(state => state.reset)
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
const [currentWxid, setCurrentWxid] = useState('')
|
||||
const [accounts, setAccounts] = useState<ManagedAccountItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [workingWxid, setWorkingWxid] = useState('')
|
||||
const [notice, setNotice] = useState<NoticeState>(null)
|
||||
const [deleteUndoState, setDeleteUndoState] = useState<DeleteUndoState | null>(null)
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [path, rawCurrentWxid, wxidConfigs] = await Promise.all([
|
||||
configService.getDbPath(),
|
||||
configService.getMyWxid(),
|
||||
configService.getWxidConfigs()
|
||||
])
|
||||
const nextDbPath = String(path || '').trim()
|
||||
const nextCurrentWxid = String(rawCurrentWxid || '').trim()
|
||||
const normalizedCurrent = normalizeAccountId(nextCurrentWxid) || nextCurrentWxid
|
||||
setDbPath(nextDbPath)
|
||||
setCurrentWxid(nextCurrentWxid)
|
||||
|
||||
let scannedWxids: ScannedWxidOption[] = []
|
||||
if (nextDbPath) {
|
||||
try {
|
||||
const scanned = await window.electronAPI.dbPath.scanWxids(nextDbPath)
|
||||
scannedWxids = Array.isArray(scanned) ? scanned as ScannedWxidOption[] : []
|
||||
} catch {
|
||||
scannedWxids = []
|
||||
}
|
||||
}
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
const configEntries = Object.entries(wxidConfigs || {})
|
||||
const configByNormalized = new Map<string, { key: string; value: configService.WxidConfig }>()
|
||||
for (const [wxid, cfg] of configEntries) {
|
||||
const normalized = normalizeAccountId(wxid) || wxid
|
||||
if (!normalized) continue
|
||||
const previous = configByNormalized.get(normalized)
|
||||
if (!previous || Number(cfg?.updatedAt || 0) > Number(previous.value?.updatedAt || 0)) {
|
||||
configByNormalized.set(normalized, { key: wxid, value: cfg || {} })
|
||||
}
|
||||
}
|
||||
|
||||
const merged = new Map<string, ManagedAccountItem>()
|
||||
for (const scanned of scannedWxids) {
|
||||
const normalized = normalizeAccountId(scanned.wxid) || scanned.wxid
|
||||
if (!normalized) continue
|
||||
const cached = accountProfileCache[scanned.wxid] || accountProfileCache[normalized]
|
||||
const matchedConfig = configByNormalized.get(normalized)
|
||||
const wxidCandidates = new Set<string>([
|
||||
String(scanned.wxid || '').trim().toLowerCase(),
|
||||
String(normalized || '').trim().toLowerCase()
|
||||
].filter(Boolean))
|
||||
const displayName = resolveAccountDisplayName(
|
||||
[scanned.nickname, cached?.displayName],
|
||||
wxidCandidates
|
||||
)
|
||||
merged.set(normalized, {
|
||||
wxid: scanned.wxid,
|
||||
normalizedWxid: normalized,
|
||||
displayName,
|
||||
avatarUrl: scanned.avatarUrl || cached?.avatarUrl,
|
||||
modifiedTime: Number(scanned.modifiedTime || 0),
|
||||
configUpdatedAt: Number(matchedConfig?.value?.updatedAt || 0),
|
||||
hasConfig: Boolean(matchedConfig),
|
||||
isCurrent: Boolean(normalizedCurrent) && normalized === normalizedCurrent,
|
||||
fromScan: true
|
||||
})
|
||||
}
|
||||
|
||||
for (const [normalized, matchedConfig] of configByNormalized.entries()) {
|
||||
if (merged.has(normalized)) continue
|
||||
const wxid = matchedConfig.key
|
||||
const cached = accountProfileCache[wxid] || accountProfileCache[normalized]
|
||||
const wxidCandidates = new Set<string>([
|
||||
String(wxid || '').trim().toLowerCase(),
|
||||
String(normalized || '').trim().toLowerCase()
|
||||
].filter(Boolean))
|
||||
const displayName = resolveAccountDisplayName(
|
||||
[cached?.displayName],
|
||||
wxidCandidates
|
||||
)
|
||||
merged.set(normalized, {
|
||||
wxid,
|
||||
normalizedWxid: normalized,
|
||||
displayName,
|
||||
avatarUrl: cached?.avatarUrl,
|
||||
modifiedTime: 0,
|
||||
configUpdatedAt: Number(matchedConfig.value?.updatedAt || 0),
|
||||
hasConfig: true,
|
||||
isCurrent: Boolean(normalizedCurrent) && normalized === normalizedCurrent,
|
||||
fromScan: false
|
||||
})
|
||||
}
|
||||
|
||||
// 被“删除配置”操作移除的账号,在当前会话中从列表隐藏;
|
||||
// 若后续再次生成配置,则自动恢复展示。
|
||||
for (const [normalized, item] of Array.from(merged.entries())) {
|
||||
if (!hiddenDeletedAccountIds.has(normalized)) continue
|
||||
if (item.hasConfig) {
|
||||
hiddenDeletedAccountIds.delete(normalized)
|
||||
continue
|
||||
}
|
||||
merged.delete(normalized)
|
||||
}
|
||||
|
||||
const nextAccounts = Array.from(merged.values()).sort((a, b) => {
|
||||
if (a.isCurrent && !b.isCurrent) return -1
|
||||
if (!a.isCurrent && b.isCurrent) return 1
|
||||
const scanDiff = Number(b.modifiedTime || 0) - Number(a.modifiedTime || 0)
|
||||
if (scanDiff !== 0) return scanDiff
|
||||
return Number(b.configUpdatedAt || 0) - Number(a.configUpdatedAt || 0)
|
||||
})
|
||||
setAccounts(nextAccounts)
|
||||
} catch (error) {
|
||||
console.error('加载账号列表失败:', error)
|
||||
setNotice({ type: 'error', text: '加载账号列表失败,请稍后重试' })
|
||||
setAccounts([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts()
|
||||
const onWxidChanged = () => { void loadAccounts() }
|
||||
const onWindowFocus = () => { void loadAccounts() }
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void loadAccounts()
|
||||
}
|
||||
}
|
||||
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.addEventListener('focus', onWindowFocus)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => {
|
||||
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [loadAccounts])
|
||||
|
||||
const clearRuntimeCacheState = useCallback(async () => {
|
||||
if (isDbConnected) {
|
||||
await window.electronAPI.chat.close()
|
||||
}
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
clearAnalyticsStoreCache()
|
||||
resetChatStore()
|
||||
}, [clearAnalyticsStoreCache, isDbConnected, resetChatStore])
|
||||
|
||||
const applyWxidConfig = useCallback(async (wxid: string, wxidConfig: configService.WxidConfig | null) => {
|
||||
await configService.setMyWxid(wxid)
|
||||
await configService.setDecryptKey(wxidConfig?.decryptKey || '')
|
||||
await configService.setImageXorKey(typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : 0)
|
||||
await configService.setImageAesKey(wxidConfig?.imageAesKey || '')
|
||||
}, [])
|
||||
|
||||
const handleSwitchAccount = useCallback(async (wxid: string) => {
|
||||
if (!wxid || workingWxid) return
|
||||
const targetNormalized = normalizeAccountId(wxid) || wxid
|
||||
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
|
||||
if (targetNormalized && currentNormalized && targetNormalized === currentNormalized) return
|
||||
|
||||
setWorkingWxid(wxid)
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
const allConfigs = await configService.getWxidConfigs()
|
||||
const configEntries = Object.entries(allConfigs || {})
|
||||
const matched = configEntries.find(([key]) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === wxid || normalized === targetNormalized
|
||||
})
|
||||
const targetConfig = matched?.[1] || null
|
||||
await applyWxidConfig(wxid, targetConfig)
|
||||
await clearRuntimeCacheState()
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid } }))
|
||||
setNotice({ type: 'success', text: `已切换到账号「${wxid}」` })
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('切换账号失败:', error)
|
||||
setNotice({ type: 'error', text: '切换账号失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, loadAccounts, workingWxid])
|
||||
|
||||
const handleAddAccount = useCallback(async () => {
|
||||
if (workingWxid) return
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
await window.electronAPI.window.openOnboardingWindow({ mode: 'add-account' })
|
||||
await loadAccounts()
|
||||
const latestWxid = String(await configService.getMyWxid() || '').trim()
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: latestWxid } }))
|
||||
} catch (error) {
|
||||
console.error('打开添加账号引导失败:', error)
|
||||
setNotice({ type: 'error', text: '打开添加账号引导失败,请稍后重试' })
|
||||
}
|
||||
}, [loadAccounts, workingWxid])
|
||||
|
||||
const handleDeleteAccountConfig = useCallback(async (targetWxid: string) => {
|
||||
if (!targetWxid || workingWxid) return
|
||||
|
||||
const normalizedTarget = normalizeAccountId(targetWxid) || targetWxid
|
||||
|
||||
setWorkingWxid(targetWxid)
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
const allConfigs = await configService.getWxidConfigs()
|
||||
const nextConfigs: Record<string, configService.WxidConfig> = { ...allConfigs }
|
||||
const matchedKeys = Object.keys(nextConfigs).filter((key) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === targetWxid || normalized === normalizedTarget
|
||||
})
|
||||
|
||||
if (matchedKeys.length === 0) {
|
||||
setNotice({ type: 'info', text: `账号「${targetWxid}」暂无可删除配置` })
|
||||
return
|
||||
}
|
||||
|
||||
const deletedConfigEntries: Array<[string, configService.WxidConfig]> = matchedKeys.map((key) => [key, nextConfigs[key] || {}])
|
||||
for (const key of matchedKeys) {
|
||||
delete nextConfigs[key]
|
||||
}
|
||||
await configService.setWxidConfigs(nextConfigs)
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
const deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> = []
|
||||
for (const key of Object.keys(accountProfileCache)) {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
if (key === targetWxid || normalized === normalizedTarget) {
|
||||
deletedProfileEntries.push([key, accountProfileCache[key]])
|
||||
delete accountProfileCache[key]
|
||||
}
|
||||
}
|
||||
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
|
||||
|
||||
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
|
||||
const isDeletingCurrent = Boolean(currentNormalized && currentNormalized === normalizedTarget)
|
||||
const undoPayload: DeleteUndoState = {
|
||||
targetWxid,
|
||||
deletedConfigEntries,
|
||||
deletedProfileEntries,
|
||||
previousCurrentWxid: currentWxid,
|
||||
shouldRestoreAsCurrent: isDeletingCurrent,
|
||||
previousDbConnected: isDbConnected
|
||||
}
|
||||
|
||||
if (isDeletingCurrent) {
|
||||
await clearRuntimeCacheState()
|
||||
|
||||
const remainingEntries = Object.entries(nextConfigs)
|
||||
.filter(([wxid]) => Boolean(String(wxid || '').trim()))
|
||||
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))
|
||||
|
||||
if (remainingEntries.length > 0) {
|
||||
const [nextWxid, nextConfig] = remainingEntries[0]
|
||||
await applyWxidConfig(nextWxid, nextConfig || null)
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } }))
|
||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}」` })
|
||||
await loadAccounts()
|
||||
return
|
||||
}
|
||||
|
||||
await configService.setMyWxid('')
|
||||
await configService.setDecryptKey('')
|
||||
await configService.setImageXorKey(0)
|
||||
await configService.setImageAesKey('')
|
||||
setDbConnected(false)
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } }))
|
||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` })
|
||||
await loadAccounts()
|
||||
return
|
||||
}
|
||||
|
||||
hiddenDeletedAccountIds.add(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` })
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('删除账号配置失败:', error)
|
||||
setNotice({ type: 'error', text: '删除账号配置失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, isDbConnected, loadAccounts, setDbConnected, workingWxid])
|
||||
|
||||
const handleUndoDelete = useCallback(async () => {
|
||||
if (!deleteUndoState || workingWxid) return
|
||||
|
||||
setWorkingWxid(`undo:${deleteUndoState.targetWxid}`)
|
||||
setNotice(null)
|
||||
try {
|
||||
const currentConfigs = await configService.getWxidConfigs()
|
||||
const restoredConfigs: Record<string, configService.WxidConfig> = { ...currentConfigs }
|
||||
for (const [key, configValue] of deleteUndoState.deletedConfigEntries) {
|
||||
restoredConfigs[key] = configValue || {}
|
||||
}
|
||||
await configService.setWxidConfigs(restoredConfigs)
|
||||
hiddenDeletedAccountIds.delete(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid)
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
for (const [key, profile] of deleteUndoState.deletedProfileEntries) {
|
||||
accountProfileCache[key] = profile
|
||||
}
|
||||
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
|
||||
|
||||
if (deleteUndoState.shouldRestoreAsCurrent && deleteUndoState.previousCurrentWxid) {
|
||||
const previousNormalized = normalizeAccountId(deleteUndoState.previousCurrentWxid) || deleteUndoState.previousCurrentWxid
|
||||
const restoreConfigEntry = Object.entries(restoredConfigs)
|
||||
.filter(([key]) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === deleteUndoState.previousCurrentWxid || normalized === previousNormalized
|
||||
})
|
||||
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))[0]
|
||||
const restoreConfig = restoreConfigEntry?.[1] || null
|
||||
|
||||
await clearRuntimeCacheState()
|
||||
await applyWxidConfig(deleteUndoState.previousCurrentWxid, restoreConfig)
|
||||
if (deleteUndoState.previousDbConnected) {
|
||||
setDbConnected(true, dbPath || undefined)
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: deleteUndoState.previousCurrentWxid } }))
|
||||
}
|
||||
|
||||
setNotice({ type: 'success', text: `已撤回删除,账号「${deleteUndoState.targetWxid}」配置已恢复` })
|
||||
setDeleteUndoState(null)
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('撤回删除失败:', error)
|
||||
setNotice({ type: 'error', text: '撤回删除失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, dbPath, deleteUndoState, loadAccounts, setDbConnected, workingWxid])
|
||||
|
||||
const currentAccountLabel = useMemo(() => {
|
||||
if (!currentWxid) return '未设置'
|
||||
return currentWxid
|
||||
}, [currentWxid])
|
||||
|
||||
const formatTime = (value?: number): string => {
|
||||
const ts = Number(value || 0)
|
||||
if (!ts) return '未知'
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-management-page">
|
||||
<header className="account-management-header">
|
||||
<div>
|
||||
<h2>账号管理</h2>
|
||||
<p>统一管理切换账号、添加账号、删除账号配置。</p>
|
||||
</div>
|
||||
<div className="account-management-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void loadAccounts()} disabled={isLoading || Boolean(workingWxid)}>
|
||||
<RefreshCw size={16} /> {isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleAddAccount} disabled={Boolean(workingWxid)}>
|
||||
<UserPlus size={16} /> 添加账号
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="account-management-summary">
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">数据库目录</span>
|
||||
<span className="summary-value">{dbPath || '未配置'}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">当前账号</span>
|
||||
<span className="summary-value">{currentAccountLabel}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">账号数量</span>
|
||||
<span className="summary-value">{accounts.length}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice && (
|
||||
<div className={`account-notice ${notice.type}`}>
|
||||
<span>{notice.text}</span>
|
||||
{deleteUndoState && (notice.type === 'success' || notice.type === 'info') && (
|
||||
<button
|
||||
type="button"
|
||||
className="notice-action"
|
||||
onClick={() => void handleUndoDelete()}
|
||||
disabled={Boolean(workingWxid)}
|
||||
>
|
||||
撤回
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<div className="account-empty">
|
||||
<Database size={20} />
|
||||
<span>未发现可管理账号,请先添加账号或检查数据库目录。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="account-list">
|
||||
{accounts.map((account) => (
|
||||
<article key={account.normalizedWxid} className={`account-card ${account.isCurrent ? 'is-current' : ''}`}>
|
||||
<div className="account-avatar">
|
||||
{account.avatarUrl ? <img src={account.avatarUrl} alt="" /> : <span>{resolveAccountAvatarText(account.displayName)}</span>}
|
||||
</div>
|
||||
<div className="account-main">
|
||||
<div className="account-title-row">
|
||||
<h3>{account.displayName}</h3>
|
||||
{account.isCurrent && (
|
||||
<span className="account-badge current">
|
||||
<CheckCircle2 size={12} /> 当前
|
||||
</span>
|
||||
)}
|
||||
{account.hasConfig ? (
|
||||
<span className="account-badge ok">已保存配置</span>
|
||||
) : (
|
||||
<span className="account-badge warn">未保存配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta">wxid: {account.wxid}</div>
|
||||
<div className="account-meta">
|
||||
最近数据更新时间: {formatTime(account.modifiedTime)} · 配置更新时间: {formatTime(account.configUpdatedAt)}
|
||||
{!account.fromScan && <span className="meta-tip">(仅配置记录)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void handleSwitchAccount(account.wxid)}
|
||||
disabled={Boolean(workingWxid) || account.isCurrent || !account.hasConfig || !account.fromScan}
|
||||
>
|
||||
<ArrowRightLeft size={14} /> {account.isCurrent ? '当前账号' : (!account.hasConfig ? '无配置' : (account.fromScan ? '切换' : '无数据'))}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void handleDeleteAccountConfig(account.wxid)}
|
||||
disabled={Boolean(workingWxid) || !account.hasConfig}
|
||||
>
|
||||
<Trash2 size={14} /> 删除配置
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="account-management-footer">
|
||||
删除仅影响 WeFlow 本地配置,不会删除微信原始数据文件。
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountManagementPage
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -286,6 +286,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('')
|
||||
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
|
||||
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
|
||||
const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false)
|
||||
|
||||
// 检查 Hello 可用性
|
||||
useEffect(() => {
|
||||
@@ -516,35 +517,38 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAiModelApiKey = await configService.getAiModelApiKey()
|
||||
const savedAiModelApiModel = await configService.getAiModelApiModel()
|
||||
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
|
||||
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
|
||||
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
|
||||
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
|
||||
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
|
||||
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
|
||||
const savedAiInsightContextCount = await configService.getAiInsightContextCount()
|
||||
const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt()
|
||||
const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled()
|
||||
const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken()
|
||||
const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds()
|
||||
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
|
||||
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
|
||||
setAiInsightEnabled(savedAiInsightEnabled)
|
||||
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
|
||||
setAiModelApiKey(savedAiModelApiKey)
|
||||
setAiModelApiModel(savedAiModelApiModel)
|
||||
setAiInsightSilenceDays(savedAiInsightSilenceDays)
|
||||
setAiInsightAllowContext(savedAiInsightAllowContext)
|
||||
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
|
||||
setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
|
||||
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
|
||||
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
|
||||
setAiInsightContextCount(savedAiInsightContextCount)
|
||||
setAiInsightSystemPrompt(savedAiInsightSystemPrompt)
|
||||
setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled)
|
||||
setAiInsightTelegramToken(savedAiInsightTelegramToken)
|
||||
setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds)
|
||||
setAiFootprintEnabled(savedAiFootprintEnabled)
|
||||
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
|
||||
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
|
||||
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
|
||||
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
|
||||
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
|
||||
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
|
||||
const savedAiInsightContextCount = await configService.getAiInsightContextCount()
|
||||
const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt()
|
||||
const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled()
|
||||
const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken()
|
||||
const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds()
|
||||
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
|
||||
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
|
||||
const savedAiInsightDebugLogEnabled = await configService.getAiInsightDebugLogEnabled()
|
||||
|
||||
setAiInsightEnabled(savedAiInsightEnabled)
|
||||
setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
|
||||
setAiModelApiKey(savedAiModelApiKey)
|
||||
setAiModelApiModel(savedAiModelApiModel)
|
||||
setAiInsightSilenceDays(savedAiInsightSilenceDays)
|
||||
setAiInsightAllowContext(savedAiInsightAllowContext)
|
||||
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
|
||||
setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
|
||||
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
|
||||
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
|
||||
setAiInsightContextCount(savedAiInsightContextCount)
|
||||
setAiInsightSystemPrompt(savedAiInsightSystemPrompt)
|
||||
setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled)
|
||||
setAiInsightTelegramToken(savedAiInsightTelegramToken)
|
||||
setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds)
|
||||
setAiFootprintEnabled(savedAiFootprintEnabled)
|
||||
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
|
||||
setAiInsightDebugLogEnabled(savedAiInsightDebugLogEnabled)
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('加载配置失败:', e)
|
||||
@@ -2722,7 +2726,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setIsTestingInsight(true)
|
||||
setInsightTestResult(null)
|
||||
try {
|
||||
const result = await (window.electronAPI as any).insight.testConnection()
|
||||
const result = await window.electronAPI.insight.testConnection()
|
||||
setInsightTestResult(result)
|
||||
} catch (e: any) {
|
||||
setInsightTestResult({ success: false, message: `调用失败:${e?.message || String(e)}` })
|
||||
@@ -2883,7 +2887,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setIsTriggeringInsightTest(true)
|
||||
setInsightTriggerResult(null)
|
||||
try {
|
||||
const result = await (window.electronAPI as any).insight.triggerTest()
|
||||
const result = await window.electronAPI.insight.triggerTest()
|
||||
setInsightTriggerResult(result)
|
||||
} catch (e: any) {
|
||||
setInsightTriggerResult({ success: false, message: `调用失败:${e?.message || String(e)}` })
|
||||
@@ -3340,6 +3344,32 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>调试日志导出</label>
|
||||
<span className="form-hint">
|
||||
开启后,AI 见解链路会额外把完整调试日志写到桌面上的 <code>weflow-ai-insight-debug-YYYY-MM-DD.log</code>。
|
||||
其中会包含发送给 AI 的完整提示词原文、近期对话上下文原文和模型输出原文,但不会记录 API Key。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{aiInsightDebugLogEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiInsightDebugLogEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiInsightDebugLogEnabled(val)
|
||||
await configService.setAiInsightDebugLogEnabled(val)
|
||||
showMessage(val ? '已开启 AI 见解调试日志,后续日志将写入桌面' : '已关闭 AI 见解调试日志', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -304,6 +304,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nav-hint {
|
||||
margin-top: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #0f766e;
|
||||
background: rgba(20, 184, 166, 0.16);
|
||||
}
|
||||
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
@@ -362,6 +375,16 @@
|
||||
font-size: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-mode-tip {
|
||||
margin: 10px 0 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.step-icon-wrapper {
|
||||
@@ -556,6 +579,41 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.auto-image-key-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.auto-image-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
code {
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
max-width: 70%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-image-key-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
@@ -31,6 +31,7 @@ const steps = [
|
||||
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
|
||||
]
|
||||
type SetupStepId = typeof steps[number]['id']
|
||||
type ImageKeyResolveSource = 'manual-cache' | 'prefetch-cache' | 'memory-scan'
|
||||
|
||||
interface WelcomePageProps {
|
||||
standalone?: boolean
|
||||
@@ -61,9 +62,44 @@ const isDbKeyReadyMessage = (message: string): boolean => (
|
||||
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
||||
)
|
||||
|
||||
const pickWxidByAnchorTime = (
|
||||
wxids: Array<{ wxid: string; modifiedTime: number }>,
|
||||
anchorTime?: number
|
||||
): string => {
|
||||
if (!Array.isArray(wxids) || wxids.length === 0) return ''
|
||||
const fallbackWxid = wxids[0]?.wxid || ''
|
||||
if (!anchorTime || !Number.isFinite(anchorTime)) return fallbackWxid
|
||||
|
||||
const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0)
|
||||
if (valid.length === 0) return fallbackWxid
|
||||
|
||||
const anchor = Number(anchorTime)
|
||||
const nearWindowMs = 10 * 60 * 1000
|
||||
|
||||
const near = valid
|
||||
.filter(item => Math.abs(item.modifiedTime - anchor) <= nearWindowMs)
|
||||
.sort((a, b) => {
|
||||
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
|
||||
if (diffGap !== 0) return diffGap
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
if (near.length > 0) return near[0].wxid
|
||||
|
||||
const closest = valid.sort((a, b) => {
|
||||
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
|
||||
if (diffGap !== 0) return diffGap
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
return closest[0]?.wxid || fallbackWxid
|
||||
}
|
||||
|
||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
|
||||
const isAddAccountMode = standalone && new URLSearchParams(location.search).get('mode') === 'add-account'
|
||||
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
@@ -92,7 +128,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||
const [isImageKeyVerified, setIsImageKeyVerified] = useState(false)
|
||||
const [isImageStepAutoCompleted, setIsImageStepAutoCompleted] = useState(false)
|
||||
const [hasReacquiredDbKey, setHasReacquiredDbKey] = useState(!isAddAccountMode)
|
||||
const [showDbKeyConfirm, setShowDbKeyConfirm] = useState(false)
|
||||
const imagePrefetchAttemptRef = useRef<string>('')
|
||||
|
||||
// 安全相关 state
|
||||
const [enableAuth, setEnableAuth] = useState(false)
|
||||
@@ -191,7 +231,79 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setWxidOptions([])
|
||||
setWxid('')
|
||||
setShowWxidSelect(false)
|
||||
}, [dbPath])
|
||||
setIsImageKeyVerified(false)
|
||||
setIsImageStepAutoCompleted(false)
|
||||
if (isAddAccountMode) {
|
||||
setHasReacquiredDbKey(false)
|
||||
setDecryptKey('')
|
||||
}
|
||||
imagePrefetchAttemptRef.current = ''
|
||||
}, [dbPath, isAddAccountMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAddAccountMode) return
|
||||
let cancelled = false
|
||||
|
||||
const hydrateAddAccountMode = async () => {
|
||||
const keyStepIndex = steps.findIndex(step => step.id === 'key')
|
||||
if (keyStepIndex >= 0) {
|
||||
setStepIndex(keyStepIndex)
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
savedDbPath,
|
||||
savedCachePath,
|
||||
savedWxid,
|
||||
savedImageXorKey,
|
||||
savedImageAesKey
|
||||
] = await Promise.all([
|
||||
configService.getDbPath(),
|
||||
configService.getCachePath(),
|
||||
configService.getMyWxid(),
|
||||
configService.getImageXorKey(),
|
||||
configService.getImageAesKey()
|
||||
])
|
||||
if (cancelled) return
|
||||
|
||||
setDbPath(savedDbPath || '')
|
||||
setCachePath(savedCachePath || '')
|
||||
setDecryptKey('')
|
||||
setHasReacquiredDbKey(false)
|
||||
if (typeof savedImageXorKey === 'number' && Number.isFinite(savedImageXorKey)) {
|
||||
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
setImageAesKey(savedImageAesKey || '')
|
||||
|
||||
if (savedDbPath) {
|
||||
const scannedWxids = await window.electronAPI.dbPath.scanWxids(savedDbPath)
|
||||
if (cancelled) return
|
||||
setWxidOptions(scannedWxids)
|
||||
|
||||
const preferredWxid = String(savedWxid || '').trim()
|
||||
const matched = scannedWxids.find(item => item.wxid === preferredWxid)
|
||||
if (matched) {
|
||||
setWxid(matched.wxid)
|
||||
} else if (preferredWxid) {
|
||||
setWxid(preferredWxid)
|
||||
} else if (scannedWxids.length > 0) {
|
||||
setWxid(scannedWxids[0].wxid)
|
||||
}
|
||||
} else if (savedWxid) {
|
||||
setWxid(savedWxid)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(`加载当前账号配置失败: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void hydrateAddAccountMode()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isAddAccountMode])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -206,10 +318,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showWxidSelect])
|
||||
|
||||
const currentStep = steps[stepIndex]
|
||||
const imageStepIndex = steps.findIndex(step => step.id === 'image')
|
||||
const securityStepIndex = steps.findIndex(step => step.id === 'security')
|
||||
const currentStep = steps[stepIndex] ?? steps[0]
|
||||
const imagePreCompletedAhead = isImageStepAutoCompleted && imageStepIndex >= 0 && stepIndex < imageStepIndex
|
||||
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
|
||||
const showWindowControls = standalone
|
||||
|
||||
const isStepCompleted = (index: number, stepId: SetupStepId): boolean => {
|
||||
if (index < stepIndex) return true
|
||||
if (stepId === 'image' && isImageStepAutoCompleted) return true
|
||||
if (isAddAccountMode && stepId !== 'key') return true
|
||||
return false
|
||||
}
|
||||
|
||||
const resolveStepDesc = (step: { id: SetupStepId; desc: string }): string => {
|
||||
if (step.id === 'image' && isImageStepAutoCompleted) {
|
||||
return '缓存校验成功,已自动完成'
|
||||
}
|
||||
if (isAddAccountMode && step.id !== 'key') {
|
||||
return '已沿用当前配置'
|
||||
}
|
||||
return step.desc
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electronAPI.window.minimize()
|
||||
}
|
||||
@@ -302,7 +434,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanWxid = async (silent = false) => {
|
||||
const handleScanWxid = async (silent = false, anchorTime?: number) => {
|
||||
if (!dbPath) {
|
||||
if (!silent) setError('请先选择数据库目录')
|
||||
return
|
||||
@@ -314,8 +446,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
if (wxids.length > 0) {
|
||||
// scanWxids 已经按时间排过序了,直接取第一个
|
||||
setWxid(wxids[0].wxid)
|
||||
// 密钥成功后使用成功时刻作为锚点,自动选择最接近该时刻的活跃账号;
|
||||
// 其余场景保持“时间最新”优先。
|
||||
const selectedWxid = pickWxidByAnchorTime(wxids, anchorTime)
|
||||
setWxid(selectedWxid || wxids[0].wxid)
|
||||
if (!silent) setError('')
|
||||
} else {
|
||||
if (!silent) setError('未检测到账号目录,请检查路径')
|
||||
@@ -364,10 +498,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const result = await window.electronAPI.key.autoGetDbKey()
|
||||
if (result.success && result.key) {
|
||||
setDecryptKey(result.key)
|
||||
setHasReacquiredDbKey(true)
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
setError('')
|
||||
await handleScanWxid(true)
|
||||
const keySuccessAt = Date.now()
|
||||
await handleScanWxid(true, keySuccessAt)
|
||||
} else {
|
||||
if (isAddAccountMode) {
|
||||
setHasReacquiredDbKey(false)
|
||||
}
|
||||
if (
|
||||
result.error?.includes('未找到微信安装路径') ||
|
||||
result.error?.includes('启动微信失败') ||
|
||||
@@ -396,25 +535,45 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
handleAutoGetDbKey()
|
||||
}
|
||||
|
||||
const handleAutoGetImageKey = async () => {
|
||||
const handleAutoGetImageKey = async (
|
||||
source: ImageKeyResolveSource = 'manual-cache',
|
||||
options?: { silentError?: boolean }
|
||||
) => {
|
||||
if (isFetchingImageKey) return
|
||||
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||
setIsFetchingImageKey(true)
|
||||
setError('')
|
||||
if (!options?.silentError) {
|
||||
setError('')
|
||||
}
|
||||
setImageKeyPercent(0)
|
||||
setImageKeyStatus('正在准备获取图片密钥...')
|
||||
setImageKeyStatus(source === 'prefetch-cache' ? '正在预计算图片密钥...' : '正在准备获取图片密钥...')
|
||||
try {
|
||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('已获取图片密钥')
|
||||
const verified = result.verified === true
|
||||
setIsImageKeyVerified(verified)
|
||||
setIsImageStepAutoCompleted(verified)
|
||||
if (verified) {
|
||||
setImageKeyStatus(source === 'prefetch-cache' ? '图片密钥已预先自动完成(缓存校验通过)' : '图片密钥获取成功(缓存校验通过)')
|
||||
} else {
|
||||
setImageKeyStatus('已自动计算图片密钥(未完成校验)')
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '自动获取图片密钥失败')
|
||||
setIsImageKeyVerified(false)
|
||||
setIsImageStepAutoCompleted(false)
|
||||
if (!options?.silentError) {
|
||||
setError(result.error || '自动获取图片密钥失败')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`自动获取图片密钥失败: ${e}`)
|
||||
setIsImageKeyVerified(false)
|
||||
setIsImageStepAutoCompleted(false)
|
||||
if (!options?.silentError) {
|
||||
setError(`自动获取图片密钥失败: ${e}`)
|
||||
}
|
||||
} finally {
|
||||
setIsFetchingImageKey(false)
|
||||
}
|
||||
@@ -433,6 +592,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setIsImageKeyVerified(false)
|
||||
setIsImageStepAutoCompleted(false)
|
||||
setImageKeyStatus('内存扫描成功,已获取图片密钥')
|
||||
} else {
|
||||
setError(result.error || '内存扫描获取图片密钥失败')
|
||||
@@ -444,6 +605,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!dbPath || !wxid || decryptKey.length !== 64) return
|
||||
const attemptKey = `${dbPath}::${wxid}::${decryptKey}`
|
||||
if (imagePrefetchAttemptRef.current === attemptKey) return
|
||||
imagePrefetchAttemptRef.current = attemptKey
|
||||
void handleAutoGetImageKey('prefetch-cache', { silentError: true })
|
||||
}, [dbPath, wxid, decryptKey])
|
||||
|
||||
const jumpToStep = (stepId: SetupStepId) => {
|
||||
const targetIndex = steps.findIndex(step => step.id === stepId)
|
||||
if (targetIndex >= 0) setStepIndex(targetIndex)
|
||||
@@ -487,6 +656,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
|
||||
const canGoNext = () => {
|
||||
if (isAddAccountMode) {
|
||||
if (currentStep.id === 'key') return hasReacquiredDbKey && decryptKey.length === 64 && Boolean(wxid)
|
||||
return true
|
||||
}
|
||||
if (currentStep.id === 'intro') return true
|
||||
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
|
||||
if (currentStep.id === 'cache') return true
|
||||
@@ -502,6 +675,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isAddAccountMode) {
|
||||
await handleConnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep.id === 'db') {
|
||||
const dbStepIssue = await validateDbStepBeforeNext()
|
||||
if (dbStepIssue) {
|
||||
@@ -520,15 +698,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
if (currentStep.id === 'key' && isImageStepAutoCompleted && securityStepIndex >= 0) {
|
||||
setStepIndex(securityStepIndex)
|
||||
return
|
||||
}
|
||||
setStepIndex((prev) => Math.min(prev + 1, steps.length - 1))
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (isAddAccountMode) return
|
||||
setError('')
|
||||
setStepIndex((prev) => Math.max(prev - 1, 0))
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (isAddAccountMode && !hasReacquiredDbKey) {
|
||||
setError('请先在当前流程中自动获取一次数据库密钥')
|
||||
return
|
||||
}
|
||||
|
||||
const configIssue = await findConfigIssueBeforeConnect()
|
||||
if (configIssue) {
|
||||
setError(configIssue.message)
|
||||
@@ -708,13 +896,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
<div className="sidebar-nav">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'completed' : ''}`}>
|
||||
<div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${isStepCompleted(index, step.id) ? 'completed' : ''}`}>
|
||||
<div className="nav-indicator">
|
||||
{index < stepIndex ? <CheckCircle2 size={14} /> : <div className="dot" />}
|
||||
{isStepCompleted(index, step.id) ? <CheckCircle2 size={14} /> : <div className="dot" />}
|
||||
</div>
|
||||
<div className="nav-info">
|
||||
<div className="nav-title">{step.title}</div>
|
||||
<div className="nav-desc">{step.desc}</div>
|
||||
<div className="nav-desc">{resolveStepDesc(step)}</div>
|
||||
{step.id === 'image' && imagePreCompletedAhead && (
|
||||
<div className="nav-hint">已预先自动完成</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -731,6 +922,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<div>
|
||||
<h2>{currentStep.title}</h2>
|
||||
<p className="header-desc">{currentStep.desc}</p>
|
||||
{isAddAccountMode && (
|
||||
<p className="header-mode-tip">添加账号模式:其他步骤已沿用当前配置,只需重新获取数据库密钥。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -863,6 +1057,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
</div>
|
||||
|
||||
{dbKeyStatus && <div className={`status-message ${isDbKeyReadyMessage(dbKeyStatus) ? 'is-success' : ''}`}>{dbKeyStatus}</div>}
|
||||
{isAddAccountMode && !hasReacquiredDbKey && (
|
||||
<div className="field-hint">添加账号模式下需先自动获取一次数据库密钥,才能完成并返回主窗口。</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -936,19 +1133,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{currentStep.id === 'image' && (
|
||||
<div className="form-group">
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="field-label">图片 XOR 密钥</label>
|
||||
<input type="text" className="field-input" placeholder="0x..." value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
|
||||
<div className="auto-image-key-preview">
|
||||
<div className="auto-image-key-row">
|
||||
<span className="auto-image-key-label">图片 XOR 密钥</span>
|
||||
<code>{imageXorKey || '等待自动计算'}</code>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">图片 AES 密钥</label>
|
||||
<input type="text" className="field-input" placeholder="16位密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
|
||||
<div className="auto-image-key-row">
|
||||
<span className="auto-image-key-label">图片 AES 密钥</span>
|
||||
<code>{imageAesKey || '等待自动计算'}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||
<button className="btn btn-primary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
|
||||
<button className="btn btn-primary btn-block" onClick={() => handleAutoGetImageKey('manual-cache')} disabled={isFetchingImageKey} title="从本地缓存快速计算">
|
||||
{isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
|
||||
@@ -960,13 +1157,23 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<div className="brute-force-progress">
|
||||
<div className="status-header">
|
||||
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
|
||||
{typeof imageKeyPercent === 'number' && Number.isFinite(imageKeyPercent) && (
|
||||
<span className="status-text">{Math.max(0, Math.min(100, imageKeyPercent)).toFixed(1)}%</span>
|
||||
)}
|
||||
</div>
|
||||
</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' }}>
|
||||
图片密钥已改为自动计算。仅当“缓存计算 + 本地校验通过”时会自动跳过本步骤;若失败可使用内存扫描兜底。
|
||||
</div>
|
||||
{isImageKeyVerified && (
|
||||
<div className="status-message is-success" style={{ marginTop: '8px' }}>
|
||||
当前密钥已通过缓存校验,可安全自动跳过图片密钥步骤。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -981,11 +1188,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
)}
|
||||
|
||||
<div className="content-actions">
|
||||
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0}>
|
||||
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0 || isAddAccountMode}>
|
||||
<ArrowLeft size={16} /> 上一步
|
||||
</button>
|
||||
|
||||
{stepIndex < steps.length - 1 ? (
|
||||
{isAddAccountMode ? (
|
||||
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
|
||||
{isConnecting ? '连接中...' : '完成并返回'} <ArrowRight size={16} />
|
||||
</button>
|
||||
) : stepIndex < steps.length - 1 ? (
|
||||
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
|
||||
下一步 <ArrowRight size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 配置服务 - 封装 Electron Store
|
||||
import { config } from './ipc'
|
||||
import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange'
|
||||
import type { ExportAutomationTask } from '../types/exportAutomation'
|
||||
|
||||
// 配置键名
|
||||
export const CONFIG_KEYS = {
|
||||
@@ -48,6 +49,7 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
|
||||
EXPORT_SESSION_MUTUAL_FRIENDS_CACHE_MAP: 'exportSessionMutualFriendsCacheMap',
|
||||
EXPORT_AUTOMATION_TASK_MAP: 'exportAutomationTaskMap',
|
||||
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
|
||||
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||
@@ -106,7 +108,8 @@ export const CONFIG_KEYS = {
|
||||
|
||||
// AI 足迹
|
||||
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
|
||||
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt'
|
||||
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
|
||||
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled'
|
||||
} as const
|
||||
|
||||
export interface WxidConfig {
|
||||
@@ -190,6 +193,10 @@ export async function getWxidConfigs(): Promise<Record<string, WxidConfig>> {
|
||||
return {}
|
||||
}
|
||||
|
||||
export async function setWxidConfigs(configs: Record<string, WxidConfig>): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.WXID_CONFIGS, configs || {})
|
||||
}
|
||||
|
||||
export async function getWxidConfig(wxid: string): Promise<WxidConfig | null> {
|
||||
if (!wxid) return null
|
||||
const configs = await getWxidConfigs()
|
||||
@@ -660,6 +667,183 @@ export async function setExportLastSnsPostCount(count: number): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
|
||||
}
|
||||
|
||||
export interface ExportAutomationTaskMapItem {
|
||||
updatedAt: number
|
||||
tasks: ExportAutomationTask[]
|
||||
}
|
||||
|
||||
const normalizeAutomationNumeric = (value: unknown, fallback: number): number => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return fallback
|
||||
return Math.floor(numeric)
|
||||
}
|
||||
|
||||
const normalizeAutomationTask = (raw: unknown): ExportAutomationTask | null => {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
|
||||
const id = String(source.id || '').trim()
|
||||
const name = String(source.name || '').trim()
|
||||
if (!id || !name) return null
|
||||
|
||||
const sessionIds = Array.isArray(source.sessionIds)
|
||||
? Array.from(new Set(source.sessionIds.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
: []
|
||||
const sessionNames = Array.isArray(source.sessionNames)
|
||||
? source.sessionNames.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (sessionIds.length === 0) return null
|
||||
|
||||
const scheduleRaw = source.schedule
|
||||
if (!scheduleRaw || typeof scheduleRaw !== 'object') return null
|
||||
const scheduleObj = scheduleRaw as Record<string, unknown>
|
||||
const scheduleType = String(scheduleObj.type || '').trim() as ExportAutomationTask['schedule']['type']
|
||||
let schedule: ExportAutomationTask['schedule'] | null = null
|
||||
if (scheduleType === 'interval') {
|
||||
const rawDays = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalDays, 0))
|
||||
const rawHours = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalHours, 0))
|
||||
const totalHours = (rawDays * 24) + rawHours
|
||||
if (totalHours <= 0) return null
|
||||
const intervalDays = Math.floor(totalHours / 24)
|
||||
const intervalHours = totalHours % 24
|
||||
schedule = { type: 'interval', intervalDays, intervalHours }
|
||||
}
|
||||
if (!schedule) return null
|
||||
|
||||
const conditionRaw = source.condition
|
||||
if (!conditionRaw || typeof conditionRaw !== 'object') return null
|
||||
const conditionType = String((conditionRaw as Record<string, unknown>).type || '').trim()
|
||||
if (conditionType !== 'new-message-since-last-success') return null
|
||||
|
||||
const templateRaw = source.template
|
||||
if (!templateRaw || typeof templateRaw !== 'object') return null
|
||||
const template = templateRaw as Record<string, unknown>
|
||||
const scope = String(template.scope || '').trim() as ExportAutomationTask['template']['scope']
|
||||
if (scope !== 'single' && scope !== 'multi' && scope !== 'content') return null
|
||||
const optionTemplate = template.optionTemplate
|
||||
if (!optionTemplate || typeof optionTemplate !== 'object') return null
|
||||
const dateRangeConfig = template.dateRangeConfig
|
||||
const outputDirRaw = String(source.outputDir || '').trim()
|
||||
const runStateRaw = source.runState && typeof source.runState === 'object'
|
||||
? (source.runState as Record<string, unknown>)
|
||||
: null
|
||||
const stopConditionRaw = source.stopCondition && typeof source.stopCondition === 'object'
|
||||
? (source.stopCondition as Record<string, unknown>)
|
||||
: null
|
||||
const rawContentType = String(template.contentType || '').trim()
|
||||
const contentType = (
|
||||
rawContentType === 'text' ||
|
||||
rawContentType === 'voice' ||
|
||||
rawContentType === 'image' ||
|
||||
rawContentType === 'video' ||
|
||||
rawContentType === 'emoji' ||
|
||||
rawContentType === 'file'
|
||||
)
|
||||
? rawContentType
|
||||
: undefined
|
||||
const rawRunStatus = runStateRaw ? String(runStateRaw.lastRunStatus || '').trim() : ''
|
||||
const lastRunStatus = (
|
||||
rawRunStatus === 'idle' ||
|
||||
rawRunStatus === 'queued' ||
|
||||
rawRunStatus === 'running' ||
|
||||
rawRunStatus === 'success' ||
|
||||
rawRunStatus === 'error' ||
|
||||
rawRunStatus === 'skipped'
|
||||
)
|
||||
? rawRunStatus
|
||||
: undefined
|
||||
const endAt = stopConditionRaw ? Math.max(0, normalizeAutomationNumeric(stopConditionRaw.endAt, 0)) : 0
|
||||
const maxRuns = stopConditionRaw ? Math.max(0, normalizeAutomationNumeric(stopConditionRaw.maxRuns, 0)) : 0
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
enabled: source.enabled !== false,
|
||||
sessionIds,
|
||||
sessionNames,
|
||||
outputDir: outputDirRaw || undefined,
|
||||
schedule,
|
||||
condition: { type: 'new-message-since-last-success' },
|
||||
stopCondition: (endAt > 0 || maxRuns > 0)
|
||||
? {
|
||||
endAt: endAt > 0 ? endAt : undefined,
|
||||
maxRuns: maxRuns > 0 ? maxRuns : undefined
|
||||
}
|
||||
: undefined,
|
||||
template: {
|
||||
scope,
|
||||
contentType,
|
||||
optionTemplate: optionTemplate as ExportAutomationTask['template']['optionTemplate'],
|
||||
dateRangeConfig: (dateRangeConfig ?? null) as ExportAutomationTask['template']['dateRangeConfig']
|
||||
},
|
||||
runState: runStateRaw
|
||||
? {
|
||||
lastRunStatus,
|
||||
lastTriggeredAt: normalizeAutomationNumeric(runStateRaw.lastTriggeredAt, 0) || undefined,
|
||||
lastStartedAt: normalizeAutomationNumeric(runStateRaw.lastStartedAt, 0) || undefined,
|
||||
lastFinishedAt: normalizeAutomationNumeric(runStateRaw.lastFinishedAt, 0) || undefined,
|
||||
lastSuccessAt: normalizeAutomationNumeric(runStateRaw.lastSuccessAt, 0) || undefined,
|
||||
lastSkipAt: normalizeAutomationNumeric(runStateRaw.lastSkipAt, 0) || undefined,
|
||||
lastSkipReason: String(runStateRaw.lastSkipReason || '').trim() || undefined,
|
||||
lastError: String(runStateRaw.lastError || '').trim() || undefined,
|
||||
lastScheduleKey: String(runStateRaw.lastScheduleKey || '').trim() || undefined,
|
||||
successCount: Math.max(0, normalizeAutomationNumeric(runStateRaw.successCount, 0)) || undefined
|
||||
}
|
||||
: undefined,
|
||||
createdAt: Math.max(0, normalizeAutomationNumeric(source.createdAt, Date.now())),
|
||||
updatedAt: Math.max(0, normalizeAutomationNumeric(source.updatedAt, Date.now()))
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExportAutomationTasks(scopeKey: string): Promise<ExportAutomationTaskMapItem | null> {
|
||||
if (!scopeKey) return null
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP)
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const rawMap = value as Record<string, unknown>
|
||||
const rawItem = rawMap[scopeKey]
|
||||
if (!rawItem || typeof rawItem !== 'object') return null
|
||||
|
||||
const item = rawItem as Record<string, unknown>
|
||||
const updatedAt = Number(item.updatedAt)
|
||||
const rawTasks = Array.isArray(item.tasks)
|
||||
? item.tasks
|
||||
: (Array.isArray(rawItem) ? rawItem : [])
|
||||
const tasks: ExportAutomationTask[] = []
|
||||
for (const rawTask of rawTasks) {
|
||||
const normalized = normalizeAutomationTask(rawTask)
|
||||
if (normalized) {
|
||||
tasks.push(normalized)
|
||||
}
|
||||
}
|
||||
return {
|
||||
updatedAt: Number.isFinite(updatedAt) ? Math.max(0, Math.floor(updatedAt)) : 0,
|
||||
tasks
|
||||
}
|
||||
}
|
||||
|
||||
export async function setExportAutomationTasks(scopeKey: string, tasks: ExportAutomationTask[]): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP)
|
||||
const map = current && typeof current === 'object'
|
||||
? { ...(current as Record<string, unknown>) }
|
||||
: {}
|
||||
map[scopeKey] = {
|
||||
updatedAt: Date.now(),
|
||||
tasks: (Array.isArray(tasks) ? tasks : []).map((task) => ({ ...task }))
|
||||
}
|
||||
await config.set(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP, map)
|
||||
}
|
||||
|
||||
export async function clearExportAutomationTasks(scopeKey: string): Promise<void> {
|
||||
if (!scopeKey) return
|
||||
const current = await config.get(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP)
|
||||
if (!current || typeof current !== 'object') return
|
||||
const map = { ...(current as Record<string, unknown>) }
|
||||
if (!(scopeKey in map)) return
|
||||
delete map[scopeKey]
|
||||
await config.set(CONFIG_KEYS.EXPORT_AUTOMATION_TASK_MAP, map)
|
||||
}
|
||||
|
||||
export interface ExportSessionMessageCountCacheItem {
|
||||
updatedAt: number
|
||||
counts: Record<string, number>
|
||||
@@ -1803,3 +1987,12 @@ export async function getAiFootprintSystemPrompt(): Promise<string> {
|
||||
export async function setAiFootprintSystemPrompt(prompt: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
|
||||
}
|
||||
|
||||
export async function getAiInsightDebugLogEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAiInsightDebugLogEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED, enabled)
|
||||
}
|
||||
|
||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
@@ -18,7 +18,7 @@ export interface ElectronAPI {
|
||||
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => Promise<boolean>
|
||||
openAgreementWindow: () => Promise<boolean>
|
||||
completeOnboarding: () => Promise<boolean>
|
||||
openOnboardingWindow: () => Promise<boolean>
|
||||
openOnboardingWindow: (options?: { mode?: 'add-account' }) => Promise<boolean>
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||
@@ -146,7 +146,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
key: {
|
||||
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
||||
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||
autoGetImageKey: (manualDir?: string, wxid?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }>
|
||||
scanImageKeyFromMemory: (userDir: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
|
||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
|
||||
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||
|
||||
68
src/types/exportAutomation.ts
Normal file
68
src/types/exportAutomation.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ExportOptions as ElectronExportOptions } from './electron'
|
||||
|
||||
export type ExportAutomationScope = 'single' | 'multi' | 'content'
|
||||
export type ExportAutomationContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||
|
||||
export type ExportAutomationSchedule =
|
||||
| {
|
||||
type: 'interval'
|
||||
intervalDays: number
|
||||
intervalHours: number
|
||||
}
|
||||
|
||||
export interface ExportAutomationCondition {
|
||||
type: 'new-message-since-last-success'
|
||||
}
|
||||
|
||||
export interface ExportAutomationDateRangeConfig {
|
||||
version?: 1
|
||||
preset?: string
|
||||
useAllTime?: boolean
|
||||
start?: string | number | Date | null
|
||||
end?: string | number | Date | null
|
||||
relativeMode?: 'last-n-days' | string
|
||||
relativeDays?: number
|
||||
}
|
||||
|
||||
export interface ExportAutomationTemplate {
|
||||
scope: ExportAutomationScope
|
||||
contentType?: ExportAutomationContentType
|
||||
optionTemplate: Omit<ElectronExportOptions, 'dateRange'>
|
||||
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
|
||||
}
|
||||
|
||||
export interface ExportAutomationStopCondition {
|
||||
endAt?: number
|
||||
maxRuns?: number
|
||||
}
|
||||
|
||||
export type ExportAutomationRunStatus = 'idle' | 'queued' | 'running' | 'success' | 'error' | 'skipped'
|
||||
|
||||
export interface ExportAutomationRunState {
|
||||
lastRunStatus?: ExportAutomationRunStatus
|
||||
lastTriggeredAt?: number
|
||||
lastStartedAt?: number
|
||||
lastFinishedAt?: number
|
||||
lastSuccessAt?: number
|
||||
lastSkipAt?: number
|
||||
lastSkipReason?: string
|
||||
lastError?: string
|
||||
lastScheduleKey?: string
|
||||
successCount?: number
|
||||
}
|
||||
|
||||
export interface ExportAutomationTask {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
sessionIds: string[]
|
||||
sessionNames: string[]
|
||||
outputDir?: string
|
||||
schedule: ExportAutomationSchedule
|
||||
condition: ExportAutomationCondition
|
||||
stopCondition?: ExportAutomationStopCondition
|
||||
template: ExportAutomationTemplate
|
||||
runState?: ExportAutomationRunState
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
Reference in New Issue
Block a user