Connect server SVN repositories

This commit is contained in:
lwt
2026-06-18 14:35:58 +08:00
parent 72153127b6
commit 7650fd5bfb
6 changed files with 831 additions and 210 deletions
+247 -6
View File
@@ -915,6 +915,46 @@ main,
gap: 18px;
}
.svn-github-shell {
width: min(1280px, calc(100% - 32px));
}
.svn-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 520px);
gap: 24px;
align-items: center;
}
.svn-hero-copy h1 {
margin-bottom: 10px;
color: #0f172a;
font-size: clamp(34px, 4vw, 48px);
line-height: 1.08;
letter-spacing: 0;
}
.svn-hero-controls {
display: grid;
gap: 12px;
}
.svn-toolbar-actions,
.svn-header-actions,
.svn-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.svn-github-layout {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.svn-console {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
@@ -923,14 +963,44 @@ main,
.svn-sidebar,
.svn-form-panel,
.svn-workbench {
.svn-workbench,
.svn-repository-view {
min-width: 0;
}
.svn-sidebar {
position: sticky;
top: 106px;
display: grid;
max-height: calc(100svh - 128px);
overflow: auto;
gap: 16px;
}
.svn-sidebar-title {
margin-bottom: 0;
}
.section-title-row.compact {
margin-bottom: 14px;
}
.svn-search input {
width: 100%;
min-height: 42px;
padding: 0 12px;
color: var(--text);
background: #f6f8fb;
border: 1px solid var(--line);
border-radius: 6px;
outline: 0;
}
.svn-search input:focus {
border-color: rgba(32, 73, 238, 0.28);
box-shadow: 0 0 0 3px rgba(32, 73, 238, 0.08);
}
.svn-repo-list {
display: grid;
gap: 8px;
@@ -939,9 +1009,9 @@ main,
.svn-repo-item {
display: grid;
width: 100%;
min-height: 86px;
min-height: 92px;
gap: 5px;
padding: 12px;
padding: 14px;
color: inherit;
text-align: left;
background: #fbfcff;
@@ -972,6 +1042,152 @@ main,
white-space: nowrap;
}
.svn-server-box,
.svn-config-details {
padding-top: 14px;
border-top: 1px solid var(--line);
}
.svn-server-box h3 {
margin: 0;
color: #111827;
font-size: 16px;
}
.svn-server-list {
display: grid;
gap: 8px;
}
.svn-server-repo {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
color: inherit;
text-align: left;
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
}
.svn-server-repo:hover,
.svn-server-repo:focus-visible {
border-color: rgba(32, 73, 238, 0.24);
outline: 0;
}
.svn-server-repo.is-linked {
background: #f7fbff;
}
.svn-server-repo strong,
.svn-server-repo small,
.svn-server-repo em {
display: block;
}
.svn-server-repo strong {
overflow: hidden;
color: #172033;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.svn-server-repo small {
overflow: hidden;
margin-top: 3px;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.svn-server-repo em {
color: var(--blue);
font-size: 11px;
font-style: normal;
font-weight: 800;
white-space: nowrap;
}
.svn-config-details summary {
display: flex;
min-height: 38px;
align-items: center;
justify-content: space-between;
color: #111827;
cursor: pointer;
font-weight: 850;
}
.svn-config-details form {
margin-top: 12px;
}
.svn-config-details .field-grid {
grid-template-columns: 1fr;
}
.svn-repo-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: start;
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}
.svn-repo-header h1 {
margin: 0 0 8px;
color: #0f172a;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.12;
letter-spacing: 0;
}
.svn-repo-meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 18px 0;
}
.svn-repo-meta-grid > div {
min-width: 0;
padding: 12px;
background: #f8fafc;
border: 1px solid var(--line);
border-radius: 8px;
}
.svn-repo-meta-grid span,
.svn-repo-meta-grid strong {
display: block;
}
.svn-repo-meta-grid span {
margin-bottom: 5px;
color: var(--muted);
font-size: 12px;
}
.svn-repo-meta-grid strong {
overflow: hidden;
color: #182235;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.svn-quick-actions {
justify-content: flex-start;
margin-bottom: 18px;
}
.svn-action-row,
.svn-output-toolbar {
display: flex;
@@ -1016,7 +1232,7 @@ main,
.svn-command-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 18px;
}
@@ -1128,10 +1344,21 @@ main,
.topic-grid,
.admin-layout,
.login-panel,
.svn-console {
.svn-console,
.svn-hero,
.svn-github-layout {
grid-template-columns: 1fr;
}
.svn-sidebar {
position: static;
max-height: none;
}
.svn-repo-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.svn-command-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -1193,10 +1420,24 @@ main,
}
.field-grid,
.token-row {
.token-row,
.svn-repo-header,
.svn-repo-meta-grid {
grid-template-columns: 1fr;
}
.svn-toolbar-actions,
.svn-header-actions,
.svn-quick-actions {
justify-content: stretch;
}
.svn-toolbar-actions button,
.svn-header-actions button,
.svn-quick-actions button {
width: 100%;
}
.soft-footer {
flex-direction: column;
align-items: center;
+107 -10
View File
@@ -10,9 +10,19 @@ const els = {
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"),
@@ -24,7 +34,9 @@ const els = {
};
let repositories = [];
let serverRepositories = [];
let selectedRepoId = "";
let repoFilter = "";
const ACTION_LABELS = {
checkout: "Checkout",
@@ -87,6 +99,12 @@ function formatDate(value) {
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 = {
@@ -125,33 +143,65 @@ function formToObject(form) {
}
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)}">
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.localPath || repo.checkoutPath || repo.id)} · ${repo.hasPassword ? "已配置凭据" : "无密码"} · ${formatDate(repo.updatedAt)}</span>
</button>`).join("") || `<p class="empty-state">暂无 SVN 仓库,先新增一个配置。</p>`;
<span class="repo-meta">${escapeHtml(repo.source === "server" ? "服务器仓库" : "手动配置")} · ${escapeHtml(repo.localPath || repo.checkoutPath || repo.id)} · ${repo.hasPassword ? "已配置凭据" : "无密码"}</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.name} · ${repo.url} · ${repo.localPath || repo.checkoutPath}`
: "请选择一个 SVN 仓库。";
? (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" ? "服务器旧仓库" : "手动配置") : "--";
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) => `<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)}` : ""}</em>
</button>`).join("") || `<p class="empty-state">没有扫描到服务器 SVN 仓库。</p>`;
}
async function loadRepos() {
try {
setStatus(els.tokenStatus, "正在读取 SVN 仓库...");
const payload = await apiFetch("/api/admin/svn/repositories");
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();
setStatus(els.tokenStatus, `SVN 根目录:${payload.svnRoot || "-"}`, "ok");
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");
}
}
@@ -166,6 +216,7 @@ function resetRepoForm() {
els.repoForm.elements.noAuthCache.checked = true;
els.repoFormTitle.textContent = "新增仓库";
els.repoSubmit.textContent = "保存仓库";
if (els.repoConfigDetails) els.repoConfigDetails.open = true;
setStatus(els.repoFormStatus, "");
}
@@ -184,8 +235,8 @@ function fillRepoForm(repo) {
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");
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function saveRepo(event) {
@@ -219,6 +270,32 @@ async function deleteRepo(repo) {
}
}
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;
@@ -268,7 +345,7 @@ 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");
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");
}
@@ -283,6 +360,11 @@ els.saveToken.addEventListener("click", () => {
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", () => {
@@ -304,6 +386,21 @@ els.repoList.addEventListener("click", (event) => {
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;