feat: implement Vue 3 frontend with all pages

Complete web UI built with Vue 3, TypeScript, Element Plus, and Pinia.
Includes Login, Dashboard, Servers, Repositories, Sync Logs, and Settings pages
with API client, auth store, and Vue Router configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
panw
2026-03-31 16:38:45 +08:00
parent 333e999d76
commit 373cb70633
19 changed files with 2360 additions and 0 deletions

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitM - Gitea Repository Sync</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1774
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
web/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "gitm-web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

3
web/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

46
web/src/api/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(r) => r,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const authApi = {
login: (password: string) => api.post('/login', { password }),
getSettings: () => api.get('/settings'),
updateSettings: (data: any) => api.put('/settings', data),
}
export const serverApi = {
list: () => api.get('/servers'),
create: (data: any) => api.post('/servers', data),
update: (id: number, data: any) => api.put(`/servers/${id}`, data),
delete: (id: number) => api.delete(`/servers/${id}`),
test: (data: any) => api.post('/servers/test', data),
getRepos: (id: number) => api.get(`/servers/${id}/repos`),
discover: (id: number) => api.post(`/servers/${id}/discover`),
sync: (id: number) => api.post(`/servers/${id}/sync`),
syncStatus: (id: number) => api.get(`/servers/${id}/sync/status`),
}
export const syncApi = {
syncAll: () => api.post('/sync/all'),
getLogs: (params?: any) => api.get('/sync/logs', { params }),
getStats: () => api.get('/sync/stats'),
}
export default api

39
web/src/api/types.ts Normal file
View File

@@ -0,0 +1,39 @@
export interface GiteaServer {
id: number
name: string
url: string
sync_interval: number
last_sync_at: string | null
status: string
created_at: string
}
export interface Repo {
id: number
server_id: number
name: string
full_name: string
clone_url: string
local_path: string
size: number
last_sync_at: string | null
sync_status: string
created_at: string
}
export interface SyncLog {
id: number
server_id: number
repo_id: number | null
status: string
message: string
started_at: string
finished_at: string | null
}
export interface Stats {
server_count: number
active_servers: number
repo_count: number
total_size: number
}

6
web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

16
web/src/main.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

37
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
children: [
{ path: '', name: 'Dashboard', component: () => import('@/views/Dashboard.vue') },
{ path: 'servers', name: 'Servers', component: () => import('@/views/Servers.vue') },
{ path: 'repos', name: 'Repos', component: () => import('@/views/Repos.vue') },
{ path: 'logs', name: 'Logs', component: () => import('@/views/Logs.vue') },
{ path: 'settings', name: 'Settings', component: () => import('@/views/Settings.vue') }
]
}
]
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/')
} else {
next()
}
})
export default router

27
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const loading = ref(false)
const isAuthenticated = computed(() => !!token.value)
async function login(password: string) {
loading.value = true
try {
const res = await authApi.login(password)
token.value = res.data.token
localStorage.setItem('token', res.data.token)
return true
} catch { return false }
finally { loading.value = false }
}
function logout() {
token.value = null
localStorage.removeItem('token')
}
return { token, loading, isAuthenticated, login, logout }
})

View File

@@ -0,0 +1,53 @@
<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-row>
<el-card style="margin-top:20px">
<template #header><span>Quick Actions</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-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>
<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>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { syncApi } from '@/api'
import { ElMessage } from 'element-plus'
const stats = ref({ server_count:0, active_servers:0, repo_count:0, total_size:0 })
const logs = ref<any[]>([])
const syncing = ref(false)
async function loadData() {
try { stats.value = (await syncApi.getStats()).data } catch {}
try { logs.value = (await syncApi.getLogs({limit:10})).data.data || [] } catch {}
}
async function syncAll() {
syncing.value = true
try { await syncApi.syncAll(); ElMessage.success('Sync started'); setTimeout(loadData, 1000) }
catch { ElMessage.error('Failed') }
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() }
onMounted(loadData)
</script>

42
web/src/views/Layout.vue Normal file
View File

