213 lines
8.7 KiB
JavaScript
213 lines
8.7 KiB
JavaScript
const $ = (id) => document.getElementById(id);
|
||
const state = { csrf: "", requests: [], selected: null };
|
||
const eventNames = {
|
||
request_created: "铸澜提交申请",
|
||
human_approved: "主人批准边界",
|
||
human_rejected: "主人拒绝申请",
|
||
human_revoked: "主人撤销能力",
|
||
capability_picked_up: "铸澜领取临时能力",
|
||
capability_verified: "运行层校验能力",
|
||
capability_expired: "临时能力到期",
|
||
request_expired: "申请单到期"
|
||
};
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = { "Content-Type": "application/json", ...(options.headers || {}) };
|
||
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
|
||
let data = {};
|
||
try { data = await response.json(); } catch (_) {}
|
||
if (!response.ok) throw new Error(data.error || `HTTP_${response.status}`);
|
||
return data;
|
||
}
|
||
|
||
function toast(message) {
|
||
const node = $("toast");
|
||
node.textContent = message;
|
||
node.classList.add("show");
|
||
window.setTimeout(() => node.classList.remove("show"), 2600);
|
||
}
|
||
|
||
function humanAction(value) {
|
||
return ({ read: "读取", edit: "编辑", test: "运行测试", commit: "提交", push_candidate: "推送候选分支" })[value] || value;
|
||
}
|
||
|
||
function stateLabel(value) {
|
||
return ({ PENDING: "等待批准", APPROVED: "已批准", REJECTED: "已拒绝", REVOKED: "已撤销", EXPIRED: "已过期" })[value] || value;
|
||
}
|
||
|
||
function renderRequest(request) {
|
||
state.selected = request;
|
||
$("empty-state").classList.add("hidden");
|
||
$("request-card").classList.remove("hidden");
|
||
$("request-id").textContent = request.id;
|
||
$("request-description").textContent = request.description;
|
||
$("persona").textContent = request.persona_id;
|
||
$("development-id").textContent = request.development_id;
|
||
$("repository").textContent = request.repository;
|
||
$("branch").textContent = request.branch;
|
||
$("base-sha").textContent = request.base_sha;
|
||
$("actions").replaceChildren(...request.actions.map((action) => {
|
||
const node = document.createElement("span");
|
||
node.className = "chip";
|
||
node.textContent = humanAction(action);
|
||
return node;
|
||
}));
|
||
$("paths").replaceChildren(...request.paths.map((path) => {
|
||
const node = document.createElement("span");
|
||
node.className = "path";
|
||
node.textContent = path;
|
||
return node;
|
||
}));
|
||
const label = stateLabel(request.state);
|
||
for (const id of ["request-state", "card-state"]) {
|
||
const node = $(id);
|
||
node.textContent = label;
|
||
node.className = `state ${request.state === "APPROVED" ? "approved" : request.state === "REJECTED" ? "rejected" : "waiting"}`;
|
||
}
|
||
$("decision-area").classList.toggle("hidden", request.state !== "PENDING");
|
||
$("revoke-area").classList.toggle("hidden", request.state !== "APPROVED");
|
||
loadReceipts(request.id);
|
||
}
|
||
|
||
function renderEmpty(message, detail) {
|
||
state.selected = null;
|
||
$("request-card").classList.add("hidden");
|
||
const empty = $("empty-state");
|
||
empty.classList.remove("hidden");
|
||
empty.querySelector("h3").textContent = message;
|
||
empty.querySelector("p").textContent = detail;
|
||
$("request-state").textContent = "湖面安静";
|
||
}
|
||
|
||
async function loadReceipts(requestId) {
|
||
try {
|
||
const data = await api(`api/v1/owner/requests/${encodeURIComponent(requestId)}/receipts`);
|
||
const nodes = data.receipts.map((receipt) => {
|
||
const li = document.createElement("li");
|
||
li.className = receipt.result === "PASS" || receipt.result === "APPROVED" || receipt.result === "PENDING" ? "pass" : "fail";
|
||
const dot = document.createElement("i");
|
||
const body = document.createElement("div");
|
||
const title = document.createElement("strong");
|
||
title.textContent = eventNames[receipt.event] || receipt.event;
|
||
const detail = document.createElement("span");
|
||
detail.textContent = `${receipt.created_at} · ${receipt.result} · ${receipt.receipt_hash.slice(0, 12)}`;
|
||
body.append(title, detail);
|
||
li.append(dot, body);
|
||
return li;
|
||
});
|
||
$("timeline").replaceChildren(...nodes);
|
||
} catch (_) {
|
||
$("timeline").innerHTML = "<li class='fail'><i></i><div><strong>回执读取失败</strong><span>运行层没有返回可验证结果。</span></div></li>";
|
||
}
|
||
}
|
||
|
||
async function loadRequests(preferredId = "") {
|
||
const data = await api("api/v1/owner/requests");
|
||
state.requests = data.requests;
|
||
const preferred = preferredId && data.requests.find((item) => item.id === preferredId);
|
||
const pending = data.requests.find((item) => item.state === "PENDING");
|
||
const selected = preferred || pending || data.requests[0];
|
||
if (selected) renderRequest(selected);
|
||
else renderEmpty("暂无真实申请", "铸澜提交结构化申请后会出现在这里;公开建单本身没有执行权。");
|
||
}
|
||
|
||
async function restoreSession() {
|
||
try {
|
||
const session = await api("api/v1/owner/session");
|
||
state.csrf = session.csrf;
|
||
$("auth-title").textContent = `主人 ${session.owner_login} · 已验证`;
|
||
$("auth-detail").textContent = "审批会话有效。任何扩大仓库、路径、动作或期限的请求都必须重新批准。";
|
||
$("login-open").classList.add("hidden");
|
||
$("logout").classList.remove("hidden");
|
||
const preferred = new URLSearchParams(location.search).get("request") || "";
|
||
await loadRequests(preferred);
|
||
const oauthReturn = new URLSearchParams(location.search).get("oauth_return");
|
||
if (oauthReturn) {
|
||
try {
|
||
const normalized = oauthReturn.replace(/-/g, "+").replace(/_/g, "/");
|
||
const decoded = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4));
|
||
if (decoded.startsWith("/oauth/authorize?")) location.assign(`/zhulan${decoded}`);
|
||
} catch (_) {}
|
||
}
|
||
} catch (_) {
|
||
state.csrf = "";
|
||
$("login-open").classList.remove("hidden");
|
||
$("logout").classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
$("login-open").addEventListener("click", () => $("login-dialog").showModal());
|
||
$("login-form").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
$("login-error").textContent = "";
|
||
const button = event.currentTarget.querySelector("button[type=submit]");
|
||
button.disabled = true;
|
||
try {
|
||
const result = await api("api/v1/owner/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username: $("username").value, password: $("password").value })
|
||
});
|
||
state.csrf = result.csrf;
|
||
$("password").value = "";
|
||
$("login-dialog").close();
|
||
toast("代码频道身份验证通过");
|
||
await restoreSession();
|
||
} catch (_) {
|
||
$("login-error").textContent = "账号验证失败。没有创建审批会话。";
|
||
} finally { button.disabled = false; }
|
||
});
|
||
|
||
$("approve").addEventListener("click", async () => {
|
||
if (!state.selected || state.selected.state !== "PENDING") return;
|
||
const exact = `${state.selected.repository}\n${state.selected.branch}\n${state.selected.paths.join(",")}`;
|
||
if (!window.confirm(`批准这一段开发?\n\n${exact}\n\n批准不会开放 root 或任意 shell。`)) return;
|
||
$("approve").disabled = true;
|
||
try {
|
||
await api(`api/v1/owner/requests/${state.selected.id}/approve`, {
|
||
method: "POST",
|
||
headers: { "X-CSRF-Token": state.csrf },
|
||
body: JSON.stringify({ ttl_seconds: Number($("ttl").value) })
|
||
});
|
||
toast("已批准。铸澜可领取一次临时能力。");
|
||
await restoreSession();
|
||
} catch (error) { toast(`批准失败:${error.message}`); }
|
||
finally { $("approve").disabled = false; }
|
||
});
|
||
|
||
$("reject").addEventListener("click", async () => {
|
||
if (!state.selected || state.selected.state !== "PENDING") return;
|
||
const reason = window.prompt("写一句拒绝原因,铸澜会据此修正申请:", "范围需要重新确认");
|
||
if (reason === null) return;
|
||
try {
|
||
await api(`api/v1/owner/requests/${state.selected.id}/reject`, {
|
||
method: "POST",
|
||
headers: { "X-CSRF-Token": state.csrf },
|
||
body: JSON.stringify({ reason })
|
||
});
|
||
toast("已拒绝并留下回执。");
|
||
await restoreSession();
|
||
} catch (error) { toast(`拒绝失败:${error.message}`); }
|
||
});
|
||
|
||
$("revoke").addEventListener("click", async () => {
|
||
if (!state.selected || state.selected.state !== "APPROVED") return;
|
||
if (!window.confirm("立即撤销这段开发能力?已连接会话会在下一次调用时被服务器驳回。")) return;
|
||
try {
|
||
await api(`api/v1/owner/requests/${state.selected.id}/revoke`, {
|
||
method: "POST",
|
||
headers: { "X-CSRF-Token": state.csrf },
|
||
body: JSON.stringify({ reason: "主人在审批端主动撤销" })
|
||
});
|
||
toast("能力已撤销,原连接不再有效。");
|
||
await restoreSession();
|
||
} catch (error) { toast(`撤销失败:${error.message}`); }
|
||
});
|
||
|
||
$("logout").addEventListener("click", async () => {
|
||
try {
|
||
await api("api/v1/owner/logout", { method: "POST", headers: { "X-CSRF-Token": state.csrf }, body: "{}" });
|
||
} catch (_) {}
|
||
location.reload();
|
||
});
|
||
|
||
restoreSession();
|