Files
2026-06-18 14:41:15 +08:00

436 lines
17 KiB
JavaScript
Raw Permalink 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.
const els = {
token: document.querySelector("#admin-token"),
saveToken: document.querySelector("#save-token"),
tokenStatus: document.querySelector("#token-status"),
health: document.querySelector("#svn-health"),
repoForm: document.querySelector("#repo-form"),
repoFormTitle: document.querySelector("#repo-form-title"),
repoFormStatus: document.querySelector("#repo-form-status"),
repoSubmit: document.querySelector("#repo-submit"),
repoReset: document.querySelector("#repo-reset"),
deleteRepo: document.querySelector("#delete-repo"),
reloadRepos: document.querySelector("#reload-repos"),
syncServerRepos: document.querySelector("#sync-server-repos"),
repoSearch: document.querySelector("#repo-search"),
repoList: document.querySelector("#repo-list"),
repoSummary: document.querySelector("#repo-summary"),
serverRepoList: document.querySelector("#server-repo-list"),
serverSummary: document.querySelector("#server-summary"),
selectedRepo: document.querySelector("#selected-repo"),
selectedRepoName: document.querySelector("#selected-repo-name"),
selectedRepoUrl: document.querySelector("#selected-repo-url"),
selectedRepoPath: document.querySelector("#selected-repo-path"),
selectedRepoRevision: document.querySelector("#selected-repo-revision"),
selectedRepoSource: document.querySelector("#selected-repo-source"),
repoConfigDetails: document.querySelector("#repo-config-details"),
tabs: [...document.querySelectorAll(".svn-tab")],
panels: [...document.querySelectorAll(".svn-tab-panel")],
actionForm: document.querySelector("#action-form"),
output: document.querySelector("#svn-output"),
lastAction: document.querySelector("#last-action"),
lastCommand: document.querySelector("#last-command"),
clearOutput: document.querySelector("#clear-output"),
actionButtons: [...document.querySelectorAll("[data-action]")],
};
let repositories = [];
let serverRepositories = [];
let selectedRepoId = "";
let repoFilter = "";
const ACTION_LABELS = {
checkout: "Checkout",
info: "Info",
status: "Status",
update: "Update",
commit: "Commit",
log: "Log",
diff: "Diff",
cleanup: "Cleanup",
revert: "Revert",
resolve: "Resolve",
add: "Add",
delete: "Delete",
mkdir: "Mkdir",
copy: "Copy",
move: "Move",
branch: "Create Branch",
tag: "Create Tag",
switch: "Switch",
relocate: "Relocate",
merge: "Merge",
lock: "Lock",
unlock: "Unlock",
list: "List",
blame: "Blame",
propget: "Propget",
propset: "Propset",
ignore: "Ignore",
export: "Export",
};
function escapeHtml(value = "") {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function getToken() {
return localStorage.getItem("sumiSoftwareAdminToken") || "";
}
function setStatus(element, message, type = "") {
element.textContent = message;
element.classList.toggle("is-error", type === "error");
element.classList.toggle("is-ok", type === "ok");
}
function selectedRepo() {
return repositories.find((repo) => repo.id === selectedRepoId) || null;
}
function formatDate(value) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { hour12: false });
}
function compactPath(value = "") {
const text = String(value || "");
if (text.length <= 56) return text || "--";
return `${text.slice(0, 24)}...${text.slice(-28)}`;
}
async function apiFetch(url, options = {}) {
const token = getToken();
const headers = {
...(options.headers || {}),
"X-Admin-Token": token,
};
if (options.json) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(url, {
...options,
headers,
body: options.json ? JSON.stringify(options.json) : options.body,
});
const isJson = response.headers.get("content-type")?.includes("application/json");
const payload = isJson ? await response.json() : await response.text();
if (!response.ok) {
const message = typeof payload === "string" ? payload : payload.error || JSON.stringify(payload);
const error = new Error(message || `HTTP ${response.status}`);
error.payload = payload;
throw error;
}
return payload;
}
function formToObject(form) {
const formData = new FormData(form);
const payload = {};
for (const [key, value] of formData.entries()) {
payload[key] = value;
}
for (const checkbox of form.querySelectorAll("input[type='checkbox']")) {
payload[checkbox.name] = checkbox.checked;
}
return payload;
}
function renderRepos() {
const keyword = repoFilter.trim().toLowerCase();
const visibleRepos = repositories.filter((repo) => {
if (!keyword) return true;
return [repo.name, repo.url, repo.checkoutPath, repo.localPath, repo.description, repo.source].join(" ").toLowerCase().includes(keyword);
});
els.repoSummary.textContent = `${repositories.length} 个仓库${keyword ? `,匹配 ${visibleRepos.length}` : ""}`;
els.repoList.innerHTML = visibleRepos.map((repo) => `<button class="svn-repo-item${repo.id === selectedRepoId ? " is-active" : ""}" type="button" data-id="${escapeHtml(repo.id)}">
<span class="repo-name">${escapeHtml(repo.name)}</span>
<span class="repo-url">${escapeHtml(repo.url)}</span>
<span class="repo-meta">${escapeHtml(repo.source === "server" ? "服务器仓库" : "手动配置")} · ${escapeHtml(repo.localPath || repo.checkoutPath || repo.id)} · ${repo.hasPassword ? "已配置凭据" : (repo.serverAuthRequired ? "需要凭据" : "无密码")}</span>
</button>`).join("") || `<p class="empty-state">${repositories.length ? "没有匹配的仓库。" : "暂无 SVN 仓库,可以先点击“接入旧仓库”。"}</p>`;
const repo = selectedRepo();
els.selectedRepoName.textContent = repo ? repo.name : "选择仓库";
els.selectedRepo.textContent = repo
? (repo.description || `${repo.name} · ${repo.url}`)
: "请选择一个 SVN 仓库,或先从服务器 SVN 服务接入旧仓库。";
els.selectedRepoUrl.textContent = compactPath(repo?.url);
els.selectedRepoPath.textContent = compactPath(repo?.localPath || repo?.checkoutPath);
els.selectedRepoRevision.textContent = repo?.serverRevision ? `r${repo.serverRevision}` : (repo?.lastAction?.at ? formatDate(repo.lastAction.at) : "--");
els.selectedRepoSource.textContent = repo ? (repo.source === "server" ? `服务器旧仓库${repo.serverAuthRequired ? " · 需要 SVN 凭据" : ""}` : "手动配置") : "--";
els.deleteRepo.disabled = !repo;
}
function renderServerRepos() {
const configuredCount = serverRepositories.filter((repo) => repo.configured).length;
els.serverSummary.textContent = serverRepositories.length
? `发现 ${serverRepositories.length} 个,已接入 ${configuredCount}`
: "未发现旧仓库";
els.serverRepoList.innerHTML = serverRepositories.map((repo) => {
const authText = repo.authRequired
? `需要凭据${repo.authUsers?.length ? `${repo.authUsers.join(", ")}` : ""}`
: "允许匿名";
return `<button class="svn-server-repo${repo.configured ? " is-linked" : ""}" type="button" data-id="${escapeHtml(repo.id)}" data-configured-id="${escapeHtml(repo.configuredId || "")}">
<span>
<strong>${escapeHtml(repo.name)}</strong>
<small>${escapeHtml(repo.url)}</small>
</span>
<em>${repo.configured ? "已接入" : "待接入"}${repo.revision ? ` · r${escapeHtml(repo.revision)}` : ""}<br>${escapeHtml(authText)}</em>
</button>`;
}).join("") || `<p class="empty-state">没有扫描到服务器 SVN 仓库。</p>`;
}
async function loadRepos() {
try {
setStatus(els.tokenStatus, "正在读取 SVN 仓库...");
const [payload, serverPayload] = await Promise.all([
apiFetch("/api/admin/svn/repositories"),
apiFetch("/api/admin/svn/server-repositories"),
]);
repositories = payload.repositories || [];
serverRepositories = serverPayload.repositories || [];
if (!selectedRepoId || !repositories.some((repo) => repo.id === selectedRepoId)) {
selectedRepoId = repositories[0]?.id || "";
}
renderRepos();
renderServerRepos();
setStatus(els.tokenStatus, `工作副本:${payload.svnRoot || "-"};服务根:${serverPayload.repositoryRoots?.filter((root) => root.exists).map((root) => root.rootPath).join("、") || "-"}`, "ok");
} catch (error) {
repositories = [];
serverRepositories = [];
selectedRepoId = "";
renderRepos();
renderServerRepos();
setStatus(els.tokenStatus, `读取失败:${error.message}`, "error");
}
}
function resetRepoForm() {
els.repoForm.reset();
els.repoForm.elements.id.value = "";
els.repoForm.elements.trunkPath.value = "trunk";
els.repoForm.elements.branchesPath.value = "branches";
els.repoForm.elements.tagsPath.value = "tags";
els.repoForm.elements.trustServerCert.checked = true;
els.repoForm.elements.noAuthCache.checked = true;
els.repoFormTitle.textContent = "新增仓库";
els.repoSubmit.textContent = "保存仓库";
if (els.repoConfigDetails) els.repoConfigDetails.open = true;
setStatus(els.repoFormStatus, "");
}
function fillRepoForm(repo) {
els.repoForm.elements.id.value = repo.id;
els.repoForm.elements.name.value = repo.name || "";
els.repoForm.elements.url.value = repo.url || "";
els.repoForm.elements.checkoutPath.value = repo.localPath || repo.checkoutPath || "";
els.repoForm.elements.username.value = repo.username || "";
els.repoForm.elements.password.value = "";
els.repoForm.elements.trunkPath.value = repo.trunkPath || "trunk";
els.repoForm.elements.branchesPath.value = repo.branchesPath || "branches";
els.repoForm.elements.tagsPath.value = repo.tagsPath || "tags";
els.repoForm.elements.description.value = repo.description || "";
els.repoForm.elements.trustServerCert.checked = repo.trustServerCert !== false;
els.repoForm.elements.noAuthCache.checked = repo.noAuthCache !== false;
els.repoFormTitle.textContent = `编辑 ${repo.name}`;
els.repoSubmit.textContent = "更新仓库";
if (els.repoConfigDetails) els.repoConfigDetails.open = true;
setStatus(els.repoFormStatus, repo.hasPassword ? "已载入仓库,密码留空会保持原密码。" : "已载入仓库。", "ok");
}
async function saveRepo(event) {
event.preventDefault();
const payload = formToObject(els.repoForm);
const id = payload.id;
delete payload.id;
try {
setStatus(els.repoFormStatus, "正在保存...");
const repository = await apiFetch(id ? `/api/admin/svn/repositories/${encodeURIComponent(id)}` : "/api/admin/svn/repositories", {
method: id ? "PUT" : "POST",
json: payload,
});
selectedRepoId = repository.repository?.id || selectedRepoId;
setStatus(els.repoFormStatus, id ? "仓库已更新。" : "仓库已新增。", "ok");
resetRepoForm();
await loadRepos();
} catch (error) {
setStatus(els.repoFormStatus, `保存失败:${error.message}`, "error");
}
}
async function deleteRepo(repo) {
if (!window.confirm(`确定删除 SVN 配置 ${repo.name} 吗?不会删除服务器上的工作副本目录。`)) return;
try {
await apiFetch(`/api/admin/svn/repositories/${encodeURIComponent(repo.id)}`, { method: "DELETE" });
if (selectedRepoId === repo.id) selectedRepoId = "";
await loadRepos();
} catch (error) {
setStatus(els.tokenStatus, `删除失败:${error.message}`, "error");
}
}
function fillRepoFormFromServer(repo) {
resetRepoForm();
els.repoForm.elements.name.value = repo.name || "";
els.repoForm.elements.url.value = repo.url || "";
els.repoForm.elements.checkoutPath.value = repo.id || repo.name || "";
els.repoForm.elements.description.value = `从服务器 SVN 服务接入:${repo.repositoryPath || repo.name}`;
setStatus(els.repoFormStatus, "已填入服务器旧仓库,保存后即可纳入管理。", "ok");
}
async function syncServerRepos() {
try {
setStatus(els.tokenStatus, "正在接入服务器旧仓库...");
const payload = await apiFetch("/api/admin/svn/server-repositories/sync", { method: "POST", json: {} });
repositories = payload.repositories || [];
serverRepositories = payload.discovered || [];
if (!selectedRepoId || !repositories.some((repo) => repo.id === selectedRepoId)) {
selectedRepoId = repositories[0]?.id || "";
}
renderRepos();
renderServerRepos();
setStatus(els.tokenStatus, `接入完成:新增 ${payload.created || 0} 个,更新 ${payload.updated || 0} 个。`, "ok");
} catch (error) {
setStatus(els.tokenStatus, `接入失败:${error.message}`, "error");
}
}
function actionPayload() {
const payload = formToObject(els.actionForm);
payload.value = payload.pattern;
return payload;
}
function appendOutput(text, type = "") {
const prefix = type ? `[${type}] ` : "";
els.output.textContent = `${els.output.textContent}${prefix}${text || ""}${text?.endsWith("\n") ? "" : "\n"}`;
els.output.scrollTop = els.output.scrollHeight;
}
async function runAction(action) {
const repo = selectedRepo();
if (!repo) {
appendOutput("请先选择一个 SVN 仓库。", "error");
return;
}
const label = ACTION_LABELS[action] || action;
const payload = actionPayload();
els.lastAction.textContent = `正在执行 ${label}...`;
els.lastCommand.textContent = "";
appendOutput(`\n# ${repo.name} > ${label}`);
try {
const data = await apiFetch(`/api/admin/svn/repositories/${encodeURIComponent(repo.id)}/actions/${encodeURIComponent(action)}`, {
method: "POST",
json: payload,
});
els.lastAction.textContent = `${label} ${data.ok ? "完成" : "失败"}`;
els.lastCommand.textContent = data.result?.command || "";
if (data.result?.stdout) appendOutput(data.result.stdout, "stdout");
if (data.result?.stderr) appendOutput(data.result.stderr, data.ok ? "stderr" : "error");
appendOutput(`退出码:${data.result?.code},耗时:${data.result?.durationMs || 0}ms`, data.ok ? "ok" : "error");
await loadRepos();
} catch (error) {
els.lastAction.textContent = `${label} 失败`;
const result = error.payload?.result;
if (result?.command) els.lastCommand.textContent = result.command;
if (result?.stdout) appendOutput(result.stdout, "stdout");
if (result?.stderr) appendOutput(result.stderr, "error");
appendOutput(error.message, "error");
}
}
async function checkHealth() {
try {
setStatus(els.tokenStatus, "正在检测 svn 命令...");
const payload = await apiFetch("/api/admin/svn/health");
setStatus(els.tokenStatus, payload.ok ? `SVN CLI 可用:${payload.version || "unknown"},工作副本:${payload.svnRoot},服务地址:${payload.publicBaseUrl || "-"}` : payload.stderr, payload.ok ? "ok" : "error");
} catch (error) {
setStatus(els.tokenStatus, `检测失败:${error.message}`, "error");
}
}
els.token.value = getToken();
els.saveToken.addEventListener("click", () => {
localStorage.setItem("sumiSoftwareAdminToken", els.token.value.trim());
setStatus(els.tokenStatus, "令牌已保存。", "ok");
loadRepos();
});
els.health.addEventListener("click", checkHealth);
els.reloadRepos.addEventListener("click", loadRepos);
els.syncServerRepos.addEventListener("click", syncServerRepos);
els.repoSearch.addEventListener("input", () => {
repoFilter = els.repoSearch.value;
renderRepos();
});
els.repoForm.addEventListener("submit", saveRepo);
els.repoReset.addEventListener("click", resetRepoForm);
els.deleteRepo.addEventListener("click", () => {
const repo = selectedRepo();
if (repo) deleteRepo(repo);
});
els.clearOutput.addEventListener("click", () => {
els.output.textContent = "";
els.lastAction.textContent = "等待操作";
els.lastCommand.textContent = "";
});
els.repoList.addEventListener("click", (event) => {
const item = event.target.closest(".svn-repo-item");
if (!item) return;
selectedRepoId = item.dataset.id;
renderRepos();
const repo = selectedRepo();
if (repo) fillRepoForm(repo);
});
els.serverRepoList.addEventListener("click", (event) => {
const item = event.target.closest(".svn-server-repo");
if (!item) return;
const configuredId = item.dataset.configuredId;
if (configuredId) {
selectedRepoId = configuredId;
renderRepos();
const repo = selectedRepo();
if (repo) fillRepoForm(repo);
return;
}
const repo = serverRepositories.find((entry) => entry.id === item.dataset.id);
if (repo) fillRepoFormFromServer(repo);
});
els.repoList.addEventListener("contextmenu", (event) => {
const item = event.target.closest(".svn-repo-item");
if (!item) return;
event.preventDefault();
const repo = repositories.find((entry) => entry.id === item.dataset.id);
if (repo) deleteRepo(repo);
});
els.tabs.forEach((tab) => {
tab.addEventListener("click", () => {
els.tabs.forEach((item) => item.classList.toggle("is-active", item === tab));
els.panels.forEach((panel) => panel.classList.toggle("is-active", panel.dataset.panel === tab.dataset.tab));
});
});
els.actionButtons.forEach((button) => {
const action = button.dataset.action;
if (!action) return;
button.addEventListener("click", () => runAction(action));
});
if (getToken()) {
loadRepos();
} else {
renderRepos();
setStatus(els.tokenStatus, "请输入服务端 ADMIN_TOKEN 后读取 SVN 仓库。");
}