删除不再使用的配置文件和脚本,更新Dockerfile以支持前后端构建,重构配置加载逻辑,添加OAuth2.0支持,优化API处理和路由设置。

This commit is contained in:
wood chen 2025-06-14 17:40:31 +08:00
parent efd55448e1
commit f2456e116b
84 changed files with 16518 additions and 1697 deletions

47
.dockerignore Normal file
View File

@ -0,0 +1,47 @@
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
data/logs/
data/server.log
# Database
data/data.db
data/stats.json
# Build artifacts
random-api-go.exe
random-api-go
random-api-test
*.exe
# Go
vendor/
# Docker
docker-compose*.yml
test-build.sh
# Documentation
*.md
DOCKER_DEPLOYMENT.md
# Misc
.env
.env.local
.env.example
README.md

BIN
.env.example Normal file

Binary file not shown.

View File

@ -22,19 +22,18 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Build for amd64
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o bin/amd64/random-api .
- name: Build for arm64
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -installsuffix cgo -o bin/arm64/random-api .
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -51,23 +50,12 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.run
file: Dockerfile
push: true
tags: woodchen/${{ env.IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm64
- name: Create artifact
run: |
zip -r public.zip public
- name: Deploy public directory to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: root
key: ${{ secrets.SERVER_SSH_KEY }}
source: 'public.zip'
target: '/tmp'
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Execute deployment commands
uses: appleboy/ssh-action@master
@ -76,28 +64,15 @@ jobs:
username: root
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
# 解压文件
unzip -o /tmp/public.zip -d /tmp/public_temp
# 删除目标目录中的现有文件
rm -rf /opt/1panel/docker/compose/random-api-go/data/public/*
# 移动新文件到目标目录
mv -f /tmp/public_temp/public/* /opt/1panel/docker/compose/random-api-go/data/public/
# 设置目录及其子文件的所有权和权限
chmod -R 0755 /opt/1panel/docker/compose/random-api-go/data/public
# 清理临时文件
rm /tmp/public.zip
rm -rf /tmp/public_temp
# 拉取镜像
# 拉取最新镜像
docker pull woodchen/random-api-go:latest
# 停止并删除容器
# 停止并删除旧容器
docker stop random-api-go || true
docker rm random-api-go || true
# 启动容器
# 启动新容器
docker compose -f /opt/1panel/docker/compose/random-api-go/docker-compose.yml up -d
# 清理未使用的镜像
docker image prune -f

View File

@ -1,63 +0,0 @@
name: Generate CSV Files
on:
push:
paths:
- lankong_tools/album_mapping.json
schedule:
- cron: '0 */4 * * *'
workflow_dispatch:
inputs:
message:
description: 'Trigger message'
required: false
default: 'Manual trigger to generate CSV files'
jobs:
generate:
runs-on: ubuntu-latest
steps:
- name: Checkout source repo
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Generate CSV files
run: |
go run lankong_tools/generate_csv.go
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
- name: Checkout target repo
uses: actions/checkout@v4
with:
repository: woodchen-ink/github-file
token: ${{ secrets.TARGET_REPO_TOKEN }}
path: target-repo
- name: Copy and commit files
run: |
# 删除不需要的文件和目录
rm -f public/index.html public/index.md
rm -rf public/css
# 创建目标目录
mkdir -p target-repo/random-api.czl.net/url/pic
# 复制所有CSV文件到pic目录
find public -name "*.csv" -exec cp -v {} target-repo/random-api.czl.net/url/pic/ \;
cd target-repo
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git remote set-url origin https://${{ secrets.TARGET_REPO_TOKEN }}@github.com/woodchen-ink/github-file.git
git add .
git commit -m "Auto update CSV files by GitHub Actions" || echo "No changes to commit"
git push origin main

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.cursorignore
.env
.cursor/rules/myrule.mdc
data/data.db
data/server.log
data/stats.json

141
DOCKER_DEPLOYMENT.md Normal file
View File

@ -0,0 +1,141 @@
# Docker 部署说明
## 概述
本项目现在使用单一Docker镜像部署包含前端Next.js和后端Go。前端被构建为静态文件并由后端服务器提供服务。
## 架构变更
### 之前的架构
- 前端独立的Next.js开发服务器
- 后端Go API服务器
- 部署:需要分别处理前后端
### 现在的架构
- 前端构建为静态文件Next.js export
- 后端Go服务器同时提供API和静态文件服务
- 部署单一Docker镜像包含完整应用
## 构建流程
### 多阶段Docker构建
1. **前端构建阶段**
```dockerfile
FROM node:20-alpine AS frontend-builder
# 安装依赖并构建前端静态文件
RUN npm run build
```
2. **后端构建阶段**
```dockerfile
FROM golang:1.23-alpine AS backend-builder
# 构建Go二进制文件
RUN go build -o random-api .
```
3. **运行阶段**
```dockerfile
FROM alpine:latest
# 复制后端二进制文件和前端静态文件
COPY --from=backend-builder /app/random-api .
COPY --from=frontend-builder /app/web/out ./web/out
```
## 路由处理
### 静态文件优先级
后端路由器现在按以下优先级处理请求:
1. **API路径** (`/api/*`) → 后端API处理器
2. **静态文件** (包含文件扩展名) → 静态文件服务
3. **前端路由** (`/`, `/admin/*`) → 返回index.html
4. **动态API端点** (其他路径) → 后端API处理器
### 路由判断逻辑
```go
func (r *Router) shouldServeStatic(path string) bool {
// API路径不由静态文件处理
if strings.HasPrefix(path, "/api/") {
return false
}
// 根路径和前端路由
if path == "/" || strings.HasPrefix(path, "/admin") {
return true
}
// 静态资源文件
if r.hasFileExtension(path) {
return true
}
return false
}
```
## 部署配置
### GitHub Actions
- 自动构建多架构镜像 (amd64/arm64)
- 推送到Docker Hub
- 自动部署到服务器
### Docker Compose
```yaml
services:
random-api-go:
container_name: random-api-go
image: woodchen/random-api-go:latest
ports:
- "5003:5003"
volumes:
- ./data:/root/data
environment:
- TZ=Asia/Shanghai
- BASE_URL=https://random-api.czl.net
restart: unless-stopped
```
## 访问地址
部署完成后,可以通过以下地址访问:
- **前端首页**: `http://localhost:5003/`
- **管理后台**: `http://localhost:5003/admin`
- **API统计**: `http://localhost:5003/api/stats`
- **动态API端点**: `http://localhost:5003/{endpoint-name}`
## 开发环境
### 本地开发
在开发环境中,前端仍然可以使用开发服务器:
```bash
# 启动后端
go run main.go
# 启动前端(另一个终端)
cd web
npm run dev
```
前端的`next.config.ts`会在开发环境中自动代理API请求到后端。
### 生产构建测试
```bash
# 构建前端
cd web
npm run build
# 启动后端(会自动服务静态文件)
cd ..
go run main.go
```
## 注意事项
1. **前端路由**: 所有前端路由都会返回`index.html`,由前端路由器处理
2. **API端点冲突**: 确保动态API端点名称不与静态文件路径冲突
3. **缓存**: 静态文件会被适当缓存API响应不会被缓存
4. **错误处理**: 404错误会根据路径类型返回相应的错误页面

View File

@ -1,43 +1,62 @@
# 构建阶段
FROM golang:1.23 AS builder
# 前端构建阶段
FROM node:20-alpine AS frontend-builder
WORKDIR /app/web
# 复制前端依赖文件
COPY web/package*.json ./
# 安装前端依赖
RUN npm ci --only=production
# 复制前端源代码
COPY web/ ./
# 构建前端静态文件
RUN npm run build
# 后端构建阶段
FROM golang:1.23-alpine AS backend-builder
WORKDIR /app
# 复制 go.mod 和 go.sum 文件(如果存在)
COPY go.mod go.sum* ./
# 安装必要的工具
RUN apk add --no-cache git
# 复制 go.mod 和 go.sum 文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制源代码
# 复制后端源代码
COPY . .
# 构建应用
# 构建后端应用
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o random-api .
# 运行阶段
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
# 安装必要的工具
RUN apk --no-cache add ca-certificates tzdata tini
WORKDIR /root/
COPY --from=builder /app/random-api .
COPY --from=builder /app/public ./public
# 复制 public 目录到一个临时位置
COPY --from=builder /app/public /tmp/public
# 从后端构建阶段复制二进制文件
COPY --from=backend-builder /app/random-api .
# 从前端构建阶段复制静态文件
COPY --from=frontend-builder /app/web/out ./web/out
# 创建必要的目录
RUN mkdir -p /root/data/logs /root/data/public
RUN mkdir -p /root/data/logs
# 暴露端口
EXPOSE 5003
# 使用 tini 作为初始化系统
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
# 创建一个启动脚本
COPY start.sh /start.sh
RUN chmod +x /start.sh
CMD ["/start.sh"]
# 启动应用
CMD ["./random-api"]

View File

@ -1,23 +0,0 @@
FROM --platform=$TARGETPLATFORM alpine:latest
WORKDIR /root/
# 安装必要的包
RUN apk --no-cache add ca-certificates tzdata tini
# 创建日志目录并设置权限
RUN mkdir -p /var/log/random-api && chmod 755 /var/log/random-api
# 根据目标平台复制对应的二进制文件
ARG TARGETARCH
COPY bin/${TARGETARCH}/random-api .
COPY public ./public
COPY public /tmp/public
COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 5003
# 使用 tini 作为初始化系统
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/start.sh"]

View File

@ -1,17 +0,0 @@
{
"server": {
"port": ":5003",
"read_timeout": "30s",
"write_timeout": "30s",
"max_header_bytes": 1048576
},
"storage": {
"data_dir": "/root/data",
"stats_file": "/root/data/stats.json",
"log_file": "/root/data/logs/server.log"
},
"api": {
"base_url": "",
"request_timeout": "10s"
}
}

View File

@ -1,45 +1,34 @@
package config
import (
"encoding/json"
"fmt"
"bufio"
"math/rand"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
EnvBaseURL = "BASE_URL"
DefaultPort = ":5003"
RequestTimeout = 10 * time.Second
)
type Config struct {
Server struct {
Port string `json:"port"`
ReadTimeout time.Duration `json:"read_timeout"`
WriteTimeout time.Duration `json:"write_timeout"`
MaxHeaderBytes int `json:"max_header_bytes"`
} `json:"server"`
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
MaxHeaderBytes int
}
Storage struct {
DataDir string `json:"data_dir"`
StatsFile string `json:"stats_file"`
LogFile string `json:"log_file"`
} `json:"storage"`
DataDir string
}
API struct {
BaseURL string `json:"base_url"`
RequestTimeout time.Duration `json:"request_timeout"`
} `json:"api"`
OAuth struct {
ClientID string
ClientSecret string
}
Performance struct {
MaxConcurrentRequests int `json:"max_concurrent_requests"`
RequestTimeout time.Duration `json:"request_timeout"`
CacheTTL time.Duration `json:"cache_ttl"`
EnableCompression bool `json:"enable_compression"`
} `json:"performance"`
App struct {
BaseURL string
}
}
var (
@ -47,88 +36,68 @@ var (
RNG *rand.Rand
)
func Load(configFile string) error {
// 尝试创建配置目录
configDir := filepath.Dir(configFile)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// 检查配置文件是否存在
if _, err := os.Stat(configFile); os.IsNotExist(err) {
// 创建默认配置
defaultConfig := Config{
Server: struct {
Port string `json:"port"`
ReadTimeout time.Duration `json:"read_timeout"`
WriteTimeout time.Duration `json:"write_timeout"`
MaxHeaderBytes int `json:"max_header_bytes"`
}{
Port: ":5003",
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
MaxHeaderBytes: 1 << 20,
},
Storage: struct {
DataDir string `json:"data_dir"`
StatsFile string `json:"stats_file"`
LogFile string `json:"log_file"`
}{
DataDir: "/root/data",
StatsFile: "/root/data/stats.json",
LogFile: "/root/data/logs/server.log",
},
API: struct {
BaseURL string `json:"base_url"`
RequestTimeout time.Duration `json:"request_timeout"`
}{
BaseURL: "",
RequestTimeout: 10 * time.Second,
},
Performance: struct {
MaxConcurrentRequests int `json:"max_concurrent_requests"`
RequestTimeout time.Duration `json:"request_timeout"`
CacheTTL time.Duration `json:"cache_ttl"`
EnableCompression bool `json:"enable_compression"`
}{
MaxConcurrentRequests: 100,
RequestTimeout: 10 * time.Second,
CacheTTL: 1 * time.Hour,
EnableCompression: true,
},
}
// 将默认配置写入文件
data, err := json.MarshalIndent(defaultConfig, "", " ")
// loadEnvFile 加载.env文件
func loadEnvFile() error {
file, err := os.Open(".env")
if err != nil {
return fmt.Errorf("failed to marshal default config: %w", err)
}
if err := os.WriteFile(configFile, data, 0644); err != nil {
return fmt.Errorf("failed to write default config: %w", err)
}
cfg = defaultConfig
return nil
}
// 读取现有配置文件
file, err := os.Open(configFile)
if err != nil {
return err
return err // .env文件不存在这是正常的
}
defer file.Close()
decoder := json.NewDecoder(file)
if err := decoder.Decode(&cfg); err != nil {
return err
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// 跳过空行和注释
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// 如果环境变量设置了 BASE_URL则覆盖配置文件中的设置
if envBaseURL := os.Getenv(EnvBaseURL); envBaseURL != "" {
cfg.API.BaseURL = envBaseURL
// 解析键值对
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// 移除引号
if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
(strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
value = value[1 : len(value)-1]
}
// 只有当环境变量不存在时才设置
if os.Getenv(key) == "" {
os.Setenv(key, value)
}
}
return scanner.Err()
}
// Load 从环境变量加载配置
func Load() error {
// 首先尝试加载.env文件
loadEnvFile() // 忽略错误,因为.env文件是可选的
// 服务器配置
cfg.Server.Port = getEnv("PORT", ":5003")
cfg.Server.ReadTimeout = getDurationEnv("READ_TIMEOUT", 30*time.Second)
cfg.Server.WriteTimeout = getDurationEnv("WRITE_TIMEOUT", 30*time.Second)
cfg.Server.MaxHeaderBytes = getIntEnv("MAX_HEADER_BYTES", 1<<20)
// 存储配置
cfg.Storage.DataDir = getEnv("DATA_DIR", "./data")
// OAuth配置
cfg.OAuth.ClientID = getEnv("OAUTH_CLIENT_ID", "")
cfg.OAuth.ClientSecret = getEnv("OAUTH_CLIENT_SECRET", "")
// 应用配置
cfg.App.BaseURL = getEnv("BASE_URL", "http://localhost:5003")
return nil
}
@ -139,3 +108,31 @@ func Get() *Config {
func InitRNG(r *rand.Rand) {
RNG = r
}
// getEnv 获取环境变量,如果不存在则返回默认值
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getIntEnv 获取整数类型的环境变量
func getIntEnv(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
// getDurationEnv 获取时间间隔类型的环境变量
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if duration, err := time.ParseDuration(value); err == nil {
return duration
}
}
return defaultValue
}

View File

@ -1,17 +0,0 @@
{
"server": {
"port": ":5003",
"read_timeout": "30s",
"write_timeout": "30s",
"max_header_bytes": 1048576
},
"storage": {
"data_dir": "/root/data",
"stats_file": "/root/data/stats.json",
"log_file": "/var/log/random-api/server.log"
},
"api": {
"base_url": "",
"request_timeout": "10s"
}
}

133
database/database.go Normal file
View File

@ -0,0 +1,133 @@
package database
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"random-api-go/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
// Initialize 初始化数据库
func Initialize(dataDir string) error {
// 确保数据目录存在
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
dbPath := filepath.Join(dataDir, "data.db")
// 配置GORM
config := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
var err error
DB, err = gorm.Open(sqlite.Open(dbPath), config)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
// 获取底层的sql.DB来设置连接池参数
sqlDB, err := DB.DB()
if err != nil {
return fmt.Errorf("failed to get underlying sql.DB: %w", err)
}
// 设置连接池参数
sqlDB.SetMaxOpenConns(1) // SQLite建议单连接
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(time.Hour)
// 自动迁移数据库结构
if err := autoMigrate(); err != nil {
return fmt.Errorf("failed to migrate database: %w", err)
}
log.Printf("Database initialized successfully at %s", dbPath)
return nil
}
// autoMigrate 自动迁移数据库结构
func autoMigrate() error {
return DB.AutoMigrate(
&models.APIEndpoint{},
&models.DataSource{},
&models.URLReplaceRule{},
&models.CachedURL{},
&models.Config{},
)
}
// Close 关闭数据库连接
func Close() error {
if DB != nil {
sqlDB, err := DB.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
return nil
}
// CleanExpiredCache 清理过期缓存
func CleanExpiredCache() error {
return DB.Where("expires_at < ?", time.Now()).Delete(&models.CachedURL{}).Error
}
// GetConfig 获取配置值
func GetConfig(key string, defaultValue string) string {
var config models.Config
if err := DB.Where("key = ?", key).First(&config).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return defaultValue
}
log.Printf("Failed to get config %s: %v", key, err)
return defaultValue
}
return config.Value
}
// SetConfig 设置配置值
func SetConfig(key, value, configType string) error {
var config models.Config
err := DB.Where("key = ?", key).First(&config).Error
if err == gorm.ErrRecordNotFound {
// 创建新配置
config = models.Config{
Key: key,
Value: value,
Type: configType,
}
return DB.Create(&config).Error
} else if err != nil {
return err
}
// 更新现有配置
config.Value = value
config.Type = configType
return DB.Save(&config).Error
}
// ListConfigs 列出所有配置
func ListConfigs() ([]models.Config, error) {
var configs []models.Config
err := DB.Order("key").Find(&configs).Error
return configs, err
}
// DeleteConfig 删除配置
func DeleteConfig(key string) error {
return DB.Where("key = ?", key).Delete(&models.Config{}).Error
}

View File

@ -8,5 +8,7 @@ services:
- ./data:/root/data
environment:
- TZ=Asia/Shanghai
- BASE_URL=https://example.net/random-api
- BASE_URL=https://random-api.czl.net
- OAUTH_CLIENT_ID=1234567890
- OAUTH_CLIENT_SECRET=1234567890
restart: unless-stopped

22
go.mod
View File

@ -4,4 +4,24 @@ go 1.23.0
toolchain go1.23.1
require golang.org/x/time v0.11.0
require (
github.com/glebarez/sqlite v1.11.0
golang.org/x/time v0.11.0
gorm.io/gorm v1.30.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.26.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.28.0 // indirect
)

33
go.sum
View File

@ -1,2 +1,35 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=

1127
handlers/admin_handler.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,108 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"random-api-go/monitoring"
"random-api-go/services"
"random-api-go/stats"
"random-api-go/utils"
"strings"
"time"
)
var statsManager *stats.StatsManager
// InitializeHandlers 初始化处理器
func InitializeHandlers(sm *stats.StatsManager) error {
statsManager = sm
return services.InitializeCSVService()
}
func HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
start := time.Now()
realIP := utils.GetRealIP(r)
path := strings.TrimPrefix(r.URL.Path, "/")
pathSegments := strings.Split(path, "/")
if len(pathSegments) < 2 {
monitoring.LogRequest(monitoring.RequestLog{
Time: time.Now().Unix(),
Path: r.URL.Path,
Method: r.Method,
StatusCode: http.StatusNotFound,
Latency: float64(time.Since(start).Microseconds()) / 1000,
IP: realIP,
Referer: r.Referer(),
})
http.NotFound(w, r)
return
}
prefix := pathSegments[0]
suffix := pathSegments[1]
services.Mu.RLock()
csvPath, ok := services.CSVPathsCache[prefix][suffix]
services.Mu.RUnlock()
if !ok {
http.NotFound(w, r)
return
}
selector, err := services.GetCSVContent(csvPath)
if err != nil {
http.Error(w, "Failed to fetch CSV content", http.StatusInternalServerError)
log.Printf("Error fetching CSV content: %v", err)
return
}
if len(selector.URLs) == 0 {
http.Error(w, "No content available", http.StatusNotFound)
return
}
randomURL := selector.GetRandomURL()
// 记录统计
endpoint := fmt.Sprintf("%s/%s", prefix, suffix)
statsManager.IncrementCalls(endpoint)
duration := time.Since(start)
// 记录请求日志
monitoring.LogRequest(monitoring.RequestLog{
Time: time.Now().Unix(),
Path: r.URL.Path,
Method: r.Method,
StatusCode: http.StatusFound,
Latency: float64(duration.Microseconds()) / 1000, // 转换为毫秒
IP: realIP,
Referer: r.Referer(),
})
log.Printf(" %-12s | %-15s | %-6s | %-20s | %-20s | %-50s",
duration, // 持续时间
realIP, // 真实IP
r.Method, // HTTP方法
r.URL.Path, // 请求路径
r.Referer(), // 来源信息
randomURL, // 重定向URL
)
http.Redirect(w, r, randomURL, http.StatusFound)
}
func HandleStats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
stats := statsManager.GetStats()
if err := json.NewEncoder(w).Encode(stats); err != nil {
http.Error(w, "Error encoding stats", http.StatusInternalServerError)
log.Printf("Error encoding stats: %v", err)
}
}

