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, ">") .replace(/"/g, """) .replace(/'/g, "'"); } 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) => ``).join("") || `
${repositories.length ? "没有匹配的仓库。" : "暂无 SVN 仓库,可以先点击“接入旧仓库”。"}
`; 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 ``; }).join("") || `没有扫描到服务器 SVN 仓库。
`; } 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 仓库。"); }