diff --git a/CONFIG.md b/CONFIG.md index f7e5c1a..4c1e9e1 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -36,11 +36,15 @@ BingPaper 支持通过配置文件(YAML)和环境变量进行配置。 - `mode`: API 行为模式。 - `local`: (默认) 接口直接返回图片的二进制流,适合图片存储对外部不可见的情况。 - `redirect`: 接口返回 302 重定向到图片的 `PublicURL`,适合配合 S3 或 WebDAV 的公共访问。 +- `enable_mkt_fallback`: 当请求的地区不存在或无数据时,是否允许兜底回退到默认地区或任意可用地区,默认 `true`。 #### cron (定时任务) - `enabled`: 是否启用定时抓取,默认 `true`。 - `daily_spec`: Cron 表达式,定义每日抓取时间。默认 `"0 10 * * *"` (每日上午 10:00)。 +#### fetcher (抓取配置) +- `regions`: 需要抓取的地区编码列表(如 `zh-CN`, `en-US` 等)。如果不设置,默认为包括主要国家在内的 17 个地区。 + #### retention (数据保留) - `days`: 图片及元数据保留天数。超过此天数的数据可能会被清理任务处理。设置为 `0` 表示永久保留,不进行自动清理。默认 `0`。 diff --git a/README.md b/README.md index 431c2a2..58fec55 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ go run . - `GET /api/v1/image/random`:返回随机图片 - `GET /api/v1/image/date/:yyyy-mm-dd`:返回指定日期图片 - **查询参数**: + - `mkt`:地区编码 (zh-CN, en-US, ja-JP 等),默认 `zh-CN` - `variant`:分辨率 (UHD, 1920x1080, 1366x768),默认 `UHD` - `format`:格式 (jpg),默认 `jpg` diff --git a/config.example.yaml b/config.example.yaml index 71ec177..ba45dd1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -16,6 +16,7 @@ log: api: mode: local # local | redirect + enable_mkt_fallback: true # 当请求的地区不存在时,是否回退到默认地区 cron: enabled: true diff --git a/docs/docs.go b/docs/docs.go index 3d67f9f..ea4ffca 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -413,6 +413,12 @@ const docTemplate = `{ "in": "path", "required": true }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -455,6 +461,12 @@ const docTemplate = `{ "name": "date", "in": "path", "required": true + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -478,6 +490,12 @@ const docTemplate = `{ ], "summary": "获取随机图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -513,6 +531,14 @@ const docTemplate = `{ "image" ], "summary": "获取随机图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -534,6 +560,12 @@ const docTemplate = `{ ], "summary": "获取今日图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -569,6 +601,14 @@ const docTemplate = `{ "image" ], "summary": "获取今日图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -614,6 +654,12 @@ const docTemplate = `{ "description": "按月份过滤 (格式: YYYY-MM)", "name": "month", "in": "query" + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -628,12 +674,39 @@ const docTemplate = `{ } } } + }, + "/regions": { + "get": { + "description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。", + "produces": [ + "application/json" + ], + "tags": [ + "image" + ], + "summary": "获取支持的地区列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/util.Region" + } + } + } + } + } } }, "definitions": { "config.APIConfig": { "type": "object", "properties": { + "enableMktFallback": { + "description": "当请求的地区不存在时,是否回退到默认地区", + "type": "boolean" + }, "mode": { "description": "local | redirect", "type": "string" @@ -666,6 +739,9 @@ const docTemplate = `{ "feature": { "$ref": "#/definitions/config.FeatureConfig" }, + "fetcher": { + "$ref": "#/definitions/config.FetcherConfig" + }, "log": { "$ref": "#/definitions/config.LogConfig" }, @@ -717,6 +793,17 @@ const docTemplate = `{ } } }, + "config.FetcherConfig": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "config.LocalConfig": { "type": "object", "properties": { @@ -917,6 +1004,9 @@ const docTemplate = `{ "hsh": { "type": "string" }, + "mkt": { + "type": "string" + }, "quiz": { "type": "string" }, @@ -1006,6 +1096,17 @@ const docTemplate = `{ "type": "string" } } + }, + "util.Region": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 674cdc6..72ef2d0 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -407,6 +407,12 @@ "in": "path", "required": true }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -449,6 +455,12 @@ "name": "date", "in": "path", "required": true + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -472,6 +484,12 @@ ], "summary": "获取随机图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -507,6 +525,14 @@ "image" ], "summary": "获取随机图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -528,6 +554,12 @@ ], "summary": "获取今日图片", "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + }, { "type": "string", "default": "UHD", @@ -563,6 +595,14 @@ "image" ], "summary": "获取今日图片元数据", + "parameters": [ + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -608,6 +648,12 @@ "description": "按月份过滤 (格式: YYYY-MM)", "name": "month", "in": "query" + }, + { + "type": "string", + "description": "地区编码 (如 zh-CN, en-US)", + "name": "mkt", + "in": "query" } ], "responses": { @@ -622,12 +668,39 @@ } } } + }, + "/regions": { + "get": { + "description": "返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。", + "produces": [ + "application/json" + ], + "tags": [ + "image" + ], + "summary": "获取支持的地区列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/util.Region" + } + } + } + } + } } }, "definitions": { "config.APIConfig": { "type": "object", "properties": { + "enableMktFallback": { + "description": "当请求的地区不存在时,是否回退到默认地区", + "type": "boolean" + }, "mode": { "description": "local | redirect", "type": "string" @@ -660,6 +733,9 @@ "feature": { "$ref": "#/definitions/config.FeatureConfig" }, + "fetcher": { + "$ref": "#/definitions/config.FetcherConfig" + }, "log": { "$ref": "#/definitions/config.LogConfig" }, @@ -711,6 +787,17 @@ } } }, + "config.FetcherConfig": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "config.LocalConfig": { "type": "object", "properties": { @@ -911,6 +998,9 @@ "hsh": { "type": "string" }, + "mkt": { + "type": "string" + }, "quiz": { "type": "string" }, @@ -1000,6 +1090,17 @@ "type": "string" } } + }, + "util.Region": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9b4a38e..14a2d67 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2,6 +2,9 @@ basePath: /api/v1 definitions: config.APIConfig: properties: + enableMktFallback: + description: 当请求的地区不存在时,是否回退到默认地区 + type: boolean mode: description: local | redirect type: string @@ -23,6 +26,8 @@ definitions: $ref: '#/definitions/config.DBConfig' feature: $ref: '#/definitions/config.FeatureConfig' + fetcher: + $ref: '#/definitions/config.FetcherConfig' log: $ref: '#/definitions/config.LogConfig' retention: @@ -56,6 +61,13 @@ definitions: writeDailyFiles: type: boolean type: object + config.FetcherConfig: + properties: + regions: + items: + type: string + type: array + type: object config.LocalConfig: properties: root: @@ -190,6 +202,8 @@ definitions: type: string hsh: type: string + mkt: + type: string quiz: type: string startdate: @@ -248,6 +262,13 @@ definitions: updated_at: type: string type: object + util.Region: + properties: + label: + type: string + value: + type: string + type: object host: localhost:8080 info: contact: {} @@ -501,6 +522,10 @@ paths: name: date required: true type: string + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string - default: UHD description: 分辨率 in: query @@ -530,6 +555,10 @@ paths: name: date required: true type: string + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -544,6 +573,10 @@ paths: get: description: 随机返回一张已抓取的图片流或重定向 parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string - default: UHD description: 分辨率 in: query @@ -567,6 +600,11 @@ paths: /image/random/meta: get: description: 随机获取一张已抓取图片的元数据 + parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -581,6 +619,10 @@ paths: get: description: 根据参数返回今日必应图片流或重定向 parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string - default: UHD description: 分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240) @@ -605,6 +647,11 @@ paths: /image/today/meta: get: description: 获取今日必应图片的标题、版权等元数据 + parameters: + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -637,6 +684,10 @@ paths: in: query name: month type: string + - description: 地区编码 (如 zh-CN, en-US) + in: query + name: mkt + type: string produces: - application/json responses: @@ -649,6 +700,21 @@ paths: summary: 获取图片列表 tags: - image + /regions: + get: + description: 返回系统支持的所有必应地区编码及标签。如果配置中指定了抓取地区,这些地区将排在列表最前面(置顶)。 + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/util.Region' + type: array + summary: 获取支持的地区列表 + tags: + - image securityDefinitions: BearerAuth: in: header diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 5729ae7..b9e3bb3 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "strings" "BingPaper/internal/config" "BingPaper/internal/cron" @@ -52,10 +53,11 @@ func Init(webFS embed.FS, configPath string) *gin.Engine { // 输出配置信息 util.Logger.Info("Application configuration loaded") - util.Logger.Info("├─ Config file", zap.String("path", config.GetRawViper().ConfigFileUsed())) - util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type)) - util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type)) - util.Logger.Info("└─ Server ", zap.Int("port", cfg.Server.Port)) + util.Logger.Info("├─ Config file ", zap.String("path", config.GetRawViper().ConfigFileUsed())) + util.Logger.Info("├─ Database ", zap.String("type", cfg.DB.Type)) + util.Logger.Info("├─ Storage ", zap.String("type", cfg.Storage.Type)) + util.Logger.Info("├─ Server ", zap.Int("port", cfg.Server.Port)) + util.Logger.Info("└─ Active Mkt ", zap.Strings("regions", cfg.Fetcher.Regions)) // 根据存储类型输出更多信息 switch cfg.Storage.Type { @@ -147,5 +149,6 @@ func LogWelcomeInfo() { fmt.Printf(" - 管理后台: %s/admin\n", baseURL) fmt.Printf(" - API 文档: %s/swagger/index.html\n", baseURL) fmt.Printf(" - 今日图片: %s/api/v1/image/today\n", baseURL) + fmt.Printf(" - 激活地区: %s\n", strings.Join(cfg.Fetcher.Regions, ", ")) fmt.Println("---------------------------------------------------------") } diff --git a/internal/config/config.go b/internal/config/config.go index c694f36..cb39215 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,8 @@ import ( "github.com/fsnotify/fsnotify" "github.com/spf13/viper" "gopkg.in/yaml.v3" + + "BingPaper/internal/util" ) type Config struct { @@ -25,6 +27,7 @@ type Config struct { Token TokenConfig `mapstructure:"token" yaml:"token"` Feature FeatureConfig `mapstructure:"feature" yaml:"feature"` Web WebConfig `mapstructure:"web" yaml:"web"` + Fetcher FetcherConfig `mapstructure:"fetcher" yaml:"fetcher"` } type ServerConfig struct { @@ -57,7 +60,8 @@ func (c LogConfig) GetShowDBLog() bool { return c.ShowDBLog } func (c LogConfig) GetDBLogLevel() string { return c.DBLogLevel } type APIConfig struct { - Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect + Mode string `mapstructure:"mode" yaml:"mode"` // local | redirect + EnableMktFallback bool `mapstructure:"enable_mkt_fallback" yaml:"enable_mkt_fallback"` // 当请求的地区不存在时,是否回退到默认地区 } type CronConfig struct { @@ -118,6 +122,10 @@ type WebConfig struct { Path string `mapstructure:"path" yaml:"path"` } +type FetcherConfig struct { + Regions []string `mapstructure:"regions" yaml:"regions"` +} + // Bing 默认配置 (内置) const ( BingMkt = "zh-CN" @@ -157,6 +165,7 @@ func Init(configPath string) error { v.SetDefault("log.show_db_log", false) v.SetDefault("log.db_log_level", "info") v.SetDefault("api.mode", "local") + v.SetDefault("api.enable_mkt_fallback", true) v.SetDefault("cron.enabled", true) v.SetDefault("cron.daily_spec", "20 8-23/4 * * *") v.SetDefault("retention.days", 0) @@ -167,6 +176,13 @@ func Init(configPath string) error { v.SetDefault("token.default_ttl", "168h") v.SetDefault("feature.write_daily_files", true) v.SetDefault("web.path", "web") + + // 默认抓取所有支持的地区 + var defaultRegions []string + for _, r := range util.AllRegions { + defaultRegions = append(defaultRegions, r.Value) + } + v.SetDefault("fetcher.regions", defaultRegions) v.SetDefault("admin.password_bcrypt", "$2a$10$fYHPeWHmwObephJvtlyH1O8DIgaLk5TINbi9BOezo2M8cSjmJchka") // 默认密码: admin123 // 绑定环境变量 @@ -311,3 +327,11 @@ func GetTokenTTL() time.Duration { } return ttl } + +// GetDefaultMkt 返回生效的默认地区编码 +func (c *Config) GetDefaultMkt() string { + if len(c.Fetcher.Regions) > 0 { + return c.Fetcher.Regions[0] + } + return BingMkt +} diff --git a/internal/http/handlers/image.go b/internal/http/handlers/image.go index 7c6ecca..9fe58cf 100644 --- a/internal/http/handlers/image.go +++ b/internal/http/handlers/image.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "sort" "strconv" "BingPaper/internal/config" @@ -27,6 +28,7 @@ type ImageVariantResp struct { type ImageMetaResp struct { Date string `json:"date"` + Mkt string `json:"mkt"` Title string `json:"title"` Copyright string `json:"copyright"` CopyrightLink string `json:"copyrightlink"` @@ -41,13 +43,15 @@ type ImageMetaResp struct { // @Summary 获取今日图片 // @Description 根据参数返回今日必应图片流或重定向 // @Tags image +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param variant query string false "分辨率 (UHD, 1920x1080, 1366x768, 1280x720, 1024x768, 800x600, 800x480, 640x480, 640x360, 480x360, 400x240, 320x240)" default(UHD) // @Param format query string false "格式 (jpg)" default(jpg) // @Produce image/jpeg // @Success 200 {file} binary // @Router /image/today [get] func GetToday(c *gin.Context) { - img, err := image.GetTodayImage() + mkt := c.Query("mkt") + img, err := image.GetTodayImage(mkt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -59,11 +63,13 @@ func GetToday(c *gin.Context) { // @Summary 获取今日图片元数据 // @Description 获取今日必应图片的标题、版权等元数据 // @Tags image +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Produce json // @Success 200 {object} ImageMetaResp // @Router /image/today/meta [get] func GetTodayMeta(c *gin.Context) { - img, err := image.GetTodayImage() + mkt := c.Query("mkt") + img, err := image.GetTodayImage(mkt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -76,13 +82,15 @@ func GetTodayMeta(c *gin.Context) { // @Summary 获取随机图片 // @Description 随机返回一张已抓取的图片流或重定向 // @Tags image +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param variant query string false "分辨率" default(UHD) // @Param format query string false "格式" default(jpg) // @Produce image/jpeg // @Success 200 {file} binary // @Router /image/random [get] func GetRandom(c *gin.Context) { - img, err := image.GetRandomImage() + mkt := c.Query("mkt") + img, err := image.GetRandomImage(mkt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -94,11 +102,13 @@ func GetRandom(c *gin.Context) { // @Summary 获取随机图片元数据 // @Description 随机获取一张已抓取图片的元数据 // @Tags image +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Produce json // @Success 200 {object} ImageMetaResp // @Router /image/random/meta [get] func GetRandomMeta(c *gin.Context) { - img, err := image.GetRandomImage() + mkt := c.Query("mkt") + img, err := image.GetRandomImage(mkt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -112,6 +122,7 @@ func GetRandomMeta(c *gin.Context) { // @Description 根据日期返回图片流或重定向 (yyyy-mm-dd) // @Tags image // @Param date path string true "日期 (yyyy-mm-dd)" +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Param variant query string false "分辨率" default(UHD) // @Param format query string false "格式" default(jpg) // @Produce image/jpeg @@ -119,7 +130,8 @@ func GetRandomMeta(c *gin.Context) { // @Router /image/date/{date} [get] func GetByDate(c *gin.Context) { date := c.Param("date") - img, err := image.GetImageByDate(date) + mkt := c.Query("mkt") + img, err := image.GetImageByDate(date, mkt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -132,12 +144,14 @@ func GetByDate(c *gin.Context) { // @Description 根据日期获取图片元数据 (yyyy-mm-dd) // @Tags image // @Param date path string true "日期 (yyyy-mm-dd)" +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Produce json // @Success 200 {object} ImageMetaResp // @Router /image/date/{date}/meta [get] func GetByDateMeta(c *gin.Context) { date := c.Param("date") - img, err := image.GetImageByDate(date) + mkt := c.Query("mkt") + img, err := image.GetImageByDate(date, mkt) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -154,6 +168,7 @@ func GetByDateMeta(c *gin.Context) { // @Param page query int false "页码 (从1开始)" // @Param page_size query int false "每页数量" // @Param month query string false "按月份过滤 (格式: YYYY-MM)" +// @Param mkt query string false "地区编码 (如 zh-CN, en-US)" // @Produce json // @Success 200 {array} ImageMetaResp // @Router /images [get] @@ -162,10 +177,12 @@ func ListImages(c *gin.Context) { pageStr := c.Query("page") pageSizeStr := c.Query("page_size") month := c.Query("month") + mkt := c.Query("mkt") // 记录请求参数,便于排查过滤失效问题 util.Logger.Debug("ListImages parameters", zap.String("month", month), + zap.String("mkt", mkt), zap.String("page", pageStr), zap.String("page_size", pageSizeStr), zap.String("limit", limitStr)) @@ -192,7 +209,7 @@ func ListImages(c *gin.Context) { offset = 0 } - images, err := image.GetImageList(limit, offset, month) + images, err := image.GetImageList(limit, offset, month, mkt) if err != nil { util.Logger.Error("ListImages service call failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -291,7 +308,7 @@ func formatMeta(img *model.Image) gin.H { if url == "" && cfg.API.Mode == "redirect" && img.URLBase != "" { url = fmt.Sprintf("https://www.bing.com%s_%s.jpg", img.URLBase, v.Variant) } else if cfg.API.Mode == "local" || url == "" { - url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format) + url = fmt.Sprintf("%s/api/v1/image/date/%s?variant=%s&format=%s&mkt=%s", cfg.Server.BaseURL, img.Date, v.Variant, v.Format, img.Mkt) } variants = append(variants, gin.H{ "variant": v.Variant, @@ -304,6 +321,7 @@ func formatMeta(img *model.Image) gin.H { return gin.H{ "date": img.Date, + "mkt": img.Mkt, "title": img.Title, "copyright": img.Copyright, "copyrightlink": img.CopyrightLink, @@ -314,3 +332,46 @@ func formatMeta(img *model.Image) gin.H { "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 + + // 创建副本以避免修改原始全局变量 + 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 // 保持非置顶地区的原有相对顺序 + }) + } + + c.JSON(http.StatusOK, all) +} diff --git a/internal/http/handlers/image_test.go b/internal/http/handlers/image_test.go index ac1d48a..f10d98b 100644 --- a/internal/http/handlers/image_test.go +++ b/internal/http/handlers/image_test.go @@ -1,6 +1,7 @@ package handlers import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -66,3 +67,27 @@ func TestHandleImageResponseRedirect(t *testing.T) { assert.Contains(t, variants[0]["url"].(string), "/api/v1/image/date/") }) } + +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(), ®ions) + 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"]) + }) +} diff --git a/internal/http/router.go b/internal/http/router.go index acef4fd..dce749f 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -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("/regions", handlers.GetRegions) // 管理接口 admin := api.Group("/admin") diff --git a/internal/model/models.go b/internal/model/models.go index 614e07d..d56fbd1 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -8,7 +8,8 @@ import ( type Image struct { ID uint `gorm:"primaryKey" json:"id"` - Date string `gorm:"uniqueIndex;type:varchar(10)" json:"date"` // YYYY-MM-DD + Date string `gorm:"uniqueIndex:idx_date_mkt;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. Title string `json:"title"` Copyright string `json:"copyright"` CopyrightLink string `json:"copyrightlink"` diff --git a/internal/service/fetcher/fetcher.go b/internal/service/fetcher/fetcher.go index f3fcce8..4029d95 100644 --- a/internal/service/fetcher/fetcher.go +++ b/internal/service/fetcher/fetcher.go @@ -55,7 +55,30 @@ func NewFetcher() *Fetcher { func (f *Fetcher) Fetch(ctx context.Context, n int) error { util.Logger.Info("Starting fetch task", zap.Int("n", n)) - url := fmt.Sprintf("%s?format=js&idx=0&n=%d&uhd=1&mkt=%s", config.BingAPIBase, n, config.BingMkt) + regions := config.GetConfig().Fetcher.Regions + if len(regions) == 0 { + regions = []string{config.GetConfig().GetDefaultMkt()} + } + + 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)) + } + } + + util.Logger.Info("Fetch task completed") + 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)) resp, err := f.httpClient.Get(url) if err != nil { @@ -70,29 +93,28 @@ func (f *Fetcher) Fetch(ctx context.Context, n int) error { 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 { - if err := f.processImage(ctx, bingImg); err != nil { - util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.Error(err)) + if err := f.processImage(ctx, bingImg, mkt); err != nil { + util.Logger.Error("Failed to process image", zap.String("date", bingImg.Enddate), zap.String("mkt", mkt), zap.Error(err)) } } - util.Logger.Info("Fetch task completed") 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]) // 幂等检查 var existing model.Image - if err := repo.DB.Where("date = ?", dateStr).First(&existing).Error; err == nil { - util.Logger.Info("Image already exists, skipping", zap.String("date", dateStr)) + 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)) return nil } - util.Logger.Info("Processing new image", zap.String("date", dateStr), zap.String("title", bingImg.Title)) + 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) @@ -113,6 +135,7 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { // 创建 DB 记录 dbImg := model.Image{ Date: dateStr, + Mkt: mkt, Title: bingImg.Title, Copyright: bingImg.Copyright, CopyrightLink: bingImg.CopyrightLink, @@ -124,7 +147,7 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { } if err := repo.DB.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "date"}}, + Columns: []clause.Column{{Name: "date"}, {Name: "mkt"}}, DoNothing: true, }).Create(&dbImg).Error; err != nil { util.Logger.Error("Failed to create image record", zap.Error(err)) @@ -134,7 +157,7 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { // 再次检查 dbImg.ID 是否被填充,如果没有被填充(说明由于冲突未插入),则需要查询出已有的 ID if dbImg.ID == 0 { 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)) return err } @@ -188,7 +211,7 @@ func (f *Fetcher) processImage(ctx context.Context, bingImg BingImage) error { // 保存今日额外文件 today := time.Now().Format("2006-01-02") if dateStr == today && config.GetConfig().Feature.WriteDailyFiles { - f.saveDailyFiles(srcImg, imgData) + f.saveDailyFiles(srcImg, imgData, mkt) } return nil @@ -213,7 +236,7 @@ func (f *Fetcher) downloadImage(url string) ([]byte, error) { } func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, format string, data []byte) error { - key := fmt.Sprintf("%s/%s_%s.%s", img.Date, img.Date, variant, format) + key := fmt.Sprintf("%s/%s/%s_%s.%s", img.Mkt, img.Date, img.Date, variant, format) contentType := "image/jpeg" if format == "webp" { contentType = "image/webp" @@ -239,20 +262,21 @@ func (f *Fetcher) saveVariant(ctx context.Context, img *model.Image, variant, fo }).Create(&vRecord).Error } -func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) { - util.Logger.Info("Saving daily files") +func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte, mkt string) { + util.Logger.Info("Saving daily files", zap.String("mkt", mkt)) localRoot := config.GetConfig().Storage.Local.Root if localRoot == "" { localRoot = "data" } - if err := os.MkdirAll(localRoot, 0755); err != nil { - util.Logger.Error("Failed to create directory", zap.String("path", localRoot), zap.Error(err)) + mktDir := filepath.Join(localRoot, mkt) + if err := os.MkdirAll(mktDir, 0755); err != nil { + util.Logger.Error("Failed to create directory", zap.String("path", mktDir), zap.Error(err)) return } // daily.jpeg (quality 100) - jpegPath := filepath.Join(localRoot, "daily.jpeg") + jpegPath := filepath.Join(mktDir, "daily.jpeg") fJpeg, err := os.Create(jpegPath) if err != nil { util.Logger.Error("Failed to create daily.jpeg", zap.Error(err)) @@ -262,8 +286,21 @@ func (f *Fetcher) saveDailyFiles(srcImg image.Image, originalData []byte) { } // original.jpeg (quality 100) - originalPath := filepath.Join(localRoot, "original.jpeg") + originalPath := filepath.Join(mktDir, "original.jpeg") if err := os.WriteFile(originalPath, originalData, 0644); err != nil { util.Logger.Error("Failed to write original.jpeg", zap.Error(err)) } + + // 同时也保留一份在根目录下(兼容旧逻辑,且作为默认地区图片) + // 如果是默认地区或者是第一个抓取的地区,可以覆盖根目录的文件 + if mkt == config.GetConfig().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) + } } diff --git a/internal/service/image/image_service.go b/internal/service/image/image_service.go index 7b0fba0..4ed11ce 100644 --- a/internal/service/image/image_service.go +++ b/internal/service/image/image_service.go @@ -50,43 +50,97 @@ func CleanupOldImages(ctx context.Context) error { return nil } -func GetTodayImage() (*model.Image, error) { +func GetTodayImage(mkt string) (*model.Image, error) { today := time.Now().Format("2006-01-02") 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 { // 如果今天没有,尝试获取最近的一张 - 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() + if mkt != defaultMkt { + return GetTodayImage(defaultMkt) + } + return GetTodayImage("") + } + return &img, err } -func GetRandomImage() (*model.Image, error) { +func GetRandomImage(mkt string) (*model.Image, error) { var img model.Image // SQLite 使用 RANDOM(), MySQL/Postgres 使用 RANDOM() 或 RAND() // 简单起见,先查总数再 Offset 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 { return nil, fmt.Errorf("no images found") } // 这种方法不适合海量数据,但对于 30 天的数据没问题 - err := repo.DB.Order("RANDOM()").Preload("Variants").First(&img).Error + tx = repo.DB.Order("RANDOM()") + if mkt != "" { + tx = tx.Where("mkt = ?", mkt) + } + err := tx.Preload("Variants").First(&img).Error if err != nil { // 适配 MySQL - err = repo.DB.Order("RAND()").Preload("Variants").First(&img).Error + tx = repo.DB.Order("RAND()") + 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() + if mkt != defaultMkt { + return GetRandomImage(defaultMkt) + } + return GetRandomImage("") + } + return &img, err } -func GetImageByDate(date string) (*model.Image, error) { +func GetImageByDate(date string, mkt string) (*model.Image, error) { var img model.Image - err := repo.DB.Where("date = ?", date).Preload("Variants").First(&img).Error + tx := repo.DB.Where("date = ?", date) + if mkt != "" { + tx = tx.Where("mkt = ?", mkt) + } + err := tx.Preload("Variants").First(&img).Error + + // 兜底逻辑 + if err != nil && mkt != "" && config.GetConfig().API.EnableMktFallback { + defaultMkt := config.GetConfig().GetDefaultMkt() + if mkt != defaultMkt { + return GetImageByDate(date, defaultMkt) + } + return GetImageByDate(date, "") + } + return &img, err } -func GetImageList(limit int, offset int, month string) ([]model.Image, error) { +func GetImageList(limit int, offset int, month string, mkt string) ([]model.Image, error) { var images []model.Image tx := repo.DB.Model(&model.Image{}) @@ -97,6 +151,10 @@ func GetImageList(limit int, offset int, month string) ([]model.Image, error) { tx = tx.Where("date LIKE ?", month+"%") } + if mkt != "" { + tx = tx.Where("mkt = ?", mkt) + } + tx = tx.Order("date desc").Preload("Variants") if limit > 0 { diff --git a/internal/util/regions.go b/internal/util/regions.go new file mode 100644 index 0000000..75ea1a3 --- /dev/null +++ b/internal/util/regions.go @@ -0,0 +1,26 @@ +package util + +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)"}, +} diff --git a/webapp/src/components/ui/calendar/Calendar.vue b/webapp/src/components/ui/calendar/Calendar.vue index f34eeb8..8e16e0d 100644 --- a/webapp/src/components/ui/calendar/Calendar.vue +++ b/webapp/src/components/ui/calendar/Calendar.vue @@ -2,10 +2,10 @@
@@ -28,7 +28,7 @@
- - () const emit = defineEmits<{ @@ -229,10 +230,14 @@ const panelPos = ref({ x: 0, y: 0 }) const isDragging = ref(false) const dragStart = ref({ x: 0, y: 0 }) +// 响应式窗口大小 +const windowSize = ref({ width: window.innerWidth, height: window.innerHeight }) +const isMobile = computed(() => windowSize.value.width < 768) + // 计算图片实际显示区域(与ImageView保持一致) const getImageDisplayBounds = () => { - const windowWidth = window.innerWidth - const windowHeight = window.innerHeight + const windowWidth = windowSize.value.width + const windowHeight = windowSize.value.height // 必应图片通常是16:9或类似宽高比 const imageAspectRatio = 16 / 9 @@ -271,11 +276,10 @@ const getImageDisplayBounds = () => { const initPanelPosition = () => { if (typeof window !== 'undefined') { const bounds = getImageDisplayBounds() - const isMobile = window.innerWidth < 640 // sm breakpoint - if (isMobile) { - // 移动端:在图片区域内居中显示 - const panelWidth = Math.min(bounds.width - 16, window.innerWidth - 16) + if (isMobile.value) { + // 移动端:居中显示,尽量在图片内,但不强求 + const panelWidth = Math.min(bounds.width - 16, windowSize.value.width - 16) const panelHeight = 580 // 估计高度 panelPos.value = { x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2), @@ -316,20 +320,6 @@ const currentMonthString = computed({ } }) -// 年份改变处理 -const onYearChange = (value: any) => { - if (value !== null && value !== undefined) { - currentYear.value = Number(value) - } -} - -// 月份改变处理 -const onMonthChange = (value: any) => { - if (value !== null && value !== undefined) { - currentMonth.value = Number(value) - } -} - // 生成年份选项(从2009年到当前年份+10年) const yearOptions = computed(() => { const currentYearValue = new Date().getFullYear() @@ -379,8 +369,20 @@ const loadHolidaysForYear = async (year: number) => { } } +// 窗口缩放处理 +const handleResize = () => { + windowSize.value = { + width: window.innerWidth, + height: window.innerHeight + } + initPanelPosition() +} + // 组件挂载时加载当前年份的假期数据 onMounted(() => { + if (typeof window !== 'undefined') { + window.addEventListener('resize', handleResize) + } const currentYearValue = currentYear.value loadHolidaysForYear(currentYearValue) // 预加载前后一年的数据 @@ -404,7 +406,9 @@ const startDrag = (e: MouseEvent | TouchEvent) => { return } - e.preventDefault() + if (e instanceof MouseEvent) { + e.preventDefault() + } isDragging.value = true const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX @@ -417,7 +421,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => { document.addEventListener('mousemove', onDrag) document.addEventListener('mouseup', stopDrag) - document.addEventListener('touchmove', onDrag, { passive: false }) + document.addEventListener('touchmove', onDrag, { passive: true }) document.addEventListener('touchend', stopDrag) } @@ -425,7 +429,7 @@ const startDrag = (e: MouseEvent | TouchEvent) => { const onDrag = (e: MouseEvent | TouchEvent) => { if (!isDragging.value) return - if (e instanceof TouchEvent) { + if (e instanceof MouseEvent) { e.preventDefault() } @@ -435,15 +439,26 @@ const onDrag = (e: MouseEvent | TouchEvent) => { const newX = clientX - dragStart.value.x const newY = clientY - dragStart.value.y - // 限制在图片实际显示区域内 + // 限制在有效区域内 if (calendarPanel.value) { const rect = calendarPanel.value.getBoundingClientRect() - const bounds = getImageDisplayBounds() - const minX = bounds.left - const maxX = bounds.right - rect.width - const minY = bounds.top - const maxY = bounds.bottom - rect.height + let minX, maxX, minY, maxY + + if (isMobile.value) { + // 移动端:不限制区域,限制在视口内即可 + minX = 0 + maxX = windowSize.value.width - rect.width + minY = 0 + maxY = windowSize.value.height - rect.height + } else { + // 桌面端:限制在图片实际显示区域内 + const bounds = getImageDisplayBounds() + minX = bounds.left + maxX = bounds.right - rect.width + minY = bounds.top + maxY = bounds.bottom - rect.height + } panelPos.value = { x: Math.max(minX, Math.min(newX, maxX)), @@ -517,7 +532,7 @@ const createDayObject = (date: Date, isCurrentMonth: boolean): CalendarDay => { const today = new Date() today.setHours(0, 0, 0, 0) - const selectedDate = new Date(props.selectedDate) + const selectedDate = new Date(props.selectedDate || new Date()) selectedDate.setHours(0, 0, 0, 0) // 转换为农历 @@ -626,6 +641,9 @@ const goToToday = () => { // 清理 import { onUnmounted } from 'vue' onUnmounted(() => { + if (typeof window !== 'undefined') { + window.removeEventListener('resize', handleResize) + } document.removeEventListener('mousemove', onDrag) document.removeEventListener('mouseup', stopDrag) document.removeEventListener('touchmove', onDrag) diff --git a/webapp/src/composables/useImages.ts b/webapp/src/composables/useImages.ts index 8377dee..4a93c22 100644 --- a/webapp/src/composables/useImages.ts +++ b/webapp/src/composables/useImages.ts @@ -2,11 +2,12 @@ import { ref, onMounted, watch } from 'vue' import type { Ref } from 'vue' import { bingPaperApi } from '@/lib/api-service' import type { ImageMeta } from '@/lib/api-types' +import { getDefaultMkt } from '@/lib/mkt-utils' /** * 获取今日图片 */ -export function useTodayImage() { +export function useTodayImage(mkt?: string) { const image = ref(null) const loading = ref(false) const error = ref(null) @@ -15,7 +16,7 @@ export function useTodayImage() { loading.value = true error.value = null try { - image.value = await bingPaperApi.getTodayImageMeta() + image.value = await bingPaperApi.getTodayImageMeta(mkt || getDefaultMkt()) } catch (e) { error.value = e as Error console.error('Failed to fetch today image:', e) @@ -46,8 +47,9 @@ export function useImageList(pageSize = 30) { const hasMore = ref(true) const currentPage = ref(1) const currentMonth = ref(undefined) + const currentMkt = ref(getDefaultMkt()) - const fetchImages = async (page = 1, month?: string) => { + const fetchImages = async (page = 1, month?: string, mkt?: string) => { if (loading.value) return loading.value = true @@ -55,7 +57,8 @@ export function useImageList(pageSize = 30) { try { const params: any = { page, - page_size: pageSize + page_size: pageSize, + mkt: mkt || currentMkt.value || getDefaultMkt() } if (month) { params.month = month @@ -84,7 +87,7 @@ export function useImageList(pageSize = 30) { const loadMore = () => { if (!loading.value && hasMore.value) { - fetchImages(currentPage.value + 1, currentMonth.value) + fetchImages(currentPage.value + 1, currentMonth.value, currentMkt.value) } } @@ -92,7 +95,14 @@ export function useImageList(pageSize = 30) { currentMonth.value = month currentPage.value = 1 hasMore.value = true - fetchImages(1, month) + fetchImages(1, month, currentMkt.value) + } + + const filterByMkt = (mkt?: string) => { + currentMkt.value = mkt + currentPage.value = 1 + hasMore.value = true + fetchImages(1, currentMonth.value, mkt) } onMounted(() => { @@ -106,10 +116,11 @@ export function useImageList(pageSize = 30) { hasMore, loadMore, filterByMonth, + filterByMkt, refetch: () => { currentPage.value = 1 hasMore.value = true - fetchImages(1, currentMonth.value) + fetchImages(1, currentMonth.value, currentMkt.value) } } } @@ -117,7 +128,7 @@ export function useImageList(pageSize = 30) { /** * 获取指定日期的图片 */ -export function useImageByDate(dateRef: Ref) { +export function useImageByDate(dateRef: Ref, mktRef?: Ref) { const image = ref(null) const loading = ref(false) const error = ref(null) @@ -126,7 +137,7 @@ export function useImageByDate(dateRef: Ref) { loading.value = true error.value = null try { - image.value = await bingPaperApi.getImageMetaByDate(dateRef.value) + image.value = await bingPaperApi.getImageMetaByDate(dateRef.value, mktRef?.value || getDefaultMkt()) } catch (e) { error.value = e as Error console.error(`Failed to fetch image for date ${dateRef.value}:`, e) @@ -135,10 +146,16 @@ export function useImageByDate(dateRef: Ref) { } } - // 监听日期变化,自动重新获取 - watch(dateRef, () => { - fetchImage() - }, { immediate: true }) + // 监听日期和地区变化,自动重新获取 + if (mktRef) { + watch([dateRef, mktRef], () => { + fetchImage() + }, { immediate: true }) + } else { + watch(dateRef, () => { + fetchImage() + }, { immediate: true }) + } return { image, diff --git a/webapp/src/lib/api-service.ts b/webapp/src/lib/api-service.ts index 66dacde..806e7b1 100644 --- a/webapp/src/lib/api-service.ts +++ b/webapp/src/lib/api-service.ts @@ -7,6 +7,7 @@ import type { UpdateTokenRequest, ChangePasswordRequest, Config, + Region, ImageMeta, ImageListParams, ManualFetchRequest, @@ -109,6 +110,7 @@ export class BingPaperApiService { if (params?.page) searchParams.set('page', params.page.toString()) if (params?.page_size) searchParams.set('page_size', params.page_size.toString()) if (params?.month) searchParams.set('month', params.month) + if (params?.mkt) searchParams.set('mkt', params.mkt) const queryString = searchParams.toString() const endpoint = queryString ? `/images?${queryString}` : '/images' @@ -116,48 +118,61 @@ export class BingPaperApiService { return apiClient.get(endpoint) } + /** + * 获取支持的地区列表 + */ + async getRegions(): Promise { + return apiClient.get('/regions') + } + /** * 获取今日图片元数据 */ - async getTodayImageMeta(): Promise { - return apiClient.get('/image/today/meta') + async getTodayImageMeta(mkt?: string): Promise { + const endpoint = mkt ? `/image/today/meta?mkt=${mkt}` : '/image/today/meta' + return apiClient.get(endpoint) } /** * 获取指定日期图片元数据 */ - async getImageMetaByDate(date: string): Promise { - return apiClient.get(`/image/date/${date}/meta`) + async getImageMetaByDate(date: string, mkt?: string): Promise { + const endpoint = mkt ? `/image/date/${date}/meta?mkt=${mkt}` : `/image/date/${date}/meta` + return apiClient.get(endpoint) } /** * 获取随机图片元数据 */ - async getRandomImageMeta(): Promise { - return apiClient.get('/image/random/meta') + async getRandomImageMeta(mkt?: string): Promise { + const endpoint = mkt ? `/image/random/meta?mkt=${mkt}` : '/image/random/meta' + return apiClient.get(endpoint) } /** * 构建图片 URL */ - getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string { + getTodayImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string { const params = new URLSearchParams({ variant, format }) + if (mkt) params.set('mkt', mkt) return `${apiConfig.baseURL}/image/today?${params.toString()}` } /** * 构建指定日期图片 URL */ - getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string { + getImageUrlByDate(date: string, variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string { const params = new URLSearchParams({ variant, format }) + if (mkt) params.set('mkt', mkt) return `${apiConfig.baseURL}/image/date/${date}?${params.toString()}` } /** * 构建随机图片 URL */ - getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg'): string { + getRandomImageUrl(variant: ImageVariant = 'UHD', format: ImageFormat = 'jpg', mkt?: string): string { const params = new URLSearchParams({ variant, format }) + if (mkt) params.set('mkt', mkt) return `${apiConfig.baseURL}/image/random?${params.toString()}` } @@ -197,6 +212,7 @@ export const { manualFetch, manualCleanup, getImages, + getRegions, getTodayImageMeta, getImageMetaByDate, getRandomImageMeta, diff --git a/webapp/src/lib/api-types.ts b/webapp/src/lib/api-types.ts index 720a39f..6ef0a01 100644 --- a/webapp/src/lib/api-types.ts +++ b/webapp/src/lib/api-types.ts @@ -61,6 +61,11 @@ export interface Config { Token: TokenConfig Feature: FeatureConfig Web: WebConfig + Fetcher: FetcherConfig +} + +export interface FetcherConfig { + Regions: string[] } export interface AdminConfig { @@ -69,6 +74,7 @@ export interface AdminConfig { export interface APIConfig { Mode: string // 'local' | 'redirect' + EnableMktFallback: boolean } export interface CronConfig { @@ -147,6 +153,7 @@ export interface WebConfig { export interface ImageMeta { date?: string + mkt?: string title?: string copyright?: string copyrightlink?: string // 图片的详细版权链接(指向 Bing 搜索页面) @@ -173,6 +180,12 @@ export interface ImageListParams extends PaginationParams { page?: number // 页码(从1开始) page_size?: number // 每页数量 month?: string // 按月份过滤(格式:YYYY-MM) + mkt?: string // 地区编码 +} + +export interface Region { + value: string + label: string } export interface ManualFetchRequest { diff --git a/webapp/src/lib/mkt-utils.ts b/webapp/src/lib/mkt-utils.ts new file mode 100644 index 0000000..cdc5b96 --- /dev/null +++ b/webapp/src/lib/mkt-utils.ts @@ -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: '中国 (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)' }, +] + +/** + * 支持的地区列表 (优先使用后端提供的) + */ +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) +} diff --git a/webapp/src/views/AdminConfig.vue b/webapp/src/views/AdminConfig.vue index d0585b1..14e8a83 100644 --- a/webapp/src/views/AdminConfig.vue +++ b/webapp/src/views/AdminConfig.vue @@ -102,6 +102,16 @@ local: 直接返回图片流; redirect: 重定向到存储位置

+
+ + +
+

+ 如果请求的地区无数据,自动回退到默认地区 +

@@ -372,6 +382,31 @@ + + + + 抓取配置 + + +
+ +
+
+ + +
+
+

+ 勾选需要定期抓取壁纸的地区。如果不勾选任何地区,默认将只抓取 zh-CN。 +

+
+
+
+ @@ -414,6 +449,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' +import { Checkbox } from '@/components/ui/checkbox' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { apiService } from '@/lib/api-service' import type { Config } from '@/lib/api-types' @@ -424,9 +460,12 @@ const loadError = ref('') const saveLoading = ref(false) const dsnError = ref('') +// 所有可选地区列表 +const allRegions = ref([]) + const config = ref({ Admin: { PasswordBcrypt: '' }, - API: { Mode: 'local' }, + API: { Mode: 'local', EnableMktFallback: true }, Cron: { Enabled: true, DailySpec: '0 9 * * *' }, DB: { Type: 'sqlite', DSN: '' }, Feature: { WriteDailyFiles: true }, @@ -464,12 +503,37 @@ const config = ref({ } }, Token: { DefaultTTL: '168h' }, - Web: { Path: './webapp/dist' } + Web: { Path: './webapp/dist' }, + Fetcher: { Regions: [] } }) const configJson = ref('') const jsonError = ref('') +// 获取所有地区 +const fetchRegions = async () => { + try { + const data = await apiService.getRegions() + allRegions.value = data + } catch (err) { + console.error('获取地区列表失败:', err) + } +} + +const toggleRegion = (regionValue: string, checked: boolean) => { + if (!config.value.Fetcher.Regions) { + config.value.Fetcher.Regions = [] + } + + if (checked) { + if (!config.value.Fetcher.Regions.includes(regionValue)) { + config.value.Fetcher.Regions.push(regionValue) + } + } else { + config.value.Fetcher.Regions = config.value.Fetcher.Regions.filter(r => r !== regionValue) + } +} + // DSN 示例 const dsnExamples = computed(() => { switch (config.value.DB.Type) { @@ -602,6 +666,7 @@ const handleSaveConfig = async () => { } onMounted(() => { + fetchRegions() fetchConfig() }) diff --git a/webapp/src/views/ApiDocs.vue b/webapp/src/views/ApiDocs.vue index 105142a..726a8a6 100644 --- a/webapp/src/views/ApiDocs.vue +++ b/webapp/src/views/ApiDocs.vue @@ -103,6 +103,10 @@ format 格式: jpg (默认: jpg)
+
+ mkt + 地区编码 (如 zh-CN, en-US, ja-JP),默认由服务器自动探测 +
@@ -182,6 +186,10 @@ format 格式 (默认: jpg) +
+ mkt + 地区编码 (如 zh-CN, en-US, ja-JP) +
@@ -254,6 +262,10 @@ format 格式 (默认: jpg) +
+ mkt + 地区编码 (如 zh-CN, en-US, ja-JP) +
@@ -334,6 +346,10 @@ date 图片日期(格式:YYYY-MM-DD) +
+ mkt + 地区编码(如 zh-CN, en-US) +
title 图片标题 @@ -458,24 +474,26 @@