29 Commits

Author SHA1 Message Date
b69db53f0a GitHub Actions: 添加响应输出日志和延迟指令打印优化 2026-01-29 19:42:13 +08:00
39d4f9c730 GitHub Actions: 移除冗余的命令输出日志以简化脚本 2026-01-29 19:12:08 +08:00
cee6bc1027 GitHub Actions: 移除冗余的命令输出日志以简化脚本 2026-01-29 19:05:27 +08:00
1984e01785 GitHub Actions: 替换通知环境变量来源为 secrets,并支持基于数字的延迟指令解析 2026-01-29 19:03:51 +08:00
31f32bdb63 GitHub Actions: 优化通知脚本,添加 HTTP 状态码输出以提升调试便利性 2026-01-29 17:28:01 +08:00
9360bd0131 GitHub Actions: 替换通知环境变量来源为 vars 提高配置灵活性 2026-01-29 17:24:38 +08:00
1ca3d15c2f GitHub Actions: 添加构建成功后的通知步骤,提高流程透明度 2026-01-29 17:22:05 +08:00
e428f5bddb 修复大图查看页面空值处理问题:增加对 image 对象的可选链操作,防止因空值导致的错误显示或逻辑异常 2026-01-29 16:21:35 +08:00
c32cb8da3f 大图查看页面优化:新增图片切换淡入淡出动画,并通过预加载提升切换速度 2026-01-29 16:15:59 +08:00
5e3defc63d 移除未使用的底部控制栏高度变量 2026-01-29 12:37:59 +08:00
ea99a31248 限制日历面板在图片显示区域内拖动,并优化初始位置计算逻辑 2026-01-29 12:37:15 +08:00
86d6517267 前端页面细节优化:图片查看页面悬浮窗按钮被遮挡的问题修复;首页大图使用独立的变量控制,不受下方筛选结果影响 2026-01-29 12:37:07 +08:00
2e5eeaf425 优化首页图片的加载模式,对于日期变更但图片未更新的情况进行提示 2026-01-29 12:22:59 +08:00
7433bc2e7e 大图查看页面增加日历展示,支持显示节假日信息,提高实用性 2026-01-28 20:23:41 +08:00
8bc9b44a14 大图查看页面增加日历展示,支持显示节假日信息,提高实用性 2026-01-28 20:22:55 +08:00
617c1d0967 新增 YAML 标签支持并优化配置文件保存逻辑 2026-01-28 16:25:34 +08:00
b31711d86d 重命名docker-compose配置文件以使用“.yaml”标准扩展名,更新相关脚本引用 2026-01-28 15:53:50 +08:00
62ac723c95 新增后端管理页面 2026-01-28 15:35:01 +08:00
5334ee9d41 优化Docker配置:支持通过默认值动态设置环境变量 2026-01-28 14:22:46 +08:00
c8a7ea5490 优化Docker配置:支持通过环境变量自定义宿主机与容器端口映射 2026-01-28 13:59:37 +08:00
61de3f44dc 优化Docker配置:支持设置GOPROXY和NPM_REGISTRY构建参数 2026-01-28 13:42:26 +08:00
69abe80264 优化Docker配置:添加时区环境变量TZ,默认设置为Asia/Shanghai 2026-01-28 12:52:21 +08:00
34848e7b91 添加docker compose部署脚本 2026-01-28 12:42:28 +08:00
9ec9a2ba91 新增配置调试功能:支持输出完整配置项与环境变量覆盖详情,调整 cron.daily_spec 默认值 2026-01-28 09:08:26 +08:00
3c1f29e4ef 修复单元测试,调整 handleImageResponse 调用参数数量 2026-01-27 20:54:27 +08:00
ae82557545 调整路由逻辑,更新 API 路径前缀为 /api/v1 2026-01-27 20:45:52 +08:00
fecbd014b3 支持自定义 Cache-Control 头配置,优化图片响应缓存逻辑 2026-01-27 20:32:03 +08:00
907e158f44 更新前端:实现无限滚动加载及前后日期可用性检测 2026-01-27 18:38:49 +08:00
f7fc3fa506 修正脚本:规范二进制文件命名并优化打包目录处理 2026-01-27 17:01:51 +08:00
36 changed files with 3125 additions and 292 deletions

View File

@@ -7,7 +7,7 @@ output
scripts
# docs
*.md
docker-compose.yml
docker-compose.yaml
Dockerfile
.dockerignore
webapp/.vscode

View File

@@ -38,3 +38,25 @@ jobs:
- name: Test
run: CGO_ENABLED=0 go test -v ./...
- name: Notification
if: success()
env:
NOTIFY_CURLS: ${{ secrets.NOTIFY_CURLS }}
run: |
if [ -n "$NOTIFY_CURLS" ]; then
printf "%s\n" "$NOTIFY_CURLS" | while read -r line; do
if [ -n "$line" ]; then
if [[ "$line" =~ ^[0-9] ]]; then
echo "Pausing for $line ms....."
sleep "$(awk "BEGIN {print $line/1000}")" || true
elif [[ "$line" == curl* ]]; then
echo "Response:"
eval "$line -w \"\\nHTTP Status: %{http_code}\\n\"" || true
else
eval "$line" || true
fi
echo ""
fi
done
fi

View File

