Files
codex-app-server/cloud/console-react/src/hooks/useCatalog.ts
T
host 08484302ac console: 项目导航移到左侧栏,工作台不再承担项目模块
左侧栏 ProjectNav:项目完整列表(名 + 短 id + 我的角色,
选中高亮,点击切换)+ 创建项目(名/GitHub 仓库/repo_url)
+ 选中项目成员(邀请/改角色/新增移除);工作台删除
sec-projects 一节与任务列表里的项目下拉框,侧栏为唯一
项目选择器,自动选第一个项目逻辑不变。
薄模块补齐(均为已核实后端路由):成员移除 DELETE、
自动化详情(GET 单条 + 产出)与编辑 PATCH、分诊条目点进
任务/自动化详情、Workers 搜索与在线筛选、管理区设置配额
POST。主线新功能(Todos、Browser、taskMeta 等)原样保留,
未做任何回退。console 标记单测本地 53 项通过。
2026-10-30 07:43:54 +00:00

349 lines
18 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 { 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 removeMember = useCallback(async (uid: string) => {
const pid = projectIdRef.current;
if (!pid) return;
if (!window.confirm("确定移除该成员吗?移除后其项目权限立即失效(唯一 owner 不可移除,服务端会拒绝)。")) return;
try {
await api(paths.projectMember(pid, uid), "DELETE");
setMemberMsg("成员已移除");
await refreshMembers(pid);
} catch (e: any) { setMemberMsg(memberErr(e)); }
}, [refreshMembers]);
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 adminSetQuota = useCallback(async (uid: string, usdText: string) => {
const target = uid.trim();
if (!target) { setAdminResult("请填要设置配额的用户 id"); return; }
// POST /admin/quotas {user_id, quota_usd_micros}: integer micros or
// null for unlimited, >= 0. The console takes dollars and converts.
let micros: number | null;
if (!usdText.trim()) micros = null;
else {
const usd = Number(usdText.trim());
if (!Number.isFinite(usd) || usd < 0) { setAdminResult("配额金额无效:请输入 >= 0 的美元数,留空表示不限额"); return; }
micros = Math.round(usd * 1000000);
}
try {
const d = await api<{ used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null; month?: string }>(paths.adminQuotas, "POST", { user_id: target, quota_usd_micros: micros });
setAdminResult("配额已设置:用户 " + target.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)); }
}, []);
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, removeMember, reloadMcp, mcpOauth, adminQuota, adminSetQuota, adminSweep, adminBackup,
};
}