feat(dev-variables/ui): 一张卡填写测试账号和密码

项目面板一次提交两个字段到同一 Tauri 本体,列表与 React Query 仅保留非秘密状态。

Rules-Applied: R01,R08

Feature-Confirmed: true
This commit is contained in:
lanrtop 2026-09-28 01:45:49 +09:00
parent 1daef98af3
commit d926591ace
3 changed files with 76 additions and 24 deletions

View File

@ -405,6 +405,7 @@ pub fn run() {
// project development variables (human filling; no reveal command) // project development variables (human filling; no reveal command)
commands::dev_variables::list_project_dev_variables, commands::dev_variables::list_project_dev_variables,
commands::dev_variables::save_project_dev_variable, commands::dev_variables::save_project_dev_variable,
commands::dev_variables::save_project_test_account,
commands::dev_variables::revoke_project_dev_variable, commands::dev_variables::revoke_project_dev_variable,
// host apps // host apps
commands::host_apps::get_host_apps, commands::host_apps::get_host_apps,

View File

@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
listProjectDevVariables, listProjectDevVariables,
saveProjectDevVariable, saveProjectDevVariable,
saveProjectTestAccount,
revokeProjectDevVariable, revokeProjectDevVariable,
} from "../../lib/commands"; } from "../../lib/commands";
import { useUIStore } from "../../store/ui"; import { useUIStore } from "../../store/ui";
@ -19,21 +20,32 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr
const showToast = useUIStore((state) => state.showToast); const showToast = useUIStore((state) => state.showToast);
const [editingName, setEditingName] = useState<string | null>(null); const [editingName, setEditingName] = useState<string | null>(null);
const [draftValue, setDraftValue] = useState(""); const [draftValue, setDraftValue] = useState("");
const [draftUsername, setDraftUsername] = useState("");
const [draftPassword, setDraftPassword] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const { data: variables = [], isPending, error } = useQuery({ const { data: variables = [], isPending, error } = useQuery({
queryKey: ["projectDevVariables", projectId], queryKey: ["projectDevVariables", projectId],
queryFn: () => listProjectDevVariables(projectId), queryFn: () => listProjectDevVariables(projectId),
retry: false, retry: false,
}); });
const handleSave = async (name: string) => { const clearDraft = () => {
if (!draftValue || saving) return; setDraftValue("");
setDraftUsername("");
setDraftPassword("");
};
const handleSave = async (name: string, kind: "scalar" | "test_account") => {
if (saving || (kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)) return;
setSaving(true); setSaving(true);
try { try {
await saveProjectDevVariable(projectId, name, draftValue); if (kind === "test_account") {
setDraftValue(""); await saveProjectTestAccount(projectId, name, draftUsername, draftPassword);
} else {
await saveProjectDevVariable(projectId, name, draftValue);
}
clearDraft();
setEditingName(null); setEditingName(null);
queryClient.invalidateQueries({ queryKey: ["projectDevVariables", projectId] }); queryClient.invalidateQueries({ queryKey: ["projectDevVariables", projectId] });
showToast("开发变量已保存"); showToast(kind === "test_account" ? "测试账户已保存" : "开发变量已保存");
} catch (cause) { } catch (cause) {
showToast(`保存失败:${cause}`); showToast(`保存失败:${cause}`);
} finally { } finally {
@ -53,7 +65,7 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr
<Dialog title={`开发变量 · ${projectName}`} onClose={onClose} size="md"> <Dialog title={`开发变量 · ${projectName}`} onClose={onClose} size="md">
<div className="overflow-y-auto p-5 space-y-4 text-sm"> <div className="overflow-y-auto p-5 space-y-4 text-sm">
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg p-3"> <p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg p-3">
仅填写非生产、低权限、可重置的开发值。已配置的值可被此项目的已认证 Agent 单项读取, 仅填写非生产、低权限、可重置的开发值或测试账户。已配置的值可被此项目的已认证 Agent 单项读取,
不会写入子项目文件;请勿存放生产密码。 不会写入子项目文件;请勿存放生产密码。
</p> </p>
{isPending && <p className="text-gray-500">正在读取需求…</p>} {isPending && <p className="text-gray-500">正在读取需求…</p>}
@ -69,34 +81,63 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr
{item.configured ? "已配置" : "待填写"} {item.configured ? "已配置" : "待填写"}
</span> </span>
</div> </div>
{item.kind === "test_account" && (
<p className="text-xs text-gray-600">测试账户 · {item.service} · {item.environment}</p>
)}
<p className="text-xs text-gray-500 whitespace-pre-wrap">{item.purpose}</p> <p className="text-xs text-gray-500 whitespace-pre-wrap">{item.purpose}</p>
{editingName === item.name ? ( {editingName === item.name ? (
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
void handleSave(item.name); void handleSave(item.name, item.kind);
}} }}
className="flex flex-wrap gap-2" className="space-y-2"
> >
<input {item.kind === "test_account" ? (
type="password" <div className="grid gap-2">
autoComplete="off" <input
autoFocus type="text"
value={draftValue} autoComplete="off"
onChange={(event) => setDraftValue(event.target.value)} autoFocus
placeholder="填写开发值" value={draftUsername}
className="flex-1 min-w-44 border border-gray-300 rounded px-2 py-1.5 text-sm" onChange={(event) => setDraftUsername(event.target.value)}
/> placeholder="测试账号"
<button type="submit" disabled={!draftValue || saving} aria-label={`${item.name} 测试账号`}
className="px-3 py-1.5 bg-blue-600 text-white rounded disabled:opacity-50"> className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
{saving ? "保存中…" : "保存"} />
</button> <input
<button type="button" onClick={() => { setDraftValue(""); setEditingName(null); }} type="password"
className="px-2 py-1.5 text-gray-500">取消</button> autoComplete="off"
value={draftPassword}
onChange={(event) => setDraftPassword(event.target.value)}
placeholder="测试密码"
aria-label={`${item.name} 测试密码`}
className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
/>
</div>
) : (
<input
type="password"
autoComplete="off"
autoFocus
value={draftValue}
onChange={(event) => setDraftValue(event.target.value)}
placeholder="填写开发值"
className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
/>
)}
<div className="flex gap-2">
<button type="submit" disabled={saving || (item.kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)}
className="px-3 py-1.5 bg-blue-600 text-white rounded disabled:opacity-50">
{saving ? "保存中…" : "保存"}
</button>
<button type="button" onClick={() => { clearDraft(); setEditingName(null); }}
className="px-2 py-1.5 text-gray-500">取消</button>
</div>
</form> </form>
) : ( ) : (
<div className="flex gap-3 text-xs"> <div className="flex gap-3 text-xs">
<button onClick={() => { setDraftValue(""); setEditingName(item.name); }} <button onClick={() => { clearDraft(); setEditingName(item.name); }}
className="text-blue-600 hover:underline">{item.configured ? "修改" : "填写"}</button> className="text-blue-600 hover:underline">{item.configured ? "修改" : "填写"}</button>
{item.configured && ( {item.configured && (
<button <button

View File

@ -1411,6 +1411,9 @@ export interface ProjectDevVariableStatus {
project_id: string; project_id: string;
name: string; name: string;
purpose: string; purpose: string;
kind: "scalar" | "test_account";
service: string | null;
environment: string | null;
configured: boolean; configured: boolean;
updated_at: string; updated_at: string;
} }
@ -1421,6 +1424,13 @@ export const listProjectDevVariables = (projectId: string) =>
export const saveProjectDevVariable = (projectId: string, name: string, value: string) => export const saveProjectDevVariable = (projectId: string, name: string, value: string) =>
invoke<void>("save_project_dev_variable", { projectId, name, value }); invoke<void>("save_project_dev_variable", { projectId, name, value });
export const saveProjectTestAccount = (
projectId: string,
name: string,
username: string,
password: string,
) => invoke<void>("save_project_test_account", { projectId, name, username, password });
export const revokeProjectDevVariable = (projectId: string, name: string) => export const revokeProjectDevVariable = (projectId: string, name: string) =>
invoke<void>("revoke_project_dev_variable", { projectId, name }); invoke<void>("revoke_project_dev_variable", { projectId, name });