diff --git a/.claude/plans/main-plan.md b/.claude/plans/main-plan.md
index eac5a50..3444293 100644
--- a/.claude/plans/main-plan.md
+++ b/.claude/plans/main-plan.md
@@ -108,8 +108,8 @@ janus/
│ ├── health/
│ │ ├── checker.go # 健康检查 Worker
│ │ └── circuit_breaker.go # 熔断器
-│ └── router/
-│ └── router.go # 路由注册
+│ └── index/
+│ └── index.go # 路由注册
├── pkg/
│ ├── response/
│ │ └── response.go # 统一响应格式
@@ -122,7 +122,7 @@ janus/
│ │ ├── views/
│ │ ├── components/
│ │ ├── api/
-│ │ └── router/
+│ │ └── index/
│ ├── package.json
│ └── vite.config.ts
├── migrations/ # 数据库迁移 SQL
diff --git a/.claude/plans/phase-1-infrastructure.md b/.claude/plans/phase-1-infrastructure.md
index 2f0bc44..c9a8448 100644
--- a/.claude/plans/phase-1-infrastructure.md
+++ b/.claude/plans/phase-1-infrastructure.md
@@ -232,13 +232,13 @@ func (h *HealthHandler) Check(c *gin.Context) {
---
-### Step 1.7: 实现路由注册 — `internal/router/router.go`
+### Step 1.7: 实现路由注册 — `internal/index/index.go`
**任务**: 集中管理所有路由注册。
**代码结构**:
```go
-package router
+package index
func Setup(r *gin.Engine, handler *handler.HealthHandler) {
r.GET("/health", handler.Check)
@@ -266,7 +266,7 @@ main()
├── database.NewDatabase(&cfg.Database) // 初始化 DB
├── handler.NewHealthHandler(db) // 创建 handler
├── gin.New() / gin.Default() // 创建 Gin 引擎
- ├── router.Setup(r, healthHandler) // 注册路由
+ ├── index.Setup(r, healthHandler) // 注册路由
└── r.Run(":8080") // 启动服务器
```
@@ -323,8 +323,8 @@ janus/
│ │ └── config.go ← Step 1.2 实现
│ ├── handler/
│ │ └── health_handler.go ← Step 1.6 实现
-│ └── router/
-│ └── router.go ← Step 1.7 实现
+│ └── index/
+│ └── index.go ← Step 1.7 实现
├── config.yaml ← Step 1.3 编写
├── .env.example ← Step 1.4 编写
├── .gitignore ← Step 1.9 编写
diff --git a/.gitignore b/.gitignore
index d48c759..3dc91d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,8 @@
.idea
-.vscode
\ No newline at end of file
+.vscode
+
+web/node_modules
+
+web/dist
+
+web/package-lock.json
\ No newline at end of file
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 49eb777..0254953 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -11,12 +11,14 @@ import (
)
func main() {
+ // 加载配置
cfg, err := config.Load("config.yaml")
if err != nil {
panic(err)
}
- db, err := database.Connect(database.DatabaseConfig{
+ // 创建数据库连接
+ db, err := database.Connect(database.Config{
Host: cfg.Database.Host,
Port: cfg.Database.Port,
Username: cfg.Database.Username,
@@ -28,8 +30,15 @@ func main() {
panic(err)
}
+ if err := database.AutoMigrate(db); err != nil {
+ panic(err)
+ }
+
+ // 创建web容器
r := gin.Default()
healthHandler := &handler.HealthHandler{Database: db}
router.SetUp(r, healthHandler)
- r.Run(fmt.Sprintf(":%d", cfg.Server.Port))
+ if err := r.Run(fmt.Sprintf(":%d", cfg.Server.Port)); err != nil {
+ panic(err)
+ }
}
diff --git a/internal/database/database.go b/internal/database/database.go
index 3f722f7..5b9d481 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -5,10 +5,11 @@ import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
+ "synoth.com/janus/internal/model"
)
-// DatabaseConfig
-type DatabaseConfig struct {
+// Config 数据库配置
+type Config struct {
Host string
Port int
Username string
@@ -17,7 +18,8 @@ type DatabaseConfig struct {
SSLMode string
}
-func Connect(cfg DatabaseConfig) (*gorm.DB, error) {
+// Connect 连接数据库
+func Connect(cfg Config) (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.DBName, cfg.SSLMode,
)
@@ -29,3 +31,8 @@ func Connect(cfg DatabaseConfig) (*gorm.DB, error) {
return db, nil
}
+
+// AutoMigrate 自动迁移数据库表结构
+func AutoMigrate(db *gorm.DB) error {
+ return db.AutoMigrate(&model.Provider{}, &model.APIKey{}, &model.Channel{}, &model.Model{})
+}
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/web/eslint.config.js b/web/eslint.config.js
new file mode 100644
index 0000000..ef614d2
--- /dev/null
+++ b/web/eslint.config.js
@@ -0,0 +1,22 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..5e3836a
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ web
+
+
+
+
+
+
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..c478f6c
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "web",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@ant-design/icons": "^6.3.2",
+ "antd": "^6.5.1",
+ "axios": "^1.18.1",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "react-router-dom": "^7.18.1"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@types/node": "^24.13.2",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.3",
+ "eslint": "^10.6.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.3",
+ "globals": "^17.7.0",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.62.0",
+ "vite": "^8.1.1"
+ }
+}
diff --git a/web/public/favicon.svg b/web/public/favicon.svg
new file mode 100644
index 0000000..6893eb1
--- /dev/null
+++ b/web/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/public/icons.svg b/web/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/web/public/icons.svg
@@ -0,0 +1,24 @@
+
diff --git a/web/src/App.css b/web/src/App.css
new file mode 100644
index 0000000..e69de29
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..b63849f
--- /dev/null
+++ b/web/src/App.tsx
@@ -0,0 +1,21 @@
+import { ConfigProvider } from 'antd'
+import { RouterProvider } from 'react-router-dom'
+import { router } from '@/router'
+import { ThemeProvider, useTheme } from '@/context/ThemeContext'
+
+function AppInner() {
+ const { algorithm } = useTheme()
+ return (
+
+
+
+ )
+}
+
+export default function App() {
+ return (
+
+
+
+ )
+}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
new file mode 100644
index 0000000..312302f
--- /dev/null
+++ b/web/src/api/client.ts
@@ -0,0 +1,19 @@
+import axios from 'axios'
+
+const apiClient = axios.create({
+ baseURL: '/api',
+ timeout: 10000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+})
+
+apiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ console.error('API Error:', error)
+ return Promise.reject(error)
+ },
+)
+
+export default apiClient
diff --git a/web/src/api/health.ts b/web/src/api/health.ts
new file mode 100644
index 0000000..5ffa6de
--- /dev/null
+++ b/web/src/api/health.ts
@@ -0,0 +1,6 @@
+import apiClient from './client'
+
+export async function checkHealth(): Promise<{ status: string }> {
+ const response = await apiClient.get('/health')
+ return response.data
+}
diff --git a/web/src/assets/hero.png b/web/src/assets/hero.png
new file mode 100644
index 0000000..02251f4
Binary files /dev/null and b/web/src/assets/hero.png differ
diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/web/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg
new file mode 100644
index 0000000..5101b67
--- /dev/null
+++ b/web/src/assets/vite.svg
@@ -0,0 +1 @@
+
diff --git a/web/src/components/Logo.tsx b/web/src/components/Logo.tsx
new file mode 100644
index 0000000..0a15ca5
--- /dev/null
+++ b/web/src/components/Logo.tsx
@@ -0,0 +1,34 @@
+import { theme } from 'antd'
+
+export default function Logo({ collapsed }: { collapsed?: boolean }) {
+ const { token } = theme.useToken()
+
+ return (
+
+
+ {!collapsed && (
+
+ Janus
+
+ )}
+
+ )
+}
diff --git a/web/src/context/ThemeContext.tsx b/web/src/context/ThemeContext.tsx
new file mode 100644
index 0000000..116e557
--- /dev/null
+++ b/web/src/context/ThemeContext.tsx
@@ -0,0 +1,34 @@
+import { createContext, useContext, useState, type ReactNode } from 'react'
+import { theme } from 'antd'
+
+type ThemeMode = 'light' | 'dark'
+
+type ThemeContextType = {
+ mode: ThemeMode
+ toggle: () => void
+ algorithm: typeof theme.defaultAlgorithm | typeof theme.darkAlgorithm
+}
+
+const ThemeContext = createContext(null)
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [mode, setMode] = useState('light')
+
+ return (
+ setMode((m) => (m === 'light' ? 'dark' : 'light')),
+ algorithm: mode === 'light' ? theme.defaultAlgorithm : theme.darkAlgorithm,
+ }}
+ >
+ {children}
+
+ )
+}
+
+export function useTheme() {
+ const ctx = useContext(ThemeContext)
+ if (!ctx) throw new Error('useTheme must be inside ThemeProvider')
+ return ctx
+}
diff --git a/web/src/index.css b/web/src/index.css
new file mode 100644
index 0000000..153917b
--- /dev/null
+++ b/web/src/index.css
@@ -0,0 +1,7 @@
+body {
+ margin: 0;
+}
+
+#root {
+ min-height: 100vh;
+}
diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx
new file mode 100644
index 0000000..a65b427
--- /dev/null
+++ b/web/src/layout/AppLayout.tsx
@@ -0,0 +1,63 @@
+import { Layout, Switch } from 'antd'
+import { Outlet } from 'react-router-dom'
+import { useState } from 'react'
+import LayoutMenu from '@/layout/menu'
+import { useTheme } from '@/context/ThemeContext'
+import Logo from '@/components/Logo'
+
+const { Header, Sider, Content } = Layout
+
+export default function AppLayout() {
+ const [collapsed, setCollapsed] = useState(false)
+ const { mode, toggle } = useTheme()
+
+ const siderStyle: React.CSSProperties = {
+ overflow: 'auto',
+ height: '100vh',
+ position: 'fixed',
+ left: 0,
+ top: 0,
+ bottom: 0,
+ }
+
+ const layoutStyle: React.CSSProperties = {
+ minHeight: '100vh',
+ marginLeft: collapsed ? 80 : 200,
+ transition: 'margin-left 0.2s',
+ }
+
+ const headerStyle: React.CSSProperties = {
+ padding: '0 24px',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ background: 'inherit',
+ }
+
+ const contentStyle: React.CSSProperties = {
+ margin: 24,
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/layout/menu/index.tsx b/web/src/layout/menu/index.tsx
new file mode 100644
index 0000000..191730c
--- /dev/null
+++ b/web/src/layout/menu/index.tsx
@@ -0,0 +1,24 @@
+import { Menu } from 'antd'
+import { useLocation, useNavigate } from 'react-router-dom'
+import { routeConfig } from '@/router/config'
+
+const menuItems = routeConfig.map((r) => ({
+ key: r.path,
+ icon: r.icon,
+ label: r.label,
+}))
+
+export default function LayoutMenu() {
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ return (
+