Connect server SVN repositories
This commit is contained in:
@@ -66,6 +66,8 @@ export SUMI_STORAGE_DIR="/var/lib/sumi-work"
|
||||
export SUMI_DATA_DIR="/var/lib/sumi-work/data"
|
||||
export SUMI_UPLOAD_DIR="/var/lib/sumi-work/uploads"
|
||||
export SUMI_SVN_DIR="/var/lib/sumi-work/svn"
|
||||
export SUMI_SVN_REPO_ROOTS="/data/tools/svn_server"
|
||||
export SUMI_SVN_PUBLIC_BASE_URL="svn://服务器IP"
|
||||
export MAX_UPLOAD_BYTES=1073741824
|
||||
export PLAYER_SESSION_TTL_MS=604800000
|
||||
```
|
||||
@@ -80,6 +82,8 @@ export PLAYER_SESSION_TTL_MS=604800000
|
||||
- `SUMI_DATA_DIR`:软件列表和玩家账号数据目录;设置后会覆盖 `SUMI_STORAGE_DIR/data`
|
||||
- `SUMI_UPLOAD_DIR`:上传图标、安装包和 version.json 目录;设置后会覆盖 `SUMI_STORAGE_DIR/uploads`
|
||||
- `SUMI_SVN_DIR`:SVN 工作副本和导出目录;设置后会覆盖 `SUMI_STORAGE_DIR/svn`
|
||||
- `SUMI_SVN_REPO_ROOTS`:服务器已安装 SVN 服务的仓库根目录,多个目录用英文分号或逗号分隔,例如 `/data/tools/svn_server`
|
||||
- `SUMI_SVN_PUBLIC_BASE_URL`:旧 SVN 服务对外访问地址,例如 `svn://43.136.76.224`;管理后台会用它生成旧仓库 URL
|
||||
- `MAX_UPLOAD_BYTES`:最大上传文件大小,默认 1GB
|
||||
- `PLAYER_SESSION_TTL_MS`:玩家登录有效期,默认 7 天
|
||||
|
||||
@@ -455,6 +459,8 @@ SVN 管理不可用:
|
||||
|
||||
- 检查服务器是否安装 `svn`:`svn --version --quiet`
|
||||
- 检查 `SUMI_SVN_DIR` 或 `/var/lib/sumi-work/svn/` 是否可写
|
||||
- 如果要接入服务器已有仓库,确认 `SUMI_SVN_REPO_ROOTS` 指向 `svnserve -r` 的根目录
|
||||
- 确认 `SUMI_SVN_PUBLIC_BASE_URL` 是玩家/管理员能访问到的 SVN 地址
|
||||
- 检查仓库 URL、账号、密码和证书信任设置
|
||||
- 写操作需要玩家登录并提供正确的 `ADMIN_TOKEN`
|
||||
|
||||
|
||||
+237
-193
@@ -6,13 +6,13 @@
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="theme-color" content="#edf5ff" />
|
||||
<title>sumi.work - SVN 管理</title>
|
||||
<link rel="stylesheet" href="../assets/css/software.css?v=20260618-1" />
|
||||
<link rel="stylesheet" href="../assets/css/software.css?v=20260618-3" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="soft-topbar">
|
||||
<div class="soft-topbar-inner">
|
||||
<a class="soft-brand" href="../software.html" aria-label="返回软件中心">
|
||||
<img src="../assets/icons/software.svg" alt="" aria-hidden="true" />
|
||||
<a class="soft-brand" href="svn.html" aria-label="返回 SVN 管理">
|
||||
<img src="../assets/icons/svn.svg" alt="" aria-hidden="true" />
|
||||
<span>SVN 管理</span>
|
||||
</a>
|
||||
<nav class="soft-nav" aria-label="SVN 管理导航">
|
||||
@@ -24,235 +24,279 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-shell svn-shell">
|
||||
<section class="admin-panel" aria-labelledby="svn-token-title">
|
||||
<div class="section-title-row">
|
||||
<main class="admin-shell svn-shell svn-github-shell">
|
||||
<section class="admin-panel svn-hero" aria-labelledby="svn-token-title">
|
||||
<div class="svn-hero-copy">
|
||||
<p class="hero-kicker">SVN Repositories</p>
|
||||
<div>
|
||||
<h1 id="svn-token-title">SVN 控制台</h1>
|
||||
<p class="admin-note">集中管理 SVN 仓库、工作副本、提交、更新、分支标签、合并、锁和属性。</p>
|
||||
<p class="admin-note">接入服务器现有 SVN 服务和旧仓库,在一个清晰的仓库视图里完成更新、提交、分支、标签、合并和维护操作。</p>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="svn-health">检测 SVN</button>
|
||||
</div>
|
||||
<div class="token-row">
|
||||
<input id="admin-token" type="password" autocomplete="current-password" placeholder="输入 ADMIN_TOKEN" />
|
||||
<button class="primary-action" type="button" id="save-token">保存令牌</button>
|
||||
<div class="svn-hero-controls">
|
||||
<div class="token-row">
|
||||
<input id="admin-token" type="password" autocomplete="current-password" placeholder="输入 ADMIN_TOKEN" />
|
||||
<button class="primary-action" type="button" id="save-token">保存令牌</button>
|
||||
</div>
|
||||
<div class="svn-toolbar-actions">
|
||||
<button class="text-button" type="button" id="svn-health">检测 SVN</button>
|
||||
<button class="text-button" type="button" id="reload-repos">刷新仓库</button>
|
||||
<button class="secondary-action" type="button" id="sync-server-repos">接入旧仓库</button>
|
||||
</div>
|
||||
<p class="status-message" id="token-status"></p>
|
||||
</div>
|
||||
<p class="status-message" id="token-status"></p>
|
||||
</section>
|
||||
|
||||
<div class="svn-console">
|
||||
<div class="svn-github-layout">
|
||||
<aside class="admin-panel svn-sidebar" aria-labelledby="repo-title">
|
||||
<div class="section-title-row compact">
|
||||
<div class="section-title-row compact svn-sidebar-title">
|
||||
<div>
|
||||
<h2 id="repo-title">仓库</h2>
|
||||
<p id="repo-summary">正在加载...</p>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="reload-repos">刷新</button>
|
||||
</div>
|
||||
<div class="svn-search">
|
||||
<input id="repo-search" type="search" autocomplete="off" placeholder="搜索仓库、URL 或路径" />
|
||||
</div>
|
||||
<div class="svn-repo-list" id="repo-list"></div>
|
||||
<div class="form-actions">
|
||||
<button class="danger-button" type="button" id="delete-repo">删除选中配置</button>
|
||||
</div>
|
||||
|
||||
<section class="svn-server-box" aria-labelledby="server-repo-title">
|
||||
<div class="section-title-row compact">
|
||||
<div>
|
||||
<h3 id="server-repo-title">服务器 SVN 服务</h3>
|
||||
<p id="server-summary">等待扫描...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="svn-server-list" id="server-repo-list"></div>
|
||||
</section>
|
||||
|
||||
<details class="svn-config-details" id="repo-config-details">
|
||||
<summary><span id="repo-form-title">新增仓库配置</span></summary>
|
||||
<form id="repo-form">
|
||||
<input type="hidden" name="id" />
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="repo-name">仓库名称</label>
|
||||
<input id="repo-name" name="name" required maxlength="120" placeholder="game-server" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="checkout-path">工作副本目录</label>
|
||||
<input id="checkout-path" name="checkoutPath" maxlength="200" placeholder="game-server" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="repo-url">仓库 URL</label>
|
||||
<input id="repo-url" name="url" required maxlength="1000" placeholder="svn://服务器IP/project" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="repo-username">用户名</label>
|
||||
<input id="repo-username" name="username" autocomplete="username" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="repo-password">密码</label>
|
||||
<input id="repo-password" name="password" type="password" autocomplete="current-password" placeholder="留空保持原密码" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="trunk-path">Trunk</label>
|
||||
<input id="trunk-path" name="trunkPath" value="trunk" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="branches-path">Branches</label>
|
||||
<input id="branches-path" name="branchesPath" value="branches" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tags-path">Tags</label>
|
||||
<input id="tags-path" name="tagsPath" value="tags" maxlength="200" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="repo-description">备注</label>
|
||||
<textarea id="repo-description" name="description" maxlength="500"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox-field"><input name="trustServerCert" type="checkbox" checked /> 信任证书</label>
|
||||
<label class="checkbox-field"><input name="noAuthCache" type="checkbox" checked /> 不缓存凭据</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="primary-action" type="submit" id="repo-submit">保存</button>
|
||||
<button class="secondary-action" type="button" id="repo-reset">清空</button>
|
||||
</div>
|
||||
<p class="status-message" id="repo-form-status"></p>
|
||||
</form>
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
<section class="admin-panel svn-form-panel" aria-labelledby="repo-form-title">
|
||||
<h2 id="repo-form-title">新增仓库</h2>
|
||||
<form id="repo-form">
|
||||
<input type="hidden" name="id" />
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="repo-name">仓库名称</label>
|
||||
<input id="repo-name" name="name" required maxlength="120" placeholder="game-server" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="checkout-path">本地目录</label>
|
||||
<input id="checkout-path" name="checkoutPath" maxlength="200" placeholder="game-server" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="repo-url">仓库 URL</label>
|
||||
<input id="repo-url" name="url" required maxlength="1000" placeholder="https://svn.example.com/project" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="repo-username">用户名</label>
|
||||
<input id="repo-username" name="username" autocomplete="username" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="repo-password">密码</label>
|
||||
<input id="repo-password" name="password" type="password" autocomplete="current-password" placeholder="留空则保持原密码" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="trunk-path">Trunk 目录</label>
|
||||
<input id="trunk-path" name="trunkPath" value="trunk" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="branches-path">Branches 目录</label>
|
||||
<input id="branches-path" name="branchesPath" value="branches" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tags-path">Tags 目录</label>
|
||||
<input id="tags-path" name="tagsPath" value="tags" maxlength="200" />
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="repo-description">备注</label>
|
||||
<textarea id="repo-description" name="description" maxlength="500"></textarea>
|
||||
</div>
|
||||
<section class="admin-panel svn-repository-view" aria-labelledby="selected-repo-name">
|
||||
<div class="svn-repo-header">
|
||||
<div>
|
||||
<p class="hero-kicker">Repository</p>
|
||||
<h1 id="selected-repo-name">选择仓库</h1>
|
||||
<p id="selected-repo">请选择一个 SVN 仓库。</p>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox-field"><input name="trustServerCert" type="checkbox" checked /> 自动信任服务端证书</label>
|
||||
<label class="checkbox-field"><input name="noAuthCache" type="checkbox" checked /> 不缓存 SVN 凭据</label>
|
||||
<div class="svn-header-actions">
|
||||
<button class="danger-button" type="button" id="delete-repo">删除配置</button>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="primary-action" type="submit" id="repo-submit">保存仓库</button>
|
||||
<button class="secondary-action" type="button" id="repo-reset">清空</button>
|
||||
</div>
|
||||
<p class="status-message" id="repo-form-status"></p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="admin-panel svn-workbench" aria-labelledby="workbench-title">
|
||||
<div class="section-title-row">
|
||||
<div>
|
||||
<h2 id="workbench-title">工作副本</h2>
|
||||
<p id="selected-repo">请选择一个 SVN 仓库。</p>
|
||||
</div>
|
||||
<div class="svn-action-row">
|
||||
|
||||
<div class="svn-repo-meta-grid">
|
||||
<div>
|
||||
<span>SVN URL</span>
|
||||
<strong id="selected-repo-url">--</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>工作副本</span>
|
||||
<strong id="selected-repo-path">--</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>服务端版本</span>
|
||||
<strong id="selected-repo-revision">--</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>来源</span>
|
||||
<strong id="selected-repo-source">--</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-quick-actions" aria-label="常用 SVN 操作">
|
||||
<button class="primary-action" type="button" data-action="checkout">Checkout</button>
|
||||
<button class="text-button" type="button" data-action="update">Update</button>
|
||||
<button class="text-button" type="button" data-action="info">Info</button>
|
||||
<button class="text-button" type="button" data-action="status">Status</button>
|
||||
<button class="text-button" type="button" data-action="log">Log</button>
|
||||
<button class="text-button" type="button" data-action="diff">Diff</button>
|
||||
<button class="text-button" type="button" data-action="list">Files</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-tabs" role="tablist" aria-label="SVN 操作分类">
|
||||
<button class="svn-tab is-active" type="button" data-tab="daily">日常操作</button>
|
||||
<button class="svn-tab" type="button" data-tab="history">历史差异</button>
|
||||
<button class="svn-tab" type="button" data-tab="branch">分支标签</button>
|
||||
<button class="svn-tab" type="button" data-tab="advanced">高级维护</button>
|
||||
</div>
|
||||
<div class="svn-tabs" role="tablist" aria-label="SVN 操作分类">
|
||||
<button class="svn-tab is-active" type="button" data-tab="daily">日常</button>
|
||||
<button class="svn-tab" type="button" data-tab="history">历史与文件</button>
|
||||
<button class="svn-tab" type="button" data-tab="branch">分支标签</button>
|
||||
<button class="svn-tab" type="button" data-tab="advanced">维护</button>
|
||||
</div>
|
||||
|
||||
<div class="svn-tab-panel is-active" data-panel="daily">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="checkout">Checkout</button>
|
||||
<button type="button" data-action="update">Update</button>
|
||||
<button type="button" data-action="cleanup">Cleanup</button>
|
||||
<button type="button" data-action="revert">Revert</button>
|
||||
<button type="button" data-action="resolve">Resolve</button>
|
||||
<button type="button" data-action="commit">Commit</button>
|
||||
<button type="button" data-action="add">Add</button>
|
||||
<button type="button" data-action="delete">Delete</button>
|
||||
<button type="button" data-action="lock">Lock</button>
|
||||
<button type="button" data-action="unlock">Unlock</button>
|
||||
<div class="svn-tab-panel is-active" data-panel="daily">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="cleanup">Cleanup</button>
|
||||
<button type="button" data-action="revert">Revert</button>
|
||||
<button type="button" data-action="resolve">Resolve</button>
|
||||
<button type="button" data-action="commit">Commit</button>
|
||||
<button type="button" data-action="add">Add</button>
|
||||
<button type="button" data-action="delete">Delete</button>
|
||||
<button type="button" data-action="lock">Lock</button>
|
||||
<button type="button" data-action="unlock">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-tab-panel" data-panel="history">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="list">List</button>
|
||||
<button type="button" data-action="blame">Blame</button>
|
||||
<button type="button" data-action="propget">Propget</button>
|
||||
<button type="button" data-action="propset">Propset</button>
|
||||
<button type="button" data-action="ignore">Ignore</button>
|
||||
<button type="button" data-action="export">Export</button>
|
||||
<div class="svn-tab-panel" data-panel="history">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="blame">Blame</button>
|
||||
<button type="button" data-action="propget">Propget</button>
|
||||
<button type="button" data-action="propset">Propset</button>
|
||||
<button type="button" data-action="ignore">Ignore</button>
|
||||
<button type="button" data-action="export">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-tab-panel" data-panel="branch">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="branch">Create Branch</button>
|
||||
<button type="button" data-action="tag">Create Tag</button>
|
||||
<button type="button" data-action="switch">Switch</button>
|
||||
<button type="button" data-action="merge">Merge</button>
|
||||
<button type="button" data-action="copy">Copy</button>
|
||||
<button type="button" data-action="move">Move</button>
|
||||
<div class="svn-tab-panel" data-panel="branch">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="branch">Create Branch</button>
|
||||
<button type="button" data-action="tag">Create Tag</button>
|
||||
<button type="button" data-action="switch">Switch</button>
|
||||
<button type="button" data-action="merge">Merge</button>
|
||||
<button type="button" data-action="copy">Copy</button>
|
||||
<button type="button" data-action="move">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="svn-tab-panel" data-panel="advanced">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="mkdir">Mkdir</button>
|
||||
<button type="button" data-action="relocate">Relocate</button>
|
||||
<div class="svn-tab-panel" data-panel="advanced">
|
||||
<div class="svn-command-grid">
|
||||
<button type="button" data-action="mkdir">Mkdir</button>
|
||||
<button type="button" data-action="relocate">Relocate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="svn-action-form" id="action-form">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="action-target">目标路径</label>
|
||||
<input id="action-target" name="target" placeholder="相对工作副本路径,默认 ." />
|
||||
<form class="svn-action-form" id="action-form">
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label for="action-target">路径</label>
|
||||
<input id="action-target" name="target" placeholder="相对工作副本路径,默认 ." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-revision">版本</label>
|
||||
<input id="action-revision" name="revision" placeholder="123 或 100:HEAD" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-source">来源</label>
|
||||
<input id="action-source" name="source" placeholder="URL 或相对路径" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-destination">目标/导出目录</label>
|
||||
<input id="action-destination" name="destination" placeholder="URL、相对路径或导出目录名" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-url">切换/迁移 URL</label>
|
||||
<input id="action-url" name="url" placeholder="switch 或 relocate 目标 URL" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-name">分支/标签名</label>
|
||||
<input id="action-name" name="name" placeholder="release-1.0.0" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-property">属性名</label>
|
||||
<input id="action-property" name="property" placeholder="svn:ignore" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-pattern">忽略规则/属性值</label>
|
||||
<input id="action-pattern" name="pattern" placeholder="*.log 或 dist" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-limit">日志数量</label>
|
||||
<input id="action-limit" name="limit" inputmode="numeric" placeholder="30" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-depth">Depth</label>
|
||||
<select id="action-depth" name="depth">
|
||||
<option value="">默认</option>
|
||||
<option value="empty">empty</option>
|
||||
<option value="files">files</option>
|
||||
<option value="immediates">immediates</option>
|
||||
<option value="infinity">infinity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-accept">冲突策略</label>
|
||||
<select id="action-accept" name="accept">
|
||||
<option value="working">working</option>
|
||||
<option value="mine-conflict">mine-conflict</option>
|
||||
<option value="theirs-conflict">theirs-conflict</option>
|
||||
<option value="base">base</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="action-message">提交说明</label>
|
||||
<textarea id="action-message" name="message" placeholder="commit、branch、tag、lock 等操作需要提交说明"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-revision">版本范围</label>
|
||||
<input id="action-revision" name="revision" placeholder="123 或 100:HEAD" />
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox-field"><input name="recursive" type="checkbox" checked /> 递归</label>
|
||||
<label class="checkbox-field"><input name="dryRun" type="checkbox" /> Merge dry-run</label>
|
||||
<label class="checkbox-field"><input name="force" type="checkbox" checked /> 强制/覆盖</label>
|
||||
<label class="checkbox-field"><input name="verbose" type="checkbox" /> Log verbose</label>
|
||||
<label class="checkbox-field"><input name="showUpdates" type="checkbox" /> Status show-updates</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-source">来源</label>
|
||||
<input id="action-source" name="source" placeholder="URL 或相对路径" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-destination">目标/导出目录</label>
|
||||
<input id="action-destination" name="destination" placeholder="URL、相对路径或导出目录名" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-url">切换/迁移 URL</label>
|
||||
<input id="action-url" name="url" placeholder="switch 或 relocate 目标 URL" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-name">分支/标签名</label>
|
||||
<input id="action-name" name="name" placeholder="release-1.0.0" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-property">属性名</label>
|
||||
<input id="action-property" name="property" placeholder="svn:ignore" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-pattern">忽略规则/属性值</label>
|
||||
<input id="action-pattern" name="pattern" placeholder="*.log 或 dist" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-depth">Depth</label>
|
||||
<select id="action-depth" name="depth">
|
||||
<option value="">默认</option>
|
||||
<option value="empty">empty</option>
|
||||
<option value="files">files</option>
|
||||
<option value="immediates">immediates</option>
|
||||
<option value="infinity">infinity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="action-accept">冲突接受策略</label>
|
||||
<select id="action-accept" name="accept">
|
||||
<option value="working">working</option>
|
||||
<option value="mine-conflict">mine-conflict</option>
|
||||
<option value="theirs-conflict">theirs-conflict</option>
|
||||
<option value="base">base</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="action-message">提交说明</label>
|
||||
<textarea id="action-message" name="message" placeholder="commit、branch、tag、lock 等操作需要提交说明"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox-field"><input name="recursive" type="checkbox" checked /> 递归</label>
|
||||
<label class="checkbox-field"><input name="dryRun" type="checkbox" /> Merge dry-run</label>
|
||||
<label class="checkbox-field"><input name="force" type="checkbox" checked /> 强制/覆盖</label>
|
||||
<label class="checkbox-field"><input name="verbose" type="checkbox" /> Log verbose</label>
|
||||
<label class="checkbox-field"><input name="showUpdates" type="checkbox" /> Status show-updates</label>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
<div class="svn-output-toolbar">
|
||||
<div>
|
||||
<strong id="last-action">等待操作</strong>
|
||||
<span id="last-command"></span>
|
||||
<div class="svn-output-toolbar">
|
||||
<div>
|
||||
<strong id="last-action">等待操作</strong>
|
||||
<span id="last-command"></span>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="clear-output">清空输出</button>
|
||||
</div>
|
||||
<button class="text-button" type="button" id="clear-output">清空输出</button>
|
||||
</div>
|
||||
<pre class="svn-output" id="svn-output" aria-live="polite"></pre>
|
||||
</section>
|
||||
<pre class="svn-output" id="svn-output" aria-live="polite"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="../assets/js/svn-admin.js?v=20260618-1"></script>
|
||||
<script src="../assets/js/svn-admin.js?v=20260618-3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+247
-6
@@ -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
@@ -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;
|
||||
|
||||
+6
-1
@@ -15,6 +15,8 @@ const storageRoot = process.env.SUMI_STORAGE_DIR ? path.resolve(process.env.SUMI
|
||||
const dataDir = path.resolve(process.env.SUMI_DATA_DIR || (storageRoot ? path.join(storageRoot, "data") : path.join(__dirname, "data")));
|
||||
const uploadDir = path.resolve(process.env.SUMI_UPLOAD_DIR || (storageRoot ? path.join(storageRoot, "uploads") : path.join(__dirname, "uploads")));
|
||||
const svnRoot = path.resolve(process.env.SUMI_SVN_DIR || (storageRoot ? path.join(storageRoot, "svn") : path.join(__dirname, "svn")));
|
||||
const svnRepositoryRoots = process.env.SUMI_SVN_REPO_ROOTS || process.env.SUMI_SVN_REPOSITORY_ROOTS || "";
|
||||
const svnPublicBaseUrl = process.env.SUMI_SVN_PUBLIC_BASE_URL || process.env.SUMI_SVN_BASE_URL || "";
|
||||
const packageDir = path.join(uploadDir, "packages");
|
||||
const iconDir = path.join(uploadDir, "icons");
|
||||
const manifestDir = path.join(uploadDir, "manifests");
|
||||
@@ -1150,7 +1152,7 @@ app.delete("/api/admin/software/:id", requirePlayer, requireAdmin, async (req, r
|
||||
}
|
||||
});
|
||||
|
||||
registerSvnRoutes(app, { svnRoot, svnDataFile, requirePlayer, requireAdmin });
|
||||
registerSvnRoutes(app, { svnRoot, svnDataFile, svnRepositoryRoots, svnPublicBaseUrl, requirePlayer, requireAdmin });
|
||||
|
||||
app.get("/software", (req, res) => res.redirect(301, "/software.html"));
|
||||
app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
|
||||
@@ -1178,6 +1180,9 @@ app.listen(port, () => {
|
||||
console.log(`data directory: ${dataDir}`);
|
||||
console.log(`upload directory: ${uploadDir}`);
|
||||
console.log(`svn directory: ${svnRoot}`);
|
||||
if (svnRepositoryRoots) {
|
||||
console.log(`svn repository roots: ${svnRepositoryRoots}`);
|
||||
}
|
||||
if (!process.env.ADMIN_TOKEN) {
|
||||
console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ import path from "node:path";
|
||||
const DEFAULT_TIMEOUT_MS = 120000;
|
||||
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
||||
const REMOTE_TARGET_PATTERN = /^(https?|svn|file):\/\//i;
|
||||
const DEFAULT_REPOSITORY_ROOTS = [
|
||||
"/data/tools/svn_server",
|
||||
"/var/svn",
|
||||
"/srv/svn",
|
||||
"/var/lib/svn",
|
||||
"/opt/svn",
|
||||
"/home/svn",
|
||||
];
|
||||
|
||||
function slugify(value) {
|
||||
const slug = String(value || "")
|
||||
@@ -34,6 +42,14 @@ function parseIntValue(value, fallback, min, max) {
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
function splitRootList(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
return String(value || "")
|
||||
.split(/[;\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function limitText(value, maxLength = 4000) {
|
||||
return String(value || "").trim().slice(0, maxLength);
|
||||
}
|
||||
@@ -114,6 +130,14 @@ async function runSvn(args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readTextIfExists(filePath) {
|
||||
try {
|
||||
return (await fsp.readFile(filePath, "utf8")).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function addAuthArgs(args, repo) {
|
||||
const next = [...args, "--non-interactive"];
|
||||
if (repo.trustServerCert !== false) {
|
||||
@@ -163,6 +187,146 @@ function publicRepo(repo) {
|
||||
return copy;
|
||||
}
|
||||
|
||||
function repositoryRootCandidates(options) {
|
||||
const roots = [...splitRootList(options.svnRepositoryRoots), ...DEFAULT_REPOSITORY_ROOTS]
|
||||
.map((item) => path.resolve(item))
|
||||
.filter((item, index, list) => list.indexOf(item) === index);
|
||||
return roots;
|
||||
}
|
||||
|
||||
async function isSvnRepositoryDir(dirPath) {
|
||||
try {
|
||||
const [formatStat, dbStat] = await Promise.all([
|
||||
fsp.stat(path.join(dirPath, "format")),
|
||||
fsp.stat(path.join(dirPath, "db")),
|
||||
]);
|
||||
return formatStat.isFile() && dbStat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverRepoDirs(rootPath, maxDepth = 2) {
|
||||
const found = [];
|
||||
|
||||
async function walk(dirPath, depth) {
|
||||
if (found.length >= 200) return;
|
||||
if (await isSvnRepositoryDir(dirPath)) {
|
||||
found.push(dirPath);
|
||||
return;
|
||||
}
|
||||
if (depth <= 0) return;
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
||||
await walk(path.join(dirPath, entry.name), depth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootPath, maxDepth);
|
||||
return found;
|
||||
}
|
||||
|
||||
function requestSvnBaseUrl(options, req) {
|
||||
const configured = String(options.svnPublicBaseUrl || "").trim().replace(/\/+$/g, "");
|
||||
if (configured) return configured;
|
||||
const host = req?.hostname || "localhost";
|
||||
return `svn://${host}`;
|
||||
}
|
||||
|
||||
function publicUrlForRepo(options, req, rootPath, repoPath) {
|
||||
const baseUrl = requestSvnBaseUrl(options, req);
|
||||
const relativePath = path.relative(rootPath, repoPath).split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
|
||||
return relativePath ? `${baseUrl}/${relativePath}` : baseUrl;
|
||||
}
|
||||
|
||||
async function discoveredRepoInfo(options, req, rootPath, repoPath) {
|
||||
const relativePath = path.relative(rootPath, repoPath).split(path.sep).join("/");
|
||||
const name = relativePath || path.basename(repoPath);
|
||||
const current = await readTextIfExists(path.join(repoPath, "db", "current"));
|
||||
const revision = current.split(/\s+/)[0] || "";
|
||||
return {
|
||||
id: slugify(name),
|
||||
name,
|
||||
url: publicUrlForRepo(options, req, rootPath, repoPath),
|
||||
rootPath,
|
||||
repositoryPath: repoPath,
|
||||
relativePath,
|
||||
revision,
|
||||
uuid: await readTextIfExists(path.join(repoPath, "db", "uuid")),
|
||||
fsType: await readTextIfExists(path.join(repoPath, "db", "fs-type")),
|
||||
format: await readTextIfExists(path.join(repoPath, "format")),
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverServerRepositories(options, req) {
|
||||
const roots = repositoryRootCandidates(options);
|
||||
const repositories = [];
|
||||
const rootStatus = [];
|
||||
|
||||
for (const rootPath of roots) {
|
||||
let exists = false;
|
||||
try {
|
||||
exists = (await fsp.stat(rootPath)).isDirectory();
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
if (!exists) {
|
||||
rootStatus.push({ rootPath, exists: false, repositoryCount: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const repoDirs = await discoverRepoDirs(rootPath);
|
||||
rootStatus.push({ rootPath, exists: true, repositoryCount: repoDirs.length });
|
||||
for (const repoPath of repoDirs) {
|
||||
repositories.push(await discoveredRepoInfo(options, req, rootPath, repoPath));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repositoryRoots: rootStatus,
|
||||
publicBaseUrl: requestSvnBaseUrl(options, req),
|
||||
repositories,
|
||||
};
|
||||
}
|
||||
|
||||
function markConfigured(discoveredRepos, configuredRepos) {
|
||||
return discoveredRepos.map((repo) => {
|
||||
const configured = configuredRepos.find((item) => (
|
||||
item.serverRepositoryPath === repo.repositoryPath
|
||||
|| item.url === repo.url
|
||||
|| item.id === repo.id
|
||||
|| item.name === repo.name
|
||||
));
|
||||
return {
|
||||
...repo,
|
||||
configured: Boolean(configured),
|
||||
configuredId: configured?.id || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function repoFromDiscovered(discovered, existing = {}) {
|
||||
return normalizeRepoPatch({
|
||||
...existing,
|
||||
name: discovered.name,
|
||||
url: discovered.url,
|
||||
checkoutPath: existing.checkoutPath || discovered.id,
|
||||
source: "server",
|
||||
serverRepositoryRoot: discovered.rootPath,
|
||||
serverRepositoryPath: discovered.repositoryPath,
|
||||
serverRelativePath: discovered.relativePath,
|
||||
serverRevision: discovered.revision,
|
||||
serverUuid: discovered.uuid,
|
||||
description: existing.description || `从服务器 SVN 服务接入:${discovered.repositoryPath}`,
|
||||
}, existing);
|
||||
}
|
||||
|
||||
function getRepoPath(options, repo) {
|
||||
const checkoutPath = String(repo.checkoutPath || repo.id || "").trim();
|
||||
const localPath = path.resolve(options.svnRoot, checkoutPath);
|
||||
@@ -228,6 +392,12 @@ function normalizeRepoPatch(body, existing = {}) {
|
||||
trustServerCert: parseBoolean(body.trustServerCert, existing.trustServerCert !== false),
|
||||
noAuthCache: parseBoolean(body.noAuthCache, existing.noAuthCache !== false),
|
||||
description: limitText(body.description || existing.description, 500),
|
||||
source: limitText(body.source || existing.source, 40),
|
||||
serverRepositoryRoot: limitText(body.serverRepositoryRoot || existing.serverRepositoryRoot, 1000),
|
||||
serverRepositoryPath: limitText(body.serverRepositoryPath || existing.serverRepositoryPath, 1000),
|
||||
serverRelativePath: limitText(body.serverRelativePath || existing.serverRelativePath, 1000),
|
||||
serverRevision: limitText(body.serverRevision || existing.serverRevision, 80),
|
||||
serverUuid: limitText(body.serverUuid || existing.serverUuid, 120),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: existing.createdAt || new Date().toISOString(),
|
||||
};
|
||||
@@ -440,10 +610,68 @@ export function registerSvnRoutes(app, options) {
|
||||
ok: result.ok,
|
||||
version: result.stdout.trim(),
|
||||
svnRoot: options.svnRoot,
|
||||
repositoryRoots: repositoryRootCandidates(options),
|
||||
publicBaseUrl: requestSvnBaseUrl(options, req),
|
||||
stderr: result.stderr,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/admin/svn/server-repositories", options.requirePlayer, options.requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const store = await readSvnStore(options);
|
||||
const discovered = await discoverServerRepositories(options, req);
|
||||
res.json({
|
||||
...discovered,
|
||||
svnRoot: options.svnRoot,
|
||||
repositories: markConfigured(discovered.repositories, store.repositories),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/admin/svn/server-repositories/sync", options.requirePlayer, options.requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const store = await readSvnStore(options);
|
||||
const discovered = await discoverServerRepositories(options, req);
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const repo of discovered.repositories) {
|
||||
const index = store.repositories.findIndex((item) => (
|
||||
item.serverRepositoryPath === repo.repositoryPath
|
||||
|| item.url === repo.url
|
||||
|| item.id === repo.id
|
||||
|| item.name === repo.name
|
||||
));
|
||||
if (index === -1) {
|
||||
let nextRepo = repoFromDiscovered(repo);
|
||||
let id = nextRepo.id;
|
||||
while (store.repositories.some((item) => item.id === id)) {
|
||||
id = `${nextRepo.id}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
nextRepo = { ...nextRepo, id };
|
||||
getRepoPath(options, nextRepo);
|
||||
store.repositories.push(nextRepo);
|
||||
created += 1;
|
||||
} else {
|
||||
store.repositories[index] = repoFromDiscovered(repo, store.repositories[index]);
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await writeSvnStore(options, store);
|
||||
res.json({
|
||||
created,
|
||||
updated,
|
||||
repositories: store.repositories.map(publicRepo),
|
||||
discovered: markConfigured(discovered.repositories, store.repositories),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/admin/svn/repositories", options.requirePlayer, options.requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const store = await readSvnStore(options);
|
||||
|
||||
Reference in New Issue
Block a user