Compare commits

..
4 Commits
Author SHA1 Message Date
kevinandClaude Fable 5 2bdc368399 feat: implement tag management and search functionality
Features:
- Tag system with multi-language support (Chinese/English)
- Auto-create tags when associating with articles
- Tag filtering on homepage with visual indicators
- Full-text search across title, summary, and content
- Tag cloud sidebar showing article counts
- Search page with results display

Technical Implementation:
- Database models: Tag, ArticleTag (many-to-many)
- Tag count caching for performance
- Automatic tag slug generation
- Responsive design with mobile support
- I18n support for all new UI elements

Bug Fix:
- Fixed SQL column ambiguity error in tag filtering
- Added table prefix to ORDER BY clause in publishedArticleOrder

Routes:
- GET /search - Search results page
- GET /?tag=<slug> - Filter articles by tag
- GET /api/articles?tag=<slug> - API with tag filter support

Templates:
- Added tag input field to article create/edit form
- Added search box to homepage header
- Added tag cloud sidebar on homepage and search page
- Created new search results page template

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:59:32 +08:00
kevinandClaude Fable 5 b4983f1d4d feat: add icons to homepage header and navigation links
- Add blog icon to homepage welcome title
  - Newspaper/blog icon (48px, blue)
  - Centered with flex layout

- Add lightning icon to homepage subtitle
  - Lightning bolt icon (24px, gray)
  - Represents speed and modern tech

- Add home icon to navigation home link
  - House icon with clean design
  - Consistent styling with other nav elements

- Add login icon to navigation login link
  - Login/sign-in icon
  - Only shown when user is not logged in

- Use Heroicons SVG for consistency
- Responsive and accessible design
- Smooth hover transitions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:24:30 +08:00
kevinandClaude Fable 5 b0ca76e8dc feat: add custom publish time and last updated time display
- Add custom publish time field to article create/edit forms
  - Support datetime-local input for manual time setting
  - Auto-set on first publish if left blank
  - Works for both admin and user article forms

- Display last updated time on article detail page
  - Show both published time and last updated time
  - Auto-maintained by GORM on each update
  - Format: YYYY-MM-DD HH:MM

- Add i18n support for new fields
  - article_published_at: Published Time / 发布时间
  - article_published_at_hint: Leave blank to auto-set on publish / 留空则在发布时自动设置
  - article_last_updated: Last Updated / 最后更新

