38 Commits

Author SHA1 Message Date
e48959d5ba 优化存储逻辑:为 WebDAV、Local 和 S3 增加 Exists 方法;调整图片处理逻辑以避免重复存储变体;新增调试日志以便于排查问题 2026-01-30 16:34:20 +08:00
852a72c597 前端构建错误修复 2026-01-30 15:56:22 +08:00
2660970320 为 Swagger 文档添加按需抓取支持:新增 enableOnDemandFetch 配置项和相关接口的 202/404 状态描述 2026-01-30 15:50:20 +08:00
fb636b9450 优化按需抓取逻辑:改为异步处理以提升性能,并为相关接口新增 202 状态支持 2026-01-30 15:49:51 +08:00
8ef66b2cb1 国家地区接口优化 2026-01-30 15:45:55 +08:00
6868a67ed7 调整悬浮信息层样式:优化字体大小、间距及适配性,以提升界面视觉一致性 2026-01-30 14:30:06 +08:00
52fb8c9328 优化图片处理逻辑:优先使用最小变体以节省流量,并新增 normalizeImageUrl 函数处理相对路径问题 2026-01-30 14:28:14 +08:00
845dc7d045 更新默认配置:将 api.mode 的默认值从 local 修改为 redirect 2026-01-30 13:41:21 +08:00
93690e10d3 增加多地区每日图片抓取能力 2026-01-30 13:33:40 +08:00
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
52 changed files with 5060 additions and 456 deletions

View File

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

View File

@@ -38,3 +38,25 @@ jobs:
- name: Test - name: Test
run: CGO_ENABLED=0 go test -v ./... 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

@@ -36,11 +36,15 @@ BingPaper 支持通过配置文件YAML和环境变量进行配置。
- `mode`: API 行为模式。 - `mode`: API 行为模式。
- `local`: (默认) 接口直接返回图片的二进制流,适合图片存储对外部不可见的情况。 - `local`: (默认) 接口直接返回图片的二进制流,适合图片存储对外部不可见的情况。
- `redirect`: 接口返回 302 重定向到图片的 `PublicURL`,适合配合 S3 或 WebDAV 的公共访问。 - `redirect`: 接口返回 302 重定向到图片的 `PublicURL`,适合配合 S3 或 WebDAV 的公共访问。
- `enable_mkt_fallback`: 当请求的地区不存在或无数据时,是否允许兜底回退到默认地区或任意可用地区,默认 `true`
#### cron (定时任务) #### cron (定时任务)
- `enabled`: 是否启用定时抓取,默认 `true` - `enabled`: 是否启用定时抓取,默认 `true`
- `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。 - `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。
#### fetcher (抓取配置)
- `regions`: 需要抓取的地区编码列表(如 `zh-CN`, `en-US` 等)。如果不设置,默认为包括主要国家在内的 17 个地区。
#### retention (数据保留) #### retention (数据保留)
- `days`: 图片及元数据保留天数。超过此天数的数据可能会被清理任务处理。设置为 `0` 表示永久保留,不进行自动清理。默认 `0` - `days`: 图片及元数据保留天数。超过此天数的数据可能会被清理任务处理。设置为 `0` 表示永久保留,不进行自动清理。默认 `0`
@@ -101,3 +105,5 @@ BingPaper 支持通过配置文件YAML和环境变量进行配置。
- `BINGPAPER_STORAGE_TYPE=s3` - `BINGPAPER_STORAGE_TYPE=s3`
- `BINGPAPER_STORAGE_S3_BUCKET=my-images` - `BINGPAPER_STORAGE_S3_BUCKET=my-images`
- `BINGPAPER_ADMIN_PASSWORD_BCRYPT="$2a$10$..."` - `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 # Stage 1: Build Frontend
FROM --platform=$BUILDPLATFORM node:20-alpine AS node-builder FROM --platform=$BUILDPLATFORM node:20-alpine AS node-builder
ARG NPM_REGISTRY
WORKDIR /webapp WORKDIR /webapp
# 复制 package.json 和 lock 文件以利用 layer 缓存 # 复制 package.json 和 lock 文件以利用 layer 缓存
COPY webapp/package*.json ./ 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 # 使用 npm ci 以获得更快且可重现的构建(如果存在 package-lock.json
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi 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 # Stage 2: Build Backend
FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine AS builder FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine AS builder
ARG GOPROXY
ENV GOPROXY=$GOPROXY
# 安装 Git 以支持某些 Go 模块依赖 # 安装 Git 以支持某些 Go 模块依赖
RUN apk add --no-cache git RUN apk add --no-cache git
WORKDIR /app WORKDIR /app

View File

@@ -57,6 +57,7 @@ go run .
- `GET /api/v1/image/random`:返回随机图片 - `GET /api/v1/image/random`:返回随机图片
- `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片 - `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片
- **查询参数** - **查询参数**
- `mkt`:地区编码 (zh-CN, en-US, ja-JP 等),默认 `zh-CN`
- `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD` - `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD`
- `format`:格式 (jpg),默认 `jpg` - `format`:格式 (jpg),默认 `jpg`

View File

@@ -16,10 +16,12 @@ log:
api: api:
mode: local # local | redirect mode: local # local | redirect
enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区
enable_on_demand_fetch: false # 是否开启按需抓取(当数据库中没有请求的地区图片时,实时从 Bing 抓取)
cron: cron:
enabled: true enabled: true
daily_spec: "0 10 * * *" daily_spec: "20 8-23/4 * * *"
retention: retention:
days: 0 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

@@ -413,6 +413,12 @@ const docTemplate = `{
"in": "path", "in": "path",
"required": true "required": true
}, },
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{ {
"type": "string", "type": "string",
"default": "UHD", "default": "UHD",
@@ -434,6 +440,24 @@ const docTemplate = `{
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -455,6 +479,12 @@ const docTemplate = `{
"name": "date", "name": "date",
"in": "path", "in": "path",
"required": true "required": true
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
} }
], ],
"responses": { "responses": {
@@ -463,6 +493,24 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -478,6 +526,12 @@ const docTemplate = `{
], ],
"summary": "获取随机图片", "summary": "获取随机图片",
"parameters": [ "parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{ {
"type": "string", "type": "string",
"default": "UHD", "default": "UHD",
@@ -499,6 +553,24 @@ const docTemplate = `{
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -513,12 +585,38 @@ const docTemplate = `{
"image" "image"
], ],
"summary": "获取随机图片元数据", "summary": "获取随机图片元数据",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -534,6 +632,12 @@ const docTemplate = `{
], ],
"summary": "获取今日图片", "summary": "获取今日图片",
"parameters": [ "parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{ {
"type": "string", "type": "string",
"default": "UHD", "default": "UHD",
@@ -555,6 +659,24 @@ const docTemplate = `{
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -569,12 +691,38 @@ const docTemplate = `{
"image" "image"
], ],
"summary": "获取今日图片元数据", "summary": "获取今日图片元数据",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -614,6 +762,12 @@ const docTemplate = `{
"description": "按月份过滤 (格式: YYYY-MM)", "description": "按月份过滤 (格式: YYYY-MM)",
"name": "month", "name": "month",
"in": "query" "in": "query"
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
} }
], ],
"responses": { "responses": {
@@ -628,12 +782,66 @@ const docTemplate = `{
} }
} }
} }
},
"/images/global/today": {
"get": {
"description": "获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取所有地区的今日图片列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
}
}
}
}
},
"/regions": {
"get": {
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取支持的地区列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/util.Region"
}
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"config.APIConfig": { "config.APIConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
"enableMktFallback": {
"description": "当请求的地区不存在时,是否回退到默认地区",
"type": "boolean"
},
"enableOnDemandFetch": {
"description": "是否启用按需抓取",
"type": "boolean"
},
"mode": { "mode": {
"description": "local | redirect", "description": "local | redirect",
"type": "string" "type": "string"
@@ -666,6 +874,9 @@ const docTemplate = `{
"feature": { "feature": {
"$ref": "#/definitions/config.FeatureConfig" "$ref": "#/definitions/config.FeatureConfig"
}, },
"fetcher": {
"$ref": "#/definitions/config.FetcherConfig"
},
"log": { "log": {
"$ref": "#/definitions/config.LogConfig" "$ref": "#/definitions/config.LogConfig"
}, },
@@ -717,6 +928,17 @@ const docTemplate = `{
} }
} }
}, },
"config.FetcherConfig": {
"type": "object",
"properties": {
"regions": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"config.LocalConfig": { "config.LocalConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -917,6 +1139,9 @@ const docTemplate = `{
"hsh": { "hsh": {
"type": "string" "type": "string"
}, },
"mkt": {
"type": "string"
},
"quiz": { "quiz": {
"type": "string" "type": "string"
}, },
@@ -1006,6 +1231,17 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
} }
},
"util.Region": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -407,6 +407,12 @@
"in": "path", "in": "path",
"required": true "required": true
}, },
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{ {
"type": "string", "type": "string",
"default": "UHD", "default": "UHD",
@@ -428,6 +434,24 @@
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -449,6 +473,12 @@
"name": "date", "name": "date",
"in": "path", "in": "path",
"required": true "required": true
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
} }
], ],
"responses": { "responses": {
@@ -457,6 +487,24 @@
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -472,6 +520,12 @@
], ],
"summary": "获取随机图片", "summary": "获取随机图片",
"parameters": [ "parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{ {
"type": "string", "type": "string",
"default": "UHD", "default": "UHD",
@@ -493,6 +547,24 @@
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -507,12 +579,38 @@
"image" "image"
], ],
"summary": "获取随机图片元数据", "summary": "获取随机图片元数据",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -528,6 +626,12 @@
], ],
"summary": "获取今日图片", "summary": "获取今日图片",
"parameters": [ "parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{ {
"type": "string", "type": "string",
"default": "UHD", "default": "UHD",
@@ -549,6 +653,24 @@
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -563,12 +685,38 @@
"image" "image"
], ],
"summary": "获取今日图片元数据", "summary": "获取今日图片元数据",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/handlers.ImageMetaResp" "$ref": "#/definitions/handlers.ImageMetaResp"
} }
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
} }
} }
} }
@@ -608,6 +756,12 @@
"description": "按月份过滤 (格式: YYYY-MM)", "description": "按月份过滤 (格式: YYYY-MM)",
"name": "month", "name": "month",
"in": "query" "in": "query"
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
} }
], ],
"responses": { "responses": {
@@ -622,12 +776,66 @@
} }
} }
} }
},
"/images/global/today": {
"get": {
"description": "获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取所有地区的今日图片列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
}
}
}
}
},
"/regions": {
"get": {
"description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
"produces": [
"application/json"
],
"tags": [
"image"
],
"summary": "获取支持的地区列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/util.Region"
}
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"config.APIConfig": { "config.APIConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
"enableMktFallback": {
"description": "当请求的地区不存在时,是否回退到默认地区",
"type": "boolean"
},
"enableOnDemandFetch": {
"description": "是否启用按需抓取",
"type": "boolean"
},
"mode": { "mode": {
"description": "local | redirect", "description": "local | redirect",
"type": "string" "type": "string"
@@ -660,6 +868,9 @@
"feature": { "feature": {
"$ref": "#/definitions/config.FeatureConfig" "$ref": "#/definitions/config.FeatureConfig"
}, },
"fetcher": {
"$ref": "#/definitions/config.FetcherConfig"
},
"log": { "log": {
"$ref": "#/definitions/config.LogConfig" "$ref": "#/definitions/config.LogConfig"
}, },
@@ -711,6 +922,17 @@
} }
} }
}, },
"config.FetcherConfig": {
"type": "object",
"properties": {
"regions": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"config.LocalConfig": { "config.LocalConfig": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -911,6 +1133,9 @@
"hsh": { "hsh": {
"type": "string" "type": "string"
}, },
"mkt": {
"type": "string"
},
"quiz": { "quiz": {
"type": "string" "type": "string"
}, },
@@ -1000,6 +1225,17 @@
"type": "string" "type": "string"
} }
} }
},
"util.Region": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "string"
}
}
} }
}, },
"securityDefinitions": { "securityDefinitions": {

View File

@@ -2,6 +2,12 @@ basePath: /api/v1
definitions: definitions:
config.APIConfig: config.APIConfig:
properties: properties:
enableMktFallback:
description: 当请求的地区不存在时,是否回退到默认地区
type: boolean
enableOnDemandFetch:
description: 是否启用按需抓取
type: boolean
mode: mode:
description: local | redirect description: local | redirect
type: string type: string
@@ -23,6 +29,8 @@ definitions:
$ref: '#/definitions/config.DBConfig' $ref: '#/definitions/config.DBConfig'
feature: feature:
$ref: '#/definitions/config.FeatureConfig' $ref: '#/definitions/config.FeatureConfig'
fetcher:
$ref: '#/definitions/config.FetcherConfig'
log: log:
$ref: '#/definitions/config.LogConfig' $ref: '#/definitions/config.LogConfig'
retention: retention:
@@ -56,6 +64,13 @@ definitions:
writeDailyFiles: writeDailyFiles:
type: boolean type: boolean
type: object type: object
config.FetcherConfig:
properties:
regions:
items:
type: string
type: array
type: object
config.LocalConfig: config.LocalConfig:
properties: properties:
root: root:
@@ -190,6 +205,8 @@ definitions:
type: string type: string
hsh: hsh:
type: string type: string
mkt:
type: string
quiz: quiz:
type: string type: string
startdate: startdate:
@@ -248,6 +265,13 @@ definitions:
updated_at: updated_at:
type: string type: string
type: object type: object
util.Region:
properties:
label:
type: string
value:
type: string
type: object
host: localhost:8080 host: localhost:8080
info: info:
contact: {} contact: {}
@@ -501,6 +525,10 @@ paths:
name: date name: date
required: true required: true
type: string type: string
- description: 地区编码 (如 zh-CN, en-US)
in: query
name: mkt
type: string
- default: UHD - default: UHD
description: 分辨率 description: 分辨率
in: query in: query
@@ -518,6 +546,18 @@ paths:
description: OK description: OK
schema: schema:
type: file type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取指定日期图片 summary: 获取指定日期图片
tags: tags:
- image - image
@@ -530,6 +570,10 @@ paths:
name: date name: date
required: true required: true
type: string type: string
- description: 地区编码 (如 zh-CN, en-US)
in: query
name: mkt
type: string
produces: produces:
- application/json - application/json
responses: responses:
@@ -537,6 +581,18 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/handlers.ImageMetaResp' $ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取指定日期图片元数据 summary: 获取指定日期图片元数据
tags: tags:
- image - image
@@ -544,6 +600,10 @@ paths:
get: get:
description: 随机返回一张已抓取的图片流或重定向 description: 随机返回一张已抓取的图片流或重定向
parameters: parameters:
- description: 地区编码 (如 zh-CN, en-US)
in: query
name: mkt
type: string
- default: UHD - default: UHD
description: 分辨率 description: 分辨率
in: query in: query
@@ -561,12 +621,29 @@ paths:
description: OK description: OK
schema: schema:
type: file type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取随机图片 summary: 获取随机图片
tags: tags:
- image - image
/image/random/meta: /image/random/meta:
get: get:
description: 随机获取一张已抓取图片的元数据 description: 随机获取一张已抓取图片的元数据
parameters:
- description: 地区编码 (如 zh-CN, en-US)
in: query
name: mkt
type: string
produces: produces:
- application/json - application/json
responses: responses:
@@ -574,6 +651,18 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/handlers.ImageMetaResp' $ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取随机图片元数据 summary: 获取随机图片元数据
tags: tags:
- image - image
@@ -581,6 +670,10 @@ paths:
get: get:
description: 根据参数返回今日必应图片流或重定向 description: 根据参数返回今日必应图片流或重定向
parameters: parameters:
- description: 地区编码 (如 zh-CN, en-US)
in: query
name: mkt
type: string
- default: UHD - default: UHD
description: 分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, description: 分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480,
640x480, 640x360, 480x360, 400x240, 320x240) 640x480, 640x360, 480x360, 400x240, 320x240)
@@ -599,12 +692,29 @@ paths:
description: OK description: OK
schema: schema:
type: file type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取今日图片 summary: 获取今日图片
tags: tags:
- image - image
/image/today/meta: /image/today/meta:
get: get:
description: 获取今日必应图片的标题、版权等元数据 description: 获取今日必应图片的标题、版权等元数据
parameters:
- description: 地区编码 (如 zh-CN, en-US)
in: query
name: mkt
type: string
produces: produces:
- application/json - application/json
responses: responses:
@@ -612,6 +722,18 @@ paths:
description: OK description: OK
schema: schema:
$ref: '#/definitions/handlers.ImageMetaResp' $ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取今日图片元数据 summary: 获取今日图片元数据
tags: tags:
- image - image
@@ -637,6 +759,10 @@ paths:
in: query in: query
name: month name: month
type: string type: string
- description: 地区编码 (如 zh-CN, en-US)
in: query
name: mkt
type: string
produces: produces:
- application/json - application/json
responses: responses:
@@ -649,6 +775,36 @@ paths:
summary: 获取图片列表 summary: 获取图片列表
tags: tags:
- image - image
/images/global/today:
get:
description: 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.ImageMetaResp'
type: array
summary: 获取所有地区的今日图片列表
tags:
- image
/regions:
get:
description: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/util.Region'
type: array
summary: 获取支持的地区列表
tags:
- image
securityDefinitions: securityDefinitions:
BearerAuth: BearerAuth:
in: header in: header

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strings"
"BingPaper/internal/config" "BingPaper/internal/config"
"BingPaper/internal/cron" "BingPaper/internal/cron"
@@ -36,12 +37,27 @@ func Init(webFS embed.FS, configPath string) *gin.Engine {
// 2. 初始化日志 // 2. 初始化日志
util.InitLogger(cfg.Log) 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("Application configuration loaded")
util.Logger.Info("├─ Config file", zap.String("path", config.GetRawViper().ConfigFileUsed())) util.Logger.Info("├─ Config file ", zap.String("path", config.GetRawViper().ConfigFileUsed()))
util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type)) util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type))
util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type)) util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type))
util.Logger.Info("─ Server ", zap.Int("port", cfg.Server.Port)) util.Logger.Info("─ Server ", zap.Int("port", cfg.Server.Port))
util.Logger.Info("└─ Active Mkt ", zap.Strings("regions", cfg.Fetcher.Regions))
// 根据存储类型输出更多信息 // 根据存储类型输出更多信息
switch cfg.Storage.Type { switch cfg.Storage.Type {
@@ -133,5 +149,6 @@ func LogWelcomeInfo() {
fmt.Printf(" - 管理后台: %s/admin\n", baseURL) fmt.Printf(" - 管理后台: %s/admin\n", baseURL)
fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL) fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL)
fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL) fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL)
fmt.Printf(" - 激活地区: %s\n", strings.Join(cfg.Fetcher.Regions, ", "))
fmt.Println("---------------------------------------------------------") fmt.Println("---------------------------------------------------------")
} }

