diff --git a/FAVICON_UPDATE.md b/FAVICON_UPDATE.md new file mode 100644 index 0000000..5ac1bae --- /dev/null +++ b/FAVICON_UPDATE.md @@ -0,0 +1,87 @@ +# Favicon 设置功能更新 + +## 概述 +在站点设置页面 (`/admin/settings/site`) 添加了 Favicon(网站图标)设置功能。 + +## 更改的文件 + +### 1. 数据库模型 (models/site_setting.go) +- 添加了 `Favicon` 字段到 `SiteSetting` 结构体 +- 添加了 `FaviconIsURL()` 方法来判断 Favicon 是外链还是本地文件 + +### 2. 处理器 (handlers/settings.go) +- `SiteSettingsPage`: 添加 `SiteFaviconIsURL` 到模板数据 +- `SiteSettingsSave`: 添加 Favicon 上传和保存逻辑,支持: + - 外链 URL 设置 + - 本地文件上传(.ico, .png, .svg) + - 清除当前 Favicon + +### 3. 模板 (templates/admin/settings_site.html) +- 添加 Favicon 设置表单区域 +- 显示当前 Favicon 预览 +- 支持 URL 输入和文件上传 +- 添加清除 Favicon 的复选框 + +### 4. 国际化 (i18n/i18n.go) +添加了以下翻译键: +- `settings_favicon`: Favicon / 网站图标(Favicon) +- `settings_favicon_url`: Favicon URL (external link) / Favicon 链接(外链地址) +- `settings_favicon_current`: Current favicon / 当前 Favicon +- `settings_favicon_hint`: 格式提示 +- `settings_favicon_clear`: Remove current favicon / 移除当前 Favicon + +### 5. 中间件 (middleware/auth.go) +- 在 `SetUserContext` 中添加 `site_favicon` 和 `site_favicon_is_url` 到上下文 + +### 6. 辅助函数 (handlers/helpers.go) +- 在 `DefaultData` 中添加 `SiteFavicon` 和 `SiteFaviconIsURL` 到模板数据 + +### 7. 页面模板 (templates/layouts/base.html) +- 在 `` 中添加 Favicon 的 `` 标签 +- 根据设置自动使用外链或本地文件 + +## 数据库迁移 + +如果数据库已存在,需要手动添加 `favicon` 字段: + +```sql +ALTER TABLE site_settings ADD COLUMN favicon VARCHAR(512) DEFAULT ''; +``` + +或者运行提供的迁移脚本: +```bash +sqlite3 data/blog.db < scripts/add_favicon_field.sql +``` + +**注意**: GORM 的 `AutoMigrate` 会在下次启动时自动添加新字段,无需手动执行 SQL。 + +## 使用说明 + +1. 访问 `/admin/settings/site` 页面 +2. 在 "网站图标(Favicon)" 部分: + - 方式一:输入外链 URL(如 CDN 链接) + - 方式二:上传本地图片文件(推荐 .ico, .png 或 .svg 格式,32x32 或 16x16 像素) +3. 点击 "保存" 按钮 +4. Favicon 将自动应用到网站所有页面 + +## 文件存储 + +- 上传的 Favicon 文件保存在 `{storage_path}/logos/` 目录下 +- 文件名固定为 `favicon.{ext}`(如 favicon.ico, favicon.png) +- 每次上传新文件时会自动删除旧文件 + +## 技术细节 + +### 支持的格式 +- ICO (.ico) - 传统格式,兼容性最好 +- PNG (.png) - 现代浏览器支持 +- SVG (.svg) - 矢量格式,适合高分辨率显示 + +### 优先级 +1. 如果同时设置了 URL 和上传文件,URL 优先 +2. 如果勾选 "移除当前 Favicon",会删除现有设置 + +### 缓存 +- Favicon 设置存储在数据库中 +- 通过 `models.RefreshConfigCache()` 刷新内存缓存 +- 更改后立即生效,无需重启服务 diff --git a/handlers/helpers.go b/handlers/helpers.go index 0a9d4c3..6753794 100644 --- a/handlers/helpers.go +++ b/handlers/helpers.go @@ -19,6 +19,8 @@ func DefaultData(c *gin.Context) gin.H { siteSetting, _ := c.Get("site_setting") siteLogo, _ := c.Get("site_logo") siteLogoIsURL, _ := c.Get("site_logo_is_url") + siteFavicon, _ := c.Get("site_favicon") + siteFaviconIsURL, _ := c.Get("site_favicon_is_url") siteLogoText, _ := c.Get("site_logo_text") siteHeaderText, _ := c.Get("site_header_text") siteHomeWelcome, _ := c.Get("site_home_welcome") @@ -37,6 +39,8 @@ func DefaultData(c *gin.Context) gin.H { "SiteSetting": siteSetting, "SiteLogo": siteLogo, "SiteLogoIsURL": siteLogoIsURL, + "SiteFavicon": siteFavicon, + "SiteFaviconIsURL": siteFaviconIsURL, "SiteLogoText": siteLogoText, "SiteHeaderText": siteHeaderText, "SiteHomeWelcome": siteHomeWelcome, diff --git a/handlers/settings.go b/handlers/settings.go index 4b816a7..822d3b8 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -74,6 +74,7 @@ func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc { // Pre-compute derived values so the template never invokes methods on // an interface{}-wrapped struct (which Go templates cannot resolve). data["SiteLogoIsURL"] = s.LogoIsURL() + data["SiteFaviconIsURL"] = s.FaviconIsURL() if msg := c.Query("saved"); msg == "1" { data["Success"] = tr["settings_saved"] } @@ -101,6 +102,46 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en")) s.UpdatedBy = userIDFromSession(c) + // Favicon upload (optional). A favicon_url form field takes precedence over an + // uploaded file, so admins can set either a local file or an external link. + if faviconURL := strings.TrimSpace(c.PostForm("favicon_url")); faviconURL != "" { + s.Favicon = faviconURL + } else if file, header, err := c.Request.FormFile("favicon"); err == nil { + defer file.Close() + check := ValidateUpload(header) + if !check.OK || check.Type.Category != models.CategoryImage { + c.Redirect(http.StatusFound, "/admin/settings/site") + return + } + logoDir := filepath.Join(storagePath, "logos") + os.MkdirAll(logoDir, 0755) + // Remove the previous local favicon (skip external URLs). + if s.Favicon != "" && !s.FaviconIsURL() { + os.Remove(filepath.Join(logoDir, s.Favicon)) + } + ext := strings.ToLower(filepath.Ext(header.Filename)) + savedName := fmt.Sprintf("favicon%s", ext) + dst, err := os.Create(filepath.Join(logoDir, savedName)) + if err != nil { + c.Redirect(http.StatusFound, "/admin/settings/site") + return + } + defer dst.Close() + if _, err := io.Copy(dst, file); err != nil { + c.Redirect(http.StatusFound, "/admin/settings/site") + return + } + s.Favicon = savedName + } + + // Remove favicon entirely if requested. + if c.PostForm("favicon_clear") == "1" { + if s.Favicon != "" && !s.FaviconIsURL() { + os.Remove(filepath.Join(storagePath, "logos", s.Favicon)) + } + s.Favicon = "" + } + // Logo upload (optional). A logo_url form field takes precedence over an // uploaded file, so admins can set either a local file or an external link. if logoURL := strings.TrimSpace(c.PostForm("logo_url")); logoURL != "" { diff --git a/i18n/i18n.go b/i18n/i18n.go index df8f416..b39d54e 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -170,6 +170,11 @@ var translations = map[Lang]map[string]string{ "settings_logo_url": "Logo URL (external link)", "settings_logo_upload": "Or upload a logo image", "settings_logo_clear": "Remove current logo", + "settings_favicon": "Favicon", + "settings_favicon_url": "Favicon URL (external link)", + "settings_favicon_current": "Current favicon", + "settings_favicon_hint": "Recommended: .ico, .png or .svg format, 32x32 or 16x16 pixels", + "settings_favicon_clear": "Remove current favicon", "settings_logo_text_zh": "Site Title (Chinese)", "settings_logo_text_en": "Site Title (English)", "settings_header_text_zh": "Header Banner Text (Chinese)", @@ -518,6 +523,11 @@ var translations = map[Lang]map[string]string{ "settings_logo_url": "Logo 链接(外链地址)", "settings_logo_upload": "或上传 Logo 图片", "settings_logo_clear": "移除当前 Logo", + "settings_favicon": "网站图标(Favicon)", + "settings_favicon_url": "Favicon 链接(外链地址)", + "settings_favicon_current": "当前 Favicon", + "settings_favicon_hint": "推荐:.ico、.png 或 .svg 格式,32x32 或 16x16 像素", + "settings_favicon_clear": "移除当前 Favicon", "settings_logo_text_zh": "站点标题(中文)", "settings_logo_text_en": "站点标题(英文)", "settings_header_text_zh": "顶部横幅文本(中文)", diff --git a/middleware/auth.go b/middleware/auth.go index bfae8ec..09558ea 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -123,6 +123,8 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc { c.Set("site_setting", site) c.Set("site_logo", site.Logo) c.Set("site_logo_is_url", site.LogoIsURL()) + c.Set("site_favicon", site.Favicon) + c.Set("site_favicon_is_url", site.FaviconIsURL()) c.Set("site_logo_text", site.LogoText(string(lang))) c.Set("site_header_text", site.HeaderText(string(lang))) c.Set("site_home_welcome", site.HomeWelcome(string(lang))) diff --git a/models/site_setting.go b/models/site_setting.go index 7debc34..11ce624 100644 --- a/models/site_setting.go +++ b/models/site_setting.go @@ -8,6 +8,7 @@ import "time" type SiteSetting struct { ID uint `gorm:"primarykey" json:"id"` Logo string `gorm:"size:512" json:"logo"` // local filename (served under /uploads/logos) OR a full URL + Favicon string `gorm:"size:512" json:"favicon"` // local filename (served under /uploads/logos) OR a full URL LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // top-left title (zh) LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // top-left title (en) HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh) @@ -36,6 +37,15 @@ func (s *SiteSetting) LogoIsURL() bool { return len(s.Logo) >= 4 && (s.Logo[:4] == "http") } +// FaviconIsURL reports whether the favicon value is an external URL rather than a +// local filename. +func (s *SiteSetting) FaviconIsURL() bool { + if s == nil || s.Favicon == "" { + return false + } + return len(s.Favicon) >= 4 && (s.Favicon[:4] == "http") +} + // LogoText returns the title for the given language code, falling back to the // other language when the requested one is empty. func (s *SiteSetting) LogoText(lang string) string { diff --git a/scripts/add_favicon_field.sql b/scripts/add_favicon_field.sql new file mode 100644 index 0000000..acdb9ff --- /dev/null +++ b/scripts/add_favicon_field.sql @@ -0,0 +1,9 @@ +-- Migration: Add favicon field to site_settings table +-- Date: 2026-06-22 +-- Description: Add support for custom favicon in site settings + +-- Add favicon column if it doesn't exist +ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS favicon VARCHAR(512) DEFAULT ''; + +-- Add comment for documentation +COMMENT ON COLUMN site_settings.favicon IS 'Local filename (served under /uploads/logos) OR a full URL for the site favicon'; diff --git a/templates/admin/settings_site.html b/templates/admin/settings_site.html index 8051d4e..fca3d4a 100644 --- a/templates/admin/settings_site.html +++ b/templates/admin/settings_site.html @@ -34,6 +34,29 @@ + +
+ + {{if .Site.Favicon}} +
+ {{index .Tr "settings_favicon_current"}}: + {{if .SiteFaviconIsURL}} + favicon + {{else}} + favicon + {{end}} +
+ {{end}} + + +

{{index .Tr "settings_favicon_hint"}}

+ +
+
diff --git a/templates/layouts/base.html b/templates/layouts/base.html index c3e6e45..286ac8b 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -5,6 +5,13 @@ {{.Title}} - {{index .Tr "site_title"}} + {{if .SiteFavicon}} + {{if .SiteFaviconIsURL}} + + {{else}} + + {{end}} + {{end}}