5 Commits

21 changed files with 1024 additions and 166 deletions

View File

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

View File

@@ -440,6 +440,24 @@ const docTemplate = `{
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -475,6 +493,24 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -517,6 +553,24 @@ const docTemplate = `{
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -545,6 +599,24 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -587,6 +659,24 @@ const docTemplate = `{
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -615,6 +705,24 @@ const docTemplate = `{
"schema": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -675,6 +783,29 @@ 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": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
@@ -707,6 +838,10 @@ const docTemplate = `{
"description": "当请求的地区不存在时,是否回退到默认地区",
"type": "boolean"
},
"enableOnDemandFetch": {
"description": "是否启用按需抓取",
"type": "boolean"
},
"mode": {
"description": "local | redirect",
"type": "string"

View File

@@ -434,6 +434,24 @@
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -469,6 +487,24 @@
"schema": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -511,6 +547,24 @@
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -539,6 +593,24 @@
"schema": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -581,6 +653,24 @@
"schema": {
"type": "file"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -609,6 +699,24 @@
"schema": {
"$ref": "#/definitions/handlers.ImageMetaResp"
}
},
"202": {
"description": "按需抓取任务已启动",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "图片未找到,响应体包含具体原因",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -669,6 +777,29 @@
}
}
},
"/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": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
@@ -701,6 +832,10 @@
"description": "当请求的地区不存在时,是否回退到默认地区",
"type": "boolean"
},
"enableOnDemandFetch": {
"description": "是否启用按需抓取",
"type": "boolean"
},
"mode": {
"description": "local | redirect",
"type": "string"

View File

@@ -5,6 +5,9 @@ definitions:
enableMktFallback:
description: 当请求的地区不存在时,是否回退到默认地区
type: boolean
enableOnDemandFetch:
description: 是否启用按需抓取
type: boolean
mode:
description: local | redirect
type: string
@@ -543,6 +546,18 @@ paths:
description: OK
schema:
type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取指定日期图片
tags:
- image
@@ -566,6 +581,18 @@ paths:
description: OK
schema:
$ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取指定日期图片元数据
tags:
- image
@@ -594,6 +621,18 @@ paths:
description: OK
schema:
type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取随机图片
tags:
- image
@@ -612,6 +651,18 @@ paths:
description: OK
schema:
$ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取随机图片元数据
tags:
- image
@@ -641,6 +692,18 @@ paths:
description: OK
schema:
type: file
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取今日图片
tags:
- image
@@ -659,6 +722,18 @@ paths:
description: OK
schema:
$ref: '#/definitions/handlers.ImageMetaResp'
"202":
description: 按需抓取任务已启动
schema:
additionalProperties:
type: string
type: object
"404":
description: 图片未找到,响应体包含具体原因
schema:
additionalProperties:
type: string
type: object
summary: 获取今日图片元数据
tags:
- image
@@ -700,6 +775,21 @@ paths:
summary: 获取图片列表
tags:
- image
/images/global/today:
get:
description: 获取配置文件中所有已开启地区的今日必应图片元数据(缩略图)
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.ImageMetaResp'
type: array
summary: 获取所有地区的今日图片列表
tags:
- image
/regions:
get:
description: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。

View File

@@ -60,8 +60,9 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog }
func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel }
type APIConfig struct {
Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect
EnableMktFallback bool `mapstructure:"enable_mkt_fallback" yaml:"enable_mkt_fallback"` // 当请求的地区不存在时,是否回退到默认地区
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 {
@@ -165,7 +166,8 @@ func Init(configPath string) error {
v.SetDefault("log.show_db_log", false)
v.SetDefault("log.db_log_level", "info")
v.SetDefault("api.mode", "redirect")
v.SetDefault("api.enable_mkt_fallback", true)
v.SetDefault("api.enable_mkt_fallback", false)
v.SetDefault("api.enable_on_demand_fetch", false)
v.SetDefault("cron.enabled", true)
v.SetDefault("cron.daily_spec", "20 8-23/4 * * *")
v.SetDefault("retention.days", 0)

View File

@@ -5,8 +5,8 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"BingPaper/internal/config"
"BingPaper/internal/model"
@@ -48,12 +48,18 @@ type ImageMetaResp struct {
// @Param format query string false "格式 (jpg)" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/today [get]
func GetToday(c *gin.Context) {
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 7200) // 2小时
@@ -66,12 +72,18 @@ func GetToday(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/today/meta [get]
func GetTodayMeta(c *gin.Context) {
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "public, max-age=7200") // 2小时
@@ -87,12 +99,18 @@ func GetTodayMeta(c *gin.Context) {
// @Param format query string false "格式" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random [get]
func GetRandom(c *gin.Context) {
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 0) // 禁用缓存
@@ -105,12 +123,18 @@ func GetRandom(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/random/meta [get]
func GetRandomMeta(c *gin.Context) {
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
@@ -127,13 +151,19 @@ func GetRandomMeta(c *gin.Context) {
// @Param format query string false "格式" default(jpg)
// @Produce image/jpeg
// @Success 200 {file} binary
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date} [get]
func GetByDate(c *gin.Context) {
date := c.Param("date")
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
handleImageResponse(c, img, 604800) // 7天
@@ -147,13 +177,19 @@ func GetByDate(c *gin.Context) {
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
// @Produce json
// @Success 200 {object} ImageMetaResp
// @Success 202 {object} map[string]string "按需抓取任务已启动"
// @Failure 404 {object} map[string]string "图片未找到,响应体包含具体原因"
// @Router /image/date/{date}/meta [get]
func GetByDateMeta(c *gin.Context) {
date := c.Param("date")
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
sendImageNotFound(c, mkt)
return
}
c.Header("Cache-Control", "public, max-age=604800") // 7天
@@ -223,6 +259,55 @@ func ListImages(c *gin.Context) {
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) {
variant := c.DefaultQuery("variant", "UHD")
format := c.DefaultQuery("format", "jpg")
@@ -387,34 +472,51 @@ func GetRegions(c *gin.Context) {
cfg := config.GetConfig()
pinned := cfg.Fetcher.Regions
// 创建副本以避免修改原始全局变量
all := make([]util.Region, len(util.AllRegions))
copy(all, util.AllRegions)
if len(pinned) > 0 {
// 创建一个 Map 用于快速查找置顶地区及其顺序
pinnedMap := make(map[string]int)
for i, v := range pinned {
pinnedMap[v] = i
}
// 对列表进行稳定排序,使置顶地区排在前面
sort.SliceStable(all, func(i, j int) bool {
idxI, okI := pinnedMap[all[i].Value]
idxJ, okJ := pinnedMap[all[j].Value]
if okI && okJ {
return idxI < idxJ
}
if okI {
return true
}
if okJ {
return false
}
return false // 保持非置顶地区的原有相对顺序
})
if len(pinned) == 0 {
// 如果没有配置抓取地区,返回所有支持的地区
c.JSON(http.StatusOK, util.AllRegions)
return
}
c.JSON(http.StatusOK, all)
// 创建一个 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

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

View File

@@ -8,8 +8,8 @@ import (
type Image struct {
ID uint `gorm:"primaryKey" json:"id"`
Date string `gorm:"uniqueIndex:idx_date_mkt;type:varchar(10)" json:"date"` // YYYY-MM-DD
Mkt string `gorm:"uniqueIndex:idx_date_mkt;type:varchar(10)" json:"mkt"` // zh-CN, en-US etc.
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"`
Copyright string `json:"copyright"`
CopyrightLink string `json:"copyrightlink"`

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"BingPaper/internal/config"
@@ -61,15 +62,8 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
}
for _, mkt := range regions {
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))
}
// 第二次 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))
if err := f.FetchRegion(ctx, mkt); err != nil {
util.Logger.Error("Failed to fetch region images", zap.String("mkt", mkt), zap.Error(err))
}
}
@@ -77,6 +71,27 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error {
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))
@@ -110,27 +125,12 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
// 幂等检查
var existing model.Image
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), zap.String("mkt", mkt))
util.Logger.Debug("Image already exists in DB, skipping", zap.String("date", dateStr), zap.String("mkt", mkt))
return nil
}
util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("title", bingImg.Title))
// 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
}
imageName := f.extractImageName(bingImg.URLBase, bingImg.HSH)
util.Logger.Info("Processing image", zap.String("date", dateStr), zap.String("mkt", mkt), zap.String("imageName", imageName))
// 创建 DB 记录
dbImg := model.Image{
@@ -154,7 +154,6 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
return err
}
// 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID
if dbImg.ID == 0 {
var existing model.Image
if err := repo.DB.Where("date = ? AND mkt = ?", dateStr, mkt).First(&existing).Error; err != nil {
@@ -164,6 +163,9 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
dbImg = existing
}
// UHD 探测
imgURL, variantName := f.probeUHD(bingImg.URLBase)
// 保存各种分辨率
targetVariants := []struct {
name string
@@ -183,40 +185,105 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage, mkt strin
{"320x240", 320, 240},
}
// 首先保存原图 (UHD 或 1080p)
if err := f.saveVariant(ctx, &dbImg, variantName, "jpg", imgData); err != nil {
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
}
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()
// 保存 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))
// 检查是否所有变体都已存在于存储中
allExist := true
// 检查 UHD/原图
uhdKey := f.generateKey(imageName, variantName, "jpg")
exists, _ := storage.GlobalStorage.Exists(ctx, uhdKey)
if !exists {
allExist = false
} else {
for _, v := range targetVariants {
if v.name == variantName {
continue
}
vKey := f.generateKey(imageName, v.name, "jpg")
exists, _ := storage.GlobalStorage.Exists(ctx, vKey)
if !exists {
allExist = false
break
}
}
}
// 保存今日额外文件
today := time.Now().Format("2006-01-02")
if dateStr == today && config.GetConfig().Feature.WriteDailyFiles {
f.saveDailyFiles(srcImg, imgData, mkt)
if allExist {
util.Logger.Debug("All image variants exist in storage, linking only", zap.String("imageName", imageName))
// 只建立关联信息
f.saveVariant(ctx, &dbImg, imageName, variantName, "jpg", nil)
for _, v := range targetVariants {
if v.name == variantName {
continue
}
f.saveVariant(ctx, &dbImg, imageName, v.name, "jpg", nil)
}
} else {
// 需要下载并处理
util.Logger.Debug("Downloading and processing image", zap.String("url", imgURL))
imgData, err := f.downloadImage(imgURL)
if err != nil {
util.Logger.Error("Failed to download image", zap.String("url", imgURL), zap.Error(err))
return err
}
srcImg, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
util.Logger.Error("Failed to decode image data", zap.Error(err))
return err
}
// 保存原图
if err := f.saveVariant(ctx, &dbImg, imageName, variantName, "jpg", imgData); err != nil {
util.Logger.Error("Failed to save original variant", zap.String("variant", variantName), zap.Error(err))
}
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
}
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) {
uhdURL := fmt.Sprintf("https://www.bing.com%s_UHD.jpg", urlBase)
resp, err := f.httpClient.Head(uhdURL)
@@ -235,25 +302,52 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) {
return io.ReadAll(resp.Body)
}
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error {
key := fmt.Sprintf("%s/%s/%s_%s.%s", img.Mkt, img.Date, img.Date, variant, format)
func (f *Fetcher) generateKey(imageName, variant, format string) string {
return fmt.Sprintf("%s/%s_%s.%s", imageName, imageName, variant, format)
}
func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, imageName, variant, format string, data []byte) error {
key := f.generateKey(imageName, variant, format)
contentType := "image/jpeg"
if format == "webp" {
contentType = "image/webp"
}
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
if err != nil {
return err
var size int64
var publicURL string
exists, _ := storage.GlobalStorage.Exists(ctx, key)
if exists {
util.Logger.Debug("Variant already exists in storage, linking", zap.String("key", key))
// 如果存在,我们需要获取它的大小和公共 URL (如果可能)
// 但目前的 Storage 接口没有 Stat我们可以尝试 Get 或者干脆 size 为 0
// 为了简单,我们只从存储中获取公共 URL
if pURL, ok := storage.GlobalStorage.PublicURL(key); ok {
publicURL = pURL
}
// size 暂时设为 0 或者从 data 中取 (如果有的话)
if data != nil {
size = int64(len(data))
}
} else if data != nil {
util.Logger.Debug("Saving variant to storage", zap.String("key", key))
stored, err := storage.GlobalStorage.Put(ctx, key, bytes.NewReader(data), contentType)
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{
ImageID: img.ID,
Variant: variant,
Format: format,
StorageKey: stored.Key,
PublicURL: stored.PublicURL,
Size: int64(len(data)),
StorageKey: key,
PublicURL: publicURL,
Size: size,
}
return repo.DB.Clauses(clause.OnConflict{

View File

@@ -2,18 +2,23 @@ package image
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
"BingPaper/internal/config"
"BingPaper/internal/model"
"BingPaper/internal/repo"
"BingPaper/internal/service/fetcher"
"BingPaper/internal/storage"
"BingPaper/internal/util"
"go.uber.org/zap"
)
var ErrFetchStarted = errors.New("on-demand fetch started")
func CleanupOldImages(ctx context.Context) error {
days := config.GetConfig().Retention.Days
if days <= 0 {
@@ -52,14 +57,26 @@ func CleanupOldImages(ctx context.Context) error {
func GetTodayImage(mkt string) (*model.Image, error) {
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
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 {
// 如果今天没有,尝试获取最近的一张
util.Logger.Debug("Today image not found, trying latest image", zap.String("mkt", mkt))
// 如果今天还是没有,尝试获取最近的一张
tx = repo.DB.Order("date desc")
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
@@ -70,16 +87,37 @@ func GetTodayImage(mkt string) (*model.Image, 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
}
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
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
// 简单起见,先查总数再 Offset
@@ -89,58 +127,83 @@ func GetRandomImage(mkt string) (*model.Image, error) {
tx = tx.Where("mkt = ?", mkt)
}
tx.Count(&count)
if count == 0 && mkt != "" && config.GetConfig().API.EnableOnDemandFetch && util.IsValidRegion(mkt) {
// 如果没找到,尝试异步按需抓取该地区
util.Logger.Info("No images found in DB for region, starting asynchronous on-demand fetch", zap.String("mkt", mkt))
f := fetcher.NewFetcher()
go func() {
_ = f.FetchRegion(context.Background(), mkt)
}()
return nil, ErrFetchStarted
}
if count == 0 {
return nil, fmt.Errorf("no images found")
}
// 这种方法不适合海量数据,但对于 30 天的数据没问题
tx = repo.DB.Order("RANDOM()")
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err := tx.Preload("Variants").First(&img).Error
if err != nil {
// 适配 MySQL
tx = repo.DB.Order("RAND()")
if mkt != "" {
tx = tx.Where("mkt = ?", mkt)
}
err = tx.Preload("Variants").First(&img).Error
}
// 优化随机查询:使用 Offset 代替 ORDER BY RANDOM()
// 注意tx 包含了前面的 Where 条件
offset := rand.Intn(int(count))
util.Logger.Debug("Random image selection", zap.Int64("total", count), zap.Int("offset", offset))
err := tx.Preload("Variants").Offset(offset).Limit(1).Find(&img).Error
// 兜底逻辑
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
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
}
func GetImageList(limit int, offset int, month string, mkt string) ([]model.Image, error) {
util.Logger.Debug("Getting image list", zap.Int("limit", limit), zap.Int("offset", offset), zap.String("month", month), zap.String("mkt", mkt))
var images []model.Image
tx := repo.DB.Model(&model.Image{})

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,35 @@
package util
import "golang.org/x/text/language"
type Region struct {
Value string `json:"value"`
Label string `json:"label"`
}
var AllRegions = []Region{
{Value: "zh-CN", Label: "中国 (zh-CN)"},
{Value: "en-US", Label: "美国 (en-US)"},
{Value: "ja-JP", Label: "日本 (ja-JP)"},
{Value: "en-AU", Label: "澳大利亚 (en-AU)"},
{Value: "en-GB", Label: "英国 (en-GB)"},
{Value: "de-DE", Label: "德国 (de-DE)"},
{Value: "en-NZ", Label: "新西兰 (en-NZ)"},
{Value: "en-CA", Label: "加拿大 (en-CA)"},
{Value: "fr-FR", Label: "法国 (fr-FR)"},
{Value: "it-IT", Label: "意大利 (it-IT)"},
{Value: "es-ES", Label: "西班牙 (es-ES)"},
{Value: "pt-BR", Label: "巴西 (pt-BR)"},
{Value: "ko-KR", Label: "韩国 (ko-KR)"},
{Value: "en-IN", Label: "印度 (en-IN)"},
{Value: "ru-RU", Label: "俄罗斯 (ru-RU)"},
{Value: "zh-HK", Label: "中国香港 (zh-HK)"},
{Value: "zh-TW", Label: "中国台湾 (zh-TW)"},
// 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

@@ -37,6 +37,39 @@ export function useTodayImage(mkt?: string) {
}
}
/**
* 获取全球今日图片
*/
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
}
}
/**
* 获取图片列表(支持分页和月份筛选)
*/

View File

@@ -118,6 +118,13 @@ export class BingPaperApiService {
return apiClient.get<ImageMeta[]>(endpoint)
}
/**
* 获取所有地区的今日图片列表
*/
async getGlobalTodayImages(): Promise<ImageMeta[]> {
return apiClient.get<ImageMeta[]>('/images/global/today')
}
/**
* 获取支持的地区列表
*/
@@ -212,6 +219,7 @@ export const {
manualFetch,
manualCleanup,
getImages,
getGlobalTodayImages,
getRegions,
getTodayImageMeta,
getImageMetaByDate,

View File

@@ -75,6 +75,7 @@ export interface AdminConfig {
export interface APIConfig {
Mode: string // 'local' | 'redirect'
EnableMktFallback: boolean
EnableOnDemandFetch: boolean
}
export interface CronConfig {

View File

@@ -6,23 +6,23 @@ const DEFAULT_MKT = 'zh-CN'
* 默认地区列表 (兜底用)
*/
export const DEFAULT_REGIONS = [
{ value: 'zh-CN', label: '中国 (zh-CN)' },
{ value: 'en-US', label: '美国 (en-US)' },
{ value: 'ja-JP', label: '日本 (ja-JP)' },
{ value: 'en-AU', label: '澳大利亚 (en-AU)' },
{ value: 'en-GB', label: '英国 (en-GB)' },
{ value: 'de-DE', label: '德国 (de-DE)' },
{ value: 'en-NZ', label: '新西兰 (en-NZ)' },
{ value: 'en-CA', label: '加拿大 (en-CA)' },
{ value: 'fr-FR', label: '法国 (fr-FR)' },
{ value: 'it-IT', label: '意大利 (it-IT)' },
{ value: 'es-ES', label: '西班牙 (es-ES)' },
{ value: 'pt-BR', label: '巴西 (pt-BR)' },
{ value: 'ko-KR', label: '韩国 (ko-KR)' },
{ value: 'en-IN', label: '印度 (en-IN)' },
{ value: 'ru-RU', label: '俄罗斯 (ru-RU)' },
{ value: 'zh-HK', label: '中国香港 (zh-HK)' },
{ value: 'zh-TW', label: '中国台湾 (zh-TW)' },
{ 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: '中国台湾' },
]
/**

View File

@@ -102,16 +102,33 @@
local: 直接返回图片流; redirect: 重定向到存储位置
</p>
</div>
<div class="flex items-center gap-2">
<Label for="api-fallback">启用地区不存在时兜底</Label>
<Switch
id="api-fallback"
v-model="config.API.EnableMktFallback"
/>
<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>
<p class="text-xs text-gray-500">
如果请求的地区无数据自动回退到默认地区
</p>
</CardContent>
</Card>
@@ -465,7 +482,7 @@ const allRegions = ref<any[]>([])
const config = ref<Config>({
Admin: { PasswordBcrypt: '' },
API: { Mode: 'local', EnableMktFallback: true },
API: { Mode: 'local', EnableMktFallback: true, EnableOnDemandFetch: false },
Cron: { Enabled: true, DailySpec: '0 9 * * *' },
DB: { Type: 'sqlite', DSN: '' },
Feature: { WriteDailyFiles: true },

View File

@@ -69,6 +69,81 @@
</div>
</section>
<!-- Global Section - 必应全球 -->
<section v-if="globalImages.length > 0" class="py-16 px-4 md:px-8 lg:px-16 bg-white/5 border-y border-white/5">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div class="space-y-1">
<h2 class="text-3xl md:text-4xl font-bold text-white">
必应全球
</h2>
</div>
</div>
<div class="relative group">
<!-- 左右切换按钮 -->
<button
@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 - 历史图片 -->
<section class="py-16 px-4 md:px-8 lg:px-16">
<div class="max-w-7xl mx-auto">
@@ -262,7 +337,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useImageList } from '@/composables/useImages'
import { useImageList, useGlobalTodayImages } from '@/composables/useImages'
import { bingPaperApi } from '@/lib/api-service'
import { normalizeImageUrl } from '@/lib/api-config'
import { useRouter } from 'vue-router'
@@ -284,9 +359,50 @@ const regions = ref(SUPPORTED_REGIONS)
const latestImage = ref<any>(null)
const todayLoading = ref(false)
// 全球今日图片
const { images: globalImages } = useGlobalTodayImages()
// 滚动功能实现
const globalScrollContainer = ref<HTMLElement | null>(null)
const scrollGlobal = (direction: 'left' | 'right') => {
if (!globalScrollContainer.value) return
// 增加翻页数量:滚动容器宽度的 80%,或者固定 3 张卡片的宽度
const scrollAmount = globalScrollContainer.value.clientWidth * 0.8 || 1000
globalScrollContainer.value.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
})
}
// 鼠标滚轮横向滑动
const handleGlobalWheel = (e: WheelEvent) => {
if (!globalScrollContainer.value) return
const container = globalScrollContainer.value
const isAtStart = container.scrollLeft <= 0
const isAtEnd = Math.ceil(container.scrollLeft + container.clientWidth) >= container.scrollWidth
// 当鼠标在滚动区域内,且是纵向滚动时
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
// 如果还没滚动到尽头,则拦截纵向滚动并转为横向
if ((e.deltaY > 0 && !isAtEnd) || (e.deltaY < 0 && !isAtStart)) {
e.preventDefault()
// 加大滚动步进以实现“快速翻页”
container.scrollLeft += e.deltaY * 1.5
}
}
}
// 历史图片列表使用服务端分页和筛选每页15张
const { images, loading, hasMore, loadMore, filterByMonth, filterByMkt } = useImageList(15)
// 获取地区标签
const getRegionLabel = (mkt?: string) => {
if (!mkt) return ''
const region = regions.value.find(r => r.value === mkt)
return region ? region.label : mkt
}
// 加载顶部最新图片
const loadLatestImage = async () => {
todayLoading.value = true
@@ -580,8 +696,9 @@ const getImageUrl = (image: any) => {
}
// 查看图片详情
const viewImage = (date: string) => {
router.push(`/image/${date}`)
const viewImage = (date: string, mkt?: string) => {
const query = mkt ? `?mkt=${mkt}` : ''
router.push(`/image/${date}${query}`)
}
// 打开版权详情链接
@@ -621,4 +738,13 @@ html {
html::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* 隐藏横向滚动条 */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
</style>