View File

@@ -3,44 +3,49 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/spf13/viper" "github.com/spf13/viper"
"gopkg.in/yaml.v3"
"BingPaper/internal/util"
) )
type Config struct { type Config struct {
Server ServerConfig `mapstructure:"server"` Server ServerConfig `mapstructure:"server" yaml:"server"`
Log LogConfig `mapstructure:"log"` Log LogConfig `mapstructure:"log" yaml:"log"`
API APIConfig `mapstructure:"api"` API APIConfig `mapstructure:"api" yaml:"api"`
Cron CronConfig `mapstructure:"cron"` Cron CronConfig `mapstructure:"cron" yaml:"cron"`
Retention RetentionConfig `mapstructure:"retention"` Retention RetentionConfig `mapstructure:"retention" yaml:"retention"`
DB DBConfig `mapstructure:"db"` DB DBConfig `mapstructure:"db" yaml:"db"`
Storage StorageConfig `mapstructure:"storage"` Storage StorageConfig `mapstructure:"storage" yaml:"storage"`
Admin AdminConfig `mapstructure:"admin"` Admin AdminConfig `mapstructure:"admin" yaml:"admin"`
Token TokenConfig `mapstructure:"token"` Token TokenConfig `mapstructure:"token" yaml:"token"`
Feature FeatureConfig `mapstructure:"feature"` Feature FeatureConfig `mapstructure:"feature" yaml:"feature"`
Web WebConfig `mapstructure:"web"` Web WebConfig `mapstructure:"web" yaml:"web"`
Fetcher FetcherConfig `mapstructure:"fetcher" yaml:"fetcher"`
} }
type ServerConfig struct { type ServerConfig struct {
Port int `mapstructure:"port"` Port int `mapstructure:"port" yaml:"port"`
BaseURL string `mapstructure:"base_url"` BaseURL string `mapstructure:"base_url" yaml:"base_url"`
} }
type LogConfig struct { type LogConfig struct {
Level string `mapstructure:"level"` Level string `mapstructure:"level" yaml:"level"`
Filename string `mapstructure:"filename"` // 业务日志文件名 Filename string `mapstructure:"filename" yaml:"filename"` // 业务日志文件名
DBFilename string `mapstructure:"db_filename"` // 数据库日志文件名 DBFilename string `mapstructure:"db_filename" yaml:"db_filename"` // 数据库日志文件名
MaxSize int `mapstructure:"max_size"` // 每个日志文件最大大小 (MB) MaxSize int `mapstructure:"max_size" yaml:"max_size"` // 每个日志文件最大大小 (MB)
MaxBackups int `mapstructure:"max_backups"` // 保留旧日志文件最大个数 MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"` // 保留旧日志文件最大个数
MaxAge int `mapstructure:"max_age"` // 保留旧日志文件最大天数 MaxAge int `mapstructure:"max_age" yaml:"max_age"` // 保留旧日志文件最大天数
Compress bool `mapstructure:"compress"` // 是否压缩旧日志文件 Compress bool `mapstructure:"compress" yaml:"compress"` // 是否压缩旧日志文件
LogConsole bool `mapstructure:"log_console"` // 是否同时输出到控制台 LogConsole bool `mapstructure:"log_console" yaml:"log_console"` // 是否同时输出到控制台
ShowDBLog bool `mapstructure:"show_db_log"` // 是否在控制台显示数据库日志 ShowDBLog bool `mapstructure:"show_db_log" yaml:"show_db_log"` // 是否在控制台显示数据库日志
DBLogLevel string `mapstructure:"db_log_level"` // 数据库日志级别: debug, info, warn, error DBLogLevel string `mapstructure:"db_log_level" yaml:"db_log_level"` // 数据库日志级别: debug, info, warn, error
} }
func (c LogConfig) GetLevel() string { return c.Level } func (c LogConfig) GetLevel() string { return c.Level }
@@ -55,65 +60,71 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel } func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
type APIConfig struct { type APIConfig struct {
Mode string `mapstructure:"mode"` // local | redirect Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect
EnableMktFallback bool `mapstructure:"enable_mkt_fallback" yaml:"enable_mkt_fallback"` // 当请求的地区不存在时,是否回退到默认地区
EnableOnDemandFetch bool `mapstructure:"enable_on_demand_fetch" yaml:"enable_on_demand_fetch"` // 是否启用按需抓取
} }
type CronConfig struct { type CronConfig struct {
Enabled bool `mapstructure:"enabled"` Enabled bool `mapstructure:"enabled" yaml:"enabled"`
DailySpec string `mapstructure:"daily_spec"` DailySpec string `mapstructure:"daily_spec" yaml:"daily_spec"`
} }
type RetentionConfig struct { type RetentionConfig struct {
Days int `mapstructure:"days"` Days int `mapstructure:"days" yaml:"days"`
} }
type DBConfig struct { type DBConfig struct {
Type string `mapstructure:"type"` // sqlite/mysql/postgres Type string `mapstructure:"type" yaml:"type"` // sqlite/mysql/postgres
DSN string `mapstructure:"dsn"` DSN string `mapstructure:"dsn" yaml:"dsn"`
} }
type StorageConfig struct { type StorageConfig struct {
Type string `mapstructure:"type"` // local/s3/webdav Type string `mapstructure:"type" yaml:"type"` // local/s3/webdav
Local LocalConfig `mapstructure:"local"` Local LocalConfig `mapstructure:"local" yaml:"local"`
S3 S3Config `mapstructure:"s3"` S3 S3Config `mapstructure:"s3" yaml:"s3"`
WebDAV WebDAVConfig `mapstructure:"webdav"` WebDAV WebDAVConfig `mapstructure:"webdav" yaml:"webdav"`
} }
type LocalConfig struct { type LocalConfig struct {
Root string `mapstructure:"root"` Root string `mapstructure:"root" yaml:"root"`
} }
type S3Config struct { type S3Config struct {
Endpoint string `mapstructure:"endpoint"` Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
Region string `mapstructure:"region"` Region string `mapstructure:"region" yaml:"region"`
Bucket string `mapstructure:"bucket"` Bucket string `mapstructure:"bucket" yaml:"bucket"`
AccessKey string `mapstructure:"access_key"` AccessKey string `mapstructure:"access_key" yaml:"access_key"`
SecretKey string `mapstructure:"secret_key"` SecretKey string `mapstructure:"secret_key" yaml:"secret_key"`
PublicURLPrefix string `mapstructure:"public_url_prefix"` PublicURLPrefix string `mapstructure:"public_url_prefix" yaml:"public_url_prefix"`
ForcePathStyle bool `mapstructure:"force_path_style"` ForcePathStyle bool `mapstructure:"force_path_style" yaml:"force_path_style"`
} }
type WebDAVConfig struct { type WebDAVConfig struct {
URL string `mapstructure:"url"` URL string `mapstructure:"url" yaml:"url"`
Username string `mapstructure:"username"` Username string `mapstructure:"username" yaml:"username"`
Password string `mapstructure:"password"` Password string `mapstructure:"password" yaml:"password"`
PublicURLPrefix string `mapstructure:"public_url_prefix"` PublicURLPrefix string `mapstructure:"public_url_prefix" yaml:"public_url_prefix"`
} }
type AdminConfig struct { type AdminConfig struct {
PasswordBcrypt string `mapstructure:"password_bcrypt"` PasswordBcrypt string `mapstructure:"password_bcrypt" yaml:"password_bcrypt"`
} }
type TokenConfig struct { type TokenConfig struct {
DefaultTTL string `mapstructure:"default_ttl"` DefaultTTL string `mapstructure:"default_ttl" yaml:"default_ttl"`
} }
type FeatureConfig struct { type FeatureConfig struct {
WriteDailyFiles bool `mapstructure:"write_daily_files"` WriteDailyFiles bool `mapstructure:"write_daily_files" yaml:"write_daily_files"`
} }
type WebConfig struct { type WebConfig struct {
Path string `mapstructure:"path"` Path string `mapstructure:"path" yaml:"path"`
}
type FetcherConfig struct {
Regions []string `mapstructure:"regions" yaml:"regions"`
} }
// Bing 默认配置 (内置) // Bing 默认配置 (内置)
@@ -154,9 +165,11 @@ func Init(configPath string) error {
v.SetDefault("log.log_console", true) v.SetDefault("log.log_console", true)
v.SetDefault("log.show_db_log", false) v.SetDefault("log.show_db_log", false)
v.SetDefault("log.db_log_level", "info") v.SetDefault("log.db_log_level", "info")
v.SetDefault("api.mode", "local") v.SetDefault("api.mode", "redirect")
v.SetDefault("api.enable_mkt_fallback", false)
v.SetDefault("api.enable_on_demand_fetch", false)
v.SetDefault("cron.enabled", true) 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("retention.days", 0)
v.SetDefault("db.type", "sqlite") v.SetDefault("db.type", "sqlite")
v.SetDefault("db.dsn", "data/bing_paper.db") v.SetDefault("db.dsn", "data/bing_paper.db")
@@ -165,6 +178,13 @@ func Init(configPath string) error {
v.SetDefault("token.default_ttl", "168h") v.SetDefault("token.default_ttl", "168h")
v.SetDefault("feature.write_daily_files", true) v.SetDefault("feature.write_daily_files", true)
v.SetDefault("web.path", "web") v.SetDefault("web.path", "web")
// 默认抓取所有支持的地区
var defaultRegions []string
for _, r := range util.AllRegions {
defaultRegions = append(defaultRegions, r.Value)
}
v.SetDefault("fetcher.regions", defaultRegions)
v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123 v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123
// 绑定环境变量 // 绑定环境变量
@@ -192,10 +212,15 @@ func Init(configPath string) error {
targetConfigPath = "data/config.yaml" targetConfigPath = "data/config.yaml"
} }
fmt.Printf("Config file not found, creating default config at %s\n", targetConfigPath) 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) fmt.Printf("Warning: Failed to create default config file: %v\n", err)
} }
} }
}
var cfg Config var cfg Config
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
@@ -236,24 +261,67 @@ func GetConfig() *Config {
} }
func SaveConfig(cfg *Config) error { func SaveConfig(cfg *Config) error {
v.Set("server", cfg.Server) configLock.Lock()
v.Set("log", cfg.Log) defer configLock.Unlock()
v.Set("api", cfg.API)
v.Set("cron", cfg.Cron) // 1. 使用 yaml.v3 序列化,它会尊重结构体字段顺序及 yaml 标签
v.Set("retention", cfg.Retention) data, err := yaml.Marshal(cfg)
v.Set("db", cfg.DB) if err != nil {
v.Set("storage", cfg.Storage) return fmt.Errorf("failed to marshal config: %v", err)
v.Set("admin", cfg.Admin) }
v.Set("token", cfg.Token)
v.Set("feature", cfg.Feature) // 2. 获取当前使用的配置文件路径
v.Set("web", cfg.Web) targetPath := v.ConfigFileUsed()
return v.WriteConfig() 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 { func GetRawViper() *viper.Viper {
return v 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 { func GetTokenTTL() time.Duration {
ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL) ttl, err := time.ParseDuration(GetConfig().Token.DefaultTTL)
if err != nil { if err != nil {
@@ -261,3 +329,11 @@ func GetTokenTTL() time.Duration {
} }
return ttl return ttl
} }
// GetDefaultMkt 返回生效的默认地区编码
func (c *Config) GetDefaultMkt() string {
if len(c.Fetcher.Regions) > 0 {
return c.Fetcher.Regions[0]
}
return BingMkt
}

View File

@@ -1,6 +1,9 @@
package config package config
import ( import (
"fmt"
"os"
"strings"
"testing" "testing"
) )
@@ -19,3 +22,46 @@ func TestDefaultConfig(t *testing.T) {
t.Errorf("Expected DB type sqlite, got %s", cfg.DB.Type) 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

@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"BingPaper/internal/config" "BingPaper/internal/config"
"BingPaper/internal/model" "BingPaper/internal/model"
@@ -27,6 +28,7 @@ type ImageVariantResp struct {
type ImageMetaResp struct { type ImageMetaResp struct {
Date string `json:"date"` Date string `json:"date"`
Mkt string `json:"mkt"`
Title string `json:"title"` Title string `json:"title"`
Copyright string `json:"copyright"` Copyright string `json:"copyright"`
CopyrightLink string `json:"copyrightlink"` CopyrightLink string `json:"copyrightlink"`
@@ -41,33 +43,50 @@ type ImageMetaResp struct {
// @Summary 获取今日图片 // @Summary 获取今日图片
// @Description 根据参数返回今日必应图片流或重定向 // @Description 根据参数返回今日必应图片流或重定向
// @Tags image // @Tags image
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)" default(UHD) // @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)" default(UHD)
// @Param format query string false "格式 (jpg)" default(jpg) // @Param format query string false "格式 (jpg)" default(jpg)
// @Produce image/jpeg // @Produce image/jpeg
// @Success 200 {file} binary // @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/today [get] // @Router /image/today [get]
func GetToday(c *gin.Context) { func GetToday(c *gin.Context) {
img, err := image.GetTodayImage() mkt := c.Query("mkt")
if err != nil { img, err := image.GetTodayImage(mkt)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) if err == image.ErrFetchStarted {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
handleImageResponse(c, img) if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 7200) // 2小时
} }
// GetTodayMeta 获取今日图片元数据 // GetTodayMeta 获取今日图片元数据
// @Summary 获取今日图片元数据 // @Summary 获取今日图片元数据
// @Description 获取今日必应图片的标题、版权等元数据 // @Description 获取今日必应图片的标题、版权等元数据
// @Tags image // @Tags image
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json // @Produce json
// @Success 200 {object} ImageMetaResp // @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/today/meta [get] // @Router /image/today/meta [get]
func GetTodayMeta(c *gin.Context) { func GetTodayMeta(c *gin.Context) {
img, err := image.GetTodayImage() mkt := c.Query("mkt")
if err != nil { img, err := image.GetTodayImage(mkt)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) if err == image.ErrFetchStarted {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
if err != nil {
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "public, max-age=7200") // 2小时
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(img))
} }
@@ -75,33 +94,50 @@ func GetTodayMeta(c *gin.Context) {
// @Summary 获取随机图片 // @Summary 获取随机图片
// @Description 随机返回一张已抓取的图片流或重定向 // @Description 随机返回一张已抓取的图片流或重定向
// @Tags image // @Tags image
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Param variant query string false "分辨率" default(UHD) // @Param variant query string false "分辨率" default(UHD)
// @Param format query string false "格式" default(jpg) // @Param format query string false "格式" default(jpg)
// @Produce image/jpeg // @Produce image/jpeg
// @Success 200 {file} binary // @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random [get] // @Router /image/random [get]
func GetRandom(c *gin.Context) { func GetRandom(c *gin.Context) {
img, err := image.GetRandomImage() mkt := c.Query("mkt")
if err != nil { img, err := image.GetRandomImage(mkt)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) if err == image.ErrFetchStarted {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
handleImageResponse(c, img) if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 0) // 禁用缓存
} }
// GetRandomMeta 获取随机图片元数据 // GetRandomMeta 获取随机图片元数据
// @Summary 获取随机图片元数据 // @Summary 获取随机图片元数据
// @Description 随机获取一张已抓取图片的元数据 // @Description 随机获取一张已抓取图片的元数据
// @Tags image // @Tags image
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json // @Produce json
// @Success 200 {object} ImageMetaResp // @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random/meta [get] // @Router /image/random/meta [get]
func GetRandomMeta(c *gin.Context) { func GetRandomMeta(c *gin.Context) {
img, err := image.GetRandomImage() mkt := c.Query("mkt")
if err != nil { img, err := image.GetRandomImage(mkt)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) if err == image.ErrFetchStarted {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
if err != nil {
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(img))
} }
@@ -110,19 +146,27 @@ func GetRandomMeta(c *gin.Context) {
// @Description 根据日期返回图片流或重定向 (yyyy-mm-dd) // @Description 根据日期返回图片流或重定向 (yyyy-mm-dd)
// @Tags image // @Tags image
// @Param date path string true "日期 (yyyy-mm-dd)" // @Param date path string true "日期 (yyyy-mm-dd)"
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Param variant query string false "分辨率" default(UHD) // @Param variant query string false "分辨率" default(UHD)
// @Param format query string false "格式" default(jpg) // @Param format query string false "格式" default(jpg)
// @Produce image/jpeg // @Produce image/jpeg
// @Success 200 {file} binary // @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date} [get] // @Router /image/date/{date} [get]
func GetByDate(c *gin.Context) { func GetByDate(c *gin.Context) {
date := c.Param("date") date := c.Param("date")
img, err := image.GetImageByDate(date) mkt := c.Query("mkt")
if err != nil { img, err := image.GetImageByDate(date, mkt)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) if err == image.ErrFetchStarted {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
handleImageResponse(c, img) if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 604800) // 7天
} }
// GetByDateMeta 获取指定日期图片元数据 // GetByDateMeta 获取指定日期图片元数据
@@ -130,16 +174,25 @@ func GetByDate(c *gin.Context) {
// @Description 根据日期获取图片元数据 (yyyy-mm-dd) // @Description 根据日期获取图片元数据 (yyyy-mm-dd)
// @Tags image // @Tags image
// @Param date path string true "日期 (yyyy-mm-dd)" // @Param date path string true "日期 (yyyy-mm-dd)"
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json // @Produce json
// @Success 200 {object} ImageMetaResp // @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date}/meta [get] // @Router /image/date/{date}/meta [get]
func GetByDateMeta(c *gin.Context) { func GetByDateMeta(c *gin.Context) {
date := c.Param("date") date := c.Param("date")
img, err := image.GetImageByDate(date) mkt := c.Query("mkt")
if err != nil { img, err := image.GetImageByDate(date, mkt)
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) if err == image.ErrFetchStarted {
c.JSON(http.StatusAccepted, gin.H{"message": fmt.Sprintf("On-demand fetch started for region [%s]. Please try again later.", mkt)})
return return
} }
if err != nil {
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "public, max-age=604800") // 7天
c.JSON(http.StatusOK, formatMeta(img)) c.JSON(http.StatusOK, formatMeta(img))
} }
@@ -151,6 +204,7 @@ func GetByDateMeta(c *gin.Context) {
// @Param page query int false "页码 (从1开始)" // @Param page query int false "页码 (从1开始)"
// @Param page_size query int false "每页数量" // @Param page_size query int false "每页数量"
// @Param month query string false "按月份过滤 (格式: YYYY-MM)" // @Param month query string false "按月份过滤 (格式: YYYY-MM)"
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json // @Produce json
// @Success 200 {array} ImageMetaResp // @Success 200 {array} ImageMetaResp
// @Router /images [get] // @Router /images [get]
@@ -159,10 +213,12 @@ func ListImages(c *gin.Context) {
pageStr := c.Query("page") pageStr := c.Query("page")
pageSizeStr := c.Query("page_size") pageSizeStr := c.Query("page_size")
month := c.Query("month") month := c.Query("month")
mkt := c.Query("mkt")
// 记录请求参数,便于排查过滤失效问题 // 记录请求参数,便于排查过滤失效问题
util.Logger.Debug("ListImages parameters", util.Logger.Debug("ListImages parameters",
zap.String("month", month), zap.String("month", month),
zap.String("mkt", mkt),
zap.String("page", pageStr), zap.String("page", pageStr),
zap.String("page_size", pageSizeStr), zap.String("page_size", pageSizeStr),
zap.String("limit", limitStr)) zap.String("limit", limitStr))
@@ -189,7 +245,7 @@ func ListImages(c *gin.Context) {
offset = 0 offset = 0
} }
images, err := image.GetImageList(limit, offset, month) images, err := image.GetImageList(limit, offset, month, mkt)
if err != nil { if err != nil {
util.Logger.Error("ListImages service call failed", zap.Error(err)) util.Logger.Error("ListImages service call failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -198,12 +254,61 @@ func ListImages(c *gin.Context) {
result := []gin.H{} result := []gin.H{}
for _, img := range images { for _, img := range images {
result = append(result, formatMeta(&img)) result = append(result, formatMetaSummary(&img))
} }
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
func handleImageResponse(c *gin.Context, img *model.Image) { // ListGlobalTodayImages 获取所有地区的今日图片列表
// @Summary 获取所有地区的今日图片列表
// @Description 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
// @Tags image
// @Produce json
// @Success 200 {array} ImageMetaResp
// @Router /images/global/today [get]
func ListGlobalTodayImages(c *gin.Context) {
images, err := image.GetAllRegionsTodayImages()
if err != nil {
util.Logger.Error("ListGlobalTodayImages service call failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := []gin.H{}
for _, img := range images {
result = append(result, formatMetaSummary(&img))
}
c.JSON(http.StatusOK, result)
}
func sendImageNotFound(c *gin.Context, mkt string) {
cfg := config.GetConfig().API
message := "image not found"
if mkt != "" {
reasons := []string{}
if !util.IsValidRegion(mkt) {
reasons = append(reasons, fmt.Sprintf("[%s] is not a standard region code", mkt))
} else {
if !cfg.EnableOnDemandFetch {
reasons = append(reasons, "on-demand fetch is disabled")
}
if !cfg.EnableMktFallback {
reasons = append(reasons, "region fallback is disabled")
}
}
if len(reasons) > 0 {
message = fmt.Sprintf("Image not found for region [%s]. Reasons: %s.", mkt, strings.Join(reasons, ", "))
} else {
message = fmt.Sprintf("Image not found for region [%s] even after on-demand fetch and fallback attempts.", mkt)
}
}
c.JSON(http.StatusNotFound, gin.H{"error": message})
}
func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
variant := c.DefaultQuery("variant", "UHD") variant := c.DefaultQuery("variant", "UHD")
format := c.DefaultQuery("format", "jpg") format := c.DefaultQuery("format", "jpg")
@@ -228,22 +333,30 @@ func handleImageResponse(c *gin.Context, img *model.Image) {
mode := config.GetConfig().API.Mode mode := config.GetConfig().API.Mode
if mode == "redirect" { if mode == "redirect" {
if selected.PublicURL != "" { 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) c.Redirect(http.StatusFound, selected.PublicURL)
} else if img.URLBase != "" { } else if img.URLBase != "" {
// 兜底重定向到原始 Bing // 兜底重定向到原始 Bing
bingURL := fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, selected.Variant) 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) c.Redirect(http.StatusFound, bingURL)
} else { } else {
serveLocal(c, selected.StorageKey, img.Date) serveLocal(c, selected.StorageKey, img.Date, maxAge)
} }
} else { } 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 != "" { if etag != "" {
c.Header("ETag", fmt.Sprintf("\"%s\"", etag)) c.Header("ETag", fmt.Sprintf("\"%s\"", etag))
if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) { if c.GetHeader("If-None-Match") == fmt.Sprintf("\"%s\"", etag) {
@@ -263,10 +376,58 @@ func serveLocal(c *gin.Context, key string, etag string) {
if contentType != "" { if contentType != "" {
c.Header("Content-Type", 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) io.Copy(c.Writer, reader)
} }
func formatMetaSummary(img *model.Image) gin.H {
cfg := config.GetConfig()
// 找到最小的变体Size 最小)
var smallest *model.ImageVariant
for i := range img.Variants {
v := &img.Variants[i]
if smallest == nil || v.Size < smallest.Size {
smallest = v
}
}
variants := []gin.H{}
if smallest != nil {
url := smallest.PublicURL
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, smallest.Variant)
} else if cfg.API.Mode == "local" || url == "" {
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, smallest.Variant, smallest.Format, img.Mkt)
}
variants = append(variants, gin.H{
"variant": smallest.Variant,
"format": smallest.Format,
"size": smallest.Size,
"url": url,
"storage_key": smallest.StorageKey,
})
}
return gin.H{
"date": img.Date,
"mkt": img.Mkt,
"title": img.Title,
"copyright": img.Copyright,
"copyrightlink": img.CopyrightLink,
"quiz": img.Quiz,
"startdate": img.StartDate,
"fullstartdate": img.FullStartDate,
"hsh": img.HSH,
"variants": variants,
}
}
func formatMeta(img *model.Image) gin.H { func formatMeta(img *model.Image) gin.H {
cfg := config.GetConfig() cfg := config.GetConfig()
variants := []gin.H{} variants := []gin.H{}
@@ -275,7 +436,7 @@ func formatMeta(img *model.Image) gin.H {
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" { if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant) url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant)
} else if cfg.API.Mode == "local" || url == "" { } else if cfg.API.Mode == "local" || url == "" {
url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format) url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format, img.Mkt)
} }
variants = append(variants, gin.H{ variants = append(variants, gin.H{
"variant": v.Variant, "variant": v.Variant,
@@ -288,6 +449,7 @@ func formatMeta(img *model.Image) gin.H {
return gin.H{ return gin.H{
"date": img.Date, "date": img.Date,
"mkt": img.Mkt,
"title": img.Title, "title": img.Title,
"copyright": img.Copyright, "copyright": img.Copyright,
"copyrightlink": img.CopyrightLink, "copyrightlink": img.CopyrightLink,
@@ -298,3 +460,63 @@ func formatMeta(img *model.Image) gin.H {
"variants": variants, "variants": variants,
} }
} }
// GetRegions 获取支持的地区列表
// @Summary 获取支持的地区列表
// @Description 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。
// @Tags image
// @Produce json
// @Success 200 {array} util.Region
// @Router /regions [get]
func GetRegions(c *gin.Context) {
cfg := config.GetConfig()
pinned := cfg.Fetcher.Regions
if len(pinned) == 0 {
// 如果没有配置抓取地区,返回所有支持的地区
c.JSON(http.StatusOK, util.AllRegions)
return
}
// 创建一个 Map 用于快速查找配置的地区
pinnedMap := make(map[string]bool)
for _, v := range pinned {
pinnedMap[v] = true
}
// 只返回配置中的地区,并保持配置中的顺序
var result []util.Region
// 为了保持配置顺序,我们遍历 pinned 而不是 AllRegions
for _, pVal := range pinned {
for _, r := range util.AllRegions {
if r.Value == pVal {
result = append(result, r)
break
}
}
}
// 如果配置了一些不在 AllRegions 里的 mkt上述循环可能漏掉
// 但根据之前的逻辑AllRegions 是已知的 17 个地区。
// 如果用户配置了 fr-CA (不在 17 个内),我们也应该返回它吗?
// 需求说 "前端页面对地区进行约束",如果配置了,前端就该显示。
// 如果不在 AllRegions 里的,我们直接返回原始编码作为 label 或者查找一下。
if len(result) < len(pinned) {
// 补全不在 AllRegions 里的地区
for _, pVal := range pinned {
found := false
for _, r := range result {
if r.Value == pVal {
found = true
break
}
}
if !found {
result = append(result, util.Region{Value: pVal, Label: pVal})
}
}
}
c.JSON(http.StatusOK, result)
}

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -38,7 +39,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil) 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.Equal(t, http.StatusFound, w.Code)
assert.Contains(t, w.Header().Get("Location"), "bing.com") assert.Contains(t, w.Header().Get("Location"), "bing.com")
@@ -65,4 +66,43 @@ func TestHandleImageResponseRedirect(t *testing.T) {
assert.Contains(t, variants[0]["url"].(string), "myserver.com") assert.Contains(t, variants[0]["url"].(string), "myserver.com")
assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/") assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/")
}) })
t.Run("FormatMetaSummary should only return the smallest variant", func(t *testing.T) {
imgWithMultipleVariants := &model.Image{
Date: "2026-01-26",
Variants: []model.ImageVariant{
{Variant: "UHD", Size: 1000, Format: "jpg"},
{Variant: "640x480", Size: 200, Format: "jpg"},
{Variant: "1920x1080", Size: 500, Format: "jpg"},
},
}
meta := formatMetaSummary(imgWithMultipleVariants)
variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants))
assert.Equal(t, "640x480", variants[0]["variant"])
})
}
func TestGetRegions(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("GetRegions should respect pinned order", func(t *testing.T) {
// Setup config with custom pinned regions
config.Init("")
config.GetConfig().Fetcher.Regions = []string{"en-US", "ja-JP"}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
GetRegions(c)
assert.Equal(t, http.StatusOK, w.Code)
var regions []map[string]string
err := json.Unmarshal(w.Body.Bytes(), &regions)
assert.NoError(t, err)
assert.GreaterOrEqual(t, len(regions), 2)
assert.Equal(t, "en-US", regions[0]["value"])
assert.Equal(t, "ja-JP", regions[1]["value"])
})
} }

View File

@@ -47,6 +47,8 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
img.GET("/date/:date/meta", handlers.GetByDateMeta) img.GET("/date/:date/meta", handlers.GetByDateMeta)
} }
api.GET("/images", handlers.ListImages) api.GET("/images", handlers.ListImages)
api.GET("/images/global/today", handlers.ListGlobalTodayImages)
api.GET("/regions", handlers.GetRegions)
// 管理接口 // 管理接口
admin := api.Group("/admin") admin := api.Group("/admin")
@@ -80,7 +82,7 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
path := c.Request.URL.Path path := c.Request.URL.Path
// 如果请求的是 API 或 Swagger则不处理静态资源 (让其返回 404) // 如果请求的是 API 或 Swagger则不处理静态资源 (让其返回 404)
if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/swagger") { if strings.HasPrefix(path, "/api/v1") || strings.HasPrefix(path, "/swagger") {
return return
} }

View File

@@ -8,7 +8,8 @@ import (
type Image struct { type Image struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD Date string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:2;type:varchar(10)" json:"date"` // YYYY-MM-DD
Mkt string `gorm:"uniqueIndex:idx_date_mkt;index:idx_mkt_date,priority:1;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc.
Title string `json:"title"` Title string `json:"title"`
Copyright string `json:"copyright"` Copyright string `json:"copyright"`
CopyrightLink string `json:"copyrightlink"` CopyrightLink string `json:"copyrightlink"`

View File

@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"BingPaper/internal/config" "BingPaper/internal/config"
@@ -55,7 +56,44 @@ func NewFetcher() *Fetcher {
func (f *Fetcher) Fetch(ctx context.Context, n int) error { func (f *Fetcher) Fetch(ctx context.Context, n int) error {
util.Logger.Info("Starting fetch task", zap.Int("n", n)) util.Logger.Info("Starting fetch task", zap.Int("n", n))
url := fmt.Sprintf("%s?format=js&idx=0&n=%d&uhd=1&mkt=%s", config.BingAPIBase, n, config.BingMkt) regions := config.GetConfig().Fetcher.Regions
if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultMkt()}
}
for _, mkt := range regions {
if err := f.FetchRegion(ctx, mkt); err != nil {
util.Logger.Error("Failed to fetch region images", zap.String("mkt", mkt), zap.Error(err))
}
}
util.Logger.Info("Fetch task completed")
return nil
}
// FetchRegion 抓取指定地区的图片
func (f *Fetcher) FetchRegion(ctx context.Context, mkt string) error {
if !util.IsValidRegion(mkt) {
util.Logger.Warn("Skipping fetch for invalid region", zap.String("mkt", mkt))
return fmt.Errorf("invalid region code: %s", mkt)
}
util.Logger.Info("Fetching images for region", zap.String("mkt", mkt))
// 调用两次 API 获取最多两周的数据
// 第一次 idx=0&n=8 (今天起往回数 8 张)
if err := f.fetchByMkt(ctx, mkt, 0, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 0), zap.Error(err))
return err
}
// 第二次 idx=7&n=8 (7天前起往回数 8 张,与第一次有重叠,确保不漏)
if err := f.fetchByMkt(ctx, mkt, 7, 8); err != nil {
util.Logger.Error("Failed to fetch images", zap.String("mkt", mkt), zap.Int("idx", 7), zap.Error(err))
// 第二次失败不一定返回错误,因为可能第一次已经拿到了
}
return nil
}
func (f *Fetcher) fetchByMkt(ctx context.Context, mkt string, idx int, n int) error {
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s", config.BingAPIBase, idx, n, mkt)
util.Logger.Debug("Requesting Bing API", zap.String("url", url)) util.Logger.Debug("Requesting Bing API", zap.String("url", url))
resp, err := f.httpClient.Get(url) resp, err := f.httpClient.Get(url)
if err != nil { if err != nil {
@@ -70,49 +108,34 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
return err return err
} }
util.Logger.Info("Fetched images from Bing", zap.Int("count", len(bingResp.Images))) util.Logger.Info("Fetched images from Bing", zap.String("mkt", mkt), zap.Int("count", len(bingResp.Images)))
for _, bingImg := range bingResp.Images { for _, bingImg := range bingResp.Images {
if err := f.processImage(ctx, bingImg); err != nil { if err := f.processImage(ctx, bingImg, mkt); err != nil {
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err)) util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err))
} }
} }
util.Logger.Info("Fetch task completed")
return nil return nil
} }
func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt string) error {
dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8]) dateStr := fmt.Sprintf("%s-%s-%s", bingImg.Enddate[0:4], bingImg.Enddate[4:6], bingImg.Enddate[6:8])
// 幂等检查 // 幂等检查
var existing model.Image var existing model.Image
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err == nil { if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err == nil {
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr)) util.Logger.Debug("Image already exists in DB, skipping", zap.String("date", dateStr), zap.String("mkt", mkt))
return nil return nil
} }
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("title", bingImg.Title)) imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH)
util.Logger.Info("Processing image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("imageName", imageName))
// UHD 探测
imgURL, variantName := f.probeUHD(bingImg.URLBase)
imgData, err := f.downloadImage(imgURL)
if err != nil {
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
return err
}
// 解码图片用于缩放
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
util.Logger.Error("Failed to decode image data", zap.Error(err))
return err
}
// 创建 DB 记录 // 创建 DB 记录
dbImg := model.Image{ dbImg := model.Image{
Date: dateStr, Date: dateStr,
Mkt: mkt,
Title: bingImg.Title, Title: bingImg.Title,
Copyright: bingImg.Copyright, Copyright: bingImg.Copyright,
CopyrightLink: bingImg.CopyrightLink, CopyrightLink: bingImg.CopyrightLink,
@@ -124,23 +147,25 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
} }
if err := repo.DB.Clauses(clause.OnConflict{ if err := repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}}, Columns: []clause.Column{{Name: "date"}, {Name: "mkt"}},
DoNothing: true, DoNothing: true,
}).Create(&dbImg).Error; err != nil { }).Create(&dbImg).Error; err != nil {
util.Logger.Error("Failed to create image record", zap.Error(err)) util.Logger.Error("Failed to create image record", zap.Error(err))
return err return err
} }
// 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID
if dbImg.ID == 0 { if dbImg.ID == 0 {
var existing model.Image var existing model.Image
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err != nil { if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err != nil {
util.Logger.Error("Failed to query existing image record after conflict", zap.Error(err)) util.Logger.Error("Failed to query existing image record after conflict", zap.Error(err))
return err return err
} }
dbImg = existing dbImg = existing
} }
// UHD 探测
imgURL, variantName := f.probeUHD(bingImg.URLBase)
// 保存各种分辨率 // 保存各种分辨率
targetVariants := []struct { targetVariants := []struct {
name string name string
@@ -160,17 +185,61 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
{"320x240", 320, 240}, {"320x240", 320, 240},
} }
// 首先保存原图 (UHD 或 1080p) // 检查是否所有变体都已存在于存储中
if err := f.saveVariant(ctx, &dbImg, variantName, "jpg", imgData); err != nil { allExist := true
// 检查 UHD/原图
uhdKey := f.generateKey(imageName, variantName, "jpg")
exists, _ := storage.GlobalStorage.Exists(ctx, uhdKey)
if !exists {
allExist = false
} else {
for _, v := range targetVariants {
if v.name == variantName {
continue
}
vKey := f.generateKey(imageName, v.name, "jpg")
exists, _ := storage.GlobalStorage.Exists(ctx, vKey)
if !exists {
allExist = false
break
}
}
}
if allExist {
util.Logger.Debug("All image variants exist in storage, linking only", zap.String("imageName", imageName))
// 只建立关联信息
f.saveVariant(ctx, &dbImg, imageName, variantName, "jpg", nil)
for _, v := range targetVariants {
if v.name == variantName {
continue
}
f.saveVariant(ctx, &dbImg, imageName, v.name, "jpg", nil)
}
} else {
// 需要下载并处理
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL))
imgData, err := f.downloadImage(imgURL)
if err != nil {
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
return err
}
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
util.Logger.Error("Failed to decode image data", zap.Error(err))
return err
}
// 保存原图
if err := f.saveVariant(ctx, &dbImg, imageName, variantName, "jpg", imgData); err != nil {
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err)) util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
} }
for _, v := range targetVariants { for _, v := range targetVariants {
// 如果目标分辨率就是原图分辨率,则跳过(已经保存过了)
if v.name == variantName { if v.name == variantName {
continue continue
} }
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos) resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil { if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil {
@@ -178,9 +247,7 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
continue continue
} }
currentImgData := buf.Bytes() currentImgData := buf.Bytes()
if err := f.saveVariant(ctx, &dbImg, imageName, v.name, "jpg", currentImgData); err != nil {
// 保存 JPG
if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil {
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err)) util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
} }
} }
@@ -188,12 +255,35 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
// 保存今日额外文件 // 保存今日额外文件
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
f.saveDailyFiles(srcImg, imgData) f.saveDailyFiles(srcImg, imgData, mkt)
}
} }
return nil return nil
} }
func (f *Fetcher) extractImageName(urlBase, hsh string) string {
// 示例: /th?id=OHR.MilwaukeeHall_ROW0871854348
start := 0
if idx := strings.Index(urlBase, "OHR."); idx != -1 {
start = idx + 4
} else if idx := strings.Index(urlBase, "id="); idx != -1 {
start = idx + 3
}
rem := urlBase[start:]
end := strings.Index(rem, "_")
if end == -1 {
end = len(rem)
}
name := rem[:end]
if name == "" {
return hsh
}
return name
}
func (f *Fetcher) probeUHD(urlBase string) (string, string) { func (f *Fetcher) probeUHD(urlBase string) (string, string) {
uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase) uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase)
resp, err := f.httpClient.Head(uhdURL) resp, err := f.httpClient.Head(uhdURL)
@@ -212,25 +302,52 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) {
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error { func (f *Fetcher) generateKey(imageName, variant, format string) string {
key := fmt.Sprintf("%s/%s_%s.%s", img.Date, img.Date, variant, format) return fmt.Sprintf("%s/%s_%s.%s", imageName, imageName, variant, format)
}
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName, variant, format string, data []byte) error {
key := f.generateKey(imageName, variant, format)
contentType := "image/jpeg" contentType := "image/jpeg"
if format == "webp" { if format == "webp" {
contentType = "image/webp" contentType = "image/webp"
} }
var size int64
var publicURL string
exists, _ := storage.GlobalStorage.Exists(ctx, key)
if exists {
util.Logger.Debug("Variant already exists in storage, linking", zap.String("key", key))
// 如果存在,我们需要获取它的大小和公共 URL (如果可能)
// 但目前的 Storage 接口没有 Stat我们可以尝试 Get 或者干脆 size 为 0
// 为了简单,我们只从存储中获取公共 URL
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
publicURL = pURL
}
// size 暂时设为 0 或者从 data 中取 (如果有的话)
if data != nil {
size = int64(len(data))
}
} else if data != nil {
util.Logger.Debug("Saving variant to storage", zap.String("key", key))
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType) stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
if err != nil { if err != nil {
return err return err
} }
publicURL = stored.PublicURL
size = stored.Size
} else {
return fmt.Errorf("variant %s does not exist and no data provided", key)
}
vRecord := model.ImageVariant{ vRecord := model.ImageVariant{
ImageID: img.ID, ImageID: img.ID,
Variant: variant, Variant: variant,
Format: format, Format: format,
StorageKey: stored.Key, StorageKey: key,
PublicURL: stored.PublicURL, PublicURL: publicURL,
Size: int64(len(data)), Size: size,
} }
return repo.DB.Clauses(clause.OnConflict{ return repo.DB.Clauses(clause.OnConflict{
@@ -239,20 +356,21 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, fo
}).Create(&vRecord).Error }).Create(&vRecord).Error
} }
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) { func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) {
util.Logger.Info("Saving daily files") util.Logger.Info("Saving daily files", zap.String("mkt", mkt))
localRoot := config.GetConfig().Storage.Local.Root localRoot := config.GetConfig().Storage.Local.Root
if localRoot == "" { if localRoot == "" {
localRoot = "data" localRoot = "data"
} }
if err := os.MkdirAll(localRoot, 0755); err != nil { mktDir := filepath.Join(localRoot, mkt)
util.Logger.Error("Failed to create directory", zap.String("path", localRoot), zap.Error(err)) if err := os.MkdirAll(mktDir, 0755); err != nil {
util.Logger.Error("Failed to create directory", zap.String("path", mktDir), zap.Error(err))
return return
} }
// daily.jpeg (quality 100) // daily.jpeg (quality 100)
jpegPath := filepath.Join(localRoot, "daily.jpeg") jpegPath := filepath.Join(mktDir, "daily.jpeg")
fJpeg, err := os.Create(jpegPath) fJpeg, err := os.Create(jpegPath)
if err != nil { if err != nil {
util.Logger.Error("Failed to create daily.jpeg", zap.Error(err)) util.Logger.Error("Failed to create daily.jpeg", zap.Error(err))
@@ -262,8 +380,21 @@ func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
} }
// original.jpeg (quality 100) // original.jpeg (quality 100)
originalPath := filepath.Join(localRoot, "original.jpeg") originalPath := filepath.Join(mktDir, "original.jpeg")
if err := os.WriteFile(originalPath, originalData, 0644); err != nil { if err := os.WriteFile(originalPath, originalData, 0644); err != nil {
util.Logger.Error("Failed to write original.jpeg", zap.Error(err)) util.Logger.Error("Failed to write original.jpeg", zap.Error(err))
} }
// 同时也保留一份在根目录下(兼容旧逻辑,且作为默认地区图片)
// 如果是默认地区或者是第一个抓取的地区,可以覆盖根目录的文件
if mkt == config.GetConfig().GetDefaultMkt() {
jpegPathRoot := filepath.Join(localRoot, "daily.jpeg")
fJpegRoot, err := os.Create(jpegPathRoot)
if err == nil {
jpeg.Encode(fJpegRoot, srcImg, &jpeg.Options{Quality: 100})
fJpegRoot.Close()
}
originalPathRoot := filepath.Join(localRoot, "original.jpeg")
os.WriteFile(originalPathRoot, originalData, 0644)
}
} }

View File

@@ -2,18 +2,23 @@ package image
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math/rand"
"time" "time"
"BingPaper/internal/config" "BingPaper/internal/config"
"BingPaper/internal/model" "BingPaper/internal/model"
"BingPaper/internal/repo" "BingPaper/internal/repo"
"BingPaper/internal/service/fetcher"
"BingPaper/internal/storage" "BingPaper/internal/storage"
"BingPaper/internal/util" "BingPaper/internal/util"
"go.uber.org/zap" "go.uber.org/zap"
) )
var ErrFetchStarted = errors.New("on-demand fetch started")
func CleanupOldImages(ctx context.Context) error { func CleanupOldImages(ctx context.Context) error {
days := config.GetConfig().Retention.Days days := config.GetConfig().Retention.Days
if days <= 0 { if days <= 0 {
@@ -50,43 +55,155 @@ func CleanupOldImages(ctx context.Context) error {
return nil return nil
} }
func GetTodayImage() (*model.Image, error) { func GetTodayImage(mkt string) (*model.Image, error) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
util.Logger.Debug("Getting today image", zap.String("mkt", mkt), zap.String("today", today))
var img model.Image var img model.Image
err := repo.DB.Where("date = ?", today).Preload("Variants").First(&img).Error tx := repo.DB.Where("date = ?", today)
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err := tx.Preload("Variants").First(&img).Error
if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
// 如果没找到,尝试异步按需抓取该地区
util.Logger.Info("Image not found in DB, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
f := fetcher.NewFetcher()
go func() {
_ = f.FetchRegion(context.Background(), mkt)
}()
return nil, ErrFetchStarted
}
if err != nil { if err != nil {
// 如果今天没有,尝试获取最近的一张 util.Logger.Debug("Today image not found, trying latest image", zap.String("mkt", mkt))
err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error // 如果今天还是没有,尝试获取最近的一张
tx = repo.DB.Order("date desc")
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err = tx.Preload("Variants").First(&img).Error
}
// 兜底逻辑:如果指定地区没找到,且开启了兜底开关,则尝试获取默认地区的图片
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultMkt()
util.Logger.Debug("Image not found, trying fallback to default market", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
if mkt != defaultMkt {
return GetTodayImage(defaultMkt)
}
return GetTodayImage("")
}
if err == nil {
util.Logger.Debug("Found image", zap.String("date", img.Date), zap.String("mkt", img.Mkt))
} }
return &img, err return &img, err
} }
func GetRandomImage() (*model.Image, error) { func GetAllRegionsTodayImages() ([]model.Image, error) {
regions := config.GetConfig().Fetcher.Regions
if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultMkt()}
}
var images []model.Image
for _, mkt := range regions {
img, err := GetTodayImage(mkt)
if err == nil {
images = append(images, *img)
}
}
return images, nil
}
func GetRandomImage(mkt string) (*model.Image, error) {
util.Logger.Debug("Getting random image", zap.String("mkt", mkt))
var img model.Image var img model.Image
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND() // SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
// 简单起见,先查总数再 Offset // 简单起见,先查总数再 Offset
var count int64 var count int64
repo.DB.Model(&model.Image{}).Count(&count) tx := repo.DB.Model(&model.Image{})
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
tx.Count(&count)
if count == 0 && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
// 如果没找到,尝试异步按需抓取该地区
util.Logger.Info("No images found in DB for region, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
f := fetcher.NewFetcher()
go func() {
_ = f.FetchRegion(context.Background(), mkt)
}()
return nil, ErrFetchStarted
}
if count == 0 { if count == 0 {
return nil, fmt.Errorf("no images found") return nil, fmt.Errorf("no images found")
} }
// 这种方法不适合海量数据,但对于 30 天的数据没问题 // 优化随机查询:使用 Offset 代替 ORDER BY RANDOM()
err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error // 注意tx 包含了前面的 Where 条件
if err != nil { offset := rand.Intn(int(count))
// 适配 MySQL util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset))
err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error err := tx.Preload("Variants").Offset(offset).Limit(1).Find(&img).Error
// 兜底逻辑
if (err != nil || img.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultMkt()
util.Logger.Debug("Random image not found, trying fallback", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
if mkt != defaultMkt {
return GetRandomImage(defaultMkt)
}
return GetRandomImage("")
}
if err == nil && img.ID == 0 {
return nil, fmt.Errorf("no images found")
}
if err == nil {
util.Logger.Debug("Found random image", zap.String("date", img.Date), zap.String("mkt", img.Mkt))
}
return &img, err
}
func GetImageByDate(date string, mkt string) (*model.Image, error) {
util.Logger.Debug("Getting image by date", zap.String("date", date), zap.String("mkt", mkt))
var img model.Image
tx := repo.DB.Where("date = ?", date)
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err := tx.Preload("Variants").First(&img).Error
if err != nil && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
// 如果没找到,尝试异步按需抓取该地区
util.Logger.Info("Image not found in DB for date, starting asynchronous on-demand fetch", zap.String("mkt", mkt), zap.String("date", date))
f := fetcher.NewFetcher()
go func() {
_ = f.FetchRegion(context.Background(), mkt)
}()
return nil, ErrFetchStarted
}
// 兜底逻辑
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultMkt()
util.Logger.Debug("Image by date not found, trying fallback", zap.String("date", date), zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
if mkt != defaultMkt {
return GetImageByDate(date, defaultMkt)
}
return GetImageByDate(date, "")
}
if err == nil {
util.Logger.Debug("Found image by date", zap.String("date", img.Date), zap.String("mkt", img.Mkt))
} }
return &img, err return &img, err
} }
func GetImageByDate(date string) (*model.Image, error) { func GetImageList(limit int, offset int, month string, mkt string) ([]model.Image, error) {
var img model.Image util.Logger.Debug("Getting image list", zap.Int("limit", limit), zap.Int("offset", offset), zap.String("month", month), zap.String("mkt", mkt))
err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error
return &img, err
}
func GetImageList(limit int, offset int, month string) ([]model.Image, error) {
var images []model.Image var images []model.Image
tx := repo.DB.Model(&model.Image{}) tx := repo.DB.Model(&model.Image{})
@@ -97,6 +214,10 @@ func GetImageList(limit int, offset int, month string) ([]model.Image, error) {
tx = tx.Where("date LIKE ?", month+"%") tx = tx.Where("date LIKE ?", month+"%")
} }
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
tx = tx.Order("date desc").Preload("Variants") tx = tx.Order("date desc").Preload("Variants")
if limit > 0 { if limit > 0 {

View File

@@ -63,3 +63,15 @@ func (l *LocalStorage) Delete(ctx context.Context, key string) error {
func (l *LocalStorage) PublicURL(key string) (string, bool) { func (l *LocalStorage) PublicURL(key string) (string, bool) {
return "", false return "", false
} }
func (l *LocalStorage) Exists(ctx context.Context, key string) (bool, error) {
path := filepath.Join(l.root, key)
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

View File

@@ -100,3 +100,18 @@ func (s *S3Storage) PublicURL(key string) (string, bool) {
// 也可以生成签名 URL但这里简单处理 // 也可以生成签名 URL但这里简单处理
return "", false return "", false
} }
func (s *S3Storage) Exists(ctx context.Context, key string) (bool, error) {
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
// 判断是否为 404
if strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "404") {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -17,6 +17,7 @@ type Storage interface {
Get(ctx context.Context, key string) (io.ReadCloser, string, error) Get(ctx context.Context, key string) (io.ReadCloser, string, error)
Delete(ctx context.Context, key string) error Delete(ctx context.Context, key string) error
PublicURL(key string) (string, bool) PublicURL(key string) (string, bool)
Exists(ctx context.Context, key string) (bool, error)
} }
var GlobalStorage Storage var GlobalStorage Storage

View File

@@ -72,3 +72,16 @@ func (w *WebDAVStorage) PublicURL(key string) (string, bool) {
} }
return "", false return "", false
} }
func (w *WebDAVStorage) Exists(ctx context.Context, key string) (bool, error) {
_, err := w.client.Stat(key)
if err == nil {
return true, nil
}
// gowebdav 的错误处理比较原始,通常 404 会返回错误
// 这里假设报错就是不存在,或者可以根据错误消息判断
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
return false, nil
}
return false, err
}

35
internal/util/regions.go Normal file
View File

@@ -0,0 +1,35 @@
package util
import "golang.org/x/text/language"
type Region struct {
Value string `json:"value"`
Label string `json:"label"`
}
// IsValidRegion 校验是否为标准的地区编码 (BCP 47)
func IsValidRegion(mkt string) bool {
if mkt == "" {
return false
}
_, err := language.Parse(mkt)
return err == nil
}
var AllRegions = []Region{
{Value: "zh-CN", Label: "中国"},
{Value: "en-US", Label: "美国"},
{Value: "ja-JP", Label: "日本"},
{Value: "en-AU", Label: "澳大利亚"},
{Value: "en-GB", Label: "英国"},
{Value: "de-DE", Label: "德国"},
{Value: "en-NZ", Label: "新西兰"},
{Value: "en-CA", Label: "加拿大"},
{Value: "fr-FR", Label: "法国"},
{Value: "it-IT", Label: "意大利"},
{Value: "es-ES", Label: "西班牙"},
{Value: "pt-BR", Label: "巴西"},
{Value: "ko-KR", Label: "韩国"},
{Value: "en-IN", Label: "印度"},
{Value: "ru-RU", Label: "俄罗斯"},
}

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 %%p in (%PLATFORMS%) do (
for /f "tokens=1,2 delims=/" %%a in ("%%p") do ( for /f "tokens=1,2 delims=/" %%a in ("%%p") do (
set OUTPUT_NAME=%APP_NAME%-%%a-%%b set OUTPUT_NAME=%APP_NAME%-%%a-%%b
set BINARY_NAME=!OUTPUT_NAME! set BINARY_NAME=%APP_NAME%
if "%%a"=="windows" set BINARY_NAME=!OUTPUT_NAME!.exe if "%%a"=="windows" set BINARY_NAME=%APP_NAME%.exe
echo 正在编译 %%a/%%b... echo 正在编译 %%a/%%b...
@@ -47,10 +47,10 @@ for %%p in (%PLATFORMS%) do (
copy /y config.example.yaml !PACKAGE_DIR!\ >nul copy /y config.example.yaml !PACKAGE_DIR!\ >nul
copy /y README.md !PACKAGE_DIR!\ >nul copy /y README.md !PACKAGE_DIR!\ >nul
pushd %OUTPUT_DIR% pushd !PACKAGE_DIR!
tar -czf !OUTPUT_NAME!.tar.gz !OUTPUT_NAME! tar -czf ..\!OUTPUT_NAME!.tar.gz .
rd /s /q !OUTPUT_NAME!
popd popd
rd /s /q !PACKAGE_DIR!
echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz echo %%a/%%b 打包完成: !OUTPUT_NAME!.tar.gz
) else ( ) else (

View File

@@ -38,9 +38,9 @@ foreach ($Platform in $Platforms) {
$Arch = $parts[1] $Arch = $parts[1]
$OutputName = "$AppName-$OS-$Arch" $OutputName = "$AppName-$OS-$Arch"
$BinaryName = $OutputName $BinaryName = $AppName
if ($OS -eq "windows") { if ($OS -eq "windows") {
$BinaryName = "$OutputName.exe" $BinaryName = "$AppName.exe"
} }
Write-Host "正在编译 $OS/$Arch..." Write-Host "正在编译 $OS/$Arch..."
@@ -63,10 +63,10 @@ foreach ($Platform in $Platforms) {
Copy-Item "README.md" $PackageDir\ Copy-Item "README.md" $PackageDir\
$CurrentDir = Get-Location $CurrentDir = Get-Location
Set-Location $OutputDir Set-Location $PackageDir
tar -czf "${OutputName}.tar.gz" $OutputName tar -czf "../${OutputName}.tar.gz" .
Remove-Item -Recurse -Force $OutputName
Set-Location $CurrentDir Set-Location $CurrentDir
Remove-Item -Recurse -Force $PackageDir
Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz" Write-Host " $OS/$Arch 打包完成: ${OutputName}.tar.gz"
} else { } else {

View File

@@ -45,9 +45,9 @@ for PLATFORM in "${PLATFORMS[@]}"; do
# 设置输出名称 # 设置输出名称
OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}" OUTPUT_NAME="${APP_NAME}-${OS}-${ARCH}"
if [ "$OS" = "windows" ]; then if [ "$OS" = "windows" ]; then
BINARY_NAME="${OUTPUT_NAME}.exe" BINARY_NAME="${APP_NAME}.exe"
else else
BINARY_NAME="${OUTPUT_NAME}" BINARY_NAME="${APP_NAME}"
fi fi
echo "正在编译 ${OS}/${ARCH}..." echo "正在编译 ${OS}/${ARCH}..."
@@ -71,7 +71,7 @@ for PLATFORM in "${PLATFORMS[@]}"; do
done done
# 压缩为 tar.gz # 压缩为 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" 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 文件夹 - 📁 自动输出到上级目录的 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/ src/
├── lib/ # 核心库 ├── lib/ # 核心库

View File

@@ -14,6 +14,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0", "lucide-vue-next": "^0.562.0",
"lunar-javascript": "^1.7.7",
"reka-ui": "^2.7.0", "reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
@@ -2022,6 +2023,12 @@
"vue": ">=3.0.1" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0", "lucide-vue-next": "^0.562.0",
"lunar-javascript": "^1.7.7",
"reka-ui": "^2.7.0", "reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",

View File

@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import 'vue-sonner/style.css' import 'vue-sonner/style.css'
import { Toaster } from '@/components/ui/sonner'
</script> </script>
<template> <template>
<div id="app"> <div id="app">
<RouterView /> <RouterView />
<Toaster />
</div> </div>
</template> </template>

View File

@@ -0,0 +1,652 @@
<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 touch-none"
:style="{ left: panelPos.x + 'px', top: panelPos.y + 'px' }"
@mousedown="startDrag"
@touchstart.passive="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">
<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">
<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,
mkt?: string
}>()
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 })
// 响应式窗口大小
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
const isMobile = computed(() => windowSize.value.width < 768)
// 计算图片实际显示区域与ImageView保持一致
const getImageDisplayBounds = () => {
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是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()
if (isMobile.value) {
// 移动端:居中显示,尽量在图片内,但不强求
const panelWidth = Math.min(bounds.width - 16, windowSize.value.width - 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)
}
})
// 生成年份选项从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
}
}
// 窗口缩放处理
const handleResize = () => {
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
}
initPanelPosition()
}
// 组件挂载时加载当前年份的假期数据
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize)
}
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
}
if (e instanceof MouseEvent) {
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: true })
document.addEventListener('touchend', stopDrag)
}
// 拖动中
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
if (e instanceof MouseEvent) {
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()
let minX, maxX, minY, maxY
if (isMobile.value) {
// 移动端:不限制区域,限制在视口内即可
minX = 0
maxX = windowSize.value.width - rect.width
minY = 0
maxY = windowSize.value.height - rect.height
} else {
// 桌面端:限制在图片实际显示区域内
const bounds = getImageDisplayBounds()
minX = bounds.left
maxX = bounds.right - rect.width
minY = bounds.top
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 || new Date())
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(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize)
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
})
</script>

View File

@@ -2,11 +2,12 @@ import { ref, onMounted, watch } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { bingPaperApi } from '@/lib/api-service' import { bingPaperApi } from '@/lib/api-service'
import type { ImageMeta } from '@/lib/api-types' import type { ImageMeta } from '@/lib/api-types'
import { getDefaultMkt } from '@/lib/mkt-utils'
/** /**
* 获取今日图片 * 获取今日图片
*/ */
export function useTodayImage() { export function useTodayImage(mkt?: string) {
const image = ref<ImageMeta | null>(null) const image = ref<ImageMeta | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<Error | null>(null) const error = ref<Error | null>(null)
@@ -15,7 +16,7 @@ export function useTodayImage() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
image.value = await bingPaperApi.getTodayImageMeta() image.value = await bingPaperApi.getTodayImageMeta(mkt || getDefaultMkt())
} catch (e) { } catch (e) {
error.value = e as Error error.value = e as Error
console.error('Failed to fetch today image:', e) console.error('Failed to fetch today image:', e)
@@ -36,6 +37,39 @@ export function useTodayImage() {
} }
} }
/**
* 获取全球今日图片
*/
export function useGlobalTodayImages() {
const images = ref<ImageMeta[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
const fetchImages = async () => {
loading.value = true
error.value = null
try {
images.value = await bingPaperApi.getGlobalTodayImages()
} catch (e) {
error.value = e as Error
console.error('Failed to fetch global today images:', e)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchImages()
})
return {
images,
loading,
error,
refetch: fetchImages
}
}
/** /**
* 获取图片列表(支持分页和月份筛选) * 获取图片列表(支持分页和月份筛选)
*/ */
@@ -46,8 +80,9 @@ export function useImageList(pageSize = 30) {
const hasMore = ref(true) const hasMore = ref(true)
const currentPage = ref(1) const currentPage = ref(1)
const currentMonth = ref<string | undefined>(undefined) const currentMonth = ref<string | undefined>(undefined)
const currentMkt = ref<string | undefined>(getDefaultMkt())
const fetchImages = async (page = 1, month?: string) => { const fetchImages = async (page = 1, month?: string, mkt?: string) => {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
@@ -55,7 +90,8 @@ export function useImageList(pageSize = 30) {
try { try {
const params: any = { const params: any = {
page, page,
page_size: pageSize page_size: pageSize,
mkt: mkt || currentMkt.value || getDefaultMkt()
} }
if (month) { if (month) {
params.month = month params.month = month
@@ -84,7 +120,7 @@ export function useImageList(pageSize = 30) {
const loadMore = () => { const loadMore = () => {
if (!loading.value && hasMore.value) { if (!loading.value && hasMore.value) {
fetchImages(currentPage.value + 1, currentMonth.value) fetchImages(currentPage.value + 1, currentMonth.value, currentMkt.value)
} }
} }
@@ -92,7 +128,14 @@ export function useImageList(pageSize = 30) {
currentMonth.value = month currentMonth.value = month
currentPage.value = 1 currentPage.value = 1
hasMore.value = true hasMore.value = true
fetchImages(1, month) fetchImages(1, month, currentMkt.value)
}
const filterByMkt = (mkt?: string) => {
currentMkt.value = mkt
currentPage.value = 1
hasMore.value = true
fetchImages(1, currentMonth.value, mkt)
} }
onMounted(() => { onMounted(() => {
@@ -106,10 +149,11 @@ export function useImageList(pageSize = 30) {
hasMore, hasMore,
loadMore, loadMore,
filterByMonth, filterByMonth,
filterByMkt,
refetch: () => { refetch: () => {
currentPage.value = 1 currentPage.value = 1
hasMore.value = true hasMore.value = true
fetchImages(1, currentMonth.value) fetchImages(1, currentMonth.value, currentMkt.value)
} }
} }
} }
@@ -117,7 +161,7 @@ export function useImageList(pageSize = 30) {
/** /**
* 获取指定日期的图片 * 获取指定日期的图片
*/ */
export function useImageByDate(dateRef: Ref<string>) { export function useImageByDate(dateRef: Ref<string>, mktRef?: Ref<string | undefined>) {
const image = ref<ImageMeta | null>(null) const image = ref<ImageMeta | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<Error | null>(null) const error = ref<Error | null>(null)
@@ -126,7 +170,7 @@ export function useImageByDate(dateRef: Ref<string>) {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
image.value = await bingPaperApi.getImageMetaByDate(dateRef.value) image.value = await bingPaperApi.getImageMetaByDate(dateRef.value, mktRef?.value || getDefaultMkt())
} catch (e) { } catch (e) {
error.value = e as Error error.value = e as Error
console.error(`Failed to fetch image for date ${dateRef.value}:`, e) console.error(`Failed to fetch image for date ${dateRef.value}:`, e)
@@ -135,10 +179,16 @@ export function useImageByDate(dateRef: Ref<string>) {
} }
} }
// 监听日期变化,自动重新获取 // 监听日期和地区变化,自动重新获取
if (mktRef) {
watch([dateRef, mktRef], () => {
fetchImage()
}, { immediate: true })
} else {
watch(dateRef, () => { watch(dateRef, () => {
fetchImage() fetchImage()
}, { immediate: true }) }, { immediate: true })
}
return { return {
image, image,

View File

@@ -35,6 +35,26 @@ export const buildApiUrl = (endpoint: string): string => {
return `${API_BASE_URL}${normalizedEndpoint}` return `${API_BASE_URL}${normalizedEndpoint}`
} }
/**
* 标准化图片 URL
* 当后端返回相对路径且配置了绝对 API 基础地址时,自动拼接完整域名
*/
export const normalizeImageUrl = (url: string | undefined): string => {
if (!url) return ''
if (url.startsWith('http')) return url
// 处理相对路径问题:如果配置了绝对 API 基础地址,则拼接 Origin
if (API_BASE_URL.startsWith('http')) {
try {
const origin = new URL(API_BASE_URL).origin
return url.startsWith('/') ? origin + url : origin + '/' + url
} catch (e) {
// 解析失败则返回原样
}
}
return url
}
/** /**
* HTTP 状态码枚举 * HTTP 状态码枚举
*/ */

View File

@@ -7,6 +7,7 @@ import type {
UpdateTokenRequest, UpdateTokenRequest,
ChangePasswordRequest, ChangePasswordRequest,
Config, Config,
Region,
ImageMeta, ImageMeta,
ImageListParams, ImageListParams,
ManualFetchRequest, ManualFetchRequest,
@@ -109,6 +110,7 @@ export class BingPaperApiService {
if (params?.page) searchParams.set('page', params.page.toString()) if (params?.page) searchParams.set('page', params.page.toString())
if (params?.page_size) searchParams.set('page_size', params.page_size.toString()) if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
if (params?.month) searchParams.set('month', params.month) if (params?.month) searchParams.set('month', params.month)
if (params?.mkt) searchParams.set('mkt', params.mkt)
const queryString = searchParams.toString() const queryString = searchParams.toString()
const endpoint = queryString ? `/images?${queryString}` : '/images' const endpoint = queryString ? `/images?${queryString}` : '/images'
@@ -116,48 +118,68 @@ export class BingPaperApiService {
return apiClient.get<ImageMeta[]>(endpoint) return apiClient.get<ImageMeta[]>(endpoint)
} }
/**
* 获取所有地区的今日图片列表
*/
async getGlobalTodayImages(): Promise<ImageMeta[]> {
return apiClient.get<ImageMeta[]>('/images/global/today')
}
/**
* 获取支持的地区列表
*/
async getRegions(): Promise<Region[]> {
return apiClient.get<Region[]>('/regions')
}
/** /**
* 获取今日图片元数据 * 获取今日图片元数据
*/ */
async getTodayImageMeta(): Promise<ImageMeta> { async getTodayImageMeta(mkt?: string): Promise<ImageMeta> {
return apiClient.get<ImageMeta>('/image/today/meta') const endpoint = mkt ? `/image/today/meta?mkt=${mkt}` : '/image/today/meta'
return apiClient.get<ImageMeta>(endpoint)
} }
/** /**
* 获取指定日期图片元数据 * 获取指定日期图片元数据
*/ */
async getImageMetaByDate(date: string): Promise<ImageMeta> { async getImageMetaByDate(date: string, mkt?: string): Promise<ImageMeta> {
return apiClient.get<ImageMeta>(`/image/date/${date}/meta`) const endpoint = mkt ? `/image/date/${date}/meta?mkt=${mkt}` : `/image/date/${date}/meta`
return apiClient.get<ImageMeta>(endpoint)
} }
/** /**
* 获取随机图片元数据 * 获取随机图片元数据
*/ */
async getRandomImageMeta(): Promise<ImageMeta> { async getRandomImageMeta(mkt?: string): Promise<ImageMeta> {
return apiClient.get<ImageMeta>('/image/random/meta') const endpoint = mkt ? `/image/random/meta?mkt=${mkt}` : '/image/random/meta'
return apiClient.get<ImageMeta>(endpoint)
} }
/** /**
* 构建图片 URL * 构建图片 URL
*/ */
getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string { getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
const params = new URLSearchParams({ variant, format }) const params = new URLSearchParams({ variant, format })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/today?${params.toString()}` return `${apiConfig.baseURL}/image/today?${params.toString()}`
} }
/** /**
* 构建指定日期图片 URL * 构建指定日期图片 URL
*/ */
getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string { getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
const params = new URLSearchParams({ variant, format }) const params = new URLSearchParams({ variant, format })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/date/${date}?${params.toString()}` return `${apiConfig.baseURL}/image/date/${date}?${params.toString()}`
} }
/** /**
* 构建随机图片 URL * 构建随机图片 URL
*/ */
getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string { getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string {
const params = new URLSearchParams({ variant, format }) const params = new URLSearchParams({ variant, format })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/random?${params.toString()}` return `${apiConfig.baseURL}/image/random?${params.toString()}`
} }
@@ -181,6 +203,9 @@ export class BingPaperApiService {
// 导出默认实例 // 导出默认实例
export const bingPaperApi = new BingPaperApiService() export const bingPaperApi = new BingPaperApiService()
// 为了兼容性,也导出为 apiService
export const apiService = bingPaperApi
// 导出便捷方法 // 导出便捷方法
export const { export const {
login, login,
@@ -194,6 +219,8 @@ export const {
manualFetch, manualFetch,
manualCleanup, manualCleanup,
getImages, getImages,
getGlobalTodayImages,
getRegions,
getTodayImageMeta, getTodayImageMeta,
getImageMetaByDate, getImageMetaByDate,
getRandomImageMeta, getRandomImageMeta,

View File

@@ -50,103 +50,111 @@ export interface ChangePasswordRequest {
// ===== 配置相关 ===== // ===== 配置相关 =====
export interface Config { export interface Config {
admin: AdminConfig Server: ServerConfig
api: APIConfig Log: LogConfig
cron: CronConfig API: APIConfig
db: DBConfig Cron: CronConfig
feature: FeatureConfig Retention: RetentionConfig
log: LogConfig DB: DBConfig
retention: RetentionConfig Storage: StorageConfig
server: ServerConfig Admin: AdminConfig
storage: StorageConfig Token: TokenConfig
token: TokenConfig Feature: FeatureConfig
web: WebConfig Web: WebConfig
Fetcher: FetcherConfig
}
export interface FetcherConfig {
Regions: string[]
} }
export interface AdminConfig { export interface AdminConfig {
passwordBcrypt: string PasswordBcrypt: string
} }
export interface APIConfig { export interface APIConfig {
mode: string // 'local' | 'redirect' Mode: string // 'local' | 'redirect'
EnableMktFallback: boolean
EnableOnDemandFetch: boolean
} }
export interface CronConfig { export interface CronConfig {
enabled: boolean Enabled: boolean
dailySpec: string DailySpec: string
} }
export interface DBConfig { export interface DBConfig {
type: string // 'sqlite' | 'mysql' | 'postgres' Type: string // 'sqlite' | 'mysql' | 'postgres'
dsn: string DSN: string
} }
export interface FeatureConfig { export interface FeatureConfig {
writeDailyFiles: boolean WriteDailyFiles: boolean
} }
export interface LogConfig { export interface LogConfig {
level: string Level: string
filename: string Filename: string
dbfilename: string DBFilename: string
dblogLevel: string DBLogLevel: string
logConsole: boolean LogConsole: boolean
showDBLog: boolean ShowDBLog: boolean
maxSize: number MaxSize: number
maxAge: number MaxAge: number
maxBackups: number MaxBackups: number
compress: boolean Compress: boolean
} }
export interface RetentionConfig { export interface RetentionConfig {
days: number Days: number
} }
export interface ServerConfig { export interface ServerConfig {
port: number Port: number
baseURL: string BaseURL: string
} }
export interface StorageConfig { export interface StorageConfig {
type: string // 'local' | 's3' | 'webdav' Type: string // 'local' | 's3' | 'webdav'
local: LocalConfig Local: LocalConfig
s3: S3Config S3: S3Config
webDAV: WebDAVConfig WebDAV: WebDAVConfig
} }
export interface LocalConfig { export interface LocalConfig {
root: string Root: string
} }
export interface S3Config { export interface S3Config {
endpoint: string Endpoint: string
accessKey: string AccessKey: string
secretKey: string SecretKey: string
bucket: string Bucket: string
region: string Region: string
forcePathStyle: boolean ForcePathStyle: boolean
publicURLPrefix: string PublicURLPrefix: string
} }
export interface WebDAVConfig { export interface WebDAVConfig {
url: string URL: string
username: string Username: string
password: string Password: string
publicURLPrefix: string PublicURLPrefix: string
} }
export interface TokenConfig { export interface TokenConfig {
defaultTTL: string DefaultTTL: string
} }
export interface WebConfig { export interface WebConfig {
path: string Path: string
} }
// ===== 图片相关 ===== // ===== 图片相关 =====
export interface ImageMeta { export interface ImageMeta {
date?: string date?: string
mkt?: string
title?: string title?: string
copyright?: string copyright?: string
copyrightlink?: string // 图片的详细版权链接(指向 Bing 搜索页面) copyrightlink?: string // 图片的详细版权链接(指向 Bing 搜索页面)
@@ -157,13 +165,28 @@ export interface ImageMeta {
url?: string url?: string
variant?: string variant?: string
format?: string format?: string
variants?: ImageVariantResp[] // 图片变体列表
[key: string]: any [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 { export interface ImageListParams extends PaginationParams {
page?: number // 页码从1开始 page?: number // 页码从1开始
page_size?: number // 每页数量 page_size?: number // 每页数量
month?: string // 按月份过滤格式YYYY-MM month?: string // 按月份过滤格式YYYY-MM
mkt?: string // 地区编码
}
export interface Region {
value: string
label: string
} }
export interface ManualFetchRequest { export interface ManualFetchRequest {

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) { if (!response.ok) {
const errorData = await this.parseResponse(response) const errorData = await this.parseResponse(response)
throw new ApiError( const apiError = new ApiError(
errorData?.message || `HTTP ${response.status}: ${response.statusText}`, errorData?.message || `HTTP ${response.status}: ${response.statusText}`,
response.status, response.status,
errorData errorData
) )
// 401 未授权错误,自动跳转到登录页
if (response.status === 401) {
this.handle401Error()
}
throw apiError
} }
return await this.parseResponse(response) 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

@@ -0,0 +1,75 @@
const MKT_STORAGE_KEY = 'bing_paper_selected_mkt'
const DEFAULT_MKT = 'zh-CN'
/**
* 默认地区列表 (兜底用)
*/
export const DEFAULT_REGIONS = [
{ value: 'zh-CN', label: '中国' },
{ value: 'en-US', label: '美国' },
{ value: 'ja-JP', label: '日本' },
{ value: 'en-AU', label: '澳大利亚' },
{ value: 'en-GB', label: '英国' },
{ value: 'de-DE', label: '德国' },
{ value: 'en-NZ', label: '新西兰' },
{ value: 'en-CA', label: '加拿大' },
{ value: 'fr-FR', label: '法国' },
{ value: 'it-IT', label: '意大利' },
{ value: 'es-ES', label: '西班牙' },
{ value: 'pt-BR', label: '巴西' },
{ value: 'ko-KR', label: '韩国' },
{ value: 'en-IN', label: '印度' },
{ value: 'ru-RU', label: '俄罗斯' },
{ value: 'zh-HK', label: '中国香港' },
{ value: 'zh-TW', label: '中国台湾' },
]
/**
* 支持的地区列表 (优先使用后端提供的)
*/
export let SUPPORTED_REGIONS = [...DEFAULT_REGIONS]
/**
* 更新支持的地区列表
*/
export function setSupportedRegions(regions: typeof DEFAULT_REGIONS): void {
SUPPORTED_REGIONS = regions
}
/**
* 获取浏览器首选地区
*/
export function getBrowserMkt(): string {
const lang = navigator.language || (navigator as any).userLanguage
if (!lang) return DEFAULT_MKT
// 尝试精确匹配
const exactMatch = SUPPORTED_REGIONS.find(r => r.value.toLowerCase() === lang.toLowerCase())
if (exactMatch) return exactMatch.value
// 尝试模糊匹配 (前两个字符,如 en-GB 匹配 en-US)
const prefix = lang.split('-')[0].toLowerCase()
const prefixMatch = SUPPORTED_REGIONS.find(r => r.value.split('-')[0].toLowerCase() === prefix)
if (prefixMatch) return prefixMatch.value
return DEFAULT_MKT
}
/**
* 获取当前选择的地区 (优先从 localStorage 获取,其次从浏览器获取)
*/
export function getDefaultMkt(): string {
const saved = localStorage.getItem(MKT_STORAGE_KEY)
if (saved && SUPPORTED_REGIONS.some(r => r.value === saved)) {
return saved
}
return getBrowserMkt()
}
/**
* 保存选择的地区
*/
export function setSavedMkt(mkt: string): void {
localStorage.setItem(MKT_STORAGE_KEY, mkt)
}

View File

@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue' import Home from '@/views/Home.vue'
import ImageView from '@/views/ImageView.vue' import ImageView from '@/views/ImageView.vue'
import ApiDocs from '@/views/ApiDocs.vue' import ApiDocs from '@/views/ApiDocs.vue'
import AdminLogin from '@/views/AdminLogin.vue'
import Admin from '@/views/Admin.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -15,11 +17,20 @@ const router = createRouter({
} }
}, },
{ {
path: '/image/:date', path: '/image/:date?',
name: 'ImageView', name: 'ImageView',
component: ImageView, component: ImageView,
meta: { meta: {
title: '图片详情' 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: { meta: {
title: 'API 文档' 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) => { router.beforeEach((to, _from, next) => {
document.title = (to.meta.title as string) || '必应每日一图' 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() 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,689 @@
<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>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="api-fallback">启用地区不存在时兜底</Label>
<p class="text-xs text-gray-500">
如果请求的地区无数据自动回退到默认地区
</p>
</div>
<Switch
id="api-fallback"
v-model="config.API.EnableMktFallback"
/>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<Label for="api-on-demand">启用按需实时抓取</Label>
<p class="text-xs text-gray-500">
如果请求的地区无数据尝试实时从 Bing 抓取
</p>
</div>
<Switch
id="api-on-demand"
v-model="config.API.EnableOnDemandFetch"
/>
</div>
</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="space-y-2">
<Label>抓取地区</Label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
<div v-for="region in allRegions" :key="region.value" class="flex items-center space-x-2">
<Checkbox
:id="'region-'+region.value"
:checked="config.Fetcher.Regions.includes(region.value)"
@update:checked="(checked: any) => toggleRegion(region.value, !!checked)"
/>
<Label :for="'region-'+region.value" class="text-sm font-normal cursor-pointer">{{ region.label }}</Label>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
勾选需要定期抓取壁纸的地区如果不勾选任何地区默认将只抓取 zh-CN
</p>
</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 { Checkbox } from '@/components/ui/checkbox'
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 allRegions = ref<any[]>([])
const config = ref<Config>({
Admin: { PasswordBcrypt: '' },
API: { Mode: 'local', EnableMktFallback: true, EnableOnDemandFetch: false },
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' },
Fetcher: { Regions: [] }
})
const configJson = ref('')
const jsonError = ref('')
// 获取所有地区
const fetchRegions = async () => {
try {
const data = await apiService.getRegions()
allRegions.value = data
} catch (err) {
console.error('获取地区列表失败:', err)
}
}
const toggleRegion = (regionValue: string, checked: boolean) => {
if (!config.value.Fetcher.Regions) {
config.value.Fetcher.Regions = []
}
if (checked) {
if (!config.value.Fetcher.Regions.includes(regionValue)) {
config.value.Fetcher.Regions.push(regionValue)
}
} else {
config.value.Fetcher.Regions = config.value.Fetcher.Regions.filter(r => r !== regionValue)
}
}
// 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(() => {
fetchRegions()
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

@@ -103,6 +103,10 @@
<code class="text-yellow-400 min-w-24">format</code> <code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式: jpg (默认: jpg)</span> <span class="text-white/50">格式: jpg (默认: jpg)</span>
</div> </div>
<div class="flex gap-4 text-sm">
<code class="text-yellow-400 min-w-24">mkt</code>
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP),默认由服务器自动探测</span>
</div>
</div> </div>
</div> </div>
@@ -182,6 +186,10 @@
<code class="text-yellow-400 min-w-24">format</code> <code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式 (默认: jpg)</span> <span class="text-white/50">格式 (默认: jpg)</span>
</div> </div>
<div class="flex gap-4 text-sm">
<code class="text-yellow-400 min-w-24">mkt</code>
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP)</span>
</div>
</div> </div>
</div> </div>
@@ -254,6 +262,10 @@
<code class="text-yellow-400 min-w-24">format</code> <code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式 (默认: jpg)</span> <span class="text-white/50">格式 (默认: jpg)</span>
</div> </div>
<div class="flex gap-4 text-sm">
<code class="text-yellow-400 min-w-24">mkt</code>
<span class="text-white/50">地区编码 (如 zh-CN, en-US, ja-JP)</span>
</div>
</div> </div>
</div> </div>
@@ -334,6 +346,10 @@
<code class="text-yellow-400 min-w-32">date</code> <code class="text-yellow-400 min-w-32">date</code>
<span class="text-white/60">图片日期格式YYYY-MM-DD</span> <span class="text-white/60">图片日期格式YYYY-MM-DD</span>
</div> </div>
<div class="flex gap-4">
<code class="text-yellow-400 min-w-32">mkt</code>
<span class="text-white/60">地区编码(如 zh-CN, en-US</span>
</div>
<div class="flex gap-4"> <div class="flex gap-4">
<code class="text-yellow-400 min-w-32">title</code> <code class="text-yellow-400 min-w-32">title</code>
<span class="text-white/60">图片标题</span> <span class="text-white/60">图片标题</span>
@@ -458,24 +474,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { API_BASE_URL } from '@/lib/api-config' import { API_BASE_URL } from '@/lib/api-config'
import { getDefaultMkt } from '@/lib/mkt-utils'
const baseURL = ref(API_BASE_URL) const baseURL = ref(API_BASE_URL)
const previewImage = ref<string | null>(null) const previewImage = ref<string | null>(null)
const defaultMkt = getDefaultMkt()
// 获取今日图片示例 // 获取今日图片示例
const getTodayImageExample = () => { const getTodayImageExample = () => {
return `${baseURL.value}/image/today?variant=UHD&format=jpg` return `${baseURL.value}/image/today?variant=UHD&format=jpg&mkt=${defaultMkt}`
} }
// 获取指定日期图片示例 // 获取指定日期图片示例
const getDateImageExample = () => { const getDateImageExample = () => {
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0]
return `${baseURL.value}/image/date/${today}?variant=1920x1080&format=jpg` return `${baseURL.value}/image/date/${today}?variant=1920x1080&format=jpg&mkt=${defaultMkt}`
} }
// 获取随机图片示例 // 获取随机图片示例
const getRandomImageExample = () => { const getRandomImageExample = () => {
return `${baseURL.value}/image/random?variant=UHD&format=jpg` return `${baseURL.value}/image/random?variant=UHD&format=jpg&mkt=${defaultMkt}`
} }
// 复制到剪贴板 // 复制到剪贴板

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 class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
</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"> <div class="absolute inset-0">
<img <img
:src="getTodayImageUrl()" :src="getLatestImageUrl()"
:alt="todayImage.title || 'Today\'s Bing Image'" :alt="latestImage.title || 'Latest Bing Image'"
class="w-full h-full object-cover" 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 class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent"></div>
</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="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="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"> <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> </div>
<h1 class="text-4xl md:text-6xl font-bold text-white leading-tight drop-shadow-2xl"> <h1 class="text-4xl md:text-6xl font-bold text-white leading-tight drop-shadow-2xl">
{{ todayImage.title || '必应每日一图' }} {{ latestImage.title || '必应每日一图' }}
</h1> </h1>
<p v-if="todayImage.copyright" class="text-lg md:text-xl text-white/80 max-w-2xl"> <p v-if="latestImage.copyright" class="text-lg md:text-xl text-white/80 max-w-2xl">
{{ todayImage.copyright }} {{ latestImage.copyright }}
</p> </p>
<div class="flex gap-4 pt-4"> <div class="flex gap-4 pt-4">
<button <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" 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>
<button <button
v-if="todayImage.copyrightlink" v-if="latestImage.copyrightlink"
@click="openCopyrightLink(todayImage.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" 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"
> >
了解更多 了解更多
@@ -59,6 +69,81 @@
</div> </div>
</section> </section>
<!-- Global Section - 必应全球 -->
<section v-if="globalImages.length > 0" class="py-16 px-4 md:px-8 lg:px-16 bg-white/5 border-y border-white/5">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div class="space-y-1">
<h2 class="text-3xl md:text-4xl font-bold text-white">
必应全球
</h2>
</div>
</div>
<div class="relative group">
<!-- 左右切换按钮 -->
<button
@click="scrollGlobal('left')"
class="absolute left-[-20px] top-1/2 -translate-y-1/2 z-20 p-2 bg-black/50 backdrop-blur-md rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity hidden md:block border border-white/10 hover:bg-black/70"
>
<svg class="w-6 h-6" 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>
<button
@click="scrollGlobal('right')"
class="absolute right-[-20px] top-1/2 -translate-y-1/2 z-20 p-2 bg-black/50 backdrop-blur-md rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity hidden md:block border border-white/10 hover:bg-black/70"
>
<svg class="w-6 h-6" 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
ref="globalScrollContainer"
class="flex overflow-x-auto gap-6 pb-6 -mx-4 px-4 md:mx-0 md:px-0 scrollbar-hide snap-x scroll-smooth"
@wheel="handleGlobalWheel"
>
<div
v-for="image in globalImages"
:key="image.mkt! + image.date!"
class="flex-none w-[280px] md:w-[350px] aspect-video rounded-xl overflow-hidden cursor-pointer transform transition-all duration-500 hover:scale-[1.02] hover:shadow-2xl snap-start relative group/card"
@click="viewImage(image.date!, image.mkt!)"
>
<!-- 图片层 -->
<img
:src="getImageUrl(image)"
:alt="image.title"
class="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
/>
<!-- 渐变层 -->
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent group-hover/card:via-black/40 transition-colors duration-500"></div>
<!-- 内容层 -->
<div class="absolute inset-0 flex flex-col justify-end p-5">
<div class="text-[10px] md:text-xs text-white/60 mb-1 transform translate-y-2 group-hover/card:translate-y-0 transition-transform duration-500">
{{ formatDate(image.date) }}
</div>
<div class="flex items-center gap-2 mb-2 transform translate-y-2 group-hover/card:translate-y-0 transition-transform duration-500">
<span class="text-xs text-white/70 font-medium">
{{ getRegionLabel(image.mkt) }}
</span>
</div>
<h3 class="text-white font-bold text-base md:text-lg line-clamp-1 transform translate-y-1 group-hover/card:translate-y-0 transition-transform duration-500">
{{ image.title || '必应每日一图' }}
</h3>
</div>
</div>
</div>
<!-- 左右渐变遮罩 (仅在大屏显示) -->
<div class="absolute right-0 top-0 bottom-6 w-24 bg-gradient-to-l from-gray-900 to-transparent pointer-events-none hidden md:block opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
</div>
</div>
</section>
<!-- Gallery Section - 历史图片 --> <!-- Gallery Section - 历史图片 -->
<section class="py-16 px-4 md:px-8 lg:px-16"> <section class="py-16 px-4 md:px-8 lg:px-16">
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
@@ -69,6 +154,23 @@
<!-- 筛选器 --> <!-- 筛选器 -->
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<!-- 地区选择 -->
<Select v-model="selectedMkt" @update:model-value="onMktChange">
<SelectTrigger class="w-[180px] bg-white/10 backdrop-blur-md text-white border-white/20 hover:bg-white/15 hover:border-white/30 focus:ring-white/50 shadow-lg">
<SelectValue placeholder="选择地区" />
</SelectTrigger>
<SelectContent class="bg-gray-900/95 backdrop-blur-xl border-white/20 text-white">
<SelectItem
v-for="region in regions"
:key="region.value"
:value="region.value"
class="focus:bg-white/10 focus:text-white cursor-pointer"
>
{{ region.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 年份选择 --> <!-- 年份选择 -->
<Select v-model="selectedYear" @update:model-value="onYearChange"> <Select v-model="selectedYear" @update:model-value="onYearChange">
<SelectTrigger class="w-[180px] bg-white/10 backdrop-blur-md text-white border-white/20 hover:bg-white/15 hover:border-white/30 focus:ring-white/50 shadow-lg"> <SelectTrigger class="w-[180px] bg-white/10 backdrop-blur-md text-white border-white/20 hover:bg-white/15 hover:border-white/30 focus:ring-white/50 shadow-lg">
@@ -129,7 +231,7 @@
</div> </div>
<!-- 图片网格 --> <!-- 图片网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
<div <div
v-for="(image, index) in images" v-for="(image, index) in images"
:key="image.date || index" :key="image.date || index"
@@ -143,7 +245,7 @@
</div> </div>
<img <img
v-else v-else
:src="getImageUrl(image.date!)" :src="getImageUrl(image)"
:alt="image.title || 'Bing Image'" :alt="image.title || 'Bing Image'"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy" loading="lazy"
@@ -151,14 +253,14 @@
<!-- 悬浮信息层 --> <!-- 悬浮信息层 -->
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-300"> <div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute bottom-0 left-0 right-0 p-4 md:p-6 transform md:translate-y-4 md:group-hover:translate-y-0 transition-transform duration-300"> <div class="absolute bottom-0 left-0 right-0 p-3 md:p-4 transform md:translate-y-4 md:group-hover:translate-y-0 transition-transform duration-300">
<div class="text-xs text-white/70 mb-1"> <div class="text-[10px] md:text-xs text-white/70 mb-0.5">
{{ formatDate(image.date) }} {{ formatDate(image.date) }}
</div> </div>
<h3 class="text-base md:text-lg font-semibold text-white mb-1 md:mb-2 line-clamp-2"> <h3 class="text-sm md:text-base font-semibold text-white mb-1 line-clamp-2">
{{ image.title || '未命名' }} {{ image.title || '未命名' }}
</h3> </h3>
<p v-if="image.copyright" class="text-xs md:text-sm text-white/80 line-clamp-2"> <p v-if="image.copyright" class="text-[10px] md:text-xs text-white/80 line-clamp-2">
{{ image.copyright }} {{ image.copyright }}
</p> </p>
</div> </div>
@@ -173,13 +275,18 @@
<span>加载中...</span> <span>加载中...</span>
</div> </div>
<button <div
v-else-if="hasMore" v-else-if="hasMore"
ref="loadMoreTrigger"
class="inline-block"
>
<button
@click="loadMore" @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" 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> </button>
</div>
<p v-else class="text-white/40"> <p v-else class="text-white/40">
已加载全部图片 已加载全部图片
@@ -230,9 +337,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useTodayImage, useImageList } from '@/composables/useImages' import { useImageList, useGlobalTodayImages } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service' import { bingPaperApi } from '@/lib/api-service'
import { normalizeImageUrl } from '@/lib/api-config'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getDefaultMkt, setSavedMkt, SUPPORTED_REGIONS, setSupportedRegions } from '@/lib/mkt-utils'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -243,26 +352,146 @@ import {
const router = useRouter() const router = useRouter()
// 获取今日图片 // 地区列表
const { image: todayImage, loading: todayLoading } = useTodayImage() const regions = ref(SUPPORTED_REGIONS)
// 获取图片列表(使用服务端分页和筛选 // 顶部最新图片(独立加载,不受筛选影响
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(30) const latestImage = ref<any>(null)
const todayLoading = ref(false)
// 全球今日图片
const { images: globalImages } = useGlobalTodayImages()
// 滚动功能实现
const globalScrollContainer = ref<HTMLElement | null>(null)
const scrollGlobal = (direction: 'left' | 'right') => {
if (!globalScrollContainer.value) return
// 增加翻页数量:滚动容器宽度的 80%,或者固定 3 张卡片的宽度
const scrollAmount = globalScrollContainer.value.clientWidth * 0.8 || 1000
globalScrollContainer.value.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
})
}
// 鼠标滚轮横向滑动
const handleGlobalWheel = (e: WheelEvent) => {
if (!globalScrollContainer.value) return
const container = globalScrollContainer.value
const isAtStart = container.scrollLeft <= 0
const isAtEnd = Math.ceil(container.scrollLeft + container.clientWidth) >= container.scrollWidth
// 当鼠标在滚动区域内,且是纵向滚动时
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
// 如果还没滚动到尽头,则拦截纵向滚动并转为横向
if ((e.deltaY > 0 && !isAtEnd) || (e.deltaY < 0 && !isAtStart)) {
e.preventDefault()
// 加大滚动步进以实现“快速翻页”
container.scrollLeft += e.deltaY * 1.5
}
}
}
// 历史图片列表使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15)
// 获取地区标签
const getRegionLabel = (mkt?: string) => {
if (!mkt) return ''
const region = regions.value.find(r => r.value === mkt)
return region ? region.label : mkt
}
// 加载顶部最新图片
const loadLatestImage = async () => {
todayLoading.value = true
try {
const params: any = { page: 1, page_size: 1, mkt: selectedMkt.value }
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(async () => {
try {
const backendRegions = await bingPaperApi.getRegions()
if (backendRegions && backendRegions.length > 0) {
regions.value = backendRegions
setSupportedRegions(backendRegions)
}
} catch (error) {
console.error('Failed to fetch regions:', error)
}
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 selectedMkt = ref(getDefaultMkt())
const selectedYear = ref('') const selectedYear = ref('')
const selectedMonth = ref('') const selectedMonth = ref('')
const onMktChange = () => {
setSavedMkt(selectedMkt.value)
filterByMkt(selectedMkt.value)
loadLatestImage()
// 重置懒加载状态
imageVisibility.value = []
setTimeout(() => {
setupObserver()
}, 100)
}
// 懒加载相关 // 懒加载相关
const imageRefs = ref<(HTMLElement | null)[]>([]) const imageRefs = ref<(HTMLElement | null)[]>([])
const imageVisibility = ref<boolean[]>([]) const imageVisibility = ref<boolean[]>([])
let observer: IntersectionObserver | null = null let observer: IntersectionObserver | null = null
// 计算可用的年份列表基于当前日期生成从2020年到当前年份 // 无限滚动加载
const loadMoreTrigger = ref<HTMLElement | null>(null)
let loadMoreObserver: IntersectionObserver | null = null
// 计算可用的年份列表基于当前日期生成计算前20年
const availableYears = computed(() => { const availableYears = computed(() => {
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const years: number[] = [] const years: number[] = []
for (let year = currentYear; year >= 2020; year--) { for (let year = currentYear; year >= currentYear - 20; year--) {
years.push(year) years.push(year)
} }
return years return years
@@ -305,11 +534,14 @@ const onFilterChange = () => {
// 重置筛选 // 重置筛选
const resetFilters = () => { const resetFilters = () => {
selectedMkt.value = getDefaultMkt()
selectedYear.value = '' selectedYear.value = ''
selectedMonth.value = '' selectedMonth.value = ''
// 重置为加载默认数据 // 重置为加载默认数据
filterByMkt(selectedMkt.value)
filterByMonth(undefined) filterByMonth(undefined)
loadLatestImage()
// 重置懒加载状态 // 重置懒加载状态
imageVisibility.value = [] imageVisibility.value = []
@@ -360,16 +592,6 @@ const setupObserver = () => {
}) })
} }
// 初始化懒加载状态
onMounted(() => {
// 初始化时设置
if (images.value.length > 0) {
imageVisibility.value = new Array(images.value.length).fill(false)
setTimeout(() => {
setupObserver()
}, 100)
}
})
// 监听 images 变化,动态更新 imageVisibility // 监听 images 变化,动态更新 imageVisibility
watch(() => images.value.length, (newLength, oldLength) => { watch(() => images.value.length, (newLength, oldLength) => {
@@ -399,11 +621,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(() => { onUnmounted(() => {
if (observer) { if (observer) {
observer.disconnect() observer.disconnect()
} }
if (loadMoreObserver) {
loadMoreObserver.disconnect()
}
}) })
// 格式化日期 // 格式化日期
@@ -417,19 +681,24 @@ const formatDate = (dateStr?: string) => {
}) })
} }
// 获取今日图片 URL // 获取最新图片 URL顶部大图使用UHD高清
const getTodayImageUrl = () => { const getLatestImageUrl = () => {
return bingPaperApi.getTodayImageUrl('UHD', 'jpg') if (!latestImage.value?.date) return ''
return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg', latestImage.value.mkt)
} }
// 获取图片 URL缩略图 - 使用较小分辨率节省流量) // 获取图片 URL缩略图 - 优先使用后端返回的最小变体以节省流量)
const getImageUrl = (date: string) => { const getImageUrl = (image: any) => {
return bingPaperApi.getImageUrlByDate(date, '640x480', 'jpg') if (image.variants && image.variants.length > 0) {
return normalizeImageUrl(image.variants[0].url)
}
return bingPaperApi.getImageUrlByDate(image.date!, '640x480', 'jpg', image.mkt)
} }
// 查看图片详情 // 查看图片详情
const viewImage = (date: string) => { const viewImage = (date: string, mkt?: string) => {
router.push(`/image/${date}`) const query = mkt ? `?mkt=${mkt}` : ''
router.push(`/image/${date}${query}`)
} }
// 打开版权详情链接 // 打开版权详情链接
@@ -448,3 +717,34 @@ const openCopyrightLink = (link: string) => {
overflow: hidden; overflow: hidden;
} }
</style> </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 */
}
/* 隐藏横向滚动条 */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
</style>

View File

@@ -1,23 +1,28 @@
<template> <template>
<div class="fixed inset-0 bg-black z-50 overflow-hidden"> <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 class="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
</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"> <div class="absolute inset-0 flex items-center justify-center">
<img <img
:src="getFullImageUrl()" :src="getFullImageUrl()"
:alt="image.title || 'Bing Image'" :alt="image?.title || 'Bing Image'"
class="max-w-full max-h-full object-contain" class="max-w-full max-h-full object-contain transition-opacity duration-500 ease-in-out"
:style="{ opacity: imageOpacity }"
/> />
</div> </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"> <div class="flex items-center justify-between max-w-7xl mx-auto">
<button <button
@click="goBack" @click="goBack"
@@ -30,37 +35,44 @@
</button> </button>
<div class="text-white/80 text-sm"> <div class="text-white/80 text-sm">
{{ formatDate(image.date) }} {{ formatDate(image?.date) }}
</div> </div>
</div> </div>
</div> </div>
<!-- 信息悬浮层类似 Windows 聚焦 --> <!-- 信息悬浮层类似 Windows 聚焦 -->
<div <div
v-if="showInfo" v-if="showInfo && !showCalendar && image"
ref="infoPanel" 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" class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 z-20 select-none"
:style="{ left: infoPanelPos.x + 'px', top: infoPanelPos.y + 'px' }" :class="{
:class="{ 'opacity-100': showInfo, 'opacity-0': !showInfo }" '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 <div
@mousedown="startDrag" @mousedown="startDrag"
@touchstart="startDrag" @touchstart.passive="startDrag"
class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/30 rounded-full cursor-move hover:bg-white/50 transition-colors touch-none" class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/30 rounded-full cursor-move hover:bg-white/50 transition-colors touch-none"
></div> ></div>
<h2 class="text-lg font-bold text-white mb-2 mt-2"> <h2 class="text-lg font-bold text-white mb-2 mt-2">
{{ image.title || '未命名' }} {{ image?.title || '未命名' }}
</h2> </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 }} {{ image.copyright }}
</p> </p>
<!-- 版权详情链接 --> <!-- 版权详情链接 -->
<a <a
v-if="image.copyrightlink" v-if="image?.copyrightlink"
:href="image.copyrightlink" :href="image.copyrightlink"
target="_blank" 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" 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>
<!-- 底部控制栏 --> <!-- 底部控制栏 -->
<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 justify-between max-w-7xl mx-auto">
<!-- 日期切换按钮 --> <!-- 日期切换按钮 -->
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<button <button
@click="previousDay" @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" 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"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -100,7 +116,7 @@
<button <button
@click="nextDay" @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" 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> <span class="hidden sm:inline">后一天</span>
@@ -110,6 +126,19 @@
</button> </button>
</div> </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 <button
v-if="!showInfo" v-if="!showInfo"
@@ -124,6 +153,14 @@
</div> </div>
</div> </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"> <div v-else-if="error" class="absolute inset-0 flex items-center justify-center">
@@ -141,40 +178,121 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useImageByDate } from '@/composables/useImages' import { useImageByDate } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service' import { bingPaperApi } from '@/lib/api-service'
import { getDefaultMkt } from '@/lib/mkt-utils'
import Calendar from '@/components/ui/calendar/Calendar.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() 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 currentDate = ref(route.params.date as string)
const currentMkt = ref(route.query.mkt as string || getDefaultMkt())
const showInfo = ref(true) const showInfo = ref(true)
const showCalendar = ref(getInitialCalendarState())
const navigating = ref(false) const navigating = ref(false)
const imageOpacity = ref(1)
const imageTransitioning = ref(false)
// 响应式窗口大小
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
const isMobile = computed(() => windowSize.value.width < 768)
// 前后日期可用性
const hasPreviousDay = ref(true)
const hasNextDay = ref(true)
const checkingDates = ref(false)
// 拖动相关状态 // 拖动相关状态
const infoPanel = ref<HTMLElement | null>(null) const infoPanel = ref<HTMLElement | null>(null)
const infoPanelPos = ref({ x: 0, y: 0 }) const infoPanelPos = ref({ x: 0, y: 0 })
const isDragging = ref(false) const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 }) const dragStart = ref({ x: 0, y: 0 })
let animationFrameId: number | null = null
// 初始化浮窗位置(居中偏下 // 计算图片实际显示区域考虑图片宽高比和object-contain
const getImageDisplayBounds = () => {
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是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 = () => { const initPanelPosition = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const windowWidth = window.innerWidth const bounds = getImageDisplayBounds()
const windowHeight = window.innerHeight
const panelWidth = Math.min(windowWidth * 0.9, 448) // max-w-md = 448px if (isMobile.value) {
// 移动端:默认居中靠下,不严格限制在图片内(因为要求可以不限制)
// 但为了好看,我们还是给它一个默认位置
const panelWidth = windowSize.value.width * 0.9
infoPanelPos.value = { infoPanelPos.value = {
x: (windowWidth - panelWidth) / 2, x: (windowSize.value.width - panelWidth) / 2,
y: windowHeight - 200 // 距底部200px y: windowSize.value.height - 240 // 靠下
}
} else {
// 桌面端:限制在图片区域内
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
infoPanelPos.value = {
x: bounds.left + (bounds.width - panelWidth) / 2,
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px避免与控制栏重叠
}
} }
} }
} }
// 开始拖动 // 开始拖动
const startDrag = (e: MouseEvent | TouchEvent) => { const startDrag = (e: MouseEvent | TouchEvent) => {
if (e instanceof MouseEvent) {
e.preventDefault() e.preventDefault()
}
isDragging.value = true isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
@@ -185,42 +303,74 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
y: clientY - infoPanelPos.value.y y: clientY - infoPanelPos.value.y
} }
document.addEventListener('mousemove', onDrag) document.addEventListener('mousemove', onDrag, { passive: false })
document.addEventListener('mouseup', stopDrag) document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false }) document.addEventListener('touchmove', onDrag, { passive: true })
document.addEventListener('touchend', stopDrag) document.addEventListener('touchend', stopDrag)
} }
// 拖动中 // 拖动中 - 使用 requestAnimationFrame 优化性能
const onDrag = (e: MouseEvent | TouchEvent) => { const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return if (!isDragging.value) return
if (e instanceof TouchEvent) { if (e instanceof MouseEvent) {
e.preventDefault() e.preventDefault()
} }
// 取消之前的动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
}
// 使用 requestAnimationFrame 进行节流优化
animationFrameId = requestAnimationFrame(() => {
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
const newX = clientX - dragStart.value.x const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y const newY = clientY - dragStart.value.y
// 限制在视口 // 限制在有效区域
if (infoPanel.value) { if (infoPanel.value) {
const rect = infoPanel.value.getBoundingClientRect() const rect = infoPanel.value.getBoundingClientRect()
const maxX = window.innerWidth - rect.width
const maxY = window.innerHeight - rect.height let minX, maxX, minY, maxY
if (isMobile.value) {
// 移动端:不限制区域,限制在视口内即可
minX = 0
maxX = windowSize.value.width - rect.width
minY = 0
maxY = windowSize.value.height - rect.height
} else {
// 桌面端限制在图片实际显示区域内考虑底部控制栏高度约80px
const bounds = getImageDisplayBounds()
minX = bounds.left
maxX = bounds.right - rect.width
minY = bounds.top
maxY = bounds.bottom - rect.height - 80 // 预留底部控制栏空间
}
infoPanelPos.value = { infoPanelPos.value = {
x: Math.max(0, Math.min(newX, maxX)), x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY)) y: Math.max(minY, Math.min(newY, maxY))
} }
} }
animationFrameId = null
})
} }
// 停止拖动 // 停止拖动
const stopDrag = () => { const stopDrag = () => {
isDragging.value = false isDragging.value = false
// 清理动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag) document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag) document.removeEventListener('touchmove', onDrag)
@@ -228,10 +378,70 @@ const stopDrag = () => {
} }
// 使用 composable 获取图片数据(传递 ref自动响应日期变化 // 使用 composable 获取图片数据(传递 ref自动响应日期变化
const { image, loading, error } = useImageByDate(currentDate) const { image, loading, error } = useImageByDate(currentDate, currentMkt)
// 初始化位置 // 检测指定日期是否有数据
initPanelPosition() const checkDateAvailability = async (dateStr: string): Promise<boolean> => {
try {
await bingPaperApi.getImageMetaByDate(dateStr, currentMkt.value)
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
}
// 监听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 })
// 监听路由变化,支持前进后退
watch(() => route.params.date, (newDate) => {
if (newDate && newDate !== currentDate.value) {
currentDate.value = newDate as string
}
})
watch(() => route.query.mkt, (newMkt) => {
const mkt = (newMkt as string) || getDefaultMkt()
if (mkt !== currentMkt.value) {
currentMkt.value = mkt
}
})
// 格式化日期 // 格式化日期
const formatDate = (dateStr?: string) => { const formatDate = (dateStr?: string) => {
@@ -245,15 +455,66 @@ const formatDate = (dateStr?: string) => {
}) })
} }
// 判断是否是今天
const isToday = computed(() => {
const today = new Date().toISOString().split('T')[0]
return currentDate.value === today
})
// 获取完整图片 URL // 获取完整图片 URL
const getFullImageUrl = () => { const getFullImageUrl = () => {
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg') return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg', currentMkt.value)
}
// 预加载图片
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', currentMkt.value)
await Promise.all([
preloadImage(imageUrl),
bingPaperApi.getImageMetaByDate(date, currentMkt.value)
])
} 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}?mkt=${currentMkt.value}`)
// 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无需额外处理 // copyrightlink 现在是完整的 URL无需额外处理
@@ -264,68 +525,89 @@ const goBack = () => {
} }
// 前一天 // 前一天
const previousDay = () => { const previousDay = async () => {
if (navigating.value) return if (navigating.value || !hasPreviousDay.value || imageTransitioning.value) return
navigating.value = true navigating.value = true
const date = new Date(currentDate.value) const date = new Date(currentDate.value)
date.setDate(date.getDate() - 1) date.setDate(date.getDate() - 1)
const newDate = date.toISOString().split('T')[0] const newDate = date.toISOString().split('T')[0]
currentDate.value = newDate await switchToDate(newDate)
router.replace(`/image/${newDate}`)
setTimeout(() => {
navigating.value = false navigating.value = false
}, 500)
} }
// 后一天 // 后一天
const nextDay = () => { const nextDay = async () => {
if (navigating.value || isToday.value) return if (navigating.value || !hasNextDay.value || imageTransitioning.value) return
navigating.value = true navigating.value = true
const date = new Date(currentDate.value) const date = new Date(currentDate.value)
date.setDate(date.getDate() + 1) date.setDate(date.getDate() + 1)
const newDate = date.toISOString().split('T')[0] const newDate = date.toISOString().split('T')[0]
currentDate.value = newDate await switchToDate(newDate)
router.replace(`/image/${newDate}`)
setTimeout(() => {
navigating.value = false navigating.value = false
}, 500) }
// 切换日历状态watch会自动保存
const toggleCalendar = (state: boolean) => {
showCalendar.value = state
} }
// 键盘导航 // 键盘导航
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') { if (e.key === 'ArrowLeft' && hasPreviousDay.value) {
previousDay() previousDay()
} else if (e.key === 'ArrowRight' && !isToday.value) { } else if (e.key === 'ArrowRight' && hasNextDay.value) {
nextDay() nextDay()
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
if (showCalendar.value) {
toggleCalendar(false)
} else {
goBack() goBack()
}
} else if (e.key === 'i' || e.key === 'I') { } else if (e.key === 'i' || e.key === 'I') {
showInfo.value = !showInfo.value showInfo.value = !showInfo.value
} else if (e.key === 'c' || e.key === 'C') {
toggleCalendar(!showCalendar.value)
} }
} }
// 添加键盘事件监听 // 窗口缩放处理
if (typeof window !== 'undefined') { const handleResize = () => {
window.addEventListener('keydown', handleKeydown) windowSize.value = {
window.addEventListener('resize', initPanelPosition) width: window.innerWidth,
height: window.innerHeight
}
initPanelPosition()
} }
// 清理 // 生命周期钩子
import { onUnmounted } from 'vue' onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', handleResize)
}
// 初始化浮窗位置
initPanelPosition()
})
onUnmounted(() => { onUnmounted(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handleKeydown) window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', initPanelPosition) window.removeEventListener('resize', handleResize)
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag) document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag) document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag) document.removeEventListener('touchend', stopDrag)
// 清理动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
}
} }
}) })
</script> </script>

View File

@@ -37,15 +37,34 @@ export default defineConfig(({ mode }) => {
// 入口文件名 // 入口文件名
entryFileNames: 'assets/[name]-[hash].js', entryFileNames: 'assets/[name]-[hash].js',
// 手动分割代码 // 手动分割代码
manualChunks: { manualChunks: (id) => {
// 将 Vue 相关代码单独打包 // 将 node_modules 中的依赖分割成不同的 chunk
'vue-vendor': ['vue', 'vue-router'], if (id.includes('node_modules')) {
// 将 UI 组件库单独打包(如果有的话) // Vue 核心库
// 'ui-vendor': ['其他UI库'] 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: { server: {
port: 5173, port: 5173,