feat: 添加同步进度显示和中文界面支持

refactor: 重构同步引擎以支持进度跟踪
style: 更新前端界面为中文
docs: 更新README为中文文档
This commit is contained in:
panw
2026-04-01 10:43:51 +08:00
parent 34944518f0
commit 5eff309a9f
13 changed files with 469 additions and 154 deletions

View File

@@ -1,25 +1,25 @@
<template>
<div style="padding:20px">
<el-row :gutter="20">
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Servers" :value="stats.server_count" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Active Servers" :value="stats.active_servers" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Repos" :value="stats.repo_count" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="Total Size" :value="formatSize(stats.total_size)" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="服务器总数" :value="stats.server_count" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="活跃服务器" :value="stats.active_servers" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="仓库总数" :value="stats.repo_count" /></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><el-statistic title="总大小" :value="formatSize(stats.total_size)" /></el-card></el-col>
</el-row>
<el-card style="margin-top:20px">
<template #header><span>Quick Actions</span></template>
<template #header><span>快捷操作</span></template>
<el-space>
<el-button type="primary" :loading="syncing" @click="syncAll">Sync All</el-button>
<el-button @click="$router.push('/servers')">Add Server</el-button>
<el-button type="primary" :loading="syncing" @click="syncAll">同步全部</el-button>
<el-button @click="$router.push('/servers')">添加服务器</el-button>
</el-space>
</el-card>
<el-card style="margin-top:20px">
<template #header><div style="display:flex;justify-content:space-between"><span>Recent Activity</span><el-button text @click="$router.push('/logs')">View All</el-button></div></template>
<template #header><div style="display:flex;justify-content:space-between"><span>最近活动</span><el-button text @click="$router.push('/logs')">查看全部</el-button></div></template>
<el-table :data="logs" stripe>
<el-table-column prop="started_at" label="Time" width="180"><template #default="{row}">{{ formatDate(row.started_at) }}</template></el-table-column>
<el-table-column prop="server_id" label="Server" width="80" />
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{row.status}}</el-tag></template></el-table-column>
<el-table-column prop="message" label="Message" />
<el-table-column prop="started_at" label="时间" width="180"><template #default="{row}">{{ formatDate(row.started_at) }}</template></el-table-column>
<el-table-column prop="server_id" label="服务器" width="80" />
<el-table-column prop="status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{statusText(row.status)}}</el-tag></template></el-table-column>
<el-table-column prop="message" label="消息" />
</el-table>
</el-card>
</div>
@@ -41,13 +41,14 @@ async function loadData() {
async function syncAll() {
syncing.value = true
try { await syncApi.syncAll(); ElMessage.success('Sync started'); setTimeout(loadData, 1000) }
catch { ElMessage.error('Failed') }
try { await syncApi.syncAll(); ElMessage.success('同步已开始'); setTimeout(loadData, 1000) }
catch { ElMessage.error('操作失败') }
finally { syncing.value = false }
}
function formatSize(b:number) { if(!b)return '0 B'; const k=1024,s=['B','KB','MB','GB']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(1)+' '+s[i] }
function formatDate(d:string) { return new Date(d).toLocaleString() }
function statusText(s:string) { const m:Record<string,string>={success:'成功',failed:'失败',in_progress:'进行中',pending:'等待'}; return m[s]||s }
onMounted(loadData)
</script>

View File

@@ -5,18 +5,18 @@
<h2 style="margin:0;color:#fff">GitM</h2>
</div>
<el-menu :default-active="$route.path" router background-color="#001529" text-color="#fff" active-text-color="#1890ff">
<el-menu-item index="/"><el-icon><DataBoard /></el-icon><span>Dashboard</span></el-menu-item>
<el-menu-item index="/servers"><el-icon><Monitor /></el-icon><span>Servers</span></el-menu-item>
<el-menu-item index="/repos"><el-icon><DocumentCopy /></el-icon><span>Repositories</span></el-menu-item>
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>Sync Logs</span></el-menu-item>
<el-menu-item index="/settings"><el-icon><Setting /></el-icon><span>Settings</span></el-menu-item>
<el-menu-item index="/"><el-icon><DataBoard /></el-icon><span>仪表盘</span></el-menu-item>
<el-menu-item index="/servers"><el-icon><Monitor /></el-icon><span>服务器</span></el-menu-item>
<el-menu-item index="/repos"><el-icon><DocumentCopy /></el-icon><span>仓库列表</span></el-menu-item>
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>同步日志</span></el-menu-item>
<el-menu-item index="/settings"><el-icon><Setting /></el-icon><span>系统设置</span></el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center">
<div style="width:100%;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:18px;font-weight:500">{{ pageTitle }}</span>
<el-button text @click="handleLogout"><el-icon><SwitchButton /></el-icon> Logout</el-button>
<el-button text @click="handleLogout"><el-icon><SwitchButton /></el-icon> 退出登录</el-button>
</div>
</el-header>
<el-main style="background:#f5f5f5"><router-view /></el-main>
@@ -34,7 +34,7 @@ const router = useRouter()
const authStore = useAuthStore()
const pageTitle = computed(() => {
const titles: Record<string,string> = { '/':'Dashboard','/servers':'Gitea Servers','/repos':'Repositories','/logs':'Sync Logs','/settings':'Settings' }
const titles: Record<string,string> = { '/':'仪表盘','/servers':'Gitea 服务器','/repos':'仓库列表','/logs':'同步日志','/settings':'系统设置' }
return titles[route.path] || 'GitM'
})

View File

@@ -4,15 +4,15 @@
<template #header>
<div style="text-align:center">
<h1 style="margin:0;color:#409eff">GitM</h1>
<p style="margin:5px 0 0;color:#909399;font-size:14px">Gitea Repository Sync Tool</p>
<p style="margin:5px 0 0;color:#909399;font-size:14px">Gitea 仓库同步工具</p>
</div>
</template>
<el-form @submit.prevent="handleLogin">
<el-form-item>
<el-input v-model="password" type="password" placeholder="Enter password" show-password size="large" @keyup.enter="handleLogin" />
<el-input v-model="password" type="password" placeholder="请输入密码" show-password size="large" @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">Login</el-button>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
<el-alert v-if="error" type="error" :title="error" :closable="false" style="margin-top:16px" />
@@ -33,12 +33,12 @@ const error = ref('')
const loading = ref(false)
async function handleLogin() {
if (!password.value) { error.value = 'Please enter password'; return }
if (!password.value) { error.value = '请输入密码'; return }
error.value = ''
loading.value = true
const ok = await authStore.login(password.value)
loading.value = false
if (ok) { ElMessage.success('Login successful'); router.push('/') }
else { error.value = 'Invalid password' }
if (ok) { ElMessage.success('登录成功'); router.push('/') }
else { error.value = '密码错误' }
}
</script>

View File

@@ -1,14 +1,14 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Sync Logs</span>
<el-select v-model="filterServer" clearable style="width:200px"><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>同步日志</span>
<el-select v-model="filterServer" clearable placeholder="筛选服务器" style="width:200px"><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
</div></template>
<el-table :data="logs" stripe v-loading="loading">
<el-table-column prop="started_at" label="Time" width="180"><template #default="{row}">{{new Date(row.started_at).toLocaleString()}}</template></el-table-column>
<el-table-column prop="server_id" label="Server" width="80" />
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{row.status}}</el-tag></template></el-table-column>
<el-table-column prop="message" label="Message" />
<el-table-column prop="started_at" label="时间" width="180"><template #default="{row}">{{new Date(row.started_at).toLocaleString()}}</template></el-table-column>
<el-table-column prop="server_id" label="服务器" width="80" />
<el-table-column prop="status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.status==='success'?'success':row.status==='failed'?'danger':'info'">{{statusText(row.status)}}</el-tag></template></el-table-column>
<el-table-column prop="message" label="消息" />
</el-table>
<el-pagination style="margin-top:20px;justify-content:center" layout="prev,pager,next" :page-size="50" @current-change="loadLogs" />
</el-card>
@@ -24,6 +24,8 @@ const logs=ref<any[]>([])
const loading=ref(false)
const filterServer=ref<number|null>(null)
function statusText(s:string){const m:Record<string,string>={success:'成功',failed:'失败',in_progress:'进行中'};return m[s]||s}
async function load(){try{servers.value=(await serverApi.list()).data}catch{}}
async function loadLogs(page=1){
loading.value=true

View File

@@ -1,15 +1,15 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Repositories</span>
<el-select v-model="selectedServer" style="width:200px"><el-option label="All Servers" :value="0" /><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>仓库列表</span>
<el-select v-model="selectedServer" style="width:200px"><el-option label="全部服务器" :value="0" /><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
</div></template>
<el-table :data="repos" stripe v-loading="loading">
<el-table-column prop="full_name" label="Repository" />
<el-table-column prop="server_id" label="Server" width="80" />
<el-table-column prop="size" label="Size" width="120"><template #default="{row}">{{formatSize(row.size)}}</template></el-table-column>
<el-table-column prop="sync_status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.sync_status==='success'?'success':row.sync_status==='failed'?'danger':row.sync_status==='syncing'?'warning':'info'">{{row.sync_status}}</el-tag></template></el-table-column>
<el-table-column prop="last_sync_at" label="Last Sync" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'Never'}}</template></el-table-column>
<el-table-column prop="full_name" label="仓库名称" />
<el-table-column prop="server_id" label="服务器" width="80" />
<el-table-column prop="size" label="大小" width="120"><template #default="{row}">{{formatSize(row.size)}}</template></el-table-column>
<el-table-column prop="sync_status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.sync_status==='success'?'success':row.sync_status==='failed'?'danger':row.sync_status==='syncing'?'warning':'info'">{{statusText(row.sync_status)}}</el-tag></template></el-table-column>
<el-table-column prop="last_sync_at" label="上次同步" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'从未'}}</template></el-table-column>
</el-table>
</el-card>
</div>
@@ -39,6 +39,7 @@ async function loadRepos() {
}
function formatSize(b:number){if(!b)return'0 B';const k=1024,s=['B','KB','MB','GB'];const i=Math.floor(Math.log(b)/Math.log(k));return(b/Math.pow(k,i)).toFixed(1)+' '+s[i]}
function statusText(s:string){const m:Record<string,string>={success:'成功',failed:'失败',in_progress:'同步中',pending:'等待'};return m[s]||s}
watch(selectedServer,loadRepos)
onMounted(async()=>{await load();loadRepos()})