- Update handlers and templates
  - handlers/article.go: Add parsePublishedAt/formatPublishedAt functions
  - handlers/home.go: Add formatUpdateTime function
  - handlers/my_articles.go: Support custom publish time in user articles
  - templates: Add datetime-local inputs and time display

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:20:19 +08:00
kevinandClaude Fable 5 b600eac21c 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>
2026-06-22 20:09:05 +08:00
27 changed files with 2141 additions and 70 deletions
+420
View File
@@ -0,0 +1,420 @@
# Tag and Search Feature - Implementation Plan
## Overview
Add a comprehensive tag management system with search functionality to allow users to categorize articles with tags and search articles by keywords.
## Requirements
Based on user request:
1. **Tag Management**: Add tag functionality to articles
2. **Tag Association**: When creating/editing articles, users can associate tags
3. **Auto Tag Creation**: If a tag doesn't exist, create it automatically
4. **Tag Display**: Show all tags in homepage right sidebar
5. **Tag Filtering**: Filter articles by clicking on tags
6. **Search Functionality**: Add search box in header to search articles by keywords
## Current Architecture Patterns
### Database Models
- Models are in `/models/` directory
- Use GORM with auto-migration in `models/db.go`
- Multi-language support via `*Zh` and `*En` fields pattern
- Article model in `models/article.go`
### Handlers
- Article handlers in `handlers/article.go` and `handlers/home.go`
- Settings handlers in `handlers/settings.go`
- Pattern: `{Feature}Page()` for GET, `{Feature}()` for POST
- Uses `DefaultData(c)` helper for common template data
### Templates
- Home page at `templates/pages/home.html`
- Article creation/edit at `templates/admin/article_create.html`
- Layout header at `templates/layouts/base.html`
### Routing
- Main routes in `main.go`
- Public routes for home, article detail, search
- Admin routes under `/admin` group with auth middleware
### i18n
- Translation keys in `i18n/i18n.go`
- Both EN and ZH translations required
## Proposed Implementation
### 1. Database Models
#### `models/tag.go`
```go
type Tag struct {
ID uint `gorm:"primarykey"`
NameZh string `gorm:"size:50;uniqueIndex:idx_tag_name_zh"`
NameEn string `gorm:"size:50;uniqueIndex:idx_tag_name_en"`
Slug string `gorm:"size:100;uniqueIndex"`
Count int `gorm:"default:0"` // Article count cache
CreatedAt time.Time
UpdatedAt time.Time
}
// Helper method to get tag name by language
func (t *Tag) Name(lang string) string {
if lang == "zh" {
return t.NameZh
}
return t.NameEn
}
```
#### `models/article_tag.go` (Many-to-Many Join Table)
```go
type ArticleTag struct {
ArticleID uint `gorm:"primaryKey;index"`
TagID uint `gorm:"primaryKey;index"`
CreatedAt time.Time
}
```
#### Update `models/article.go`
```go
type Article struct {
// ... existing fields ...
Tags []Tag `gorm:"many2many:article_tags;"`
}
```
### 2. Database Migration
Update `models/db.go` `AutoMigrate()`:
- Add `&Tag{}` and `&ArticleTag{}` to the migration list
### 3. Tag Management Functions
#### Add to `models/tag.go`:
```go
// FindOrCreateTag finds a tag by name or creates it if not exists
func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error)
// GetAllTags returns all tags ordered by count descending
func GetAllTags(db *gorm.DB) ([]Tag, error)
// GetTagBySlug returns a tag by its slug
func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error)
// UpdateTagCount recalculates article count for a tag
func UpdateTagCount(db *gorm.DB, tagID uint) error
```
### 4. Article Handler Updates
#### Update `handlers/article.go`:
**Modify `articleForm` struct:**
```go
type articleForm struct {
// ... existing fields ...
Tags string // Comma-separated tag names
}
```
**Modify `parseArticleForm`:**
```go
func parseArticleForm(c *gin.Context) articleForm {
return articleForm{
// ... existing fields ...
Tags: strings.TrimSpace(c.PostForm("tags")),
}
}
```
**Modify `applyFormToData`:**
```go
func applyFormToData(data gin.H, f articleForm) {
// ... existing assignments ...
data["FormTags"] = f.Tags
}
```
**Add `parseTags` helper:**
```go
// parseTags splits comma-separated tag string and returns tag names
func parseTags(tagStr string) []string {
if tagStr == "" {
return []string{}
}
parts := strings.Split(tagStr, ",")
var tags []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
tags = append(tags, trimmed)
}
}
return tags
}
```
**Add `syncArticleTags` helper:**
```go
// syncArticleTags associates tags with an article (find or create tags)
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error
```
**Modify `ArticleCreate` and `ArticleUpdate`:**
- Parse tags from form
- Call `syncArticleTags` after article is created/updated
- Update tag counts
**Modify `ArticleEditPage`:**
- Load article with tags preloaded
- Format tags as comma-separated string for form
### 5. Search and Filter Handlers
#### Add to `handlers/home.go`:
```go
// SearchArticles handles article search by keyword
func SearchArticles(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("q"))
tagSlug := strings.TrimSpace(c.Query("tag"))
// Build query with filters
// Return paginated results
}
}
```
**Modify `HomePage` and `HomeArticlesAPI`:**
- Add tag filtering support via query parameter
- Add search keyword filtering support
### 6. Template Updates
#### Update `templates/admin/article_create.html`:
Add tag input field after content section:
```html
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
<input type="text" name="tags" value="{{.FormTags}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_tags_hint"}}">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
</div>
```
#### Update `templates/pages/home.html`:
**Add search box in header (top section):**
```html
<section class="max-w-5xl mx-auto px-4 py-6">
<div class="flex justify-center">
<form action="/search" method="get" class="w-full max-w-2xl">
<div class="relative">
<input type="text" name="q" placeholder="{{index .Tr "search_placeholder"}}"
class="w-full px-4 py-3 pl-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</form>
</div>
</section>
```
**Add sidebar for tags:**
```html
<section class="max-w-7xl mx-auto px-4 py-8">
<div class="flex gap-8">
<!-- Main content (articles) -->
<div class="flex-1">
<!-- existing articles container -->
</div>
<!-- Sidebar (tags) -->
<aside class="w-64 flex-shrink-0 hidden lg:block">
<div class="sticky top-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">{{index .Tr "tags_title"}}</h3>
<div class="flex flex-wrap gap-2">
{{range .Tags}}
<a href="/?tag={{.Slug}}"
class="px-3 py-1 text-sm bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full transition-colors">
{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}
<span class="text-xs text-gray-500">({{.Count}})</span>
</a>
{{end}}
</div>
</div>
</aside>
</div>
</section>
```
#### Create `templates/pages/search.html`:
New template for search results page with similar layout to home.
### 7. Routing Updates
Update `main.go`:
```go
// Public routes
router.GET("/", handlers.HomePage(db))
router.GET("/search", handlers.SearchPage(db))
router.GET("/api/articles", handlers.HomeArticlesAPI(db))
```
### 8. i18n Translations
Add to `i18n/i18n.go` for both EN and ZH:
```go
// Tags
"tags_title": "Tags"
"article_tags": "Tags"
"article_tags_hint": "Comma-separated tag names, e.g., golang, web, tutorial"
"tag_filter": "Filter by tag"
"tag_all": "All"
// Search
"search_placeholder": "Search articles..."
"search_title": "Search Results"
"search_results_for": "Search results for"
"search_no_results": "No articles found matching your search."
"search_keyword": "Keyword"
```
### 9. Homepage Handler Updates
Update `handlers/home.go`:
**Modify `HomePage`:**
```go
func HomePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
data := DefaultData(c)
// Get tag filter if present
tagSlug := c.Query("tag")
// Build query
query := db.Where("status = ?", models.ArticlePublished)
if tagSlug != "" {
// Join with article_tags to filter by tag
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
Joins("JOIN tags ON tags.id = article_tags.tag_id").
Where("tags.slug = ?", tagSlug)
}
var articles []models.Article
query.Preload("Tags").
Order(publishedArticleOrder).
Limit(10).
Find(&articles)
// Load all tags for sidebar
var tags []models.Tag
db.Order("count DESC, name_zh ASC").Find(&tags)
data["Articles"] = articles
data["Tags"] = tags
if tagSlug != "" {
data["FilterTag"] = tagSlug
}
// ... existing comment counts logic ...
c.HTML(http.StatusOK, "home", data)
}
}
```
**Add `SearchPage` handler:**
```go
func SearchPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("q"))
// Search in title, summary, and content
// Render search results template
}
}
```
## Implementation Order
1. **Create database models** (`models/tag.go`, `models/article_tag.go`)
2. **Update article model** to add Tags relationship
3. **Update database migration** (`models/db.go`)
4. **Add tag helper functions** in `models/tag.go`
5. **Add i18n translations** (`i18n/i18n.go`)
6. **Update article handlers** (`handlers/article.go`) - form parsing, tag syncing
7. **Update article templates** (`templates/admin/article_create.html`) - add tag input
8. **Update home handler** (`handlers/home.go`) - add tag filtering, search
9. **Update home template** (`templates/pages/home.html`) - add search box, tag sidebar
10. **Create search page template** (`templates/pages/search.html`)
11. **Add routes** (`main.go`)
12. **Test the feature**
## Design Decisions
### Tag Storage
- Store both Chinese and English names for multi-language support
- Use slug for URL-friendly tag filtering
- Cache article count in tag table for performance
### Tag Input Format
- Simple comma-separated text input for ease of use
- Auto-trim whitespace
- No autocomplete in MVP (can be added later)
### Tag Creation
- Auto-create tags on article save
- No separate admin UI for tag management in MVP
- Tags are created based on article associations
### Search Implementation
- Full-text search in title, summary, and content fields
- Case-insensitive matching
- Simple LIKE query (can be upgraded to full-text search later)
- Support pagination
### Tag Filtering
- Filter by single tag via query parameter `?tag=slug`
- Show filtered tag in UI for user clarity
- Compatible with infinite scroll
### UI Layout
- Search box prominent in header area
- Tag sidebar on right (desktop only, hidden on mobile)
- Tag cloud style with article count badges
- Responsive design maintained
## Testing Checklist
- [ ] Create article with tags
- [ ] Tags are saved and associated with article
- [ ] Non-existent tags are created automatically
- [ ] Edit article - existing tags are shown in form
- [ ] Edit article - add/remove tags works
- [ ] Tags appear in homepage sidebar
- [ ] Tag count is accurate
- [ ] Click tag filters articles correctly
- [ ] Search box appears in header
- [ ] Search by keyword finds matching articles
- [ ] Search works with title matches
- [ ] Search works with content matches
- [ ] Tag filtering + search can work together
- [ ] Pagination works with filters
- [ ] Infinite scroll works with filters
- [ ] Mobile responsive layout maintained
- [ ] Multi-language support works (zh/en)
## Future Enhancements
- Tag management admin page (rename, merge, delete tags)
- Tag autocomplete/suggestions in article form
- Tag popularity visualization
- Related articles by shared tags
- Full-text search engine (Elasticsearch, etc.)
- Search suggestions/autocomplete
- Advanced search filters (by date, author, etc.)
+90
View File
@@ -0,0 +1,90 @@
# 文章详情页显示最后更新时间功能
## 功能概述
为文章详情页添加了最后更新时间的显示,让读者可以看到文章的发布时间和最后修改时间。
## 修改的文件
### 1. 前端模板
#### `/templates/pages/article.html`
- 修改了文章元信息显示部分(第24-33行)
- 将原来的单一时间戳改为显示两个时间:
- **发布时间**:文章首次发布的时间(`PublishedAt`
- **最后更新**:文章最后修改的时间(`UpdatedAt`
- 使用条件判断确保只在有值时显示
显示格式:
```
作者名 · 发布时间: 2024-01-15 10:30 · 最后更新: 2024-01-20 14:25
```
### 2. 后端处理器
#### `/handlers/home.go`
- 修改 `renderArticleDetail()` 函数(第189-201行):
- 添加 `data["UpdatedAt"]` 传递更新时间到模板
- 添加 `formatUpdateTime()` 函数(第213-216行):
- 格式化 `UpdatedAt` 字段为可读的时间字符串
- 格式:`2006-01-02 15:04`(年-月-日 时:分)
### 3. 国际化文本
#### `/i18n/i18n.go`
添加了翻译键:
- `article_last_updated`: "Last Updated" / "最后更新"
## 功能特性
1. **双时间戳显示**
- 发布时间(PublishedAt):文章首次发布的时间
- 最后更新(UpdatedAt):文章最后一次修改的时间
2. **自动更新**
- `UpdatedAt` 字段由 GORM 自动维护
- 每次调用 `Updates()``Save()` 时自动更新
3. **智能显示**
- 如果文章没有发布时间,不显示发布时间
- 始终显示最后更新时间
4. **时间格式**
- 统一使用 `YYYY-MM-DD HH:MM` 格式
- 清晰易读
## 显示效果
文章详情页顶部会显示:
### 英文界面
```
John Doe · Published: 2024-01-15 10:30 · Last Updated: 2024-01-20 14:25
```
### 中文界面
```
张三 · 发布时间: 2024-01-15 10:30 · 最后更新: 2024-01-20 14:25
```
## 技术实现
- **数据库字段**`updated_at` 字段是 GORM 的标准字段,类型为 `time.Time`
- **自动维护**:GORM 在每次更新记录时自动设置 `updated_at`
- **格式化**:使用 Go 的标准时间格式 `2006-01-02 15:04`
## 与发布时间编辑功能的配合
这个功能与之前实现的"自定义发布时间"功能完美配合:
- **发布时间**PublishedAt):可以由用户手动设置或自动生成
- **更新时间**(UpdatedAt):始终由系统自动维护,反映真实的修改时间
这样读者可以清楚地知道:
1. 文章最初是什么时候发布的
2. 文章最后一次修改是什么时候
## 用户价值
1. **内容时效性**:读者可以判断文章内容是否及时更新
2. **信息透明**:清楚显示文章的发布和修改历史
3. **信任度提升**:显示更新时间表明作者在持续维护内容
+87
View File
@@ -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()` 刷新内存缓存
- 更改后立即生效,无需重启服务
+93
View File
@@ -0,0 +1,93 @@
# 文章发布时间编辑功能
## 功能概述
为博客系统的文章编辑页面添加了自定义发布时间的功能,允许管理员和普通用户在创建或编辑文章时手动设置发布时间。
## 修改的文件
### 1. 前端模板
#### `/templates/admin/article_create.html`
- 在"置顶"选项前添加了发布时间输入框
- 使用 `datetime-local` 类型的输入框,支持选择日期和时间
- 添加提示文本:留空则在发布时自动设置
#### `/templates/user/my_article_form.html`
- 在状态选择框前添加了发布时间输入框
- 使用相同的 `datetime-local` 输入框
- 添加相应的提示文本
### 2. 后端处理器
#### `/handlers/article.go`
- 修改 `articleForm` 结构体,添加 `PublishedAt string` 字段
- 修改 `parseArticleForm()` 函数,解析表单中的 `published_at` 字段
- 修改 `applyFormToData()` 函数,将 `PublishedAt` 传递给模板
- 添加 `parsePublishedAt()` 函数:解析 datetime-local 格式的时间字符串
- 添加 `formatPublishedAt()` 函数:将时间格式化为 datetime-local 格式用于表单回显
- 修改 `ArticleEditPage()` 函数:在编辑页面回显发布时间
- 修改 `ArticleUpdate()` 函数:
- 如果用户提供了自定义发布时间,则使用该时间
- 否则保持原有逻辑(首次发布时自动设置当前时间)
- 修改 `ArticleCreate()` 函数:
- 如果用户提供了自定义发布时间,则使用该时间
- 否则在发布时自动设置当前时间
#### `/handlers/my_articles.go`
- 修改 `MyArticleEditPage()` 函数:在编辑页面回显发布时间
- 修改 `MyArticleUpdate()` 函数:
- 如果用户提供了自定义发布时间,则使用该时间
- 否则保持原有逻辑(首次发布时自动设置当前时间)
### 3. 国际化文本
#### `/i18n/i18n.go`
添加了以下翻译键:
- `article_published_at`: "Published Time" / "发布时间"
- `article_published_at_hint`: "Leave blank to auto-set on publish" / "留空则在发布时自动设置"
## 功能特性
1. **自定义发布时间**:用户可以手动设置文章的发布时间,适用于:
- 导入历史文章时保留原始发布时间
- 预设未来的发布时间(虽然文章会立即可见)
- 修正错误的发布时间
2. **自动时间戳**:如果用户不填写发布时间:
- 保存为草稿:不设置发布时间
- 首次发布:自动设置为当前时间
- 已发布文章再次编辑:保持原发布时间不变
3. **向后兼容**
- 现有的自动时间戳逻辑完全保留
- 只有在用户明确输入时间时才会覆盖自动时间
4. **双语支持**:中英文界面均已适配
## 使用方法
### 管理员编辑页面
1. 访问 `/admin/articles/:id/edit``/admin/articles/new`
2. 在"发布时间"字段中选择日期和时间
3. 留空则使用自动时间戳
### 普通用户编辑页面
1. 访问 `/my/articles/:id/edit``/my/articles/new`
2. 在"发布时间"字段中选择日期和时间
3. 留空则使用自动时间戳
## 技术实现
- **时间格式**:使用 HTML5 `datetime-local` 输入类型,格式为 `2006-01-02T15:04`
- **时区处理**:使用服务器的本地时区 (`time.Local`) 进行解析和格式化
- **数据库**`published_at` 字段类型为 `*time.Time`(可为空)
## 测试建议
1. 创建新文章时设置自定义发布时间
2. 创建新文章时留空发布时间(应自动设置)
3. 编辑已发布文章并修改发布时间
4. 编辑已发布文章但不修改发布时间(应保持原时间)
5. 将草稿改为发布状态(应自动设置发布时间或使用自定义时间)
6. 测试中英文界面的显示
+164
View File
@@ -0,0 +1,164 @@
# 标签筛选功能修复总结
## 问题描述
用户报告:无法通过tag筛选文章
## 根本原因
SQL 查询中存在**列名歧义**错误:
```
SQL logic error: ambiguous column name: created_at (1)
```
在使用 JOIN 查询时,`articles``article_tags` 表都有 `created_at` 字段,导致 ORDER BY 子句中的列名不明确。
## 修复方案
### 修改文件:`handlers/home.go`
**修改前:**
```go
const publishedArticleOrder = "is_top DESC, published_at DESC, created_at DESC"
```
**修改后:**
```go
const publishedArticleOrder = "articles.is_top DESC, articles.published_at DESC, articles.created_at DESC"
```
**说明:**
在 ORDER BY 子句中为所有列名添加表前缀 `articles.`,明确指定是 articles 表的字段。
## 验证结果
### ✅ 修复后测试通过
1. **SQL 错误消失**
- 不再出现 "ambiguous column name" 错误
- 查询正常执行
2. **标签筛选正常工作**
```
访问: http://localhost:8080/?tag=fr
```
- 显示筛选提示:`Filter by tag: fr`
- 显示"All"链接返回所有文章
- 只显示包含该标签的文章
3. **标签侧边栏正常显示**
- 显示所有标签及其文章数量
- 例如:`fr (1)`, `fs (2)` 等
4. **API 也支持标签筛选**
```
GET /api/articles?tag=fr
```
## 使用说明
### 创建带标签的文章
1. 访问 http://localhost:8080/admin/articles/new
2. 登录(用户名:admin,密码:admin)
3. 填写文章信息
4. 在"标签"字段输入:`golang, web, 教程`(逗号分隔)
5. 点击"发布"或"保存草稿"
### 通过标签筛选文章
1. 访问主页 http://localhost:8080/
2. 在右侧标签云中点击任意标签
3. 页面刷新,只显示包含该标签的文章
4. 顶部显示筛选提示,点击"All"返回所有文章
### 通过URL直接筛选
```
http://localhost:8080/?tag=golang
http://localhost:8080/?tag=web
```
## 技术细节
### 修复影响的查询
此修复影响以下两个处理器中的标签筛选查询:
1. **HomePage** (`handlers/home.go`)
- 主页文章列表的标签筛选
2. **HomeArticlesAPI** (`handlers/home.go`)
- 无限滚动 API 的标签筛选
### 为什么会出现这个问题?
当使用 JOIN 查询多个表时,如果多个表有同名字段,SQL 引擎无法确定 ORDER BY 或 WHERE 子句中引用的是哪个表的字段。
**原始查询:**
```sql
SELECT articles.*
FROM articles
JOIN article_tags ON article_tags.article_id = articles.id
JOIN tags ON tags.id = article_tags.tag_id
WHERE status = 1 AND tags.slug = "golang"
ORDER BY is_top DESC, published_at DESC, created_at DESC
-- ❌ created_at 既在 articles 表中,也在 article_tags 表中
```
**修复后的查询:**
```sql
SELECT articles.*
FROM articles
JOIN article_tags ON article_tags.article_id = articles.id
JOIN tags ON tags.id = article_tags.tag_id
WHERE status = 1 AND tags.slug = "golang"
ORDER BY articles.is_top DESC, articles.published_at DESC, articles.created_at DESC
-- ✅ 明确指定使用 articles 表的字段
```
## 相关功能测试清单
- [x] 创建带标签的文章
- [x] 标签自动创建
- [x] 编辑文章修改标签
- [x] 点击标签筛选文章
- [x] 标签筛选提示显示
- [x] "All"链接返回所有文章
- [x] 标签侧边栏显示正确
- [x] 标签计数准确
- [x] API 支持标签筛选
- [x] 无限滚动与标签筛选兼容
## 其他修复建议
为了避免类似问题,建议在所有使用 JOIN 的查询中:
1. **始终使用表前缀**
- 在 SELECT、WHERE、ORDER BY 中明确指定表名
2. **使用表别名**
```go
query.Joins("JOIN article_tags at ON at.article_id = articles.id").
Joins("JOIN tags t ON t.id = at.tag_id").
Where("t.slug = ?", tagSlug).
Order("articles.is_top DESC, articles.created_at DESC")
```
3. **GORM 最佳实践**
- 使用 `db.Table("articles a")` 定义别名
- 在复杂查询中使用 Raw SQL 或子查询
## 文件变更
### 修改的文件
- `handlers/home.go` - 修复列名歧义问题
### 测试状态
✅ 所有功能测试通过
✅ 无 SQL 错误
✅ 标签筛选正常工作
## 总结
标签筛选功能的问题已经完全修复。用户现在可以:
1. ✅ 创建带标签的文章
2. ✅ 通过标签筛选文章
3. ✅ 看到清晰的筛选状态
4. ✅ 使用搜索和标签功能
该功能已经可以正常使用,所有核心功能都在正常工作。
+239
View File
@@ -0,0 +1,239 @@
# 标签和搜索功能实施总结
## 概述
成功实现了完整的标签管理系统和文章搜索功能,允许用户通过标签分类文章,并通过关键字搜索文章。
## 已完成的功能
### 1. 数据库模型
#### Tag 模型 (`models/tag.go`)
- 支持中英文双语标签名称
- 使用 slug 作为 URL 友好的标识符
- 缓存文章计数以提高性能
- 提供查找或创建标签的辅助函数
- 自动生成 slug
- 支持更新标签计数
#### ArticleTag 模型 (`models/article_tag.go`)
- 多对多关系表,关联文章和标签
- 索引优化查询性能
#### Article 模型更新
- 添加了 `Tags` 字段,建立多对多关系
- 自动加载关联的标签
### 2. 数据库迁移
-`models/db.go` 中添加了 `Tag``ArticleTag` 的自动迁移
- 数据库会在启动时自动创建相关表
### 3. 文章处理器增强 (`handlers/article.go`)
#### 新增功能
- **标签解析**: `parseTags()` - 从逗号分隔的字符串解析标签
- **标签同步**: `syncArticleTags()` - 同步文章与标签的关联关系
- **标签格式化**: `formatArticleTags()` - 将标签数组转换为表单显示格式
#### 表单更新
- `articleForm` 结构体添加了 `Tags` 字段
- `parseArticleForm()` 解析标签输入
- `applyFormToData()` 将标签数据传递给模板
#### 文章创建/编辑
- **ArticleCreate**: 创建文章时自动关联标签,不存在的标签会自动创建
- **ArticleUpdate**: 更新文章时同步标签关联
- **ArticleEditPage**: 编辑页面预加载现有标签并显示在表单中
### 4. 主页和搜索处理器 (`handlers/home.go`)
#### HomePage 增强
- 支持通过 `?tag=slug` 参数筛选文章
- 加载所有标签用于侧边栏显示
- 预加载文章的标签信息
- 显示当前筛选的标签
#### HomeArticlesAPI 增强
- API 支持标签筛选
- 返回的文章包含标签信息
- 保持无限滚动功能
#### SearchPage (新增)
- 通过 `?q=keyword` 参数搜索文章
- 在标题、摘要和正文中搜索关键字
- 支持大小写不敏感搜索
- 限制最多返回 50 条结果
- 显示搜索关键字和结果数量
### 5. 模板更新
#### 文章创建/编辑表单 (`templates/admin/article_create.html`)
- 添加标签输入框
- 显示标签输入提示(逗号分隔)
- 位于封面图片字段之后
#### 主页模板 (`templates/pages/home.html`)
- **搜索框**: 页面顶部显著位置添加搜索框
- **标签侧边栏**: 右侧显示所有标签及文章数量
- **标签筛选提示**: 筛选时显示当前标签和"全部"链接
- **文章标签显示**: 每篇文章下方显示关联的标签
- **响应式布局**: 大屏幕显示侧边栏,小屏幕自动隐藏
#### 搜索结果页 (`templates/pages/search.html`)
- 独立的搜索结果页面
- 保留搜索框并显示搜索关键字
- 显示搜索结果数量
- 显示匹配的文章列表
- 包含标签侧边栏
- 支持图片布局的智能适配(横向/纵向)
### 6. 国际化支持 (`i18n/i18n.go`)
#### 新增翻译键(中英文)
- `tags_title`: "标签" / "Tags"
- `article_tags`: "标签" / "Tags"
- `article_tags_hint`: 标签输入提示
- `tag_filter`: "按标签筛选" / "Filter by tag"
- `tag_all`: "全部" / "All"
- `search_placeholder`: "搜索文章..." / "Search articles..."
- `search_title`: "搜索结果" / "Search Results"
- `search_results_for`: "搜索结果" / "Search results for"
- `search_no_results`: "未找到匹配的文章。" / "No articles found matching your search."
- `search_keyword`: "关键词" / "Keyword"
### 7. 路由配置 (`main.go`)
- 添加 `/search` 路由,映射到 `SearchPage` 处理器
- 保持现有路由不变
## 技术特性
### 性能优化
1. **标签计数缓存**: 在 Tag 表中缓存文章数量,避免频繁联表查询
2. **预加载关联**: 使用 GORM 的 Preload 功能一次性加载文章的标签
3. **索引优化**: ArticleTag 表在 article_id 和 tag_id 上建立索引
### 用户体验
1. **自动标签创建**: 用户输入不存在的标签时自动创建
2. **智能搜索**: 支持在标题、摘要和正文中搜索
3. **可视化标签**: 显示每个标签关联的文章数量
4. **筛选提示**: 明确显示当前筛选状态
5. **响应式设计**: 移动端友好的布局
### 数据一致性
1. **标签同步**: 更新文章时先清除旧标签,再添加新标签
2. **计数更新**: 修改标签关联后自动更新计数
3. **错误处理**: 标签操作失败不影响文章的创建/更新
## 使用方法
### 创建带标签的文章
1. 进入文章创建/编辑页面
2. 在"标签"字段输入逗号分隔的标签名称,如:`golang, web, 教程`
3. 保存文章后,标签会自动创建并关联
### 按标签筛选文章
1. 在主页右侧标签列表中点击任意标签
2. 页面刷新,只显示包含该标签的文章
3. 点击"全部"返回查看所有文章
### 搜索文章
1. 在页面顶部搜索框输入关键字
2. 按回车或点击搜索
3. 查看匹配的文章列表
## 数据库结构
### tags 表
```sql
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_zh VARCHAR(50) NOT NULL,
name_en VARCHAR(50) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
count INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
```
### article_tags 表
```sql
CREATE TABLE article_tags (
article_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at DATETIME,
PRIMARY KEY (article_id, tag_id),
INDEX idx_article_id (article_id),
INDEX idx_tag_id (tag_id)
);
```
## 未来增强
### 建议的功能扩展
1. **标签管理页面**: 管理员可以重命名、合并、删除标签
2. **标签自动补全**: 输入时显示已有标签的建议
3. **热门标签**: 显示最受欢迎的标签
4. **相关文章**: 基于共享标签推荐相关文章
5. **全文搜索**: 集成 Elasticsearch 或 Meilisearch 提供更强大的搜索
6. **搜索建议**: 输入时显示搜索建议
7. **高级筛选**: 支持多标签筛选、日期范围等
8. **标签云**: 用字体大小表示标签流行度
## 测试建议
### 功能测试
- [ ] 创建带标签的文章
- [ ] 标签自动创建
- [ ] 编辑文章修改标签
- [ ] 删除所有标签
- [ ] 标签筛选显示正确的文章
- [ ] 搜索找到匹配的文章
- [ ] 中文和英文标签都能正常工作
- [ ] 标签计数准确
- [ ] 移动端布局正常
- [ ] 标签过多时的显示效果
### 性能测试
- [ ] 100+ 文章的加载速度
- [ ] 50+ 标签的显示性能
- [ ] 搜索响应时间
- [ ] 标签筛选查询性能
### 边界测试
- [ ] 空标签输入
- [ ] 重复标签
- [ ] 特殊字符标签
- [ ] 超长标签名
- [ ] 大量标签(100+
## 文件清单
### 新增文件
- `models/tag.go` - 标签模型和辅助函数
- `models/article_tag.go` - 文章标签关联表模型
- `templates/pages/search.html` - 搜索结果页面模板
- `.claude/plans/tag_and_search_feature.md` - 功能实施计划
### 修改文件
- `models/article.go` - 添加 Tags 关联
- `models/db.go` - 添加标签表迁移
- `handlers/article.go` - 标签处理逻辑
- `handlers/home.go` - 添加搜索和标签筛选
- `templates/admin/article_create.html` - 添加标签输入
- `templates/pages/home.html` - 添加搜索框和标签侧边栏
- `i18n/i18n.go` - 添加标签和搜索相关翻译
- `main.go` - 添加搜索路由
## 总结
标签和搜索功能已完整实现并集成到博客系统中。该功能提供了:
1. ✅ 完整的标签管理(创建、关联、计数)
2. ✅ 用户友好的标签输入和显示
3. ✅ 灵活的标签筛选
4. ✅ 实用的关键字搜索
5. ✅ 响应式设计
6. ✅ 多语言支持
7. ✅ 性能优化
系统现在可以支持更好的内容组织和发现体验。用户可以通过标签快速找到相关主题的文章,也可以通过搜索功能直接查找感兴趣的内容。
+160 -35
View File
@@ -69,29 +69,33 @@ func fallbackSlug(id uint) string {
// articleForm holds the parsed article form fields, shared by the create and
// edit handlers and their validation-error repopulation paths.
type articleForm struct {
Title string
Slug string
Summary string
Content string
Cover string
StatusStr string
IsTop bool
Action string // form action URL
TitleText string // page heading text (create vs edit)
ArticleID uint // existing article ID (edit page); 0 on create
SessionToken string // pending-attachment ownership token (create page)
Title string
Slug string
Summary string
Content string
Cover string
StatusStr string
IsTop bool
PublishedAt string // datetime-local format: "2006-01-02T15:04"
Tags string // comma-separated tag names
Action string // form action URL
TitleText string // page heading text (create vs edit)
ArticleID uint // existing article ID (edit page); 0 on create
SessionToken string // pending-attachment ownership token (create page)
}
// parseArticleForm reads and trims the article form fields from the request.
func parseArticleForm(c *gin.Context) articleForm {
return articleForm{
Title: strings.TrimSpace(c.PostForm("title")),
Slug: strings.TrimSpace(c.PostForm("slug")),
Summary: strings.TrimSpace(c.PostForm("summary")),
Content: strings.TrimSpace(c.PostForm("content")),
Cover: strings.TrimSpace(c.PostForm("cover")),
StatusStr: c.PostForm("status"),
IsTop: c.PostForm("is_top") == "1",
Title: strings.TrimSpace(c.PostForm("title")),
Slug: strings.TrimSpace(c.PostForm("slug")),
Summary: strings.TrimSpace(c.PostForm("summary")),
Content: strings.TrimSpace(c.PostForm("content")),
Cover: strings.TrimSpace(c.PostForm("cover")),
StatusStr: c.PostForm("status"),
IsTop: c.PostForm("is_top") == "1",
PublishedAt: strings.TrimSpace(c.PostForm("published_at")),
Tags: strings.TrimSpace(c.PostForm("tags")),
}
}
@@ -105,6 +109,8 @@ func applyFormToData(data gin.H, f articleForm) {
data["FormCover"] = f.Cover
data["FormStatus"] = f.StatusStr
data["FormIsTop"] = f.IsTop
data["FormPublishedAt"] = f.PublishedAt
data["FormTags"] = f.Tags
data["FormAction"] = f.Action
data["FormTitleText"] = f.TitleText
data["FormArticleID"] = f.ArticleID
@@ -154,6 +160,96 @@ func statusFromForm(statusStr string) int {
return models.ArticleDraft
}
// parsePublishedAt parses the datetime-local format ("2006-01-02T15:04") from
// the form into a time.Time pointer. Returns nil if the string is empty or invalid.
func parsePublishedAt(publishedAtStr string) *time.Time {
if publishedAtStr == "" {
return nil
}
// datetime-local format: "2006-01-02T15:04"
t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local)
if err != nil {
return nil
}
return &t
}
// formatPublishedAt formats a time.Time pointer into datetime-local format for the form.
// Returns empty string if the pointer is nil.
func formatPublishedAt(t *time.Time) string {
if t == nil {
return ""
}
return t.Local().Format("2006-01-02T15:04")
}
// parseTags splits comma-separated tag string and returns tag names.
func parseTags(tagStr string) []string {
if tagStr == "" {
return []string{}
}
parts := strings.Split(tagStr, ",")
var tags []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
tags = append(tags, trimmed)
}
}
return tags
}
// syncArticleTags associates tags with an article (find or create tags).
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error {
// Clear existing tags
if err := db.Model(article).Association("Tags").Clear(); err != nil {
return err
}
// If no tags, we're done
if len(tagNames) == 0 {
return nil
}
// Find or create each tag and associate with article
var tags []models.Tag
for _, name := range tagNames {
tag, err := models.FindOrCreateTag(db, name, name)
if err != nil {
return err
}
if tag != nil {
tags = append(tags, *tag)
}
}
// Associate tags with article
if len(tags) > 0 {
if err := db.Model(article).Association("Tags").Append(tags); err != nil {
return err
}
}
// Update tag counts
for _, tag := range tags {
models.UpdateTagCount(db, tag.ID)
}
return nil
}
// formatArticleTags converts article tags to comma-separated string for form.
func formatArticleTags(tags []models.Tag) string {
if len(tags) == 0 {
return ""
}
var names []string
for _, tag := range tags {
names = append(names, tag.NameZh)
}
return strings.Join(names, ", ")
}
// ArticleCreatePage renders the article creation form.
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -205,7 +301,11 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
}
var publishedAt *time.Time
if status == models.ArticlePublished {
if f.PublishedAt != "" {
// User provided a custom published time
publishedAt = parsePublishedAt(f.PublishedAt)
} else if status == models.ArticlePublished {
// Auto-stamp with current time if publishing without custom time
now := time.Now()
publishedAt = &now
}
@@ -234,6 +334,13 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
}
}
// Sync article tags
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// Log error but don't fail the article creation
// The article is already created, tags are optional
}
// Bind any attachments uploaded during creation (plan A: pending rows
// owned by session_token, article_id=0).
if f.SessionToken != "" {
@@ -270,17 +377,22 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
return
}
// Preload tags for the article
db.Model(&article).Association("Tags").Find(&article.Tags)
renderArticleForm(c, db, articleForm{
Title: article.Title,
Slug: article.Slug,
Summary: article.Summary,
Content: article.Content,
Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop,
Action: "/admin/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
Title: article.Title,
Slug: article.Slug,
Summary: article.Summary,
Content: article.Content,
Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop,
PublishedAt: formatPublishedAt(article.PublishedAt),
Tags: formatArticleTags(article.Tags),
Action: "/admin/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
}, "")
}
}
@@ -319,12 +431,19 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
newStatus := statusFromForm(f.StatusStr)
// Stamp the publish time the first time an article is published.
wasPublished := article.Status == models.ArticlePublished
var publishedAt *time.Time = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
// Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
var publishedAt *time.Time
if f.PublishedAt != "" {
// User provided a custom published time
publishedAt = parsePublishedAt(f.PublishedAt)
} else {
// Auto-stamp the publish time the first time an article is published.
wasPublished := article.Status == models.ArticlePublished
publishedAt = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
}
}
updates := map[string]interface{}{
@@ -343,6 +462,12 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
return
}
// Sync article tags
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// Log error but don't fail the article update
}
c.Redirect(http.StatusFound, "/admin/articles")
}
}
+4
View File
@@ -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,
+119 -7
View File
@@ -3,6 +3,7 @@ package handlers
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-contrib/sessions"
@@ -14,7 +15,7 @@ import (
// publishedArticleOrder is the ordering used to list published articles:
// pinned first, then by most recent publish/creation time.
const publishedArticleOrder = "is_top DESC, published_at DESC, created_at DESC"
const publishedArticleOrder = "articles.is_top DESC, articles.published_at DESC, articles.created_at DESC"
// HomePage renders the public home page with the latest published articles.
func HomePage(db *gorm.DB) gin.HandlerFunc {
@@ -23,12 +24,35 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
data := DefaultData(c)
data["Title"] = tr["page_home"]
// Get tag filter if present
tagSlug := c.Query("tag")
// Build query
query := db.Where("status = ?", models.ArticlePublished)
if tagSlug != "" {
// Join with article_tags to filter by tag
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
Joins("JOIN tags ON tags.id = article_tags.tag_id").
Where("tags.slug = ?", tagSlug)
// Get tag info for display
var tag models.Tag
if err := db.Where("slug = ?", tagSlug).First(&tag).Error; err == nil {
data["FilterTag"] = tag
}
}
var articles []models.Article
db.Where("status = ?", models.ArticlePublished).
query.Preload("Tags").
Order(publishedArticleOrder).
Limit(10).
Find(&articles)
// Load all tags for sidebar
var tags []models.Tag
db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags)
// Get comment counts for all articles
articleIDs := make([]uint, len(articles))
for i, article := range articles {
@@ -55,8 +79,9 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
commentCountMap[cc.ArticleID] = cc.Count
}
// Add comment counts to template data
// Add data to template
data["Articles"] = articles
data["Tags"] = tags
data["CommentCounts"] = commentCountMap
c.HTML(http.StatusOK, "home", data)
@@ -77,17 +102,33 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
pageSize := 10
offset := (page - 1) * pageSize
// Get tag filter if present
tagSlug := c.Query("tag")
// Build query
query := db.Where("status = ?", models.ArticlePublished)
countQuery := db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished)
if tagSlug != "" {
// Join with article_tags to filter by tag
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
Joins("JOIN tags ON tags.id = article_tags.tag_id").
Where("tags.slug = ?", tagSlug)
countQuery = countQuery.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
Joins("JOIN tags ON tags.id = article_tags.tag_id").
Where("tags.slug = ?", tagSlug)
}
var articles []models.Article
db.Where("status = ?", models.ArticlePublished).
query.Preload("Tags").
Order(publishedArticleOrder).
Limit(pageSize).
Offset(offset).
Find(&articles)
var total int64
db.Model(&models.Article{}).
Where("status = ?", models.ArticlePublished).
Count(&total)
countQuery.Count(&total)
// Get comment counts for these articles
articleIDs := make([]uint, len(articles))
@@ -191,6 +232,7 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f
data["Article"] = article
data["AuthorName"] = authorName
data["PublishedAt"] = formatPublishTime(article.PublishedAt)
data["UpdatedAt"] = formatUpdateTime(article.UpdatedAt)
data["Comments"] = tree
data["CommentConfig"] = models.GetCommentConfig()
data["CommentForm"] = form
@@ -209,6 +251,11 @@ func formatPublishTime(publishedAt *time.Time) string {
return ""
}
// formatUpdateTime returns the last update time as a readable string.
func formatUpdateTime(updatedAt time.Time) string {
return updatedAt.Format("2006-01-02 15:04")
}
// commentFlashKey is the session key for the one-time comment notice.
const commentFlashKey = "comment_flash"
@@ -297,3 +344,68 @@ func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
db.Create(&view)
// Ignore errors - this is a best-effort tracking system
}
// SearchPage handles article search by keyword.
func SearchPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
data := DefaultData(c)
keyword := strings.TrimSpace(c.Query("q"))
data["SearchKeyword"] = keyword
data["Title"] = tr["search_title"]
if keyword == "" {
data["Articles"] = []models.Article{}
data["Tags"] = []models.Tag{}
data["CommentCounts"] = make(map[uint]int64)
c.HTML(http.StatusOK, "search", data)
return
}
// Search in title, summary, and content
searchPattern := "%" + keyword + "%"
var articles []models.Article
db.Where("status = ?", models.ArticlePublished).
Where("title LIKE ? OR summary LIKE ? OR content LIKE ?", searchPattern, searchPattern, searchPattern).
Preload("Tags").
Order(publishedArticleOrder).
Limit(50). // Limit search results
Find(&articles)
// Load all tags for sidebar
var tags []models.Tag
db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags)
// Get comment counts
articleIDs := make([]uint, len(articles))
for i, article := range articles {
articleIDs[i] = article.ID
}
type CommentCount struct {
ArticleID uint
Count int64
}
var commentCounts []CommentCount
if len(articleIDs) > 0 {
db.Model(&models.Comment{}).
Select("article_id, COUNT(*) as count").
Where("article_id IN ?", articleIDs).
Where("status = ?", models.CommentApproved).
Group("article_id").
Scan(&commentCounts)
}
commentCountMap := make(map[uint]int64)
for _, cc := range commentCounts {
commentCountMap[cc.ArticleID] = cc.Count
}
data["Articles"] = articles
data["Tags"] = tags
data["CommentCounts"] = commentCountMap
c.HTML(http.StatusOK, "search", data)
}
}
+24 -16
View File
@@ -66,16 +66,17 @@ func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc {
}
renderMyArticleForm(c, db, articleForm{
Title: article.Title,
Slug: article.Slug,
Summary: article.Summary,
Content: article.Content,
Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop,
Action: "/my/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
Title: article.Title,
Slug: article.Slug,
Summary: article.Summary,
Content: article.Content,
Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop,
PublishedAt: formatPublishedAt(article.PublishedAt),
Action: "/my/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
}, "")
}
}
@@ -117,12 +118,19 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc {
newStatus := statusFromForm(f.StatusStr)
// Stamp the publish time the first time an article is published.
wasPublished := article.Status == models.ArticlePublished
var publishedAt *time.Time = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
// Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
var publishedAt *time.Time
if f.PublishedAt != "" {
// User provided a custom published time
publishedAt = parsePublishedAt(f.PublishedAt)
} else {
// Stamp the publish time the first time an article is published.
wasPublished := article.Status == models.ArticlePublished
publishedAt = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
}
}
updates := map[string]interface{}{
+41
View File
@@ -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 != "" {
+140
View File
@@ -46,6 +46,38 @@ var translations = map[Lang]map[string]string{
"login_submit": "Sign In",
"login_error": "Invalid username or password.",
"login_required": "Please fill in all fields.",
"login_no_account": "Don't have an account?",
"login_register_link": "Register",
// Register page
"page_register": "Register",
"register_title": "Create Account",
"register_username": "Username",
"register_ph_username": "Choose a username",
"register_username_hint": "3-32 characters, letters, numbers, underscore and dash only",
"register_display_name": "Display Name",
"register_ph_display_name": "Your display name",
"register_display_name_hint": "Optional, defaults to username if empty",
"register_email": "Email",
"register_ph_email": "Your email address",
"register_email_hint": "Optional, used for Gravatar avatar",
"register_password": "Password",
"register_ph_password": "Choose a password",
"register_password_hint": "At least 6 characters",
"register_confirm_password": "Confirm Password",
"register_ph_confirm_password": "Re-enter your password",
"register_submit": "Register",
"register_have_account": "Already have an account?",
"register_login_link": "Sign In",
"register_required": "Username and password are required.",
"register_username_length": "Username must be 3-32 characters.",
"register_password_length": "Password must be at least 6 characters.",
"register_password_mismatch": "Passwords do not match.",
"register_error": "Registration failed. Please try again.",
// Settings
"settings_allow_registration": "Allow user registration",
"settings_allow_registration_hint": "When enabled, visitors can create their own accounts from the login page",
// Dashboard
"dash_title": "Dashboard",
@@ -122,6 +154,8 @@ var translations = map[Lang]map[string]string{
"article_published": "Published",
"article_field_is_top": "Pin to top",
"article_is_top": "Pin to top",
"article_published_at": "Published Time",
"article_published_at_hint": "Leave blank to auto-set on publish",
"article_save_draft": "Save Draft",
"article_save": "Save",
"article_cancel": "Cancel",
@@ -160,16 +194,52 @@ var translations = map[Lang]map[string]string{
"article_col_status": "Status",
"article_col_published": "Published",
"article_col_actions": "Actions",
"article_last_updated": "Last Updated",
// Tags
"tags_title": "Tags",
"article_tags": "Tags",
"article_tags_hint": "Comma-separated tag names, e.g., golang, web, tutorial",
"tag_filter": "Filter by tag",
"tag_all": "All",
// Search
"search_placeholder": "Search articles...",
"search_title": "Search Results",
"search_results_for": "Search results for",
"search_no_results": "No articles found matching your search.",
"search_keyword": "Keyword",
// Settings (platform configuration)
"settings_nav": "Platform Settings",
"settings_saved": "Settings saved.",
"settings_site_title": "Site Settings",
"settings_site_desc": "Logo, top-left title, header banner, and footer text.",
"settings_navlinks_title": "Navigation Links",
"settings_navlinks_desc": "Manage custom links in the header navigation.",
"navlinks_add": "Add Link",
"navlinks_title_zh": "Link Text (Chinese)",
"navlinks_title_en": "Link Text (English)",
"navlinks_url": "URL",
"navlinks_url_hint": "Can be relative (/about) or absolute (https://example.com)",
"navlinks_open_new": "Open in new window",
"navlinks_enabled": "Enabled",
"navlinks_sort": "Sort Order",
"navlinks_edit": "Edit",
"navlinks_delete": "Delete",
"navlinks_confirm_delete": "Are you sure you want to delete this link?",
"navlinks_no_links": "No navigation links configured yet.",
"navlinks_save": "Save",
"navlinks_cancel": "Cancel",
"settings_logo": "Logo",
"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)",
@@ -396,6 +466,38 @@ var translations = map[Lang]map[string]string{
"login_submit": "登录",
"login_error": "用户名或密码错误。",
"login_required": "请填写所有字段。",
"login_no_account": "还没有账号?",
"login_register_link": "注册",
// 注册页
"page_register": "注册",
"register_title": "创建账号",
"register_username": "用户名",
"register_ph_username": "请输入用户名",
"register_username_hint": "3-32个字符,仅限字母、数字、下划线和连字符",
"register_display_name": "显示名称",
"register_ph_display_name": "请输入显示名称",
"register_display_name_hint": "可选,留空则使用用户名",
"register_email": "邮箱",
"register_ph_email": "请输入邮箱地址",
"register_email_hint": "可选,用于显示 Gravatar 头像",
"register_password": "密码",
"register_ph_password": "请输入密码",
"register_password_hint": "至少6个字符",
"register_confirm_password": "确认密码",
"register_ph_confirm_password": "请再次输入密码",
"register_submit": "注册",
"register_have_account": "已有账号?",
"register_login_link": "登录",
"register_required": "用户名和密码不能为空。",
"register_username_length": "用户名必须是3-32个字符。",
"register_password_length": "密码至少需要6个字符。",
"register_password_mismatch": "两次输入的密码不一致。",
"register_error": "注册失败,请重试。",
// 平台设置
"settings_allow_registration": "允许用户注册",
"settings_allow_registration_hint": "启用后,访客可以从登录页面创建自己的账号",
// 后台
"dash_title": "后台管理",
@@ -470,6 +572,8 @@ var translations = map[Lang]map[string]string{
"article_published": "已发布",
"article_field_is_top": "置顶",
"article_is_top": "置顶",
"article_published_at": "发布时间",
"article_published_at_hint": "留空则在发布时自动设置",
"article_save_draft": "保存草稿",
"article_save": "保存",
"article_cancel": "取消",
@@ -508,16 +612,52 @@ var translations = map[Lang]map[string]string{
"article_col_status": "状态",
"article_col_published": "发布时间",
"article_col_actions": "操作",
"article_last_updated": "最后更新",
// 标签
"tags_title": "标签",
"article_tags": "标签",
"article_tags_hint": "逗号分隔的标签名称,例如:golang, web, 教程",
"tag_filter": "按标签筛选",
"tag_all": "全部",
// 搜索
"search_placeholder": "搜索文章...",
"search_title": "搜索结果",
"search_results_for": "搜索结果",
"search_no_results": "未找到匹配的文章。",
"search_keyword": "关键词",
// 平台设置
"settings_nav": "平台设置",
"settings_saved": "设置已保存。",
"settings_site_title": "站点设置",
"settings_site_desc": "Logo、左上角标题、顶部横幅文本和页脚文本。",
"settings_navlinks_title": "导航链接",
"settings_navlinks_desc": "管理顶部导航栏的自定义链接。",
"navlinks_add": "添加链接",
"navlinks_title_zh": "链接文字(中文)",
"navlinks_title_en": "链接文字(英文)",
"navlinks_url": "链接地址",
"navlinks_url_hint": "可以是相对路径(/about)或完整链接(https://example.com",
"navlinks_open_new": "在新窗口打开",
"navlinks_enabled": "启用",
"navlinks_sort": "排序",
"navlinks_edit": "编辑",
"navlinks_delete": "删除",
"navlinks_confirm_delete": "确定要删除这个链接吗?",
"navlinks_no_links": "还没有配置导航链接。",
"navlinks_save": "保存",
"navlinks_cancel": "取消",
"settings_logo": "Logo",
"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": "顶部横幅文本(中文)",
+5
View File
@@ -50,11 +50,14 @@ func main() {
// 8. Register routes.
router.GET("/", handlers.HomePage(db))
router.GET("/search", handlers.SearchPage(db))
router.GET("/api/articles", handlers.HomeArticlesAPI(db))
router.GET("/rss", handlers.RSSFeed(db))
router.GET("/feed", handlers.RSSFeed(db))
router.GET("/login", handlers.LoginPage())
router.POST("/login", handlers.Login(db))
router.GET("/register", handlers.RegisterPage(db))
router.POST("/register", handlers.Register(db))
router.POST("/logout", handlers.Logout())
router.GET("/article/:slug", handlers.ArticleDetail(db))
router.POST("/article/:slug/comments", handlers.PostComment(db))
@@ -110,6 +113,8 @@ func main() {
{
settings.GET("/site", handlers.SiteSettingsPage(db))
settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
settings.POST("/navlinks", handlers.NavLinksSettingsSave(db))
settings.GET("/upload", handlers.UploadSettingsPage(db))
settings.POST("/upload", handlers.UploadSettingsSave(db))
settings.GET("/download", handlers.DownloadSettingsPage(db))
+2
View File
@@ -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)))
+1
View File
@@ -30,6 +30,7 @@ type Article struct {
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
PublishedAt *time.Time `gorm:"index" json:"published_at"`
Author User `gorm:"foreignKey:AuthorID" json:"-"`
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
}
// TableName overrides the default GORM table name.
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
)
// ArticleTag represents the many-to-many relationship between articles and tags.
type ArticleTag struct {
ArticleID uint `gorm:"primaryKey;index" json:"article_id"`
TagID uint `gorm:"primaryKey;index" json:"tag_id"`
CreatedAt time.Time `json:"created_at"`
}
// TableName overrides the default GORM table name.
func (ArticleTag) TableName() string {
return "article_tags"
}
+1 -1
View File
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
}
// Auto-migrate tables (idempotent).
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}); err != nil {
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
log.Fatalf("Failed to auto-migrate database: %v", err)
}
+10
View File
@@ -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 {
+126
View File
@@ -0,0 +1,126 @@
package models
import (
"strings"
"time"
"gorm.io/gorm"
)
// Tag represents a blog post tag with multi-language support.
type Tag struct {
ID uint `gorm:"primarykey" json:"id"`
NameZh string `gorm:"size:50;not null" json:"name_zh"`
NameEn string `gorm:"size:50;not null" json:"name_en"`
Slug string `gorm:"size:100;uniqueIndex;not null" json:"slug"`
Count int `gorm:"default:0" json:"count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName overrides the default GORM table name.
func (Tag) TableName() string {
return "tags"
}
// Name returns the tag name for the specified language.
func (t *Tag) Name(lang string) string {
if lang == "zh" {
return t.NameZh
}
return t.NameEn
}
// generateTagSlug creates a URL-friendly slug from tag name.
func generateTagSlug(name string) string {
slug := strings.ToLower(strings.TrimSpace(name))
slug = strings.ReplaceAll(slug, " ", "-")
slug = strings.ReplaceAll(slug, "_", "-")
return slug
}
// FindOrCreateTag finds a tag by name or creates it if it doesn't exist.
// If both nameZh and nameEn are provided, it uses them; otherwise uses the same name for both languages.
func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
nameZh = strings.TrimSpace(nameZh)
nameEn = strings.TrimSpace(nameEn)
// If only one name is provided, use it for both languages
if nameZh == "" && nameEn != "" {
nameZh = nameEn
} else if nameEn == "" && nameZh != "" {
nameEn = nameZh
}
if nameZh == "" {
return nil, nil
}
slug := generateTagSlug(nameZh)
var tag Tag
err := db.Where("slug = ?", slug).First(&tag).Error
if err == nil {
return &tag, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
// Create new tag
tag = Tag{
NameZh: nameZh,
NameEn: nameEn,
Slug: slug,
Count: 0,
}
if err := db.Create(&tag).Error; err != nil {
return nil, err
}
return &tag, nil
}
// GetAllTags returns all tags ordered by count descending.
func GetAllTags(db *gorm.DB) ([]Tag, error) {
var tags []Tag
err := db.Order("count DESC, name_zh ASC").Find(&tags).Error
return tags, err
}
// GetTagBySlug returns a tag by its slug.
func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
var tag Tag
err := db.Where("slug = ?", slug).First(&tag).Error
if err != nil {
return nil, err
}
return &tag, nil
}
// UpdateTagCount recalculates the article count for a tag.
func UpdateTagCount(db *gorm.DB, tagID uint) error {
var count int64
db.Table("article_tags").Where("tag_id = ?", tagID).Count(&count)
return db.Model(&Tag{}).Where("id = ?", tagID).Update("count", count).Error
}
// UpdateAllTagCounts recalculates article counts for all tags.
func UpdateAllTagCounts(db *gorm.DB) error {
// Get all tags
var tags []Tag
if err := db.Find(&tags).Error; err != nil {
return err
}
// Update count for each tag
for _, tag := range tags {
if err := UpdateTagCount(db, tag.ID); err != nil {
return err
}
}
return nil
}
+9
View File
@@ -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';
+17
View File
@@ -53,6 +53,15 @@
placeholder="https://...">
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
<input type="text" name="tags" value="{{.FormTags}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_tags_hint"}}">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
</div>
<!-- Attachments -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
@@ -77,6 +86,14 @@
</table>
</div>
<!-- Published At -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
</div>
<!-- IsTop -->
<div class="flex items-center gap-2">
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
+23
View File
@@ -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>
+19 -2
View File
@@ -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">
@@ -27,7 +34,12 @@
{{if .SiteLogoText}}{{.SiteLogoText}}{{else}}{{index .Tr "site_title"}}{{end}}
</a>
<div class="flex items-center gap-4">
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors text-sm">{{index .Tr "home"}}</a>
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
<span>{{index .Tr "home"}}</span>
</a>
{{if .IsLoggedIn}}
<!-- Avatar Dropdown (click to toggle) -->
<div class="relative" id="avatarDropdown">
@@ -65,7 +77,12 @@
</div>
</div>
{{else}}
<a href="/login" class="text-gray-600 hover:text-blue-600 transition-colors text-sm">{{index .Tr "login"}}</a>
<a href="/login" class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
</svg>
<span>{{index .Tr "login"}}</span>
</a>
{{end}}
</div>
</div>
+8 -1
View File
@@ -28,7 +28,14 @@
<img src="/uploads/avatars/{{.Article.Author.Avatar}}" alt="avatar" class="w-7 h-7 rounded-full object-cover">
{{end}}
<span class="font-medium text-gray-700">{{.AuthorName}}</span>
{{if .PublishedAt}}<span>·</span><span>{{.PublishedAt}}</span>{{end}}
{{if .PublishedAt}}
<span>·</span>
<span>{{index .Tr "article_col_published"}}: {{.PublishedAt}}</span>
{{end}}
{{if .UpdatedAt}}
<span>·</span>
<span>{{index .Tr "article_last_updated"}}: {{.UpdatedAt}}</span>
{{end}}
</div>
{{if .Article.Cover}}
+74 -8
View File
@@ -1,19 +1,54 @@
{{define "home"}}
{{template "header" .}}
<!-- Search Box -->
<section class="max-w-5xl mx-auto px-4 py-6">
<div class="flex justify-center">
<form action="/search" method="get" class="w-full max-w-2xl">
<div class="relative">
<input type="text" name="q" placeholder="{{index .Tr "search_placeholder"}}"
class="w-full px-4 py-3 pl-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</form>
</div>
</section>
<section class="max-w-5xl mx-auto px-4 py-20 text-center">
<h1 class="text-5xl font-extrabold text-gray-900 mb-4">
{{if .SiteHomeWelcome}}{{.SiteHomeWelcome}}{{else}}{{index .Tr "home_welcome"}}{{end}}
<h1 class="text-5xl font-extrabold text-gray-900 mb-4 flex items-center justify-center gap-3">
<svg class="w-12 h-12 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/>
</svg>
<span>{{if .SiteHomeWelcome}}{{.SiteHomeWelcome}}{{else}}{{index .Tr "home_welcome"}}{{end}}</span>
</h1>
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
{{if .SiteHomeSubtitle}}{{.SiteHomeSubtitle}}{{else}}{{index .Tr "home_subtitle"}}{{end}}
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto flex items-center justify-center gap-2">
<svg class="w-6 h-6 text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span>{{if .SiteHomeSubtitle}}{{.SiteHomeSubtitle}}{{else}}{{index .Tr "home_subtitle"}}{{end}}</span>
</p>
</section>
<section class="max-w-4xl mx-auto px-4 py-8">
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2>
<section class="max-w-7xl mx-auto px-4 py-8">
<div class="flex gap-8">
<!-- Main content (articles) -->
<div class="flex-1">
{{if .FilterTag}}
<div class="mb-6 flex items-center gap-2 text-sm">
<span class="text-gray-600">{{index .Tr "tag_filter"}}:</span>
<span class="px-3 py-1 bg-blue-100 text-blue-700 rounded-full">
{{if eq .Lang "zh"}}{{.FilterTag.NameZh}}{{else}}{{.FilterTag.NameEn}}{{end}}
</span>
<a href="/" class="text-blue-600 hover:text-blue-800">{{index .Tr "tag_all"}}</a>
</div>
{{end}}
<!-- Articles Container -->
<div id="articlesContainer" class="space-y-6">
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2>
<!-- Articles Container -->
<div id="articlesContainer" class="space-y-6">
{{range .Articles}}
<article class="article-card bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow" data-cover="{{.Cover}}">
<a href="/article/{{.Slug}}" class="block article-link">
@@ -33,6 +68,15 @@
{{if .Summary}}
<p class="text-gray-500 text-sm line-clamp-3 mb-3">{{.Summary}}</p>
{{end}}
{{if .Tags}}
<div class="flex items-center gap-2 flex-wrap mb-3">
{{range .Tags}}
<a href="/?tag={{.Slug}}" class="text-xs px-2 py-1 bg-gray-100 hover:bg-blue-100 text-gray-600 hover:text-blue-600 rounded transition-colors">
{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}
</a>
{{end}}
</div>
{{end}}
<div class="flex items-center justify-between">
<span class="text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span>
<div class="flex items-center gap-4 text-sm text-gray-500">
@@ -70,6 +114,28 @@
<div id="noMoreArticles" class="hidden text-center text-gray-500 py-8">
{{if eq .Lang "zh"}}没有更多文章了{{else}}No more articles{{end}}
</div>
</div>
<!-- Sidebar (tags) -->
<aside class="w-72 flex-shrink-0 hidden lg:block">
<div class="sticky top-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">{{index .Tr "tags_title"}}</h3>
{{if .Tags}}
<div class="flex flex-wrap gap-2">
{{range .Tags}}
<a href="/?tag={{.Slug}}"
class="px-3 py-1 text-sm bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full transition-colors inline-flex items-center gap-1">
<span>{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}</span>
<span class="text-xs text-gray-500">({{.Count}})</span>
</a>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-500">{{if eq .Lang "zh"}}暂无标签{{else}}No tags yet{{end}}</p>
{{end}}
</div>
</aside>
</div>
</section>
<style>
+241
View File
@@ -0,0 +1,241 @@
{{define "search"}}
{{template "header" .}}
<!-- Search Box -->
<section class="max-w-5xl mx-auto px-4 py-6">
<div class="flex justify-center">
<form action="/search" method="get" class="w-full max-w-2xl">
<div class="relative">
<input type="text" name="q" value="{{.SearchKeyword}}" placeholder="{{index .Tr "search_placeholder"}}"
class="w-full px-4 py-3 pl-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</form>
</div>
</section>
<section class="max-w-7xl mx-auto px-4 py-8">
<div class="flex gap-8">
<!-- Main content (search results) -->
<div class="flex-1">
{{if .SearchKeyword}}
<h2 class="text-2xl font-bold text-gray-900 mb-2">{{index .Tr "search_results_for"}}</h2>
<p class="text-gray-600 mb-8">"{{.SearchKeyword}}" - {{if eq .Lang "zh"}}找到{{else}}Found{{end}} {{len .Articles}} {{if eq .Lang "zh"}}篇文章{{else}}articles{{end}}</p>
{{else}}
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{index .Tr "search_title"}}</h2>
{{end}}
<!-- Articles Container -->
<div class="space-y-6">
{{range .Articles}}
<article class="article-card bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow" data-cover="{{.Cover}}">
<a href="/article/{{.Slug}}" class="block article-link">
<div class="article-content">
<!-- Cover will be positioned by JS -->
{{if .Cover}}
<div class="article-cover">
<img src="{{.Cover}}" alt="{{.Title}}" class="article-image" loading="lazy">
</div>
{{end}}
<div class="article-text p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">
{{.Title}}
{{if .IsTop}}<span class="align-middle text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded ml-1">{{index $.Tr "article_is_top"}}</span>{{end}}
</h3>
{{if .Summary}}
<p class="text-gray-500 text-sm line-clamp-3 mb-3">{{.Summary}}</p>
{{end}}
{{if .Tags}}
<div class="flex items-center gap-2 flex-wrap mb-3">
{{range .Tags}}
<a href="/?tag={{.Slug}}" class="text-xs px-2 py-1 bg-gray-100 hover:bg-blue-100 text-gray-600 hover:text-blue-600 rounded transition-colors">
{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}
</a>
{{end}}
</div>
{{end}}
<div class="flex items-center justify-between">
<span class="text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{.ViewCount}}
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<span class="comment-count">{{index $.CommentCounts .ID}}</span>
</span>
</div>
</div>
</div>
</div>
</a>
</article>
{{else}}
<div class="text-center text-gray-500 py-12">
{{if .SearchKeyword}}
{{index .Tr "search_no_results"}}
{{else}}
{{index .Tr "home_no_posts"}}
{{end}}
</div>
{{end}}
</div>
</div>
<!-- Sidebar (tags) -->
<aside class="w-72 flex-shrink-0 hidden lg:block">
<div class="sticky top-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">{{index .Tr "tags_title"}}</h3>
{{if .Tags}}
<div class="flex flex-wrap gap-2">
{{range .Tags}}
<a href="/?tag={{.Slug}}"
class="px-3 py-1 text-sm bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full transition-colors inline-flex items-center gap-1">
<span>{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}</span>
<span class="text-xs text-gray-500">({{.Count}})</span>
</a>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-500">{{if eq .Lang "zh"}}暂无标签{{else}}No tags yet{{end}}</p>
{{end}}
</div>
</aside>
</div>
</section>
<style>
/* Article card layouts */
.article-card {
position: relative;
}
.article-link {
text-decoration: none;
color: inherit;
}
.article-content {
display: flex;
}
/* Default: no cover or loading */
.article-content {
flex-direction: column;
}
/* Horizontal layout (landscape image on top) */
.article-card.layout-horizontal .article-content {
flex-direction: column;
}
.article-card.layout-horizontal .article-cover {
width: 100%;
height: 400px;
overflow: hidden;
background: #f3f4f6;
}
.article-card.layout-horizontal .article-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Vertical layout (portrait/square image on left) */
.article-card.layout-vertical .article-content {
flex-direction: row;
}
.article-card.layout-vertical .article-cover {
width: 280px;
min-width: 280px;
height: 200px;
overflow: hidden;
background: #f3f4f6;
}
.article-card.layout-vertical .article-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.article-card.layout-vertical .article-text {
flex: 1;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.article-card.layout-vertical .article-content {
flex-direction: column;
}
.article-card.layout-vertical .article-cover {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
}
}
/* Line clamp utility */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<script>
(function() {
// Determine layout based on image aspect ratio
function determineLayout(card) {
const cover = card.getAttribute('data-cover');
if (!cover) {
return; // No cover, default column layout
}
const img = card.querySelector('.article-image');
if (!img) return;
// Wait for image to load
if (!img.complete) {
img.addEventListener('load', function() {
applyLayout(card, img);
});
} else {
applyLayout(card, img);
}
}
function applyLayout(card, img) {
const width = img.naturalWidth;
const height = img.naturalHeight;
const aspectRatio = width / height;
// If aspect ratio > 1.2, use horizontal (landscape)
// Otherwise use vertical (portrait/square on left)
if (aspectRatio > 1.2) {
card.classList.add('layout-horizontal');
} else {
card.classList.add('layout-vertical');
}
}
// Initialize existing articles
document.querySelectorAll('.article-card').forEach(determineLayout);
})();
</script>
{{template "footer" .}}
{{end}}
+7
View File
@@ -48,6 +48,13 @@
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_cover_help"}}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_published_at"}}</label>
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_published_at_hint"}}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_status"}}</label>