feat: add favicon settings to site configuration
- Add Favicon field to SiteSetting model with FaviconIsURL() helper - Implement favicon upload and management in site settings page - Support both external URL and local file upload for favicon - Add favicon display in page header with automatic URL detection - Add i18n translations for favicon settings (EN/ZH) - Update middleware and helpers to pass favicon data to templates - Include migration script for existing databases Users can now customize their site's favicon from /admin/settings/site by either providing an external URL or uploading a local image file (recommended formats: .ico, .png, .svg, 16x16 or 32x32 pixels). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
- 在 `<head>` 中添加 Favicon 的 `<link>` 标签
|
||||
- 根据设置自动使用外链或本地文件
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
如果数据库已存在,需要手动添加 `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()` 刷新内存缓存
|
||||
- 更改后立即生效,无需重启服务
|
||||
@@ -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,
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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": "顶部横幅文本(中文)",
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -34,6 +34,29 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Favicon -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_favicon"}}</label>
|
||||
{{if .Site.Favicon}}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-600">{{index .Tr "settings_favicon_current"}}:</span>
|
||||
{{if .SiteFaviconIsURL}}
|
||||
<img src="{{.Site.Favicon}}" alt="favicon" class="h-8 w-8">
|
||||
{{else}}
|
||||
<img src="/uploads/logos/{{.Site.Favicon}}" alt="favicon" class="h-8 w-8">
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<input type="text" name="favicon_url" placeholder="{{index .Tr "settings_favicon_url"}}"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteFaviconIsURL}}{{.Site.Favicon}}{{end}}">
|
||||
<input type="file" name="favicon" accept="image/x-icon,image/png,image/svg+xml"
|
||||
class="block w-full text-sm text-gray-500 mb-2">
|
||||
<p class="text-xs text-gray-500 mb-2">{{index .Tr "settings_favicon_hint"}}</p>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
|
||||
<input type="checkbox" name="favicon_clear" value="1"> {{index .Tr "settings_favicon_clear"}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Title texts -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
|
||||
{{if .SiteFavicon}}
|
||||
{{if .SiteFaviconIsURL}}
|
||||
<link rel="icon" href="{{.SiteFavicon}}">
|
||||
{{else}}
|
||||
<link rel="icon" href="/uploads/logos/{{.SiteFavicon}}">
|
||||
{{end}}
|
||||
{{end}}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
|
||||
|
||||
Reference in New Issue
Block a user