mirror of
https://github.com/woodchen-ink/random-api-go.git
synced 2025-07-18 05:42:01 +08:00
删除不再使用的配置文件和脚本,更新Dockerfile以支持前后端构建,重构配置加载逻辑,添加OAuth2.0支持,优化API处理和路由设置。
This commit is contained in:
parent
efd55448e1
commit
f2456e116b
47
.dockerignore
Normal file
47
.dockerignore
Normal 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
BIN
.env.example
Normal file
Binary file not shown.
57
.github/workflows/docker.yml
vendored
57
.github/workflows/docker.yml
vendored
@ -22,19 +22,18 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.23'
|
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
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
@ -51,23 +50,12 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.run
|
file: Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: woodchen/${{ env.IMAGE_NAME }}:latest
|
tags: woodchen/${{ env.IMAGE_NAME }}:latest
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
cache-from: type=gha
|
||||||
- name: Create artifact
|
cache-to: type=gha,mode=max
|
||||||
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'
|
|
||||||
|
|
||||||
- name: Execute deployment commands
|
- name: Execute deployment commands
|
||||||
uses: appleboy/ssh-action@master
|
uses: appleboy/ssh-action@master
|
||||||
@ -76,28 +64,15 @@ jobs:
|
|||||||
username: root
|
username: root
|
||||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||||
script: |
|
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 pull woodchen/random-api-go:latest
|
||||||
|
|
||||||
# 停止并删除容器
|
# 停止并删除旧容器
|
||||||
docker stop random-api-go || true
|
docker stop random-api-go || true
|
||||||
docker rm 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 compose -f /opt/1panel/docker/compose/random-api-go/docker-compose.yml up -d
|
||||||
|
|
||||||
|
# 清理未使用的镜像
|
||||||
|
docker image prune -f
|
||||||
|
63
.github/workflows/generate-csv.yml
vendored
63
.github/workflows/generate-csv.yml
vendored
@ -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
6
.gitignore
vendored
Normal 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
141
DOCKER_DEPLOYMENT.md
Normal 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错误会根据路径类型返回相应的错误页面
|
55
Dockerfile
55
Dockerfile
@ -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
|
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
|
RUN go mod download
|
||||||
|
|
||||||
# 复制源代码
|
# 复制后端源代码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 构建应用
|
# 构建后端应用
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o random-api .
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o random-api .
|
||||||
|
|
||||||
# 运行阶段
|
# 运行阶段
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates tzdata
|
# 安装必要的工具
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata tini
|
||||||
|
|
||||||
WORKDIR /root/
|
WORKDIR /root/
|
||||||
|
|
||||||
COPY --from=builder /app/random-api .
|
# 从后端构建阶段复制二进制文件
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=backend-builder /app/random-api .
|
||||||
# 复制 public 目录到一个临时位置
|
|
||||||
COPY --from=builder /app/public /tmp/public
|
# 从前端构建阶段复制静态文件
|
||||||
|
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
|
EXPOSE 5003
|
||||||
|
|
||||||
# 使用 tini 作为初始化系统
|
# 使用 tini 作为初始化系统
|
||||||
RUN apk add --no-cache tini
|
|
||||||
ENTRYPOINT ["/sbin/tini", "--"]
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
|
||||||
# 创建一个启动脚本
|
# 启动应用
|
||||||
COPY start.sh /start.sh
|
CMD ["./random-api"]
|
||||||
RUN chmod +x /start.sh
|
|
||||||
|
|
||||||
CMD ["/start.sh"]
|
|
||||||
|
@ -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"]
|
|
17
config.json
17
config.json
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
203
config/config.go
203
config/config.go
@ -1,45 +1,34 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"bufio"
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
EnvBaseURL = "BASE_URL"
|
|
||||||
DefaultPort = ":5003"
|
|
||||||
RequestTimeout = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server struct {
|
Server struct {
|
||||||
Port string `json:"port"`
|
Port string
|
||||||
ReadTimeout time.Duration `json:"read_timeout"`
|
ReadTimeout time.Duration
|
||||||
WriteTimeout time.Duration `json:"write_timeout"`
|
WriteTimeout time.Duration
|
||||||
MaxHeaderBytes int `json:"max_header_bytes"`
|
MaxHeaderBytes int
|
||||||
} `json:"server"`
|
}
|
||||||
|
|
||||||
Storage struct {
|
Storage struct {
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string
|
||||||
StatsFile string `json:"stats_file"`
|
}
|
||||||
LogFile string `json:"log_file"`
|
|
||||||
} `json:"storage"`
|
|
||||||
|
|
||||||
API struct {
|
OAuth struct {
|
||||||
BaseURL string `json:"base_url"`
|
ClientID string
|
||||||
RequestTimeout time.Duration `json:"request_timeout"`
|
ClientSecret string
|
||||||
} `json:"api"`
|
}
|
||||||
|
|
||||||
Performance struct {
|
App struct {
|
||||||
MaxConcurrentRequests int `json:"max_concurrent_requests"`
|
BaseURL string
|
||||||
RequestTimeout time.Duration `json:"request_timeout"`
|
}
|
||||||
CacheTTL time.Duration `json:"cache_ttl"`
|
|
||||||
EnableCompression bool `json:"enable_compression"`
|
|
||||||
} `json:"performance"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -47,87 +36,67 @@ var (
|
|||||||
RNG *rand.Rand
|
RNG *rand.Rand
|
||||||
)
|
)
|
||||||
|
|
||||||
func Load(configFile string) error {
|
// loadEnvFile 加载.env文件
|
||||||
// 尝试创建配置目录
|
func loadEnvFile() error {
|
||||||
configDir := filepath.Dir(configFile)
|
file, err := os.Open(".env")
|
||||||
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, "", " ")
|
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err // .env文件不存在,这是正常的
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
decoder := json.NewDecoder(file)
|
scanner := bufio.NewScanner(file)
|
||||||
if err := decoder.Decode(&cfg); err != nil {
|
for scanner.Scan() {
|
||||||
return err
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
// 跳过空行和注释
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析键值对
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果环境变量设置了 BASE_URL,则覆盖配置文件中的设置
|
return scanner.Err()
|
||||||
if envBaseURL := os.Getenv(EnvBaseURL); envBaseURL != "" {
|
}
|
||||||
cfg.API.BaseURL = envBaseURL
|
|
||||||
}
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
@ -139,3 +108,31 @@ func Get() *Config {
|
|||||||
func InitRNG(r *rand.Rand) {
|
func InitRNG(r *rand.Rand) {
|
||||||
RNG = r
|
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
|
||||||
|
}
|
||||||
|
@ -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
133
database/database.go
Normal 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
|
||||||
|
}
|
@ -8,5 +8,7 @@ services:
|
|||||||
- ./data:/root/data
|
- ./data:/root/data
|
||||||
environment:
|
environment:
|
||||||
- TZ=Asia/Shanghai
|
- 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
|
restart: unless-stopped
|
||||||
|
22
go.mod
22
go.mod
@ -4,4 +4,24 @@ go 1.23.0
|
|||||||
|
|
||||||
toolchain go1.23.1
|
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
33
go.sum
@ -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 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
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
1127
handlers/admin_handler.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -20,7 +20,8 @@ type Router interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
Stats *stats.StatsManager
|
Stats *stats.StatsManager
|
||||||
|
endpointService *services.EndpointService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
|
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)
|
realIP := utils.GetRealIP(r)
|
||||||
|
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
pathSegments := strings.Split(path, "/")
|
|
||||||
|
|
||||||
if len(pathSegments) < 2 {
|
// 初始化端点服务
|
||||||
monitoring.LogRequest(monitoring.RequestLog{
|
if h.endpointService == nil {
|
||||||
Time: time.Now().UnixMilli(),
|
h.endpointService = services.GetEndpointService()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := pathSegments[0]
|
// 使用新的端点服务
|
||||||
suffix := pathSegments[1]
|
randomURL, err := h.endpointService.GetRandomURL(path)
|
||||||
|
|
||||||
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)
|
|
||||||
if err != nil {
|
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{
|
monitoring.LogRequest(monitoring.RequestLog{
|
||||||
Time: time.Now().UnixMilli(),
|
Time: time.Now().UnixMilli(),
|
||||||
Path: r.URL.Path,
|
Path: r.URL.Path,
|
||||||
@ -102,13 +58,12 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
IP: realIP,
|
IP: realIP,
|
||||||
Referer: r.Referer(),
|
Referer: r.Referer(),
|
||||||
})
|
})
|
||||||
resultChan <- result{err: fmt.Errorf("no content available")}
|
resultChan <- result{err: fmt.Errorf("endpoint not found: %v", err)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
randomURL := selector.GetRandomURL()
|
// 成功获取到URL
|
||||||
endpoint := fmt.Sprintf("%s/%s", prefix, suffix)
|
h.Stats.IncrementCalls(path)
|
||||||
h.Stats.IncrementCalls(endpoint)
|
|
||||||
|
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
monitoring.LogRequest(monitoring.RequestLog{
|
monitoring.LogRequest(monitoring.RequestLog{
|
||||||
@ -137,7 +92,7 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
select {
|
select {
|
||||||
case res := <-resultChan:
|
case res := <-resultChan:
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
http.Error(w, res.err.Error(), http.StatusInternalServerError)
|
http.Error(w, res.err.Error(), http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, res.url, http.StatusFound)
|
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) {
|
func (h *Handlers) HandleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
stats := h.Stats.GetStats()
|
stats := h.Stats.GetStatsForAPI()
|
||||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
|
||||||
|
// 包装数据格式以匹配前端期望
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"Stats": stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
http.Error(w, "Error encoding stats", http.StatusInternalServerError)
|
http.Error(w, "Error encoding stats", http.StatusInternalServerError)
|
||||||
log.Printf("Error encoding stats: %v", err)
|
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) {
|
func (h *Handlers) HandleURLStats(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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 {
|
response := make(map[string]struct {
|
||||||
TotalURLs int `json:"total_urls"`
|
TotalURLs int `json:"total_urls"`
|
||||||
})
|
})
|
||||||
|
|
||||||
for endpoint, stat := range stats {
|
for _, endpoint := range endpoints {
|
||||||
response[endpoint] = struct {
|
if endpoint.IsActive {
|
||||||
TotalURLs int `json:"total_urls"`
|
totalURLs := 0
|
||||||
}{
|
for _, ds := range endpoint.DataSources {
|
||||||
TotalURLs: stat.TotalURLs,
|
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: totalURLs,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,12 +179,11 @@ func (h *Handlers) HandleMetrics(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) Setup(r *router.Router) {
|
func (h *Handlers) Setup(r *router.Router) {
|
||||||
// 动态路由处理
|
// 通用路由处理 - 匹配所有路径
|
||||||
r.HandleFunc("/pic/", h.HandleAPIRequest)
|
r.HandleFunc("/", h.HandleAPIRequest)
|
||||||
r.HandleFunc("/video/", h.HandleAPIRequest)
|
|
||||||
|
|
||||||
// API 统计和监控
|
// API 统计和监控
|
||||||
r.HandleFunc("/stats", h.HandleStats)
|
r.HandleFunc("/api/stats", h.HandleStats)
|
||||||
r.HandleFunc("/urlstats", h.HandleURLStats)
|
r.HandleFunc("/api/urlstats", h.HandleURLStats)
|
||||||
r.HandleFunc("/metrics", h.HandleMetrics)
|
r.HandleFunc("/api/metrics", h.HandleMetrics)
|
||||||
}
|
}
|
||||||
|
95
handlers/static_handler.go
Normal file
95
handlers/static_handler.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
@ -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"]
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
55
main.go
55
main.go
@ -8,7 +8,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"random-api-go/config"
|
"random-api-go/config"
|
||||||
|
"random-api-go/database"
|
||||||
"random-api-go/handlers"
|
"random-api-go/handlers"
|
||||||
"random-api-go/logging"
|
"random-api-go/logging"
|
||||||
"random-api-go/router"
|
"random-api-go/router"
|
||||||
@ -19,9 +21,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
router *router.Router
|
router *router.Router
|
||||||
Stats *stats.StatsManager
|
Stats *stats.StatsManager
|
||||||
|
adminHandler *handlers.AdminHandler
|
||||||
|
staticHandler *handlers.StaticHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
@ -32,7 +36,7 @@ func NewApp() *App {
|
|||||||
|
|
||||||
func (a *App) Initialize() error {
|
func (a *App) Initialize() error {
|
||||||
// 先加载配置
|
// 先加载配置
|
||||||
if err := config.Load("/root/data/config.json"); err != nil {
|
if err := config.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,15 +49,35 @@ func (a *App) Initialize() error {
|
|||||||
return fmt.Errorf("failed to create data directory: %w", err)
|
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()
|
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 {
|
services.GetEndpointService()
|
||||||
return err
|
|
||||||
|
// 创建管理后台处理器
|
||||||
|
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
|
// 创建 handlers
|
||||||
@ -63,6 +87,12 @@ func (a *App) Initialize() error {
|
|||||||
|
|
||||||
// 设置路由
|
// 设置路由
|
||||||
a.router.Setup(handlers)
|
a.router.Setup(handlers)
|
||||||
|
a.router.SetupAdminRoutes(a.adminHandler)
|
||||||
|
|
||||||
|
// 设置静态文件路由(如果静态文件处理器存在)
|
||||||
|
if a.staticHandler != nil {
|
||||||
|
a.router.SetupStaticRoutes(a.staticHandler)
|
||||||
|
}
|
||||||
|
|
||||||
// 创建 HTTP 服务器
|
// 创建 HTTP 服务器
|
||||||
cfg := config.Get().Server
|
cfg := config.Get().Server
|
||||||
@ -81,6 +111,10 @@ func (a *App) Run() error {
|
|||||||
// 启动服务器
|
// 启动服务器
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Server starting on %s...\n", a.server.Addr)
|
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 {
|
if err := a.server.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
log.Fatalf("Server failed: %v", err)
|
log.Fatalf("Server failed: %v", err)
|
||||||
}
|
}
|
||||||
@ -102,6 +136,11 @@ func (a *App) gracefulShutdown() error {
|
|||||||
|
|
||||||
a.Stats.Shutdown()
|
a.Stats.Shutdown()
|
||||||
|
|
||||||
|
// 关闭数据库连接
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
log.Printf("Error closing database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := a.server.Shutdown(ctx); err != nil {
|
if err := a.server.Shutdown(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
120
models/api_endpoint.go
Normal file
120
models/api_endpoint.go
Normal 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列表
|
||||||
|
}
|
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"pic": {
|
|
||||||
"all": "随机图片",
|
|
||||||
"fj": "随机风景",
|
|
||||||
"loading": "随机加载图"
|
|
||||||
},
|
|
||||||
"video": {
|
|
||||||
"all": "随机视频"
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定期更新监控数据
|
|
||||||
setInterval(loadMetrics, 5000);
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
loadMetrics();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
@ -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
119
readme.md
@ -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管理界面
|
||||||
|
|
||||||

|
## 环境变量配置
|
||||||
|
|
||||||
|
复制 `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
|
||||||
|
190
router/router.go
190
router/router.go
@ -2,17 +2,56 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"random-api-go/middleware"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
staticHandler StaticHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler interface {
|
type Handler interface {
|
||||||
Setup(r *Router)
|
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 {
|
func New() *Router {
|
||||||
return &Router{
|
return &Router{
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
@ -20,21 +59,154 @@ func New() *Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) Setup(h Handler) {
|
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路由
|
// 设置API路由
|
||||||
h.Setup(r)
|
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)) {
|
func (r *Router) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
|
||||||
r.mux.HandleFunc(pattern, handler)
|
r.mux.HandleFunc(pattern, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
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)
|
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
79
services/README.md
Normal 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
176
services/api_fetcher.go
Normal 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
176
services/cache_manager.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
179
services/data_source_fetcher.go
Normal file
179
services/data_source_fetcher.go
Normal 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
|
||||||
|
}
|
362
services/endpoint_service.go
Normal file
362
services/endpoint_service.go
Normal 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
126
services/lankong_fetcher.go
Normal 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
275
services/preloader.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
start.sh
13
start.sh
@ -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
|
|
@ -13,6 +13,13 @@ type EndpointStats struct {
|
|||||||
LastResetDate string `json:"last_reset_date"`
|
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 {
|
type StatsManager struct {
|
||||||
Stats map[string]*EndpointStats `json:"stats"`
|
Stats map[string]*EndpointStats `json:"stats"`
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@ -171,6 +178,22 @@ func (sm *StatsManager) GetStats() map[string]*EndpointStats {
|
|||||||
return statsCopy
|
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 {
|
func (sm *StatsManager) LastSaveTime() time.Time {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
|
41
web/.gitignore
vendored
Normal file
41
web/.gitignore
vendored
Normal 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
36
web/README.md
Normal 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"
|
||||||
|
}
|
||||||
|
```
|
48
web/app/admin/home/page.tsx
Normal file
48
web/app/admin/home/page.tsx
Normal 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
127
web/app/admin/layout.tsx
Normal 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
54
web/app/admin/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
106
web/app/admin/rules/page.tsx
Normal file
106
web/app/admin/rules/page.tsx
Normal 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
BIN
web/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
122
web/app/globals.css
Normal file
122
web/app/globals.css
Normal 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
24
web/app/layout.tsx
Normal 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
457
web/app/page.tsx
Normal 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
21
web/components.json
Normal 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"
|
||||||
|
}
|
564
web/components/admin/DataSourceConfigForm.tsx
Normal file
564
web/components/admin/DataSourceConfigForm.tsx
Normal 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
|
||||||
|
}
|
387
web/components/admin/DataSourceManagement.tsx
Normal file
387
web/components/admin/DataSourceManagement.tsx
Normal 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">
|
||||||
|
设置为0表示不缓存,建议设置3600秒(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">
|
||||||
|
暂无数据源,点击"添加数据源"开始配置
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
323
web/components/admin/EndpointsTab.tsx
Normal file
323
web/components/admin/EndpointsTab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
95
web/components/admin/HomeConfigTab.tsx
Normal file
95
web/components/admin/HomeConfigTab.tsx
Normal 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">
|
||||||
|
您有未保存的更改,请点击"保存配置"按钮保存修改。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
169
web/components/admin/LoginPage.tsx
Normal file
169
web/components/admin/LoginPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
291
web/components/admin/URLRulesTab.tsx
Normal file
291
web/components/admin/URLRulesTab.tsx
Normal 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替换规则,点击"创建规则"开始配置
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
59
web/components/ui/button.tsx
Normal file
59
web/components/ui/button.tsx
Normal 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 }
|
92
web/components/ui/card.tsx
Normal file
92
web/components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
32
web/components/ui/checkbox.tsx
Normal file
32
web/components/ui/checkbox.tsx
Normal 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 }
|
143
web/components/ui/dialog.tsx
Normal file
143
web/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
21
web/components/ui/input.tsx
Normal file
21
web/components/ui/input.tsx
Normal 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 }
|
24
web/components/ui/label.tsx
Normal file
24
web/components/ui/label.tsx
Normal 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 }
|
185
web/components/ui/select.tsx
Normal file
185
web/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
25
web/components/ui/sonner.tsx
Normal file
25
web/components/ui/sonner.tsx
Normal 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 }
|
31
web/components/ui/switch.tsx
Normal file
31
web/components/ui/switch.tsx
Normal 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
116
web/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
66
web/components/ui/tabs.tsx
Normal file
66
web/components/ui/tabs.tsx
Normal 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 }
|
18
web/components/ui/textarea.tsx
Normal file
18
web/components/ui/textarea.tsx
Normal 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 }
|
61
web/components/ui/tooltip.tsx
Normal file
61
web/components/ui/tooltip.tsx
Normal 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
16
web/eslint.config.mjs
Normal 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
149
web/lib/auth.ts
Normal 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
61
web/lib/config.ts
Normal 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
6
web/lib/utils.ts
Normal 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
27
web/next.config.ts
Normal 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
8711
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
web/package.json
Normal file
49
web/package.json
Normal 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
5
web/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
1
web/public/file.svg
Normal file
1
web/public/file.svg
Normal 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
1
web/public/globe.svg
Normal 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
1
web/public/next.svg
Normal 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
1
web/public/vercel.svg
Normal 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
1
web/public/window.svg
Normal 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
27
web/tsconfig.json
Normal 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
48
web/types/admin.ts
Normal 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
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user