113 lines
14 KiB
TypeScript
113 lines
14 KiB
TypeScript
import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
||
import { Button } from "../../ui";
|
||
import { ConnectionAdapter } from "./mockAdapter";
|
||
import { blankConnection, ConnectionConfig, ConnectionRecord, connectionStates, createConnectionId, DiagnosticCategory, DiagnosticResult, maskedSecret, parseImportPreview, toSafeExport, validateConnection } from "./model";
|
||
|
||
const field = (config: ConnectionConfig, onChange: (next: ConnectionConfig) => void) =>
|
||
<K extends keyof ConnectionConfig>(key: K, value: ConnectionConfig[K]) => onChange({ ...config, [key]: value });
|
||
|
||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||
return <fieldset className="connection-section"><legend>{title}</legend>{children}</fieldset>;
|
||
}
|
||
|
||
export function TlsSettings({ config, onChange, developmentMode }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void; developmentMode: boolean }) {
|
||
const set = (value: Partial<ConnectionConfig["tls"]>) => onChange({ ...config, tls: { ...config.tls, ...value } });
|
||
return <Section title="TLS / mTLS">
|
||
<label className="check"><input type="checkbox" checked={config.tls.enabled} onChange={(e) => set({ enabled: e.target.checked })} />启用 TLS</label>
|
||
<label>自定义 CA<textarea value={config.tls.customCa} onChange={(e) => set({ customCa: e.target.value })} placeholder="PEM 内容(仅保存在内存)" /></label>
|
||
<label>客户端证书<textarea value={config.tls.clientCertificate} onChange={(e) => set({ clientCertificate: e.target.value })} /></label>
|
||
<label>客户端私钥<input type="password" autoComplete="off" value={config.tls.clientKey} onChange={(e) => set({ clientKey: e.target.value })} /></label>
|
||
<label>证书指纹<input value={config.tls.fingerprint} onChange={(e) => set({ fingerprint: e.target.value })} placeholder="sha256:…" /></label>
|
||
<label>SNI<input value={config.tls.sni} onChange={(e) => set({ sni: e.target.value })} /></label>
|
||
{developmentMode && <label className="check danger"><input type="checkbox" checked={config.tls.skipVerification} onChange={(e) => set({ skipVerification: e.target.checked })} />跳过证书校验(仅开发模式,不安全)</label>}
|
||
</Section>;
|
||
}
|
||
|
||
export function AuthSettings({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
|
||
const set = (value: Partial<ConnectionConfig["auth"]>) => onChange({ ...config, auth: { ...config.auth, ...value } });
|
||
return <Section title="认证">
|
||
<label>方式<select value={config.auth.method} onChange={(e) => set({ method: e.target.value as ConnectionConfig["auth"]["method"] })}><option value="token">Token</option><option value="password">用户名 / 密码</option><option value="mtls">仅 mTLS</option></select></label>
|
||
{config.auth.method === "password" && <label>用户名<input value={config.auth.username} onChange={(e) => set({ username: e.target.value })} /></label>}
|
||
{config.auth.method !== "mtls" && <label>秘密<input aria-label="认证秘密" type="password" autoComplete="new-password" value={config.auth.secret} onChange={(e) => set({ secret: e.target.value })} placeholder={maskedSecret(config.auth.secret)} /></label>}
|
||
</Section>;
|
||
}
|
||
|
||
export function ProxySettings({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
|
||
const set = (value: Partial<ConnectionConfig["proxy"]>) => onChange({ ...config, proxy: { ...config.proxy, ...value } });
|
||
return <Section title="代理">
|
||
<label className="check"><input type="checkbox" checked={config.proxy.enabled} onChange={(e) => set({ enabled: e.target.checked })} />使用代理</label>
|
||
{config.proxy.enabled && <><label>代理 URL<input value={config.proxy.url} onChange={(e) => set({ url: e.target.value })} placeholder="http://proxy.local:8080" /></label><label>代理用户名<input value={config.proxy.username} onChange={(e) => set({ username: e.target.value })} /></label><label>代理密码<input type="password" autoComplete="new-password" value={config.proxy.password} onChange={(e) => set({ password: e.target.value })} /></label></>}
|
||
</Section>;
|
||
}
|
||
|
||
export function ReconnectPolicy({ config, onChange }: { config: ConnectionConfig; onChange: (next: ConnectionConfig) => void }) {
|
||
const set = (value: Partial<ConnectionConfig["reconnect"]>) => onChange({ ...config, reconnect: { ...config.reconnect, ...value } });
|
||
return <Section title="重连策略">
|
||
<label className="check"><input type="checkbox" checked={config.reconnect.enabled} onChange={(e) => set({ enabled: e.target.checked })} />指数退避重连</label>
|
||
<label>初始退避(秒)<input type="number" value={config.reconnect.initialSeconds} onChange={(e) => set({ initialSeconds: Number(e.target.value) })} /></label>
|
||
<label>最大退避(秒)<input type="number" value={config.reconnect.maxSeconds} onChange={(e) => set({ maxSeconds: Number(e.target.value) })} /></label>
|
||
<label>倍率<input type="number" step="0.1" value={config.reconnect.multiplier} onChange={(e) => set({ multiplier: Number(e.target.value) })} /></label>
|
||
<label>抖动(0–1)<input type="number" step="0.1" value={config.reconnect.jitter} onChange={(e) => set({ jitter: Number(e.target.value) })} /></label>
|
||
</Section>;
|
||
}
|
||
|
||
export function ConnectionEditor({ initial, developmentMode = false, existingAddresses = [], onSave, onCancel }: { initial?: ConnectionConfig; developmentMode?: boolean; existingAddresses?: string[]; onSave: (config: ConnectionConfig) => void | Promise<void>; onCancel: () => void }) {
|
||
const [config, setConfig] = useState(() => structuredClone(initial ?? blankConnection()));
|
||
const [submitted, setSubmitted] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saveError, setSaveError] = useState("");
|
||
const errors = useMemo(() => { const next = validateConnection(config, developmentMode); if (existingAddresses.some((address) => address === config.address.trim() && config.address.trim())) next.address = "该 Teamserver 地址已存在"; return next; }, [config, developmentMode, existingAddresses]);
|
||
const set = field(config, setConfig);
|
||
return <form className="connection-editor" onSubmit={async (e) => { e.preventDefault(); setSubmitted(true); setSaveError(""); if (Object.keys(errors).length) return; setSaving(true); try { await onSave(config); } catch (error) { setSaveError(error instanceof Error ? error.message : "保存失败,请重试"); } finally { setSaving(false); } }}>
|
||
<div className="editor-grid"><Section title="基本设置">
|
||
<label>名称<input value={config.name} onChange={(e) => set("name", e.target.value)} />{submitted && errors.name && <small className="field-error">{errors.name}</small>}</label>
|
||
<label>传输<select value={config.transport} onChange={(e) => set("transport", e.target.value as ConnectionConfig["transport"])}><option value="grpc">gRPC</option><option value="websocket">WebSocket</option></select></label>
|
||
<label>地址<input value={config.address} onChange={(e) => set("address", e.target.value)} placeholder={config.transport === "grpc" ? "https://teamserver.example" : "wss://teamserver.example/events"} />{submitted && errors.address && <small className="field-error">{errors.address}</small>}</label>
|
||
<label>请求超时(秒)<input type="number" value={config.timeoutSeconds} onChange={(e) => set("timeoutSeconds", Number(e.target.value))} /></label>
|
||
<label>心跳(秒)<input type="number" value={config.heartbeatSeconds} onChange={(e) => set("heartbeatSeconds", Number(e.target.value))} /></label>
|
||
</Section><TlsSettings config={config} onChange={setConfig} developmentMode={developmentMode} /><AuthSettings config={config} onChange={setConfig} /><ProxySettings config={config} onChange={setConfig} /><ReconnectPolicy config={config} onChange={setConfig} /></div>
|
||
{submitted && Object.keys(errors).length > 0 && <p role="alert" className="form-error">请修正 {Object.keys(errors).length} 项配置错误</p>}{saveError && <p role="alert" className="form-error">{saveError}</p>}
|
||
<footer><Button type="button" onClick={onCancel} disabled={saving}>取消</Button><Button intent="primary" loading={saving} type="submit">保存到内存</Button></footer>
|
||
</form>;
|
||
}
|
||
|
||
export function ConnectionList({ records, selectedId, onSelect, onCreate, onCopy, onExport, onDelete }: { records: ConnectionRecord[]; selectedId?: string; onSelect: (id: string) => void; onCreate: () => void; onCopy: (record: ConnectionRecord) => void; onExport: (record: ConnectionRecord) => void; onDelete: (record: ConnectionRecord) => void }) {
|
||
return <section className="connection-list" aria-label="连接配置列表"><header><div><h2>连接中心</h2><p>{records.length} 个内存配置 · Mock adapter</p></div><button className="primary" onClick={onCreate}>+ 新建连接</button></header>
|
||
<div className="state-legend" aria-label="连接状态机">{connectionStates.map((state) => <span key={state}>{state}</span>)}</div>
|
||
{records.length === 0 ? <div className="empty-state">尚无连接配置。配置仅保存在当前进程内存。</div> : records.map((record) => <article key={record.config.id} className={selectedId === record.config.id ? "selected" : ""} onClick={() => onSelect(record.config.id)}>
|
||
<div><strong>{record.config.name}</strong><code>{record.config.address}</code></div><span className={`connection-state ${record.state.toLowerCase()}`}>{record.state}</span>
|
||
<div className="record-actions"><button onClick={(e) => { e.stopPropagation(); onCopy(record); }}>复制</button><button onClick={(e) => { e.stopPropagation(); onExport(record); }}>安全导出</button><button className="danger-button" onClick={(e) => { e.stopPropagation(); onDelete(record); }}>删除</button></div>
|
||
</article>)}</section>;
|
||
}
|
||
|
||
const categories: DiagnosticCategory[] = ["network", "tls", "authentication", "version", "permission", "rate_limit", "timeout", "message_format", "server"];
|
||
export function ConnectionDiagnostics({ record, adapter }: { record: ConnectionRecord; adapter: ConnectionAdapter }) {
|
||
const [results, setResults] = useState<DiagnosticResult[]>([]);
|
||
return <section className="diagnostics"><h3>连接诊断</h3><p>仅检查 mock 配置结构,不向地址发送请求。</p><button className="secondary" onClick={async () => setResults(await Promise.all(categories.map((category) => adapter.diagnose(record.config.id, category))))}>运行安全诊断</button><div>{results.map((result) => <output key={result.category}><b>{result.category}</b><span>{result.summary}</span></output>)}</div></section>;
|
||
}
|
||
|
||
export function DeleteConfirmation({ record, onConfirm, onCancel }: { record: ConnectionRecord; onConfirm: () => void; onCancel: () => void }) {
|
||
return <div className="modal-backdrop" role="presentation"><section role="dialog" aria-modal="true" aria-labelledby="delete-title" className="connection-dialog"><h3 id="delete-title">删除“{record.config.name}”?</h3><p>仅移除当前进程中的 mock 配置,不会联系 Teamserver。</p><footer><button className="secondary" onClick={onCancel}>取消</button><button className="danger-button" onClick={onConfirm}>确认删除</button></footer></section></div>;
|
||
}
|
||
|
||
export function ConnectionCenter({ adapter, developmentMode = false }: { adapter: ConnectionAdapter; developmentMode?: boolean }) {
|
||
const [records, setRecords] = useState<ConnectionRecord[]>([]);
|
||
const [editing, setEditing] = useState<ConnectionConfig>();
|
||
const [selected, setSelected] = useState<string>();
|
||
const [deleting, setDeleting] = useState<ConnectionRecord>();
|
||
const [importText, setImportText] = useState("");
|
||
const [notice, setNotice] = useState("");
|
||
useEffect(() => { void adapter.list().then(setRecords); }, [adapter]);
|
||
const refresh = () => adapter.list().then(setRecords);
|
||
const selectedRecord = records.find((item) => item.config.id === selected);
|
||
const importPreview = () => { try { setEditing(parseImportPreview(importText)); setNotice("导入预览已加载;秘密字段已清空,保存前请确认。"); } catch (error) { setNotice(error instanceof Error ? error.message : "导入失败"); } };
|
||
const exportRecord = (record: ConnectionRecord) => { const safe = JSON.stringify(toSafeExport(record.config), null, 2); setNotice(`安全导出预览(不含 Token、密码和客户端私钥):\n${safe}`); };
|
||
if (editing) return <ConnectionEditor initial={editing} developmentMode={developmentMode} onCancel={() => setEditing(undefined)} onSave={async (config) => { await adapter.save(config); await refresh(); setSelected(config.id); setEditing(undefined); setNotice("配置已保存到内存 mock adapter;尚未建立网络连接。"); }} />;
|
||
return <div className="connection-center"><ConnectionList records={records} selectedId={selected} onSelect={setSelected} onCreate={() => setEditing(blankConnection())} onCopy={(record) => setEditing({ ...structuredClone(record.config), id: createConnectionId(), name: `${record.config.name} 副本`, auth: { ...record.config.auth, secret: "" }, proxy: { ...record.config.proxy, password: "" }, tls: { ...record.config.tls, clientKey: "" } })} onExport={exportRecord} onDelete={setDeleting} />
|
||
<section className="import-panel"><h3>导入预览</h3><textarea aria-label="导入 JSON" value={importText} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setImportText(e.target.value)} placeholder="粘贴由本客户端安全导出的 JSON" /><button className="secondary" onClick={importPreview}>验证并预览</button></section>
|
||
{selectedRecord && <ConnectionDiagnostics record={selectedRecord} adapter={adapter} />}
|
||
{notice && <pre className="connection-notice" aria-live="polite">{notice}</pre>}
|
||
{deleting && <DeleteConfirmation record={deleting} onCancel={() => setDeleting(undefined)} onConfirm={async () => { await adapter.remove(deleting.config.id); await refresh(); setDeleting(undefined); if (selected === deleting.config.id) setSelected(undefined); }} />}
|
||
</div>;
|
||
}
|