@@ -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` (控制应用监听端口及容器内部端口)

View File

@@ -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

View File

@@ -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
View 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:-}

View File

@@ -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=

View File

@@ -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()))

View File

@@ -3,44 +3,46 @@ package config
import (
"fmt"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Log LogConfig `mapstructure:"log"`
API APIConfig `mapstructure:"api"`
Cron CronConfig `mapstructure:"cron"`
Retention RetentionConfig `mapstructure:"retention"`
DB DBConfig `mapstructure:"db"`
Storage StorageConfig `mapstructure:"storage"`
Admin AdminConfig `mapstructure:"admin"`
Token TokenConfig `mapstructure:"token"`
Feature FeatureConfig `mapstructure:"feature"`
Web WebConfig `mapstructure:"web"`
Server ServerConfig `mapstructure:"server" yaml:"server"`
Log LogConfig `mapstructure:"log" yaml:"log"`
API APIConfig `mapstructure:"api" yaml:"api"`
Cron CronConfig `mapstructure:"cron" yaml:"cron"`
Retention RetentionConfig `mapstructure:"retention" yaml:"retention"`
DB DBConfig `mapstructure:"db" yaml:"db"`
Storage StorageConfig `mapstructure:"storage" yaml:"storage"`
Admin AdminConfig `mapstructure:"admin" yaml:"admin"`
Token TokenConfig `mapstructure:"token" yaml:"token"`
Feature FeatureConfig `mapstructure:"feature" yaml:"feature"`
Web WebConfig `mapstructure:"web" yaml:"web"`
}
type ServerConfig struct {
Port int `mapstructure:"port"`
BaseURL string `mapstructure:"base_url"`
Port int `mapstructure:"port" yaml:"port"`
BaseURL string `mapstructure:"base_url" yaml:"base_url"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
Filename string `mapstructure:"filename"` // 业务日志文件名
DBFilename string `mapstructure:"db_filename"` // 数据库日志文件名
MaxSize int `mapstructure:"max_size"` // 每个日志文件最大大小 (MB)
MaxBackups int `mapstructure:"max_backups"` // 保留旧日志文件最大个数
MaxAge int `mapstructure:"max_age"` // 保留旧日志文件最大天数
Compress bool `mapstructure:"compress"` // 是否压缩旧日志文件
LogConsole bool `mapstructure:"log_console"` // 是否同时输出到控制台
ShowDBLog bool `mapstructure:"show_db_log"` // 是否在控制台显示数据库日志
DBLogLevel string `mapstructure:"db_log_level"` // 数据库日志级别: debug, info, warn, error
Level string `mapstructure:"level" yaml:"level"`
Filename string `mapstructure:"filename" yaml:"filename"` // 业务日志文件名
DBFilename string `mapstructure:"db_filename" yaml:"db_filename"` // 数据库日志文件名
MaxSize int `mapstructure:"max_size" yaml:"max_size"` // 每个日志文件最大大小 (MB)
MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` // 保留旧日志文件最大个数
MaxAge int `mapstructure:"max_age" yaml:"max_age"` // 保留旧日志文件最大天数
Compress bool `mapstructure:"compress" yaml:"compress"` // 是否压缩旧日志文件
LogConsole bool `mapstructure:"log_console" yaml:"log_console"` // 是否同时输出到控制台
ShowDBLog bool `mapstructure:"show_db_log" yaml:"show_db_log"` // 是否在控制台显示数据库日志
DBLogLevel string `mapstructure:"db_log_level" yaml:"db_log_level"` // 数据库日志级别: debug, info, warn, error
}
func (c LogConfig) GetLevel() string { return c.Level }
@@ -55,65 +57,65 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
type APIConfig struct {
Mode string `mapstructure:"mode"` // local | redirect
Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect
}
type CronConfig struct {
Enabled bool `mapstructure:"enabled"`
DailySpec string `mapstructure:"daily_spec"`
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
DailySpec string `mapstructure:"daily_spec" yaml:"daily_spec"`
}
type RetentionConfig struct {
Days int `mapstructure:"days"`
Days int `mapstructure:"days" yaml:"days"`
}
type DBConfig struct {
Type string `mapstructure:"type"` // sqlite/mysql/postgres
DSN string `mapstructure:"dsn"`
Type string `mapstructure:"type" yaml:"type"` // sqlite/mysql/postgres
DSN string `mapstructure:"dsn" yaml:"dsn"`
}
type StorageConfig struct {
Type string `mapstructure:"type"` // local/s3/webdav
Local LocalConfig `mapstructure:"local"`
S3 S3Config `mapstructure:"s3"`
WebDAV WebDAVConfig `mapstructure:"webdav"`
Type string `mapstructure:"type" yaml:"type"` // local/s3/webdav
Local LocalConfig `mapstructure:"local" yaml:"local"`
S3 S3Config `mapstructure:"s3" yaml:"s3"`
WebDAV WebDAVConfig `mapstructure:"webdav" yaml:"webdav"`
}
type LocalConfig struct {
Root string `mapstructure:"root"`
Root string `mapstructure:"root" yaml:"root"`
}
type S3Config struct {
Endpoint string `mapstructure:"endpoint"`
Region string `mapstructure:"region"`
Bucket string `mapstructure:"bucket"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
PublicURLPrefix string `mapstructure:"public_url_prefix"`
ForcePathStyle bool `mapstructure:"force_path_style"`
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
Region string `mapstructure:"region" yaml:"region"`
Bucket string `mapstructure:"bucket" yaml:"bucket"`
AccessKey string `mapstructure:"access_key" yaml:"access_key"`
SecretKey string `mapstructure:"secret_key" yaml:"secret_key"`
PublicURLPrefix string `mapstructure:"public_url_prefix" yaml:"public_url_prefix"`
ForcePathStyle bool `mapstructure:"force_path_style" yaml:"force_path_style"`
}
type WebDAVConfig struct {
URL string `mapstructure:"url"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
PublicURLPrefix string `mapstructure:"public_url_prefix"`
URL string `mapstructure:"url" yaml:"url"`
Username string `mapstructure:"username" yaml:"username"`
Password string `mapstructure:"password" yaml:"password"`
PublicURLPrefix string `mapstructure:"public_url_prefix" yaml:"public_url_prefix"`
}
type AdminConfig struct {
PasswordBcrypt string `mapstructure:"password_bcrypt"`
PasswordBcrypt string `mapstructure:"password_bcrypt" yaml:"password_bcrypt"`
}
type TokenConfig struct {
DefaultTTL string `mapstructure:"default_ttl"`
DefaultTTL string `mapstructure:"default_ttl" yaml:"default_ttl"`
}
type FeatureConfig struct {
WriteDailyFiles bool `mapstructure:"write_daily_files"`
WriteDailyFiles bool `mapstructure:"write_daily_files" yaml:"write_daily_files"`
}
type WebConfig struct {
Path string `mapstructure:"path"`
Path string `mapstructure:"path" yaml:"path"`
}
// Bing 默认配置 (内置)
@@ -156,7 +158,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")
@@ -192,10 +194,15 @@ func Init(configPath string) error {
targetConfigPath = "data/config.yaml"
}
fmt.Printf("Config file not found, creating default config at %s\n", targetConfigPath)
if err := v.SafeWriteConfigAs(targetConfigPath); err != nil {
var defaultCfg Config
if err := v.Unmarshal(&defaultCfg); err == nil {
data, _ := yaml.Marshal(&defaultCfg)
if err := os.WriteFile(targetConfigPath, data, 0644); err != nil {
fmt.Printf("Warning: Failed to create default config file: %v\n", err)
}
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
@@ -236,24 +243,67 @@ func GetConfig() *Config {
}
func SaveConfig(cfg *Config) error {
v.Set("server", cfg.Server)
v.Set("log", cfg.Log)
v.Set("api", cfg.API)
v.Set("cron", cfg.Cron)
v.Set("retention", cfg.Retention)
v.Set("db", cfg.DB)
v.Set("storage", cfg.Storage)
v.Set("admin", cfg.Admin)
v.Set("token", cfg.Token)
v.Set("feature", cfg.Feature)
v.Set("web", cfg.Web)
return v.WriteConfig()
configLock.Lock()
defer configLock.Unlock()
// 1. 使用 yaml.v3 序列化,它会尊重结构体字段顺序及 yaml 标签
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal config: %v", err)
}
// 2. 获取当前使用的配置文件路径
targetPath := v.ConfigFileUsed()
if targetPath == "" {
targetPath = "data/config.yaml" // 默认回退路径
}
// 3. 直接写入文件,绕过 viper 的字母序排序逻辑
if err := os.WriteFile(targetPath, data, 0644); err != nil {
return fmt.Errorf("failed to write config file: %v", err)
}
// 4. 同步更新内存中的全局配置对象
GlobalConfig = cfg
return nil
}
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 {

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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 (

View File

@@ -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 {

View File

@@ -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
View 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 "部署任务完成。"

View File

@@ -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/ # 核心库

View File

@@ -14,6 +14,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0",
"lunar-javascript": "^1.7.7",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
@@ -2022,6 +2023,12 @@
"vue": ">=3.0.1"
}
},
"node_modules/lunar-javascript": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz",
"integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -18,6 +18,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0",
"lunar-javascript": "^1.7.7",
"reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",

View File

@@ -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>

View File

@@ -0,0 +1,634 @@
<template>
<div class="fixed inset-0 z-40">
<div
ref="calendarPanel"
class="fixed bg-gradient-to-br from-black/30 via-black/20 to-black/30 backdrop-blur-xl rounded-3xl p-3 sm:p-4 w-[calc(100%-1rem)] sm:w-full max-w-[95vw] sm:max-w-[420px] shadow-2xl border border-white/10 cursor-move select-none"
:style="{ left: panelPos.x + 'px', top: panelPos.y + 'px' }"
@mousedown="startDrag"
@touchstart="startDrag"
@click.stop
>
<!-- 拖动手柄指示器 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/20 rounded-full"></div>
<!-- 头部 -->
<div class="flex items-center justify-between mb-3 sm:mb-4 mt-2">
<div class="flex items-center gap-1.5 sm:gap-2 flex-1">
<button
@click.stop="previousMonth"
:disabled="!canGoPrevious"
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<div class="text-center flex-1">
<!-- 年月选择器 -->
<div class="flex items-center justify-center gap-1 sm:gap-1.5 mb-0.5">
<!-- 年份选择 -->
<Select v-model="currentYearString" @update:modelValue="onYearChange">
<SelectTrigger
class="w-[90px] sm:w-[105px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
@click.stop
@mousedown.stop
>
<SelectValue>{{ currentYear }}</SelectValue>
</SelectTrigger>
<SelectContent class="max-h-[300px] bg-gray-900/95 backdrop-blur-xl border-white/20">
<SelectItem
v-for="year in yearOptions"
:key="year"
:value="String(year)"
class="text-white hover:bg-white/20 focus:bg-white/20 cursor-pointer"
>
{{ year }}
</SelectItem>
</SelectContent>
</Select>
<!-- 月份选择 -->
<Select v-model="currentMonthString" @update:modelValue="onMonthChange">
<SelectTrigger
class="w-[65px] sm:w-[75px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
@click.stop
@mousedown.stop
>
<SelectValue>{{ currentMonth + 1 }}</SelectValue>
</SelectTrigger>
<SelectContent class="bg-gray-900/95 backdrop-blur-xl border-white/20">
<SelectItem
v-for="month in 12"
:key="month"
:value="String(month - 1)"
class="text-white hover:bg-white/20 focus:bg-white/20 cursor-pointer"
>
{{ month }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="text-[10px] sm:text-xs text-white/60 drop-shadow-md font-['Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] leading-relaxed">
{{ lunarMonthYear }}
</div>
</div>
<button
@click.stop="nextMonth"
:disabled="!canGoNext"
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
<button
@click.stop="$emit('close')"
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors ml-1.5 sm:ml-2"
>
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white drop-shadow-lg" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- 星期标题 -->
<div class="grid grid-cols-7 gap-1 sm:gap-1.5 mb-1.5 sm:mb-2 pointer-events-none">
<div
v-for="(day, idx) in weekDays"
:key="day"
class="text-center text-[11px] sm:text-[13px] font-medium py-1 sm:py-1.5 drop-shadow-md leading-none"
:class="idx === 0 || idx === 6 ? 'text-red-300/80' : 'text-white/70'"
>
{{ day }}
</div>
</div>
<!-- 日期格子 -->
<div class="grid grid-cols-7 gap-1 sm:gap-1.5">
<div
v-for="(day, index) in calendarDays"
:key="index"
class="relative aspect-square flex flex-col items-center justify-center rounded-lg transition-opacity pointer-events-none py-0.5 sm:py-1"
:class="[
day.isCurrentMonth && !day.isFuture ? 'text-white' : 'text-white/25',
day.isToday ? 'bg-blue-400/40 ring-2 ring-blue-300/50' : '',
day.isSelected ? 'bg-white/30 ring-1 ring-white/40' : '',
day.isFuture ? 'opacity-40' : '',
day.isWeekend && day.isCurrentMonth ? 'text-red-200/90' : '',
(day.apiHoliday?.isOffDay || (!day.apiHoliday && day.isWeekend)) ? 'text-red-300' : ''
]"
>
<!-- 休息/上班标记 (API优先其次周末) - 使用圆形SVG -->
<div
v-if="day.isCurrentMonth && (day.apiHoliday || day.isWeekend)"
class="absolute top-0 right-0 w-[14px] h-[14px] sm:w-4 sm:h-4"
>
<svg viewBox="0 0 20 20" class="w-full h-full drop-shadow-md">
<circle
cx="10"
cy="10"
r="9"
:fill="day.apiHoliday ? (day.apiHoliday.isOffDay ? '#ef4444' : '#3b82f6') : '#ef4444'"
opacity="0.65"
/>
<text
x="9.8"
y="10.5"
text-anchor="middle"
dominant-baseline="middle"
fill="white"
font-size="11"
font-weight="bold"
font-family="'Microsoft YaHei UI','Microsoft YaHei','PingFang SC','Hiragino Sans GB',sans-serif"
>
{{ day.apiHoliday ? (day.apiHoliday.isOffDay ? '休' : '班') : '休' }}
</text>
</svg>
</div>
<!-- 公历日期 -->
<div
class="text-[13px] sm:text-[15px] font-medium drop-shadow-md font-['Helvetica','Arial',sans-serif] leading-none mb-0.5 sm:mb-1"
:class="(day.apiHoliday?.isOffDay || (!day.apiHoliday && day.isWeekend)) ? 'text-red-300 font-bold' : ''"
>
{{ day.day }}
</div>
<!-- 农历/节日/节气 (不显示API节假日名称) -->
<div
class="text-[9px] sm:text-[10px] leading-tight drop-shadow-sm font-['Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] text-center px-0.5"
:class="[
day.festival || day.solarTerm || day.lunarFestival ? 'text-red-300 font-semibold' : 'text-white/60'
]"
>
{{ day.festival || day.solarTerm || day.lunarFestival || day.lunarDay }}
</div>
</div>
</div>
<!-- 今日按钮 -->
<div class="mt-3 sm:mt-4 flex justify-center">
<button
@click.stop="goToToday"
class="px-4 sm:px-5 py-1 sm:py-1.5 bg-white/15 hover:bg-white/30 text-white rounded-lg text-[11px] sm:text-xs font-medium transition-all hover:scale-105 active:scale-95 drop-shadow-lg"
>
回到今天
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Solar } from 'lunar-javascript'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getHolidaysByYear, getHolidayByDate, type Holidays, type HolidayDay } from '@/lib/holiday-service'
interface CalendarDay {
day: number
isCurrentMonth: boolean
isToday: boolean
isSelected: boolean
isFuture: boolean
isWeekend: boolean
isHoliday: boolean
holidayName: string
apiHoliday: HolidayDay | null // API返回的假期信息
lunarDay: string
festival: string
lunarFestival: string
solarTerm: string
date: Date
}
const props = defineProps<{
selectedDate: string // YYYY-MM-DD
}>()
const emit = defineEmits<{
close: []
}>()
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
// 日历面板位置
const calendarPanel = ref<HTMLElement | null>(null)
const panelPos = ref({ x: 0, y: 0 })
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
// 计算图片实际显示区域与ImageView保持一致
const getImageDisplayBounds = () => {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
// 必应图片通常是16:9或类似宽高比
const imageAspectRatio = 16 / 9
const windowAspectRatio = windowWidth / windowHeight
let displayWidth: number
let displayHeight: number
let offsetX: number
let offsetY: number
if (windowAspectRatio > imageAspectRatio) {
// 窗口更宽,图片上下占满,左右留黑边
displayHeight = windowHeight
displayWidth = displayHeight * imageAspectRatio
offsetX = (windowWidth - displayWidth) / 2
offsetY = 0
} else {
// 窗口更高,图片左右占满,上下留黑边
displayWidth = windowWidth
displayHeight = displayWidth / imageAspectRatio
offsetX = 0
offsetY = (windowHeight - displayHeight) / 2
}
return {
left: offsetX,
top: offsetY,
right: offsetX + displayWidth,
bottom: offsetY + displayHeight,
width: displayWidth,
height: displayHeight
}
}
// 初始化面板位置(移动端居中,桌面端右上角,限制在图片显示区域内)
const initPanelPosition = () => {
if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds()
const isMobile = window.innerWidth < 640 // sm breakpoint
if (isMobile) {
// 移动端:在图片区域内居中显示
const panelWidth = Math.min(bounds.width - 16, window.innerWidth - 16)
const panelHeight = 580 // 估计高度
panelPos.value = {
x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2),
y: Math.max(bounds.top + 8, bounds.top + (bounds.height - panelHeight) / 2)
}
} else {
// 桌面端:在图片区域右上角
const panelWidth = Math.min(420, bounds.width * 0.9)
const panelHeight = 600
panelPos.value = {
x: bounds.right - panelWidth - 40,
y: Math.max(bounds.top + 80, bounds.top + (bounds.height - panelHeight) / 2)
}
}
}
}
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth())
const isChangingMonth = ref(false)
// 假期数据
const holidaysData = ref<Map<number, Holidays | null>>(new Map())
const loadingHolidays = ref(false)
// 字符串版本的年月用于Select组件
const currentYearString = computed({
get: () => String(currentYear.value),
set: (val: string) => {
currentYear.value = Number(val)
}
})
const currentMonthString = computed({
get: () => String(currentMonth.value),
set: (val: string) => {
currentMonth.value = Number(val)
}
})
// 年份改变处理
const onYearChange = (value: any) => {
if (value !== null && value !== undefined) {
currentYear.value = Number(value)
}
}
// 月份改变处理
const onMonthChange = (value: any) => {
if (value !== null && value !== undefined) {
currentMonth.value = Number(value)
}
}
// 生成年份选项从2009年到当前年份+10年
const yearOptions = computed(() => {
const currentYearValue = new Date().getFullYear()
const years: number[] = []
for (let year = currentYearValue - 30; year <= currentYearValue + 10; year++) {
years.push(year)
}
return years
})
// 计算是否可以切换月份(不限制)
const canGoPrevious = computed(() => {
return !isChangingMonth.value
})
const canGoNext = computed(() => {
return !isChangingMonth.value
})
// 初始化为选中的日期
watch(() => props.selectedDate, (newDate) => {
if (newDate) {
const date = new Date(newDate)
currentYear.value = date.getFullYear()
currentMonth.value = date.getMonth()
}
}, { immediate: true })
// 初始化位置
initPanelPosition()
// 加载假期数据
const loadHolidaysForYear = async (year: number) => {
if (holidaysData.value.has(year)) {
return
}
loadingHolidays.value = true
try {
const data = await getHolidaysByYear(year)
holidaysData.value.set(year, data)
} catch (error) {
console.error(`加载${year}年假期数据失败:`, error)
holidaysData.value.set(year, null)
} finally {
loadingHolidays.value = false
}
}
// 组件挂载时加载当前年份的假期数据
onMounted(() => {
const currentYearValue = currentYear.value
loadHolidaysForYear(currentYearValue)
// 预加载前后一年的数据
loadHolidaysForYear(currentYearValue - 1)
loadHolidaysForYear(currentYearValue + 1)
})
// 监听年份变化,加载对应的假期数据
watch(currentYear, (newYear) => {
loadHolidaysForYear(newYear)
// 预加载前后一年
loadHolidaysForYear(newYear - 1)
loadHolidaysForYear(newYear + 1)
})
// 开始拖动
const startDrag = (e: MouseEvent | TouchEvent) => {
const target = e.target as HTMLElement
// 如果点击的是按钮或其子元素,不触发拖拽
if (target.closest('button') || target.closest('[class*="grid"]')) {
return
}
e.preventDefault()
isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
dragStart.value = {
x: clientX - panelPos.value.x,
y: clientY - panelPos.value.y
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
}
// 拖动中
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
if (e instanceof TouchEvent) {
e.preventDefault()
}
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y
// 限制在图片实际显示区域内
if (calendarPanel.value) {
const rect = calendarPanel.value.getBoundingClientRect()
const bounds = getImageDisplayBounds()
const minX = bounds.left
const maxX = bounds.right - rect.width
const minY = bounds.top
const maxY = bounds.bottom - rect.height
panelPos.value = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
}
}
}
// 停止拖动
const stopDrag = () => {
isDragging.value = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
}
// 农历月份年份
const lunarMonthYear = computed(() => {
const solar = Solar.fromDate(new Date(currentYear.value, currentMonth.value, 15))
const lunar = solar.getLunar()
return `${lunar.getYearInChinese()}${lunar.getMonthInChinese()}`
})
// 获取日历天数
const calendarDays = computed<CalendarDay[]>(() => {
const year = currentYear.value
const month = currentMonth.value
// 当月第一天
const firstDay = new Date(year, month, 1)
const firstDayWeek = firstDay.getDay()
// 当月最后一天
const lastDay = new Date(year, month + 1, 0)
const lastDate = lastDay.getDate()
// 上月最后几天
const prevLastDay = new Date(year, month, 0)
const prevLastDate = prevLastDay.getDate()
const days: CalendarDay[] = []
const today = new Date()
today.setHours(0, 0, 0, 0)
// 填充上月日期
for (let i = firstDayWeek - 1; i >= 0; i--) {
const day = prevLastDate - i
const date = new Date(year, month - 1, day)
days.push(createDayObject(date, false))
}
// 填充当月日期
for (let day = 1; day <= lastDate; day++) {
const date = new Date(year, month, day)
days.push(createDayObject(date, true))
}
// 填充下月日期
const remainingDays = 42 - days.length // 6行7列
for (let day = 1; day <= remainingDays; day++) {
const date = new Date(year, month + 1, day)
days.push(createDayObject(date, false))
}
return days
})
// 创建日期对象
const createDayObject = (date: Date, isCurrentMonth: boolean): CalendarDay => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const selectedDate = new Date(props.selectedDate)
selectedDate.setHours(0, 0, 0, 0)
// 转换为农历
const solar = Solar.fromDate(date)
const lunar = solar.getLunar()
// 获取节日
const festivals = solar.getFestivals()
const festival = festivals.length > 0 ? festivals[0] : ''
// 获取农历节日
const lunarFestivals = lunar.getFestivals()
const lunarFestival = lunarFestivals.length > 0 ? lunarFestivals[0] : ''
// 获取节气
const solarTerm = lunar.getJieQi()
// 获取API假期数据 - 使用本地时间避免时区偏移
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const yearHolidays = holidaysData.value.get(date.getFullYear())
const apiHoliday = getHolidayByDate(yearHolidays || null, dateStr)
// 检查是否为假期使用lunar-javascript的节日信息
let isHoliday = false
let holidayName = ''
try {
if (festival || lunarFestival) {
// 常见法定节假日
const legalHolidays = ['元旦', '春节', '清明', '劳动节', '端午', '中秋', '国庆']
const holidayNames = [festival, lunarFestival].filter(Boolean)
for (const name of holidayNames) {
if (legalHolidays.some(legal => name.includes(legal))) {
isHoliday = true
holidayName = name
break
}
}
}
} catch (e) {
console.debug('假期信息获取失败:', e)
}
// 判断是否为周末(周六或周日)
const isWeekend = date.getDay() === 0 || date.getDay() === 6
// 农历日期显示
let lunarDay = lunar.getDayInChinese()
if (lunar.getDay() === 1) {
lunarDay = lunar.getMonthInChinese() + '月'
}
return {
day: date.getDate(),
isCurrentMonth,
isToday: date.getTime() === today.getTime(),
isSelected: date.getTime() === selectedDate.getTime(),
isFuture: date > today,
isWeekend,
isHoliday,
holidayName,
apiHoliday,
lunarDay,
festival,
lunarFestival,
solarTerm,
date
}
}
// 上一月
const previousMonth = () => {
if (!canGoPrevious.value) return
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value--
} else {
currentMonth.value--
}
}
// 下一月
const nextMonth = () => {
if (!canGoNext.value) return
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value++
} else {
currentMonth.value++
}
}
// 回到今天
const goToToday = () => {
const today = new Date()
currentYear.value = today.getFullYear()
currentMonth.value = today.getMonth()
}
// 不再支持点击日期选择
// 日历仅作为台历展示功能
// 清理
import { onUnmounted } from 'vue'
onUnmounted(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
})
</script>

View File

@@ -181,6 +181,9 @@ export class BingPaperApiService {
// 导出默认实例
export const bingPaperApi = new BingPaperApiService()
// 为了兼容性,也导出为 apiService
export const apiService = bingPaperApi
// 导出便捷方法
export const {
login,

View File

@@ -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 // 每页数量

View File

@@ -0,0 +1,65 @@
// 假期API类型定义
export interface HolidayDay {
/** 节日名称 */
name: string;
/** 日期, ISO 8601 格式 */
date: string;
/** 是否为休息日 */
isOffDay: boolean;
}
export interface Holidays {
/** 完整年份, 整数。*/
year: number;
/** 所用国务院文件网址列表 */
papers: string[];
days: HolidayDay[];
}
// 假期数据缓存
const holidayCache = new Map<number, Holidays>();
/**
* 获取指定年份的假期数据
*/
export async function getHolidaysByYear(year: number): Promise<Holidays | null> {
// 检查缓存
if (holidayCache.has(year)) {
return holidayCache.get(year)!;
}
try {
const response = await fetch(`https://api.coding.icu/cnholiday/${year}.json`);
if (!response.ok) {
console.warn(`获取${year}年假期数据失败: ${response.status}`);
return null;
}
const data: Holidays = await response.json();
// 缓存数据
holidayCache.set(year, data);
return data;
} catch (error) {
console.error(`获取${year}年假期数据出错:`, error);
return null;
}
}
/**
* 获取指定日期的假期信息
*/
export function getHolidayByDate(holidays: Holidays | null, dateStr: string): HolidayDay | null {
if (!holidays) return null;
return holidays.days.find(day => day.date === dateStr) || null;
}
/**
* 清除假期缓存
*/
export function clearHolidayCache() {
holidayCache.clear();
}

