436 lines
17 KiB
JavaScript
436 lines
17 KiB
JavaScript
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, """)
|
||
.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) => `<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 仓库。");
|
||
}
|