mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 05:27:48 +08:00
实现图片解密设置页
This commit is contained in:
@@ -3645,6 +3645,15 @@ body {
|
||||
.database-key-diagnostics { margin-top:22px; border:1px solid #dde3e0; border-radius:9px; background:#fff; }.database-key-diagnostics summary { padding:14px 16px; color:#46514c; cursor:pointer; font-size:13px; font-weight:600; }.database-key-diagnostics dl { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px 28px; margin:0; padding:2px 16px 16px; }.database-key-diagnostics button { margin:0 16px 16px; border:0; padding:0; background:transparent; color:#247a63; cursor:pointer; font:600 12px inherit; }
|
||||
.database-key-danger { margin-top:30px; padding-bottom:1px; }.database-key-danger>h2 { margin:0 0 14px; color:#c85a5a; font-size:15px; }.database-key-danger>div { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 20px; border:1px solid #efcdcd; background:#fff7f7; }.database-key-danger>div:first-of-type { border-radius:9px 9px 0 0; }.database-key-danger>div:last-of-type { border-top:0; border-radius:0 0 9px 9px; }.database-key-danger span { display:grid; gap:4px; }.database-key-danger strong { color:#4a3c3c; font-size:13px; }.database-key-danger small { color:#786868; font-size:12px; }.database-key-danger button { min-height:34px; flex:0 0 auto; border:1px solid #e8baba; border-radius:8px; padding:0 13px; background:#fff; color:#c85a5a; cursor:pointer; }.database-key-danger button:disabled { cursor:not-allowed; opacity:.45; }
|
||||
.database-key-confirm-backdrop { position:fixed; z-index:30; inset:0; display:grid; place-items:center; padding:24px; background:rgba(25,32,29,.34); }.database-key-confirm { width:min(100%,430px); padding:22px; border-radius:12px; background:#fff; box-shadow:0 18px 50px rgba(24,35,30,.2); }.database-key-confirm h2 { margin:0; color:#202724; font-size:17px; }.database-key-confirm p { margin:12px 0 22px; color:#66706b; font-size:13px; line-height:1.7; }.database-key-confirm>div { display:flex; justify-content:flex-end; gap:8px; }.database-key-confirm button { min-height:34px; border:1px solid #dde3e0; border-radius:8px; padding:0 14px; background:#fff; color:#46514c; cursor:pointer; }.database-key-confirm button.danger { border-color:#c85a5a; background:#c85a5a; color:#fff; }
|
||||
/* SETTINGS-03: image decryption */
|
||||
.image-decryption-content { padding-bottom:48px; }
|
||||
.image-decrypt-badge.unconfigured { background:#f1f3f2; color:#66706b; }.image-decrypt-badge.partial { background:#fff6e8; color:#a56a24; }
|
||||
.image-decrypt-status dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px 30px; margin:0 0 20px; }.image-decrypt-status dl div { min-width:0; }.image-decrypt-status dt { color:#929a96; font-size:12px; }.image-decrypt-status dd { overflow:hidden; margin:5px 0 0; color:#35403b; font-size:13px; text-overflow:ellipsis; white-space:nowrap; }.image-decrypt-status .image-decrypt-wide { grid-column:span 2; }.image-decrypt-mono { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }.image-state-success { color:#2e8b68!important; }.image-state-muted { color:#66706b!important; }.image-state-error { color:#c85a5a!important; }
|
||||
.image-resource-checks { padding:10px 18px; }.image-resource-checks>div { display:flex; min-width:0; align-items:center; gap:11px; padding:13px 2px; border-bottom:1px solid #edf0ee; }.image-resource-checks>div:last-child { border-bottom:0; }.image-resource-checks strong { color:#3d4742; font-size:13px; }.image-resource-checks small { margin-left:auto; overflow:hidden; color:#66706b; text-overflow:ellipsis; white-space:nowrap; font-size:12px; }.image-resource-icon { display:grid; width:18px; height:18px; flex:0 0 auto; place-items:center; border-radius:50%; background:#f1f3f2; color:#66706b; font-size:11px; font-weight:700; }.image-resource-icon.available { background:#e6f2ed; color:#2e8b68; }.image-resource-icon.unavailable { background:#f9eaea; color:#c85a5a; }
|
||||
.image-key-editor { display:grid; gap:17px; }.image-key-editor label { display:grid; min-width:0; gap:8px; color:#46514c; font-size:12px; font-weight:600; }.image-key-editor input,.image-test-section select { box-sizing:border-box; width:100%; height:40px; border:1px solid #d5ddda; border-radius:8px; outline:0; padding:0 12px; background:#f4f7f5; color:#202724; font:12px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; }.image-key-editor input:focus,.image-test-section select:focus { border-color:#247a63; box-shadow:0 0 0 2px rgba(36,122,99,.09); }.image-key-grid { display:grid; grid-template-columns:180px minmax(0,1fr); gap:14px; }.image-key-editor>p { margin:0; color:#66706b; font-size:12px; line-height:1.6; }
|
||||
.image-test-section>div:first-child strong,.image-auto-detect strong,.image-auto-unavailable strong { color:#35403b; font-size:14px; }.image-test-section>div:first-child p,.image-auto-detect p,.image-auto-unavailable p { margin:6px 0 0; color:#66706b; font-size:12px; line-height:1.6; }.image-test-section>label { display:block; margin:18px 0 8px; color:#46514c; font-size:12px; font-weight:600; }.image-test-actions { display:flex; gap:9px; margin-top:14px; }.image-test-result { display:flex; flex-wrap:wrap; gap:8px 18px; margin-top:14px; padding:12px 14px; border-radius:8px; color:#2e765d; font-size:12px; }.image-test-result.success { border:1px solid #bfded3; background:#eaf5f1; }.image-test-result.error { border:1px solid #efcaca; background:#fff2f2; color:#a84444; }.image-test-result p { width:100%; margin:2px 0 0; }.image-inline-error { margin:14px 0 0; color:#a84444; font-size:12px; }
|
||||
.image-auto-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; }.image-auto-detect ul { display:flex; flex-wrap:wrap; gap:10px 22px; margin:17px 0 0; padding:14px 0 0; border-top:1px solid #edf0ee; list-style:none; color:#8b5c25; font-size:12px; }.image-auto-detect li::before { content:'○'; margin-right:6px; }.image-auto-detect li.ok { color:#2e8b68; }.image-auto-detect li.ok::before { content:'✓'; }.image-auto-progress { margin-top:12px!important; padding:9px 12px; border-radius:7px; background:#f1f4f3; }.image-auto-unavailable { padding-top:20px; padding-bottom:20px; }.image-key-danger>div { border-radius:9px!important; }.image-key-danger { margin-top:30px; }
|
||||
.image-auto-phases { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin:18px 0 0; padding:0; counter-reset:image-auto-phase; list-style:none; }.image-auto-phases li { position:relative; padding-top:24px; color:#929a96; text-align:center; font-size:11px; }.image-auto-phases li::before { position:absolute; top:0; left:50%; display:grid; width:18px; height:18px; place-items:center; border:1px solid #d5ddda; border-radius:50%; background:#fff; content:counter(image-auto-phase); counter-increment:image-auto-phase; transform:translateX(-50%); }.image-auto-phases li.active { color:#247a63; }.image-auto-phases li.active::before { border-color:#247a63; background:#247a63; color:#fff; }.image-auto-error { margin:12px 0 0!important; padding:10px 12px; border-left:3px solid #c85a5a; background:#fff2f2; color:#a84444!important; }.image-auto-success { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:7px 20px; align-items:center; margin-top:14px; padding:14px 16px; border:1px solid #bfded3; border-radius:8px; background:#eaf5f1; color:#2e765d; font-size:12px; }.image-auto-success strong { grid-column:1; color:#2e765d; }.image-auto-success span { grid-column:1; }.image-auto-success button { grid-column:2; grid-row:1/4; }
|
||||
@keyframes settings-spin { to { transform:rotate(360deg); } }
|
||||
@media (max-width:900px) {
|
||||
.settings-sidebar { width:248px; flex-basis:248px; }
|
||||
@@ -3654,6 +3663,7 @@ body {
|
||||
.settings-account-facts { grid-column:1/-1; grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.settings-account-actions { grid-row:1; grid-column:2; }
|
||||
.database-key-security-info { grid-template-columns:1fr; }
|
||||
.image-decrypt-status dl { grid-template-columns:repeat(2,minmax(0,1fr)); }.image-decrypt-status .image-decrypt-wide { grid-column:span 2; }
|
||||
}
|
||||
@media (max-width:700px) {
|
||||
.settings-account-overview { grid-template-columns:minmax(0,1fr); }
|
||||
@@ -3661,6 +3671,7 @@ body {
|
||||
.settings-account-root { grid-column:1; }
|
||||
.database-key-status-card dl,.database-key-diagnostics dl { grid-template-columns:1fr; }
|
||||
.database-key-auto-heading { flex-direction:column; }.database-key-phases { grid-template-columns:1fr; }.database-key-phases li { padding:0 0 0 28px; text-align:left; }.database-key-phases li::before { top:-2px; left:0; transform:none; }
|
||||
.image-decrypt-status dl,.image-key-grid { grid-template-columns:1fr; }.image-decrypt-status .image-decrypt-wide { grid-column:1; }.image-auto-heading { flex-direction:column; }.image-auto-phases { grid-template-columns:1fr; }.image-auto-phases li { padding:0 0 0 28px; text-align:left; }.image-auto-phases li::before { top:-2px; left:0; transform:none; }.image-auto-success { grid-template-columns:1fr; }.image-auto-success button { grid-column:1; grid-row:auto; justify-self:start; }.image-resource-checks>div { align-items:flex-start; }.image-resource-checks small { white-space:normal; text-align:right; }
|
||||
}
|
||||
.api-center-layout > * { min-width: 0; min-height: 0; box-sizing: border-box; }
|
||||
.api-section-heading h2, .api-introduction h2, .api-integrations h2, .api-runtime-title h2 { margin: 0; color: var(--wxex-text-primary); font: 700 17px/24px var(--wxex-font); }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SETTINGS_CATEGORY_LABELS } from './model/settingsNavigation'
|
||||
import type { SettingsCategoryId, SettingsSelfInfo } from './model/types'
|
||||
import { AccountDatabasePage } from './pages/AccountDatabasePage'
|
||||
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
||||
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
|
||||
export function SettingsWorkspace({
|
||||
@@ -61,6 +62,8 @@ export function SettingsWorkspace({
|
||||
onFilteredContactsChange={onFilteredContactsChange}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
) : selectedCategory === 'image-key' ? (
|
||||
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||
) : (
|
||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
)}
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
} from './diagnostics'
|
||||
import type { ConnectionCheckState } from './types'
|
||||
|
||||
interface AppSettings {
|
||||
imageAesKey: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export function useAccountDatabaseController({
|
||||
dbKey,
|
||||
@@ -25,19 +21,19 @@ export function useAccountDatabaseController({
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
onNotice: (message: string) => void
|
||||
}) {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
const [hasImageKey, setHasImageKey] = useState(false)
|
||||
const [checkState, setCheckState] = useState<ConnectionCheckState>({ status: 'idle' })
|
||||
const [clock, setClock] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getSettings()
|
||||
.getImageDecryptionStatus()
|
||||
.then((result) => {
|
||||
if (active) setSettings(result.settings)
|
||||
if (active) setHasImageKey(result.configured)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setSettings(null)
|
||||
if (active) setHasImageKey(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
@@ -50,7 +46,6 @@ export function useAccountDatabaseController({
|
||||
return () => window.clearInterval(timer)
|
||||
}, [checkState.checkedAt])
|
||||
|
||||
const hasImageKey = Boolean(settings?.imageAesKey.trim())
|
||||
const diagnostics = useMemo(
|
||||
() =>
|
||||
buildConnectionDiagnostics({
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ImageDecryptionState } from './types'
|
||||
|
||||
const PHASE_LABELS = {
|
||||
scanning: '扫描中',
|
||||
'candidate-found': '发现候选密钥',
|
||||
validating: '自动验证图片',
|
||||
success: '验证成功',
|
||||
saving: '正在保存',
|
||||
saved: '已保存'
|
||||
} as const
|
||||
|
||||
export function AutoDetectImageKeySection({
|
||||
state,
|
||||
disabled,
|
||||
canSave,
|
||||
onDetect,
|
||||
onSave
|
||||
}: {
|
||||
state: ImageDecryptionState
|
||||
disabled: boolean
|
||||
canSave: boolean
|
||||
onDetect: () => void
|
||||
onSave: () => void
|
||||
}): React.ReactElement {
|
||||
if (!state.status?.autoDetectSupported) {
|
||||
return (
|
||||
<section className="settings-card image-auto-unavailable">
|
||||
<strong>手动配置图片解密</strong>
|
||||
<p>当前平台不支持自动扫描图片密钥,请手动配置。</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const directoryReady = state.status.resources.imageDirectory.state === 'available'
|
||||
const processReady = state.status.platform !== 'win32' || state.status.wechatRunning
|
||||
const autoBusy = ['scanning', 'candidate-found', 'validating', 'saving'].includes(state.autoPhase)
|
||||
const showSuccess = state.autoPhase === 'success' || state.autoPhase === 'saved'
|
||||
|
||||
return (
|
||||
<section className="settings-card image-auto-detect">
|
||||
<div className="image-auto-heading">
|
||||
<div>
|
||||
<strong>自动获取图片密钥</strong>
|
||||
<p>
|
||||
{state.status.platform === 'darwin'
|
||||
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
|
||||
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="database-key-secondary"
|
||||
disabled={
|
||||
disabled ||
|
||||
autoBusy ||
|
||||
!processReady ||
|
||||
!state.status.accountIdentified ||
|
||||
!directoryReady
|
||||
}
|
||||
onClick={onDetect}
|
||||
>
|
||||
{state.autoPhase === 'scanning' ? '扫描中…' : '开始自动获取'}
|
||||
</button>
|
||||
</div>
|
||||
<ul>
|
||||
<li className={state.status.wechatRunning ? 'ok' : ''}>
|
||||
微信运行状态:{state.status.wechatRunning ? '正在运行' : '未运行'}
|
||||
</li>
|
||||
<li className={state.status.accountIdentified ? 'ok' : ''}>
|
||||
当前账号状态:{state.status.accountIdentified ? '已登录' : '未识别'}
|
||||
</li>
|
||||
<li className={directoryReady ? 'ok' : ''}>
|
||||
图片目录状态:{directoryReady ? '已找到' : '未找到'}
|
||||
</li>
|
||||
</ul>
|
||||
{state.autoPhase !== 'idle' && state.autoPhase !== 'failed' ? (
|
||||
<ol className="image-auto-phases">
|
||||
{(['scanning', 'candidate-found', 'validating', 'success'] as const).map((phase) => (
|
||||
<li
|
||||
key={phase}
|
||||
className={getPhaseIndex(state.autoPhase) >= getPhaseIndex(phase) ? 'active' : ''}
|
||||
>
|
||||
{PHASE_LABELS[phase]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
{state.autoProgress ? <p className="image-auto-progress">{state.autoProgress}</p> : null}
|
||||
{state.autoError ? <p className="image-auto-error">{state.autoError}</p> : null}
|
||||
{showSuccess ? (
|
||||
<div className="image-auto-success">
|
||||
<strong>图片密钥验证成功</strong>
|
||||
<span>发现账号:{state.autoAccount || '当前微信账号'}</span>
|
||||
<span>图片解析:正常</span>
|
||||
<button className="database-key-primary" disabled={!canSave} onClick={onSave}>
|
||||
{state.autoPhase === 'saved' ? '已保存' : '保存图片密钥'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function getPhaseIndex(phase: ImageDecryptionState['autoPhase']): number {
|
||||
const order: ImageDecryptionState['autoPhase'][] = [
|
||||
'idle',
|
||||
'scanning',
|
||||
'candidate-found',
|
||||
'validating',
|
||||
'success',
|
||||
'saving',
|
||||
'saved'
|
||||
]
|
||||
return order.indexOf(phase)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
export function DangerZone({
|
||||
disabled,
|
||||
onClear
|
||||
}: {
|
||||
disabled: boolean
|
||||
onClear: () => void
|
||||
}): React.ReactElement {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<section className="database-key-danger image-key-danger">
|
||||
<h2>图片密钥管理</h2>
|
||||
<div>
|
||||
<span>
|
||||
<strong>清除图片密钥</strong>
|
||||
<small>聊天记录和微信原始图片不会被删除。</small>
|
||||
</span>
|
||||
<button disabled={disabled} onClick={() => setConfirming(true)}>
|
||||
清除图片密钥
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{confirming ? (
|
||||
<div className="database-key-confirm-backdrop" role="presentation">
|
||||
<div className="database-key-confirm" role="dialog" aria-modal="true">
|
||||
<h2>确认清除图片解密配置?</h2>
|
||||
<p>清除后聊天记录仍然存在,但图片需要重新配置后才能解析。</p>
|
||||
<div>
|
||||
<button onClick={() => setConfirming(false)}>取消</button>
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
setConfirming(false)
|
||||
onClear()
|
||||
}}
|
||||
>
|
||||
清除图片密钥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { SettingsSelfInfo } from '../model/types'
|
||||
import type { ImageDecryptionState } from './types'
|
||||
import { formatImageConfigTime } from './utils'
|
||||
|
||||
export function ImageDecryptStatus({
|
||||
state,
|
||||
selfInfo,
|
||||
disabled,
|
||||
onValidate
|
||||
}: {
|
||||
state: ImageDecryptionState
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
disabled: boolean
|
||||
onValidate: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="settings-card image-decrypt-status">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>图片密钥</dt>
|
||||
<dd className={state.config?.configured ? 'image-state-success' : 'image-state-muted'}>
|
||||
{state.config?.configured ? '已配置' : '未配置'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>当前账号</dt>
|
||||
<dd>{selfInfo?.nickname || '尚未识别'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>wxid</dt>
|
||||
<dd className="image-decrypt-mono" title={selfInfo?.wxid}>
|
||||
{selfInfo?.wxid || '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近验证</dt>
|
||||
<dd>{formatImageConfigTime(state.config?.updatedAt)}</dd>
|
||||
</div>
|
||||
<div className="image-decrypt-wide">
|
||||
<dt>图片资源目录</dt>
|
||||
<dd className="image-decrypt-mono" title={state.status?.resourceRoot}>
|
||||
{state.status?.resourceRoot || '尚未定位'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>缓存状态</dt>
|
||||
<dd
|
||||
className={
|
||||
state.status?.cacheState === 'normal' ? 'image-state-success' : 'image-state-error'
|
||||
}
|
||||
>
|
||||
{state.status?.cacheState === 'normal' ? '正常' : '不可用'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{state.config?.configured ? (
|
||||
<button className="database-key-secondary" disabled={disabled} onClick={onValidate}>
|
||||
重新验证
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ImageDecryptionState } from './types'
|
||||
|
||||
export function ImageKeyConfiguration({
|
||||
state,
|
||||
disabled,
|
||||
onEdit
|
||||
}: {
|
||||
state: ImageDecryptionState
|
||||
disabled: boolean
|
||||
onEdit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="settings-card image-key-editor">
|
||||
<label>
|
||||
<span>图片资源目录</span>
|
||||
<input
|
||||
value={state.resourceRoot}
|
||||
disabled={disabled}
|
||||
title={state.resourceRoot}
|
||||
onChange={(event) => onEdit('resourceRoot', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="image-key-grid">
|
||||
<label>
|
||||
<span>XOR Key</span>
|
||||
<input
|
||||
value={state.xorKey}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onEdit('xorKey', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>AES Key</span>
|
||||
<input
|
||||
type="password"
|
||||
value={state.aesKey}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
placeholder="输入 16 位图片密钥"
|
||||
onChange={(event) => onEdit('aesKey', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p>修改后请先选择会话完成图片解析测试,再确认保存。</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ImageDecryptionState } from './types'
|
||||
|
||||
export function ImageTestSection({
|
||||
state,
|
||||
disabled,
|
||||
canSave,
|
||||
onSelect,
|
||||
onTest,
|
||||
onSave
|
||||
}: {
|
||||
state: ImageDecryptionState
|
||||
disabled: boolean
|
||||
canSave: boolean
|
||||
onSelect: (value: string) => void
|
||||
onTest: () => void
|
||||
onSave: () => void
|
||||
}): React.ReactElement {
|
||||
const result = state.testResult
|
||||
return (
|
||||
<section className="settings-card image-test-section">
|
||||
<div>
|
||||
<strong>图片解析测试</strong>
|
||||
<p>选择一条聊天记录测试图片解密能力。</p>
|
||||
</div>
|
||||
<label htmlFor="image-test-chat">聊天记录</label>
|
||||
<select
|
||||
id="image-test-chat"
|
||||
value={state.selectedUserMd5}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onSelect(event.target.value)}
|
||||
>
|
||||
<option value="">请选择会话</option>
|
||||
{state.contacts.map((contact) => (
|
||||
<option key={contact.md5} value={contact.md5}>
|
||||
{contact.m_nsNickName || contact.m_nsUsrName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="image-test-actions">
|
||||
<button
|
||||
className="database-key-primary"
|
||||
disabled={disabled || !state.selectedUserMd5}
|
||||
onClick={onTest}
|
||||
>
|
||||
{state.phase === 'testing' ? '正在测试…' : '测试图片解析'}
|
||||
</button>
|
||||
<button className="database-key-secondary" disabled={!canSave} onClick={onSave}>
|
||||
确认保存
|
||||
</button>
|
||||
</div>
|
||||
{result ? (
|
||||
<div className={`image-test-result ${result.success ? 'success' : 'error'}`}>
|
||||
<span>{result.fileFound ? '✓' : '×'} 找到图片文件</span>
|
||||
<span>{result.decrypted ? '✓' : '×'} 解密成功</span>
|
||||
<span>{result.readable ? '✓' : '×'} 图片可以读取</span>
|
||||
{!result.success ? <p>{result.error}</p> : null}
|
||||
</div>
|
||||
) : state.error ? (
|
||||
<p className="image-inline-error">{state.error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
ImageDecryptionStatus,
|
||||
ImageResourceCheck
|
||||
} from '../../../../../shared/image-decryption'
|
||||
|
||||
const ITEMS: { key: keyof ImageDecryptionStatus['resources']; label: string }[] = [
|
||||
{ key: 'imageIndex', label: '图片索引' },
|
||||
{ key: 'imageDirectory', label: '图片文件目录' },
|
||||
{ key: 'thumbnail', label: '缩略图资源' },
|
||||
{ key: 'original', label: '原图资源' },
|
||||
{ key: 'sticker', label: '表情资源' }
|
||||
]
|
||||
|
||||
export function ResourceCheckSection({
|
||||
status
|
||||
}: {
|
||||
status: ImageDecryptionStatus | null
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="settings-card image-resource-checks">
|
||||
{ITEMS.map(({ key, label }) => {
|
||||
const item: ImageResourceCheck = status?.resources[key] || {
|
||||
state: 'unknown',
|
||||
detail: '正在检查'
|
||||
}
|
||||
return (
|
||||
<div key={key}>
|
||||
<span className={`image-resource-icon ${item.state}`}>
|
||||
{item.state === 'available' ? '✓' : item.state === 'unavailable' ? '!' : '·'}
|
||||
</span>
|
||||
<strong>{label}</strong>
|
||||
<small>{item.detail}</small>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function SecurityInfoSection(): React.ReactElement {
|
||||
return (
|
||||
<div className="database-key-security-info">
|
||||
<span>
|
||||
<strong>系统安全存储</strong>图片密钥通过系统安全能力保存。
|
||||
</span>
|
||||
<span>
|
||||
<strong>账号绑定</strong>密钥只用于当前微信账号。
|
||||
</span>
|
||||
<span>
|
||||
<strong>无损清除</strong>清除密钥不会删除微信原始图片。
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ImageDecryptionAction, ImageDecryptionState } from './types'
|
||||
|
||||
export const initialImageDecryptionState: ImageDecryptionState = {
|
||||
phase: 'checking',
|
||||
config: null,
|
||||
status: null,
|
||||
contacts: [],
|
||||
selectedUserMd5: '',
|
||||
resourceRoot: '',
|
||||
xorKey: '0x40',
|
||||
aesKey: '',
|
||||
testResult: null,
|
||||
autoPhase: 'idle',
|
||||
autoProgress: '',
|
||||
dirty: false
|
||||
}
|
||||
|
||||
export function imageDecryptionReducer(
|
||||
state: ImageDecryptionState,
|
||||
action: ImageDecryptionAction
|
||||
): ImageDecryptionState {
|
||||
switch (action.type) {
|
||||
case 'LOADED':
|
||||
return {
|
||||
...state,
|
||||
phase: action.config.configured ? 'configured' : 'not-configured',
|
||||
config: action.config,
|
||||
status: action.status,
|
||||
contacts: action.contacts,
|
||||
resourceRoot: action.config.resourceRoot,
|
||||
xorKey: action.config.xorKey || '0x40',
|
||||
aesKey: action.config.aesKey || '',
|
||||
error: action.config.success ? undefined : action.config.error,
|
||||
dirty: false
|
||||
}
|
||||
case 'LOAD_ERROR':
|
||||
return { ...state, phase: 'not-configured', error: action.error }
|
||||
case 'EDIT':
|
||||
return {
|
||||
...state,
|
||||
[action.field]: action.value,
|
||||
phase: state.config?.configured ? 'configured' : 'not-configured',
|
||||
testResult: null,
|
||||
error: undefined,
|
||||
dirty: true
|
||||
}
|
||||
case 'SELECT_CHAT':
|
||||
return { ...state, selectedUserMd5: action.userMd5, testResult: null, error: undefined }
|
||||
case 'TEST_START':
|
||||
return { ...state, phase: 'testing', testResult: null, error: undefined }
|
||||
case 'TEST_DONE':
|
||||
return {
|
||||
...state,
|
||||
phase: action.result.success ? 'test-success' : 'test-failed',
|
||||
testResult: action.result,
|
||||
error: action.result.error,
|
||||
status:
|
||||
action.result.success && state.status
|
||||
? {
|
||||
...state.status,
|
||||
resources: {
|
||||
...state.status.resources,
|
||||
thumbnail: action.result.isThumbnail
|
||||
? { state: 'available', detail: '正常' }
|
||||
: state.status.resources.thumbnail,
|
||||
original: action.result.isThumbnail
|
||||
? { state: 'unavailable', detail: '本次未找到原图' }
|
||||
: { state: 'available', detail: '正常' }
|
||||
}
|
||||
}
|
||||
: state.status
|
||||
}
|
||||
case 'AUTO_START':
|
||||
return {
|
||||
...state,
|
||||
phase: 'checking',
|
||||
autoPhase: 'scanning',
|
||||
autoProgress: '正在检查微信运行环境',
|
||||
autoAccount: undefined,
|
||||
autoError: undefined,
|
||||
error: undefined
|
||||
}
|
||||
case 'AUTO_PROGRESS':
|
||||
return { ...state, autoProgress: action.message }
|
||||
case 'AUTO_CANDIDATE':
|
||||
return {
|
||||
...state,
|
||||
autoPhase: 'candidate-found',
|
||||
resourceRoot: action.resourceRoot,
|
||||
xorKey: action.xorKey,
|
||||
aesKey: action.aesKey,
|
||||
autoAccount: action.account,
|
||||
autoProgress: '已发现候选密钥',
|
||||
dirty: true
|
||||
}
|
||||
case 'AUTO_VALIDATING':
|
||||
return { ...state, autoPhase: 'validating', autoProgress: '正在验证候选密钥的图片解析能力' }
|
||||
case 'AUTO_DONE':
|
||||
return {
|
||||
...state,
|
||||
phase: 'test-success',
|
||||
autoPhase: 'success',
|
||||
autoProgress: '图片密钥验证成功',
|
||||
dirty: true
|
||||
}
|
||||
case 'AUTO_SAVE_START':
|
||||
return { ...state, autoPhase: 'saving', autoProgress: '正在安全保存图片密钥' }
|
||||
case 'AUTO_SAVED':
|
||||
return { ...state, autoPhase: 'saved', autoProgress: '图片密钥已安全保存' }
|
||||
case 'AUTO_ERROR':
|
||||
return {
|
||||
...state,
|
||||
phase: 'test-failed',
|
||||
autoPhase: 'failed',
|
||||
autoError: action.error,
|
||||
autoProgress: ''
|
||||
}
|
||||
case 'OPERATION_ERROR':
|
||||
return { ...state, phase: 'test-failed', error: action.error }
|
||||
case 'CLEAR_START':
|
||||
return { ...state, phase: 'clearing', error: undefined }
|
||||
case 'CLEAR_DONE':
|
||||
return {
|
||||
...initialImageDecryptionState,
|
||||
phase: 'clear-success',
|
||||
config: action.config,
|
||||
status: action.status,
|
||||
contacts: state.contacts,
|
||||
resourceRoot: action.config.resourceRoot
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Contact } from '../../../../../shared/types'
|
||||
import type {
|
||||
ImageDecryptionStatus,
|
||||
ImageDecryptionTestResult,
|
||||
ImageKeyConfigResult
|
||||
} from '../../../../../shared/image-decryption'
|
||||
|
||||
export type ImageDecryptionPhase =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'configured'
|
||||
| 'not-configured'
|
||||
| 'testing'
|
||||
| 'test-success'
|
||||
| 'test-failed'
|
||||
| 'clearing'
|
||||
| 'clear-success'
|
||||
| 'clear-failed'
|
||||
|
||||
export type ImageKeyAutoDetectPhase =
|
||||
| 'idle'
|
||||
| 'scanning'
|
||||
| 'candidate-found'
|
||||
| 'validating'
|
||||
| 'success'
|
||||
| 'saving'
|
||||
| 'saved'
|
||||
| 'failed'
|
||||
|
||||
export interface ImageDecryptionState {
|
||||
phase: ImageDecryptionPhase
|
||||
config: ImageKeyConfigResult | null
|
||||
status: ImageDecryptionStatus | null
|
||||
contacts: Contact[]
|
||||
selectedUserMd5: string
|
||||
resourceRoot: string
|
||||
xorKey: string
|
||||
aesKey: string
|
||||
testResult: ImageDecryptionTestResult | null
|
||||
autoPhase: ImageKeyAutoDetectPhase
|
||||
autoProgress: string
|
||||
autoAccount?: string
|
||||
autoError?: string
|
||||
error?: string
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
export type ImageDecryptionAction =
|
||||
| {
|
||||
type: 'LOADED'
|
||||
config: ImageKeyConfigResult
|
||||
status: ImageDecryptionStatus
|
||||
contacts: Contact[]
|
||||
}
|
||||
| { type: 'LOAD_ERROR'; error: string }
|
||||
| { type: 'EDIT'; field: 'resourceRoot' | 'xorKey' | 'aesKey'; value: string }
|
||||
| { type: 'SELECT_CHAT'; userMd5: string }
|
||||
| { type: 'TEST_START' }
|
||||
| { type: 'TEST_DONE'; result: ImageDecryptionTestResult }
|
||||
| { type: 'AUTO_START' }
|
||||
| { type: 'AUTO_PROGRESS'; message: string }
|
||||
| {
|
||||
type: 'AUTO_CANDIDATE'
|
||||
resourceRoot: string
|
||||
xorKey: string
|
||||
aesKey: string
|
||||
account: string
|
||||
}
|
||||
| { type: 'AUTO_VALIDATING' }
|
||||
| { type: 'AUTO_DONE' }
|
||||
| { type: 'AUTO_SAVE_START' }
|
||||
| { type: 'AUTO_SAVED' }
|
||||
| { type: 'AUTO_ERROR'; error: string }
|
||||
| { type: 'OPERATION_ERROR'; error: string }
|
||||
| { type: 'CLEAR_START' }
|
||||
| { type: 'CLEAR_DONE'; config: ImageKeyConfigResult; status: ImageDecryptionStatus }
|
||||
|
||||
export interface ImageDecryptionController {
|
||||
state: ImageDecryptionState
|
||||
pageStatus: 'configured' | 'unconfigured' | 'partial'
|
||||
busy: boolean
|
||||
canSave: boolean
|
||||
edit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
|
||||
selectChat: (userMd5: string) => void
|
||||
test: () => Promise<void>
|
||||
save: () => Promise<void>
|
||||
autoDetect: () => Promise<void>
|
||||
clear: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer } from 'react'
|
||||
import type { SettingsSelfInfo } from '../model/types'
|
||||
import { imageDecryptionReducer, initialImageDecryptionState } from './imageDecryptionReducer'
|
||||
import type { ImageDecryptionController } from './types'
|
||||
import { normalizeAutoXorKey, sanitizeImageError } from './utils'
|
||||
|
||||
export function useImageDecryptionController({
|
||||
selfInfo,
|
||||
onNotice
|
||||
}: {
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
onNotice: (message: string) => void
|
||||
}): ImageDecryptionController {
|
||||
const [state, dispatch] = useReducer(imageDecryptionReducer, initialImageDecryptionState)
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const [config, status, contacts] = await Promise.all([
|
||||
window.api.getImageKeyConfig(),
|
||||
window.api.getImageDecryptionStatus(),
|
||||
window.api.getContacts()
|
||||
])
|
||||
dispatch({ type: 'LOADED', config, status, contacts })
|
||||
} catch {
|
||||
dispatch({ type: 'LOAD_ERROR', error: '无法读取图片解密状态' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
return window.api.onImageKeyStatus(({ message }) => {
|
||||
dispatch({ type: 'AUTO_PROGRESS', message })
|
||||
})
|
||||
}, [refresh])
|
||||
|
||||
const edit = useCallback((field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string): void => {
|
||||
dispatch({ type: 'EDIT', field, value })
|
||||
}, [])
|
||||
|
||||
const selectChat = useCallback((userMd5: string): void => {
|
||||
dispatch({ type: 'SELECT_CHAT', userMd5 })
|
||||
}, [])
|
||||
|
||||
const test = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'TEST_START' })
|
||||
const result = await window.api.testImageDecryption({
|
||||
userMd5: state.selectedUserMd5,
|
||||
resourceRoot: state.resourceRoot,
|
||||
xorKey: state.xorKey,
|
||||
aesKey: state.aesKey
|
||||
})
|
||||
dispatch({
|
||||
type: 'TEST_DONE',
|
||||
result: result.success ? result : { ...result, error: sanitizeImageError(result.error) }
|
||||
})
|
||||
}, [state.aesKey, state.resourceRoot, state.selectedUserMd5, state.xorKey])
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
if (!state.testResult?.success && state.autoPhase !== 'success') return
|
||||
if (state.autoPhase === 'success') dispatch({ type: 'AUTO_SAVE_START' })
|
||||
const result = await window.api.saveImageKeyConfig({
|
||||
resourceRoot: state.resourceRoot,
|
||||
xorKey: state.xorKey,
|
||||
aesKey: state.aesKey
|
||||
})
|
||||
if (!result.success) {
|
||||
dispatch({ type: 'OPERATION_ERROR', error: sanitizeImageError(result.error) })
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
if (state.autoPhase === 'success') dispatch({ type: 'AUTO_SAVED' })
|
||||
onNotice('图片解密配置已安全保存')
|
||||
}, [
|
||||
onNotice,
|
||||
refresh,
|
||||
state.aesKey,
|
||||
state.autoPhase,
|
||||
state.resourceRoot,
|
||||
state.testResult,
|
||||
state.xorKey
|
||||
])
|
||||
|
||||
const autoDetect = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'AUTO_START' })
|
||||
const result = await window.api.autoGetImageKey({ save: false })
|
||||
if (!result.success || !result.aesKey || !result.verified) {
|
||||
dispatch({
|
||||
type: 'AUTO_ERROR',
|
||||
error: result.success
|
||||
? '获取到候选密钥,但未通过图片验证'
|
||||
: sanitizeImageError(result.error)
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'AUTO_CANDIDATE',
|
||||
resourceRoot: state.resourceRoot || selfInfo?.accountRoot || '',
|
||||
xorKey: normalizeAutoXorKey(result.xorKey, result.imageXorKey),
|
||||
aesKey: result.aesKey,
|
||||
account: selfInfo?.nickname || selfInfo?.wxid || '当前微信账号'
|
||||
})
|
||||
dispatch({ type: 'AUTO_VALIDATING' })
|
||||
// Windows 内存扫描只会返回通过模板图片头验证的候选密钥。
|
||||
dispatch({ type: 'AUTO_DONE' })
|
||||
onNotice('已获取有效图片密钥,请确认保存')
|
||||
}, [onNotice, selfInfo?.accountRoot, selfInfo?.nickname, selfInfo?.wxid, state.resourceRoot])
|
||||
|
||||
const clear = useCallback(async (): Promise<void> => {
|
||||
dispatch({ type: 'CLEAR_START' })
|
||||
const result = await window.api.clearImageKeyConfig()
|
||||
if (!result.success) {
|
||||
dispatch({ type: 'OPERATION_ERROR', error: '图片解密配置清除失败' })
|
||||
return
|
||||
}
|
||||
const [config, status] = await Promise.all([
|
||||
window.api.getImageKeyConfig(),
|
||||
window.api.getImageDecryptionStatus()
|
||||
])
|
||||
dispatch({ type: 'CLEAR_DONE', config, status })
|
||||
onNotice('图片密钥已清除,微信原始数据未受影响')
|
||||
}, [onNotice])
|
||||
|
||||
const busy = ['checking', 'testing', 'clearing'].includes(state.phase)
|
||||
const canSave = Boolean(
|
||||
((state.testResult?.success && state.dirty) || state.autoPhase === 'success') &&
|
||||
state.status?.encryptionAvailable
|
||||
)
|
||||
const pageStatus = useMemo<ImageDecryptionController['pageStatus']>(() => {
|
||||
if (!state.config?.configured) return 'unconfigured'
|
||||
if (!state.config.saved || !state.status?.encryptionAvailable) return 'partial'
|
||||
if (state.status.resources.imageDirectory.state === 'unavailable') return 'partial'
|
||||
return 'configured'
|
||||
}, [state.config, state.status])
|
||||
|
||||
return {
|
||||
state,
|
||||
pageStatus,
|
||||
busy,
|
||||
canSave,
|
||||
edit,
|
||||
selectChat,
|
||||
test,
|
||||
save,
|
||||
autoDetect,
|
||||
clear,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function formatImageConfigTime(value?: number): string {
|
||||
if (!value) return '尚未验证'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function sanitizeImageError(error?: string): string {
|
||||
const value = String(error || '').toLowerCase()
|
||||
if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配'
|
||||
if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在'
|
||||
if (value.includes('账号')) return '当前账号不匹配'
|
||||
if (value.includes('目录')) return '图片资源目录不可用'
|
||||
return error ? '无法解析媒体文件' : '图片解析测试未通过'
|
||||
}
|
||||
|
||||
export function normalizeAutoXorKey(value?: number, formatted?: string): string {
|
||||
if (formatted) return formatted
|
||||
return `0x${Number(value ?? 0x40)
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { SettingsSelfInfo } from '../model/types'
|
||||
import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection'
|
||||
import { DangerZone } from '../image-decryption/DangerZone'
|
||||
import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus'
|
||||
import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration'
|
||||
import { ImageTestSection } from '../image-decryption/ImageTestSection'
|
||||
import { ResourceCheckSection } from '../image-decryption/ResourceCheckSection'
|
||||
import { SecurityInfoSection } from '../image-decryption/SecurityInfoSection'
|
||||
import { useImageDecryptionController } from '../image-decryption/useImageDecryptionController'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
configured: '已配置',
|
||||
unconfigured: '未配置',
|
||||
partial: '部分能力不可用'
|
||||
}
|
||||
|
||||
export function ImageDecryptionPage({
|
||||
selfInfo,
|
||||
onNotice
|
||||
}: {
|
||||
selfInfo: SettingsSelfInfo | null
|
||||
onNotice: (message: string) => void
|
||||
}): React.ReactElement {
|
||||
const controller = useImageDecryptionController({ selfInfo, onNotice })
|
||||
const revalidate = (): void => {
|
||||
if (controller.state.selectedUserMd5) {
|
||||
void controller.test()
|
||||
return
|
||||
}
|
||||
document.getElementById('image-test-chat')?.scrollIntoView({ behavior: 'smooth' })
|
||||
window.setTimeout(() => document.getElementById('image-test-chat')?.focus(), 250)
|
||||
onNotice('请先选择一条包含图片的聊天记录')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page image-decryption-page">
|
||||
<header className="settings-page-header">
|
||||
<div>
|
||||
<h1>图片解密</h1>
|
||||
<p>管理微信图片、表情和媒体资源解析能力</p>
|
||||
</div>
|
||||
<span className={`settings-status-badge image-decrypt-badge ${controller.pageStatus}`}>
|
||||
{STATUS_LABELS[controller.pageStatus]}
|
||||
</span>
|
||||
</header>
|
||||
<div className="settings-page-scroll">
|
||||
<div className="settings-page-content image-decryption-content">
|
||||
<section className="settings-privacy-notice">
|
||||
<svg viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M12 3 5.5 5.7v5.2c0 4.3 2.7 8.2 6.5 10.1 3.8-1.9 6.5-5.8 6.5-10.1V5.7L12 3Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<strong>图片仅在本机解析</strong>
|
||||
<p>WechatExplorer 不会上传您的微信图片。所有图片解析和缓存处理均在本地完成。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 className="settings-section-heading">图片解密状态</h2>
|
||||
<ImageDecryptStatus
|
||||
state={controller.state}
|
||||
selfInfo={selfInfo}
|
||||
disabled={controller.busy}
|
||||
onValidate={revalidate}
|
||||
/>
|
||||
|
||||
<h2 className="settings-section-heading">资源检测</h2>
|
||||
<ResourceCheckSection status={controller.state.status} />
|
||||
|
||||
<h2 className="settings-section-heading">图片密钥管理</h2>
|
||||
<ImageKeyConfiguration
|
||||
state={controller.state}
|
||||
disabled={controller.busy}
|
||||
onEdit={controller.edit}
|
||||
/>
|
||||
|
||||
<h2 className="settings-section-heading">自动获取图片密钥</h2>
|
||||
<AutoDetectImageKeySection
|
||||
state={controller.state}
|
||||
disabled={controller.busy}
|
||||
canSave={controller.canSave}
|
||||
onDetect={() => void controller.autoDetect()}
|
||||
onSave={() => void controller.save()}
|
||||
/>
|
||||
|
||||
<h2 className="settings-section-heading">图片解析测试</h2>
|
||||
<ImageTestSection
|
||||
state={controller.state}
|
||||
disabled={controller.busy}
|
||||
canSave={controller.canSave}
|
||||
onSelect={controller.selectChat}
|
||||
onTest={() => void controller.test()}
|
||||
onSave={() => void controller.save()}
|
||||
/>
|
||||
|
||||
<h2 className="settings-section-heading">安全说明</h2>
|
||||
<SecurityInfoSection />
|
||||
<DangerZone
|
||||
disabled={controller.busy || !controller.state.config?.configured}
|
||||
onClear={() => void controller.clear()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user