View File

@@ -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'
}
}
}
/**
* 解析响应数据
*/

View File

@@ -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(),
@@ -15,11 +17,20 @@ const router = createRouter({
}
},
{
path: '/image/:date',
path: '/image/:date?',
name: 'ImageView',
component: ImageView,
meta: {
title: '图片详情'
},
beforeEnter: (to, _from, next) => {
// 如果没有提供日期参数,重定向到今天的日期
if (!to.params.date) {
const today = new Date().toISOString().split('T')[0]
next({ path: `/image/${today}`, replace: true })
} else {
next()
}
}
},
{
@@ -29,13 +40,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()
})

20
webapp/src/types/lunar-javascript.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
declare module 'lunar-javascript' {
export class Solar {
static fromDate(date: Date): Solar
getLunar(): Lunar
getFestivals(): string[]
}
export class Lunar {
getYearInChinese(): string
getMonthInChinese(): string
getDayInChinese(): string
getDay(): number
getJieQi(): string
getFestivals(): string[]
}
export class HolidayUtil {
// Add methods if needed
}
}

199
webapp/src/views/Admin.vue Normal file
View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -6,42 +6,52 @@
<div class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
</div>
<div v-else-if="todayImage" class="relative h-full w-full group">
<div v-else-if="latestImage" class="relative h-full w-full group">
<!-- 背景图片 -->
<div class="absolute inset-0">
<img
:src="getTodayImageUrl()"
:alt="todayImage.title || 'Today\'s Bing Image'"
:src="getLatestImageUrl()"
:alt="latestImage.title || 'Latest Bing Image'"
class="w-full h-full object-cover"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent"></div>
</div>
<!-- 更新提示仅在非今日图片时显示 - 右上角简约徽章 -->
<div v-if="!isToday" class="absolute top-4 right-4 md:top-8 md:right-8 z-20">
<div class="flex items-center gap-1.5 px-3 py-1.5 bg-black/30 backdrop-blur-md rounded-full border border-white/10 text-white/70 text-xs">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>下次更新 {{ nextUpdateTime }}</span>
</div>
</div>
<!-- 内容叠加层 -->
<div class="relative h-full flex flex-col justify-end p-8 md:p-16 z-10">
<div class="max-w-4xl space-y-4 transform transition-transform duration-500 group-hover:translate-y-[-10px]">
<div class="inline-block px-4 py-2 bg-white/10 backdrop-blur-md rounded-full text-white/90 text-sm font-medium">
今日精选 · {{ formatDate(todayImage.date) }}
{{ isToday ? '今日精选' : '最新图片' }} · {{ formatDate(latestImage.date) }}
</div>
<h1 class="text-4xl md:text-6xl font-bold text-white leading-tight drop-shadow-2xl">
{{ todayImage.title || '必应每日一图' }}
{{ latestImage.title || '必应每日一图' }}
</h1>
<p v-if="todayImage.copyright" class="text-lg md:text-xl text-white/80 max-w-2xl">
{{ todayImage.copyright }}
<p v-if="latestImage.copyright" class="text-lg md:text-xl text-white/80 max-w-2xl">
{{ latestImage.copyright }}
</p>
<div class="flex gap-4 pt-4">
<button
@click="viewImage(todayImage.date!)"
@click="viewImage(latestImage.date!)"
class="px-6 py-3 bg-white text-gray-900 rounded-lg font-semibold hover:bg-white/90 transition-all transform hover:scale-105 shadow-xl"
>
查看大图
</button>
<button
v-if="todayImage.copyrightlink"
@click="openCopyrightLink(todayImage.copyrightlink)"
v-if="latestImage.copyrightlink"
@click="openCopyrightLink(latestImage.copyrightlink)"
class="px-6 py-3 bg-white/10 backdrop-blur-md text-white rounded-lg font-semibold hover:bg-white/20 transition-all border border-white/30"
>
了解更多
@@ -173,13 +183,18 @@
<span>加载中...</span>
</div>
<button
<div
v-else-if="hasMore"
ref="loadMoreTrigger"
class="inline-block"
>
<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">
已加载全部图片
@@ -230,7 +245,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useTodayImage, useImageList } from '@/composables/useImages'
import { useImageList } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
import { useRouter } from 'vue-router'
import {
@@ -243,11 +258,61 @@ import {
const router = useRouter()
// 获取今日图片
const { image: todayImage, loading: todayLoading } = useTodayImage()
// 顶部最新图片(独立加载,不受筛选影响)
const latestImage = ref<any>(null)
const todayLoading = ref(false)
// 获取图片列表(使用服务端分页和筛选)
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(30)
// 历史图片列表(使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(15)
// 加载顶部最新图片
const loadLatestImage = async () => {
todayLoading.value = true
try {
const params = { page: 1, page_size: 1 }
const result = await bingPaperApi.getImages(params)
if (result.length > 0) {
latestImage.value = result[0]
}
} catch (error) {
console.error('Failed to load latest image:', error)
} finally {
todayLoading.value = false
}
}
// 初始化加载顶部图片
onMounted(() => {
loadLatestImage()
})
// 判断最新图片是否为今天的图片
const isToday = computed(() => {
if (!latestImage.value?.date) return false
const imageDate = new Date(latestImage.value.date).toDateString()
const today = new Date().toDateString()
return imageDate === today
})
// 计算下次更新时间提示
const nextUpdateTime = computed(() => {
const now = new Date()
const hours = now.getHours()
// 更新时间点8:20, 12:20, 16:20, 20:20, 0:20, 4:20
const updateHours = [0, 4, 8, 12, 16, 20]
const updateMinute = 20
// 找到下一个更新时间点
for (const hour of updateHours) {
if (hours < hour || (hours === hour && now.getMinutes() < updateMinute)) {
return `${String(hour).padStart(2, '0')}:${String(updateMinute).padStart(2, '0')}`
}
}
// 如果今天没有下一个更新点,返回明天的第一个更新点
return `次日 00:20`
})
// 筛选相关状态
const selectedYear = ref('')
@@ -258,11 +323,15 @@ const imageRefs = ref<(HTMLElement | null)[]>([])
const imageVisibility = ref<boolean[]>([])
let observer: IntersectionObserver | null = null
// 计算可用的年份列表基于当前日期生成从2020年到当前年份
// 无限滚动加载
const loadMoreTrigger = ref<HTMLElement | null>(null)
let loadMoreObserver: IntersectionObserver | null = null
// 计算可用的年份列表基于当前日期生成计算前20年
const availableYears = computed(() => {
const currentYear = new Date().getFullYear()
const years: number[] = []
for (let year = currentYear; year >= 2020; year--) {
for (let year = currentYear; year >= currentYear - 20; year--) {
years.push(year)
}
return years
@@ -360,16 +429,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 +458,53 @@ 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(() => {
loadLatestImage()
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()
}
})
// 格式化日期
@@ -417,9 +518,10 @@ const formatDate = (dateStr?: string) => {
})
}
// 获取今日图片 URL
const getTodayImageUrl = () => {
return bingPaperApi.getTodayImageUrl('UHD', 'jpg')
// 获取最新图片 URL顶部大图使用UHD高清
const getLatestImageUrl = () => {
if (!latestImage.value?.date) return ''
return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg')
}
// 获取图片 URL缩略图 - 使用较小分辨率节省流量)
@@ -448,3 +550,25 @@ const openCopyrightLink = (link: string) => {
overflow: hidden;
}
</style>
<style>
/* 隐藏滚动条但保持滚动功能 */
body {
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
body::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
html {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
html::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
</style>

View File

@@ -1,23 +1,28 @@
<template>
<div class="fixed inset-0 bg-black z-50 overflow-hidden">
<!-- 加载状态 -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center">
<!-- 加载状态动画过渡中不显示 -->
<div v-if="loading && !imageTransitioning" class="absolute inset-0 flex items-center justify-center">
<div class="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
</div>
<!-- 主要内容 -->
<div v-else-if="image" class="relative h-full w-full">
<div v-else-if="image || imageTransitioning" class="relative h-full w-full">
<!-- 全屏图片 -->
<div class="absolute inset-0 flex items-center justify-center">
<img
:src="getFullImageUrl()"
:alt="image.title || 'Bing Image'"
class="max-w-full max-h-full object-contain"
:alt="image?.title || 'Bing Image'"
class="max-w-full max-h-full object-contain transition-opacity duration-500 ease-in-out"
:style="{ opacity: imageOpacity }"
/>
</div>
<!-- 顶部工具栏 -->
<div class="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 to-transparent p-6 z-10">
<div
v-show="!showCalendar"
class="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 to-transparent p-6 z-10 transition-opacity duration-300"
:class="{ 'opacity-0 pointer-events-none': showCalendar }"
>
<div class="flex items-center justify-between max-w-7xl mx-auto">
<button
@click="goBack"
@@ -30,18 +35,25 @@
</button>
<div class="text-white/80 text-sm">
{{ formatDate(image.date) }}
{{ formatDate(image?.date) }}
</div>
</div>
</div>
<!-- 信息悬浮层类似 Windows 聚焦 -->
<div
v-if="showInfo"
v-if="showInfo && !showCalendar && image"
ref="infoPanel"
class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 transform transition-opacity duration-300 z-10 select-none"
:style="{ left: infoPanelPos.x + 'px', top: infoPanelPos.y + 'px' }"
:class="{ 'opacity-100': showInfo, 'opacity-0': !showInfo }"
class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 z-20 select-none"
:class="{
'opacity-100': showInfo && !showCalendar,
'opacity-0 pointer-events-none': showCalendar,
'transition-opacity duration-300': !isDragging
}"
:style="{
transform: `translate(${infoPanelPos.x}px, ${infoPanelPos.y}px)`,
willChange: isDragging ? 'transform' : 'auto'
}"
>
<!-- 拖动手柄 -->
<div
@@ -51,16 +63,16 @@
></div>
<h2 class="text-lg font-bold text-white mb-2 mt-2">
{{ image.title || '未命名' }}
{{ image?.title || '未命名' }}
</h2>
<p v-if="image.copyright" class="text-white/80 text-xs mb-3 leading-relaxed">
<p v-if="image?.copyright" class="text-white/80 text-xs mb-3 leading-relaxed">
{{ image.copyright }}
</p>
<!-- 版权详情链接 -->
<a
v-if="image.copyrightlink"
v-if="image?.copyrightlink"
:href="image.copyrightlink"
target="_blank"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white rounded-lg text-xs font-medium transition-all group"
@@ -83,13 +95,17 @@
</div>
<!-- 底部控制栏 -->
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 z-10">
<div
v-show="!showCalendar"
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 z-10 transition-opacity duration-300"
:class="{ 'opacity-0 pointer-events-none': showCalendar }"
>
<div class="flex items-center justify-between max-w-7xl mx-auto">
<!-- 日期切换按钮 -->
<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 +116,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>
@@ -110,6 +126,19 @@
</button>
</div>
<!-- 右侧按钮组 -->
<div class="flex items-center gap-3">
<!-- 日历按钮 -->
<button
@click="toggleCalendar(true)"
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"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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"></path>
</svg>
<span class="hidden sm:inline">日历</span>
</button>
<!-- 信息按钮 -->
<button
v-if="!showInfo"
@@ -124,6 +153,14 @@
</div>
</div>
</div>
</div>
<!-- 日历弹窗 -->
<Calendar
v-if="showCalendar"
:selected-date="currentDate"
@close="toggleCalendar(false)"
/>
<!-- 错误状态 -->
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center">
@@ -141,33 +178,96 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useImageByDate } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
import Calendar from '@/components/ui/calendar/Calendar.vue'
const route = useRoute()
const router = useRouter()
// 从 localStorage 读取日历状态,默认关闭
const CALENDAR_STATE_KEY = 'imageView_showCalendar'
// 获取初始日历状态
const getInitialCalendarState = (): boolean => {
try {
const stored = localStorage.getItem(CALENDAR_STATE_KEY)
return stored === 'true'
} catch (error) {
console.warn('Failed to read calendar state from localStorage:', error)
return false
}
}
const currentDate = ref(route.params.date as string)
const showInfo = ref(true)
const showCalendar = ref(getInitialCalendarState())
const navigating = ref(false)
const imageOpacity = ref(1)
const imageTransitioning = 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 })
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
let animationFrameId: number | null = null
// 初始化浮窗位置(居中偏下
const initPanelPosition = () => {
if (typeof window !== 'undefined') {
// 计算图片实际显示区域考虑图片宽高比和object-contain
const getImageDisplayBounds = () => {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const panelWidth = Math.min(windowWidth * 0.9, 448) // max-w-md = 448px
// 必应图片通常是16:9或类似宽高比
// 使用UHD分辨率: 1920x1080 (16:9)
const imageAspectRatio = 16 / 9
const windowAspectRatio = windowWidth / windowHeight
let displayWidth: number
let displayHeight: number
let offsetX: number
let offsetY: number
if (windowAspectRatio > imageAspectRatio) {
// 窗口更宽,图片上下占满,左右留黑边
displayHeight = windowHeight
displayWidth = displayHeight * imageAspectRatio
offsetX = (windowWidth - displayWidth) / 2
offsetY = 0
} else {
// 窗口更高,图片左右占满,上下留黑边
displayWidth = windowWidth
displayHeight = displayWidth / imageAspectRatio
offsetX = 0
offsetY = (windowHeight - displayHeight) / 2
}
return {
left: offsetX,
top: offsetY,
right: offsetX + displayWidth,
bottom: offsetY + displayHeight,
width: displayWidth,
height: displayHeight
}
}
// 初始化浮窗位置(居中偏下,限制在图片显示区域内)
const initPanelPosition = () => {
if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds()
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
infoPanelPos.value = {
x: (windowWidth - panelWidth) / 2,
y: windowHeight - 200 // 距底部200px
x: bounds.left + (bounds.width - panelWidth) / 2,
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px,避免与控制栏重叠
}
}
}
@@ -185,42 +285,61 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
y: clientY - infoPanelPos.value.y
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mousemove', onDrag, { passive: false })
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
}
// 拖动中
// 拖动中 - 使用 requestAnimationFrame 优化性能
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
if (e instanceof TouchEvent) {
e.preventDefault()
// 取消之前的动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
}
// 使用 requestAnimationFrame 进行节流优化
animationFrameId = requestAnimationFrame(() => {
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y
// 限制在视口内
// 限制在图片实际显示区域内考虑底部控制栏高度约80px
if (infoPanel.value) {
const rect = infoPanel.value.getBoundingClientRect()
const maxX = window.innerWidth - rect.width
const maxY = window.innerHeight - rect.height
const bounds = getImageDisplayBounds()
const minX = bounds.left
const maxX = bounds.right - rect.width
const minY = bounds.top
const maxY = bounds.bottom - rect.height - 80 // 预留底部控制栏空间
infoPanelPos.value = {
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
}
}
animationFrameId = null
})
}
// 停止拖动
const stopDrag = () => {
isDragging.value = false
// 清理动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
@@ -230,9 +349,58 @@ 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()
// 监听showCalendar变化并自动保存到localStorage
watch(showCalendar, (newValue) => {
try {
localStorage.setItem(CALENDAR_STATE_KEY, String(newValue))
} catch (error) {
console.warn('Failed to save calendar state:', error)
}
})
// 监听日期变化,检测前后日期可用性
watch(currentDate, () => {
checkAdjacentDates()
}, { immediate: true })
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return ''
@@ -245,17 +413,68 @@ 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')
}
// 预加载图片
const preloadImage = (url: string): Promise<void> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => reject(new Error('Failed to load image'))
img.src = url
})
}
// 预加载图片和数据
const preloadImageAndData = async (date: string): Promise<void> => {
try {
// 并行预加载图片和数据
const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg')
await Promise.all([
preloadImage(imageUrl),
bingPaperApi.getImageMetaByDate(date)
])
} catch (error) {
console.warn('Failed to preload image or data:', error)
// 即使预加载失败也继续
}
}
// 切换日期并带动画
const switchToDate = async (newDate: string) => {
if (imageTransitioning.value) return
imageTransitioning.value = true
// 1. 淡出当前图片的同时预加载新图片和数据
imageOpacity.value = 0
const preloadPromise = preloadImageAndData(newDate)
// 2. 等待淡出动画完成500ms
await Promise.all([
new Promise(resolve => setTimeout(resolve, 500)),
preloadPromise
])
// 3. 更新日期(此时图片和数据已经预加载完成)
currentDate.value = newDate
router.replace(`/image/${newDate}`)
// 4. 等待一个微任务,确保 DOM 更新
await new Promise(resolve => setTimeout(resolve, 50))
// 5. 淡入新图片
imageOpacity.value = 1
// 6. 等待淡入完成
await new Promise(resolve => setTimeout(resolve, 500))
imageTransitioning.value = false
}
// copyrightlink 现在是完整的 URL无需额外处理
// 返回首页
@@ -264,49 +483,54 @@ const goBack = () => {
}
// 前一天
const previousDay = () => {
if (navigating.value) return
const previousDay = async () => {
if (navigating.value || !hasPreviousDay.value || imageTransitioning.value) return
navigating.value = true
const date = new Date(currentDate.value)
date.setDate(date.getDate() - 1)
const newDate = date.toISOString().split('T')[0]
currentDate.value = newDate
router.replace(`/image/${newDate}`)
await switchToDate(newDate)
setTimeout(() => {
navigating.value = false
}, 500)
}
// 后一天
const nextDay = () => {
if (navigating.value || isToday.value) return
const nextDay = async () => {
if (navigating.value || !hasNextDay.value || imageTransitioning.value) return
navigating.value = true
const date = new Date(currentDate.value)
date.setDate(date.getDate() + 1)
const newDate = date.toISOString().split('T')[0]
currentDate.value = newDate
router.replace(`/image/${newDate}`)
await switchToDate(newDate)
setTimeout(() => {
navigating.value = false
}, 500)
}
// 切换日历状态watch会自动保存
const toggleCalendar = (state: boolean) => {
showCalendar.value = state
}
// 键盘导航
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') {
if (showCalendar.value) {
toggleCalendar(false)
} else {
goBack()
}
} else if (e.key === 'i' || e.key === 'I') {
showInfo.value = !showInfo.value
} else if (e.key === 'c' || e.key === 'C') {
toggleCalendar(!showCalendar.value)
}
}
@@ -326,6 +550,11 @@ onUnmounted(() => {
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
// 清理动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
}
}
})
</script>

View File

@@ -37,15 +37,34 @@ export default defineConfig(({ mode }) => {
// 入口文件名
entryFileNames: 'assets/[name]-[hash].js',
// 手动分割代码
manualChunks: {
// 将 Vue 相关代码单独打包
'vue-vendor': ['vue', 'vue-router'],
// 将 UI 组件库单独打包(如果有的话)
// 'ui-vendor': ['其他UI库']
manualChunks: (id) => {
// 将 node_modules 中的依赖分割成不同的 chunk
if (id.includes('node_modules')) {
// Vue 核心库
if (id.includes('vue') || id.includes('vue-router')) {
return 'vue-vendor'
}
// Radix UI / Reka UI 组件库
if (id.includes('reka-ui') || id.includes('@vueuse')) {
return 'ui-vendor'
}
// Lucide 图标库
if (id.includes('lucide-vue-next')) {
return 'icons'
}
// lunar-javascript 农历库
if (id.includes('lunar-javascript')) {
return 'lunar'
}
// 其他 node_modules 依赖
return 'vendor'
}
}
}
},
// 增加 chunk 大小警告限制
chunkSizeWarningLimit: 1000
},
// 开发服务器配置
server: {
port: 5173,