22 Commits

Author SHA1 Message Date
e48959d5ba 优化存储逻辑:为 WebDAV、Local 和 S3 增加 Exists 方法;调整图片处理逻辑以避免重复存储变体;新增调试日志以便于排查问题 2026-01-30 16:34:20 +08:00
852a72c597 前端构建错误修复 2026-01-30 15:56:22 +08:00
2660970320 为 Swagger 文档添加按需抓取支持:新增 enableOnDemandFetch 配置项和相关接口的 202/404 状态描述 2026-01-30 15:50:20 +08:00
fb636b9450 优化按需抓取逻辑:改为异步处理以提升性能,并为相关接口新增 202 状态支持 2026-01-30 15:49:51 +08:00
8ef66b2cb1 国家地区接口优化 2026-01-30 15:45:55 +08:00
6868a67ed7 调整悬浮信息层样式:优化字体大小、间距及适配性,以提升界面视觉一致性 2026-01-30 14:30:06 +08:00
52fb8c9328 优化图片处理逻辑:优先使用最小变体以节省流量,并新增 normalizeImageUrl 函数处理相对路径问题 2026-01-30 14:28:14 +08:00
845dc7d045 更新默认配置:将 api.mode 的默认值从 local 修改为 redirect 2026-01-30 13:41:21 +08:00
93690e10d3 增加多地区每日图片抓取能力 2026-01-30 13:33:40 +08:00
b69db53f0a GitHub Actions: 添加响应输出日志和延迟指令打印优化 2026-01-29 19:42:13 +08:00
39d4f9c730 GitHub Actions: 移除冗余的命令输出日志以简化脚本 2026-01-29 19:12:08 +08:00
cee6bc1027 GitHub Actions: 移除冗余的命令输出日志以简化脚本 2026-01-29 19:05:27 +08:00
1984e01785 GitHub Actions: 替换通知环境变量来源为 secrets,并支持基于数字的延迟指令解析 2026-01-29 19:03:51 +08:00
31f32bdb63 GitHub Actions: 优化通知脚本,添加 HTTP 状态码输出以提升调试便利性 2026-01-29 17:28:01 +08:00
9360bd0131 GitHub Actions: 替换通知环境变量来源为 vars 提高配置灵活性 2026-01-29 17:24:38 +08:00
1ca3d15c2f GitHub Actions: 添加构建成功后的通知步骤,提高流程透明度 2026-01-29 17:22:05 +08:00
e428f5bddb 修复大图查看页面空值处理问题:增加对 image 对象的可选链操作,防止因空值导致的错误显示或逻辑异常 2026-01-29 16:21:35 +08:00
c32cb8da3f 大图查看页面优化:新增图片切换淡入淡出动画,并通过预加载提升切换速度 2026-01-29 16:15:59 +08:00
5e3defc63d 移除未使用的底部控制栏高度变量 2026-01-29 12:37:59 +08:00
ea99a31248 限制日历面板在图片显示区域内拖动,并优化初始位置计算逻辑 2026-01-29 12:37:15 +08:00
86d6517267 前端页面细节优化:图片查看页面悬浮窗按钮被遮挡的问题修复;首页大图使用独立的变量控制,不受下方筛选结果影响 2026-01-29 12:37:07 +08:00
2e5eeaf425 优化首页图片的加载模式,对于日期变更但图片未更新的情况进行提示 2026-01-29 12:22:59 +08:00
30 changed files with 2322 additions and 277 deletions

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,8 @@ log:
api: api:
mode: local # local | redirect mode: local # local | redirect
enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区
enable_on_demand_fetch: false # 是否开启按需抓取(当数据库中没有请求的地区图片时,实时从 Bing 抓取)
cron: cron:
enabled: true enabled: true

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ import (
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/spf13/viper" "github.com/spf13/viper"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"BingPaper/internal/util"
) )
type Config struct { type Config struct {
@@ -25,6 +27,7 @@ type Config struct {
Token TokenConfig `mapstructure:"token" yaml:"token"` Token TokenConfig `mapstructure:"token" yaml:"token"`
Feature FeatureConfig `mapstructure:"feature" yaml:"feature"` Feature FeatureConfig `mapstructure:"feature" yaml:"feature"`
Web WebConfig `mapstructure:"web" yaml:"web"` Web WebConfig `mapstructure:"web" yaml:"web"`
Fetcher FetcherConfig `mapstructure:"fetcher" yaml:"fetcher"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -58,6 +61,8 @@ func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
type APIConfig struct { 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 { type CronConfig struct {
@@ -118,6 +123,10 @@ type WebConfig struct {
Path string `mapstructure:"path" yaml:"path"` Path string `mapstructure:"path" yaml:"path"`
} }
type FetcherConfig struct {
Regions []string `mapstructure:"regions" yaml:"regions"`
}
// Bing 默认配置 (内置) // Bing 默认配置 (内置)
const ( const (
BingMkt = "zh-CN" BingMkt = "zh-CN"
@@ -156,7 +165,9 @@ func Init(configPath string) error {
v.SetDefault("log.log_console", true) v.SetDefault("log.log_console", true)
v.SetDefault("log.show_db_log", false) v.SetDefault("log.show_db_log", false)
v.SetDefault("log.db_log_level", "info") v.SetDefault("log.db_log_level", "info")
v.SetDefault("api.mode", "local") v.SetDefault("api.mode", "redirect")
v.SetDefault("api.enable_mkt_fallback", false)
v.SetDefault("api.enable_on_demand_fetch", false)
v.SetDefault("cron.enabled", true) v.SetDefault("cron.enabled", true)
v.SetDefault("cron.daily_spec", "20 8-23/4 * * *") v.SetDefault("cron.daily_spec", "20 8-23/4 * * *")
v.SetDefault("retention.days", 0) v.SetDefault("retention.days", 0)
@@ -167,6 +178,13 @@ func Init(configPath string) error {
v.SetDefault("token.default_ttl", "168h") v.SetDefault("token.default_ttl", "168h")
v.SetDefault("feature.write_daily_files", true) v.SetDefault("feature.write_daily_files", true)
v.SetDefault("web.path", "web") v.SetDefault("web.path", "web")
// 默认抓取所有支持的地区
var defaultRegions []string
for _, r := range util.AllRegions {
defaultRegions = append(defaultRegions, r.Value)
}
v.SetDefault("fetcher.regions", defaultRegions)
v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123 v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123
// 绑定环境变量 // 绑定环境变量
@@ -311,3 +329,11 @@ func GetTokenTTL() time.Duration {
} }
return ttl return ttl
} }
// GetDefaultMkt 返回生效的默认地区编码
func (c *Config) GetDefaultMkt() string {
if len(c.Fetcher.Regions) > 0 {
return c.Fetcher.Regions[0]
}
return BingMkt
}

View File

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

View File

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

View File

@@ -47,6 +47,8 @@ func SetupRouter(webFS embed.FS) *gin.Engine {
img.GET("/date/:date/meta", handlers.GetByDateMeta) img.GET("/date/:date/meta", handlers.GetByDateMeta)
} }
api.GET("/images", handlers.ListImages) api.GET("/images", handlers.ListImages)
api.GET("/images/global/today", handlers.ListGlobalTodayImages)
api.GET("/regions", handlers.GetRegions)
// 管理接口 // 管理接口
admin := api.Group("/admin") admin := api.Group("/admin")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -2,10 +2,10 @@
<div class="fixed inset-0 z-40"> <div class="fixed inset-0 z-40">
<div <div
ref="calendarPanel" 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' }" :style="{ left: panelPos.x + 'px', top: panelPos.y + 'px' }"
@mousedown="startDrag" @mousedown="startDrag"
@touchstart="startDrag" @touchstart.passive="startDrag"
@click.stop @click.stop
> >
<!-- 拖动手柄指示器 --> <!-- 拖动手柄指示器 -->
@@ -28,7 +28,7 @@
<!-- 年月选择器 --> <!-- 年月选择器 -->
<div class="flex items-center justify-center gap-1 sm:gap-1.5 mb-0.5"> <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 <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" 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 @click.stop
@@ -49,7 +49,7 @@
</Select> </Select>
<!-- 月份选择 --> <!-- 月份选择 -->
<Select v-model="currentMonthString" @update:modelValue="onMonthChange"> <Select v-model="currentMonthString">
<SelectTrigger <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" 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 @click.stop
@@ -214,7 +214,8 @@ interface CalendarDay {
} }
const props = defineProps<{ const props = defineProps<{
selectedDate: string // YYYY-MM-DD selectedDate?: string,
mkt?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -229,27 +230,68 @@ const panelPos = ref({ x: 0, y: 0 })
const isDragging = ref(false) const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 }) const dragStart = ref({ x: 0, y: 0 })
// 初始化面板位置(移动端居中,桌面端右上角) // 响应式窗口大小
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
const isMobile = computed(() => windowSize.value.width < 768)
// 计算图片实际显示区域与ImageView保持一致
const getImageDisplayBounds = () => {
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是16:9或类似宽高比
const imageAspectRatio = 16 / 9
const windowAspectRatio = windowWidth / windowHeight
let displayWidth: number
let displayHeight: number
let offsetX: number
let offsetY: number
if (windowAspectRatio > imageAspectRatio) {
// 窗口更宽,图片上下占满,左右留黑边
displayHeight = windowHeight
displayWidth = displayHeight * imageAspectRatio
offsetX = (windowWidth - displayWidth) / 2
offsetY = 0
} else {
// 窗口更高,图片左右占满,上下留黑边
displayWidth = windowWidth
displayHeight = displayWidth / imageAspectRatio
offsetX = 0
offsetY = (windowHeight - displayHeight) / 2
}
return {
left: offsetX,
top: offsetY,
right: offsetX + displayWidth,
bottom: offsetY + displayHeight,
width: displayWidth,
height: displayHeight
}
}
// 初始化面板位置(移动端居中,桌面端右上角,限制在图片显示区域内)
const initPanelPosition = () => { const initPanelPosition = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const windowWidth = window.innerWidth const bounds = getImageDisplayBounds()
const windowHeight = window.innerHeight
const isMobile = windowWidth < 640 // sm breakpoint
if (isMobile) { if (isMobile.value) {
// 移动端:居中显示 // 移动端:居中显示,尽量在图片内,但不强求
const panelWidth = Math.min(bounds.width - 16, windowSize.value.width - 16)
const panelHeight = 580 // 估计高度 const panelHeight = 580 // 估计高度
panelPos.value = { panelPos.value = {
x: 8, x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2),
y: Math.max(8, (windowHeight - panelHeight) / 2) y: Math.max(bounds.top + 8, bounds.top + (bounds.height - panelHeight) / 2)
} }
} else { } else {
// 桌面端:右上角 // 桌面端:在图片区域右上角
const panelWidth = Math.min(420, windowWidth * 0.9) const panelWidth = Math.min(420, bounds.width * 0.9)
const panelHeight = 600 const panelHeight = 600
panelPos.value = { panelPos.value = {
x: windowWidth - panelWidth - 40, x: bounds.right - panelWidth - 40,
y: Math.min(80, (windowHeight - panelHeight) / 2) y: Math.max(bounds.top + 80, bounds.top + (bounds.height - panelHeight) / 2)
} }
} }
} }
@@ -278,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年 // 生成年份选项从2009年到当前年份+10年
const yearOptions = computed(() => { const yearOptions = computed(() => {
const currentYearValue = new Date().getFullYear() const currentYearValue = new Date().getFullYear()
@@ -341,8 +369,20 @@ const loadHolidaysForYear = async (year: number) => {
} }
} }
// 窗口缩放处理
const handleResize = () => {
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
}
initPanelPosition()
}
// 组件挂载时加载当前年份的假期数据 // 组件挂载时加载当前年份的假期数据
onMounted(() => { onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize)
}
const currentYearValue = currentYear.value const currentYearValue = currentYear.value
loadHolidaysForYear(currentYearValue) loadHolidaysForYear(currentYearValue)
// 预加载前后一年的数据 // 预加载前后一年的数据
@@ -366,7 +406,9 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
return return
} }
if (e instanceof MouseEvent) {
e.preventDefault() e.preventDefault()
}
isDragging.value = true isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
@@ -379,7 +421,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
document.addEventListener('mousemove', onDrag) document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag) document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false }) document.addEventListener('touchmove', onDrag, { passive: true })
document.addEventListener('touchend', stopDrag) document.addEventListener('touchend', stopDrag)
} }
@@ -387,7 +429,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
const onDrag = (e: MouseEvent | TouchEvent) => { const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return if (!isDragging.value) return
if (e instanceof TouchEvent) { if (e instanceof MouseEvent) {
e.preventDefault() e.preventDefault()
} }
@@ -397,15 +439,30 @@ const onDrag = (e: MouseEvent | TouchEvent) => {
const newX = clientX - dragStart.value.x const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y const newY = clientY - dragStart.value.y
// 限制在视口 // 限制在有效区域
if (calendarPanel.value) { if (calendarPanel.value) {
const rect = calendarPanel.value.getBoundingClientRect() const rect = calendarPanel.value.getBoundingClientRect()
const maxX = window.innerWidth - rect.width
const maxY = window.innerHeight - rect.height let minX, maxX, minY, maxY
if (isMobile.value) {
// 移动端:不限制区域,限制在视口内即可
minX = 0
maxX = windowSize.value.width - rect.width
minY = 0
maxY = windowSize.value.height - rect.height
} else {
// 桌面端:限制在图片实际显示区域内
const bounds = getImageDisplayBounds()
minX = bounds.left
maxX = bounds.right - rect.width
minY = bounds.top
maxY = bounds.bottom - rect.height
}
panelPos.value = { panelPos.value = {
x: Math.max(0, Math.min(newX, maxX)), x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY)) y: Math.max(minY, Math.min(newY, maxY))
} }
} }
} }
@@ -475,7 +532,7 @@ const createDayObject = (date: Date, isCurrentMonth: boolean): CalendarDay => {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) 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) selectedDate.setHours(0, 0, 0, 0)
// 转换为农历 // 转换为农历
@@ -584,6 +641,9 @@ const goToToday = () => {
// 清理 // 清理
import { onUnmounted } from 'vue' import { onUnmounted } from 'vue'
onUnmounted(() => { onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize)
}
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag) document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag) document.removeEventListener('touchmove', onDrag)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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: 重定向到存储位置 local: 直接返回图片流; redirect: 重定向到存储位置
</p> </p>
</div> </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> </CardContent>
</Card> </Card>
@@ -372,6 +399,31 @@
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader> <CardHeader>
@@ -414,6 +466,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { apiService } from '@/lib/api-service' import { apiService } from '@/lib/api-service'
import type { Config } from '@/lib/api-types' import type { Config } from '@/lib/api-types'
@@ -424,9 +477,12 @@ const loadError = ref('')
const saveLoading = ref(false) const saveLoading = ref(false)
const dsnError = ref('') const dsnError = ref('')
// 所有可选地区列表
const allRegions = ref<any[]>([])
const config = ref<Config>({ const config = ref<Config>({
Admin: { PasswordBcrypt: '' }, Admin: { PasswordBcrypt: '' },
API: { Mode: 'local' }, API: { Mode: 'local', EnableMktFallback: true, EnableOnDemandFetch: false },
Cron: { Enabled: true, DailySpec: '0 9 * * *' }, Cron: { Enabled: true, DailySpec: '0 9 * * *' },
DB: { Type: 'sqlite', DSN: '' }, DB: { Type: 'sqlite', DSN: '' },
Feature: { WriteDailyFiles: true }, Feature: { WriteDailyFiles: true },
@@ -464,12 +520,37 @@ const config = ref<Config>({
} }
}, },
Token: { DefaultTTL: '168h' }, Token: { DefaultTTL: '168h' },
Web: { Path: './webapp/dist' } Web: { Path: './webapp/dist' },
Fetcher: { Regions: [] }
}) })
const configJson = ref('') const configJson = ref('')
const jsonError = 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 示例 // DSN 示例
const dsnExamples = computed(() => { const dsnExamples = computed(() => {
switch (config.value.DB.Type) { switch (config.value.DB.Type) {
@@ -602,6 +683,7 @@ const handleSaveConfig = async () => {
} }
onMounted(() => { onMounted(() => {
fetchRegions()
fetchConfig() fetchConfig()
}) })
</script> </script>

View File

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

View File

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

View File

@@ -1,18 +1,19 @@
<template> <template>
<div class="fixed inset-0 bg-black z-50 overflow-hidden"> <div class="fixed inset-0 bg-black z-50 overflow-hidden">
<!-- 加载状态 --> <!-- 加载状态动画过渡中不显示 -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center"> <div v-if="loading && !imageTransitioning" class="absolute inset-0 flex items-center justify-center">
<div class="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin"></div> <div class="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin"></div>
</div> </div>
<!-- 主要内容 --> <!-- 主要内容 -->
<div v-else-if="image" class="relative h-full w-full"> <div v-else-if="image || imageTransitioning" class="relative h-full w-full">
<!-- 全屏图片 --> <!-- 全屏图片 -->
<div class="absolute inset-0 flex items-center justify-center"> <div class="absolute inset-0 flex items-center justify-center">
<img <img
:src="getFullImageUrl()" :src="getFullImageUrl()"
:alt="image.title || 'Bing Image'" :alt="image?.title || 'Bing Image'"
class="max-w-full max-h-full object-contain" class="max-w-full max-h-full object-contain transition-opacity duration-500 ease-in-out"
:style="{ opacity: imageOpacity }"
/> />
</div> </div>
@@ -34,37 +35,44 @@
</button> </button>
<div class="text-white/80 text-sm"> <div class="text-white/80 text-sm">
{{ formatDate(image.date) }} {{ formatDate(image?.date) }}
</div> </div>
</div> </div>
</div> </div>
<!-- 信息悬浮层类似 Windows 聚焦 --> <!-- 信息悬浮层类似 Windows 聚焦 -->
<div <div
v-if="showInfo && !showCalendar" v-if="showInfo && !showCalendar && image"
ref="infoPanel" ref="infoPanel"
class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 transform transition-all duration-300 z-10 select-none" class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 z-20 select-none"
:style="{ left: infoPanelPos.x + 'px', top: infoPanelPos.y + 'px' }" :class="{
:class="{ 'opacity-100': showInfo && !showCalendar, 'opacity-0 pointer-events-none': showCalendar }" 'opacity-100': showInfo && !showCalendar,
'opacity-0 pointer-events-none': showCalendar,
'transition-opacity duration-300': !isDragging
}"
:style="{
transform: `translate(${infoPanelPos.x}px, ${infoPanelPos.y}px)`,
willChange: isDragging ? 'transform' : 'auto'
}"
> >
<!-- 拖动手柄 --> <!-- 拖动手柄 -->
<div <div
@mousedown="startDrag" @mousedown="startDrag"
@touchstart="startDrag" @touchstart.passive="startDrag"
class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/30 rounded-full cursor-move hover:bg-white/50 transition-colors touch-none" class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/30 rounded-full cursor-move hover:bg-white/50 transition-colors touch-none"
></div> ></div>
<h2 class="text-lg font-bold text-white mb-2 mt-2"> <h2 class="text-lg font-bold text-white mb-2 mt-2">
{{ image.title || '未命名' }} {{ image?.title || '未命名' }}
</h2> </h2>
<p v-if="image.copyright" class="text-white/80 text-xs mb-3 leading-relaxed"> <p v-if="image?.copyright" class="text-white/80 text-xs mb-3 leading-relaxed">
{{ image.copyright }} {{ image.copyright }}
</p> </p>
<!-- 版权详情链接 --> <!-- 版权详情链接 -->
<a <a
v-if="image.copyrightlink" v-if="image?.copyrightlink"
:href="image.copyrightlink" :href="image.copyrightlink"
target="_blank" target="_blank"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white rounded-lg text-xs font-medium transition-all group" class="inline-flex items-center gap-2 px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white rounded-lg text-xs font-medium transition-all group"
@@ -170,10 +178,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useImageByDate } from '@/composables/useImages' import { useImageByDate } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service' import { bingPaperApi } from '@/lib/api-service'
import { getDefaultMkt } from '@/lib/mkt-utils'
import Calendar from '@/components/ui/calendar/Calendar.vue' import Calendar from '@/components/ui/calendar/Calendar.vue'
const route = useRoute() const route = useRoute()
@@ -194,9 +203,16 @@ const getInitialCalendarState = (): boolean => {
} }
const currentDate = ref(route.params.date as string) const currentDate = ref(route.params.date as string)
const currentMkt = ref(route.query.mkt as string || getDefaultMkt())
const showInfo = ref(true) const showInfo = ref(true)
const showCalendar = ref(getInitialCalendarState()) const showCalendar = ref(getInitialCalendarState())
const navigating = ref(false) const navigating = ref(false)
const imageOpacity = ref(1)
const imageTransitioning = ref(false)
// 响应式窗口大小
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
const isMobile = computed(() => windowSize.value.width < 768)
// 前后日期可用性 // 前后日期可用性
const hasPreviousDay = ref(true) const hasPreviousDay = ref(true)
@@ -208,23 +224,75 @@ const infoPanel = ref<HTMLElement | null>(null)
const infoPanelPos = ref({ x: 0, y: 0 }) const infoPanelPos = ref({ x: 0, y: 0 })
const isDragging = ref(false) const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 }) const dragStart = ref({ x: 0, y: 0 })
let animationFrameId: number | null = null
// 初始化浮窗位置(居中偏下 // 计算图片实际显示区域考虑图片宽高比和object-contain
const getImageDisplayBounds = () => {
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是16:9
const imageAspectRatio = 16 / 9
const windowAspectRatio = windowWidth / windowHeight
let displayWidth: number
let displayHeight: number
let offsetX: number
let offsetY: number
if (windowAspectRatio > imageAspectRatio) {
// 窗口更宽,图片上下占满,左右留黑边
displayHeight = windowHeight
displayWidth = displayHeight * imageAspectRatio
offsetX = (windowWidth - displayWidth) / 2
offsetY = 0
} else {
// 窗口更高,图片左右占满,上下留黑边
displayWidth = windowWidth
displayHeight = displayWidth / imageAspectRatio
offsetX = 0
offsetY = (windowHeight - displayHeight) / 2
}
return {
left: offsetX,
top: offsetY,
right: offsetX + displayWidth,
bottom: offsetY + displayHeight,
width: displayWidth,
height: displayHeight
}
}
// 初始化浮窗位置(限制在图片显示区域内,移动端默认展示在底部)
const initPanelPosition = () => { const initPanelPosition = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const windowWidth = window.innerWidth const bounds = getImageDisplayBounds()
const windowHeight = window.innerHeight
const panelWidth = Math.min(windowWidth * 0.9, 448) // max-w-md = 448px if (isMobile.value) {
// 移动端:默认居中靠下,不严格限制在图片内(因为要求可以不限制)
// 但为了好看,我们还是给它一个默认位置
const panelWidth = windowSize.value.width * 0.9
infoPanelPos.value = { infoPanelPos.value = {
x: (windowWidth - panelWidth) / 2, x: (windowSize.value.width - panelWidth) / 2,
y: windowHeight - 200 // 距底部200px y: windowSize.value.height - 240 // 靠下
}
} else {
// 桌面端:限制在图片区域内
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
infoPanelPos.value = {
x: bounds.left + (bounds.width - panelWidth) / 2,
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px避免与控制栏重叠
}
} }
} }
} }
// 开始拖动 // 开始拖动
const startDrag = (e: MouseEvent | TouchEvent) => { const startDrag = (e: MouseEvent | TouchEvent) => {
if (e instanceof MouseEvent) {
e.preventDefault() e.preventDefault()
}
isDragging.value = true isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
@@ -235,42 +303,74 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
y: clientY - infoPanelPos.value.y y: clientY - infoPanelPos.value.y
} }
document.addEventListener('mousemove', onDrag) document.addEventListener('mousemove', onDrag, { passive: false })
document.addEventListener('mouseup', stopDrag) document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false }) document.addEventListener('touchmove', onDrag, { passive: true })
document.addEventListener('touchend', stopDrag) document.addEventListener('touchend', stopDrag)
} }
// 拖动中 // 拖动中 - 使用 requestAnimationFrame 优化性能
const onDrag = (e: MouseEvent | TouchEvent) => { const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return if (!isDragging.value) return
if (e instanceof TouchEvent) { if (e instanceof MouseEvent) {
e.preventDefault() e.preventDefault()
} }
// 取消之前的动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
}
// 使用 requestAnimationFrame 进行节流优化
animationFrameId = requestAnimationFrame(() => {
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
const newX = clientX - dragStart.value.x const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y const newY = clientY - dragStart.value.y
// 限制在视口 // 限制在有效区域
if (infoPanel.value) { if (infoPanel.value) {
const rect = infoPanel.value.getBoundingClientRect() const rect = infoPanel.value.getBoundingClientRect()
const maxX = window.innerWidth - rect.width
const maxY = window.innerHeight - rect.height let minX, maxX, minY, maxY
if (isMobile.value) {
// 移动端:不限制区域,限制在视口内即可
minX = 0
maxX = windowSize.value.width - rect.width
minY = 0
maxY = windowSize.value.height - rect.height
} else {
// 桌面端限制在图片实际显示区域内考虑底部控制栏高度约80px
const bounds = getImageDisplayBounds()
minX = bounds.left
maxX = bounds.right - rect.width
minY = bounds.top
maxY = bounds.bottom - rect.height - 80 // 预留底部控制栏空间
}
infoPanelPos.value = { infoPanelPos.value = {
x: Math.max(0, Math.min(newX, maxX)), x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY)) y: Math.max(minY, Math.min(newY, maxY))
} }
} }
animationFrameId = null
})
} }
// 停止拖动 // 停止拖动
const stopDrag = () => { const stopDrag = () => {
isDragging.value = false isDragging.value = false
// 清理动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag) document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag) document.removeEventListener('touchmove', onDrag)
@@ -278,12 +378,12 @@ const stopDrag = () => {
} }
// 使用 composable 获取图片数据(传递 ref自动响应日期变化 // 使用 composable 获取图片数据(传递 ref自动响应日期变化
const { image, loading, error } = useImageByDate(currentDate) const { image, loading, error } = useImageByDate(currentDate, currentMkt)
// 检测指定日期是否有数据 // 检测指定日期是否有数据
const checkDateAvailability = async (dateStr: string): Promise<boolean> => { const checkDateAvailability = async (dateStr: string): Promise<boolean> => {
try { try {
await bingPaperApi.getImageMetaByDate(dateStr) await bingPaperApi.getImageMetaByDate(dateStr, currentMkt.value)
return true return true
} catch (e) { } catch (e) {
return false return false
@@ -315,9 +415,6 @@ const checkAdjacentDates = async () => {
checkingDates.value = false checkingDates.value = false
} }
// 初始化位置
initPanelPosition()
// 监听showCalendar变化并自动保存到localStorage // 监听showCalendar变化并自动保存到localStorage
watch(showCalendar, (newValue) => { watch(showCalendar, (newValue) => {
try { try {
@@ -332,6 +429,20 @@ watch(currentDate, () => {
checkAdjacentDates() checkAdjacentDates()
}, { immediate: true }) }, { immediate: true })
// 监听路由变化,支持前进后退
watch(() => route.params.date, (newDate) => {
if (newDate && newDate !== currentDate.value) {
currentDate.value = newDate as string
}
})
watch(() => route.query.mkt, (newMkt) => {
const mkt = (newMkt as string) || getDefaultMkt()
if (mkt !== currentMkt.value) {
currentMkt.value = mkt
}
})
// 格式化日期 // 格式化日期
const formatDate = (dateStr?: string) => { const formatDate = (dateStr?: string) => {
if (!dateStr) return '' if (!dateStr) return ''
@@ -346,7 +457,64 @@ const formatDate = (dateStr?: string) => {
// 获取完整图片 URL // 获取完整图片 URL
const getFullImageUrl = () => { const getFullImageUrl = () => {
return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg') return bingPaperApi.getImageUrlByDate(currentDate.value, 'UHD', 'jpg', currentMkt.value)
}
// 预加载图片
const preloadImage = (url: string): Promise<void> => {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => reject(new Error('Failed to load image'))
img.src = url
})
}
// 预加载图片和数据
const preloadImageAndData = async (date: string): Promise<void> => {
try {
// 并行预加载图片和数据
const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg', currentMkt.value)
await Promise.all([
preloadImage(imageUrl),
bingPaperApi.getImageMetaByDate(date, currentMkt.value)
])
} catch (error) {
console.warn('Failed to preload image or data:', error)
// 即使预加载失败也继续
}
}
// 切换日期并带动画
const switchToDate = async (newDate: string) => {
if (imageTransitioning.value) return
imageTransitioning.value = true
// 1. 淡出当前图片的同时预加载新图片和数据
imageOpacity.value = 0
const preloadPromise = preloadImageAndData(newDate)
// 2. 等待淡出动画完成500ms
await Promise.all([
new Promise(resolve => setTimeout(resolve, 500)),
preloadPromise
])
// 3. 更新日期(此时图片和数据已经预加载完成)
currentDate.value = newDate
router.replace(`/image/${newDate}?mkt=${currentMkt.value}`)
// 4. 等待一个微任务,确保 DOM 更新
await new Promise(resolve => setTimeout(resolve, 50))
// 5. 淡入新图片
imageOpacity.value = 1
// 6. 等待淡入完成
await new Promise(resolve => setTimeout(resolve, 500))
imageTransitioning.value = false
} }
// copyrightlink 现在是完整的 URL无需额外处理 // copyrightlink 现在是完整的 URL无需额外处理
@@ -357,37 +525,31 @@ const goBack = () => {
} }
// 前一天 // 前一天
const previousDay = () => { const previousDay = async () => {
if (navigating.value || !hasPreviousDay.value) return if (navigating.value || !hasPreviousDay.value || imageTransitioning.value) return
navigating.value = true navigating.value = true
const date = new Date(currentDate.value) const date = new Date(currentDate.value)
date.setDate(date.getDate() - 1) date.setDate(date.getDate() - 1)
const newDate = date.toISOString().split('T')[0] const newDate = date.toISOString().split('T')[0]
currentDate.value = newDate await switchToDate(newDate)
router.replace(`/image/${newDate}`)
setTimeout(() => {
navigating.value = false navigating.value = false
}, 500)
} }
// 后一天 // 后一天
const nextDay = () => { const nextDay = async () => {
if (navigating.value || !hasNextDay.value) return if (navigating.value || !hasNextDay.value || imageTransitioning.value) return
navigating.value = true navigating.value = true
const date = new Date(currentDate.value) const date = new Date(currentDate.value)
date.setDate(date.getDate() + 1) date.setDate(date.getDate() + 1)
const newDate = date.toISOString().split('T')[0] const newDate = date.toISOString().split('T')[0]
currentDate.value = newDate await switchToDate(newDate)
router.replace(`/image/${newDate}`)
setTimeout(() => {
navigating.value = false navigating.value = false
}, 500)
} }
// 切换日历状态watch会自动保存 // 切换日历状态watch会自动保存
@@ -414,22 +576,38 @@ const handleKeydown = (e: KeyboardEvent) => {
} }
} }
// 添加键盘事件监听 // 窗口缩放处理
if (typeof window !== 'undefined') { const handleResize = () => {
window.addEventListener('keydown', handleKeydown) windowSize.value = {
window.addEventListener('resize', initPanelPosition) width: window.innerWidth,
height: window.innerHeight
}
initPanelPosition()
} }
// 清理 // 生命周期钩子
import { onUnmounted } from 'vue' onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', handleResize)
}
// 初始化浮窗位置
initPanelPosition()
})
onUnmounted(() => { onUnmounted(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handleKeydown) window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', initPanelPosition) window.removeEventListener('resize', handleResize)
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag) document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag) document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag) document.removeEventListener('touchend', stopDrag)
// 清理动画帧
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
}
} }
}) })
</script> </script>