Add SVN management console

This commit is contained in:
lwt
2026-06-18 10:11:25 +08:00
parent 539729ae00
commit 771635b372
10 changed files with 1336 additions and 3 deletions
+182 -1
View File
@@ -893,6 +893,178 @@ main,
justify-content: flex-end;
}
.svn-shell {
display: grid;
gap: 18px;
}
.svn-console {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 18px;
}
.svn-sidebar,
.svn-form-panel,
.svn-workbench {
min-width: 0;
}
.section-title-row.compact {
margin-bottom: 14px;
}
.svn-repo-list {
display: grid;
gap: 8px;
}
.svn-repo-item {
display: grid;
width: 100%;
min-height: 86px;
gap: 5px;
padding: 12px;
color: inherit;
text-align: left;
background: #fbfcff;
border: 1px solid var(--line);
border-radius: 8px;
}
.svn-repo-item:hover,
.svn-repo-item:focus-visible,
.svn-repo-item.is-active {
background: #edf5ff;
border-color: rgba(32, 73, 238, 0.28);
outline: 0;
}
.repo-name {
color: #17171a;
font-size: 15px;
font-weight: 800;
}
.repo-url,
.repo-meta {
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.svn-action-row,
.svn-output-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.svn-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.svn-tab {
min-height: 34px;
padding: 0 14px;
color: #394150;
background: #f5f7fb;
border: 1px solid transparent;
border-radius: 4px;
font-weight: 700;
}
.svn-tab:hover,
.svn-tab:focus-visible,
.svn-tab.is-active {
color: var(--blue);
background: rgba(32, 73, 238, 0.08);
border-color: rgba(32, 73, 238, 0.18);
}
.svn-tab-panel {
display: none;
}
.svn-tab-panel.is-active {
display: block;
}
.svn-command-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
margin-bottom: 18px;
}
.svn-command-grid button {
min-height: 38px;
color: #1f2a44;
background: #f8fbff;
border: 1px solid rgba(32, 73, 238, 0.14);
border-radius: 6px;
font-weight: 800;
}
.svn-command-grid button:hover,
.svn-command-grid button:focus-visible {
color: #fff;
background: var(--blue);
}
.svn-action-form {
padding: 16px;
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.svn-output-toolbar {
justify-content: space-between;
margin-top: 18px;
padding: 12px 0;
border-top: 1px solid var(--line);
}
.svn-output-toolbar strong {
display: block;
color: #111827;
font-size: 14px;
}
.svn-output-toolbar span {
display: block;
max-width: min(760px, 70vw);
overflow: hidden;
color: var(--muted);
font-family: Consolas, "SFMono-Regular", ui-monospace, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.svn-output {
min-height: 320px;
max-height: 540px;
margin: 0;
overflow: auto;
padding: 16px;
color: #d7e2ff;
background: #111827;
border-radius: 8px;
font-family: Consolas, "SFMono-Regular", ui-monospace, monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
}
.status-message {
min-height: 21px;
margin-top: 12px;
@@ -938,10 +1110,15 @@ main,
.software-grid,
.topic-grid,
.admin-layout,
.login-panel {
.login-panel,
.svn-console {
grid-template-columns: 1fr;
}
.svn-command-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.download-hero::before,
.download-hero::after {
opacity: 0.28;
@@ -1009,4 +1186,8 @@ main,
gap: 6px;
text-align: center;
}
.svn-command-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+333
View File
@@ -0,0 +1,333 @@
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"),
repoList: document.querySelector("#repo-list"),
repoSummary: document.querySelector("#repo-summary"),
selectedRepo: document.querySelector("#selected-repo"),
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 selectedRepoId = "";
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 });
}
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() {
els.repoSummary.textContent = `${repositories.length} 个仓库`;
els.repoList.innerHTML = repositories.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.localPath || repo.checkoutPath || repo.id)} · ${repo.hasPassword ? "已配置凭据" : "无密码"} · ${formatDate(repo.updatedAt)}</span>
</button>`).join("") || `<p class="empty-state">暂无 SVN 仓库,先新增一个配置。</p>`;
const repo = selectedRepo();
els.selectedRepo.textContent = repo
? `${repo.name} · ${repo.url} · ${repo.localPath || repo.checkoutPath}`
: "请选择一个 SVN 仓库。";
}
async function loadRepos() {
try {
setStatus(els.tokenStatus, "正在读取 SVN 仓库...");
const payload = await apiFetch("/api/admin/svn/repositories");
repositories = payload.repositories || [];
if (!selectedRepoId || !repositories.some((repo) => repo.id === selectedRepoId)) {
selectedRepoId = repositories[0]?.id || "";
}
renderRepos();
setStatus(els.tokenStatus, `SVN 根目录:${payload.svnRoot || "-"}`, "ok");
} catch (error) {
repositories = [];
selectedRepoId = "";
renderRepos();
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 = "保存仓库";
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 = "更新仓库";
setStatus(els.repoFormStatus, repo.hasPassword ? "已载入仓库,密码留空会保持原密码。" : "已载入仓库。", "ok");
window.scrollTo({ top: 0, behavior: "smooth" });
}
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 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.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.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.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 仓库。");
}