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

181
README.md
View File

@@ -1,60 +1,161 @@
# GitM - Gitea Repository Sync Tool # GitM - Gitea 仓库镜像同步工具
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage. 将多个 Gitea 服务器的仓库自动镜像同步到本地存储,支持定时同步和手动同步。
## Features ## 功能特性
- Single binary deployment (frontend embedded) - **单文件部署** - 前端内嵌到 Go 二进制,无需额外依赖
- Web UI for management (Vue 3 + Element Plus) - **Web 管理界面** - Vue 3 + Element Plus,中文界面
- SQLite database - **多服务器管理** - 同时管理多个 Gitea 服务器
- JWT authentication - **仓库自动发现** - 一键发现服务器上的所有仓库
- Scheduled and manual sync - **镜像同步** - 使用 `git clone --mirror` 完整镜像,支持 Token 认证
- Cross-platform (Windows, Linux) - **实时进度** - 同步过程实时展示当前克隆/同步的仓库及进度
- **定时同步** - 可配置每个服务器的自动同步间隔
- **SQLite 存储** - 轻量级数据库,无需额外安装
## Build ## 快速开始
### 前置要求
- Go 1.21+(需要 CGO 支持 SQLite
- GCCWindows 下需安装 MinGW-w64
- Node.js 18+(仅开发时需要)
### 安装与运行
```bash ```bash
# Build binary with embedded frontend # 1. 初始化(设置管理员密码)
go build -o bin/gitm . $env:CGO_ENABLED="1"; go run main.go --init
# Or with CGO for SQLite support # 2. 启动服务(默认监听 :9000
CGO_ENABLED=1 go build -o bin/gitm . $env:CGO_ENABLED="1"; go run main.go
``` ```
## Run 启动后浏览器访问 `http://localhost:9000`
### 使用流程
1. **登录** - 使用初始化时设置的密码登录
2. **添加服务器** - 填写 Gitea 服务器地址和 API Token
3. **测试连接** - 验证服务器和 Token 是否可用
4. **发现仓库** - 扫描服务器上的所有仓库
5. **同步** - 将仓库镜像克隆到本地,支持手动和定时同步
## 打包部署
```bash ```bash
# First-time initialization (set admin password) # 构建前端
./bin/gitm --init cd web
npm install
npm run build
cd ..
# Start server (default :9000) # 打包为单文件 exe
./bin/gitm $env:CGO_ENABLED="1"; go build -o gitm.exe
# Custom address
./bin/gitm --addr :9090
# Custom data directory
./bin/gitm --data /path/to/data
``` ```
## Usage 打包后的 `gitm.exe` 包含前后端,直接运行即可。
1. Initialize: `./bin/gitm --init` -- sets admin password ### 运行
2. Start: `./bin/gitm` -- starts web server
3. Open browser to `http://localhost:9000`
4. Login and add Gitea servers
5. Discover repos and sync
## Development
```bash ```bash
# Frontend dev server # 首次初始化
cd web && npm install && npm run dev .\gitm.exe --init
# Backend # 启动服务
go run main.go .\gitm.exe
# Run tests # 指定监听地址
go test ./internal/gitea/... -v .\gitm.exe --addr :8080
CGO_ENABLED=1 go test ./internal/database/... -v
# 指定数据目录
.\gitm.exe --data /path/to/data
``` ```
## 开发模式
前后端分别启动,支持热更新:
```bash
# 终端 1启动后端默认 :9000
$env:CGO_ENABLED="1"; go run main.go
# 终端 2启动前端开发服务器:5173自动代理 API 到 :9000
cd web
npm install
npm run dev
```
开发时访问 `http://localhost:5173`
## 命令行参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `--init` | 初始化数据库并设置管理员密码 | - |
| `--addr` | 监听地址 | `:9000` |
| `--data` | 数据存储目录 | `./data` |
## 系统设置
通过 Web 界面可配置:
- **管理员密码** - 修改登录密码
- **监听地址** - 修改服务端口(需重启)
- **仓库目录** - 仓库镜像存储路径
- **最大并发数** - 同步时的并发数量1-10
## 项目结构
```
├── main.go # 程序入口
├── internal/
│ ├── config/ # 配置管理
│ ├── database/ # SQLite 数据库操作
│ ├── gitea/ # Gitea API 客户端
│ ├── handler/ # HTTP 请求处理
│ ├── middleware/ # JWT 认证中间件
│ ├── models/ # 数据模型
│ └── sync/ # 同步引擎与调度器
├── web/ # Vue 3 前端
│ ├── src/
│ │ ├── api/ # API 请求封装
│ │ ├── views/ # 页面组件
│ │ ├── stores/ # Pinia 状态管理
│ │ └── router/ # 路由配置
│ └── vite.config.ts
└── data/ # 运行时数据(自动创建)
├── gitm.db # SQLite 数据库
└── repos/ # 镜像仓库存储
```
## API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/login` | 登录 |
| POST | `/api/init` | 初始化密码 |
| GET | `/api/settings` | 获取系统设置 |
| PUT | `/api/settings` | 更新系统设置 |
| GET | `/api/servers` | 服务器列表 |
| POST | `/api/servers` | 添加服务器 |
| PUT | `/api/servers/:id` | 更新服务器 |
| DELETE | `/api/servers/:id` | 删除服务器 |
| POST | `/api/servers/test` | 测试连接 |
| GET | `/api/servers/:id/repos` | 仓库列表 |
| POST | `/api/servers/:id/discover` | 发现仓库 |
| POST | `/api/servers/:id/sync` | 同步服务器 |
| GET | `/api/servers/:id/sync/status` | 同步进度 |
| POST | `/api/sync/all` | 同步全部服务器 |
| GET | `/api/sync/logs` | 同步日志 |
| GET | `/api/sync/stats` | 同步统计 |
## 技术栈
**后端:** Go / Gin / SQLite / JWT
**前端:** Vue 3 / TypeScript / Element Plus / Vite / Pinia
## License
MIT

View File

@@ -2,12 +2,13 @@ package database
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
"gitm/internal/models" "gitm/internal/models"
) )
func TestDatabase(t *testing.T) { func TestDatabase(t *testing.T) {
tmpDB := "/tmp/test_gitm.db" tmpDB := filepath.Join(os.TempDir(), "test_gitm.db")
defer os.Remove(tmpDB) defer os.Remove(tmpDB)
if err := Initialize(tmpDB); err != nil { if err := Initialize(tmpDB); err != nil {

View File

@@ -30,11 +30,26 @@ func NewClient(serverURL, token string) (*Client, error) {
} }
func (c *Client) do(path string) (*http.Response, error) { func (c *Client) do(path string) (*http.Response, error) {
apiURL := c.baseURL.JoinPath(path) reqURL := c.baseURL.JoinPath(path)
q := apiURL.Query() q := reqURL.Query()
q.Set("token", c.token) q.Set("token", c.token)
apiURL.RawQuery = q.Encode() reqURL.RawQuery = q.Encode()
resp, err := c.httpClient.Get(apiURL.String()) resp, err := c.httpClient.Get(reqURL.String())
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
return resp, nil
}
func (c *Client) doSearch(path string, params map[string]string) (*http.Response, error) {
reqURL := c.baseURL.JoinPath(path)
q := reqURL.Query()
q.Set("token", c.token)
for k, v := range params {
q.Set(k, v)
}
reqURL.RawQuery = q.Encode()
resp, err := c.httpClient.Get(reqURL.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, fmt.Errorf("request failed: %w", err)
} }
@@ -59,8 +74,10 @@ func (c *Client) ValidateToken() (*GiteaUser, error) {
} }
func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) { func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) {
path := fmt.Sprintf("/api/v1/repos/search?page=%d&limit=%d", page, limit) resp, err := c.doSearch("/api/v1/repos/search", map[string]string{
resp, err := c.do(path) "page": fmt.Sprintf("%d", page),
"limit": fmt.Sprintf("%d", limit),
})
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -229,10 +229,7 @@ func HandleGetSyncStatus(engine *sync.Engine) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return return
} }
status := "idle" progress := engine.GetProgress(id)
if engine.IsSyncing(id) { c.JSON(http.StatusOK, progress)
status = "syncing"
}
c.JSON(http.StatusOK, gin.H{"server_id": id, "status": status})
} }
} }

