init: 初始化
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# WeChat Database Key (Optional, can be entered in UI)
|
||||||
|
VITE_DB_KEY=
|
||||||
|
|
||||||
|
# DeepSeek API Key (Optional, can be entered in UI)
|
||||||
|
VITE_DEEPSEEK_API_KEY=
|
||||||
|
|
||||||
|
# Message types to filter out (comma separated)
|
||||||
|
VITE_FILTER_MSG_TYPES=分享消息,图片,表情包,视频
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.eslintcache
|
||||||
|
*.log*
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
out
|
||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
|
LICENSE.md
|
||||||
|
tsconfig.json
|
||||||
|
tsconfig.*.json
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
singleQuote: true
|
||||||
|
semi: false
|
||||||
|
printWidth: 100
|
||||||
|
trailingComma: none
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Main Process",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceRoot}",
|
||||||
|
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
|
||||||
|
"windows": {
|
||||||
|
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||||
|
},
|
||||||
|
"runtimeArgs": ["--sourcemap"],
|
||||||
|
"env": {
|
||||||
|
"REMOTE_DEBUGGING_PORT": "9222"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Renderer Process",
|
||||||
|
"port": 9222,
|
||||||
|
"request": "attach",
|
||||||
|
"type": "chrome",
|
||||||
|
"webRoot": "${workspaceFolder}/src/renderer",
|
||||||
|
"timeout": 60000,
|
||||||
|
"presentation": {
|
||||||
|
"hidden": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Debug All",
|
||||||
|
"configurations": ["Debug Main Process", "Debug Renderer Process"],
|
||||||
|
"presentation": {
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 WechatExplorer Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# WechatExplorer
|
||||||
|
|
||||||
|
WechatExplorer 是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
||||||
|
|
||||||
|
|
||||||
|
## 📸 预览
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between;">
|
||||||
|
<img src="./public/WX20251215-155324.png" alt="预览图1" width="48%" />
|
||||||
|
<img src="./public/WX20251215-155524.png" alt="预览图2" width="48%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录。
|
||||||
|
- **全局搜索**: 快速搜索聊天内容。
|
||||||
|
- **AI 智能总结**: 集成 DeepSeek AI,一键总结群聊精华内容,生成话题报告。
|
||||||
|
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||||
|
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
||||||
|
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
- **Electron**: 跨平台桌面应用框架
|
||||||
|
- **React**: 用于构建用户界面
|
||||||
|
- **TypeScript**: 提供类型安全
|
||||||
|
- **Vite**: 极速构建工具
|
||||||
|
- **Better-SQLite3**: 高性能 SQLite 数据库操作
|
||||||
|
- **OpenAI SDK**: 用于调用 DeepSeek API
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Node.js (推荐 v16+)
|
||||||
|
- pnpm (推荐) 或 npm
|
||||||
|
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
|
||||||
|
- [DeepSeek API Key](https://www.deepseek.com/) (用于 AI 总结功能)
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://github.com/your-username/WechatExplorer.git
|
||||||
|
|
||||||
|
# 进入目录
|
||||||
|
cd WechatExplorer
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
复制 `.env.example` 文件为 `.env`,并填写相关配置(可选,也可以在应用内设置):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发模式运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 打包构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
pnpm build:win
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
pnpm build:mac
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
pnpm build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ 免责声明
|
||||||
|
|
||||||
|
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
[MIT](./LICENSE) License
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
appId: com.electron.app
|
||||||
|
productName: wechatexplorer
|
||||||
|
directories:
|
||||||
|
buildResources: build
|
||||||
|
files:
|
||||||
|
- out
|
||||||
|
- '!**/.vscode/*'
|
||||||
|
- '!src/*'
|
||||||
|
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||||
|
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||||
|
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||||
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
|
extraMetadata:
|
||||||
|
main: out/main/index.js
|
||||||
|
asarUnpack:
|
||||||
|
- resources/**
|
||||||
|
win:
|
||||||
|
executableName: wechatexplorer
|
||||||
|
nsis:
|
||||||
|
artifactName: ${name}-${version}-setup.${ext}
|
||||||
|
shortcutName: ${productName}
|
||||||
|
uninstallDisplayName: ${productName}
|
||||||
|
createDesktopShortcut: always
|
||||||
|
mac:
|
||||||
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
|
extendInfo:
|
||||||
|
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||||
|
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||||
|
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||||
|
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||||
|
notarize: false
|
||||||
|
dmg:
|
||||||
|
artifactName: ${name}-${version}.${ext}
|
||||||
|
linux:
|
||||||
|
target:
|
||||||
|
- AppImage
|
||||||
|
- snap
|
||||||
|
- deb
|
||||||
|
maintainer: electronjs.org
|
||||||
|
category: Utility
|
||||||
|
appImage:
|
||||||
|
artifactName: ${name}-${version}.${ext}
|
||||||
|
npmRebuild: false
|
||||||
|
publish:
|
||||||
|
provider: generic
|
||||||
|
url: https://example.com/auto-updates
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {},
|
||||||
|
preload: {},
|
||||||
|
renderer: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve('src/renderer/src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react()]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from 'eslint/config'
|
||||||
|
import tseslint from '@electron-toolkit/eslint-config-ts'
|
||||||
|
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
|
||||||
|
import eslintPluginReact from 'eslint-plugin-react'
|
||||||
|
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
eslintPluginReact.configs.flat.recommended,
|
||||||
|
eslintPluginReact.configs.flat['jsx-runtime'],
|
||||||
|
{
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': eslintPluginReactHooks,
|
||||||
|
'react-refresh': eslintPluginReactRefresh
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...eslintPluginReactHooks.configs.recommended.rules,
|
||||||
|
...eslintPluginReactRefresh.configs.vite.rules
|
||||||
|
}
|
||||||
|
},
|
||||||
|
eslintConfigPrettier
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "wechatexplorer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "mac 版本获取聊天记录, AI群聊总结助手",
|
||||||
|
"author": "Qingmao",
|
||||||
|
"main": "./out/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "eslint --cache .",
|
||||||
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
"start": "electron-vite preview",
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "npm run typecheck && electron-vite build",
|
||||||
|
"postinstall": "electron-builder install-app-deps",
|
||||||
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
|
"build:win": "npm run build && electron-builder --win",
|
||||||
|
"build:mac": "electron-vite build && electron-builder --mac",
|
||||||
|
"build:linux": "electron-vite build && electron-builder --linux"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"better-sqlite3-multiple-ciphers": "^12.5.0",
|
||||||
|
"fs-extra": "^11.3.2",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
|
"openai": "^6.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||||
|
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||||
|
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||||
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/node": "^22.19.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"electron": "^39.2.6",
|
||||||
|
"electron-builder": "^26.0.12",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"react": "^19.2.1",
|
||||||
|
"react-dom": "^19.2.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.6"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"electron",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 463 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,244 @@
|
|||||||
|
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
|
import icon from '../../resources/icon.png?asset'
|
||||||
|
import { WechatDb, Contact, WechatMessage } from './wechat-db'
|
||||||
|
|
||||||
|
let wechatDb: WechatDb | null = null
|
||||||
|
|
||||||
|
function createWindow(): void {
|
||||||
|
// 创建浏览器窗口
|
||||||
|
const mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 670,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
...(process.platform === 'linux' ? { icon } : {}),
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
mainWindow.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
|
shell.openExternal(details.url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 基于 electron-vite cli 的渲染器 HMR
|
||||||
|
// 加载开发环境的远程 URL 或生产环境的本地 html 文件
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
|
||||||
|
// 某些 API 只能在此事件发生后使用
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// 为窗口设置应用程序用户模型 ID
|
||||||
|
electronApp.setAppUserModelId('com.electron')
|
||||||
|
|
||||||
|
// 在开发环境中默认按 F12 打开或关闭 DevTools
|
||||||
|
// 在生产环境中忽略 CommandOrControl + R
|
||||||
|
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||||
|
app.on('browser-window-created', (_, window) => {
|
||||||
|
optimizer.watchWindowShortcuts(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
// IPC test
|
||||||
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
|
||||||
|
ipcMain.handle('db:init', (_, key: string) => {
|
||||||
|
try {
|
||||||
|
wechatDb = new WechatDb(key)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to init DB:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:getContacts', (_, filter?: string) => {
|
||||||
|
if (!wechatDb) return []
|
||||||
|
|
||||||
|
const contacts: Contact[] = []
|
||||||
|
const groupContacts = wechatDb.getAllGroupContacts()
|
||||||
|
const userList = wechatDb.getUserList(filter)
|
||||||
|
const existingMd5s = new Set<string>()
|
||||||
|
|
||||||
|
// 1. 处理普通联系人
|
||||||
|
for (const user of userList) {
|
||||||
|
const md5 = wechatDb.md5(user.m_nsUsrName)
|
||||||
|
existingMd5s.add(md5)
|
||||||
|
contacts.push({
|
||||||
|
m_nsUsrName: user.m_nsUsrName,
|
||||||
|
m_nsNickName: user.nickname || '未知用户',
|
||||||
|
md5: md5,
|
||||||
|
type: 'user'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 处理聊天表
|
||||||
|
const chatTables = wechatDb.getAllChatTables()
|
||||||
|
for (const table of chatTables) {
|
||||||
|
if (!table.name.startsWith('Chat_')) continue
|
||||||
|
const md5 = table.name.substring(5)
|
||||||
|
|
||||||
|
if (!existingMd5s.has(md5)) {
|
||||||
|
if (groupContacts[md5]) {
|
||||||
|
contacts.push({
|
||||||
|
m_nsUsrName: `Group_${md5}`,
|
||||||
|
m_nsNickName: groupContacts[md5],
|
||||||
|
md5: md5,
|
||||||
|
type: 'group'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contacts.push({
|
||||||
|
m_nsUsrName: `Unknown_${md5}`,
|
||||||
|
m_nsNickName: `Chat_${md5}`,
|
||||||
|
md5: md5,
|
||||||
|
type: 'user'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contacts
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||||
|
if (!wechatDb) return []
|
||||||
|
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
|
||||||
|
const groupMembers = wechatDb.getAllGroupMembers()
|
||||||
|
|
||||||
|
return rawMessages.map((msg: WechatMessage) => {
|
||||||
|
const typeDict: Record<number, string> = {
|
||||||
|
1: '普通文本',
|
||||||
|
3: '图片',
|
||||||
|
34: '语音',
|
||||||
|
43: '视频',
|
||||||
|
47: '表情包',
|
||||||
|
48: '位置',
|
||||||
|
49: '分享消息',
|
||||||
|
10000: '系统消息'
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgType = parseInt(msg.messageType)
|
||||||
|
const createTime = parseInt(msg.msgCreateTime)
|
||||||
|
const date = new Date(createTime * 1000)
|
||||||
|
|
||||||
|
let content = msg.msgContent
|
||||||
|
let img = ''
|
||||||
|
let name = ''
|
||||||
|
// 检查内容是否以 wxid 开头并包含冒号
|
||||||
|
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
|
||||||
|
if (content && typeof content === 'string') {
|
||||||
|
const colonIndex = content.indexOf(':')
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
const potentialWxid = content.substring(0, colonIndex)
|
||||||
|
if (potentialWxid.startsWith('wxid_')) {
|
||||||
|
// 尝试获取头像
|
||||||
|
if (wechatDb) {
|
||||||
|
const member = wechatDb.getGroupMember(potentialWxid)
|
||||||
|
if (member) {
|
||||||
|
img = member.m_nsHeadImgUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupMembers[potentialWxid]) {
|
||||||
|
const nickname = groupMembers[potentialWxid]
|
||||||
|
name = nickname
|
||||||
|
content = content.substring(colonIndex + 1) // +1 to skip the colon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg.mesLocalID || Math.random().toString(),
|
||||||
|
from: msg.mesDes === '1' ? 'user' : 'assistant', // 1 通常是接收到的,0 是发送的?需要验证。Swift 说:[1: "user", 0: "assistant"]
|
||||||
|
type: typeDict[msgType] || msg.messageType,
|
||||||
|
datetime: date.toLocaleString('zh-CN', { hour12: false }),
|
||||||
|
content: content,
|
||||||
|
isSender: msg.mesDes === '0',
|
||||||
|
img: img,
|
||||||
|
name: name
|
||||||
|
// 实际上让我们坚持我们观察到的:1=用户(其他人), 0=助手(我)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:search', (_, keyword: string) => {
|
||||||
|
if (!wechatDb) return null
|
||||||
|
return wechatDb.searchAllMessages(keyword)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'ai:chat',
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
options?: { apiKey?: string; model?: string }
|
||||||
|
) => {
|
||||||
|
// @ts-ignore: vite env
|
||||||
|
const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY
|
||||||
|
const model = options?.model || 'deepseek-chat'
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return { success: false, error: '未配置 DeepSeek API Key' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态导入以避免如果未安装或初始类型缺失的问题
|
||||||
|
const { OpenAI } = await import('openai')
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
baseURL: 'https://api.deepseek.com',
|
||||||
|
apiKey: apiKey
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
messages: messages as any,
|
||||||
|
model: model
|
||||||
|
})
|
||||||
|
return { success: true, data: completion.choices[0].message.content }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('DeepSeek API Error:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return { success: false, error: errorMessage }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle('copy-image', async (_, base64String) => {
|
||||||
|
try {
|
||||||
|
const image = nativeImage.createFromDataURL(base64String)
|
||||||
|
clipboard.writeImage(image)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
app.on('activate', function () {
|
||||||
|
// 在 macOS 上,当点击 dock 图标且没有其他窗口打开时,
|
||||||
|
// 通常会在应用程序中重新创建一个窗口。
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当所有窗口关闭时退出,除了 macOS。在那里,
|
||||||
|
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
||||||
|
// 显式使用 Cmd + Q 退出。
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import DatabaseConstructor from 'better-sqlite3-multiple-ciphers'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import os from 'os'
|
||||||
|
|
||||||
|
type Database = import('better-sqlite3-multiple-ciphers').Database
|
||||||
|
|
||||||
|
export interface UserContact {
|
||||||
|
m_nsUsrName: string
|
||||||
|
nickname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatMessage {
|
||||||
|
mesLocalID: string
|
||||||
|
mesDes: string
|
||||||
|
messageType: string
|
||||||
|
msgCreateTime: string
|
||||||
|
msgContent: string
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
m_nsUsrName: string
|
||||||
|
m_nsNickName: string
|
||||||
|
md5: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberInfo {
|
||||||
|
m_nsUsrName: string
|
||||||
|
nickname: string
|
||||||
|
m_nsHeadImgUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WechatDb {
|
||||||
|
private static WECHAT_DIR = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
|
||||||
|
)
|
||||||
|
|
||||||
|
private rawKey: string
|
||||||
|
private correctUserId: string | null = null
|
||||||
|
private chatDb: { name: string; db_number: string }[] | null = null
|
||||||
|
private groupMemberCache = new Map<string, GroupMemberInfo | null>()
|
||||||
|
|
||||||
|
constructor(rawKey: string) {
|
||||||
|
this.rawKey = rawKey
|
||||||
|
console.log(`Initializing WechatDb with key: ${rawKey}`)
|
||||||
|
|
||||||
|
if (!fs.existsSync(WechatDb.WECHAT_DIR)) {
|
||||||
|
throw new Error(`WeChat directory not found at ${WechatDb.WECHAT_DIR}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.getUser()) {
|
||||||
|
console.log('User found, getting chat DB number')
|
||||||
|
this.chatDb = this.getChatDbNumber()
|
||||||
|
} else {
|
||||||
|
throw new Error('No valid user found or invalid key')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectDb(dbPath: string): Database | null {
|
||||||
|
// 1. 路径预检查
|
||||||
|
const targetPath = path.resolve(dbPath)
|
||||||
|
if (!fs.existsSync(targetPath)) {
|
||||||
|
console.error(`❌ 错误:数据库文件不存在于路径:${targetPath}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// console.log(`🔌 正在尝试连接至:${path.basename(targetPath)}`)
|
||||||
|
// console.log('路径是', targetPath)
|
||||||
|
// 2. 初始化数据库句柄
|
||||||
|
// 关键:使用 better-sqlite3-multiple-ciphers 的构造函数
|
||||||
|
// verbose 选项有助于调试 Pragma 的执行情况
|
||||||
|
const db = new DatabaseConstructor(targetPath, {
|
||||||
|
// verbose: console.log
|
||||||
|
})
|
||||||
|
|
||||||
|
// 此时 db 实例已创建,但因为未提供密钥,任何数据读取都会失败。
|
||||||
|
// 必须立即配置加密参数。
|
||||||
|
|
||||||
|
// 3. 配置加密方案 (Cipher Scheme)
|
||||||
|
// 必须显式切换到 'sqlcipher',因为该库默认可能使用 'chacha20'
|
||||||
|
db.pragma("cipher='sqlcipher'")
|
||||||
|
|
||||||
|
// 4. 配置页大小 (Page Size)
|
||||||
|
// macOS 版 WeChat 使用 1024 字节。这必须在设置密钥前(或紧随其后)生效。
|
||||||
|
db.pragma('cipher_page_size = 1024')
|
||||||
|
|
||||||
|
// 5. 配置兼容性与算法细节
|
||||||
|
// 显式指定 SQLCipher 3 的行为
|
||||||
|
db.pragma('legacy=3')
|
||||||
|
db.pragma('kdf_iter = 64000')
|
||||||
|
db.pragma('cipher_hmac_algorithm = HMAC_SHA1')
|
||||||
|
db.pragma('cipher_kdf_algorithm = PBKDF2_HMAC_SHA1')
|
||||||
|
|
||||||
|
// 6. 应用密钥
|
||||||
|
// 使用正确的 hex 语法: key = "x'HEXSTRING'"
|
||||||
|
const processedKey = this.processRawKey(this.rawKey)
|
||||||
|
// console.log(`🔑 应用密钥 (前6位): ${processedKey.substring(0, 6)}...`)
|
||||||
|
|
||||||
|
db.pragma(`key = "x'${processedKey}'"`)
|
||||||
|
|
||||||
|
// 7. 验证连接
|
||||||
|
// 如果参数错误,这里将抛出 "file is not a database" 或 "HMAC check failed"
|
||||||
|
db.prepare('SELECT count(*) as count FROM sqlite_master').get()
|
||||||
|
|
||||||
|
// console.log(`✅ 连接成功!当前数据库 ${path.basename(targetPath)} 包含 ${row.count} 张表。`)
|
||||||
|
return db
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 连接失败:`, error)
|
||||||
|
|
||||||
|
const errString = String(error)
|
||||||
|
if (errString.includes('file is not a database')) {
|
||||||
|
console.error(
|
||||||
|
"💡 诊断建议:\n1. 请确认是否已安装 'better-sqlite3-multiple-ciphers'。\n2. 检查密钥是否正确(WeChat密钥与设备强绑定)。\n3. 尝试将页大小改为 4096 (db.pragma('cipher_page_size = 4096'))。"
|
||||||
|
)
|
||||||
|
} else if (errString.includes('HMAC')) {
|
||||||
|
console.error(
|
||||||
|
'💡 诊断建议:密钥可能正确,但HMAC算法或页大小不匹配。尝试调整 cipher_hmac_algorithm。'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private processRawKey(key: string): string {
|
||||||
|
// 移除可能的 '0x' 前缀并修剪空格
|
||||||
|
return key.replace(/^0x/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUser(): boolean {
|
||||||
|
const keyValuePath = path.join(WechatDb.WECHAT_DIR, 'KeyValue')
|
||||||
|
if (!fs.existsSync(keyValuePath)) return false
|
||||||
|
|
||||||
|
const contents = fs.readdirSync(keyValuePath)
|
||||||
|
const potentialUsers = contents.filter((name) => !name.startsWith('.'))
|
||||||
|
|
||||||
|
for (const user of potentialUsers) {
|
||||||
|
const dbPath = path.join(keyValuePath, user, 'KeyValue.db')
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
const db = this.connectDb(dbPath)
|
||||||
|
if (db) {
|
||||||
|
this.correctUserId = user
|
||||||
|
console.log(`Valid user found: ${user}`)
|
||||||
|
db.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private getChatDbNumber(): { name: string; db_number: string }[] {
|
||||||
|
const chatDb: { name: string; db_number: string }[] = []
|
||||||
|
if (!this.correctUserId) return []
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Message', `msg_${i}.db`)
|
||||||
|
if (!fs.existsSync(dbPath)) continue
|
||||||
|
|
||||||
|
const db = this.connectDb(dbPath)
|
||||||
|
if (db) {
|
||||||
|
const tables = db
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
|
||||||
|
.all() as { name: string }[]
|
||||||
|
for (const table of tables) {
|
||||||
|
chatDb.push({ name: table.name, db_number: `msg_${i}.db` })
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chatDb
|
||||||
|
}
|
||||||
|
|
||||||
|
public getUserList(nicknameFilter?: string): UserContact[] {
|
||||||
|
if (!this.correctUserId) return []
|
||||||
|
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Contact/wccontact_new2.db')
|
||||||
|
const db = this.connectDb(dbPath)
|
||||||
|
if (!db) return []
|
||||||
|
|
||||||
|
let query = "SELECT m_nsUsrName, nickname FROM WCContact WHERE m_nsUsrName NOT LIKE 'gh_%'"
|
||||||
|
if (nicknameFilter) {
|
||||||
|
query += ` AND nickname LIKE '%${nicknameFilter}%'`
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = db.prepare(query).all() as unknown as UserContact[]
|
||||||
|
db.close()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllGroupContacts(): Record<string, string> {
|
||||||
|
if (!this.correctUserId) return {}
|
||||||
|
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
|
||||||
|
const db = this.connectDb(dbPath)
|
||||||
|
if (!db) return {}
|
||||||
|
|
||||||
|
const results = db.prepare('SELECT m_nsUsrName, nickname FROM GroupContact').all() as {
|
||||||
|
m_nsUsrName: string
|
||||||
|
nickname: string
|
||||||
|
}[]
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
const groupContacts: Record<string, string> = {}
|
||||||
|
for (const row of results) {
|
||||||
|
if (row.m_nsUsrName && row.nickname) {
|
||||||
|
const md5 = this.md5(row.m_nsUsrName)
|
||||||
|
groupContacts[md5] = row.nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groupContacts
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllGroupMembers(): Record<string, string> {
|
||||||
|
if (!this.correctUserId) return {}
|
||||||
|
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
|
||||||
|
const db = this.connectDb(dbPath)
|
||||||
|
if (!db) return {}
|
||||||
|
|
||||||
|
// 查询 GroupMember 表
|
||||||
|
const results = db.prepare('SELECT m_nsUsrName, nickname FROM GroupMember').all() as {
|
||||||
|
m_nsUsrName: string
|
||||||
|
nickname: string
|
||||||
|
}[]
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
const groupMembers: Record<string, string> = {}
|
||||||
|
for (const row of results) {
|
||||||
|
if (row.m_nsUsrName && row.nickname) {
|
||||||
|
// 将 wxid (m_nsUsrName) 映射到昵称
|
||||||
|
groupMembers[row.m_nsUsrName] = row.nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groupMembers
|
||||||
|
}
|
||||||
|
|
||||||
|
public getGroupMember(wxid: string): GroupMemberInfo | null {
|
||||||
|
if (!this.correctUserId) return null
|
||||||
|
|
||||||
|
// 1. 检查缓存
|
||||||
|
if (this.groupMemberCache.has(wxid)) {
|
||||||
|
return this.groupMemberCache.get(wxid) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
|
||||||
|
const db = this.connectDb(dbPath)
|
||||||
|
if (!db) {
|
||||||
|
this.groupMemberCache.set(wxid, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT m_nsUsrName, nickname, m_nsHeadImgUrl FROM GroupMember WHERE m_nsUsrName = ?'
|
||||||
|
)
|
||||||
|
.get(wxid) as GroupMemberInfo
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
// 2. 写入缓存
|
||||||
|
this.groupMemberCache.set(wxid, result || null)
|
||||||
|
|
||||||
|
return result || null
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllChatTables(): { name: string; db_number: string }[] {
|
||||||
|
return this.chatDb || []
|
||||||
|
}
|
||||||
|
|
||||||
|
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
|
||||||
|
if (!this.chatDb || !this.correctUserId) return []
|
||||||
|
|
||||||
|
const tableName = `Chat_${userMd5}`
|
||||||
|
const tableInfo = this.chatDb.find((t) => t.name === tableName)
|
||||||
|
|
||||||
|
if (!tableInfo) {
|
||||||
|
console.error(`No table found for ${tableName}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = path.join(
|
||||||
|
WechatDb.WECHAT_DIR,
|
||||||
|
this.correctUserId,
|
||||||
|
'Message',
|
||||||
|
tableInfo.db_number
|
||||||
|
)
|
||||||
|
const db = this.connectDb(dbPath)
|
||||||
|
if (!db) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
let query = `SELECT * FROM ${tableName}`
|
||||||
|
const conditions: string[] = []
|
||||||
|
|
||||||
|
// console.log(startTime, endTime, 'time range')
|
||||||
|
|
||||||
|
if (startTime) {
|
||||||
|
conditions.push(`msgCreateTime >= ${startTime}`)
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
conditions.push(`msgCreateTime <= ${endTime}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
query += ` WHERE ${conditions.join(' AND ')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加排序以确保存储正确的顺序
|
||||||
|
query += ` ORDER BY msgCreateTime ASC`
|
||||||
|
|
||||||
|
const results = db.prepare(query).all() as unknown as WechatMessage[]
|
||||||
|
db.close()
|
||||||
|
return results
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error querying messages for ${tableName}:`, e)
|
||||||
|
db.close()
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public searchAllMessages(keyword: string): string | null {
|
||||||
|
if (!this.correctUserId) return null
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const dbName = `msg_${i}.db`
|
||||||
|
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Message', dbName)
|
||||||
|
if (!fs.existsSync(dbPath)) continue
|
||||||
|
|
||||||
|
const db = this.connectDb(dbPath)
|
||||||
|
if (!db) continue
|
||||||
|
|
||||||
|
const tables = db
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
|
||||||
|
.all() as { name: string }[]
|
||||||
|
for (const table of tables) {
|
||||||
|
try {
|
||||||
|
// 首先检查 msgContent 列是否存在以避免错误?
|
||||||
|
// 或者像 Swift 代码中那样使用 try/catch "抑制错误"
|
||||||
|
const results = db
|
||||||
|
.prepare(`SELECT 1 FROM ${table.name} WHERE msgContent LIKE '%${keyword}%' LIMIT 1`)
|
||||||
|
.get()
|
||||||
|
if (results) {
|
||||||
|
console.log(`Found keyword in ${table.name} (${dbName})`)
|
||||||
|
db.close()
|
||||||
|
return table.name
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略没有 msgContent 的表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
public md5(str: string): string {
|
||||||
|
return crypto.createHash('md5').update(str).digest('hex')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||||
|
import { Contact, Message } from '../shared/types'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electron: ElectronAPI
|
||||||
|
api: {
|
||||||
|
initDb: (key: string) => Promise<boolean>
|
||||||
|
getContacts: (filter?: string) => Promise<Contact[]>
|
||||||
|
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
||||||
|
search: (keyword: string) => Promise<string | null>
|
||||||
|
aiChat: (
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
options?: { apiKey?: string; model?: string }
|
||||||
|
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
|
copyImage: (base64String: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
|
|
||||||
|
// 渲染器的自定义 API
|
||||||
|
const api = {
|
||||||
|
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||||
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
|
getMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime),
|
||||||
|
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
||||||
|
aiChat: (
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
options?: { apiKey?: string; model?: string }
|
||||||
|
) => ipcRenderer.invoke('ai:chat', messages, options),
|
||||||
|
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.contextIsolated) {
|
||||||
|
try {
|
||||||
|
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||||
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// @ts-ignore (define in dts)
|
||||||
|
window.electron = electronAPI
|
||||||
|
// @ts-ignore (define in dts)
|
||||||
|
window.api = api
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Electron</title>
|
||||||
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Sidebar } from './components/Sidebar'
|
||||||
|
import ChatWindow from './components/ChatWindow'
|
||||||
|
import { Contact, Message } from '../../shared/types'
|
||||||
|
|
||||||
|
function App(): React.ReactElement {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
|
||||||
|
const [dateRange, setDateRange] = useState('today') // 默认为今天
|
||||||
|
const [contentFilter, setContentFilter] = useState('')
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (import.meta.env.VITE_DB_KEY) {
|
||||||
|
// handleLogin(import.meta.env.VITE_DB_KEY);
|
||||||
|
// }
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
const handleLogin = async (keyInput?: string): Promise<void> => {
|
||||||
|
const keyToUse = keyInput || dbKey
|
||||||
|
if (!keyToUse) return
|
||||||
|
try {
|
||||||
|
const success = await window.api.initDb(keyToUse)
|
||||||
|
if (success) {
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
loadContacts()
|
||||||
|
} else {
|
||||||
|
alert('Failed to open database. Check your key.')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
alert('Error connecting to database')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadContacts = async (): Promise<void> => {
|
||||||
|
const list = await window.api.getContacts()
|
||||||
|
setContacts(list)
|
||||||
|
setFilteredContacts(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateRangeParams = (
|
||||||
|
range: string
|
||||||
|
): { startTime: number | undefined; endTime: number | undefined } => {
|
||||||
|
const now = new Date()
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||||
|
|
||||||
|
let startTime: number | undefined
|
||||||
|
let endTime: number | undefined
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case 'today':
|
||||||
|
startTime = startOfToday
|
||||||
|
break
|
||||||
|
case 'yesterday':
|
||||||
|
startTime = startOfToday - 86400
|
||||||
|
endTime = startOfToday - 1 // 昨天结束
|
||||||
|
break
|
||||||
|
case '7':
|
||||||
|
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
|
||||||
|
break
|
||||||
|
case '30':
|
||||||
|
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
|
||||||
|
break
|
||||||
|
case 'all':
|
||||||
|
startTime = undefined
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
startTime = startOfToday
|
||||||
|
}
|
||||||
|
return { startTime, endTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectContact = async (contact: Contact): Promise<void> => {
|
||||||
|
setSelectedContact(contact)
|
||||||
|
const { startTime, endTime } = getDateRangeParams(dateRange)
|
||||||
|
const msgs = await window.api.getMessages(contact.md5, startTime, endTime)
|
||||||
|
setMessages(msgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateRangeChange = (range: string): void => {
|
||||||
|
setDateRange(range)
|
||||||
|
// 如果选择了联系人,则使用新范围重新加载消息
|
||||||
|
if (selectedContact) {
|
||||||
|
// 需要调用 handleSelectContact,但它需要一个联系人对象。
|
||||||
|
// 由于状态更新是异步的,可能需要使用状态中的当前联系人,
|
||||||
|
// 此函数内部 'selectedContact' 可从闭包中获得。
|
||||||
|
// 但是,需要确保 'dateRange' 已更新。
|
||||||
|
// 实际上,就在这里使用新范围手动触发获取。
|
||||||
|
|
||||||
|
const { startTime, endTime } = getDateRangeParams(range)
|
||||||
|
window.api.getMessages(selectedContact.md5, startTime, endTime).then(setMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchContacts = (keyword: string): void => {
|
||||||
|
if (!keyword) {
|
||||||
|
setFilteredContacts(contacts)
|
||||||
|
} else {
|
||||||
|
const lower = keyword.toLowerCase()
|
||||||
|
const filtered = contacts.filter(
|
||||||
|
(c) =>
|
||||||
|
c.m_nsNickName.toLowerCase().includes(lower) ||
|
||||||
|
c.m_nsUsrName.toLowerCase().includes(lower)
|
||||||
|
)
|
||||||
|
setFilteredContacts(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(250)
|
||||||
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
|
||||||
|
const startResizing = React.useCallback(() => {
|
||||||
|
setIsResizing(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopResizing = React.useCallback(() => {
|
||||||
|
setIsResizing(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resize = React.useCallback(
|
||||||
|
(mouseMoveEvent: MouseEvent) => {
|
||||||
|
if (isResizing) {
|
||||||
|
setSidebarWidth(mouseMoveEvent.clientX)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isResizing]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.addEventListener('mousemove', resize)
|
||||||
|
window.addEventListener('mouseup', stopResizing)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', resize)
|
||||||
|
window.removeEventListener('mouseup', stopResizing)
|
||||||
|
}
|
||||||
|
}, [resize, stopResizing])
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="login-modal">
|
||||||
|
<div className="login-box">
|
||||||
|
<h2>Enter WeChat DB Key</h2>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="login-input"
|
||||||
|
value={dbKey}
|
||||||
|
onChange={(e) => setDbKey(e.target.value)}
|
||||||
|
placeholder="Key (e.g. 0x...)"
|
||||||
|
/>
|
||||||
|
<button className="login-btn" onClick={() => handleLogin()}>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container">
|
||||||
|
<Sidebar
|
||||||
|
contacts={filteredContacts}
|
||||||
|
selectedContact={selectedContact}
|
||||||
|
onSelectContact={handleSelectContact}
|
||||||
|
onSearch={handleSearchContacts}
|
||||||
|
onContentFilter={setContentFilter}
|
||||||
|
width={sidebarWidth}
|
||||||
|
dateRange={dateRange}
|
||||||
|
onDateRangeChange={handleDateRangeChange}
|
||||||
|
/>
|
||||||
|
<div className="resizer" onMouseDown={startResizing} />
|
||||||
|
<ChatWindow
|
||||||
|
key={`${selectedContact?.md5}-${contentFilter}`}
|
||||||
|
contact={selectedContact}
|
||||||
|
messages={messages}
|
||||||
|
contentFilter={contentFilter}
|
||||||
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
||||||
|
onRefreshData={loadContacts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
:root {
|
||||||
|
--ev-c-white: #ffffff;
|
||||||
|
--ev-c-white-soft: #f8f8f8;
|
||||||
|
--ev-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--ev-c-black: #1b1b1f;
|
||||||
|
--ev-c-black-soft: #222222;
|
||||||
|
--ev-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--ev-c-gray-1: #515c67;
|
||||||
|
--ev-c-gray-2: #414853;
|
||||||
|
--ev-c-gray-3: #32363f;
|
||||||
|
|
||||||
|
--ev-c-text-1: rgba(255, 255, 245, 0.86);
|
||||||
|
--ev-c-text-2: rgba(235, 235, 245, 0.6);
|
||||||
|
--ev-c-text-3: rgba(235, 235, 245, 0.38);
|
||||||
|
|
||||||
|
--ev-button-alt-border: transparent;
|
||||||
|
--ev-button-alt-text: var(--ev-c-text-1);
|
||||||
|
--ev-button-alt-bg: var(--ev-c-gray-3);
|
||||||
|
--ev-button-alt-hover-border: transparent;
|
||||||
|
--ev-button-alt-hover-text: var(--ev-c-text-1);
|
||||||
|
--ev-button-alt-hover-bg: var(--ev-c-gray-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-background: var(--ev-c-black);
|
||||||
|
--color-background-soft: var(--ev-c-black-soft);
|
||||||
|
--color-background-mute: var(--ev-c-black-mute);
|
||||||
|
|
||||||
|
--color-text: var(--ev-c-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="64" cy="64" r="64" fill="#2F3242"/>
|
||||||
|
<ellipse cx="63.9835" cy="23.2036" rx="4.48794" ry="4.495" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||||
|
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.6153 43.5751L30.1748 44.4741L30.1748 44.4741L28.6153 43.5751ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM53.7489 81.7014L52.8478 83.2597L53.7489 81.7014ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||||
|
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM99.3169 43.6354L97.7574 44.5344L99.3169 43.6354ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM53.7836 46.3728L54.6847 47.931L53.7836 46.3728ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||||
|
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM84.3832 64.0673H82.5832H84.3832ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077V84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027V89.4027V89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.8501 68.0857C62.6341 68.5652 60.451 67.1547 59.9713 64.9353C59.4934 62.7159 60.9007 60.5293 63.1167 60.0489C65.3326 59.5693 67.5157 60.9798 67.9954 63.1992C68.4742 65.4186 67.066 67.6052 64.8501 68.0857Z" fill="#A2ECFB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,380 @@
|
|||||||
|
:root {
|
||||||
|
--bg-color: #f5f5f5;
|
||||||
|
--sidebar-bg: #e7e7e7;
|
||||||
|
--sidebar-hover: #d6d6d6;
|
||||||
|
--sidebar-active: #c6c6c6;
|
||||||
|
--chat-bg: #f5f5f5;
|
||||||
|
--message-bg-user: #95ec69;
|
||||||
|
--message-bg-other: #ffffff;
|
||||||
|
--text-color: #000000;
|
||||||
|
--border-color: #dcdcdc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background-color: var(--sidebar-bg);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Prevent shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-status {
|
||||||
|
color: #07c160;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-btn {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-btn.active {
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #07c160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header .arrow {
|
||||||
|
margin-right: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item:hover {
|
||||||
|
background-color: var(--sidebar-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item.active {
|
||||||
|
background-color: var(--sidebar-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ccc;
|
||||||
|
margin-right: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-name {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer {
|
||||||
|
width: 5px;
|
||||||
|
cursor: col-resize;
|
||||||
|
background-color: transparent;
|
||||||
|
border-right: 1px solid transparent;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizer:hover,
|
||||||
|
.resizer:active {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-window {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--chat-bg);
|
||||||
|
min-width: 0;
|
||||||
|
/* Allow flex item to shrink below content size */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
height: 50px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
/* For resizer positioning */
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-resizer {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 5px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-resizer:hover {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-table td {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
vertical-align: top;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-table tr:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-sender {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-type {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-time {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-content {}
|
||||||
|
|
||||||
|
.chat-toolbar {
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #dcdcdc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 300px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
background-color: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn:hover {
|
||||||
|
background-color: #06ad56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview-modal {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1422 800" opacity="0.3">
|
||||||
|
<defs>
|
||||||
|
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="oooscillate-grad">
|
||||||
|
<stop stop-color="hsl(206, 75%, 49%)" stop-opacity="1" offset="0%"></stop>
|
||||||
|
<stop stop-color="hsl(331, 90%, 56%)" stop-opacity="1" offset="100%"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g stroke-width="1" stroke="url(#oooscillate-grad)" fill="none" stroke-linecap="round">
|
||||||
|
<path d="M 0 448 Q 355.5 -100 711 400 Q 1066.5 900 1422 448" opacity="0.05"></path>
|
||||||
|
<path d="M 0 420 Q 355.5 -100 711 400 Q 1066.5 900 1422 420" opacity="0.11"></path>
|
||||||
|
<path d="M 0 392 Q 355.5 -100 711 400 Q 1066.5 900 1422 392" opacity="0.18"></path>
|
||||||
|
<path d="M 0 364 Q 355.5 -100 711 400 Q 1066.5 900 1422 364" opacity="0.24"></path>
|
||||||
|
<path d="M 0 336 Q 355.5 -100 711 400 Q 1066.5 900 1422 336" opacity="0.30"></path>
|
||||||
|
<path d="M 0 308 Q 355.5 -100 711 400 Q 1066.5 900 1422 308" opacity="0.37"></path>
|
||||||
|
<path d="M 0 280 Q 355.5 -100 711 400 Q 1066.5 900 1422 280" opacity="0.43"></path>
|
||||||
|
<path d="M 0 252 Q 355.5 -100 711 400 Q 1066.5 900 1422 252" opacity="0.49"></path>
|
||||||
|
<path d="M 0 224 Q 355.5 -100 711 400 Q 1066.5 900 1422 224" opacity="0.56"></path>
|
||||||
|
<path d="M 0 196 Q 355.5 -100 711 400 Q 1066.5 900 1422 196" opacity="0.62"></path>
|
||||||
|
<path d="M 0 168 Q 355.5 -100 711 400 Q 1066.5 900 1422 168" opacity="0.68"></path>
|
||||||
|
<path d="M 0 140 Q 355.5 -100 711 400 Q 1066.5 900 1422 140" opacity="0.75"></path>
|
||||||
|
<path d="M 0 112 Q 355.5 -100 711 400 Q 1066.5 900 1422 112" opacity="0.81"></path>
|
||||||
|
<path d="M 0 84 Q 355.5 -100 711 400 Q 1066.5 900 1422 84" opacity="0.87"></path>
|
||||||
|
<path d="M 0 56 Q 355.5 -100 711 400 Q 1066.5 900 1422 56" opacity="0.94"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,479 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { toPng } from 'html-to-image'
|
||||||
|
import { Message, Contact } from '../../../shared/types'
|
||||||
|
|
||||||
|
interface ChatWindowProps {
|
||||||
|
contact: Contact | null
|
||||||
|
messages: Message[]
|
||||||
|
contentFilter?: string
|
||||||
|
onRefresh?: () => void
|
||||||
|
onRefreshData?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
|
||||||
|
请注意 不要回复总结除外的内容, 并且不要输出 群友的wxid 微信id 只需要显示群名称
|
||||||
|
请帮我将给出的群聊内容总结成一个群聊报告,需要你生成7个最重要 最火爆的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
|
||||||
|
- 整体评价
|
||||||
|
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
|
||||||
|
- 参与者(不超过5个人,将重复的人名去重)
|
||||||
|
- 注意按时间排序,时间段(从日期几点到几点)
|
||||||
|
- 过程(50到200字左右)
|
||||||
|
- 评价(50字以下)
|
||||||
|
- 生成这7天内热度最高的话题,27日到2日一共7天
|
||||||
|
需要生成27, 28, 29, 30, 31, 1, 2日的话题总结
|
||||||
|
- 分割线: ------------
|
||||||
|
|
||||||
|
另外有以下要求:
|
||||||
|
1. 每个话题结束使用------------分割
|
||||||
|
2. 使用中文冒号
|
||||||
|
3. 无需大标题
|
||||||
|
4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
|
||||||
|
|
||||||
|
最后总结下今日最活跃的前五个发言者`
|
||||||
|
|
||||||
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||||
|
contact,
|
||||||
|
messages,
|
||||||
|
contentFilter,
|
||||||
|
onRefresh,
|
||||||
|
onRefreshData
|
||||||
|
}) => {
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [colWidths, setColWidths] = useState([150, 100, 180, 400])
|
||||||
|
const [resizingColIndex, setResizingColIndex] = useState<number | null>(null)
|
||||||
|
const startXRef = useRef(0)
|
||||||
|
const startWidthRef = useRef(0)
|
||||||
|
|
||||||
|
// AI Settings
|
||||||
|
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||||
|
const [apiKey, setApiKey] = useState(() => localStorage.getItem('deepseek_api_key') || '')
|
||||||
|
const [model, setModel] = useState('deepseek-chat')
|
||||||
|
|
||||||
|
const handleSaveSettings = (): void => {
|
||||||
|
localStorage.setItem('deepseek_api_key', apiKey)
|
||||||
|
setShowSettingsModal(false)
|
||||||
|
AIChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = (): void => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const startResizing = (index: number, e: React.MouseEvent): void => {
|
||||||
|
e.preventDefault()
|
||||||
|
setResizingColIndex(index)
|
||||||
|
startXRef.current = e.clientX
|
||||||
|
startWidthRef.current = colWidths[index]
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent): void => {
|
||||||
|
if (resizingColIndex === null) return
|
||||||
|
const diff = e.clientX - startXRef.current
|
||||||
|
const newWidth = Math.max(50, startWidthRef.current + diff)
|
||||||
|
|
||||||
|
setColWidths((prev) => {
|
||||||
|
const newCols = [...prev]
|
||||||
|
newCols[resizingColIndex] = newWidth
|
||||||
|
return newCols
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = (): void => {
|
||||||
|
setResizingColIndex(null)
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = (days: number | 'all'): void => {
|
||||||
|
if (!messages.length) return
|
||||||
|
|
||||||
|
let filtered = messages
|
||||||
|
if (days !== 'all') {
|
||||||
|
const now = new Date()
|
||||||
|
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||||
|
|
||||||
|
filtered = messages.filter((m) => {
|
||||||
|
const parsed = new Date(m.datetime).getTime()
|
||||||
|
if (isNaN(parsed)) return true
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
// 今天
|
||||||
|
return parsed >= startOfDay
|
||||||
|
} else if (days === 1) {
|
||||||
|
// 昨天
|
||||||
|
const startOfYesterday = startOfDay - 86400000
|
||||||
|
return parsed >= startOfYesterday && parsed < startOfDay
|
||||||
|
} else {
|
||||||
|
// 过去 7 天
|
||||||
|
const startOf7DaysAgo = startOfDay - 7 * 86400000
|
||||||
|
return parsed >= startOf7DaysAgo
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = ['发送者', '类型', '时间', '内容']
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...filtered.map((m) => {
|
||||||
|
const content = m.content.replace(/"/g, '""').replace(/\n/g, ' ')
|
||||||
|
return `"${m.from}","${m.type}","${m.datetime}","${content}"`
|
||||||
|
})
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.setAttribute('download', `${contact?.m_nsNickName || 'chat'}_export.csv`)
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [summaryContent, setSummaryContent] = useState<string>('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const AIChat = async (): Promise<void> => {
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
alert('当前没有消息可供总结')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const filteredMessages = messages.filter((msg) => {
|
||||||
|
delete msg.img
|
||||||
|
// @ts-ignore 暂时去除
|
||||||
|
delete msg.from
|
||||||
|
// @ts-ignore 暂时去除
|
||||||
|
delete msg.id
|
||||||
|
// @ts-ignore 暂时去除
|
||||||
|
delete msg.isSender
|
||||||
|
return !'分享消息,图片,表情包,视频'.split(',').includes(msg.type)
|
||||||
|
})
|
||||||
|
const recentMessages = filteredMessages
|
||||||
|
.map((msg) => {
|
||||||
|
return `${msg.datetime} ${msg.from}: ${msg.content}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const prompt = `请总结以下微信聊天记录的核心内容:\n\n${recentMessages}`
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
console.log('正在请求AI...')
|
||||||
|
const result = await window.api.aiChat(
|
||||||
|
[
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: prompt }
|
||||||
|
],
|
||||||
|
{ apiKey, model }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
console.log('AI Summary:', result.data)
|
||||||
|
setSummaryContent(result.data)
|
||||||
|
|
||||||
|
// 等待状态更新和渲染
|
||||||
|
setTimeout(() => {
|
||||||
|
textToImage()
|
||||||
|
setIsLoading(false) // 图片生成开始后停止加载
|
||||||
|
}, 500)
|
||||||
|
} else {
|
||||||
|
console.error('AI Error:', result.error)
|
||||||
|
alert(`AI 请求失败: ${result.error}`)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI Call Failed:', error)
|
||||||
|
alert('AI 请求发生错误')
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textToImage = async (): Promise<void> => {
|
||||||
|
if (imageContainerRef.current) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await toPng(imageContainerRef.current, {
|
||||||
|
cacheBust: true,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
style: {
|
||||||
|
transform: 'scale(1)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (dataUrl && dataUrl.length > 100) {
|
||||||
|
setGeneratedImage(dataUrl)
|
||||||
|
} else {
|
||||||
|
alert('生成图片为空')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate image', err)
|
||||||
|
alert('生成图片失败: ' + (err instanceof Error ? err.message : String(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleCopyImage = async (): Promise<void> => {
|
||||||
|
if (!generatedImage) return
|
||||||
|
const result = await window.api.copyImage(generatedImage)
|
||||||
|
if (result.success) {
|
||||||
|
alert('复制成功')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [displayLimit, setDisplayLimit] = useState(100)
|
||||||
|
|
||||||
|
const filteredMessages = React.useMemo(() => {
|
||||||
|
return messages.filter((msg) => {
|
||||||
|
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '分享消息,图片,表情包,视频')
|
||||||
|
.split(',')
|
||||||
|
.filter(Boolean)
|
||||||
|
const typeMatch = !filterTypes.includes(msg.type)
|
||||||
|
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
||||||
|
return typeMatch && contentMatch
|
||||||
|
})
|
||||||
|
}, [messages, contentFilter])
|
||||||
|
|
||||||
|
const visibleMessages = filteredMessages.slice(0, displayLimit)
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return (
|
||||||
|
<div className="chat-window">
|
||||||
|
<div className="empty-state">选择一条消息</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-window">
|
||||||
|
<div className="chat-header">
|
||||||
|
<h2>{contact.m_nsNickName}</h2>
|
||||||
|
<div className="window-controls"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="message-list">
|
||||||
|
<table className="chat-table" style={{ tableLayout: 'fixed' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: colWidths[0], position: 'relative' }}>
|
||||||
|
发送者
|
||||||
|
<div className="column-resizer" onMouseDown={(e) => startResizing(0, e)} />
|
||||||
|
</th>
|
||||||
|
<th style={{ width: colWidths[1], position: 'relative' }}>
|
||||||
|
类型
|
||||||
|
<div className="column-resizer" onMouseDown={(e) => startResizing(1, e)} />
|
||||||
|
</th>
|
||||||
|
<th style={{ width: colWidths[2], position: 'relative' }}>
|
||||||
|
时间
|
||||||
|
<div className="column-resizer" onMouseDown={(e) => startResizing(2, e)} />
|
||||||
|
</th>
|
||||||
|
<th style={{ width: colWidths[3] }}>内容</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleMessages.map((msg) => (
|
||||||
|
<tr key={msg.id}>
|
||||||
|
<td
|
||||||
|
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
title={msg.from}
|
||||||
|
>
|
||||||
|
{msg.from}
|
||||||
|
</td>
|
||||||
|
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{msg.type}
|
||||||
|
</td>
|
||||||
|
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{msg.datetime}
|
||||||
|
</td>
|
||||||
|
<td style={{ wordBreak: 'break-all', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 12 }}>
|
||||||
|
<span>{msg.name}</span>
|
||||||
|
{msg?.img && (
|
||||||
|
<img style={{ width: '50px', height: '50px' }} src={msg?.img}></img>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* <span style={{ marginRight: '24px' }}>:</span> */}
|
||||||
|
<div>{msg.content}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{filteredMessages.length > displayLimit && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '10px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setDisplayLimit((prev) => prev + 100)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
加载更多 ({filteredMessages.length - displayLimit} 条剩余)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-toolbar">
|
||||||
|
<button className="toolbar-btn" onClick={onRefresh}>
|
||||||
|
🔄 刷新聊天记录
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn" onClick={onRefreshData}>
|
||||||
|
🔄 刷新数据
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => handleExport('all')}>
|
||||||
|
📤 导出全部
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => handleExport(0)}>
|
||||||
|
🕒 导出今日
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => handleExport(1)}>
|
||||||
|
📅 导出昨日
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => handleExport(7)}>
|
||||||
|
📅 导出近7天
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => setShowSettingsModal(true)}>
|
||||||
|
🤖 AI总结群聊
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
zIndex: -1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '820px',
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
fontSize: '22px',
|
||||||
|
color: '#000',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}
|
||||||
|
ref={imageContainerRef}
|
||||||
|
>
|
||||||
|
{summaryContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 加载模态框 */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
|
||||||
|
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
|
||||||
|
<div style={{ fontSize: '16px', color: '#333' }}>正在生成 AI 总结...</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
|
||||||
|
请稍候,生成后将自动转换为图片
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图片预览模态框 */}
|
||||||
|
{generatedImage && (
|
||||||
|
<div className="modal-overlay" onClick={() => setGeneratedImage(null)}>
|
||||||
|
<div className="modal-content image-preview-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<img
|
||||||
|
src={generatedImage}
|
||||||
|
alt="Generated Summary"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '80vh', border: '1px solid #ccc' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
// justifyContent: 'flex-end',
|
||||||
|
gap: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyImage}
|
||||||
|
style={{
|
||||||
|
padding: '8px 15px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📋 复制图片
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setGeneratedImage(null)}
|
||||||
|
style={{ padding: '5px 10px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Settings Modal */}
|
||||||
|
{showSettingsModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>AI 设置</h3>
|
||||||
|
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '5px' }}>模型:</label>
|
||||||
|
<select
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px' }}
|
||||||
|
>
|
||||||
|
<option value="deepseek-chat">DeepSeek Chat</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: '20px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '5px' }}>API Key:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="Enter your DeepSeek API Key"
|
||||||
|
style={{ width: '95%', padding: '8px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||||
|
<button onClick={() => setShowSettingsModal(false)}>取消</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 15px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
生成总结
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default ChatWindow
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Contact } from '../../../shared/types'
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
contacts: Contact[]
|
||||||
|
selectedContact: Contact | null
|
||||||
|
onSelectContact: (contact: Contact) => void
|
||||||
|
onSearch: (keyword: string) => void
|
||||||
|
onContentFilter: (keyword: string) => void
|
||||||
|
width: number
|
||||||
|
dateRange: string
|
||||||
|
onDateRangeChange: (range: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
|
contacts,
|
||||||
|
selectedContact,
|
||||||
|
onSelectContact,
|
||||||
|
onSearch,
|
||||||
|
onContentFilter,
|
||||||
|
width,
|
||||||
|
dateRange,
|
||||||
|
onDateRangeChange
|
||||||
|
}) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [contentFilter, setContentFilter] = useState('')
|
||||||
|
const [isGroupsExpanded, setIsGroupsExpanded] = useState(true)
|
||||||
|
const [isContactsExpanded, setIsContactsExpanded] = useState(true)
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const term = e.target.value
|
||||||
|
setSearchTerm(term)
|
||||||
|
onSearch(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentFilterChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const term = e.target.value
|
||||||
|
setContentFilter(term)
|
||||||
|
onContentFilter(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = contacts.filter((c) => c.type === 'group')
|
||||||
|
const users = contacts.filter((c) => c.type === 'user')
|
||||||
|
|
||||||
|
const renderContactItem = (contact: Contact): React.ReactElement => (
|
||||||
|
<div
|
||||||
|
key={contact.md5}
|
||||||
|
className={`contact-item ${selectedContact?.md5 === contact.md5 ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelectContact(contact)}
|
||||||
|
>
|
||||||
|
<div className="contact-avatar">{contact.m_nsNickName.charAt(0)}</div>
|
||||||
|
<div className="contact-info">
|
||||||
|
<div className="contact-name">{contact.m_nsNickName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar" style={{ width: width }}>
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="search-input"
|
||||||
|
placeholder="搜索联系人"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
style={{ marginBottom: '8px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="search-input"
|
||||||
|
placeholder="过滤消息内容"
|
||||||
|
value={contentFilter}
|
||||||
|
onChange={handleContentFilterChange}
|
||||||
|
/>
|
||||||
|
<div className="date-range-selector">
|
||||||
|
{[
|
||||||
|
{ key: 'today', label: '当天' },
|
||||||
|
{ key: 'yesterday', label: '昨日' },
|
||||||
|
{ key: '7', label: '近7天' },
|
||||||
|
{ key: '30', label: '近30天' },
|
||||||
|
{ key: 'all', label: '所有' }
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
className={`range-btn ${dateRange === item.key ? 'active' : ''}`}
|
||||||
|
onClick={() => onDateRangeChange(item.key)}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="contact-list">
|
||||||
|
{/* 群聊部分 */}
|
||||||
|
<div className="section-header" onClick={() => setIsGroupsExpanded(!isGroupsExpanded)}>
|
||||||
|
<span className="arrow">{isGroupsExpanded ? '▼' : '▶'}</span> 群聊 ({groups.length})
|
||||||
|
</div>
|
||||||
|
{isGroupsExpanded && groups.map(renderContactItem)}
|
||||||
|
|
||||||
|
{/* 联系人部分 */}
|
||||||
|
<div className="section-header" onClick={() => setIsContactsExpanded(!isContactsExpanded)}>
|
||||||
|
<span className="arrow">{isContactsExpanded ? '▼' : '▶'}</span> 联系人 ({users.length})
|
||||||
|
</div>
|
||||||
|
{isContactsExpanded && users.map(renderContactItem)}
|
||||||
|
</div>
|
||||||
|
{/* <div className="sidebar-footer">
|
||||||
|
<div className="sidebar-btn" onClick={() => window.location.reload()}>
|
||||||
|
<span className="icon">↪️</span> 退出
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-status">
|
||||||
|
✅ 已获得
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
function Versions(): React.JSX.Element {
|
||||||
|
const [versions] = useState(window.electron.process.versions)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="versions">
|
||||||
|
<li className="electron-version">Electron v{versions.electron}</li>
|
||||||
|
<li className="chrome-version">Chromium v{versions.chrome}</li>
|
||||||
|
<li className="node-version">Node v{versions.node}</li>
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Versions
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface Contact {
|
||||||
|
m_nsUsrName: string
|
||||||
|
m_nsNickName: string
|
||||||
|
md5: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string
|
||||||
|
from: string
|
||||||
|
type: string
|
||||||
|
datetime: string
|
||||||
|
content: string
|
||||||
|
isSender: boolean
|
||||||
|
img?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatTable {
|
||||||
|
name: string
|
||||||
|
db_number: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
|
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["electron-vite/node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
|
"include": [
|
||||||
|
"src/renderer/src/env.d.ts",
|
||||||
|
"src/renderer/src/**/*",
|
||||||
|
"src/renderer/src/**/*.tsx",
|
||||||
|
"src/preload/*.d.ts",
|
||||||
|
"src/shared/**/*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@renderer/*": [
|
||||||
|
"src/renderer/src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||