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:
12
web/index.html
Normal file
12
web/index.html
Normal 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
1774
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
web/package.json
Normal file
23
web/package.json
Normal 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
3
web/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
46
web/src/api/index.ts
Normal file
46
web/src/api/index.ts
Normal 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
39
web/src/api/types.ts
Normal 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
6
web/src/env.d.ts
vendored
Normal 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
16
web/src/main.ts
Normal 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
37
web/src/router/index.ts
Normal 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
27
web/src/stores/auth.ts
Normal 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 }
|
||||
})
|
||||
53
web/src/views/Dashboard.vue
Normal file
53
web/src/views/Dashboard.vue
Normal 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
42
web/src/views/Layout.vue
Normal 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
44
web/src/views/Login.vue
Normal 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
36
web/src/views/Logs.vue
Normal 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
45
web/src/views/Repos.vue
Normal 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
78
web/src/views/Servers.vue
Normal 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>
|
||||
38
web/src/views/Settings.vue
Normal file
38
web/src/views/Settings.vue
Normal 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
16
web/tsconfig.json
Normal 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
25
web/vite.config.ts
Normal 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'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user