Compare commits

...

11 Commits

Author SHA1 Message Date
panw
5eff309a9f feat: 添加同步进度显示和中文界面支持
refactor: 重构同步引擎以支持进度跟踪
style: 更新前端界面为中文
docs: 更新README为中文文档
2026-04-01 10:43:51 +08:00
panw
34944518f0 docs: 更新实施计划文档格式和内容
- 添加Claude本地设置文件,允许Bash操作权限
- 将文档中的分隔线从"---"统一改为"***"
- 修复markdown代码块嵌套格式问题
- 调整任务列表格式,移除多余空行
- 修正HTTP链接的markdown格式
- 更新.gitignore文件格式,修复转义字符问题
2026-03-31 17:26:07 +08:00
panw
a7cfa381e7 docs: update .gitignore and README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:45:41 +08:00
panw
6e8cbb38d4 test: add unit tests for database and Gitea client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:44:43 +08:00
panw
932b367806 feat: embed frontend in Go binary
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:42:54 +08:00
panw
373cb70633 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>
2026-03-31 16:38:45 +08:00
panw
333e999d76 feat: wire up main server with all routes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:24:36 +08:00
panw
56deffc6a4 feat: implement sync log and stats handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:24:28 +08:00
panw
86b21ccc31 feat: implement server management handlers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:24:20 +08:00
panw
e55c892488 feat: implement sync engine and scheduler
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:17:50 +08:00
panw
872bab34b6 feat: implement Gitea API client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:17:39 +08:00
32 changed files with 3756 additions and 99 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(rm:*)"
]
}
}

10
.gitignore vendored
View File

@@ -1,6 +1,12 @@
bin/
data/
*.db
*.db-shm
*.db-wal
web/node_modules/
web/dist/
*.exe
*.test
*.out
web/dist/
web/node_modules/
.idea/
.vscode/

164
README.md
View File

