From 411f8a8d611cf68f1128454548de5431477fb6e7 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:12:08 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=8B=E5=8F=8B?= =?UTF-8?q?=E5=9C=88=E5=B0=81=E9=9D=A2=E4=BF=A1=E6=81=AF=E8=A2=AB=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=A7=A3=E6=9E=90=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9B?= =?UTF-8?q?=E8=A7=A3=E5=86=B3=E4=BA=86=E4=B8=80=E4=BA=9B=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 5 + electron/preload.ts | 3 +- electron/services/config.ts | 221 +++++++++++++++++++++++++++- src/App.tsx | 2 +- src/components/LockScreen.tsx | 10 ++ src/components/Sidebar.tsx | 4 +- src/components/Sns/SnsMediaGrid.tsx | 19 ++- src/components/Sns/SnsPostItem.tsx | 2 +- src/pages/SettingsPage.tsx | 10 +- src/types/electron.d.ts | 4 + src/vite-env.d.ts | 1 + 11 files changed, 263 insertions(+), 18 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index d29bf64..cb6dea8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1034,6 +1034,11 @@ function registerIpcHandlers() { return windowsHelloService.verify(message, targetWin) }) + // 验证应用锁状态(带签名校验,防篡改) + ipcMain.handle('auth:verifyEnabled', async () => { + return configService?.verifyAuthEnabled() ?? false + }) + // 导出相关 ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => { return exportService.getExportStats(sessionIds, options) diff --git a/electron/preload.ts b/electron/preload.ts index 674ee21..5a11899 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -24,7 +24,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 认证 auth: { - hello: (message?: string) => ipcRenderer.invoke('auth:hello', message) + hello: (message?: string) => ipcRenderer.invoke('auth:hello', message), + verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled') }, diff --git a/electron/services/config.ts b/electron/services/config.ts index d9eda16..beefeb7 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -1,7 +1,10 @@ import { join } from 'path' -import { app } from 'electron' +import { app, safeStorage } from 'electron' import Store from 'electron-store' +// safeStorage 加密后的前缀标记,用于区分明文和密文 +const SAFE_PREFIX = 'safe:' + interface ConfigSchema { // 数据库相关 dbPath: string // 数据库根目录 (xwechat_files) @@ -32,7 +35,7 @@ interface ConfigSchema { exportDefaultConcurrency: number analyticsExcludedUsernames: string[] - // 安全相关 + // 安全相关(通过 safeStorage 加密存储,JSON 中为密文) authEnabled: boolean authPassword: string // SHA-256 hash authUseHello: boolean @@ -48,6 +51,11 @@ interface ConfigSchema { wordCloudExcludeWords: string[] } +// 需要 safeStorage 加密的字段集合 +const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword']) +const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) +const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) + export class ConfigService { private static instance: ConfigService private store!: Store @@ -103,16 +111,221 @@ export class ConfigService { wordCloudExcludeWords: [] } }) + + // 首次启动时迁移旧版明文安全字段 + this.migrateAuthFields() } get(key: K): ConfigSchema[K] { - return this.store.get(key) + const raw = this.store.get(key) + + // 布尔型加密字段:存储为加密字符串,读取时解密还原为布尔值 + if (ENCRYPTED_BOOL_KEYS.has(key)) { + const str = typeof raw === 'string' ? raw : '' + if (!str || !str.startsWith(SAFE_PREFIX)) return raw + const decrypted = this.safeDecrypt(str) + return (decrypted === 'true') as ConfigSchema[K] + } + + // 数字型加密字段:存储为加密字符串,读取时解密还原为数字 + if (ENCRYPTED_NUMBER_KEYS.has(key)) { + const str = typeof raw === 'string' ? raw : '' + if (!str || !str.startsWith(SAFE_PREFIX)) return raw + const decrypted = this.safeDecrypt(str) + const num = Number(decrypted) + return (Number.isFinite(num) ? num : 0) as ConfigSchema[K] + } + + // 字符串型加密字段 + if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') { + return this.safeDecrypt(raw) as ConfigSchema[K] + } + + // wxidConfigs 中嵌套的敏感字段 + if (key === 'wxidConfigs' && raw && typeof raw === 'object') { + return this.decryptWxidConfigs(raw as any) as ConfigSchema[K] + } + + return raw } set(key: K, value: ConfigSchema[K]): void { - this.store.set(key, value) + let toStore = value + + // 布尔型加密字段:序列化为字符串后加密 + if (ENCRYPTED_BOOL_KEYS.has(key)) { + toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + } + // 数字型加密字段:序列化为字符串后加密 + else if (ENCRYPTED_NUMBER_KEYS.has(key)) { + toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + } + // 字符串型加密字段 + else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') { + toStore = this.safeEncrypt(value) as ConfigSchema[K] + } + // wxidConfigs 中嵌套的敏感字段 + else if (key === 'wxidConfigs' && value && typeof value === 'object') { + toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K] + } + + this.store.set(key, toStore) } + // === safeStorage 加解密 === + + private safeEncrypt(plaintext: string): string { + if (!plaintext) return '' + if (plaintext.startsWith(SAFE_PREFIX)) return plaintext + if (!safeStorage.isEncryptionAvailable()) return plaintext + const encrypted = safeStorage.encryptString(plaintext) + return SAFE_PREFIX + encrypted.toString('base64') + } + + private safeDecrypt(stored: string): string { + if (!stored) return '' + if (!stored.startsWith(SAFE_PREFIX)) { + return stored + } + if (!safeStorage.isEncryptionAvailable()) return '' + try { + const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64') + return safeStorage.decryptString(buf) + } catch { + return '' + } + } + + // === 旧版本迁移 === + + // 将旧版明文 auth 字段迁移为 safeStorage 加密格式 + private migrateAuthFields(): void { + if (!safeStorage.isEncryptionAvailable()) return + + // 迁移字符串型字段(decryptKey, imageAesKey, authPassword) + for (const key of ENCRYPTED_STRING_KEYS) { + const raw = this.store.get(key as keyof ConfigSchema) + if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX)) { + this.store.set(key as any, this.safeEncrypt(raw)) + } + } + + // 迁移布尔型字段(authEnabled, authUseHello) + for (const key of ENCRYPTED_BOOL_KEYS) { + const raw = this.store.get(key as keyof ConfigSchema) + // 如果是原始布尔值(未加密),转为加密字符串 + if (typeof raw === 'boolean') { + this.store.set(key as any, this.safeEncrypt(String(raw))) + } + } + + // 迁移数字型字段(imageXorKey) + for (const key of ENCRYPTED_NUMBER_KEYS) { + const raw = this.store.get(key as keyof ConfigSchema) + // 如果是原始数字值(未加密),转为加密字符串 + if (typeof raw === 'number') { + this.store.set(key as any, this.safeEncrypt(String(raw))) + } + } + + // 迁移 wxidConfigs 中的嵌套敏感字段 + const wxidConfigs = this.store.get('wxidConfigs') + if (wxidConfigs && typeof wxidConfigs === 'object') { + let needsUpdate = false + const updated = { ...wxidConfigs } + for (const [wxid, cfg] of Object.entries(updated)) { + if (cfg.decryptKey && !cfg.decryptKey.startsWith(SAFE_PREFIX)) { + updated[wxid] = { ...cfg, decryptKey: this.safeEncrypt(cfg.decryptKey) } + needsUpdate = true + } + if (cfg.imageAesKey && !cfg.imageAesKey.startsWith(SAFE_PREFIX)) { + updated[wxid] = { ...updated[wxid], imageAesKey: this.safeEncrypt(cfg.imageAesKey) } + needsUpdate = true + } + if (cfg.imageXorKey !== undefined && typeof cfg.imageXorKey === 'number') { + updated[wxid] = { ...updated[wxid], imageXorKey: this.safeEncrypt(String(cfg.imageXorKey)) as any } + needsUpdate = true + } + } + if (needsUpdate) { + this.store.set('wxidConfigs', updated) + } + } + + // 清理旧版 authSignature 字段(不再需要) + this.store.delete('authSignature' as any) + } + + // === wxidConfigs 加解密 === + + private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { + const result: ConfigSchema['wxidConfigs'] = {} + for (const [wxid, cfg] of Object.entries(configs)) { + result[wxid] = { ...cfg } + if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey) + if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey) + if (cfg.imageXorKey !== undefined) { + (result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey)) + } + } + return result + } + + private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { + const result: ConfigSchema['wxidConfigs'] = {} + for (const [wxid, cfg] of Object.entries(configs)) { + result[wxid] = { ...cfg } + if (cfg.decryptKey) result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey) + if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey) + if (cfg.imageXorKey !== undefined) { + const raw = cfg.imageXorKey as any + if (typeof raw === 'string' && raw.startsWith(SAFE_PREFIX)) { + const decrypted = this.safeDecrypt(raw) + const num = Number(decrypted) + result[wxid].imageXorKey = Number.isFinite(num) ? num : 0 + } + } + } + return result + } + + // === 应用锁验证 === + + // 验证应用锁状态,防篡改: + // - 所有 auth 字段都是 safeStorage 密文,删除/修改密文 → 解密失败 + // - 解密失败时,检查 authPassword 密文是否曾经存在(非空非默认值) + // 如果存在则说明被篡改,强制锁定 + verifyAuthEnabled(): boolean { + // 用 as any 绕过泛型推断,因为加密后实际存储的是字符串而非 boolean + const rawEnabled: any = this.store.get('authEnabled') + const rawPassword: any = this.store.get('authPassword') + + // 情况1:字段是加密密文,正常解密 + if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) { + const enabled = this.safeDecrypt(rawEnabled) === 'true' + const password = typeof rawPassword === 'string' ? this.safeDecrypt(rawPassword) : '' + + if (!enabled && !password) return false + return enabled + } + + // 情况2:字段是原始布尔值(旧版本,尚未迁移) + if (typeof rawEnabled === 'boolean') { + return rawEnabled + } + + // 情况3:字段被删除(electron-store 返回默认值 false)或被篡改为无法解密的值 + // 检查 authPassword 是否有密文残留(说明之前设置过密码) + if (typeof rawPassword === 'string' && rawPassword.startsWith(SAFE_PREFIX)) { + // 密码密文还在,说明之前启用过应用锁,字段被篡改了 → 强制锁定 + return true + } + + return false + } + + // === 其他 === + getCacheBasePath(): string { const configured = this.get('cachePath') if (configured && configured.trim().length > 0) { diff --git a/src/App.tsx b/src/App.tsx index 1473e18..06db6fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -312,7 +312,7 @@ function App() { const checkLock = async () => { // 并行获取配置,减少等待 const [enabled, useHello] = await Promise.all([ - configService.getAuthEnabled(), + window.electronAPI.auth.verifyEnabled(), configService.getAuthUseHello() ]) diff --git a/src/components/LockScreen.tsx b/src/components/LockScreen.tsx index 94d5701..6cb8b55 100644 --- a/src/components/LockScreen.tsx +++ b/src/components/LockScreen.tsx @@ -105,9 +105,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS try { const storedHash = await configService.getAuthPassword() + + // 兜底:如果没有设置过密码,直接放行并关闭应用锁 + if (!storedHash) { + await configService.setAuthEnabled(false) + handleUnlock() + return + } + const inputHash = await sha256(password) if (inputHash === storedHash) { + // 解锁成功,重新写入 authEnabled 以修复可能被篡改的签名 + await configService.setAuthEnabled(true) handleUnlock() } else { setError('密码错误') diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 014e0d9..0085b6d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react' import { useAppStore } from '../stores/appStore' -import * as configService from '../services/config' + import './Sidebar.scss' function Sidebar() { @@ -12,7 +12,7 @@ function Sidebar() { const setLocked = useAppStore(state => state.setLocked) useEffect(() => { - configService.getAuthEnabled().then(setAuthEnabled) + window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) }, []) const isActive = (path: string) => { diff --git a/src/components/Sns/SnsMediaGrid.tsx b/src/components/Sns/SnsMediaGrid.tsx index a2b832f..6200223 100644 --- a/src/components/Sns/SnsMediaGrid.tsx +++ b/src/components/Sns/SnsMediaGrid.tsx @@ -21,6 +21,7 @@ interface SnsMedia { interface SnsMediaGridProps { mediaList: SnsMedia[] + postType?: number onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onMediaDeleted?: () => void } @@ -80,7 +81,7 @@ const extractVideoFrame = async (videoPath: string): Promise => { }) } -const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { +const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => { const [error, setError] = useState(false) const [deleted, setDeleted] = useState(false) const [loading, setLoading] = useState(true) @@ -96,6 +97,8 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr const isVideo = isSnsVideoUrl(media.url) const isLive = !!media.livePhoto const targetUrl = media.thumb || media.url + // type 7 的朋友圈媒体不需要解密,直接使用原始 URL + const skipDecrypt = postType === 7 // 视频重试:失败时重试最多2次,耗尽才标记删除 const videoRetryOrDelete = () => { @@ -119,7 +122,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr // For images, we proxy to get the local path/base64 const result = await window.electronAPI.sns.proxyImage({ url: targetUrl, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (cancelled) return @@ -134,7 +137,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr if (isLive && media.livePhoto?.url) { window.electronAPI.sns.proxyImage({ url: media.livePhoto.url, - key: media.livePhoto.key || media.key + key: skipDecrypt ? undefined : (media.livePhoto.key || media.key) }).then((res: any) => { if (!cancelled && res.success && res.videoPath) { setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`) @@ -150,7 +153,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr // Usually we need to call proxyImage with the video URL to decrypt it to cache const result = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (cancelled) return @@ -201,7 +204,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr try { const res = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (res.success && res.videoPath) { const local = `file://${res.videoPath.replace(/\\/g, '/')}` @@ -229,7 +232,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr try { const result = await window.electronAPI.sns.proxyImage({ url: media.url, - key: media.key + key: skipDecrypt ? undefined : media.key }) if (result.success) { @@ -334,7 +337,7 @@ const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPr ) } -export const SnsMediaGrid: React.FC = ({ mediaList, onPreview, onMediaDeleted }) => { +export const SnsMediaGrid: React.FC = ({ mediaList, postType, onPreview, onMediaDeleted }) => { if (!mediaList || mediaList.length === 0) return null const count = mediaList.length @@ -350,7 +353,7 @@ export const SnsMediaGrid: React.FC = ({ mediaList, onPreview return (
{mediaList.map((media, idx) => ( - + ))}
) diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 6cef3b5..bf65dca 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -264,7 +264,7 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb {showMediaGrid && (
- setMediaDeleted(true) : undefined} /> + setMediaDeleted(true) : undefined} />
)} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 83d3c66..27b12e2 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -279,7 +279,7 @@ function SettingsPage() { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() - const savedAuthEnabled = await configService.getAuthEnabled() + const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() setAuthEnabled(savedAuthEnabled) setAuthUseHello(savedAuthUseHello) @@ -2046,6 +2046,14 @@ function SettingsPage() { checked={authEnabled} onChange={async (e) => { const enabled = e.target.checked + if (enabled) { + // 检查是否已设置密码,未设置则阻止开启 + const storedHash = await configService.getAuthPassword() + if (!storedHash) { + showMessage('请先设置密码再启用应用锁', false) + return + } + } setAuthEnabled(enabled) await configService.setAuthEnabled(enabled) }} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index fe6fefa..f01814e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -19,6 +19,10 @@ export interface ElectronAPI { set: (key: string, value: unknown) => Promise clear: () => Promise } + auth: { + hello: (message?: string) => Promise<{ success: boolean; error?: string }> + verifyEnabled: () => Promise + } dialog: { openFile: (options?: Electron.OpenDialogOptions) => Promise openDirectory: (options?: Electron.OpenDialogOptions) => Promise diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b1ee881..b17b74f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ interface Window { // ... other methods ... auth: { hello: (message?: string) => Promise<{ success: boolean; error?: string }> + verifyEnabled: () => Promise } // For brevity, using 'any' for other parts or properly importing types if available. // In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts From b547ac1aed7835259b523b11cc8b2aa18e945808 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:25:25 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E9=87=8D=E8=A6=81=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 57 +++- electron/preload.ts | 9 +- electron/services/config.ts | 589 ++++++++++++++++++++++++++-------- src/components/LockScreen.tsx | 45 +-- src/pages/SettingsPage.tsx | 173 +++++++--- src/types/electron.d.ts | 7 + src/vite-env.d.ts | 7 + 7 files changed, 657 insertions(+), 230 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index cb6dea8..f43d707 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1031,14 +1031,67 @@ function registerIpcHandlers() { ? mainWindow : (BrowserWindow.fromWebContents(event.sender) || undefined) - return windowsHelloService.verify(message, targetWin) + const result = await windowsHelloService.verify(message, targetWin) + + // Hello 验证成功后,自动用 authHelloSecret 中的密码解锁密钥 + if (result && configService) { + const secret = configService.getHelloSecret() + if (secret && configService.isLockMode()) { + configService.unlock(secret) + } + } + + return result }) - // 验证应用锁状态(带签名校验,防篡改) + // 验证应用锁状态(检测 lock: 前缀,防篡改) ipcMain.handle('auth:verifyEnabled', async () => { return configService?.verifyAuthEnabled() ?? false }) + // 密码解锁(验证 + 解密密钥到内存) + ipcMain.handle('auth:unlock', async (_event, password: string) => { + if (!configService) return { success: false, error: '配置服务未初始化' } + return configService.unlock(password) + }) + + // 开启应用锁 + ipcMain.handle('auth:enableLock', async (_event, password: string) => { + if (!configService) return { success: false, error: '配置服务未初始化' } + return configService.enableLock(password) + }) + + // 关闭应用锁 + ipcMain.handle('auth:disableLock', async (_event, password: string) => { + if (!configService) return { success: false, error: '配置服务未初始化' } + return configService.disableLock(password) + }) + + // 修改密码 + ipcMain.handle('auth:changePassword', async (_event, oldPassword: string, newPassword: string) => { + if (!configService) return { success: false, error: '配置服务未初始化' } + return configService.changePassword(oldPassword, newPassword) + }) + + // 设置 Hello Secret + ipcMain.handle('auth:setHelloSecret', async (_event, password: string) => { + if (!configService) return { success: false } + configService.setHelloSecret(password) + return { success: true } + }) + + // 清除 Hello Secret + ipcMain.handle('auth:clearHelloSecret', async () => { + if (!configService) return { success: false } + configService.clearHelloSecret() + return { success: true } + }) + + // 检查是否处于 lock: 模式 + ipcMain.handle('auth:isLockMode', async () => { + return configService?.isLockMode() ?? false + }) + // 导出相关 ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => { return exportService.getExportStats(sessionIds, options) diff --git a/electron/preload.ts b/electron/preload.ts index 5a11899..e1ad7a8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -25,7 +25,14 @@ contextBridge.exposeInMainWorld('electronAPI', { // 认证 auth: { hello: (message?: string) => ipcRenderer.invoke('auth:hello', message), - verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled') + verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'), + unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password), + enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password), + disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password), + changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword), + setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password), + clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'), + isLockMode: () => ipcRenderer.invoke('auth:isLockMode') }, diff --git a/electron/services/config.ts b/electron/services/config.ts index beefeb7..8d54af5 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -1,15 +1,17 @@ import { join } from 'path' import { app, safeStorage } from 'electron' +import crypto from 'crypto' import Store from 'electron-store' -// safeStorage 加密后的前缀标记,用于区分明文和密文 -const SAFE_PREFIX = 'safe:' +// 加密前缀标记 +const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式) +const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式) interface ConfigSchema { // 数据库相关 - dbPath: string // 数据库根目录 (xwechat_files) - decryptKey: string // 解密密钥 - myWxid: string // 当前用户 wxid + dbPath: string + decryptKey: string + myWxid: string onboardingDone: boolean imageXorKey: number imageAesKey: string @@ -17,7 +19,6 @@ interface ConfigSchema { // 缓存相关 cachePath: string - lastOpenedDb: string lastSession: string @@ -35,10 +36,11 @@ interface ConfigSchema { exportDefaultConcurrency: number analyticsExcludedUsernames: string[] - // 安全相关(通过 safeStorage 加密存储,JSON 中为密文) + // 安全相关 authEnabled: boolean - authPassword: string // SHA-256 hash + authPassword: string // SHA-256 hash(safeStorage 加密) authUseHello: boolean + authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用) // 更新相关 ignoredUpdateVersion: string @@ -51,15 +53,23 @@ interface ConfigSchema { wordCloudExcludeWords: string[] } -// 需要 safeStorage 加密的字段集合 +// 需要 safeStorage 加密的字段(普通模式) const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword']) const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) +// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密) +const LOCKABLE_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey']) +const LOCKABLE_NUMBER_KEYS: Set = new Set(['imageXorKey']) + export class ConfigService { private static instance: ConfigService private store!: Store + // 锁定模式运行时状态 + private unlockedKeys: Map = new Map() + private unlockPassword: string | null = null + static getInstance(): ConfigService { if (!ConfigService.instance) { ConfigService.instance = new ConfigService() @@ -83,7 +93,6 @@ export class ConfigService { imageAesKey: '', wxidConfigs: {}, cachePath: '', - lastOpenedDb: '', lastSession: '', theme: 'system', @@ -98,11 +107,10 @@ export class ConfigService { transcribeLanguages: ['zh'], exportDefaultConcurrency: 2, analyticsExcludedUsernames: [], - authEnabled: false, authPassword: '', authUseHello: false, - + authHelloSecret: '', ignoredUpdateVersion: '', notificationEnabled: true, notificationPosition: 'top-right', @@ -111,37 +119,52 @@ export class ConfigService { wordCloudExcludeWords: [] } }) - - // 首次启动时迁移旧版明文安全字段 this.migrateAuthFields() } + // === 状态查询 === + + isLockMode(): boolean { + const raw: any = this.store.get('decryptKey') + return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX) + } + + isUnlocked(): boolean { + return !this.isLockMode() || this.unlockedKeys.size > 0 + } + + // === get / set === + get(key: K): ConfigSchema[K] { const raw = this.store.get(key) - // 布尔型加密字段:存储为加密字符串,读取时解密还原为布尔值 if (ENCRYPTED_BOOL_KEYS.has(key)) { const str = typeof raw === 'string' ? raw : '' if (!str || !str.startsWith(SAFE_PREFIX)) return raw - const decrypted = this.safeDecrypt(str) - return (decrypted === 'true') as ConfigSchema[K] + return (this.safeDecrypt(str) === 'true') as ConfigSchema[K] } - // 数字型加密字段:存储为加密字符串,读取时解密还原为数字 if (ENCRYPTED_NUMBER_KEYS.has(key)) { const str = typeof raw === 'string' ? raw : '' - if (!str || !str.startsWith(SAFE_PREFIX)) return raw - const decrypted = this.safeDecrypt(str) - const num = Number(decrypted) + if (!str) return raw + if (str.startsWith(LOCK_PREFIX)) { + const cached = this.unlockedKeys.get(key as string) + return (cached !== undefined ? cached : 0) as ConfigSchema[K] + } + if (!str.startsWith(SAFE_PREFIX)) return raw + const num = Number(this.safeDecrypt(str)) return (Number.isFinite(num) ? num : 0) as ConfigSchema[K] } - // 字符串型加密字段 if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') { + if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K] + if (raw.startsWith(LOCK_PREFIX)) { + const cached = this.unlockedKeys.get(key as string) + return (cached !== undefined ? cached : '') as ConfigSchema[K] + } return this.safeDecrypt(raw) as ConfigSchema[K] } - // wxidConfigs 中嵌套的敏感字段 if (key === 'wxidConfigs' && raw && typeof raw === 'object') { return this.decryptWxidConfigs(raw as any) as ConfigSchema[K] } @@ -151,28 +174,38 @@ export class ConfigService { set(key: K, value: ConfigSchema[K]): void { let toStore = value + const inLockMode = this.isLockMode() && this.unlockPassword - // 布尔型加密字段:序列化为字符串后加密 if (ENCRYPTED_BOOL_KEYS.has(key)) { toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] - } - // 数字型加密字段:序列化为字符串后加密 - else if (ENCRYPTED_NUMBER_KEYS.has(key)) { - toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] - } - // 字符串型加密字段 - else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') { - toStore = this.safeEncrypt(value) as ConfigSchema[K] - } - // wxidConfigs 中嵌套的敏感字段 - else if (key === 'wxidConfigs' && value && typeof value === 'object') { - toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K] + } else if (ENCRYPTED_NUMBER_KEYS.has(key)) { + if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) { + toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K] + this.unlockedKeys.set(key as string, value) + } else { + toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + } + } else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') { + if (key === 'authPassword') { + toStore = this.safeEncrypt(value) as ConfigSchema[K] + } else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) { + toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K] + this.unlockedKeys.set(key as string, value) + } else { + toStore = this.safeEncrypt(value) as ConfigSchema[K] + } + } else if (key === 'wxidConfigs' && value && typeof value === 'object') { + if (inLockMode) { + toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K] + } else { + toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K] + } } this.store.set(key, toStore) } - // === safeStorage 加解密 === + // === 加密/解密工具 === private safeEncrypt(plaintext: string): string { if (!plaintext) return '' @@ -184,9 +217,7 @@ export class ConfigService { private safeDecrypt(stored: string): string { if (!stored) return '' - if (!stored.startsWith(SAFE_PREFIX)) { - return stored - } + if (!stored.startsWith(SAFE_PREFIX)) return stored if (!safeStorage.isEncryptionAvailable()) return '' try { const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64') @@ -196,67 +227,52 @@ export class ConfigService { } } - // === 旧版本迁移 === - - // 将旧版明文 auth 字段迁移为 safeStorage 加密格式 - private migrateAuthFields(): void { - if (!safeStorage.isEncryptionAvailable()) return - - // 迁移字符串型字段(decryptKey, imageAesKey, authPassword) - for (const key of ENCRYPTED_STRING_KEYS) { - const raw = this.store.get(key as keyof ConfigSchema) - if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX)) { - this.store.set(key as any, this.safeEncrypt(raw)) - } - } - - // 迁移布尔型字段(authEnabled, authUseHello) - for (const key of ENCRYPTED_BOOL_KEYS) { - const raw = this.store.get(key as keyof ConfigSchema) - // 如果是原始布尔值(未加密),转为加密字符串 - if (typeof raw === 'boolean') { - this.store.set(key as any, this.safeEncrypt(String(raw))) - } - } - - // 迁移数字型字段(imageXorKey) - for (const key of ENCRYPTED_NUMBER_KEYS) { - const raw = this.store.get(key as keyof ConfigSchema) - // 如果是原始数字值(未加密),转为加密字符串 - if (typeof raw === 'number') { - this.store.set(key as any, this.safeEncrypt(String(raw))) - } - } - - // 迁移 wxidConfigs 中的嵌套敏感字段 - const wxidConfigs = this.store.get('wxidConfigs') - if (wxidConfigs && typeof wxidConfigs === 'object') { - let needsUpdate = false - const updated = { ...wxidConfigs } - for (const [wxid, cfg] of Object.entries(updated)) { - if (cfg.decryptKey && !cfg.decryptKey.startsWith(SAFE_PREFIX)) { - updated[wxid] = { ...cfg, decryptKey: this.safeEncrypt(cfg.decryptKey) } - needsUpdate = true - } - if (cfg.imageAesKey && !cfg.imageAesKey.startsWith(SAFE_PREFIX)) { - updated[wxid] = { ...updated[wxid], imageAesKey: this.safeEncrypt(cfg.imageAesKey) } - needsUpdate = true - } - if (cfg.imageXorKey !== undefined && typeof cfg.imageXorKey === 'number') { - updated[wxid] = { ...updated[wxid], imageXorKey: this.safeEncrypt(String(cfg.imageXorKey)) as any } - needsUpdate = true - } - } - if (needsUpdate) { - this.store.set('wxidConfigs', updated) - } - } - - // 清理旧版 authSignature 字段(不再需要) - this.store.delete('authSignature' as any) + private lockEncrypt(plaintext: string, password: string): string { + if (!plaintext) return '' + const salt = crypto.randomBytes(16) + const iv = crypto.randomBytes(12) + const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256') + const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv) + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) + const authTag = cipher.getAuthTag() + const combined = Buffer.concat([salt, iv, authTag, encrypted]) + return LOCK_PREFIX + combined.toString('base64') } - // === wxidConfigs 加解密 === + private lockDecrypt(stored: string, password: string): string | null { + if (!stored || !stored.startsWith(LOCK_PREFIX)) return null + try { + const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64') + const salt = combined.subarray(0, 16) + const iv = combined.subarray(16, 28) + const authTag = combined.subarray(28, 44) + const ciphertext = combined.subarray(44) + const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256') + const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv) + decipher.setAuthTag(authTag) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return decrypted.toString('utf8') + } catch { + return null + } + } + + // 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用) + private verifyPasswordByDecrypt(password: string): boolean { + // 依次尝试解密任意一个 lock: 字段,GCM authTag 会验证密码正确性 + const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const + for (const key of lockFields) { + const raw: any = this.store.get(key as any) + if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) { + const result = this.lockDecrypt(raw, password) + // lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功 + return result !== null + } + } + return false + } + + // === wxidConfigs 加密/解密 === private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { const result: ConfigSchema['wxidConfigs'] = {} @@ -271,74 +287,367 @@ export class ConfigService { return result } + private decryptLockedWxidConfigs(password: string): void { + const wxidConfigs = this.store.get('wxidConfigs') + if (!wxidConfigs || typeof wxidConfigs !== 'object') return + for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) { + if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(cfg.decryptKey, password) + if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d) + } + if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(cfg.imageAesKey, password) + if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d) + } + if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(cfg.imageXorKey, password) + if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d)) + } + } + } + private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { const result: ConfigSchema['wxidConfigs'] = {} - for (const [wxid, cfg] of Object.entries(configs)) { - result[wxid] = { ...cfg } - if (cfg.decryptKey) result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey) - if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey) - if (cfg.imageXorKey !== undefined) { - const raw = cfg.imageXorKey as any - if (typeof raw === 'string' && raw.startsWith(SAFE_PREFIX)) { - const decrypted = this.safeDecrypt(raw) - const num = Number(decrypted) + for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) { + result[wxid] = { ...cfg, updatedAt: cfg.updatedAt } + // decryptKey + if (typeof cfg.decryptKey === 'string') { + if (cfg.decryptKey.startsWith(LOCK_PREFIX)) { + result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? '' + } else { + result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey) + } + } + // imageAesKey + if (typeof cfg.imageAesKey === 'string') { + if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) { + result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? '' + } else { + result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey) + } + } + // imageXorKey + if (typeof cfg.imageXorKey === 'string') { + if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) { + result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0 + } else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) { + const num = Number(this.safeDecrypt(cfg.imageXorKey)) result[wxid].imageXorKey = Number.isFinite(num) ? num : 0 } } } return result } + private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] { + const result: ConfigSchema['wxidConfigs'] = {} + for (const [wxid, cfg] of Object.entries(configs)) { + result[wxid] = { ...cfg } + if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any + if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any + if (cfg.imageXorKey !== undefined) { + (result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!) + } + } + return result + } - // === 应用锁验证 === + // === 业务方法 === - // 验证应用锁状态,防篡改: - // - 所有 auth 字段都是 safeStorage 密文,删除/修改密文 → 解密失败 - // - 解密失败时,检查 authPassword 密文是否曾经存在(非空非默认值) - // 如果存在则说明被篡改,强制锁定 - verifyAuthEnabled(): boolean { - // 用 as any 绕过泛型推断,因为加密后实际存储的是字符串而非 boolean + enableLock(password: string): { success: boolean; error?: string } { + try { + // 先读取当前所有明文密钥 + const decryptKey = this.get('decryptKey') + const imageAesKey = this.get('imageAesKey') + const imageXorKey = this.get('imageXorKey') + const wxidConfigs = this.get('wxidConfigs') + + // 存储密码 hash(safeStorage 加密) + const passwordHash = crypto.createHash('sha256').update(password).digest('hex') + this.store.set('authPassword', this.safeEncrypt(passwordHash) as any) + this.store.set('authEnabled', this.safeEncrypt('true') as any) + + // 设置运行时状态 + this.unlockPassword = password + this.unlockedKeys.set('decryptKey', decryptKey) + this.unlockedKeys.set('imageAesKey', imageAesKey) + this.unlockedKeys.set('imageXorKey', imageXorKey) + + // 用密码派生密钥重新加密所有敏感字段 + if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any) + if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any) + if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any) + + // 处理 wxidConfigs 中的嵌套密钥 + if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { + const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs) + this.store.set('wxidConfigs', lockedConfigs) + for (const [wxid, cfg] of Object.entries(wxidConfigs)) { + if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey) + if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey) + if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey) + } + } + + return { success: true } + } catch (e: any) { + return { success: false, error: e.message } + } + } + + unlock(password: string): { success: boolean; error?: string } { + try { + // 验证密码 + const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) + const inputHash = crypto.createHash('sha256').update(password).digest('hex') + + if (storedHash && storedHash !== inputHash) { + // authPassword 存在但密码不匹配 + return { success: false, error: '密码错误' } + } + + if (!storedHash) { + // authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证 + const verified = this.verifyPasswordByDecrypt(password) + if (!verified) { + return { success: false, error: '密码错误' } + } + // 密码正确,自愈 authPassword + const newHash = crypto.createHash('sha256').update(password).digest('hex') + this.store.set('authPassword', this.safeEncrypt(newHash) as any) + this.store.set('authEnabled', this.safeEncrypt('true') as any) + } + + // 解密所有 lock: 字段到内存缓存 + const rawDecryptKey: any = this.store.get('decryptKey') + if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(rawDecryptKey, password) + if (d !== null) this.unlockedKeys.set('decryptKey', d) + } + + const rawImageAesKey: any = this.store.get('imageAesKey') + if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(rawImageAesKey, password) + if (d !== null) this.unlockedKeys.set('imageAesKey', d) + } + + const rawImageXorKey: any = this.store.get('imageXorKey') + if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) { + const d = this.lockDecrypt(rawImageXorKey, password) + if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d)) + } + + // 解密 wxidConfigs 嵌套密钥 + this.decryptLockedWxidConfigs(password) + + // 保留密码供 set() 使用 + this.unlockPassword = password + return { success: true } + } catch (e: any) { + return { success: false, error: e.message } + } + } + + disableLock(password: string): { success: boolean; error?: string } { + try { + // 验证密码 + const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) + const inputHash = crypto.createHash('sha256').update(password).digest('hex') + if (storedHash !== inputHash) { + return { success: false, error: '密码错误' } + } + + // 先解密所有 lock: 字段 + if (this.unlockedKeys.size === 0) { + this.unlock(password) + } + + // 将所有密钥转回 safe: 格式 + const decryptKey = this.unlockedKeys.get('decryptKey') + const imageAesKey = this.unlockedKeys.get('imageAesKey') + const imageXorKey = this.unlockedKeys.get('imageXorKey') + + if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any) + if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any) + if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any) + + // 转换 wxidConfigs + const wxidConfigs = this.get('wxidConfigs') + if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { + const safeConfigs = this.encryptWxidConfigs(wxidConfigs) + this.store.set('wxidConfigs', safeConfigs) + } + + // 清除 auth 字段 + this.store.set('authEnabled', false as any) + this.store.set('authPassword', '' as any) + this.store.set('authUseHello', false as any) + this.store.set('authHelloSecret', '' as any) + + // 清除运行时状态 + this.unlockedKeys.clear() + this.unlockPassword = null + + return { success: true } + } catch (e: any) { + return { success: false, error: e.message } + } + } + + changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } { + try { + // 验证旧密码 + const storedHash = this.safeDecrypt(this.store.get('authPassword') as any) + const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex') + if (storedHash !== oldHash) { + return { success: false, error: '旧密码错误' } + } + + // 确保已解锁 + if (this.unlockedKeys.size === 0) { + this.unlock(oldPassword) + } + + // 用新密码重新加密所有密钥 + const decryptKey = this.unlockedKeys.get('decryptKey') + const imageAesKey = this.unlockedKeys.get('imageAesKey') + const imageXorKey = this.unlockedKeys.get('imageXorKey') + + if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any) + if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any) + if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any) + + // 重新加密 wxidConfigs + const wxidConfigs = this.get('wxidConfigs') + if (wxidConfigs && Object.keys(wxidConfigs).length > 0) { + this.unlockPassword = newPassword + const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs) + this.store.set('wxidConfigs', lockedConfigs) + } + + // 更新密码 hash + const newHash = crypto.createHash('sha256').update(newPassword).digest('hex') + this.store.set('authPassword', this.safeEncrypt(newHash) as any) + + // 更新 Hello secret(如果启用了 Hello) + const useHello = this.get('authUseHello') + if (useHello) { + this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any) + } + + this.unlockPassword = newPassword + return { success: true } + } catch (e: any) { + return { success: false, error: e.message } + } + } + + // === Hello 相关 === + + setHelloSecret(password: string): void { + this.store.set('authHelloSecret', this.safeEncrypt(password) as any) + this.store.set('authUseHello', this.safeEncrypt('true') as any) + } + + getHelloSecret(): string { + const raw: any = this.store.get('authHelloSecret') + if (!raw || typeof raw !== 'string') return '' + return this.safeDecrypt(raw) + } + + clearHelloSecret(): void { + this.store.set('authHelloSecret', '' as any) + this.store.set('authUseHello', this.safeEncrypt('false') as any) + } + + // === 迁移 === + + private migrateAuthFields(): void { + // 将旧版明文 auth 字段迁移为 safeStorage 加密格式 + // 如果已经是 safe: 或 lock: 前缀则跳过 const rawEnabled: any = this.store.get('authEnabled') - const rawPassword: any = this.store.get('authPassword') - - // 情况1:字段是加密密文,正常解密 - if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) { - const enabled = this.safeDecrypt(rawEnabled) === 'true' - const password = typeof rawPassword === 'string' ? this.safeDecrypt(rawPassword) : '' - - if (!enabled && !password) return false - return enabled - } - - // 情况2:字段是原始布尔值(旧版本,尚未迁移) if (typeof rawEnabled === 'boolean') { - return rawEnabled + this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any) } - // 情况3:字段被删除(electron-store 返回默认值 false)或被篡改为无法解密的值 - // 检查 authPassword 是否有密文残留(说明之前设置过密码) - if (typeof rawPassword === 'string' && rawPassword.startsWith(SAFE_PREFIX)) { - // 密码密文还在,说明之前启用过应用锁,字段被篡改了 → 强制锁定 + const rawUseHello: any = this.store.get('authUseHello') + if (typeof rawUseHello === 'boolean') { + this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any) + } + + const rawPassword: any = this.store.get('authPassword') + if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) { + this.store.set('authPassword', this.safeEncrypt(rawPassword) as any) + } + + // 迁移敏感密钥字段(明文 → safe:) + for (const key of LOCKABLE_STRING_KEYS) { + const raw: any = this.store.get(key as any) + if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) { + this.store.set(key as any, this.safeEncrypt(raw) as any) + } + } + + // imageXorKey: 数字 → safe: + const rawXor: any = this.store.get('imageXorKey') + if (typeof rawXor === 'number' && rawXor !== 0) { + this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any) + } + + // wxidConfigs 中的嵌套密钥 + const wxidConfigs: any = this.store.get('wxidConfigs') + if (wxidConfigs && typeof wxidConfigs === 'object') { + let changed = false + for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) { + if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) { + cfg.decryptKey = this.safeEncrypt(cfg.decryptKey) + changed = true + } + if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) { + cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey) + changed = true + } + if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) { + cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey)) + changed = true + } + } + if (changed) { + this.store.set('wxidConfigs', wxidConfigs) + } + } + } + + // === 验证 === + + verifyAuthEnabled(): boolean { + // 先检查 authEnabled 字段 + const rawEnabled: any = this.store.get('authEnabled') + if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) { + if (this.safeDecrypt(rawEnabled) === 'true') return true + } + + // 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁 + const rawDecryptKey: any = this.store.get('decryptKey') + if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) { return true } return false } - // === 其他 === + // === 工具方法 === getCacheBasePath(): string { - const configured = this.get('cachePath') - if (configured && configured.trim().length > 0) { - return configured - } - return join(app.getPath('documents'), 'WeFlow') + return join(app.getPath('userData'), 'cache') } - getAll(): ConfigSchema { + getAll(): Partial { return this.store.store } clear(): void { this.store.clear() + this.unlockedKeys.clear() + this.unlockPassword = null } -} +} \ No newline at end of file diff --git a/src/components/LockScreen.tsx b/src/components/LockScreen.tsx index 6cb8b55..5f9945f 100644 --- a/src/components/LockScreen.tsx +++ b/src/components/LockScreen.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useRef } from 'react' -import * as configService from '../services/config' import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react' import './LockScreen.scss' @@ -9,14 +8,6 @@ interface LockScreenProps { useHello?: boolean } -async function sha256(message: string) { - const msgBuffer = new TextEncoder().encode(message) - const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') - return hashHex -} - export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) { const [password, setPassword] = useState('') const [error, setError] = useState('') @@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS const quickStartHello = async () => { try { - // 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC - let shouldUseHello = useHello - - // 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config - if (!shouldUseHello) { - shouldUseHello = await configService.getAuthUseHello() - } - - if (shouldUseHello) { - // 标记为可用,显示按钮 + if (useHello) { setHelloAvailable(true) setShowHello(true) - // 立即执行验证 (0延迟) verifyHello() } } catch (e) { @@ -96,35 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS e?.preventDefault() if (!password || isUnlocked) return - // 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消 - // 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可 - - // 不再检查 isVerifying,因为我们允许打断 Hello setIsVerifying(true) setError('') try { - const storedHash = await configService.getAuthPassword() + // 发送原始密码到主进程,由主进程验证并解密密钥 + const result = await window.electronAPI.auth.unlock(password) - // 兜底:如果没有设置过密码,直接放行并关闭应用锁 - if (!storedHash) { - await configService.setAuthEnabled(false) - handleUnlock() - return - } - - const inputHash = await sha256(password) - - if (inputHash === storedHash) { - // 解锁成功,重新写入 authEnabled 以修复可能被篡改的签名 - await configService.setAuthEnabled(true) + if (result.success) { handleUnlock() } else { - setError('密码错误') + setError(result.error || '密码错误') setPassword('') setIsVerifying(false) - // 如果密码错误,是否重新触发 Hello? - // 用户可能想重试密码,暂时不自动触发 } } catch (e) { setError('验证失败') diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 27b12e2..48b2f2e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -146,6 +146,11 @@ function SettingsPage() { const [helloAvailable, setHelloAvailable] = useState(false) const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') + const [oldPassword, setOldPassword] = useState('') + const [helloPassword, setHelloPassword] = useState('') + const [disableLockPassword, setDisableLockPassword] = useState('') + const [showDisableLockInput, setShowDisableLockInput] = useState(false) + const [isLockMode, setIsLockMode] = useState(false) const [isSettingHello, setIsSettingHello] = useState(false) // HTTP API 设置 state @@ -184,14 +189,6 @@ function SettingsPage() { checkApiStatus() }, []) - async function sha256(message: string) { - const msgBuffer = new TextEncoder().encode(message) - const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') - return hashHex - } - useEffect(() => { loadConfig() loadAppVersion() @@ -281,8 +278,10 @@ function SettingsPage() { const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled() const savedAuthUseHello = await configService.getAuthUseHello() + const savedIsLockMode = await window.electronAPI.auth.isLockMode() setAuthEnabled(savedAuthEnabled) setAuthUseHello(savedAuthUseHello) + setIsLockMode(savedIsLockMode) if (savedPath) setDbPath(savedPath) if (savedWxid) setWxid(savedWxid) @@ -1931,6 +1930,10 @@ function SettingsPage() { ) const handleSetupHello = async () => { + if (!helloPassword) { + showMessage('请输入当前密码以开启 Hello', false) + return + } setIsSettingHello(true) try { const challenge = new Uint8Array(32) @@ -1948,8 +1951,10 @@ function SettingsPage() { }) if (credential) { + // 存储密码作为 Hello Secret,以便 Hello 解锁时能派生密钥 + await window.electronAPI.auth.setHelloSecret(helloPassword) setAuthUseHello(true) - await configService.setAuthUseHello(true) + setHelloPassword('') showMessage('Windows Hello 设置成功', true) } } catch (e: any) { @@ -1967,18 +1972,40 @@ function SettingsPage() { return } - // 简单的保存逻辑,实际上应该先验证旧密码,但为了简化流程,这里直接允许覆盖 - // 因为能进入设置页面说明已经解锁了 try { - const hash = await sha256(newPassword) - await configService.setAuthPassword(hash) - await configService.setAuthEnabled(true) - setAuthEnabled(true) - setNewPassword('') - setConfirmPassword('') - showMessage('密码已更新', true) + const lockMode = await window.electronAPI.auth.isLockMode() + + if (authEnabled && lockMode) { + // 已开启应用锁且已是 lock: 模式 → 修改密码 + if (!oldPassword) { + showMessage('请输入旧密码', false) + return + } + const result = await window.electronAPI.auth.changePassword(oldPassword, newPassword) + if (result.success) { + setNewPassword('') + setConfirmPassword('') + setOldPassword('') + showMessage('密码已更新', true) + } else { + showMessage(result.error || '密码更新失败', false) + } + } else { + // 未开启应用锁,或旧版 safe: 模式 → 开启/升级为 lock: 模式 + const result = await window.electronAPI.auth.enableLock(newPassword) + if (result.success) { + setAuthEnabled(true) + setIsLockMode(true) + setNewPassword('') + setConfirmPassword('') + setOldPassword('') + showMessage('应用锁已开启', true) + } else { + showMessage(result.error || '开启失败', false) + } + } } catch (e: any) { - showMessage('密码更新失败', false) + showMessage('操作失败', false) } } @@ -2037,39 +2064,73 @@ function SettingsPage() {
- - 每次启动应用时需要验证密码 + + { + isLockMode ? '已开启' : + authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' : + '未开启 — 请设置密码以开启' + }
- + {authEnabled && !showDisableLockInput && ( + + )}
+ {showDisableLockInput && ( +
+ setDisableLockPassword(e.target.value)} + style={{ flex: 1 }} + /> + + +
+ )}
- - 设置新的启动密码 + + {isLockMode ? '修改应用锁密码(需要旧密码验证)' : '设置密码后将自动开启应用锁'}
+ {isLockMode && ( + setOldPassword(e.target.value)} + /> + )} setConfirmPassword(e.target.value)} style={{ flex: 1 }} /> - +
@@ -2098,23 +2161,39 @@ function SettingsPage() {
使用面容、指纹快速解锁 - {!helloAvailable &&
当前设备不支持 Windows Hello
} + {!authEnabled &&
请先开启应用锁
} + {!helloAvailable && authEnabled &&
当前设备不支持 Windows Hello
}
{authUseHello ? ( - + ) : ( )}
+ {!authUseHello && authEnabled && ( +
+ setHelloPassword(e.target.value)} + /> +
+ )} ) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index f01814e..d56cf81 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -22,6 +22,13 @@ export interface ElectronAPI { auth: { hello: (message?: string) => Promise<{ success: boolean; error?: string }> verifyEnabled: () => Promise + unlock: (password: string) => Promise<{ success: boolean; error?: string }> + enableLock: (password: string) => Promise<{ success: boolean; error?: string }> + disableLock: (password: string) => Promise<{ success: boolean; error?: string }> + changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }> + setHelloSecret: (password: string) => Promise<{ success: boolean }> + clearHelloSecret: () => Promise<{ success: boolean }> + isLockMode: () => Promise } dialog: { openFile: (options?: Electron.OpenDialogOptions) => Promise diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b17b74f..5b4176e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,13 @@ interface Window { auth: { hello: (message?: string) => Promise<{ success: boolean; error?: string }> verifyEnabled: () => Promise + unlock: (password: string) => Promise<{ success: boolean; error?: string }> + enableLock: (password: string) => Promise<{ success: boolean; error?: string }> + disableLock: (password: string) => Promise<{ success: boolean; error?: string }> + changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }> + setHelloSecret: (password: string) => Promise<{ success: boolean }> + clearHelloSecret: () => Promise<{ success: boolean }> + isLockMode: () => Promise } // For brevity, using 'any' for other parts or properly importing types if available. // In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts From fbcf7d2fc30583be07e968bd440996792dc8fbf7 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 13:54:06 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E5=AE=9E=E5=86=B5=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E6=9B=B4=E5=8A=A0=E4=B8=9D=E6=BB=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ImageWindow.scss | 42 ++++++++- src/pages/ImageWindow.tsx | 173 ++++++++++++++++++++++++++----------- 2 files changed, 161 insertions(+), 54 deletions(-) diff --git a/src/pages/ImageWindow.scss b/src/pages/ImageWindow.scss index 3004bbf..c1d842d 100644 --- a/src/pages/ImageWindow.scss +++ b/src/pages/ImageWindow.scss @@ -46,6 +46,18 @@ background: var(--bg-tertiary); color: var(--text-primary); } + + &:disabled { + cursor: default; + opacity: 1; + } + + &.live-play-btn { + &.active { + background: rgba(var(--primary-rgb, 76, 132, 255), 0.16); + color: var(--primary, #4c84ff); + } + } } .scale-text { @@ -78,14 +90,40 @@ cursor: grabbing; } - img, video { + .media-wrapper { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + will-change: transform; + } + + img, + video { + display: block; max-width: none; max-height: none; object-fit: contain; - will-change: transform; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); pointer-events: auto; } + + .live-video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: fill; + pointer-events: none; + opacity: 0; + will-change: opacity; + transition: opacity 0.3s ease-in-out; + } + + .live-video.visible { + opacity: 1; + } } } diff --git a/src/pages/ImageWindow.tsx b/src/pages/ImageWindow.tsx index 9e5b4eb..e6b2e5d 100644 --- a/src/pages/ImageWindow.tsx +++ b/src/pages/ImageWindow.tsx @@ -1,4 +1,3 @@ - import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' @@ -9,15 +8,19 @@ export default function ImageWindow() { const [searchParams] = useSearchParams() const imagePath = searchParams.get('imagePath') const liveVideoPath = searchParams.get('liveVideoPath') - const [showLive, setShowLive] = useState(false) + const hasLiveVideo = !!liveVideoPath + + const [isPlayingLive, setIsPlayingLive] = useState(false) + const [isVideoVisible, setIsVideoVisible] = useState(false) const videoRef = useRef(null) + const liveCleanupTimerRef = useRef(null) + const [scale, setScale] = useState(1) const [rotation, setRotation] = useState(0) const [position, setPosition] = useState({ x: 0, y: 0 }) const [initialScale, setInitialScale] = useState(1) - const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 }) const viewportRef = useRef(null) - + // 使用 ref 存储拖动状态,避免闭包问题 const dragStateRef = useRef({ isDragging: false, @@ -27,11 +30,49 @@ export default function ImageWindow() { startPosY: 0 }) + const clearLiveCleanupTimer = useCallback(() => { + if (liveCleanupTimerRef.current !== null) { + window.clearTimeout(liveCleanupTimerRef.current) + liveCleanupTimerRef.current = null + } + }, []) + + const stopLivePlayback = useCallback((immediate = false) => { + clearLiveCleanupTimer() + setIsVideoVisible(false) + + if (immediate) { + if (videoRef.current) { + videoRef.current.pause() + videoRef.current.currentTime = 0 + } + setIsPlayingLive(false) + return + } + + liveCleanupTimerRef.current = window.setTimeout(() => { + if (videoRef.current) { + videoRef.current.pause() + videoRef.current.currentTime = 0 + } + setIsPlayingLive(false) + liveCleanupTimerRef.current = null + }, 300) + }, [clearLiveCleanupTimer]) + + const handlePlayLiveVideo = useCallback(() => { + if (!liveVideoPath || isPlayingLive) return + + clearLiveCleanupTimer() + setIsPlayingLive(true) + setIsVideoVisible(false) + }, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive]) + const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10)) const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1)) const handleRotate = () => setRotation(prev => (prev + 90) % 360) const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360) - + // 重置视图 const handleReset = useCallback(() => { setScale(1) @@ -44,8 +85,7 @@ export default function ImageWindow() { const img = e.currentTarget const naturalWidth = img.naturalWidth const naturalHeight = img.naturalHeight - setImgNatural({ w: naturalWidth, h: naturalHeight }) - + if (viewportRef.current) { const viewportWidth = viewportRef.current.clientWidth * 0.9 const viewportHeight = viewportRef.current.clientHeight * 0.9 @@ -57,14 +97,37 @@ export default function ImageWindow() { } }, []) + // 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播 + useEffect(() => { + if (!isPlayingLive || !videoRef.current) return + + const timer = window.setTimeout(() => { + const video = videoRef.current + if (!video || !isPlayingLive || !video.paused) return + + video.currentTime = 0 + void video.play().catch(() => { + stopLivePlayback(true) + }) + }, 0) + + return () => window.clearTimeout(timer) + }, [isPlayingLive, stopLivePlayback]) + + useEffect(() => { + return () => { + clearLiveCleanupTimer() + } + }, [clearLiveCleanupTimer]) + // 使用原生事件监听器处理拖动 useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!dragStateRef.current.isDragging) return - + const dx = e.clientX - dragStateRef.current.startX const dy = e.clientY - dragStateRef.current.startY - + setPosition({ x: dragStateRef.current.startPosX + dx, y: dragStateRef.current.startPosY + dy @@ -88,7 +151,7 @@ export default function ImageWindow() { const handleMouseDown = (e: React.MouseEvent) => { if (e.button !== 0) return e.preventDefault() - + dragStateRef.current = { isDragging: true, startX: e.clientX, @@ -112,15 +175,25 @@ export default function ImageWindow() { // 快捷键支持 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') window.electronAPI.window.close() + if (e.key === 'Escape') { + if (isPlayingLive) { + stopLivePlayback(true) + return + } + window.electronAPI.window.close() + } if (e.key === '=' || e.key === '+') handleZoomIn() if (e.key === '-') handleZoomOut() if (e.key === 'r' || e.key === 'R') handleRotate() if (e.key === '0') handleReset() + if (e.key === ' ' && hasLiveVideo) { + e.preventDefault() + handlePlayLiveVideo() + } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [handleReset]) + }, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback]) if (!imagePath) { return ( @@ -137,22 +210,19 @@ export default function ImageWindow() {
- {liveVideoPath && ( - + {hasLiveVideo && ( + <> + +
+ )} {Math.round(displayScale * 100)}% @@ -170,32 +240,31 @@ export default function ImageWindow() { onDoubleClick={handleDoubleClick} onMouseDown={handleMouseDown} > - {liveVideoPath && ( -
) From 83c07b27f914f77b8eec81e5f4df9a7ac2e8cb4c Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 14:23:22 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=A7=A3=E5=AF=86=20=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 279 ++++++++++++++++++++- src/App.tsx | 2 + src/components/BatchImageDecryptGlobal.tsx | 133 ++++++++++ src/pages/ChatPage.tsx | 205 +++++++++++++++ src/stores/batchImageDecryptStore.ts | 64 +++++ src/styles/batchTranscribe.scss | 46 +++- 6 files changed, 725 insertions(+), 4 deletions(-) create mode 100644 src/components/BatchImageDecryptGlobal.tsx create mode 100644 src/stores/batchImageDecryptStore.ts diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 45be6d9..5d90f93 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,4 +1,4 @@ -import { join, dirname, basename, extname } from 'path' +import { join, dirname, basename, extname } from 'path' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs' import * as path from 'path' import * as fs from 'fs' @@ -73,6 +73,17 @@ export interface Message { fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 xmlType?: string // XML 中的 type 字段 + appMsgKind?: string // 归一化 appmsg 类型 + appMsgDesc?: string + appMsgAppName?: string + appMsgSourceName?: string + appMsgSourceUsername?: string + appMsgThumbUrl?: string + appMsgMusicUrl?: string + appMsgDataUrl?: string + appMsgLocationLabel?: string + finderNickname?: string + finderUsername?: string // 名片消息 cardUsername?: string // 名片的微信ID cardNickname?: string // 名片的昵称 @@ -1224,6 +1235,17 @@ class ChatService { let fileSize: number | undefined let fileExt: string | undefined let xmlType: string | undefined + let appMsgKind: string | undefined + let appMsgDesc: string | undefined + let appMsgAppName: string | undefined + let appMsgSourceName: string | undefined + let appMsgSourceUsername: string | undefined + let appMsgThumbUrl: string | undefined + let appMsgMusicUrl: string | undefined + let appMsgDataUrl: string | undefined + let appMsgLocationLabel: string | undefined + let finderNickname: string | undefined + let finderUsername: string | undefined // 名片消息 let cardUsername: string | undefined let cardNickname: string | undefined @@ -1284,6 +1306,33 @@ class ChatService { quotedSender = quoteInfo.sender } + const looksLikeAppMsg = Boolean(content && (content.includes(' 0 && genericTitle.length < 100) { @@ -1416,6 +1487,23 @@ class ChatService { private parseType49(content: string): string { const title = this.extractXmlValue(content, 'title') const type = this.extractXmlValue(content, 'type') + const normalized = content.toLowerCase() + const locationLabel = + this.extractXmlAttribute(content, 'location', 'label') || + this.extractXmlAttribute(content, 'location', 'poiname') || + this.extractXmlValue(content, 'label') || + this.extractXmlValue(content, 'poiname') + const isFinder = + type === '51' || + normalized.includes('') || + normalized.includes('') || + normalized.includes('') // 群公告消息(type 87)特殊处理 if (type === '87') { @@ -1426,6 +1514,19 @@ class ChatService { return '[群公告]' } + if (isFinder) { + return title ? `[视频号] ${title}` : '[视频号]' + } + if (isRedPacket) { + return title ? `[红包] ${title}` : '[红包]' + } + if (locationLabel) { + return `[位置] ${locationLabel}` + } + if (isMusic) { + return title ? `[音乐] ${title}` : '[音乐]' + } + if (title) { switch (type) { case '5': @@ -1443,6 +1544,8 @@ class ChatService { return title case '2000': return `[转账] ${title}` + case '2001': + return `[红包] ${title}` default: return title } @@ -1459,6 +1562,13 @@ class ChatService { return '[小程序]' case '2000': return '[转账]' + case '2001': + return '[红包]' + case '3': + return '[音乐]' + case '5': + case '49': + return '[链接]' case '87': return '[群公告]' default: @@ -1790,6 +1900,17 @@ class ChatService { linkTitle?: string linkUrl?: string linkThumb?: string + appMsgKind?: string + appMsgDesc?: string + appMsgAppName?: string + appMsgSourceName?: string + appMsgSourceUsername?: string + appMsgThumbUrl?: string + appMsgMusicUrl?: string + appMsgDataUrl?: string + appMsgLocationLabel?: string + finderNickname?: string + finderUsername?: string fileName?: string fileSize?: number fileExt?: string @@ -1816,6 +1937,82 @@ class ChatService { // 提取通用字段 const title = this.extractXmlValue(content, 'title') const url = this.extractXmlValue(content, 'url') + const desc = this.extractXmlValue(content, 'des') || this.extractXmlValue(content, 'description') + const appName = this.extractXmlValue(content, 'appname') + const sourceName = this.extractXmlValue(content, 'sourcename') + const sourceUsername = this.extractXmlValue(content, 'sourceusername') + const thumbUrl = + this.extractXmlValue(content, 'thumburl') || + this.extractXmlValue(content, 'cdnthumburl') || + this.extractXmlValue(content, 'cover') || + this.extractXmlValue(content, 'coverurl') || + this.extractXmlValue(content, 'thumb_url') + const musicUrl = + this.extractXmlValue(content, 'musicurl') || + this.extractXmlValue(content, 'playurl') || + this.extractXmlValue(content, 'songalbumurl') + const dataUrl = this.extractXmlValue(content, 'dataurl') || this.extractXmlValue(content, 'lowurl') + const locationLabel = + this.extractXmlAttribute(content, 'location', 'label') || + this.extractXmlAttribute(content, 'location', 'poiname') || + this.extractXmlValue(content, 'label') || + this.extractXmlValue(content, 'poiname') + const finderUsername = + this.extractXmlValue(content, 'finderusername') || + this.extractXmlValue(content, 'finder_username') || + this.extractXmlValue(content, 'finderuser') + const finderNickname = + this.extractXmlValue(content, 'findernickname') || + this.extractXmlValue(content, 'finder_nickname') + const normalized = content.toLowerCase() + const isFinder = + xmlType === '51' || + normalized.includes(' { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + let tables = this.sessionTablesCache.get(sessionId) + if (!tables) { + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return { success: false, error: '未找到会话消息表' } + } + tables = tableStats.tables + .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) + .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> + if (tables.length > 0) { + this.sessionTablesCache.set(sessionId, tables) + setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) + } + } + + let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = [] + + for (const { tableName, dbPath } of tables) { + try { + const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC` + const result = await wcdbService.execQuery('message', dbPath, sql) + if (result.success && result.rows && result.rows.length > 0) { + const mapped = this.mapRowsToMessages(result.rows as Record[]) + const images = mapped + .filter(msg => msg.localType === 3) + .map(msg => ({ + imageMd5: msg.imageMd5 || undefined, + imageDatName: msg.imageDatName || undefined, + createTime: msg.createTime || undefined + })) + .filter(img => Boolean(img.imageMd5 || img.imageDatName)) + allImages.push(...images) + } + } catch (e) { + console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e) + } + } + + allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0)) + + const seen = new Set() + allImages = allImages.filter(img => { + const key = img.imageMd5 || img.imageDatName || '' + if (!key || seen.has(key)) return false + seen.add(key) + return true + }) + + console.log(`[ChatService] 共找到 ${allImages.length} 条图片消息(去重后)`) + return { success: true, images: allImages } + } catch (e) { + console.error('[ChatService] 获取全部图片消息失败:', e) + return { success: false, error: String(e) } + } + } + async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> { try { const connectResult = await this.ensureConnected() @@ -4017,6 +4282,14 @@ class ChatService { msg.emojiThumbUrl = emojiInfo.thumbUrl msg.emojiEncryptUrl = emojiInfo.encryptUrl msg.emojiAesKey = emojiInfo.aesKey + } else if (msg.localType === 42) { + const cardInfo = this.parseCardInfo(rawContent) + msg.cardUsername = cardInfo.username + msg.cardNickname = cardInfo.nickname + } + + if (rawContent && (rawContent.includes(' + {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && ( diff --git a/src/components/BatchImageDecryptGlobal.tsx b/src/components/BatchImageDecryptGlobal.tsx new file mode 100644 index 0000000..e819d14 --- /dev/null +++ b/src/components/BatchImageDecryptGlobal.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react' +import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' +import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' +import '../styles/batchTranscribe.scss' + +export const BatchImageDecryptGlobal: React.FC = () => { + const { + isBatchDecrypting, + progress, + showToast, + showResultToast, + result, + sessionName, + startTime, + setShowToast, + setShowResultToast + } = useBatchImageDecryptStore() + + const voiceToastOccupied = useBatchTranscribeStore( + state => state.isBatchTranscribing && state.showToast + ) + + const [eta, setEta] = useState('') + + useEffect(() => { + if (!isBatchDecrypting || !startTime || progress.current === 0) { + setEta('') + return + } + + const timer = setInterval(() => { + const elapsed = Date.now() - startTime + if (elapsed <= 0) return + const rate = progress.current / elapsed + const remain = progress.total - progress.current + if (remain <= 0 || rate <= 0) { + setEta('') + return + } + const seconds = Math.ceil((remain / rate) / 1000) + if (seconds < 60) { + setEta(`${seconds}秒`) + } else { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + setEta(`${m}分${s}秒`) + } + }, 1000) + + return () => clearInterval(timer) + }, [isBatchDecrypting, progress.current, progress.total, startTime]) + + useEffect(() => { + if (!showResultToast) return + const timer = window.setTimeout(() => setShowResultToast(false), 6000) + return () => window.clearTimeout(timer) + }, [showResultToast, setShowResultToast]) + + const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied]) + + return ( + <> + {showToast && isBatchDecrypting && createPortal( +
+
+
+ + 批量解密图片{sessionName ? `(${sessionName})` : ''} +
+ +
+
+
+
+ {progress.current} / {progress.total} + + {progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}% + +
+ {eta && ( +
+ + 剩余 {eta} +
+ )} +
+
+
0 ? (progress.current / progress.total) * 100 : 0}%` + }} + /> +
+
+
, + document.body + )} + + {showResultToast && createPortal( +
+
+
+ + 图片批量解密完成 +
+ +
+
+
+
+ + 成功 {result.success} +
+
0 ? 'fail' : 'muted'}`}> + + 失败 {result.fail} +
+
+
+
, + document.body + )} + + ) +} + diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 89422bd..df72d96 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' +import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' @@ -27,6 +28,12 @@ interface XmlField { path: string; } +interface BatchImageDecryptCandidate { + imageMd5?: string + imageDatName?: string + createTime?: number +} + // 尝试解析 XML 为可编辑字段 function parseXmlToFields(xml: string): XmlField[] { const fields: XmlField[] = [] @@ -301,11 +308,16 @@ function ChatPage(_props: ChatPageProps) { // 批量语音转文字相关状态(进度/结果 由全局 store 管理) const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() + const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore() const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState(null) const [batchVoiceDates, setBatchVoiceDates] = useState([]) const [batchSelectedDates, setBatchSelectedDates] = useState>(new Set()) + const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false) + const [batchImageMessages, setBatchImageMessages] = useState(null) + const [batchImageDates, setBatchImageDates] = useState([]) + const [batchImageSelectedDates, setBatchImageSelectedDates] = useState>(new Set()) // 批量删除相关状态 const [isDeleting, setIsDeleting] = useState(false) @@ -1434,6 +1446,37 @@ function ChatPage(_props: ChatPageProps) { setShowBatchConfirm(true) }, [sessions, currentSessionId, isBatchTranscribing]) + const handleBatchDecrypt = useCallback(async () => { + if (!currentSessionId || isBatchDecrypting) return + const session = sessions.find(s => s.username === currentSessionId) + if (!session) { + alert('未找到当前会话') + return + } + + const result = await window.electronAPI.chat.getAllImageMessages(currentSessionId) + if (!result.success || !result.images) { + alert(`获取图片消息失败: ${result.error || '未知错误'}`) + return + } + + if (result.images.length === 0) { + alert('当前会话没有图片消息') + return + } + + const dateSet = new Set() + result.images.forEach((img: BatchImageDecryptCandidate) => { + if (img.createTime) dateSet.add(new Date(img.createTime * 1000).toISOString().slice(0, 10)) + }) + const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a)) + + setBatchImageMessages(result.images) + setBatchImageDates(sortedDates) + setBatchImageSelectedDates(new Set(sortedDates)) + setShowBatchDecryptConfirm(true) + }, [currentSessionId, isBatchDecrypting, sessions]) + const handleExportCurrentSession = useCallback(() => { if (!currentSessionId) return navigate('/export', { @@ -1557,6 +1600,88 @@ function ChatPage(_props: ChatPageProps) { const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates]) const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), []) + const confirmBatchDecrypt = useCallback(async () => { + if (!currentSessionId) return + + const selected = batchImageSelectedDates + if (selected.size === 0) { + alert('请至少选择一个日期') + return + } + + const images = (batchImageMessages || []).filter(img => + img.createTime && selected.has(new Date(img.createTime * 1000).toISOString().slice(0, 10)) + ) + if (images.length === 0) { + alert('所选日期下没有图片消息') + return + } + + const session = sessions.find(s => s.username === currentSessionId) + if (!session) return + + setShowBatchDecryptConfirm(false) + setBatchImageMessages(null) + setBatchImageDates([]) + setBatchImageSelectedDates(new Set()) + + startDecrypt(images.length, session.displayName || session.username) + + let successCount = 0 + let failCount = 0 + for (let i = 0; i < images.length; i++) { + const img = images[i] + try { + const r = await window.electronAPI.image.decrypt({ + sessionId: session.username, + imageMd5: img.imageMd5, + imageDatName: img.imageDatName, + force: false + }) + if (r?.success) successCount++ + else failCount++ + } catch { + failCount++ + } + + updateDecryptProgress(i + 1, images.length) + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + } + + finishDecrypt(successCount, failCount) + }, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) + + const batchImageCountByDate = useMemo(() => { + const map = new Map() + if (!batchImageMessages) return map + batchImageMessages.forEach(img => { + if (!img.createTime) return + const d = new Date(img.createTime * 1000).toISOString().slice(0, 10) + map.set(d, (map.get(d) ?? 0) + 1) + }) + return map + }, [batchImageMessages]) + + const batchImageSelectedCount = useMemo(() => { + if (!batchImageMessages) return 0 + return batchImageMessages.filter(img => + img.createTime && batchImageSelectedDates.has(new Date(img.createTime * 1000).toISOString().slice(0, 10)) + ).length + }, [batchImageMessages, batchImageSelectedDates]) + + const toggleBatchImageDate = useCallback((date: string) => { + setBatchImageSelectedDates(prev => { + const next = new Set(prev) + if (next.has(date)) next.delete(date) + else next.add(date) + return next + }) + }, []) + const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates]) + const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), []) + const lastSelectedIdRef = useRef(null) const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => { @@ -1996,6 +2121,26 @@ function ChatPage(_props: ChatPageProps) { )} + + +
+
    + {batchImageDates.map(dateStr => { + const count = batchImageCountByDate.get(dateStr) ?? 0 + const checked = batchImageSelectedDates.has(dateStr) + return ( +
  • + +
  • + ) + })} +
+ + )} +
+
+ 已选: + {batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片 +
+
+
+ + 批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。 +
+ +
+ + +
+ + , + document.body + )} {contextMenu && createPortal( <>
setContextMenu(null)} diff --git a/src/stores/batchImageDecryptStore.ts b/src/stores/batchImageDecryptStore.ts new file mode 100644 index 0000000..d074362 --- /dev/null +++ b/src/stores/batchImageDecryptStore.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand' + +export interface BatchImageDecryptState { + isBatchDecrypting: boolean + progress: { current: number; total: number } + showToast: boolean + showResultToast: boolean + result: { success: number; fail: number } + startTime: number + sessionName: string + + startDecrypt: (total: number, sessionName: string) => void + updateProgress: (current: number, total: number) => void + finishDecrypt: (success: number, fail: number) => void + setShowToast: (show: boolean) => void + setShowResultToast: (show: boolean) => void + reset: () => void +} + +export const useBatchImageDecryptStore = create((set) => ({ + isBatchDecrypting: false, + progress: { current: 0, total: 0 }, + showToast: false, + showResultToast: false, + result: { success: 0, fail: 0 }, + startTime: 0, + sessionName: '', + + startDecrypt: (total, sessionName) => set({ + isBatchDecrypting: true, + progress: { current: 0, total }, + showToast: true, + showResultToast: false, + result: { success: 0, fail: 0 }, + startTime: Date.now(), + sessionName + }), + + updateProgress: (current, total) => set({ + progress: { current, total } + }), + + finishDecrypt: (success, fail) => set({ + isBatchDecrypting: false, + showToast: false, + showResultToast: true, + result: { success, fail }, + startTime: 0 + }), + + setShowToast: (show) => set({ showToast: show }), + setShowResultToast: (show) => set({ showResultToast: show }), + + reset: () => set({ + isBatchDecrypting: false, + progress: { current: 0, total: 0 }, + showToast: false, + showResultToast: false, + result: { success: 0, fail: 0 }, + startTime: 0, + sessionName: '' + }) +})) + diff --git a/src/styles/batchTranscribe.scss b/src/styles/batchTranscribe.scss index 5a7256f..b17f561 100644 --- a/src/styles/batchTranscribe.scss +++ b/src/styles/batchTranscribe.scss @@ -167,6 +167,50 @@ } } +.batch-inline-result-toast { + .batch-progress-toast-title { + svg { + color: #22c55e; + } + } + + .batch-inline-result-summary { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .batch-inline-result-item { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg-tertiary); + font-size: 12px; + color: var(--text-secondary); + + svg { + flex-shrink: 0; + } + + &.success { + color: #16a34a; + svg { color: #16a34a; } + } + + &.fail { + color: #dc2626; + svg { color: #dc2626; } + } + + &.muted { + color: var(--text-tertiary, #999); + svg { color: var(--text-tertiary, #999); } + } + } +} + // 批量转写结果对话框 .batch-result-modal { width: 420px; @@ -293,4 +337,4 @@ } } } -} \ No newline at end of file +} From 1a07c3970f8f7228c47d46c853cbff97cf47a7df Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 14:54:08 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E7=AE=80=E5=8D=95=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/weflow-overview-sync/SKILL.md | 32 ++++++++ electron/main.ts | 3 + electron/preload.ts | 1 + electron/services/imageDecryptService.ts | 81 +++++++++++++++----- electron/services/wcdbCore.ts | 2 +- package-lock.json | 22 ++---- src/pages/ChatPage.scss | 77 ++++++++++++++++++- src/pages/ChatPage.tsx | 59 ++++++++++++-- src/types/electron.d.ts | 5 ++ src/types/models.ts | 11 +++ 10 files changed, 246 insertions(+), 47 deletions(-) create mode 100644 .agents/skills/weflow-overview-sync/SKILL.md diff --git a/.agents/skills/weflow-overview-sync/SKILL.md b/.agents/skills/weflow-overview-sync/SKILL.md new file mode 100644 index 0000000..c8d8b11 --- /dev/null +++ b/.agents/skills/weflow-overview-sync/SKILL.md @@ -0,0 +1,32 @@ +--- +name: weflow-overview-sync +description: Keep the WeFlow architecture overview document synchronized with code and interface changes. Use when editing WeFlow source files, Electron services, IPC contracts, DB access logic, export and analytics flows, or related docs that affect architecture, fields, or data paths. +--- + +# WeFlow Overview Sync + +## Workflow + +1. Read the architecture overview markdown at repo root before any WeFlow edit. +2. Identify touched files and impacted concepts (module, interface, data flow, field definition, export behavior). +3. Update the overview document in the same task when affected items are already documented. +4. Add a new subsection in the overview document when the requested change is not documented yet. +5. Preserve the existing formatting style of the overview document before finalizing: +- Keep heading hierarchy and numbering style consistent. +- Keep concise wording and use `-` list markers. +- Wrap file paths, APIs, and field names in backticks. +- Place new content in the logically matching section. +6. Re-check the overview document for format consistency and architecture accuracy before replying. + +## Update Rules + +- Update existing sections when they already cover the changed files or interfaces. +- Add missing coverage when new modules, IPC methods, SQL fields, or service flows appear. +- Avoid broad rewrites; apply focused edits that keep the document stable and scannable. +- Reflect any renamed path, API, or field immediately to prevent architecture drift. + +## Collaboration and UI Rules + +- If unrelated additions from other collaborators appear in files you edit, leave them as-is and focus only on the current task scope. +- For dropdown menu UI design, inspect and follow existing in-app dropdown patterns; do not use native browser dropdown styles. +- Do not use native styles for frontend UI design; implement consistent custom-styled components aligned with the product's existing visual system. diff --git a/electron/main.ts b/electron/main.ts index f43d707..2e635b6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -914,6 +914,9 @@ function registerIpcHandlers() { ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => { return chatService.getAllVoiceMessages(sessionId) }) + ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => { + return chatService.getAllImageMessages(sessionId) + }) ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => { return chatService.getMessageDates(sessionId) }) diff --git a/electron/preload.ts b/electron/preload.ts index e1ad7a8..4cf585b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -154,6 +154,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), + getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId), getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index b3c8b05..7a8c043 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -155,6 +155,17 @@ export class ImageDecryptService { return { success: false, error: '缺少图片标识' } } + if (payload.force) { + const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId) + if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) { + const dataUrl = this.fileToDataUrl(hdCached) + const localPath = dataUrl || this.filePathToUrl(hdCached) + const liveVideoPath = this.checkLiveVideoCache(hdCached) + this.emitCacheResolved(payload, cacheKey, localPath) + return { success: true, localPath, isThumb: false, liveVideoPath } + } + } + if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { @@ -346,23 +357,37 @@ export class ImageDecryptService { * 获取解密后的缓存目录(用于查找 hardlink.db) */ private getDecryptedCacheDir(wxid: string): string | null { - const cachePath = this.configService.get('cachePath') - if (!cachePath) return null - const cleanedWxid = this.cleanAccountDirName(wxid) - const cacheAccountDir = join(cachePath, cleanedWxid) + const configured = this.configService.get('cachePath') + const documentsPath = app.getPath('documents') + const baseCandidates = Array.from(new Set([ + configured || '', + join(documentsPath, 'WeFlow'), + join(documentsPath, 'WeFlowData'), + this.configService.getCacheBasePath() + ].filter(Boolean))) - // 检查缓存目录下是否有 hardlink.db - if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { - return cacheAccountDir - } - if (existsSync(join(cachePath, 'hardlink.db'))) { - return cachePath - } - const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink') - if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) { - return cacheHardlinkDir + for (const base of baseCandidates) { + const accountCandidates = Array.from(new Set([ + join(base, wxid), + join(base, cleanedWxid), + join(base, 'databases', wxid), + join(base, 'databases', cleanedWxid) + ])) + for (const accountDir of accountCandidates) { + if (existsSync(join(accountDir, 'hardlink.db'))) { + return accountDir + } + const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink') + if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) { + return hardlinkSubdir + } + } + if (existsSync(join(base, 'hardlink.db'))) { + return base + } } + return null } @@ -371,7 +396,8 @@ export class ImageDecryptService { existsSync(join(dirPath, 'hardlink.db')) || existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'FileStorage', 'Image')) || - existsSync(join(dirPath, 'FileStorage', 'Image2')) + existsSync(join(dirPath, 'FileStorage', 'Image2')) || + existsSync(join(dirPath, 'msg', 'attach')) ) } @@ -437,6 +463,12 @@ export class ImageDecryptService { if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } + const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false) + if (hdInDir) { + this.cacheDatPath(accountDir, imageMd5, hdInDir) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir) + return hdInDir + } // 没找到高清图,返回 null(不进行全局搜索) return null } @@ -454,9 +486,16 @@ export class ImageDecryptService { // 找到缩略图但要求高清图,尝试同目录查找高清图变体 const hdPath = this.findHdVariantInSameDir(fallbackPath) if (hdPath) { + this.cacheDatPath(accountDir, imageMd5, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } + const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false) + if (hdInDir) { + this.cacheDatPath(accountDir, imageMd5, hdInDir) + this.cacheDatPath(accountDir, imageDatName, hdInDir) + return hdInDir + } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) @@ -479,15 +518,17 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } + const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false) + if (hdInDir) { + this.cacheDatPath(accountDir, imageDatName, hdInDir) + return hdInDir + } return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } - // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) - if (!allowThumbnail) { - return null - } + // force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图 if (!imageDatName) return null if (!skipResolvedCache) { @@ -497,6 +538,8 @@ export class ImageDecryptService { // 缓存的是缩略图,尝试找高清图 const hdPath = this.findHdVariantInSameDir(cached) if (hdPath) return hdPath + const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false) + if (hdInDir) return hdInDir } } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 08fcf8c..89b5039 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1024,7 +1024,7 @@ export class WcdbCore { } try { // 1. 打开游标 (使用 Ascending=1 从指定时间往后查) - const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0) + const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0) if (!openRes.success || !openRes.cursor) { return { success: false, error: openRes.error } } diff --git a/package-lock.json b/package-lock.json index 4c688ff..92d10ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2910,7 +2909,6 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3057,7 +3055,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3997,7 +3994,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5107,7 +5103,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5295,7 +5290,6 @@ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" @@ -5382,6 +5376,7 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5395,6 +5390,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5410,6 +5406,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5423,6 +5420,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -9152,7 +9150,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9162,7 +9159,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9597,7 +9593,6 @@ "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -9828,9 +9823,6 @@ "sherpa-onnx-win-x64": "^1.12.23" } }, - "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": { - "optional": true - }, "node_modules/sherpa-onnx-win-ia32": { "version": "1.12.23", "resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz", @@ -10442,7 +10434,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10890,7 +10881,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10980,8 +10970,7 @@ "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -11007,7 +10996,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 8a41b65..7964e3b 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1114,6 +1114,53 @@ } } } + + .appmsg-meta-badge { + font-size: 11px; + line-height: 1; + color: var(--primary); + background: rgba(127, 127, 127, 0.08); + border: 1px solid rgba(127, 127, 127, 0.18); + border-radius: 999px; + padding: 3px 7px; + align-self: flex-start; + white-space: nowrap; + } + + .link-desc-block { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .appmsg-url-line { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &.appmsg-rich-card { + .link-header { + flex-direction: column; + align-items: flex-start; + } + } +} + +.link-thumb.theme-adaptive, +.miniapp-thumb.theme-adaptive { + transition: filter 0.2s ease; +} + +[data-mode="dark"] { + .link-thumb.theme-adaptive, + .miniapp-thumb.theme-adaptive { + filter: invert(1) hue-rotate(180deg); + } } // 适配发送出去的消息中的链接卡片 @@ -2752,12 +2799,14 @@ .card-message, .chat-record-message, - .miniapp-message { + .miniapp-message, + .appmsg-rich-card { background: rgba(255, 255, 255, 0.15); .card-name, .miniapp-title, - .source-name { + .source-name, + .link-title { color: white; } @@ -2765,7 +2814,9 @@ .miniapp-label, .chat-record-item, .chat-record-meta-line, - .chat-record-desc { + .chat-record-desc, + .link-desc, + .appmsg-url-line { color: rgba(255, 255, 255, 0.8); } @@ -2778,6 +2829,12 @@ .chat-record-more { color: rgba(255, 255, 255, 0.9); } + + .appmsg-meta-badge { + color: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); + } } .call-message { @@ -3235,4 +3292,16 @@ } } } -} \ No newline at end of file +} + +.miniapp-message-rich { + .miniapp-thumb { + width: 42px; + height: 42px; + border-radius: 8px; + object-fit: cover; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + flex-shrink: 0; + } +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index df72d96..939aad1 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3061,7 +3061,7 @@ function MessageBubble({ setImageLocalPath(result.localPath) setImageHasUpdate(false) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) - return + return result } } @@ -3072,7 +3072,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, dataUrl) setImageLocalPath(dataUrl) setImageHasUpdate(false) - return + return { success: true, localPath: dataUrl } as any } if (!silent) setImageError(true) } catch { @@ -3080,6 +3080,7 @@ function MessageBubble({ } finally { if (!silent) setImageLoading(false) } + return { success: false } as any }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) const triggerForceHd = useCallback(() => { @@ -3110,6 +3111,55 @@ function MessageBubble({ void requestImageDecrypt() }, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username]) + const handleOpenImageViewer = useCallback(async () => { + if (!imageLocalPath) return + + let finalImagePath = imageLocalPath + let finalLiveVideoPath = imageLiveVideoPath || undefined + + // If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer. + if (imageHasUpdate) { + try { + const upgraded = await requestImageDecrypt(true, true) + if (upgraded?.success && upgraded.localPath) { + finalImagePath = upgraded.localPath + finalLiveVideoPath = upgraded.liveVideoPath || finalLiveVideoPath + } + } catch { } + } + + // One more resolve helps when background/batch decrypt has produced a clearer image or live video + // but local component state hasn't caught up yet. + if (message.imageMd5 || message.imageDatName) { + try { + const resolved = await window.electronAPI.image.resolveCache({ + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName + }) + if (resolved?.success && resolved.localPath) { + finalImagePath = resolved.localPath + finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath + imageDataUrlCache.set(imageCacheKey, resolved.localPath) + setImageLocalPath(resolved.localPath) + if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) + setImageHasUpdate(Boolean(resolved.hasUpdate)) + } + } catch { } + } + + void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath) + }, [ + imageHasUpdate, + imageLiveVideoPath, + imageLocalPath, + imageCacheKey, + message.imageDatName, + message.imageMd5, + requestImageDecrypt, + session.username + ]) + useEffect(() => { return () => { if (imageClickTimerRef.current) { @@ -3631,10 +3681,7 @@ function MessageBubble({ src={imageLocalPath} alt="图片" className="image-message" - onClick={() => { - if (imageHasUpdate) void requestImageDecrypt(true, true) - void window.electronAPI.window.openImageViewerWindow(imageLocalPath!, imageLiveVideoPath || undefined) - }} + onClick={() => { void handleOpenImageViewer() }} onLoad={() => setImageError(false)} onError={() => setImageError(true)} /> diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index d56cf81..b00f3c0 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -126,6 +126,11 @@ export interface ElectronAPI { getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }> + getAllImageMessages: (sessionId: string) => Promise<{ + success: boolean + images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[] + error?: string + }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void diff --git a/src/types/models.ts b/src/types/models.ts index 5b5a3bf..7d93279 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -64,6 +64,17 @@ export interface Message { fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 xmlType?: string // XML 中的 type 字段 + appMsgKind?: string // 归一化 appmsg 类型 + appMsgDesc?: string + appMsgAppName?: string + appMsgSourceName?: string + appMsgSourceUsername?: string + appMsgThumbUrl?: string + appMsgMusicUrl?: string + appMsgDataUrl?: string + appMsgLocationLabel?: string + finderNickname?: string + finderUsername?: string // 转账消息 transferPayerUsername?: string // 转账付款方 wxid transferReceiverUsername?: string // 转账收款方 wxid From bc0671440c7282ec9e3ef69da01a220c65f934ef Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 17:07:47 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 153 ++++++++- src/pages/ChatPage.scss | 524 ++++++++++++++++++++++++++++++- src/pages/ChatPage.tsx | 328 ++++++++++++++++++- src/types/models.ts | 16 + 4 files changed, 999 insertions(+), 22 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 5d90f93..eda6e7a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -84,9 +84,25 @@ export interface Message { appMsgLocationLabel?: string finderNickname?: string finderUsername?: string + finderCoverUrl?: string + finderAvatar?: string + finderDuration?: number + // 位置消息 + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + // 音乐消息 + musicAlbumUrl?: string + musicUrl?: string + // 礼物消息 + giftImageUrl?: string + giftWish?: string + giftPrice?: string // 名片消息 cardUsername?: string // 名片的微信ID cardNickname?: string // 名片的昵称 + cardAvatarUrl?: string // 名片头像 URL // 转账消息 transferPayerUsername?: string // 转账付款人 transferReceiverUsername?: string // 转账收款人 @@ -744,15 +760,15 @@ class ChatService { } const batchSize = Math.max(1, limit || this.messageBatchDefault) - + // 使用互斥锁保护游标状态访问 while (this.messageCursorMutex) { await new Promise(resolve => setTimeout(resolve, 1)) } this.messageCursorMutex = true - + let state = this.messageCursors.get(sessionId) - + // 只在以下情况重新创建游标: // 1. 没有游标状态 // 2. offset 为 0 (重新加载会话) @@ -789,7 +805,7 @@ class ChatService { state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) this.messageCursorMutex = false - + // 如果需要跳过消息(offset > 0),逐批获取但不返回 // 注意:仅在 offset === 0 时重建游标最安全; // 当 startTime/endTime 变化导致重建时,offset 应由前端重置为 0 @@ -890,7 +906,7 @@ class ChatService { // 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文 // 单聊消息:senderUsername 应该是 sessionId 或自己 const isGroupChat = sessionId.includes('@chatroom') - + if (isGroupChat) { // 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId) return true @@ -927,7 +943,7 @@ class ChatService { state.fetched += rows.length this.messageCursorMutex = false - + this.messageCacheService.set(sessionId, filtered) return { success: true, messages: filtered, hasMore } } catch (e) { @@ -1246,9 +1262,22 @@ class ChatService { let appMsgLocationLabel: string | undefined let finderNickname: string | undefined let finderUsername: string | undefined + let finderCoverUrl: string | undefined + let finderAvatar: string | undefined + let finderDuration: number | undefined + let locationLat: number | undefined + let locationLng: number | undefined + let locationPoiname: string | undefined + let locationLabel: string | undefined + let musicAlbumUrl: string | undefined + let musicUrl: string | undefined + let giftImageUrl: string | undefined + let giftWish: string | undefined + let giftPrice: string | undefined // 名片消息 let cardUsername: string | undefined let cardNickname: string | undefined + let cardAvatarUrl: string | undefined // 转账消息 let transferPayerUsername: string | undefined let transferReceiverUsername: string | undefined @@ -1286,6 +1315,15 @@ class ChatService { const cardInfo = this.parseCardInfo(content) cardUsername = cardInfo.username cardNickname = cardInfo.nickname + cardAvatarUrl = cardInfo.avatarUrl + } else if (localType === 48 && content) { + // 位置消息 + const latStr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude') + const lngStr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude') + if (latStr) { const v = parseFloat(latStr); if (Number.isFinite(v)) locationLat = v } + if (lngStr) { const v = parseFloat(lngStr); if (Number.isFinite(v)) locationLng = v } + locationLabel = this.extractXmlAttribute(content, 'location', 'label') || this.extractXmlValue(content, 'label') || undefined + locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || this.extractXmlValue(content, 'poiname') || undefined } else if ((localType === 49 || localType === 8589934592049) && content) { // Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型 const type49Info = this.parseType49Message(content) @@ -1327,6 +1365,18 @@ class ChatService { appMsgLocationLabel = appMsgLocationLabel || type49Info.appMsgLocationLabel finderNickname = finderNickname || type49Info.finderNickname finderUsername = finderUsername || type49Info.finderUsername + finderCoverUrl = finderCoverUrl || type49Info.finderCoverUrl + finderAvatar = finderAvatar || type49Info.finderAvatar + finderDuration = finderDuration ?? type49Info.finderDuration + locationLat = locationLat ?? type49Info.locationLat + locationLng = locationLng ?? type49Info.locationLng + locationPoiname = locationPoiname || type49Info.locationPoiname + locationLabel = locationLabel || type49Info.locationLabel + musicAlbumUrl = musicAlbumUrl || type49Info.musicAlbumUrl + musicUrl = musicUrl || type49Info.musicUrl + giftImageUrl = giftImageUrl || type49Info.giftImageUrl + giftWish = giftWish || type49Info.giftWish + giftPrice = giftPrice || type49Info.giftPrice chatRecordTitle = chatRecordTitle || type49Info.chatRecordTitle chatRecordList = chatRecordList || type49Info.chatRecordList transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername @@ -1372,8 +1422,21 @@ class ChatService { appMsgLocationLabel, finderNickname, finderUsername, + finderCoverUrl, + finderAvatar, + finderDuration, + locationLat, + locationLng, + locationPoiname, + locationLabel, + musicAlbumUrl, + musicUrl, + giftImageUrl, + giftWish, + giftPrice, cardUsername, cardNickname, + cardAvatarUrl, transferPayerUsername, transferReceiverUsername, chatRecordTitle, @@ -1874,7 +1937,7 @@ class ChatService { * 解析名片消息 * 格式: */ - private parseCardInfo(content: string): { username?: string; nickname?: string } { + private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } { try { if (!content) return {} @@ -1884,7 +1947,11 @@ class ChatService { // 提取 nickname const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined - return { username, nickname } + // 提取头像 + const avatarUrl = this.extractXmlAttribute(content, 'msg', 'bigheadimgurl') || + this.extractXmlAttribute(content, 'msg', 'smallheadimgurl') || undefined + + return { username, nickname, avatarUrl } } catch (e) { console.error('[ChatService] 名片解析失败:', e) return {} @@ -1911,6 +1978,19 @@ class ChatService { appMsgLocationLabel?: string finderNickname?: string finderUsername?: string + finderCoverUrl?: string + finderAvatar?: string + finderDuration?: number + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + musicAlbumUrl?: string + musicUrl?: string + giftImageUrl?: string + giftWish?: string + giftPrice?: string + cardAvatarUrl?: string fileName?: string fileSize?: number fileExt?: string @@ -1965,14 +2045,10 @@ class ChatService { this.extractXmlValue(content, 'findernickname') || this.extractXmlValue(content, 'finder_nickname') const normalized = content.toLowerCase() - const isFinder = - xmlType === '51' || - normalized.includes(' 0) result.finderDuration = d + } + } + + // 位置经纬度 + if (isLocation) { + const latAttr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude') + const lngAttr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude') + if (latAttr) { const v = parseFloat(latAttr); if (Number.isFinite(v)) result.locationLat = v } + if (lngAttr) { const v = parseFloat(lngAttr); if (Number.isFinite(v)) result.locationLng = v } + result.locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || locationLabel || undefined + result.locationLabel = this.extractXmlAttribute(content, 'location', 'label') || undefined + } + + // 音乐专辑封面 + if (isMusic) { + const albumUrl = this.extractXmlValue(content, 'songalbumurl') + if (albumUrl) result.musicAlbumUrl = albumUrl + result.musicUrl = musicUrl || dataUrl || url || undefined + } + + // 礼物消息 + const isGift = xmlType === '115' + if (isGift) { + result.giftWish = this.extractXmlValue(content, 'wishmessage') || undefined + result.giftImageUrl = this.extractXmlValue(content, 'skuimgurl') || undefined + result.giftPrice = this.extractXmlValue(content, 'skuprice') || undefined + } + if (isFinder) { result.appMsgKind = 'finder' } else if (isRedPacket) { result.appMsgKind = 'red-packet' + } else if (isGift) { + result.appMsgKind = 'gift' } else if (isLocation) { result.appMsgKind = 'location' } else if (isMusic) { @@ -4286,6 +4406,7 @@ class ChatService { const cardInfo = this.parseCardInfo(rawContent) msg.cardUsername = cardInfo.username msg.cardNickname = cardInfo.nickname + msg.cardAvatarUrl = cardInfo.avatarUrl } if (rawContent && (rawContent.includes('
- - - - + {cardAvatar ? ( + + ) : ( + + + + + )}
{cardName}
+ {message.cardUsername && message.cardUsername !== message.cardNickname && ( +
微信号: {message.cardUsername}
+ )}
个人名片
@@ -3972,7 +3980,319 @@ function MessageBubble({ ) } + // 位置消息 + if (message.localType === 48) { + const raw = message.rawContent || '' + const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' + const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' + const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) + const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) + const mapTileUrl = (lat && lng) + ? `https://restapi.amap.com/v3/staticmap?location=${lng},${lat}&zoom=15&size=280*100&markers=mid,,A:${lng},${lat}&key=e1dedc6bfbb8413ab2185e7a0e21f0a1` + : '' + return ( +
window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}> +
+
+ + + + +
+
+ {poiname &&
{poiname}
} + {label &&
{label}
} +
+
+ {mapTileUrl && ( +
+ 地图 +
+ )} +
+ ) + } + // 链接消息 (AppMessage) + const appMsgRichPreview = (() => { + const rawXml = message.rawContent || '' + if (!rawXml || (!rawXml.includes(' { + if (doc) return doc + try { + const start = rawXml.indexOf('') + const xml = start >= 0 ? rawXml.slice(start) : rawXml + doc = new DOMParser().parseFromString(xml, 'text/xml') + } catch { + doc = null + } + return doc + } + const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || '' + + const xmlType = message.xmlType || q('appmsg > type') || q('type') + const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card' + const desc = message.appMsgDesc || q('des') + const url = message.linkUrl || q('url') + const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') + const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl') + const sourceName = message.appMsgSourceName || q('sourcename') + const appName = message.appMsgAppName || q('appname') + const sourceUsername = message.appMsgSourceUsername || q('sourceusername') + const finderName = + message.finderNickname || + message.finderUsername || + q('findernickname') || + q('finder_nickname') || + q('finderusername') || + q('finder_username') + + const lower = rawXml.toLowerCase() + + const kind = message.appMsgKind || ( + (xmlType === '2001' || lower.includes('hongbao')) ? 'red-packet' + : (xmlType === '115' ? 'gift' + : ((xmlType === '33' || xmlType === '36') ? 'miniapp' + : (((xmlType === '5' || xmlType === '49') && (sourceUsername.startsWith('gh_') || !!sourceName || appName.includes('公众号'))) ? 'official-link' + : (xmlType === '51' ? 'finder' + : (xmlType === '3' ? 'music' + : ((xmlType === '5' || xmlType === '49') ? 'link' // Fallback for standard links + : (!!musicUrl ? 'music' : ''))))))) + ) + + if (!kind) return null + + // 对视频号提取真实标题,避免出现 "当前版本不支持该内容" + let displayTitle = title + if (kind === 'finder' && title.includes('不支持')) { + displayTitle = desc || '' + } + + const openExternal = (e: React.MouseEvent, nextUrl?: string) => { + if (!nextUrl) return + e.stopPropagation() + if (window.electronAPI?.shell?.openExternal) { + window.electronAPI.shell.openExternal(nextUrl) + } else { + window.open(nextUrl, '_blank') + } + } + + const metaLabel = + kind === 'red-packet' ? '红包' + : kind === 'finder' ? (finderName || '视频号') + : kind === 'location' ? '位置' + : kind === 'music' ? (sourceName || appName || '音乐') + : (sourceName || appName || (sourceUsername.startsWith('gh_') ? '公众号' : '')) + + const renderCard = (cardKind: string, clickableUrl?: string) => ( +
openExternal(e, clickableUrl) : undefined} + title={clickableUrl} + > +
+
{title}
+ {metaLabel ?
{metaLabel}
: null} +
+
+
+ {desc ?
{desc}
: null} +
+ {thumbUrl ? ( + + ) : ( +
{cardKind.slice(0, 2).toUpperCase()}
+ )} +
+
+ ) + + if (kind === 'red-packet') { + // 专属红包卡片 + const greeting = (() => { + try { + const d = getDoc() + if (!d) return '' + return d.querySelector('receivertitle')?.textContent?.trim() || + d.querySelector('sendertitle')?.textContent?.trim() || '' + } catch { return '' } + })() + return ( +
+
+ + + + + ¥ + +
+
+
{greeting || '恭喜发财,大吉大利'}
+
微信红包
+
+
+ ) + } + + if (kind === 'gift') { + // 礼物卡片 + const giftImg = message.giftImageUrl || thumbUrl + const giftWish = message.giftWish || title || '送你一份心意' + const giftPriceRaw = message.giftPrice + const giftPriceYuan = giftPriceRaw ? (parseInt(giftPriceRaw) / 100).toFixed(2) : '' + return ( +
+ {giftImg && } +
+
{giftWish}
+ {giftPriceYuan &&
¥{giftPriceYuan}
} +
微信礼物
+
+
+ ) + } + + if (kind === 'finder') { + // 视频号专属卡片 + const coverUrl = message.finderCoverUrl || thumbUrl + const duration = message.finderDuration + const authorName = finderName || '' + const authorAvatar = message.finderAvatar + const fmtDuration = duration ? `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, '0')}` : '' + return ( +
openExternal(e, url) : undefined}> +
+ {coverUrl ? ( + + ) : ( +
+ + + +
+ )} + {fmtDuration && {fmtDuration}} +
+
+
{displayTitle || '视频号视频'}
+
+ {authorAvatar && } + {authorName || '视频号'} +
+
+
+ ) + } + + + + if (kind === 'music') { + // 音乐专属卡片 + const albumUrl = message.musicAlbumUrl || thumbUrl + const playUrl = message.musicUrl || musicUrl || url + const songTitle = title || '未知歌曲' + const artist = desc || '' + const appLabel = sourceName || appName || '' + return ( +
openExternal(e, playUrl) : undefined}> +
+ {albumUrl ? ( + + ) : ( + + + + )} +
+
+
{songTitle}
+ {artist &&
{artist}
} + {appLabel &&
{appLabel}
} +
+
+ ) + } + + if (kind === 'official-link') { + const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || message.cardAvatarUrl + const authorName = q('publisher > nickname') || sourceName || appName || '公众号' + const coverPic = q('mmreader > category > item > cover') || thumbUrl + const digest = q('mmreader > category > item > digest') || desc + const articleTitle = q('mmreader > category > item > title') || title + + return ( +
openExternal(e, url) : undefined}> +
+ {authorAvatar ? ( + + ) : ( +
+ + + + +
+ )} + {authorName} +
+
+ {coverPic ? ( +
+ +
{articleTitle}
+
+ ) : ( +
{articleTitle}
+ )} + {digest &&
{digest}
} +
+
+ ) + } + + if (kind === 'link') return renderCard('link', url || undefined) + if (kind === 'card') return renderCard('card', url || undefined) + if (kind === 'miniapp') { + return ( +
+
+ + + +
+
+
{title}
+
{metaLabel || '小程序'}
+
+ {thumbUrl ? ( + + ) : null} +
+ ) + } + return null + })() + + if (appMsgRichPreview) { + return appMsgRichPreview + } + const isAppMsg = message.rawContent?.includes(' Date: Wed, 25 Feb 2026 17:26:45 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 15 ++++++++++----- src/pages/ChatPage.tsx | 20 +++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 99fd618..d44b3fd 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1047,6 +1047,7 @@ cursor: pointer; transition: all 0.2s ease; border: 1px solid var(--border-color); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); &:hover { background: var(--bg-hover); @@ -3107,7 +3108,7 @@ .chat-record-message, .miniapp-message, .appmsg-rich-card { - background: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.25); .card-name, .miniapp-title, @@ -3618,11 +3619,12 @@ align-items: center; gap: 12px; padding: 12px; - background: var(--bg-primary); // 添加底色 + background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); &:hover { background: var(--bg-hover); @@ -3672,12 +3674,13 @@ // 聊天记录消息外观 .chat-record-message { - background: var(--bg-primary); // 添加底色 + background: var(--card-bg) !important; border: 1px solid var(--border-color); border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); &:hover { - background: var(--bg-hover); + background: var(--bg-hover) !important; } .chat-record-list { @@ -3710,13 +3713,15 @@ .official-message { display: flex; flex-direction: column; - background: var(--bg-primary); + background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; cursor: pointer; transition: all 0.2s ease; + min-width: 240px; max-width: 320px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); &:hover { background: var(--bg-hover); diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b5c41fe..b8192c9 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -3987,8 +3987,12 @@ function MessageBubble({ const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) + const zoom = 15 + const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom)) + const latRad = lat * Math.PI / 180 + const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom)) const mapTileUrl = (lat && lng) - ? `https://restapi.amap.com/v3/staticmap?location=${lng},${lat}&zoom=15&size=280*100&markers=mid,,A:${lng},${lat}&key=e1dedc6bfbb8413ab2185e7a0e21f0a1` + ? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}` : '' return (
window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}> @@ -4039,6 +4043,7 @@ function MessageBubble({ const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl') const sourceName = message.appMsgSourceName || q('sourcename') + const sourceDisplayName = q('sourcedisplayname') || '' const appName = message.appMsgAppName || q('appname') const sourceUsername = message.appMsgSourceUsername || q('sourceusername') const finderName = @@ -4066,8 +4071,13 @@ function MessageBubble({ // 对视频号提取真实标题,避免出现 "当前版本不支持该内容" let displayTitle = title - if (kind === 'finder' && title.includes('不支持')) { - displayTitle = desc || '' + if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) { + try { + const d = new DOMParser().parseFromString(rawXml, 'text/xml') + displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || '' + } catch { + displayTitle = desc || '' + } } const openExternal = (e: React.MouseEvent, nextUrl?: string) => { @@ -4224,8 +4234,8 @@ function MessageBubble({ } if (kind === 'official-link') { - const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || message.cardAvatarUrl - const authorName = q('publisher > nickname') || sourceName || appName || '公众号' + const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || q('headimgurl') || message.cardAvatarUrl + const authorName = sourceDisplayName || q('publisher > nickname') || sourceName || appName || '公众号' const coverPic = q('mmreader > category > item > cover') || thumbUrl const digest = q('mmreader > category > item > digest') || desc const articleTitle = q('mmreader > category > item > title') || title From 9585a0295981ab8e11e8c3a11ffc805d6198b316 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 17:59:42 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=8F=E6=98=8E?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 49 ++++++++++++++++++++++++++++++----------- src/styles/main.scss | 22 ++++++++++++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index d44b3fd..abd6368 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1041,7 +1041,7 @@ // 链接卡片消息样式 .link-message { width: 280px; - background: var(--card-bg); + background: var(--card-inner-bg); border-radius: 8px; overflow: hidden; cursor: pointer; @@ -1167,15 +1167,24 @@ // 适配发送出去的消息中的链接卡片 .message-bubble.sent .link-message { - background: var(--card-bg); - border: 1px solid var(--border-color); + background: var(--sent-card-bg); + border: 1px solid rgba(255, 255, 255, 0.15); + + &:hover { + background: var(--primary-hover); + border-color: rgba(255, 255, 255, 0.25); + } .link-title { - color: var(--text-primary); + color: white; } .link-desc { - color: var(--text-secondary); + color: rgba(255, 255, 255, 0.8); + } + + .appmsg-url-line { + color: rgba(255, 255, 255, 0.6); } } @@ -1258,7 +1267,7 @@ // 视频号卡片 .channel-video-card { width: 200px; - background: var(--card-bg); + background: var(--card-inner-bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border-color); @@ -1403,7 +1412,7 @@ // 位置消息卡片 .location-message { width: 240px; - background: var(--card-bg); + background: var(--card-inner-bg); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; @@ -1847,6 +1856,20 @@ } } +// 卡片类消息:气泡变透明,让卡片自己做视觉容器 +.message-bubble .bubble-content:has(.link-message), +.message-bubble .bubble-content:has(.card-message), +.message-bubble .bubble-content:has(.chat-record-message), +.message-bubble .bubble-content:has(.official-message), +.message-bubble .bubble-content:has(.channel-video-card), +.message-bubble .bubble-content:has(.location-message) { + background: transparent !important; + padding: 0 !important; + border: none !important; + box-shadow: none !important; + backdrop-filter: none !important; +} + .emoji-image { max-width: 120px; max-height: 120px; @@ -2823,7 +2846,7 @@ align-items: center; gap: 12px; padding: 12px 14px; - background: var(--card-bg); + background: var(--card-inner-bg); border: 1px solid var(--border-color); border-radius: 8px; min-width: 200px; @@ -2859,7 +2882,7 @@ // 聊天记录消息 (合并转发) .chat-record-message { - background: var(--card-bg) !important; + background: var(--card-inner-bg) !important; border: 1px solid var(--border-color) !important; transition: opacity 0.2s ease; cursor: pointer; @@ -3108,7 +3131,7 @@ .chat-record-message, .miniapp-message, .appmsg-rich-card { - background: rgba(255, 255, 255, 0.25); + background: var(--sent-card-bg); .card-name, .miniapp-title, @@ -3619,7 +3642,7 @@ align-items: center; gap: 12px; padding: 12px; - background: var(--card-bg); + background: var(--card-inner-bg); border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; @@ -3674,7 +3697,7 @@ // 聊天记录消息外观 .chat-record-message { - background: var(--card-bg) !important; + background: var(--card-inner-bg) !important; border: 1px solid var(--border-color); border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); @@ -3713,7 +3736,7 @@ .official-message { display: flex; flex-direction: column; - background: var(--card-bg); + background: var(--card-inner-bg); border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; diff --git a/src/styles/main.scss b/src/styles/main.scss index 88324e6..9f81ffd 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -37,6 +37,8 @@ // 卡片背景 --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #FAFAF7; + --sent-card-bg: var(--primary); } // ==================== 浅色主题 ==================== @@ -59,6 +61,8 @@ --bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%); --primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #FAFAF7; + --sent-card-bg: var(--primary); } // 刚玉蓝主题 @@ -79,6 +83,8 @@ --bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%); --primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #F8FAFB; + --sent-card-bg: var(--primary); } // 冰猕猴桃汁绿主题 @@ -99,6 +105,8 @@ --bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%); --primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #F8FBF6; + --sent-card-bg: var(--primary); } // 辛辣红主题 @@ -119,6 +127,8 @@ --bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%); --primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #FAF8F8; + --sent-card-bg: var(--primary); } // 明水鸭色主题 @@ -139,6 +149,8 @@ --bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%); --primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%); --card-bg: rgba(255, 255, 255, 0.7); + --card-inner-bg: #F6FBFB; + --sent-card-bg: var(--primary); } // ==================== 深色主题 ==================== @@ -160,6 +172,8 @@ --bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%); --primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%); --card-bg: rgba(40, 36, 32, 0.9); + --card-inner-bg: #27231F; + --sent-card-bg: var(--primary); } // 刚玉蓝 - 深色 @@ -179,6 +193,8 @@ --bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%); --primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%); --card-bg: rgba(30, 40, 44, 0.9); + --card-inner-bg: #1D272A; + --sent-card-bg: var(--primary); } // 冰猕猴桃汁绿 - 深色 @@ -198,6 +214,8 @@ --bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%); --primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%); --card-bg: rgba(34, 42, 30, 0.9); + --card-inner-bg: #21281D; + --sent-card-bg: var(--primary); } // 辛辣红 - 深色 @@ -217,6 +235,8 @@ --bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%); --primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%); --card-bg: rgba(42, 32, 34, 0.9); + --card-inner-bg: #281F21; + --sent-card-bg: var(--primary); } // 明水鸭色 - 深色 @@ -236,6 +256,8 @@ --bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%); --primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%); --card-bg: rgba(28, 42, 42, 0.9); + --card-inner-bg: #1B2828; + --sent-card-bg: var(--primary); } // 重置样式 From 49d951e96afea9cb74b825ed6c03e503b1aef3ae Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 25 Feb 2026 18:01:27 +0800 Subject: [PATCH 9/9] 1 --- .agents/skills/weflow-overview-sync/SKILL.md | 32 -------------------- .gitignore | 3 +- 2 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 .agents/skills/weflow-overview-sync/SKILL.md diff --git a/.agents/skills/weflow-overview-sync/SKILL.md b/.agents/skills/weflow-overview-sync/SKILL.md deleted file mode 100644 index c8d8b11..0000000 --- a/.agents/skills/weflow-overview-sync/SKILL.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: weflow-overview-sync -description: Keep the WeFlow architecture overview document synchronized with code and interface changes. Use when editing WeFlow source files, Electron services, IPC contracts, DB access logic, export and analytics flows, or related docs that affect architecture, fields, or data paths. ---- - -# WeFlow Overview Sync - -## Workflow - -1. Read the architecture overview markdown at repo root before any WeFlow edit. -2. Identify touched files and impacted concepts (module, interface, data flow, field definition, export behavior). -3. Update the overview document in the same task when affected items are already documented. -4. Add a new subsection in the overview document when the requested change is not documented yet. -5. Preserve the existing formatting style of the overview document before finalizing: -- Keep heading hierarchy and numbering style consistent. -- Keep concise wording and use `-` list markers. -- Wrap file paths, APIs, and field names in backticks. -- Place new content in the logically matching section. -6. Re-check the overview document for format consistency and architecture accuracy before replying. - -## Update Rules - -- Update existing sections when they already cover the changed files or interfaces. -- Add missing coverage when new modules, IPC methods, SQL fields, or service flows appear. -- Avoid broad rewrites; apply focused edits that keep the document stable and scannable. -- Reflect any renamed path, API, or field immediately to prevent architecture drift. - -## Collaboration and UI Rules - -- If unrelated additions from other collaborators appear in files you edit, leave them as-is and focus only on the current task scope. -- For dropdown menu UI design, inspect and follow existing in-app dropdown patterns; do not use native browser dropdown styles. -- Do not use native styles for frontend UI design; implement consistent custom-styled components aligned with the product's existing visual system. diff --git a/.gitignore b/.gitignore index d1425df..ce31e26 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,5 @@ wcdb/ chatlab-format.md *.bak AGENTS.md -.claude/ \ No newline at end of file +.claude/ +.agents/ \ No newline at end of file