14 Commits

Author SHA1 Message Date
fb7545f9a2 优化 Bing API 请求逻辑,增加语言和地区识别支持,统一传递上下文并增强 HTTP 请求头配置 2026-01-31 00:22:24 +08:00
ee814f0380 提升日志级别并增加详细日志信息,优化 Bing API 调用与数据库操作可见性 2026-01-31 00:09:55 +08:00
d3ca6fa919 优化每日图片查询逻辑,使用数据库查询替代循环调用获取多地区图片 2026-01-30 23:40:07 +08:00
49c78506b2 数据库表重新设计,精简数据结构以及存储结构 2026-01-30 23:02:59 +08:00
e40677f105 Merge pull request #2 from hanxuanyu/feature/multi-region-fetch
支持多地区数据切换,优化数据存储结构,基于图片名分类存储,避免重复下载
2026-01-30 16:44:26 +08:00
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
32 changed files with 2256 additions and 398 deletions

View File

@@ -36,11 +36,15 @@ BingPaper 支持通过配置文件YAML和环境变量进行配置。
- `mode`: API 行为模式。
- `local`: (默认) 接口直接返回图片的二进制流,适合图片存储对外部不可见的情况。
- `redirect`: 接口返回 302 重定向到图片的 `PublicURL`,适合配合 S3 或 WebDAV 的公共访问。
- `enable_mkt_fallback`: 当请求的地区不存在或无数据时,是否允许兜底回退到默认地区或任意可用地区,默认 `true`
#### cron (定时任务)
- `enabled`: 是否启用定时抓取,默认 `true`
- `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。
#### fetcher (抓取配置)
- `regions`: 需要抓取的地区编码列表(如 `zh-CN`, `en-US` 等)。如果不设置,默认为包括主要国家在内的 17 个地区。
#### retention (数据保留)
- `days`: 图片及元数据保留天数。超过此天数的数据可能会被清理任务处理。设置为 `0` 表示永久保留,不进行自动清理。默认 `0`

View File

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

View File

@@ -1,7 +1,6 @@
server:
port: 8080
base_url: ""
log:
level: info
filename: data/logs/app.log
@@ -13,23 +12,20 @@ log:
log_console: true
show_db_log: false
db_log_level: info
api:
mode: local # local | redirect
mode: redirect
enable_mkt_fallback: false
enable_on_demand_fetch: false
cron:
enabled: true
daily_spec: "20 8-23/4 * * *"
daily_spec: 20 8-23/4 * * *
retention:
days: 0
db:
type: sqlite # sqlite | mysql | postgres
type: sqlite
dsn: data/bing_paper.db
storage:
type: local # local | s3 | webdav
type: local
local:
root: data/picture
s3:
@@ -45,15 +41,28 @@ storage:
username: ""
password: ""
public_url_prefix: ""
admin:
password_bcrypt: "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka" # 默认密码: admin123
password_bcrypt: $2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka
token:
default_ttl: 168h
feature:
write_daily_files: true
web:
path: web
fetcher:
regions:
- zh-CN
- en-US
- ja-JP
- en-AU
- en-GB
- de-DE
- en-NZ
- en-CA
- fr-FR
- it-IT
- es-ES
- pt-BR
- ko-KR
- en-IN
- ru-RU

View File

@@ -14,23 +14,4 @@ services:
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:-}
- BINGPAPER_LOG_LEVEL=${BINGPAPER_LOG_LEVEL:-info}

View File

@@ -413,6 +413,12 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{
"type": "string",
"default": "UHD",
@@ -434,6 +440,24 @@ const docTemplate = `{
"schema": {
"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",
"in": "path",
"required": true
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": {
@@ -463,6 +493,24 @@ const docTemplate = `{
"schema": {
"$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": "获取随机图片",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{
"type": "string",
"default": "UHD",
@@ -499,6 +553,24 @@ const docTemplate = `{
"schema": {
"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"
],
"summary": "获取随机图片元数据",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$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": "获取今日图片",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{
"type": "string",
"default": "UHD",
@@ -555,6 +659,24 @@ const docTemplate = `{
"schema": {
"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"
],
"summary": "获取今日图片元数据",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$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)",
"name": "month",
"in": "query"
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"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": {
"config.APIConfig": {
"type": "object",
"properties": {
"enableMktFallback": {
"description": "当请求的地区不存在时,是否回退到默认地区",
"type": "boolean"
},
"enableOnDemandFetch": {
"description": "是否启用按需抓取",
"type": "boolean"
},
"mode": {
"description": "local | redirect",
"type": "string"
@@ -666,6 +874,9 @@ const docTemplate = `{
"feature": {
"$ref": "#/definitions/config.FeatureConfig"
},
"fetcher": {
"$ref": "#/definitions/config.FetcherConfig"
},
"log": {
"$ref": "#/definitions/config.LogConfig"
},
@@ -717,6 +928,17 @@ const docTemplate = `{
}
}
},
"config.FetcherConfig": {
"type": "object",
"properties": {
"regions": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"config.LocalConfig": {
"type": "object",
"properties": {
@@ -917,6 +1139,9 @@ const docTemplate = `{
"hsh": {
"type": "string"
},
"mkt": {
"type": "string"
},
"quiz": {
"type": "string"
},
@@ -1006,6 +1231,17 @@ const docTemplate = `{
"type": "string"
}
}
},
"util.Region": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
"securityDefinitions": {

View File

@@ -407,6 +407,12 @@
"in": "path",
"required": true
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{
"type": "string",
"default": "UHD",
@@ -428,6 +434,24 @@
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -449,6 +473,12 @@
"name": "date",
"in": "path",
"required": true
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": {
@@ -457,6 +487,24 @@
"schema": {
"$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": "获取随机图片",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{
"type": "string",
"default": "UHD",
@@ -493,6 +547,24 @@
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -507,12 +579,38 @@
"image"
],
"summary": "获取随机图片元数据",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$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": "获取今日图片",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
},
{
"type": "string",
"default": "UHD",
@@ -549,6 +653,24 @@
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -563,12 +685,38 @@
"image"
],
"summary": "获取今日图片元数据",
"parameters": [
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$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)",
"name": "month",
"in": "query"
},
{
"type": "string",
"description": "地区编码 (如 zh-CN, en-US)",
"name": "mkt",
"in": "query"
}
],
"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": {
"config.APIConfig": {
"type": "object",
"properties": {
"enableMktFallback": {
"description": "当请求的地区不存在时,是否回退到默认地区",
"type": "boolean"
},
"enableOnDemandFetch": {
"description": "是否启用按需抓取",
"type": "boolean"
},
"mode": {
"description": "local | redirect",
"type": "string"
@@ -660,6 +868,9 @@
"feature": {
"$ref": "#/definitions/config.FeatureConfig"
},
"fetcher": {
"$ref": "#/definitions/config.FetcherConfig"
},
"log": {
"$ref": "#/definitions/config.LogConfig"
},
@@ -711,6 +922,17 @@
}
}
},
"config.FetcherConfig": {
"type": "object",
"properties": {
"regions": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"config.LocalConfig": {
"type": "object",
"properties": {
@@ -911,6 +1133,9 @@
"hsh": {
"type": "string"
},
"mkt": {
"type": "string"
},
"quiz": {
"type": "string"
},
@@ -1000,6 +1225,17 @@
"type": "string"
}
}
},
"util.Region": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
"securityDefinitions": {

View File

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

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"os"
"strings"
"BingPaper/internal/config"
"BingPaper/internal/cron"
@@ -52,10 +53,11 @@ func Init(webFS embed.FS, configPath string) *gin.Engine {
// 输出配置信息
util.Logger.Info("Application configuration loaded")
util.Logger.Info("├─ Config file", zap.String("path", config.GetRawViper().ConfigFileUsed()))
util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type))
util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type))
util.Logger.Info("─ Server ", zap.Int("port", cfg.Server.Port))
util.Logger.Info("├─ Config file ", zap.String("path", config.GetRawViper().ConfigFileUsed()))
util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type))
util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type))
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 {
@@ -147,5 +149,6 @@ func LogWelcomeInfo() {
fmt.Printf(" - 管理后台: %s/admin\n", baseURL)
fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL)
fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL)
fmt.Printf(" - 激活地区: %s\n", strings.Join(cfg.Fetcher.Regions, ", "))
fmt.Println("---------------------------------------------------------")
}

View File

@@ -11,6 +11,8 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
"BingPaper/internal/util"
)
type Config struct {
@@ -25,6 +27,7 @@ type Config struct {
Token TokenConfig `mapstructure:"token" yaml:"token"`
Feature FeatureConfig `mapstructure:"feature" yaml:"feature"`
Web WebConfig `mapstructure:"web" yaml:"web"`
Fetcher FetcherConfig `mapstructure:"fetcher" yaml:"fetcher"`
}
type ServerConfig struct {
@@ -57,7 +60,9 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
type APIConfig struct {
Mode string `mapstructure:"mode" yaml:"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 {
@@ -118,6 +123,10 @@ type WebConfig struct {
Path string `mapstructure:"path" yaml:"path"`
}
type FetcherConfig struct {
Regions []string `mapstructure:"regions" yaml:"regions"`
}
// Bing 默认配置 (内置)
const (
BingMkt = "zh-CN"
@@ -156,7 +165,9 @@ func Init(configPath string) error {
v.SetDefault("log.log_console", true)
v.SetDefault("log.show_db_log", false)
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.daily_spec", "20 8-23/4 * * *")
v.SetDefault("retention.days", 0)
@@ -167,6 +178,13 @@ func Init(configPath string) error {
v.SetDefault("token.default_ttl", "168h")
v.SetDefault("feature.write_daily_files", true)
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
// 绑定环境变量
@@ -311,3 +329,11 @@ func GetTokenTTL() time.Duration {
}
return ttl
}
// GetDefaultRegion 返回生效的默认地区编码
func (c *Config) GetDefaultRegion() string {
if len(c.Fetcher.Regions) > 0 {
return c.Fetcher.Regions[0]
}
return BingMkt
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"BingPaper/internal/config"
"BingPaper/internal/model"
@@ -27,6 +28,7 @@ type ImageVariantResp struct {
type ImageMetaResp struct {
Date string `json:"date"`
Mkt string `json:"mkt"`
Title string `json:"title"`
Copyright string `json:"copyright"`
CopyrightLink string `json:"copyrightlink"`
@@ -41,70 +43,103 @@ type ImageMetaResp struct {
// @Summary 获取今日图片
// @Description 根据参数返回今日必应图片流或重定向
// @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 format query string false "格式 (jpg)" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/today [get]
func GetToday(c *gin.Context) {
img, err := image.GetTodayImage()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
mkt := c.Query("mkt")
imgRegion, err := image.GetTodayImage(mkt)
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
}
handleImageResponse(c, img, 7200) // 2小时
if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, imgRegion, 7200) // 2小时
}
// GetTodayMeta 获取今日图片元数据
// @Summary 获取今日图片元数据
// @Description 获取今日必应图片的标题、版权等元数据
// @Tags image
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/today/meta [get]
func GetTodayMeta(c *gin.Context) {
img, err := image.GetTodayImage()
mkt := c.Query("mkt")
imgRegion, err := image.GetTodayImage(mkt)
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
}
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "public, max-age=7200") // 2小时
c.JSON(http.StatusOK, formatMeta(img))
c.JSON(http.StatusOK, formatMeta(imgRegion))
}
// GetRandom 获取随机图片
// @Summary 获取随机图片
// @Description 随机返回一张已抓取的图片流或重定向
// @Tags image
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Param variant query string false "分辨率" default(UHD)
// @Param format query string false "格式" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random [get]
// GetRandom 获取随机图片
func GetRandom(c *gin.Context) {
img, err := image.GetRandomImage()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
mkt := c.Query("mkt")
imgRegion, err := image.GetRandomImage(mkt)
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
}
handleImageResponse(c, img, 0) // 禁用缓存
if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, imgRegion, 0) // 禁用缓存
}
// GetRandomMeta 获取随机图片元数据
// @Summary 获取随机图片元数据
// @Description 随机获取一张已抓取图片的元数据
// @Tags image
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random/meta [get]
func GetRandomMeta(c *gin.Context) {
img, err := image.GetRandomImage()
mkt := c.Query("mkt")
imgRegion, err := image.GetRandomImage(mkt)
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
}
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
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(imgRegion))
}
// GetByDate 获取指定日期图片
@@ -112,19 +147,27 @@ func GetRandomMeta(c *gin.Context) {
// @Description 根据日期返回图片流或重定向 (yyyy-mm-dd)
// @Tags image
// @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 format query string false "格式" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date} [get]
func GetByDate(c *gin.Context) {
date := c.Param("date")
img, err := image.GetImageByDate(date)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
mkt := c.Query("mkt")
imgRegion, err := image.GetImageByDate(date, mkt)
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
}
handleImageResponse(c, img, 604800) // 7天
if err != nil {
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, imgRegion, 604800) // 7天
}
// GetByDateMeta 获取指定日期图片元数据
@@ -132,18 +175,26 @@ func GetByDate(c *gin.Context) {
// @Description 根据日期获取图片元数据 (yyyy-mm-dd)
// @Tags image
// @Param date path string true "日期 (yyyy-mm-dd)"
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date}/meta [get]
func GetByDateMeta(c *gin.Context) {
date := c.Param("date")
img, err := image.GetImageByDate(date)
mkt := c.Query("mkt")
imgRegion, err := image.GetImageByDate(date, mkt)
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
}
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "public, max-age=604800") // 7天
c.JSON(http.StatusOK, formatMeta(img))
c.JSON(http.StatusOK, formatMeta(imgRegion))
}
// ListImages 获取图片列表
@@ -154,6 +205,7 @@ func GetByDateMeta(c *gin.Context) {
// @Param page query int false "页码 (从1开始)"
// @Param page_size query int false "每页数量"
// @Param month query string false "按月份过滤 (格式: YYYY-MM)"
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {array} ImageMetaResp
// @Router /images [get]
@@ -162,10 +214,12 @@ func ListImages(c *gin.Context) {
pageStr := c.Query("page")
pageSizeStr := c.Query("page_size")
month := c.Query("month")
mkt := c.Query("mkt")
// 记录请求参数,便于排查过滤失效问题
util.Logger.Debug("ListImages parameters",
zap.String("month", month),
zap.String("mkt", mkt),
zap.String("page", pageStr),
zap.String("page_size", pageSizeStr),
zap.String("limit", limitStr))
@@ -192,7 +246,7 @@ func ListImages(c *gin.Context) {
offset = 0
}
images, err := image.GetImageList(limit, offset, month)
images, err := image.GetImageList(limit, offset, month, mkt)
if err != nil {
util.Logger.Error("ListImages service call failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -201,26 +255,75 @@ func ListImages(c *gin.Context) {
result := []gin.H{}
for _, img := range images {
result = append(result, formatMeta(&img))
result = append(result, formatMetaSummary(&img))
}
c.JSON(http.StatusOK, result)
}
func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
// 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, m *model.ImageRegion, maxAge int) {
variant := c.DefaultQuery("variant", "UHD")
format := c.DefaultQuery("format", "jpg")
var selected *model.ImageVariant
for _, v := range img.Variants {
for _, v := range m.Variants {
if v.Variant == variant && v.Format == format {
selected = &v
break
}
}
if selected == nil && len(img.Variants) > 0 {
if selected == nil && len(m.Variants) > 0 {
// 回退逻辑
selected = &img.Variants[0]
selected = &m.Variants[0]
}
if selected == nil {
@@ -237,9 +340,9 @@ func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
}
c.Redirect(http.StatusFound, selected.PublicURL)
} else if img.URLBase != "" {
} else if m.URLBase != "" {
// 兜底重定向到原始 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", m.URLBase, selected.Variant)
if maxAge > 0 {
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
} else {
@@ -247,10 +350,10 @@ func handleImageResponse(c *gin.Context, img *model.Image, maxAge int) {
}
c.Redirect(http.StatusFound, bingURL)
} else {
serveLocal(c, selected.StorageKey, img.Date, maxAge)
serveLocal(c, selected.StorageKey, m.Date, maxAge)
}
} else {
serveLocal(c, selected.StorageKey, img.Date, maxAge)
serveLocal(c, selected.StorageKey, m.Date, maxAge)
}
}
@@ -283,15 +386,69 @@ func serveLocal(c *gin.Context, key string, etag string, maxAge int) {
io.Copy(c.Writer, reader)
}
func formatMeta(img *model.Image) gin.H {
func formatMetaSummary(m *model.ImageRegion) gin.H {
cfg := config.GetConfig()
// 找到最小的变体
var smallest *model.ImageVariant
for i := range m.Variants {
v := &m.Variants[i]
if smallest == nil {
smallest = v
continue
}
// 如果当前变体 Size 更小且不为 0或者 smallest 的 Size 为 0
if v.Size > 0 && (smallest.Size == 0 || v.Size < smallest.Size) {
smallest = v
} else if v.Size == smallest.Size {
// 如果 Size 相同(包括都为 0根据分辨率名称判断
if compareResolution(v.Variant, smallest.Variant) < 0 {
smallest = v
}
}
}
variants := []gin.H{}
if smallest != nil {
url := smallest.PublicURL
if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.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, m.Date, smallest.Variant, smallest.Format, m.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": m.Date,
"mkt": m.Mkt,
"title": m.Title,
"copyright": m.Copyright,
"copyrightlink": m.CopyrightLink,
"quiz": m.Quiz,
"startdate": m.StartDate,
"fullstartdate": m.FullStartDate,
"hsh": m.HSH,
"variants": variants,
}
}
func formatMeta(m *model.ImageRegion) gin.H {
cfg := config.GetConfig()
variants := []gin.H{}
for _, v := range img.Variants {
for _, v := range m.Variants {
url := v.PublicURL
if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" {
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant)
if url == "" && cfg.API.Mode == "redirect" && m.URLBase != "" {
url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", m.URLBase, v.Variant)
} 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, m.Date, v.Variant, v.Format, m.Mkt)
}
variants = append(variants, gin.H{
"variant": v.Variant,
@@ -303,14 +460,109 @@ func formatMeta(img *model.Image) gin.H {
}
return gin.H{
"date": img.Date,
"title": img.Title,
"copyright": img.Copyright,
"copyrightlink": img.CopyrightLink,
"quiz": img.Quiz,
"startdate": img.StartDate,
"fullstartdate": img.FullStartDate,
"hsh": img.HSH,
"date": m.Date,
"mkt": m.Mkt,
"title": m.Title,
"copyright": m.Copyright,
"copyrightlink": m.CopyrightLink,
"quiz": m.Quiz,
"startdate": m.StartDate,
"fullstartdate": m.FullStartDate,
"hsh": m.HSH,
"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)
}
// compareResolution 比较两个分辨率变体的大小。
// 返回 < 0 表示 v1 < v2返回 > 0 表示 v1 > v2返回 0 表示相等。
func compareResolution(v1, v2 string) int {
resOrder := map[string]int{
"320x240": 1,
"400x240": 2,
"480x360": 3,
"640x360": 4,
"640x480": 5,
"800x480": 6,
"800x600": 7,
"1024x768": 8,
"1280x720": 9,
"1366x768": 10,
"1920x1080": 11,
"UHD": 12,
}
o1, ok1 := resOrder[v1]
o2, ok2 := resOrder[v2]
if !ok1 && !ok2 {
return strings.Compare(v1, v2)
}
if !ok1 {
return 1 // 未知的分辨率认为比已知的大
}
if !ok2 {
return -1
}
return o1 - o2
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -20,11 +21,15 @@ func TestHandleImageResponseRedirect(t *testing.T) {
config.GetConfig().API.Mode = "redirect"
// Mock Image and Variant
img := &model.Image{
Date: "2026-01-26",
URLBase: "/th?id=OHR.TestImage",
imgRegion := &model.ImageRegion{
Date: "2026-01-26",
Mkt: "zh-CN",
HSH: "testhsh",
ImageName: "TestImage",
URLBase: "/th?id=OHR.TestImage",
Variants: []model.ImageVariant{
{
ImageName: "TestImage",
Variant: "UHD",
Format: "jpg",
PublicURL: "", // Empty for local storage simulation
@@ -38,7 +43,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/api/v1/image/today?variant=UHD", nil)
handleImageResponse(c, img, 0)
handleImageResponse(c, imgRegion, 0)
assert.Equal(t, http.StatusFound, w.Code)
assert.Contains(t, w.Header().Get("Location"), "bing.com")
@@ -47,7 +52,7 @@ func TestHandleImageResponseRedirect(t *testing.T) {
t.Run("FormatMeta in redirect mode should return Bing URL if PublicURL is empty", func(t *testing.T) {
config.GetConfig().API.Mode = "redirect"
meta := formatMeta(img)
meta := formatMeta(imgRegion)
variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants))
@@ -58,11 +63,66 @@ func TestHandleImageResponseRedirect(t *testing.T) {
t.Run("FormatMeta in local mode should return API URL", func(t *testing.T) {
config.GetConfig().API.Mode = "local"
config.GetConfig().Server.BaseURL = "http://myserver.com"
meta := formatMeta(img)
meta := formatMeta(imgRegion)
variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants))
assert.Contains(t, variants[0]["url"].(string), "myserver.com")
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.ImageRegion{
Date: "2026-01-26",
ImageName: "TestImage2",
Variants: []model.ImageVariant{
{ImageName: "TestImage2", Variant: "UHD", Size: 1000, Format: "jpg"},
{ImageName: "TestImage2", Variant: "640x480", Size: 200, Format: "jpg"},
{ImageName: "TestImage2", 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"])
})
t.Run("FormatMetaSummary should handle zero size by following order if names suggest it", func(t *testing.T) {
imgWithZeroSize := &model.ImageRegion{
Date: "2026-01-26",
ImageName: "TestImage3",
Variants: []model.ImageVariant{
{ImageName: "TestImage3", Variant: "UHD", Size: 0, Format: "jpg"},
{ImageName: "TestImage3", Variant: "320x240", Size: 0, Format: "jpg"},
},
}
meta := formatMetaSummary(imgWithZeroSize)
variants := meta["variants"].([]gin.H)
assert.Equal(t, 1, len(variants))
assert.Equal(t, "320x240", 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)
}
api.GET("/images", handlers.ListImages)
api.GET("/images/global/today", handlers.ListGlobalTodayImages)
api.GET("/regions", handlers.GetRegions)
// 管理接口
admin := api.Group("/admin")

View File

@@ -6,28 +6,30 @@ import (
"gorm.io/gorm"
)
type Image struct {
type ImageRegion struct {
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.
HSH string `gorm:"type:varchar(64)" json:"hsh"`
URLBase string `json:"urlbase"`
ImageName string `gorm:"index" json:"image_name"`
Title string `json:"title"`
Copyright string `json:"copyright"`
CopyrightLink string `json:"copyrightlink"`
URLBase string `json:"urlbase"`
Quiz string `json:"quiz"`
StartDate string `json:"startdate"`
FullStartDate string `json:"fullstartdate"`
HSH string `json:"hsh"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Variants []ImageVariant `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"variants"`
Variants []ImageVariant `gorm:"foreignKey:ImageName;references:ImageName" json:"variants"`
}
type ImageVariant struct {
ID uint `gorm:"primaryKey" json:"id"`
ImageID uint `gorm:"index;uniqueIndex:idx_image_variant_format" json:"image_id"`
Variant string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
Format string `gorm:"uniqueIndex:idx_image_variant_format;type:varchar(10)" json:"format"` // jpg, webp
ImageName string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(100)" json:"image_name"`
Variant string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(20)" json:"variant"` // UHD, 1920x1080, etc.
Format string `gorm:"uniqueIndex:idx_name_variant_format;type:varchar(10)" json:"format"` // jpg, webp
StorageKey string `json:"storage_key"`
PublicURL string `json:"public_url"`
Size int64 `json:"size"`

View File

@@ -131,7 +131,7 @@ func InitDB() error {
// 但此处假设 DSN 中指定的数据库已经存在。AutoMigrate 会负责创建表。
// 迁移
if err := db.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
if err := db.AutoMigrate(&model.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil {
util.Logger.Error("Database migration failed", zap.Error(err))
return err
}

View File

@@ -29,19 +29,17 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
}
// 2. 自动迁移结构
if err := newDB.AutoMigrate(&model.Image{}, &model.ImageVariant{}, &model.Token{}); err != nil {
if err := newDB.AutoMigrate(&model.ImageRegion{}, &model.ImageVariant{}, &model.Token{}); err != nil {
return fmt.Errorf("failed to migrate schema in new DB: %w", err)
}
// 3. 清空新数据库中的现有数据(防止冲突)
util.Logger.Info("Cleaning up destination database before migration")
// 备份或清空目标数据库。由于用户要求“可能需要清空或备份”,
// 这里我们选择在迁移前清空目标表,以确保迁移过来的数据是完整且不冲突的。
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageVariant{}).Error; err != nil {
return fmt.Errorf("failed to clear ImageVariants: %w", err)
}
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Image{}).Error; err != nil {
return fmt.Errorf("failed to clear Images: %w", err)
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ImageRegion{}).Error; err != nil {
return fmt.Errorf("failed to clear ImageRegions: %w", err)
}
if err := newDB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Token{}).Error; err != nil {
return fmt.Errorf("failed to clear Tokens: %w", err)
@@ -50,15 +48,15 @@ func MigrateDataToNewDB(oldDB *gorm.DB, newConfig *config.Config) error {
// 4. 开始迁移数据
// 使用事务确保迁移的原子性
return newDB.Transaction(func(tx *gorm.DB) error {
// 迁移 Images
var images []model.Image
if err := oldDB.Find(&images).Error; err != nil {
return fmt.Errorf("failed to fetch images from old DB: %w", err)
// 迁移 ImageRegions
var regions []model.ImageRegion
if err := oldDB.Find(&regions).Error; err != nil {
return fmt.Errorf("failed to fetch image regions from old DB: %w", err)
}
if len(images) > 0 {
util.Logger.Info("Migrating images", zap.Int("count", len(images)))
if err := tx.Create(&images).Error; err != nil {
return fmt.Errorf("failed to insert images into new DB: %w", err)
if len(regions) > 0 {
util.Logger.Info("Migrating image regions", zap.Int("count", len(regions)))
if err := tx.Create(&regions).Error; err != nil {
return fmt.Errorf("failed to insert image regions into new DB: %w", err)
}
}

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"BingPaper/internal/config"
@@ -55,26 +56,14 @@ func NewFetcher() *Fetcher {
func (f *Fetcher) Fetch(ctx context.Context, n int) error {
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)
util.Logger.Debug("Requesting Bing API", zap.String("url", url))
resp, err := f.httpClient.Get(url)
if err != nil {
util.Logger.Error("Failed to request Bing API", zap.Error(err))
return err
}
defer resp.Body.Close()
var bingResp BingResponse
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
util.Logger.Error("Failed to decode Bing API response", zap.Error(err))
return err
regions := config.GetConfig().Fetcher.Regions
if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultRegion()}
}
util.Logger.Info("Fetched images from Bing", zap.Int("count", len(bingResp.Images)))
for _, bingImg := range bingResp.Images {
if err := f.processImage(ctx, bingImg); err != nil {
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err))
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))
}
}
@@ -82,66 +71,88 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
return nil
}
func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
// 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 {
lang := strings.Split(mkt, "-")[0]
url := fmt.Sprintf("%s?format=js&idx=%d&n=%d&uhd=1&mkt=%s&setlang=%s", config.BingAPIBase, idx, n, mkt, lang)
util.Logger.Info("Requesting Bing API", zap.String("url", url))
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
util.Logger.Error("Failed to create Bing API request", zap.Error(err))
return err
}
// 添加请求头以增强地区/语言识别
req.Header.Set("Accept-Language", fmt.Sprintf("%s,%s;q=0.9", mkt, lang))
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err != nil {
util.Logger.Error("Failed to request Bing API", zap.Error(err))
return err
}
defer resp.Body.Close()
util.Logger.Info("Received response from Bing API", zap.String("mkt", mkt), zap.Int("status", resp.StatusCode))
var bingResp BingResponse
if err := json.NewDecoder(resp.Body).Decode(&bingResp); err != nil {
util.Logger.Error("Failed to decode Bing API response", zap.Error(err))
return err
}
util.Logger.Info("Fetched images from Bing", zap.String("mkt", mkt), zap.Int("count", len(bingResp.Images)))
for _, bingImg := range bingResp.Images {
util.Logger.Info("Bing image metadata",
zap.String("mkt", mkt),
zap.String("date", bingImg.Enddate),
zap.String("title", bingImg.Title),
zap.String("hsh", bingImg.HSH))
if err := f.processImage(ctx, bingImg, mkt); err != nil {
util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err))
}
}
return nil
}
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])
// 幂等检查
var existing model.Image
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err == nil {
util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr))
// 1. 地区关联幂等检查
var existingRegion model.ImageRegion
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existingRegion).Error; err == nil {
util.Logger.Info("ImageRegion record already exists, skipping", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title))
return nil
}
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("title", bingImg.Title))
imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH)
// 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 记录
dbImg := model.Image{
Date: dateStr,
Title: bingImg.Title,
Copyright: bingImg.Copyright,
CopyrightLink: bingImg.CopyrightLink,
URLBase: bingImg.URLBase,
Quiz: bingImg.Quiz,
StartDate: bingImg.Startdate,
FullStartDate: bingImg.Fullstartdate,
HSH: bingImg.HSH,
}
if err := repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}},
DoNothing: true,
}).Create(&dbImg).Error; err != nil {
util.Logger.Error("Failed to create image record", zap.Error(err))
return err
}
// 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID
if dbImg.ID == 0 {
var existing model.Image
if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err != nil {
util.Logger.Error("Failed to query existing image record after conflict", zap.Error(err))
return err
}
dbImg = existing
}
// 保存各种分辨率
// 2. 处理变体
imgURL, variantName := f.probeUHD(ctx, bingImg.URLBase)
targetVariants := []struct {
name string
width int
@@ -160,51 +171,138 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error {
{"320x240", 320, 240},
}
// 首先保存原图 (UHD 或 1080p)
if err := f.saveVariant(ctx, &dbImg, variantName, "jpg", imgData); err != nil {
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
}
// 检查变体是否已存在 (通过 ImageName)
var existingVariants []model.ImageVariant
repo.DB.Where("image_name = ?", imageName).Find(&existingVariants)
for _, v := range targetVariants {
// 如果目标分辨率就是原图分辨率,则跳过(已经保存过了)
if v.name == variantName {
continue
allVariantsExist := len(existingVariants) > 0
var srcImg image.Image
var imgData []byte
if allVariantsExist {
util.Logger.Debug("Image variants already exist for name, linking only", zap.String("imageName", imageName))
} else {
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL), zap.String("imageName", imageName))
var err error
imgData, err = f.downloadImage(ctx, imgURL)
if err != nil {
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
return err
}
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil {
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
continue
srcImg, _, err = image.Decode(bytes.NewReader(imgData))
if err != nil {
util.Logger.Error("Failed to decode image data", zap.Error(err))
return err
}
currentImgData := buf.Bytes()
// 保存 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))
// 保存原图变体
if err := f.saveVariant(ctx, imageName, variantName, "jpg", imgData); err != nil {
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
}
for _, v := range targetVariants {
if v.name == variantName {
continue
}
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil {
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
continue
}
currentImgData := buf.Bytes()
if err := f.saveVariant(ctx, imageName, v.name, "jpg", currentImgData); err != nil {
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
}
}
}
// 保存今日额外文件
// 3. 创建 ImageRegion 记录
regionRecord := model.ImageRegion{
HSH: bingImg.HSH,
URLBase: bingImg.URLBase,
ImageName: imageName,
Date: dateStr,
Mkt: mkt,
Title: bingImg.Title,
Copyright: bingImg.Copyright,
CopyrightLink: bingImg.CopyrightLink,
Quiz: bingImg.Quiz,
StartDate: bingImg.Startdate,
FullStartDate: bingImg.Fullstartdate,
}
if err := repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}, {Name: "mkt"}},
UpdateAll: true,
}).Create(&regionRecord).Error; err != nil {
util.Logger.Error("Failed to create region record", zap.Error(err))
return err
}
util.Logger.Info("Successfully saved/updated ImageRegion record to database",
zap.String("date", dateStr),
zap.String("mkt", mkt),
zap.String("title", regionRecord.Title))
// 4. 保存今日额外文件
today := time.Now().Format("2006-01-02")
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
f.saveDailyFiles(srcImg, imgData)
if imgData != nil && srcImg != nil {
f.saveDailyFiles(srcImg, imgData, mkt)
}
}
return nil
}
func (f *Fetcher) probeUHD(urlBase string) (string, string) {
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(ctx context.Context, urlBase string) (string, string) {
uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase)
resp, err := f.httpClient.Head(uhdURL)
req, err := http.NewRequestWithContext(ctx, "HEAD", uhdURL, nil)
if err != nil {
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err == nil && resp.StatusCode == http.StatusOK {
return uhdURL, "UHD"
}
return fmt.Sprintf("https://www.bing.com%s_1920x1080.jpg", urlBase), "1920x1080"
}
func (f *Fetcher) downloadImage(url string) ([]byte, error) {
resp, err := f.httpClient.Get(url)
func (f *Fetcher) downloadImage(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := f.httpClient.Do(req)
if err != nil {
return nil, err
}
@@ -212,47 +310,84 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) {
return io.ReadAll(resp.Body)
}
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error {
key := fmt.Sprintf("%s/%s_%s.%s", img.Date, img.Date, variant, format)
func (f *Fetcher) generateKey(imageName, variant, format string) string {
return fmt.Sprintf("%s/%s_%s.%s", imageName, imageName, variant, format)
}
func (f *Fetcher) saveVariant(ctx context.Context, imageName, variant, format string, data []byte) error {
key := f.generateKey(imageName, variant, format)
contentType := "image/jpeg"
if format == "webp" {
contentType = "image/webp"
}
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
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
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
publicURL = pURL
}
// 如果传入了数据,则使用数据大小
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)
if err != nil {
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{
ImageName: imageName,
Variant: variant,
Format: format,
StorageKey: key,
PublicURL: publicURL,
Size: size,
}
err := repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "image_name"}, {Name: "variant"}, {Name: "format"}},
DoNothing: true,
}).Create(&vRecord).Error
if err != nil {
return err
}
vRecord := model.ImageVariant{
ImageID: img.ID,
Variant: variant,
Format: format,
StorageKey: stored.Key,
PublicURL: stored.PublicURL,
Size: int64(len(data)),
}
util.Logger.Info("Successfully saved ImageVariant record to database",
zap.String("image_name", imageName),
zap.String("variant", variant),
zap.String("format", format))
return repo.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "image_id"}, {Name: "variant"}, {Name: "format"}},
DoNothing: true,
}).Create(&vRecord).Error
return nil
}
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
util.Logger.Info("Saving daily files")
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) {
util.Logger.Info("Saving daily files", zap.String("mkt", mkt))
localRoot := config.GetConfig().Storage.Local.Root
if localRoot == "" {
localRoot = "data"
}
if err := os.MkdirAll(localRoot, 0755); err != nil {
util.Logger.Error("Failed to create directory", zap.String("path", localRoot), zap.Error(err))
mktDir := filepath.Join(localRoot, mkt)
if err := os.MkdirAll(mktDir, 0755); err != nil {
util.Logger.Error("Failed to create directory", zap.String("path", mktDir), zap.Error(err))
return
}
// daily.jpeg (quality 100)
jpegPath := filepath.Join(localRoot, "daily.jpeg")
jpegPath := filepath.Join(mktDir, "daily.jpeg")
fJpeg, err := os.Create(jpegPath)
if err != nil {
util.Logger.Error("Failed to create daily.jpeg", zap.Error(err))
@@ -262,8 +397,21 @@ func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
}
// 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 {
util.Logger.Error("Failed to write original.jpeg", zap.Error(err))
}
// 同时也保留一份在根目录下(兼容旧逻辑,且作为默认地区图片)
// 如果是默认地区或者是第一个抓取的地区,可以覆盖根目录的文件
if mkt == config.GetConfig().GetDefaultRegion() {
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,24 @@ package image
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
"BingPaper/internal/config"
"BingPaper/internal/model"
"BingPaper/internal/repo"
"BingPaper/internal/service/fetcher"
"BingPaper/internal/storage"
"BingPaper/internal/util"
"go.uber.org/zap"
"gorm.io/gorm"
)
var ErrFetchStarted = errors.New("on-demand fetch started")
func CleanupOldImages(ctx context.Context) error {
days := config.GetConfig().Retention.Days
if days <= 0 {
@@ -23,81 +29,195 @@ func CleanupOldImages(ctx context.Context) error {
threshold := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
util.Logger.Info("Starting cleanup task", zap.Int("retention_days", days), zap.String("threshold", threshold))
var images []model.Image
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&images).Error; err != nil {
util.Logger.Error("Failed to query old images for cleanup", zap.Error(err))
var regionRecords []model.ImageRegion
if err := repo.DB.Where("date < ?", threshold).Preload("Variants").Find(&regionRecords).Error; err != nil {
util.Logger.Error("Failed to query old image regions for cleanup", zap.Error(err))
return err
}
for _, img := range images {
util.Logger.Info("Deleting old image", zap.String("date", img.Date))
for _, v := range img.Variants {
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
for _, m := range regionRecords {
util.Logger.Info("Deleting old image region record", zap.String("date", m.Date), zap.String("mkt", m.Mkt))
// 检查该图片名是否还有其他地区或日期在使用
var count int64
repo.DB.Model(&model.ImageRegion{}).Where("image_name = ? AND id != ?", m.ImageName, m.ID).Count(&count)
if count == 0 {
util.Logger.Info("Image content no longer referenced, deleting files and variants", zap.String("image_name", m.ImageName))
for _, v := range m.Variants {
if err := storage.GlobalStorage.Delete(ctx, v.StorageKey); err != nil {
util.Logger.Warn("Failed to delete storage object", zap.String("key", v.StorageKey), zap.Error(err))
}
}
// 删除变体记录
if err := repo.DB.Where("image_name = ?", m.ImageName).Delete(&model.ImageVariant{}).Error; err != nil {
util.Logger.Error("Failed to delete variants", zap.String("image_name", m.ImageName), zap.Error(err))
}
}
// 删除关联记录(逻辑外键控制)
if err := repo.DB.Where("image_id = ?", img.ID).Delete(&model.ImageVariant{}).Error; err != nil {
util.Logger.Error("Failed to delete variants", zap.Uint("image_id", img.ID), zap.Error(err))
}
// 删除主表记录
if err := repo.DB.Delete(&img).Error; err != nil {
util.Logger.Error("Failed to delete image", zap.Uint("id", img.ID), zap.Error(err))
// 删除地区记录
if err := repo.DB.Delete(&m).Error; err != nil {
util.Logger.Error("Failed to delete image region record", zap.Uint("id", m.ID), zap.Error(err))
}
}
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(images)))
util.Logger.Info("Cleanup task completed", zap.Int("deleted_count", len(regionRecords)))
return nil
}
func GetTodayImage() (*model.Image, error) {
func GetTodayImage(mkt string) (*model.ImageRegion, error) {
today := time.Now().Format("2006-01-02")
var img model.Image
err := repo.DB.Where("date = ?", today).Preload("Variants").First(&img).Error
if err != nil {
// 如果今天没有,尝试获取最近的一张
err = repo.DB.Order("date desc").Preload("Variants").First(&img).Error
util.Logger.Debug("Getting today image", zap.String("mkt", mkt), zap.String("today", today))
var imgRegion model.ImageRegion
tx := repo.DB.Where("date = ?", today)
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
return &img, err
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).First(&imgRegion).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 {
util.Logger.Debug("Today image not found, trying latest image", zap.String("mkt", mkt))
// 如果今天还是没有,尝试获取最近的一张
tx = repo.DB.Order("date desc")
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err = tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).First(&imgRegion).Error
}
// 兜底逻辑
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultRegion()
util.Logger.Debug("Image not found, trying fallback to default region", zap.String("mkt", mkt), zap.String("defaultMkt", defaultMkt))
if mkt != defaultMkt {
return GetTodayImage(defaultMkt)
}
return GetTodayImage("")
}
if err == nil {
util.Logger.Debug("Found image region record", zap.String("date", imgRegion.Date), zap.String("mkt", imgRegion.Mkt))
}
return &imgRegion, err
}
func GetRandomImage() (*model.Image, error) {
var img model.Image
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
// 简单起见,先查总数再 Offset
func GetAllRegionsTodayImages() ([]model.ImageRegion, error) {
today := time.Now().Format("2006-01-02")
regions := config.GetConfig().Fetcher.Regions
if len(regions) == 0 {
regions = []string{config.GetConfig().GetDefaultRegion()}
}
var images []model.ImageRegion
err := repo.DB.Where("date = ? AND mkt IN ?", today, regions).
Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).Find(&images).Error
return images, err
}
func GetRandomImage(mkt string) (*model.ImageRegion, error) {
util.Logger.Debug("Getting random image", zap.String("mkt", mkt))
var imgRegion model.ImageRegion
var count int64
repo.DB.Model(&model.Image{}).Count(&count)
tx := repo.DB.Model(&model.ImageRegion{})
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 {
return nil, fmt.Errorf("no images found")
}
// 这种方法不适合海量数据,但对于 30 天的数据没问题
err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error
if err != nil {
// 适配 MySQL
err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error
offset := rand.Intn(int(count))
util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset))
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).Offset(offset).Limit(1).Find(&imgRegion).Error
if (err != nil || imgRegion.ID == 0) && mkt != "" && config.GetConfig().API.EnableMktFallback {
defaultMkt := config.GetConfig().GetDefaultRegion()
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("")
}
return &img, err
if err == nil && imgRegion.ID == 0 {
return nil, fmt.Errorf("no images found")
}
return &imgRegion, err
}
func GetImageByDate(date string) (*model.Image, error) {
var img model.Image
err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error
return &img, err
func GetImageByDate(date string, mkt string) (*model.ImageRegion, error) {
util.Logger.Debug("Getting image by date", zap.String("date", date), zap.String("mkt", mkt))
var imgRegion model.ImageRegion
tx := repo.DB.Where("date = ?", date)
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err := tx.Preload("Variants", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
}).First(&imgRegion).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().GetDefaultRegion()
if mkt != defaultMkt {
return GetImageByDate(date, defaultMkt)
}
return GetImageByDate(date, "")
}
return &imgRegion, err
}
func GetImageList(limit int, offset int, month string) ([]model.Image, error) {
var images []model.Image
tx := repo.DB.Model(&model.Image{})
func GetImageList(limit int, offset int, month string, mkt string) ([]model.ImageRegion, error) {
var images []model.ImageRegion
tx := repo.DB.Model(&model.ImageRegion{})
if month != "" {
// 增强过滤:确保只处理 YYYY-MM 格式,防止注入或非法字符
// 这里简单处理:只要不为空就增加 LIKE 过滤
util.Logger.Debug("Filtering images by month", zap.String("month", 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", func(db *gorm.DB) *gorm.DB {
return db.Order("size asc")
})
if limit > 0 {
tx = tx.Limit(limit)
@@ -107,8 +227,5 @@ func GetImageList(limit int, offset int, month string) ([]model.Image, error) {
}
err := tx.Find(&images).Error
if err != nil {
util.Logger.Error("Failed to get image list", zap.Error(err), zap.String("month", month))
}
return images, err
}

View File

@@ -63,3 +63,15 @@ func (l *LocalStorage) Delete(ctx context.Context, key string) error {
func (l *LocalStorage) PublicURL(key string) (string, bool) {
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但这里简单处理
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)
Delete(ctx context.Context, key string) error
PublicURL(key string) (string, bool)
Exists(ctx context.Context, key string) (bool, error)
}
var GlobalStorage Storage

View File

@@ -72,3 +72,16 @@ func (w *WebDAVStorage) PublicURL(key string) (string, bool) {
}
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

@@ -2,10 +2,10 @@
<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"
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="startDrag"
@touchstart.passive="startDrag"
@click.stop
>
<!-- 拖动手柄指示器 -->
@@ -28,7 +28,7 @@
<!-- 年月选择器 -->
<div class="flex items-center justify-center gap-1 sm:gap-1.5 mb-0.5">
<!-- 年份选择 -->
<Select v-model="currentYearString" @update:modelValue="onYearChange">
<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
@@ -49,7 +49,7 @@
</Select>
<!-- 月份选择 -->
<Select v-model="currentMonthString" @update:modelValue="onMonthChange">
<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
@@ -214,7 +214,8 @@ interface CalendarDay {
}
const props = defineProps<{
selectedDate: string // YYYY-MM-DD
selectedDate?: string,
mkt?: string
}>()
const emit = defineEmits<{
@@ -229,10 +230,14 @@ 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 = window.innerWidth
const windowHeight = window.innerHeight
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是16:9或类似宽高比
const imageAspectRatio = 16 / 9
@@ -271,11 +276,10 @@ const getImageDisplayBounds = () => {
const initPanelPosition = () => {
if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds()
const isMobile = window.innerWidth < 640 // sm breakpoint
if (isMobile) {
// 移动端:在图片区域内居中显示
const panelWidth = Math.min(bounds.width - 16, window.innerWidth - 16)
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),
@@ -316,20 +320,6 @@ const currentMonthString = computed({
}
})
// 年份改变处理
const onYearChange = (value: any) => {
if (value !== null && value !== undefined) {
currentYear.value = Number(value)
}
}
// 月份改变处理
const onMonthChange = (value: any) => {
if (value !== null && value !== undefined) {
currentMonth.value = Number(value)
}
}
// 生成年份选项从2009年到当前年份+10年
const yearOptions = computed(() => {
const currentYearValue = new Date().getFullYear()
@@ -379,8 +369,20 @@ const loadHolidaysForYear = async (year: number) => {
}
}
// 窗口缩放处理
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)
// 预加载前后一年的数据
@@ -404,7 +406,9 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
return
}
e.preventDefault()
if (e instanceof MouseEvent) {
e.preventDefault()
}
isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
@@ -417,7 +421,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchmove', onDrag, { passive: true })
document.addEventListener('touchend', stopDrag)
}
@@ -425,7 +429,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
if (e instanceof TouchEvent) {
if (e instanceof MouseEvent) {
e.preventDefault()
}
@@ -435,15 +439,26 @@ const onDrag = (e: MouseEvent | TouchEvent) => {
const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y
// 限制在图片实际显示区域内
// 限制在有效区域内
if (calendarPanel.value) {
const rect = calendarPanel.value.getBoundingClientRect()
const bounds = getImageDisplayBounds()
const minX = bounds.left
const maxX = bounds.right - rect.width
const minY = bounds.top
const maxY = bounds.bottom - rect.height
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)),
@@ -517,7 +532,7 @@ const createDayObject = (date: Date, isCurrentMonth: boolean): CalendarDay => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const selectedDate = new Date(props.selectedDate)
const selectedDate = new Date(props.selectedDate || new Date())
selectedDate.setHours(0, 0, 0, 0)
// 转换为农历
@@ -626,6 +641,9 @@ const goToToday = () => {
// 清理
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)

View File

@@ -2,11 +2,12 @@ import { ref, onMounted, watch } from 'vue'
import type { Ref } from 'vue'
import { bingPaperApi } from '@/lib/api-service'
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 loading = ref(false)
const error = ref<Error | null>(null)
@@ -15,7 +16,7 @@ export function useTodayImage() {
loading.value = true
error.value = null
try {
image.value = await bingPaperApi.getTodayImageMeta()
image.value = await bingPaperApi.getTodayImageMeta(mkt || getDefaultMkt())
} catch (e) {
error.value = e as Error
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 currentPage = ref(1)
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
loading.value = true
@@ -55,7 +90,8 @@ export function useImageList(pageSize = 30) {
try {
const params: any = {
page,
page_size: pageSize
page_size: pageSize,
mkt: mkt || currentMkt.value || getDefaultMkt()
}
if (month) {
params.month = month
@@ -84,7 +120,7 @@ export function useImageList(pageSize = 30) {
const loadMore = () => {
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
currentPage.value = 1
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(() => {
@@ -106,10 +149,11 @@ export function useImageList(pageSize = 30) {
hasMore,
loadMore,
filterByMonth,
filterByMkt,
refetch: () => {
currentPage.value = 1
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 loading = ref(false)
const error = ref<Error | null>(null)
@@ -126,7 +170,7 @@ export function useImageByDate(dateRef: Ref<string>) {
loading.value = true
error.value = null
try {
image.value = await bingPaperApi.getImageMetaByDate(dateRef.value)
image.value = await bingPaperApi.getImageMetaByDate(dateRef.value, mktRef?.value || getDefaultMkt())
} catch (e) {
error.value = e as Error
console.error(`Failed to fetch image for date ${dateRef.value}:`, e)
@@ -135,10 +179,16 @@ export function useImageByDate(dateRef: Ref<string>) {
}
}
// 监听日期变化,自动重新获取
watch(dateRef, () => {
fetchImage()
}, { immediate: true })
// 监听日期和地区变化,自动重新获取
if (mktRef) {
watch([dateRef, mktRef], () => {
fetchImage()
}, { immediate: true })
} else {
watch(dateRef, () => {
fetchImage()
}, { immediate: true })
}
return {
image,

View File

@@ -35,6 +35,26 @@ export const buildApiUrl = (endpoint: string): string => {
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 状态码枚举
*/

View File

@@ -7,6 +7,7 @@ import type {
UpdateTokenRequest,
ChangePasswordRequest,
Config,
Region,
ImageMeta,
ImageListParams,
ManualFetchRequest,
@@ -109,6 +110,7 @@ export class BingPaperApiService {
if (params?.page) searchParams.set('page', params.page.toString())
if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
if (params?.month) searchParams.set('month', params.month)
if (params?.mkt) searchParams.set('mkt', params.mkt)
const queryString = searchParams.toString()
const endpoint = queryString ? `/images?${queryString}` : '/images'
@@ -116,48 +118,68 @@ export class BingPaperApiService {
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> {
return apiClient.get<ImageMeta>('/image/today/meta')
async getTodayImageMeta(mkt?: string): Promise<ImageMeta> {
const endpoint = mkt ? `/image/today/meta?mkt=${mkt}` : '/image/today/meta'
return apiClient.get<ImageMeta>(endpoint)
}
/**
* 获取指定日期图片元数据
*/
async getImageMetaByDate(date: string): Promise<ImageMeta> {
return apiClient.get<ImageMeta>(`/image/date/${date}/meta`)
async getImageMetaByDate(date: string, mkt?: string): Promise<ImageMeta> {
const endpoint = mkt ? `/image/date/${date}/meta?mkt=${mkt}` : `/image/date/${date}/meta`
return apiClient.get<ImageMeta>(endpoint)
}
/**
* 获取随机图片元数据
*/
async getRandomImageMeta(): Promise<ImageMeta> {
return apiClient.get<ImageMeta>('/image/random/meta')
async getRandomImageMeta(mkt?: string): Promise<ImageMeta> {
const endpoint = mkt ? `/image/random/meta?mkt=${mkt}` : '/image/random/meta'
return apiClient.get<ImageMeta>(endpoint)
}
/**
* 构建图片 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 })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/today?${params.toString()}`
}
/**
* 构建指定日期图片 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 })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/date/${date}?${params.toString()}`
}
/**
* 构建随机图片 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 })
if (mkt) params.set('mkt', mkt)
return `${apiConfig.baseURL}/image/random?${params.toString()}`
}
@@ -197,6 +219,8 @@ export const {
manualFetch,
manualCleanup,
getImages,
getGlobalTodayImages,
getRegions,
getTodayImageMeta,
getImageMetaByDate,
getRandomImageMeta,

View File

@@ -61,6 +61,11 @@ export interface Config {
Token: TokenConfig
Feature: FeatureConfig
Web: WebConfig
Fetcher: FetcherConfig
}
export interface FetcherConfig {
Regions: string[]
}
export interface AdminConfig {
@@ -69,6 +74,8 @@ export interface AdminConfig {
export interface APIConfig {
Mode: string // 'local' | 'redirect'
EnableMktFallback: boolean
EnableOnDemandFetch: boolean
}
export interface CronConfig {
@@ -147,6 +154,7 @@ export interface WebConfig {
export interface ImageMeta {
date?: string
mkt?: string
title?: string
copyright?: string
copyrightlink?: string // 图片的详细版权链接(指向 Bing 搜索页面)
@@ -173,6 +181,12 @@ export interface ImageListParams extends PaginationParams {
page?: number // 页码从1开始
page_size?: number // 每页数量
month?: string // 按月份过滤格式YYYY-MM
mkt?: string // 地区编码
}
export interface Region {
value: string
label: string
}
export interface ManualFetchRequest {

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

@@ -102,6 +102,33 @@
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>
@@ -372,6 +399,31 @@
</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>
@@ -414,6 +466,7 @@ 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'
@@ -424,9 +477,12 @@ const loadError = ref('')
const saveLoading = ref(false)
const dsnError = ref('')
// 所有可选地区列表
const allRegions = ref<any[]>([])
const config = ref<Config>({
Admin: { PasswordBcrypt: '' },
API: { Mode: 'local' },
API: { Mode: 'local', EnableMktFallback: true, EnableOnDemandFetch: false },
Cron: { Enabled: true, DailySpec: '0 9 * * *' },
DB: { Type: 'sqlite', DSN: '' },
Feature: { WriteDailyFiles: true },
@@ -464,12 +520,37 @@ const config = ref<Config>({
}
},
Token: { DefaultTTL: '168h' },
Web: { Path: './webapp/dist' }
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) {
@@ -602,6 +683,7 @@ const handleSaveConfig = async () => {
}
onMounted(() => {
fetchRegions()
fetchConfig()
})
</script>

View File

@@ -103,6 +103,10 @@
<code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式: jpg (默认: jpg)</span>
</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>
@@ -182,6 +186,10 @@
<code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式 (默认: jpg)</span>
</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>
@@ -254,6 +262,10 @@
<code class="text-yellow-400 min-w-24">format</code>
<span class="text-white/50">格式 (默认: jpg)</span>
</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>
@@ -334,6 +346,10 @@
<code class="text-yellow-400 min-w-32">date</code>
<span class="text-white/60">图片日期格式YYYY-MM-DD</span>
</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">
<code class="text-yellow-400 min-w-32">title</code>
<span class="text-white/60">图片标题</span>
@@ -458,24 +474,26 @@
<script setup lang="ts">
import { ref } from 'vue'
import { API_BASE_URL } from '@/lib/api-config'
import { getDefaultMkt } from '@/lib/mkt-utils'
const baseURL = ref(API_BASE_URL)
const previewImage = ref<string | null>(null)
const defaultMkt = getDefaultMkt()
// 获取今日图片示例
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 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 = () => {
return `${baseURL.value}/image/random?variant=UHD&format=jpg`
return `${baseURL.value}/image/random?variant=UHD&format=jpg&mkt=${defaultMkt}`
}
// 复制到剪贴板

View File

@@ -69,6 +69,89 @@
</div>
</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
v-show="canScrollLeft"
@click="scrollGlobal('left')"
class="absolute left-[-20px] top-1/2 -translate-y-1/2 z-30 p-2 bg-black/60 backdrop-blur-md rounded-full text-white transition-all hidden md:block border border-white/10 hover:bg-black/80 hover:scale-110 active:scale-95 shadow-2xl"
>
<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
v-show="canScrollRight"
@click="scrollGlobal('right')"
class="absolute right-[-20px] top-1/2 -translate-y-1/2 z-30 p-2 bg-black/60 backdrop-blur-md rounded-full text-white transition-all hidden md:block border border-white/10 hover:bg-black/80 hover:scale-110 active:scale-95 shadow-2xl"
>
<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"
>
<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 left-0 top-0 bottom-6 w-24 bg-gradient-to-r from-gray-900 to-transparent pointer-events-none hidden md:block transition-opacity duration-500"
:class="canScrollLeft ? 'opacity-100' : 'opacity-0'"
></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 transition-opacity duration-500"
:class="canScrollRight ? 'opacity-100' : 'opacity-0'"
></div>
</div>
</div>
</section>
<!-- Gallery Section - 历史图片 -->
<section class="py-16 px-4 md:px-8 lg:px-16">
<div class="max-w-7xl mx-auto">
@@ -79,6 +162,23 @@
<!-- 筛选器 -->
<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">
<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">
@@ -139,7 +239,7 @@
</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
v-for="(image, index) in images"
:key="image.date || index"
@@ -153,7 +253,7 @@
</div>
<img
v-else
:src="getImageUrl(image.date!)"
:src="getImageUrl(image)"
:alt="image.title || 'Bing Image'"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
@@ -161,14 +261,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 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="text-xs text-white/70 mb-1">
<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-[10px] md:text-xs text-white/70 mb-0.5">
{{ formatDate(image.date) }}
</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 || '未命名' }}
</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 }}
</p>
</div>
@@ -245,9 +345,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useImageList } from '@/composables/useImages'
import { useImageList, useGlobalTodayImages } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
import { normalizeImageUrl } from '@/lib/api-config'
import { useRouter } from 'vue-router'
import { getDefaultMkt, setSavedMkt, SUPPORTED_REGIONS, setSupportedRegions } from '@/lib/mkt-utils'
import {
Select,
SelectContent,
@@ -258,18 +360,52 @@ import {
const router = useRouter()
// 地区列表
const regions = ref(SUPPORTED_REGIONS)
// 顶部最新图片(独立加载,不受筛选影响)
const latestImage = ref<any>(null)
const todayLoading = ref(false)
// 全球今日图片
const { images: globalImages } = useGlobalTodayImages()
// 滚动功能实现
const globalScrollContainer = ref<HTMLElement | null>(null)
const canScrollLeft = ref(false)
const canScrollRight = ref(false)
const updateScrollState = () => {
if (!globalScrollContainer.value) return
const { scrollLeft, scrollWidth, clientWidth } = globalScrollContainer.value
canScrollLeft.value = scrollLeft > 5
canScrollRight.value = scrollLeft + clientWidth < scrollWidth - 5
}
const scrollGlobal = (direction: 'left' | 'right') => {
if (!globalScrollContainer.value) return
const scrollAmount = globalScrollContainer.value.clientWidth * 0.8 || 1000
globalScrollContainer.value.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
})
}
// 历史图片列表使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(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 = { page: 1, page_size: 1 }
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]
@@ -281,11 +417,24 @@ const loadLatestImage = async () => {
}
}
// 初始化加载顶部图片
onMounted(() => {
loadLatestImage()
// 监听滚动容器的变化
watch(globalScrollContainer, (el, _, onCleanup) => {
if (el) {
el.addEventListener('scroll', updateScrollState)
// 初始检查
setTimeout(updateScrollState, 100)
onCleanup(() => {
el.removeEventListener('scroll', updateScrollState)
})
}
})
// 监听全球图片数据变化
watch(globalImages, () => {
setTimeout(updateScrollState, 500)
}, { deep: true })
// 判断最新图片是否为今天的图片
const isToday = computed(() => {
if (!latestImage.value?.date) return false
@@ -315,9 +464,22 @@ const nextUpdateTime = computed(() => {
})
// 筛选相关状态
const selectedMkt = ref(getDefaultMkt())
const selectedYear = ref('')
const selectedMonth = ref('')
const onMktChange = () => {
setSavedMkt(selectedMkt.value)
filterByMkt(selectedMkt.value)
loadLatestImage()
// 重置懒加载状态
imageVisibility.value = []
setTimeout(() => {
setupObserver()
}, 100)
}
// 懒加载相关
const imageRefs = ref<(HTMLElement | null)[]>([])
const imageVisibility = ref<boolean[]>([])
@@ -374,11 +536,14 @@ const onFilterChange = () => {
// 重置筛选
const resetFilters = () => {
selectedMkt.value = getDefaultMkt()
selectedYear.value = ''
selectedMonth.value = ''
// 重置为加载默认数据
filterByMkt(selectedMkt.value)
filterByMonth(undefined)
loadLatestImage()
// 重置懒加载状态
imageVisibility.value = []
@@ -485,16 +650,34 @@ const setupLoadMoreObserver = () => {
}
// 初始化
onMounted(() => {
onMounted(async () => {
// 1. 获取地区信息
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)
}
// 2. 加载最新图片
loadLatestImage()
// 3. 设置观察者和滚动状态
if (images.value.length > 0) {
imageVisibility.value = new Array(images.value.length).fill(false)
setTimeout(() => {
setupObserver()
setupLoadMoreObserver()
}, 100)
}
setTimeout(() => {
setupObserver()
setupLoadMoreObserver()
updateScrollState()
}, 100)
// 4. 全局事件
window.addEventListener('resize', updateScrollState)
})
// 清理
@@ -505,6 +688,7 @@ onUnmounted(() => {
if (loadMoreObserver) {
loadMoreObserver.disconnect()
}
window.removeEventListener('resize', updateScrollState)
})
// 格式化日期
@@ -521,17 +705,21 @@ const formatDate = (dateStr?: string) => {
// 获取最新图片 URL顶部大图使用UHD高清
const getLatestImageUrl = () => {
if (!latestImage.value?.date) return ''
return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg')
return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg', latestImage.value.mkt)
}
// 获取图片 URL缩略图 - 使用较小分辨率节省流量)
const getImageUrl = (date: string) => {
return bingPaperApi.getImageUrlByDate(date, '640x480', 'jpg')
// 获取图片 URL缩略图 - 优先使用后端返回的最小变体以节省流量)
const getImageUrl = (image: any) => {
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) => {
router.push(`/image/${date}`)
const viewImage = (date: string, mkt?: string) => {
const query = mkt ? `?mkt=${mkt}` : ''
router.push(`/image/${date}${query}`)
}
// 打开版权详情链接
@@ -571,4 +759,13 @@ html {
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

@@ -58,7 +58,7 @@
<!-- 拖动手柄 -->
<div
@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"
></div>
@@ -178,10 +178,11 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useImageByDate } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
import { getDefaultMkt } from '@/lib/mkt-utils'
import Calendar from '@/components/ui/calendar/Calendar.vue'
const route = useRoute()
@@ -202,12 +203,17 @@ const getInitialCalendarState = (): boolean => {
}
const currentDate = ref(route.params.date as string)
const currentMkt = ref(route.query.mkt as string || getDefaultMkt())
const showInfo = ref(true)
const showCalendar = ref(getInitialCalendarState())
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)
@@ -222,11 +228,10 @@ let animationFrameId: number | null = null
// 计算图片实际显示区域考虑图片宽高比和object-contain
const getImageDisplayBounds = () => {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是16:9或类似宽高比
// 使用UHD分辨率: 1920x1080 (16:9)
// 必应图片通常是16:9
const imageAspectRatio = 16 / 9
const windowAspectRatio = windowWidth / windowHeight
@@ -259,22 +264,35 @@ const getImageDisplayBounds = () => {
}
}
// 初始化浮窗位置(居中偏下,限制在图片显示区域内)
// 初始化浮窗位置(限制在图片显示区域内,移动端默认展示在底部
const initPanelPosition = () => {
if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds()
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
infoPanelPos.value = {
x: bounds.left + (bounds.width - panelWidth) / 2,
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px避免与控制栏重叠
if (isMobile.value) {
// 移动端:默认居中靠下,不严格限制在图片内(因为要求可以不限制)
// 但为了好看,我们还是给它一个默认位置
const panelWidth = windowSize.value.width * 0.9
infoPanelPos.value = {
x: (windowSize.value.width - panelWidth) / 2,
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) => {
e.preventDefault()
if (e instanceof MouseEvent) {
e.preventDefault()
}
isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
@@ -287,7 +305,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
document.addEventListener('mousemove', onDrag, { passive: false })
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchmove', onDrag, { passive: true })
document.addEventListener('touchend', stopDrag)
}
@@ -295,7 +313,9 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
e.preventDefault()
if (e instanceof MouseEvent) {
e.preventDefault()
}
// 取消之前的动画帧
if (animationFrameId !== null) {
@@ -310,15 +330,26 @@ const onDrag = (e: MouseEvent | TouchEvent) => {
const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y
// 限制在图片实际显示区域内考虑底部控制栏高度约80px
// 限制在有效区域内
if (infoPanel.value) {
const rect = infoPanel.value.getBoundingClientRect()
const bounds = getImageDisplayBounds()
const minX = bounds.left
const maxX = bounds.right - rect.width
const minY = bounds.top
const maxY = bounds.bottom - rect.height - 80 // 预留底部控制栏空间
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 = {
x: Math.max(minX, Math.min(newX, maxX)),
@@ -347,12 +378,12 @@ const stopDrag = () => {
}
// 使用 composable 获取图片数据(传递 ref自动响应日期变化
const { image, loading, error } = useImageByDate(currentDate)
const { image, loading, error } = useImageByDate(currentDate, currentMkt)
// 检测指定日期是否有数据
const checkDateAvailability = async (dateStr: string): Promise<boolean> => {
try {
await bingPaperApi.getImageMetaByDate(dateStr)
await bingPaperApi.getImageMetaByDate(dateStr, currentMkt.value)
return true
} catch (e) {
return false
@@ -384,9 +415,6 @@ const checkAdjacentDates = async () => {
checkingDates.value = false
}
// 初始化位置
initPanelPosition()
// 监听showCalendar变化并自动保存到localStorage
watch(showCalendar, (newValue) => {
try {
@@ -401,6 +429,20 @@ 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) => {
if (!dateStr) return ''
@@ -415,7 +457,7 @@ const formatDate = (dateStr?: string) => {
// 获取完整图片 URL
const getFullImageUrl = () => {
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg')
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg', currentMkt.value)
}
// 预加载图片
@@ -432,10 +474,10 @@ const preloadImage = (url: string): Promise<void> => {
const preloadImageAndData = async (date: string): Promise<void> => {
try {
// 并行预加载图片和数据
const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg')
const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg', currentMkt.value)
await Promise.all([
preloadImage(imageUrl),
bingPaperApi.getImageMetaByDate(date)
bingPaperApi.getImageMetaByDate(date, currentMkt.value)
])
} catch (error) {
console.warn('Failed to preload image or data:', error)
@@ -461,7 +503,7 @@ const switchToDate = async (newDate: string) => {
// 3. 更新日期(此时图片和数据已经预加载完成)
currentDate.value = newDate
router.replace(`/image/${newDate}`)
router.replace(`/image/${newDate}?mkt=${currentMkt.value}`)
// 4. 等待一个微任务,确保 DOM 更新
await new Promise(resolve => setTimeout(resolve, 50))
@@ -534,18 +576,29 @@ const handleKeydown = (e: KeyboardEvent) => {
}
}
// 添加键盘事件监听
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', initPanelPosition)
// 窗口缩放处理
const handleResize = () => {
windowSize.value = {
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(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', initPanelPosition)
window.removeEventListener('resize', handleResize)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)