mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 本地 HTTP API + 设置面板 + 账号自助入口
让 WechatExplorer 既能用图形界面浏览聊天记录,也能作为本机 MCP 数据源被
Claude / Codex 等客户端通过 127.0.0.1:6131 直接拉取。
本机 HTTP API
- 新增 http-server: 8 个端点,覆盖 health / current_time / contact /
chatroom / recent_chat / chatlog / group_snapshot / resolve / report
- 时间参数支持 YYYY-MM-DD / YYYY-MM-DD/HH:mm / Unix 秒级;日期单独使用
时自动补到 00:00:00~23:59:59,避免漏消息
- apiServer 单例支持动态启停,启动失败返回 friendlyMessage(把
EADDRINUSE 翻译成"端口已被占用"的中文错误并附 4 次重试)
数据库根目录与自服务入口
- Wcdb4Client 接受 accountRoot 时会自动解析:父目录下找最新含
db_storage 的 wxid 子目录;设置面板"测试连接"成功后回写解析后的
精确路径
- 抽 chat-service.ts:IPC 和 HTTP 共享 listContacts / listMessages /
getGroupSnapshot / searchMessages / getSelfAccountInfo / testConnection
/ reopenWithRoot
- WechatDb 接受可选 accountRoot;设置面板新增"应用并重新初始化"
按钮,改完 dbRoot 立即生效
- 新增 settings-store.ts,dbRoot / apiEnabled / apiHost / apiPort 落到
userData/settings.json
主进程健壮性
- 新增 safe-log.ts 包一层 console.log/warn/error,electron-vite 关闭
子进程 stderr 后写 EPIPE 不再炸 IPC handler(原 main build 启动时
即 installSafeConsole)
This commit is contained in:
@@ -83,6 +83,74 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
|||||||
1. 从 WeFlow/Chatlog 设置中导出
|
1. 从 WeFlow/Chatlog 设置中导出
|
||||||
2. 使用内存扫描工具从微信进程中自动提取(待实现)
|
2. 使用内存扫描工具从微信进程中自动提取(待实现)
|
||||||
|
|
||||||
|
## 🤖 AI 集成(本地 HTTP API)
|
||||||
|
|
||||||
|
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||||
|
|
||||||
|
### 启用本地 API
|
||||||
|
|
||||||
|
API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要:
|
||||||
|
1. 安装并启动 WechatExplorer
|
||||||
|
2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库
|
||||||
|
3. API 即在 `http://127.0.0.1:6131` 可用
|
||||||
|
|
||||||
|
### 7×24 提供 API(菜单栏常驻模式)
|
||||||
|
|
||||||
|
默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 任选一种方式
|
||||||
|
WXE_TRAY=1 open /Applications/WechatExplorer.app
|
||||||
|
/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray
|
||||||
|
```
|
||||||
|
|
||||||
|
启用后:
|
||||||
|
- macOS dock 图标自动隐藏
|
||||||
|
- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态)
|
||||||
|
- 主窗口关闭后 API 服务继续运行
|
||||||
|
|
||||||
|
### API 端点一览
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET /api/v1/health` | 健康检查 |
|
||||||
|
| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) |
|
||||||
|
| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 |
|
||||||
|
| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 |
|
||||||
|
| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 |
|
||||||
|
| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 |
|
||||||
|
| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 |
|
||||||
|
|
||||||
|
详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。
|
||||||
|
|
||||||
|
### 让 Claude 自动总结你的群聊
|
||||||
|
|
||||||
|
复制 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md) 到 `~/.claude/skills/`,然后在 Claude Desktop 里说:
|
||||||
|
|
||||||
|
> "今天 技术交流群 聊了啥?"
|
||||||
|
|
||||||
|
Claude 会自动:
|
||||||
|
1. 调 `current_time` 拿到今天日期
|
||||||
|
2. 调 `chatroom` 找到目标群
|
||||||
|
3. 调 `chatlog` 拿 JSON 聊天记录
|
||||||
|
4. 自己用 LLM 生成总结报告
|
||||||
|
|
||||||
|
### curl 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 健康检查
|
||||||
|
curl http://127.0.0.1:6131/api/v1/health
|
||||||
|
|
||||||
|
# 今天 摸鱼交流群 的聊天记录
|
||||||
|
curl -G "http://127.0.0.1:6131/api/v1/chatlog" \
|
||||||
|
--data-urlencode "talker=摸鱼交流群" \
|
||||||
|
--data-urlencode "time=$(date +%Y-%m-%d)"
|
||||||
|
|
||||||
|
# 把群昵称解析成 md5
|
||||||
|
curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||||
|
--data-urlencode "q=摸鱼交流群"
|
||||||
|
```
|
||||||
|
|
||||||
## ⚠️ 免责声明
|
## ⚠️ 免责声明
|
||||||
|
|
||||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import http, { IncomingMessage, ServerResponse, Server } from 'http'
|
||||||
|
import {
|
||||||
|
isReady,
|
||||||
|
listContacts,
|
||||||
|
listMessages,
|
||||||
|
getGroupSnapshot,
|
||||||
|
listRecentChat,
|
||||||
|
resolveMd5
|
||||||
|
} from './services/chat-service'
|
||||||
|
import { exportGroupReport } from './group-report-service'
|
||||||
|
import { GroupReportExportRequest } from '../shared/group-report'
|
||||||
|
import { safeError, safeLog, safeWarn } from './safe-log'
|
||||||
|
|
||||||
|
export const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||||
|
export const DEFAULT_HTTP_PORT = 6131
|
||||||
|
|
||||||
|
export interface HttpServerHandle {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
req: IncomingMessage
|
||||||
|
res: ServerResponse
|
||||||
|
url: URL
|
||||||
|
body?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||||
|
const body = JSON.stringify(payload, null, 2)
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'no-store'
|
||||||
|
})
|
||||||
|
res.end(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
|
||||||
|
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
|
||||||
|
req.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeToSec(input: string, endOfUnit = false): number | null {
|
||||||
|
const m = input.match(/^(\d{4})-(\d{2})-(\d{2})(?:\/(\d{2}):(\d{2}))?$/)
|
||||||
|
if (!m) return null
|
||||||
|
const [, y, mo, d, hStr, miStr] = m
|
||||||
|
const hasTime = hStr !== undefined
|
||||||
|
|
||||||
|
let hh: number, mi: number, ss: number, ms: number
|
||||||
|
if (hasTime) {
|
||||||
|
hh = Number(hStr)
|
||||||
|
mi = Number(miStr)
|
||||||
|
ss = endOfUnit ? 59 : 0
|
||||||
|
ms = endOfUnit ? 999 : 0
|
||||||
|
} else if (endOfUnit) {
|
||||||
|
hh = 23
|
||||||
|
mi = 59
|
||||||
|
ss = 59
|
||||||
|
ms = 999
|
||||||
|
} else {
|
||||||
|
hh = 0
|
||||||
|
mi = 0
|
||||||
|
ss = 0
|
||||||
|
ms = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(Number(y), Number(mo) - 1, Number(d), hh, mi, ss, ms)
|
||||||
|
return Math.floor(date.getTime() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeRange(value: string | null): { startTime?: number; endTime?: number } {
|
||||||
|
if (!value) return {}
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return {}
|
||||||
|
|
||||||
|
if (/^\d{10,13}$/.test(trimmed)) {
|
||||||
|
const n = Number(trimmed)
|
||||||
|
if (!Number.isFinite(n)) return {}
|
||||||
|
return { startTime: n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.includes('~')) {
|
||||||
|
const [a, b] = trimmed.split('~').map((s) => s.trim())
|
||||||
|
const start = rangeToSec(a, false)
|
||||||
|
const end = rangeToSec(b, true)
|
||||||
|
return {
|
||||||
|
startTime: start ?? undefined,
|
||||||
|
endTime: end ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = rangeToSec(trimmed, false)
|
||||||
|
const end = rangeToSec(trimmed, true)
|
||||||
|
return {
|
||||||
|
startTime: start ?? undefined,
|
||||||
|
endTime: end ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumeric(value: string | null, fallback: number): number {
|
||||||
|
if (!value) return fallback
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: Record<string, RouteHandler> = {
|
||||||
|
'/api/v1/health': ({ res }) => {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
ready: isReady(),
|
||||||
|
service: 'WechatExplorer Reader',
|
||||||
|
version: '1.0.0',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/current_time': ({ res }) => {
|
||||||
|
const now = new Date()
|
||||||
|
sendJson(res, 200, {
|
||||||
|
time: now.toISOString(),
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
timestamp: Math.floor(now.getTime() / 1000),
|
||||||
|
localDate: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
|
now.getDate()
|
||||||
|
).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/contact': ({ res, url }) => {
|
||||||
|
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||||
|
const filter = url.searchParams.get('filter') || undefined
|
||||||
|
const type = url.searchParams.get('type') || undefined
|
||||||
|
let contacts = listContacts(filter)
|
||||||
|
if (type === 'user' || type === 'group') {
|
||||||
|
contacts = contacts.filter((c) => c.type === type)
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { count: contacts.length, contacts })
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/chatroom': ({ res, url }) => {
|
||||||
|
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||||
|
const keyword = url.searchParams.get('keyword') || ''
|
||||||
|
let groups = listContacts().filter((c) => c.type === 'group')
|
||||||
|
if (keyword) {
|
||||||
|
const lower = keyword.toLowerCase()
|
||||||
|
groups = groups.filter(
|
||||||
|
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { count: groups.length, chatrooms: groups })
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/recent_chat': ({ res, url }) => {
|
||||||
|
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||||
|
const limit = parseNumeric(url.searchParams.get('limit'), 50)
|
||||||
|
const items = listRecentChat(limit)
|
||||||
|
sendJson(res, 200, { count: items.length, items })
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/chatlog': ({ res, url }) => {
|
||||||
|
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||||
|
const talker = url.searchParams.get('talker')
|
||||||
|
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
|
||||||
|
|
||||||
|
const resolved = resolveMd5(talker)
|
||||||
|
if (!resolved) return sendError(res, 404, `未找到会话: ${talker}`)
|
||||||
|
|
||||||
|
const timeParam = url.searchParams.get('time')
|
||||||
|
const startParam = url.searchParams.get('startTime')
|
||||||
|
const endParam = url.searchParams.get('endTime')
|
||||||
|
|
||||||
|
let startTime: number | undefined
|
||||||
|
let endTime: number | undefined
|
||||||
|
if (timeParam) {
|
||||||
|
const range = parseTimeRange(timeParam)
|
||||||
|
startTime = range.startTime
|
||||||
|
endTime = range.endTime
|
||||||
|
} else {
|
||||||
|
if (startParam) {
|
||||||
|
const r = parseTimeRange(startParam)
|
||||||
|
startTime = r.startTime
|
||||||
|
}
|
||||||
|
if (endParam) {
|
||||||
|
const r = parseTimeRange(endParam)
|
||||||
|
endTime = r.endTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = listMessages(resolved.md5, startTime, endTime)
|
||||||
|
sendJson(res, 200, {
|
||||||
|
contact: resolved,
|
||||||
|
query: { talker, time: timeParam, startTime, endTime },
|
||||||
|
count: messages.length,
|
||||||
|
messages
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/group_snapshot': ({ res, url }) => {
|
||||||
|
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||||
|
const md5 = url.searchParams.get('md5')
|
||||||
|
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
|
||||||
|
const snapshot = getGroupSnapshot(md5)
|
||||||
|
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
|
||||||
|
sendJson(res, 200, snapshot)
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/resolve': ({ res, url }) => {
|
||||||
|
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||||
|
const q = url.searchParams.get('q')
|
||||||
|
if (!q) return sendError(res, 400, '缺少必要参数 q')
|
||||||
|
const contact = resolveMd5(q)
|
||||||
|
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
|
||||||
|
sendJson(res, 200, contact)
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/report': async ({ req, res, body }) => {
|
||||||
|
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
|
||||||
|
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
|
||||||
|
if (typeof body !== 'string' || !body.trim()) {
|
||||||
|
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
|
||||||
|
}
|
||||||
|
let request: GroupReportExportRequest
|
||||||
|
try {
|
||||||
|
request = JSON.parse(body) as GroupReportExportRequest
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
if (!request?.report || !request?.metadata) {
|
||||||
|
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
|
||||||
|
}
|
||||||
|
const result = await exportGroupReport(request)
|
||||||
|
sendJson(res, result.success ? 200 : 500, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHttpServer(
|
||||||
|
host: string = DEFAULT_HTTP_HOST,
|
||||||
|
port: number = DEFAULT_HTTP_PORT
|
||||||
|
): Promise<HttpServerHandle> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server: Server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.writeHead(204, {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': '*'
|
||||||
|
})
|
||||||
|
return res.end()
|
||||||
|
}
|
||||||
|
const handler = routes[url.pathname]
|
||||||
|
if (!handler) {
|
||||||
|
return sendError(res, 404, `端点不存在: ${url.pathname}`)
|
||||||
|
}
|
||||||
|
let body: string | undefined
|
||||||
|
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
body = await readBody(req)
|
||||||
|
}
|
||||||
|
const ctx: RouteContext = { req, res, url, body }
|
||||||
|
await handler(ctx)
|
||||||
|
} catch (error) {
|
||||||
|
safeError('[HttpServer] 请求处理失败:', error)
|
||||||
|
if (!res.headersSent) {
|
||||||
|
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
server.once('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
const message =
|
||||||
|
error.code === 'EADDRINUSE'
|
||||||
|
? `端口 ${port} 已被占用,请关闭占用进程或在设置中更换端口`
|
||||||
|
: error.message
|
||||||
|
reject(Object.assign(error, { friendlyMessage: message }))
|
||||||
|
})
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
server.off('error', () => undefined)
|
||||||
|
const actualPort = (server.address() as { port: number } | null)?.port ?? port
|
||||||
|
safeLog(`[HttpServer] Listening on http://${host}:${actualPort}`)
|
||||||
|
resolve({
|
||||||
|
host,
|
||||||
|
port: actualPort,
|
||||||
|
close: () =>
|
||||||
|
new Promise<void>((res) => {
|
||||||
|
server.close(() => res())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiServerState {
|
||||||
|
running: boolean
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let singleton: HttpServerHandle | null = null
|
||||||
|
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiServer = {
|
||||||
|
isRunning(): boolean {
|
||||||
|
return singleton !== null
|
||||||
|
},
|
||||||
|
|
||||||
|
getState(): ApiServerState {
|
||||||
|
return { ...singletonState }
|
||||||
|
},
|
||||||
|
|
||||||
|
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
|
||||||
|
if (singleton) {
|
||||||
|
return this.getState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAttempts = 4
|
||||||
|
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
singleton = await startHttpServer(host, port)
|
||||||
|
singletonState = {
|
||||||
|
running: true,
|
||||||
|
host: singleton.host,
|
||||||
|
port: singleton.port
|
||||||
|
}
|
||||||
|
safeLog(`[ApiServer] started on http://${singleton.host}:${singleton.port}`)
|
||||||
|
return { ...singletonState }
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as NodeJS.ErrnoException & { friendlyMessage?: string }
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE' || attempt === maxAttempts) break
|
||||||
|
// Brief wait to let the OS release the port (TIME_WAIT / concurrent dev session).
|
||||||
|
await sleep(400 * attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
lastError?.friendlyMessage ||
|
||||||
|
(lastError instanceof Error ? lastError.message : String(lastError)) ||
|
||||||
|
'API 启动失败'
|
||||||
|
singletonState = {
|
||||||
|
running: false,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
error: message
|
||||||
|
}
|
||||||
|
safeError('[ApiServer] start failed:', message)
|
||||||
|
return { ...singletonState }
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop(): Promise<ApiServerState> {
|
||||||
|
if (!singleton) {
|
||||||
|
return this.getState()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await singleton.close()
|
||||||
|
} catch (error) {
|
||||||
|
safeWarn('[ApiServer] close failed:', error)
|
||||||
|
}
|
||||||
|
singleton = null
|
||||||
|
singletonState = { ...singletonState, running: false }
|
||||||
|
safeLog('[ApiServer] stopped')
|
||||||
|
return { ...singletonState }
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
-244
@@ -1,61 +1,47 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { WechatDb, Contact, WechatMessage } from './wechat-db'
|
import { WechatDb } from './wechat-db'
|
||||||
import { VoiceService } from './voice-service'
|
import { VoiceService } from './voice-service'
|
||||||
import { StickerService } from './sticker-service'
|
import { StickerService } from './sticker-service'
|
||||||
import {
|
import { parseMessageContent } from './message-parser'
|
||||||
parseImageDatNameFromRow,
|
|
||||||
parseMessageContent,
|
|
||||||
parseStickerMessageFromRow
|
|
||||||
} from './message-parser'
|
|
||||||
import { ImageDecryptService } from './image-decrypt-service'
|
import { ImageDecryptService } from './image-decrypt-service'
|
||||||
import { exportGroupReport } from './group-report-service'
|
import { exportGroupReport } from './group-report-service'
|
||||||
import { GroupReportExportRequest } from '../shared/group-report'
|
import { GroupReportExportRequest } from '../shared/group-report'
|
||||||
import { DatabaseKeyStore } from './database-key-store'
|
import { DatabaseKeyStore } from './database-key-store'
|
||||||
import { KeyServiceMac } from './key-service-mac'
|
import { KeyServiceMac } from './key-service-mac'
|
||||||
|
import * as chat from './services/chat-service'
|
||||||
|
import {
|
||||||
|
apiServer
|
||||||
|
} from './http-server'
|
||||||
|
import {
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
|
getSettingsPath,
|
||||||
|
AppSettings
|
||||||
|
} from './services/settings-store'
|
||||||
|
import { installSafeConsole } from './safe-log'
|
||||||
|
|
||||||
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
|
// handler. Wrap console.* before any other module logs anything.
|
||||||
|
installSafeConsole()
|
||||||
|
|
||||||
let wechatDb: WechatDb | null = null
|
|
||||||
let voiceService: VoiceService | null = null
|
let voiceService: VoiceService | null = null
|
||||||
let imageDecryptService: ImageDecryptService | null = null
|
let imageDecryptService: ImageDecryptService | null = null
|
||||||
let stickerService: StickerService | null = null
|
let stickerService: StickerService | null = null
|
||||||
const databaseKeyStore = new DatabaseKeyStore()
|
const databaseKeyStore = new DatabaseKeyStore()
|
||||||
const keyServiceMac = new KeyServiceMac()
|
const keyServiceMac = new KeyServiceMac()
|
||||||
const BUILD_MARK = 'wechat4-open-account-continues-after-init-1000'
|
let tray: Tray | null = null
|
||||||
|
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||||
|
const TRAY_MODE =
|
||||||
|
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
|
||||||
|
|
||||||
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
|
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
|
||||||
// In dev, matching the host app name avoids failing the native protection gate.
|
// In dev, matching the host app name avoids failing the native protection gate.
|
||||||
app.setName('WechatExplorer')
|
app.setName('WechatExplorer')
|
||||||
|
|
||||||
const MSG_TYPE_DICT: Record<number, string> = {
|
|
||||||
1: '普通文本',
|
|
||||||
3: '图片',
|
|
||||||
34: '语音',
|
|
||||||
42: '名片',
|
|
||||||
43: '视频',
|
|
||||||
47: '表情包',
|
|
||||||
48: '位置',
|
|
||||||
49: '分享消息',
|
|
||||||
50: '通话',
|
|
||||||
10000: '系统消息'
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMsgType(value: string | number | undefined): number {
|
|
||||||
const raw = String(value ?? '').trim()
|
|
||||||
if (!raw) return 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = BigInt(raw)
|
|
||||||
const low32 = Number(parsed & 0xffffffffn)
|
|
||||||
return low32 || Number(parsed)
|
|
||||||
} catch {
|
|
||||||
const parsed = Number(raw)
|
|
||||||
if (!Number.isFinite(parsed)) return 0
|
|
||||||
return parsed > 0xffffffff ? parsed >>> 0 : parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// 创建浏览器窗口
|
// 创建浏览器窗口
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
@@ -90,7 +76,7 @@ function createWindow(): void {
|
|||||||
|
|
||||||
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
|
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
|
||||||
// 某些 API 只能在此事件发生后使用
|
// 某些 API 只能在此事件发生后使用
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||||
// 为窗口设置应用程序用户模型 ID
|
// 为窗口设置应用程序用户模型 ID
|
||||||
electronApp.setAppUserModelId('com.electron')
|
electronApp.setAppUserModelId('com.electron')
|
||||||
@@ -110,8 +96,7 @@ app.whenReady().then(() => {
|
|||||||
const trimmedKey = String(key || '').trim()
|
const trimmedKey = String(key || '').trim()
|
||||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||||
const nextWechatDb = new WechatDb(key)
|
const nextWechatDb = new WechatDb(key)
|
||||||
wechatDb?.close()
|
chat.setChatDb(nextWechatDb)
|
||||||
wechatDb = nextWechatDb
|
|
||||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||||
voiceService = new VoiceService(wcdb4Client)
|
voiceService = new VoiceService(wcdb4Client)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
@@ -151,208 +136,15 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:getContacts', (_, filter?: string) => {
|
ipcMain.handle('db:getContacts', (_, filter?: string) => chat.listContacts(filter))
|
||||||
if (!wechatDb) return []
|
|
||||||
|
|
||||||
const contacts: Contact[] = []
|
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) =>
|
||||||
const groupContacts = wechatDb.getAllGroupContacts()
|
chat.listMessages(userMd5, startTime, endTime)
|
||||||
const userList = wechatDb.getUserList(filter)
|
)
|
||||||
const existingMd5s = new Set<string>()
|
|
||||||
|
|
||||||
// 1. 处理普通联系人
|
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
|
||||||
for (const user of userList) {
|
|
||||||
const md5 = wechatDb.md5(user.m_nsUsrName)
|
|
||||||
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
|
|
||||||
existingMd5s.add(md5)
|
|
||||||
contacts.push({
|
|
||||||
m_nsUsrName: user.m_nsUsrName,
|
|
||||||
m_nsNickName: user.nickname || '未知用户',
|
|
||||||
md5: md5,
|
|
||||||
type: isGroup ? 'group' : 'user',
|
|
||||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 处理聊天表
|
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
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 wcdb4Client = wechatDb.getWcdb4Client()
|
|
||||||
const username = wcdb4Client.getUsernameByMd5(userMd5)
|
|
||||||
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
|
|
||||||
const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
|
|
||||||
const myAvatar = wechatDb.getMyAvatarUrl()
|
|
||||||
const myGroupNickname = username?.endsWith('@chatroom')
|
|
||||||
? wcdb4Client.getMyGroupNickname(username)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return rawMessages.map((msg: WechatMessage) => {
|
|
||||||
const rawMsgType = parseInt(msg.messageType)
|
|
||||||
const msgType = normalizeMsgType(msg.messageType)
|
|
||||||
const createTime = parseInt(msg.msgCreateTime)
|
|
||||||
const date = new Date(createTime * 1000)
|
|
||||||
const isMine = msg.mesDes !== 1
|
|
||||||
const localId = parseInt(msg.mesLocalID) || 0
|
|
||||||
|
|
||||||
let content = msg.msgContent
|
|
||||||
let img = ''
|
|
||||||
let name = ''
|
|
||||||
if (isMine) {
|
|
||||||
if (myAvatar) img = myAvatar
|
|
||||||
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
|
|
||||||
} else {
|
|
||||||
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
|
|
||||||
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
|
||||||
}
|
|
||||||
// 检查内容是否以 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析富媒体消息内容
|
|
||||||
let contentData: ReturnType<typeof parseMessageContent> | undefined = undefined
|
|
||||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
|
||||||
const inferredMsgType =
|
|
||||||
typeof content === 'string' &&
|
|
||||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
|
||||||
? 49
|
|
||||||
: msgType
|
|
||||||
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
|
||||||
try {
|
|
||||||
const parsed =
|
|
||||||
inferredMsgType === 47
|
|
||||||
? parseStickerMessageFromRow(msg, content)
|
|
||||||
: parseMessageContent(content, inferredMsgType)
|
|
||||||
if (parsed.type === 'system') {
|
|
||||||
content = parsed.content
|
|
||||||
contentData = parsed
|
|
||||||
} else if (parsed.type !== 'unknown') {
|
|
||||||
content = ''
|
|
||||||
}
|
|
||||||
if (parsed.type === 'image') {
|
|
||||||
const imageDatName = parseImageDatNameFromRow(msg)
|
|
||||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
|
||||||
} else if (parsed.type !== 'system') {
|
|
||||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
|
||||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
|
||||||
}
|
|
||||||
contentData = parsed
|
|
||||||
}
|
|
||||||
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
|
||||||
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!contentData &&
|
|
||||||
typeof content === 'string' &&
|
|
||||||
/^[0-9a-fA-F]{64,}$/.test(content.trim())
|
|
||||||
) {
|
|
||||||
const parsed = parseStickerMessageFromRow(msg, content)
|
|
||||||
if (parsed.type === 'sticker') {
|
|
||||||
if (!parsed.url && parsed.md5) {
|
|
||||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
|
||||||
}
|
|
||||||
content = ''
|
|
||||||
contentData = parsed
|
|
||||||
displayType = '表情包'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msgType === 34) {
|
|
||||||
content = '[语音消息]'
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: msg.mesLocalID || Math.random().toString(),
|
|
||||||
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
|
||||||
type: displayType,
|
|
||||||
datetime: date.toLocaleString('zh-CN', { hour12: false }),
|
|
||||||
content: content,
|
|
||||||
img: img,
|
|
||||||
name: name,
|
|
||||||
sessionId: username,
|
|
||||||
localId: localId,
|
|
||||||
createTime: createTime,
|
|
||||||
contentData: contentData
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => {
|
|
||||||
if (!wechatDb) return null
|
|
||||||
const wcdb4Client = wechatDb.getWcdb4Client()
|
|
||||||
|
|
||||||
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
|
||||||
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
|
||||||
|
|
||||||
const members = wcdb4Client
|
|
||||||
.getGroupMembers(roomId)
|
|
||||||
.filter((member) => member?.m_nsUsrName)
|
|
||||||
.map((member) => ({
|
|
||||||
wxid: member.m_nsUsrName,
|
|
||||||
nickname: member.nickname || '',
|
|
||||||
avatar: member.m_nsHeadImgUrl || ''
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
roomId,
|
|
||||||
memberCount: members.length,
|
|
||||||
members
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('db:search', (_, keyword: string) => {
|
|
||||||
if (!wechatDb) return null
|
|
||||||
return wechatDb.searchAllMessages(keyword)
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'ai:chat',
|
'ai:chat',
|
||||||
@@ -441,7 +233,7 @@ app.whenReady().then(() => {
|
|||||||
if (!aesKey) {
|
if (!aesKey) {
|
||||||
return { success: false, error: '未配置图片解密密钥' }
|
return { success: false, error: '未配置图片解密密钥' }
|
||||||
}
|
}
|
||||||
imageDecryptService = new ImageDecryptService(xorKey, aesKey, wechatDb?.getWcdb4Client())
|
imageDecryptService = new ImageDecryptService(xorKey, aesKey, chat.getChatDb()?.getWcdb4Client())
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||||
@@ -461,13 +253,75 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
|
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
|
||||||
if (!stickerService) {
|
if (!stickerService) {
|
||||||
stickerService = new StickerService(wechatDb?.getWcdb4Client())
|
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
|
||||||
}
|
}
|
||||||
return stickerService.resolveSticker(cdnUrl, md5)
|
return stickerService.resolveSticker(cdnUrl, md5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -------- Settings & API service --------
|
||||||
|
|
||||||
|
ipcMain.handle('settings:get', () => ({
|
||||||
|
settings: loadSettings(),
|
||||||
|
settingsPath: getSettingsPath()
|
||||||
|
}))
|
||||||
|
|
||||||
|
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
|
||||||
|
const merged = saveSettings({ ...loadSettings(), ...patch })
|
||||||
|
return { settings: merged, settingsPath: getSettingsPath() }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:getSelf', () => {
|
||||||
|
const info = chat.getSelfAccountInfo()
|
||||||
|
if (!info) return { ready: false }
|
||||||
|
return { ready: true, info }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:testConnection', (_, key: string, accountRoot?: string) => {
|
||||||
|
return chat.testConnection(key, accountRoot)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
|
||||||
|
const ok = chat.reopenWithRoot(accountRoot)
|
||||||
|
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||||
|
const info = chat.getSelfAccountInfo()
|
||||||
|
return { success: true, info }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('api:getStatus', () => apiServer.getState())
|
||||||
|
|
||||||
|
ipcMain.handle('api:start', async (_, host?: string, port?: number) => {
|
||||||
|
const settings = loadSettings()
|
||||||
|
const target = {
|
||||||
|
host: host || settings.apiHost,
|
||||||
|
port: port || settings.apiPort
|
||||||
|
}
|
||||||
|
if (host || port) saveSettings({ ...settings, ...target })
|
||||||
|
return apiServer.start(target.host, target.port)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('api:stop', async () => apiServer.stop())
|
||||||
|
|
||||||
|
ipcMain.handle('api:toggle', async (_, enabled: boolean) => {
|
||||||
|
const settings = saveSettings({ ...loadSettings(), apiEnabled: enabled })
|
||||||
|
if (enabled) {
|
||||||
|
return apiServer.start(settings.apiHost, settings.apiPort)
|
||||||
|
}
|
||||||
|
return apiServer.stop()
|
||||||
|
})
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
|
// 启动本地 HTTP API(根据 settings.apiEnabled 控制)
|
||||||
|
const settings = loadSettings()
|
||||||
|
if (settings.apiEnabled) {
|
||||||
|
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TRAY_MODE) {
|
||||||
|
app.dock?.hide()
|
||||||
|
setupTray()
|
||||||
|
}
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
// 在 macOS 上,当点击 dock 图标且没有其他窗口打开时,
|
// 在 macOS 上,当点击 dock 图标且没有其他窗口打开时,
|
||||||
// 通常会在应用程序中重新创建一个窗口。
|
// 通常会在应用程序中重新创建一个窗口。
|
||||||
@@ -479,12 +333,65 @@ app.whenReady().then(() => {
|
|||||||
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
||||||
// 显式使用 Cmd + Q 退出。
|
// 显式使用 Cmd + Q 退出。
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
if (TRAY_MODE) return
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', async () => {
|
||||||
wechatDb?.close()
|
chat.setChatDb(null)
|
||||||
wechatDb = null
|
await apiServer.stop().catch(() => undefined)
|
||||||
|
if (tray) {
|
||||||
|
tray.destroy()
|
||||||
|
tray = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function showMainWindow(): void {
|
||||||
|
if (TRAY_MODE) app.dock?.show().catch(() => undefined)
|
||||||
|
const wins = BrowserWindow.getAllWindows()
|
||||||
|
if (wins.length === 0) {
|
||||||
|
createWindow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const win = wins[0]
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.show()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrayMenu(): Menu {
|
||||||
|
return Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '打开主窗口',
|
||||||
|
click: () => showMainWindow()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'API 状态',
|
||||||
|
click: () => showMainWindow()
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '退出 WechatExplorer',
|
||||||
|
click: () => {
|
||||||
|
tray?.destroy()
|
||||||
|
tray = null
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTray(): void {
|
||||||
|
if (tray) return
|
||||||
|
try {
|
||||||
|
const image = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
|
||||||
|
tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image)
|
||||||
|
tray.setToolTip('WechatExplorer')
|
||||||
|
tray.setContextMenu(buildTrayMenu())
|
||||||
|
tray.on('click', () => showMainWindow())
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Tray] Failed to create tray:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Safe logging for Electron child processes whose stdout/stderr pipes can be
|
||||||
|
// closed by the parent (electron-vite). Plain console.error throws EPIPE
|
||||||
|
// against a closed pipe, which crashes the IPC handler. Wrap writes so any
|
||||||
|
// pipe error is swallowed.
|
||||||
|
type SafeConsoleMethod = (...args: unknown[]) => void
|
||||||
|
|
||||||
|
function makeSafe(method: SafeConsoleMethod): SafeConsoleMethod {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
try {
|
||||||
|
method(...args)
|
||||||
|
} catch {
|
||||||
|
// Swallow EPIPE / ERR_STREAM_DESTROYED; logging must never crash the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const safeLog = makeSafe(console.log.bind(console))
|
||||||
|
export const safeWarn = makeSafe(console.warn.bind(console))
|
||||||
|
export const safeError = makeSafe(console.error.bind(console))
|
||||||
|
|
||||||
|
export function installSafeConsole(): void {
|
||||||
|
console.log = safeLog
|
||||||
|
console.warn = safeWarn
|
||||||
|
console.error = safeError
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||||
|
import {
|
||||||
|
parseImageDatNameFromRow,
|
||||||
|
parseMessageContent,
|
||||||
|
parseStickerMessageFromRow
|
||||||
|
} from '../message-parser'
|
||||||
|
|
||||||
|
export function getCurrentKey(): string {
|
||||||
|
if (!dbRef) return ''
|
||||||
|
try {
|
||||||
|
return dbRef.getWcdb4Client().getKey()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormattedContact {
|
||||||
|
m_nsUsrName: string
|
||||||
|
m_nsNickName: string
|
||||||
|
md5: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormattedMessage {
|
||||||
|
id: string
|
||||||
|
from: string
|
||||||
|
type: string
|
||||||
|
datetime: string
|
||||||
|
content: string
|
||||||
|
isSender: boolean
|
||||||
|
img?: string
|
||||||
|
name?: string
|
||||||
|
contentData?: ReturnType<typeof parseMessageContent>
|
||||||
|
voiceDataUrl?: string
|
||||||
|
voiceDuration?: number
|
||||||
|
localId?: number
|
||||||
|
createTime?: number
|
||||||
|
sessionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSnapshot {
|
||||||
|
roomId: string
|
||||||
|
memberCount: number
|
||||||
|
members: { wxid: string; nickname: string; avatar: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MSG_TYPE_DICT: Record<number, string> = {
|
||||||
|
1: '普通文本',
|
||||||
|
3: '图片',
|
||||||
|
34: '语音',
|
||||||
|
42: '名片',
|
||||||
|
43: '视频',
|
||||||
|
47: '表情包',
|
||||||
|
48: '位置',
|
||||||
|
49: '分享消息',
|
||||||
|
50: '通话',
|
||||||
|
10000: '系统消息'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMsgType(value: string | number | undefined): number {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (!raw) return 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = BigInt(raw)
|
||||||
|
const low32 = Number(parsed & 0xffffffffn)
|
||||||
|
return low32 || Number(parsed)
|
||||||
|
} catch {
|
||||||
|
const parsed = Number(raw)
|
||||||
|
if (!Number.isFinite(parsed)) return 0
|
||||||
|
return parsed > 0xffffffff ? parsed >>> 0 : parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbRef: WechatDb | null = null
|
||||||
|
|
||||||
|
export function setChatDb(db: WechatDb | null): void {
|
||||||
|
dbRef?.close()
|
||||||
|
dbRef = db
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatDb(): WechatDb | null {
|
||||||
|
return dbRef
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReady(): boolean {
|
||||||
|
return dbRef !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listContacts(filter?: string): FormattedContact[] {
|
||||||
|
if (!dbRef) return []
|
||||||
|
|
||||||
|
const contacts: FormattedContact[] = []
|
||||||
|
const groupContacts = dbRef.getAllGroupContacts()
|
||||||
|
const userList = dbRef.getUserList(filter)
|
||||||
|
const existingMd5s = new Set<string>()
|
||||||
|
|
||||||
|
for (const user of userList) {
|
||||||
|
const md5 = dbRef.md5(user.m_nsUsrName)
|
||||||
|
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
|
||||||
|
existingMd5s.add(md5)
|
||||||
|
contacts.push({
|
||||||
|
m_nsUsrName: user.m_nsUsrName,
|
||||||
|
m_nsNickName: user.nickname || '未知用户',
|
||||||
|
md5,
|
||||||
|
type: isGroup ? 'group' : 'user',
|
||||||
|
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatTables = dbRef.getAllChatTables()
|
||||||
|
for (const table of chatTables) {
|
||||||
|
if (!table.name.startsWith('Chat_')) continue
|
||||||
|
const md5 = table.name.substring(5)
|
||||||
|
if (existingMd5s.has(md5)) continue
|
||||||
|
if (groupContacts[md5]) {
|
||||||
|
contacts.push({
|
||||||
|
m_nsUsrName: `Group_${md5}`,
|
||||||
|
m_nsNickName: groupContacts[md5],
|
||||||
|
md5,
|
||||||
|
type: 'group'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
contacts.push({
|
||||||
|
m_nsUsrName: `Unknown_${md5}`,
|
||||||
|
m_nsNickName: `Chat_${md5}`,
|
||||||
|
md5,
|
||||||
|
type: 'user'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contacts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMessages(
|
||||||
|
userMd5: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): FormattedMessage[] {
|
||||||
|
if (!dbRef) return []
|
||||||
|
|
||||||
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
|
const username = wcdb4Client.getUsernameByMd5(userMd5)
|
||||||
|
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime)
|
||||||
|
const groupMembers = dbRef.getGroupMembersForChat(userMd5)
|
||||||
|
const myAvatar = dbRef.getMyAvatarUrl()
|
||||||
|
const myGroupNickname = username?.endsWith('@chatroom')
|
||||||
|
? wcdb4Client.getMyGroupNickname(username)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return rawMessages.map((msg: WechatMessage) => {
|
||||||
|
const rawMsgType = parseInt(msg.messageType)
|
||||||
|
const msgType = normalizeMsgType(msg.messageType)
|
||||||
|
const createTime = parseInt(msg.msgCreateTime)
|
||||||
|
const date = new Date(createTime * 1000)
|
||||||
|
const isMine = msg.mesDes !== 1
|
||||||
|
const localId = parseInt(msg.mesLocalID) || 0
|
||||||
|
|
||||||
|
let content = msg.msgContent
|
||||||
|
let img = ''
|
||||||
|
let name = ''
|
||||||
|
if (isMine) {
|
||||||
|
if (myAvatar) img = myAvatar
|
||||||
|
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
|
||||||
|
} else {
|
||||||
|
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
|
||||||
|
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
|
||||||
|
}
|
||||||
|
if (content && typeof content === 'string') {
|
||||||
|
const colonIndex = content.indexOf(':')
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
const potentialWxid = content.substring(0, colonIndex)
|
||||||
|
if (potentialWxid.startsWith('wxid_')) {
|
||||||
|
const member = dbRef!.getGroupMember(potentialWxid)
|
||||||
|
if (member) img = member.m_nsHeadImgUrl
|
||||||
|
if (groupMembers[potentialWxid]) {
|
||||||
|
name = groupMembers[potentialWxid]
|
||||||
|
content = content.substring(colonIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||||
|
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||||
|
const inferredMsgType =
|
||||||
|
typeof content === 'string' &&
|
||||||
|
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||||
|
? 49
|
||||||
|
: msgType
|
||||||
|
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||||
|
try {
|
||||||
|
const parsed =
|
||||||
|
inferredMsgType === 47
|
||||||
|
? parseStickerMessageFromRow(msg, content)
|
||||||
|
: parseMessageContent(content, inferredMsgType)
|
||||||
|
if (parsed.type === 'system') {
|
||||||
|
content = parsed.content
|
||||||
|
contentData = parsed
|
||||||
|
} else if (parsed.type !== 'unknown') {
|
||||||
|
content = ''
|
||||||
|
}
|
||||||
|
if (parsed.type === 'image') {
|
||||||
|
const imageDatName = parseImageDatNameFromRow(msg)
|
||||||
|
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||||
|
} else if (parsed.type !== 'system') {
|
||||||
|
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||||
|
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||||
|
}
|
||||||
|
contentData = parsed
|
||||||
|
}
|
||||||
|
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
||||||
|
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!contentData &&
|
||||||
|
typeof content === 'string' &&
|
||||||
|
/^[0-9a-fA-F]{64,}$/.test(content.trim())
|
||||||
|
) {
|
||||||
|
const parsed = parseStickerMessageFromRow(msg, content)
|
||||||
|
if (parsed.type === 'sticker') {
|
||||||
|
if (!parsed.url && parsed.md5) {
|
||||||
|
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||||
|
}
|
||||||
|
content = ''
|
||||||
|
contentData = parsed
|
||||||
|
displayType = '表情包'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgType === 34) content = '[语音消息]'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: msg.mesLocalID || Math.random().toString(),
|
||||||
|
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
||||||
|
isSender: isMine,
|
||||||
|
type: displayType,
|
||||||
|
datetime: date.toLocaleString('zh-CN', { hour12: false }),
|
||||||
|
content,
|
||||||
|
img,
|
||||||
|
name,
|
||||||
|
sessionId: username,
|
||||||
|
localId,
|
||||||
|
createTime,
|
||||||
|
contentData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||||
|
if (!dbRef) return null
|
||||||
|
const wcdb4Client = dbRef.getWcdb4Client()
|
||||||
|
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||||
|
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
||||||
|
|
||||||
|
const members = wcdb4Client
|
||||||
|
.getGroupMembers(roomId)
|
||||||
|
.filter((member) => member?.m_nsUsrName)
|
||||||
|
.map((member) => ({
|
||||||
|
wxid: member.m_nsUsrName,
|
||||||
|
nickname: member.nickname || '',
|
||||||
|
avatar: member.m_nsHeadImgUrl || ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { roomId, memberCount: members.length, members }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchMessages(keyword: string): string | null {
|
||||||
|
if (!dbRef) return null
|
||||||
|
return dbRef.searchAllMessages(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRecentChat(limit = 50): FormattedContact[] {
|
||||||
|
const contacts = listContacts()
|
||||||
|
return contacts.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMd5(query: string): FormattedContact | null {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const lower = trimmed.toLowerCase()
|
||||||
|
const contacts = listContacts()
|
||||||
|
|
||||||
|
const exact = contacts.find(
|
||||||
|
(c) =>
|
||||||
|
c.md5 === trimmed ||
|
||||||
|
c.m_nsUsrName.toLowerCase() === lower ||
|
||||||
|
c.m_nsNickName.toLowerCase() === lower
|
||||||
|
)
|
||||||
|
if (exact) return exact
|
||||||
|
|
||||||
|
const partial = contacts.find(
|
||||||
|
(c) =>
|
||||||
|
c.m_nsNickName.toLowerCase().includes(lower) ||
|
||||||
|
c.m_nsUsrName.toLowerCase().includes(lower)
|
||||||
|
)
|
||||||
|
return partial || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfAccountInfo {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
accountRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelfAccountInfo(): SelfAccountInfo | null {
|
||||||
|
if (!dbRef) return null
|
||||||
|
const wcdb = dbRef.getWcdb4Client()
|
||||||
|
const accountRoot = wcdb.getAccountRoot()
|
||||||
|
const usernameCandidates = wcdb.getMyUsernameCandidates()
|
||||||
|
const primaryUsername = usernameCandidates[0] ?? ''
|
||||||
|
const wxid =
|
||||||
|
primaryUsername && primaryUsername.toLowerCase().startsWith('wxid_')
|
||||||
|
? primaryUsername
|
||||||
|
: wcdb.getUsernameByMd5(wcdb.md5(accountRoot.split('/').pop() || '')) || primaryUsername
|
||||||
|
|
||||||
|
let nickname = ''
|
||||||
|
let avatar: string | undefined
|
||||||
|
try {
|
||||||
|
avatar = wcdb.getMyAvatarUrl()
|
||||||
|
} catch {
|
||||||
|
avatar = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usernameCandidates.length) {
|
||||||
|
const contacts = listContacts()
|
||||||
|
const self = contacts.find(
|
||||||
|
(c) =>
|
||||||
|
usernameCandidates.includes(c.m_nsUsrName) ||
|
||||||
|
(c.type === 'user' && usernameCandidates.some((u) => c.m_nsUsrName.includes(u)))
|
||||||
|
)
|
||||||
|
if (self) {
|
||||||
|
nickname = self.m_nsNickName
|
||||||
|
avatar = avatar || self.avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
wxid: wxid || primaryUsername || '',
|
||||||
|
nickname: nickname || wxid || '我',
|
||||||
|
avatar,
|
||||||
|
accountRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testConnection(
|
||||||
|
key: string,
|
||||||
|
accountRoot?: string
|
||||||
|
): { success: boolean; error?: string; accountRoot?: string; wxid?: string } {
|
||||||
|
try {
|
||||||
|
const probeKey = key.replace(/^0x/i, '').trim()
|
||||||
|
if (!probeKey) {
|
||||||
|
return { success: false, error: '密钥不能为空' }
|
||||||
|
}
|
||||||
|
const probe = accountRoot ? new WechatDb(probeKey, accountRoot) : new WechatDb(probeKey)
|
||||||
|
try {
|
||||||
|
probe.close()
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
accountRoot: probe.getWcdb4Client().getAccountRoot(),
|
||||||
|
wxid: (probe.getWcdb4Client().getMyUsernameCandidates?.() ?? [])[0] || ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reopenWithRoot(accountRoot: string): boolean {
|
||||||
|
if (!dbRef) return false
|
||||||
|
const key = getCurrentKey()
|
||||||
|
if (!key) return false
|
||||||
|
try {
|
||||||
|
const next = new WechatDb(key, accountRoot)
|
||||||
|
setChatDb(next)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ChatService] reopen with root failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
dbRoot: string
|
||||||
|
apiEnabled: boolean
|
||||||
|
apiHost: string
|
||||||
|
apiPort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
dbRoot: path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
|
||||||
|
),
|
||||||
|
apiEnabled: true,
|
||||||
|
apiHost: '127.0.0.1',
|
||||||
|
apiPort: 6131
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_FILE = path.join(
|
||||||
|
process.env['WE_SETTINGS_DIR'] || app.getPath('userData'),
|
||||||
|
'settings.json'
|
||||||
|
)
|
||||||
|
|
||||||
|
let cache: AppSettings | null = null
|
||||||
|
|
||||||
|
function ensureDir(): void {
|
||||||
|
fs.ensureDirSync(path.dirname(SETTINGS_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSettings(): AppSettings {
|
||||||
|
if (cache) return cache
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(SETTINGS_FILE)) {
|
||||||
|
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
|
||||||
|
cache = { ...DEFAULT_SETTINGS, ...raw }
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Settings] failed to load, fallback to defaults:', error)
|
||||||
|
}
|
||||||
|
cache = { ...DEFAULT_SETTINGS }
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(next: AppSettings): AppSettings {
|
||||||
|
cache = { ...next }
|
||||||
|
try {
|
||||||
|
ensureDir()
|
||||||
|
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Settings] failed to save:', error)
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(patch: Partial<AppSettings>): AppSettings {
|
||||||
|
return saveSettings({ ...loadSettings(), ...patch })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSettings(): AppSettings {
|
||||||
|
cache = null
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(SETTINGS_FILE)) fs.unlinkSync(SETTINGS_FILE)
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
return loadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettingsPath(): string {
|
||||||
|
return SETTINGS_FILE
|
||||||
|
}
|
||||||
@@ -145,7 +145,9 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
constructor(key: string, accountRoot?: string) {
|
constructor(key: string, accountRoot?: string) {
|
||||||
this.key = key.replace(/^0x/i, '').trim()
|
this.key = key.replace(/^0x/i, '').trim()
|
||||||
this.accountRoot = accountRoot || Wcdb4Client.findLatestAccountRoot()
|
this.accountRoot = accountRoot
|
||||||
|
? Wcdb4Client.resolveAccountRoot(accountRoot)
|
||||||
|
: Wcdb4Client.findLatestAccountRoot()
|
||||||
this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot))
|
this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot))
|
||||||
this.dbStoragePath = path.join(this.accountRoot, 'db_storage')
|
this.dbStoragePath = path.join(this.accountRoot, 'db_storage')
|
||||||
this.sessionDbPath = this.findSessionDb()
|
this.sessionDbPath = this.findSessionDb()
|
||||||
@@ -155,6 +157,37 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static resolveAccountRoot(accountRoot: string): string {
|
||||||
|
const target = (accountRoot || '').trim().replace(/\/+$/, '')
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('微信 4.0 账号目录不能为空')
|
||||||
|
}
|
||||||
|
if (fs.existsSync(path.join(target, 'db_storage'))) {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(target)) {
|
||||||
|
throw new Error(`未找到微信 4.0 数据目录: ${target}`)
|
||||||
|
}
|
||||||
|
const candidates = fs
|
||||||
|
.readdirSync(target)
|
||||||
|
.map((name) => path.join(target, name))
|
||||||
|
.filter((candidate) => {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
fs.statSync(candidate).isDirectory() &&
|
||||||
|
fs.existsSync(path.join(candidate, 'db_storage'))
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
|
||||||
|
if (!candidates[0]) {
|
||||||
|
throw new Error(`未找到包含 db_storage 的微信 4.0 账号目录: ${target}`)
|
||||||
|
}
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
static findLatestAccountRoot(): string {
|
static findLatestAccountRoot(): string {
|
||||||
const root = Wcdb4Client.defaultRoot
|
const root = Wcdb4Client.defaultRoot
|
||||||
if (!fs.existsSync(root)) {
|
if (!fs.existsSync(root)) {
|
||||||
@@ -714,6 +747,10 @@ export class Wcdb4Client {
|
|||||||
return this.accountRoot
|
return this.accountRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getKey(): string {
|
||||||
|
return this.key
|
||||||
|
}
|
||||||
|
|
||||||
resolveImageHardlink(md5: string): Wcdb4ImageHardlink | null {
|
resolveImageHardlink(md5: string): Wcdb4ImageHardlink | null {
|
||||||
if (!this.wcdbResolveImageHardlink) return null
|
if (!this.wcdbResolveImageHardlink) return null
|
||||||
const normalizedMd5 = String(md5 || '')
|
const normalizedMd5 = String(md5 || '')
|
||||||
@@ -1390,7 +1427,7 @@ export class Wcdb4Client {
|
|||||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMyUsernameCandidates(): string[] {
|
getMyUsernameCandidates(): string[] {
|
||||||
const rawAccountName = path.basename(this.accountRoot)
|
const rawAccountName = path.basename(this.accountRoot)
|
||||||
return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)])
|
return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export class WechatDb {
|
|||||||
private wcdb4Client: Wcdb4Client
|
private wcdb4Client: Wcdb4Client
|
||||||
private chatMd5ToUsername = new Map<string, string>()
|
private chatMd5ToUsername = new Map<string, string>()
|
||||||
|
|
||||||
constructor(rawKey: string) {
|
constructor(rawKey: string, accountRoot?: string) {
|
||||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||||
const client = new Wcdb4Client(rawKey)
|
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||||
client.open()
|
client.open()
|
||||||
this.wcdb4Client = client
|
this.wcdb4Client = client
|
||||||
for (const table of client.getChatTables()) {
|
for (const table of client.getChatTables()) {
|
||||||
|
|||||||
Vendored
+61
@@ -81,6 +81,67 @@ declare global {
|
|||||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
||||||
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
||||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||||
|
getSettings: () => Promise<{
|
||||||
|
settings: {
|
||||||
|
dbRoot: string
|
||||||
|
apiEnabled: boolean
|
||||||
|
apiHost: string
|
||||||
|
apiPort: number
|
||||||
|
}
|
||||||
|
settingsPath: string
|
||||||
|
}>
|
||||||
|
setSettings: (patch: Partial<{
|
||||||
|
dbRoot: string
|
||||||
|
apiEnabled: boolean
|
||||||
|
apiHost: string
|
||||||
|
apiPort: number
|
||||||
|
}>) => Promise<{
|
||||||
|
settings: {
|
||||||
|
dbRoot: string
|
||||||
|
apiEnabled: boolean
|
||||||
|
apiHost: string
|
||||||
|
apiPort: number
|
||||||
|
}
|
||||||
|
settingsPath: string
|
||||||
|
}>
|
||||||
|
getSelf: () => Promise<
|
||||||
|
| {
|
||||||
|
ready: true
|
||||||
|
info: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||||
|
}
|
||||||
|
| { ready: false }
|
||||||
|
>
|
||||||
|
testConnection: (
|
||||||
|
key: string,
|
||||||
|
accountRoot?: string
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
accountRoot?: string
|
||||||
|
wxid?: string
|
||||||
|
}>
|
||||||
|
reopenWithRoot: (accountRoot: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
info?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||||
|
}>
|
||||||
|
apiStatus: () => Promise<{
|
||||||
|
running: boolean
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
apiStart: (
|
||||||
|
host?: string,
|
||||||
|
port?: number
|
||||||
|
) => Promise<{ running: boolean; host: string; port: number; error?: string }>
|
||||||
|
apiStop: () => Promise<{ running: boolean; host: string; port: number; error?: string }>
|
||||||
|
apiToggle: (enabled: boolean) => Promise<{
|
||||||
|
running: boolean
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -42,7 +42,17 @@ const api = {
|
|||||||
callback(payload)
|
callback(payload)
|
||||||
ipcRenderer.on('key:dbKeyStatus', listener)
|
ipcRenderer.on('key:dbKeyStatus', listener)
|
||||||
return () => ipcRenderer.removeListener('key:dbKeyStatus', listener)
|
return () => ipcRenderer.removeListener('key:dbKeyStatus', listener)
|
||||||
}
|
},
|
||||||
|
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||||
|
setSettings: (patch) => ipcRenderer.invoke('settings:set', patch),
|
||||||
|
getSelf: () => ipcRenderer.invoke('settings:getSelf'),
|
||||||
|
testConnection: (key: string, accountRoot?: string) =>
|
||||||
|
ipcRenderer.invoke('db:testConnection', key, accountRoot),
|
||||||
|
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
||||||
|
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
||||||
|
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
||||||
|
apiStop: () => ipcRenderer.invoke('api:stop'),
|
||||||
|
apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import ChatWindow from './components/ChatWindow'
|
import ChatWindow from './components/ChatWindow'
|
||||||
|
import { SettingsPanel } from './components/SettingsPanel'
|
||||||
import { Contact, Message } from '../../shared/types'
|
import { Contact, Message } from '../../shared/types'
|
||||||
|
|
||||||
|
interface SelfInfo {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
accountRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 250
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
@@ -96,6 +104,8 @@ function App(): React.ReactElement {
|
|||||||
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
||||||
const [showDbKey, setShowDbKey] = useState(false)
|
const [showDbKey, setShowDbKey] = useState(false)
|
||||||
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
||||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||||
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||||
@@ -137,6 +147,7 @@ function App(): React.ReactElement {
|
|||||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
loadContacts()
|
loadContacts()
|
||||||
|
void refreshSelfInfo()
|
||||||
} else {
|
} else {
|
||||||
const error = typeof result === 'boolean' ? '' : result.error
|
const error = typeof result === 'boolean' ? '' : result.error
|
||||||
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
|
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
|
||||||
@@ -147,6 +158,20 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshSelfInfo = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await window.api.getSelf()
|
||||||
|
if (result.ready) {
|
||||||
|
setSelfInfo(result.info)
|
||||||
|
} else {
|
||||||
|
setSelfInfo(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[SelfInfo] 加载失败:', error)
|
||||||
|
setSelfInfo(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const logGroupSnapshot = React.useCallback(
|
const logGroupSnapshot = React.useCallback(
|
||||||
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
|
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
|
||||||
if (!contact || contact.type !== 'group') return null
|
if (!contact || contact.type !== 'group') return null
|
||||||
@@ -476,6 +501,9 @@ function App(): React.ReactElement {
|
|||||||
width={sidebarWidth}
|
width={sidebarWidth}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
|
selfInfo={selfInfo}
|
||||||
|
dbReady={isAuthenticated}
|
||||||
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
/>
|
/>
|
||||||
<div className="resizer" onMouseDown={startResizing} />
|
<div className="resizer" onMouseDown={startResizing} />
|
||||||
<ChatWindow
|
<ChatWindow
|
||||||
@@ -486,6 +514,18 @@ function App(): React.ReactElement {
|
|||||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
||||||
onRefreshData={loadContacts}
|
onRefreshData={loadContacts}
|
||||||
/>
|
/>
|
||||||
|
<SettingsPanel
|
||||||
|
open={showSettings}
|
||||||
|
selfInfo={selfInfo}
|
||||||
|
dbReady={isAuthenticated}
|
||||||
|
dbKey={dbKey}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onDbKeyChange={setDbKey}
|
||||||
|
onDbRootChanged={() => {
|
||||||
|
void refreshSelfInfo()
|
||||||
|
void loadContacts()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1204,3 +1204,359 @@ body {
|
|||||||
.voip-status {
|
.voip-status {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar 自助卡片 + 入口 */
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #f3f4f5;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer:hover {
|
||||||
|
background-color: #e6e9eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-self-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-self-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-self-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-self-nickname {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2429;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-self-wxid {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8a9298;
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-self-arrow {
|
||||||
|
color: #8a9298;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 设置面板 */
|
||||||
|
.settings-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 21, 26, 0.45);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: settings-fade-in 0.16s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settings-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal {
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
max-height: 84vh;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 24px 60px rgba(15, 21, 26, 0.28);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: settings-pop-in 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes settings-pop-in {
|
||||||
|
from {
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 22px;
|
||||||
|
border-bottom: 1px solid #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #7d858a;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-close:hover {
|
||||||
|
background: #f0f2f4;
|
||||||
|
color: #1f2429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-body {
|
||||||
|
padding: 12px 22px 22px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fafbfc;
|
||||||
|
border: 1px solid #ececec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section:first-child {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6f767c;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border: 1px solid #d4d9dc;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f2429;
|
||||||
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input:focus {
|
||||||
|
border-color: #07c160;
|
||||||
|
box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input-half {
|
||||||
|
flex: 0 1 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input-quarter {
|
||||||
|
flex: 0 1 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: 1px solid #d4d9dc;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #30383d;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn:hover:not(:disabled) {
|
||||||
|
border-color: #07c160;
|
||||||
|
color: #078f49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn-primary {
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #07c160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn-primary:hover:not(:disabled) {
|
||||||
|
background: #06ad56;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #06ad56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8a9298;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hint code {
|
||||||
|
background: #eef0f2;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #59636a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-status.ok {
|
||||||
|
color: #078f49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-status.fail {
|
||||||
|
color: #c73737;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #30383d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #07c160;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-self {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-self-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #07c160;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-self-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-self-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-self-nickname {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-self-wxid {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6f767c;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-self-account {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8a9298;
|
||||||
|
margin-top: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-self-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8a9298;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-path {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #eef0f2;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #30383d;
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface SelfInfo {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
accountRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppSettings {
|
||||||
|
dbRoot: string
|
||||||
|
apiEnabled: boolean
|
||||||
|
apiHost: string
|
||||||
|
apiPort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiState {
|
||||||
|
running: boolean
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
open: boolean
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
dbReady: boolean
|
||||||
|
dbKey: string
|
||||||
|
onClose: () => void
|
||||||
|
onDbKeyChange: (key: string) => void
|
||||||
|
onDbRootChanged: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
|
||||||
|
open,
|
||||||
|
selfInfo,
|
||||||
|
dbReady,
|
||||||
|
dbKey,
|
||||||
|
onClose,
|
||||||
|
onDbKeyChange,
|
||||||
|
onDbRootChanged
|
||||||
|
}) => {
|
||||||
|
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||||
|
const [settingsPath, setSettingsPath] = useState('')
|
||||||
|
const [apiState, setApiState] = useState<ApiState | null>(null)
|
||||||
|
const [testStatus, setTestStatus] = useState<
|
||||||
|
{ kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string }
|
||||||
|
>({ kind: 'idle', message: '' })
|
||||||
|
const [reopenStatus, setReopenStatus] = useState<string>('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
void refresh()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
const [{ settings, settingsPath }, api] = await Promise.all([
|
||||||
|
window.api.getSettings(),
|
||||||
|
window.api.apiStatus()
|
||||||
|
])
|
||||||
|
setSettings(settings)
|
||||||
|
setSettingsPath(settingsPath)
|
||||||
|
setApiState(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleSave(patch: Partial<AppSettings>): Promise<void> {
|
||||||
|
if (!settings) return
|
||||||
|
setBusy(true)
|
||||||
|
const next = await window.api.setSettings(patch)
|
||||||
|
setSettings(next.settings)
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest(): Promise<void> {
|
||||||
|
setTestStatus({ kind: 'idle', message: '测试中...' })
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await window.api.testConnection(dbKey, settings?.dbRoot)
|
||||||
|
if (result.success) {
|
||||||
|
setTestStatus({
|
||||||
|
kind: 'ok',
|
||||||
|
message: '连接成功',
|
||||||
|
wxid: result.wxid,
|
||||||
|
accountRoot: result.accountRoot
|
||||||
|
})
|
||||||
|
if (result.accountRoot && settings && result.accountRoot !== settings.dbRoot) {
|
||||||
|
const next = await window.api.setSettings({ dbRoot: result.accountRoot })
|
||||||
|
setSettings(next.settings)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTestStatus({ kind: 'fail', message: result.error || '连接失败' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setTestStatus({ kind: 'fail', message: error instanceof Error ? error.message : String(error) })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReopen(): Promise<void> {
|
||||||
|
if (!settings) return
|
||||||
|
setBusy(true)
|
||||||
|
setReopenStatus('重新初始化中...')
|
||||||
|
try {
|
||||||
|
const result = await window.api.reopenWithRoot(settings.dbRoot)
|
||||||
|
if (result.success) {
|
||||||
|
setReopenStatus(`已重新打开:${result.info?.wxid || '未知'}`)
|
||||||
|
onDbRootChanged()
|
||||||
|
} else {
|
||||||
|
setReopenStatus(result.error || '重新打开失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setReopenStatus(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApiToggle(enabled: boolean): Promise<void> {
|
||||||
|
setBusy(true)
|
||||||
|
await handleSave({ apiEnabled: enabled })
|
||||||
|
const state = await window.api.apiToggle(enabled)
|
||||||
|
setApiState(state)
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApiRestart(): Promise<void> {
|
||||||
|
if (!settings) return
|
||||||
|
setBusy(true)
|
||||||
|
await window.api.apiStop()
|
||||||
|
const state = await window.api.apiStart(settings.apiHost, settings.apiPort)
|
||||||
|
setApiState(state)
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-overlay" onClick={onClose}>
|
||||||
|
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="settings-header">
|
||||||
|
<h2>设置</h2>
|
||||||
|
<button className="settings-close" onClick={onClose} title="关闭">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-body">
|
||||||
|
{/* 自我信息卡片 */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">账号信息</div>
|
||||||
|
{dbReady && selfInfo ? (
|
||||||
|
<div className="settings-self">
|
||||||
|
<div className="settings-self-avatar">
|
||||||
|
{selfInfo.avatar ? (
|
||||||
|
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
|
(selfInfo.nickname || selfInfo.wxid || '?').charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="settings-self-info">
|
||||||
|
<div className="settings-self-nickname">{selfInfo.nickname}</div>
|
||||||
|
<div className="settings-self-wxid">{selfInfo.wxid}</div>
|
||||||
|
<div className="settings-self-account">{selfInfo.accountRoot}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="settings-self-empty">尚未连接数据库</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 测试连接 */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">连接测试</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<button
|
||||||
|
className="settings-btn settings-btn-primary"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={busy || !dbKey}
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</button>
|
||||||
|
{testStatus.kind !== 'idle' && (
|
||||||
|
<span className={`settings-status ${testStatus.kind}`}>
|
||||||
|
{testStatus.kind === 'ok' ? '✓' : '✗'} {testStatus.message}
|
||||||
|
{testStatus.wxid ? ` · ${testStatus.wxid}` : ''}
|
||||||
|
{testStatus.accountRoot ? ` · ${testStatus.accountRoot}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="settings-hint">
|
||||||
|
使用当前密钥 + 下方配置的根目录尝试打开数据库,只校验不持久化。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 解密密钥 */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">解密密钥</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
value={dbKey}
|
||||||
|
onChange={(e) => onDbKeyChange(e.target.value)}
|
||||||
|
placeholder="64 位 hex 密钥,如 0x..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-hint">
|
||||||
|
密钥保存在本机 macOS Keychain(safeStorage 加密),不会上传任何服务器。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 数据库根目录 */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">数据库根目录</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
value={settings?.dbRoot ?? ''}
|
||||||
|
onChange={(e) => setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)}
|
||||||
|
onBlur={(e) => handleSave({ dbRoot: e.target.value })}
|
||||||
|
placeholder="~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<button
|
||||||
|
className="settings-btn"
|
||||||
|
onClick={handleReopen}
|
||||||
|
disabled={busy || !dbReady}
|
||||||
|
>
|
||||||
|
应用并重新初始化
|
||||||
|
</button>
|
||||||
|
{reopenStatus && <span className="settings-status">{reopenStatus}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="settings-hint">
|
||||||
|
指向 xwechat_files 目录,内部包含 db_storage/。修改后需重新初始化才能生效。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* API 服务 */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">本地 HTTP API</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<label className="settings-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings?.apiEnabled ?? false}
|
||||||
|
onChange={(e) => handleApiToggle(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span>启用 API 服务(127.0.0.1:6131)</span>
|
||||||
|
</label>
|
||||||
|
{apiState && (
|
||||||
|
<span className={`settings-status ${apiState.running ? 'ok' : 'fail'}`}>
|
||||||
|
{apiState.running ? '运行中' : '已停止'}
|
||||||
|
{apiState.error ? ` · ${apiState.error}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input settings-input-half"
|
||||||
|
value={settings?.apiHost ?? ''}
|
||||||
|
onChange={(e) => setSettings(settings ? { ...settings, apiHost: e.target.value } : null)}
|
||||||
|
onBlur={(e) => handleSave({ apiHost: e.target.value })}
|
||||||
|
placeholder="host"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="settings-input settings-input-quarter"
|
||||||
|
value={settings?.apiPort ?? 6131}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings(settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null)
|
||||||
|
}
|
||||||
|
onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })}
|
||||||
|
placeholder="port"
|
||||||
|
/>
|
||||||
|
<button className="settings-btn" onClick={handleApiRestart} disabled={busy}>
|
||||||
|
重启 API
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="settings-hint">
|
||||||
|
API 仅本机访问,无鉴权。关闭后 Claude / Codex 等客户端无法读取聊天数据。
|
||||||
|
<br />
|
||||||
|
配置文档:<code>docs/skill/wechatexplorer-reader/SKILL.md</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 配置文件位置 */}
|
||||||
|
<section className="settings-section">
|
||||||
|
<div className="settings-section-title">配置文件</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<code className="settings-path">{settingsPath}</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Contact } from '../../../shared/types'
|
import { Contact } from '../../../shared/types'
|
||||||
|
|
||||||
|
interface SelfInfo {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
accountRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
contacts: Contact[]
|
contacts: Contact[]
|
||||||
selectedContact: Contact | null
|
selectedContact: Contact | null
|
||||||
@@ -10,6 +17,9 @@ interface SidebarProps {
|
|||||||
width: number
|
width: number
|
||||||
dateRange: string
|
dateRange: string
|
||||||
onDateRangeChange: (range: string) => void
|
onDateRangeChange: (range: string) => void
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
dbReady: boolean
|
||||||
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
@@ -20,7 +30,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onContentFilter,
|
onContentFilter,
|
||||||
width,
|
width,
|
||||||
dateRange,
|
dateRange,
|
||||||
onDateRangeChange
|
onDateRangeChange,
|
||||||
|
selfInfo,
|
||||||
|
dbReady,
|
||||||
|
onOpenSettings
|
||||||
}) => {
|
}) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [contentFilter, setContentFilter] = useState('')
|
const [contentFilter, setContentFilter] = useState('')
|
||||||
@@ -115,14 +128,24 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<div className="section-empty">暂无联系人</div>
|
<div className="section-empty">暂无联系人</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="sidebar-footer">
|
<div className="sidebar-footer" onClick={onOpenSettings} title="设置">
|
||||||
<div className="sidebar-btn" onClick={() => window.location.reload()}>
|
<div className="sidebar-self-avatar">
|
||||||
<span className="icon">↪️</span> 退出
|
{selfInfo?.avatar ? (
|
||||||
</div>
|
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
|
||||||
<div className="sidebar-status">
|
) : (
|
||||||
✅ 已获得
|
((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0))
|
||||||
</div>
|
)}
|
||||||
</div> */}
|
</div>
|
||||||
|
<div className="sidebar-self-info">
|
||||||
|
<div className="sidebar-self-nickname">
|
||||||
|
{dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '我' : '未连接'}
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-self-wxid">
|
||||||
|
{dbReady && selfInfo ? selfInfo.wxid : '点击设置 →'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-self-arrow" aria-hidden>⚙</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user