@@ -0,0 +1,42 @@
<template>
<el-container style="height:100vh">
<el-aside width="200px" style="background:#001529">
<div style="padding:20px;text-align:center;border-bottom:1px solid #1f1f1f">
<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>
</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>
</div>
</el-header>
<el-main style="background:#f5f5f5"><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
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' }
return titles[route.path] || 'GitM'
})
function handleLogout() { authStore.logout(); router.push('/login') }
</script>

44
web/src/views/Login.vue Normal file
View File

@@ -0,0 +1,44 @@
<template>
<div style="display:flex;align-items:center;justify-content:center;height:100vh;background:linear-gradient(135deg,#667eea,#764ba2)">
<el-card style="width:400px">
<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>
</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-form-item>
<el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">Login</el-button>
</el-form-item>
</el-form>
<el-alert v-if="error" type="error" :title="error" :closable="false" style="margin-top:16px" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
if (!password.value) { error.value = 'Please enter password'; 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' }
}
</script>

36
web/src/views/Logs.vue Normal file
View File

@@ -0,0 +1,36 @@
<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>
</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>
<el-pagination style="margin-top:20px;justify-content:center" layout="prev,pager,next" :page-size="50" @current-change="loadLogs" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { serverApi, syncApi } from '@/api'
const servers=ref<any[]>([])
const logs=ref<any[]>([])
const loading=ref(false)
const filterServer=ref<number|null>(null)
async function load(){try{servers.value=(await serverApi.list()).data}catch{}}
async function loadLogs(page=1){
loading.value=true
try{const params:any={page,limit:50};if(filterServer.value)params.server_id=filterServer.value;const r=(await syncApi.getLogs(params)).data;logs.value=r.data||[];console.log(r)}
catch{}finally{loading.value=false}
}
watch(filterServer,()=>loadLogs(1))
onMounted(async()=>{await load();loadLogs()})
</script>

45
web/src/views/Repos.vue Normal file
View File

@@ -0,0 +1,45 @@
<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>
</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>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { serverApi } from '@/api'
const servers = ref<any[]>([])
const repos = ref<any[]>([])
const loading = ref(false)
const selectedServer = ref(0)
async function load() { try { servers.value=(await serverApi.list()).data } catch{} }
async function loadRepos() {
loading.value=true
try {
const all:any[]=[]
for(const s of servers.value) {
if(selectedServer.value===0||selectedServer.value===s.id) {
const r=(await serverApi.getRepos(s.id)).data; all.push(...r)
}
}
repos.value=all
} catch{} finally{loading.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]}
watch(selectedServer,loadRepos)
onMounted(async()=>{await load();loadRepos()})
</script>

78
web/src/views/Servers.vue Normal file
View File

@@ -0,0 +1,78 @@
<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>
<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">
<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>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showDialog" :title="editMode?'Edit Server':'Add Server'" 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="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>
<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-space>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { serverApi } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const servers = ref<any[]>([])
const loading = ref(false)
const showDialog = ref(false)
const editMode = ref(false)
const editingId = ref(0)
const form = ref({ name:'', url:'', token:'', sync_interval:0 })
async function load() { loading.value=true; try { servers.value=(await serverApi.list()).data } catch{} finally{loading.value=false} }
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() {
try {
if(editMode.value) {
const data:any={name:form.value.name,url:form.value.url,sync_interval:form.value.sync_interval}
if(form.value.token) data.token=form.value.token
await serverApi.update(editingId.value, data)
} else {
await serverApi.create(form.value)
}
ElMessage.success(editMode.value?'Updated':'Added'); showDialog.value=false; load()
} catch { ElMessage.error('Failed') }
}
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') }
}
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(()=>{}) }
onMounted(load)
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><span>Settings</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>
<el-alert type="info" title="Listen address change requires restart" :closable="false" style="margin-top:16px" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { authApi } from '@/api'
import { ElMessage } from 'element-plus'
const form=ref({password:'',listen_addr:':9000',repos_dir:'',max_concurrent:3})
async function load(){
try{const r=(await authApi.getSettings()).data;form.value={password:'',listen_addr:r.listen_addr||':9000',repos_dir:r.repos_dir||'',max_concurrent:r.max_concurrent||3}}
catch{}
}
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')}
}
onMounted(load)
</script>

16
web/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

25
web/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:9000',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})