View File

@@ -1,43 +1,68 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Gitea Servers</span><el-button type="primary" @click="showDialog=true;editMode=false;form={name:'',url:'',token:'',sync_interval:0}">Add Server</el-button></div></template>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Gitea 服务器</span><el-button type="primary" @click="showDialog=true;editMode=false;form={name:'',url:'',token:'',sync_interval:0}">添加服务器</el-button></div></template>
<el-table :data="servers" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="Name" />
<el-table-column prop="url" label="URL" />
<el-table-column prop="sync_interval" label="Interval" width="100"><template #default="{row}">{{row.sync_interval>0?row.sync_interval+' min':'Manual'}}</template></el-table-column>
<el-table-column prop="status" label="Status" width="100"><template #default="{row}"><el-tag :type="row.status==='active'?'success':'info'">{{row.status}}</el-tag></template></el-table-column>
<el-table-column prop="last_sync_at" label="Last Sync" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'Never'}}</template></el-table-column>
<el-table-column label="Actions" width="280" fixed="right">
<el-table-column prop="name" label="名称" />
<el-table-column prop="url" label="地址" />
<el-table-column prop="sync_interval" label="同步间隔" width="100"><template #default="{row}">{{row.sync_interval>0?row.sync_interval+' 分钟':'手动'}}</template></el-table-column>
<el-table-column prop="status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.status==='active'?'success':'info'">{{row.status==='active'?'活跃':'禁用'}}</el-tag></template></el-table-column>
<el-table-column prop="last_sync_at" label="上次同步" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'从未'}}</template></el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{row}">
<el-button size="small" @click="discover(row)">Discover</el-button>
<el-button size="small" type="primary" @click="syncOne(row)">Sync</el-button>
<el-button size="small" @click="edit(row)">Edit</el-button>
<el-button size="small" type="danger" @click="remove(row)">Delete</el-button>
<el-button size="small" @click="discover(row)">发现</el-button>
<el-button size="small" type="primary" @click="syncOne(row)">同步</el-button>
<el-button size="small" @click="edit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showDialog" :title="editMode?'Edit Server':'Add Server'" width="500px">
<!-- 同步进度面板 -->
<el-card v-if="progressList.length > 0" style="margin-top:20px">
<template #header><span>同步进度</span></template>
<div v-for="p in progressList" :key="p.server_id" style="margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-weight:500">{{ getServerName(p.server_id) }}</span>
<span style="color:#909399">{{ p.completed }} / {{ p.total }}</span>
</div>
<el-progress
:percentage="p.total > 0 ? Math.round(p.completed / p.total * 100) : 0"
:status="p.status === 'completed' ? 'success' : undefined"
:stroke-width="20"
:text-inside="true"
style="margin-bottom:6px"
/>
<div v-if="p.current" style="display:flex;align-items:center;gap:8px;margin-top:4px">
<el-tag type="warning" size="small" effect="dark">{{ p.current.action }}</el-tag>
<span style="font-size:13px;color:#606266">{{ p.current.full_name }}</span>
</div>
<div v-if="p.status === 'completed'" style="margin-top:4px;font-size:13px;color:#67c23a">
同步完成: {{ p.success_count }} 成功, {{ p.failed_count }} 失败
</div>
</div>
</el-card>
<el-dialog v-model="showDialog" :title="editMode?'编辑服务器':'添加服务器'" width="500px">
<el-form :model="form" label-width="120px">
<el-form-item label="Name"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="URL"><el-input v-model="form.url" placeholder="https://git.example.com" /></el-form-item>
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.url" placeholder="https://git.example.com" /></el-form-item>
<el-form-item label="Token"><el-input v-model="form.token" type="password" show-password /></el-form-item>
<el-form-item label="Sync Interval"><el-input-number v-model="form.sync_interval" :min="0" :max="1440" /><span style="margin-left:10px;color:#909399">min (0=manual)</span></el-form-item>
<el-form-item label="同步间隔"><el-input-number v-model="form.sync_interval" :min="0" :max="1440" /><span style="margin-left:10px;color:#909399">分钟 (0=手动)</span></el-form-item>
</el-form>
<el-space style="margin-top:16px">
<el-button type="primary" @click="save">Save</el-button>
<el-button @click="testConn">Test Connection</el-button>
<el-button @click="showDialog=false">Cancel</el-button>
<el-button type="primary" @click="save">保存</el-button>
<el-button @click="testConn">测试连接</el-button>
<el-button @click="showDialog=false">取消</el-button>
</el-space>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { serverApi } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -47,9 +72,33 @@ const showDialog = ref(false)
const editMode = ref(false)
const editingId = ref(0)
const form = ref({ name:'', url:'', token:'', sync_interval:0 })
const progressList = ref<any[]>([])
let pollTimer: any = null
async function load() { loading.value=true; try { servers.value=(await serverApi.list()).data } catch{} finally{loading.value=false} }
function getServerName(id: number) {
const s = servers.value.find((s: any) => s.id === id)
return s ? s.name : `服务器 #${id}`
}
async function pollProgress() {
if (!servers.value || servers.value.length === 0) return
const syncing = servers.value.filter(() => true)
if (syncing.length === 0) return
const results: any[] = []
for (const s of syncing) {
try {
const r = (await serverApi.syncStatus(s.id)).data
if (r.status !== 'idle') {
results.push(r)
}
} catch {}
}
progressList.value = results
}
function edit(row:any) { editMode.value=true; editingId.value=row.id; form.value={name:row.name,url:row.url,token:'',sync_interval:row.sync_interval}; showDialog.value=true }
async function save() {
@@ -61,18 +110,37 @@ async function save() {
} else {
await serverApi.create(form.value)
}
ElMessage.success(editMode.value?'Updated':'Added'); showDialog.value=false; load()
} catch { ElMessage.error('Failed') }
ElMessage.success(editMode.value?'更新成功':'添加成功'); showDialog.value=false; load()
} catch { ElMessage.error('操作失败') }
}
async function testConn() {
try { const r=(await serverApi.test({url:form.value.url,token:form.value.token})).data; ElMessage.success('Connected! User: '+r.user) }
catch { ElMessage.error('Connection failed') }
try { const r=(await serverApi.test({url:form.value.url,token:form.value.token})).data; ElMessage.success('连接成功!用户: '+r.user) }
catch { ElMessage.error('连接失败') }
}
async function discover(row:any) { try { const r=(await serverApi.discover(row.id)).data; ElMessage.success(r.message) } catch { ElMessage.error('Failed') } }
async function syncOne(row:any) { try { await serverApi.sync(row.id); ElMessage.success('Sync started') } catch(e:any) { if(e.response?.status===409) ElMessage.warning('Already syncing'); else ElMessage.error('Failed') } }
function remove(row:any) { ElMessageBox.confirm('Delete this server?','Confirm',{type:'warning'}).then(async()=>{ await serverApi.delete(row.id); ElMessage.success('Deleted'); load() }).catch(()=>{}) }
async function discover(row:any) { try { const r=(await serverApi.discover(row.id)).data; ElMessage.success(r.message) } catch(e:any) { ElMessage.error('发现失败: '+(e.response?.data?.error||e.message)) } }
onMounted(load)
async function syncOne(row:any) {
try {
await serverApi.sync(row.id)
ElMessage.success('同步已开始')
pollProgress()
} catch(e:any) {
if(e.response?.status===409) ElMessage.warning('正在同步中')
else ElMessage.error('操作失败')
}
}
function remove(row:any) { ElMessageBox.confirm('确定删除该服务器?','确认',{type:'warning'}).then(async()=>{ await serverApi.delete(row.id); ElMessage.success('已删除'); load() }).catch(()=>{}) }
onMounted(() => {
load()
pollTimer = setInterval(pollProgress, 2000)
pollProgress()
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>

View File

@@ -1,15 +1,15 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><span>Settings</span></template>
<template #header><span>系统设置</span></template>
<el-form :model="form" label-width="150px" style="max-width:600px">
<el-form-item label="New Password"><el-input v-model="form.password" type="password" show-password placeholder="Leave empty to keep" /></el-form-item>
<el-form-item label="Listen Address"><el-input v-model="form.listen_addr" /></el-form-item>
<el-form-item label="Repos Directory"><el-input v-model="form.repos_dir" /></el-form-item>
<el-form-item label="Max Concurrent"><el-input-number v-model="form.max_concurrent" :min="1" :max="10" /></el-form-item>
<el-form-item><el-button type="primary" @click="save">Save</el-button></el-form-item>
<el-form-item label="新密码"><el-input v-model="form.password" type="password" show-password placeholder="留空则不修改" /></el-form-item>
<el-form-item label="监听地址"><el-input v-model="form.listen_addr" /></el-form-item>
<el-form-item label="仓库目录"><el-input v-model="form.repos_dir" /></el-form-item>
<el-form-item label="最大并发数"><el-input-number v-model="form.max_concurrent" :min="1" :max="10" /></el-form-item>
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
</el-form>
<el-alert type="info" title="Listen address change requires restart" :closable="false" style="margin-top:16px" />
<el-alert type="info" title="修改监听地址后需要重启程序才能生效" :closable="false" style="margin-top:16px" />
</el-card>
</div>
</template>
@@ -30,8 +30,8 @@ async function save(){
try{
const data:any={listen_addr:form.value.listen_addr,repos_dir:form.value.repos_dir,max_concurrent:form.value.max_concurrent}
if(form.value.password)data.admin_password=form.value.password
await authApi.updateSettings(data);ElMessage.success('Saved');form.value.password=''
}catch{ElMessage.error('Failed')}
await authApi.updateSettings(data);ElMessage.success('保存成功');form.value.password=''
}catch{ElMessage.error('保存失败')}
}
onMounted(load)