Compare commits

...

37 Commits

Author SHA1 Message Date
cc
a215886015 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-25 14:25:27 +08:00
cc
1d9e8aded0 feat: 大幅提升语音解密速度;优化引导页面;优化图片密钥扫描逻辑 2026-01-25 14:25:24 +08:00
cc
b7e31c9cff Merge pull request #93 from xunchahaha/dev
Dev
2026-01-25 13:37:57 +08:00
xuncha
4e9c81a93d feat: 导出页面新增群昵称 备注等选择 2026-01-25 10:45:38 +08:00
xuncha
9181ac5d34 feat: ecxel导出支持群昵称显示 2026-01-25 09:40:00 +08:00
xuncha
3a10aeb23e feat:新增了切换账号的功能 (#89) 2026-01-24 12:43:09 +08:00
xuncha
178f9c4fdc Merge branch 'dev' into dev 2026-01-24 12:42:33 +08:00
xuncha
4d647a9467 feat:新增了切换账号的功能 2026-01-24 12:39:20 +08:00
Forrest
16cbc6adb1 Merge pull request #88 from 5xiao0qing5/main
fix:修复打包后html导出渲染失败
2026-01-24 03:40:22 +08:00
QingXiao
7afb872bff Bug Fix:修复打包后html导出渲染失败
Add bundled fallback CSS for HTML export (fix missing styles in builds)
2026-01-24 01:13:02 +08:00
QingXiao
7df6182e70 Fix html export styles fallback 2026-01-24 01:02:11 +08:00
xuncha
40efb04a36 hh (#87) 2026-01-24 00:39:21 +08:00
cc
3efaed488a Merge pull request #82 from 5xiao0qing5/dev
实现 TXT导出 和 HTML导出
2026-01-24 00:25:34 +08:00
QingXiao
decdbf95f7 Merge pull request #9 from 5xiao0qing5/codex/implement-html-export-feature-1g9o7z 2026-01-24 00:19:37 +08:00
QingXiao
cccc712814 Merge pull request #8 from 5xiao0qing5/codex/format-txt-export-for-messages
Adjust txt/excel export message formatting
2026-01-24 00:17:58 +08:00
QingXiao
135f4819fb Align HTML export parsing and voip placeholders 2026-01-24 00:07:49 +08:00
QingXiao
388923257b Handle more message types in exports 2026-01-23 23:53:33 +08:00
cc
6918e359e8 Merge pull request #86 from hicccc77/dev
Dev
2026-01-23 23:46:34 +08:00
cc
d5b33c7e77 Merge branch 'main' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 23:45:26 +08:00
QingXiao
d37f53e120 Adjust txt/excel export message formatting 2026-01-23 23:37:16 +08:00
cc
26478217e7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 23:33:08 +08:00
cc
a100f4ef97 feat: 一些朋友圈功能的优化实现 2026-01-23 23:33:06 +08:00
QingXiao
91b746dc59 Merge pull request #7 from 5xiao0qing5/codex/implement-html-export-feature-1g9o7z
将 HTML 导出样式移至外部文件并强化 HTML 导出(清理、表情符号、视频)
2026-01-23 22:40:12 +08:00
QingXiao
1817a847de Merge branch 'dev' into codex/implement-html-export-feature-1g9o7z 2026-01-23 22:38:56 +08:00
cc
7e99feae1e Merge pull request #85 from xunchahaha/dev
fix:修复了头像加载失败的问题
2026-01-23 22:34:42 +08:00
QingXiao
2977c45365 Move HTML export styles to CSS file 2026-01-23 22:32:26 +08:00
Forrest
3b363a3efa Merge pull request #84 from xunchahaha/docs/add-3wm-qrcode
hh
2026-01-23 22:28:01 +08:00
xuncha
e2b0bd44d9 hh 2026-01-23 22:15:42 +08:00
QingXiao
cc26860504 实现 HTML 导出功能 2026-01-23 15:06:07 +08:00
QingXiao
54f3e0481f Fix HTML export app messages and emoji rendering 2026-01-23 15:00:43 +08:00
QingXiao
a61371c8ad Refine HTML export layout and theming 2026-01-23 14:48:34 +08:00
QingXiao
fd6d5e4296 Implement HTML chat export 2026-01-23 14:34:40 +08:00
QingXiao
514a617c55 Merge pull request #4 from 5xiao0qing5/codex/add-txt-export-feature-with-configurable-options
完成未实现的 TXT 导出功能
2026-01-23 13:58:56 +08:00
QingXiao
b47007ea0c Add configurable TXT export 2026-01-23 13:52:47 +08:00
xuncha
6436c39c90 Dev (#79)
* fix:尝试修复闪退的问题

* hhhhh

* fix(chatService): 优化头像加载兜底机制:收集无 URL 的用户名,从 head_image.db 批量获取并转换为 base64 格式,更新头像缓存并添加错误处理,避免聊天界面头像缺失。(解决了部分,我电脑上有几个不显示)

* 优化表诉

* 导出优化

* fix: 尝试修复运行库缺失的问题

* 优化表述

* feat: 实现朋友圈获取; 实现聊天页面跳转到指定日期

* fix:修复了头像加载失败的问题

* Bump version from 1.3.1 to 1.3.2

---------

Co-authored-by: Forrest <jin648862@gmail.com>
Co-authored-by: cc <98377878+hicccc77@users.noreply.github.com>
2026-01-23 10:06:16 +08:00
xuncha
49614bf6d8 Dev (#77)
* fix:尝试修复闪退的问题

* hhhhh
2026-01-22 18:46:22 +08:00
xuncha
0e3ab8e4d6 Merge pull request #72 from hicccc77/dev
Dev
2026-01-21 21:01:38 +08:00
32 changed files with 4343 additions and 11220 deletions

View File

@@ -39,13 +39,23 @@ jobs:
npx tsc npx tsc
npx vite build npx vite build
- name: Inject Configuration
shell: bash
run: |
npm pkg set build.releaseInfo.releaseNotes=$'仅适配微信 4.0 及以上版本\n\n修复了一些已知问题\n\n详情前往 Telegram 群查看'
- name: Package and Publish - name: Package and Publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npx electron-builder --publish always npx electron-builder --publish always
- name: Update Release Notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
cat <<EOF > release_notes.md
## 更新日志
修复了一些已知问题
## 加入我们的群
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md

BIN
3wm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -25,16 +25,22 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
</a> </a>
</p> </p>
> [!TIP] > [!TIP]
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
> [!TIP]
> 仅支持微信 **4.0** 及以上版本
# 加入微信交流群 # 加入微信交流群
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。 > 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
<p align="center"> <p align="center">
<img src="2wm.png" alt="WeFlow 微信交流群二维码" width="220"> <img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220">
</p> </p>
<p align="center">一群满了加二群</p>
## 主要功能 ## 主要功能

View File

@@ -209,10 +209,11 @@ function createOnboardingWindow() {
: join(process.resourcesPath, 'icon.ico') : join(process.resourcesPath, 'icon.ico')
onboardingWindow = new BrowserWindow({ onboardingWindow = new BrowserWindow({
width: 1100, width: 960,
height: 720, height: 680,
minWidth: 900, minWidth: 900,
minHeight: 600, minHeight: 620,
resizable: false,
frame: false, frame: false,
transparent: true, transparent: true,
backgroundColor: '#00000000', backgroundColor: '#00000000',
@@ -673,6 +674,10 @@ function registerIpcHandlers() {
return chatService.getMessageById(sessionId, localId) return chatService.getMessageById(sessionId, localId)
}) })
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
return chatService.execQuery(kind, path, sql)
})
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
}) })

View File

@@ -118,7 +118,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
ipcRenderer.on('chat:voiceTranscriptPartial', listener) ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
} },
execQuery: (kind: string, path: string | null, sql: string) =>
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
}, },

View File

@@ -3384,6 +3384,19 @@ class ChatService {
} }
return parsed return parsed
} }
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
return wcdbService.execQuery(kind, path, sql)
} catch (e) {
console.error('ChatService: 执行自定义查询失败:', e)
return { success: false, error: String(e) }
}
}
} }
export const chatService = new ChatService() export const chatService = new ChatService()

View File