View File

@@ -2,6 +2,7 @@ package sync
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -13,16 +14,75 @@ import (
"gitm/internal/models" "gitm/internal/models"
) )
type RepoProgress struct {
FullName string `json:"full_name"`
Action string `json:"action"` // "cloning" or "fetching"
}
type ServerProgress struct {
ServerID int64 `json:"server_id"`
Total int `json:"total"`
Completed int `json:"completed"`
SuccessCount int `json:"success_count"`
FailedCount int `json:"failed_count"`
Current *RepoProgress `json:"current"`
Status string `json:"status"` // "syncing" or "idle"
}
type Engine struct { type Engine struct {
maxConcurrent int maxConcurrent int
mu sync.Mutex mu sync.Mutex
activeTasks map[int64]string activeTasks map[int64]string
progress map[int64]*ServerProgress
progressMu sync.RWMutex
} }
func NewEngine(maxConcurrent int) *Engine { func NewEngine(maxConcurrent int) *Engine {
return &Engine{ return &Engine{
maxConcurrent: maxConcurrent, maxConcurrent: maxConcurrent,
activeTasks: make(map[int64]string), activeTasks: make(map[int64]string),
progress: make(map[int64]*ServerProgress),
}
}
func (e *Engine) GetProgress(serverID int64) *ServerProgress {
e.progressMu.RLock()
defer e.progressMu.RUnlock()
if p, ok := e.progress[serverID]; ok {
return p
}
return &ServerProgress{ServerID: serverID, Status: "idle"}
}
func (e *Engine) setProgress(serverID int64, p *ServerProgress) {
e.progressMu.Lock()
defer e.progressMu.Unlock()
e.progress[serverID] = p
}
func (e *Engine) updateProgressCounts(serverID int64, successDelta, failedDelta int) {
e.progressMu.Lock()
defer e.progressMu.Unlock()
if p, ok := e.progress[serverID]; ok {
p.SuccessCount += successDelta
p.FailedCount += failedDelta
p.Completed = p.SuccessCount + p.FailedCount
}
}
func (e *Engine) setCurrentRepo(serverID int64, fullName, action string) {
e.progressMu.Lock()
defer e.progressMu.Unlock()
if p, ok := e.progress[serverID]; ok {
p.Current = &RepoProgress{FullName: fullName, Action: action}
}
}
func (e *Engine) clearCurrentRepo(serverID int64) {
e.progressMu.Lock()
defer e.progressMu.Unlock()
if p, ok := e.progress[serverID]; ok {
p.Current = nil
} }
} }
@@ -47,33 +107,52 @@ func (e *Engine) SyncServer(serverID int64) error {
return fmt.Errorf("failed to get server: %w", err) return fmt.Errorf("failed to get server: %w", err)
} }
// Initialize progress
e.setProgress(serverID, &ServerProgress{
ServerID: serverID,
Total: 0,
Status: "syncing",
})
syncLog := &models.SyncLog{ syncLog := &models.SyncLog{
ServerID: serverID, ServerID: serverID,
Status: "in_progress", Status: "in_progress",
Message: "Starting sync", Message: "开始同步",
StartedAt: time.Now(), StartedAt: time.Now(),
} }
if err := database.CreateSyncLog(syncLog); err != nil { if err := database.CreateSyncLog(syncLog); err != nil {
return fmt.Errorf("failed to create sync log: %w", err) return fmt.Errorf("failed to create sync log: %w", err)
} }
e.setCurrentRepo(serverID, "", "验证认证中")
client, err := gitea.NewClient(server.URL, server.Token) client, err := gitea.NewClient(server.URL, server.Token)
if err != nil { if err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("Failed to create client: %v", err)) e.finishLog(syncLog, "failed", fmt.Sprintf("创建客户端失败: %v", err))
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
return err return err
} }
if _, err := client.ValidateToken(); err != nil { if _, err := client.ValidateToken(); err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("Authentication failed: %v", err)) e.finishLog(syncLog, "failed", fmt.Sprintf("认证失败: %v", err))
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
return err return err
} }
e.setCurrentRepo(serverID, "", "获取仓库列表中")
giteaRepos, err := client.GetAllRepos() giteaRepos, err := client.GetAllRepos()
if err != nil { if err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("Failed to fetch repos: %v", err)) e.finishLog(syncLog, "failed", fmt.Sprintf("获取仓库列表失败: %v", err))
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
return err return err
} }
// Update total count
e.progressMu.Lock()
if p, ok := e.progress[serverID]; ok {
p.Total = len(giteaRepos)
}
e.progressMu.Unlock()
reposDir, _ := database.GetSetting("repos_dir") reposDir, _ := database.GetSetting("repos_dir")
if reposDir == "" { if reposDir == "" {
reposDir = "./data/repos" reposDir = "./data/repos"
@@ -81,11 +160,8 @@ func (e *Engine) SyncServer(serverID int64) error {
serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name)) serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name))
os.MkdirAll(serverDir, 0755) os.MkdirAll(serverDir, 0755)
successCount := 0
failedCount := 0
sem := make(chan struct{}, e.maxConcurrent) sem := make(chan struct{}, e.maxConcurrent)
var wg sync.WaitGroup var wg sync.WaitGroup
var resultsMu sync.Mutex
for _, gr := range giteaRepos { for _, gr := range giteaRepos {
wg.Add(1) wg.Add(1)
@@ -99,7 +175,8 @@ func (e *Engine) SyncServer(serverID int64) error {
var err error var err error
if existingRepo == nil { if existingRepo == nil {
err = e.cloneMirror(giteaRepo.CloneURL, localPath) e.setCurrentRepo(serverID, giteaRepo.FullName, "克隆")
err = e.cloneMirror(giteaRepo.CloneURL, server.Token, localPath)
if err == nil { if err == nil {
repo := &models.Repo{ repo := &models.Repo{
ServerID: serverID, ServerID: serverID,
@@ -112,15 +189,25 @@ func (e *Engine) SyncServer(serverID int64) error {
database.CreateRepo(repo) database.CreateRepo(repo)
} }
} else { } else {
if _, statErr := os.Stat(localPath); statErr != nil {
e.setCurrentRepo(serverID, giteaRepo.FullName, "克隆")
err = e.cloneMirror(giteaRepo.CloneURL, server.Token, localPath)
if err == nil {
existingRepo.LocalPath = localPath
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
}
} else {
e.setCurrentRepo(serverID, giteaRepo.FullName, "同步")
err = e.fetchMirror(localPath) err = e.fetchMirror(localPath)
if err == nil { if err == nil {
database.UpdateRepoSyncStatus(existingRepo.ID, "success") database.UpdateRepoSyncStatus(existingRepo.ID, "success")
} }
} }
}
resultsMu.Lock() e.clearCurrentRepo(serverID)
if err != nil { if err != nil {
failedCount++ e.updateProgressCounts(serverID, 0, 1)
failLog := &models.SyncLog{ failLog := &models.SyncLog{
ServerID: serverID, ServerID: serverID,
RepoID: getRepoID(serverID, giteaRepo.FullName), RepoID: getRepoID(serverID, giteaRepo.FullName),
@@ -130,26 +217,48 @@ func (e *Engine) SyncServer(serverID int64) error {
} }
database.CreateSyncLog(failLog) database.CreateSyncLog(failLog)
} else { } else {
successCount++ e.updateProgressCounts(serverID, 1, 0)
} }
resultsMu.Unlock()
}(gr) }(gr)
} }
wg.Wait() wg.Wait()
database.UpdateServerLastSync(serverID) database.UpdateServerLastSync(serverID)
message := fmt.Sprintf("Sync completed: %d succeeded, %d failed", successCount, failedCount) // Get final counts for message
p := e.GetProgress(serverID)
message := fmt.Sprintf("同步完成: %d 成功, %d 失败", p.SuccessCount, p.FailedCount)
e.finishLog(syncLog, "success", message) e.finishLog(syncLog, "success", message)
// Mark as idle but keep final progress visible briefly
e.progressMu.Lock()
finalP := e.progress[serverID]
if finalP != nil {
finalP.Current = nil
finalP.Status = "completed"
}
e.progressMu.Unlock()
// Clear after 30 seconds
go func() {
time.Sleep(30 * time.Second)
e.progressMu.Lock()
if p, ok := e.progress[serverID]; ok && p.Status == "completed" {
p.Status = "idle"
}
e.progressMu.Unlock()
}()
return nil return nil
} }
func (e *Engine) cloneMirror(url, path string) error { func (e *Engine) cloneMirror(cloneURL, token, path string) error {
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
return nil return nil
} }
os.MkdirAll(filepath.Dir(path), 0755) os.MkdirAll(filepath.Dir(path), 0755)
cmd := exec.Command("git", "clone", "--mirror", url, path) authURL := injectToken(cloneURL, token)
cmd := exec.Command("git", "clone", "--mirror", authURL, path)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("clone failed: %w - %s", err, string(output)) return fmt.Errorf("clone failed: %w - %s", err, string(output))
@@ -157,6 +266,15 @@ func (e *Engine) cloneMirror(url, path string) error {
return nil return nil
} }
func injectToken(rawURL, token string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
u.User = url.UserPassword(token, "x-oauth-basic")
return u.String()
}
func (e *Engine) fetchMirror(path string) error { func (e *Engine) fetchMirror(path string) error {
cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune") cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()

25
main.go
View File

@@ -101,16 +101,25 @@ func runServer(cfg *config.Config, engine *sync.Engine) error {
// Serve embedded frontend // Serve embedded frontend
distFS, err := fs.Sub(webFS, "web/dist") distFS, err := fs.Sub(webFS, "web/dist")
if err == nil { if err == nil {
// Serve assets fileServer := http.FileServer(http.FS(distFS))
r.StaticFS("/assets", http.FS(distFS))
// SPA fallback - serve index.html for non-API routes
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api") { if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
// Try to serve static file first
name := strings.TrimPrefix(c.Request.URL.Path, "/")
if name != "" {
f, err := distFS.Open(name)
if err == nil {
f.Close()
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
}
// SPA fallback - serve index.html
data, _ := webFS.ReadFile("web/dist/index.html") data, _ := webFS.ReadFile("web/dist/index.html")
c.Data(http.StatusOK, "text/html; charset=utf-8", data) c.Data(http.StatusOK, "text/html; charset=utf-8", data)
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
}
}) })
} }
@@ -124,10 +133,10 @@ func runServer(cfg *config.Config, engine *sync.Engine) error {
protected.GET("/settings", handler.HandleGetSettings) protected.GET("/settings", handler.HandleGetSettings)
protected.PUT("/settings", handler.HandleUpdateSettings) protected.PUT("/settings", handler.HandleUpdateSettings)
protected.GET("/servers", handler.HandleListServers) protected.GET("/servers", handler.HandleListServers)
protected.POST("/servers/test", handler.HandleTestConnection)
protected.POST("/servers", handler.HandleCreateServer) protected.POST("/servers", handler.HandleCreateServer)
protected.PUT("/servers/:id", handler.HandleUpdateServer) protected.PUT("/servers/:id", handler.HandleUpdateServer)
protected.DELETE("/servers/:id", handler.HandleDeleteServer) protected.DELETE("/servers/:id", handler.HandleDeleteServer)
protected.POST("/servers/:id/test", handler.HandleTestConnection)
protected.GET("/servers/:id/repos", handler.HandleListRepos) protected.GET("/servers/:id/repos", handler.HandleListRepos)
protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos) protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos)
protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine)) protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine))

