mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 07:35:50 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3956989b67 | ||
|
|
33d7c243a7 | ||
|
|
a215886015 | ||
|
|
1d9e8aded0 | ||
|
|
b7e31c9cff | ||
|
|
4e9c81a93d | ||
|
|
9181ac5d34 | ||
|
|
3a10aeb23e | ||
|
|
178f9c4fdc | ||
|
|
4d647a9467 | ||
|
|
16cbc6adb1 | ||
|
|
7afb872bff | ||
|
|
7df6182e70 | ||
|
|
40efb04a36 | ||
|
|
3efaed488a | ||
|
|
decdbf95f7 | ||
|
|
cccc712814 | ||
|
|
135f4819fb | ||
|
|
388923257b | ||
|
|
6918e359e8 | ||
|
|
d5b33c7e77 | ||
|
|
d37f53e120 | ||
|
|
26478217e7 | ||
|
|
a100f4ef97 | ||
|
|
91b746dc59 | ||
|
|
1817a847de | ||
|
|
7e99feae1e | ||
|
|
2977c45365 | ||
|
|
3b363a3efa | ||
|
|
e2b0bd44d9 | ||
|
|
cc26860504 | ||
|
|
54f3e0481f | ||
|
|
a61371c8ad | ||
|
|
fd6d5e4296 | ||
|
|
514a617c55 | ||
|
|
b47007ea0c | ||
|
|
6436c39c90 | ||
|
|
49614bf6d8 | ||
|
|
0e3ab8e4d6 |
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@@ -39,13 +39,23 @@ jobs:
|
||||
npx tsc
|
||||
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
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
@@ -25,16 +25,22 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||
|
||||
> [!TIP]
|
||||
> 仅支持微信 **4.0** 及以上版本
|
||||
|
||||
# 加入微信交流群
|
||||
|
||||
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
|
||||
|
||||
<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 align="center">一群满了加二群</p>
|
||||
|
||||
## 主要功能
|
||||
|
||||
|
||||
@@ -209,10 +209,11 @@ function createOnboardingWindow() {
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
onboardingWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
width: 960,
|
||||
height: 680,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
minHeight: 620,
|
||||
resizable: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
@@ -673,6 +674,10 @@ function registerIpcHandlers() {
|
||||
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) => {
|
||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
})
|
||||
|
||||
@@ -118,7 +118,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||
}
|
||||
},
|
||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
|
||||
},
|
||||
|
||||
|
||||
|
||||
@@ -3384,6 +3384,19 @@ class ChatService {
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ConfigSchema {
|
||||
onboardingDone: boolean
|
||||
imageXorKey: number
|
||||
imageAesKey: string
|
||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||
|
||||
// 缓存相关
|
||||
cachePath: string
|
||||
@@ -40,6 +41,7 @@ export class ConfigService {
|
||||
onboardingDone: false,
|
||||
imageXorKey: 0,
|
||||
imageAesKey: '',
|
||||
wxidConfigs: {},
|
||||
cachePath: '',
|
||||
lastOpenedDb: '',
|
||||
lastSession: '',
|
||||
|
||||
301
electron/services/exportHtml.css
Normal file
301
electron/services/exportHtml.css
Normal 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;
|
||||
}
|
||||
302
electron/services/exportHtmlStyles.ts
Normal file
302
electron/services/exportHtmlStyles.ts
Normal 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
@@ -882,16 +882,17 @@ export class KeyService {
|
||||
return null
|
||||
}
|
||||
|
||||
private isAlphaNumAscii(byte: number): boolean {
|
||||
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)
|
||||
private isAlphaNumLower(byte: number): boolean {
|
||||
// 只匹配小写字母 a-z 和数字 0-9(AES密钥格式)
|
||||
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
|
||||
for (let j = 0; j < 32; j++) {
|
||||
const charByte = buf[start + j * 2]
|
||||
const nullByte = buf[start + j * 2 + 1]
|
||||
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) {
|
||||
if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -924,8 +925,6 @@ export class KeyService {
|
||||
const regions: Array<[number, number]> = []
|
||||
const MEM_COMMIT = 0x1000
|
||||
const MEM_PRIVATE = 0x20000
|
||||
const MEM_MAPPED = 0x40000
|
||||
const MEM_IMAGE = 0x1000000
|
||||
const PAGE_NOACCESS = 0x01
|
||||
const PAGE_GUARD = 0x100
|
||||
|
||||
@@ -940,10 +939,9 @@ export class KeyService {
|
||||
const protect = info.Protect
|
||||
const type = info.Type
|
||||
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) {
|
||||
regions.push([Number(info.BaseAddress), regionSize])
|
||||
}
|
||||
// 只收集已提交的私有内存(大幅减少扫描区域)
|
||||
if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
|
||||
regions.push([Number(info.BaseAddress), regionSize])
|
||||
}
|
||||
|
||||
const nextAddress = address + regionSize
|
||||
@@ -972,86 +970,51 @@ export class KeyService {
|
||||
|
||||
try {
|
||||
const allRegions = this.getMemoryRegions(hProcess)
|
||||
const totalRegions = allRegions.length
|
||||
let scannedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
// 优化1: 只保留小内存区域(< 10MB)- 密钥通常在小区域,可大幅减少扫描时间
|
||||
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024)
|
||||
for (const [baseAddress, regionSize] of allRegions) {
|
||||
// 跳过太大的内存区域(> 100MB)
|
||||
if (regionSize > 100 * 1024 * 1024) {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
|
||||
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
|
||||
scannedCount++
|
||||
if (scannedCount % 10 === 0) {
|
||||
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
|
||||
// 优化3: 计算总字节数用于精确进度报告
|
||||
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
|
||||
let processedBytes = 0
|
||||
const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
|
||||
if (!memory) continue
|
||||
|
||||
// 优化4: 减小分块大小到 1MB(参考 wx_key 项目)
|
||||
const chunkSize = 1 * 1024 * 1024
|
||||
const overlap = 65
|
||||
let currentRegion = 0
|
||||
// 直接在原始字节中搜索32字节的小写字母数字序列
|
||||
for (let i = 0; i < memory.length - 34; i++) {
|
||||
// 检查前导字符(不是小写字母或数字)
|
||||
if (this.isAlphaNumLower(memory[i])) continue
|
||||
|
||||
for (const [baseAddress, regionSize] of sortedRegions) {
|
||||
currentRegion++
|
||||
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
|
||||
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
|
||||
// 检查接下来32个字节是否都是小写字母或数字
|
||||
let valid = true
|
||||
for (let j = 1; j <= 32; j++) {
|
||||
if (!this.isAlphaNumLower(memory[i + j])) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!valid) continue
|
||||
|
||||
// 每个区域都让出主线程,确保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
|
||||
// 检查尾部字符(不是小写字母或数字)
|
||||
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
|
||||
continue
|
||||
}
|
||||
|
||||
let dataToScan: Buffer
|
||||
if (trailing && trailing.length) {
|
||||
dataToScan = Buffer.concat([trailing, chunk])
|
||||
} else {
|
||||
dataToScan = chunk
|
||||
const keyBytes = memory.subarray(i + 1, i + 33)
|
||||
if (this.verifyKey(ciphertext, keyBytes)) {
|
||||
return keyBytes.toString('ascii')
|
||||
}
|
||||
|
||||
for (let i = 0; i < dataToScan.length - 34; i++) {
|
||||
if (this.isAlphaNumAscii(dataToScan[i])) continue
|
||||
let valid = true
|
||||
for (let j = 1; j <= 32; j++) {
|
||||
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
|
||||
valid = false
|
||||
}
|
||||
if (valid) {
|
||||
const keyBytes = dataToScan.subarray(i + 1, i + 33)
|
||||
if (this.verifyKey(ciphertext, keyBytes)) {
|
||||
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
|
||||
} finally {
|
||||
|
||||
@@ -20,6 +20,7 @@ export class WcdbCore {
|
||||
private currentWxid: string | null = null
|
||||
|
||||
// 函数引用
|
||||
private wcdbInitProtection: any = null
|
||||
private wcdbInit: any = null
|
||||
private wcdbShutdown: any = null
|
||||
private wcdbOpenAccount: any = null
|
||||
@@ -243,6 +244,18 @@ export class WcdbCore {
|
||||
|
||||
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()
|
||||
this.wcdbInit = this.lib.func('int32 wcdb_init()')
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.3.2",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "weflow",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
@@ -8537,6 +8537,12 @@
|
||||
"sherpa-onnx-win-x64": "^1.12.23"
|
||||
}
|
||||
},
|
||||
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sherpa-onnx-win-ia32": {
|
||||
"version": "1.12.23",
|
||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
"postinstall": "echo 'No native modules to rebuild'",
|
||||
"rebuild": "echo 'No native modules to rebuild'",
|
||||
|
||||
Binary file not shown.
@@ -185,9 +185,15 @@ function App() {
|
||||
const decryptKey = await configService.getDecryptKey()
|
||||
const wxid = await configService.getMyWxid()
|
||||
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) {
|
||||
await configService.setOnboardingDone(true)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
width: 220px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
transition: width 0.25s ease;
|
||||
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
|
||||
|
||||
.nav-menu,
|
||||
.sidebar-footer {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
@@ -32,14 +32,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9999px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
@@ -49,13 +49,12 @@
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
@@ -99,9 +98,9 @@
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 4px;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
@@ -16,7 +16,7 @@ function AnalyticsPage() {
|
||||
|
||||
const themeMode = useThemeStore((state) => state.themeMode)
|
||||
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
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
@@ -55,14 +55,22 @@ function AnalyticsPage() {
|
||||
setIsLoading(false)
|
||||
if (removeListener) removeListener()
|
||||
}
|
||||
}
|
||||
}, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const force = location.state?.forceRefresh === true
|
||||
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)
|
||||
|
||||
|
||||
@@ -1076,8 +1076,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: rgba(10, 10, 10, 0.28);
|
||||
backdrop-filter: blur(6px);
|
||||
background: var(--bg-tertiary);
|
||||
transition: opacity 200ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,38 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
}, [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 }) => {
|
||||
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(() => {
|
||||
const nextSet = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
|
||||
@@ -16,6 +16,11 @@ function DataManagementPage() {
|
||||
setWxid(id)
|
||||
}
|
||||
loadConfig()
|
||||
const handleChange = () => {
|
||||
loadConfig()
|
||||
}
|
||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1130,11 +1223,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
input:checked+.slider {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
input:checked + .slider::before {
|
||||
input:checked+.slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 * as configService from '../services/config'
|
||||
import './ExportPage.scss'
|
||||
@@ -22,6 +22,8 @@ interface ExportOptions {
|
||||
exportEmojis: boolean
|
||||
exportVoiceAsText: boolean
|
||||
excelCompactColumns: boolean
|
||||
txtColumns: string[]
|
||||
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
|
||||
}
|
||||
|
||||
interface ExportResult {
|
||||
@@ -34,6 +36,7 @@ interface ExportResult {
|
||||
type SessionLayout = 'shared' | 'per-session'
|
||||
|
||||
function ExportPage() {
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||
@@ -47,6 +50,8 @@ function ExportPage() {
|
||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||
const [selectingStart, setSelectingStart] = useState(true)
|
||||
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'excel',
|
||||
@@ -61,7 +66,9 @@ function ExportPage() {
|
||||
exportVoices: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: true,
|
||||
excelCompactColumns: true
|
||||
excelCompactColumns: true,
|
||||
txtColumns: defaultTxtColumns,
|
||||
displayNamePreference: 'remark'
|
||||
})
|
||||
|
||||
const buildDateRangeFromPreset = (preset: string) => {
|
||||
@@ -125,17 +132,20 @@ function ExportPage() {
|
||||
savedRange,
|
||||
savedMedia,
|
||||
savedVoiceAsText,
|
||||
savedExcelCompactColumns
|
||||
savedExcelCompactColumns,
|
||||
savedTxtColumns
|
||||
] = await Promise.all([
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns()
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
configService.getExportDefaultTxtColumns()
|
||||
])
|
||||
|
||||
const preset = savedRange || 'today'
|
||||
const rangeDefaults = buildDateRangeFromPreset(preset)
|
||||
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
||||
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
@@ -144,7 +154,8 @@ function ExportPage() {
|
||||
dateRange: rangeDefaults.dateRange,
|
||||
exportMedia: savedMedia ?? false,
|
||||
exportVoiceAsText: savedVoiceAsText ?? true,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? true
|
||||
excelCompactColumns: savedExcelCompactColumns ?? true,
|
||||
txtColumns
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('加载导出默认设置失败:', e)
|
||||
@@ -157,6 +168,19 @@ function ExportPage() {
|
||||
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(() => {
|
||||
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
|
||||
setExportProgress({
|
||||
@@ -169,6 +193,16 @@ function ExportPage() {
|
||||
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(() => {
|
||||
if (!searchKeyword.trim()) {
|
||||
@@ -209,6 +243,23 @@ function ExportPage() {
|
||||
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 () => {
|
||||
if (exportFolder) {
|
||||
await window.electronAPI.shell.openPath(exportFolder)
|
||||
@@ -233,6 +284,8 @@ function ExportPage() {
|
||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
||||
excelCompactColumns: options.excelCompactColumns,
|
||||
txtColumns: options.txtColumns,
|
||||
displayNamePreference: options.displayNamePreference,
|
||||
sessionLayout,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
@@ -241,7 +294,7 @@ function ExportPage() {
|
||||
} : 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(
|
||||
sessionList,
|
||||
exportFolder,
|
||||
@@ -364,6 +417,25 @@ function ExportPage() {
|
||||
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, 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 (
|
||||
<div className="export-page">
|
||||
@@ -447,7 +519,7 @@ function ExportPage() {
|
||||
<div
|
||||
key={fmt.value}
|
||||
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} />
|
||||
<span className="format-label">{fmt.label}</span>
|
||||
@@ -478,6 +550,44 @@ function ExportPage() {
|
||||
</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">
|
||||
<h3>媒体文件</h3>
|
||||
<p className="setting-subtitle">导出图片/语音/表情并在记录内写入相对路径</p>
|
||||
|
||||
@@ -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 { Avatar } from '../components/Avatar'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
@@ -56,7 +56,7 @@ function GroupAnalyticsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups()
|
||||
}, [])
|
||||
}, [loadGroups])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
@@ -93,7 +93,7 @@ function GroupAnalyticsPage() {
|
||||
}
|
||||
}, [dateRangeReady])
|
||||
|
||||
const loadGroups = async () => {
|
||||
const loadGroups = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
||||
@@ -106,7 +106,23 @@ function GroupAnalyticsPage() {
|
||||
} finally {
|
||||
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) => {
|
||||
if (selectedGroup?.username !== group.username) {
|
||||
|
||||
@@ -1156,7 +1156,6 @@
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding-right: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
@@ -28,7 +29,8 @@ interface WxidOption {
|
||||
}
|
||||
|
||||
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 clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
|
||||
|
||||
@@ -40,7 +42,6 @@ function SettingsPage() {
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||
const wxidDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
||||
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||
@@ -92,9 +93,6 @@ function SettingsPage() {
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
|
||||
setShowWxidSelect(false)
|
||||
}
|
||||
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
||||
setShowExportFormatSelect(false)
|
||||
}
|
||||
@@ -107,7 +105,7 @@ function SettingsPage() {
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
|
||||
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||
@@ -142,14 +140,24 @@ function SettingsPage() {
|
||||
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||
|
||||
if (savedKey) setDecryptKey(savedKey)
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
if (savedWxid) setWxid(savedWxid)
|
||||
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)
|
||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||
setTranscribeLanguages(savedTranscribeLanguages)
|
||||
@@ -166,6 +174,7 @@ function SettingsPage() {
|
||||
await configService.setTranscribeLanguages(defaultLanguages)
|
||||
}
|
||||
|
||||
|
||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e)
|
||||
@@ -254,6 +263,103 @@ function SettingsPage() {
|
||||
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 () => {
|
||||
if (isDetectingPath) return
|
||||
setIsDetectingPath(true)
|
||||
@@ -267,11 +373,10 @@ function SettingsPage() {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
|
||||
setWxidOptions(wxids)
|
||||
if (wxids.length === 1) {
|
||||
setWxid(wxids[0].wxid)
|
||||
await configService.setMyWxid(wxids[0].wxid)
|
||||
showMessage(`已检测到账号:${wxids[0].wxid}`, true)
|
||||
await applyWxidSelection(wxids[0].wxid, {
|
||||
toastText: `已检测到账号:${wxids[0].wxid}`
|
||||
})
|
||||
} else if (wxids.length > 1) {
|
||||
// 多账号时弹出选择对话框
|
||||
setShowWxidSelect(true)
|
||||
}
|
||||
} 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 (!silent) showMessage('请先选择数据库目录', false)
|
||||
return
|
||||
@@ -304,12 +412,14 @@ function SettingsPage() {
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
const allowDialog = options?.showDialog ?? !silent
|
||||
if (wxids.length === 1) {
|
||||
setWxid(wxids[0].wxid)
|
||||
await configService.setMyWxid(wxids[0].wxid)
|
||||
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true)
|
||||
} else if (wxids.length > 1) {
|
||||
// 多账号时弹出选择对话框
|
||||
await applyWxidSelection(wxids[0].wxid, {
|
||||
preferCurrentKeys: options?.preferCurrentKeys ?? false,
|
||||
showToast: !silent,
|
||||
toastText: `已检测到账号:${wxids[0].wxid}`
|
||||
})
|
||||
} else if (wxids.length > 1 && allowDialog) {
|
||||
setShowWxidSelect(true)
|
||||
} else {
|
||||
if (!silent) showMessage('未检测到账号目录,请检查路径', false)
|
||||
@@ -320,10 +430,7 @@ function SettingsPage() {
|
||||
}
|
||||
|
||||
const handleSelectWxid = async (selectedWxid: string) => {
|
||||
setWxid(selectedWxid)
|
||||
await configService.setMyWxid(selectedWxid)
|
||||
setShowWxidSelect(false)
|
||||
showMessage(`已选择账号:${selectedWxid}`, true)
|
||||
await applyWxidSelection(selectedWxid)
|
||||
}
|
||||
|
||||
const handleSelectCachePath = async () => {
|
||||
@@ -396,7 +503,7 @@ function SettingsPage() {
|
||||
setDecryptKey(result.key)
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
showMessage('已自动获取解密密钥', true)
|
||||
await handleScanWxid(true)
|
||||
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false })
|
||||
} else {
|
||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||
setIsManualStartPrompt(true)
|
||||
@@ -482,19 +589,14 @@ function SettingsPage() {
|
||||
await configService.setDbPath(dbPath)
|
||||
await configService.setMyWxid(wxid)
|
||||
await configService.setCachePath(cachePath)
|
||||
if (imageXorKey) {
|
||||
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
await configService.setImageXorKey(parsed)
|
||||
}
|
||||
} else {
|
||||
await configService.setImageXorKey(0)
|
||||
}
|
||||
if (imageAesKey) {
|
||||
await configService.setImageAesKey(imageAesKey)
|
||||
} else {
|
||||
await configService.setImageAesKey('')
|
||||
}
|
||||
const parsedXorKey = parseImageXorKey(imageXorKey)
|
||||
await configService.setImageXorKey(typeof parsedXorKey === 'number' ? parsedXorKey : 0)
|
||||
await configService.setImageAesKey(imageAesKey || '')
|
||||
await configService.setWxidConfig(wxid, {
|
||||
decryptKey,
|
||||
imageXorKey: typeof parsedXorKey === 'number' ? parsedXorKey : 0,
|
||||
imageAesKey
|
||||
})
|
||||
await configService.setWhisperModelDir(whisperModelDir)
|
||||
await configService.setAutoTranscribeVoice(autoTranscribeVoice)
|
||||
await configService.setTranscribeLanguages(transcribeLanguages)
|
||||
@@ -687,37 +789,13 @@ function SettingsPage() {
|
||||
<div className="form-group">
|
||||
<label>账号 wxid</label>
|
||||
<span className="form-hint">微信账号标识</span>
|
||||
<div className="wxid-input-wrapper" ref={wxidDropdownRef}>
|
||||
<div className="wxid-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如: wxid_xxxxxx"
|
||||
value={wxid}
|
||||
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>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> 扫描 wxid</button>
|
||||
</div>
|
||||
@@ -1074,6 +1152,7 @@ function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1225,4 +1304,3 @@ function SettingsPage() {
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react'
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import { ImagePreview } from '../components/ImagePreview'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
import './SnsPage.scss'
|
||||
|
||||
interface SnsPost {
|
||||
@@ -20,23 +21,21 @@ interface SnsPost {
|
||||
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="media-item error">
|
||||
<span>无法加载</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="media-item">
|
||||
<img
|
||||
src={thumb || url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onClick={onPreview}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
<div className={`media-item ${error ? 'error' : ''}`}>
|
||||
{!error ? (
|
||||
<img
|
||||
src={thumb || url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onClick={onPreview}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="media-error-placeholder" onClick={onPreview}>
|
||||
<ImageIcon size={24} style={{ opacity: 0.3 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -57,32 +56,102 @@ export default function SnsPage() {
|
||||
// 筛选与搜索状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
|
||||
// 联系人列表状态
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [contactSearch, setContactSearch] = useState('')
|
||||
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 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
|
||||
|
||||
loadingRef.current = true
|
||||
setLoading(true)
|
||||
if (direction === 'newer') setLoadingNewer(true)
|
||||
else setLoading(true)
|
||||
|
||||
try {
|
||||
const currentOffset = reset ? 0 : offset
|
||||
const limit = 20
|
||||
let startTs: number | undefined = undefined
|
||||
let endTs: number | undefined = undefined
|
||||
|
||||
// 转换日期为秒级时间戳
|
||||
const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined
|
||||
const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天
|
||||
if (reset) {
|
||||
if (jumpTargetDate) {
|
||||
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(
|
||||
limit,
|
||||
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,
|
||||
currentOffset,
|
||||
0,
|
||||
selectedUsernames,
|
||||
searchKeyword,
|
||||
startTs,
|
||||
@@ -92,11 +161,24 @@ export default function SnsPage() {
|
||||
if (result.success && result.timeline) {
|
||||
if (reset) {
|
||||
setPosts(result.timeline)
|
||||
setOffset(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 {
|
||||
setHasNewer(false);
|
||||
}
|
||||
|
||||
if (postsContainerRef.current) {
|
||||
postsContainerRef.current.scrollTop = 0
|
||||
}
|
||||
} else {
|
||||
setPosts(prev => [...prev, ...result.timeline!])
|
||||
setOffset(prev => prev + limit)
|
||||
if (result.timeline.length > 0) {
|
||||
setPosts(prev => [...prev, ...result.timeline!])
|
||||
}
|
||||
if (result.timeline.length < limit) {
|
||||
setHasMore(false)
|
||||
}
|
||||
@@ -106,40 +188,25 @@ export default function SnsPage() {
|
||||
console.error('Failed to load SNS timeline:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingNewer(false)
|
||||
loadingRef.current = false
|
||||
}
|
||||
}, [offset, selectedUsernames, searchKeyword, startDate, endDate])
|
||||
}, [selectedUsernames, searchKeyword, jumpTargetDate])
|
||||
|
||||
// 获取联系人列表
|
||||
const loadContacts = async () => {
|
||||
const loadContacts = useCallback(async () => {
|
||||
setContactsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessions()
|
||||
if (result.success && result.sessions) {
|
||||
// 系统账号和特殊前缀
|
||||
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
|
||||
|
||||
// 初步提取并过滤联系人
|
||||
const initialContacts = result.sessions
|
||||
.filter((s: any) => {
|
||||
if (!s.username) return false;
|
||||
const u = s.username.toLowerCase();
|
||||
|
||||
// 1. 排除群聊 (WeChat 群组以 @chatroom 结尾)
|
||||
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 排除公众号 (通常以 gh_ 开头)
|
||||
if (u.startsWith('gh_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 排除系统账号
|
||||
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false;
|
||||
if (u.startsWith('gh_')) return false;
|
||||
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false;
|
||||
return true;
|
||||
})
|
||||
.map((s: any) => ({
|
||||
@@ -149,7 +216,6 @@ export default function SnsPage() {
|
||||
}))
|
||||
setContacts(initialContacts)
|
||||
|
||||
// 异步进一步富化(获取更多准确的昵称和头像)
|
||||
const usernames = initialContacts.map(c => c.username)
|
||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||
if (enriched.success && enriched.contacts) {
|
||||
@@ -171,20 +237,69 @@ export default function SnsPage() {
|
||||
} finally {
|
||||
setContactsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadContacts()
|
||||
}, [])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadPosts(true)
|
||||
}, [selectedUsernames, searchKeyword, startDate, endDate])
|
||||
const checkSchema = async () => {
|
||||
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(undefined)
|
||||
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 { 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) => {
|
||||
// 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑
|
||||
// 或者保持原样。根据用户反馈“乱跳”,我们在这里选择:
|
||||
// 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。
|
||||
setJumpTargetDate(undefined);
|
||||
|
||||
setSelectedUsernames(prev => {
|
||||
if (prev.includes(username)) {
|
||||
return prev.filter(u => u !== username)
|
||||
@@ -214,8 +334,7 @@ export default function SnsPage() {
|
||||
const clearFilters = () => {
|
||||
setSearchKeyword('')
|
||||
setSelectedUsernames([])
|
||||
setStartDate('')
|
||||
setEndDate('')
|
||||
setJumpTargetDate(undefined)
|
||||
}
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
@@ -223,89 +342,122 @@ export default function SnsPage() {
|
||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||
)
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="sns-page">
|
||||
<div className="sns-container">
|
||||
{/* 侧边栏:过滤与搜索 */}
|
||||
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
|
||||
<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)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-content">
|
||||
{/* 关键词与时间 */}
|
||||
<div className="filter-group">
|
||||
<div className="filter-content custom-scrollbar">
|
||||
{/* 1. 搜索分组 (放到最顶上) */}
|
||||
<div className="filter-card">
|
||||
<div className="filter-section">
|
||||
<label><Search size={14} /> 关键词内容</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索正文..."
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-section">
|
||||
<label><Calendar size={14} /> 时间范围</label>
|
||||
<div className="date-inputs">
|
||||
<label><Search size={14} /> 关键词搜索</label>
|
||||
<div className="search-input-wrapper">
|
||||
<Search size={14} className="input-icon" />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={e => setStartDate(e.target.value)}
|
||||
/>
|
||||
<span>至</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
type="text"
|
||||
placeholder="搜索动态内容..."
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<button className="clear-input" onClick={() => setSearchKeyword('')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 联系人列表 */}
|
||||
<div className="contact-filter-section">
|
||||
<div className="section-header">
|
||||
<label><User size={14} /> 联系人筛选</label>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="selected-count">已选 {selectedUsernames.length}</span>
|
||||
{/* 2. 日期跳转 (放搜索下面) */}
|
||||
<div className="filter-card jump-date-card">
|
||||
<div className="filter-section">
|
||||
<label><Calendar size={14} /> 时间跳转</label>
|
||||
<button className={`jump-date-btn ${jumpTargetDate ? 'active' : ''}`} onClick={() => setShowJumpDialog(true)}>
|
||||
<span className="text">
|
||||
{jumpTargetDate ? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '选择跳转日期...'}
|
||||
</span>
|
||||
<Calendar size={14} className="icon" />
|
||||
</button>
|
||||
{jumpTargetDate && (
|
||||
<button className="clear-jump-date-inline" onClick={() => setJumpTargetDate(undefined)}>
|
||||
返回最新动态
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-search">
|
||||
<Search size={12} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索好友..."
|
||||
value={contactSearch}
|
||||
onChange={e => setContactSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="contact-list custom-scrollbar">
|
||||
{filteredContacts.map(contact => (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
|
||||
onClick={() => toggleUserSelection(contact.username)}
|
||||
>
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" />
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
{selectedUsernames.includes(contact.username) && (
|
||||
<div className="check-mark">✓</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 3. 联系人筛选 (放最下面,高度自适应) */}
|
||||
<div className="filter-card contact-card">
|
||||
<div className="contact-filter-section">
|
||||
<div className="section-header">
|
||||
<label><User size={14} /> 联系人</label>
|
||||
<div className="header-actions">
|
||||
{selectedUsernames.length > 0 && (
|
||||
<button className="clear-selection-btn" onClick={() => setSelectedUsernames([])}>清除</button>
|
||||
)}
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="selected-count">{selectedUsernames.length}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{contacts.length === 0 && !contactsLoading && (
|
||||
<div className="empty-contacts">无可显示联系人</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-search">
|
||||
<Search size={12} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索好友..."
|
||||
value={contactSearch}
|
||||
onChange={e => setContactSearch(e.target.value)}
|
||||
/>
|
||||
{contactSearch && (
|
||||
<X size={12} className="clear-search-icon" onClick={() => setContactSearch('')} />
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-list custom-scrollbar">
|
||||
{filteredContacts.map(contact => (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
|
||||
onClick={() => toggleUserSelection(contact.username)}
|
||||
>
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" />
|
||||
{selectedUsernames.includes(contact.username) && (
|
||||
<div className="active-badge"></div>
|
||||
)}
|
||||
</div>
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
<div className="check-box">
|
||||
{selectedUsernames.includes(contact.username) && <div className="inner-check"></div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredContacts.length === 0 && (
|
||||
<div className="empty-contacts">无可显示联系人</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button className="clear-btn" onClick={clearFilters}>清除全部筛选</button>
|
||||
<button className="clear-btn" onClick={clearFilters}>
|
||||
<RefreshCw size={14} />
|
||||
重置所有筛选
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -313,109 +465,146 @@ export default function SnsPage() {
|
||||
<div className="sns-header">
|
||||
<div className="header-left">
|
||||
{!isSidebarOpen && (
|
||||
<button className="icon-btn" onClick={() => setIsSidebarOpen(true)}>
|
||||
<button className="icon-btn sidebar-trigger" onClick={() => setIsSidebarOpen(true)}>
|
||||
<Filter size={20} />
|
||||
</button>
|
||||
)}
|
||||
<h2>朋友圈</h2>
|
||||
<h2>社交动态</h2>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button onClick={() => loadPosts(true)} disabled={loading} className="icon-btn refresh-btn">
|
||||
<RefreshCw size={18} className={loading ? 'spinning' : ''} />
|
||||
<button
|
||||
onClick={() => {
|
||||
if (jumpTargetDate) setJumpTargetDate(undefined);
|
||||
loadPosts({ reset: true });
|
||||
}}
|
||||
disabled={loading || loadingNewer}
|
||||
className="icon-btn refresh-btn"
|
||||
>
|
||||
<RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sns-content" onScroll={handleScroll}>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<div className="active-filters">
|
||||
<span>筛选中: {selectedUsernames.length} 位好友</span>
|
||||
<button onClick={() => setSelectedUsernames([])} className="clear-chip-btn">清除</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map(post => (
|
||||
<div key={post.id} className="sns-post">
|
||||
<div className="post-header">
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={44}
|
||||
shape="rounded"
|
||||
/>
|
||||
<div className="post-info">
|
||||
<div className="nickname">{post.nickname}</div>
|
||||
<div className="time">{formatTime(post.createTime)}</div>
|
||||
<div className="sns-content-wrapper">
|
||||
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
|
||||
<div className="posts-list">
|
||||
{loadingNewer && (
|
||||
<div className="status-indicator loading-newer">
|
||||
<RefreshCw size={16} className="spinning" />
|
||||
<span>正在检查更新的动态...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loadingNewer && hasNewer && (
|
||||
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
|
||||
查看更新的动态
|
||||
</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">
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={44}
|
||||
shape="rounded"
|
||||
/>
|
||||
<div className="post-info">
|
||||
<div className="nickname">{post.nickname}</div>
|
||||
<div className="time">{formatTime(post.createTime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="post-body">
|
||||
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
||||
<div className="post-body">
|
||||
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
||||
|
||||
{post.type === 15 ? (
|
||||
<div className="post-video-placeholder">
|
||||
[视频]
|
||||
</div>
|
||||
) : post.media.length > 0 && (
|
||||
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
||||
{post.media.map((m, idx) => (
|
||||
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
|
||||
))}
|
||||
{post.type === 15 ? (
|
||||
<div className="post-video-placeholder">
|
||||
<Play size={20} />
|
||||
<span>视频动态</span>
|
||||
</div>
|
||||
) : post.media.length > 0 && (
|
||||
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
||||
{post.media.map((m, idx) => (
|
||||
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||
<div className="post-footer">
|
||||
{post.likes.length > 0 && (
|
||||
<div className="likes-section">
|
||||
<Heart size={14} className="icon" />
|
||||
<span className="likes-list">
|
||||
{post.likes.join('、')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.comments.length > 0 && (
|
||||
<div className="comments-section">
|
||||
{post.comments.map((c, idx) => (
|
||||
<div key={idx} className="comment-item">
|
||||
<span className="comment-user">{c.nickname}</span>
|
||||
{c.refNickname && (
|
||||
<>
|
||||
<span className="reply-text">回复</span>
|
||||
<span className="comment-user">{c.refNickname}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="comment-separator">: </span>
|
||||
<span className="comment-content">{c.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading && <div className="status-indicator loading-more">
|
||||
<RefreshCw size={16} className="spinning" />
|
||||
<span>正在加载更多...</span>
|
||||
</div>}
|
||||
{!hasMore && posts.length > 0 && <div className="status-indicator no-more">已经到底啦</div>}
|
||||
{!loading && posts.length === 0 && (
|
||||
<div className="no-results">
|
||||
<div className="no-results-icon"><Search size={48} /></div>
|
||||
<p>未找到相关动态</p>
|
||||
{(selectedUsernames.length > 0 || searchKeyword) && (
|
||||
<button onClick={clearFilters} className="reset-inline">
|
||||
重置搜索条件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||
<div className="post-footer">
|
||||
{post.likes.length > 0 && (
|
||||
<div className="likes-section">
|
||||
<Heart size={14} className="icon" />
|
||||
<span className="likes-list">
|
||||
{post.likes.join('、')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.comments.length > 0 && (
|
||||
<div className="comments-section">
|
||||
{post.comments.map((c, idx) => (
|
||||
<div key={idx} className="comment-item">
|
||||
<span className="comment-user">{c.nickname}</span>
|
||||
{c.refNickname && (
|
||||
<>
|
||||
<span className="reply-text">回复</span>
|
||||
<span className="comment-user">{c.refNickname}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="comment-separator">: </span>
|
||||
<span className="comment-content">{c.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && <div className="loading-more">加载中...</div>}
|
||||
{!hasMore && posts.length > 0 && <div className="no-more">没有更多了</div>}
|
||||
{!loading && posts.length === 0 && (
|
||||
<div className="no-results">
|
||||
<p>没有找到符合条件的朋友圈</p>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<button onClick={() => setSelectedUsernames([])} className="reset-inline">
|
||||
清除人员筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{previewImage && (
|
||||
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
|
||||
)}
|
||||
<JumpToDateDialog
|
||||
isOpen={showJumpDialog}
|
||||
onClose={() => {
|
||||
setShowJumpDialog(false)
|
||||
}}
|
||||
onSelect={(date) => {
|
||||
setJumpTargetDate(date)
|
||||
setShowJumpDialog(false)
|
||||
}}
|
||||
currentDate={jumpTargetDate || new Date()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -269,15 +269,14 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
await configService.setDecryptKey(decryptKey)
|
||||
await configService.setMyWxid(wxid)
|
||||
await configService.setCachePath(cachePath)
|
||||
if (imageXorKey) {
|
||||
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
await configService.setImageXorKey(parsed)
|
||||
}
|
||||
}
|
||||
if (imageAesKey) {
|
||||
await configService.setImageAesKey(imageAesKey)
|
||||
}
|
||||
const parsedXorKey = imageXorKey ? parseInt(imageXorKey.replace(/^0x/i, ''), 16) : null
|
||||
await configService.setImageXorKey(typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0)
|
||||
await configService.setImageAesKey(imageAesKey || '')
|
||||
await configService.setWxidConfig(wxid, {
|
||||
decryptKey,
|
||||
imageXorKey: typeof parsedXorKey === 'number' && !Number.isNaN(parsedXorKey) ? parsedXorKey : 0,
|
||||
imageAesKey
|
||||
})
|
||||
await configService.setOnboardingDone(true)
|
||||
|
||||
setDbConnected(true, dbPath)
|
||||
@@ -313,6 +312,67 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (isDbConnected) {
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
<div className="welcome-container">
|
||||
{showWindowControls && (
|
||||
<div className="window-controls">
|
||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="welcome-sidebar">
|
||||
<div className="sidebar-header">
|
||||
<img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
|
||||
<div className="sidebar-brand">
|
||||
<span className="brand-name">WeFlow</span>
|
||||
<span className="brand-tag">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-spacer" style={{ flex: 1 }} />
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<ShieldCheck size={14} />
|
||||
<span>本地安全存储</span>
|
||||
</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
|
||||
className="btn btn-primary btn-large"
|
||||
onClick={() => {
|
||||
if (standalone) {
|
||||
setIsClosing(true)
|
||||
setTimeout(() => {
|
||||
window.electronAPI.window.completeOnboarding()
|
||||
}, 450)
|
||||
} else {
|
||||
navigate('/home')
|
||||
}
|
||||
}}
|
||||
>
|
||||
进入首页 <ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
<div className="welcome-container">
|
||||
{showWindowControls && (
|
||||
<div className="window-controls">
|
||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||
@@ -323,234 +383,204 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="welcome-shell">
|
||||
<div className="welcome-panel">
|
||||
<div className="panel-header">
|
||||
<img src="./logo.png" alt="WeFlow" className="panel-logo" />
|
||||
<div>
|
||||
<p className="panel-kicker">WeFlow</p>
|
||||
<h1>已连接数据库</h1>
|
||||
</div>
|
||||
<div className="welcome-sidebar">
|
||||
<div className="sidebar-header">
|
||||
<img src="./logo.png" alt="WeFlow" className="sidebar-logo" />
|
||||
<div className="sidebar-brand">
|
||||
<span className="brand-name">WeFlow</span>
|
||||
<span className="brand-tag">Setup</span>
|
||||
</div>
|
||||
<div className="panel-note">
|
||||
<CheckCircle2 size={16} />
|
||||
<span>配置已完成,可直接进入首页</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-full"
|
||||
onClick={() => {
|
||||
if (standalone) {
|
||||
setIsClosing(true)
|
||||
setTimeout(() => {
|
||||
window.electronAPI.window.completeOnboarding()
|
||||
}, 450)
|
||||
} else {
|
||||
navigate('/home')
|
||||
}
|
||||
}}
|
||||
>
|
||||
进入首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
{showWindowControls && (
|
||||
<div className="window-controls">
|
||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="welcome-shell">
|
||||
<div className="welcome-panel">
|
||||
<div className="panel-header">
|
||||
<img src="./logo.png" alt="WeFlow" className="panel-logo" />
|
||||
<div>
|
||||
<p className="panel-kicker">首次配置</p>
|
||||
<h1>WeFlow 初始引导</h1>
|
||||
<p className="panel-subtitle">一步一步完成数据库与密钥设置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="step-list">
|
||||
<div className="sidebar-nav">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className={`step-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'done' : ''}`}>
|
||||
<div className="step-index">{index < stepIndex ? <CheckCircle2 size={14} /> : index + 1}</div>
|
||||
<div>
|
||||
<div className="step-title">{step.title}</div>
|
||||
<div className="step-desc">{step.desc}</div>
|
||||
<div key={step.id} className={`nav-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'completed' : ''}`}>
|
||||
<div className="nav-indicator">
|
||||
{index < stepIndex ? <CheckCircle2 size={14} /> : <div className="dot" />}
|
||||
</div>
|
||||
<div className="nav-info">
|
||||
<div className="nav-title">{step.title}</div>
|
||||
<div className="nav-desc">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="panel-foot">
|
||||
<ShieldCheck size={16} />
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<ShieldCheck size={14} />
|
||||
<span>数据仅在本地处理,不上传服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setup-card">
|
||||
<div className="setup-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 className="welcome-content">
|
||||
<div className="content-header">
|
||||
<div>
|
||||
<h2>{currentStep.title}</h2>
|
||||
<p>{currentStep.desc}</p>
|
||||
<p className="header-desc">{currentStep.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStep.id === 'intro' && (
|
||||
<div className="setup-body">
|
||||
<div className="intro-card">
|
||||
<Wand2 size={18} />
|
||||
<div>
|
||||
<h3>准备好了吗?</h3>
|
||||
<p>接下来只需配置数据库目录和获取解密密钥。</p>
|
||||
<div className="content-body">
|
||||
{currentStep.id === 'intro' && (
|
||||
<div className="intro-block">
|
||||
{/* 内容移至底部 */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'db' && (
|
||||
<div className="form-group">
|
||||
<label className="field-label">数据库根目录</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="例如:C:\\Users\\xxx\\Documents\\xwechat_files"
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'db' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">数据库根目录</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="例如:C:\\Users\\xxx\\Documents\\xwechat_files"
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
/>
|
||||
<div className="button-row">
|
||||
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSelectPath}>
|
||||
<FolderOpen size={16} /> 浏览选择
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
||||
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'cache' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">缓存目录</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="留空使用默认目录"
|
||||
value={cachePath}
|
||||
onChange={(e) => setCachePath(e.target.value)}
|
||||
/>
|
||||
<div className="button-row">
|
||||
<button className="btn btn-primary" onClick={handleSelectCachePath}>
|
||||
<FolderOpen size={16} /> 浏览选择
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}>
|
||||
<RotateCcw size={16} /> 使用默认
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">用于头像、表情与图片缓存,留空使用默认目录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'key' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">微信账号 wxid</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="获取密钥后将自动填充"
|
||||
value={wxid}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
/>
|
||||
<label className="field-label">解密密钥</label>
|
||||
<div className="field-with-toggle">
|
||||
<input
|
||||
type={showDecryptKey ? 'text' : 'password'}
|
||||
className="field-input"
|
||||
placeholder="64 位十六进制密钥"
|
||||
value={decryptKey}
|
||||
onChange={(e) => setDecryptKey(e.target.value.trim())}
|
||||
/>
|
||||
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
||||
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isManualStartPrompt ? (
|
||||
<div className="manual-prompt">
|
||||
<p className="prompt-text">未能自动启动微信,请手动启动并登录后点击下方确认</p>
|
||||
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
||||
我已启动微信,继续检测
|
||||
<div className="action-row">
|
||||
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleSelectPath}>
|
||||
<FolderOpen size={16} /> 浏览...
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
|
||||
{isFetchingDbKey ? '获取中...' : '自动获取密钥'}
|
||||
|
||||
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
||||
<div className="field-hint warning">
|
||||
⚠️ 目录路径不可包含中文,如有中文请先在微信中迁移至全英文目录
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'cache' && (
|
||||
<div className="form-group">
|
||||
<label className="field-label">缓存目录</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="留空即使用默认目录"
|
||||
value={cachePath}
|
||||
onChange={(e) => setCachePath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectCachePath}>
|
||||
<FolderOpen size={16} /> 浏览
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}>
|
||||
<RotateCcw size={16} /> 重置默认
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">用于头像、表情与图片缓存</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'key' && (
|
||||
<div className="form-group">
|
||||
<label className="field-label">微信账号 (Wxid)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="等待获取..."
|
||||
value={wxid}
|
||||
readOnly
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
/>
|
||||
|
||||
<label className="field-label mt-4">解密密钥</label>
|
||||
<div className="field-with-toggle">
|
||||
<input
|
||||
type={showDecryptKey ? 'text' : 'password'}
|
||||
className="field-input"
|
||||
placeholder="64 位十六进制密钥"
|
||||
value={decryptKey}
|
||||
onChange={(e) => setDecryptKey(e.target.value.trim())}
|
||||
/>
|
||||
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
||||
{showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="key-actions">
|
||||
{isManualStartPrompt ? (
|
||||
<div className="manual-prompt">
|
||||
<p>未能自动启动微信,请手动启动并登录</p>
|
||||
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
||||
我已登录,继续
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-block" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
|
||||
{isFetchingDbKey ? '正在获取...' : '自动获取密钥'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dbKeyStatus && <div className="status-message">{dbKeyStatus}</div>}
|
||||
<div className="field-hint">点击自动获取后微信将重启,请留意弹窗提示</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'image' && (
|
||||
<div className="form-group">
|
||||
<div className="grid-2">
|
||||
<div>
|
||||
<label className="field-label">图片 XOR 密钥</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="0x..."
|
||||
value={imageXorKey}
|
||||
onChange={(e) => setImageXorKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">图片 AES 密钥</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="16位密钥"
|
||||
value={imageAesKey}
|
||||
onChange={(e) => setImageAesKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示<span style={{color: 'red'}}>hook安装成功,现在登录微信</span>后再点击登录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'image' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">图片 XOR 密钥</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="例如:0xA4"
|
||||
value={imageXorKey}
|
||||
onChange={(e) => setImageXorKey(e.target.value)}
|
||||
/>
|
||||
<label className="field-label">图片 AES 密钥</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="16 位密钥"
|
||||
value={imageAesKey}
|
||||
onChange={(e) => setImageAesKey(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
||||
<div className="field-hint">请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作</div>
|
||||
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
)}
|
||||
{imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
|
||||
<div className="field-hint">请在微信中打开几张图片后再点击获取</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="setup-actions">
|
||||
<button className="btn btn-tertiary" onClick={handleBack} disabled={stepIndex === 0}>
|
||||
{currentStep.id === 'intro' && (
|
||||
<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} /> 上一步
|
||||
</button>
|
||||
|
||||
{stepIndex < steps.length - 1 ? (
|
||||
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
|
||||
下一步 <ArrowRight size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
|
||||
{isConnecting ? '连接中...' : '测试并完成'}
|
||||
{isConnecting ? '连接中...' : '完成配置'} <ArrowRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const CONFIG_KEYS = {
|
||||
DECRYPT_KEY: 'decryptKey',
|
||||
DB_PATH: 'dbPath',
|
||||
MY_WXID: 'myWxid',
|
||||
WXID_CONFIGS: 'wxidConfigs',
|
||||
THEME: 'theme',
|
||||
THEME_ID: 'themeId',
|
||||
LAST_SESSION: 'lastSession',
|
||||
@@ -27,9 +28,17 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||
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
|
||||
|
||||
export interface WxidConfig {
|
||||
decryptKey?: string
|
||||
imageXorKey?: number
|
||||
imageAesKey?: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
// 获取解密密钥
|
||||
export async function getDecryptKey(): Promise<string | null> {
|
||||
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)
|
||||
}
|
||||
|
||||
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'> {
|
||||
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> {
|
||||
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)
|
||||
}
|
||||
|
||||
3
src/types/electron.d.ts
vendored
3
src/types/electron.d.ts
vendored
@@ -100,6 +100,7 @@ export interface ElectronAPI {
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
|
||||
}
|
||||
|
||||
image: {
|
||||
@@ -351,7 +352,9 @@ export interface ExportOptions {
|
||||
exportEmojis?: boolean
|
||||
exportVoiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
txtColumns?: string[]
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
|
||||
Reference in New Issue
Block a user