@@ -8,6 +8,7 @@ interface ConfigSchema {
onboardingDone: boolean onboardingDone: boolean
imageXorKey: number imageXorKey: number
imageAesKey: string imageAesKey: string
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
// 缓存相关 // 缓存相关
cachePath: string cachePath: string
@@ -40,6 +41,7 @@ export class ConfigService {
onboardingDone: false, onboardingDone: false,
imageXorKey: 0, imageXorKey: 0,
imageAesKey: '', imageAesKey: '',
wxidConfigs: {},
cachePath: '', cachePath: '',
lastOpenedDb: '', lastOpenedDb: '',
lastSession: '', lastSession: '',

View File

@@ -0,0 +1,301 @@
:root {
color-scheme: light;
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2a37;
--muted: #6b7280;
--accent: #4f46e5;
--sent: #dbeafe;
--received: #ffffff;
--border: #e5e7eb;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1080px;
margin: 32px auto 60px;
padding: 0 20px;
}
.header {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
margin-bottom: 24px;
}
.title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px;
}
.meta {
color: var(--muted);
font-size: 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
}
.control label {
font-size: 13px;
color: var(--muted);
}
.control input,
.control select,
.control button {
border-radius: 12px;
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
}
.control button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
transition: transform 0.1s ease;
}
.control button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
display: flex;
align-items: flex-end;
}
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
}
.message {
display: flex;
flex-direction: column;
gap: 8px;
}
.message.hidden {
display: none;
}
.message-time {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message.sent .message-row {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #475569;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble {
max-width: min(70%, 720px);
background: var(--received);
border-radius: 18px;
padding: 12px 14px;
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.message.sent .bubble {
background: var(--sent);
border-color: transparent;
}
.sender-name {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
line-height: 1.6;
}
.message-text {
word-break: break-word;
}
.inline-emoji {
width: 22px;
height: 22px;
vertical-align: text-bottom;
margin: 0 2px;
}
.message-media {
border-radius: 14px;
max-width: 100%;
}
.previewable {
cursor: zoom-in;
}
.message-media.image,
.message-media.emoji {
max-height: 260px;
object-fit: contain;
background: #f1f5f9;
padding: 6px;
}
.message-media.emoji {
max-height: 160px;
width: auto;
}
.message-media.video {
max-height: 360px;
background: #111827;
}
.message-media.audio {
width: 260px;
}
.image-preview {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 999;
}
.image-preview.active {
opacity: 1;
pointer-events: auto;
}
.image-preview img {
max-width: min(90vw, 1200px);
max-height: 90vh;
border-radius: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
background: #0f172a;
transition: transform 0.1s ease;
cursor: zoom-out;
}
body[data-theme="cloud-dancer"] {
--accent: #6b8cff;
--sent: #e0e7ff;
--received: #ffffff;
--border: #d8e0f7;
--bg: #f6f7fb;
}
body[data-theme="corundum-blue"] {
--accent: #2563eb;
--sent: #dbeafe;
--received: #ffffff;
--border: #c7d2fe;
--bg: #eef2ff;
}
body[data-theme="kiwi-green"] {
--accent: #16a34a;
--sent: #dcfce7;
--received: #ffffff;
--border: #bbf7d0;
--bg: #f0fdf4;
}
body[data-theme="spicy-red"] {
--accent: #e11d48;
--sent: #ffe4e6;
--received: #ffffff;
--border: #fecdd3;
--bg: #fff1f2;
}
body[data-theme="teal-water"] {
--accent: #0f766e;
--sent: #ccfbf1;
--received: #ffffff;
--border: #99f6e4;
--bg: #f0fdfa;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
}
.empty {
text-align: center;
color: var(--muted);
padding: 40px;
}

View File

@@ -0,0 +1,302 @@
export const EXPORT_HTML_STYLES = `:root {
color-scheme: light;
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2a37;
--muted: #6b7280;
--accent: #4f46e5;
--sent: #dbeafe;
--received: #ffffff;
--border: #e5e7eb;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1080px;
margin: 32px auto 60px;
padding: 0 20px;
}
.header {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
margin-bottom: 24px;
}
.title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px;
}
.meta {
color: var(--muted);
font-size: 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
}
.control label {
font-size: 13px;
color: var(--muted);
}
.control input,
.control select,
.control button {
border-radius: 12px;
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 14px;
font-family: inherit;
}
.control button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
transition: transform 0.1s ease;
}
.control button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
display: flex;
align-items: flex-end;
}
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
}
.message {
display: flex;
flex-direction: column;
gap: 8px;
}
.message.hidden {
display: none;
}
.message-time {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message.sent .message-row {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #475569;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble {
max-width: min(70%, 720px);
background: var(--received);
border-radius: 18px;
padding: 12px 14px;
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.message.sent .bubble {
background: var(--sent);
border-color: transparent;
}
.sender-name {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
line-height: 1.6;
}
.message-text {
word-break: break-word;
}
.inline-emoji {
width: 22px;
height: 22px;
vertical-align: text-bottom;
margin: 0 2px;
}
.message-media {
border-radius: 14px;
max-width: 100%;
}
.previewable {
cursor: zoom-in;
}
.message-media.image,
.message-media.emoji {
max-height: 260px;
object-fit: contain;
background: #f1f5f9;
padding: 6px;
}
.message-media.emoji {
max-height: 160px;
width: auto;
}
.message-media.video {
max-height: 360px;
background: #111827;
}
.message-media.audio {
width: 260px;
}
.image-preview {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 999;
}
.image-preview.active {
opacity: 1;
pointer-events: auto;
}
.image-preview img {
max-width: min(90vw, 1200px);
max-height: 90vh;
border-radius: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
background: #0f172a;
transition: transform 0.1s ease;
cursor: zoom-out;
}
body[data-theme="cloud-dancer"] {
--accent: #6b8cff;
--sent: #e0e7ff;
--received: #ffffff;
--border: #d8e0f7;
--bg: #f6f7fb;
}
body[data-theme="corundum-blue"] {
--accent: #2563eb;
--sent: #dbeafe;
--received: #ffffff;
--border: #c7d2fe;
--bg: #eef2ff;
}
body[data-theme="kiwi-green"] {
--accent: #16a34a;
--sent: #dcfce7;
--received: #ffffff;
--border: #bbf7d0;
--bg: #f0fdf4;
}
body[data-theme="spicy-red"] {
--accent: #e11d48;
--sent: #ffe4e6;
--received: #ffffff;
--border: #fecdd3;
--bg: #fff1f2;
}
body[data-theme="teal-water"] {
--accent: #0f766e;
--sent: #ccfbf1;
--received: #ffffff;
--border: #99f6e4;
--bg: #f0fdfa;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
}
.empty {
text-align: center;
color: var(--muted);
padding: 40px;
}
`;

File diff suppressed because it is too large Load Diff

View File

@@ -882,16 +882,17 @@ export class KeyService {
return null return null
} }
private isAlphaNumAscii(byte: number): boolean { private isAlphaNumLower(byte: number): boolean {
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39) // 只匹配小写字母 a-z 和数字 0-9AES密钥格式
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39)
} }
private isUtf16AsciiKey(buf: Buffer, start: number): boolean { private isUtf16LowerKey(buf: Buffer, start: number): boolean {
if (start + 64 > buf.length) return false if (start + 64 > buf.length) return false
for (let j = 0; j < 32; j++) { for (let j = 0; j < 32; j++) {
const charByte = buf[start + j * 2] const charByte = buf[start + j * 2]
const nullByte = buf[start + j * 2 + 1] const nullByte = buf[start + j * 2 + 1]
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) { if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
return false return false
} }
} }
@@ -924,8 +925,6 @@ export class KeyService {
const regions: Array<[number, number]> = [] const regions: Array<[number, number]> = []
const MEM_COMMIT = 0x1000 const MEM_COMMIT = 0x1000
const MEM_PRIVATE = 0x20000 const MEM_PRIVATE = 0x20000
const MEM_MAPPED = 0x40000
const MEM_IMAGE = 0x1000000
const PAGE_NOACCESS = 0x01 const PAGE_NOACCESS = 0x01
const PAGE_GUARD = 0x100 const PAGE_GUARD = 0x100
@@ -940,11 +939,10 @@ export class KeyService {
const protect = info.Protect const protect = info.Protect
const type = info.Type const type = info.Type
const regionSize = Number(info.RegionSize) const regionSize = Number(info.RegionSize)
if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) { // 只收集已提交的私有内存(大幅减少扫描区域)
if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) { if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
regions.push([Number(info.BaseAddress), regionSize]) regions.push([Number(info.BaseAddress), regionSize])
} }
}
const nextAddress = address + regionSize const nextAddress = address + regionSize
if (nextAddress <= address) break if (nextAddress <= address) break
@@ -972,87 +970,52 @@ export class KeyService {
try { try {
const allRegions = this.getMemoryRegions(hProcess) const allRegions = this.getMemoryRegions(hProcess)
const totalRegions = allRegions.length
let scannedCount = 0
let skippedCount = 0
// 优化1: 只保留小内存区域(< 10MB- 密钥通常在小区域,可大幅减少扫描时间 for (const [baseAddress, regionSize] of allRegions) {
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024) // 跳过太大的内存区域(> 100MB
if (regionSize > 100 * 1024 * 1024) {
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域) skippedCount++
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
// 优化3: 计算总字节数用于精确进度报告
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
let processedBytes = 0
// 优化4: 减小分块大小到 1MB参考 wx_key 项目)
const chunkSize = 1 * 1024 * 1024
const overlap = 65
let currentRegion = 0
for (const [baseAddress, regionSize] of sortedRegions) {
currentRegion++
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
// 每个区域都让出主线程确保UI流畅
await new Promise(resolve => setImmediate(resolve))
let offset = 0
let trailing: Buffer | null = null
while (offset < regionSize) {
const remaining = regionSize - offset
const currentChunkSize = remaining > chunkSize ? chunkSize : remaining
const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize)
if (!chunk || !chunk.length) {
offset += currentChunkSize
trailing = null
continue continue
} }
let dataToScan: Buffer scannedCount++
if (trailing && trailing.length) { if (scannedCount % 10 === 0) {
dataToScan = Buffer.concat([trailing, chunk]) onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
} else { await new Promise(resolve => setImmediate(resolve))
dataToScan = chunk
} }
for (let i = 0; i < dataToScan.length - 34; i++) { const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
if (this.isAlphaNumAscii(dataToScan[i])) continue if (!memory) continue
// 直接在原始字节中搜索32字节的小写字母数字序列
for (let i = 0; i < memory.length - 34; i++) {
// 检查前导字符(不是小写字母或数字)
if (this.isAlphaNumLower(memory[i])) continue
// 检查接下来32个字节是否都是小写字母或数字
let valid = true let valid = true
for (let j = 1; j <= 32; j++) { for (let j = 1; j <= 32; j++) {
if (!this.isAlphaNumAscii(dataToScan[i + j])) { if (!this.isAlphaNumLower(memory[i + j])) {
valid = false valid = false
break break
} }
} }
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) { if (!valid) continue
valid = false
// 检查尾部字符(不是小写字母或数字)
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
continue
} }
if (valid) {
const keyBytes = dataToScan.subarray(i + 1, i + 33) const keyBytes = memory.subarray(i + 1, i + 33)
if (this.verifyKey(ciphertext, keyBytes)) { if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii') return keyBytes.toString('ascii')
} }
} }
} }
for (let i = 0; i < dataToScan.length - 65; i++) {
if (!this.isUtf16AsciiKey(dataToScan, i)) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) {
keyBytes[j] = dataToScan[i + j * 2]
}
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
const start = dataToScan.length - overlap
trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize
}
// 更新已处理字节数
processedBytes += regionSize
}
return null return null
} finally { } finally {
try { try {

View File

@@ -20,6 +20,7 @@ export class WcdbCore {
private currentWxid: string | null = null private currentWxid: string | null = null
// 函数引用 // 函数引用
private wcdbInitProtection: any = null
private wcdbInit: any = null private wcdbInit: any = null
private wcdbShutdown: any = null private wcdbShutdown: any = null
private wcdbOpenAccount: any = null private wcdbOpenAccount: any = null
@@ -243,6 +244,18 @@ export class WcdbCore {
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
// InitProtection (Added for security)
try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
const protectionOk = this.wcdbInitProtection(dllDir)
if (!protectionOk) {
console.error('Core security check failed')
return false
}
} catch (e) {
console.warn('InitProtection symbol not found:', e)
}
// 定义类型 // 定义类型
// wcdb_status wcdb_init() // wcdb_status wcdb_init()
this.wcdbInit = this.lib.func('int32 wcdb_init()') this.wcdbInit = this.lib.func('int32 wcdb_init()')

9797
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.3.1", "version": "1.4.0",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
"//": "二改不应改变此处的作者与应用信息",
"scripts": { "scripts": {
"postinstall": "echo 'No native modules to rebuild'", "postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'", "rebuild": "echo 'No native modules to rebuild'",

Binary file not shown.

View File

@@ -185,9 +185,15 @@ function App() {
const decryptKey = await configService.getDecryptKey() const decryptKey = await configService.getDecryptKey()
const wxid = await configService.getMyWxid() const wxid = await configService.getMyWxid()
const onboardingDone = await configService.getOnboardingDone() const onboardingDone = await configService.getOnboardingDone()
const wxidConfig = wxid ? await configService.getWxidConfig(wxid) : null
const effectiveDecryptKey = wxidConfig?.decryptKey || decryptKey
if (wxidConfig?.decryptKey && wxidConfig.decryptKey !== decryptKey) {
await configService.setDecryptKey(wxidConfig.decryptKey)
}
// 如果配置完整,自动测试连接 // 如果配置完整,自动测试连接
if (dbPath && decryptKey && wxid) { if (dbPath && effectiveDecryptKey && wxid) {
if (!onboardingDone) { if (!onboardingDone) {
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
} }

View File

@@ -1,5 +1,5 @@
.sidebar { .sidebar {
width: 200px; width: 220px;
background: var(--bg-secondary); background: var(--bg-secondary);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
display: flex; display: flex;
@@ -32,14 +32,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
padding: 0 8px; padding: 0 12px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 16px; padding: 10px 12px;
border-radius: 9999px; border-radius: 9999px;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
@@ -49,7 +49,6 @@
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
width: 100%;
&:hover { &:hover {
background: var(--bg-tertiary); background: var(--bg-tertiary);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react' import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
@@ -16,7 +16,7 @@ function AnalyticsPage() {
const themeMode = useThemeStore((state) => state.themeMode) const themeMode = useThemeStore((state) => state.themeMode)
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore() const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
const loadData = async (forceRefresh = false) => { const loadData = useCallback(async (forceRefresh = false) => {
if (isLoaded && !forceRefresh) return if (isLoaded && !forceRefresh) return
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -55,14 +55,22 @@ function AnalyticsPage() {
setIsLoading(false) setIsLoading(false)
if (removeListener) removeListener() if (removeListener) removeListener()
} }
} }, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
const location = useLocation() const location = useLocation()
useEffect(() => { useEffect(() => {
const force = location.state?.forceRefresh === true const force = location.state?.forceRefresh === true
loadData(force) loadData(force)
}, [location.state]) }, [location.state, loadData])
useEffect(() => {
const handleChange = () => {
loadData(true)
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadData])
const handleRefresh = () => loadData(true) const handleRefresh = () => loadData(true)

View File

@@ -1076,8 +1076,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
background: rgba(10, 10, 10, 0.28); background: var(--bg-tertiary);
backdrop-filter: blur(6px);
transition: opacity 200ms ease; transition: opacity 200ms ease;
z-index: 2; z-index: 2;
} }

View File

@@ -245,6 +245,38 @@ function ChatPage(_props: ChatPageProps) {
} }
}, [loadMyAvatar]) }, [loadMyAvatar])
const handleAccountChanged = useCallback(async () => {
senderAvatarCache.clear()
senderAvatarLoading.clear()
preloadImageKeysRef.current.clear()
lastPreloadSessionRef.current = null
setSessionDetail(null)
setCurrentSession(null)
setSessions([])
setFilteredSessions([])
setMessages([])
setSearchKeyword('')
setConnectionError(null)
setConnected(false)
setConnecting(false)
setHasMoreMessages(true)
setHasMoreLater(false)
await connect()
}, [
connect,
setConnected,
setConnecting,
setConnectionError,
setCurrentSession,
setFilteredSessions,
setHasMoreLater,
setHasMoreMessages,
setMessages,
setSearchKeyword,
setSessionDetail,
setSessions
])
// 加载会话列表(优化:先返回基础数据,异步加载联系人信息) // 加载会话列表(优化:先返回基础数据,异步加载联系人信息)
const loadSessions = async (options?: { silent?: boolean }) => { const loadSessions = async (options?: { silent?: boolean }) => {
if (options?.silent) { if (options?.silent) {
@@ -842,6 +874,14 @@ function ChatPage(_props: ChatPageProps) {
} }
}, []) }, [])
useEffect(() => {
const handleChange = () => {
void handleAccountChanged()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [handleAccountChanged])
useEffect(() => { useEffect(() => {
const nextSet = new Set<string>() const nextSet = new Set<string>()
for (const msg of messages) { for (const msg of messages) {

View File

@@ -16,6 +16,11 @@ function DataManagementPage() {
setWxid(id) setWxid(id)
} }
loadConfig() loadConfig()
const handleChange = () => {
loadConfig()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, []) }, [])
return ( return (

View File

@@ -396,6 +396,99 @@
} }
} }
.select-field {
position: relative;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 260px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.media-options { .media-options {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1130,11 +1223,11 @@
} }
} }
input:checked + .slider { input:checked+.slider {
background-color: var(--primary); background-color: var(--primary);
} }
input:checked + .slider::before { input:checked+.slider::before {
transform: translateX(20px); transform: translateX(20px);
} }
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
import * as configService from '../services/config' import * as configService from '../services/config'
import './ExportPage.scss' import './ExportPage.scss'
@@ -22,6 +22,8 @@ interface ExportOptions {
exportEmojis: boolean exportEmojis: boolean
exportVoiceAsText: boolean exportVoiceAsText: boolean
excelCompactColumns: boolean excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
} }
interface ExportResult { interface ExportResult {
@@ -34,6 +36,7 @@ interface ExportResult {
type SessionLayout = 'shared' | 'per-session' type SessionLayout = 'shared' | 'per-session'
function ExportPage() { function ExportPage() {
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const [sessions, setSessions] = useState<ChatSession[]>([]) const [sessions, setSessions] = useState<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]) const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set()) const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
@@ -47,6 +50,8 @@ function ExportPage() {
const [calendarDate, setCalendarDate] = useState(new Date()) const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'excel', format: 'excel',
@@ -61,7 +66,9 @@ function ExportPage() {
exportVoices: true, exportVoices: true,
exportEmojis: true, exportEmojis: true,
exportVoiceAsText: true, exportVoiceAsText: true,
excelCompactColumns: true excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark'
}) })
const buildDateRangeFromPreset = (preset: string) => { const buildDateRangeFromPreset = (preset: string) => {
@@ -125,17 +132,20 @@ function ExportPage() {
savedRange, savedRange,
savedMedia, savedMedia,
savedVoiceAsText, savedVoiceAsText,
savedExcelCompactColumns savedExcelCompactColumns,
savedTxtColumns
] = await Promise.all([ ] = await Promise.all([
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultDateRange(), configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(), configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(), configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns() configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns()
]) ])
const preset = savedRange || 'today' const preset = savedRange || 'today'
const rangeDefaults = buildDateRangeFromPreset(preset) const rangeDefaults = buildDateRangeFromPreset(preset)
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
setOptions((prev) => ({ setOptions((prev) => ({
...prev, ...prev,
@@ -144,7 +154,8 @@ function ExportPage() {
dateRange: rangeDefaults.dateRange, dateRange: rangeDefaults.dateRange,
exportMedia: savedMedia ?? false, exportMedia: savedMedia ?? false,
exportVoiceAsText: savedVoiceAsText ?? true, exportVoiceAsText: savedVoiceAsText ?? true,
excelCompactColumns: savedExcelCompactColumns ?? true excelCompactColumns: savedExcelCompactColumns ?? true,
txtColumns
})) }))
} catch (e) { } catch (e) {
console.error('加载导出默认设置失败:', e) console.error('加载导出默认设置失败:', e)
@@ -157,6 +168,19 @@ function ExportPage() {
loadExportDefaults() loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults]) }, [loadSessions, loadExportPath, loadExportDefaults])
useEffect(() => {
const handleChange = () => {
setSelectedSessions(new Set())
setSearchKeyword('')
setExportResult(null)
setSessions([])
setFilteredSessions([])
loadSessions()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadSessions])
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => { const removeListener = window.electronAPI.export.onProgress?.((payload) => {
setExportProgress({ setExportProgress({
@@ -169,6 +193,16 @@ function ExportPage() {
removeListener?.() removeListener?.()
} }
}, []) }, [])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
setShowDisplayNameSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect])
useEffect(() => { useEffect(() => {
if (!searchKeyword.trim()) { if (!searchKeyword.trim()) {
@@ -209,6 +243,23 @@ function ExportPage() {
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
} }
const handleFormatChange = (format: ExportOptions['format']) => {
setOptions((prev) => {
const next = { ...prev, format }
if (format === 'html') {
return {
...next,
exportMedia: true,
exportImages: true,
exportVoices: true,
exportEmojis: true,
exportVoiceAsText: true
}
}
return next
})
}
const openExportFolder = async () => { const openExportFolder = async () => {
if (exportFolder) { if (exportFolder) {
await window.electronAPI.shell.openPath(exportFolder) await window.electronAPI.shell.openPath(exportFolder)
@@ -233,6 +284,8 @@ function ExportPage() {
exportEmojis: options.exportMedia && options.exportEmojis, exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
sessionLayout, sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(options.dateRange.start.getTime() / 1000),
@@ -241,7 +294,7 @@ function ExportPage() {
} : null } : null
} }
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') { if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') {
const result = await window.electronAPI.export.exportSessions( const result = await window.electronAPI.export.exportSessions(
sessionList, sessionList,
exportFolder, exportFolder,
@@ -364,6 +417,25 @@ function ExportPage() {
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
] ]
const displayNameOptions = [
{
value: 'group-nickname',
label: '群昵称优先',
desc: '仅群聊有效,私聊显示备注/昵称'
},
{
value: 'remark',
label: '备注优先',
desc: '有备注显示备注,否则显示昵称'
},
{
value: 'nickname',
label: '微信昵称',
desc: '始终显示微信昵称'
}
]
const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference)
const displayNameLabel = displayNameOption?.label || '备注优先'
return ( return (
<div className="export-page"> <div className="export-page">
@@ -447,7 +519,7 @@ function ExportPage() {
<div <div
key={fmt.value} key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`} className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
onClick={() => setOptions({ ...options, format: fmt.value as any })} onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
> >
<fmt.icon size={24} /> <fmt.icon size={24} />
<span className="format-label">{fmt.label}</span> <span className="format-label">{fmt.label}</span>
@@ -478,6 +550,44 @@ function ExportPage() {
</div> </div>
</div> </div>
{/* 发送者名称显示偏好 */}
{(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle"></p>
<div className="select-field" ref={displayNameDropdownRef}>
<button
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => setShowDisplayNameSelect(!showDisplayNameSelect)}
>
<span className="select-value">{displayNameLabel}</span>
<ChevronDown size={16} />
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${options.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setOptions({
...options,
displayNamePreference: option.value as ExportOptions['displayNamePreference']
})
setShowDisplayNameSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<p className="setting-subtitle">//</p> <p className="setting-subtitle">//</p>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
@@ -56,7 +56,7 @@ function GroupAnalyticsPage() {
useEffect(() => { useEffect(() => {
loadGroups() loadGroups()
}, []) }, [loadGroups])
useEffect(() => { useEffect(() => {
if (searchQuery) { if (searchQuery) {
@@ -93,7 +93,7 @@ function GroupAnalyticsPage() {
} }
}, [dateRangeReady]) }, [dateRangeReady])
const loadGroups = async () => { const loadGroups = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const result = await window.electronAPI.groupAnalytics.getGroupChats() const result = await window.electronAPI.groupAnalytics.getGroupChats()
@@ -106,7 +106,23 @@ function GroupAnalyticsPage() {
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, [])
useEffect(() => {
const handleChange = () => {
setGroups([])
setFilteredGroups([])
setSelectedGroup(null)
setSelectedFunction(null)
setMembers([])
setRankings([])
setActiveHours({})
setMediaStats(null)
void loadGroups()
} }
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadGroups])
const handleGroupSelect = (group: GroupChatInfo) => { const handleGroupSelect = (group: GroupChatInfo) => {
if (selectedGroup?.username !== group.username) { if (selectedGroup?.username !== group.username) {

View File

@@ -1156,7 +1156,6 @@
input { input {
flex: 1; flex: 1;
padding-right: 36px;
} }
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc' import { dialog } from '../services/ipc'
@@ -28,7 +29,8 @@ interface WxidOption {
} }
function SettingsPage() { function SettingsPage() {
const { setDbConnected, setLoading, reset } = useAppStore() const { isDbConnected, setDbConnected, setLoading, reset } = useAppStore()
const resetChatStore = useChatStore((state) => state.reset)
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
@@ -40,7 +42,6 @@ function SettingsPage() {
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([]) const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false) const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidDropdownRef = useRef<HTMLDivElement>(null)
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
@@ -92,9 +93,6 @@ function SettingsPage() {
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node const target = e.target as Node
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
setShowWxidSelect(false)
}
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
setShowExportFormatSelect(false) setShowExportFormatSelect(false)
} }
@@ -107,7 +105,7 @@ function SettingsPage() {
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect]) }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -142,14 +140,24 @@ function SettingsPage() {
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath) if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid) if (savedWxid) setWxid(savedWxid)
if (savedCachePath) setCachePath(savedCachePath) if (savedCachePath) setCachePath(savedCachePath)
if (savedImageXorKey != null) {
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`) const wxidConfig = savedWxid ? await configService.getWxidConfig(savedWxid) : null
const decryptKeyToUse = wxidConfig?.decryptKey ?? savedKey ?? ''
const imageXorKeyToUse = typeof wxidConfig?.imageXorKey === 'number'
? wxidConfig.imageXorKey
: savedImageXorKey
const imageAesKeyToUse = wxidConfig?.imageAesKey ?? savedImageAesKey ?? ''
setDecryptKey(decryptKeyToUse)
if (typeof imageXorKeyToUse === 'number') {
setImageXorKey(`0x${imageXorKeyToUse.toString(16).toUpperCase().padStart(2, '0')}`)
} else {
setImageXorKey('')
} }
if (savedImageAesKey) setImageAesKey(savedImageAesKey) setImageAesKey(imageAesKeyToUse)
setLogEnabled(savedLogEnabled) setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe) setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages) setTranscribeLanguages(savedTranscribeLanguages)
@@ -166,6 +174,7 @@ function SettingsPage() {
await configService.setTranscribeLanguages(defaultLanguages) await configService.setTranscribeLanguages(defaultLanguages)
} }
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
} catch (e) { } catch (e) {
console.error('加载配置失败:', e) console.error('加载配置失败:', e)
@@ -254,6 +263,103 @@ function SettingsPage() {
setTimeout(() => setMessage(null), 3000) setTimeout(() => setMessage(null), 3000)
} }
type WxidKeys = {
decryptKey: string
imageXorKey: number | null
imageAesKey: string
}
const formatImageXorKey = (value: number) => `0x${value.toString(16).toUpperCase().padStart(2, '0')}`
const parseImageXorKey = (value: string) => {
if (!value) return null
const parsed = parseInt(value.replace(/^0x/i, ''), 16)
return Number.isNaN(parsed) ? null : parsed
}
const buildKeysFromState = (): WxidKeys => ({
decryptKey: decryptKey || '',
imageXorKey: parseImageXorKey(imageXorKey),
imageAesKey: imageAesKey || ''
})
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
decryptKey: wxidConfig?.decryptKey || '',
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
imageAesKey: wxidConfig?.imageAesKey || ''
})
const applyKeysToState = (keys: WxidKeys) => {
setDecryptKey(keys.decryptKey)
if (typeof keys.imageXorKey === 'number') {
setImageXorKey(formatImageXorKey(keys.imageXorKey))
} else {
setImageXorKey('')
}
setImageAesKey(keys.imageAesKey)
}
const syncKeysToConfig = async (keys: WxidKeys) => {
await configService.setDecryptKey(keys.decryptKey)
await configService.setImageXorKey(typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0)
await configService.setImageAesKey(keys.imageAesKey)
}
const applyWxidSelection = async (
selectedWxid: string,
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string }
) => {
if (!selectedWxid) return
const currentWxid = wxid
const isSameWxid = currentWxid === selectedWxid
if (currentWxid && currentWxid !== selectedWxid) {
const currentKeys = buildKeysFromState()
await configService.setWxidConfig(currentWxid, {
decryptKey: currentKeys.decryptKey,
imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0,
imageAesKey: currentKeys.imageAesKey
})
}
const preferCurrentKeys = options?.preferCurrentKeys ?? false
const keys = preferCurrentKeys
? buildKeysFromState()
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))
setWxid(selectedWxid)
applyKeysToState(keys)
await configService.setMyWxid(selectedWxid)
await syncKeysToConfig(keys)
await configService.setWxidConfig(selectedWxid, {
decryptKey: keys.decryptKey,
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
imageAesKey: keys.imageAesKey
})
setShowWxidSelect(false)
if (isDbConnected) {
try {
await window.electronAPI.chat.close()
const result = await window.electronAPI.chat.connect()
setDbConnected(result.success, dbPath || undefined)
if (!result.success && result.error) {
showMessage(result.error, false)
}
} catch (e) {
showMessage(`切换账号后重新连接失败: ${e}`, false)
setDbConnected(false)
}
}
if (!isSameWxid) {
clearAnalyticsStoreCache()
resetChatStore()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
}
if (options?.showToast ?? true) {
showMessage(options?.toastText || `已选择账号:${selectedWxid}`, true)
}
}
const handleAutoDetectPath = async () => { const handleAutoDetectPath = async () => {
if (isDetectingPath) return if (isDetectingPath) return
setIsDetectingPath(true) setIsDetectingPath(true)
@@ -267,11 +373,10 @@ function SettingsPage() {
const wxids = await window.electronAPI.dbPath.scanWxids(result.path) const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
setWxidOptions(wxids) setWxidOptions(wxids)
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) await applyWxidSelection(wxids[0].wxid, {
await configService.setMyWxid(wxids[0].wxid) toastText: `已检测到账号:${wxids[0].wxid}`
showMessage(`已检测到账号:${wxids[0].wxid}`, true) })
} else if (wxids.length > 1) { } else if (wxids.length > 1) {
// 多账号时弹出选择对话框
setShowWxidSelect(true) setShowWxidSelect(true)
} }
} else { } else {
@@ -296,7 +401,10 @@ function SettingsPage() {
} }
} }
const handleScanWxid = async (silent = false) => { const handleScanWxid = async (
silent = false,
options?: { preferCurrentKeys?: boolean; showDialog?: boolean }
) => {
if (!dbPath) { if (!dbPath) {
if (!silent) showMessage('请先选择数据库目录', false) if (!silent) showMessage('请先选择数据库目录', false)
return return
@@ -304,12 +412,14 @@ function SettingsPage() {
try { try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
setWxidOptions(wxids) setWxidOptions(wxids)
const allowDialog = options?.showDialog ?? !silent
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) await applyWxidSelection(wxids[0].wxid, {
await configService.setMyWxid(wxids[0].wxid) preferCurrentKeys: options?.preferCurrentKeys ?? false,
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true) showToast: !silent,
} else if (wxids.length > 1) { toastText: `已检测到账号:${wxids[0].wxid}`
// 多账号时弹出选择对话框 })
} else if (wxids.length > 1 && allowDialog) {
setShowWxidSelect(true) setShowWxidSelect(true)
} else { } else {
if (!silent) showMessage('未检测到账号目录,请检查路径', false) if (!silent) showMessage('未检测到账号目录,请检查路径', false)
@@ -320,10 +430,7 @@ function SettingsPage() {
} }
const handleSelectWxid = async (selectedWxid: string) => { const handleSelectWxid = async (selectedWxid: string) => {
setWxid(selectedWxid) await applyWxidSelection(selectedWxid)
await configService.setMyWxid(selectedWxid)
setShowWxidSelect(false)
showMessage(`已选择账号:${selectedWxid}`, true)
} }
const handleSelectCachePath = async () => { const handleSelectCachePath = async () => {
@@ -396,7 +503,7 @@ function SettingsPage() {
setDecryptKey(result.key) setDecryptKey(result.key)
setDbKeyStatus('密钥获取成功') setDbKeyStatus('密钥获取成功')
showMessage('已自动获取解密密钥', true) showMessage('已自动获取解密密钥', true)
await handleScanWxid(true) await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false })
} else { } else {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
@@ -482,19 +589,14 @@ function SettingsPage() {
await configService.setDbPath(dbPath) await configService.setDbPath(dbPath)
await configService.setMyWxid(wxid) await configService.setMyWxid(wxid)
await configService.setCachePath(cachePath) await configService.setCachePath(cachePath)
if (imageXorKey) { const parsedXorKey = parseImageXorKey(imageXorKey)
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) await configService.setImageXorKey(typeof parsedXorKey === 'number' ? parsedXorKey : 0)
if (!Number.isNaN(parsed)) { await configService.setImageAesKey(imageAesKey || '')
await configService.setImageXorKey(parsed) await configService.setWxidConfig(wxid, {
} decryptKey,
} else { imageXorKey: typeof parsedXorKey === 'number' ? parsedXorKey : 0,
await configService.setImageXorKey(0) imageAesKey
} })
if (imageAesKey) {
await configService.setImageAesKey(imageAesKey)
} else {
await configService.setImageAesKey('')
}
await configService.setWhisperModelDir(whisperModelDir) await configService.setWhisperModelDir(whisperModelDir)
await configService.setAutoTranscribeVoice(autoTranscribeVoice) await configService.setAutoTranscribeVoice(autoTranscribeVoice)
await configService.setTranscribeLanguages(transcribeLanguages) await configService.setTranscribeLanguages(transcribeLanguages)
@@ -687,37 +789,13 @@ function SettingsPage() {
<div className="form-group"> <div className="form-group">
<label> wxid</label> <label> wxid</label>
<span className="form-hint"></span> <span className="form-hint"></span>
<div className="wxid-input-wrapper" ref={wxidDropdownRef}> <div className="wxid-input-wrapper">
<input <input
type="text" type="text"
placeholder="例如: wxid_xxxxxx" placeholder="例如: wxid_xxxxxx"
value={wxid} value={wxid}
onChange={(e) => setWxid(e.target.value)} onChange={(e) => setWxid(e.target.value)}
/> />
<button
type="button"
className={`wxid-dropdown-btn ${showWxidSelect ? 'open' : ''}`}
onClick={() => wxidOptions.length > 0 ? setShowWxidSelect(!showWxidSelect) : handleScanWxid()}
title={wxidOptions.length > 0 ? "选择已检测到的账号" : "扫描账号"}
>
<ChevronDown size={16} />
</button>
{showWxidSelect && wxidOptions.length > 0 && (
<div className="wxid-dropdown">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-value">{opt.wxid}</span>
<span className="wxid-time">
{new Date(opt.modifiedTime).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</div> </div>
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> wxid</button> <button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> wxid</button>
</div> </div>
@@ -1074,6 +1152,7 @@ function SettingsPage() {
)} )}
</div> </div>
</div> </div>
</div> </div>
) )
} }
@@ -1225,4 +1304,3 @@ function SettingsPage() {
} }
export default SettingsPage export default SettingsPage

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react' import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import { ImagePreview } from '../components/ImagePreview' import { ImagePreview } from '../components/ImagePreview'
import JumpToDateDialog from '../components/JumpToDateDialog'
import './SnsPage.scss' import './SnsPage.scss'
interface SnsPost { interface SnsPost {
@@ -20,16 +21,9 @@ interface SnsPost {
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
const [error, setError] = useState(false); const [error, setError] = useState(false);
if (error) {
return ( return (
<div className="media-item error"> <div className={`media-item ${error ? 'error' : ''}`}>
<span></span> {!error ? (
</div>
);
}
return (
<div className="media-item">
<img <img
src={thumb || url} src={thumb || url}
alt="" alt=""
@@ -37,6 +31,11 @@ const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPr
onClick={onPreview} onClick={onPreview}
onError={() => setError(true)} onError={() => setError(true)}
/> />
) : (
<div className="media-error-placeholder" onClick={onPreview}>
<ImageIcon size={24} style={{ opacity: 0.3 }} />
</div>
)}
</div> </div>
); );
}; };
@@ -57,32 +56,102 @@ export default function SnsPage() {
// 筛选与搜索状态 // 筛选与搜索状态
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]) const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [isSidebarOpen, setIsSidebarOpen] = useState(true) const [isSidebarOpen, setIsSidebarOpen] = useState(true)
// 联系人列表状态 // 联系人列表状态
const [contacts, setContacts] = useState<Contact[]>([]) const [contacts, setContacts] = useState<Contact[]>([])
const [contactSearch, setContactSearch] = useState('') const [contactSearch, setContactSearch] = useState('')
const [contactsLoading, setContactsLoading] = useState(false) const [contactsLoading, setContactsLoading] = useState(false)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
const [previewImage, setPreviewImage] = useState<string | null>(null) const [previewImage, setPreviewImage] = useState<string | null>(null)
const loadPosts = useCallback(async (reset = false) => { const postsContainerRef = useRef<HTMLDivElement>(null)
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([])
const scrollAdjustmentRef = useRef<number>(0)
// 同步 posts 到 ref 供 loadPosts 使用
useEffect(() => {
postsRef.current = posts
}, [posts])
// 处理向上加载动态时的滚动位置保持
useEffect(() => {
if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) {
const container = postsContainerRef.current;
const newHeight = container.scrollHeight;
const diff = newHeight - scrollAdjustmentRef.current;
if (diff > 0) {
container.scrollTop += diff;
}
scrollAdjustmentRef.current = 0;
}
}, [posts])
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options
if (loadingRef.current) return if (loadingRef.current) return
loadingRef.current = true loadingRef.current = true
setLoading(true) if (direction === 'newer') setLoadingNewer(true)
else setLoading(true)
try { try {
const currentOffset = reset ? 0 : offset
const limit = 20 const limit = 20
let startTs: number | undefined = undefined
let endTs: number | undefined = undefined
// 转换日期为秒级时间戳 if (reset) {
const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined if (jumpTargetDate) {
const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天 endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399
}
} else if (direction === 'newer') {
const currentPosts = postsRef.current
if (currentPosts.length > 0) {
const topTs = currentPosts[0].createTime
console.log('[SnsPage] Fetching newer posts starts from:', topTs + 1);
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
currentOffset, 0,
selectedUsernames,
searchKeyword,
topTs + 1,
undefined
);
if (result.success && result.timeline && result.timeline.length > 0) {
if (postsContainerRef.current) {
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
}
const existingIds = new Set(currentPosts.map(p => p.id));
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id));
if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev]);
}
setHasNewer(result.timeline.length >= limit);
} else {
setHasNewer(false);
}
}
setLoadingNewer(false);
loadingRef.current = false;
return;
} else {
const currentPosts = postsRef.current
if (currentPosts.length > 0) {
endTs = currentPosts[currentPosts.length - 1].createTime - 1
}
}
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
selectedUsernames, selectedUsernames,
searchKeyword, searchKeyword,
startTs, startTs,
@@ -92,11 +161,24 @@ export default function SnsPage() {
if (result.success && result.timeline) { if (result.success && result.timeline) {
if (reset) { if (reset) {
setPosts(result.timeline) setPosts(result.timeline)
setOffset(limit)
setHasMore(result.timeline.length >= limit) setHasMore(result.timeline.length >= limit)
// 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL
const topTs = result.timeline[0]?.createTime || 0;
if (topTs > 0) {
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined);
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
} else { } else {
setHasNewer(false);
}
if (postsContainerRef.current) {
postsContainerRef.current.scrollTop = 0
}
} else {
if (result.timeline.length > 0) {
setPosts(prev => [...prev, ...result.timeline!]) setPosts(prev => [...prev, ...result.timeline!])
setOffset(prev => prev + limit) }
if (result.timeline.length < limit) { if (result.timeline.length < limit) {
setHasMore(false) setHasMore(false)
} }
@@ -106,40 +188,25 @@ export default function SnsPage() {
console.error('Failed to load SNS timeline:', error) console.error('Failed to load SNS timeline:', error)
} finally { } finally {
setLoading(false) setLoading(false)
setLoadingNewer(false)
loadingRef.current = false loadingRef.current = false
} }
}, [offset, selectedUsernames, searchKeyword, startDate, endDate]) }, [selectedUsernames, searchKeyword, jumpTargetDate])
// 获取联系人列表 // 获取联系人列表
const loadContacts = async () => { const loadContacts = useCallback(async () => {
setContactsLoading(true) setContactsLoading(true)
try { try {
const result = await window.electronAPI.chat.getSessions() const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) { if (result.success && result.sessions) {
// 系统账号和特殊前缀
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder']; const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
// 初步提取并过滤联系人
const initialContacts = result.sessions const initialContacts = result.sessions
.filter((s: any) => { .filter((s: any) => {
if (!s.username) return false; if (!s.username) return false;
const u = s.username.toLowerCase(); const u = s.username.toLowerCase();
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false;
// 1. 排除群聊 (WeChat 群组以 @chatroom 结尾) if (u.startsWith('gh_')) return false;
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) { if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false;
return false;
}
// 2. 排除公众号 (通常以 gh_ 开头)
if (u.startsWith('gh_')) {
return false;
}
// 3. 排除系统账号
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) {
return false;
}
return true; return true;
}) })
.map((s: any) => ({ .map((s: any) => ({
@@ -149,7 +216,6 @@ export default function SnsPage() {
})) }))
setContacts(initialContacts) setContacts(initialContacts)
// 异步进一步富化(获取更多准确的昵称和头像)
const usernames = initialContacts.map(c => c.username) const usernames = initialContacts.map(c => c.username)
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (enriched.success && enriched.contacts) { if (enriched.success && enriched.contacts) {
@@ -171,20 +237,69 @@ export default function SnsPage() {
} finally { } finally {
setContactsLoading(false) setContactsLoading(false)
} }
}
useEffect(() => {
loadContacts()
}, []) }, [])
// 初始加载
useEffect(() => { useEffect(() => {
loadPosts(true) const checkSchema = async () => {
}, [selectedUsernames, searchKeyword, startDate, endDate]) try {
const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)");
console.log('[SnsPage] SnsTimeLine Schema:', schema);
if (schema.success && schema.rows) {
const columns = schema.rows.map((r: any) => r.name);
console.log('[SnsPage] Available columns:', columns);
}
} catch (e) {
console.error('[SnsPage] Failed to check schema:', e);
}
};
checkSchema();
loadContacts()
}, [loadContacts])
useEffect(() => {
const handleChange = () => {
setPosts([])
setHasMore(true)
setHasNewer(false)
setSelectedUsernames([])
setSearchKeyword('')
setJumpTargetDate(null)
loadContacts()
loadPosts({ reset: true })
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadContacts, loadPosts])
useEffect(() => {
loadPosts({ reset: true })
}, [selectedUsernames, searchKeyword, jumpTargetDate])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) {
loadPosts() // 加载更旧的动态(触底)
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
loadPosts({ direction: 'older' })
}
// 加载更新的动态(触顶触发)
// 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划
if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) {
loadPosts({ direction: 'newer' })
}
}
// 处理到顶后的手动上滚意图
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
const container = postsContainerRef.current
if (!container) return
// deltaY < 0 表示向上滚scrollTop === 0 表示已经在最顶端
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
console.log('[SnsPage] Wheel-up detected at top, loading newer posts...');
loadPosts({ direction: 'newer' })
} }
} }
@@ -202,6 +317,11 @@ export default function SnsPage() {
} }
const toggleUserSelection = (username: string) => { const toggleUserSelection = (username: string) => {
// 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑
// 或者保持原样。根据用户反馈“乱跳”,我们在这里选择:
// 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。
setJumpTargetDate(undefined);
setSelectedUsernames(prev => { setSelectedUsernames(prev => {
if (prev.includes(username)) { if (prev.includes(username)) {
return prev.filter(u => u !== username) return prev.filter(u => u !== username)
@@ -214,8 +334,7 @@ export default function SnsPage() {
const clearFilters = () => { const clearFilters = () => {
setSearchKeyword('') setSearchKeyword('')
setSelectedUsernames([]) setSelectedUsernames([])
setStartDate('') setJumpTargetDate(undefined)
setEndDate('')
} }
const filteredContacts = contacts.filter(c => const filteredContacts = contacts.filter(c =>
@@ -223,56 +342,77 @@ export default function SnsPage() {
c.username.toLowerCase().includes(contactSearch.toLowerCase()) c.username.toLowerCase().includes(contactSearch.toLowerCase())
) )
return ( return (
<div className="sns-page"> <div className="sns-page">
<div className="sns-container"> <div className="sns-container">
{/* 侧边栏:过滤与搜索 */} {/* 侧边栏:过滤与搜索 */}
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}> <aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
<div className="sidebar-header"> <div className="sidebar-header">
<h3></h3> <div className="title-wrapper">
<Filter size={18} className="title-icon" />
<h3></h3>
</div>
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}> <button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
<div className="filter-content"> <div className="filter-content custom-scrollbar">
{/* 关键词与时间 */} {/* 1. 搜索分组 (放到最顶上) */}
<div className="filter-group"> <div className="filter-card">
<div className="filter-section"> <div className="filter-section">
<label><Search size={14} /> </label> <label><Search size={14} /> </label>
<div className="search-input-wrapper">
<Search size={14} className="input-icon" />
<input <input
type="text" type="text"
placeholder="搜索正文..." placeholder="搜索动态内容..."
value={searchKeyword} value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)} onChange={e => setSearchKeyword(e.target.value)}
/> />
{searchKeyword && (
<button className="clear-input" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
</div>
</div> </div>
{/* 2. 日期跳转 (放搜索下面) */}
<div className="filter-card jump-date-card">
<div className="filter-section"> <div className="filter-section">
<label><Calendar size={14} /> </label> <label><Calendar size={14} /> </label>
<div className="date-inputs"> <button className={`jump-date-btn ${jumpTargetDate ? 'active' : ''}`} onClick={() => setShowJumpDialog(true)}>
<input <span className="text">
type="date" {jumpTargetDate ? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '选择跳转日期...'}
value={startDate} </span>
onChange={e => setStartDate(e.target.value)} <Calendar size={14} className="icon" />
/> </button>
<span></span> {jumpTargetDate && (
<input <button className="clear-jump-date-inline" onClick={() => setJumpTargetDate(undefined)}>
type="date"
value={endDate} </button>
onChange={e => setEndDate(e.target.value)} )}
/>
</div>
</div> </div>
</div> </div>
{/* 联系人列表 */}
{/* 3. 联系人筛选 (放最下面,高度自适应) */}
<div className="filter-card contact-card">
<div className="contact-filter-section"> <div className="contact-filter-section">
<div className="section-header"> <div className="section-header">
<label><User size={14} /> </label> <label><User size={14} /> </label>
<div className="header-actions">
{selectedUsernames.length > 0 && ( {selectedUsernames.length > 0 && (
<span className="selected-count"> {selectedUsernames.length}</span> <button className="clear-selection-btn" onClick={() => setSelectedUsernames([])}></button>
)} )}
{selectedUsernames.length > 0 && (
<span className="selected-count">{selectedUsernames.length}</span>
)}
</div>
</div> </div>
<div className="contact-search"> <div className="contact-search">
<Search size={12} className="search-icon" /> <Search size={12} className="search-icon" />
@@ -282,6 +422,9 @@ export default function SnsPage() {
value={contactSearch} value={contactSearch}
onChange={e => setContactSearch(e.target.value)} onChange={e => setContactSearch(e.target.value)}
/> />
{contactSearch && (
<X size={12} className="clear-search-icon" onClick={() => setContactSearch('')} />
)}
</div> </div>
<div className="contact-list custom-scrollbar"> <div className="contact-list custom-scrollbar">
{filteredContacts.map(contact => ( {filteredContacts.map(contact => (
@@ -290,22 +433,31 @@ export default function SnsPage() {
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`} className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
onClick={() => toggleUserSelection(contact.username)} onClick={() => toggleUserSelection(contact.username)}
> >
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" /> <div className="avatar-wrapper">
<span className="contact-name">{contact.displayName}</span> <Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" />
{selectedUsernames.includes(contact.username) && ( {selectedUsernames.includes(contact.username) && (
<div className="check-mark"></div> <div className="active-badge"></div>
)} )}
</div> </div>
<span className="contact-name">{contact.displayName}</span>
<div className="check-box">
{selectedUsernames.includes(contact.username) && <div className="inner-check"></div>}
</div>
</div>
))} ))}
{contacts.length === 0 && !contactsLoading && ( {filteredContacts.length === 0 && (
<div className="empty-contacts"></div> <div className="empty-contacts"></div>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="sidebar-footer"> <div className="sidebar-footer">
<button className="clear-btn" onClick={clearFilters}></button> <button className="clear-btn" onClick={clearFilters}>
<RefreshCw size={14} />
</button>
</div> </div>
</aside> </aside>
@@ -313,29 +465,45 @@ export default function SnsPage() {
<div className="sns-header"> <div className="sns-header">
<div className="header-left"> <div className="header-left">
{!isSidebarOpen && ( {!isSidebarOpen && (
<button className="icon-btn" onClick={() => setIsSidebarOpen(true)}> <button className="icon-btn sidebar-trigger" onClick={() => setIsSidebarOpen(true)}>
<Filter size={20} /> <Filter size={20} />
</button> </button>
)} )}
<h2></h2> <h2></h2>
</div> </div>
<div className="header-right"> <div className="header-right">
<button onClick={() => loadPosts(true)} disabled={loading} className="icon-btn refresh-btn"> <button
<RefreshCw size={18} className={loading ? 'spinning' : ''} /> onClick={() => {
if (jumpTargetDate) setJumpTargetDate(undefined);
loadPosts({ reset: true });
}}
disabled={loading || loadingNewer}
className="icon-btn refresh-btn"
>
<RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} />
</button> </button>
</div> </div>
</div> </div>
<div className="sns-content" onScroll={handleScroll}> <div className="sns-content-wrapper">
{selectedUsernames.length > 0 && ( <div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="active-filters"> <div className="posts-list">
<span>: {selectedUsernames.length} </span> {loadingNewer && (
<button onClick={() => setSelectedUsernames([])} className="clear-chip-btn"></button> <div className="status-indicator loading-newer">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div> </div>
)} )}
{!loadingNewer && hasNewer && (
{posts.map(post => ( <div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
<div key={post.id} className="sns-post">
</div>
)}
{posts.map((post, index) => {
return (
<div key={post.id} className="sns-post-row">
<div className="sns-post-wrapper">
<div className="sns-post">
<div className="post-header"> <div className="post-header">
<Avatar <Avatar
src={post.avatarUrl} src={post.avatarUrl}
@@ -354,7 +522,8 @@ export default function SnsPage() {
{post.type === 15 ? ( {post.type === 15 ? (
<div className="post-video-placeholder"> <div className="post-video-placeholder">
[] <Play size={20} />
<span></span>
</div> </div>
) : post.media.length > 0 && ( ) : post.media.length > 0 && (
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}> <div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
@@ -396,26 +565,46 @@ export default function SnsPage() {
</div> </div>
)} )}
</div> </div>
))} </div>
</div>
)
})}
</div>
{loading && <div className="loading-more">...</div>} {loading && <div className="status-indicator loading-more">
{!hasMore && posts.length > 0 && <div className="no-more"></div>} <RefreshCw size={16} className="spinning" />
<span>...</span>
</div>}
{!hasMore && posts.length > 0 && <div className="status-indicator no-more"></div>}
{!loading && posts.length === 0 && ( {!loading && posts.length === 0 && (
<div className="no-results"> <div className="no-results">
<p></p> <div className="no-results-icon"><Search size={48} /></div>
{selectedUsernames.length > 0 && ( <p></p>
<button onClick={() => setSelectedUsernames([])} className="reset-inline"> {(selectedUsernames.length > 0 || searchKeyword) && (
<button onClick={clearFilters} className="reset-inline">
</button> </button>
)} )}
</div> </div>
)} )}
</div> </div>
</div>
</main> </main>
</div> </div>
{previewImage && ( {previewImage && (
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} /> <ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
)} )}
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => {
setShowJumpDialog(false)
}}
onSelect={(date) => {
setJumpTargetDate(date)
setShowJumpDialog(false)
}}
currentDate={jumpTargetDate || new Date()}
/>
</div> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -269,15 +269,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
await configService.setDecryptKey(decryptKey) await configService.setDecryptKey(decryptKey)
await configService.setMyWxid(wxid) await configService.setMyWxid(wxid)
await configService.setCachePath(cachePath) await configService.setCachePath(cachePath)
if (imageXorKey) { const parsedXorKey = imageXorKey ? parseInt(imageXorKey.replace(/^0x/i, ''), 16) : null
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16) await configService.setImageXorKey(typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0)
if (!Number.isNaN(parsed)) { await configService.setImageAesKey(imageAesKey || '')
await configService.setImageXorKey(parsed) await configService.setWxidConfig(wxid, {
} decryptKey,
} imageXorKey: typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0,
if (imageAesKey) { imageAesKey
await configService.setImageAesKey(imageAesKey) })
}
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
setDbConnected(true, dbPath) setDbConnected(true, dbPath)
@@ -313,6 +312,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
if (isDbConnected) { if (isDbConnected) {
return ( return (
<div className={rootClassName}> <div className={rootClassName}>
<div className="welcome-container">
{showWindowControls && ( {showWindowControls && (
<div className="window-controls"> <div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化"> <button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
@@ -323,21 +323,33 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button> </button>
</div> </div>
)} )}
<div className="welcome-shell"> <div className="welcome-sidebar">
<div className="welcome-panel"> <div className="sidebar-header">
<div className="panel-header"> <img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
<img src="./logo.png" alt="WeFlow" className="panel-logo" /> <div className="sidebar-brand">
<div> <span className="brand-name">WeFlow</span>
<p className="panel-kicker">WeFlow</p> <span className="brand-tag">Connected</span>
<h1></h1>
</div> </div>
</div> </div>
<div className="panel-note">
<CheckCircle2 size={16} /> <div className="sidebar-spacer" style={{ flex: 1 }} />
<span></span>
<div className="sidebar-footer">
<ShieldCheck size={14} />
<span></span>
</div> </div>
</div>
<div className="welcome-content success-content">
<div className="success-body">
<div className="success-icon">
<CheckCircle2 size={48} />
</div>
<h1 className="success-title"></h1>
<p className="success-desc">使</p>
<button <button
className="btn btn-primary btn-full" className="btn btn-primary btn-large"
onClick={() => { onClick={() => {
if (standalone) { if (standalone) {
setIsClosing(true) setIsClosing(true)
@@ -349,16 +361,18 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
}} }}
> >
<ArrowRight size={18} />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }
return ( return (
<div className={rootClassName}> <div className={rootClassName}>
<div className="welcome-container">
{showWindowControls && ( {showWindowControls && (
<div className="window-controls"> <div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化"> <button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
@@ -369,63 +383,54 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button> </button>
</div> </div>
)} )}
<div className="welcome-shell"> <div className="welcome-sidebar">
<div className="welcome-panel"> <div className="sidebar-header">
<div className="panel-header"> <img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
<img src="./logo.png" alt="WeFlow" className="panel-logo" /> <div className="sidebar-brand">
<div> <span className="brand-name">WeFlow</span>
<p className="panel-kicker"></p> <span className="brand-tag">Setup</span>
<h1>WeFlow </h1>
<p className="panel-subtitle"></p>
</div> </div>
</div> </div>
<div className="step-list">
<div className="sidebar-nav">
{steps.map((step, index) => ( {steps.map((step, index) => (
<div key={step.id} className={`step-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'done' : ''}`}> <div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'completed' : ''}`}>
<div className="step-index">{index < stepIndex ? <CheckCircle2 size={14} /> : index + 1}</div> <div className="nav-indicator">
<div> {index < stepIndex ? <CheckCircle2 size={14} /> : <div className="dot" />}
<div className="step-title">{step.title}</div> </div>
<div className="step-desc">{step.desc}</div> <div className="nav-info">
<div className="nav-title">{step.title}</div>
<div className="nav-desc">{step.desc}</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div className="panel-foot">
<ShieldCheck size={16} /> <div className="sidebar-footer">
<ShieldCheck size={14} />
<span></span> <span></span>
</div> </div>
</div> </div>
<div className="setup-card"> <div className="welcome-content">
<div className="setup-header"> <div className="content-header">
<div className="setup-icon">
{currentStep.id === 'intro' && <Sparkles size={18} />}
{currentStep.id === 'db' && <Database size={18} />}
{currentStep.id === 'cache' && <HardDrive size={18} />}
{currentStep.id === 'key' && <KeyRound size={18} />}
{currentStep.id === 'image' && <ShieldCheck size={18} />}
</div>
<div> <div>
<h2>{currentStep.title}</h2> <h2>{currentStep.title}</h2>
<p>{currentStep.desc}</p> <p className="header-desc">{currentStep.desc}</p>
</div> </div>
</div> </div>
<div className="content-body">
{currentStep.id === 'intro' && ( {currentStep.id === 'intro' && (
<div className="setup-body"> <div className="intro-block">
<div className="intro-card"> {/* 内容移至底部 */}
<Wand2 size={18} />
<div>
<h3></h3>
<p></p>
</div>
</div>
</div> </div>
)} )}
{currentStep.id === 'db' && ( {currentStep.id === 'db' && (
<div className="setup-body"> <div className="form-group">
<label className="field-label"></label> <label className="field-label"></label>
<div className="input-group">
<input <input
type="text" type="text"
className="field-input" className="field-input"
@@ -433,52 +438,60 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
value={dbPath} value={dbPath}
onChange={(e) => setDbPath(e.target.value)} onChange={(e) => setDbPath(e.target.value)}
/> />
<div className="button-row"> </div>
<div className="action-row">
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}> <button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'} <FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
</button> </button>
<button className="btn btn-primary" onClick={handleSelectPath}> <button className="btn btn-secondary" onClick={handleSelectPath}>
<FolderOpen size={16} /> <FolderOpen size={16} /> ...
</button> </button>
</div> </div>
<div className="field-hint">--</div> <div className="field-hint">--</div>
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}> --</div> <div className="field-hint warning">
</div>
</div> </div>
)} )}
{currentStep.id === 'cache' && ( {currentStep.id === 'cache' && (
<div className="setup-body"> <div className="form-group">
<label className="field-label"></label> <label className="field-label"></label>
<div className="input-group">
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="留空使用默认目录" placeholder="留空使用默认目录"
value={cachePath} value={cachePath}
onChange={(e) => setCachePath(e.target.value)} onChange={(e) => setCachePath(e.target.value)}
/> />
<div className="button-row"> </div>
<button className="btn btn-primary" onClick={handleSelectCachePath}> <div className="action-row">
<FolderOpen size={16} /> <button className="btn btn-secondary" onClick={handleSelectCachePath}>
<FolderOpen size={16} />
</button> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}> <button className="btn btn-secondary" onClick={() => setCachePath('')}>
<RotateCcw size={16} /> 使 <RotateCcw size={16} />
</button> </button>
</div> </div>
<div className="field-hint">使</div> <div className="field-hint"></div>
</div> </div>
)} )}
{currentStep.id === 'key' && ( {currentStep.id === 'key' && (
<div className="setup-body"> <div className="form-group">
<label className="field-label"> wxid</label> <label className="field-label"> (Wxid)</label>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="获取密钥后将自动填充" placeholder="等待获取..."
value={wxid} value={wxid}
readOnly
onChange={(e) => setWxid(e.target.value)} onChange={(e) => setWxid(e.target.value)}
/> />
<label className="field-label"></label>
<label className="field-label mt-4"></label>
<div className="field-with-toggle"> <div className="field-with-toggle">
<input <input
type={showDecryptKey ? 'text' : 'password'} type={showDecryptKey ? 'text' : 'password'}
@@ -488,69 +501,86 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
onChange={(e) => setDecryptKey(e.target.value.trim())} onChange={(e) => setDecryptKey(e.target.value.trim())}
/> />
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}> <button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />} {showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>
</div> </div>
<div className="key-actions">
{isManualStartPrompt ? ( {isManualStartPrompt ? (
<div className="manual-prompt"> <div className="manual-prompt">
<p className="prompt-text"></p> <p></p>
<button className="btn btn-primary" onClick={handleManualConfirm}> <button className="btn btn-primary" onClick={handleManualConfirm}>
</button> </button>
</div> </div>
) : ( ) : (
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}> <button className="btn btn-secondary btn-block" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
{isFetchingDbKey ? '获取...' : '自动获取密钥'} {isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
</button> </button>
)} )}
</div>
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>} {dbKeyStatus && <div className="status-message">{dbKeyStatus}</div>}
<div className="field-hint"></div> <div className="field-hint"></div>
<div className="field-hint"><span style={{color: 'red'}}>hook安装成功</span></div>
</div> </div>
)} )}
{currentStep.id === 'image' && ( {currentStep.id === 'image' && (
<div className="setup-body"> <div className="form-group">
<div className="grid-2">
<div>
<label className="field-label"> XOR </label> <label className="field-label"> XOR </label>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="例如0xA4" placeholder="0x..."
value={imageXorKey} value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)} onChange={(e) => setImageXorKey(e.target.value)}
/> />
</div>
<div>
<label className="field-label"> AES </label> <label className="field-label"> AES </label>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="16 位密钥" placeholder="16位密钥"
value={imageAesKey} value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)} onChange={(e) => setImageAesKey(e.target.value)}
/> />
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}> </div>
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} </div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
</button> </button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint"></div> {imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
{isFetchingImageKey && <div className="field-hint status-text">...</div>} <div className="field-hint"></div>
</div> </div>
)} )}
</div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<div className="setup-actions"> {currentStep.id === 'intro' && (
<button className="btn btn-tertiary" onClick={handleBack} disabled={stepIndex === 0}> <div className="intro-footer">
<p></p>
<p>WeFlow 访</p>
</div>
)}
<div className="content-actions">
<button className="btn btn-ghost" onClick={handleBack} disabled={stepIndex === 0}>
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
{stepIndex < steps.length - 1 ? ( {stepIndex < steps.length - 1 ? (
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}> <button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
<ArrowRight size={16} /> <ArrowRight size={16} />
</button> </button>
) : ( ) : (
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}> <button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
{isConnecting ? '连接中...' : '测试并完成'} {isConnecting ? '连接中...' : '完成配置'} <ArrowRight size={16} />
</button> </button>
)} )}
</div> </div>

View File

@@ -6,6 +6,7 @@ export const CONFIG_KEYS = {
DECRYPT_KEY: 'decryptKey', DECRYPT_KEY: 'decryptKey',
DB_PATH: 'dbPath', DB_PATH: 'dbPath',
MY_WXID: 'myWxid', MY_WXID: 'myWxid',
WXID_CONFIGS: 'wxidConfigs',
THEME: 'theme', THEME: 'theme',
THEME_ID: 'themeId', THEME_ID: 'themeId',
LAST_SESSION: 'lastSession', LAST_SESSION: 'lastSession',
@@ -27,9 +28,17 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns' EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns'
} as const } as const
export interface WxidConfig {
decryptKey?: string
imageXorKey?: number
imageAesKey?: string
updatedAt?: number
}
// 获取解密密钥 // 获取解密密钥
export async function getDecryptKey(): Promise<string | null> { export async function getDecryptKey(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY) const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
@@ -63,6 +72,32 @@ export async function setMyWxid(wxid: string): Promise<void> {
await config.set(CONFIG_KEYS.MY_WXID, wxid) await config.set(CONFIG_KEYS.MY_WXID, wxid)
} }
export async function getWxidConfigs(): Promise<Record<string, WxidConfig>> {
const value = await config.get(CONFIG_KEYS.WXID_CONFIGS)
if (value && typeof value === 'object') {
return value as Record<string, WxidConfig>
}
return {}
}
export async function getWxidConfig(wxid: string): Promise<WxidConfig | null> {
if (!wxid) return null
const configs = await getWxidConfigs()
return configs[wxid] || null
}
export async function setWxidConfig(wxid: string, configValue: WxidConfig): Promise<void> {
if (!wxid) return
const configs = await getWxidConfigs()
const previous = configs[wxid] || {}
configs[wxid] = {
...previous,
...configValue,
updatedAt: Date.now()
}
await config.set(CONFIG_KEYS.WXID_CONFIGS, configs)
}
// 获取主题 // 获取主题
export async function getTheme(): Promise<'light' | 'dark'> { export async function getTheme(): Promise<'light' | 'dark'> {
const value = await config.get(CONFIG_KEYS.THEME) const value = await config.get(CONFIG_KEYS.THEME)
@@ -306,3 +341,14 @@ export async function getExportDefaultExcelCompactColumns(): Promise<boolean | n
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> { export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
} }
// 获取导出默认 TXT 列配置
export async function getExportDefaultTxtColumns(): Promise<string[] | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS)
return Array.isArray(value) ? (value as string[]) : null
}
// 设置导出默认 TXT 列配置
export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
}

View File

@@ -100,6 +100,7 @@ export interface ElectronAPI {
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: 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 }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
} }
image: { image: {
@@ -351,7 +352,9 @@ export interface ExportOptions {
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
} }
export interface ExportProgress { export interface ExportProgress {