feat: add svn preset management and optimize docker builds

This commit is contained in:
liumangmang
2026-06-11 13:57:20 +08:00
parent 409c5a81e4
commit b5c7907c23
24 changed files with 1317 additions and 138 deletions
+5 -1
View File
@@ -14,7 +14,11 @@
</router-link>
<router-link class="sidebar-link" to="/svn-fetch" active-class="active" aria-label="SVN 日志抓取">
<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>SVN 日志抓取</span>
<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>
</router-link>
<router-link class="sidebar-link" to="/history" 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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
+3 -1
View File
@@ -5,13 +5,15 @@ import DashboardView from './views/DashboardView.vue'
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 './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: 'SVN 日志抓取', desc: '一键抓取 SVN 日志并导出工作量 Excel' } },
{ path: '/svn-fetch', name: 'svn-fetch', component: SvnFetchView, meta: { title: '日志抓取', desc: '一键抓取 SVN 日志并导出工作量 Excel' } },
{ path: '/history', name: 'history', component: HistoryView, meta: { title: '任务历史', desc: '查看任务执行状态、日志与产物' } },
{ path: '/presets', name: 'presets', component: SvnPresetsView, meta: { title: '仓库管理', desc: '管理 SVN 仓库预设' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: '系统设置', desc: '配置 API Key 与输出目录' } },
]
+15 -3
View File
@@ -1,12 +1,22 @@
<template>
<div class="svn-fetch">
<div class="card">
<!-- 空状态无预设时引导去仓库管理新增 -->
<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>
<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>
<router-link to="/presets" class="btn btn-primary">前往仓库管理</router-link>
</div>
<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);">
默认已填充 3 个常用项目路径可选月份自动填充版本号或手动填写
支持多个项目批量抓取选月份自动填充版本号或手动填写
</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);">
@@ -114,6 +124,7 @@ const { toast } = useToast()
const presets = ref([])
const defaultPresetId = ref('')
const loading = ref(true)
const testing = ref(false)
const running = ref(false)
const autoFillLoading = ref(false)
@@ -166,7 +177,8 @@ onMounted(async () => {
startRevision: '',
endRevision: '',
}))
} catch (err) { toast(err.message, true) }
loading.value = false
} catch (err) { loading.value = false; toast(err.message, true) }
const now = new Date()
const y = now.getFullYear()
+335
View File
@@ -0,0 +1,335 @@
<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 地址和可选的账号密码以开始使用
</p>
<button type="button" class="btn btn-primary" @click="showAddForm = true">新增仓库</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">
<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>
</div>
<!-- 新增表单弹窗 -->
<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>
<form @submit.prevent="onAdd">
<div class="form-grid">
<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" />
</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>
</div>
<div class="btn-group" style="margin-top:var(--space-lg);">
<button type="submit" class="btn btn-primary" :disabled="adding">
<span v-if="adding" class="spinner"></span>
创建
</button>
<button type="button" class="btn" @click="cancelAdd">取消</button>
</div>
</form>
</div>
</div>
<!-- 编辑弹窗 -->
<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>
<form @submit.prevent="onEdit">
<div class="form-grid">
<div class="form-group span-all">
<label for="edit-name">名称</label>
<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" />
</div>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.clearSvnPassword" />
清空已保存的 SVN 密码
</label>
</div>
<div class="form-group span-all">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.enabled" />
启用
</label>
</div>
</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="editTarget = null">取消</button>
</div>
</form>
</div>
</div>
<!-- 删除确认弹窗 -->
<div v-if="deleteTarget" class="modal-overlay" @click.self="deleteTarget = 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>
<p style="font-size:13px;color:var(--c-text-secondary);margin-bottom:var(--space-lg);">
确定要将仓库 <strong style="color:var(--c-text);">{{ deleteTarget.name }}</strong> 停用吗可随时在编辑中重新启用
</p>
<div class="btn-group">
<button type="button" class="btn btn-danger" :disabled="deleting" @click="onDelete">
<span v-if="deleting" class="spinner"></span>
停用仓库
</button>
<button type="button" class="btn" @click="deleteTarget = null">取消</button>
</div>
</div>
</div>
<!-- 表格列表 -->
<div v-if="items.length" class="table-wrap" style="margin-top:var(--space-md);">
<table>
<thead>
<tr>
<th>名称</th>
<th>SVN 地址</th>
<th>SVN 账号</th>
<th>凭据</th>
<th>最后使用</th>
<th>状态</th>
<th>操作</th>
</tr>
</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>
</td>
<td style="font-size:12px;color:var(--c-text-muted);font-family:var(--font-mono);">{{ formatTime(item.lastUsedAt) }}</td>
<td>
<span :class="['tag', item.enabled ? 'tag-success' : 'tag-muted']">{{ item.enabled ? '启用' : '停用' }}</span>
</td>
<td>
<div class="btn-group" style="gap:4px;">
<button type="button" class="btn btn-sm" @click="startEdit(item)" title="编辑">编辑</button>
<button type="button" class="btn btn-sm" :disabled="testingId === item.id" @click="onTest(item)" title="测试连接">
<span v-if="testingId === item.id" class="spinner"></span>
测试
</button>
<button type="button" class="btn btn-sm btn-danger" @click="deleteTarget = item" title="停用">停用</button>
</div>
</td>
</tr>
</tbody>
</table>
</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, onMounted } from 'vue'
import { useApi, useToast } from '../composables/useApi'
const { apiFetch } = useApi()
const { toast } = useToast()
const items = ref([])
const loading = ref(true)
const adding = ref(false)
const saving = ref(false)
const deleting = ref(false)
const testingId = ref(null)
const showAddForm = ref(false)
const addForm = ref({ name: '', url: '', svnUsername: '', svnPassword: '' })
const editTarget = ref(null)
const editForm = ref({ name: '', url: '', svnUsername: '', svnPassword: '', clearSvnPassword: false, enabled: true })
const deleteTarget = ref(null)
onMounted(async () => {
await loadItems()
})
async function loadItems() {
loading.value = true
try {
const data = await apiFetch('/api/svn/presets/manage')
items.value = data || []
} catch (err) {
toast(err.message, true)
} finally {
loading.value = false
}
}
function cancelAdd() {
showAddForm.value = false
addForm.value = { name: '', url: '', svnUsername: '', svnPassword: '' }
}
async function onAdd() {
if (!addForm.value.name.trim() || !addForm.value.url.trim()) {
toast('名称和地址为必填项', true)
return
}
adding.value = true
try {
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)
} finally {
adding.value = false
}
}
function startEdit(item) {
editTarget.value = item
editForm.value = {
name: item.name,
url: item.url,
svnUsername: '',
svnPassword: '',
clearSvnPassword: false,
enabled: item.enabled,
}
}
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),
})
toast('保存成功')
editTarget.value = null
await loadItems()
} catch (err) {
toast(err.message, true)
} finally {
saving.value = false
}
}
async function onTest(item) {
testingId.value = item.id
try {
await apiFetch(`/api/svn/presets/${encodeURIComponent(item.id)}/test`, { method: 'POST' })
toast(`连接成功: ${item.name}`)
await loadItems()
} catch (err) {
toast(err.message, true)
} finally {
testingId.value = null
}
}
async function onDelete() {
if (!deleteTarget.value) return
deleting.value = true
try {
await apiFetch(`/api/svn/presets/${encodeURIComponent(deleteTarget.value.id)}`, { method: 'DELETE' })
toast(`已停用: ${deleteTarget.value.name}`)
deleteTarget.value = null
await loadItems()
} catch (err) {
toast(err.message, true)
} finally {
deleting.value = false
}
}
function formatTime(ts) {
if (!ts) return '从未使用'
const d = new Date(ts)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
</script>
<style scoped>
.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);
max-height: 80vh;
overflow-y: auto;
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--c-text-secondary);
cursor: pointer;
}
.checkbox-label input {
width: 16px;
height: 16px;
accent-color: var(--c-primary);
}
</style>