View File

@ -21,6 +21,7 @@ type Router interface {
type Handlers struct {
Stats *stats.StatsManager
endpointService *services.EndpointService
}
func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
@ -39,60 +40,15 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
realIP := utils.GetRealIP(r)
path := strings.TrimPrefix(r.URL.Path, "/")
pathSegments := strings.Split(path, "/")
if len(pathSegments) < 2 {
monitoring.LogRequest(monitoring.RequestLog{
Time: time.Now().UnixMilli(),
Path: r.URL.Path,
Method: r.Method,
StatusCode: http.StatusNotFound,
Latency: float64(time.Since(start).Microseconds()) / 1000,
IP: realIP,
Referer: r.Referer(),
})
resultChan <- result{err: fmt.Errorf("not found")}
return
// 初始化端点服务
if h.endpointService == nil {
h.endpointService = services.GetEndpointService()
}
prefix := pathSegments[0]
suffix := pathSegments[1]
services.Mu.RLock()
csvPath, ok := services.CSVPathsCache[prefix][suffix]
services.Mu.RUnlock()
if !ok {
monitoring.LogRequest(monitoring.RequestLog{
Time: time.Now().UnixMilli(),
Path: r.URL.Path,
Method: r.Method,
StatusCode: http.StatusNotFound,
Latency: float64(time.Since(start).Microseconds()) / 1000,
IP: realIP,
Referer: r.Referer(),
})
resultChan <- result{err: fmt.Errorf("not found")}
return
}
selector, err := services.GetCSVContent(csvPath)
// 使用新的端点服务
randomURL, err := h.endpointService.GetRandomURL(path)
if err != nil {
log.Printf("Error fetching CSV content: %v", err)
monitoring.LogRequest(monitoring.RequestLog{
Time: time.Now().UnixMilli(),
Path: r.URL.Path,
Method: r.Method,
StatusCode: http.StatusInternalServerError,
Latency: float64(time.Since(start).Microseconds()) / 1000,
IP: realIP,
Referer: r.Referer(),
})
resultChan <- result{err: err}
return
}
if len(selector.URLs) == 0 {
monitoring.LogRequest(monitoring.RequestLog{
Time: time.Now().UnixMilli(),
Path: r.URL.Path,
@ -102,13 +58,12 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
IP: realIP,
Referer: r.Referer(),
})
resultChan <- result{err: fmt.Errorf("no content available")}
resultChan <- result{err: fmt.Errorf("endpoint not found: %v", err)}
return
}
randomURL := selector.GetRandomURL()
endpoint := fmt.Sprintf("%s/%s", prefix, suffix)
h.Stats.IncrementCalls(endpoint)
// 成功获取到URL
h.Stats.IncrementCalls(path)
duration := time.Since(start)
monitoring.LogRequest(monitoring.RequestLog{
@ -137,7 +92,7 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
select {
case res := <-resultChan:
if res.err != nil {
http.Error(w, res.err.Error(), http.StatusInternalServerError)
http.Error(w, res.err.Error(), http.StatusNotFound)
return
}
http.Redirect(w, r, res.url, http.StatusFound)
@ -148,8 +103,14 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleStats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
stats := h.Stats.GetStats()
if err := json.NewEncoder(w).Encode(stats); err != nil {
stats := h.Stats.GetStatsForAPI()
// 包装数据格式以匹配前端期望
response := map[string]interface{}{
"Stats": stats,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Error encoding stats", http.StatusInternalServerError)
log.Printf("Error encoding stats: %v", err)
}
@ -157,18 +118,51 @@ func (h *Handlers) HandleStats(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleURLStats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
stats := services.GetURLCounts()
// 使用新的端点服务获取统计信息
if h.endpointService == nil {
h.endpointService = services.GetEndpointService()
}
endpoints, err := h.endpointService.ListEndpoints()
if err != nil {
http.Error(w, "Error getting endpoint stats", http.StatusInternalServerError)
return
}
// 转换为前端期望的格式
response := make(map[string]struct {
TotalURLs int `json:"total_urls"`
})
for endpoint, stat := range stats {
response[endpoint] = struct {
for _, endpoint := range endpoints {
if endpoint.IsActive {
totalURLs := 0
for _, ds := range endpoint.DataSources {
if ds.IsActive {
// 尝试获取实际的URL数量
urls, err := h.endpointService.GetDataSourceURLCount(&ds)
if err != nil {
log.Printf("Failed to get URL count for data source %d: %v", ds.ID, err)
// 如果获取失败,使用估算值
switch ds.Type {
case "manual":
totalURLs += 5 // 手动数据源估算
case "lankong":
totalURLs += 50 // 兰空图床估算
case "api_get", "api_post":
totalURLs += 1 // API数据源每次返回1个
}
} else {
totalURLs += urls
}
}
}
response[endpoint.URL] = struct {
TotalURLs int `json:"total_urls"`
}{
TotalURLs: stat.TotalURLs,
TotalURLs: totalURLs,
}
}
}
@ -185,12 +179,11 @@ func (h *Handlers) HandleMetrics(w http.ResponseWriter, r *http.Request) {
}
func (h *Handlers) Setup(r *router.Router) {
// 动态路由处理
r.HandleFunc("/pic/", h.HandleAPIRequest)
r.HandleFunc("/video/", h.HandleAPIRequest)
// 通用路由处理 - 匹配所有路径
r.HandleFunc("/", h.HandleAPIRequest)
// API 统计和监控
r.HandleFunc("/stats", h.HandleStats)
r.HandleFunc("/urlstats", h.HandleURLStats)
r.HandleFunc("/metrics", h.HandleMetrics)
r.HandleFunc("/api/stats", h.HandleStats)
r.HandleFunc("/api/urlstats", h.HandleURLStats)
r.HandleFunc("/api/metrics", h.HandleMetrics)
}

View File

@ -0,0 +1,95 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"strings"
)
type StaticHandler struct {
staticDir string
}
func NewStaticHandler(staticDir string) *StaticHandler {
return &StaticHandler{
staticDir: staticDir,
}
}
// ServeStatic 处理静态文件请求
func (s *StaticHandler) ServeStatic(w http.ResponseWriter, r *http.Request) {
// 获取请求路径
path := r.URL.Path
// 如果是根路径,重定向到 index.html
if path == "/" {
path = "/index.html"
}
// 构建文件路径
filePath := filepath.Join(s.staticDir, path)
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// 如果文件不存在,检查是否是前端路由
if s.isFrontendRoute(path) {
// 对于前端路由,返回 index.html
filePath = filepath.Join(s.staticDir, "index.html")
} else {
// 不是前端路由,返回 404
http.NotFound(w, r)
return
}
}
// 设置正确的 Content-Type
s.setContentType(w, filePath)
// 服务文件
http.ServeFile(w, r, filePath)
}
// isFrontendRoute 判断是否是前端路由
func (s *StaticHandler) isFrontendRoute(path string) bool {
// 前端路由通常以 /admin 开头
if strings.HasPrefix(path, "/admin") {
return true
}
// 排除 API 路径和静态资源
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/_next/") ||
strings.HasPrefix(path, "/static/") ||
strings.Contains(path, ".") {
return false
}
return false
}
// setContentType 设置正确的 Content-Type
func (s *StaticHandler) setContentType(w http.ResponseWriter, filePath string) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".html":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
case ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case ".js":
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
case ".json":
w.Header().Set("Content-Type", "application/json; charset=utf-8")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
case ".svg":
w.Header().Set("Content-Type", "image/svg+xml")
case ".ico":
w.Header().Set("Content-Type", "image/x-icon")
}
}

View File

@ -1,8 +0,0 @@
{
"pic/loading.csv": ["19"],
"pic/ai.csv": ["18"],
"pic/fj.csv": ["16"],
"pic/ecy.csv": ["14"],
"pic/truegirl.csv": ["10"],
"pic/czlwb.csv": ["12"]
}

View File

@ -1,164 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
const (
BaseURL = "https://img.czl.net/api/v1/images"
)
// API响应结构体
type Response struct {
Status bool `json:"status"`
Message string `json:"message"`
Data struct {
CurrentPage int `json:"current_page"`
LastPage int `json:"last_page"`
Data []struct {
Links struct {
URL string `json:"url"`
} `json:"links"`
} `json:"data"`
} `json:"data"`
}
// 修改映射类型为 map[string][]string键为CSV文件路径值为相册ID数组
type AlbumMapping map[string][]string
func main() {
apiToken := os.Getenv("API_TOKEN")
if apiToken == "" {
panic("API_TOKEN environment variable is required")
}
// 读取本地的相册映射配置
mappingFile, err := os.ReadFile("lankong_tools/album_mapping.json")
if err != nil {
panic(fmt.Sprintf("Failed to read album mapping file: %v", err))
}
var albumMapping AlbumMapping
if err := json.Unmarshal(mappingFile, &albumMapping); err != nil {
panic(fmt.Sprintf("Failed to parse album mapping: %v", err))
}
// 创建输出目录
if err := os.MkdirAll("public", 0755); err != nil {
panic(fmt.Sprintf("Failed to create output directory: %v", err))
}
// 处理每个CSV文件的映射
for csvPath, albumIDs := range albumMapping {
fmt.Printf("Processing CSV file: %s (Albums: %v)\n", csvPath, albumIDs)
// 收集所有相册的URLs
var allURLs []string
for _, albumID := range albumIDs {
fmt.Printf("Fetching URLs for album %s\n", albumID)
urls := fetchAllURLs(albumID, apiToken)
allURLs = append(allURLs, urls...)
}
// 确保目录存在
dir := filepath.Dir(filepath.Join("public", csvPath))
if err := os.MkdirAll(dir, 0755); err != nil {
panic(fmt.Sprintf("Failed to create directory for %s: %v", csvPath, err))
}
// 写入CSV文件
if err := writeURLsToFile(allURLs, filepath.Join("public", csvPath)); err != nil {
panic(fmt.Sprintf("Failed to write URLs to file %s: %v", csvPath, err))
}
fmt.Printf("Finished processing %s: wrote %d URLs\n", csvPath, len(allURLs))
}
fmt.Println("All CSV files generated successfully!")
}
func fetchAllURLs(albumID string, apiToken string) []string {
var allURLs []string
client := &http.Client{}
// 获取第一页以确定总页数
firstPageURL := fmt.Sprintf("%s?album_id=%s&page=1", BaseURL, albumID)
response, err := fetchPage(firstPageURL, apiToken, client)
if err != nil {
panic(fmt.Sprintf("Failed to fetch first page: %v", err))
}
totalPages := response.Data.LastPage
fmt.Printf("Album %s has %d pages in total\n", albumID, totalPages)
// 处理所有页面
for page := 1; page <= totalPages; page++ {
reqURL := fmt.Sprintf("%s?album_id=%s&page=%d", BaseURL, albumID, page)
response, err := fetchPage(reqURL, apiToken, client)
if err != nil {
panic(fmt.Sprintf("Failed to fetch page %d: %v", page, err))
}
for _, item := range response.Data.Data {
if item.Links.URL != "" {
allURLs = append(allURLs, item.Links.URL)
}
}
fmt.Printf("Fetched page %d of %d for album %s (total URLs so far: %d)\n",
page, totalPages, albumID, len(allURLs))
}
fmt.Printf("Finished album %s: collected %d URLs in total\n", albumID, len(allURLs))
return allURLs
}
func fetchPage(url string, apiToken string, client *http.Client) (*Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Authorization", apiToken)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %v", err)
}
var response Response
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("failed to parse response: %v", err)
}
return &response, nil
}
func writeURLsToFile(urls []string, filepath string) error {
file, err := os.Create(filepath)
if err != nil {
return err
}
defer file.Close()
for _, url := range urls {
if _, err := file.WriteString(url + "\n"); err != nil {
return err
}
}
return nil
}

49
main.go
View File

@ -8,7 +8,9 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"random-api-go/config"
"random-api-go/database"
"random-api-go/handlers"
"random-api-go/logging"
"random-api-go/router"
@ -22,6 +24,8 @@ type App struct {
server *http.Server
router *router.Router
Stats *stats.StatsManager
adminHandler *handlers.AdminHandler
staticHandler *handlers.StaticHandler
}
func NewApp() *App {
@ -32,7 +36,7 @@ func NewApp() *App {
func (a *App) Initialize() error {
// 先加载配置
if err := config.Load("/root/data/config.json"); err != nil {
if err := config.Load(); err != nil {
return err
}
@ -45,15 +49,35 @@ func (a *App) Initialize() error {
return fmt.Errorf("failed to create data directory: %w", err)
}
// 初始化数据库
if err := database.Initialize(config.Get().Storage.DataDir); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// 初始化日志
logging.SetupLogging()
// 初始化统计管理器
a.Stats = stats.NewStatsManager(config.Get().Storage.StatsFile)
statsFile := config.Get().Storage.DataDir + "/stats.json"
a.Stats = stats.NewStatsManager(statsFile)
// 初始化服务
if err := services.InitializeCSVService(); err != nil {
return err
// 初始化端点服务
services.GetEndpointService()
// 创建管理后台处理器
a.adminHandler = handlers.NewAdminHandler()
// 创建静态文件处理器
staticDir := "./web/out"
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
log.Printf("Warning: Static directory %s does not exist, static file serving will be disabled", staticDir)
} else {
absStaticDir, err := filepath.Abs(staticDir)
if err != nil {
return fmt.Errorf("failed to get absolute path for static directory: %w", err)
}
a.staticHandler = handlers.NewStaticHandler(absStaticDir)
log.Printf("Static file serving enabled from: %s", absStaticDir)
}
// 创建 handlers
@ -63,6 +87,12 @@ func (a *App) Initialize() error {
// 设置路由
a.router.Setup(handlers)
a.router.SetupAdminRoutes(a.adminHandler)
// 设置静态文件路由(如果静态文件处理器存在)
if a.staticHandler != nil {
a.router.SetupStaticRoutes(a.staticHandler)
}
// 创建 HTTP 服务器
cfg := config.Get().Server
@ -81,6 +111,10 @@ func (a *App) Run() error {
// 启动服务器
go func() {
log.Printf("Server starting on %s...\n", a.server.Addr)
if a.staticHandler != nil {
log.Printf("Frontend available at: http://localhost%s", a.server.Addr)
log.Printf("Admin panel available at: http://localhost%s/admin", a.server.Addr)
}
if err := a.server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
@ -102,6 +136,11 @@ func (a *App) gracefulShutdown() error {
a.Stats.Shutdown()
// 关闭数据库连接
if err := database.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
if err := a.server.Shutdown(ctx); err != nil {
return err
}

120
models/api_endpoint.go Normal file
View File

@ -0,0 +1,120 @@
package models
import (
"time"
"gorm.io/gorm"
)
// APIEndpoint API端点模型
type APIEndpoint struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
URL string `json:"url" gorm:"uniqueIndex;not null"`
Description string `json:"description"`
IsActive bool `json:"is_active" gorm:"default:true"`
ShowOnHomepage bool `json:"show_on_homepage" gorm:"default:true"`
SortOrder int `json:"sort_order" gorm:"default:0;index"` // 排序字段,数值越小越靠前
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// 关联
DataSources []DataSource `json:"data_sources,omitempty" gorm:"foreignKey:EndpointID"`
URLReplaceRules []URLReplaceRule `json:"url_replace_rules,omitempty" gorm:"foreignKey:EndpointID"`
}
// DataSource 数据源模型
type DataSource struct {
ID uint `json:"id" gorm:"primaryKey"`
EndpointID uint `json:"endpoint_id" gorm:"not null;index"`
Name string `json:"name" gorm:"not null"`
Type string `json:"type" gorm:"not null;check:type IN ('lankong', 'manual', 'api_get', 'api_post', 'endpoint')"`
Config string `json:"config" gorm:"not null"`
CacheDuration int `json:"cache_duration" gorm:"default:3600"` // 缓存时长(秒)
IsActive bool `json:"is_active" gorm:"default:true"`
LastSync *time.Time `json:"last_sync,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// 关联
Endpoint APIEndpoint `json:"-" gorm:"foreignKey:EndpointID"`
CachedURLs []CachedURL `json:"-" gorm:"foreignKey:DataSourceID"`
}
// URLReplaceRule URL替换规则模型
type URLReplaceRule struct {
ID uint `json:"id" gorm:"primaryKey"`
EndpointID *uint `json:"endpoint_id" gorm:"index"` // 可以为空,表示全局规则
Name string `json:"name" gorm:"not null"`
FromURL string `json:"from_url" gorm:"not null"`
ToURL string `json:"to_url" gorm:"not null"`
IsActive bool `json:"is_active" gorm:"default:true"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// 关联
Endpoint *APIEndpoint `json:"endpoint,omitempty" gorm:"foreignKey:EndpointID"`
}
// CachedURL 缓存URL模型
type CachedURL struct {
ID uint `json:"id" gorm:"primaryKey"`
DataSourceID uint `json:"data_source_id" gorm:"not null;index"`
OriginalURL string `json:"original_url" gorm:"not null"`
FinalURL string `json:"final_url" gorm:"not null"`
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
// 关联
DataSource DataSource `json:"-" gorm:"foreignKey:DataSourceID"`
}
// Config 通用配置表
type Config struct {
ID uint `json:"id" gorm:"primaryKey"`
Key string `json:"key" gorm:"uniqueIndex;not null"` // 配置键,如 "homepage_content"
Value string `json:"value" gorm:"type:text"` // 配置值
Type string `json:"type" gorm:"default:'string'"` // 配置类型string, json, number, boolean
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// DataSourceConfig 数据源配置结构体
type DataSourceConfig struct {
// 兰空图床配置
LankongConfig *LankongConfig `json:"lankong_config,omitempty"`
// 手动数据配置
ManualConfig *ManualConfig `json:"manual_config,omitempty"`
// API配置
APIConfig *APIConfig `json:"api_config,omitempty"`
// 端点配置
EndpointConfig *EndpointConfig `json:"endpoint_config,omitempty"`
}
type LankongConfig struct {
APIToken string `json:"api_token"`
AlbumIDs []string `json:"album_ids"`
BaseURL string `json:"base_url"`
}
type ManualConfig struct {
URLs []string `json:"urls"`
}
type APIConfig struct {
URL string `json:"url"`
Method string `json:"method"` // GET, POST
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
URLField string `json:"url_field"` // JSON字段路径如 "data.url" 或 "urls[0]"
}
type EndpointConfig struct {
EndpointIDs []uint `json:"endpoint_ids"` // 选中的端点ID列表
}

View File

@ -1,10 +0,0 @@
{
"pic": {
"all": "随机图片",
"fj": "随机风景",
"loading": "随机加载图"
},
"video": {
"all": "随机视频"
}
}

View File

@ -1,406 +0,0 @@
html,
body {
height: 100%;
margin: 0;
font-weight: 300;
background: transparent;
overflow: auto;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url(https://random-api.czl.net/pic/normal);
background-size: cover;
background-position: center;
z-index: -1;
opacity: 0.8;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 2;
overflow-y: auto;
}
#markdown-content {
position: relative;
z-index: 3;
background-color: transparent;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
* {
box-sizing: border-box;
}
main {
padding: 1vw;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
}
img {
max-width: 100%;
height: auto;
}
.stats-summary {
background: rgba(255, 255, 255, 0.05);
padding: 20px;
border-radius: 8px;
margin: 20px 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stats-item {
background: rgba(255, 255, 255, 0.05);
padding: 12px 15px;
border-radius: 6px;
font-size: 0.95em;
color: #999;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 0 10px 0;
}
.stats-header h2 {
margin: 0;
padding: 0;
color: #fff;
}
.refresh-icon {
font-size: 16px;
margin-left: 10px;
display: inline-block;
animation: none;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spinning {
animation: spin 1s linear infinite;
}
.stats-summary,
table {
transition: opacity 0.3s ease;
}
/* .fade {
opacity: 0.6;
} */
.endpoint-link {
color: #2196f3;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
}
.endpoint-link:hover {
background: rgba(33, 150, 243, 0.1);
color: #2196f3;
transform: translateY(-1px);
}
/* 点击时的效果 */
.endpoint-link:active {
transform: translateY(0);
box-shadow: none;
}
/* 提示框样式也稍作优化 */
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background-color: #2196f3; /* 改为蓝色背景 */
color: white;
padding: 12px 24px;
border-radius: 4px;
z-index: 1000;
animation: fadeInOut 2s ease;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
font-weight: 500;
}
@keyframes fadeInOut {
0% {
opacity: 0;
transform: translate(-50%, 20px);
}
15% {
opacity: 1;
transform: translate(-50%, 0);
}
85% {
opacity: 1;
transform: translate(-50%, 0);
}
100% {
opacity: 0;
transform: translate(-50%, -20px);
}
}
/* 系统监控样式 */
.metrics-container {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.metrics-section {
margin-bottom: 20px;
}
.metrics-section h3 {
color: #2196f3;
margin-bottom: 15px;
font-size: 1.1em;
border-bottom: 1px solid rgba(33, 150, 243, 0.2);
padding-bottom: 5px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.metric-item {
background: rgba(255, 255, 255, 0.1);
padding: 12px;
border-radius: 6px;
font-size: 0.9em;
}
.status-codes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.status-code-item {
background: rgba(255, 255, 255, 0.1);
padding: 8px 12px;
border-radius: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.recent-requests table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.recent-requests th,
.recent-requests td {
padding: 8px;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.recent-requests th {
color: #2196f3;
font-weight: 500;
}
.top-referers {
display: grid;
gap: 8px;
}
.referer-item {
background: rgba(255, 255, 255, 0.1);
padding: 8px 12px;
border-radius: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.referer {
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
color: #2196f3;
font-weight: 500;
}
/* 更新表格样式 */
.stats-table {
margin-top: 20px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
overflow: hidden;
margin: 20px 0;
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: #999;
}
th {
background: rgba(33, 150, 243, 0.1);
font-weight: 500;
color: #2196f3;
}
tr:hover {
background: rgba(255, 255, 255, 0.05);
}
/* 操作按钮样式 */
td a {
color: #2196f3;
text-decoration: none;
margin-right: 8px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
}
td a:hover {
background: rgba(33, 150, 243, 0.1);
}
/* 响应式优化 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
th, td {
padding: 8px 10px;
}
.stats-table {
margin: 10px -15px;
width: calc(100% + 30px);
}
}
/* 系统指标样式 */
.metrics-section {
margin-bottom: 20px;
}
.metric-label {
color: #999;
font-size: 0.9em;
margin-bottom: 4px;
}
.metric-value {
font-size: 1.1em;
font-weight: 500;
color: #2196f3;
}
.error-message {
background: rgba(255, 0, 0, 0.1);
color: #ff4444;
padding: 12px;
border-radius: 6px;
margin: 10px 0;
text-align: center;
}
/* 确保系统指标和统计数据之间有适当间距 */
#system-metrics {
max-width: 800px;
margin: 0 auto 30px auto;
}
/* 优化移动端显示 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.metric-label {
font-size: 0.85em;
}
.metric-value {
font-size: 1em;
}
}
.main-title {
text-align: center;
color: #fff;
margin: 20px 0;
font-size: 2em;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
position: relative;
z-index: 3;
}
/* 修改统计数据容器的宽度限制 */
.stats-container {
max-width: 800px;
margin: 0 auto;
}
/* 移动端适配 */
@media (max-width: 768px) {
.stats-container {
padding: 0 15px;
}
}

View File

@ -1,318 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<title>随机文件api</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="随机图API, 随机视频等 ">
<link rel="shortcut icon" size="32x32" href="https://i.czl.net/r2/2023/06/20/649168ebc2b5d.png">
<link rel="stylesheet" href="https://i.czl.net/g-f/frame/prose.css" media="all">
<link rel="stylesheet" href="./css/main.css" media="all">
<script src="https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-M/markdown-it/12.3.2/markdown-it.min.js"></script>
</head>
<body>
<h1 class="main-title">Random-Api 随机文件API</h1>
<div class="overlay">
<main>
<div id="system-metrics"></div>
<div class="stats-container">
<div id="stats-summary"></div>
<div id="stats-detail"></div>
</div>
<div id="markdown-content" class="prose prose-dark">
</div>
</main>
</div>
<!-- 渲染markdown -->
<script>
// 创建带有配置的 markdown-it 实例
var md = window.markdownit({
html: true
});
// 用于存储配置的全局变量
let cachedEndpointConfig = null;
// 加载配置的函数
async function loadEndpointConfig() {
if (cachedEndpointConfig) {
return cachedEndpointConfig;
}
try {
const response = await fetch('/config/endpoint.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
cachedEndpointConfig = await response.json();
return cachedEndpointConfig;
} catch (error) {
console.error('加载endpoint配置失败:', error);
return {};
}
}
// 加载统计数据
async function loadStats() {
try {
// 添加刷新动画
const refreshIcon = document.querySelector('.refresh-icon');
const summaryElement = document.getElementById('stats-summary');
const detailElement = document.getElementById('stats-detail');
if (refreshIcon) {
refreshIcon.classList.add('spinning');
}
if (summaryElement) summaryElement.classList.add('fade');
if (detailElement) detailElement.classList.add('fade');
// 获取数据
const [statsResponse, urlStatsResponse, endpointConfig] = await Promise.all([
fetch('/stats'),
fetch('/urlstats'),
loadEndpointConfig()
]);
const stats = await statsResponse.json();
const urlStats = await urlStatsResponse.json();
// 更新统计
await updateStats(stats, urlStats);
// 移除动画
setTimeout(() => {
if (refreshIcon) {
refreshIcon.classList.remove('spinning');
}
if (summaryElement) summaryElement.classList.remove('fade');
if (detailElement) detailElement.classList.remove('fade');
}, 300);
} catch (error) {
console.error('Error loading stats:', error);
}
}
// 更新统计显示
async function updateStats(stats, urlStats) {
const startDate = new Date('2024-11-1');
const today = new Date();
const daysSinceStart = Math.ceil((today - startDate) / (1000 * 60 * 60 * 24));
let totalCalls = 0;
let todayCalls = 0;
// 计算总调用次数
Object.entries(stats).forEach(([endpoint, stat]) => {
totalCalls += stat.total_calls;
todayCalls += stat.today_calls;
});
const avgCallsPerDay = Math.round(totalCalls / daysSinceStart);
// 获取 endpoint 配置
const endpointConfig = await loadEndpointConfig();
// 更新总览统计
const summaryHtml = `
<div class="stats-summary">
<div class="stats-header">
<h2>📊 接口调用次数 <span class="refresh-icon">🔄</span></h2>
</div>
<div class="stats-grid">
<div class="stats-item">今日总调用:${todayCalls} 次</div>
<div class="stats-item">平均每天调用:${avgCallsPerDay} 次</div>
<div class="stats-item">总调用次数:${totalCalls} 次</div>
<div class="stats-item">统计开始日期2024-11-1</div>
</div>
</div>
<table>
<thead>
<tr>
<th>接口名称</th>
<th>今日调用</th>
<th>总调用</th>
<th>URL数量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
${Object.entries(endpointConfig)
.sort(([, a], [, b]) => (a.order || 0) - (b.order || 0))
.map(([endpoint, config]) => {
const stat = stats[endpoint] || { today_calls: 0, total_calls: 0 };
const urlCount = urlStats[endpoint]?.total_urls || 0;
return `
<tr>
<td>
<a href="javascript:void(0)"
onclick="copyToClipboard('${endpoint}')"
class="endpoint-link"
title="点击复制链接">
${config.name}
</a>
</td>
<td>${stat.today_calls}</td>
<td>${stat.total_calls}</td>
<td>${urlCount}</td>
<td>
<a href="/${endpoint}" target="_blank" rel="noopener noreferrer" title="测试接口">👀</a>
<a href="javascript:void(0)" onclick="copyToClipboard('${endpoint}')" title="复制链接">📋</a>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
// 更新 DOM
const container = document.querySelector('.stats-container');
if (container) {
container.innerHTML = summaryHtml;
}
}
// 复制链接功能
function copyToClipboard(endpoint) {
const url = `${window.location.protocol}//${window.location.host}/${endpoint}`;
navigator.clipboard.writeText(url).then(() => {
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = '链接已复制到剪贴板!';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}).catch(err => {
console.error('复制失败:', err);
});
}
// 先加载 markdown 内容
fetch('./index.md')
.then(response => response.text())
.then(markdownText => {
document.getElementById('markdown-content').innerHTML = md.render(markdownText);
// markdown 加载完成后等待一小段时间再加载统计数据
setTimeout(loadStats, 100);
})
.catch(error => console.error('Error loading index.md:', error));
// 定期更新统计数据
setInterval(loadStats, 5 * 1000);
async function loadMetrics() {
try {
const response = await fetch('/metrics');
const data = await response.json();
if (!data || typeof data !== 'object') {
throw new Error('Invalid metrics data received');
}
// 格式化函数
const formatUptime = (ns) => {
const seconds = Math.floor(ns / 1e9);
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${days}天 ${hours}小时 ${minutes}分钟`;
};
const formatBytes = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const metricsHtml = `
<div class="metrics-section">
<div class="stats-summary">
<div class="stats-header">
<h2>💻 系统状态</h2>
</div>
<div class="stats-grid">
<div class="stats-item">
<div class="metric-label">运行时间</div>
<div class="metric-value">${formatUptime(data.uptime)}</div>
</div>
<div class="stats-item">
<div class="metric-label">启动时间</div>
<div class="metric-value">${formatDate(data.start_time)}</div>
</div>
<div class="stats-item">
<div class="metric-label">CPU核心数</div>
<div class="metric-value">${data.num_cpu} 核</div>
</div>
<div class="stats-item">
<div class="metric-label">Goroutine数量</div>
<div class="metric-value">${data.num_goroutine}</div>
</div>
<div class="stats-item">
<div class="metric-label">平均延迟</div>
<div class="metric-value">${data.average_latency.toFixed(2)} ms</div>
</div>
<div class="stats-item">
<div class="metric-label">堆内存分配</div>
<div class="metric-value">${formatBytes(data.memory_stats.heap_alloc)}</div>
</div>
<div class="stats-item">
<div class="metric-label">系统内存</div>
<div class="metric-value">${formatBytes(data.memory_stats.heap_sys)}</div>
</div>
</div>
</div>
</div>
`;
const container = document.getElementById('system-metrics');
if (container) {
container.innerHTML = metricsHtml;
}
} catch (error) {
console.error('Error loading metrics:', error);
const container = document.getElementById('system-metrics');
if (container) {
container.innerHTML = '<div class="error-message">加载系统指标失败</div>';
}
}
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// 定期更新监控数据
setInterval(loadMetrics, 5000);
// 初始加载
document.addEventListener('DOMContentLoaded', () => {
loadMetrics();
});
</script>
</body>
</html>

View File

@ -1,19 +0,0 @@
<div id="system-metrics"></div>
<div class="stats-container">
<div id="stats-summary"></div>
<div id="stats-detail"></div>
</div>
---
## 部署和原理
请见我的帖子:[https://www.q58.club/t/topic/127](https://www.q58.club/t/topic/127)
## 讨论
请在帖子下留言,我看到后会回复,谢谢。
**永久可用**

119
readme.md
View File

@ -1,18 +1,119 @@
# Random API
# Random API Go
介绍,使用方法和更新记录: https://q58.club/t/topic/127
一个基于Go的随机API服务支持多种数据源和管理后台。
Random API 是一个用 Go 语言编写的简单而强大的随机图片/视频 API 服务。它允许用户通过配置文件轻松管理和提供随机媒体内容。
## 功能特性
## 压测
- 🎯 支持多种数据源兰空图床API、手动配置、通用API接口
- 🔐 OAuth2.0 管理后台登录CZL Connect
- 💾 SQLite数据库存储
- ⚡ 内存缓存机制
- 🔄 URL替换规则
- 📝 可配置首页内容
- 🎨 现代化Web管理界面
![d3d80d258e1e4805289b607666681e8b](https://github.com/user-attachments/assets/aeeacf76-02ec-4ea3-b38d-b8b25a94f92a)
## 环境变量配置
复制 `env.example``.env` 并配置以下环境变量:
## 贡献
```bash
# 服务器配置
PORT=:5003 # 服务端口
READ_TIMEOUT=30s # 读取超时
WRITE_TIMEOUT=30s # 写入超时
MAX_HEADER_BYTES=1048576 # 最大请求头大小
欢迎贡献!请提交 pull request 或创建 issue 来提出建议和报告 bug。
# 数据存储目录
DATA_DIR=./data # 数据存储目录
## 许可
# OAuth2.0 配置 (必需)
OAUTH_CLIENT_ID=your-oauth-client-id # CZL Connect 客户端ID
OAUTH_CLIENT_SECRET=your-oauth-client-secret # CZL Connect 客户端密钥
```
[MIT License](LICENSE)
## 快速开始
1. 克隆项目
```bash
git clone <repository-url>
cd random-api-go
```
2. 配置环境变量
```bash
cp env.example .env
# 编辑 .env 文件,填入正确的 OAuth 配置
```
3. 运行服务
```bash
go run main.go
```
4. 访问服务
- 首页: http://localhost:5003
- 管理后台: http://localhost:5003/admin
## OAuth2.0 配置
本项目使用 CZL Connect 作为 OAuth2.0 提供商:
- 授权端点: https://connect.czl.net/oauth2/authorize
- 令牌端点: https://connect.czl.net/api/oauth2/token
- 用户信息端点: https://connect.czl.net/api/oauth2/userinfo
请在 CZL Connect 中注册应用并获取 `client_id``client_secret`
## API 端点
### 公开API
- `GET /` - 首页
- `GET /{endpoint}` - 随机API端点
### 管理API
- `GET /admin/api/oauth-config` - 获取OAuth配置
- `POST /admin/api/oauth-verify` - 验证OAuth授权码
- `GET /admin/api/endpoints` - 列出所有端点
- `POST /admin/api/endpoints/` - 创建端点
- `GET /admin/api/endpoints/{id}` - 获取端点详情
- `PUT /admin/api/endpoints/{id}` - 更新端点
- `DELETE /admin/api/endpoints/{id}` - 删除端点
- `POST /admin/api/data-sources` - 创建数据源
- `GET /admin/api/url-replace-rules` - 列出URL替换规则
- `POST /admin/api/url-replace-rules/` - 创建URL替换规则
- `GET /admin/api/home-config` - 获取首页配置
- `PUT /admin/api/home-config/` - 更新首页配置
## 数据源类型
1. **兰空图床 (lankong)**: 从兰空图床API获取图片
2. **手动配置 (manual)**: 手动配置的URL列表
3. **API GET (api_get)**: 从GET接口获取数据
4. **API POST (api_post)**: 从POST接口获取数据
## 部署
### Docker 部署
```dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o random-api-server main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/random-api-server .
COPY --from=builder /app/web ./web
EXPOSE 5003
CMD ["./random-api-server"]
```
### 环境变量部署
确保在生产环境中正确设置所有必需的环境变量特别是OAuth配置。
## 许可证
MIT License

View File

@ -2,17 +2,56 @@ package router
import (
"net/http"
"random-api-go/middleware"
"strings"
)
type Router struct {
mux *http.ServeMux
staticHandler StaticHandler
}
type Handler interface {
Setup(r *Router)
}
// StaticHandler 接口定义静态文件处理器需要的方法
type StaticHandler interface {
ServeStatic(w http.ResponseWriter, r *http.Request)
}
// AdminHandler 接口定义管理后台处理器需要的方法
type AdminHandler interface {
// OAuth相关
GetOAuthConfig(w http.ResponseWriter, r *http.Request)
VerifyOAuthToken(w http.ResponseWriter, r *http.Request)
HandleOAuthCallback(w http.ResponseWriter, r *http.Request)
// 端点管理
HandleEndpoints(w http.ResponseWriter, r *http.Request)
HandleEndpointByID(w http.ResponseWriter, r *http.Request)
HandleEndpointDataSources(w http.ResponseWriter, r *http.Request)
UpdateEndpointSortOrder(w http.ResponseWriter, r *http.Request)
// 数据源管理
CreateDataSource(w http.ResponseWriter, r *http.Request)
HandleDataSourceByID(w http.ResponseWriter, r *http.Request)
SyncDataSource(w http.ResponseWriter, r *http.Request)
// URL替换规则
ListURLReplaceRules(w http.ResponseWriter, r *http.Request)
CreateURLReplaceRule(w http.ResponseWriter, r *http.Request)
HandleURLReplaceRuleByID(w http.ResponseWriter, r *http.Request)
// 首页配置
GetHomePageConfig(w http.ResponseWriter, r *http.Request)
UpdateHomePageConfig(w http.ResponseWriter, r *http.Request)
// 通用配置管理
ListConfigs(w http.ResponseWriter, r *http.Request)
CreateOrUpdateConfig(w http.ResponseWriter, r *http.Request)
DeleteConfigByKey(w http.ResponseWriter, r *http.Request)
}
func New() *Router {
return &Router{
mux: http.NewServeMux(),
@ -20,21 +59,154 @@ func New() *Router {
}
func (r *Router) Setup(h Handler) {
// 静态文件服务
fileServer := http.FileServer(http.Dir("/root/data/public"))
r.mux.Handle("/", middleware.Chain(
middleware.Recovery,
middleware.MetricsMiddleware,
)(fileServer))
// 设置API路由
h.Setup(r)
}
// SetupStaticRoutes 设置静态文件路由
func (r *Router) SetupStaticRoutes(staticHandler StaticHandler) {
r.staticHandler = staticHandler
}
// SetupAdminRoutes 设置管理后台路由
func (r *Router) SetupAdminRoutes(adminHandler AdminHandler) {
// OAuth配置API前端需要获取client_id等信息
r.HandleFunc("/api/admin/oauth-config", adminHandler.GetOAuthConfig)
// OAuth令牌验证API保留以防需要
r.HandleFunc("/api/admin/oauth-verify", adminHandler.VerifyOAuthToken)
// OAuth回调处理使用API前缀以便区分前后端
r.HandleFunc("/api/admin/oauth/callback", adminHandler.HandleOAuthCallback)
// 管理后台API路由
r.HandleFunc("/api/admin/endpoints", adminHandler.HandleEndpoints)
// 端点排序路由
r.HandleFunc("/api/admin/endpoints/sort-order", adminHandler.UpdateEndpointSortOrder)
// 数据源路由 - 需要在端点路由之前注册,因为路径更具体
r.HandleFunc("/api/admin/data-sources", adminHandler.CreateDataSource)
// 端点相关路由 - 使用通配符处理所有端点相关请求
r.HandleFunc("/api/admin/endpoints/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.Contains(path, "/data-sources") {
adminHandler.HandleEndpointDataSources(w, r)
} else {
adminHandler.HandleEndpointByID(w, r)
}
})
// 数据源操作路由 - 使用通配符处理所有数据源相关请求
r.HandleFunc("/api/admin/data-sources/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.Contains(path, "/sync") {
adminHandler.SyncDataSource(w, r)
} else {
adminHandler.HandleDataSourceByID(w, r)
}
})
// URL替换规则路由
r.HandleFunc("/api/admin/url-replace-rules", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
adminHandler.ListURLReplaceRules(w, r)
} else if r.Method == http.MethodPost {
adminHandler.CreateURLReplaceRule(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
r.HandleFunc("/api/admin/url-replace-rules/", adminHandler.HandleURLReplaceRuleByID)
// 首页配置路由
r.HandleFunc("/api/admin/home-config", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
adminHandler.GetHomePageConfig(w, r)
} else {
adminHandler.UpdateHomePageConfig(w, r)
}
})
// 通用配置管理路由
r.HandleFunc("/api/admin/configs", adminHandler.ListConfigs)
r.HandleFunc("/api/admin/configs/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
adminHandler.DeleteConfigByKey(w, r)
} else {
adminHandler.CreateOrUpdateConfig(w, r)
}
})
}
func (r *Router) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
r.mux.HandleFunc(pattern, handler)
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// 首先检查是否是静态文件请求或前端路由
if r.staticHandler != nil && r.shouldServeStatic(req.URL.Path) {
r.staticHandler.ServeStatic(w, req)
return
}
// 否则使用默认的路由处理
r.mux.ServeHTTP(w, req)
}
// shouldServeStatic 判断是否应该由静态文件处理器处理
func (r *Router) shouldServeStatic(path string) bool {
// API 路径不由静态文件处理器处理
if strings.HasPrefix(path, "/api/") {
return false
}
// 根路径由静态文件处理器处理(返回首页)
if path == "/" {
return true
}
// 前端路由(以 /admin 开头)由静态文件处理器处理
if strings.HasPrefix(path, "/admin") {
return true
}
// 静态资源文件(包含文件扩展名或特定前缀)
if strings.HasPrefix(path, "/_next/") ||
strings.HasPrefix(path, "/static/") ||
strings.HasPrefix(path, "/favicon.ico") ||
r.hasFileExtension(path) {
return true
}
// 其他路径可能是动态API端点不由静态文件处理器处理
return false
}
// hasFileExtension 检查路径是否包含文件扩展名
func (r *Router) hasFileExtension(path string) bool {
// 获取路径的最后一部分
parts := strings.Split(path, "/")
if len(parts) == 0 {
return false
}
lastPart := parts[len(parts)-1]
// 检查是否包含点号且不是隐藏文件
if strings.Contains(lastPart, ".") && !strings.HasPrefix(lastPart, ".") {
// 常见的文件扩展名
commonExts := []string{
".html", ".css", ".js", ".json", ".png", ".jpg", ".jpeg",
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot",
".txt", ".xml", ".pdf", ".zip", ".mp4", ".mp3",
}
for _, ext := range commonExts {
if strings.HasSuffix(strings.ToLower(lastPart), ext) {
return true
}
}
}
return false
}

79
services/README.md Normal file
View File

@ -0,0 +1,79 @@
# Services 架构说明
## 文件结构
### 核心服务
- **endpoint_service.go** - 主要的端点服务提供API端点的CRUD操作和随机URL获取
- **cache_manager.go** - 缓存管理器,负责内存缓存和数据库缓存的管理
- **preloader.go** - 预加载管理器,负责主动预加载和定时刷新数据
### 数据获取器
- **data_source_fetcher.go** - 数据源获取器,统一管理不同类型数据源的获取逻辑
- **lankong_fetcher.go** - 兰空图床专用获取器处理兰空图床API的分页获取
- **api_fetcher.go** - API接口获取器支持GET/POST接口的批量预获取
### 其他
- **url_counter.go** - URL计数器原有功能
## 主要改进
### 1. 主动预加载机制
- **保存时预加载**: 创建或更新数据源时,立即在后台预加载数据
- **定时刷新**: 每30分钟检查一次自动刷新过期或需要更新的数据源
- **智能刷新策略**:
- 兰空图床: 每2小时刷新一次
- API接口: 每1小时刷新一次
- 手动数据: 不自动刷新
### 2. 优化的缓存策略
- **双层缓存**: 内存缓存(5分钟) + 数据库缓存(可配置)
- **智能更新**: 只有当上游数据变化时才更新数据库缓存
- **自动清理**: 定期清理过期的内存和数据库缓存
### 3. API接口预获取优化
- **批量获取**: GET接口预获取100次POST接口预获取200次
- **去重处理**: 自动去除重复的URL
- **智能停止**: GET接口如果效率太低会提前停止预获取
### 4. 错误处理和日志
- **详细日志**: 记录每个步骤的执行情况
- **错误恢复**: 单个数据源失败不影响其他数据源
- **进度显示**: 大批量操作时显示进度信息
## 使用方式
### 基本操作
```go
// 获取服务实例
service := GetEndpointService()
// 创建端点(会自动预加载)
endpoint := &models.APIEndpoint{...}
service.CreateEndpoint(endpoint)
// 获取随机URL优先使用缓存
url, err := service.GetRandomURL("/api/random")
```
### 手动刷新
```go
// 刷新单个数据源
service.RefreshDataSource(dataSourceID)
// 刷新整个端点
service.RefreshEndpoint(endpointID)
```
### 控制预加载器
```go
preloader := service.GetPreloader()
preloader.Stop() // 停止自动刷新
preloader.Start() // 重新启动
```
## 性能优化
1. **并发处理**: 多个数据源并行获取数据
2. **请求限制**: 添加延迟避免请求过快
3. **缓存优先**: 优先使用缓存数据减少API调用
4. **智能刷新**: 根据数据源类型设置不同的刷新策略

176
services/api_fetcher.go Normal file
View File

@ -0,0 +1,176 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"random-api-go/models"
"strings"
"time"
)
// APIFetcher API接口获取器
type APIFetcher struct {
client *http.Client
}
// NewAPIFetcher 创建API接口获取器
func NewAPIFetcher() *APIFetcher {
return &APIFetcher{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// FetchURLs 从API接口获取URL列表
func (af *APIFetcher) FetchURLs(config *models.APIConfig) ([]string, error) {
var allURLs []string
// 对于GET/POST接口我们预获取多次以获得不同的URL
maxFetches := 200
if config.Method == "GET" {
maxFetches = 100 // GET接口可能返回相同结果减少请求次数
}
log.Printf("开始从 %s 接口预获取 %d 次URL", config.Method, maxFetches)
urlSet := make(map[string]bool) // 用于去重
for i := 0; i < maxFetches; i++ {
urls, err := af.fetchSingleRequest(config)
if err != nil {
log.Printf("第 %d 次请求失败: %v", i+1, err)
continue
}
// 添加到集合中(自动去重)
for _, url := range urls {
if url != "" && !urlSet[url] {
urlSet[url] = true
allURLs = append(allURLs, url)
}
}
// 如果是GET接口且连续几次都没有新URL提前结束
if config.Method == "GET" && i > 10 && len(allURLs) > 0 {
// 检查最近10次是否有新增URL
if i%10 == 0 {
currentCount := len(allURLs)
// 如果URL数量没有显著增长可能接口返回固定结果
if currentCount < i/5 { // 如果平均每5次请求才有1个新URL可能效率太低
log.Printf("GET接口效率较低在第 %d 次请求后停止预获取", i+1)
break
}
}
}
// 添加小延迟避免请求过快
if i < maxFetches-1 {
time.Sleep(50 * time.Millisecond)
}
// 每50次请求输出一次进度
if (i+1)%50 == 0 {
log.Printf("已完成 %d/%d 次请求,获得 %d 个唯一URL", i+1, maxFetches, len(allURLs))
}
}
log.Printf("完成API预获取: 总共获得 %d 个唯一URL", len(allURLs))
return allURLs, nil
}
// FetchSingleURL 实时获取单个URL (用于GET/POST实时请求)
func (af *APIFetcher) FetchSingleURL(config *models.APIConfig) ([]string, error) {
log.Printf("实时请求 %s 接口: %s", config.Method, config.URL)
return af.fetchSingleRequest(config)
}
// fetchSingleRequest 执行单次API请求
func (af *APIFetcher) fetchSingleRequest(config *models.APIConfig) ([]string, error) {
var req *http.Request
var err error
if config.Method == "POST" {
var body io.Reader
if config.Body != "" {
body = strings.NewReader(config.Body)
}
req, err = http.NewRequest("POST", config.URL, body)
if config.Body != "" {
req.Header.Set("Content-Type", "application/json")
}
} else {
req, err = http.NewRequest("GET", config.URL, nil)
}
if err != nil {
return nil, err
}
// 设置请求头
for key, value := range config.Headers {
req.Header.Set(key, value)
}
resp, err := af.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var data interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
}
return af.extractURLsFromJSON(data, config.URLField)
}
// extractURLsFromJSON 从JSON数据中提取URL
func (af *APIFetcher) extractURLsFromJSON(data interface{}, fieldPath string) ([]string, error) {
var urls []string
// 分割字段路径
fields := strings.Split(fieldPath, ".")
// 递归提取URL
af.extractURLsRecursive(data, fields, 0, &urls)
return urls, nil
}
// extractURLsRecursive 递归提取URL
func (af *APIFetcher) extractURLsRecursive(data interface{}, fields []string, depth int, urls *[]string) {
if depth >= len(fields) {
// 到达目标字段提取URL
if url, ok := data.(string); ok && url != "" {
*urls = append(*urls, url)
}
return
}
currentField := fields[depth]
switch v := data.(type) {
case map[string]interface{}:
if value, exists := v[currentField]; exists {
af.extractURLsRecursive(value, fields, depth+1, urls)
}
case []interface{}:
for _, item := range v {
af.extractURLsRecursive(item, fields, depth, urls)
}
}
}

176
services/cache_manager.go Normal file
View File

@ -0,0 +1,176 @@
package services
import (
"log"
"random-api-go/database"
"random-api-go/models"
"sync"
"time"
)
// CacheManager 缓存管理器
type CacheManager struct {
memoryCache map[string]*CachedEndpoint
mutex sync.RWMutex
}
// 注意CachedEndpoint 类型定义在 endpoint_service.go 中
// NewCacheManager 创建缓存管理器
func NewCacheManager() *CacheManager {
cm := &CacheManager{
memoryCache: make(map[string]*CachedEndpoint),
}
// 启动定期清理过期缓存的协程
go cm.cleanupExpiredCache()
return cm
}
// GetFromMemoryCache 从内存缓存获取数据
func (cm *CacheManager) GetFromMemoryCache(key string) ([]string, bool) {
cm.mutex.RLock()
defer cm.mutex.RUnlock()
cached, exists := cm.memoryCache[key]
if !exists || len(cached.URLs) == 0 {
return nil, false
}
return cached.URLs, true
}
// SetMemoryCache 设置内存缓存duration参数保留以兼容现有接口但不再使用
func (cm *CacheManager) SetMemoryCache(key string, urls []string, duration time.Duration) {
cm.mutex.Lock()
defer cm.mutex.Unlock()
cm.memoryCache[key] = &CachedEndpoint{
URLs: urls,
}
}
// InvalidateMemoryCache 清理指定key的内存缓存
func (cm *CacheManager) InvalidateMemoryCache(key string) {
cm.mutex.Lock()
defer cm.mutex.Unlock()
delete(cm.memoryCache, key)
}
// GetFromDBCache 从数据库缓存获取URL
func (cm *CacheManager) GetFromDBCache(dataSourceID uint) ([]string, error) {
var cachedURLs []models.CachedURL
if err := database.DB.Where("data_source_id = ? AND expires_at > ?", dataSourceID, time.Now()).
Find(&cachedURLs).Error; err != nil {
return nil, err
}
var urls []string
for _, cached := range cachedURLs {
urls = append(urls, cached.FinalURL)
}
return urls, nil
}
// SetDBCache 设置数据库缓存
func (cm *CacheManager) SetDBCache(dataSourceID uint, urls []string, duration time.Duration) error {
// 先删除旧的缓存
if err := database.DB.Where("data_source_id = ?", dataSourceID).Delete(&models.CachedURL{}).Error; err != nil {
log.Printf("Failed to delete old cache for data source %d: %v", dataSourceID, err)
}
// 插入新的缓存
expiresAt := time.Now().Add(duration)
for _, url := range urls {
cachedURL := models.CachedURL{
DataSourceID: dataSourceID,
OriginalURL: url,
FinalURL: url,
ExpiresAt: expiresAt,
}
if err := database.DB.Create(&cachedURL).Error; err != nil {
log.Printf("Failed to cache URL: %v", err)
}
}
return nil
}
// UpdateDBCacheIfChanged 只有当数据变化时才更新数据库缓存,并返回是否需要清理内存缓存
func (cm *CacheManager) UpdateDBCacheIfChanged(dataSourceID uint, newURLs []string, duration time.Duration) (bool, error) {
// 获取现有缓存
existingURLs, err := cm.GetFromDBCache(dataSourceID)
if err != nil {
// 如果获取失败,直接设置新缓存
return true, cm.SetDBCache(dataSourceID, newURLs, duration)
}
// 比较URL列表是否相同
if cm.urlSlicesEqual(existingURLs, newURLs) {
// 数据没有变化,只更新过期时间
expiresAt := time.Now().Add(duration)
if err := database.DB.Model(&models.CachedURL{}).
Where("data_source_id = ?", dataSourceID).
Update("expires_at", expiresAt).Error; err != nil {
log.Printf("Failed to update cache expiry for data source %d: %v", dataSourceID, err)
}
return false, nil
}
// 数据有变化,更新缓存
return true, cm.SetDBCache(dataSourceID, newURLs, duration)
}
// InvalidateMemoryCacheForDataSource 清理与数据源相关的内存缓存
func (cm *CacheManager) InvalidateMemoryCacheForDataSource(dataSourceID uint) error {
// 获取数据源信息
var dataSource models.DataSource
if err := database.DB.Preload("Endpoint").First(&dataSource, dataSourceID).Error; err != nil {
return err
}
// 清理该端点的内存缓存
cm.InvalidateMemoryCache(dataSource.Endpoint.URL)
log.Printf("已清理端点 %s 的内存缓存(数据源 %d 数据发生变化)", dataSource.Endpoint.URL, dataSourceID)
return nil
}
// urlSlicesEqual 比较两个URL切片是否相等
func (cm *CacheManager) urlSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
// 创建map来比较
urlMap := make(map[string]bool)
for _, url := range a {
urlMap[url] = true
}
for _, url := range b {
if !urlMap[url] {
return false
}
}
return true
}
// cleanupExpiredCache 定期清理过期的数据库缓存(内存缓存不再自动过期)
func (cm *CacheManager) cleanupExpiredCache() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
// 内存缓存不再自动过期,只清理数据库中的过期缓存
if err := database.DB.Where("expires_at < ?", now).Delete(&models.CachedURL{}).Error; err != nil {
log.Printf("Failed to cleanup expired cache: %v", err)
}
}
}

View File

@ -1,271 +0,0 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"random-api-go/config"
"random-api-go/models"
"random-api-go/utils"
"strings"
"sync"
"time"
)
type CSVCache struct {
selector *models.URLSelector
lastCheck time.Time
mu sync.RWMutex
}
var (
CSVPathsCache map[string]map[string]string
csvCache = make(map[string]*CSVCache)
cacheTTL = 1 * time.Hour
Mu sync.RWMutex
)
// InitializeCSVService 初始化CSV服务
func InitializeCSVService() error {
// 加载url.json
if err := LoadCSVPaths(); err != nil {
return fmt.Errorf("failed to load CSV paths: %v", err)
}
// 获取一个CSVPathsCache的副本避免长时间持有锁
Mu.RLock()
pathsCopy := make(map[string]map[string]string)
for prefix, suffixMap := range CSVPathsCache {
pathsCopy[prefix] = make(map[string]string)
for suffix, path := range suffixMap {
pathsCopy[prefix][suffix] = path
}
}
Mu.RUnlock()
// 使用副本进行初始化
for prefix, suffixMap := range pathsCopy {
for suffix, csvPath := range suffixMap {
selector, err := GetCSVContent(csvPath)
if err != nil {
log.Printf("Warning: Failed to load CSV content for %s/%s: %v", prefix, suffix, err)
continue
}
// 更新URL计数
endpoint := fmt.Sprintf("%s/%s", prefix, suffix)
UpdateURLCount(endpoint, csvPath, len(selector.URLs))
log.Printf("Loaded %d URLs for endpoint: %s/%s", len(selector.URLs), prefix, suffix)
}
}
return nil
}
func LoadCSVPaths() error {
var data []byte
var err error
// 获取环境变量中的基础URL
baseURL := os.Getenv(config.EnvBaseURL)
if baseURL != "" {
// 构建完整的URL
var fullURL string
if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") {
fullURL = utils.JoinURLPath(baseURL, "url.json")
} else {
fullURL = "https://" + utils.JoinURLPath(baseURL, "url.json")
}
log.Printf("Attempting to read url.json from: %s", fullURL)
// 创建HTTP客户端
client := &http.Client{
Timeout: config.RequestTimeout,
}
resp, err := client.Get(fullURL)
if err != nil {
return fmt.Errorf("failed to fetch url.json: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to fetch url.json, status code: %d", resp.StatusCode)
}
data, err = io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read url.json response: %w", err)
}
} else {
// 从本地文件读取
jsonPath := filepath.Join("public", "url.json")
log.Printf("Attempting to read local file: %s", jsonPath)
data, err = os.ReadFile(jsonPath)
if err != nil {
return fmt.Errorf("failed to read local url.json: %w", err)
}
}
var result map[string]map[string]string
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("failed to unmarshal url.json: %w", err)
}
Mu.Lock()
CSVPathsCache = result
Mu.Unlock()
log.Println("CSV paths loaded from url.json")
return nil
}
func GetCSVContent(path string) (*models.URLSelector, error) {
cache, ok := csvCache[path]
if ok {
cache.mu.RLock()
if time.Since(cache.lastCheck) < cacheTTL {
defer cache.mu.RUnlock()
return cache.selector, nil
}
cache.mu.RUnlock()
}
// 更新缓存
selector, err := loadCSVContent(path)
if err != nil {
return nil, err
}
cache = &CSVCache{
selector: selector,
lastCheck: time.Now(),
}
csvCache[path] = cache
return selector, nil
}
func loadCSVContent(path string) (*models.URLSelector, error) {
Mu.RLock()
selector, exists := csvCache[path]
Mu.RUnlock()
if exists {
return selector.selector, nil
}
var fileContent []byte
var err error
baseURL := os.Getenv(config.EnvBaseURL)
if baseURL != "" {
var fullURL string
if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") {
fullURL = utils.JoinURLPath(baseURL, path)
} else {
fullURL = "https://" + utils.JoinURLPath(baseURL, path)
}
log.Printf("尝试从URL获取: %s", fullURL)
client := &http.Client{
Timeout: config.RequestTimeout,
}
resp, err := client.Get(fullURL)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return nil, fmt.Errorf("HTTP请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("HTTP请求返回非200状态码: %d", resp.StatusCode)
return nil, fmt.Errorf("HTTP请求返回非200状态码: %d", resp.StatusCode)
}
fileContent, err = io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应内容失败: %v", err)
return nil, fmt.Errorf("读取响应内容失败: %w", err)
}
log.Printf("成功读取到CSV内容长度: %d bytes", len(fileContent))
} else {
// 如果没有设置基础URL从本地文件读取
fullPath := filepath.Join("public", path)
log.Printf("尝试读取本地文件: %s", fullPath)
fileContent, err = os.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("读取CSV内容时出错: %w", err)
}
}
lines := strings.Split(string(fileContent), "\n")
log.Printf("CSV文件包含 %d 行", len(lines))
if len(lines) == 0 {
return nil, fmt.Errorf("CSV文件为空")
}
uniqueURLs := make(map[string]bool)
var fileArray []string
var invalidLines []string
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
// 验证URL格式
if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
invalidLines = append(invalidLines, fmt.Sprintf("第%d行: %s (无效的URL格式)", i+1, trimmed))
continue
}
// 检查URL是否包含非法字符
if strings.ContainsAny(trimmed, "\"'") {
invalidLines = append(invalidLines, fmt.Sprintf("第%d行: %s (URL包含非法字符)", i+1, trimmed))
continue
}
if !uniqueURLs[trimmed] {
fileArray = append(fileArray, trimmed)
uniqueURLs[trimmed] = true
}
}
if len(invalidLines) > 0 {
errMsg := "发现无效的URL格式:\n" + strings.Join(invalidLines, "\n")
log.Printf("%s", errMsg)
return nil, fmt.Errorf("%s", errMsg)
}
if len(fileArray) == 0 {
return nil, fmt.Errorf("CSV文件中没有有效的URL")
}
log.Printf("处理后得到 %d 个有效的唯一URL", len(fileArray))
urlSelector := models.NewURLSelector(fileArray)
Mu.Lock()
csvCache[path] = &CSVCache{
selector: urlSelector,
lastCheck: time.Now(),
}
Mu.Unlock()
return urlSelector, nil
}

View File

@ -0,0 +1,179 @@
package services
import (
"encoding/json"
"fmt"
"log"
"random-api-go/models"
"strings"
"time"
)
// DataSourceFetcher 数据源获取器
type DataSourceFetcher struct {
cacheManager *CacheManager
lankongFetcher *LankongFetcher
apiFetcher *APIFetcher
}
// NewDataSourceFetcher 创建数据源获取器
func NewDataSourceFetcher(cacheManager *CacheManager) *DataSourceFetcher {
return &DataSourceFetcher{
cacheManager: cacheManager,
lankongFetcher: NewLankongFetcher(),
apiFetcher: NewAPIFetcher(),
}
}
// FetchURLs 从数据源获取URL列表
func (dsf *DataSourceFetcher) FetchURLs(dataSource *models.DataSource) ([]string, error) {
// API类型的数据源直接实时请求不使用缓存
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
log.Printf("实时请求API数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID)
return dsf.fetchAPIURLs(dataSource)
}
// 其他类型的数据源先检查数据库缓存
if cachedURLs, err := dsf.cacheManager.GetFromDBCache(dataSource.ID); err == nil && len(cachedURLs) > 0 {
log.Printf("从数据库缓存获取到 %d 个URL (数据源ID: %d)", len(cachedURLs), dataSource.ID)
return cachedURLs, nil
}
var urls []string
var err error
log.Printf("开始从数据源获取URL (类型: %s, ID: %d)", dataSource.Type, dataSource.ID)
switch dataSource.Type {
case "lankong":
urls, err = dsf.fetchLankongURLs(dataSource)
case "manual":
urls, err = dsf.fetchManualURLs(dataSource)
case "endpoint":
urls, err = dsf.fetchEndpointURLs(dataSource)
default:
return nil, fmt.Errorf("unsupported data source type: %s", dataSource.Type)
}
if err != nil {
return nil, fmt.Errorf("failed to fetch URLs from %s data source: %w", dataSource.Type, err)
}
if len(urls) == 0 {
log.Printf("警告: 数据源 %d 没有获取到任何URL", dataSource.ID)
return urls, nil
}
// 缓存结果到数据库
cacheDuration := time.Duration(dataSource.CacheDuration) * time.Second
changed, err := dsf.cacheManager.UpdateDBCacheIfChanged(dataSource.ID, urls, cacheDuration)
if err != nil {
log.Printf("Failed to cache URLs for data source %d: %v", dataSource.ID, err)
} else if changed {
log.Printf("数据源 %d 的数据已更新,缓存了 %d 个URL", dataSource.ID, len(urls))
// 数据发生变化,清理相关的内存缓存
if err := dsf.cacheManager.InvalidateMemoryCacheForDataSource(dataSource.ID); err != nil {
log.Printf("Failed to invalidate memory cache for data source %d: %v", dataSource.ID, err)
}
} else {
log.Printf("数据源 %d 的数据未变化,仅更新了过期时间", dataSource.ID)
}
// 更新最后同步时间
now := time.Now()
dataSource.LastSync = &now
if err := dsf.updateDataSourceSyncTime(dataSource); err != nil {
log.Printf("Failed to update sync time for data source %d: %v", dataSource.ID, err)
}
return urls, nil
}
// fetchLankongURLs 获取兰空图床URL
func (dsf *DataSourceFetcher) fetchLankongURLs(dataSource *models.DataSource) ([]string, error) {
var config models.LankongConfig
if err := json.Unmarshal([]byte(dataSource.Config), &config); err != nil {
return nil, fmt.Errorf("invalid lankong config: %w", err)
}
return dsf.lankongFetcher.FetchURLs(&config)
}
// fetchManualURLs 获取手动配置的URL
func (dsf *DataSourceFetcher) fetchManualURLs(dataSource *models.DataSource) ([]string, error) {
// 手动配置可能是JSON格式或者纯文本格式
config := strings.TrimSpace(dataSource.Config)
// 尝试解析为JSON格式
var manualConfig models.ManualConfig
if err := json.Unmarshal([]byte(config), &manualConfig); err == nil {
return manualConfig.URLs, nil
}
// 如果不是JSON按行分割处理
lines := strings.Split(config, "\n")
var urls []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") { // 忽略空行和注释
urls = append(urls, line)
}
}
return urls, nil
}
// fetchAPIURLs 获取API接口URL (实时请求,不缓存)
func (dsf *DataSourceFetcher) fetchAPIURLs(dataSource *models.DataSource) ([]string, error) {
var config models.APIConfig
if err := json.Unmarshal([]byte(dataSource.Config), &config); err != nil {
return nil, fmt.Errorf("invalid API config: %w", err)
}
// 对于API类型的数据源直接进行实时请求不使用预存储的数据
return dsf.apiFetcher.FetchSingleURL(&config)
}
// fetchEndpointURLs 获取端点URL (直接返回端点URL列表)
func (dsf *DataSourceFetcher) fetchEndpointURLs(dataSource *models.DataSource) ([]string, error) {
var config models.EndpointConfig
if err := json.Unmarshal([]byte(dataSource.Config), &config); err != nil {
return nil, fmt.Errorf("invalid endpoint config: %w", err)
}
if len(config.EndpointIDs) == 0 {
return nil, fmt.Errorf("no endpoints configured")
}
// 这里我们需要导入database包来查询端点信息
// 为了避免循环依赖我们返回一个特殊的URL格式让服务层处理
var urls []string
for _, endpointID := range config.EndpointIDs {
// 使用特殊格式标记这是一个端点引用
urls = append(urls, fmt.Sprintf("endpoint://%d", endpointID))
}
return urls, nil
}
// updateDataSourceSyncTime 更新数据源的同步时间
func (dsf *DataSourceFetcher) updateDataSourceSyncTime(dataSource *models.DataSource) error {
// 这里需要导入database包来更新数据库
// 为了避免循环依赖,我们通过回调或者接口来处理
// 暂时先记录日志,具体实现在主服务中处理
log.Printf("需要更新数据源 %d 的同步时间", dataSource.ID)
return nil
}
// PreloadDataSource 预加载数据源(在保存时调用)
func (dsf *DataSourceFetcher) PreloadDataSource(dataSource *models.DataSource) error {
log.Printf("开始预加载数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID)
_, err := dsf.FetchURLs(dataSource)
if err != nil {
return fmt.Errorf("failed to preload data source %d: %w", dataSource.ID, err)
}
log.Printf("数据源 %d 预加载完成", dataSource.ID)
return nil
}

View File

@ -0,0 +1,362 @@
package services
import (
"fmt"
"log"
"math/rand"
"random-api-go/database"
"random-api-go/models"
"strconv"
"strings"
"sync"
)
// CachedEndpoint 缓存的端点数据
type CachedEndpoint struct {
URLs []string
// 移除ExpiresAt字段内存缓存不再自动过期
}
// EndpointService API端点服务
type EndpointService struct {
cacheManager *CacheManager
dataSourceFetcher *DataSourceFetcher
preloader *Preloader
}
var endpointService *EndpointService
var once sync.Once
// GetEndpointService 获取端点服务单例
func GetEndpointService() *EndpointService {
once.Do(func() {
// 创建组件
cacheManager := NewCacheManager()
dataSourceFetcher := NewDataSourceFetcher(cacheManager)
preloader := NewPreloader(dataSourceFetcher, cacheManager)
endpointService = &EndpointService{
cacheManager: cacheManager,
dataSourceFetcher: dataSourceFetcher,
preloader: preloader,
}
// 启动预加载器
preloader.Start()
})
return endpointService
}
// CreateEndpoint 创建API端点
func (s *EndpointService) CreateEndpoint(endpoint *models.APIEndpoint) error {
if err := database.DB.Create(endpoint).Error; err != nil {
return fmt.Errorf("failed to create endpoint: %w", err)
}
// 清理缓存
s.cacheManager.InvalidateMemoryCache(endpoint.URL)
// 预加载数据源
s.preloader.PreloadEndpointOnSave(endpoint)
return nil
}
// GetEndpoint 获取API端点
func (s *EndpointService) GetEndpoint(id uint) (*models.APIEndpoint, error) {
var endpoint models.APIEndpoint
if err := database.DB.Preload("DataSources").Preload("URLReplaceRules").First(&endpoint, id).Error; err != nil {
return nil, fmt.Errorf("failed to get endpoint: %w", err)
}
return &endpoint, nil
}
// GetEndpointByURL 根据URL获取端点
func (s *EndpointService) GetEndpointByURL(url string) (*models.APIEndpoint, error) {
var endpoint models.APIEndpoint
if err := database.DB.Preload("DataSources").Preload("URLReplaceRules").
Where("url = ? AND is_active = ?", url, true).First(&endpoint).Error; err != nil {
return nil, fmt.Errorf("failed to get endpoint by URL: %w", err)
}
return &endpoint, nil
}
// ListEndpoints 列出所有端点
func (s *EndpointService) ListEndpoints() ([]*models.APIEndpoint, error) {
var endpoints []*models.APIEndpoint
if err := database.DB.Preload("DataSources").Preload("URLReplaceRules").
Order("sort_order ASC, created_at DESC").Find(&endpoints).Error; err != nil {
return nil, fmt.Errorf("failed to list endpoints: %w", err)
}
return endpoints, nil
}
// UpdateEndpoint 更新API端点
func (s *EndpointService) UpdateEndpoint(endpoint *models.APIEndpoint) error {
if err := database.DB.Save(endpoint).Error; err != nil {
return fmt.Errorf("failed to update endpoint: %w", err)
}
// 清理缓存
s.cacheManager.InvalidateMemoryCache(endpoint.URL)
// 预加载数据源
s.preloader.PreloadEndpointOnSave(endpoint)
return nil
}
// DeleteEndpoint 删除API端点
func (s *EndpointService) DeleteEndpoint(id uint) error {
// 先获取URL用于清理缓存
endpoint, err := s.GetEndpoint(id)
if err != nil {
return fmt.Errorf("failed to get endpoint for deletion: %w", err)
}
// 删除相关的数据源和URL替换规则
if err := database.DB.Select("DataSources", "URLReplaceRules").Delete(&models.APIEndpoint{}, id).Error; err != nil {
return fmt.Errorf("failed to delete endpoint: %w", err)
}
// 清理缓存
s.cacheManager.InvalidateMemoryCache(endpoint.URL)
return nil
}
// GetRandomURL 获取随机URL
func (s *EndpointService) GetRandomURL(url string) (string, error) {
// 获取端点信息
endpoint, err := s.GetEndpointByURL(url)
if err != nil {
return "", fmt.Errorf("endpoint not found: %w", err)
}
// 检查是否包含API类型或端点类型的数据源
hasRealtimeDataSource := false
for _, dataSource := range endpoint.DataSources {
if dataSource.IsActive && (dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint") {
hasRealtimeDataSource = true
break
}
}
// 如果包含实时数据源,不使用内存缓存,直接实时获取
if hasRealtimeDataSource {
log.Printf("端点包含实时数据源,使用实时请求模式: %s", url)
return s.getRandomURLRealtime(endpoint)
}
// 非实时数据源,使用缓存模式但也先选择数据源
return s.getRandomURLWithCache(endpoint)
}
// getRandomURLRealtime 实时获取随机URL用于包含API数据源的端点
func (s *EndpointService) getRandomURLRealtime(endpoint *models.APIEndpoint) (string, error) {
// 收集所有激活的数据源
var activeDataSources []models.DataSource
for _, dataSource := range endpoint.DataSources {
if dataSource.IsActive {
activeDataSources = append(activeDataSources, dataSource)
}
}
if len(activeDataSources) == 0 {
return "", fmt.Errorf("no active data sources for endpoint: %s", endpoint.URL)
}
// 先随机选择一个数据源
selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))]
log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID)
// 只从选中的数据源获取URL
urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource)
if err != nil {
return "", fmt.Errorf("failed to get URLs from selected data source %d: %w", selectedDataSource.ID, err)
}
if len(urls) == 0 {
return "", fmt.Errorf("no URLs available from selected data source %d", selectedDataSource.ID)
}
// 从选中数据源的URL中随机选择一个
randomURL := urls[rand.Intn(len(urls))]
// 如果是端点类型的URL需要递归调用
if strings.HasPrefix(randomURL, "endpoint://") {
endpointIDStr := strings.TrimPrefix(randomURL, "endpoint://")
endpointID, err := strconv.ParseUint(endpointIDStr, 10, 32)
if err != nil {
return "", fmt.Errorf("invalid endpoint ID in URL: %s", randomURL)
}
// 获取目标端点信息
targetEndpoint, err := s.GetEndpoint(uint(endpointID))
if err != nil {
return "", fmt.Errorf("target endpoint not found: %w", err)
}
// 递归调用获取目标端点的随机URL
return s.GetRandomURL(targetEndpoint.URL)
}
return s.applyURLReplaceRules(randomURL, endpoint.URL), nil
}
// getRandomURLWithCache 使用缓存模式获取随机URL先选择数据源
func (s *EndpointService) getRandomURLWithCache(endpoint *models.APIEndpoint) (string, error) {
// 收集所有激活的数据源
var activeDataSources []models.DataSource
for _, dataSource := range endpoint.DataSources {
if dataSource.IsActive {
activeDataSources = append(activeDataSources, dataSource)
}
}
if len(activeDataSources) == 0 {
return "", fmt.Errorf("no active data sources for endpoint: %s", endpoint.URL)
}
// 先随机选择一个数据源
selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))]
log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID)
// 从选中的数据源获取URL会使用缓存
urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource)
if err != nil {
return "", fmt.Errorf("failed to get URLs from selected data source %d: %w", selectedDataSource.ID, err)
}
if len(urls) == 0 {
return "", fmt.Errorf("no URLs available from selected data source %d", selectedDataSource.ID)
}
// 从选中数据源的URL中随机选择一个
randomURL := urls[rand.Intn(len(urls))]
// 如果是端点类型的URL需要递归调用
if strings.HasPrefix(randomURL, "endpoint://") {
endpointIDStr := strings.TrimPrefix(randomURL, "endpoint://")
endpointID, err := strconv.ParseUint(endpointIDStr, 10, 32)
if err != nil {
return "", fmt.Errorf("invalid endpoint ID in URL: %s", randomURL)
}
// 获取目标端点信息
targetEndpoint, err := s.GetEndpoint(uint(endpointID))
if err != nil {
return "", fmt.Errorf("target endpoint not found: %w", err)
}
// 递归调用获取目标端点的随机URL
return s.GetRandomURL(targetEndpoint.URL)
}
return s.applyURLReplaceRules(randomURL, endpoint.URL), nil
}
// applyURLReplaceRules 应用URL替换规则
func (s *EndpointService) applyURLReplaceRules(url, endpointURL string) string {
// 获取端点的替换规则
endpoint, err := s.GetEndpointByURL(endpointURL)
if err != nil {
log.Printf("Failed to get endpoint for URL replacement: %v", err)
return url
}
result := url
for _, rule := range endpoint.URLReplaceRules {
if rule.IsActive {
result = strings.ReplaceAll(result, rule.FromURL, rule.ToURL)
}
}
return result
}
// CreateDataSource 创建数据源
func (s *EndpointService) CreateDataSource(dataSource *models.DataSource) error {
if err := database.DB.Create(dataSource).Error; err != nil {
return fmt.Errorf("failed to create data source: %w", err)
}
// 获取关联的端点URL用于清理缓存
if endpoint, err := s.GetEndpoint(dataSource.EndpointID); err == nil {
s.cacheManager.InvalidateMemoryCache(endpoint.URL)
}
// 预加载数据源
s.preloader.PreloadDataSourceOnSave(dataSource)
return nil
}
// UpdateDataSource 更新数据源
func (s *EndpointService) UpdateDataSource(dataSource *models.DataSource) error {
if err := database.DB.Save(dataSource).Error; err != nil {
return fmt.Errorf("failed to update data source: %w", err)
}
// 获取关联的端点URL用于清理缓存
if endpoint, err := s.GetEndpoint(dataSource.EndpointID); err == nil {
s.cacheManager.InvalidateMemoryCache(endpoint.URL)
}
// 预加载数据源
s.preloader.PreloadDataSourceOnSave(dataSource)
return nil
}
// DeleteDataSource 删除数据源
func (s *EndpointService) DeleteDataSource(id uint) error {
// 先获取数据源信息
var dataSource models.DataSource
if err := database.DB.First(&dataSource, id).Error; err != nil {
return fmt.Errorf("failed to get data source: %w", err)
}
// 删除数据源
if err := database.DB.Delete(&dataSource).Error; err != nil {
return fmt.Errorf("failed to delete data source: %w", err)
}
// 获取关联的端点URL用于清理缓存
if endpoint, err := s.GetEndpoint(dataSource.EndpointID); err == nil {
s.cacheManager.InvalidateMemoryCache(endpoint.URL)
}
return nil
}
// RefreshDataSource 手动刷新数据源
func (s *EndpointService) RefreshDataSource(dataSourceID uint) error {
return s.preloader.RefreshDataSource(dataSourceID)
}
// RefreshEndpoint 手动刷新端点
func (s *EndpointService) RefreshEndpoint(endpointID uint) error {
return s.preloader.RefreshEndpoint(endpointID)
}
// GetPreloader 获取预加载器(用于外部控制)
func (s *EndpointService) GetPreloader() *Preloader {
return s.preloader
}
// GetDataSourceURLCount 获取数据源的URL数量
func (s *EndpointService) GetDataSourceURLCount(dataSource *models.DataSource) (int, error) {
// 对于API类型和端点类型的数据源返回1因为每次都是实时请求
if dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" {
return 1, nil
}
// 对于其他类型的数据源尝试获取实际的URL数量
urls, err := s.dataSourceFetcher.FetchURLs(dataSource)
if err != nil {
return 0, err
}
return len(urls), nil
}

126
services/lankong_fetcher.go Normal file
View File

@ -0,0 +1,126 @@
package services
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"random-api-go/models"
"time"
)
// LankongFetcher 兰空图床获取器
type LankongFetcher struct {
client *http.Client
}
// NewLankongFetcher 创建兰空图床获取器
func NewLankongFetcher() *LankongFetcher {
return &LankongFetcher{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// LankongResponse 兰空图床API响应
type LankongResponse struct {
Status bool `json:"status"`
Message string `json:"message"`
Data struct {
CurrentPage int `json:"current_page"`
LastPage int `json:"last_page"`
Data []struct {
Links struct {
URL string `json:"url"`
} `json:"links"`
} `json:"data"`
} `json:"data"`
}
// FetchURLs 从兰空图床获取URL列表
func (lf *LankongFetcher) FetchURLs(config *models.LankongConfig) ([]string, error) {
var allURLs []string
baseURL := config.BaseURL
if baseURL == "" {
baseURL = "https://img.czl.net/api/v1/images"
}
for _, albumID := range config.AlbumIDs {
log.Printf("开始获取相册 %s 的图片", albumID)
// 获取第一页以确定总页数
firstPageURL := fmt.Sprintf("%s?album_id=%s&page=1", baseURL, albumID)
response, err := lf.fetchPage(firstPageURL, config.APIToken)
if err != nil {
log.Printf("Failed to fetch first page for album %s: %v", albumID, err)
continue
}
totalPages := response.Data.LastPage
log.Printf("相册 %s 共有 %d 页", albumID, totalPages)
// 处理所有页面
for page := 1; page <= totalPages; page++ {
reqURL := fmt.Sprintf("%s?album_id=%s&page=%d", baseURL, albumID, page)
pageResponse, err := lf.fetchPage(reqURL, config.APIToken)
if err != nil {
log.Printf("Failed to fetch page %d for album %s: %v", page, albumID, err)
continue
}
for _, item := range pageResponse.Data.Data {
if item.Links.URL != "" {
allURLs = append(allURLs, item.Links.URL)
}
}
// 添加小延迟避免请求过快
if page < totalPages {
time.Sleep(100 * time.Millisecond)
}
}
log.Printf("完成相册 %s: 收集到 %d 个URL", albumID, len(allURLs))
}
return allURLs, nil
}
// fetchPage 获取兰空图床单页数据
func (lf *LankongFetcher) fetchPage(url string, apiToken string) (*LankongResponse, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiToken)
req.Header.Set("Accept", "application/json")
resp, err := lf.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var lankongResp LankongResponse
if err := json.Unmarshal(body, &lankongResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !lankongResp.Status {
return nil, fmt.Errorf("API error: %s", lankongResp.Message)
}
return &lankongResp, nil
}

275
services/preloader.go Normal file
View File

@ -0,0 +1,275 @@
package services
import (
"log"
"random-api-go/database"
"random-api-go/models"
"sync"
"time"
)
// Preloader 预加载管理器
type Preloader struct {
dataSourceFetcher *DataSourceFetcher
cacheManager *CacheManager
running bool
stopChan chan struct{}
mutex sync.RWMutex
}
// NewPreloader 创建预加载管理器
func NewPreloader(dataSourceFetcher *DataSourceFetcher, cacheManager *CacheManager) *Preloader {
return &Preloader{
dataSourceFetcher: dataSourceFetcher,
cacheManager: cacheManager,
stopChan: make(chan struct{}),
}
}
// Start 启动预加载器
func (p *Preloader) Start() {
p.mutex.Lock()
defer p.mutex.Unlock()
if p.running {
return
}
p.running = true
go p.runPeriodicRefresh()
log.Println("预加载器已启动")
}
// Stop 停止预加载器
func (p *Preloader) Stop() {
p.mutex.Lock()
defer p.mutex.Unlock()
if !p.running {
return
}
p.running = false
close(p.stopChan)
log.Println("预加载器已停止")
}
// PreloadDataSourceOnSave 在保存数据源时预加载数据
func (p *Preloader) PreloadDataSourceOnSave(dataSource *models.DataSource) {
// API类型的数据源不需要预加载使用实时请求
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
log.Printf("API数据源 %d (%s) 使用实时请求,跳过预加载", dataSource.ID, dataSource.Type)
return
}
// 异步预加载,避免阻塞保存操作
go func() {
log.Printf("开始预加载数据源 %d (%s)", dataSource.ID, dataSource.Type)
if err := p.dataSourceFetcher.PreloadDataSource(dataSource); err != nil {
log.Printf("预加载数据源 %d 失败: %v", dataSource.ID, err)
} else {
log.Printf("数据源 %d 预加载成功", dataSource.ID)
}
}()
}
// PreloadEndpointOnSave 在保存端点时预加载所有相关数据源
func (p *Preloader) PreloadEndpointOnSave(endpoint *models.APIEndpoint) {
// 异步预加载,避免阻塞保存操作
go func() {
log.Printf("开始预加载端点 %d 的所有数据源", endpoint.ID)
var wg sync.WaitGroup
for _, dataSource := range endpoint.DataSources {
if !dataSource.IsActive {
continue
}
// API类型和端点类型的数据源跳过预加载
if dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" {
log.Printf("实时数据源 %d (%s) 使用实时请求,跳过预加载", dataSource.ID, dataSource.Type)
continue
}
wg.Add(1)
go func(ds models.DataSource) {
defer wg.Done()
if err := p.dataSourceFetcher.PreloadDataSource(&ds); err != nil {
log.Printf("预加载数据源 %d 失败: %v", ds.ID, err)
}
}(dataSource)
}
wg.Wait()
log.Printf("端点 %d 的所有数据源预加载完成", endpoint.ID)
// 预加载完成后,清理该端点的内存缓存,强制下次访问时重新构建
p.cacheManager.InvalidateMemoryCache(endpoint.URL)
}()
}
// RefreshDataSource 手动刷新指定数据源
func (p *Preloader) RefreshDataSource(dataSourceID uint) error {
var dataSource models.DataSource
if err := database.DB.First(&dataSource, dataSourceID).Error; err != nil {
return err
}
log.Printf("手动刷新数据源 %d", dataSourceID)
return p.dataSourceFetcher.PreloadDataSource(&dataSource)
}
// RefreshEndpoint 手动刷新指定端点的所有数据源
func (p *Preloader) RefreshEndpoint(endpointID uint) error {
var endpoint models.APIEndpoint
if err := database.DB.Preload("DataSources").First(&endpoint, endpointID).Error; err != nil {
return err
}
log.Printf("手动刷新端点 %d 的所有数据源", endpointID)
var wg sync.WaitGroup
var lastErr error
for _, dataSource := range endpoint.DataSources {
if !dataSource.IsActive {
continue
}
// API类型和端点类型的数据源跳过刷新
if dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" {
log.Printf("实时数据源 %d (%s) 使用实时请求,跳过刷新", dataSource.ID, dataSource.Type)
continue
}
wg.Add(1)
go func(ds models.DataSource) {
defer wg.Done()
if err := p.dataSourceFetcher.PreloadDataSource(&ds); err != nil {
log.Printf("刷新数据源 %d 失败: %v", ds.ID, err)
lastErr = err
}
}(dataSource)
}
wg.Wait()
// 刷新完成后,清理该端点的内存缓存
p.cacheManager.InvalidateMemoryCache(endpoint.URL)
return lastErr
}
// runPeriodicRefresh 运行定期刷新任务
func (p *Preloader) runPeriodicRefresh() {
// 定期刷新间隔每30分钟检查一次
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
// 启动时立即执行一次检查
p.checkAndRefreshExpiredData()
for {
select {
case <-ticker.C:
p.checkAndRefreshExpiredData()
case <-p.stopChan:
return
}
}
}
// checkAndRefreshExpiredData 检查并刷新过期数据
func (p *Preloader) checkAndRefreshExpiredData() {
log.Println("开始检查过期数据...")
// 获取所有活跃的数据源
var dataSources []models.DataSource
if err := database.DB.Where("is_active = ?", true).Find(&dataSources).Error; err != nil {
log.Printf("获取数据源列表失败: %v", err)
return
}
var refreshCount int
var wg sync.WaitGroup
for _, dataSource := range dataSources {
// API类型和端点类型的数据源跳过定期刷新
if dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" {
continue
}
// 检查缓存是否即将过期提前5分钟刷新
cachedURLs, err := p.cacheManager.GetFromDBCache(dataSource.ID)
if err != nil || len(cachedURLs) == 0 {
// 没有缓存数据,需要刷新
refreshCount++
wg.Add(1)
go func(ds models.DataSource) {
defer wg.Done()
p.refreshDataSourceAsync(&ds)
}(dataSource)
continue
}
// 检查是否需要定期刷新(兰空图床需要定期刷新)
if p.shouldPeriodicRefresh(&dataSource) {
refreshCount++
wg.Add(1)
go func(ds models.DataSource) {
defer wg.Done()
p.refreshDataSourceAsync(&ds)
}(dataSource)
}
}
if refreshCount > 0 {
log.Printf("正在刷新 %d 个数据源...", refreshCount)
wg.Wait()
log.Printf("数据源刷新完成")
} else {
log.Println("所有数据源都是最新的,无需刷新")
}
}
// shouldPeriodicRefresh 判断是否需要定期刷新
func (p *Preloader) shouldPeriodicRefresh(dataSource *models.DataSource) bool {
// 手动数据、API数据和端点数据不需要定期刷新
if dataSource.Type == "manual" || dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" {
return false
}
// 如果没有最后同步时间,需要刷新
if dataSource.LastSync == nil {
return true
}
// 根据数据源类型设置不同的刷新间隔
var refreshInterval time.Duration
switch dataSource.Type {
case "lankong":
refreshInterval = 24 * time.Hour // 兰空图床每24小时刷新一次
default:
return false
}
return time.Since(*dataSource.LastSync) > refreshInterval
}
// refreshDataSourceAsync 异步刷新数据源
func (p *Preloader) refreshDataSourceAsync(dataSource *models.DataSource) {
if err := p.dataSourceFetcher.PreloadDataSource(dataSource); err != nil {
log.Printf("定期刷新数据源 %d 失败: %v", dataSource.ID, err)
} else {
log.Printf("数据源 %d 定期刷新成功", dataSource.ID)
// 更新数据库中的同步时间
now := time.Now()
if err := database.DB.Model(dataSource).Update("last_sync", now).Error; err != nil {
log.Printf("更新数据源 %d 同步时间失败: %v", dataSource.ID, err)
}
}
}

View File

@ -1,13 +0,0 @@
#!/bin/sh
# 如果挂载的 public 目录为空,则从临时位置复制文件
if [ ! "$(ls -A /root/data/public)" ]; then
mkdir -p /root/data/public
cp -r /tmp/public/* /root/data/public/
fi
# 创建其他必要的目录
mkdir -p /root/data/logs
# 启动应用
./random-api

View File

@ -13,6 +13,13 @@ type EndpointStats struct {
LastResetDate string `json:"last_reset_date"`
}
// EndpointStatsResponse 用于API响应的结构体使用PascalCase
type EndpointStatsResponse struct {
TotalCalls int64 `json:"TotalCalls"`
TodayCalls int64 `json:"TodayCalls"`
LastResetDate string `json:"LastResetDate"`
}
type StatsManager struct {
Stats map[string]*EndpointStats `json:"stats"`
mu sync.RWMutex
@ -171,6 +178,22 @@ func (sm *StatsManager) GetStats() map[string]*EndpointStats {
return statsCopy
}
func (sm *StatsManager) GetStatsForAPI() map[string]*EndpointStatsResponse {
sm.mu.RLock()
defer sm.mu.RUnlock()
statsCopy := make(map[string]*EndpointStatsResponse)
for k, v := range sm.Stats {
statsCopy[k] = &EndpointStatsResponse{
TotalCalls: v.TotalCalls,
TodayCalls: v.TodayCalls,
LastResetDate: v.LastResetDate,
}
}
return statsCopy
}
func (sm *StatsManager) LastSaveTime() time.Time {
sm.mu.RLock()
defer sm.mu.RUnlock()

41
web/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
web/README.md Normal file
View File

@ -0,0 +1,36 @@
# 端点拖拽排序功能
## 功能说明
在管理页面的API端点管理中现在支持通过拖拽来重新排序端点。
## 使用方法
1. 进入管理页面 (`/admin`)
2. 在API端点管理表格中每行左侧有一个拖拽图标 (⋮⋮)
3. 点击并拖拽该图标可以重新排列端点的顺序
4. 松开鼠标后,新的排序会自动保存到后端
## 技术实现
- 使用 `@dnd-kit` 库实现拖拽功能
- 支持鼠标和键盘操作
- 拖拽过程中有视觉反馈(透明度变化)
- 自动保存排序到数据库
## 特性
- **无障碍支持**: 支持键盘操作
- **视觉反馈**: 拖拽时元素半透明显示
- **自动保存**: 排序变更后自动同步到后端
- **错误处理**: 如果保存失败会显示错误提示
## 依赖
```json
{
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2"
}
```

View File

@ -0,0 +1,48 @@
'use client'
import { useState, useEffect } from 'react'
import HomeConfigTab from '@/components/admin/HomeConfigTab'
import { authenticatedFetch } from '@/lib/auth'
export default function HomePage() {
const [homeConfig, setHomeConfig] = useState('')
useEffect(() => {
loadHomeConfig()
}, [])
const loadHomeConfig = async () => {
try {
const response = await authenticatedFetch('/api/admin/home-config')
if (response.ok) {
const data = await response.json()
setHomeConfig(data.data?.content || '')
}
} catch (error) {
console.error('Failed to load home config:', error)
}
}
const updateHomeConfig = async (content: string) => {
try {
const response = await authenticatedFetch('/api/admin/home-config', {
method: 'POST',
body: JSON.stringify({ content }),
})
if (response.ok) {
alert('首页配置更新成功')
setHomeConfig(content) // 更新本地状态
} else {
alert('首页配置更新失败')
}
} catch (error) {
console.error('Failed to update home config:', error)
alert('首页配置更新失败')
}
}
return (
<HomeConfigTab config={homeConfig} onUpdate={updateHomeConfig} />
)
}

127
web/app/admin/layout.tsx Normal file
View File

@ -0,0 +1,127 @@
'use client'
import { useState, useEffect } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import LoginPage from '@/components/admin/LoginPage'
import {
getUserInfo,
clearAuthInfo,
isAuthenticated,
type AuthUser
} from '@/lib/auth'
const navItems = [
{ key: 'endpoints', label: 'API端点', href: '/admin' },
{ key: 'rules', label: 'URL替换规则', href: '/admin/rules' },
{ key: 'home', label: '首页配置', href: '/admin/home' },
]
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
const pathname = usePathname()
const router = useRouter()
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
if (!isAuthenticated()) {
setLoading(false)
return
}
const savedUser = getUserInfo()
if (savedUser) {
setUser(savedUser)
setLoading(false)
return
}
// 如果没有用户信息,清除认证状态
clearAuthInfo()
setLoading(false)
}
const handleLoginSuccess = (userInfo: AuthUser) => {
setUser(userInfo)
setLoading(false)
}
const handleLogout = () => {
clearAuthInfo()
setUser(null)
router.push('/admin')
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary"></div>
</div>
)
}
if (!user) {
return <LoginPage onLoginSuccess={handleLoginSuccess} />
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="bg-background shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-semibold mr-8">
API管理后台
</h1>
{/* Navigation */}
<nav className="flex space-x-8">
{navItems.map((item) => (
<button
key={item.key}
onClick={() => router.push(item.href)}
className={`px-3 py-2 text-sm font-medium rounded-md transition-colors ${
pathname === item.href
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
>
{item.label}
</button>
))}
</nav>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-muted-foreground">
, {user.name}
</span>
<Button
onClick={handleLogout}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
>
退
</Button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
)
}

54
web/app/admin/page.tsx Normal file
View File

@ -0,0 +1,54 @@
'use client'
import { useState, useEffect } from 'react'
import EndpointsTab from '@/components/admin/EndpointsTab'
import type { APIEndpoint } from '@/types/admin'
import { authenticatedFetch } from '@/lib/auth'
export default function AdminPage() {
const [endpoints, setEndpoints] = useState<APIEndpoint[]>([])
useEffect(() => {
loadEndpoints()
}, [])
const loadEndpoints = async () => {
try {
const response = await authenticatedFetch('/api/admin/endpoints')
if (response.ok) {
const data = await response.json()
setEndpoints(data.data || [])
}
} catch (error) {
console.error('Failed to load endpoints:', error)
}
}
const createEndpoint = async (endpointData: Partial<APIEndpoint>) => {
try {
const response = await authenticatedFetch('/api/admin/endpoints/', {
method: 'POST',
body: JSON.stringify(endpointData),
})
if (response.ok) {
loadEndpoints() // 重新加载数据
} else {
alert('创建端点失败')
}
} catch (error) {
console.error('Failed to create endpoint:', error)
alert('创建端点失败')
}
}
return (
<EndpointsTab
endpoints={endpoints}
onCreateEndpoint={createEndpoint}
onUpdateEndpoints={loadEndpoints}
/>
)
}

View File

@ -0,0 +1,106 @@
'use client'
import { useState, useEffect } from 'react'
import URLRulesTab from '@/components/admin/URLRulesTab'
import type { URLReplaceRule, APIEndpoint } from '@/types/admin'
import { authenticatedFetch } from '@/lib/auth'
export default function RulesPage() {
const [urlRules, setUrlRules] = useState<URLReplaceRule[]>([])
const [endpoints, setEndpoints] = useState<APIEndpoint[]>([])
useEffect(() => {
loadURLRules()
loadEndpoints()
}, [])
const loadURLRules = async () => {
try {
const response = await authenticatedFetch('/api/admin/url-replace-rules')
if (response.ok) {
const data = await response.json()
setUrlRules(data.data || [])
}
} catch (error) {
console.error('Failed to load URL rules:', error)
}
}
const loadEndpoints = async () => {
try {
const response = await authenticatedFetch('/api/admin/endpoints')
if (response.ok) {
const data = await response.json()
setEndpoints(data.data || [])
}
} catch (error) {
console.error('Failed to load endpoints:', error)
}
}
const createURLRule = async (ruleData: Partial<URLReplaceRule>) => {
try {
const response = await authenticatedFetch('/api/admin/url-replace-rules', {
method: 'POST',
body: JSON.stringify(ruleData),
})
if (response.ok) {
loadURLRules() // 重新加载数据
alert('URL替换规则创建成功')
} else {
alert('创建URL替换规则失败')
}
} catch (error) {
console.error('Failed to create URL rule:', error)
alert('创建URL替换规则失败')
}
}
const updateURLRule = async (id: number, ruleData: Partial<URLReplaceRule>) => {
try {
const response = await authenticatedFetch(`/api/admin/url-replace-rules/${id}`, {
method: 'PUT',
body: JSON.stringify(ruleData),
})
if (response.ok) {
loadURLRules() // 重新加载数据
alert('URL替换规则更新成功')
} else {
alert('更新URL替换规则失败')
}
} catch (error) {
console.error('Failed to update URL rule:', error)
alert('更新URL替换规则失败')
}
}
const deleteURLRule = async (id: number) => {
try {
const response = await authenticatedFetch(`/api/admin/url-replace-rules/${id}`, {
method: 'DELETE',
})
if (response.ok) {
loadURLRules() // 重新加载数据
alert('URL替换规则删除成功')
} else {
alert('删除URL替换规则失败')
}
} catch (error) {
console.error('Failed to delete URL rule:', error)
alert('删除URL替换规则失败')
}
}
return (
<URLRulesTab
rules={urlRules}
endpoints={endpoints}
onCreateRule={createURLRule}
onUpdateRule={updateURLRule}
onDeleteRule={deleteURLRule}
/>
)
}

BIN
web/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

122
web/app/globals.css Normal file
View File

@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: -apple-system, BlinkMacSystemFont, 'Noto Sans SC', system-ui, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-mono: -apple-system, BlinkMacSystemFont, 'Noto Sans SC', system-ui, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

24
web/app/layout.tsx Normal file
View File

@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Random-Api 随机文件API",
description: "随机图API, 随机视频等 ",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body
className={`antialiased`}
>
{children}
</body>
</html>
);
}

457
web/app/page.tsx Normal file
View File

@ -0,0 +1,457 @@
'use client'
import { useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { apiFetch } from '@/lib/config'
interface Endpoint {
id: number;
name: string;
url: string;
description?: string;
is_active: boolean;
show_on_homepage: boolean;
sort_order: number;
}
interface SystemMetrics {
uptime: number; // 纳秒
start_time: string;
num_cpu: number;
num_goroutine: number;
average_latency: number;
memory_stats: {
heap_alloc: number;
heap_sys: number;
};
}
async function getHomePageConfig() {
try {
const res = await apiFetch('/api/admin/home-config')
if (!res.ok) {
throw new Error('Failed to fetch home page config')
}
const data = await res.json()
return data.data?.content || '# 欢迎使用随机API服务\n\n服务正在启动中...'
} catch (error) {
console.error('Error fetching home page config:', error)
return '# 欢迎使用随机API服务\n\n这是一个可配置的随机API服务。'
}
}
async function getStats() {
try {
const res = await apiFetch('/api/stats')
if (!res.ok) {
throw new Error('Failed to fetch stats')
}
return await res.json()
} catch (error) {
console.error('Error fetching stats:', error)
return {}
}
}
async function getURLStats() {
try {
const res = await apiFetch('/api/urlstats')
if (!res.ok) {
throw new Error('Failed to fetch URL stats')
}
return await res.json()
} catch (error) {
console.error('Error fetching URL stats:', error)
return {}
}
}
async function getSystemMetrics(): Promise<SystemMetrics | null> {
try {
const res = await apiFetch('/api/metrics')
if (!res.ok) {
throw new Error('Failed to fetch system metrics')
}
return await res.json()
} catch (error) {
console.error('Error fetching system metrics:', error)
return null
}
}
async function getEndpoints() {
try {
const res = await apiFetch('/api/admin/endpoints')
if (!res.ok) {
throw new Error('Failed to fetch endpoints')
}
const data = await res.json()
return data.data || []
} catch (error) {
console.error('Error fetching endpoints:', error)
return []
}
}
function formatUptime(uptimeNs: number): string {
const uptimeMs = uptimeNs / 1000000; // 纳秒转毫秒
const days = Math.floor(uptimeMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((uptimeMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((uptimeMs % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) {
return `${days}${hours}小时 ${minutes}分钟`;
} else if (hours > 0) {
return `${hours}小时 ${minutes}分钟`;
} else {
return `${minutes}分钟`;
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatStartTime(startTime: string): string {
const date = new Date(startTime);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
// 复制到剪贴板的函数
function copyToClipboard(text: string) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
} else {
// 降级方案 - 抑制弃用警告
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
return new Promise<void>((resolve, reject) => {
try {
const success = document.execCommand('copy');
if (success) {
resolve();
} else {
reject(new Error('Copy command failed'));
}
} catch (err) {
reject(err);
} finally {
textArea.remove();
}
});
}
}
export default function Home() {
const [content, setContent] = useState('')
const [stats, setStats] = useState<{Stats?: Record<string, {TotalCalls: number, TodayCalls: number}>}>({})
const [urlStats, setUrlStats] = useState<Record<string, {total_urls: number}>>({})
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
const [endpoints, setEndpoints] = useState<Endpoint[]>([])
const [copiedUrl, setCopiedUrl] = useState<string | null>(null)
useEffect(() => {
const loadData = async () => {
const [contentData, statsData, urlStatsData, systemMetricsData, endpointsData] = await Promise.all([
getHomePageConfig(),
getStats(),
getURLStats(),
getSystemMetrics(),
getEndpoints()
])
setContent(contentData)
setStats(statsData)
setUrlStats(urlStatsData)
setSystemMetrics(systemMetricsData)
setEndpoints(endpointsData)
}
loadData()
}, [])
// 过滤出首页可见的端点
const visibleEndpoints = endpoints.filter((endpoint: Endpoint) =>
endpoint.is_active && endpoint.show_on_homepage
)
const handleCopyUrl = async (endpoint: Endpoint) => {
const fullUrl = `${window.location.origin}/${endpoint.url}`
try {
await copyToClipboard(fullUrl)
setCopiedUrl(endpoint.url)
setTimeout(() => setCopiedUrl(null), 2000) // 2秒后清除复制状态
} catch (err) {
console.error('复制失败:', err)
}
}
return (
<div
className="min-h-screen bg-gray-100 dark:bg-gray-900 relative"
style={{
backgroundImage: 'url(http://localhost:5003/pic/all)',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundAttachment: 'fixed'
}}
>
{/* 背景遮罩 */}
<div className="absolute inset-0 bg-white/70 dark:bg-black/70 backdrop-blur-sm"></div>
<div className="container mx-auto px-4 py-6 relative z-10">
<div className="max-w-7xl mx-auto">
{/* Header - 更简洁 */}
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gray-800 dark:bg-gray-200 rounded-full mb-3">
<svg className="w-6 h-6 text-white dark:text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
{/* System Status Section - 性冷淡风格 */}
{systemMetrics && (
<div className="mb-6">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-200 text-center">
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{/* 运行时间 */}
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"></h3>
<div className="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full"></div>
</div>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100 leading-tight">
{formatUptime(systemMetrics.uptime)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate">
{formatStartTime(systemMetrics.start_time)}
</p>
</div>
{/* CPU核心数 */}
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">CPU核心</h3>
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{systemMetrics.num_cpu}
</p>
</div>
{/* Goroutine数量 */}
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"></h3>
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{systemMetrics.num_goroutine}
</p>
</div>
{/* 平均延迟 */}
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"></h3>
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{systemMetrics.average_latency.toFixed(2)} ms
</p>
</div>
{/* 堆内存分配 */}
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"></h3>
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H9a2 2 0 01-2-2z" />
</svg>
</div>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">
{formatBytes(systemMetrics.memory_stats.heap_alloc)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
: {formatBytes(systemMetrics.memory_stats.heap_sys)}
</p>
</div>
{/*
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300"></h3>
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p className="text-sm font-bold text-gray-900 dark:text-gray-100 leading-tight">
{new Date().toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div> */}
</div>
</div>
)}
{/* API端点统计 - 全宽布局 */}
{visibleEndpoints.length > 0 && (
<div className="mb-6">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-gray-200">
API
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{visibleEndpoints.map((endpoint: Endpoint) => {
const endpointStats = stats.Stats?.[endpoint.url] || { TotalCalls: 0, TodayCalls: 0 }
const urlCount = urlStats[endpoint.url]?.total_urls || 0
return (
<div key={endpoint.id} className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-lg shadow-sm border border-gray-200/50 dark:border-gray-700/50 p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
{endpoint.name}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 font-mono truncate">
/{endpoint.url}
</p>
</div>
<div className="flex items-center space-x-2 ml-2">
<Button
size="sm"
variant="outline"
className="text-xs px-2 py-1 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => handleCopyUrl(endpoint)}
>
{copiedUrl === endpoint.url ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</Button>
<Button size="sm" variant="outline" className="text-xs px-2 py-1 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
<Link href={`/${endpoint.url}`} target="_blank">
访
</Link>
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-3 text-center mb-3">
<div className="bg-gray-50/50 dark:bg-gray-700/50 rounded-md p-2">
<p className="text-xs text-gray-600 dark:text-gray-400"></p>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">
{endpointStats.TodayCalls}
</p>
</div>
<div className="bg-gray-50/50 dark:bg-gray-700/50 rounded-md p-2">
<p className="text-xs text-gray-600 dark:text-gray-400"></p>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">
{endpointStats.TotalCalls}
</p>
</div>
<div className="bg-gray-50/50 dark:bg-gray-700/50 rounded-md p-2">
<p className="text-xs text-gray-600 dark:text-gray-400">URL</p>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">
{urlCount}
</p>
</div>
</div>
{endpoint.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{endpoint.description}
</p>
)}
</div>
)
})}
</div>
</div>
)}
{/* Main Content - 半透明 */}
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-sm border border-gray-200/50 dark:border-gray-700/50 p-6 mb-6">
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({children}) => <h1 className="text-3xl font-bold mb-4 text-gray-900 dark:text-white">{children}</h1>,
h2: ({children}) => <h2 className="text-xl font-semibold mb-3 text-gray-800 dark:text-gray-200">{children}</h2>,
h3: ({children}) => <h3 className="text-lg font-medium mb-2 text-gray-700 dark:text-gray-300">{children}</h3>,
p: ({children}) => <p className="mb-3 text-gray-600 dark:text-gray-400">{children}</p>,
ul: ({children}) => <ul className="list-disc list-inside mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal list-inside mb-3 space-y-1">{children}</ol>,
li: ({children}) => <li className="mb-1 text-gray-600 dark:text-gray-400">{children}</li>,
strong: ({children}) => <strong className="font-semibold text-gray-900 dark:text-white">{children}</strong>,
em: ({children}) => <em className="italic">{children}</em>,
code: ({children}) => <code className="bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-sm font-mono">{children}</code>,
pre: ({children}) => <pre className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto mb-4">{children}</pre>,
blockquote: ({children}) => <blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-700 dark:text-gray-300 mb-4">{children}</blockquote>,
a: ({href, children}) => <a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">{children}</a>,
}}
>
{content}
</ReactMarkdown>
</div>
</div>
{/* Footer - 包含管理后台链接 */}
<div className="text-center mt-8 text-sm text-gray-600 dark:text-gray-400">
<p>API服务 - Next.js Go </p>
<p className="mt-2">
<Link href="/admin" className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 underline">
</Link>
</p>
</div>
</div>
</div>
</div>
)
}

21
web/components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,564 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Trash2, Plus } from 'lucide-react'
import { authenticatedFetch } from '@/lib/auth'
interface DataSourceConfigFormProps {
type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint'
config: string
onChange: (config: string) => void
}
interface LankongConfig {
api_token: string
album_ids: string[]
base_url?: string
}
interface APIConfig {
url: string
method?: string
headers: { [key: string]: string }
body?: string
url_field: string
}
interface SavedToken {
id: string
name: string
token: string
}
interface EndpointConfig {
endpoint_ids: number[]
}
export default function DataSourceConfigForm({ type, config, onChange }: DataSourceConfigFormProps) {
const [lankongConfig, setLankongConfig] = useState<LankongConfig>({
api_token: '',
album_ids: [''],
base_url: ''
})
const [apiConfig, setAPIConfig] = useState<APIConfig>({
url: '',
method: type === 'api_post' ? 'POST' : 'GET',
headers: {},
body: '',
url_field: 'url'
})
const [endpointConfig, setEndpointConfig] = useState<EndpointConfig>({
endpoint_ids: []
})
const [availableEndpoints, setAvailableEndpoints] = useState<Array<{id: number, name: string, url: string}>>([])
const [headerPairs, setHeaderPairs] = useState<Array<{key: string, value: string}>>([{key: '', value: ''}])
const [savedTokens, setSavedTokens] = useState<SavedToken[]>([])
const [newTokenName, setNewTokenName] = useState<string>('')
// 从localStorage加载保存的token
useEffect(() => {
const saved = localStorage.getItem('lankong_tokens')
if (saved) {
try {
setSavedTokens(JSON.parse(saved))
} catch (error) {
console.error('Failed to parse saved tokens:', error)
}
}
}, [])
// 获取可用端点列表
useEffect(() => {
if (type === 'endpoint') {
loadAvailableEndpoints()
}
}, [type])
const loadAvailableEndpoints = async () => {
try {
const response = await authenticatedFetch('/api/admin/endpoints')
if (response.ok) {
const data = await response.json()
setAvailableEndpoints(data.data || [])
}
} catch (error) {
console.error('Failed to load endpoints:', error)
}
}
// 解析现有配置
useEffect(() => {
if (!config) return
try {
const parsed = JSON.parse(config)
if (type === 'lankong') {
setLankongConfig({
api_token: parsed.api_token || '',
album_ids: parsed.album_ids || [''],
base_url: parsed.base_url || ''
})
} else if (type === 'api_get' || type === 'api_post') {
setAPIConfig({
url: parsed.url || '',
method: parsed.method || (type === 'api_post' ? 'POST' : 'GET'),
headers: parsed.headers || {},
body: parsed.body || '',
url_field: parsed.url_field || 'url'
})
// 转换headers为键值对数组
const pairs = Object.entries(parsed.headers || {}).map(([key, value]) => ({key, value: value as string}))
if (pairs.length === 0) pairs.push({key: '', value: ''})
setHeaderPairs(pairs)
} else if (type === 'endpoint') {
setEndpointConfig({
endpoint_ids: parsed.endpoint_ids || []
})
}
} catch (error) {
console.error('Failed to parse config:', error)
}
}, [config, type])
// 保存token到localStorage
const saveToken = () => {
if (!newTokenName.trim() || !lankongConfig.api_token.trim()) {
alert('请输入token名称和token值')
return
}
const newToken: SavedToken = {
id: Date.now().toString(),
name: newTokenName.trim(),
token: lankongConfig.api_token
}
const updated = [...savedTokens, newToken]
setSavedTokens(updated)
localStorage.setItem('lankong_tokens', JSON.stringify(updated))
setNewTokenName('')
alert('Token保存成功')
}
// 删除保存的token
const deleteToken = (tokenId: string) => {
if (!confirm('确定要删除这个token吗')) return
const updated = savedTokens.filter(t => t.id !== tokenId)
setSavedTokens(updated)
localStorage.setItem('lankong_tokens', JSON.stringify(updated))
}
// 更新兰空图床配置
const updateConfig = (newConfig: LankongConfig | APIConfig) => {
onChange(JSON.stringify(newConfig))
}
// 添加相册ID
const addAlbumId = () => {
const newConfig = {
...lankongConfig,
album_ids: [...lankongConfig.album_ids, '']
}
setLankongConfig(newConfig)
updateConfig(newConfig)
}
// 删除相册ID
const removeAlbumId = (index: number) => {
const newConfig = {
...lankongConfig,
album_ids: lankongConfig.album_ids.filter((_, i) => i !== index)
}
setLankongConfig(newConfig)
updateConfig(newConfig)
}
// 更新相册ID
const updateAlbumId = (index: number, value: string) => {
const newConfig = {
...lankongConfig,
album_ids: lankongConfig.album_ids.map((id, i) => i === index ? value : id)
}
setLankongConfig(newConfig)
updateConfig(newConfig)
}
// 添加请求头
const addHeader = () => {
setHeaderPairs([...headerPairs, {key: '', value: ''}])
}
// 删除请求头
const removeHeader = (index: number) => {
const newPairs = headerPairs.filter((_, i) => i !== index)
setHeaderPairs(newPairs)
updateAPIHeaders(newPairs)
}
// 更新请求头
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
const newPairs = headerPairs.map((pair, i) =>
i === index ? { ...pair, [field]: value } : pair
)
setHeaderPairs(newPairs)
updateAPIHeaders(newPairs)
}
// 更新API配置的headers
const updateAPIHeaders = (pairs: Array<{key: string, value: string}>) => {
const headers: { [key: string]: string } = {}
pairs.forEach(pair => {
if (pair.key.trim() && pair.value.trim()) {
headers[pair.key.trim()] = pair.value.trim()
}
})
const newConfig = { ...apiConfig, headers }
setAPIConfig(newConfig)
updateConfig(newConfig)
}
// 更新API配置
const updateAPIConfig = (field: keyof APIConfig, value: string) => {
// 对URL字段进行trim处理去除前后空格
const trimmedValue = field === 'url' ? value.trim() : value
const newConfig = { ...apiConfig, [field]: trimmedValue }
setAPIConfig(newConfig)
updateConfig(newConfig)
}
// 更新端点配置
const updateEndpointConfig = (endpointIds: number[]) => {
const newConfig = { endpoint_ids: endpointIds }
setEndpointConfig(newConfig)
onChange(JSON.stringify(newConfig))
}
// 切换端点选择
const toggleEndpoint = (endpointId: number) => {
const currentIds = endpointConfig.endpoint_ids
const newIds = currentIds.includes(endpointId)
? currentIds.filter(id => id !== endpointId)
: [...currentIds, endpointId]
updateEndpointConfig(newIds)
}
if (type === 'manual') {
return (
<div className="space-y-2">
<Label htmlFor="manual-config">URL列表</Label>
<Textarea
id="manual-config"
value={config}
onChange={(e) => onChange(e.target.value)}
placeholder="每行输入一个URL地址"
rows={4}
/>
<p className="text-xs text-muted-foreground">
URL地址#
</p>
</div>
)
}
if (type === 'lankong') {
return (
<div className="space-y-4">
{/* Token管理 */}
<Card>
<CardHeader>
<CardTitle className="text-sm">Token管理</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 保存的Token列表 */}
{savedTokens.length > 0 && (
<div className="space-y-2">
<Label className="text-xs">使Token</Label>
<div className="space-y-1">
{savedTokens.map((token) => (
<div key={token.id} className="flex items-center gap-2 p-2 border rounded">
<span className="flex-1 text-sm">{token.name}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const newConfig = { ...lankongConfig, api_token: token.token }
setLankongConfig(newConfig)
updateConfig(newConfig)
}}
>
使
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => deleteToken(token.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* API Token */}
<div className="space-y-2">
<Label htmlFor="api-token">API Token</Label>
<Input
id="api-token"
type="password"
value={lankongConfig.api_token}
onChange={(e) => {
const newConfig = { ...lankongConfig, api_token: e.target.value }
setLankongConfig(newConfig)
updateConfig(newConfig)
}}
placeholder="输入兰空图床API Token"
/>
</div>
{/* 保存Token */}
<div className="flex gap-2">
<Input
placeholder="Token名称主账号、备用账号"
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
className="flex-1"
/>
<Button
type="button"
size="sm"
onClick={saveToken}
disabled={!newTokenName.trim() || !lankongConfig.api_token.trim()}
>
Token
</Button>
</div>
</CardContent>
</Card>
{/* 相册配置 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 相册ID列表 */}
<div className="space-y-2">
<Label>ID列表</Label>
{lankongConfig.album_ids.map((albumId, index) => (
<div key={index} className="flex gap-2">
<Input
value={albumId}
onChange={(e) => updateAlbumId(index, e.target.value)}
placeholder="输入相册ID"
className="flex-1"
/>
{lankongConfig.album_ids.length > 1 && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => removeAlbumId(index)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
onClick={addAlbumId}
className="w-full"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{/* Base URL */}
<div className="space-y-2">
<Label htmlFor="base-url">Base URL</Label>
<Input
id="base-url"
value={lankongConfig.base_url}
onChange={(e) => {
const newConfig = { ...lankongConfig, base_url: e.target.value.trim() }
setLankongConfig(newConfig)
updateConfig(newConfig)
}}
placeholder="默认: https://img.czl.net/api/v1/images"
/>
<p className="text-xs text-muted-foreground">
使
</p>
</div>
</CardContent>
</Card>
</div>
)
}
if (type === 'api_get' || type === 'api_post') {
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">API配置</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* API URL */}
<div className="space-y-2">
<Label htmlFor="api-url">API地址</Label>
<Input
id="api-url"
value={apiConfig.url}
onChange={(e) => updateAPIConfig('url', e.target.value)}
placeholder="https://api.example.com/images"
/>
</div>
{/* 请求头 */}
<div className="space-y-2">
<Label></Label>
{headerPairs.map((pair, index) => (
<div key={index} className="flex gap-2">
<Input
value={pair.key}
onChange={(e) => updateHeader(index, 'key', e.target.value)}
placeholder="Header名称"
className="flex-1"
/>
<Input
value={pair.value}
onChange={(e) => updateHeader(index, 'value', e.target.value)}
placeholder="Header值"
className="flex-1"
/>
{headerPairs.length > 1 && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => removeHeader(index)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
onClick={addHeader}
className="w-full"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{/* POST请求体 */}
{type === 'api_post' && (
<div className="space-y-2">
<Label htmlFor="request-body">JSON</Label>
<Textarea
id="request-body"
value={apiConfig.body}
onChange={(e) => updateAPIConfig('body', e.target.value)}
placeholder='{"key": "value"}'
rows={3}
/>
</div>
)}
{/* URL字段路径 */}
<div className="space-y-2">
<Label htmlFor="url-field">URL字段路径</Label>
<Input
id="url-field"
value={apiConfig.url_field}
onChange={(e) => updateAPIConfig('url_field', e.target.value)}
placeholder="data.url 或 urls.0 或 url"
/>
<p className="text-xs text-muted-foreground">
JSON中URL字段的路径 data.url urls.0
</p>
</div>
</CardContent>
</Card>
</div>
)
}
if (type === 'endpoint') {
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label></Label>
{availableEndpoints.length === 0 ? (
<p className="text-sm text-muted-foreground">...</p>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{availableEndpoints.map((endpoint) => (
<div key={endpoint.id} className="flex items-center space-x-2">
<Checkbox
id={`endpoint-${endpoint.id}`}
checked={endpointConfig.endpoint_ids.includes(endpoint.id)}
onCheckedChange={() => toggleEndpoint(endpoint.id)}
/>
<Label
htmlFor={`endpoint-${endpoint.id}`}
className="flex-1 cursor-pointer"
>
<div className="flex flex-col">
<span className="font-medium">{endpoint.name}</span>
<span className="text-xs text-muted-foreground">/{endpoint.url}</span>
</div>
</Label>
</div>
))}
</div>
)}
{endpointConfig.endpoint_ids.length > 0 && (
<p className="text-xs text-muted-foreground">
{endpointConfig.endpoint_ids.length}
</p>
)}
</div>
</CardContent>
</Card>
</div>
)
}
return null
}

View File

@ -0,0 +1,387 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import DataSourceConfigForm from './DataSourceConfigForm'
import type { APIEndpoint, DataSource } from '@/types/admin'
import { authenticatedFetch } from '@/lib/auth'
interface DataSourceManagementProps {
endpoint: APIEndpoint
onClose: () => void
onUpdate: () => void
}
export default function DataSourceManagement({
endpoint,
onClose,
onUpdate
}: DataSourceManagementProps) {
const [showCreateForm, setShowCreateForm] = useState(false)
const [editingDataSource, setEditingDataSource] = useState<DataSource | null>(null)
const [formData, setFormData] = useState({
name: '',
type: 'manual' as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint',
config: '',
cache_duration: 3600,
is_active: true
})
const createDataSource = async (e: React.FormEvent) => {
e.preventDefault()
try {
// 处理配置数据
let config = formData.config
if (formData.type === 'manual') {
// 将每行URL转换为JSON格式过滤掉空行和注释行
const urls = formData.config.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0 && !url.startsWith('#'))
config = JSON.stringify({ urls })
}
const response = await authenticatedFetch(`/api/admin/endpoints/${endpoint.id}/data-sources`, {
method: 'POST',
body: JSON.stringify({
...formData,
config,
endpoint_id: endpoint.id
}),
})
if (response.ok) {
onUpdate()
setFormData({ name: '', type: 'manual' as const, config: '', cache_duration: 3600, is_active: true })
setShowCreateForm(false)
alert('数据源创建成功')
} else {
alert('创建数据源失败')
}
} catch (error) {
console.error('Failed to create data source:', error)
alert('创建数据源失败')
}
}
const syncDataSource = async (dataSourceId: number) => {
try {
const response = await authenticatedFetch(`/api/admin/data-sources/${dataSourceId}/sync`, {
method: 'POST',
})
if (response.ok) {
onUpdate()
alert('数据源同步成功')
} else {
alert('数据源同步失败')
}
} catch (error) {
console.error('Failed to sync data source:', error)
alert('数据源同步失败')
}
}
const updateDataSource = async (e: React.FormEvent) => {
e.preventDefault()
if (!editingDataSource) return
try {
// 处理配置数据
let config = formData.config
if (formData.type === 'manual') {
// 将每行URL转换为JSON格式过滤掉空行和注释行
const urls = formData.config.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0 && !url.startsWith('#'))
config = JSON.stringify({ urls })
}
const response = await authenticatedFetch(`/api/admin/data-sources/${editingDataSource.id}`, {
method: 'PUT',
body: JSON.stringify({
...formData,
config
}),
})
if (response.ok) {
onUpdate()
setFormData({ name: '', type: 'manual' as const, config: '', cache_duration: 3600, is_active: true })
setEditingDataSource(null)
alert('数据源更新成功')
} else {
alert('更新数据源失败')
}
} catch (error) {
console.error('Failed to update data source:', error)
alert('更新数据源失败')
}
}
const startEditDataSource = (dataSource: DataSource) => {
setEditingDataSource(dataSource)
// 处理配置数据回显
let config = dataSource.config
if (dataSource.type === 'manual') {
try {
// 将JSON格式转换为每行一个URL的格式
const parsed = JSON.parse(dataSource.config)
if (parsed.urls && Array.isArray(parsed.urls)) {
config = parsed.urls.join('\n')
}
} catch (error) {
console.error('Failed to parse manual config:', error)
// 如果解析失败,保持原始配置
}
}
setFormData({
name: dataSource.name,
type: dataSource.type,
config: config,
cache_duration: dataSource.cache_duration,
is_active: dataSource.is_active
})
setShowCreateForm(false) // 关闭创建表单
}
const cancelEdit = () => {
setEditingDataSource(null)
setFormData({ name: '', type: 'manual' as const, config: '', cache_duration: 3600, is_active: true })
}
const deleteDataSource = async (dataSourceId: number) => {
if (!confirm('确定要删除这个数据源吗?')) {
return
}
try {
const response = await authenticatedFetch(`/api/admin/data-sources/${dataSourceId}`, {
method: 'DELETE',
})
if (response.ok) {
onUpdate()
alert('数据源删除成功')
} else {
alert('数据源删除失败')
}
} catch (error) {
console.error('Failed to delete data source:', error)
alert('数据源删除失败')
}
}
const getTypeDisplayName = (type: string) => {
switch (type) {
case 'manual': return '手动'
case 'lankong': return '兰空图床'
case 'api_get': return 'GET接口'
case 'api_post': return 'POST接口'
case 'endpoint': return '已有端点'
default: return type
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
- {endpoint.name}
</h3>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
</Button>
</div>
</div>
<div className="p-6 flex-1 overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h4 className="text-md font-medium text-gray-900 dark:text-gray-100"></h4>
<Button
onClick={() => {
setShowCreateForm(true)
setEditingDataSource(null)
setFormData({ name: '', type: 'manual' as const, config: '', cache_duration: 3600, is_active: true })
}}
size="sm"
>
</Button>
</div>
{(showCreateForm || editingDataSource) && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-4">
<h5 className="text-sm font-medium mb-3 text-gray-900 dark:text-gray-100">
{editingDataSource ? '编辑数据源' : '创建新数据源'}
</h5>
<form onSubmit={editingDataSource ? updateDataSource : createDataSource} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="ds-name"></Label>
<Input
id="ds-name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="ds-type"></Label>
<select
id="ds-type"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' })}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="manual"></option>
<option value="lankong"></option>
<option value="api_get">GET接口</option>
<option value="api_post">POST接口</option>
<option value="endpoint"></option>
</select>
</div>
</div>
<DataSourceConfigForm
type={formData.type}
config={formData.config}
onChange={(config) => setFormData({ ...formData, config })}
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="ds-cache">()</Label>
<Input
id="ds-cache"
type="number"
value={formData.cache_duration}
onChange={(e) => setFormData({ ...formData, cache_duration: parseInt(e.target.value) || 0 })}
min="0"
/>
<p className="text-xs text-muted-foreground">
03600(1)
</p>
</div>
<div className="flex items-center space-x-2 pt-6">
<Switch
id="ds-active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label htmlFor="ds-active"></Label>
</div>
</div>
<div className="flex space-x-2">
<Button type="submit" size="sm">
{editingDataSource ? '更新' : '创建'}
</Button>
<Button
type="button"
onClick={editingDataSource ? cancelEdit : () => setShowCreateForm(false)}
variant="outline"
size="sm"
>
</Button>
</div>
</form>
</div>
)}
<div className="rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{endpoint.data_sources && endpoint.data_sources.length > 0 ? (
endpoint.data_sources.map((dataSource) => (
<TableRow key={dataSource.id}>
<TableCell className="font-medium">
{dataSource.name}
</TableCell>
<TableCell>
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100">
{getTypeDisplayName(dataSource.type)}
</span>
</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
dataSource.is_active
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}>
{dataSource.is_active ? '启用' : '禁用'}
</span>
</TableCell>
<TableCell>
{dataSource.cache_duration > 0 ? `${dataSource.cache_duration}` : '不缓存'}
</TableCell>
<TableCell>
{dataSource.last_sync ? new Date(dataSource.last_sync).toLocaleString() : '未同步'}
</TableCell>
<TableCell>
<div className="flex space-x-1">
<Button
variant="outline"
size="sm"
onClick={() => startEditDataSource(dataSource)}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => syncDataSource(dataSource.id)}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => deleteDataSource(dataSource.id)}
className="text-red-600 hover:text-red-700"
>
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
&quot;&quot;
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,323 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import DataSourceManagement from './DataSourceManagement'
import type { APIEndpoint } from '@/types/admin'
import { authenticatedFetch } from '@/lib/auth'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import {
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical } from 'lucide-react'
interface EndpointsTabProps {
endpoints: APIEndpoint[]
onCreateEndpoint: (data: Partial<APIEndpoint>) => void
onUpdateEndpoints: () => void
}
// 可拖拽的表格行组件
function SortableTableRow({ endpoint, onManageDataSources }: {
endpoint: APIEndpoint
onManageDataSources: (endpoint: APIEndpoint) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: endpoint.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<TableRow ref={setNodeRef} style={style} className={isDragging ? 'z-50' : ''}>
<TableCell>
<div
{...attributes}
{...listeners}
className="flex items-center justify-center cursor-grab active:cursor-grabbing p-1 hover:bg-muted rounded"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
</TableCell>
<TableCell className="font-medium">
{endpoint.name}
</TableCell>
<TableCell>
{endpoint.url}
</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
endpoint.is_active
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}>
{endpoint.is_active ? '启用' : '禁用'}
</span>
</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
endpoint.show_on_homepage
? 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100'
}`}>
{endpoint.show_on_homepage ? '显示' : '隐藏'}
</span>
</TableCell>
<TableCell>
{new Date(endpoint.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<Button
onClick={() => onManageDataSources(endpoint)}
variant="outline"
size="sm"
>
</Button>
</TableCell>
</TableRow>
)
}
export default function EndpointsTab({ endpoints, onCreateEndpoint, onUpdateEndpoints }: EndpointsTabProps) {
const [showCreateForm, setShowCreateForm] = useState(false)
const [selectedEndpoint, setSelectedEndpoint] = useState<APIEndpoint | null>(null)
const [formData, setFormData] = useState({
name: '',
url: '',
description: '',
is_active: true,
show_on_homepage: true
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onCreateEndpoint(formData)
setFormData({ name: '', url: '', description: '', is_active: true, show_on_homepage: true })
setShowCreateForm(false)
}
const loadEndpointDataSources = async (endpointId: number) => {
try {
const response = await authenticatedFetch(`/api/admin/endpoints/${endpointId}/data-sources`)
if (response.ok) {
const data = await response.json()
const endpoint = endpoints.find(e => e.id === endpointId)
if (endpoint) {
endpoint.data_sources = data.data || []
setSelectedEndpoint({ ...endpoint })
}
}
} catch (error) {
console.error('Failed to load data sources:', error)
}
}
const handleManageDataSources = (endpoint: APIEndpoint) => {
setSelectedEndpoint(endpoint)
loadEndpointDataSources(endpoint.id)
}
// 处理拖拽结束事件
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
const oldIndex = endpoints.findIndex(endpoint => endpoint.id === active.id)
const newIndex = endpoints.findIndex(endpoint => endpoint.id === over.id)
if (oldIndex === -1 || newIndex === -1) {
return
}
// 创建新的排序数组
const newEndpoints = arrayMove(endpoints, oldIndex, newIndex)
// 更新排序值
const endpointOrders = newEndpoints.map((endpoint, index) => ({
id: endpoint.id,
sort_order: index
}))
try {
const response = await authenticatedFetch('/api/admin/endpoints/sort-order', {
method: 'PUT',
body: JSON.stringify({ endpoint_orders: endpointOrders }),
})
if (response.ok) {
onUpdateEndpoints()
} else {
alert('更新排序失败')
}
} catch (error) {
console.error('Failed to update sort order:', error)
alert('更新排序失败')
}
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold tracking-tight">API端点管理</h2>
<Button
onClick={() => setShowCreateForm(true)}
>
</Button>
</div>
{showCreateForm && (
<div className="bg-card rounded-lg border p-6 mb-6">
<h3 className="text-lg font-medium mb-4"></h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="url">URL路径</Label>
<Input
id="url"
type="text"
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
placeholder="例如: pic/anime"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="flex space-x-6">
<div className="flex items-center space-x-2">
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label htmlFor="is_active"></Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="show_on_homepage"
checked={formData.show_on_homepage}
onCheckedChange={(checked) => setFormData({ ...formData, show_on_homepage: checked })}
/>
<Label htmlFor="show_on_homepage"></Label>
</div>
</div>
<div className="flex space-x-3">
<Button type="submit">
</Button>
<Button
type="button"
onClick={() => setShowCreateForm(false)}
variant="outline"
>
</Button>
</div>
</form>
</div>
)}
<div className="rounded-md border">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
<TableHead>URL</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<SortableContext
items={endpoints.map(endpoint => endpoint.id)}
strategy={verticalListSortingStrategy}
>
{endpoints.map((endpoint) => (
<SortableTableRow
key={endpoint.id}
endpoint={endpoint}
onManageDataSources={handleManageDataSources}
/>
))}
</SortableContext>
</TableBody>
</Table>
</DndContext>
</div>
{/* 数据源管理弹窗 */}
{selectedEndpoint && (
<DataSourceManagement
endpoint={selectedEndpoint}
onClose={() => setSelectedEndpoint(null)}
onUpdate={() => loadEndpointDataSources(selectedEndpoint.id)}
/>
)}
</div>
)
}

View File

@ -0,0 +1,95 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
interface HomeConfigTabProps {
config: string
onUpdate: (content: string) => void
}
export default function HomeConfigTab({ config, onUpdate }: HomeConfigTabProps) {
const [content, setContent] = useState(config)
useEffect(() => {
setContent(config)
}, [config])
const handleSave = () => {
onUpdate(content)
}
const handleReset = () => {
setContent(config)
}
const hasChanges = content !== config
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold tracking-tight"></h2>
<div className="flex space-x-2">
{hasChanges && (
<Button
onClick={handleReset}
variant="outline"
>
</Button>
)}
<Button
onClick={handleSave}
disabled={!hasChanges}
>
</Button>
</div>
</div>
<div className="bg-card rounded-lg border p-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="markdown-content">Markdown内容</Label>
<Textarea
id="markdown-content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-96 font-mono text-sm"
placeholder="输入Markdown格式的内容..."
/>
</div>
<div className="bg-muted rounded-lg p-4">
<h4 className="text-sm font-medium mb-2">使</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Markdown语法</li>
<li> 使API使用示例</li>
<li> </li>
<li> </li>
</ul>
</div>
{hasChanges && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-yellow-800">
&quot;&quot;
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,169 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
saveAuthInfo,
saveOAuthState,
getOAuthState,
clearOAuthState,
type AuthUser
} from '@/lib/auth'
import type { OAuthConfig } from '@/types/admin'
// OAuth2.0 端点配置
const OAUTH_ENDPOINTS = {
authorizeUrl: 'https://connect.czl.net/oauth2/authorize',
tokenUrl: 'https://connect.czl.net/api/oauth2/token',
userInfoUrl: 'https://connect.czl.net/api/oauth2/userinfo',
// 使用配置的BASE_URL构建回调地址
getRedirectUri: (baseUrl: string) => {
return `${baseUrl}/api/admin/oauth/callback`
}
}
interface LoginPageProps {
onLoginSuccess: (user: AuthUser) => void
}
export default function LoginPage({ onLoginSuccess }: LoginPageProps) {
const [loading, setLoading] = useState(true)
const [oauthConfig, setOauthConfig] = useState<OAuthConfig | null>(null)
useEffect(() => {
// 首先检查URL参数中是否有token
checkURLParams()
loadOAuthConfig()
}, [])
const checkURLParams = () => {
const urlParams = new URLSearchParams(window.location.search)
const token = urlParams.get('token')
const error = urlParams.get('error')
const userName = urlParams.get('user')
const state = urlParams.get('state')
if (error) {
alert(`登录失败: ${error}`)
// 清理URL参数
window.history.replaceState({}, document.title, window.location.pathname)
return
}
if (token) {
// 验证state参数防止CSRF攻击如果存在的话
if (state) {
const savedState = getOAuthState()
if (savedState !== state) {
alert('登录状态验证失败,请重新登录')
clearOAuthState()
window.history.replaceState({}, document.title, window.location.pathname)
return
}
} else {
console.warn('OAuth回调缺少state参数可能存在安全风险')
}
// 保存认证信息
if (userName) {
const userInfo: AuthUser = { id: '', name: userName, email: '' }
saveAuthInfo(token, userInfo)
onLoginSuccess(userInfo)
}
// 清理URL参数和OAuth状态
clearOAuthState()
window.history.replaceState({}, document.title, window.location.pathname)
return
}
setLoading(false)
}
const loadOAuthConfig = async () => {
try {
console.log('Loading OAuth config...')
const response = await fetch('/api/admin/oauth-config')
console.log('OAuth config response status:', response.status)
if (response.ok) {
const data = await response.json()
console.log('OAuth config data:', data)
if (data.success) {
setOauthConfig(data.data)
} else {
// OAuth配置错误
console.error('OAuth配置错误:', data.error)
alert(`OAuth配置错误: ${data.error}`)
}
} else {
const errorText = await response.text()
console.error('Failed to load OAuth config: HTTP', response.status, errorText)
alert(`无法加载OAuth配置: HTTP ${response.status}`)
}
} catch (error) {
console.error('Failed to load OAuth config:', error)
alert(`网络错误: ${error instanceof Error ? error.message : '未知错误'}`)
} finally {
setLoading(false)
}
}
const handleLogin = () => {
if (!oauthConfig) {
alert('OAuth配置未加载')
return
}
// 生成随机state值防止CSRF攻击
const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
saveOAuthState(state)
const params = new URLSearchParams({
client_id: oauthConfig.client_id,
redirect_uri: OAUTH_ENDPOINTS.getRedirectUri(oauthConfig.base_url), // 使用配置的BASE_URL
response_type: 'code',
scope: 'read write', // 根据CZL Connect文档使用正确的scope
state: state,
})
window.location.href = `${OAUTH_ENDPOINTS.authorizeUrl}?${params.toString()}`
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary"></div>
</div>
)
}
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="bg-card rounded-lg border shadow-lg p-8 max-w-md w-full mx-4">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-full mb-4">
<svg className="w-8 h-8 text-primary-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h1 className="text-2xl font-bold mb-2">
</h1>
<p className="text-muted-foreground">
使 CZL Connect
</p>
</div>
<Button
onClick={handleLogin}
className="w-full"
size="lg"
disabled={!oauthConfig}
>
{oauthConfig ? '使用 CZL Connect 登录' : '加载中...'}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,291 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import type { URLReplaceRule, APIEndpoint } from '@/types/admin'
interface URLRulesTabProps {
rules: URLReplaceRule[]
endpoints: APIEndpoint[]
onCreateRule?: (data: Partial<URLReplaceRule>) => void
onUpdateRule?: (id: number, data: Partial<URLReplaceRule>) => void
onDeleteRule?: (id: number) => void
}
export default function URLRulesTab({
rules,
endpoints,
onCreateRule,
onUpdateRule,
onDeleteRule
}: URLRulesTabProps) {
const [showCreateForm, setShowCreateForm] = useState(false)
const [editingRule, setEditingRule] = useState<URLReplaceRule | null>(null)
const [formData, setFormData] = useState({
name: '',
from_url: '',
to_url: '',
endpoint_id: undefined as number | undefined,
is_active: true
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (editingRule) {
// 更新规则
if (onUpdateRule) {
onUpdateRule(editingRule.id, formData)
setEditingRule(null)
}
} else {
// 创建规则
if (onCreateRule) {
onCreateRule(formData)
}
}
setFormData({ name: '', from_url: '', to_url: '', endpoint_id: undefined, is_active: true })
setShowCreateForm(false)
}
const handleEdit = (rule: URLReplaceRule) => {
setEditingRule(rule)
setFormData({
name: rule.name,
from_url: rule.from_url,
to_url: rule.to_url,
endpoint_id: rule.endpoint_id,
is_active: rule.is_active
})
setShowCreateForm(true)
}
const handleCancelEdit = () => {
setEditingRule(null)
setFormData({ name: '', from_url: '', to_url: '', endpoint_id: undefined, is_active: true })
setShowCreateForm(false)
}
const handleDelete = (ruleId: number) => {
if (confirm('确定要删除这个URL替换规则吗')) {
if (onDeleteRule) {
onDeleteRule(ruleId)
}
}
}
const toggleRuleStatus = (rule: URLReplaceRule) => {
if (onUpdateRule) {
onUpdateRule(rule.id, { is_active: !rule.is_active })
}
}
const getEndpointName = (endpointId?: number) => {
if (!endpointId) return '全局规则'
const endpoint = endpoints.find(ep => ep.id === endpointId)
return endpoint ? endpoint.name : `端点 ${endpointId}`
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold tracking-tight">URL替换规则</h2>
<Button onClick={() => setShowCreateForm(true)}>
</Button>
</div>
{showCreateForm && (
<div className="bg-card rounded-lg border p-6 mb-6">
<h3 className="text-lg font-medium mb-4">
{editingRule ? '编辑规则' : '创建新规则'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="rule-name"></Label>
<Input
id="rule-name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如: 替换图床域名"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="endpoint-select"></Label>
<Select
value={formData.endpoint_id?.toString() || 'global'}
onValueChange={(value) => setFormData({
...formData,
endpoint_id: value === 'global' ? undefined : parseInt(value)
})}
>
<SelectTrigger>
<SelectValue placeholder="选择端点或设为全局规则" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global"></SelectItem>
{endpoints.map((endpoint) => (
<SelectItem key={endpoint.id} value={endpoint.id.toString()}>
{endpoint.name} ({endpoint.url})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="from-url">URL模式</Label>
<Input
id="from-url"
type="text"
value={formData.from_url}
onChange={(e) => setFormData({ ...formData, from_url: e.target.value })}
placeholder="例如: a.com"
required
/>
<p className="text-xs text-muted-foreground">
URL片段匹配
</p>
</div>
<div className="space-y-2">
<Label htmlFor="to-url">URL模式</Label>
<Input
id="to-url"
type="text"
value={formData.to_url}
onChange={(e) => setFormData({ ...formData, to_url: e.target.value })}
placeholder="例如: b.com"
required
/>
<p className="text-xs text-muted-foreground">
URL片段
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
id="rule-active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label htmlFor="rule-active"></Label>
</div>
<div className="flex space-x-3">
<Button type="submit">
{editingRule ? '更新' : '创建'}
</Button>
<Button
type="button"
onClick={handleCancelEdit}
variant="outline"
>
</Button>
</div>
</form>
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>URL</TableHead>
<TableHead>URL</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.length > 0 ? (
rules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="font-medium">
{rule.name}
</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
rule.endpoint_id
? 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100'
: 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100'
}`}>
{getEndpointName(rule.endpoint_id)}
</span>
</TableCell>
<TableCell>
<code className="bg-muted px-2 py-1 rounded text-sm">
{rule.from_url}
</code>
</TableCell>
<TableCell>
<code className="bg-muted px-2 py-1 rounded text-sm">
{rule.to_url}
</code>
</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
rule.is_active
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}>
{rule.is_active ? '启用' : '禁用'}
</span>
</TableCell>
<TableCell>
{new Date(rule.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex space-x-1">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(rule)}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => toggleRuleStatus(rule)}
>
{rule.is_active ? '禁用' : '启用'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(rule.id)}
className="text-red-600 hover:text-red-700"
>
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
URL替换规则&quot;&quot;
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
web/components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

16
web/eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

149
web/lib/auth.ts Normal file
View File

@ -0,0 +1,149 @@
import Cookies from 'js-cookie'
const TOKEN_COOKIE_NAME = 'admin_token'
const REFRESH_TOKEN_COOKIE_NAME = 'admin_refresh_token'
const USER_INFO_COOKIE_NAME = 'admin_user'
// Cookie配置
const COOKIE_OPTIONS = {
expires: 7, // 7天过期
secure: process.env.NODE_ENV === 'production', // 生产环境使用HTTPS
sameSite: 'strict' as const,
path: '/'
}
export interface AuthUser {
id: string
name: string
email: string
}
// 保存认证信息
export function saveAuthInfo(token: string, user: AuthUser, refreshToken?: string) {
Cookies.set(TOKEN_COOKIE_NAME, token, COOKIE_OPTIONS)
Cookies.set(USER_INFO_COOKIE_NAME, JSON.stringify(user), COOKIE_OPTIONS)
if (refreshToken) {
Cookies.set(REFRESH_TOKEN_COOKIE_NAME, refreshToken, COOKIE_OPTIONS)
}
}
// 获取访问令牌
export function getAccessToken(): string | null {
return Cookies.get(TOKEN_COOKIE_NAME) || null
}
// 获取刷新令牌
export function getRefreshToken(): string | null {
return Cookies.get(REFRESH_TOKEN_COOKIE_NAME) || null
}
// 获取用户信息
export function getUserInfo(): AuthUser | null {
const userStr = Cookies.get(USER_INFO_COOKIE_NAME)
if (!userStr) return null
try {
return JSON.parse(userStr)
} catch {
return null
}
}
// 清除认证信息
export function clearAuthInfo() {
Cookies.remove(TOKEN_COOKIE_NAME, { path: '/' })
Cookies.remove(REFRESH_TOKEN_COOKIE_NAME, { path: '/' })
Cookies.remove(USER_INFO_COOKIE_NAME, { path: '/' })
}
// 检查是否已登录
export function isAuthenticated(): boolean {
return !!getAccessToken()
}
// 创建带认证的fetch请求
export async function authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> {
const token = getAccessToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(url, {
...options,
headers,
credentials: 'include', // 包含cookie
})
// 如果token过期尝试刷新
if (response.status === 401) {
const refreshed = await refreshAccessToken()
if (refreshed) {
// 重新发送请求
const newToken = getAccessToken()
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`
return fetch(url, {
...options,
headers,
credentials: 'include',
})
}
}
// 刷新失败,清除认证信息
clearAuthInfo()
}
return response
}
// 刷新访问令牌
async function refreshAccessToken(): Promise<boolean> {
const refreshToken = getRefreshToken()
if (!refreshToken) return false
try {
const response = await fetch('/api/admin/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: refreshToken }),
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
if (data.success && data.data.access_token) {
const user = getUserInfo()
if (user) {
saveAuthInfo(data.data.access_token, user, data.data.refresh_token)
return true
}
}
}
} catch (error) {
console.error('Failed to refresh token:', error)
}
return false
}
// OAuth状态管理
export function saveOAuthState(state: string) {
sessionStorage.setItem('oauth_state', state)
}
export function getOAuthState(): string | null {
return sessionStorage.getItem('oauth_state')
}
export function clearOAuthState() {
sessionStorage.removeItem('oauth_state')
}

61
web/lib/config.ts Normal file
View File

@ -0,0 +1,61 @@
// 应用配置管理
export interface AppConfig {
apiBaseUrl: string
isProduction: boolean
isDevelopment: boolean
}
// 获取API基础URL
export function getApiBaseUrl(): string {
// 在服务端渲染时
if (typeof window === 'undefined') {
// 1. 优先使用环境变量
if (process.env.BASE_URL) {
return process.env.BASE_URL
}
// 2. 生产环境使用相对路径(假设前后端部署在同一域名)
if (process.env.NODE_ENV === 'production') {
return ''
}
// 3. 开发环境默认值
return 'http://localhost:5003'
}
// 在客户端,使用相对路径(自动使用当前域名和端口)
return ''
}
// 获取完整的API URL
export function getApiUrl(path: string): string {
const baseUrl = getApiBaseUrl()
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${baseUrl}${cleanPath}`
}
// 获取应用配置
export function getAppConfig(): AppConfig {
return {
apiBaseUrl: getApiBaseUrl(),
isProduction: process.env.NODE_ENV === 'production',
isDevelopment: process.env.NODE_ENV === 'development',
}
}
// 创建带有默认配置的fetch函数
export async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const url = getApiUrl(path)
const defaultOptions: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
cache: 'no-store', // 默认不缓存
...options,
}
return fetch(url, defaultOptions)
}

6
web/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

27
web/next.config.ts Normal file
View File

@ -0,0 +1,27 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'export',
trailingSlash: true,
images: {
unoptimized: true
},
// 在生产环境中不需要代理,因为前后端在同一个服务器上
...(process.env.NODE_ENV === 'development' && {
rewrites: async () => {
return [
{
source: "/api/admin/:path*",
destination: "http://localhost:5003/api/admin/:path*",
},
{
source: "/api/:path*",
destination: "http://localhost:5003/api/:path*",
}
];
}
})
};
export default nextConfig;

8711
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
web/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@types/js-cookie": "^3.0.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"lucide-react": "^0.515.0",
"next": "15.3.3",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.4",
"typescript": "^5"
}
}

5
web/postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
web/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
web/public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
web/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
web/public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
web/public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

27
web/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

48
web/types/admin.ts Normal file
View File

@ -0,0 +1,48 @@
export interface User {
id: string
name: string
email: string
}
export interface APIEndpoint {
id: number
name: string
url: string
description: string
is_active: boolean
show_on_homepage: boolean
sort_order: number
created_at: string
updated_at: string
data_sources?: DataSource[]
}
export interface DataSource {
id: number
endpoint_id: number
name: string
type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint'
config: string
cache_duration: number
is_active: boolean
last_sync?: string
created_at: string
updated_at: string
}
export interface URLReplaceRule {
id: number
endpoint_id?: number
name: string
from_url: string
to_url: string
is_active: boolean
created_at: string
updated_at: string
endpoint?: APIEndpoint
}
export interface OAuthConfig {
client_id: string
base_url: string
}