mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-03-08 07:29:32 +08:00
Compare commits
13 Commits
v0.0.2
...
b31711d86d
| Author | SHA1 | Date | |
|---|---|---|---|
| b31711d86d | |||
| 62ac723c95 | |||
| 5334ee9d41 | |||
| c8a7ea5490 | |||
| 61de3f44dc | |||
| 69abe80264 | |||
| 34848e7b91 | |||
| 9ec9a2ba91 | |||
| 3c1f29e4ef | |||
| ae82557545 | |||
| fecbd014b3 | |||
| 907e158f44 | |||
| f7fc3fa506 |
@@ -7,7 +7,7 @@ output
|
||||
scripts
|
||||
# docs
|
||||
*.md
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
webapp/.vscode
|
||||
|
||||
@@ -101,3 +101,5 @@ BingPaper 支持通过配置文件(YAML)和环境变量进行配置。
|
||||
- `BINGPAPER_STORAGE_TYPE=s3`
|
||||
- `BINGPAPER_STORAGE_S3_BUCKET=my-images`
|
||||
- `BINGPAPER_ADMIN_PASSWORD_BCRYPT="$2a$10$..."`
|
||||
- `HOST_PORT=8080` (仅限 Docker Compose 部署,控制宿主机映射到外部的端口)
|
||||
- `BINGPAPER_SERVER_PORT=8080` (控制应用监听端口及容器内部端口)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Stage 1: Build Frontend
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS node-builder
|
||||
ARG NPM_REGISTRY
|
||||
WORKDIR /webapp
|
||||
# 复制 package.json 和 lock 文件以利用 layer 缓存
|
||||
COPY webapp/package*.json ./
|
||||
# 如果设置了 NPM_REGISTRY,则配置 npm 镜像
|
||||
RUN if [ -n "$NPM_REGISTRY" ]; then npm config set registry $NPM_REGISTRY; fi
|
||||
# 使用 npm ci 以获得更快且可重现的构建(如果存在 package-lock.json)
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
# 复制其余源码并构建
|
||||
@@ -11,6 +14,8 @@ RUN npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine AS builder
|
||||
ARG GOPROXY
|
||||
ENV GOPROXY=$GOPROXY
|
||||
# 安装 Git 以支持某些 Go 模块依赖
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
|
||||
@@ -19,7 +19,7 @@ api:
|
||||
|
||||
cron:
|
||||
enabled: true
|
||||
daily_spec: "0 10 * * *"
|
||||
daily_spec: "20 8-23/4 * * *"
|
||||
|
||||
retention:
|
||||
days: 0
|
||||
|
||||
36
docker-compose.yaml
Normal file
36
docker-compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
bingpaper:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- GOPROXY=${GOPROXY:-https://proxy.golang.org,direct}
|
||||
- NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
container_name: bingpaper
|
||||
restart: always
|
||||
ports:
|
||||
- "${HOST_PORT:-8080}:${BINGPAPER_SERVER_PORT:-8080}"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- BINGPAPER_SERVER_PORT=${BINGPAPER_SERVER_PORT:-8080}
|
||||
- BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info}
|
||||
- BINGPAPER_API_MODE=${BINGPAPER_API_MODE:-local}
|
||||
- BINGPAPER_CRON_ENABLED=${BINGPAPER_CRON_ENABLED:-true}
|
||||
- BINGPAPER_DB_TYPE=${BINGPAPER_DB_TYPE:-sqlite}
|
||||
- BINGPAPER_DB_DSN=${BINGPAPER_DB_DSN:-data/bing_paper.db}
|
||||
- BINGPAPER_STORAGE_TYPE=${BINGPAPER_STORAGE_TYPE:-local}
|
||||
- BINGPAPER_STORAGE_LOCAL_ROOT=${BINGPAPER_STORAGE_LOCAL_ROOT:-data/picture}
|
||||
- BINGPAPER_RETENTION_DAYS=${BINGPAPER_RETENTION_DAYS:-30}
|
||||
# S3 配置 (可选)
|
||||
# - BINGPAPER_STORAGE_S3_ENDPOINT=${BINGPAPER_STORAGE_S3_ENDPOINT:-}
|
||||
# - BINGPAPER_STORAGE_S3_REGION=${BINGPAPER_STORAGE_S3_REGION:-}
|
||||
# - BINGPAPER_STORAGE_S3_BUCKET=${BINGPAPER_STORAGE_S3_BUCKET:-}
|
||||
# - BINGPAPER_STORAGE_S3_ACCESS_KEY=${BINGPAPER_STORAGE_S3_ACCESS_KEY:-}
|
||||
# - BINGPAPER_STORAGE_S3_SECRET_KEY=${BINGPAPER_STORAGE_S3_SECRET_KEY:-}
|
||||
# - BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX=${BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX:-}
|
||||
# WebDAV 配置 (可选)
|
||||
# - BINGPAPER_STORAGE_WEBDAV_URL=${BINGPAPER_STORAGE_WEBDAV_URL:-}
|
||||
# - BINGPAPER_STORAGE_WEBDAV_USERNAME=${BINGPAPER_STORAGE_WEBDAV_USERNAME:-}
|
||||
# - BINGPAPER_STORAGE_WEBDAV_PASSWORD=${BINGPAPER_STORAGE_WEBDAV_PASSWORD:-}
|
||||
# - BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX=${BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX:-}
|
||||
@@ -1,31 +0,0 @@
|
||||
services:
|
||||
bingpaper:
|
||||
build: .
|
||||
container_name: bingpaper
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- BINGPAPER_SERVER_PORT=8080
|
||||
- BINGPAPER_LOG_LEVEL=info
|
||||
- BINGPAPER_API_MODE=local
|
||||
- BINGPAPER_CRON_ENABLED=true
|
||||
- BINGPAPER_DB_TYPE=sqlite
|
||||
- BINGPAPER_DB_DSN=data/bing_paper.db
|
||||
- BINGPAPER_STORAGE_TYPE=local
|
||||
- BINGPAPER_STORAGE_LOCAL_ROOT=data/picture
|
||||
- BINGPAPER_RETENTION_DAYS=30
|
||||
# S3 配置 (可选)
|
||||
# - BINGPAPER_STORAGE_S3_ENDPOINT=
|
||||
# - BINGPAPER_STORAGE_S3_REGION=
|
||||
# - BINGPAPER_STORAGE_S3_BUCKET=
|
||||
# - BINGPAPER_STORAGE_S3_ACCESS_KEY=
|
||||
# - BINGPAPER_STORAGE_S3_SECRET_KEY=
|
||||
# - BINGPAPER_STORAGE_S3_PUBLIC_URL_PREFIX=
|
||||
# WebDAV 配置 (可选)
|
||||
# - BINGPAPER_STORAGE_WEBDAV_URL=
|
||||
# - BINGPAPER_STORAGE_WEBDAV_USERNAME=
|
||||
# - BINGPAPER_STORAGE_WEBDAV_PASSWORD=
|
||||
# - BINGPAPER_STORAGE_WEBDAV_PUBLIC_URL_PREFIX=
|
||||
@@ -36,6 +36,20 @@ func Init(webFS embed.FS, configPath string) *gin.Engine {
|
||||
// 2. 初始化日志
|
||||
util.InitLogger(cfg.Log)
|
||||
|
||||
// 以 debug 级别输出配置加载详情和环境变量覆盖情况
|
||||
util.Logger.Debug("Configuration loading details",
|
||||
zap.String("config_file", config.GetRawViper().ConfigFileUsed()),
|
||||
)
|
||||
envOverrides := config.GetEnvOverrides()
|
||||
if len(envOverrides) > 0 {
|
||||
for _, override := range envOverrides {
|
||||
util.Logger.Debug("Environment variable override applied", zap.String("detail", override))
|
||||
}
|
||||
} else {
|
||||
util.Logger.Debug("No environment variable overrides detected")
|
||||
}
|
||||
util.Logger.Debug("Full effective configuration:\n" + config.GetFormattedSettings())
|
||||
|
||||
// 输出配置信息
|
||||
util.Logger.Info("Application configuration loaded")
|
||||
util.Logger.Info("├─ Config file", zap.String("path", config.GetRawViper().ConfigFileUsed()))
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -156,7 +157,7 @@ func Init(configPath string) error {
|
||||
v.SetDefault("log.db_log_level", "info")
|
||||
v.SetDefault("api.mode", "local")
|
||||
v.SetDefault("cron.enabled", true)
|
||||
v.SetDefault("cron.daily_spec", "0 10 * * *")
|
||||
v.SetDefault("cron.daily_spec", "20 8-23/4 * * *")
|
||||
v.SetDefault("retention.days", 0)
|
||||
v.SetDefault("db.type", "sqlite")
|
||||
v.SetDefault("db.dsn", "data/bing_paper.db")
|
||||
@@ -254,6 +255,38 @@ func GetRawViper() *viper.Viper {
|
||||
return v
|
||||
}
|
||||
|
||||
// GetAllSettings 返回所有生效配置项
|
||||
func GetAllSettings() map[string]interface{} {
|
||||
return v.AllSettings()
|
||||
}
|
||||
|
||||
// GetFormattedSettings 以 key: value 形式返回所有配置项的字符串
|
||||
func GetFormattedSettings() string {
|
||||
keys := v.AllKeys()
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
for _, k := range keys {
|
||||
sb.WriteString(fmt.Sprintf("%s: %v\n", k, v.Get(k)))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetEnvOverrides 返回环境变量覆盖详情(已排序)
|
||||
func GetEnvOverrides() []string {
|
||||
var overrides []string
|
||||
keys := v.AllKeys()
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
// 根据 viper 的配置生成对应的环境变量名
|
||||
// Prefix: BINGPAPER, KeyReplacer: . -> _
|
||||
envKey := strings.ToUpper(fmt.Sprintf("BINGPAPER_%s", strings.ReplaceAll(key, ".", "_")))
|
||||
if val, ok := os.LookupEnv(envKey); ok {
|
||||
overrides = append(overrides, fmt.Sprintf("%s: %s=%s", key, envKey, val))
|
||||
}
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
func GetTokenTTL() time.Duration {
|
||||
ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,3 +22,46 @@ func TestDefaultConfig(t *testing.T) {
|
||||
t.Errorf("Expected DB type sqlite, got %s", cfg.DB.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugFunctions(t *testing.T) {
|
||||
// 设置一个环境变量
|
||||
os.Setenv("BINGPAPER_SERVER_PORT", "9999")
|
||||
defer os.Unsetenv("BINGPAPER_SERVER_PORT")
|
||||
|
||||
err := Init("")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init config: %v", err)
|
||||
}
|
||||
|
||||
settings := GetAllSettings()
|
||||
serverCfg, ok := settings["server"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected server config map, got %v", settings["server"])
|
||||
}
|
||||
|
||||
// Viper numbers in AllSettings are often int
|
||||
portValue := serverCfg["port"]
|
||||
// 允许不同的数字类型,因为 viper 内部实现可能变化
|
||||
portStr := fmt.Sprintf("%v", portValue)
|
||||
if portStr != "9999" {
|
||||
t.Errorf("Expected port 9999 in settings, got %v (%T)", portValue, portValue)
|
||||
}
|
||||
|
||||
overrides := GetEnvOverrides()
|
||||
found := false
|
||||
for _, o := range overrides {
|
||||
if strings.Contains(o, "server.port") && strings.Contains(o, "9999") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected server.port override in %v", overrides)
|
||||
}
|
||||
|
||||
// 验证格式化输出
|
||||
formatted := GetFormattedSettings()
|
||||
if !strings.Contains(formatted, "server.port: 9999") {
|
||||
t.Errorf("Expected formatted settings to contain server.port: 9999, got %s", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func GetToday(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
handleImageResponse(c, img, 7200) // 2小时
|
||||
}
|
||||
|
||||
// GetTodayMeta 获取今日图片元数据
|
||||
@@ -68,6 +68,7 @@ func GetTodayMeta(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=7200") // 2小时
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ func GetRandom(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
handleImageResponse(c, img, 0) // 禁用缓存
|
||||
}
|
||||
|
||||
// GetRandomMeta 获取随机图片元数据
|
||||
@@ -102,6 +103,7 @@ func GetRandomMeta(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
@@ -122,7 +124,7 @@ func GetByDate(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img)
|
||||
handleImageResponse(c, img, 604800) // 7天
|
||||
}
|
||||
|
||||
// GetByDateMeta 获取指定日期图片元数据
|
||||
@@ -140,6 +142,7 @@ func GetByDateMeta(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
||||
c.JSON(http.StatusOK, formatMeta(img))
|
||||
}
|
||||
|
||||
@@ -203,7 +206,7 @@ func ListImages(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func handleImageResponse(c *gin.Context, img *model.Image) {
|
||||
func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
|
||||
variant := c.DefaultQuery("variant", "UHD")
|
||||
format := c.DefaultQuery("format", "jpg")
|
||||
|
||||
@@ -228,22 +231,30 @@ func handleImageResponse(c *gin.Context, img *model.Image) {
|
||||
mode := config.GetConfig().API.Mode
|
||||
if mode == "redirect" {
|
||||
if selected.PublicURL != "" {
|
||||
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
||||
if maxAge > 0 {
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
c.Redirect(http.StatusFound, selected.PublicURL)
|
||||
} else if img.URLBase != "" {
|
||||
// 兜底重定向到原始 Bing
|
||||
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant)
|
||||
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
||||
if maxAge > 0 {
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
c.Redirect(http.StatusFound, bingURL)
|
||||
} else {
|
||||
serveLocal(c, selected.StorageKey, img.Date)
|
||||
serveLocal(c, selected.StorageKey, img.Date, maxAge)
|
||||
}
|
||||
} else {
|
||||
serveLocal(c, selected.StorageKey, img.Date)
|
||||
serveLocal(c, selected.StorageKey, img.Date, maxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func serveLocal(c *gin.Context, key string, etag string) {
|
||||
func serveLocal(c *gin.Context, key string, etag string, maxAge int) {
|
||||
if etag != "" {
|
||||
c.Header("ETag", fmt.Sprintf("\"%s\"", etag))
|
||||
if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) {
|
||||
@@ -263,7 +274,12 @@ func serveLocal(c *gin.Context, key string, etag string) {
|
||||
if contentType != "" {
|
||||
c.Header("Content-Type", contentType)
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=604800") // 7天
|
||||
|
||||
if maxAge > 0 {
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
io.Copy(c.Writer, reader)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil)
|
||||
|
||||
handleImageResponse(c, img)
|
||||
handleImageResponse(c, img, 0)
|
||||
|
||||
assert.Equal(t, http.StatusFound, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Location"), "bing.com")
|
||||
|
||||
@@ -80,7 +80,7 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 如果请求的是 API 或 Swagger,则不处理静态资源 (让其返回 404)
|
||||
if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/swagger") {
|
||||
if strings.HasPrefix(path, "/api/v1") || strings.HasPrefix(path, "/swagger") {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ set PLATFORMS=linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 d
|
||||
for %%p in (%PLATFORMS%) do (
|
||||
for /f "tokens=1,2 delims=/" %%a in ("%%p") do (
|
||||
set OUTPUT_NAME=%APP_NAME%-%%a-%%b
|
||||
set BINARY_NAME=!OUTPUT_NAME!
|
||||
if "%%a"=="windows" set BINARY_NAME=!OUTPUT_NAME!.exe
|
||||
set BINARY_NAME=%APP_NAME%
|
||||
if "%%a"=="windows" set BINARY_NAME=%APP_NAME%.exe
|
||||
|
||||
echo 正在编译 %%a/%%b...
|
||||
|
||||
@@ -47,10 +47,10 @@ for %%p in (%PLATFORMS%) do (
|
||||
copy /y config.example.yaml !PACKAGE_DIR!\ >nul
|
||||
copy /y README.md !PACKAGE_DIR!\ >nul
|
||||
|
||||
pushd %OUTPUT_DIR%
|
||||
tar -czf !OUTPUT_NAME!.tar.gz !OUTPUT_NAME!
|
||||
rd /s /q !OUTPUT_NAME!
|
||||
pushd !PACKAGE_DIR!
|
||||
tar -czf ..\!OUTPUT_NAME!.tar.gz .
|
||||
popd
|
||||
rd /s /q !PACKAGE_DIR!
|
||||
|
||||
echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz
|
||||
) else (
|
||||
|
||||
@@ -38,9 +38,9 @@ foreach ($Platform in $Platforms) {
|
||||
$Arch = $parts[1]
|
||||
|
||||
$OutputName = "$AppName-$OS-$Arch"
|
||||
$BinaryName = $OutputName
|
||||
$BinaryName = $AppName
|
||||
if ($OS -eq "windows") {
|
||||
$BinaryName = "$OutputName.exe"
|
||||
$BinaryName = "$AppName.exe"
|
||||
}
|
||||
|
||||
Write-Host "正在编译 $OS/$Arch..."
|
||||
@@ -63,10 +63,10 @@ foreach ($Platform in $Platforms) {
|
||||
Copy-Item "README.md" $PackageDir\
|
||||
|
||||
$CurrentDir = Get-Location
|
||||
Set-Location $OutputDir
|
||||
tar -czf "${OutputName}.tar.gz" $OutputName
|
||||
Remove-Item -Recurse -Force $OutputName
|
||||
Set-Location $PackageDir
|
||||
tar -czf "../${OutputName}.tar.gz" .
|
||||
Set-Location $CurrentDir
|
||||
Remove-Item -Recurse -Force $PackageDir
|
||||
|
||||
Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz"
|
||||
} else {
|
||||
|
||||
@@ -45,9 +45,9 @@ for PLATFORM in "${PLATFORMS[@]}"; do
|
||||
# 设置输出名称
|
||||
OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}"
|
||||
if [ "$OS" = "windows" ]; then
|
||||
BINARY_NAME="${OUTPUT_NAME}.exe"
|
||||
BINARY_NAME="${APP_NAME}.exe"
|
||||
else
|
||||
BINARY_NAME="${OUTPUT_NAME}"
|
||||
BINARY_NAME="${APP_NAME}"
|
||||
fi
|
||||
|
||||
echo "正在编译 ${OS}/${ARCH}..."
|
||||
@@ -71,7 +71,7 @@ for PLATFORM in "${PLATFORMS[@]}"; do
|
||||
done
|
||||
|
||||
# 压缩为 tar.gz
|
||||
tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${OUTPUT_DIR}" "${OUTPUT_NAME}"
|
||||
tar -czf "${OUTPUT_DIR}/${OUTPUT_NAME}.tar.gz" -C "${PACKAGE_DIR}" .
|
||||
|
||||
# 删除临时打包目录
|
||||
rm -rf "$PACKAGE_DIR"
|
||||
|
||||
101
scripts/deploy.sh
Normal file
101
scripts/deploy.sh
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 定时任务示例 (每30分钟执行一次):
|
||||
# */30 * * * * /path/to/project/scripts/deploy.sh >> /path/to/project/scripts/deploy_cron.log 2>&1
|
||||
|
||||
# 项目根目录
|
||||
# 假设脚本位于 scripts 目录下
|
||||
PROJECT_DIR=$(cd $(dirname $0)/.. && pwd)
|
||||
cd $PROJECT_DIR
|
||||
|
||||
# 日志文件
|
||||
LOG_FILE="$PROJECT_DIR/scripts/deploy.log"
|
||||
|
||||
log() {
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# 确保在项目根目录
|
||||
if [ ! -f "docker-compose.yaml" ]; then
|
||||
log "错误: 未能在 $PROJECT_DIR 找到 docker-compose.yml,请确保脚本位置正确。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "开始检查更新..."
|
||||
|
||||
# 获取远程代码
|
||||
git fetch origin
|
||||
|
||||
# 获取当前分支名
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# 检查本地分支是否有上游分支
|
||||
UPSTREAM=$(git rev-parse --abbrev-ref @{u} 2>/dev/null)
|
||||
if [ -z "$UPSTREAM" ]; then
|
||||
log "错误: 当前分支 $BRANCH 没有设置上游分支,无法自动对比更新。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查本地是否落后于远程
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse "$UPSTREAM")
|
||||
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
log "代码已是最新 ($LOCAL),无需更新。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "检测到远程变更 ($LOCAL -> $REMOTE),准备开始升级..."
|
||||
|
||||
# 检查是否有本地修改
|
||||
HAS_CHANGES=$(git status --porcelain)
|
||||
|
||||
if [ -n "$HAS_CHANGES" ]; then
|
||||
log "检测到本地修改,正在暂存以保留个性化配置..."
|
||||
git stash
|
||||
STASHED=true
|
||||
else
|
||||
STASHED=false
|
||||
fi
|
||||
|
||||
# 拉取新代码
|
||||
log "正在拉取远程代码 ($BRANCH)..."
|
||||
if git pull origin "$BRANCH"; then
|
||||
log "代码拉取成功。"
|
||||
else
|
||||
log "错误: 代码拉取失败。"
|
||||
if [ "$STASHED" = true ]; then
|
||||
git stash pop
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 恢复本地修改
|
||||
if [ "$STASHED" = true ]; then
|
||||
log "正在恢复本地修改..."
|
||||
if git stash pop; then
|
||||
log "本地修改已成功恢复。"
|
||||
else
|
||||
log "警告: 恢复本地修改时发生冲突,请手动检查 docker-compose.yml 等文件。"
|
||||
# 即使冲突也尝试继续,或者你可以选择在此退出
|
||||
fi
|
||||
fi
|
||||
|
||||
# 确定 docker-compose 命令
|
||||
DOCKER_COMPOSE_BIN="docker-compose"
|
||||
if ! command -v $DOCKER_COMPOSE_BIN &> /dev/null; then
|
||||
DOCKER_COMPOSE_BIN="docker compose"
|
||||
fi
|
||||
|
||||
# 执行 docker-compose 部署
|
||||
log "正在执行 $DOCKER_COMPOSE_BIN 部署..."
|
||||
if $DOCKER_COMPOSE_BIN up -d --build; then
|
||||
log "服务升级成功!"
|
||||
# 清理无用镜像(可选)
|
||||
docker image prune -f
|
||||
else
|
||||
log "错误: $DOCKER_COMPOSE_BIN 部署失败。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "部署任务完成。"
|
||||
@@ -15,6 +15,7 @@ BingPaper 的前端 Web 应用,使用 Vue 3 + TypeScript + Vite 构建。
|
||||
- ⚡ 浏览器缓存优化(内容哈希 + 代码分割)
|
||||
- 🌐 支持自定义后端路径
|
||||
- 📁 自动输出到上级目录的 web 文件夹
|
||||
- 🔐 完整的管理后台系统(Token 管理、定时任务、系统配置)
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -93,6 +94,77 @@ const images = await bingPaperApi.getImages({ limit: 10 })
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/ # 静态资源
|
||||
├── components/ # Vue 组件
|
||||
│ └── ui/ # shadcn-vue UI 组件库
|
||||
├── composables/ # 可组合函数
|
||||
│ └── useImages.ts # 图片管理相关逻辑
|
||||
├── lib/ # 核心库
|
||||
│ ├── api-config.ts # API 配置
|
||||
│ ├── api-service.ts # API 服务类
|
||||
│ ├── api-types.ts # TypeScript 类型定义
|
||||
│ ├── http-client.ts # HTTP 客户端
|
||||
│ └── utils.ts # 工具函数
|
||||
├── router/ # 路由配置
|
||||
│ └── index.ts
|
||||
├── views/ # 页面组件
|
||||
│ ├── Home.vue # 首页
|
||||
│ ├── ImageView.vue # 图片详情页
|
||||
│ ├── ApiDocs.vue # API 文档页
|
||||
│ ├── AdminLogin.vue # 管理员登录页
|
||||
│ ├── Admin.vue # 管理后台主页面
|
||||
│ ├── AdminTokens.vue # Token 管理
|
||||
│ ├── AdminTasks.vue # 定时任务管理
|
||||
│ └── AdminConfig.vue # 系统配置管理
|
||||
├── App.vue # 根组件
|
||||
└── main.ts # 入口文件
|
||||
```
|
||||
|
||||
## 管理后台
|
||||
|
||||
访问 `/admin/login` 进入管理后台登录页面。
|
||||
|
||||
### 功能模块
|
||||
|
||||
#### 1. Token 管理
|
||||
- 查看所有 API Token
|
||||
- 创建新的 Token(支持设置过期时间)
|
||||
- 启用/禁用 Token
|
||||
- 删除 Token
|
||||
|
||||
#### 2. 定时任务管理
|
||||
- 手动触发图片抓取(可指定抓取天数)
|
||||
- 手动触发旧图片清理
|
||||
- 查看任务执行历史
|
||||
|
||||
#### 3. 系统配置管理
|
||||
- **JSON 编辑模式**:直接编辑配置 JSON
|
||||
- **表单编辑模式**:通过友好的表单界面修改配置
|
||||
- 支持的配置项:
|
||||
- 服务器配置(端口、基础 URL)
|
||||
- API 模式(本地/重定向)
|
||||
- 定时任务配置
|
||||
- 数据库配置
|
||||
- 存储配置(本地/S3/WebDAV)
|
||||
- 图片保留策略
|
||||
- Token 配置
|
||||
- 日志配置
|
||||
- 功能特性开关
|
||||
|
||||
#### 4. 密码管理
|
||||
- 修改管理员密码
|
||||
- 安全退出登录
|
||||
|
||||
### 安全特性
|
||||
- 基于 JWT Token 的身份验证
|
||||
- Token 过期自动跳转登录页
|
||||
- 路由守卫保护管理页面
|
||||
- 密码修改后强制重新登录
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/ # 核心库
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import 'vue-sonner/style.css'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<RouterView />
|
||||
<Toaster />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -181,6 +181,9 @@ export class BingPaperApiService {
|
||||
// 导出默认实例
|
||||
export const bingPaperApi = new BingPaperApiService()
|
||||
|
||||
// 为了兼容性,也导出为 apiService
|
||||
export const apiService = bingPaperApi
|
||||
|
||||
// 导出便捷方法
|
||||
export const {
|
||||
login,
|
||||
|
||||
@@ -50,97 +50,97 @@ export interface ChangePasswordRequest {
|
||||
// ===== 配置相关 =====
|
||||
|
||||
export interface Config {
|
||||
admin: AdminConfig
|
||||
api: APIConfig
|
||||
cron: CronConfig
|
||||
db: DBConfig
|
||||
feature: FeatureConfig
|
||||
log: LogConfig
|
||||
retention: RetentionConfig
|
||||
server: ServerConfig
|
||||
storage: StorageConfig
|
||||
token: TokenConfig
|
||||
web: WebConfig
|
||||
Server: ServerConfig
|
||||
Log: LogConfig
|
||||
API: APIConfig
|
||||
Cron: CronConfig
|
||||
Retention: RetentionConfig
|
||||
DB: DBConfig
|
||||
Storage: StorageConfig
|
||||
Admin: AdminConfig
|
||||
Token: TokenConfig
|
||||
Feature: FeatureConfig
|
||||
Web: WebConfig
|
||||
}
|
||||
|
||||
export interface AdminConfig {
|
||||
passwordBcrypt: string
|
||||
PasswordBcrypt: string
|
||||
}
|
||||
|
||||
export interface APIConfig {
|
||||
mode: string // 'local' | 'redirect'
|
||||
Mode: string // 'local' | 'redirect'
|
||||
}
|
||||
|
||||
export interface CronConfig {
|
||||
enabled: boolean
|
||||
dailySpec: string
|
||||
Enabled: boolean
|
||||
DailySpec: string
|
||||
}
|
||||
|
||||
export interface DBConfig {
|
||||
type: string // 'sqlite' | 'mysql' | 'postgres'
|
||||
dsn: string
|
||||
Type: string // 'sqlite' | 'mysql' | 'postgres'
|
||||
DSN: string
|
||||
}
|
||||
|
||||
export interface FeatureConfig {
|
||||
writeDailyFiles: boolean
|
||||
WriteDailyFiles: boolean
|
||||
}
|
||||
|
||||
export interface LogConfig {
|
||||
level: string
|
||||
filename: string
|
||||
dbfilename: string
|
||||
dblogLevel: string
|
||||
logConsole: boolean
|
||||
showDBLog: boolean
|
||||
maxSize: number
|
||||
maxAge: number
|
||||
maxBackups: number
|
||||
compress: boolean
|
||||
Level: string
|
||||
Filename: string
|
||||
DBFilename: string
|
||||
DBLogLevel: string
|
||||
LogConsole: boolean
|
||||
ShowDBLog: boolean
|
||||
MaxSize: number
|
||||
MaxAge: number
|
||||
MaxBackups: number
|
||||
Compress: boolean
|
||||
}
|
||||
|
||||
export interface RetentionConfig {
|
||||
days: number
|
||||
Days: number
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number
|
||||
baseURL: string
|
||||
Port: number
|
||||
BaseURL: string
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
type: string // 'local' | 's3' | 'webdav'
|
||||
local: LocalConfig
|
||||
s3: S3Config
|
||||
webDAV: WebDAVConfig
|
||||
Type: string // 'local' | 's3' | 'webdav'
|
||||
Local: LocalConfig
|
||||
S3: S3Config
|
||||
WebDAV: WebDAVConfig
|
||||
}
|
||||
|
||||
export interface LocalConfig {
|
||||
root: string
|
||||
Root: string
|
||||
}
|
||||
|
||||
export interface S3Config {
|
||||
endpoint: string
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
bucket: string
|
||||
region: string
|
||||
forcePathStyle: boolean
|
||||
publicURLPrefix: string
|
||||
Endpoint: string
|
||||
AccessKey: string
|
||||
SecretKey: string
|
||||
Bucket: string
|
||||
Region: string
|
||||
ForcePathStyle: boolean
|
||||
PublicURLPrefix: string
|
||||
}
|
||||
|
||||
export interface WebDAVConfig {
|
||||
url: string
|
||||
username: string
|
||||
password: string
|
||||
publicURLPrefix: string
|
||||
URL: string
|
||||
Username: string
|
||||
Password: string
|
||||
PublicURLPrefix: string
|
||||
}
|
||||
|
||||
export interface TokenConfig {
|
||||
defaultTTL: string
|
||||
DefaultTTL: string
|
||||
}
|
||||
|
||||
export interface WebConfig {
|
||||
path: string
|
||||
Path: string
|
||||
}
|
||||
|
||||
// ===== 图片相关 =====
|
||||
@@ -157,9 +157,18 @@ export interface ImageMeta {
|
||||
url?: string
|
||||
variant?: string
|
||||
format?: string
|
||||
variants?: ImageVariantResp[] // 图片变体列表
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ImageVariantResp {
|
||||
variant: string // 分辨率变体 (UHD, 1920x1080, 等)
|
||||
format: string // 格式 (jpg)
|
||||
url: string // 访问 URL
|
||||
storage_key: string // 存储键
|
||||
size: number // 文件大小(字节)
|
||||
}
|
||||
|
||||
export interface ImageListParams extends PaginationParams {
|
||||
page?: number // 页码(从1开始)
|
||||
page_size?: number // 每页数量
|
||||
|
||||
@@ -108,11 +108,18 @@ export class ApiClient {
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
const errorData = await this.parseResponse(response)
|
||||
throw new ApiError(
|
||||
const apiError = new ApiError(
|
||||
errorData?.message || `HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
errorData
|
||||
)
|
||||
|
||||
// 401 未授权错误,自动跳转到登录页
|
||||
if (response.status === 401) {
|
||||
this.handle401Error()
|
||||
}
|
||||
|
||||
throw apiError
|
||||
}
|
||||
|
||||
return await this.parseResponse(response)
|
||||
@@ -130,6 +137,24 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 401 错误
|
||||
*/
|
||||
private handle401Error() {
|
||||
// 清除本地存储的 token
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_token_expires')
|
||||
this.clearAuthToken()
|
||||
|
||||
// 只有在管理页面时才跳转到登录页
|
||||
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/admin')) {
|
||||
// 避免重复跳转
|
||||
if (!window.location.pathname.includes('/admin/login')) {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析响应数据
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '@/views/Home.vue'
|
||||
import ImageView from '@/views/ImageView.vue'
|
||||
import ApiDocs from '@/views/ApiDocs.vue'
|
||||
import AdminLogin from '@/views/AdminLogin.vue'
|
||||
import Admin from '@/views/Admin.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -29,13 +31,54 @@ const router = createRouter({
|
||||
meta: {
|
||||
title: 'API 文档'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: AdminLogin,
|
||||
meta: {
|
||||
title: '管理员登录'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: Admin,
|
||||
meta: {
|
||||
title: '管理后台',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 路由守卫 - 更新页面标题
|
||||
// 路由守卫 - 更新页面标题和认证检查
|
||||
router.beforeEach((to, _from, next) => {
|
||||
document.title = (to.meta.title as string) || '必应每日一图'
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth) {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
// 未登录,重定向到登录页
|
||||
next('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 token 是否过期
|
||||
const expiresAt = localStorage.getItem('admin_token_expires')
|
||||
if (expiresAt) {
|
||||
const expireDate = new Date(expiresAt)
|
||||
if (expireDate < new Date()) {
|
||||
// token 已过期,清除并重定向到登录页
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_token_expires')
|
||||
next('/admin/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
199
webapp/src/views/Admin.vue
Normal file
199
webapp/src/views/Admin.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="bg-white border-b">
|
||||
<div class="container mx-auto px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-xl font-bold">BingPaper 管理后台</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="outline" size="sm" @click="showPasswordDialog = true">
|
||||
修改密码
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" @click="handleLogout">
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<Tabs v-model="activeTab" class="space-y-4">
|
||||
<TabsList class="grid w-full grid-cols-3 lg:w-[400px]">
|
||||
<TabsTrigger value="tokens">Token 管理</TabsTrigger>
|
||||
<TabsTrigger value="tasks">定时任务</TabsTrigger>
|
||||
<TabsTrigger value="config">系统配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tokens" class="space-y-4">
|
||||
<AdminTokens />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks" class="space-y-4">
|
||||
<AdminTasks />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="config" class="space-y-4">
|
||||
<AdminConfig />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<Dialog v-model:open="showPasswordDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改管理员密码</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入旧密码和新密码
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="old-password">旧密码</Label>
|
||||
<Input
|
||||
id="old-password"
|
||||
v-model="passwordForm.oldPassword"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="new-password">新密码</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="confirm-password">确认新密码</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div v-if="passwordError" class="text-sm text-red-600">
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="showPasswordDialog = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" :disabled="passwordLoading">
|
||||
{{ passwordLoading ? '提交中...' : '确认修改' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { apiService } from '@/lib/api-service'
|
||||
import { apiClient } from '@/lib/http-client'
|
||||
import AdminTokens from './AdminTokens.vue'
|
||||
import AdminTasks from './AdminTasks.vue'
|
||||
import AdminConfig from './AdminConfig.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const activeTab = ref('tokens')
|
||||
|
||||
const showPasswordDialog = ref(false)
|
||||
const passwordForm = ref({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
const passwordLoading = ref(false)
|
||||
const passwordError = ref('')
|
||||
|
||||
// 检查认证状态
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
router.push('/admin/login')
|
||||
return false
|
||||
}
|
||||
|
||||
// 设置认证头
|
||||
apiClient.setAuthToken(token)
|
||||
|
||||
// 检查是否过期
|
||||
const expiresAt = localStorage.getItem('admin_token_expires')
|
||||
if (expiresAt) {
|
||||
const expireDate = new Date(expiresAt)
|
||||
if (expireDate < new Date()) {
|
||||
toast.warning('登录已过期,请重新登录')
|
||||
handleLogout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_token_expires')
|
||||
apiClient.clearAuthToken()
|
||||
router.push('/admin/login')
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
passwordError.value = ''
|
||||
|
||||
// 验证新密码
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
|
||||
passwordError.value = '两次输入的新密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword.length < 6) {
|
||||
passwordError.value = '新密码长度至少为 6 位'
|
||||
return
|
||||
}
|
||||
|
||||
passwordLoading.value = true
|
||||
|
||||
try {
|
||||
await apiService.changePassword({
|
||||
old_password: passwordForm.value.oldPassword,
|
||||
new_password: passwordForm.value.newPassword
|
||||
})
|
||||
|
||||
toast.success('密码修改成功,请重新登录')
|
||||
showPasswordDialog.value = false
|
||||
passwordForm.value = {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
handleLogout()
|
||||
} catch (err: any) {
|
||||
passwordError.value = err.message || '密码修改失败'
|
||||
console.error('修改密码失败:', err)
|
||||
} finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth()
|
||||
})
|
||||
</script>
|
||||
607
webapp/src/views/AdminConfig.vue
Normal file
607
webapp/src/views/AdminConfig.vue
Normal file
@@ -0,0 +1,607 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">系统配置</h3>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="editMode = editMode === 'json' ? 'form' : 'json'"
|
||||
>
|
||||
切换到{{ editMode === 'json' ? '表单' : 'JSON' }}编辑
|
||||
</Button>
|
||||
<Button @click="handleSaveConfig" :disabled="saveLoading">
|
||||
{{ saveLoading ? '保存中...' : '保存配置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<p class="text-gray-500">加载配置中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="text-red-600 bg-red-50 p-4 rounded-md">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- JSON 编辑模式 -->
|
||||
<Card v-if="editMode === 'json'">
|
||||
<CardHeader>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>JSON 配置编辑器</CardTitle>
|
||||
<CardDescription>
|
||||
直接编辑配置 JSON,请确保格式正确
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="formatJson"
|
||||
:disabled="!configJson.trim()"
|
||||
>
|
||||
格式化 JSON
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
v-model="configJson"
|
||||
class="font-mono text-sm min-h-[500px]"
|
||||
:class="{ 'border-red-500': jsonError }"
|
||||
placeholder="配置 JSON"
|
||||
/>
|
||||
<div v-if="jsonError" class="mt-2 text-sm text-red-600 bg-red-50 p-2 rounded">
|
||||
❌ {{ jsonError }}
|
||||
</div>
|
||||
<div v-else-if="isValidJson" class="mt-2 text-sm text-green-600">
|
||||
✓ JSON 格式正确
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 表单编辑模式 -->
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 服务器配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>服务器配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>端口</Label>
|
||||
<Input v-model.number="config.Server.Port" type="number" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>基础 URL</Label>
|
||||
<Input v-model="config.Server.BaseURL" placeholder="http://localhost:8080" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- API 配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>API 模式</Label>
|
||||
<Select v-model="config.API.Mode">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择 API 模式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">本地 (local)</SelectItem>
|
||||
<SelectItem value="redirect">重定向 (redirect)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-gray-500">
|
||||
local: 直接返回图片流; redirect: 重定向到存储位置
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 定时任务配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>定时任务配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="cron-enabled">启用定时任务</Label>
|
||||
<Switch
|
||||
id="cron-enabled"
|
||||
v-model="config.Cron.Enabled"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>定时表达式 (Cron)</Label>
|
||||
<Input v-model="config.Cron.DailySpec" placeholder="0 9 * * *" />
|
||||
<p class="text-xs text-gray-500">
|
||||
例如: "0 9 * * *" 表示每天 9:00 执行
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 数据库配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>数据库配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>数据库类型</Label>
|
||||
<Select v-model="config.DB.Type">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择数据库类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sqlite">SQLite</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>DSN (数据源名称)</Label>
|
||||
<Input
|
||||
v-model="config.DB.DSN"
|
||||
placeholder="数据库连接字符串"
|
||||
:class="{ 'border-red-500': dsnError }"
|
||||
@blur="validateDSN"
|
||||
/>
|
||||
<p v-if="dsnExamples" class="text-xs text-gray-500">
|
||||
💡 示例: {{ dsnExamples }}
|
||||
</p>
|
||||
<p v-if="dsnError" class="text-xs text-red-600">
|
||||
❌ {{ dsnError }}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 存储配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>存储配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>存储类型</Label>
|
||||
<Select v-model="config.Storage.Type">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择存储类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">本地存储</SelectItem>
|
||||
<SelectItem value="s3">S3 存储</SelectItem>
|
||||
<SelectItem value="webdav">WebDAV</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 本地存储配置 -->
|
||||
<div v-if="config.Storage.Type === 'local'" class="space-y-2">
|
||||
<Label>本地存储路径</Label>
|
||||
<Input v-model="config.Storage.Local.Root" placeholder="./data/images" />
|
||||
</div>
|
||||
|
||||
<!-- S3 存储配置 -->
|
||||
<div v-if="config.Storage.Type === 's3'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Endpoint</Label>
|
||||
<Input v-model="config.Storage.S3.Endpoint" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Region</Label>
|
||||
<Input v-model="config.Storage.S3.Region" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Bucket</Label>
|
||||
<Input v-model="config.Storage.S3.Bucket" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Access Key</Label>
|
||||
<Input v-model="config.Storage.S3.AccessKey" type="password" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Secret Key</Label>
|
||||
<Input v-model="config.Storage.S3.SecretKey" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>公开 URL 前缀</Label>
|
||||
<Input v-model="config.Storage.S3.PublicURLPrefix" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="s3-force-path">强制路径样式</Label>
|
||||
<Switch
|
||||
id="s3-force-path"
|
||||
v-model="config.Storage.S3.ForcePathStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebDAV 配置 -->
|
||||
<div v-if="config.Storage.Type === 'webdav'" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>WebDAV URL</Label>
|
||||
<Input v-model="config.Storage.WebDAV.URL" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>用户名</Label>
|
||||
<Input v-model="config.Storage.WebDAV.Username" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>密码</Label>
|
||||
<Input v-model="config.Storage.WebDAV.Password" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>公开 URL 前缀</Label>
|
||||
<Input v-model="config.Storage.WebDAV.PublicURLPrefix" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 保留策略配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>图片保留策略</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>保留天数</Label>
|
||||
<Input v-model.number="config.Retention.Days" type="number" min="1" />
|
||||
<p class="text-xs text-gray-500">
|
||||
超过指定天数的图片将被自动清理
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Token 配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Token 配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>默认过期时间 (TTL)</Label>
|
||||
<Input v-model="config.Token.DefaultTTL" placeholder="168h" />
|
||||
<p class="text-xs text-gray-500">
|
||||
例如: 168h (7天), 720h (30天)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 日志配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>日志配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>日志级别</Label>
|
||||
<Select v-model="config.Log.Level">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择日志级别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debug">Debug</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warn">Warn</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>日志文件</Label>
|
||||
<Input v-model="config.Log.Filename" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>数据库日志文件</Label>
|
||||
<Input v-model="config.Log.DBFilename" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>数据库日志级别</Label>
|
||||
<Select v-model="config.Log.DBLogLevel">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择数据库日志级别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debug">Debug</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warn">Warn</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="log-console">输出到控制台</Label>
|
||||
<Switch
|
||||
id="log-console"
|
||||
v-model="config.Log.LogConsole"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="log-show-db">显示数据库日志</Label>
|
||||
<Switch
|
||||
id="log-show-db"
|
||||
v-model="config.Log.ShowDBLog"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="log-compress">压缩旧日志</Label>
|
||||
<Switch
|
||||
id="log-compress"
|
||||
v-model="config.Log.Compress"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>单文件大小 (MB)</Label>
|
||||
<Input v-model.number="config.Log.MaxSize" type="number" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>最大文件数</Label>
|
||||
<Input v-model.number="config.Log.MaxBackups" type="number" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>保留天数</Label>
|
||||
<Input v-model.number="config.Log.MaxAge" type="number" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 功能特性配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>功能特性</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="feature-write-daily">写入每日文件</Label>
|
||||
<Switch
|
||||
id="feature-write-daily"
|
||||
v-model="config.Feature.WriteDailyFiles"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Web 配置 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Web 前端配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>前端静态文件路径</Label>
|
||||
<Input v-model="config.Web.Path" placeholder="./webapp/dist" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { apiService } from '@/lib/api-service'
|
||||
import type { Config } from '@/lib/api-types'
|
||||
|
||||
const editMode = ref<'json' | 'form'>('form')
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const saveLoading = ref(false)
|
||||
const dsnError = ref('')
|
||||
|
||||
const config = ref<Config>({
|
||||
Admin: { PasswordBcrypt: '' },
|
||||
API: { Mode: 'local' },
|
||||
Cron: { Enabled: true, DailySpec: '0 9 * * *' },
|
||||
DB: { Type: 'sqlite', DSN: '' },
|
||||
Feature: { WriteDailyFiles: true },
|
||||
Log: {
|
||||
Level: 'info',
|
||||
Filename: '',
|
||||
DBFilename: '',
|
||||
DBLogLevel: 'warn',
|
||||
LogConsole: true,
|
||||
ShowDBLog: false,
|
||||
MaxSize: 10,
|
||||
MaxAge: 30,
|
||||
MaxBackups: 10,
|
||||
Compress: true
|
||||
},
|
||||
Retention: { Days: 30 },
|
||||
Server: { Port: 8080, BaseURL: '' },
|
||||
Storage: {
|
||||
Type: 'local',
|
||||
Local: { Root: './data/images' },
|
||||
S3: {
|
||||
Endpoint: '',
|
||||
AccessKey: '',
|
||||
SecretKey: '',
|
||||
Bucket: '',
|
||||
Region: '',
|
||||
ForcePathStyle: false,
|
||||
PublicURLPrefix: ''
|
||||
},
|
||||
WebDAV: {
|
||||
URL: '',
|
||||
Username: '',
|
||||
Password: '',
|
||||
PublicURLPrefix: ''
|
||||
}
|
||||
},
|
||||
Token: { DefaultTTL: '168h' },
|
||||
Web: { Path: './webapp/dist' }
|
||||
})
|
||||
|
||||
const configJson = ref('')
|
||||
const jsonError = ref('')
|
||||
|
||||
// DSN 示例
|
||||
const dsnExamples = computed(() => {
|
||||
switch (config.value.DB.Type) {
|
||||
case 'sqlite':
|
||||
return 'data/bing_paper.db 或 file:data/bing_paper.db?cache=shared'
|
||||
case 'mysql':
|
||||
return 'user:password@tcp(localhost:3306)/dbname?charset=utf8mb4&parseTime=True'
|
||||
case 'postgres':
|
||||
return 'host=localhost user=postgres password=secret dbname=mydb port=5432 sslmode=disable'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// 验证 DSN
|
||||
const validateDSN = () => {
|
||||
dsnError.value = ''
|
||||
const dsn = config.value.DB.DSN.trim()
|
||||
|
||||
if (!dsn) {
|
||||
dsnError.value = 'DSN 不能为空'
|
||||
return false
|
||||
}
|
||||
|
||||
switch (config.value.DB.Type) {
|
||||
case 'mysql':
|
||||
if (!dsn.includes('@tcp(') && !dsn.includes('://')) {
|
||||
dsnError.value = 'MySQL DSN 格式不正确,应包含 @tcp( 或使用 URI 格式'
|
||||
return false
|
||||
}
|
||||
break
|
||||
case 'postgres':
|
||||
if (!dsn.includes('host=') && !dsn.includes('://')) {
|
||||
dsnError.value = 'PostgreSQL DSN 格式不正确,应包含 host= 或使用 URI 格式'
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 格式化 JSON
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(configJson.value)
|
||||
configJson.value = JSON.stringify(parsed, null, 2)
|
||||
jsonError.value = ''
|
||||
toast.success('JSON 格式化成功')
|
||||
} catch (err: any) {
|
||||
jsonError.value = 'JSON 格式错误: ' + err.message
|
||||
toast.error('JSON 格式错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 JSON 是否有效
|
||||
const isValidJson = computed(() => {
|
||||
if (!configJson.value.trim()) return false
|
||||
try {
|
||||
JSON.parse(configJson.value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const data = await apiService.getConfig()
|
||||
config.value = data
|
||||
configJson.value = JSON.stringify(data, null, 2)
|
||||
} catch (err: any) {
|
||||
loadError.value = err.message || '获取配置失败'
|
||||
console.error('获取配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听表单变化更新 JSON
|
||||
watch(config, (newConfig) => {
|
||||
if (editMode.value === 'form') {
|
||||
configJson.value = JSON.stringify(newConfig, null, 2)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 监听 JSON 变化更新表单
|
||||
watch(configJson, (newJson) => {
|
||||
if (editMode.value === 'json') {
|
||||
try {
|
||||
const parsed = JSON.parse(newJson)
|
||||
config.value = parsed
|
||||
jsonError.value = ''
|
||||
} catch (err: any) {
|
||||
jsonError.value = err.message
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
saveLoading.value = true
|
||||
|
||||
try {
|
||||
// 如果是 JSON 模式,先验证格式
|
||||
if (editMode.value === 'json') {
|
||||
if (!isValidJson.value) {
|
||||
throw new Error('JSON 格式不正确,请检查语法')
|
||||
}
|
||||
config.value = JSON.parse(configJson.value)
|
||||
} else {
|
||||
// 表单模式下验证 DSN
|
||||
if (!validateDSN()) {
|
||||
throw new Error('DSN 格式不正确: ' + dsnError.value)
|
||||
}
|
||||
}
|
||||
|
||||
await apiService.updateConfig(config.value)
|
||||
toast.success('配置保存成功')
|
||||
|
||||
// 重新加载配置
|
||||
await fetchConfig()
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || '保存配置失败')
|
||||
console.error('保存配置失败:', err)
|
||||
} finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</script>
|
||||
79
webapp/src/views/AdminLogin.vue
Normal file
79
webapp/src/views/AdminLogin.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
|
||||
<Card class="w-full max-w-md">
|
||||
<CardHeader class="space-y-1">
|
||||
<CardTitle class="text-2xl font-bold text-center">管理员登录</CardTitle>
|
||||
<CardDescription class="text-center">
|
||||
输入管理员密码以访问后台管理系统
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入管理员密码"
|
||||
required
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-red-600 bg-red-50 p-3 rounded-md">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="loading">
|
||||
<span v-if="loading">登录中...</span>
|
||||
<span v-else>登录</span>
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { apiService } from '@/lib/api-service'
|
||||
import { apiClient } from '@/lib/http-client'
|
||||
|
||||
const router = useRouter()
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await apiService.login({ password: password.value })
|
||||
|
||||
// 保存 token 到 localStorage
|
||||
localStorage.setItem('admin_token', response.token)
|
||||
localStorage.setItem('admin_token_expires', response.expires_at || '')
|
||||
|
||||
// 设置 HTTP 客户端的认证头
|
||||
apiClient.setAuthToken(response.token)
|
||||
|
||||
toast.success('登录成功')
|
||||
|
||||
// 跳转到管理后台
|
||||
router.push('/admin')
|
||||
} catch (err: any) {
|
||||
console.error('登录失败:', err)
|
||||
error.value = err.message || '登录失败,请检查密码'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
189
webapp/src/views/AdminTasks.vue
Normal file
189
webapp/src/views/AdminTasks.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">定时任务管理</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
手动触发图片抓取和清理任务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 手动抓取 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>手动抓取图片</CardTitle>
|
||||
<CardDescription>
|
||||
立即从 Bing 抓取最新的图片
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="fetch-days">抓取天数</Label>
|
||||
<Input
|
||||
id="fetch-days"
|
||||
v-model.number="fetchDays"
|
||||
type="number"
|
||||
min="1"
|
||||
max="30"
|
||||
placeholder="输入要抓取的天数,默认 1 天"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">
|
||||
指定要抓取的天数(包括今天),最多 30 天
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@click="handleManualFetch"
|
||||
:disabled="fetchLoading"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
{{ fetchLoading ? '抓取中...' : '开始抓取' }}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 手动清理 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>手动清理旧图片</CardTitle>
|
||||
<CardDescription>
|
||||
清理超过保留期限的旧图片
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<p class="text-sm text-gray-600">
|
||||
根据系统配置的保留天数,清理过期的图片文件和数据库记录
|
||||
</p>
|
||||
<Button
|
||||
@click="handleManualCleanup"
|
||||
:disabled="cleanupLoading"
|
||||
variant="destructive"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
{{ cleanupLoading ? '清理中...' : '开始清理' }}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 任务历史记录 -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>任务执行历史</CardTitle>
|
||||
<CardDescription>
|
||||
最近的任务执行记录
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div v-if="taskHistory.length === 0" class="text-center py-8 text-gray-500">
|
||||
暂无执行记录
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(task, index) in taskHistory"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 border rounded-md"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :variant="task.success ? 'default' : 'destructive'">
|
||||
{{ task.type }}
|
||||
</Badge>
|
||||
<span class="text-sm">{{ task.message }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
{{ task.timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { apiService } from '@/lib/api-service'
|
||||
|
||||
interface TaskRecord {
|
||||
type: string
|
||||
success: boolean
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const fetchDays = ref<number>(1)
|
||||
const fetchLoading = ref(false)
|
||||
|
||||
const cleanupLoading = ref(false)
|
||||
|
||||
const taskHistory = ref<TaskRecord[]>([])
|
||||
|
||||
const handleManualFetch = async () => {
|
||||
fetchLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await apiService.manualFetch({ n: fetchDays.value })
|
||||
toast.success(response.message || '抓取任务已启动')
|
||||
|
||||
// 添加到历史记录
|
||||
taskHistory.value.unshift({
|
||||
type: '图片抓取',
|
||||
success: true,
|
||||
message: `抓取 ${fetchDays.value} 天的图片`,
|
||||
timestamp: new Date().toLocaleString('zh-CN')
|
||||
})
|
||||
|
||||
// 只保留最近 10 条记录
|
||||
if (taskHistory.value.length > 10) {
|
||||
taskHistory.value = taskHistory.value.slice(0, 10)
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || '抓取失败')
|
||||
|
||||
taskHistory.value.unshift({
|
||||
type: '图片抓取',
|
||||
success: false,
|
||||
message: err.message || '抓取失败',
|
||||
timestamp: new Date().toLocaleString('zh-CN')
|
||||
})
|
||||
} finally {
|
||||
fetchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualCleanup = async () => {
|
||||
cleanupLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await apiService.manualCleanup()
|
||||
toast.success(response.message || '清理任务已完成')
|
||||
|
||||
taskHistory.value.unshift({
|
||||
type: '清理任务',
|
||||
success: true,
|
||||
message: '清理旧图片',
|
||||
timestamp: new Date().toLocaleString('zh-CN')
|
||||
})
|
||||
|
||||
if (taskHistory.value.length > 10) {
|
||||
taskHistory.value = taskHistory.value.slice(0, 10)
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || '清理失败')
|
||||
|
||||
taskHistory.value.unshift({
|
||||
type: '清理任务',
|
||||
success: false,
|
||||
message: err.message || '清理失败',
|
||||
timestamp: new Date().toLocaleString('zh-CN')
|
||||
})
|
||||
} finally {
|
||||
cleanupLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
236
webapp/src/views/AdminTokens.vue
Normal file
236
webapp/src/views/AdminTokens.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">Token 管理</h3>
|
||||
<Button @click="showCreateDialog = true">
|
||||
<span>创建 Token</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<p class="text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="text-red-600 bg-red-50 p-4 rounded-md">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="tokens.length === 0" class="text-center py-8 text-gray-500">
|
||||
暂无 Token,点击上方按钮创建
|
||||
</div>
|
||||
|
||||
<div v-else class="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>Token</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>过期时间</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead class="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="token in tokens" :key="token.id">
|
||||
<TableCell>{{ token.id }}</TableCell>
|
||||
<TableCell>{{ token.name }}</TableCell>
|
||||
<TableCell>
|
||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||
{{ token.token.substring(0, 20) }}...
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="token.disabled ? 'destructive' : 'default'">
|
||||
{{ token.disabled ? '已禁用' : '启用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{{ formatDate(token.expires_at) }}</TableCell>
|
||||
<TableCell>{{ formatDate(token.created_at) }}</TableCell>
|
||||
<TableCell class="text-right space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="toggleTokenStatus(token)"
|
||||
>
|
||||
{{ token.disabled ? '启用' : '禁用' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
@click="handleDeleteToken(token)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 创建 Token 对话框 -->
|
||||
<Dialog v-model:open="showCreateDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建 Token</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建新的 API Token 用于访问接口
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="handleCreateToken" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="createForm.name"
|
||||
placeholder="输入 Token 名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="expires_in">过期时间</Label>
|
||||
<Input
|
||||
id="expires_in"
|
||||
v-model="createForm.expires_in"
|
||||
placeholder="例如: 168h (7天), 720h (30天)"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">留空表示永不过期</p>
|
||||
</div>
|
||||
<div v-if="createError" class="text-sm text-red-600">
|
||||
{{ createError }}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="showCreateDialog = false">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" :disabled="createLoading">
|
||||
{{ createLoading ? '创建中...' : '创建' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog v-model:open="showDeleteDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除 Token "{{ deleteTarget?.name }}" 吗?此操作无法撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction @click="confirmDelete" class="bg-red-600 hover:bg-red-700">
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { apiService } from '@/lib/api-service'
|
||||
import type { Token } from '@/lib/api-types'
|
||||
|
||||
const tokens = ref<Token[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
expires_in: ''
|
||||
})
|
||||
const createLoading = ref(false)
|
||||
const createError = ref('')
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref<Token | null>(null)
|
||||
|
||||
const fetchTokens = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
tokens.value = await apiService.getTokens()
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '获取 Token 列表失败'
|
||||
console.error('获取 Token 失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateToken = async () => {
|
||||
createLoading.value = true
|
||||
createError.value = ''
|
||||
try {
|
||||
await apiService.createToken(createForm.value)
|
||||
showCreateDialog.value = false
|
||||
createForm.value = { name: '', expires_in: '' }
|
||||
toast.success('Token 创建成功')
|
||||
await fetchTokens()
|
||||
} catch (err: any) {
|
||||
createError.value = err.message || '创建 Token 失败'
|
||||
console.error('创建 Token 失败:', err)
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTokenStatus = async (token: Token) => {
|
||||
try {
|
||||
await apiService.updateToken(token.id, { disabled: !token.disabled })
|
||||
toast.success(`Token 已${token.disabled ? '启用' : '禁用'}`)
|
||||
await fetchTokens()
|
||||
} catch (err: any) {
|
||||
console.error('更新 Token 状态失败:', err)
|
||||
toast.error(err.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteToken = (token: Token) => {
|
||||
deleteTarget.value = token
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget.value) return
|
||||
|
||||
try {
|
||||
await apiService.deleteToken(deleteTarget.value.id)
|
||||
showDeleteDialog.value = false
|
||||
deleteTarget.value = null
|
||||
toast.success('Token 删除成功')
|
||||
await fetchTokens()
|
||||
} catch (err: any) {
|
||||
console.error('删除 Token 失败:', err)
|
||||
toast.error(err.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTokens()
|
||||
})
|
||||
</script>
|
||||
@@ -173,13 +173,18 @@
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<div
|
||||
v-else-if="hasMore"
|
||||
@click="loadMore"
|
||||
class="px-8 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30"
|
||||
ref="loadMoreTrigger"
|
||||
class="inline-block"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
<button
|
||||
@click="loadMore"
|
||||
class="px-8 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-white/40">
|
||||
已加载全部图片
|
||||
@@ -246,8 +251,8 @@ const router = useRouter()
|
||||
// 获取今日图片
|
||||
const { image: todayImage, loading: todayLoading } = useTodayImage()
|
||||
|
||||
// 获取图片列表(使用服务端分页和筛选)
|
||||
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(30)
|
||||
// 获取图片列表(使用服务端分页和筛选,每页15张)
|
||||
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(15)
|
||||
|
||||
// 筛选相关状态
|
||||
const selectedYear = ref('')
|
||||
@@ -258,6 +263,10 @@ const imageRefs = ref<(HTMLElement | null)[]>([])
|
||||
const imageVisibility = ref<boolean[]>([])
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
// 无限滚动加载
|
||||
const loadMoreTrigger = ref<HTMLElement | null>(null)
|
||||
let loadMoreObserver: IntersectionObserver | null = null
|
||||
|
||||
// 计算可用的年份列表(基于当前日期生成,从2020年到当前年份)
|
||||
const availableYears = computed(() => {
|
||||
const currentYear = new Date().getFullYear()
|
||||
@@ -360,16 +369,6 @@ const setupObserver = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化懒加载状态
|
||||
onMounted(() => {
|
||||
// 初始化时设置
|
||||
if (images.value.length > 0) {
|
||||
imageVisibility.value = new Array(images.value.length).fill(false)
|
||||
setTimeout(() => {
|
||||
setupObserver()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 images 变化,动态更新 imageVisibility
|
||||
watch(() => images.value.length, (newLength, oldLength) => {
|
||||
@@ -399,11 +398,51 @@ watch(() => images.value.length, (newLength, oldLength) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 设置无限滚动 Observer
|
||||
const setupLoadMoreObserver = () => {
|
||||
if (loadMoreObserver) {
|
||||
loadMoreObserver.disconnect()
|
||||
}
|
||||
|
||||
loadMoreObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !loading.value && hasMore.value) {
|
||||
loadMore()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
if (loadMoreTrigger.value) {
|
||||
loadMoreObserver.observe(loadMoreTrigger.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时设置无限滚动
|
||||
onMounted(() => {
|
||||
if (images.value.length > 0) {
|
||||
imageVisibility.value = new Array(images.value.length).fill(false)
|
||||
setTimeout(() => {
|
||||
setupObserver()
|
||||
setupLoadMoreObserver()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
if (loadMoreObserver) {
|
||||
loadMoreObserver.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click="previousDay"
|
||||
:disabled="navigating"
|
||||
:disabled="navigating || !hasPreviousDay"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<button
|
||||
@click="nextDay"
|
||||
:disabled="navigating || isToday"
|
||||
:disabled="navigating || !hasNextDay"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="hidden sm:inline">后一天</span>
|
||||
@@ -141,7 +141,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useImageByDate } from '@/composables/useImages'
|
||||
import { bingPaperApi } from '@/lib/api-service'
|
||||
@@ -153,6 +153,11 @@ const currentDate = ref(route.params.date as string)
|
||||
const showInfo = ref(true)
|
||||
const navigating = ref(false)
|
||||
|
||||
// 前后日期可用性
|
||||
const hasPreviousDay = ref(true)
|
||||
const hasNextDay = ref(true)
|
||||
const checkingDates = ref(false)
|
||||
|
||||
// 拖动相关状态
|
||||
const infoPanel = ref<HTMLElement | null>(null)
|
||||
const infoPanelPos = ref({ x: 0, y: 0 })
|
||||
@@ -230,9 +235,50 @@ const stopDrag = () => {
|
||||
// 使用 composable 获取图片数据(传递 ref,自动响应日期变化)
|
||||
const { image, loading, error } = useImageByDate(currentDate)
|
||||
|
||||
// 检测指定日期是否有数据
|
||||
const checkDateAvailability = async (dateStr: string): Promise<boolean> => {
|
||||
try {
|
||||
await bingPaperApi.getImageMetaByDate(dateStr)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检测前后日期可用性
|
||||
const checkAdjacentDates = async () => {
|
||||
if (checkingDates.value) return
|
||||
|
||||
checkingDates.value = true
|
||||
const date = new Date(currentDate.value)
|
||||
|
||||
// 检测前一天
|
||||
const prevDate = new Date(date)
|
||||
prevDate.setDate(prevDate.getDate() - 1)
|
||||
hasPreviousDay.value = await checkDateAvailability(prevDate.toISOString().split('T')[0])
|
||||
|
||||
// 检测后一天(不能超过今天)
|
||||
const nextDate = new Date(date)
|
||||
nextDate.setDate(nextDate.getDate() + 1)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (nextDate.toISOString().split('T')[0] > today) {
|
||||
hasNextDay.value = false
|
||||
} else {
|
||||
hasNextDay.value = await checkDateAvailability(nextDate.toISOString().split('T')[0])
|
||||
}
|
||||
|
||||
checkingDates.value = false
|
||||
}
|
||||
|
||||
// 初始化位置
|
||||
initPanelPosition()
|
||||
|
||||
// 监听日期变化,检测前后日期可用性
|
||||
import { watch } from 'vue'
|
||||
watch(currentDate, () => {
|
||||
checkAdjacentDates()
|
||||
}, { immediate: true })
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return ''
|
||||
@@ -245,12 +291,6 @@ const formatDate = (dateStr?: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 判断是否是今天
|
||||
const isToday = computed(() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return currentDate.value === today
|
||||
})
|
||||
|
||||
// 获取完整图片 URL
|
||||
const getFullImageUrl = () => {
|
||||
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg')
|
||||
@@ -265,7 +305,7 @@ const goBack = () => {
|
||||
|
||||
// 前一天
|
||||
const previousDay = () => {
|
||||
if (navigating.value) return
|
||||
if (navigating.value || !hasPreviousDay.value) return
|
||||
|
||||
navigating.value = true
|
||||
const date = new Date(currentDate.value)
|
||||
@@ -282,7 +322,7 @@ const previousDay = () => {
|
||||
|
||||
// 后一天
|
||||
const nextDay = () => {
|
||||
if (navigating.value || isToday.value) return
|
||||
if (navigating.value || !hasNextDay.value) return
|
||||
|
||||
navigating.value = true
|
||||
const date = new Date(currentDate.value)
|
||||
@@ -299,9 +339,9 @@ const nextDay = () => {
|
||||
|
||||
// 键盘导航
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (e.key === 'ArrowLeft' && hasPreviousDay.value) {
|
||||
previousDay()
|
||||
} else if (e.key === 'ArrowRight' && !isToday.value) {
|
||||
} else if (e.key === 'ArrowRight' && hasNextDay.value) {
|
||||
nextDay()
|
||||
} else if (e.key === 'Escape') {
|
||||
goBack()
|
||||
|
||||
Reference in New Issue
Block a user