console: React 生产级重写(左列表 + 中对话 + 右详情)
旧单页 console.html 换成 Vite + React + TypeScript 单文件构建产物 (309525 字节,sha 90345ec8…,重建字节一致),源码在 cloud/console-react/,改页面只改 src 再 npm run build:console, 产物不手改。布局:左侧列表(模块导航 + 任务列表)、中间标准对话 (实时链路时间线 + 继续会话聊天框,共用 Chat 组件)、右侧详情 (元数据、操作、评审、审批、协调子任务、子代理、线程)。 API 与后端语义未改,key 只存浏览器 localStorage。 6 个 console 标记单测随实现改名更新(意图不变:壳确实调了 这些接口、turn 信息仍在详情里、旧 goal 输入仍不存在); P1 标记单测未动且通过。
This commit is contained in:
1 parent
3f4e849a93
commit
ebc5624893
57 files changed
+4789
-1746
No files matched your search
@@ -0,0 +1,319 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { api, getKey, saveKey, clearKey, memberErr, adminErr, fmtUsd } from "../api/client";
|
||||
import { paths } from "../api/paths";
|
||||
import type { Env, GithubRepo, Health, Member, Project, Quota, SkillItem, McpServer, Worker } from "../api/types";
|
||||
|
||||
// Project-scoped catalogue data: key/quota/health, projects, workers,
|
||||
// environments, members, skills/MCP, relay models, GitHub repos, admin.
|
||||
// No task-detail state lives here; see useTaskDetail.
|
||||
export function useCatalog() {
|
||||
const [keySaved, setKeySaved] = useState(!!getKey());
|
||||
const [keyState, setKeyState] = useState(getKey() ? "已保存到本浏览器(只用于调本站 API)" : "未填 key,数据接口会返回 401");
|
||||
const [quotaText, setQuotaText] = useState("未加载");
|
||||
const [healthText, setHealthText] = useState("未加载");
|
||||
const [adminGate, setAdminGate] = useState("填 key 后判断权限…");
|
||||
const [adminBody, setAdminBody] = useState(false);
|
||||
const [adminResult, setAdminResult] = useState("");
|
||||
const [adminUserId, setAdminUserId] = useState("");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [projectMsg, setProjectMsg] = useState("");
|
||||
const [githubRepos, setGithubRepos] = useState<GithubRepo[]>([]);
|
||||
const [workers, setWorkers] = useState<Worker[] | null>(null);
|
||||
const [workersSummary, setWorkersSummary] = useState("未加载");
|
||||
const [workersError, setWorkersError] = useState("");
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [memberMsg, setMemberMsg] = useState("");
|
||||
const [memberError, setMemberError] = useState("");
|
||||
const [skills, setSkills] = useState<SkillItem[]>([]);
|
||||
const [apps, setApps] = useState<SkillItem[]>([]);
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||
const [skillMsg, setSkillMsg] = useState("先选项目");
|
||||
const [skillError, setSkillError] = useState("");
|
||||
const [mcpMsg, setMcpMsg] = useState("");
|
||||
const [mcpOauthMsg, setMcpOauthMsg] = useState("");
|
||||
const [mcpOauthBox, setMcpOauthBox] = useState("");
|
||||
const [envs, setEnvs] = useState<Env[]>([]);
|
||||
const [envCount, setEnvCount] = useState("");
|
||||
const [envMsg, setEnvMsg] = useState("");
|
||||
const [envRepoLine, setEnvRepoLine] = useState("");
|
||||
const [envRepoBar, setEnvRepoBar] = useState(false);
|
||||
const [envRepoMsg, setEnvRepoMsg] = useState("");
|
||||
const [envSelect, setEnvSelect] = useState("");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [modelCustomVisible, setModelCustomVisible] = useState(false);
|
||||
const projectIdRef = useRef("");
|
||||
projectIdRef.current = projectId;
|
||||
const envSelectRef = useRef("");
|
||||
envSelectRef.current = envSelect;
|
||||
|
||||
const refreshHealth = useCallback(async () => {
|
||||
try {
|
||||
const h = await api<Health>(paths.health);
|
||||
setHealthText("backend=" + h.backend + " db_ok=" + h.db_ok + " worker_ok=" + h.worker_ok + " commit=" + String(h.commit || "").slice(0, 7));
|
||||
} catch (e: any) { setHealthText("加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const loadQuota = useCallback(async () => {
|
||||
if (!getKey()) { setQuotaText("未填 key,配额不显示"); return; }
|
||||
try {
|
||||
const d = await api<Quota>(paths.quotaMe);
|
||||
const used = ((d.used_usd_micros || 0) / 1000000).toFixed(2);
|
||||
const usdLine = d.quota_usd_micros === null || d.quota_usd_micros === undefined
|
||||
? "美元:本月用量 $" + used + "(不限额)"
|
||||
: "美元:本月用量 $" + used + " / $" + ((d.quota_usd_micros || 0) / 1000000).toFixed(2) + "(剩余 $" + ((d.remaining_usd_micros || 0) / 1000000).toFixed(2) + ")";
|
||||
const tokLine = d.unpriced_token_cap === undefined || d.unpriced_tokens_used === undefined
|
||||
? "无价 token:未返回(旧服务端)"
|
||||
: "无价 token:" + d.unpriced_tokens_used + " / " + d.unpriced_token_cap + " tokens";
|
||||
setQuotaText(usdLine + " | " + tokLine);
|
||||
} catch (e: any) { setQuotaText("配额加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const probeAdmin = useCallback(async () => {
|
||||
if (!getKey()) { setAdminGate("未填 key,管理区不可用"); setAdminBody(false); return; }
|
||||
try {
|
||||
const r = await fetch(paths.adminQuotas, { headers: { "X-API-Key": getKey() } });
|
||||
if (r.status === 400) { setAdminGate("已确认为全局管理员,管理操作可用"); setAdminBody(true); }
|
||||
else if (r.status === 403) { setAdminGate("管理区仅全局管理员可用:当前 key 无全局管理员权限(服务端返回 403)"); setAdminBody(false); }
|
||||
else if (r.status === 401) { setAdminGate("key 无效,管理区不可用(服务端返回 401)"); setAdminBody(false); }
|
||||
else { setAdminGate("管理权限判断失败(HTTP " + r.status + "),管理操作暂不可用"); setAdminBody(false); }
|
||||
} catch (e: any) { setAdminGate("管理权限判断失败:" + e.message); setAdminBody(false); }
|
||||
}, []);
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
try {
|
||||
const d = await api<{ projects: Project[] }>(paths.projects);
|
||||
const list = d.projects || [];
|
||||
setProjects(list);
|
||||
setProjectMsg(list.length + " 个项目");
|
||||
if (projectIdRef.current && !list.some((p) => p.id === projectIdRef.current)) {
|
||||
// Selection is preserved when still present; a deleted project
|
||||
// simply leaves the select empty on next render via value.
|
||||
}
|
||||
} catch (e: any) { setProjectMsg("加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const loadGithubRepos = useCallback(async () => {
|
||||
try {
|
||||
const d = await api<{ repos: GithubRepo[] }>(paths.githubRepos);
|
||||
setGithubRepos(d.repos || []);
|
||||
} catch { /* 未登录或无安装记录时只留手动输入,不报错 */ }
|
||||
}, []);
|
||||
|
||||
const loadRelayModels = useCallback(async () => {
|
||||
try {
|
||||
const d = await api<{ models: string[] }>(paths.relayModels);
|
||||
setModels(d.models || []);
|
||||
setModelCustomVisible(false);
|
||||
} catch { setModelCustomVisible(true); }
|
||||
}, []);
|
||||
|
||||
const refreshWorkers = useCallback(async () => {
|
||||
try {
|
||||
const d = await api<{ workers?: Worker[]; workers_total?: number; workers_online?: number; workers_offline?: number } | Worker[]>(paths.workers);
|
||||
if (!Array.isArray(d) && d.workers === undefined && d.workers_total !== undefined) {
|
||||
setWorkers(null);
|
||||
setWorkersSummary(!d.workers_total ? "无 worker 注册" : "在线 " + d.workers_online + " / 离线 " + d.workers_offline + "(共 " + d.workers_total + ";完整 worker 清单仅全局管理员可见)");
|
||||
setWorkersError("");
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(d) ? d : (d.workers || []);
|
||||
setWorkers(list);
|
||||
setWorkersSummary(list.length ? "" : "无 worker 注册");
|
||||
setWorkersError("");
|
||||
} catch (e: any) { setWorkersError("加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const refreshEnvs = useCallback(async (pid?: string) => {
|
||||
const id = pid !== undefined ? pid : projectIdRef.current;
|
||||
const cur = envSelectRef.current;
|
||||
if (!id) { setEnvs([]); setEnvCount(""); setEnvSelect(""); return; }
|
||||
try {
|
||||
const d = await api<{ environments: Env[] }>(paths.environments(id));
|
||||
const list = d.environments || [];
|
||||
setEnvs(list);
|
||||
setEnvCount(list.length + " 个环境");
|
||||
if (cur && list.some((e) => e.id === cur && e.status === "ready")) setEnvSelect(cur);
|
||||
else if (cur && list.some((e) => e.id === cur)) setEnvSelect(cur);
|
||||
const proj = projects.find((p) => p.id === id);
|
||||
if (proj && proj.repo_url) { setEnvRepoLine("仓库:" + proj.repo_url); setEnvRepoBar(false); }
|
||||
else if (proj) { setEnvRepoLine("该项目未绑定仓库,环境无法创建——先选一个仓库保存"); setEnvRepoBar(true); }
|
||||
else { setEnvRepoLine(""); setEnvRepoBar(false); }
|
||||
} catch (e: any) { setEnvMsg("加载失败:" + e.message); }
|
||||
}, [projects]);
|
||||
|
||||
const refreshMembers = useCallback(async (pid?: string) => {
|
||||
const id = pid !== undefined ? pid : projectIdRef.current;
|
||||
if (!id) { setMembers([]); return; }
|
||||
try {
|
||||
const d = await api<{ members: Member[] }>(paths.projectMembers(id));
|
||||
setMembers(d.members || []);
|
||||
setMemberError("");
|
||||
} catch (e: any) { setMemberError("成员加载失败:" + memberErr(e)); }
|
||||
}, []);
|
||||
|
||||
const refreshSkills = useCallback(async (pid?: string) => {
|
||||
const id = pid !== undefined ? pid : projectIdRef.current;
|
||||
if (!id) { setSkills([]); setApps([]); setMcpServers([]); setSkillMsg("先选项目"); return; }
|
||||
setSkillMsg("");
|
||||
try {
|
||||
const s = await api<{ skills: SkillItem[] }>(paths.projectSkills(id));
|
||||
setSkills(s.skills || []);
|
||||
} catch (e: any) { setSkillError("技能加载失败:" + e.message); }
|
||||
try {
|
||||
const a = await api<{ apps: SkillItem[] }>(paths.projectApps(id));
|
||||
setApps(a.apps || []);
|
||||
} catch (e: any) { setSkillError("应用加载失败:" + e.message); }
|
||||
try {
|
||||
const m = await api<{ servers?: McpServer[]; mcp_servers?: McpServer[] }>(paths.projectMcp(id));
|
||||
setMcpServers(m.servers || m.mcp_servers || []);
|
||||
} catch (e: any) { setSkillError("MCP 服务加载失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const saveKeyInput = useCallback((v: string) => {
|
||||
if (!v.trim()) { setKeyState("key 为空,未保存"); return; }
|
||||
saveKey(v.trim());
|
||||
setKeySaved(true);
|
||||
setKeyState("已保存到本浏览器(只用于调本站 API)");
|
||||
}, []);
|
||||
|
||||
const clearKeyInput = useCallback(() => {
|
||||
clearKey();
|
||||
setKeySaved(false);
|
||||
setKeyState("未填 key,数据接口会返回 401");
|
||||
void probeAdmin();
|
||||
}, [probeAdmin]);
|
||||
|
||||
const createProject = useCallback(async (name: string, repo: string) => {
|
||||
if (!name.trim()) { setProjectMsg("项目名必填"); return; }
|
||||
const body: { name: string; repo_url?: string } = { name: name.trim() };
|
||||
if (repo.trim()) body.repo_url = repo.trim();
|
||||
try {
|
||||
await api(paths.projects, "POST", body);
|
||||
setProjectMsg("项目已建");
|
||||
await refreshProjects();
|
||||
} catch (e: any) { setProjectMsg("新建失败:" + e.message); }
|
||||
}, [refreshProjects]);
|
||||
|
||||
const createEnv = useCallback(async (name: string, branch: string, executor: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) { setEnvMsg("先选项目"); return; }
|
||||
if (!name.trim()) { setEnvMsg("环境名必填"); return; }
|
||||
const body: { project_id: string; name: string; branch?: string; executor?: string } = { project_id: pid, name: name.trim() };
|
||||
if (branch.trim()) body.branch = branch.trim();
|
||||
if (executor) body.executor = executor;
|
||||
try {
|
||||
await api(paths.environmentsCreate, "POST", body);
|
||||
setEnvMsg("环境已建,worker 准备中(creating → ready,失败会在列表里写原因)");
|
||||
await refreshEnvs(pid);
|
||||
} catch (e: any) { setEnvMsg("新建失败:" + e.message); }
|
||||
}, [refreshEnvs]);
|
||||
|
||||
const archiveEnv = useCallback(async (id: string) => {
|
||||
if (!window.confirm("确定归档该环境吗?归档后新任务不能再选它,进行中的任务不受影响(有进行中任务在用它时归档会被拒绝)。")) return;
|
||||
try { await api(paths.environmentArchive(id), "POST", {}); await refreshEnvs(); }
|
||||
catch (e: any) { setEnvMsg("归档失败:" + e.message); }
|
||||
}, [refreshEnvs]);
|
||||
|
||||
const saveEnvRepo = useCallback(async (repo: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) { setEnvRepoMsg("先选项目"); return; }
|
||||
if (!repo) { setEnvRepoMsg("先选一个仓库"); return; }
|
||||
try {
|
||||
await api(paths.project(pid), "PATCH", { repo_url: repo });
|
||||
setEnvRepoMsg("仓库已保存,现在可以建环境了");
|
||||
await refreshProjects();
|
||||
await refreshEnvs(pid);
|
||||
} catch (e: any) { setEnvRepoMsg("保存失败:" + e.message); }
|
||||
}, [refreshProjects, refreshEnvs]);
|
||||
|
||||
const inviteMember = useCallback(async (raw: string, role: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) { setMemberMsg("先选项目"); return; }
|
||||
if (!raw.trim()) { setMemberMsg("请填成员 user_id 或用户名"); return; }
|
||||
try {
|
||||
await api(paths.projectMembers(pid), "POST", { user_id: raw.trim(), role });
|
||||
} catch (e: any) {
|
||||
if (e.message.includes("user not found")) {
|
||||
try { await api(paths.projectMembers(pid), "POST", { username: raw.trim(), role }); }
|
||||
catch (e2: any) { setMemberMsg(memberErr(e2)); return; }
|
||||
} else { setMemberMsg(memberErr(e)); return; }
|
||||
}
|
||||
setMemberMsg("已邀请成员");
|
||||
await refreshMembers(pid);
|
||||
}, [refreshMembers]);
|
||||
|
||||
const changeMemberRole = useCallback(async (uid: string, role: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
try {
|
||||
await api(paths.projectMember(pid, uid), "PATCH", { role });
|
||||
setMembers((prev) => prev.map((m) => (m.user_id === uid ? { ...m, role } : m)));
|
||||
setMemberMsg("角色已改为 " + role);
|
||||
} catch (e: any) { setMemberMsg(memberErr(e)); }
|
||||
}, []);
|
||||
|
||||
const reloadMcp = useCallback(async () => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) { setMcpMsg("先选项目"); return; }
|
||||
try {
|
||||
await api(paths.projectMcpReload(pid), "POST", {});
|
||||
setMcpMsg("配置已重载");
|
||||
await refreshSkills(pid);
|
||||
} catch (e: any) { setMcpMsg("重载失败:" + e.message); }
|
||||
}, [refreshSkills]);
|
||||
|
||||
const mcpOauth = useCallback(async (name: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) { setMcpOauthMsg("先选项目"); return; }
|
||||
if (!name.trim()) { setMcpOauthMsg("服务名不能为空"); return; }
|
||||
try {
|
||||
const d = await api<{ url?: string; auth_url?: string; login_url?: string; authorization_url?: string; steps?: string | string[]; instructions?: string | string[] } & Record<string, unknown>>(paths.projectMcpOauth(pid, name.trim()), "POST", {});
|
||||
const url = d.url || d.auth_url || d.login_url || d.authorization_url || "";
|
||||
const steps = d.steps || d.instructions || "";
|
||||
let box = "登录尚未完成:请在浏览器打开下面的链接或按步骤完成,完成后回到本页刷新 MCP 状态。\n";
|
||||
if (url) box += url + "\n";
|
||||
if (steps) box += (Array.isArray(steps) ? steps.join("\n") : String(steps)) + "\n";
|
||||
box += JSON.stringify(d, null, 2).slice(0, 4000);
|
||||
setMcpOauthBox(box);
|
||||
setMcpOauthMsg("已返回登录信息,见下方");
|
||||
} catch (e: any) { setMcpOauthMsg("发起失败:" + e.message); }
|
||||
}, []);
|
||||
|
||||
const adminQuota = useCallback(async () => {
|
||||
const uid = adminUserId.trim();
|
||||
if (!uid) { setAdminResult("请填要查配额的用户 id"); return; }
|
||||
try {
|
||||
const d = await api<{ used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null; month?: string }>(paths.adminQuotaUser(uid));
|
||||
setAdminResult("用户 " + uid.slice(0, 8) + ":本月用量 " + fmtUsd(d.used_usd_micros) + " / 配额 " + fmtUsd(d.quota_usd_micros) + (d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + fmtUsd(d.remaining_usd_micros) + ")" : "") + " | 月份 " + (d.month || ""));
|
||||
} catch (e: any) { setAdminResult(adminErr(e)); }
|
||||
}, [adminUserId]);
|
||||
|
||||
const adminSweep = useCallback(async () => {
|
||||
if (!window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
|
||||
try {
|
||||
const d = await api<{ swept?: string[] }>(paths.adminSweep, "POST", {});
|
||||
const swept = d.swept || [];
|
||||
setAdminResult("sweep 完成,清理 " + swept.length + " 个任务" + (swept.length ? ":" + swept.join("、") : ""));
|
||||
} catch (e: any) { setAdminResult(adminErr(e)); }
|
||||
}, []);
|
||||
|
||||
const adminBackup = useCallback(async () => {
|
||||
try {
|
||||
const d = await api<{ backend?: string; path?: string; size_bytes?: number; task_count?: number }>(paths.adminBackup, "POST", {});
|
||||
setAdminResult("备份完成:backend=" + d.backend + " | 路径 " + d.path + " | 大小 " + d.size_bytes + " 字节 | 任务 " + d.task_count + " 行");
|
||||
} catch (e: any) { setAdminResult(adminErr(e)); }
|
||||
}, []);
|
||||
|
||||
return {
|
||||
keySaved, keyState, quotaText, healthText, adminGate, adminBody, adminResult, adminUserId, setAdminUserId,
|
||||
projects, projectId, setProjectId, projectMsg, githubRepos,
|
||||
workers, workersSummary, workersError, members, memberMsg, memberError,
|
||||
skills, apps, mcpServers, skillMsg, skillError, mcpMsg, mcpOauthMsg, mcpOauthBox,
|
||||
envs, envCount, envMsg, envRepoLine, envRepoBar, envRepoMsg, envSelect, setEnvSelect,
|
||||
models, modelCustomVisible,
|
||||
refreshHealth, loadQuota, probeAdmin, refreshProjects, loadGithubRepos, loadRelayModels, refreshWorkers,
|
||||
refreshEnvs, refreshMembers, refreshSkills, saveKeyInput, clearKeyInput, createProject, createEnv,
|
||||
archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, reloadMcp, mcpOauth, adminQuota, adminSweep, adminBackup,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user