Wave 2: 五件套打通全链路(安装/检测/配置/授权/诊断)
- 五个适配器 YAML 全字段补齐(codex/claude-code/gemini/kimi/opencode) - agentdock-config:TOML/JSON 编解码 + 原子写 + 自动备份 - agentdock-diag:PATH/依赖版本/版本冲突/配置损坏四类规则 - agentdock-core:detectCli/previewAction/runAction 流式/readConfig/writeConfig/authStatus/diagnose - Tauri IPC 命令 + CLI 详情页/安装确认弹窗/配置表单/诊断页 - cargo test 75 项通过(含 4 项真机 ignored 自测),前端 build 通过
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, Eye, EyeOff, Lock } from "lucide-react";
|
||||
import { readConfig, writeConfig } from "../ipc";
|
||||
import type { ConfigFieldState, ConfigFormState, WriteResult } from "../ipc/types";
|
||||
import { MonoChip } from "./MonoChip";
|
||||
|
||||
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条) */
|
||||
export function ConfigForm({ id }: { id: string }) {
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
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;
|
||||
|
||||
async function onSave() {
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setResult(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);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-form">
|
||||
{!hasFields && <p className="panel-empty">该 CLI 未声明可配置的中文表单字段。</p>}
|
||||
|
||||
{state.fields.map((field) => (
|
||||
<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 }))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{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 inputType = isPassword ? (revealed ? "text" : "password") : field.field_type === "url" ? "text" : "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>
|
||||
)}
|
||||
<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 ? " · 已加密保存于系统密钥库" : " · 保存后写入系统密钥库,绝不明文落盘"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user