@@ -1,33 +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
- Web UI for management (Vue 3 + Element Plus)
- SQLite database
- JWT authentication
- Scheduled and manual sync
- Cross-platform (Windows, Linux)
- **单文件部署** - 前端内嵌到 Go 二进制,无需额外依赖
- **Web 管理界面** - Vue 3 + Element Plus,中文界面
- **多服务器管理** - 同时管理多个 Gitea 服务器
- **仓库自动发现** - 一键发现服务器上的所有仓库
- **镜像同步** - 使用 `git clone --mirror` 完整镜像,支持 Token 认证
- **实时进度** - 同步过程实时展示当前克隆/同步的仓库及进度
- **定时同步** - 可配置每个服务器的自动同步间隔
- **SQLite 存储** - 轻量级数据库,无需额外安装
## Build
## 快速开始
### 前置要求
- Go 1.21+(需要 CGO 支持 SQLite
- GCCWindows 下需安装 MinGW-w64
- Node.js 18+(仅开发时需要)
### 安装与运行
```bash
make all
# 1. 初始化(设置管理员密码)
$env:CGO_ENABLED="1"; go run main.go --init
# 2. 启动服务(默认监听 :9000
$env:CGO_ENABLED="1"; go run main.go
```
## Run
启动后浏览器访问 `http://localhost:9000`
### 使用流程
1. **登录** - 使用初始化时设置的密码登录
2. **添加服务器** - 填写 Gitea 服务器地址和 API Token
3. **测试连接** - 验证服务器和 Token 是否可用
4. **发现仓库** - 扫描服务器上的所有仓库
5. **同步** - 将仓库镜像克隆到本地,支持手动和定时同步
## 打包部署
```bash
./bin/gitm --init
./bin/gitm
./bin/gitm --addr :9090
# 构建前端
cd web
npm install
npm run build
cd ..
# 打包为单文件 exe
$env:CGO_ENABLED="1"; go build -o gitm.exe
```
## Development
打包后的 `gitm.exe` 包含前后端,直接运行即可。
### 运行
```bash
cd web && npm install && npm run dev
go run main.go
# 首次初始化
.\gitm.exe --init
# 启动服务
.\gitm.exe
# 指定监听地址
.\gitm.exe --addr :8080
# 指定数据目录
.\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

@@ -8,19 +8,19 @@
**Tech Stack:** Go 1.26+, Gin, SQLite (mattn/go-sqlite3), Vue 3, Element Plus, Pinia, Vite, golang-jwt/jwt, bcrypt
---
***
## Phase 1: Project Initialization and Core Infrastructure
### Task 1: Initialize Go Module and Project Structure
**Files:**
- Create: `go.mod`
- Create: `go.sum` (auto-generated)
- Create: `Makefile`
- Create: `main.go`
- Create: `README.md`
- [ ] **Step 1: Initialize Go module**
```bash
@@ -139,7 +139,7 @@ func runServer() error {
Create `README.md`:
```markdown
````markdown
# GitM - Gitea Repository Sync Tool
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
@@ -157,7 +157,7 @@ Cross-platform tool to synchronize all repositories from multiple Gitea servers
```bash
make all
```
````
## Run
@@ -184,23 +184,24 @@ cd web && npm run dev
# Run backend (requires frontend built first)
go run main.go
```
```
````
- [ ] **Step 7: Commit**
```bash
git add go.mod go.sum Makefile main.go README.md
git commit -m "feat: initialize project structure and dependencies"
```
````
---
***
### Task 2: Create Internal Package Structure
**Files:**
- Create: `internal/config/config.go`
- Create: `internal/models/models.go`
- [ ] **Step 1: Create config package**
Create `internal/config/config.go`:
@@ -319,13 +320,13 @@ git add internal/config/config.go internal/models/models.go
git commit -m "feat: add config and models packages"
```
---
***
### Task 3: Implement Database Layer
**Files:**
- Create: `internal/database/database.go`
- Create: `internal/database/database.go`
- [ ] **Step 1: Create database package**
Create `internal/database/database.go`:
@@ -655,14 +656,14 @@ git add internal/database/
git commit -m "feat: implement SQLite database layer"
```
---
***
### Task 4: Implement JWT Authentication
**Files:**
- Create: `internal/middleware/auth.go`
- Create: `internal/handler/auth.go`
- [ ] **Step 1: Create auth middleware**
Create `internal/middleware/auth.go`:
@@ -961,16 +962,16 @@ git add internal/middleware/ internal/handler/
git commit -m "feat: implement JWT authentication middleware and handlers"
```
---
***
## Phase 2: Gitea API Integration
### Task 5: Implement Gitea API Client
**Files:**
- Create: `internal/gitea/client.go`
- Create: `internal/gitea/types.go`
- [ ] **Step 1: Create Gitea types**
Create `internal/gitea/types.go`:
@@ -1145,14 +1146,14 @@ git add internal/gitea/
git commit -m "feat: implement Gitea API client"
```
---
***
### Task 6: Implement Sync Engine
**Files:**
- Create: `internal/sync/engine.go`
- Create: `internal/sync/scheduler.go`
- [ ] **Step 1: Create sync engine**
Create `internal/sync/engine.go`:
@@ -1532,15 +1533,15 @@ git add internal/sync/
git commit -m "feat: implement sync engine and scheduler"
```
---
***
## Phase 3: HTTP Handlers
### Task 7: Implement Server Management Handlers
**Files:**
- Create: `internal/handler/server.go`
- Create: `internal/handler/server.go`
- [ ] **Step 1: Create server handler**
Create `internal/handler/server.go`:
@@ -1885,13 +1886,13 @@ git add internal/handler/server.go
git commit -m "feat: implement server management handlers"
```
---
***
### Task 8: Implement Log and Stats Handlers
**Files:**
- Create: `internal/handler/log.go`
- Create: `internal/handler/log.go`
- [ ] **Step 1: Create log handler**
Create `internal/handler/log.go`:
@@ -1991,13 +1992,13 @@ git add internal/handler/log.go
git commit -m "feat: implement sync log and stats handlers"
```
---
***
### Task 9: Wire Up Main Server
**Files:**
- Modify: `main.go`
- Modify: `main.go`
- [ ] **Step 1: Update main.go with full server implementation**
Replace `main.go` with:
@@ -2204,20 +2205,20 @@ git add main.go
git commit -m "feat: wire up main server with all routes"
```
---
***
## Phase 4: Frontend Implementation
### Task 10: Initialize Vue 3 Frontend
**Files:**
- Create: `web/package.json`
- Create: `web/vite.config.ts`
- Create: `web/tsconfig.json`
- Create: `web/index.html`
- Create: `web/src/main.ts`
- Create: `web/src/App.vue`
- [ ] **Step 1: Create web/package.json**
Create `web/package.json`:
@@ -2478,15 +2479,15 @@ git add web/
git commit -m "feat: initialize Vue 3 frontend structure"
```
---
***
### Task 11: Create API Client and Auth Store
**Files:**
- Create: `web/src/api/index.ts`
- Create: `web/src/api/types.ts`
- Create: `web/src/stores/auth.ts`
- [ ] **Step 1: Create API types**
Create `web/src/api/types.ts`:
@@ -2687,13 +2688,13 @@ git add web/src/api/ web/src/stores/
git commit -m "feat: add API client and auth store"
```
---
***
### Task 12: Create Login Page
**Files:**
- Create: `web/src/views/Login.vue`
- Create: `web/src/views/Login.vue`
- [ ] **Step 1: Create Login.vue**
Create `web/src/views/Login.vue`:
@@ -2814,13 +2815,13 @@ git add web/src/views/Login.vue
git commit -m "feat: add login page"
```
---
***
### Task 13: Create Layout Component
**Files:**
- Create: `web/src/views/Layout.vue`
- Create: `web/src/views/Layout.vue`
- [ ] **Step 1: Create Layout.vue**
Create `web/src/views/Layout.vue`:
@@ -2962,13 +2963,13 @@ git add web/src/views/Layout.vue
git commit -m "feat: add layout component with navigation"
```
---
***
### Task 14: Create Dashboard Page
**Files:**
- Create: `web/src/views/Dashboard.vue`
- Create: `web/src/views/Dashboard.vue`
- [ ] **Step 1: Create Dashboard.vue**
Create `web/src/views/Dashboard.vue`:
@@ -3162,13 +3163,13 @@ git add web/src/views/Dashboard.vue
git commit -m "feat: add dashboard page"
```
---
***
### Task 15: Create Servers Page
**Files:**
- Create: `web/src/views/Servers.vue`
- Create: `web/src/views/Servers.vue`
- [ ] **Step 1: Create Servers.vue**
Create `web/src/views/Servers.vue`:
@@ -3433,15 +3434,15 @@ git add web/src/views/Servers.vue
git commit -m "feat: add servers management page"
```
---
***
### Task 16: Create Repositories, Logs, and Settings Pages
**Files:**
- Create: `web/src/views/Repos.vue`
- Create: `web/src/views/Logs.vue`
- Create: `web/src/views/Settings.vue`
- [ ] **Step 1: Create Repos.vue**
Create `web/src/views/Repos.vue`:
@@ -3847,16 +3848,16 @@ git add web/src/views/Repos.vue web/src/views/Logs.vue web/src/views/Settings.vu
git commit -m "feat: add repos, logs, and settings pages"
```
---
***
## Phase 5: Frontend Build Integration
### Task 17: Embed Frontend in Go Binary
**Files:**
- Modify: `main.go`
- Modify: `Makefile`
- [ ] **Step 1: Add embed directive to main.go**
Edit `main.go`, add after the imports:
@@ -3904,7 +3905,7 @@ Edit `main.go`, update the `runServer` function. Find the comment about static f
})
```
- [ ] **Step 3: Add `"net/http"` to imports**
- [ ] **Step 3: Add** **`"net/http"`** **to imports**
Edit `main.go` imports:
@@ -4055,16 +4056,16 @@ git add main.go Makefile
git commit -m "feat: embed frontend in Go binary"
```
---
***
## Phase 6: Testing and Documentation
### Task 18: Create Tests
**Files:**
- Create: `internal/database/database_test.go`
- Create: `internal/gitea/client_test.go`
- [ ] **Step 1: Create database tests**
Create `internal/database/database_test.go`:
@@ -4205,19 +4206,19 @@ git add internal/database/database_test.go internal/gitea/client_test.go
git commit -m "test: add unit tests for database and Gitea client"
```
---
***
### Task 19: Final Documentation
**Files:**
- Modify: `README.md`
- Create: `.gitignore`
- [ ] **Step 1: Update README.md**
Replace `README.md` with:
```markdown
````markdown
# GitM - Gitea Repository Sync Tool
Cross-platform tool to synchronize all repositories from multiple Gitea servers to local storage.
@@ -4246,7 +4247,7 @@ cd gitm
make all
# The binary will be at bin/gitm
```
````
### First Run
@@ -4264,8 +4265,9 @@ make all
### Access Web UI
Open your browser and navigate to:
- http://localhost:9000 (default)
- http://localhost:9090 (if you used --addr :9090)
- <http://localhost:9000> (default)
- <http://localhost:9090> (if you used --addr :9090)
## Usage
@@ -4302,6 +4304,7 @@ To discover new repositories without syncing, click **Discover**.
### Storage
Repositories are stored as bare git mirrors at:
```
<data_dir>/repos/server_<id>_<name>/<owner>/<repo>.git
```
@@ -4322,14 +4325,17 @@ Settings are stored in the SQLite database and can be changed via the Web UI:
The API is available at `/api/` and requires JWT authentication:
### Authentication
- `POST /api/login` - Authenticate and get token
- `POST /api/init` - Initialize admin password
### Settings
- `GET /api/settings` - Get all settings
- `PUT /api/settings` - Update settings
### Servers
- `GET /api/servers` - List all servers
- `POST /api/servers` - Add a server
- `PUT /api/servers/:id` - Update a server
@@ -4341,6 +4347,7 @@ The API is available at `/api/` and requires JWT authentication:
- `GET /api/servers/:id/sync/status` - Get sync status
### Sync
- `POST /api/sync/all` - Sync all servers
- `GET /api/sync/logs` - Get sync logs
- `GET /api/sync/stats` - Get statistics
@@ -4361,7 +4368,7 @@ npm install
npm run dev
```
The dev server runs on http://localhost:5173 with API proxy to :9000.
The dev server runs on <http://localhost:5173> with API proxy to :9000.
### Backend Development
@@ -4409,6 +4416,7 @@ gitm/
## License
MIT License
```
- [ ] **Step 2: Create .gitignore**
@@ -4416,62 +4424,71 @@ MIT License
Create `.gitignore`:
```
# Binaries
bin/
gitm
gitm.exe
gitm-linux
# Data
data/
*.db
*.db-shm
*.db-wal
\*.db
\*.db-shm
\*.db-wal
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
\*.exe
\*.exe\~
\*.dll
\*.so
\*.dylib
\*.test
\*.out
go.work
# Node
web/node_modules/
web/node\_modules/
web/dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
\*.swp
\*.swo
\*\~
# OS
.DS_Store
.DS\_Store
Thumbs.db
# Temporary
*.log
*.tmp
```
\*.log
\*.tmp
````
- [ ] **Step 3: Commit**
```bash
git add README.md .gitignore
git commit -m "docs: update README and add .gitignore"
```
````
---
***
## Task 20: Final Build Verification
**Files:**
- None (verification task)
- None (verification task)
- [ ] **Step 1: Full clean build**
```bash
@@ -4537,7 +4554,7 @@ git add -A
git commit -m "chore: final build verification complete"
```
---
***
## Implementation Complete
@@ -4566,3 +4583,4 @@ The GitM project is now fully implemented with:
6. Add more detailed sync progress reporting
7. Implement backup/restore functionality
8. Add Docker support

View File

@@ -0,0 +1,109 @@
package database
import (
"os"
"path/filepath"
"testing"
"gitm/internal/models"
)
func TestDatabase(t *testing.T) {
tmpDB := filepath.Join(os.TempDir(), "test_gitm.db")
defer os.Remove(tmpDB)
if err := Initialize(tmpDB); err != nil {
t.Fatalf("Initialize failed: %v", err)
}
defer Close()
t.Run("Settings", func(t *testing.T) {
if err := SetSetting("test_key", "test_value"); err != nil {
t.Fatal(err)
}
val, err := GetSetting("test_key")
if err != nil {
t.Fatal(err)
}
if val != "test_value" {
t.Errorf("expected test_value, got %s", val)
}
})
t.Run("ServerCRUD", func(t *testing.T) {
s := &models.GiteaServer{Name: "Test", URL: "https://test.com", Token: "tok", Status: "active"}
if err := CreateServer(s); err != nil {
t.Fatal(err)
}
if s.ID == 0 {
t.Error("ID not set")
}
got, err := GetServer(s.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "Test" {
t.Error("name mismatch")
}
servers, err := GetServers()
if err != nil {
t.Fatal(err)
}
if len(servers) != 1 {
t.Errorf("expected 1, got %d", len(servers))
}
if err := DeleteServer(s.ID); err != nil {
t.Fatal(err)
}
})
t.Run("RepoCRUD", func(t *testing.T) {
s := &models.GiteaServer{Name: "RepoTest", URL: "https://test.com", Token: "tok", Status: "active"}
CreateServer(s)
r := &models.Repo{ServerID: s.ID, Name: "repo1", FullName: "user/repo1", CloneURL: "https://test.com/user/repo1.git", SyncStatus: "pending"}
if err := CreateRepo(r); err != nil {
t.Fatal(err)
}
got, err := GetRepoByFullName(s.ID, "user/repo1")
if err != nil {
t.Fatal(err)
}
if got == nil || got.Name != "repo1" {
t.Error("repo not found")
}
repos, err := GetReposByServer(s.ID)
if err != nil {
t.Fatal(err)
}
if len(repos) != 1 {
t.Errorf("expected 1, got %d", len(repos))
}
DeleteServer(s.ID)
})
t.Run("SyncLogs", func(t *testing.T) {
s := &models.GiteaServer{Name: "LogTest", URL: "https://test.com", Token: "tok", Status: "active"}
CreateServer(s)
l := &models.SyncLog{ServerID: s.ID, Status: "success", Message: "test"}
if err := CreateSyncLog(l); err != nil {
t.Fatal(err)
}
logs, err := GetSyncLogs(s.ID, 10, 0)
if err != nil {
t.Fatal(err)
}
if len(logs) != 1 {
t.Errorf("expected 1, got %d", len(logs))
}
DeleteServer(s.ID)
})
}

115
internal/gitea/client.go Normal file
View File

@@ -0,0 +1,115 @@
package gitea
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type Client struct {
baseURL *url.URL
httpClient *http.Client
token string
}
func NewClient(serverURL, token string) (*Client, error) {
u, err := url.Parse(serverURL)
if err != nil {
return nil, fmt.Errorf("invalid server URL: %w", err)
}
return &Client{
baseURL: u,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
token: token,
}, nil
}
func (c *Client) do(path string) (*http.Response, error) {
reqURL := c.baseURL.JoinPath(path)
q := reqURL.Query()
q.Set("token", c.token)
reqURL.RawQuery = q.Encode()
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 {
return nil, fmt.Errorf("request failed: %w", err)
}
return resp, nil
}
func (c *Client) ValidateToken() (*GiteaUser, error) {
resp, err := c.do("/api/v1/user")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("authentication failed: %s - %s", resp.Status, string(body))
}
var user GiteaUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &user, nil
}
func (c *Client) SearchRepos(page, limit int) ([]GiteaRepo, error) {
resp, err := c.doSearch("/api/v1/repos/search", map[string]string{
"page": fmt.Sprintf("%d", page),
"limit": fmt.Sprintf("%d", limit),
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("search failed: %s - %s", resp.Status, string(body))
}
var searchResp GiteaSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return searchResp.Data, nil
}
func (c *Client) GetAllRepos() ([]GiteaRepo, error) {
var allRepos []GiteaRepo
page := 1
limit := 50
for {
repos, err := c.SearchRepos(page, limit)
if err != nil {
return nil, err
}
if len(repos) == 0 {
break
}
allRepos = append(allRepos, repos...)
if len(repos) < limit {
break
}
page++
}
return allRepos, nil
}

View File

@@ -0,0 +1,20 @@
package gitea
import "testing"
func TestNewClient(t *testing.T) {
client, err := NewClient("https://gitea.com", "")
if err != nil {
t.Fatal(err)
}
if client == nil {
t.Error("client is nil")
}
}
func TestNewClientInvalidURL(t *testing.T) {
_, err := NewClient("://invalid", "")
if err == nil {
t.Error("expected error for invalid URL")
}
}

25
internal/gitea/types.go Normal file
View File

@@ -0,0 +1,25 @@
package gitea
type GiteaUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
}
type GiteaRepo struct {
ID int64 `json:"id"`
Name string `json:"name"`
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"`
Private bool `json:"private"`
Size int64 `json:"size"`
Updated string `json:"updated_at"`
Owner *GiteaUser `json:"owner"`
}
type GiteaSearchResponse struct {
OK bool `json:"ok"`
Data []GiteaRepo `json:"data"`
}

