mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 05:59:32 +08:00
国家地区接口优化
This commit is contained in:
@@ -17,6 +17,7 @@ log:
|
||||
api:
|
||||
mode: local # local | redirect
|
||||
enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区
|
||||
enable_on_demand_fetch: false # 是否开启按需抓取(当数据库中没有请求的地区图片时,实时从 Bing 抓取)
|
||||
|
||||
cron:
|
||||
enabled: true
|
||||
|
||||
23
docs/docs.go
23
docs/docs.go
@@ -675,6 +675,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": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
|
||||
|
||||
@@ -669,6 +669,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": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。",
|
||||
|
||||
@@ -700,6 +700,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: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"BingPaper/internal/config"
|
||||
"BingPaper/internal/model"
|
||||
@@ -48,12 +48,13 @@ type ImageMetaResp struct {
|
||||
// @Param format query string false "格式 (jpg)" default(jpg)
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary
|
||||
// @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 != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img, 7200) // 2小时
|
||||
@@ -66,12 +67,13 @@ func GetToday(c *gin.Context) {
|
||||
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} ImageMetaResp
|
||||
// @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 != 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 +89,13 @@ func GetTodayMeta(c *gin.Context) {
|
||||
// @Param format query string false "格式" default(jpg)
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary
|
||||
// @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 != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img, 0) // 禁用缓存
|
||||
@@ -105,12 +108,13 @@ func GetRandom(c *gin.Context) {
|
||||
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} ImageMetaResp
|
||||
// @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 != 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 +131,14 @@ func GetRandomMeta(c *gin.Context) {
|
||||
// @Param format query string false "格式" default(jpg)
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary
|
||||
// @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 != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
sendImageNotFound(c, mkt)
|
||||
return
|
||||
}
|
||||
handleImageResponse(c, img, 604800) // 7天
|
||||
@@ -147,13 +152,14 @@ func GetByDate(c *gin.Context) {
|
||||
// @Param mkt query string false "地区编码 (如 zh-CN, en-US)"
|
||||
// @Produce json
|
||||
// @Success 200 {object} ImageMetaResp
|
||||
// @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 != 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 +229,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 +442,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 管理接口
|
||||
|
||||
@@ -61,15 +61,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 +70,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))
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"BingPaper/internal/config"
|
||||
"BingPaper/internal/model"
|
||||
"BingPaper/internal/repo"
|
||||
"BingPaper/internal/service/fetcher"
|
||||
"BingPaper/internal/storage"
|
||||
"BingPaper/internal/util"
|
||||
|
||||
@@ -58,8 +59,19 @@ func GetTodayImage(mkt string) (*model.Image, error) {
|
||||
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, attempting on-demand fetch", zap.String("mkt", mkt))
|
||||
f := fetcher.NewFetcher()
|
||||
_ = f.FetchRegion(context.Background(), mkt)
|
||||
|
||||
// 抓取后重新查询
|
||||
tx = repo.DB.Where("date = ?", today).Where("mkt = ?", mkt)
|
||||
err = tx.Preload("Variants").First(&img).Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// 如果今天没有,尝试获取最近的一张
|
||||
// 如果今天还是没有,尝试获取最近的一张
|
||||
tx = repo.DB.Order("date desc")
|
||||
if mkt != "" {
|
||||
tx = tx.Where("mkt = ?", mkt)
|
||||
@@ -79,6 +91,22 @@ func GetTodayImage(mkt string) (*model.Image, error) {
|
||||
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) {
|
||||
var img model.Image
|
||||
// SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND()
|
||||
@@ -89,6 +117,17 @@ 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, attempting on-demand fetch", zap.String("mkt", mkt))
|
||||
f := fetcher.NewFetcher()
|
||||
_ = f.FetchRegion(context.Background(), mkt)
|
||||
|
||||
// 抓取后重新计数
|
||||
tx = repo.DB.Model(&model.Image{}).Where("mkt = ?", mkt)
|
||||
tx.Count(&count)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("no images found")
|
||||
}
|
||||
@@ -127,6 +166,16 @@ func GetImageByDate(date string, mkt string) (*model.Image, error) {
|
||||
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, attempting on-demand fetch", zap.String("mkt", mkt), zap.String("date", date))
|
||||
f := fetcher.NewFetcher()
|
||||
_ = f.FetchRegion(context.Background(), mkt)
|
||||
|
||||
// 抓取后重新查询
|
||||
tx = repo.DB.Where("date = ?", date).Where("mkt = ?", mkt)
|
||||
err = tx.Preload("Variants").First(&img).Error
|
||||
}
|
||||
|
||||
// 兜底逻辑
|
||||
if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback {
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
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: "俄罗斯"},
|
||||
{Value: "zh-HK", Label: "中国香港"},
|
||||
{Value: "zh-TW", Label: "中国台湾"},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片列表(支持分页和月份筛选)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface AdminConfig {
|
||||
export interface APIConfig {
|
||||
Mode: string // 'local' | 'redirect'
|
||||
EnableMktFallback: boolean
|
||||
EnableOnDemandFetch: boolean
|
||||
}
|
||||
|
||||
export interface CronConfig {
|
||||
|
||||
@@ -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: '中国台湾' },
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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, loading: globalLoading } = 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>
|
||||
|
||||
Reference in New Issue
Block a user