305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Check, ChevronDown, CircleAlert, ExternalLink, Eye, EyeOff, Lock, ShieldCheck } from "lucide-react";
|
||
import { readConfig, verifyConfig, writeConfig } from "../ipc";
|
||
import type {
|
||
ConfigFieldState,
|
||
ConfigFormState,
|
||
ConfigVerifyResult,
|
||
WriteResult,
|
||
} from "../ipc/types";
|
||
import { AuthPanel } from "./AuthPanel";
|
||
import { MonoChip } from "./MonoChip";
|
||
|
||
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条)
|
||
* Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」
|
||
* Wave 2.2:授权/登录层改为可操作授权流程(AuthPanel) */
|
||
export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?: () => void }) {
|
||
const [state, setState] = useState<ConfigFormState | null>(null);
|
||
const [values, setValues] = useState<Record<string, string>>({});
|
||
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [result, setResult] = useState<WriteResult | null>(null);
|
||
const [verify, setVerify] = useState<ConfigVerifyResult | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
readConfig(id)
|
||
.then((s) => {
|
||
if (cancelled) return;
|
||
setState(s);
|
||
const init: Record<string, string> = {};
|
||
for (const f of s.fields) {
|
||
if (!f.sensitive && f.value != null) init[f.id] = f.value;
|
||
}
|
||
setValues(init);
|
||
setLoading(false);
|
||
})
|
||
.catch((e) => {
|
||
if (!cancelled) {
|
||
setError(String(e));
|
||
setLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [id]);
|
||
|
||
const sensitiveSaved = useMemo(() => {
|
||
const m: Record<string, boolean> = {};
|
||
state?.fields.forEach((f) => {
|
||
if (f.sensitive) m[f.id] = f.has_value;
|
||
});
|
||
return m;
|
||
}, [state]);
|
||
|
||
if (loading) return <p className="panel-empty">配置加载中…</p>;
|
||
if (error) return <p className="panel-empty">配置加载失败:{error}</p>;
|
||
if (!state) return null;
|
||
|
||
const hasFields = state.fields.length > 0;
|
||
const authFields = state.fields.filter((f) => f.group === "auth");
|
||
const commonFields = state.fields.filter((f) => f.group === "common");
|
||
const advancedFields = state.fields.filter((f) => f.group === "advanced");
|
||
|
||
function renderField(field: ConfigFieldState) {
|
||
return (
|
||
<Field
|
||
key={field.id}
|
||
field={field}
|
||
value={values[field.id] ?? ""}
|
||
saved={sensitiveSaved[field.id] ?? false}
|
||
revealed={!!revealed[field.id]}
|
||
onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))}
|
||
onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))}
|
||
/>
|
||
);
|
||
}
|
||
|
||
async function onSave() {
|
||
setSaving(true);
|
||
setSaved(false);
|
||
setResult(null);
|
||
setVerify(null);
|
||
setError(null);
|
||
const patch: Record<string, string> = {};
|
||
for (const f of state!.fields) {
|
||
const v = values[f.id];
|
||
if (v != null && v !== "") {
|
||
patch[f.id] = v;
|
||
}
|
||
}
|
||
if (Object.keys(patch).length === 0) {
|
||
setError("没有需要保存的改动");
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
try {
|
||
const r = await writeConfig(id, patch);
|
||
setResult(r);
|
||
setSaved(true);
|
||
setValues({});
|
||
// 刷新回显
|
||
const s = await readConfig(id);
|
||
setState(s);
|
||
// 生效检查:写回的配置能否被 CLI 接受(配置文件解析 + CLI 版本探测)
|
||
try {
|
||
const v = await verifyConfig(id);
|
||
setVerify(v);
|
||
} catch {
|
||
setVerify(null);
|
||
}
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="config-form">
|
||
{!hasFields && <p className="panel-empty">该 CLI 未声明可配置的中文表单字段。</p>}
|
||
|
||
{/* 第一层:授权 / 登录(可操作授权流程 + API Key 类字段) */}
|
||
{(state.auth_modes.length > 0 || authFields.length > 0) && (
|
||
<section className="config-section">
|
||
<h4 className="config-section-title">授权 / 登录</h4>
|
||
{state.auth_modes.length > 0 && (
|
||
<AuthPanel id={id} authModes={state.auth_modes} onAuthChanged={onAuthChanged ?? (() => {})} />
|
||
)}
|
||
{authFields.map(renderField)}
|
||
</section>
|
||
)}
|
||
|
||
{/* 第二层:常用配置 */}
|
||
{commonFields.length > 0 && (
|
||
<section className="config-section">
|
||
<h4 className="config-section-title">常用配置</h4>
|
||
{commonFields.map(renderField)}
|
||
</section>
|
||
)}
|
||
|
||
{/* 第三层:高级配置(默认折叠) */}
|
||
{advancedFields.length > 0 && (
|
||
<section className="config-section">
|
||
<button
|
||
type="button"
|
||
className="config-advanced-toggle"
|
||
onClick={() => setAdvancedOpen((v) => !v)}
|
||
aria-expanded={advancedOpen}
|
||
>
|
||
<span className="config-section-title">高级配置</span>
|
||
<span className="advanced-badge">高级</span>
|
||
<ChevronDown
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
className={advancedOpen ? "advanced-chevron open" : "advanced-chevron"}
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
<p className="advanced-hint">以下为官方支持但少数用户使用的高级项,一般无需修改。</p>
|
||
{advancedOpen && <div className="advanced-fields">{advancedFields.map(renderField)}</div>}
|
||
</section>
|
||
)}
|
||
|
||
{state.files.length > 0 && (
|
||
<div className="config-file-info">
|
||
<span className="config-file-label">配置文件</span>
|
||
{state.files.map((f) => (
|
||
<span key={f.path} className="config-file-path">
|
||
<MonoChip>{f.path}</MonoChip>
|
||
{f.exists ? (f.parse_ok ? " · 已解析" : " · 解析异常") : " · 尚未创建"}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{hasFields && (
|
||
<div className="config-savebar">
|
||
<div className="config-save-msg">
|
||
{saved && (
|
||
<span className="save-ok">
|
||
<Check size={14} strokeWidth={1.5} aria-hidden="true" /> 已保存
|
||
</span>
|
||
)}
|
||
{verify && (
|
||
<span className={verify.ok ? "verify-ok" : "verify-fail"}>
|
||
{verify.ok ? (
|
||
<ShieldCheck size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
) : (
|
||
<CircleAlert size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
)}
|
||
{verify.message_zh}
|
||
</span>
|
||
)}
|
||
{result?.backup_path && (
|
||
<span className="save-detail">
|
||
已备份原文件 · {result.backup_path.split(/[\\/]/).pop()}
|
||
</span>
|
||
)}
|
||
{error && <span className="save-error">{error}</span>}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={onSave}
|
||
disabled={saving}
|
||
>
|
||
{saving ? "保存中…" : "保存配置"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Field({
|
||
field,
|
||
value,
|
||
saved,
|
||
revealed,
|
||
onChange,
|
||
onToggleReveal,
|
||
}: {
|
||
field: ConfigFieldState;
|
||
value: string;
|
||
saved: boolean;
|
||
revealed: boolean;
|
||
onChange: (v: string) => void;
|
||
onToggleReveal: () => void;
|
||
}) {
|
||
const isPassword = field.sensitive;
|
||
const isEnum = field.field_type === "enum";
|
||
const inputType = isPassword ? (revealed ? "text" : "password") : "text";
|
||
return (
|
||
<div className="field">
|
||
<label className="field-label" htmlFor={`field-${field.id}`}>
|
||
{field.label_zh}
|
||
{field.required && <span className="field-required"> *</span>}
|
||
</label>
|
||
<div className="field-control">
|
||
{isPassword && saved && (
|
||
<span className="field-lock" title="已加密保存于系统密钥库">
|
||
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
|
||
</span>
|
||
)}
|
||
{isEnum ? (
|
||
<select
|
||
id={`field-${field.id}`}
|
||
className="field-select"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
>
|
||
<option value="">(使用 CLI 默认)</option>
|
||
{field.options.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label_zh}({o.value})
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : (
|
||
<input
|
||
id={`field-${field.id}`}
|
||
type={inputType}
|
||
value={value}
|
||
placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
)}
|
||
{isPassword && (
|
||
<button
|
||
type="button"
|
||
className="field-eye"
|
||
onClick={onToggleReveal}
|
||
aria-label={revealed ? "隐藏" : "显示"}
|
||
tabIndex={-1}
|
||
>
|
||
{revealed ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="field-help">
|
||
{field.help_zh}
|
||
{isPassword && (
|
||
<span className="field-keyring-note">
|
||
{saved
|
||
? ` · 已加密保存于系统密钥库${field.env_key ? `(${field.env_key})` : ""}`
|
||
: ` · 保存后写入系统密钥库,绝不明文落盘${field.env_key ? `(${field.env_key})` : ""}`}
|
||
</span>
|
||
)}
|
||
{field.docs_url && (
|
||
<a className="field-docs-link" href={field.docs_url} target="_blank" rel="noreferrer">
|
||
官方文档 <ExternalLink size={11} strokeWidth={1.5} aria-hidden="true" />
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|