Files
Agent-Tools/apps/desktop/src/components/ConfigForm.tsx
T

305 lines
10 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 { 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>
);
}