View File

@@ -1,25 +1,25 @@
<template> <template>
<div style="padding:20px"> <div style="padding:20px">
<el-row :gutter="20"> <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="服务器总数" :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="活跃服务器" :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="仓库总数" :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="formatSize(stats.total_size)" /></el-card></el-col>
</el-row> </el-row>
<el-card style="margin-top:20px"> <el-card style="margin-top:20px">
<template #header><span>Quick Actions</span></template> <template #header><span>快捷操作</span></template>
<el-space> <el-space>
<el-button type="primary" :loading="syncing" @click="syncAll">Sync All</el-button> <el-button type="primary" :loading="syncing" @click="syncAll">同步全部</el-button>
<el-button @click="$router.push('/servers')">Add Server</el-button> <el-button @click="$router.push('/servers')">添加服务器</el-button>
</el-space> </el-space>
</el-card> </el-card>
<el-card style="margin-top:20px"> <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 :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="started_at" label="时间" 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="server_id" label="服务器" 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="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="Message" /> <el-table-column prop="message" label="消息" />
</el-table> </el-table>
</el-card> </el-card>
</div> </div>
@@ -41,13 +41,14 @@ async function loadData() {
async function syncAll() { async function syncAll() {
syncing.value = true syncing.value = true
try { await syncApi.syncAll(); ElMessage.success('Sync started'); setTimeout(loadData, 1000) } try { await syncApi.syncAll(); ElMessage.success('同步已开始'); setTimeout(loadData, 1000) }
catch { ElMessage.error('Failed') } catch { ElMessage.error('操作失败') }
finally { syncing.value = false } 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 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 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) onMounted(loadData)
</script> </script>

View File

@@ -5,18 +5,18 @@
<h2 style="margin:0;color:#fff">GitM</h2> <h2 style="margin:0;color:#fff">GitM</h2>
</div> </div>
<el-menu :default-active="$route.path" router background-color="#001529" text-color="#fff" active-text-color="#1890ff"> <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="/"><el-icon><DataBoard /></el-icon><span>仪表盘</span></el-menu-item>
<el-menu-item index="/servers"><el-icon><Monitor /></el-icon><span>Servers</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>Repositories</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>Sync Logs</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>Settings</span></el-menu-item> <el-menu-item index="/settings"><el-icon><Setting /></el-icon><span>系统设置</span></el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-container> <el-container>
<el-header style="background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center"> <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"> <div style="width:100%;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:18px;font-weight:500">{{ pageTitle }}</span> <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> </div>
</el-header> </el-header>
<el-main style="background:#f5f5f5"><router-view /></el-main> <el-main style="background:#f5f5f5"><router-view /></el-main>
@@ -34,7 +34,7 @@ const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const pageTitle = computed(() => { 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' return titles[route.path] || 'GitM'
}) })