61
internal/handler/log.go Normal file
View File

@@ -0,0 +1,61 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitm/internal/database"
)
func HandleGetSyncLogs(c *gin.Context) {
serverIDStr := c.Query("server_id")
pageStr := c.DefaultQuery("page", "1")
limitStr := c.DefaultQuery("limit", "50")
page, _ := strconv.Atoi(pageStr)
limit, _ := strconv.Atoi(limitStr)
if limit > 100 {
limit = 100
}
var serverID int64
if serverIDStr != "" {
serverID, _ = strconv.ParseInt(serverIDStr, 10, 64)
}
offset := (page - 1) * limit
logs, err := database.GetSyncLogs(serverID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": logs, "page": page, "limit": limit, "count": len(logs)})
}
func HandleGetStats(c *gin.Context) {
servers, err := database.GetServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
serverCount := len(servers)
repoCount := 0
totalSize := int64(0)
activeServers := 0
for _, server := range servers {
if server.Status == "active" {
activeServers++
}
repos, err := database.GetReposByServer(server.ID)
if err == nil {
repoCount += len(repos)
for _, repo := range repos {
totalSize += repo.Size
}
}
}
c.JSON(http.StatusOK, gin.H{
"server_count": serverCount,
"active_servers": activeServers,
"repo_count": repoCount,
"total_size": totalSize,
})
}

235
internal/handler/server.go Normal file
View File

@@ -0,0 +1,235 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitm/internal/database"
"gitm/internal/gitea"
"gitm/internal/models"
"gitm/internal/sync"
)
func HandleListServers(c *gin.Context) {
servers, err := database.GetServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, servers)
}
func HandleCreateServer(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Token string `json:"token" binding:"required"`
SyncInterval int `json:"sync_interval"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
server := &models.GiteaServer{
Name: req.Name,
URL: req.URL,
Token: req.Token,
SyncInterval: req.SyncInterval,
Status: "active",
}
if err := database.CreateServer(server); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, server)
}
func HandleUpdateServer(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
server, err := database.GetServer(id)
if err != nil || server == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
return
}
var req struct {
Name *string `json:"name"`
URL *string `json:"url"`
Token *string `json:"token"`
SyncInterval *int `json:"sync_interval"`
Status *string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != nil {
server.Name = *req.Name
}
if req.URL != nil {
server.URL = *req.URL
}
if req.Token != nil {
server.Token = *req.Token
}
if req.SyncInterval != nil {
server.SyncInterval = *req.SyncInterval
}
if req.Status != nil {
server.Status = *req.Status
}
if err := database.UpdateServer(server); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, server)
}
func HandleDeleteServer(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
if err := database.DeleteServer(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Server deleted"})
}
func HandleTestConnection(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
client, err := gitea.NewClient(req.URL, req.Token)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := client.ValidateToken()
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Connection successful", "user": user.Login})
}
func HandleListRepos(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
repos, err := database.GetReposByServer(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, repos)
}
func HandleDiscoverRepos(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
server, err := database.GetServer(id)
if err != nil || server == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"})
return
}
client, err := gitea.NewClient(server.URL, server.Token)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
giteaRepos, err := client.GetAllRepos()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
reposDir, _ := database.GetSetting("repos_dir")
if reposDir == "" {
reposDir = "./data/repos"
}
serverDir := reposDir + "/" + strconv.FormatInt(id, 10) + "_" + server.Name
discovered := 0
for _, gr := range giteaRepos {
existing, _ := database.GetRepoByFullName(id, gr.FullName)
if existing == nil {
repo := &models.Repo{
ServerID: id,
Name: gr.Name,
FullName: gr.FullName,
CloneURL: gr.CloneURL,
LocalPath: serverDir + "/" + gr.FullName + ".git",
SyncStatus: "pending",
}
database.CreateRepo(repo)
discovered++
}
}
c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Discovered %d new repositories", discovered),
"total_repos": len(giteaRepos),
"new_repos": discovered,
})
}
func HandleSyncServer(engine *sync.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
if engine.IsSyncing(id) {
c.JSON(http.StatusConflict, gin.H{"error": "Sync already in progress"})
return
}
go engine.SyncServer(id)
c.JSON(http.StatusAccepted, gin.H{"message": "Sync started", "server_id": id})
}
}
func HandleSyncAll(engine *sync.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
servers, err := database.GetServers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
started := 0
for _, server := range servers {
if server.Status == "active" && !engine.IsSyncing(server.ID) {
go engine.SyncServer(server.ID)
started++
}
}
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("Started sync for %d servers", started)})
}
}
func HandleGetSyncStatus(engine *sync.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server ID"})
return
}
progress := engine.GetProgress(id)
c.JSON(http.StatusOK, progress)
}
}

308
internal/sync/engine.go Normal file
View File

@@ -0,0 +1,308 @@
package sync
import (
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"gitm/internal/database"
"gitm/internal/gitea"
"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 {
maxConcurrent int
mu sync.Mutex
activeTasks map[int64]string
progress map[int64]*ServerProgress
progressMu sync.RWMutex
}
func NewEngine(maxConcurrent int) *Engine {
return &Engine{
maxConcurrent: maxConcurrent,
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
}
}
func (e *Engine) SyncServer(serverID int64) error {
e.mu.Lock()
if _, active := e.activeTasks[serverID]; active {
e.mu.Unlock()
return fmt.Errorf("sync already in progress for server %d", serverID)
}
taskID := fmt.Sprintf("%d-%d", serverID, time.Now().Unix())
e.activeTasks[serverID] = taskID
e.mu.Unlock()
defer func() {
e.mu.Lock()
delete(e.activeTasks, serverID)
e.mu.Unlock()
}()
server, err := database.GetServer(serverID)
if err != nil || server == nil {
return fmt.Errorf("failed to get server: %w", err)
}
// Initialize progress
e.setProgress(serverID, &ServerProgress{
ServerID: serverID,
Total: 0,
Status: "syncing",
})
syncLog := &models.SyncLog{
ServerID: serverID,
Status: "in_progress",
Message: "开始同步",
StartedAt: time.Now(),
}
if err := database.CreateSyncLog(syncLog); err != nil {
return fmt.Errorf("failed to create sync log: %w", err)
}
e.setCurrentRepo(serverID, "", "验证认证中")
client, err := gitea.NewClient(server.URL, server.Token)
if err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("创建客户端失败: %v", err))
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
return err
}
if _, err := client.ValidateToken(); err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("认证失败: %v", err))
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
return err
}
e.setCurrentRepo(serverID, "", "获取仓库列表中")
giteaRepos, err := client.GetAllRepos()
if err != nil {
e.finishLog(syncLog, "failed", fmt.Sprintf("获取仓库列表失败: %v", err))
e.setProgress(serverID, &ServerProgress{ServerID: serverID, Status: "idle"})
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")
if reposDir == "" {
reposDir = "./data/repos"
}
serverDir := filepath.Join(reposDir, fmt.Sprintf("server_%d_%s", serverID, server.Name))
os.MkdirAll(serverDir, 0755)
sem := make(chan struct{}, e.maxConcurrent)
var wg sync.WaitGroup
for _, gr := range giteaRepos {
wg.Add(1)
go func(giteaRepo gitea.GiteaRepo) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
localPath := filepath.Join(serverDir, giteaRepo.FullName+".git")
existingRepo, _ := database.GetRepoByFullName(serverID, giteaRepo.FullName)
var err error
if existingRepo == nil {
e.setCurrentRepo(serverID, giteaRepo.FullName, "克隆")
err = e.cloneMirror(giteaRepo.CloneURL, server.Token, localPath)
if err == nil {
repo := &models.Repo{
ServerID: serverID,
Name: giteaRepo.Name,
FullName: giteaRepo.FullName,
CloneURL: giteaRepo.CloneURL,
LocalPath: localPath,
SyncStatus: "success",
}
database.CreateRepo(repo)
}
} 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)
if err == nil {
database.UpdateRepoSyncStatus(existingRepo.ID, "success")
}
}
}
e.clearCurrentRepo(serverID)
if err != nil {
e.updateProgressCounts(serverID, 0, 1)
failLog := &models.SyncLog{
ServerID: serverID,
RepoID: getRepoID(serverID, giteaRepo.FullName),
Status: "failed",
Message: err.Error(),
StartedAt: time.Now(),
}
database.CreateSyncLog(failLog)
} else {
e.updateProgressCounts(serverID, 1, 0)
}
}(gr)
}
wg.Wait()
database.UpdateServerLastSync(serverID)
// Get final counts for message
p := e.GetProgress(serverID)
message := fmt.Sprintf("同步完成: %d 成功, %d 失败", p.SuccessCount, p.FailedCount)
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
}
func (e *Engine) cloneMirror(cloneURL, token, path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
os.MkdirAll(filepath.Dir(path), 0755)
authURL := injectToken(cloneURL, token)
cmd := exec.Command("git", "clone", "--mirror", authURL, path)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("clone failed: %w - %s", err, string(output))
}
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 {
cmd := exec.Command("git", "--git-dir", path, "fetch", "--all", "--prune")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("fetch failed: %w - %s", err, string(output))
}
return nil
}
func (e *Engine) finishLog(log *models.SyncLog, status, message string) {
now := time.Now()
log.Status = status
log.Message = message
log.FinishedAt = &now
database.UpdateSyncLog(log.ID, status, message, log.FinishedAt)
}
func (e *Engine) IsSyncing(serverID int64) bool {
e.mu.Lock()
defer e.mu.Unlock()
_, active := e.activeTasks[serverID]
return active
}
func getRepoID(serverID int64, fullName string) *int64 {
repo, _ := database.GetRepoByFullName(serverID, fullName)
if repo != nil {
return &repo.ID
}
return nil
}

View File

@@ -0,0 +1,74 @@
package sync
import (
"fmt"
"log"
"github.com/robfig/cron/v3"
"gitm/internal/database"
"gitm/internal/models"
)
type Scheduler struct {
cron *cron.Cron
engine *Engine
serverJobs map[int64]cron.EntryID
}
func NewScheduler(engine *Engine) *Scheduler {
return &Scheduler{
cron: cron.New(),
engine: engine,
serverJobs: make(map[int64]cron.EntryID),
}
}
func (s *Scheduler) Start() {
s.cron.Start()
log.Println("Scheduler started")
}
func (s *Scheduler) Stop() {
s.cron.Stop()
log.Println("Scheduler stopped")
}
func (s *Scheduler) UpdateServer(server *models.GiteaServer) {
if entryID, exists := s.serverJobs[server.ID]; exists {
s.cron.Remove(entryID)
delete(s.serverJobs, server.ID)
}
if server.SyncInterval <= 0 || server.Status != "active" {
return
}
schedule := fmt.Sprintf("*/%d * * * *", server.SyncInterval)
entryID, err := s.cron.AddFunc(schedule, func() {
log.Printf("Scheduled sync for server %d (%s)", server.ID, server.Name)
if err := s.engine.SyncServer(server.ID); err != nil {
log.Printf("Scheduled sync failed for server %d: %v", server.ID, err)
}
})
if err != nil {
log.Printf("Failed to schedule for server %d: %v", server.ID, err)
return
}
s.serverJobs[server.ID] = entryID
}
func (s *Scheduler) RemoveServer(serverID int64) {
if entryID, exists := s.serverJobs[serverID]; exists {
s.cron.Remove(entryID)
delete(s.serverJobs, serverID)
}
}
func (s *Scheduler) ReloadAll() error {
servers, err := database.GetServers()
if err != nil {
return err
}
for _, server := range servers {
s.UpdateServer(&server)
}
return nil
}

136
main.go
View File

@@ -1,32 +1,150 @@
package main
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gitm/internal/config"
"gitm/internal/database"
"gitm/internal/handler"
"gitm/internal/middleware"
"gitm/internal/sync"
)
//go:embed all:web/dist
var webFS embed.FS
var (
flagAddr = flag.String("addr", ":9000", "Listen address")
flagDataDir = flag.String("data", "./data", "Data directory")
flagAddr = flag.String("addr", "", "Listen address")
flagDataDir = flag.String("data", "", "Data directory")
flagInit = flag.Bool("init", false, "Initialize database and set password")
)
func main() {
flag.Parse()
cfg := config.Get()
if *flagDataDir != "" {
cfg.SetDataDir(*flagDataDir)
} else {
cfg.SetDataDir(cfg.DataDir)
}
if *flagAddr != "" {
cfg.ListenAddr = *flagAddr
}
if err := cfg.EnsureDirs(); err != nil {
log.Fatalf("Failed to create directories: %v", err)
}
if err := database.Initialize(cfg.DBPath); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
fmt.Printf("GitM - Gitea Repository Sync Tool\n")
fmt.Printf("Listen: %s\n", *flagAddr)
fmt.Printf("Data: %s\n", *flagDataDir)
if listenAddr, err := database.GetSetting("listen_addr"); err == nil && listenAddr != "" {
cfg.ListenAddr = listenAddr
}
maxConcurrent := 3
if maxStr, err := database.GetSetting("max_concurrent"); err == nil && maxStr != "" {
fmt.Sscanf(maxStr, "%d", &maxConcurrent)
}
engine := sync.NewEngine(maxConcurrent)
scheduler := sync.NewScheduler(engine)
if *flagInit {
fmt.Println("Initialize mode: TODO")
runInitMode()
return
}
log.Fatal(runServer())
if pwd, _ := database.GetSetting("admin_password"); pwd == "" {
fmt.Println("Not initialized. Please run with --init flag first.")
os.Exit(1)
}
middleware.SetJWTSecret("gitm-default-secret")
scheduler.ReloadAll()
scheduler.Start()
defer scheduler.Stop()
log.Printf("GitM starting on %s", cfg.ListenAddr)
log.Fatal(runServer(cfg, engine))
}
func runServer() error {
return fmt.Errorf("not implemented")
func runInitMode() {
var password string
fmt.Print("Enter admin password: ")
fmt.Scanln(&password)
if password == "" {
log.Fatal("Password cannot be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
database.SetSetting("admin_password", string(hash))
database.SetSetting("listen_addr", ":9000")
database.SetSetting("max_concurrent", "3")
fmt.Println("Initialized successfully!")
}
func runServer(cfg *config.Config, engine *sync.Engine) error {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// Serve embedded frontend
distFS, err := fs.Sub(webFS, "web/dist")
if err == nil {
fileServer := http.FileServer(http.FS(distFS))
r.NoRoute(func(c *gin.Context) {
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")
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
})
}
api := r.Group("/api")
{
api.POST("/login", handler.HandleLogin)
api.POST("/init", handler.HandleInit)
protected := api.Group("")
protected.Use(middleware.AuthMiddleware())
{
protected.GET("/settings", handler.HandleGetSettings)
protected.PUT("/settings", handler.HandleUpdateSettings)
protected.GET("/servers", handler.HandleListServers)
protected.POST("/servers/test", handler.HandleTestConnection)
protected.POST("/servers", handler.HandleCreateServer)
protected.PUT("/servers/:id", handler.HandleUpdateServer)
protected.DELETE("/servers/:id", handler.HandleDeleteServer)
protected.GET("/servers/:id/repos", handler.HandleListRepos)
protected.POST("/servers/:id/discover", handler.HandleDiscoverRepos)
protected.POST("/servers/:id/sync", handler.HandleSyncServer(engine))
protected.GET("/servers/:id/sync/status", handler.HandleGetSyncStatus(engine))
protected.POST("/sync/all", handler.HandleSyncAll(engine))
protected.GET("/sync/logs", handler.HandleGetSyncLogs)
protected.GET("/sync/stats", handler.HandleGetStats)
}
}
return r.Run(cfg.ListenAddr)
}

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

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>仪表盘</span></el-menu-item>
<el-menu-item index="/servers"><el-icon><Monitor /></el-icon><span>服务器</span></el-menu-item>
<el-menu-item index="/repos"><el-icon><DocumentCopy /></el-icon><span>仓库列表</span></el-menu-item>
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>同步日志</span></el-menu-item>
<el-menu-item index="/settings"><el-icon><Setting /></el-icon><span>系统设置</span></el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header style="background:#fff;border-bottom:1px solid #f0f0f0;display:flex;align-items:center">
<div style="width:100%;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:18px;font-weight:500">{{ pageTitle }}</span>
<el-button text @click="handleLogout"><el-icon><SwitchButton /></el-icon> 退出登录</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> = { '/':'仪表盘','/servers':'Gitea 服务器','/repos':'仓库列表','/logs':'同步日志','/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 仓库同步工具</p>
</div>
</template>
<el-form @submit.prevent="handleLogin">
<el-form-item>
<el-input v-model="password" type="password" placeholder="请输入密码" show-password size="large" @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">登录</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 = '请输入密码'; return }
error.value = ''
loading.value = true
const ok = await authStore.login(password.value)
loading.value = false
if (ok) { ElMessage.success('登录成功'); router.push('/') }
else { error.value = '密码错误' }
}
</script>

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

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

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

@@ -0,0 +1,46 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center"><span>仓库列表</span>
<el-select v-model="selectedServer" style="width:200px"><el-option label="全部服务器" :value="0" /><el-option v-for="s in servers" :key="s.id" :label="s.name" :value="s.id" /></el-select>
</div></template>
<el-table :data="repos" stripe v-loading="loading">
<el-table-column prop="full_name" label="仓库名称" />
<el-table-column prop="server_id" label="服务器" width="80" />
<el-table-column prop="size" label="大小" width="120"><template #default="{row}">{{formatSize(row.size)}}</template></el-table-column>
<el-table-column prop="sync_status" label="状态" width="100"><template #default="{row}"><el-tag :type="row.sync_status==='success'?'success':row.sync_status==='failed'?'danger':row.sync_status==='syncing'?'warning':'info'">{{statusText(row.sync_status)}}</el-tag></template></el-table-column>
<el-table-column prop="last_sync_at" label="上次同步" width="180"><template #default="{row}">{{row.last_sync_at?new Date(row.last_sync_at).toLocaleString():'从未'}}</template></el-table-column>
</el-table>
</el-card>
</div>
</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]}
function statusText(s:string){const m:Record<string,string>={success:'成功',failed:'失败',in_progress:'同步中',pending:'等待'};return m[s]||s}
watch(selectedServer,loadRepos)
onMounted(async()=>{await load();loadRepos()})
</script>

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

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

View File

@@ -0,0 +1,38 @@
<template>
<div style="padding:20px">
<el-card>
<template #header><span>系统设置</span></template>
<el-form :model="form" label-width="150px" style="max-width:600px">
<el-form-item label="新密码"><el-input v-model="form.password" type="password" show-password placeholder="留空则不修改" /></el-form-item>
<el-form-item label="监听地址"><el-input v-model="form.listen_addr" /></el-form-item>
<el-form-item label="仓库目录"><el-input v-model="form.repos_dir" /></el-form-item>
<el-form-item label="最大并发数"><el-input-number v-model="form.max_concurrent" :min="1" :max="10" /></el-form-item>
<el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
</el-form>
<el-alert type="info" title="修改监听地址后需要重启程序才能生效" :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('保存成功');form.value.password=''
}catch{ElMessage.error('保存失败')}
}
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'
}
})