Merge pull request #750 from hicccc77/dev

Dev
This commit is contained in:
cc authored and GitHub committed 2026-04-13 20:00:03 +08:00
commit 00b63eed54
26 files changed
+4149 -791

No files matched your search

+19 -23
View File
@@ -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
+48 -5
View File
@@ -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
})
+1 -1
View File
@@ -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),
+6 -1
View File
@@ -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 = {
+235 -30
View File
@@ -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|&lt;\?xml|&lt;msg\b|&lt;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}`)
}
}
+3 -3
View File
@@ -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 优点)---
+39 -2
View File
@@ -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
+3 -3
View File
@@ -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}` }
}
+15 -17
View File
@@ -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
View 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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+9
View 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;
+2
View File
@@ -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 {
-257
View File
@@ -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);
+75 -188
View File
@@ -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
View 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
View 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
+801 -123
View File
@@ -3,10 +3,8 @@
height: 100%;
margin: -24px -24px 0;
padding: 18px 22px 12px;
background:
radial-gradient(1200px 520px at 6% -8%, color-mix(in srgb, var(--primary) 11%, transparent), transparent 65%),
radial-gradient(860px 420px at 90% 0%, color-mix(in srgb, var(--primary) 7%, transparent), transparent 66%),
var(--bg-primary);
background: var(--bg-primary);
/* Minimal background matching Footprint */
display: flex;
flex-direction: column;
gap: 16px;
@@ -38,7 +36,9 @@
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
animation: exportSectionReveal 0.38s ease both;
}
@@ -119,7 +119,9 @@
}
@keyframes sessionLoadDetailBars {
0%, 100% {
0%,
100% {
transform: scaleY(0.72);
opacity: 0.5;
}
@@ -460,7 +462,7 @@
border-bottom: none;
}
> span {
>span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -1308,12 +1310,29 @@
}
}
.content-card-grid .content-card:nth-child(1) { animation-delay: 0.03s; }
.content-card-grid .content-card:nth-child(2) { animation-delay: 0.07s; }
.content-card-grid .content-card:nth-child(3) { animation-delay: 0.11s; }
.content-card-grid .content-card:nth-child(4) { animation-delay: 0.15s; }
.content-card-grid .content-card:nth-child(5) { animation-delay: 0.19s; }
.content-card-grid .content-card:nth-child(6) { animation-delay: 0.23s; }
.content-card-grid .content-card:nth-child(1) {
animation-delay: 0.03s;
}
.content-card-grid .content-card:nth-child(2) {
animation-delay: 0.07s;
}
.content-card-grid .content-card:nth-child(3) {
animation-delay: 0.11s;
}
.content-card-grid .content-card:nth-child(4) {
animation-delay: 0.15s;
}
.content-card-grid .content-card:nth-child(5) {
animation-delay: 0.19s;
}
.content-card-grid .content-card:nth-child(6) {
animation-delay: 0.23s;
}
.count-loading {
color: var(--text-tertiary);
@@ -1716,19 +1735,20 @@
flex: 0 0 auto;
width: auto;
max-width: max-content;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border: none;
background: transparent;
color: var(--text-secondary);
min-height: 32px;
padding: 7px 10px;
padding: 7px 12px;
border-radius: 999px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease;
transition: all 0.2s ease;
.tab-btn-content {
display: inline-flex;
@@ -1753,21 +1773,18 @@
}
&:hover {
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
color: var(--text-primary);
transform: translateY(-1px);
box-shadow: 0 7px 14px rgba(15, 23, 42, 0.08);
}
&.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
box-shadow: 0 6px 14px color-mix(in srgb, var(--primary) 24%, transparent);
background: color-mix(in srgb, var(--primary) 12%, transparent);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
.tab-btn-content span:last-child {
background: color-mix(in srgb, var(--primary) 16%, var(--bg-secondary));
color: color-mix(in srgb, var(--primary) 84%, var(--text-primary));
background: color-mix(in srgb, var(--primary) 16%, transparent);
color: var(--primary);
}
}
@@ -1812,15 +1829,17 @@
gap: 6px;
padding: 8px 11px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary));
min-width: 240px;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
border: none;
background: color-mix(in srgb, var(--text-tertiary) 4%, transparent);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03) inset;
flex: 1;
min-width: 180px;
max-width: 320px;
transition: all 0.2s ease;
&:focus-within {
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
background: var(--bg-secondary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent), 0 1px 3px rgba(0, 0, 0, 0.02) inset;
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
}
input {
@@ -1829,13 +1848,14 @@
color: var(--text-primary);
font-size: 13px;
outline: none;
width: 220px;
flex: 1;
min-width: 0;
}
.clear-search {
border: 1px solid transparent;
background: color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary));
color: var(--text-tertiary);
border: none;
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
color: var(--text-secondary);
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -1843,12 +1863,11 @@
width: 18px;
height: 18px;
border-radius: 999px;
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
transition: all 0.2s ease;
&:hover {
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
background: color-mix(in srgb, var(--primary) 14%, transparent);
}
}
}
@@ -1892,25 +1911,21 @@
--contacts-message-col-width: 120px;
--contacts-media-col-width: 72px;
--contacts-action-col-width: 140px;
--contacts-actions-sticky-width: max(var(--contacts-action-col-width), 184px);
--contacts-table-min-width: 1200px;
--contacts-actions-sticky-width: 240px;
--contacts-table-min-width: 1240px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
border: none;
border-radius: 12px;
min-height: 320px;
height: auto;
flex: 1;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 84%, var(--bg-primary)) 0%, var(--bg-secondary) 100%);
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent);
transition: border-color 0.16s ease, box-shadow 0.16s ease;
background: var(--bg-secondary);
transition: all 0.2s ease;
&:hover {
border-color: color-mix(in srgb, var(--primary) 22%, var(--border-color));
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 24%, transparent),
0 8px 18px rgba(15, 23, 42, 0.06);
background: color-mix(in srgb, var(--text-tertiary) 2%, var(--bg-secondary));
}
}
@@ -2031,11 +2046,12 @@
}
.issue-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border: none;
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
border-radius: 8px;
padding: 7px 10px;
padding: 7px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
@@ -2045,14 +2061,17 @@
&:hover {
color: var(--text-primary);
border-color: var(--text-tertiary);
background: var(--bg-hover);
background: color-mix(in srgb, var(--text-tertiary) 16%, transparent);
transform: translateY(-1px);
}
&.primary {
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: var(--primary);
&:hover {
background: color-mix(in srgb, var(--primary) 20%, transparent);
}
}
}
@@ -2070,20 +2089,20 @@
}
.contacts-list-header {
--contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
--contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
display: flex;
align-items: center;
gap: var(--contacts-column-gap);
padding: 10px var(--contacts-inline-padding) 8px;
min-width: max(100%, var(--contacts-table-min-width));
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
background: var(--contacts-header-bg);
font-size: 12px;
color: var(--text-tertiary);
font-weight: 600;
letter-spacing: 0.01em;
flex-shrink: 0;
backdrop-filter: saturate(115%) blur(3px);
backdrop-filter: blur(10px);
&.is-draggable {
cursor: grab;
@@ -2164,7 +2183,7 @@
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
gap: 10px;
flex-wrap: nowrap;
flex-shrink: 0;
position: sticky;
@@ -2248,25 +2267,25 @@
}
.selection-clear-btn {
border: 1px solid var(--border-color);
border: none;
border-radius: 8px;
background: var(--bg-secondary);
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
padding: 6px 10px;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease;
transition: all 0.2s ease;
&:hover:not(:disabled) {
border-color: var(--text-tertiary);
color: var(--text-primary);
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
background: color-mix(in srgb, var(--text-tertiary) 16%, transparent);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.65;
opacity: 0.5;
cursor: not-allowed;
}
}
@@ -2275,19 +2294,21 @@
border: none;
border-radius: 8px;
padding: 6px 10px;
background: linear-gradient(180deg, color-mix(in srgb, var(--primary) 94%, #ffffff) 0%, var(--primary) 100%);
background: var(--primary);
color: #fff;
font-size: 12px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
flex-shrink: 0;
transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s ease;
transition: all 0.2s ease;
box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 20%, transparent);
&:hover:not(:disabled) {
background: var(--primary-hover);
background: color-mix(in srgb, var(--primary) 85%, #fff);
transform: translateY(-1px);
box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent);
}
@@ -2360,7 +2381,7 @@
}
.contact-item {
--contacts-row-bg: var(--bg-secondary);
--contacts-row-bg: transparent;
display: flex;
align-items: center;
gap: var(--contacts-column-gap);
@@ -2369,15 +2390,14 @@
height: 72px;
box-sizing: border-box;
border-radius: 10px;
transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease;
transition: all 0.2s ease;
cursor: default;
background: var(--contacts-row-bg);
box-shadow: inset 0 0 0 1px transparent;
box-shadow: none;
&:hover {
background: var(--contacts-row-bg);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent);
transform: translateX(1px);
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
transform: translateX(2px);
}
}
@@ -2634,32 +2654,34 @@
min-width: 1300px;
border-collapse: separate;
border-spacing: 0;
background: var(--bg-secondary);
background: transparent;
thead th {
position: sticky;
top: 0;
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary));
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
backdrop-filter: blur(8px);
z-index: 4;
font-size: 12px;
text-align: left;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
padding: 10px 10px;
white-space: nowrap;
}
tbody td {
padding: 10px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 4%, transparent);
font-size: 13px;
color: var(--text-primary);
vertical-align: middle;
white-space: nowrap;
transition: background 0.15s ease;
}
tbody tr:hover {
background: rgba(var(--primary-rgb), 0.03);
tbody tr:hover td {
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
}
.selected-row,
@@ -2797,27 +2819,26 @@
}
.row-detail-btn {
border: 1px solid var(--border-color);
border: none;
border-radius: 8px;
padding: 7px 10px;
background: var(--bg-secondary);
padding: 7px 12px;
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease;
transition: all 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
color: var(--text-primary);
background: var(--bg-hover);
background: color-mix(in srgb, var(--text-tertiary) 14%, transparent);
transform: translateY(-1px);
}
&.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
}
@@ -2883,7 +2904,7 @@
text-align: center;
}
.row-export-link.state-running + .row-export-time {
.row-export-link.state-running+.row-export-time {
color: var(--primary);
font-weight: 600;
}
@@ -2918,23 +2939,22 @@
width: min(448px, calc(100vw - 24px));
height: 100%;
max-height: calc(100vh - 24px);
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
border-radius: 16px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 82%, var(--bg-primary)) 0%, var(--bg-secondary-solid, #ffffff) 100%);
border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
border-radius: 20px;
background: var(--bg-primary);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: -18px 0 40px rgba(0, 0, 0, 0.24);
box-shadow: -18px 24px 60px rgba(0, 0, 0, 0.16);
animation: exportDetailPanelIn 0.26s cubic-bezier(0.22, 0.8, 0.24, 1) both;
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
background: color-mix(in srgb, var(--bg-primary) 92%, var(--card-bg));
padding: 20px 20px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
background: transparent;
.detail-header-main {
display: flex;
@@ -3318,22 +3338,22 @@
.export-session-sns-dialog {
width: min(760px, 100%);
max-height: min(86vh, 860px);
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, #ffffff);
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent);
background: var(--bg-primary);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.16);
display: flex;
flex-direction: column;
overflow: hidden;
animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both;
animation: footprintFadeSlideUp 0.3s cubic-bezier(0.2, 0.78, 0.26, 1) both;
.sns-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
padding: 16px 20px;
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
}
.sns-dialog-header-main {
@@ -3673,12 +3693,10 @@
position: relative;
overflow: hidden;
border-radius: 8px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0.08) 100%
);
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.35) 50%,
rgba(255, 255, 255, 0.08) 100%);
background-size: 220% 100%;
animation: exportSkeletonShimmer 1.2s linear infinite;
}
@@ -3700,13 +3718,39 @@
height: 12px;
}
.skeleton-line.w-12 { width: 48%; min-width: 42px; }
.skeleton-line.w-20 { width: 22%; min-width: 36px; }
.skeleton-line.w-30 { width: 32%; min-width: 120px; }
.skeleton-line.w-40 { width: 45%; min-width: 80px; }
.skeleton-line.w-60 { width: 62%; min-width: 110px; }
.skeleton-line.w-100 { width: 100%; }
.skeleton-line.h-32 { height: 32px; border-radius: 10px; }
.skeleton-line.w-12 {
width: 48%;
min-width: 42px;
}
.skeleton-line.w-20 {
width: 22%;
min-width: 36px;
}
.skeleton-line.w-30 {
width: 32%;
min-width: 120px;
}
.skeleton-line.w-40 {
width: 45%;
min-width: 80px;
}
.skeleton-line.w-60 {
width: 62%;
min-width: 110px;
}
.skeleton-line.w-100 {
width: 100%;
}
.skeleton-line.h-32 {
height: 32px;
border-radius: 10px;
}
.export-dialog-overlay {
position: fixed;
@@ -4440,11 +4484,9 @@
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: linear-gradient(
180deg,
transparent,
var(--card-bg) 38%
);
background: linear-gradient(180deg,
transparent,
var(--card-bg) 38%);
}
.primary-btn,
@@ -4824,6 +4866,7 @@
0% {
background-position: 220% 0;
}
100% {
background-position: -20% 0;
}
@@ -4833,6 +4876,7 @@
0% {
width: 0;
}
100% {
width: 1.8em;
}
@@ -4843,10 +4887,12 @@
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.35);
}
70% {
transform: scale(1.02);
box-shadow: 0 0 0 6px rgba(255, 77, 79, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0);
@@ -4854,6 +4900,7 @@
}
@media (prefers-reduced-motion: reduce) {
.export-board-page,
.export-top-panel,
.export-section-title-row,
@@ -5207,3 +5254,634 @@
}
}
}
.automation-hint-pill {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 5px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--primary);
font-size: 12px;
font-weight: 500;
}
.automation-modal-overlay {
position: fixed;
inset: 0;
z-index: 7750;
background: rgba(0, 0, 0, 0.38);
display: flex;
align-items: center;
justify-content: center;
padding: 24px 20px;
animation: exportOverlayFadeIn 0.2s ease both;
}
.automation-modal {
width: min(680px, 100%);
max-height: min(80vh, 820px);
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent);
background: var(--bg-primary);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.16);
display: flex;
flex-direction: column;
overflow: hidden;
animation: footprintFadeSlideUp 0.28s cubic-bezier(0.2, 0.78, 0.26, 1) both;
}
.automation-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
flex-shrink: 0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
p {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-tertiary);
}
}
.automation-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 20px;
}
.automation-empty {
padding: 40px 0;
text-align: center;
font-size: 13px;
color: var(--text-tertiary);
}
.automation-task-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.automation-task-card {
border-radius: 14px;
background: color-mix(in srgb, var(--text-tertiary) 5%, transparent);
padding: 14px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
transition: background 0.2s ease;
&:hover {
background: color-mix(in srgb, var(--text-tertiary) 9%, transparent);
}
&.disabled {
opacity: 0.6;
}
}
.automation-task-main {
flex: 1;
min-width: 0;
p {
margin: 3px 0 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
}
.automation-task-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
strong {
font-size: 14px;
color: var(--text-primary);
font-weight: 600;
}
}
.automation-task-status {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
&.enabled {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
&.disabled {
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
color: var(--text-tertiary);
}
&.running {
background: rgba(82, 196, 26, 0.14);
color: #52c41a;
}
&.queued {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.automation-task-actions {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.automation-editor-overlay {
position: fixed;
inset: 0;
z-index: 7800;
background: rgba(0, 0, 0, 0.42);
display: flex;
align-items: center;
justify-content: center;
padding: 24px 20px;
animation: exportOverlayFadeIn 0.2s ease both;
}
.automation-editor-modal {
width: min(560px, 100%);
max-height: min(88vh, 900px);
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent);
background: var(--bg-primary);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
animation: footprintFadeSlideUp 0.28s cubic-bezier(0.2, 0.78, 0.26, 1) both;
}
.automation-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
flex-shrink: 0;
h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
}
.automation-editor-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
/* 裸 input 统一样式(未套 .automation-form-field 的情况) */
>input[type='datetime-local'],
>input[type='number'],
>input[type='text'] {
height: 36px;
border: none;
border-radius: 10px;
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
color: var(--text-primary);
padding: 0 12px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
outline: none;
transition: background 0.2s ease, box-shadow 0.2s ease;
&:focus {
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* datetime-local 的日历图标调整外观 */
&::-webkit-calendar-picker-indicator {
opacity: 0.5;
cursor: pointer;
filter: var(--datetime-picker-icon-filter, none);
transition: opacity 0.18s ease;
border-radius: 4px;
padding: 2px;
&:hover {
opacity: 1;
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
}
}
&::-webkit-datetime-edit {
padding: 0;
}
&::-webkit-datetime-edit-fields-wrapper {
background: transparent;
}
&::-webkit-datetime-edit-text {
color: var(--text-tertiary);
padding: 0 1px;
}
&::-webkit-datetime-edit-year-field,
&::-webkit-datetime-edit-month-field,
&::-webkit-datetime-edit-day-field,
&::-webkit-datetime-edit-hour-field,
&::-webkit-datetime-edit-minute-field,
&::-webkit-datetime-edit-ampm-field {
color: var(--text-primary);
border-radius: 3px;
padding: 0 2px;
&:focus {
background: color-mix(in srgb, var(--primary) 16%, transparent);
color: var(--primary);
outline: none;
}
}
}
/* 嵌套在 div 内的裸 input(如 stopAt 在 .automation-form-field > div 里) */
input[type='datetime-local']:not(.automation-form-field input) {
height: 36px;
border: none;
border-radius: 10px;
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
color: var(--text-primary);
padding: 0 12px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
outline: none;
transition: background 0.2s ease, box-shadow 0.2s ease;
&:focus {
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}
&::-webkit-calendar-picker-indicator {
opacity: 0.5;
cursor: pointer;
transition: opacity 0.18s ease;
border-radius: 4px;
padding: 2px;
&:hover {
opacity: 1;
}
}
&::-webkit-datetime-edit-year-field,
&::-webkit-datetime-edit-month-field,
&::-webkit-datetime-edit-day-field,
&::-webkit-datetime-edit-hour-field,
&::-webkit-datetime-edit-minute-field {
color: var(--text-primary);
border-radius: 3px;
padding: 0 2px;
&:focus {
background: color-mix(in srgb, var(--primary) 16%, transparent);
color: var(--primary);
outline: none;
}
}
}
input[type='number']:not(.automation-form-field input) {
height: 36px;
border: none;
border-radius: 10px;
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
color: var(--text-primary);
padding: 0 12px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
outline: none;
transition: background 0.2s ease, box-shadow 0.2s ease;
&:focus {
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
.automation-editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent);
flex-shrink: 0;
}
.automation-form-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
>span {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
input[type='text'],
input[type='number'],
input[type='datetime-local'] {
height: 36px;
border: none;
border-radius: 10px;
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
color: var(--text-primary);
padding: 0 12px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
outline: none;
transition: background 0.2s ease, box-shadow 0.2s ease;
&:focus {
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
&::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
}
}
.automation-inline-time {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.automation-inline-check {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
user-select: none;
input[type='checkbox'] {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--text-tertiary) 30%, transparent);
background: transparent;
transition: all 0.2s ease;
position: relative;
&:hover {
background: color-mix(in srgb, var(--text-tertiary) 6%, transparent);
}
&:checked {
background: var(--primary);
border-color: var(--primary);
&::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 7px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
}
}
.automation-segment-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.automation-segment-btn {
border: none;
border-radius: 8px;
padding: 6px 14px;
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.18s ease;
&:hover {
background: color-mix(in srgb, var(--text-tertiary) 14%, transparent);
color: var(--text-primary);
}
&.active {
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: var(--primary);
}
}
.automation-path-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-tertiary);
padding: 4px 2px;
}
.automation-draft-summary {
padding: 10px 12px;
border-radius: 10px;
background: color-mix(in srgb, var(--text-tertiary) 5%, transparent);
font-size: 12px;
color: var(--text-secondary);
line-height: 1.6;
}
.close-icon-btn {
border: none;
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
color: var(--text-secondary);
width: 30px;
height: 30px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: all 0.18s ease;
&:hover {
background: color-mix(in srgb, var(--text-tertiary) 16%, transparent);
color: var(--text-primary);
}
}
.primary-btn {
border: none;
border-radius: 9px;
padding: 8px 18px;
background: var(--primary);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 20%, transparent);
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 85%, #fff);
transform: translateY(-1px);
box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* 终止时间选择器 */
.automation-stopat-picker {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
input {
flex: 1;
min-width: 0;
height: 36px;
border: none;
border-radius: 10px;
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
color: var(--text-primary);
padding: 0 10px;
font-size: 13px;
outline: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-variant-numeric: tabular-nums;
&:hover {
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
}
&:focus {
background: color-mix(in srgb, var(--text-tertiary) 10%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent);
}
}
.automation-stopat-date {
flex: 1.4;
}
.automation-stopat-time {
flex: 1;
text-align: center;
}
}
/* 自动化创建模式提示 */
.automation-create-mode-pill {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 16px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--primary) 15%, transparent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
animation: footprintFadeSlideUp 0.3s ease both;
white-space: nowrap;
margin-left: 8px;
span {
font-size: 13px;
font-weight: 500;
color: var(--primary);
}
.secondary-btn {
height: 24px;
padding: 0 10px;
font-size: 11px;
border-radius: 6px;
}
}
+1393 -76
View File
@@ -32,6 +32,12 @@ import {
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
import type { BackgroundTaskRecord } from '../types/backgroundTask'
import type {
ExportAutomationCondition,
ExportAutomationDateRangeConfig,
ExportAutomationSchedule,
ExportAutomationTask
} from '../types/exportAutomation'
import * as configService from '../services/config'
import {
emitExportSessionStatus,
@@ -55,12 +61,15 @@ import type { SnsPost } from '../types/sns'
import {
cloneExportDateRange,
cloneExportDateRangeSelection,
createDateRangeByLastNDays,
createDefaultDateRange,
createDefaultExportDateRangeSelection,
getExportDateRangeLabel,
resolveExportDateRangeConfig,
serializeExportDateRangeConfig,
startOfDay,
endOfDay,
type ExportDateRangePreset,
type ExportDateRangeSelection
} from '../utils/exportDateRange'
import './ExportPage.scss'
@@ -147,6 +156,8 @@ interface ExportTaskPayload {
outputDir: string
options?: ElectronExportOptions
scope: TaskScope
source: 'manual' | 'automation'
automationTaskId?: string
contentType?: ContentType
sessionNames: string[]
snsOptions?: {
@@ -175,6 +186,7 @@ interface ExportTask {
interface ExportDialogState {
open: boolean
intent: 'manual' | 'automation-create'
scope: TaskScope
contentType?: ContentType
sessionIds: string[]
@@ -182,6 +194,27 @@ interface ExportDialogState {
title: string
}
interface AutomationTaskDraft {
mode: 'create' | 'edit'
id?: string
name: string
enabled: boolean
sessionIds: string[]
sessionNames: string[]
outputDir: string
useGlobalOutputDir: boolean
scope: Exclude<TaskScope, 'sns'>
contentType?: ContentType
optionTemplate: Omit<ElectronExportOptions, 'dateRange'>
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
intervalDays: number
intervalHours: number
stopAtEnabled: boolean
stopAtValue: string
maxRunsEnabled: boolean
maxRuns: number
}
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
@@ -589,6 +622,7 @@ const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean
}
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const createAutomationTaskId = (): string => `auto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
@@ -599,6 +633,220 @@ const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
type SessionDataSource = 'cache' | 'network' | null
type ContactsDataSource = 'cache' | 'network' | null
const normalizeAutomationIntervalDays = (value: unknown): number => Math.max(0, Math.floor(Number(value) || 0))
const normalizeAutomationIntervalHours = (value: unknown): number => Math.max(0, Math.min(23, Math.floor(Number(value) || 0)))
const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
const totalHours = (days * 24) + hours
if (totalHours <= 0) return 0
return totalHours * 60 * 60 * 1000
}
const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
const parts: string[] = []
if (days > 0) parts.push(`${days} 天`)
if (hours > 0) parts.push(`${hours} 小时`)
return `每间隔 ${parts.length > 0 ? parts.join(' ') : '0 小时'} 执行一次`
}
const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date): string | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const nowMs = now.getTime()
const anchorAt = Math.max(
0,
Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)
)
if (nowMs < anchorAt + intervalMs) return null
return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / intervalMs)}`
}
const toDateTimeLocalValue = (timestamp: number): string => {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hours = `${date.getHours()}`.padStart(2, '0')
const minutes = `${date.getMinutes()}`.padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
const parseDateTimeLocalValue = (value: string): number | null => {
const text = String(value || '').trim()
if (!text) return null
const parsed = new Date(text)
const timestamp = parsed.getTime()
if (!Number.isFinite(timestamp)) return null
return Math.floor(timestamp)
}
type AutomationRangeMode = 'all' | 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last1year' | 'lastNDays' | 'custom'
const AUTOMATION_RANGE_OPTIONS: Array<{ mode: AutomationRangeMode; label: string }> = [
{ mode: 'all', label: '全部时间' },
{ mode: 'yesterday', label: '往前1天' },
{ mode: 'last7days', label: '往前7天' },
{ mode: 'last30days', label: '往前30天' },
{ mode: 'last1year', label: '往前1年' },
{ mode: 'lastNDays', label: '往前N天' },
{ mode: 'custom', label: '完整时间' }
]
const AUTOMATION_LAST_N_DAYS_MIN = 1
const AUTOMATION_LAST_N_DAYS_MAX = 3650
const AUTOMATION_LAST_N_DAYS_DEFAULT = 3
const normalizeAutomationLastNDays = (value: unknown): number => {
const parsed = Math.floor(Number(value) || 0)
if (!Number.isFinite(parsed) || parsed <= 0) return AUTOMATION_LAST_N_DAYS_DEFAULT
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, parsed))
}
const readAutomationLastNDays = (
config: ExportAutomationDateRangeConfig | string | null | undefined
): number | null => {
if (!config || typeof config !== 'object') return null
const raw = config as Record<string, unknown>
const mode = String(raw.relativeMode || '').trim()
if (mode !== 'last-n-days') return null
const days = Math.floor(Number(raw.relativeDays) || 0)
if (!Number.isFinite(days) || days <= 0) return null
return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, days))
}
const buildAutomationLastNDaysConfig = (days: number): ExportAutomationDateRangeConfig => ({
version: 1,
preset: 'custom',
useAllTime: false,
relativeMode: 'last-n-days',
relativeDays: normalizeAutomationLastNDays(days)
})
const resolveAutomationDateRangeSelection = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
now = new Date()
): ExportDateRangeSelection => {
const relativeDays = readAutomationLastNDays(config)
if (relativeDays) {
return {
preset: 'custom',
useAllTime: false,
dateRange: createDateRangeByLastNDays(relativeDays, now)
}
}
return resolveExportDateRangeConfig(config as any, now)
}
const resolveAutomationRangeMode = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
selection: ExportDateRangeSelection
): AutomationRangeMode => {
if (readAutomationLastNDays(config)) return 'lastNDays'
if (selection.useAllTime) return 'all'
if (selection.preset === 'today') return 'today'
if (selection.preset === 'yesterday') return 'yesterday'
if (selection.preset === 'last7days') return 'last7days'
if (selection.preset === 'last30days') return 'last30days'
if (selection.preset === 'last1year') return 'last1year'
return 'custom'
}
const createAutomationSelectionByMode = (
mode: Exclude<AutomationRangeMode, 'custom' | 'lastNDays'>,
now = new Date()
): ExportDateRangeSelection => {
const preset: ExportDateRangePreset = mode
return resolveExportDateRangeConfig({
version: 1,
preset,
useAllTime: mode === 'all'
}, now)
}
const formatAutomationRangeLabel = (
config: ExportAutomationDateRangeConfig | string | null | undefined,
selection?: ExportDateRangeSelection
): string => {
const resolved = selection || resolveAutomationDateRangeSelection(config, new Date())
const mode = resolveAutomationRangeMode(config, resolved)
if (mode === 'all') return '每次触发导出全部历史消息'
if (mode === 'today') return '每次触发导出当天'
if (mode === 'yesterday') return '每次触发导出前1天(昨日)'
if (mode === 'last7days') return '每次触发导出前7天'
if (mode === 'last30days') return '每次触发导出前30天'
if (mode === 'last1year') return '每次触发导出前1年'
if (mode === 'lastNDays') {
return `每次触发导出前 ${readAutomationLastNDays(config) || AUTOMATION_LAST_N_DAYS_DEFAULT} 天`
}
return `完整时间:${getExportDateRangeLabel(resolved)}`
}
const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
const endAt = Number(task.stopCondition?.endAt || 0)
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
const labels: string[] = []
if (endAt > 0) {
labels.push(`截止到 ${new Date(endAt).toLocaleString('zh-CN')}`)
}
if (maxRuns > 0) {
const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0)))
labels.push(`成功 ${successCount}/${maxRuns} 次后停止`)
}
return labels.length > 0 ? labels.join(' · ') : '无'
}
const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0))
if (!anchorAt) return null
return anchorAt + intervalMs
}
const formatAutomationCurrentState = (
task: ExportAutomationTask,
queueState: 'queued' | 'running' | null,
nowMs: number
): string => {
if (!task.enabled) return '已停用'
if (queueState === 'running') return '执行中'
if (queueState === 'queued') return '排队中'
const nextTriggerAt = resolveAutomationNextTriggerAt(task)
if (!nextTriggerAt) return '等待触发'
const diff = nextTriggerAt - nowMs
if (diff <= 0) return '即将触发'
return `等待触发 · 下次 ${new Date(nextTriggerAt).toLocaleString('zh-CN')}(约 ${formatDurationMs(diff)} 后)`
}
const formatAutomationLastRunSummary = (task: ExportAutomationTask): string => {
const status = task.runState?.lastRunStatus || 'idle'
const label = (
status === 'idle' ? '尚未执行' :
status === 'queued' ? '已入队' :
status === 'running' ? '执行中' :
status === 'success' ? '执行成功' :
status === 'error' ? '执行失败' :
status === 'skipped' ? '已跳过' :
status
)
const parts: string[] = [label]
if (task.runState?.lastSuccessAt) {
parts.push(`最近成功于 ${new Date(task.runState.lastSuccessAt).toLocaleString('zh-CN')}`)
}
if (task.runState?.lastSkipReason) {
parts.push(task.runState.lastSkipReason)
}
if (task.runState?.lastError) {
parts.push(task.runState.lastError)
}
return parts.join(' · ')
}
interface ContactsLoadSession {
requestId: string
startedAt: number
@@ -1574,6 +1822,8 @@ function ExportPage() {
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false)
const [isAutomationModalOpen, setIsAutomationModalOpen] = useState(false)
const [automationHint, setAutomationHint] = useState<string | null>(null)
const [expandedPerfTaskId, setExpandedPerfTaskId] = useState<string | null>(null)
const [sessions, setSessions] = useState<SessionRow[]>([])
const [sessionDataSource, setSessionDataSource] = useState<SessionDataSource>(null)
@@ -1680,14 +1930,22 @@ function ExportPage() {
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
open: false,
intent: 'manual',
scope: 'single',
sessionIds: [],
sessionNames: [],
title: ''
})
const [isAutomationCreateMode, setIsAutomationCreateMode] = useState(false)
const [showSessionFormatSelect, setShowSessionFormatSelect] = useState(false)
const [tasks, setTasks] = useState<ExportTask[]>([])
const [automationTasks, setAutomationTasks] = useState<ExportAutomationTask[]>([])
const [automationTaskDraft, setAutomationTaskDraft] = useState<AutomationTaskDraft | null>(null)
const [isAutomationRangeDialogOpen, setIsAutomationRangeDialogOpen] = useState(false)
const [isResolvingAutomationRangeBounds, setIsResolvingAutomationRangeBounds] = useState(false)
const [automationRangeBounds, setAutomationRangeBounds] = useState<TimeRangeBounds | null>(null)
const [automationRangeSelection, setAutomationRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({})
const [exportRecordsBySession, setExportRecordsBySession] = useState<Record<string, configService.ExportSessionRecordEntry[]>>({})
@@ -1714,6 +1972,10 @@ function ExportPage() {
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
const runningTaskIdRef = useRef<string | null>(null)
const tasksRef = useRef<ExportTask[]>([])
const automationTasksRef = useRef<ExportAutomationTask[]>([])
const automationTasksReadyRef = useRef(false)
const automationSchedulerRunningRef = useRef(false)
const automationQueueStatusByTaskIdRef = useRef<Map<string, TaskStatus>>(new Map())
const hasSeededSnsStatsRef = useRef(false)
const sessionLoadTokenRef = useRef(0)
const preselectAppliedRef = useRef(false)
@@ -1810,6 +2072,46 @@ function ExportPage() {
return scopeKey
}, [])
const persistAutomationTasks = useCallback(async (nextTasks: ExportAutomationTask[]) => {
if (!automationTasksReadyRef.current) return
const scopeKey = await ensureExportCacheScope()
await configService.setExportAutomationTasks(scopeKey, nextTasks)
}, [ensureExportCacheScope])
const updateAutomationTasks = useCallback((
updater: (prev: ExportAutomationTask[]) => ExportAutomationTask[]
) => {
setAutomationTasks((prev) => {
const next = updater(prev)
void persistAutomationTasks(next)
return next
})
}, [persistAutomationTasks])
const patchAutomationTask = useCallback((
taskId: string,
updater: (task: ExportAutomationTask) => ExportAutomationTask
) => {
updateAutomationTasks((prev) => prev.map((task) => (task.id === taskId ? updater(task) : task)))
}, [updateAutomationTasks])
const markAutomationTaskSkipped = useCallback((taskId: string, reason: string, scheduleKey?: string) => {
const now = Date.now()
patchAutomationTask(taskId, (task) => ({
...task,
updatedAt: now,
runState: {
...(task.runState || {}),
lastRunStatus: 'skipped',
lastTriggeredAt: now,
lastSkipAt: now,
lastSkipReason: reason,
lastError: undefined,
lastScheduleKey: scheduleKey || task.runState?.lastScheduleKey
}
}))
}, [patchAutomationTask])
const loadContactsCaches = useCallback(async (scopeKey: string) => {
const [contactsItem, avatarItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
@@ -1821,6 +2123,22 @@ function ExportPage() {
}
}, [])
const ensureAutomationTasksLoaded = useCallback(async () => {
if (automationTasksReadyRef.current) return
try {
const scopeKey = await ensureExportCacheScope()
const automationTaskItem = await configService.getExportAutomationTasks(scopeKey)
setAutomationTasks(automationTaskItem?.tasks || [])
automationTasksReadyRef.current = true
} catch (error) {
console.error('加载自动化导出任务失败:', error)
}
}, [ensureExportCacheScope])
useEffect(() => {
void ensureAutomationTasksLoaded()
}, [ensureAutomationTasksLoaded])
useEffect(() => {
let cancelled = false
void (async () => {
@@ -2234,6 +2552,10 @@ function ExportPage() {
tasksRef.current = tasks
}, [tasks])
useEffect(() => {
automationTasksRef.current = automationTasks
}, [automationTasks])
useEffect(() => {
sessionsRef.current = sessions
}, [sessions])
@@ -2288,8 +2610,16 @@ function ExportPage() {
return () => window.clearInterval(timer)
}, [isTaskCenterOpen, expandedPerfTaskId, tasks])
useEffect(() => {
if (!isAutomationModalOpen) return
setNowTick(Date.now())
const timer = window.setInterval(() => setNowTick(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [isAutomationModalOpen])
const loadBaseConfig = useCallback(async (): Promise<boolean> => {
setIsBaseConfigLoading(true)
automationTasksReadyRef.current = false
let isReady = true
try {
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
@@ -2314,6 +2644,7 @@ function ExportPage() {
])
const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope)
const automationTaskItem = await configService.getExportAutomationTasks(exportCacheScope)
if (savedPath) {
setExportFolder(savedPath)
@@ -2342,6 +2673,8 @@ function ExportPage() {
setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setAutomationTasks(automationTaskItem?.tasks || [])
automationTasksReadyRef.current = true
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
setTimeRangeSelection(resolvedDefaultDateRange)
@@ -2381,6 +2714,7 @@ function ExportPage() {
}))
} catch (error) {
isReady = false
automationTasksReadyRef.current = false
console.error('加载导出配置失败:', error)
} finally {
setIsBaseConfigLoading(false)
@@ -4046,6 +4380,7 @@ function ExportPage() {
useEffect(() => {
if (isExportRoute) return
// 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。
setIsAutomationCreateMode(false)
sessionLoadTokenRef.current = Date.now()
sessionCountRequestIdRef.current += 1
snsUserPostCountsHydrationTokenRef.current += 1
@@ -4126,8 +4461,8 @@ function ExportPage() {
const clearSelection = () => setSelectedSessions(new Set())
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
setExportDialog({ open: true, ...payload })
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open' | 'intent'> & { intent?: ExportDialogState['intent'] }) => {
setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload })
setIsTimeRangeDialogOpen(false)
setTimeRangeBounds(null)
setTimeRangeSelection(exportDefaultDateRangeSelection)
@@ -4197,7 +4532,7 @@ function ExportPage() {
])
const closeExportDialog = useCallback(() => {
setExportDialog(prev => ({ ...prev, open: false }))
setExportDialog(prev => ({ ...prev, open: false, intent: 'manual' }))
setIsTimeRangeDialogOpen(false)
setTimeRangeBounds(null)
}, [])
@@ -4488,6 +4823,202 @@ function ExportPage() {
}
}
const openCreateAutomationDraft = useCallback(() => {
setIsAutomationModalOpen(false)
setAutomationTaskDraft(null)
setIsAutomationRangeDialogOpen(false)
setIsAutomationCreateMode(true)
setSelectedSessions(new Set())
setAutomationHint('已进入自动化任务创建:请勾选联系人,然后点击「加入任务」')
}, [])
const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => {
const schedule = task.schedule
const stopAt = Number(task.stopCondition?.endAt || 0)
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
setAutomationRangeSelection(resolvedRange)
setAutomationRangeBounds(null)
setAutomationTaskDraft({
mode: 'edit',
id: task.id,
name: task.name,
enabled: task.enabled,
sessionIds: task.sessionIds,
sessionNames: task.sessionNames,
outputDir: task.outputDir || exportFolder,
useGlobalOutputDir: !task.outputDir,
scope: task.template.scope,
contentType: task.template.contentType,
optionTemplate: task.template.optionTemplate,
dateRangeConfig: task.template.dateRangeConfig,
intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays),
intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours),
stopAtEnabled: stopAt > 0,
stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '',
maxRunsEnabled: maxRuns > 0,
maxRuns: maxRuns > 0 ? maxRuns : 0
})
setIsAutomationModalOpen(true)
}, [exportFolder])
const openAutomationDateRangeDialog = useCallback(() => {
if (!automationTaskDraft) return
void (async () => {
if (isResolvingAutomationRangeBounds) return
setIsResolvingAutomationRangeBounds(true)
try {
const nextBounds = await resolveChatExportTimeRangeBounds(automationTaskDraft.sessionIds)
setAutomationRangeBounds(nextBounds)
if (nextBounds) {
const nextSelection = clampExportSelectionToBounds(automationRangeSelection, nextBounds)
if (!areExportSelectionsEqual(nextSelection, automationRangeSelection)) {
setAutomationRangeSelection(nextSelection)
setAutomationTaskDraft((prev) => prev ? {
...prev,
dateRangeConfig: serializeExportDateRangeConfig(nextSelection)
} : prev)
}
}
setIsAutomationRangeDialogOpen(true)
} catch (error) {
console.error('自动化导出解析时间范围边界失败', error)
setAutomationRangeBounds(null)
setIsAutomationRangeDialogOpen(true)
} finally {
setIsResolvingAutomationRangeBounds(false)
}
})()
}, [
automationRangeSelection,
automationTaskDraft,
isResolvingAutomationRangeBounds,
resolveChatExportTimeRangeBounds
])
const applyAutomationRangeMode = useCallback((mode: AutomationRangeMode) => {
if (!automationTaskDraft) return
if (mode === 'custom') {
openAutomationDateRangeDialog()
return
}
if (mode === 'lastNDays') {
const relativeDays = readAutomationLastNDays(automationTaskDraft.dateRangeConfig) || AUTOMATION_LAST_N_DAYS_DEFAULT
const nextSelection: ExportDateRangeSelection = {
preset: 'custom',
useAllTime: false,
dateRange: createDateRangeByLastNDays(relativeDays, new Date())
}
setAutomationRangeSelection(nextSelection)
setAutomationTaskDraft((prev) => prev ? {
...prev,
dateRangeConfig: buildAutomationLastNDaysConfig(relativeDays)
} : prev)
return
}
const nextSelection = createAutomationSelectionByMode(mode, new Date())
setAutomationRangeSelection(nextSelection)
setAutomationTaskDraft((prev) => prev ? {
...prev,
dateRangeConfig: serializeExportDateRangeConfig(nextSelection)
} : prev)
}, [automationTaskDraft, openAutomationDateRangeDialog])
const updateAutomationLastNDays = useCallback((value: unknown) => {
const days = normalizeAutomationLastNDays(value)
const nextSelection: ExportDateRangeSelection = {
preset: 'custom',
useAllTime: false,
dateRange: createDateRangeByLastNDays(days, new Date())
}
setAutomationRangeSelection(nextSelection)
setAutomationTaskDraft((prev) => prev ? {
...prev,
dateRangeConfig: buildAutomationLastNDaysConfig(days)
} : prev)
}, [])
const saveAutomationTaskDraft = useCallback(() => {
if (!automationTaskDraft) return
if (!automationTasksReadyRef.current) {
automationTasksReadyRef.current = true
}
const normalizedName = automationTaskDraft.name.trim()
if (!normalizedName) {
window.alert('请输入任务名称')
return
}
if (automationTaskDraft.sessionIds.length === 0) {
window.alert('自动化任务至少需要一个会话')
return
}
const intervalDays = normalizeAutomationIntervalDays(automationTaskDraft.intervalDays)
const intervalHours = normalizeAutomationIntervalHours(automationTaskDraft.intervalHours)
if (intervalDays <= 0 && intervalHours <= 0) {
window.alert('执行间隔不能为 0,请至少设置天数或小时')
return
}
const schedule: ExportAutomationSchedule = { type: 'interval', intervalDays, intervalHours }
const stopAtTimestamp = automationTaskDraft.stopAtEnabled
? parseDateTimeLocalValue(automationTaskDraft.stopAtValue)
: null
if (automationTaskDraft.stopAtEnabled && !stopAtTimestamp) {
window.alert('请填写有效的终止时间')
return
}
const maxRuns = automationTaskDraft.maxRunsEnabled
? Math.max(0, Math.floor(Number(automationTaskDraft.maxRuns || 0)))
: 0
if (automationTaskDraft.maxRunsEnabled && maxRuns <= 0) {
window.alert('请填写大于 0 的最大执行次数')
return
}
const stopCondition = {
endAt: stopAtTimestamp && stopAtTimestamp > 0 ? stopAtTimestamp : undefined,
maxRuns: maxRuns > 0 ? maxRuns : undefined
}
const now = Date.now()
const condition: ExportAutomationCondition = { type: 'new-message-since-last-success' }
const nextTask: ExportAutomationTask = {
id: automationTaskDraft.mode === 'edit' && automationTaskDraft.id
? automationTaskDraft.id
: createAutomationTaskId(),
name: normalizedName,
enabled: automationTaskDraft.enabled,
sessionIds: [...automationTaskDraft.sessionIds],
sessionNames: [...automationTaskDraft.sessionNames],
outputDir: automationTaskDraft.useGlobalOutputDir ? undefined : String(automationTaskDraft.outputDir || '').trim(),
schedule,
condition,
stopCondition: (stopCondition.endAt || stopCondition.maxRuns) ? stopCondition : undefined,
template: {
scope: automationTaskDraft.scope,
contentType: automationTaskDraft.contentType,
optionTemplate: { ...automationTaskDraft.optionTemplate },
dateRangeConfig: automationTaskDraft.dateRangeConfig
},
runState: automationTaskDraft.mode === 'edit'
? automationTasksRef.current.find((item) => item.id === automationTaskDraft.id)?.runState
: { lastRunStatus: 'idle', successCount: 0 },
createdAt: automationTaskDraft.mode === 'edit'
? (automationTasksRef.current.find((item) => item.id === automationTaskDraft.id)?.createdAt || now)
: now,
updatedAt: now
}
updateAutomationTasks((prev) => {
if (automationTaskDraft.mode === 'edit' && automationTaskDraft.id) {
return prev.map((task) => (task.id === automationTaskDraft.id ? nextTask : task))
}
return [nextTask, ...prev]
})
setAutomationTaskDraft(null)
setIsAutomationRangeDialogOpen(false)
setAutomationHint(automationTaskDraft.mode === 'edit' ? '自动化任务已更新' : '自动化任务已创建')
}, [automationTaskDraft, updateAutomationTasks])
const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => {
setLastExportBySession(prev => {
const next = { ...prev }
@@ -4963,10 +5494,123 @@ function ExportPage() {
}
}, [])
const enqueueExportTask = useCallback((title: string, payload: ExportTaskPayload): string => {
const task: ExportTask = {
id: createTaskId(),
title,
status: 'queued',
settledSessionIds: [],
createdAt: Date.now(),
payload,
progress: createEmptyProgress(),
performance: payload.scope === 'content' && payload.contentType === 'text'
? createEmptyTaskPerformance()
: undefined
}
setTasks(prev => [task, ...prev])
return task.id
}, [])
const buildAutomationExportOptions = useCallback((task: ExportAutomationTask): ElectronExportOptions => {
const selection = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
const dateRange = selection.useAllTime
? null
: {
start: Math.floor(selection.dateRange.start.getTime() / 1000),
end: Math.floor(selection.dateRange.end.getTime() / 1000)
}
return {
...task.template.optionTemplate,
dateRange
}
}, [])
const enqueueAutomationTask = useCallback((
task: ExportAutomationTask,
options?: { scheduleKey?: string; force?: boolean; reason?: string }
): { queued: boolean; reason?: string } => {
const outputDir = String(task.outputDir || exportFolder || '').trim()
if (!outputDir) {
return { queued: false, reason: '导出目录未设置' }
}
const hasConflict = tasksRef.current.some((item) => {
if (item.status !== 'running' && item.status !== 'queued') return false
return item.payload.automationTaskId === task.id
})
if (hasConflict) {
return { queued: false, reason: '任务已有执行队列,本次触发已跳过' }
}
const exportOptions = buildAutomationExportOptions(task)
const contentType = task.template.contentType
const title = `自动化导出:${task.name}`
enqueueExportTask(title, {
sessionIds: task.sessionIds,
sessionNames: task.sessionNames,
outputDir,
options: exportOptions,
scope: task.template.scope,
source: 'automation',
automationTaskId: task.id,
contentType
})
const now = Date.now()
patchAutomationTask(task.id, (prev) => ({
...prev,
updatedAt: now,
runState: {
...(prev.runState || {}),
lastRunStatus: 'queued',
lastTriggeredAt: now,
lastSkipReason: undefined,
lastError: undefined,
lastScheduleKey: options?.scheduleKey || prev.runState?.lastScheduleKey
}
}))
if (options?.reason) {
setAutomationHint(options.reason)
}
return { queued: true }
}, [
buildAutomationExportOptions,
enqueueExportTask,
exportFolder,
patchAutomationTask
])
const resolveAutomationHasNewMessages = useCallback(async (task: ExportAutomationTask): Promise<{ shouldRun: boolean; reason?: string }> => {
const lastSuccessAt = Number(task.runState?.lastSuccessAt || 0)
if (!lastSuccessAt) return { shouldRun: true }
const stats = await window.electronAPI.chat.getExportSessionStats(task.sessionIds, {
includeRelations: false,
allowStaleCache: true
})
if (!stats.success || !stats.data) {
return { shouldRun: false, reason: stats.error || '会话统计失败,已跳过' }
}
let latestTimestamp = 0
for (const sessionId of task.sessionIds) {
const raw = Number(stats.data?.[sessionId]?.lastTimestamp || 0)
if (Number.isFinite(raw) && raw > latestTimestamp) {
latestTimestamp = Math.max(0, Math.floor(raw))
}
}
if (latestTimestamp <= 0) {
return { shouldRun: false, reason: '未检测到可用会话时间戳,已跳过' }
}
const lastSuccessSeconds = Math.floor(lastSuccessAt / 1000)
if (latestTimestamp <= lastSuccessSeconds) {
return { shouldRun: false, reason: '目标会话无新消息,本次已跳过' }
}
return { shouldRun: true }
}, [])
const createTask = async () => {
if (!exportDialog.open || !exportFolder) return
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
const isAutomationCreateIntent = exportDialog.intent === 'automation-create'
const exportOptions = exportDialog.scope === 'sns'
? undefined
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
@@ -4982,30 +5626,58 @@ function ExportPage() {
? '朋友圈批量导出'
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出`
const task: ExportTask = {
id: createTaskId(),
title,
status: 'queued',
settledSessionIds: [],
createdAt: Date.now(),
payload: {
sessionIds: exportDialog.sessionIds,
sessionNames: exportDialog.sessionNames,
if (isAutomationCreateIntent) {
if (!exportOptions || exportDialog.scope === 'sns') {
window.alert('自动化任务仅支持会话导出')
return
}
const { dateRange: _discard, ...optionTemplate } = exportOptions
const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection)
const scope = exportDialog.scope === 'single'
? 'single'
: exportDialog.scope === 'content'
? 'content'
: 'multi'
setAutomationRangeSelection(normalizedRangeSelection)
setAutomationRangeBounds(null)
setAutomationTaskDraft({
mode: 'create',
name: exportDialog.sessionIds.length === 1
? `${exportDialog.sessionNames[0] || '单会话'} 自动化导出`
: `自动化导出(${exportDialog.sessionIds.length} 个会话)`,
enabled: true,
sessionIds: [...exportDialog.sessionIds],
sessionNames: [...exportDialog.sessionNames],
outputDir: exportFolder,
options: exportOptions,
scope: exportDialog.scope,
contentType: exportDialog.contentType,
snsOptions
},
progress: createEmptyProgress(),
performance: exportDialog.scope === 'content' && exportDialog.contentType === 'text'
? createEmptyTaskPerformance()
: undefined
useGlobalOutputDir: true,
scope,
contentType: scope === 'content' ? exportDialog.contentType : undefined,
optionTemplate,
dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection),
intervalDays: 1,
intervalHours: 0,
stopAtEnabled: false,
stopAtValue: '',
maxRunsEnabled: false,
maxRuns: 0
})
setIsAutomationCreateMode(false)
setAutomationHint('导出配置已完成,请继续设置自动化规则并保存任务')
closeExportDialog()
} else {
enqueueExportTask(title, {
sessionIds: exportDialog.sessionIds,
sessionNames: exportDialog.sessionNames,
outputDir: exportFolder,
options: exportOptions,
scope: exportDialog.scope,
source: 'manual',
contentType: exportDialog.contentType,
snsOptions
})
closeExportDialog()
}
setTasks(prev => [task, ...prev])
closeExportDialog()
await configService.setExportDefaultFormat(options.format)
await configService.setExportDefaultAvatars(options.exportAvatars)
await configService.setExportDefaultMedia({
@@ -5062,6 +5734,30 @@ function ExportPage() {
.map((item) => item.session)
}, [resolveSessionExistingMessageCount])
const exitAutomationCreateMode = useCallback(() => {
setIsAutomationCreateMode(false)
setAutomationHint('已退出自动化任务创建')
}, [])
const openAutomationExportConfigDialog = useCallback(() => {
const selectedSet = new Set(selectedSessions)
const selectedRows = sessions.filter((session) => selectedSet.has(session.username))
const orderedRows = orderSessionsForExport(selectedRows)
if (orderedRows.length === 0) {
window.alert('请先勾选至少一个可导出的会话')
return
}
const ids = orderedRows.map((session) => session.username)
const names = orderedRows.map((session) => session.displayName || session.username)
openExportDialog({
scope: 'multi',
sessionIds: ids,
sessionNames: names,
title: `自动化任务导出配置(${ids.length} 个会话)`,
intent: 'automation-create'
})
}, [openExportDialog, orderSessionsForExport, selectedSessions, sessions])
const openBatchExport = () => {
const selectedSet = new Set(selectedSessions)
const selectedRows = sessions.filter((session) => selectedSet.has(session.username))
@@ -5147,6 +5843,181 @@ function ExportPage() {
[tasks]
)
useEffect(() => {
const previous = automationQueueStatusByTaskIdRef.current
const next = new Map<string, TaskStatus>()
for (const task of tasks) {
if (task.payload.source !== 'automation' || !task.payload.automationTaskId) continue
const automationTaskId = task.payload.automationTaskId
next.set(task.id, task.status)
const previousStatus = previous.get(task.id)
if (previousStatus === task.status) continue
const now = Date.now()
if (task.status === 'running') {
patchAutomationTask(automationTaskId, (current) => ({
...current,
updatedAt: now,
runState: {
...(current.runState || {}),
lastRunStatus: 'running',
lastStartedAt: now,
lastSkipReason: undefined,
lastError: undefined
}
}))
} else if (task.status === 'success') {
patchAutomationTask(automationTaskId, (current) => ({
...current,
updatedAt: now,
runState: {
...(current.runState || {}),
lastRunStatus: 'success',
lastFinishedAt: now,
lastSuccessAt: now,
lastSkipReason: undefined,
lastError: undefined,
successCount: Math.max(0, Math.floor(Number(current.runState?.successCount || 0))) + 1
}
}))
} else if (task.status === 'error') {
patchAutomationTask(automationTaskId, (current) => ({
...current,
updatedAt: now,
runState: {
...(current.runState || {}),
lastRunStatus: 'error',
lastFinishedAt: now,
lastError: task.error || '导出失败'
}
}))
}
}
automationQueueStatusByTaskIdRef.current = next
}, [patchAutomationTask, tasks])
const evaluateAutomationSchedules = useCallback(async () => {
if (!automationTasksReadyRef.current) return
if (automationSchedulerRunningRef.current) return
automationSchedulerRunningRef.current = true
try {
const now = new Date()
const enabledTasks = automationTasksRef.current.filter((task) => task.enabled)
for (const task of enabledTasks) {
const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0)))
const maxRuns = Math.max(0, Math.floor(Number(task.stopCondition?.maxRuns || 0)))
if (maxRuns > 0 && successCount >= maxRuns) {
const stopAt = Date.now()
patchAutomationTask(task.id, (current) => ({
...current,
enabled: false,
updatedAt: stopAt,
runState: {
...(current.runState || {}),
lastRunStatus: 'skipped',
lastSkipAt: stopAt,
lastSkipReason: `已达到最大执行次数(${maxRuns} 次),任务已自动停用`,
successCount: Math.max(0, Math.floor(Number(current.runState?.successCount || 0)))
}
}))
continue
}
const endAt = Number(task.stopCondition?.endAt || 0)
if (endAt > 0 && now.getTime() > endAt) {
const stopAt = Date.now()
patchAutomationTask(task.id, (current) => ({
...current,
enabled: false,
updatedAt: stopAt,
runState: {
...(current.runState || {}),
lastRunStatus: 'skipped',
lastSkipAt: stopAt,
lastSkipReason: '已超过终止时间,任务已自动停用'
}
}))
continue
}
const scheduleKey = resolveAutomationDueScheduleKey(task, now)
if (!scheduleKey) continue
if (task.runState?.lastScheduleKey === scheduleKey) continue
const hasConflict = tasksRef.current.some((item) => {
if (item.status !== 'running' && item.status !== 'queued') return false
return item.payload.automationTaskId === task.id
})
if (hasConflict) {
markAutomationTaskSkipped(task.id, '任务仍在执行中,本次触发已跳过', scheduleKey)
continue
}
if (task.condition.type === 'new-message-since-last-success') {
const checkResult = await resolveAutomationHasNewMessages(task)
if (!checkResult.shouldRun) {
markAutomationTaskSkipped(task.id, checkResult.reason || '无新消息,本次触发已跳过', scheduleKey)
continue
}
}
const queued = enqueueAutomationTask(task, { scheduleKey })
if (!queued.queued) {
markAutomationTaskSkipped(task.id, queued.reason || '触发失败,本次已跳过', scheduleKey)
}
}
} finally {
automationSchedulerRunningRef.current = false
}
}, [
enqueueAutomationTask,
markAutomationTaskSkipped,
patchAutomationTask,
resolveAutomationHasNewMessages
])
useEffect(() => {
let cancelled = false
const run = async () => {
if (cancelled) return
if (!automationTasksReadyRef.current) return
try {
await evaluateAutomationSchedules()
} catch (error) {
console.error('自动化导出调度失败:', error)
}
}
void run()
const timer = window.setInterval(() => {
void run()
}, 30_000)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [evaluateAutomationSchedules])
const runAutomationTaskNow = useCallback((taskId: string) => {
const target = automationTasksRef.current.find((task) => task.id === taskId)
if (!target) return
const queued = enqueueAutomationTask(target, {
reason: `已手动触发「${target.name}」`,
scheduleKey: target.runState?.lastScheduleKey
})
if (!queued.queued) {
markAutomationTaskSkipped(taskId, queued.reason || '手动触发失败')
setAutomationHint(queued.reason || '手动触发失败')
return
}
setAutomationHint(`已加入队列:${target.name}`)
}, [enqueueAutomationTask, markAutomationTaskSkipped])
useEffect(() => {
if (!automationHint) return
const timer = window.setTimeout(() => setAutomationHint(null), 2600)
return () => window.clearTimeout(timer)
}, [automationHint])
const inProgressSessionIdsKey = useMemo(
() => inProgressSessionIds.join('||'),
[inProgressSessionIds]
@@ -6497,6 +7368,7 @@ function ExportPage() {
const canCreateTask = exportDialog.scope === 'sns'
? Boolean(exportFolder)
: Boolean(exportFolder) && exportDialog.sessionIds.length > 0
const isAutomationCreateDialog = exportDialog.intent === 'automation-create'
const scopeLabel = exportDialog.scope === 'single'
? '单会话'
: exportDialog.scope === 'multi'
@@ -6815,6 +7687,43 @@ function ExportPage() {
const toggleTaskPerfDetail = useCallback((taskId: string) => {
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
}, [])
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
const now = Date.now()
patchAutomationTask(taskId, (task) => ({
...task,
enabled,
updatedAt: now
}))
setAutomationHint(enabled ? '自动化任务已启用' : '自动化任务已停用')
}, [patchAutomationTask])
const deleteAutomationTask = useCallback((taskId: string) => {
const target = automationTasksRef.current.find((task) => task.id === taskId)
if (!target) return
const confirmed = window.confirm(`确认删除自动化任务「${target.name}」吗?`)
if (!confirmed) return
updateAutomationTasks((prev) => prev.filter((task) => task.id !== taskId))
setAutomationHint('自动化任务已删除')
}, [updateAutomationTasks])
const chooseAutomationDraftOutputDir = useCallback(async () => {
if (!automationTaskDraft) return
const result = await window.electronAPI.dialog.openFile({
title: '选择任务导出目录',
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) return
const outputDir = result.filePaths[0]
setAutomationTaskDraft((prev) => {
if (!prev) return prev
return {
...prev,
outputDir,
useGlobalOutputDir: false
}
})
}, [automationTaskDraft])
const renderContactRow = useCallback((index: number, contact: ContactInfo) => {
const matchedSession = sessionRowByUsername.get(contact.username)
const canExport = Boolean(matchedSession?.hasSession)
@@ -6824,6 +7733,7 @@ function ExportPage() {
const isQueued = canExport && queuedSessionIds.has(contact.username)
const recentExportTimestamp = lastExportBySession[contact.username]
const hasRecentExport = canExport && Boolean(recentExportTimestamp)
const showRecentExport = !isAutomationCreateMode && hasRecentExport
const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : ''
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
@@ -7014,24 +7924,26 @@ function ExportPage() {
</div>
)}
<div className="row-action-cell">
<div className={`row-action-main ${hasRecentExport ? '' : 'single-line'}`.trim()}>
<div className={`row-export-action-stack ${hasRecentExport ? '' : 'single-line'}`.trim()}>
<button
type="button"
className={`row-export-link ${isRunning ? 'state-running' : ''} ${!canExport ? 'state-disabled' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'}
</button>
{hasRecentExport && <span className="row-export-time">{recentExportTime}</span>}
</div>
<div className={`row-action-main ${showRecentExport ? '' : 'single-line'}`.trim()}>
{!isAutomationCreateMode && (
<div className={`row-export-action-stack ${showRecentExport ? '' : 'single-line'}`.trim()}>
<button
type="button"
className={`row-export-link ${isRunning ? 'state-running' : ''} ${!canExport ? 'state-disabled' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '导出'}
</button>
{showRecentExport && <span className="row-export-time">{recentExportTime}</span>}
</div>
)}
<button
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
onClick={() => openSessionDetail(contact.username)}
@@ -7045,6 +7957,7 @@ function ExportPage() {
)
}, [
filteredContacts,
isAutomationCreateMode,
lastExportBySession,
navigate,
nowTick,
@@ -7142,6 +8055,10 @@ function ExportPage() {
}
}, [])
const sortedAutomationTasks = useMemo(() => (
[...automationTasks].sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
), [automationTasks])
return (
<div className="export-board-page">
<div className="export-top-panel">
@@ -7183,6 +8100,16 @@ function ExportPage() {
/>
<div className="more-export-settings-control">
<button
className="more-export-settings-btn"
type="button"
onClick={() => {
setIsAutomationCreateMode(false)
setIsAutomationModalOpen(true)
}}
>
自动化导出
</button>
<button
className="more-export-settings-btn"
type="button"
@@ -7204,6 +8131,9 @@ function ExportPage() {
)}
</button>
</div>
{automationHint && (
<div className="automation-hint-pill">{automationHint}</div>
)}
</div>
<TaskCenterModal
@@ -7217,6 +8147,368 @@ function ExportPage() {
onTogglePerfTask={toggleTaskPerfDetail}
/>
{isAutomationModalOpen && createPortal(
<div
className="automation-modal-overlay"
onClick={() => {
setIsAutomationModalOpen(false)
setAutomationTaskDraft(null)
setIsAutomationRangeDialogOpen(false)
}}
>
<div
className="automation-modal"
role="dialog"
aria-modal="true"
aria-label="自动化导出任务"
onClick={(event) => event.stopPropagation()}
>
<div className="automation-modal-header">
<div>
<h3>自动化导出</h3>
<p>仅在应用运行期间生效;错过触发不会补跑。</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
type="button"
className="secondary-btn"
onClick={openCreateAutomationDraft}
>
新建任务
</button>
<button
className="close-icon-btn"
type="button"
onClick={() => {
setIsAutomationModalOpen(false)
setAutomationTaskDraft(null)
setIsAutomationRangeDialogOpen(false)
}}
aria-label="关闭自动化导出"
>
<X size={16} />
</button>
</div>
</div>
<div className="automation-modal-body">
{sortedAutomationTasks.length === 0 ? (
<div className="automation-empty">
暂无自动化任务。点击右上角「新建任务」开始配置。
</div>
) : (
<div className="automation-task-list">
{sortedAutomationTasks.map((task) => {
const linkedQueueTask = tasks.find((item) => (
(item.status === 'running' || item.status === 'queued') &&
item.payload.automationTaskId === task.id
))
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
? 'running'
: linkedQueueTask?.status === 'queued'
? 'queued'
: null
return (
<div key={task.id} className={`automation-task-card ${task.enabled ? '' : 'disabled'}`.trim()}>
<div className="automation-task-main">
<div className="automation-task-title-row">
<strong>{task.name}</strong>
<span className={`automation-task-status ${task.enabled ? 'enabled' : 'disabled'}`}>
{task.enabled ? '已启用' : '已停用'}
</span>
{queueState === 'running' && <span className="automation-task-status running">执行中</span>}
{queueState === 'queued' && <span className="automation-task-status queued">排队中</span>}
</div>
<p>{formatAutomationScheduleLabel(task.schedule)}</p>
<p>时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}</p>
<p>会话范围:{task.sessionIds.length} 个</p>
<p>导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}</p>
<p>当前状态:{formatAutomationCurrentState(task, queueState, nowTick)}</p>
<p>终止条件:{formatAutomationStopCondition(task)}</p>
<p>最近结果:{formatAutomationLastRunSummary(task)}</p>
</div>
<div className="automation-task-actions">
<button
type="button"
className="task-action-btn"
onClick={() => toggleAutomationTaskEnabled(task.id, !task.enabled)}
>
{task.enabled ? '停用' : '启用'}
</button>
<button
type="button"
className="task-action-btn"
onClick={() => runAutomationTaskNow(task.id)}
>
立即执行
</button>
<button
type="button"
className="task-action-btn"
onClick={() => openEditAutomationTaskDraft(task)}
>
编辑
</button>
<button
type="button"
className="task-action-btn danger"
onClick={() => deleteAutomationTask(task.id)}
>
删除
</button>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>,
document.body
)}
{automationTaskDraft && createPortal(
<div className="automation-editor-overlay" onClick={() => {
setAutomationTaskDraft(null)
setIsAutomationRangeDialogOpen(false)
}}>
<div
className="automation-editor-modal"
role="dialog"
aria-modal="true"
aria-label="编辑自动化任务"
onClick={(event) => event.stopPropagation()}
>
<div className="automation-editor-header">
<h3>{automationTaskDraft.mode === 'edit' ? '编辑自动化任务' : '创建自动化任务'}</h3>
<button
className="close-icon-btn"
type="button"
onClick={() => {
setAutomationTaskDraft(null)
setIsAutomationRangeDialogOpen(false)
}}
aria-label="关闭自动化任务编辑"
>
<X size={16} />
</button>
</div>
<div className="automation-editor-body">
<label className="automation-form-field">
<span>任务名称</span>
<input
type="text"
value={automationTaskDraft.name}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? { ...prev, name: event.target.value } : prev)}
placeholder="例如:工作日会话归档"
/>
</label>
<div className="automation-inline-time">
<label className="automation-form-field">
<span>间隔天数</span>
<input
type="number"
min={0}
value={automationTaskDraft.intervalDays}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
...prev,
intervalDays: normalizeAutomationIntervalDays(event.target.value)
} : prev)}
/>
</label>
<label className="automation-form-field">
<span>间隔小时</span>
<input
type="number"
min={0}
max={23}
value={automationTaskDraft.intervalHours}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
...prev,
intervalHours: normalizeAutomationIntervalHours(event.target.value)
} : prev)}
/>
</label>
</div>
<div className="automation-form-field">
<span>导出时间范围(按触发时间动态计算)</span>
<div className="automation-segment-row">
{AUTOMATION_RANGE_OPTIONS.map((option) => {
const active = resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === option.mode
return (
<button
key={option.mode}
type="button"
className={`automation-segment-btn ${active ? 'active' : ''}`.trim()}
onClick={() => applyAutomationRangeMode(option.mode)}
>
{option.label}
</button>
)
})}
</div>
{resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === 'lastNDays' && (
<label className="automation-form-field">
<span>往前天数</span>
<input
type="number"
min={AUTOMATION_LAST_N_DAYS_MIN}
max={AUTOMATION_LAST_N_DAYS_MAX}
value={readAutomationLastNDays(automationTaskDraft.dateRangeConfig) || AUTOMATION_LAST_N_DAYS_DEFAULT}
onChange={(event) => updateAutomationLastNDays(event.target.value)}
/>
</label>
)}
<div className="automation-path-row">
<span>{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)}</span>
{resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === 'custom' && (
<button
type="button"
className="task-action-btn"
onClick={openAutomationDateRangeDialog}
disabled={isResolvingAutomationRangeBounds}
>
{isResolvingAutomationRangeBounds ? '解析中...' : '编辑完整时间'}
</button>
)}
</div>
</div>
<div className="automation-form-field">
<span>终止条件(可选)</span>
<label className="automation-inline-check">
<input
type="checkbox"
checked={automationTaskDraft.stopAtEnabled}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
...prev,
stopAtEnabled: event.target.checked
} : prev)}
/>
到指定时间后自动停止
</label>
{automationTaskDraft.stopAtEnabled && (
<div className="automation-stopat-picker">
<input
type="date"
className="automation-stopat-date"
value={automationTaskDraft.stopAtValue ? automationTaskDraft.stopAtValue.slice(0, 10) : ''}
onChange={(e) => {
const datePart = e.target.value
const timePart = automationTaskDraft.stopAtValue?.slice(11) || '23:59'
setAutomationTaskDraft((prev) => prev ? { ...prev, stopAtValue: datePart ? `${datePart}T${timePart}` : '' } : prev)
}}
/>
<input
type="time"
className="automation-stopat-time"
value={automationTaskDraft.stopAtValue ? automationTaskDraft.stopAtValue.slice(11) : '23:59'}
onChange={(e) => {
const timePart = e.target.value
const datePart = automationTaskDraft.stopAtValue?.slice(0, 10) || new Date().toISOString().slice(0, 10)
setAutomationTaskDraft((prev) => prev ? { ...prev, stopAtValue: `${datePart}T${timePart}` } : prev)
}}
/>
</div>
)}
<label className="automation-inline-check">
<input
type="checkbox"
checked={automationTaskDraft.maxRunsEnabled}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
...prev,
maxRunsEnabled: event.target.checked
} : prev)}
/>
成功执行指定次数后自动停止
</label>
{automationTaskDraft.maxRunsEnabled && (
<input
type="number"
min={1}
value={automationTaskDraft.maxRuns}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
...prev,
maxRuns: Math.max(0, Math.floor(Number(event.target.value) || 0))
} : prev)}
/>
)}
</div>
<div className="automation-form-field">
<span>导出目录</span>
<label className="automation-inline-check">
<input
type="checkbox"
checked={automationTaskDraft.useGlobalOutputDir}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
...prev,
useGlobalOutputDir: event.target.checked
} : prev)}
/>
使用全局导出目录
</label>
{!automationTaskDraft.useGlobalOutputDir && (
<div className="automation-path-row">
<button type="button" className="task-action-btn" onClick={() => void chooseAutomationDraftOutputDir()}>
选择目录
</button>
<span>{automationTaskDraft.outputDir || '未设置'}</span>
</div>
)}
</div>
<label className="automation-inline-check">
<input
type="checkbox"
checked={automationTaskDraft.enabled}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? { ...prev, enabled: event.target.checked } : prev)}
/>
创建后立即启用
</label>
<div className="automation-draft-summary">
会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
</div>
</div>
<div className="automation-editor-actions">
<button
type="button"
className="secondary-btn"
onClick={() => {
setAutomationTaskDraft(null)
setIsAutomationRangeDialogOpen(false)
}}
>
取消
</button>
<button type="button" className="primary-btn" onClick={saveAutomationTaskDraft}>保存任务</button>
</div>
<ExportDateRangeDialog
open={isAutomationRangeDialogOpen}
value={automationRangeSelection}
minDate={automationRangeBounds?.minDate}
maxDate={automationRangeBounds?.maxDate}
onClose={() => setIsAutomationRangeDialogOpen(false)}
onConfirm={(nextSelection) => {
setAutomationRangeSelection(nextSelection)
setAutomationTaskDraft((prev) => prev ? {
...prev,
dateRangeConfig: serializeExportDateRangeConfig(nextSelection)
} : prev)
setIsAutomationRangeDialogOpen(false)
}}
/>
</div>
</div>,
document.body
)}
{isExportDefaultsModalOpen && createPortal(
<div
className="export-defaults-modal-overlay"
@@ -7307,24 +8599,26 @@ function ExportPage() {
</div>
))}
</div>
<button
className={`card-export-btn ${isPrimaryCard ? 'primary' : 'secondary'} ${isCardRunning ? 'running' : ''}`}
disabled={isCardRunning}
onClick={() => {
if (card.type === 'sns') {
openSnsExport()
return
}
openContentExport(card.type)
}}
>
{isCardRunning ? (
<>
<span>批量导出中</span>
<Loader2 size={14} className="spin" />
</>
) : '批量导出'}
</button>
{!isAutomationCreateMode && (
<button
className={`card-export-btn ${isPrimaryCard ? 'primary' : 'secondary'} ${isCardRunning ? 'running' : ''}`}
disabled={isCardRunning}
onClick={() => {
if (card.type === 'sns') {
openSnsExport()
return
}
openContentExport(card.type)
}}
>
{isCardRunning ? (
<>
<span>批量导出中</span>
<Loader2 size={14} className="spin" />
</>
) : '批量导出'}
</button>
)}
</div>
)
})}
@@ -7340,6 +8634,18 @@ function ExportPage() {
'你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。'
]}
/>
{isAutomationCreateMode && (
<div className="automation-create-mode-pill">
<span>自动化创建中:先勾选联系人,再点击「加入任务」</span>
<button
type="button"
className="secondary-btn"
onClick={exitAutomationCreateMode}
>
退出
</button>
</div>
)}
<button
className={`session-load-detail-entry ${showSessionLoadDetailModal ? 'open' : ''} ${isSessionLoadDetailActive && !showSessionLoadDetailModal ? 'active' : ''}`.trim()}
type="button"
@@ -7450,6 +8756,15 @@ function ExportPage() {
<span className="contacts-list-header-media">共同好友</span>
)}
<span className="contacts-list-header-actions">
{isAutomationCreateMode && (
<button
className="selection-clear-btn"
type="button"
onClick={exitAutomationCreateMode}
>
退出创建
</button>
)}
{selectedCount > 0 && (
<>
<button
@@ -7462,9 +8777,9 @@ function ExportPage() {
<button
className="selection-export-btn"
type="button"
onClick={openBatchExport}
onClick={isAutomationCreateMode ? openAutomationExportConfigDialog : openBatchExport}
>
<span>批量导出</span>
<span>{isAutomationCreateMode ? '加入任务' : '批量导出'}</span>
<span className="selection-export-count">{selectedCount}</span>
</button>
</>
@@ -8299,20 +9614,22 @@ function ExportPage() {
<div className="dialog-section">
<div className="section-header-action">
<h4>时间范围</h4>
<button
type="button"
className="time-range-trigger"
onClick={openTimeRangeDialog}
disabled={isResolvingTimeRangeBounds}
>
<span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
<span className="time-range-arrow">&gt;</span>
</button>
{!isAutomationCreateDialog && (
<div className="dialog-section">
<div className="section-header-action">
<h4>时间范围</h4>
<button
type="button"
className="time-range-trigger"
onClick={openTimeRangeDialog}
disabled={isResolvingTimeRangeBounds}
>
<span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
<span className="time-range-arrow">&gt;</span>
</button>
</div>
</div>
</div>
)}
{shouldShowMediaSection && (
<div className="dialog-section">
@@ -8490,7 +9807,7 @@ function ExportPage() {
<div className="dialog-actions">
<button className="secondary-btn" onClick={closeExportDialog}>取消</button>
<button className="primary-btn" onClick={() => void createTask()} disabled={!canCreateTask}>
<Download size={14} /> 创建导出任务
<Download size={14} /> {isAutomationCreateDialog ? '下一步:自动化规则' : '创建导出任务'}
</button>
</div>
+61 -31
View File
@@ -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>
)
+58
View File
@@ -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;
}
+238 -27
View File
@@ -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>
+194 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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
View 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
}