View File

@@ -4,15 +4,15 @@
<template #header> <template #header>
<div style="text-align:center"> <div style="text-align:center">
<h1 style="margin:0;color:#409eff">GitM</h1> <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> </div>
</template> </template>
<el-form @submit.prevent="handleLogin"> <el-form @submit.prevent="handleLogin">
<el-form-item> <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-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-item>
</el-form> </el-form>
<el-alert v-if="error" type="error" :title="error" :closable="false" style="margin-top:16px" /> <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) const loading = ref(false)
async function handleLogin() { async function handleLogin() {
if (!password.value) { error.value = 'Please enter password'; return } if (!password.value) { error.value = '请输入密码'; return }
error.value = '' error.value = ''
loading.value = true loading.value = true
const ok = await authStore.login(password.value) const ok = await authStore.login(password.value)
loading.value = false loading.value = false
if (ok) { ElMessage.success('Login successful'); router.push('/') } if (ok) { ElMessage.success('登录成功'); router.push('/') }
else { error.value = 'Invalid password' } else { error.value = '密码错误' }
} }
</script> </script>

View File

@@ -1,14 +1,14 @@
<template> <template>
<div style="padding:20px"> <div style="padding:20px">
<el-card> <el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Sync Logs</span> <template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>同步日志</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> <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> </div></template>
<el-table :data="logs" stripe v-loading="loading"> <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="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="Server" width="80" /> <el-table-column prop="server_id" label="服务器" 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="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="Message" /> <el-table-column prop="message" label="消息" />
</el-table> </el-table>
<el-pagination style="margin-top:20px;justify-content:center" layout="prev,pager,next" :page-size="50" @current-change="loadLogs" /> <el-pagination style="margin-top:20px;justify-content:center" layout="prev,pager,next" :page-size="50" @current-change="loadLogs" />
</el-card> </el-card>
@@ -24,6 +24,8 @@ const logs=ref<any[]>([])
const loading=ref(false) const loading=ref(false)
const filterServer=ref<number|null>(null) 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 load(){try{servers.value=(await serverApi.list()).data}catch{}}
async function loadLogs(page=1){ async function loadLogs(page=1){
loading.value=true loading.value=true

View File

@@ -1,15 +1,15 @@
<template> <template>
<div style="padding:20px"> <div style="padding:20px">
<el-card> <el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>Repositories</span> <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="All Servers" :value="0" /><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select> <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> </div></template>
<el-table :data="repos" stripe v-loading="loading"> <el-table :data="repos" stripe v-loading="loading">
<el-table-column prop="full_name" label="Repository" /> <el-table-column prop="full_name" label="仓库名称" />
<el-table-column prop="server_id" label="Server" width="80" /> <el-table-column prop="server_id" label="服务器" 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="size" label="大小" 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="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="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="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-table>
</el-card> </el-card>
</div> </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 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) watch(selectedServer,loadRepos)
onMounted(async()=>{await load();loadRepos()}) onMounted(async()=>{await load();loadRepos()})

View File

@@ -1,43 +1,68 @@
<template> <template>
<div style="padding:20px"> <div style="padding:20px">
<el-card> <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 :data="servers" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="60" /> <el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="Name" /> <el-table-column prop="name" label="名称" />
<el-table-column prop="url" label="URL" /> <el-table-column prop="url" label="地址" />
<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="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="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="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="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="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="Actions" width="280" fixed="right"> <el-table-column label="操作" width="280" fixed="right">
<template #default="{row}"> <template #default="{row}">
<el-button size="small" @click="discover(row)">Discover</el-button> <el-button size="small" @click="discover(row)">发现</el-button>
<el-button size="small" type="primary" @click="syncOne(row)">Sync</el-button> <el-button size="small" type="primary" @click="syncOne(row)">同步</el-button>
<el-button size="small" @click="edit(row)">Edit</el-button> <el-button size="small" @click="edit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="remove(row)">Delete</el-button> <el-button size="small" type="danger" @click="remove(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </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 :model="form" label-width="120px">
<el-form-item label="Name"><el-input v-model="form.name" /></el-form-item> <el-form-item label="名称"><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.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="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-form>
<el-space style="margin-top:16px"> <el-space style="margin-top:16px">
<el-button type="primary" @click="save">Save</el-button> <el-button type="primary" @click="save">保存</el-button>
<el-button @click="testConn">Test Connection</el-button> <el-button @click="testConn">测试连接</el-button>
<el-button @click="showDialog=false">Cancel</el-button> <el-button @click="showDialog=false">取消</el-button>
</el-space> </el-space>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { serverApi } from '@/api' import { serverApi } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@@ -47,9 +72,33 @@ const showDialog = ref(false)
const editMode = ref(false) const editMode = ref(false)
const editingId = ref(0) const editingId = ref(0)
const form = ref({ name:'', url:'', token:'', sync_interval: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} } 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 } 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() { async function save() {
@@ -61,18 +110,37 @@ async function save() {
} else { } else {
await serverApi.create(form.value) await serverApi.create(form.value)
} }
ElMessage.success(editMode.value?'Updated':'Added'); showDialog.value=false; load() ElMessage.success(editMode.value?'更新成功':'添加成功'); showDialog.value=false; load()
} catch { ElMessage.error('Failed') } } catch { ElMessage.error('操作失败') }
} }
async function testConn() { async function testConn() {
try { const r=(await serverApi.test({url:form.value.url,token:form.value.token})).data; ElMessage.success('Connected! User: '+r.user) } try { const r=(await serverApi.test({url:form.value.url,token:form.value.token})).data; ElMessage.success('连接成功!用户: '+r.user) }
catch { ElMessage.error('Connection failed') } 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 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)) } }
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) 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> </script>

View File

@@ -1,15 +1,15 @@
<template> <template>
<div style="padding:20px"> <div style="padding:20px">
<el-card> <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 :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="新密码"><el-input v-model="form.password" type="password" show-password placeholder="留空则不修改" /></el-form-item>
<el-form-item label="Listen Address"><el-input v-model="form.listen_addr" /></el-form-item> <el-form-item label="监听地址"><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="仓库目录"><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 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">Save</el-button></el-form-item> <el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
</el-form> </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> </el-card>
</div> </div>
</template> </template>
@@ -30,8 +30,8 @@ async function save(){
try{ try{
const data:any={listen_addr:form.value.listen_addr,repos_dir:form.value.repos_dir,max_concurrent:form.value.max_concurrent} 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 if(form.value.password)data.admin_password=form.value.password
await authApi.updateSettings(data);ElMessage.success('Saved');form.value.password='' await authApi.updateSettings(data);ElMessage.success('保存成功');form.value.password=''
}catch{ElMessage.error('Failed')} }catch{ElMessage.error('保存失败')}
} }
onMounted(load) onMounted(load)