feat: support git repositories and project management

- Git (JGit) log fetching: mirror clone cache, incremental fetch, time/author filter, HTTPS token & SSH key auth
- Git preset management and /api/git/* endpoints (test-connection, version-range, fetch)
- Markdown report generalization: project meta, commit range, short hash entries
- Project management: project entities, drag-drop repo assignment, sorting, rename sync, non-empty delete protection
- AI analysis: project-based grouping merges SVN+Git commits, excludes empty projects from Excel
- Batch fetch: skip repos without logs instead of aborting the whole flow
- Frontend: project management page, repo page project dropdown, Git fields, sidebar updates
- Tests: GitLogFetcherTest, DateRangeResolverTest, ProjectConfigServiceTest, merge/exclusion coverage
This commit is contained in:
liujing
2026-07-31 21:19:12 +08:00
parent b5c7907c23
commit 53f8b2e21b
50 changed files with 4434 additions and 291 deletions
+1
View File
@@ -2,6 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0F172A" />
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="28" fill="#22C55E"/>
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-weight="900" font-size="48" fill="#FFFFFF">LOG</text>
</svg>

After

Width:  |  Height:  |  Size: 310 B

+8 -4
View File
@@ -3,8 +3,8 @@
<div class="sidebar-brand">
<div class="sidebar-brand-icon" aria-hidden="true">S</div>
<div class="sidebar-brand-text">
<h1>SVN 工作台</h1>
<p>v2 · Log &amp; Analysis</p>
<h1>日志工作台</h1>
<p>v2 · SVN &amp; Git</p>
</div>
</div>
<nav class="sidebar-nav">
@@ -12,10 +12,14 @@
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span>工作台</span>
</router-link>
<router-link class="sidebar-link" to="/svn-fetch" active-class="active" aria-label="SVN 日志抓取">
<router-link class="sidebar-link" to="/svn-fetch" active-class="active" aria-label="日志抓取">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span>日志抓取</span>
</router-link>
<router-link class="sidebar-link" to="/projects" active-class="active" aria-label="项目管理">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>项目管理</span>
</router-link>
<router-link class="sidebar-link" to="/presets" active-class="active" aria-label="仓库管理">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span>仓库管理</span>
@@ -29,6 +33,6 @@
<span>系统设置</span>
</router-link>
</nav>
<div class="sidebar-footer">SVN Log Tool v2</div>
<div class="sidebar-footer">SVN Log Tool v2 · Git Ready</div>
</aside>
</template>
+4 -2
View File
@@ -6,14 +6,16 @@ import SvnFetchView from './views/SvnFetchView.vue'
import HistoryView from './views/HistoryView.vue'
import SettingsView from './views/SettingsView.vue'
import SvnPresetsView from './views/SvnPresetsView.vue'
import ProjectManageView from './views/ProjectManageView.vue'
import './styles/main.css'
const routes = [
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'dashboard', component: DashboardView, meta: { title: '工作台', desc: '查看系统状态与最近产物' } },
{ path: '/svn-fetch', name: 'svn-fetch', component: SvnFetchView, meta: { title: '日志抓取', desc: '一键抓取 SVN 日志并导出工作量 Excel' } },
{ path: '/svn-fetch', name: 'svn-fetch', component: SvnFetchView, meta: { title: '日志抓取', desc: '一键抓取 SVN/Git 日志并导出工作量 Excel' } },
{ path: '/history', name: 'history', component: HistoryView, meta: { title: '任务历史', desc: '查看任务执行状态、日志与产物' } },
{ path: '/presets', name: 'presets', component: SvnPresetsView, meta: { title: '仓库管理', desc: '管理 SVN 仓库预设' } },
{ path: '/projects', name: 'projects', component: ProjectManageView, meta: { title: '项目管理', desc: '创建项目并将 SVN/Git 仓库拖拽分配到项目' } },
{ path: '/presets', name: 'presets', component: SvnPresetsView, meta: { title: '仓库管理', desc: '管理 SVN / Git 仓库预设' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: '系统设置', desc: '配置 API Key 与输出目录' } },
]
+1
View File
@@ -423,6 +423,7 @@ body {
.tag-danger { background: var(--c-danger-bg); color: var(--c-danger); }
.tag-muted { background: rgba(100, 116, 139, 0.15); color: var(--c-text-muted); }
.tag-info { background: var(--c-info-bg); color: var(--c-info); }
.tag-default { background: var(--c-info-bg); color: var(--c-info); }
/* ========== Tables ========== */
.table-wrap {
@@ -0,0 +1,341 @@
<template>
<div class="project-manage">
<!-- 新建项目 -->
<div class="card" style="margin-bottom:var(--space-lg);">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
新建项目
</div>
<form @submit.prevent="onCreate" class="form-group" style="display:flex;gap:8px;max-width:480px;margin-top:var(--space-sm);">
<input v-model="newProjectName" class="form-input" placeholder="输入项目名称,例如 PRS-7050场站智慧管控" required autocomplete="off" spellcheck="false" />
<button type="submit" class="btn btn-primary" :disabled="creating">
<span v-if="creating" class="spinner"></span>
创建
</button>
</form>
<p style="font-size:12px;color:var(--c-text-muted);margin-top:var(--space-sm);">
提示将下方仓库卡片拖拽到项目卡片中即可分配一个仓库只能属于一个项目
</p>
</div>
<!-- 项目卡片列表 -->
<div v-if="projects.length" class="project-grid">
<div
v-for="(project, idx) in projects"
:key="project.id"
class="project-card"
:class="{ 'drag-over': dragOverProjectId === project.id }"
@dragover.prevent="onDragOver(project.id)"
@dragleave="onDragLeave(project.id)"
@drop.prevent="onDrop($event, project.id)"
>
<div class="project-card-header">
<h3 class="project-card-name">{{ project.name }}</h3>
<div class="btn-group" style="gap:4px;">
<button type="button" class="btn btn-sm" :disabled="idx === 0" @click="onMove(project.id, 'up')" title="上移"></button>
<button type="button" class="btn btn-sm" :disabled="idx === projects.length - 1" @click="onMove(project.id, 'down')" title="下移"></button>
<button type="button" class="btn btn-sm" @click="startRename(project)" title="重命名">改名</button>
<button type="button" class="btn btn-sm btn-danger" :disabled="project.repos.length > 0" :title="project.repos.length ? '项目下有仓库,需先移出' : '删除项目'" @click="onDelete(project)">删除</button>
</div>
</div>
<div class="project-card-repos">
<div v-if="!project.repos.length" class="repo-empty">拖拽仓库到此项目</div>
<div
v-for="repo in project.repos"
:key="repo.id"
class="repo-chip"
draggable="true"
@dragstart="onDragStart($event, repo.id)"
>
<span :class="['tag', repo.type === 'GIT' ? 'tag-info' : 'tag-default']">{{ repo.type === 'GIT' ? 'Git' : 'SVN' }}</span>
<span class="repo-name" :title="repo.url">{{ repo.name }}</span>
<span class="repo-type-hint">可拖出</span>
</div>
</div>
</div>
</div>
<div v-else-if="!loading" class="card" style="text-align:center;padding:var(--space-2xl);">
<p style="font-size:13px;color:var(--c-text-muted);">暂无项目请先在上方创建</p>
</div>
<!-- 未分配仓库 -->
<div class="card" style="margin-top:var(--space-lg);">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
仓库拖拽到上方项目卡片分配
</div>
<div class="project-card-repos" style="margin-top:var(--space-sm);">
<div
v-for="repo in allRepos"
:key="repo.id"
class="repo-chip"
draggable="true"
@dragstart="onDragStart($event, repo.id)"
>
<span :class="['tag', repo.type === 'GIT' ? 'tag-info' : 'tag-default']">{{ repo.type === 'GIT' ? 'Git' : 'SVN' }}</span>
<span class="repo-name" :title="repo.url">{{ repo.name }}</span>
<span v-if="!repo.assigned" class="repo-type-hint">未分配</span>
</div>
<div v-if="!allRepos.length" class="repo-empty">暂无仓库</div>
</div>
</div>
<!-- 重命名弹窗 -->
<div v-if="renameTarget" class="modal-overlay" @click.self="renameTarget = null">
<div class="modal" style="max-width:400px;" role="dialog" aria-label="重命名项目">
<h3 style="font-size:15px;font-weight:600;margin-bottom:var(--space-md);">重命名项目</h3>
<form @submit.prevent="onRename">
<div class="form-group">
<label for="rename-name">项目名称</label>
<input id="rename-name" class="form-input" v-model="renameForm.name" required autocomplete="off" spellcheck="false" />
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner"></span>
保存
</button>
<button type="button" class="btn" @click="renameTarget = null">取消</button>
</div>
</form>
</div>
</div>
<div v-if="loading" class="card" style="text-align:center;padding:var(--space-2xl);">
<span class="spinner"></span>
<p style="margin-top:var(--space-sm);font-size:13px;color:var(--c-text-muted);">加载中...</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch } = useApi()
const { toast } = useToast()
const loading = ref(true)
const creating = ref(false)
const saving = ref(false)
const projects = ref([])
const unassigned = ref([])
const newProjectName = ref('')
const renameTarget = ref(null)
const renameForm = ref({ name: '' })
const dragOverProjectId = ref(null)
const draggingRepoId = ref(null)
const allRepos = computed(() => {
const assigned = new Set()
for (const p of projects.value) {
for (const r of p.repos) {
r.assigned = true
assigned.add(r.id)
}
}
return [...unassigned.value.map(r => ({ ...r, assigned: false })), ...[].concat(...projects.value.map(p => p.repos))]
})
onMounted(async () => {
await loadProjects()
})
async function loadProjects() {
loading.value = true
try {
const data = await apiFetch('/api/projects')
projects.value = data.projects || []
unassigned.value = data.unassigned || []
} catch (err) {
toast(err.message, true)
} finally {
loading.value = false
}
}
async function onCreate() {
const name = newProjectName.value.trim()
if (!name) { toast('请输入项目名称', true); return }
creating.value = true
try {
await apiFetch('/api/projects', { method: 'POST', body: JSON.stringify({ name }) })
toast(`项目创建成功: ${name}`)
newProjectName.value = ''
await loadProjects()
} catch (err) {
toast(err.message, true)
} finally {
creating.value = false
}
}
function startRename(project) {
renameTarget.value = project
renameForm.value = { name: project.name }
}
async function onRename() {
if (!renameTarget.value) return
const name = renameForm.value.name.trim()
if (!name) { toast('项目名称不能为空', true); return }
saving.value = true
try {
await apiFetch(`/api/projects/${encodeURIComponent(renameTarget.value.id)}`, {
method: 'PUT',
body: JSON.stringify({ name }),
})
toast('重命名成功')
renameTarget.value = null
await loadProjects()
} catch (err) {
toast(err.message, true)
} finally {
saving.value = false
}
}
async function onDelete(project) {
if (project.repos.length) { toast('项目下仍有仓库,请先移出', true); return }
if (!window.confirm(`确定删除项目「${project.name}」吗?`)) return
try {
await apiFetch(`/api/projects/${encodeURIComponent(project.id)}`, { method: 'DELETE' })
toast(`已删除: ${project.name}`)
await loadProjects()
} catch (err) {
toast(err.message, true)
}
}
async function onMove(id, direction) {
try {
await apiFetch(`/api/projects/${encodeURIComponent(id)}/move`, {
method: 'POST',
body: JSON.stringify({ direction }),
})
await loadProjects()
} catch (err) {
toast(err.message, true)
}
}
function onDragStart(event, repoId) {
draggingRepoId.value = repoId
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', repoId)
}
function onDragOver(projectId) {
dragOverProjectId.value = projectId
}
function onDragLeave(projectId) {
if (dragOverProjectId.value === projectId) dragOverProjectId.value = null
}
async function onDrop(event, projectId) {
dragOverProjectId.value = null
const repoId = draggingRepoId.value || event.dataTransfer?.getData('text/plain')
draggingRepoId.value = null
if (!repoId) return
try {
await apiFetch('/api/projects/assign', {
method: 'POST',
body: JSON.stringify({ repoId, projectId }),
})
toast('分配成功')
await loadProjects()
} catch (err) {
toast(err.message, true)
}
}
</script>
<style scoped>
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--space-md);
}
.project-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
padding: var(--space-md);
min-height: 120px;
transition: border-color 0.15s ease, background 0.15s ease;
}
.project-card.drag-over {
border-color: var(--c-primary);
background: rgba(56, 189, 248, 0.06);
}
.project-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: var(--space-sm);
}
.project-card-name {
font-size: 14px;
font-weight: 600;
color: var(--c-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-card-repos {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.repo-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(15, 23, 42, 0.5);
border: 1px solid var(--c-border-light);
border-radius: var(--radius-md);
cursor: grab;
font-size: 12px;
max-width: 100%;
}
.repo-chip:active {
cursor: grabbing;
}
.repo-name {
color: var(--c-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
.repo-type-hint {
font-size: 11px;
color: var(--c-text-muted);
}
.repo-empty {
font-size: 12px;
color: var(--c-text-muted);
padding: 8px 4px;
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
}
.modal {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
width: 100%;
max-width: 520px;
box-shadow: var(--shadow-elevated);
}
</style>
+130 -61
View File
@@ -1,9 +1,9 @@
<template>
<div class="svn-fetch">
<!-- 空状态预设时引导去仓库管理新增 -->
<!-- 空状态仓库时引导去仓库管理新增 -->
<div v-if="!loading && !presets.length" class="card" style="text-align:center;padding:var(--space-2xl) var(--space-lg);">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--c-text-muted);margin-bottom:var(--space-md);" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<h3 style="font-size:16px;font-weight:600;margin-bottom:8px;color:var(--c-text);">尚未配置 SVN 仓库</h3>
<h3 style="font-size:16px;font-weight:600;margin-bottom:8px;color:var(--c-text);">尚未配置仓库</h3>
<p style="font-size:13px;color:var(--c-text-muted);margin-bottom:var(--space-lg);">
请先在<a href="#/presets" style="color:var(--c-primary);font-weight:500;">仓库管理</a>页面新增仓库后再来抓取日志
</p>
@@ -13,14 +13,14 @@
<div v-else class="card">
<div class="card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
SVN 批量抓取参数
批量抓取参数
</div>
<div class="alert alert-info" style="margin-bottom:var(--space-md);">
支持多个项目批量抓取可先选月份自动填充版本号或手动填写
支持 SVN Git 仓库混合批量抓取SVN 可自动填充版本号Git 按时间范围直接抓取
</div>
<div style="padding:var(--space-md);background:rgba(15,23,42,0.4);border-radius:var(--radius-md);margin-bottom:var(--space-lg);border:1px solid var(--c-border-light);">
<h4 style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--c-text);">智能版本号辅助</h4>
<h4 style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--c-text);">时间范围</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select v-model="rangeType" class="form-select" style="width:80px;" aria-label="范围类型">
<option value="month"></option>
@@ -40,17 +40,28 @@
<form @submit.prevent="onRunSvn">
<div class="form-grid" style="gap:var(--space-lg);">
<div class="span-all project-block" v-for="(proj, idx) in projects" :key="idx">
<h4>{{ proj.name }}</h4>
<div class="form-grid">
<div class="form-group">
<label :for="'start-'+idx">开始版本号</label>
<input :id="'start-'+idx" class="form-input" v-model="proj.startRevision" inputmode="numeric" placeholder="请输入开始版本" autocomplete="off" />
<h4>
<span :class="['tag', proj.type === 'GIT' ? 'tag-info' : 'tag-default']" style="margin-right:8px;vertical-align:middle;">{{ proj.type === 'GIT' ? 'Git' : 'SVN' }}</span>
{{ proj.name }}
</h4>
<template v-if="proj.type === 'GIT'">
<div class="form-group" style="max-width:420px;">
<label :for="'range-' + idx">提交范围</label>
<input :id="'range-' + idx" class="form-input" :value="proj.commitInfo || '点击「自动计算并填充」获取提交范围'" readonly style="background:rgba(15,23,42,0.5);color:var(--c-text-muted);" />
</div>
<div class="form-group">
<label :for="'end-'+idx">结束版本号</label>
<input :id="'end-'+idx" class="form-input" v-model="proj.endRevision" inputmode="numeric" placeholder="请输入结束版本" autocomplete="off" />
</template>
<template v-else>
<div class="form-grid">
<div class="form-group">
<label :for="'start-'+idx">开始版本号</label>
<input :id="'start-'+idx" class="form-input" v-model="proj.startRevision" inputmode="numeric" placeholder="请输入开始版本" autocomplete="off" />
</div>
<div class="form-group">
<label :for="'end-'+idx">结束版本号</label>
<input :id="'end-'+idx" class="form-input" v-model="proj.endRevision" inputmode="numeric" placeholder="请输入结束版本" autocomplete="off" />
</div>
</div>
</div>
</template>
</div>
<div class="form-group">
@@ -68,7 +79,7 @@
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="button" class="btn" @click="onTestConnection" :disabled="testing" aria-label="测试 SVN 连接">
<button type="button" class="btn" @click="onTestConnection" :disabled="testing" aria-label="测试连接">
<span v-if="testing" class="spinner"></span>
测试连接
</button>
@@ -168,15 +179,29 @@ function scrollPane(refEl) {
onMounted(async () => {
try {
const data = await apiFetch('/api/svn/presets')
presets.value = data.presets || []
defaultPresetId.value = data.defaultPresetId || ''
projects.value = (data.presets || []).map(p => ({
const [svnData, gitData] = await Promise.all([
apiFetch('/api/svn/presets').catch(() => ({ presets: [], defaultPresetId: '' })),
apiFetch('/api/git/presets').catch(() => ({ presets: [] })),
])
const svnPresets = (svnData.presets || []).map(p => ({
presetId: p.id,
name: p.name,
type: 'SVN',
startRevision: '',
endRevision: '',
commitInfo: '',
}))
const gitPresets = (gitData.presets || []).map(p => ({
presetId: p.id,
name: p.name,
type: 'GIT',
startRevision: '',
endRevision: '',
commitInfo: '',
}))
presets.value = [...svnPresets, ...gitPresets]
defaultPresetId.value = svnData.defaultPresetId || ''
projects.value = presets.value
loading.value = false
} catch (err) { loading.value = false; toast(err.message, true) }
@@ -189,73 +214,95 @@ onMounted(async () => {
})
function getFormProjects() {
return projects.value.filter(p => p.startRevision && p.endRevision).map(p => ({ ...p }))
return projects.value.filter(p => p.type === 'GIT' || (p.startRevision && p.endRevision)).map(p => ({ ...p }))
}
async function onTestConnection() {
if (!presets.value.length) { toast('未加载到 SVN 预设', true); return }
if (!presets.value.length) { toast('未加载到仓库预设', true); return }
testing.value = true
try {
const pid = defaultPresetId.value && presets.value.some(p => p.id === defaultPresetId.value)
? defaultPresetId.value : presets.value[0].id
await apiFetch('/api/svn/test-connection', { method: 'POST', body: JSON.stringify({ presetId: pid }) })
toast('SVN 连接成功')
const proj = presets.value.find(p => p.id === pid) || presets.value[0]
if (proj.type === 'GIT') {
await apiFetch('/api/git/test-connection', { method: 'POST', body: JSON.stringify({ presetId: pid }) })
toast('Git 连接成功')
} else {
await apiFetch('/api/svn/test-connection', { method: 'POST', body: JSON.stringify({ presetId: pid }) })
toast('SVN 连接成功')
}
} catch (err) { toast(err.message, true) }
finally { testing.value = false }
}
function buildRangeBody(rt, dv) {
let body
if (rt === 'month') {
const [y, m] = dv.split('-')
if (!y || !m) { toast('请选择月份', true); return null }
body = { year: parseInt(y, 10), month: parseInt(m, 10) }
} else if (rt === 'week') {
body = { rangeType: 'week', week: dv }
} else {
body = { rangeType: 'date', date: dv }
}
return body
}
async function autoFillVersions() {
if (!presets.value.length) { toast('未加载到 SVN 预设', true); return }
if (!presets.value.length) { toast('未加载到仓库预设', true); return }
const rt = rangeType.value
const dv = dateValue.value
if (!dv) { toast('请选择日期范围', true); return }
let logPrefix = ''
let body
if (rt === 'month') {
const [y, m] = dv.split('-')
if (!y || !m) { toast('请选择月份', true); return }
logPrefix = `${y}${m}`
body = { presetId: '', year: parseInt(y, 10), month: parseInt(m, 10) }
} else if (rt === 'week') {
logPrefix = dv
body = { presetId: '', rangeType: 'week', week: dv }
} else {
logPrefix = dv
body = { presetId: '', rangeType: 'date', date: dv }
}
const logPrefix = rt === 'month' ? `${dv.split('-')[0]}${dv.split('-')[1]}` : dv
const body = buildRangeBody(rt, dv)
if (!body) return
autoFillLoading.value = true
showLogPanel.value = true
clearLogs()
appendSyslog(`开始查询 ${logPrefix}版本范围...`)
appendSyslog(`开始查询 ${logPrefix}提交范围...`)
try {
for (let i = 0; i < presets.value.length; i++) {
const proj = projects.value[i]
appendSyslog(`正在查询 ${proj.name}版本范围...`)
const data = await apiFetch('/api/svn/version-range', {
method: 'POST',
body: JSON.stringify({ ...body, presetId: proj.presetId }),
})
if (data.startRevision && data.endRevision) {
proj.startRevision = String(data.startRevision)
proj.endRevision = String(data.endRevision)
appendSyslog(`${proj.name} 版本范围: ${data.startRevision} - ${data.endRevision}`)
appendSyslog(`正在查询 ${proj.name}提交范围...`)
if (proj.type === 'GIT') {
const data = await apiFetch('/api/git/version-range', {
method: 'POST',
body: JSON.stringify({ ...body, presetId: proj.presetId }),
})
if (data.commitCount) {
proj.commitInfo = `${data.commitCount} 条(${shortHash(data.startCommit)} - ${shortHash(data.endCommit)}`
appendSyslog(`${proj.name} 提交范围: ${data.commitCount} `)
} else {
proj.commitInfo = '该时间范围无提交'
appendSyslog(`${proj.name} 该范围无提交记录`, true)
}
} else {
appendSyslog(`${proj.name} 该范围无提交记录`, true)
const data = await apiFetch('/api/svn/version-range', {
method: 'POST',
body: JSON.stringify({ ...body, presetId: proj.presetId }),
})
if (data.startRevision && data.endRevision) {
proj.startRevision = String(data.startRevision)
proj.endRevision = String(data.endRevision)
appendSyslog(`${proj.name} 版本范围: ${data.startRevision} - ${data.endRevision}`)
} else {
appendSyslog(`${proj.name} 该范围无提交记录`, true)
}
}
}
appendSyslog('所有项目版本号填充完成')
toast('版本号填充完成')
appendSyslog('所有项目范围填充完成')
toast('范围填充完成')
} catch (err) { appendSyslog(`填充失败: ${err.message}`, true); toast(err.message, true) }
finally { autoFillLoading.value = false }
}
async function onRunSvn() {
if (!presets.value.length) { toast('SVN 预设加载异常', true); return }
if (!presets.value.length) { toast('仓库预设加载异常', true); return }
const formProjects = getFormProjects()
if (!formProjects.length) { toast('请至少填写一个项目的开始和结束版本号', true); return }
if (!formProjects.length) { toast('请至少填写一个 SVN 项目的开始和结束版本号,或选择 Git 仓库', true); return }
showLogPanel.value = true
clearLogs()
running.value = true
@@ -263,20 +310,36 @@ async function onRunSvn() {
let aiStream = null
try {
const mdFiles = []
const rt = rangeType.value
const dv = dateValue.value
const rangeBody = dv ? buildRangeBody(rt, dv) : null
for (let i = 0; i < formProjects.length; i++) {
const proj = formProjects[i]
appendSyslog(`正在提交 ${proj.name} 的抓取任务...`)
const data = await apiFetch('/api/svn/fetch', {
method: 'POST',
body: JSON.stringify({ presetId: proj.presetId, startRevision: toNum(proj.startRevision), endRevision: toNum(proj.endRevision), filterUser: filterUser.value || '' }),
})
let data
if (proj.type === 'GIT') {
data = await apiFetch('/api/git/fetch', {
method: 'POST',
body: JSON.stringify({ ...rangeBody, presetId: proj.presetId, projectName: proj.name, filterUser: filterUser.value || '' }),
})
} else {
data = await apiFetch('/api/svn/fetch', {
method: 'POST',
body: JSON.stringify({ presetId: proj.presetId, projectName: proj.name, startRevision: toNum(proj.startRevision), endRevision: toNum(proj.endRevision), filterUser: filterUser.value || '' }),
})
}
const taskId = data.taskId
appendSyslog(`已创建抓取任务: ${proj.name} (${taskId.slice(0,8)})`)
while (true) {
const task = await apiFetch(`/api/tasks/${encodeURIComponent(taskId)}`)
if (task.status === 'SUCCESS') {
appendSyslog(`${proj.name} 抓取完成`)
if (task.files) mdFiles.push(...task.files.filter(f => f.endsWith('.md')))
const md = (task.files || []).filter(f => f.endsWith('.md'))
if (md.length) {
appendSyslog(`${proj.name} 抓取完成`)
mdFiles.push(...md)
} else {
appendSyslog(`${proj.name} 该时间范围无符合条件日志,已跳过`)
}
break
}
if (task.status === 'FAILED' || task.status === 'CANCELLED') {
@@ -286,7 +349,12 @@ async function onRunSvn() {
await sleep(2000)
}
}
appendSyslog(`所有 SVN 抓取完成,共 ${mdFiles.length} 个文件`)
if (!mdFiles.length) {
appendSyslog('所有仓库均无符合条件的日志,无法生成报告', true)
toast('所有仓库均无符合条件的日志', true)
return
}
appendSyslog(`所有抓取完成,共 ${mdFiles.length} 个文件`)
appendSyslog('正在提交 AI 分析任务...')
const aiData = await apiFetch('/api/ai/analyze', {
method: 'POST',
@@ -386,5 +454,6 @@ function syncAiOutput(task, state) {
}
function toNum(v) { const n = Number(v); return Number.isFinite(n) ? n : null }
function shortHash(h) { return h && h.length > 7 ? h.slice(0, 7) : (h || '') }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
</script>
+251 -59
View File
@@ -1,19 +1,19 @@
<template>
<div class="presets-manage">
<!-- 空状态预设时显示引导 -->
<!-- 空状态仓库时显示引导 -->
<div v-if="!loading && !items.length" class="card" style="text-align:center;padding:var(--space-2xl) var(--space-lg);">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--c-text-muted);margin-bottom:var(--space-md);" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<h3 style="font-size:16px;font-weight:600;margin-bottom:8px;color:var(--c-text);">暂无 SVN 仓库</h3>
<p style="font-size:13px;color:var(--c-text-muted);margin-bottom:var(--space-lg);max-width:400px;margin-left:auto;margin-right:auto;">
请新增一个仓库填写 SVN 地址和可选的账号密码以开始使用
<h3 style="font-size:16px;font-weight:600;margin-bottom:8px;color:var(--c-text);">暂无仓库</h3>
<p style="font-size:13px;color:var(--c-text-muted);margin-bottom:var(--space-lg);max-width:420px;margin-left:auto;margin-right:auto;">
请新增一个仓库填写 SVN 地址 Git 地址及认证信息以开始使用
</p>
<button type="button" class="btn btn-primary" @click="showAddForm = true">新增仓库</button>
<button type="button" class="btn btn-primary" @click="openAdd">新增仓库</button>
</div>
<!-- 顶部操作栏有预设时显示 -->
<!-- 顶部操作栏 -->
<div v-if="items.length" class="toolbar" style="justify-content:space-between;">
<span style="font-size:13px;color:var(--c-text-secondary);"> {{ items.length }} 个仓库</span>
<button type="button" class="btn btn-primary btn-sm" @click="showAddForm = true">
<button type="button" class="btn btn-primary btn-sm" @click="openAdd">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
新增仓库
</button>
@@ -21,26 +21,76 @@
<!-- 新增表单弹窗 -->
<div v-if="showAddForm" class="modal-overlay" @click.self="cancelAdd">
<div class="modal" role="dialog" aria-label="新增 SVN 仓库">
<h3 style="font-size:15px;font-weight:600;margin-bottom:var(--space-md);">新增 SVN 仓库</h3>
<div class="modal" role="dialog" aria-label="新增仓库">
<h3 style="font-size:15px;font-weight:600;margin-bottom:var(--space-md);">新增仓库</h3>
<form @submit.prevent="onAdd">
<div class="form-grid">
<div class="form-group span-all">
<label for="add-type">仓库类型</label>
<select id="add-type" class="form-select" v-model="addForm.type">
<option value="SVN">SVN</option>
<option value="GIT">Git</option>
</select>
</div>
<div class="form-group span-all">
<label for="add-name">名称</label>
<input id="add-name" class="form-input" v-model="addForm.name" placeholder="例如 PRS-7050场站智慧管控" required autocomplete="off" />
</div>
<div class="form-group span-all">
<label for="add-url">SVN 地址</label>
<input id="add-url" class="form-input" v-model="addForm.url" placeholder="https://svn.example.com/svn/project" required autocomplete="off" spellcheck="false" />
<label for="add-project">项目</label>
<select id="add-project" class="form-select" v-model="addForm.projectName" required>
<option value="" disabled>请选择所属项目</option>
<option v-for="p in projectOptions" :key="p.name" :value="p.name">{{ p.name }}</option>
</select>
<span style="font-size:11px;color:var(--c-text-muted);">同一项目的 SVN/Git 仓库选相同项目可在项目管理页创建</span>
</div>
<div class="form-group">
<label for="add-username">SVN 用户名可选</label>
<input id="add-username" class="form-input" v-model="addForm.svnUsername" placeholder="预设专用" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="add-password">SVN 密码可选</label>
<input id="add-password" class="form-input" type="password" v-model="addForm.svnPassword" placeholder="预设专用" autocomplete="new-password" />
<div class="form-group span-all">
<label for="add-url">{{ addForm.type === 'GIT' ? 'Git 地址' : 'SVN 地址' }}</label>
<input id="add-url" class="form-input" v-model="addForm.url" :placeholder="addForm.type === 'GIT' ? 'https://git.example.com/org/project.git 或 git@host:org/project.git' : 'https://svn.example.com/svn/project'" required autocomplete="off" spellcheck="false" />
</div>
<!-- SVN 字段 -->
<template v-if="addForm.type === 'SVN'">
<div class="form-group">
<label for="add-username">SVN 用户名可选</label>
<input id="add-username" class="form-input" v-model="addForm.svnUsername" placeholder="预设专用" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="add-password">SVN 密码可选</label>
<input id="add-password" class="form-input" type="password" v-model="addForm.svnPassword" placeholder="预设专用" autocomplete="new-password" />
</div>
</template>
<!-- Git 字段 -->
<template v-else>
<div class="form-group">
<label for="add-branch">分支可选</label>
<input id="add-branch" class="form-input" v-model="addForm.gitBranch" placeholder="留空自动探测 master/main" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="add-auth-type">认证方式</label>
<select id="add-auth-type" class="form-select" v-model="addForm.gitAuthType">
<option value="PASSWORD">用户名 + Token</option>
<option value="SSH_KEY">SSH 私钥</option>
</select>
</div>
<template v-if="addForm.gitAuthType === 'SSH_KEY'">
<div class="form-group span-all">
<label for="add-ssh-key">SSH 私钥可选</label>
<textarea id="add-ssh-key" class="form-input" v-model="addForm.gitSshKey" rows="5" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ..." autocomplete="off" spellcheck="false" style="font-family:var(--font-mono);font-size:12px;"></textarea>
</div>
</template>
<template v-else>
<div class="form-group">
<label for="add-git-username">用户名可选</label>
<input id="add-git-username" class="form-input" v-model="addForm.gitUsername" placeholder="默认 git" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="add-git-token">Token / 密码可选</label>
<input id="add-git-token" class="form-input" type="password" v-model="addForm.gitToken" placeholder="预设专用" autocomplete="new-password" />
</div>
</template>
</template>
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="submit" class="btn btn-primary" :disabled="adding">
@@ -55,8 +105,8 @@
<!-- 编辑弹窗 -->
<div v-if="editTarget" class="modal-overlay" @click.self="editTarget = null">
<div class="modal" role="dialog" aria-label="编辑 SVN 仓库">
<h3 style="font-size:15px;font-weight:600;margin-bottom:var(--space-md);">编辑仓库</h3>
<div class="modal" role="dialog" aria-label="编辑仓库">
<h3 style="font-size:15px;font-weight:600;margin-bottom:var(--space-md);">编辑仓库 <span :class="['tag', typeTagClass(editTarget.type)]" style="margin-left:8px;vertical-align:middle;">{{ editTarget.type === 'GIT' ? 'Git' : 'SVN' }}</span></h3>
<form @submit.prevent="onEdit">
<div class="form-grid">
<div class="form-group span-all">
@@ -64,24 +114,80 @@
<input id="edit-name" class="form-input" v-model="editForm.name" required autocomplete="off" />
</div>
<div class="form-group span-all">
<label for="edit-url">SVN 地址</label>
<input id="edit-url" class="form-input" v-model="editForm.url" required autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="edit-username">SVN 用户名</label>
<input id="edit-username" class="form-input" v-model="editForm.svnUsername" placeholder="留空不覆盖" autocomplete="off" spellcheck="false" />
<span v-if="editTarget.svnCredentialsConfigured" style="font-size:11px;color:var(--c-warning);">已有凭据留空则保留原值</span>
</div>
<div class="form-group">
<label for="edit-password">SVN 密码</label>
<input id="edit-password" class="form-input" type="password" v-model="editForm.svnPassword" placeholder="留空不覆盖" autocomplete="new-password" />
<label for="edit-project">项目</label>
<select id="edit-project" class="form-select" v-model="editForm.projectName" required>
<option value="" disabled>请选择所属项目</option>
<option v-for="p in projectOptions" :key="p.name" :value="p.name">{{ p.name }}</option>
</select>
</div>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.clearSvnPassword" />
清空已保存的 SVN 密码
</label>
<label for="edit-url">{{ editTarget.type === 'GIT' ? 'Git 地址' : 'SVN 地址' }}</label>
<input id="edit-url" class="form-input" v-model="editForm.url" required autocomplete="off" spellcheck="false" />
</div>
<!-- SVN 编辑字段 -->
<template v-if="editTarget.type === 'SVN'">
<div class="form-group">
<label for="edit-username">SVN 用户名</label>
<input id="edit-username" class="form-input" v-model="editForm.svnUsername" placeholder="留空不覆盖" autocomplete="off" spellcheck="false" />
<span v-if="editTarget.svnCredentialsConfigured" style="font-size:11px;color:var(--c-warning);">已有凭据留空则保留原值</span>
</div>
<div class="form-group">
<label for="edit-password">SVN 密码</label>
<input id="edit-password" class="form-input" type="password" v-model="editForm.svnPassword" placeholder="留空不覆盖" autocomplete="new-password" />
</div>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.clearSvnPassword" />
清空已保存的 SVN 密码
</label>
</div>
</template>
<!-- Git 编辑字段 -->
<template v-else>
<div class="form-group">
<label for="edit-branch">分支</label>
<input id="edit-branch" class="form-input" v-model="editForm.gitBranch" placeholder="留空自动探测" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="edit-auth-type">认证方式</label>
<select id="edit-auth-type" class="form-select" v-model="editForm.gitAuthType">
<option value="PASSWORD">用户名 + Token</option>
<option value="SSH_KEY">SSH 私钥</option>
</select>
</div>
<template v-if="editForm.gitAuthType === 'SSH_KEY'">
<div class="form-group span-all">
<label for="edit-ssh-key">SSH 私钥</label>
<textarea id="edit-ssh-key" class="form-input" v-model="editForm.gitSshKey" rows="5" placeholder="留空不覆盖" autocomplete="off" spellcheck="false" style="font-family:var(--font-mono);font-size:12px;"></textarea>
<span v-if="editTarget.credentialsConfigured" style="font-size:11px;color:var(--c-warning);">已有私钥留空则保留原值</span>
</div>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.clearGitSshKey" />
清空已保存的 SSH 私钥
</label>
</div>
</template>
<template v-else>
<div class="form-group">
<label for="edit-git-username">用户名</label>
<input id="edit-git-username" class="form-input" v-model="editForm.gitUsername" placeholder="留空不覆盖" autocomplete="off" spellcheck="false" />
</div>
<div class="form-group">
<label for="edit-git-token">Token / 密码</label>
<input id="edit-git-token" class="form-input" type="password" v-model="editForm.gitToken" placeholder="留空不覆盖" autocomplete="new-password" />
</div>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.clearGitToken" />
清空已保存的 Token
</label>
</div>
</template>
</template>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.enabled" />
@@ -122,9 +228,11 @@
<table>
<thead>
<tr>
<th>类型</th>
<th>名称</th>
<th>SVN 地址</th>
<th>SVN 账号</th>
<th>项目</th>
<th>地址</th>
<th>账号 / 分支</th>
<th>凭据</th>
<th>最后使用</th>
<th>状态</th>
@@ -133,11 +241,18 @@
</thead>
<tbody>
<tr v-for="item in items" :key="item.id" :style="{ opacity: item.enabled ? 1 : 0.5 }">
<td><strong>{{ item.name }}</strong></td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:12px;" :title="item.url">{{ item.url }}</td>
<td style="font-family:var(--font-mono);font-size:12px;">{{ item.svnUsername || '—' }}</td>
<td>
<span :class="['tag', item.svnCredentialsConfigured ? 'tag-success' : 'tag-muted']">{{ item.svnCredentialsConfigured ? '已配置' : '未配置' }}</span>
<span :class="['tag', typeTagClass(item.type)]">{{ item.type === 'GIT' ? 'Git' : 'SVN' }}</span>
</td>
<td><strong>{{ item.name }}</strong></td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;" :title="item.projectName">{{ item.projectName || '—' }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:12px;" :title="item.url">{{ item.url }}</td>
<td style="font-family:var(--font-mono);font-size:12px;">
<template v-if="item.type === 'GIT'">{{ authLabel(item) }}</template>
<template v-else>{{ item.svnUsername || '' }}</template>
</td>
<td>
<span :class="['tag', item.credentialsConfigured ? 'tag-success' : 'tag-muted']">{{ item.credentialsConfigured ? '已配置' : '未配置' }}</span>
</td>
<td style="font-size:12px;color:var(--c-text-muted);font-family:var(--font-mono);">{{ formatTime(item.lastUsedAt) }}</td>
<td>
@@ -181,22 +296,53 @@ const deleting = ref(false)
const testingId = ref(null)
const showAddForm = ref(false)
const addForm = ref({ name: '', url: '', svnUsername: '', svnPassword: '' })
const addForm = ref(emptyAddForm())
const editTarget = ref(null)
const editForm = ref({ name: '', url: '', svnUsername: '', svnPassword: '', clearSvnPassword: false, enabled: true })
const editForm = ref({})
const deleteTarget = ref(null)
const projectOptions = ref([])
onMounted(async () => {
await loadItems()
await Promise.all([loadItems(), loadProjects()])
})
async function loadProjects() {
try {
const data = await apiFetch('/api/projects')
projectOptions.value = (data.projects || []).map(p => ({ name: p.name }))
} catch (err) {
toast(err.message, true)
}
}
function emptyAddForm() {
return {
type: 'SVN',
name: '',
url: '',
projectName: '',
svnUsername: '',
svnPassword: '',
gitBranch: '',
gitAuthType: 'PASSWORD',
gitUsername: '',
gitToken: '',
gitSshKey: '',
}
}
async function loadItems() {
loading.value = true
try {
const data = await apiFetch('/api/svn/presets/manage')
items.value = data || []
const [svnData, gitData] = await Promise.all([
apiFetch('/api/svn/presets/manage').catch(() => []),
apiFetch('/api/git/presets/manage').catch(() => []),
])
const svnItems = (svnData || []).map(item => ({ ...item, type: 'SVN', credentialsConfigured: item.svnCredentialsConfigured }))
const gitItems = (gitData || []).map(item => ({ ...item, type: 'GIT' }))
items.value = [...svnItems, ...gitItems]
} catch (err) {
toast(err.message, true)
} finally {
@@ -204,9 +350,13 @@ async function loadItems() {
}
}
function openAdd() {
addForm.value = emptyAddForm()
showAddForm.value = true
}
function cancelAdd() {
showAddForm.value = false
addForm.value = { name: '', url: '', svnUsername: '', svnPassword: '' }
}
async function onAdd() {
@@ -216,13 +366,19 @@ async function onAdd() {
}
adding.value = true
try {
await apiFetch('/api/svn/presets', {
method: 'POST',
body: JSON.stringify(addForm.value),
})
if (addForm.value.type === 'GIT') {
await apiFetch('/api/git/presets', {
method: 'POST',
body: JSON.stringify(addForm.value),
})
} else {
await apiFetch('/api/svn/presets', {
method: 'POST',
body: JSON.stringify(addForm.value),
})
}
toast('仓库创建成功')
showAddForm.value = false
addForm.value = { name: '', url: '', svnUsername: '', svnPassword: '' }
await loadItems()
} catch (err) {
toast(err.message, true)
@@ -236,9 +392,17 @@ function startEdit(item) {
editForm.value = {
name: item.name,
url: item.url,
projectName: item.projectName || '',
svnUsername: '',
svnPassword: '',
clearSvnPassword: false,
gitBranch: item.branch || '',
gitAuthType: item.authType || 'PASSWORD',
gitUsername: '',
gitToken: '',
gitSshKey: '',
clearGitToken: false,
clearGitSshKey: false,
enabled: item.enabled,
}
}
@@ -247,10 +411,18 @@ async function onEdit() {
if (!editTarget.value) return
saving.value = true
try {
await apiFetch(`/api/svn/presets/${encodeURIComponent(editTarget.value.id)}`, {
method: 'PUT',
body: JSON.stringify(editForm.value),
})
const id = encodeURIComponent(editTarget.value.id)
if (editTarget.value.type === 'GIT') {
await apiFetch(`/api/git/presets/${id}`, {
method: 'PUT',
body: JSON.stringify(editForm.value),
})
} else {
await apiFetch(`/api/svn/presets/${id}`, {
method: 'PUT',
body: JSON.stringify(editForm.value),
})
}
toast('保存成功')
editTarget.value = null
await loadItems()
@@ -264,7 +436,12 @@ async function onEdit() {
async function onTest(item) {
testingId.value = item.id
try {
await apiFetch(`/api/svn/presets/${encodeURIComponent(item.id)}/test`, { method: 'POST' })
const id = encodeURIComponent(item.id)
if (item.type === 'GIT') {
await apiFetch(`/api/git/presets/${id}/test`, { method: 'POST' })
} else {
await apiFetch(`/api/svn/presets/${id}/test`, { method: 'POST' })
}
toast(`连接成功: ${item.name}`)
await loadItems()
} catch (err) {
@@ -278,7 +455,12 @@ async function onDelete() {
if (!deleteTarget.value) return
deleting.value = true
try {
await apiFetch(`/api/svn/presets/${encodeURIComponent(deleteTarget.value.id)}`, { method: 'DELETE' })
const id = encodeURIComponent(deleteTarget.value.id)
if (deleteTarget.value.type === 'GIT') {
await apiFetch(`/api/git/presets/${id}`, { method: 'DELETE' })
} else {
await apiFetch(`/api/svn/presets/${id}`, { method: 'DELETE' })
}
toast(`已停用: ${deleteTarget.value.name}`)
deleteTarget.value = null
await loadItems()
@@ -289,6 +471,16 @@ async function onDelete() {
}
}
function authLabel(item) {
const branch = item.branch ? item.branch : '默认分支'
const auth = item.authType === 'SSH_KEY' ? 'SSH 私钥' : 'Token'
return `${auth} · ${branch}`
}
function typeTagClass(type) {
return type === 'GIT' ? 'tag-info' : 'tag-default'
}
function formatTime(ts) {
if (!ts) return '从未使用'
const d = new Date(ts)
@@ -314,7 +506,7 @@ function formatTime(ts) {
border-radius: var(--radius-lg);
padding: var(--space-lg);
width: 100%;
max-width: 520px;
max-width: 560px;
box-shadow: var(--shadow-elevated);
max-height: 80vh;
overflow-y: auto;