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:
@@ -1,8 +1,16 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import ChatWindow from './components/ChatWindow'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
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 MESSAGE_MONITOR_DEBOUNCE_MS = 250
|
||||
|
||||
@@ -96,6 +104,8 @@ function App(): React.ReactElement {
|
||||
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
||||
const [showDbKey, setShowDbKey] = 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 currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
|
||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||
@@ -137,6 +147,7 @@ function App(): React.ReactElement {
|
||||
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||
setIsAuthenticated(true)
|
||||
loadContacts()
|
||||
void refreshSelfInfo()
|
||||
} else {
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
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(
|
||||
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
|
||||
if (!contact || contact.type !== 'group') return null
|
||||
@@ -476,6 +501,9 @@ function App(): React.ReactElement {
|
||||
width={sidebarWidth}
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isAuthenticated}
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
<div className="resizer" onMouseDown={startResizing} />
|
||||
<ChatWindow
|
||||
@@ -486,6 +514,18 @@ function App(): React.ReactElement {
|
||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
||||
onRefreshData={loadContacts}
|
||||
/>
|
||||
<SettingsPanel
|
||||
open={showSettings}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isAuthenticated}
|
||||
dbKey={dbKey}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onDbKeyChange={setDbKey}
|
||||
onDbRootChanged={() => {
|
||||
void refreshSelfInfo()
|
||||
void loadContacts()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1204,3 +1204,359 @@ body {
|
||||
.voip-status {
|
||||
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 { Contact } from '../../../shared/types'
|
||||
|
||||
interface SelfInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
contacts: Contact[]
|
||||
selectedContact: Contact | null
|
||||
@@ -10,6 +17,9 @@ interface SidebarProps {
|
||||
width: number
|
||||
dateRange: string
|
||||
onDateRangeChange: (range: string) => void
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
@@ -20,7 +30,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
onContentFilter,
|
||||
width,
|
||||
dateRange,
|
||||
onDateRangeChange
|
||||
onDateRangeChange,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onOpenSettings
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [contentFilter, setContentFilter] = useState('')
|
||||
@@ -115,14 +128,24 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<div className="section-empty">暂无联系人</div>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="sidebar-footer">
|
||||
<div className="sidebar-btn" onClick={() => window.location.reload()}>
|
||||
<span className="icon">↪️</span> 退出
|
||||
</div>
|
||||
<div className="sidebar-status">
|
||||
✅ 已获得
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="sidebar-footer" onClick={onOpenSettings} title="设置">
|
||||
<div className="sidebar-self-avatar">
|
||||
{selfInfo?.avatar ? (
|
||||
<img src={selfInfo.avatar} alt={selfInfo.nickname} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0))
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user