mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
Merge branch 'hicccc77:dev' into dev
This commit is contained in:
@@ -660,17 +660,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
|||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
webSecurity: false // 允许加载本地文件
|
webSecurity: false // 允许加载本地文件
|
||||||
},
|
},
|
||||||
titleBarStyle: 'hidden',
|
frame: false,
|
||||||
titleBarOverlay: {
|
|
||||||
color: '#00000000',
|
|
||||||
symbolColor: '#ffffff',
|
|
||||||
height: 40
|
|
||||||
},
|
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: '#000000',
|
backgroundColor: '#000000',
|
||||||
autoHideMenuBar: true
|
autoHideMenuBar: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setupCustomTitleBarWindow(win)
|
||||||
|
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
win.show()
|
win.show()
|
||||||
})
|
})
|
||||||
@@ -975,6 +972,17 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('log:clear', async () => {
|
||||||
|
try {
|
||||||
|
const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
|
||||||
|
await mkdir(dirname(logPath), { recursive: true })
|
||||||
|
await writeFile(logPath, '', 'utf8')
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
|
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
|
||||||
return exportCardDiagnosticsService.snapshot(options?.limit)
|
return exportCardDiagnosticsService.snapshot(options?.limit)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
log: {
|
log: {
|
||||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
getPath: () => ipcRenderer.invoke('log:getPath'),
|
||||||
read: () => ipcRenderer.invoke('log:read'),
|
read: () => ipcRenderer.invoke('log:read'),
|
||||||
|
clear: () => ipcRenderer.invoke('log:clear'),
|
||||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -359,8 +359,9 @@ class ChatService {
|
|||||||
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
||||||
wcdbService.setMonitor((type, json) => {
|
wcdbService.setMonitor((type, json) => {
|
||||||
this.handleSessionStatsMonitorChange(type, json)
|
this.handleSessionStatsMonitorChange(type, json)
|
||||||
|
const windows = BrowserWindow.getAllWindows()
|
||||||
// 广播给所有渲染进程窗口
|
// 广播给所有渲染进程窗口
|
||||||
BrowserWindow.getAllWindows().forEach((win) => {
|
windows.forEach((win) => {
|
||||||
if (!win.isDestroyed()) {
|
if (!win.isDestroyed()) {
|
||||||
win.webContents.send('wcdb-change', { type, json })
|
win.webContents.send('wcdb-change', { type, json })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class KeyServiceMac {
|
|||||||
private machVmRegion: any = null
|
private machVmRegion: any = null
|
||||||
private machVmReadOverwrite: any = null
|
private machVmReadOverwrite: any = null
|
||||||
private machPortDeallocate: any = null
|
private machPortDeallocate: any = null
|
||||||
|
private _needsElevation = false
|
||||||
|
|
||||||
private getHelperPath(): string {
|
private getHelperPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
@@ -49,6 +50,26 @@ export class KeyServiceMac {
|
|||||||
throw new Error('xkey_helper not found')
|
throw new Error('xkey_helper not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getImageScanHelperPath(): string {
|
||||||
|
const isPackaged = app.isPackaged
|
||||||
|
const candidates: string[] = []
|
||||||
|
|
||||||
|
if (isPackaged) {
|
||||||
|
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
|
||||||
|
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
||||||
|
} else {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
|
||||||
|
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
if (existsSync(path)) return path
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('image_scan_helper not found')
|
||||||
|
}
|
||||||
|
|
||||||
private getDylibPath(): string {
|
private getDylibPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
@@ -258,7 +279,7 @@ export class KeyServiceMac {
|
|||||||
stdout += data
|
stdout += data
|
||||||
stdoutBuf += data
|
stdoutBuf += data
|
||||||
const parts = stdoutBuf.split(/\r?\n/)
|
const parts = stdoutBuf.split(/\r?\n/)
|
||||||
stdoutBuf = parts.pop() || ''
|
stdoutBuf = parts.pop()!
|
||||||
})
|
})
|
||||||
|
|
||||||
child.stderr.on('data', (chunk: Buffer | string) => {
|
child.stderr.on('data', (chunk: Buffer | string) => {
|
||||||
@@ -266,7 +287,7 @@ export class KeyServiceMac {
|
|||||||
stderr += data
|
stderr += data
|
||||||
stderrBuf += data
|
stderrBuf += data
|
||||||
const parts = stderrBuf.split(/\r?\n/)
|
const parts = stderrBuf.split(/\r?\n/)
|
||||||
stderrBuf = parts.pop() || ''
|
stderrBuf = parts.pop()!
|
||||||
for (const line of parts) processHelperLine(line.trim())
|
for (const line of parts) processHelperLine(line.trim())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -337,13 +358,13 @@ export class KeyServiceMac {
|
|||||||
const result = await execFileAsync('osascript', scriptLines.flatMap(line => ['-e', line]), {
|
const result = await execFileAsync('osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||||
timeout: waitMs + 20_000
|
timeout: waitMs + 20_000
|
||||||
})
|
})
|
||||||
stdout = result.stdout || ''
|
stdout = result.stdout
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim()
|
const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim()
|
||||||
throw new Error(msg || 'elevated helper execution failed')
|
throw new Error(msg || 'elevated helper execution failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = String(stdout || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
||||||
const last = lines[lines.length - 1]
|
const last = lines[lines.length - 1]
|
||||||
if (!last) throw new Error('elevated helper returned empty output')
|
if (!last) throw new Error('elevated helper returned empty output')
|
||||||
|
|
||||||
@@ -614,6 +635,32 @@ export class KeyServiceMac {
|
|||||||
ciphertext: Buffer,
|
ciphertext: Buffer,
|
||||||
onProgress?: (message: string) => void
|
onProgress?: (message: string) => void
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
// 优先通过 image_scan_helper 子进程调用
|
||||||
|
try {
|
||||||
|
const helperPath = this.getImageScanHelperPath()
|
||||||
|
const ciphertextHex = ciphertext.toString('hex')
|
||||||
|
|
||||||
|
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
||||||
|
if (!this._needsElevation) {
|
||||||
|
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
|
||||||
|
if (direct.key) return direct.key
|
||||||
|
if (direct.permissionError) {
|
||||||
|
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
||||||
|
this._needsElevation = true
|
||||||
|
onProgress?.('需要管理员权限,请在弹出的对话框中输入密码...')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
||||||
|
if (this._needsElevation) {
|
||||||
|
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
|
||||||
|
if (elevated.key) return elevated.key
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[KeyServiceMac] image_scan_helper unavailable, fallback to Mach API:', e?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: 直接通过 Mach API 扫描内存(Electron 进程可能没有 task_for_pid 权限)
|
||||||
if (!this.ensureMachApis()) return null
|
if (!this.ensureMachApis()) return null
|
||||||
|
|
||||||
const VM_PROT_READ = 0x1
|
const VM_PROT_READ = 0x1
|
||||||
@@ -708,6 +755,45 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _spawnScanHelper(
|
||||||
|
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
|
||||||
|
): Promise<{ key: string | null; permissionError: boolean }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let child: ReturnType<typeof spawn>
|
||||||
|
if (elevated) {
|
||||||
|
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
|
||||||
|
child = spawn('osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
||||||
|
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
} else {
|
||||||
|
child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
}
|
||||||
|
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
|
||||||
|
let stdout = '', stderr = ''
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
console.log(tag, chunk.toString().trim())
|
||||||
|
})
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('close', () => {
|
||||||
|
const permissionError = !elevated && stderr.includes('task_for_pid failed')
|
||||||
|
try {
|
||||||
|
const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
||||||
|
const last = lines[lines.length - 1]
|
||||||
|
if (!last) { resolve({ key: null, permissionError }); return }
|
||||||
|
const payload = JSON.parse(last)
|
||||||
|
resolve({
|
||||||
|
key: payload?.success && payload?.aesKey ? payload.aesKey : null,
|
||||||
|
permissionError
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
resolve({ key: null, permissionError })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setTimeout(() => { try { child.kill('SIGTERM') } catch {} }, elevated ? 60_000 : 30_000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async findWeChatPid(): Promise<number | null> {
|
private async findWeChatPid(): Promise<number | null> {
|
||||||
const { execSync } = await import('child_process')
|
const { execSync } = await import('child_process')
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -173,7 +173,6 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connectMonitorPipe(pipePath)
|
this.connectMonitorPipe(pipePath)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -190,13 +189,18 @@ export class WcdbCore {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.monitorCallback) return
|
if (!this.monitorCallback) return
|
||||||
|
|
||||||
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
|
||||||
})
|
|
||||||
|
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
this.monitorPipeClient.on('data', (data: Buffer) => {
|
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||||
buffer += data.toString('utf8')
|
const rawChunk = data.toString('utf8')
|
||||||
const lines = buffer.split('\n')
|
// macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包
|
||||||
|
const normalizedChunk = rawChunk
|
||||||
|
.replace(/\u0000/g, '\n')
|
||||||
|
.replace(/}\s*{/g, '}\n{')
|
||||||
|
|
||||||
|
buffer += normalizedChunk
|
||||||
|
const lines = buffer.split(/\r?\n/)
|
||||||
buffer = lines.pop() || ''
|
buffer = lines.pop() || ''
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
@@ -208,9 +212,22 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兜底:如果没有分隔符但已形成完整 JSON,则直接上报
|
||||||
|
const tail = buffer.trim()
|
||||||
|
if (tail.startsWith('{') && tail.endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(tail)
|
||||||
|
this.monitorCallback?.(parsed.action || 'update', tail)
|
||||||
|
buffer = ''
|
||||||
|
} catch {
|
||||||
|
// 不可解析则继续等待下一块数据
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.monitorPipeClient.on('error', () => {
|
this.monitorPipeClient.on('error', () => {
|
||||||
|
// 保持静默,与现有错误处理策略一致
|
||||||
})
|
})
|
||||||
|
|
||||||
this.monitorPipeClient.on('close', () => {
|
this.monitorPipeClient.on('close', () => {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setMonitor(callback: (type: string, json: string) => void): void {
|
setMonitor(callback: (type: string, json: string) => void): void {
|
||||||
this.monitorListener = callback;
|
this.monitorListener = callback;
|
||||||
this.callWorker('setMonitor').catch(() => { });
|
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,15 +20,17 @@ if (parentPort) {
|
|||||||
result = { success: true }
|
result = { success: true }
|
||||||
break
|
break
|
||||||
case 'setMonitor':
|
case 'setMonitor':
|
||||||
core.setMonitor((type, json) => {
|
{
|
||||||
|
const monitorOk = core.setMonitor((type, json) => {
|
||||||
parentPort!.postMessage({
|
parentPort!.postMessage({
|
||||||
id: -1,
|
id: -1,
|
||||||
type: 'monitor',
|
type: 'monitor',
|
||||||
payload: { type, json }
|
payload: { type, json }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
result = { success: true }
|
result = { success: monitorOk }
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'testConnection':
|
case 'testConnection':
|
||||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
break
|
break
|
||||||
|
|||||||
10
resources/image_scan_entitlements.plist
Normal file
10
resources/image_scan_entitlements.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.debugger</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
resources/image_scan_helper
Executable file
BIN
resources/image_scan_helper
Executable file
Binary file not shown.
77
resources/image_scan_helper.c
Normal file
77
resources/image_scan_helper.c
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* image_scan_helper - 轻量包装程序
|
||||||
|
* 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey
|
||||||
|
* 用法: image_scan_helper <pid> <ciphertext_hex>
|
||||||
|
* 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."}
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <libgen.h>
|
||||||
|
#include <mach-o/dyld.h>
|
||||||
|
|
||||||
|
typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext);
|
||||||
|
typedef void (*FreeStringFn)(const char* str);
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
if (argc != 3) {
|
||||||
|
fprintf(stderr, "Usage: %s <pid> <ciphertext_hex>\n", argv[0]);
|
||||||
|
printf("{\"success\":false,\"error\":\"invalid arguments\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pid = atoi(argv[1]);
|
||||||
|
const char* ciphertext_hex = argv[2];
|
||||||
|
|
||||||
|
if (pid <= 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"invalid pid\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定位 dylib: 与自身同目录下的 libwx_key.dylib */
|
||||||
|
char exe_path[4096];
|
||||||
|
uint32_t size = sizeof(exe_path);
|
||||||
|
if (_NSGetExecutablePath(exe_path, &size) != 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
char* dir = dirname(exe_path);
|
||||||
|
char dylib_path[4096];
|
||||||
|
snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir);
|
||||||
|
|
||||||
|
void* handle = dlopen(dylib_path, RTLD_LAZY);
|
||||||
|
if (!handle) {
|
||||||
|
printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey");
|
||||||
|
if (!scan_fn) {
|
||||||
|
printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n");
|
||||||
|
dlclose(handle);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString");
|
||||||
|
|
||||||
|
fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex);
|
||||||
|
|
||||||
|
const char* result = scan_fn(pid, ciphertext_hex);
|
||||||
|
|
||||||
|
if (result && strlen(result) > 0) {
|
||||||
|
/* 检查是否是错误 */
|
||||||
|
if (strncmp(result, "ERROR", 5) == 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"%s\"}\n", result);
|
||||||
|
} else {
|
||||||
|
printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result);
|
||||||
|
}
|
||||||
|
if (free_fn) free_fn(result);
|
||||||
|
} else {
|
||||||
|
printf("{\"success\":false,\"error\":\"no key found\"}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
dlclose(handle);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -46,7 +46,6 @@ export function GlobalSessionMonitor() {
|
|||||||
return () => {
|
return () => {
|
||||||
removeListener()
|
removeListener()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
return () => { }
|
return () => { }
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-sidebar-toggle {
|
.title-sidebar-toggle {
|
||||||
@@ -90,3 +92,55 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-right: auto;
|
||||||
|
padding-left: 16px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.live-play-btn.active {
|
||||||
|
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
|
||||||
|
color: var(--primary, #4c84ff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-text {
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ interface TitleBarProps {
|
|||||||
sidebarCollapsed?: boolean
|
sidebarCollapsed?: boolean
|
||||||
onToggleSidebar?: () => void
|
onToggleSidebar?: () => void
|
||||||
showWindowControls?: boolean
|
showWindowControls?: boolean
|
||||||
|
customControls?: React.ReactNode
|
||||||
|
showLogo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function TitleBar({
|
function TitleBar({
|
||||||
title,
|
title,
|
||||||
sidebarCollapsed = false,
|
sidebarCollapsed = false,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
showWindowControls = true
|
showWindowControls = true,
|
||||||
|
customControls,
|
||||||
|
showLogo = true
|
||||||
}: TitleBarProps = {}) {
|
}: TitleBarProps = {}) {
|
||||||
const [isMaximized, setIsMaximized] = useState(false)
|
const [isMaximized, setIsMaximized] = useState(false)
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@ function TitleBar({
|
|||||||
return (
|
return (
|
||||||
<div className="title-bar">
|
<div className="title-bar">
|
||||||
<div className="title-brand">
|
<div className="title-brand">
|
||||||
<img src="./logo.png" alt="WeFlow" className="title-logo" />
|
{showLogo && <img src="./logo.png" alt="WeFlow" className="title-logo" />}
|
||||||
<span className="titles">{title || 'WeFlow'}</span>
|
<span className="titles">{title || 'WeFlow'}</span>
|
||||||
{onToggleSidebar ? (
|
{onToggleSidebar ? (
|
||||||
<button
|
<button
|
||||||
@@ -46,6 +50,7 @@ function TitleBar({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{customControls}
|
||||||
{showWindowControls ? (
|
{showWindowControls ? (
|
||||||
<div className="title-window-controls">
|
<div className="title-window-controls">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
.update-dialog {
|
.update-dialog {
|
||||||
width: 680px;
|
width: 680px;
|
||||||
background: #f5f5f5;
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
/* Top Section (White/Gradient) */
|
/* Top Section (White/Gradient) */
|
||||||
.dialog-header {
|
.dialog-header {
|
||||||
background: #ffffff;
|
background: var(--bg-primary, #ffffff);
|
||||||
padding: 40px 20px 30px;
|
padding: 40px 20px 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -41,14 +41,14 @@
|
|||||||
left: -50px;
|
left: -50px;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%);
|
background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%);
|
||||||
opacity: 0.8;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-tag {
|
.version-tag {
|
||||||
background: #f0eee9;
|
background: var(--bg-tertiary, #f0eee9);
|
||||||
color: #8c7b6e;
|
color: var(--text-tertiary, #8c7b6e);
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -60,21 +60,21 @@
|
|||||||
h2 {
|
h2 {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #333333;
|
color: var(--text-primary, #333333);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
letter-spacing: -0.5px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #999999;
|
color: var(--text-secondary, #999999);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content Section (Light Gray) */
|
/* Content Section (Light Gray) */
|
||||||
.dialog-content {
|
.dialog-content {
|
||||||
background: #f2f2f2;
|
background: var(--bg-tertiary, #f2f2f2);
|
||||||
padding: 24px 40px 40px;
|
padding: 24px 40px 40px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
|
||||||
.icon-box {
|
.icon-box {
|
||||||
background: #fbfbfb; // Beige-ish white
|
background: var(--bg-primary, #fbfbfb);
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #8c7b6e;
|
color: var(--text-tertiary, #8c7b6e);
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@@ -107,27 +107,38 @@
|
|||||||
.text-box {
|
.text-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
h3 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-size: 18px;
|
color: var(--text-primary, #333333);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #333333;
|
margin: 16px 0 8px;
|
||||||
margin: 0 0 8px;
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666666;
|
color: var(--text-secondary, #666666);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
margin: 8px 0 0 18px;
|
margin: 4px 0 0 18px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666666;
|
color: var(--text-secondary, #666666);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,19 +153,19 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: var(--text-secondary, #888);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-bg {
|
.progress-bar-bg {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: #e0e0e0;
|
background: var(--border-color, #e0e0e0);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #000000;
|
background: var(--text-primary, #000000);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -164,7 +175,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #666;
|
color: var(--text-secondary, #666);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +186,8 @@
|
|||||||
|
|
||||||
.btn-ignore {
|
.btn-ignore {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #666666;
|
color: var(--text-secondary, #666666);
|
||||||
border: 1px solid #d0d0d0;
|
border: 1px solid var(--border-color, #d0d0d0);
|
||||||
padding: 16px 32px;
|
padding: 16px 32px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -185,9 +196,9 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f5f5f5;
|
background: var(--bg-hover, #f5f5f5);
|
||||||
border-color: #999999;
|
border-color: var(--text-secondary, #999999);
|
||||||
color: #333333;
|
color: var(--text-primary, #333333);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@@ -196,11 +207,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-update {
|
.btn-update {
|
||||||
background: #000000;
|
background: var(--text-primary, #000000);
|
||||||
color: #ffffff;
|
color: var(--bg-primary, #ffffff);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 16px 48px;
|
padding: 16px 48px;
|
||||||
border-radius: 20px; // Pill shape
|
border-radius: 20px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -231,7 +242,7 @@
|
|||||||
right: 16px;
|
right: 16px;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
border: none;
|
border: none;
|
||||||
color: #999;
|
color: var(--text-secondary, #999);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -244,7 +255,7 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
color: #333;
|
color: var(--text-primary, #333);
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
<Quote size={20} />
|
<Quote size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-box">
|
<div className="text-box">
|
||||||
<h3>优化</h3>
|
|
||||||
{updateInfo.releaseNotes ? (
|
{updateInfo.releaseNotes ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
|
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4442,18 +4442,23 @@
|
|||||||
|
|
||||||
// 折叠群入口样式
|
// 折叠群入口样式
|
||||||
.session-item.fold-entry {
|
.session-item.fold-entry {
|
||||||
background: var(--card-inner-bg, rgba(0,0,0,0.03));
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg, rgba(0,0,0,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
.fold-entry-avatar {
|
.fold-entry-avatar {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--primary-color, #07c160);
|
background: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #fff;
|
color: #fa9d3b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-name {
|
.session-name {
|
||||||
|
|||||||
@@ -356,18 +356,19 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
if (isFoldEntry) {
|
if (isFoldEntry) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`session-item fold-entry`}
|
className={`session-item fold-entry ${isActive ? 'active' : ''}`}
|
||||||
onClick={() => onSelect(session)}
|
onClick={() => onSelect(session)}
|
||||||
>
|
>
|
||||||
<div className="fold-entry-avatar">
|
<div className="fold-entry-avatar">
|
||||||
<FolderClosed size={22} />
|
<MessageSquare size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div className="session-info">
|
<div className="session-info">
|
||||||
<div className="session-top">
|
<div className="session-top">
|
||||||
<span className="session-name">折叠的群聊</span>
|
<span className="session-name">折叠的聊天</span>
|
||||||
|
<span className="session-time">{timeText}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="session-bottom">
|
<div className="session-bottom">
|
||||||
<span className="session-summary">{session.summary || ''}</span>
|
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2966,10 +2967,51 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setFilteredSessions([])
|
setFilteredSessions([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const visible = sessions.filter(s => {
|
|
||||||
|
// 检查是否有折叠的群聊
|
||||||
|
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||||
|
const hasFoldedGroups = foldedGroups.length > 0
|
||||||
|
|
||||||
|
let visible = sessions.filter(s => {
|
||||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 如果有折叠的群聊,但列表中没有入口,则插入入口
|
||||||
|
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
||||||
|
// 找到最新的折叠消息
|
||||||
|
const latestFolded = foldedGroups.reduce((latest, current) => {
|
||||||
|
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||||
|
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||||
|
return currentTime > latestTime ? current : latest
|
||||||
|
})
|
||||||
|
|
||||||
|
const foldEntry: ChatSession = {
|
||||||
|
username: 'placeholder_foldgroup',
|
||||||
|
displayName: '折叠的聊天',
|
||||||
|
summary: `${latestFolded.displayName || latestFolded.username}: ${latestFolded.summary}`,
|
||||||
|
type: 0,
|
||||||
|
sortTimestamp: latestFolded.sortTimestamp || latestFolded.lastTimestamp,
|
||||||
|
lastTimestamp: latestFolded.lastTimestamp || latestFolded.sortTimestamp,
|
||||||
|
lastMsgType: 0,
|
||||||
|
unreadCount: foldedGroups.reduce((sum, s) => sum + (s.unreadCount || 0), 0),
|
||||||
|
isMuted: false,
|
||||||
|
isFolded: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按时间戳插入到正确位置
|
||||||
|
const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp
|
||||||
|
const insertIndex = visible.findIndex(s => {
|
||||||
|
const sTime = s.sortTimestamp || s.lastTimestamp
|
||||||
|
return sTime < foldTime
|
||||||
|
})
|
||||||
|
if (insertIndex === -1) {
|
||||||
|
visible.push(foldEntry)
|
||||||
|
} else {
|
||||||
|
visible.splice(insertIndex, 0, foldEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
setFilteredSessions(visible)
|
setFilteredSessions(visible)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,76 +7,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
.title-bar {
|
|
||||||
height: 40px;
|
|
||||||
min-height: 40px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
padding-right: 140px; // 为原生窗口控件留出空间
|
|
||||||
|
|
||||||
.window-drag-area {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-bar-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
margin-right: 16px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.live-play-btn {
|
|
||||||
&.active {
|
|
||||||
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
|
|
||||||
color: var(--primary, #4c84ff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scale-text {
|
|
||||||
min-width: 50px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 14px;
|
|
||||||
background: var(--border-color);
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-viewport {
|
.image-viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
||||||
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||||
|
import TitleBar from '../components/TitleBar'
|
||||||
import './ImageWindow.scss'
|
import './ImageWindow.scss'
|
||||||
|
|
||||||
export default function ImageWindow() {
|
export default function ImageWindow() {
|
||||||
@@ -207,31 +208,35 @@ export default function ImageWindow() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="image-window-container">
|
<div className="image-window-container">
|
||||||
<div className="title-bar">
|
<TitleBar
|
||||||
<div className="window-drag-area"></div>
|
title="图片查看"
|
||||||
<div className="title-bar-controls">
|
showWindowControls={true}
|
||||||
{hasLiveVideo && (
|
showLogo={false}
|
||||||
<>
|
customControls={
|
||||||
<button
|
<div className="image-controls">
|
||||||
onClick={handlePlayLiveVideo}
|
{hasLiveVideo && (
|
||||||
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
|
<>
|
||||||
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
|
<button
|
||||||
disabled={isPlayingLive}
|
onClick={handlePlayLiveVideo}
|
||||||
>
|
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
|
||||||
<LivePhotoIcon size={16} />
|
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
|
||||||
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
disabled={isPlayingLive}
|
||||||
</button>
|
>
|
||||||
<div className="divider"></div>
|
<LivePhotoIcon size={16} />
|
||||||
</>
|
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||||
)}
|
</button>
|
||||||
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
<div className="divider"></div>
|
||||||
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
</>
|
||||||
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
)}
|
||||||
<div className="divider"></div>
|
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
||||||
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
|
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
||||||
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
|
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
||||||
</div>
|
<div className="divider"></div>
|
||||||
</div>
|
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
|
||||||
|
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="image-viewport"
|
className="image-viewport"
|
||||||
|
|||||||
@@ -897,6 +897,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClearLog = async () => {
|
||||||
|
const confirmed = window.confirm('确定清空 wcdb.log 吗?')
|
||||||
|
if (!confirmed) return
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.log.clear()
|
||||||
|
if (!result.success) {
|
||||||
|
showMessage(result.error || '清空日志失败', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showMessage('日志已清空', true)
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`清空日志失败: ${e}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClearAnalyticsCache = async () => {
|
const handleClearAnalyticsCache = async () => {
|
||||||
if (isClearingCache) return
|
if (isClearingCache) return
|
||||||
setIsClearingAnalyticsCache(true)
|
setIsClearingAnalyticsCache(true)
|
||||||
@@ -1379,15 +1394,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="form-hint" style={{ color: '#f59e0b', margin: '6px 0' }}>
|
|
||||||
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用「内存扫描」方案。
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
<button className="btn btn-primary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
|
||||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高">
|
<button className="btn btn-secondary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
|
||||||
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
{isFetchingImageKey ? '扫描中...' : '内存扫描'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isFetchingImageKey ? (
|
{isFetchingImageKey ? (
|
||||||
@@ -1399,7 +1411,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
) : (
|
) : (
|
||||||
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
|
||||||
)}
|
)}
|
||||||
<span className="form-hint">内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</span>
|
<span className="form-hint">优先推荐缓存计算方案。若图片无法解密,可使用内存扫描(需微信运行并打开 2-3 张图片大图)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1430,6 +1442,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<button className="btn btn-secondary" onClick={handleCopyLog}>
|
<button className="btn btn-secondary" onClick={handleCopyLog}>
|
||||||
<Copy size={16} /> 复制日志内容
|
<Copy size={16} /> 复制日志内容
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={handleClearLog}>
|
||||||
|
<Trash2 size={16} /> 清空日志
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2046,7 +2061,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
||||||
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -780,9 +780,6 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
{currentStep.id === 'image' && (
|
{currentStep.id === 'image' && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="field-hint" style={{ color: '#f59e0b', marginBottom: '12px' }}>
|
|
||||||
⚠️ 快速获取方案基于本地缓存计算,可能因账号信息不匹配而不准确。若图片无法解密,请使用下方「内存扫描」方案。
|
|
||||||
</div>
|
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label">图片 XOR 密钥</label>
|
<label className="field-label">图片 XOR 密钥</label>
|
||||||
@@ -795,11 +792,11 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
|
||||||
<button className="btn btn-secondary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
|
<button className="btn btn-primary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
|
||||||
{isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
|
{isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高,需要微信正在运行">
|
<button className="btn btn-secondary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
|
||||||
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
|
{isFetchingImageKey ? '扫描中...' : '内存扫描'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -813,7 +810,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="field-hint" style={{ marginTop: '8px' }}>内存扫描需要微信正在运行,并在微信中打开 2-3 张图片大图后再点击</div>
|
<div className="field-hint" style={{ marginTop: '8px' }}>优先推荐缓存计算方案。若图片无法解密,可使用内存扫描(需微信运行并打开 2-3 张图片大图)</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -69,6 +69,7 @@ export interface ElectronAPI {
|
|||||||
log: {
|
log: {
|
||||||
getPath: () => Promise<string>
|
getPath: () => Promise<string>
|
||||||
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
||||||
|
clear: () => Promise<{ success: boolean; error?: string }>
|
||||||
debug: (data: any) => void
|
debug: (data: any) => void
|
||||||
}
|
}
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
|
|||||||
Reference in New Issue
Block a user