gaza/src/features/connections/components.tsx
Ubuntu 82376fea9d 兼容缺少 randomUUID 的 WebView
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 20:25:17 +00:00

113 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>01<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>;
}