Merge pull request #2 from hanxuanyu/feature/multi-region-fetch

支持多地区数据切换,优化数据存储结构,基于图片名分类存储,避免重复下载
This commit is contained in:
2026-01-30 16:44:26 +08:00
committed by GitHub
29 changed files with 1994 additions and 223 deletions

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"
@@ -52,10 +53,11 @@ func Init(webFS embed.FS, configPath string) *gin.Engine {
// 输出配置信息 // 输出配置信息
util.Logger.Info("Application configuration loaded") util.Logger.Info("Application configuration loaded")
util.Logger.Info("├─ Config file", zap.String("path", config.GetRawViper().ConfigFileUsed())) util.Logger.Info("├─ Config file ", zap.String("path", config.GetRawViper().ConfigFileUsed()))
util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type)) util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type))
util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type)) util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type))
util.Logger.Info("─ Server ", zap.Int("port", cfg.Server.Port)) util.Logger.Info("─ Server ", zap.Int("port", cfg.Server.Port))
util.Logger.Info("└─ Active Mkt ", zap.Strings("regions", cfg.Fetcher.Regions))
// 根据存储类型输出更多信息 // 根据存储类型输出更多信息
switch cfg.Storage.Type { switch cfg.Storage.Type {
@@ -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 {
@@ -57,7 +60,9 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel } func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
type APIConfig struct { type APIConfig struct {
Mode string `mapstructure:"mode" 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,40 +185,105 @@ 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
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err)) // 检查 UHD/原图
} uhdKey := f.generateKey(imageName, variantName, "jpg")
exists, _ := storage.GlobalStorage.Exists(ctx, uhdKey)
for _, v := range targetVariants { if !exists {
// 如果目标分辨率就是原图分辨率,则跳过(已经保存过了) allExist = false
if v.name == variantName { } else {
continue for _, v := range targetVariants {
} if v.name == variantName {
continue
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos) }
buf := new(bytes.Buffer) vKey := f.generateKey(imageName, v.name, "jpg")
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil { exists, _ := storage.GlobalStorage.Exists(ctx, vKey)
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err)) if !exists {
continue allExist = false
} break
currentImgData := buf.Bytes() }
// 保存 JPG
if err := f.saveVariant(ctx, &dbImg, v.name, "jpg", currentImgData); err != nil {
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
} }
} }
// 保存今日额外文件 if allExist {
today := time.Now().Format("2006-01-02") util.Logger.Debug("All image variants exist in storage, linking only", zap.String("imageName", imageName))
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { // 只建立关联信息
f.saveDailyFiles(srcImg, imgData) 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))
}
for _, v := range targetVariants {
if v.name == variantName {
continue
}
resized := imaging.Fill(srcImg, v.width, v.height, imaging.Center, imaging.Lanczos)
buf := new(bytes.Buffer)
if err := jpeg.Encode(buf, resized, &jpeg.Options{Quality: 100}); err != nil {
util.Logger.Warn("Failed to encode jpeg", zap.String("variant", v.name), zap.Error(err))
continue
}
currentImgData := buf.Bytes()
if err := f.saveVariant(ctx, &dbImg, imageName, v.name, "jpg", currentImgData); err != nil {
util.Logger.Error("Failed to save variant", zap.String("variant", v.name), zap.Error(err))
}
}
// 保存今日额外文件
today := time.Now().Format("2006-01-02")
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
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"
} }
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType) var size int64
if err != nil { var publicURL string
return err
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)
if err != nil {
return err
}
publicURL = stored.PublicURL
size = stored.Size
} else {
return fmt.Errorf("variant %s does not exist and no data provided", key)
} }
vRecord := model.ImageVariant{ vRecord := model.ImageVariant{
ImageID: img.ID, ImageID: img.ID,
Variant: variant, Variant: variant,
Format: format, Format: format,
StorageKey: stored.Key, StorageKey: key,
PublicURL: stored.PublicURL, PublicURL: publicURL,
Size: int64(len(data)), Size: size,
} }
return repo.DB.Clauses(clause.OnConflict{ return repo.DB.Clauses(clause.OnConflict{
@@ -239,20 +356,21 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, fo
}).Create(&vRecord).Error }).Create(&vRecord).Error
} }
func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) { func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) {
util.Logger.Info("Saving daily files") util.Logger.Info("Saving daily files", zap.String("mkt", mkt))
localRoot := config.GetConfig().Storage.Local.Root localRoot := config.GetConfig().Storage.Local.Root
if localRoot == "" { if localRoot == "" {
localRoot = "data" localRoot = "data"
} }
if err := os.MkdirAll(localRoot, 0755); err != nil { mktDir := filepath.Join(localRoot, mkt)
util.Logger.Error("Failed to create directory", zap.String("path", localRoot), zap.Error(err)) if err := os.MkdirAll(mktDir, 0755); err != nil {
util.Logger.Error("Failed to create directory", zap.String("path", mktDir), zap.Error(err))
return return
} }
// daily.jpeg (quality 100) // daily.jpeg (quality 100)
jpegPath := filepath.Join(localRoot, "daily.jpeg") jpegPath := filepath.Join(mktDir, "daily.jpeg")
fJpeg, err := os.Create(jpegPath) fJpeg, err := os.Create(jpegPath)
if err != nil { if err != nil {
util.Logger.Error("Failed to create daily.jpeg", zap.Error(err)) util.Logger.Error("Failed to create daily.jpeg", zap.Error(err))
@@ -262,8 +380,21 @@ func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) {
} }
// original.jpeg (quality 100) // original.jpeg (quality 100)
originalPath := filepath.Join(localRoot, "original.jpeg") originalPath := filepath.Join(mktDir, "original.jpeg")
if err := os.WriteFile(originalPath, originalData, 0644); err != nil { if err := os.WriteFile(originalPath, originalData, 0644); err != nil {
util.Logger.Error("Failed to write original.jpeg", zap.Error(err)) util.Logger.Error("Failed to write original.jpeg", zap.Error(err))
} }
// 同时也保留一份在根目录下(兼容旧逻辑,且作为默认地区图片)
// 如果是默认地区或者是第一个抓取的地区,可以覆盖根目录的文件
if mkt == config.GetConfig().GetDefaultMkt() {
jpegPathRoot := filepath.Join(localRoot, "daily.jpeg")
fJpegRoot, err := os.Create(jpegPathRoot)
if err == nil {
jpeg.Encode(fJpegRoot, srcImg, &jpeg.Options{Quality: 100})
fJpegRoot.Close()
}
originalPathRoot := filepath.Join(localRoot, "original.jpeg")
os.WriteFile(originalPathRoot, originalData, 0644)
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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,10 +230,14 @@ 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保持一致 // 计算图片实际显示区域与ImageView保持一致
const getImageDisplayBounds = () => { const getImageDisplayBounds = () => {
const windowWidth = window.innerWidth const windowWidth = windowSize.value.width
const windowHeight = window.innerHeight const windowHeight = windowSize.value.height
// 必应图片通常是16:9或类似宽高比 // 必应图片通常是16:9或类似宽高比
const imageAspectRatio = 16 / 9 const imageAspectRatio = 16 / 9
@@ -271,11 +276,10 @@ const getImageDisplayBounds = () => {
const initPanelPosition = () => { const initPanelPosition = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds() const bounds = getImageDisplayBounds()
const isMobile = window.innerWidth < 640 // sm breakpoint
if (isMobile) { if (isMobile.value) {
// 移动端:在图片区域内居中显示 // 移动端:居中显示,尽量在图片内,但不强求
const panelWidth = Math.min(bounds.width - 16, window.innerWidth - 16) const panelWidth = Math.min(bounds.width - 16, windowSize.value.width - 16)
const panelHeight = 580 // 估计高度 const panelHeight = 580 // 估计高度
panelPos.value = { panelPos.value = {
x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2), x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2),
@@ -316,20 +320,6 @@ const currentMonthString = computed({
} }
}) })
// 年份改变处理
const onYearChange = (value: any) => {
if (value !== null && value !== undefined) {
currentYear.value = Number(value)
}
}
// 月份改变处理
const onMonthChange = (value: any) => {
if (value !== null && value !== undefined) {
currentMonth.value = Number(value)
}
}
// 生成年份选项从2009年到当前年份+10年 // 生成年份选项从2009年到当前年份+10年
const yearOptions = computed(() => { const yearOptions = computed(() => {
const currentYearValue = new Date().getFullYear() const currentYearValue = new Date().getFullYear()
@@ -379,8 +369,20 @@ const loadHolidaysForYear = async (year: number) => {
} }
} }
// 窗口缩放处理
const handleResize = () => {
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
}
initPanelPosition()
}
// 组件挂载时加载当前年份的假期数据 // 组件挂载时加载当前年份的假期数据
onMounted(() => { onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize)
}
const currentYearValue = currentYear.value const currentYearValue = currentYear.value
loadHolidaysForYear(currentYearValue) loadHolidaysForYear(currentYearValue)
// 预加载前后一年的数据 // 预加载前后一年的数据
@@ -404,7 +406,9 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
return return
} }
e.preventDefault() if (e instanceof MouseEvent) {
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
@@ -417,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)
} }
@@ -425,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()
} }
@@ -435,15 +439,26 @@ 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 bounds = getImageDisplayBounds()
const minX = bounds.left let minX, maxX, minY, maxY
const maxX = bounds.right - rect.width
const minY = bounds.top if (isMobile.value) {
const maxY = bounds.bottom - rect.height // 移动端:不限制区域,限制在视口内即可
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(minX, Math.min(newX, maxX)), x: Math.max(minX, Math.min(newX, maxX)),
@@ -517,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)
// 转换为农历 // 转换为农历
@@ -626,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>) {
} }
} }
// 监听日期变化,自动重新获取 // 监听日期和地区变化,自动重新获取
watch(dateRef, () => { if (mktRef) {
fetchImage() watch([dateRef, mktRef], () => {
}, { immediate: true }) fetchImage()
}, { immediate: true })
} else {
watch(dateRef, () => {
fetchImage()
}, { 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

@@ -69,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">
@@ -79,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">
@@ -139,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"
@@ -153,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"
@@ -161,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>
@@ -245,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 { 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,
@@ -258,18 +352,62 @@ import {
const router = useRouter() const router = useRouter()
// 地区列表
const regions = ref(SUPPORTED_REGIONS)
// 顶部最新图片(独立加载,不受筛选影响) // 顶部最新图片(独立加载,不受筛选影响)
const latestImage = ref<any>(null) const latestImage = ref<any>(null)
const todayLoading = ref(false) 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张 // 历史图片列表使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth } = useImageList(15) const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15)
// 获取地区标签
const getRegionLabel = (mkt?: string) => {
if (!mkt) return ''
const region = regions.value.find(r => r.value === mkt)
return region ? region.label : mkt
}
// 加载顶部最新图片 // 加载顶部最新图片
const loadLatestImage = async () => { const loadLatestImage = async () => {
todayLoading.value = true todayLoading.value = true
try { try {
const params = { page: 1, page_size: 1 } const params: any = { page: 1, page_size: 1, mkt: selectedMkt.value }
const result = await bingPaperApi.getImages(params) const result = await bingPaperApi.getImages(params)
if (result.length > 0) { if (result.length > 0) {
latestImage.value = result[0] latestImage.value = result[0]
@@ -281,8 +419,17 @@ const loadLatestImage = async () => {
} }
} }
// 初始化加载顶部图片 // 初始化加载
onMounted(() => { 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() loadLatestImage()
}) })
@@ -315,9 +462,22 @@ const nextUpdateTime = computed(() => {
}) })
// 筛选相关状态 // 筛选相关状态
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[]>([])
@@ -374,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 = []
@@ -521,17 +684,21 @@ const formatDate = (dateStr?: string) => {
// 获取最新图片 URL顶部大图使用UHD高清 // 获取最新图片 URL顶部大图使用UHD高清
const getLatestImageUrl = () => { const getLatestImageUrl = () => {
if (!latestImage.value?.date) return '' if (!latestImage.value?.date) return ''
return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg') return bingPaperApi.getImageUrlByDate(latestImage.value.date, 'UHD', 'jpg', latestImage.value.mkt)
} }
// 获取图片 URL缩略图 - 使用较小分辨率节省流量) // 获取图片 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}`)
} }
// 打开版权详情链接 // 打开版权详情链接
@@ -571,4 +738,13 @@ html {
html::-webkit-scrollbar { html::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */ 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> </style>

View File

@@ -58,7 +58,7 @@
<!-- 拖动手柄 --> <!-- 拖动手柄 -->
<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>
@@ -178,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()
@@ -202,12 +203,17 @@ 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 imageOpacity = ref(1)
const imageTransitioning = ref(false) 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)
const hasNextDay = ref(true) const hasNextDay = ref(true)
@@ -222,11 +228,10 @@ let animationFrameId: number | null = null
// 计算图片实际显示区域考虑图片宽高比和object-contain // 计算图片实际显示区域考虑图片宽高比和object-contain
const getImageDisplayBounds = () => { const getImageDisplayBounds = () => {
const windowWidth = window.innerWidth const windowWidth = windowSize.value.width
const windowHeight = window.innerHeight const windowHeight = windowSize.value.height
// 必应图片通常是16:9或类似宽高比 // 必应图片通常是16:9
// 使用UHD分辨率: 1920x1080 (16:9)
const imageAspectRatio = 16 / 9 const imageAspectRatio = 16 / 9
const windowAspectRatio = windowWidth / windowHeight const windowAspectRatio = windowWidth / windowHeight
@@ -259,22 +264,35 @@ const getImageDisplayBounds = () => {
} }
} }
// 初始化浮窗位置(居中偏下,限制在图片显示区域内) // 初始化浮窗位置(限制在图片显示区域内,移动端默认展示在底部
const initPanelPosition = () => { const initPanelPosition = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds() const bounds = getImageDisplayBounds()
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
infoPanelPos.value = { if (isMobile.value) {
x: bounds.left + (bounds.width - panelWidth) / 2, // 移动端:默认居中靠下,不严格限制在图片内(因为要求可以不限制)
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px避免与控制栏重叠 // 但为了好看,我们还是给它一个默认位置
const panelWidth = windowSize.value.width * 0.9
infoPanelPos.value = {
x: (windowSize.value.width - panelWidth) / 2,
y: windowSize.value.height - 240 // 靠下
}
} else {
// 桌面端:限制在图片区域内
const panelWidth = Math.min(bounds.width * 0.9, 448) // max-w-md = 448px
infoPanelPos.value = {
x: bounds.left + (bounds.width - panelWidth) / 2,
y: Math.max(bounds.top, bounds.bottom - 280) // 距底部280px避免与控制栏重叠
}
} }
} }
} }
// 开始拖动 // 开始拖动
const startDrag = (e: MouseEvent | TouchEvent) => { const startDrag = (e: MouseEvent | TouchEvent) => {
e.preventDefault() if (e instanceof MouseEvent) {
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
@@ -287,7 +305,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
document.addEventListener('mousemove', onDrag, { passive: false }) 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)
} }
@@ -295,7 +313,9 @@ const startDrag = (e: MouseEvent | TouchEvent) => {
const onDrag = (e: MouseEvent | TouchEvent) => { const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return if (!isDragging.value) return
e.preventDefault() if (e instanceof MouseEvent) {
e.preventDefault()
}
// 取消之前的动画帧 // 取消之前的动画帧
if (animationFrameId !== null) { if (animationFrameId !== null) {
@@ -310,15 +330,26 @@ 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
// 限制在图片实际显示区域内考虑底部控制栏高度约80px // 限制在有效区域内
if (infoPanel.value) { if (infoPanel.value) {
const rect = infoPanel.value.getBoundingClientRect() const rect = infoPanel.value.getBoundingClientRect()
const bounds = getImageDisplayBounds()
const minX = bounds.left let minX, maxX, minY, maxY
const maxX = bounds.right - rect.width
const minY = bounds.top if (isMobile.value) {
const maxY = bounds.bottom - rect.height - 80 // 预留底部控制栏空间 // 移动端:不限制区域,限制在视口内即可
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(minX, Math.min(newX, maxX)), x: Math.max(minX, Math.min(newX, maxX)),
@@ -347,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
@@ -384,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 {
@@ -401,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 ''
@@ -415,7 +457,7 @@ 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)
} }
// 预加载图片 // 预加载图片
@@ -432,10 +474,10 @@ const preloadImage = (url: string): Promise<void> => {
const preloadImageAndData = async (date: string): Promise<void> => { const preloadImageAndData = async (date: string): Promise<void> => {
try { try {
// 并行预加载图片和数据 // 并行预加载图片和数据
const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg') const imageUrl = bingPaperApi.getImageUrlByDate(date, 'UHD', 'jpg', currentMkt.value)
await Promise.all([ await Promise.all([
preloadImage(imageUrl), preloadImage(imageUrl),
bingPaperApi.getImageMetaByDate(date) bingPaperApi.getImageMetaByDate(date, currentMkt.value)
]) ])
} catch (error) { } catch (error) {
console.warn('Failed to preload image or data:', error) console.warn('Failed to preload image or data:', error)
@@ -461,7 +503,7 @@ const switchToDate = async (newDate: string) => {
// 3. 更新日期(此时图片和数据已经预加载完成) // 3. 更新日期(此时图片和数据已经预加载完成)
currentDate.value = newDate currentDate.value = newDate
router.replace(`/image/${newDate}`) router.replace(`/image/${newDate}?mkt=${currentMkt.value}`)
// 4. 等待一个微任务,确保 DOM 更新 // 4. 等待一个微任务,确保 DOM 更新
await new Promise(resolve => setTimeout(resolve, 50)) await new Promise(resolve => setTimeout(resolve, 50))
@@ -534,18 +576,29 @@ 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)