Compare commits
9
Commits
2bdc368399
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d9ccdde75 | ||
|
|
2875918289 | ||
|
|
1fc933e014 | ||
|
|
fe69a2e38b | ||
|
|
d8779ed5bf | ||
|
|
a316932d3f | ||
|
|
5f8c7be5ac | ||
|
|
fe72cbaf9d | ||
|
|
d9bb91af00 |
-163
@@ -1,163 +0,0 @@
|
|||||||
# RSS订阅功能实现计划
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
为Go博客系统添加RSS 2.0订阅功能,允许用户通过RSS阅读器订阅博客的最新文章。
|
|
||||||
|
|
||||||
## 当前项目分析
|
|
||||||
|
|
||||||
### 技术栈
|
|
||||||
- **框架**: Gin (HTTP框架)
|
|
||||||
- **ORM**: GORM
|
|
||||||
- **数据库**: SQLite/MySQL (可配置)
|
|
||||||
- **模板**: Go HTML templates
|
|
||||||
|
|
||||||
### 现有结构
|
|
||||||
1. **路由**: 在 `main.go` 中集中注册
|
|
||||||
2. **处理器**: 在 `handlers/` 目录下,按功能分文件
|
|
||||||
3. **模型**: 在 `models/` 目录下
|
|
||||||
4. **文章模型** (`models/article.go`):
|
|
||||||
- 包含标题、摘要、内容、作者、发布时间等字段
|
|
||||||
- 有状态管理(草稿/已发布/已归档)
|
|
||||||
- 支持slug用于URL
|
|
||||||
5. **主页** (`handlers/home.go`):
|
|
||||||
- 已有获取已发布文章的逻辑
|
|
||||||
- 按置顶和发布时间排序
|
|
||||||
- 限制返回10篇文章
|
|
||||||
|
|
||||||
### 发现
|
|
||||||
- 项目没有现有的RSS或feed相关代码
|
|
||||||
- 没有RSS相关的Go依赖包
|
|
||||||
- 项目支持中英文双语(i18n)
|
|
||||||
- 有网站设置模型 (`SiteSetting`),包含网站标题、描述等信息
|
|
||||||
|
|
||||||
## 实现方案
|
|
||||||
|
|
||||||
### 方案选择
|
|
||||||
**推荐方案**: 使用标准库 `encoding/xml` 手动构建RSS 2.0格式
|
|
||||||
- **优点**:
|
|
||||||
- 无需引入新依赖
|
|
||||||
- RSS 2.0格式简单明确
|
|
||||||
- 完全控制输出格式
|
|
||||||
- 符合项目轻量化原则
|
|
||||||
- **缺点**:
|
|
||||||
- 需要手动定义XML结构
|
|
||||||
|
|
||||||
**备选方案**: 使用第三方库如 `github.com/gorilla/feeds`
|
|
||||||
- **优点**: 简化RSS生成,支持RSS/Atom/JSON Feed
|
|
||||||
- **缺点**: 引入新依赖
|
|
||||||
|
|
||||||
**决策**: 采用推荐方案,使用标准库实现RSS 2.0
|
|
||||||
|
|
||||||
### 实现步骤
|
|
||||||
|
|
||||||
#### 1. 创建RSS模型和生成器 (`handlers/rss.go`)
|
|
||||||
- 定义RSS 2.0的XML结构体:
|
|
||||||
- `RSS` (根元素)
|
|
||||||
- `Channel` (频道信息)
|
|
||||||
- `Item` (文章项)
|
|
||||||
- 实现 `RSSFeed` 处理器函数:
|
|
||||||
- 从数据库获取最近20篇已发布文章(包含作者信息)
|
|
||||||
- 从 `SiteSetting` 获取网站标题和描述
|
|
||||||
- 根据当前语言决定使用中文或英文的网站信息
|
|
||||||
- 构建RSS XML结构
|
|
||||||
- 设置正确的Content-Type: `application/rss+xml; charset=utf-8`
|
|
||||||
- 返回XML响应
|
|
||||||
|
|
||||||
#### 2. RSS内容规范
|
|
||||||
- **Channel级别**:
|
|
||||||
- `title`: 网站标题(从 `SiteSetting.LogoText` 获取)
|
|
||||||
- `link`: 网站首页URL
|
|
||||||
- `description`: 网站描述(从 `SiteSetting.HomeSubtitle` 获取)
|
|
||||||
- `language`: zh-CN 或 en-US(根据当前语言)
|
|
||||||
- `lastBuildDate`: 最新文章的发布时间
|
|
||||||
|
|
||||||
- **Item级别**:
|
|
||||||
- `title`: 文章标题
|
|
||||||
- `link`: 文章详情页URL(使用slug)
|
|
||||||
- `description`: 文章摘要(如果有)或内容前200字符
|
|
||||||
- `author`: 作者用户名或显示名称
|
|
||||||
- `pubDate`: 文章发布时间(RFC822格式)
|
|
||||||
- `guid`: 文章的唯一标识(使用文章详情URL)
|
|
||||||
|
|
||||||
#### 3. 注册路由 (`main.go`)
|
|
||||||
在public路由区域添加:
|
|
||||||
```go
|
|
||||||
router.GET("/rss", handlers.RSSFeed(db))
|
|
||||||
router.GET("/feed", handlers.RSSFeed(db)) // 别名,增加兼容性
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 可选:在模板中添加RSS链接
|
|
||||||
在HTML head中添加RSS自动发现标签:
|
|
||||||
```html
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss">
|
|
||||||
```
|
|
||||||
|
|
||||||
### 技术细节
|
|
||||||
|
|
||||||
#### URL生成
|
|
||||||
- 需要构建完整的文章URL(包含域名)
|
|
||||||
- 从请求的 `Host` 头获取域名
|
|
||||||
- 构建格式: `http(s)://domain/article/{slug}`
|
|
||||||
|
|
||||||
#### 时间格式
|
|
||||||
- RSS 2.0要求使用RFC822格式
|
|
||||||
- Go time包: `time.RFC1123Z` 或手动格式化
|
|
||||||
|
|
||||||
#### 文章内容处理
|
|
||||||
- 优先使用 `Summary` 字段
|
|
||||||
- 如果没有摘要,截取 `Content` 前200字符
|
|
||||||
- 需要处理HTML标签(strip或escape)
|
|
||||||
|
|
||||||
#### 字符编码
|
|
||||||
- 确保XML声明中包含 `encoding="UTF-8"`
|
|
||||||
- Gin自动处理UTF-8编码
|
|
||||||
|
|
||||||
### 安全考虑
|
|
||||||
- RSS是只读接口,不需要认证
|
|
||||||
- 只返回状态为"已发布"的文章
|
|
||||||
- XSS防护:XML自动转义特殊字符
|
|
||||||
|
|
||||||
### 性能考虑
|
|
||||||
- 限制返回文章数量(20篇)
|
|
||||||
- 使用数据库索引(status和published_at字段已有索引)
|
|
||||||
- 可以考虑添加缓存(后续优化)
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
### 新建文件
|
|
||||||
1. `handlers/rss.go` - RSS处理器和XML结构定义
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
1. `main.go` - 添加RSS路由
|
|
||||||
2. 可选:`templates/layouts/base.html` - 添加RSS自动发现标签
|
|
||||||
|
|
||||||
## 测试验证
|
|
||||||
|
|
||||||
### 手动测试
|
|
||||||
1. 启动服务器
|
|
||||||
2. 访问 `http://localhost:PORT/rss`
|
|
||||||
3. 验证返回的XML格式正确
|
|
||||||
4. 检查Content-Type头
|
|
||||||
5. 使用RSS阅读器(如Feedly)订阅测试
|
|
||||||
|
|
||||||
### 测试点
|
|
||||||
- [ ] RSS XML格式符合RSS 2.0规范
|
|
||||||
- [ ] 包含最新的已发布文章
|
|
||||||
- [ ] 文章链接可点击且正确
|
|
||||||
- [ ] 中英文切换正常工作
|
|
||||||
- [ ] 时间格式正确
|
|
||||||
- [ ] 特殊字符正确转义
|
|
||||||
- [ ] 在RSS阅读器中可正常显示
|
|
||||||
|
|
||||||
## 实现优先级
|
|
||||||
1. **核心功能**: RSS feed端点,返回最新文章
|
|
||||||
2. **可选优化**:
|
|
||||||
- 在页面头部添加RSS自动发现标签
|
|
||||||
- 添加RSS订阅链接到导航栏
|
|
||||||
- 实现缓存机制
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
- 保持代码风格与现有项目一致
|
|
||||||
- 遵循项目的命名约定
|
|
||||||
- 添加适当的注释(中英文)
|
|
||||||
- 确保RSS feed在中英文环境下都能正常工作
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
# 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.)
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(curl -s -c /tmp/cookies.txt -L -X POST http://localhost:8099/login -d \"username=admin&password=admin\" -o /dev/null -w \"login final HTTP %{http_code}\\\\n\")",
|
|
||||||
"Bash(curl -s -b /tmp/cookies.txt http://localhost:8099/admin -o /tmp/dash.html -w \"HTTP %{http_code}\\\\n\")",
|
|
||||||
"Read(//tmp/**)",
|
|
||||||
"Bash(sed -n 's/.*\\\\\\(text-3xl font-bold text-gray-900 mt-1\">1\\\\n\\\\\\).*//p' /tmp/dash.html)",
|
|
||||||
"Bash(perl -0777 -ne 'while\\(/dash_posts.*?text-3xl[^>]*>\\(.*?\\)<\\\\/p>/sg\\){print \"POSTS BLOCK: $1\\\\n\"}' /tmp/dash.html)",
|
|
||||||
"Bash(python -c ' *)",
|
|
||||||
"Bash(curl -s -b /tmp/cookies.txt http://localhost:8099/admin -o dash.html)",
|
|
||||||
"Bash(rm -f dash.html)",
|
|
||||||
"Bash(curl -s http://localhost:8099/article/123 -o detail.html -w \"detail HTTP %{http_code}\\\\n\")",
|
|
||||||
"Bash(rm -f detail.html)",
|
|
||||||
"Bash(cp /tmp/cfg_backup.yaml win/etc/blog_go/config.yaml)",
|
|
||||||
"Bash(pkill -f blogtest.exe)",
|
|
||||||
"Bash(pkill -f \"blog_go.exe\")",
|
|
||||||
"Bash(cd c:/Users/wuwen/Documents/project/go_blog && rm -f /tmp/blogtest.exe && git status --short && echo \"--- diff stat ---\" && git diff --stat)"
|
|
||||||
],
|
|
||||||
"additionalDirectories": [
|
|
||||||
"\\tmp"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# 阅读统计系统 - 更新日志
|
|
||||||
|
|
||||||
## 2026-06-22 - v1.1
|
|
||||||
|
|
||||||
### ✨ 改进:文章筛选方式优化
|
|
||||||
|
|
||||||
**问题**:之前使用下拉框选择文章,当文章数量很多时不便使用。
|
|
||||||
|
|
||||||
**解决方案**:改为输入框模糊搜索
|
|
||||||
|
|
||||||
#### 变更内容
|
|
||||||
|
|
||||||
**旧版本(v1.0)**:
|
|
||||||
- 使用 `<select>` 下拉框
|
|
||||||
- 需要遍历所有文章填充选项
|
|
||||||
- 文章多时下拉框很长,难以查找
|
|
||||||
- 只能精确匹配文章ID
|
|
||||||
|
|
||||||
**新版本(v1.1)**:
|
|
||||||
- 使用 `<input>` 文本输入框
|
|
||||||
- 支持关键字模糊搜索
|
|
||||||
- 输入文章标题的部分内容即可筛选
|
|
||||||
- 使用 SQL `LIKE` 查询,支持中英文
|
|
||||||
|
|
||||||
#### 使用方法
|
|
||||||
|
|
||||||
在"筛选条件"区域的"文章标题"输入框中:
|
|
||||||
- 输入完整标题:`Getting Started`
|
|
||||||
- 或输入部分关键字:`start`
|
|
||||||
- 支持中文:`入门`
|
|
||||||
- 点击"应用筛选"查看结果
|
|
||||||
|
|
||||||
#### 技术细节
|
|
||||||
|
|
||||||
**Handler 变更**:
|
|
||||||
```go
|
|
||||||
// 旧代码
|
|
||||||
articleIDStr := c.Query("article_id")
|
|
||||||
if articleIDStr != "" {
|
|
||||||
query = query.Where("article_id = ?", articleIDStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新代码
|
|
||||||
articleTitle := c.Query("article_title")
|
|
||||||
if articleTitle != "" {
|
|
||||||
query = query.Joins("JOIN articles ON article_views.article_id = articles.id").
|
|
||||||
Where("articles.title LIKE ?", "%"+articleTitle+"%")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**模板变更**:
|
|
||||||
```html
|
|
||||||
<!-- 旧代码 -->
|
|
||||||
<select name="article_id">
|
|
||||||
<option value="">全部文章</option>
|
|
||||||
{{range .Articles}}
|
|
||||||
<option value="{{.ID}}">{{.Title}}</option>
|
|
||||||
{{end}}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- 新代码 -->
|
|
||||||
<input type="text" name="article_title"
|
|
||||||
placeholder="搜索文章标题"
|
|
||||||
value="{{.Filters.ArticleTitle}}">
|
|
||||||
```
|
|
||||||
|
|
||||||
**翻译变更**:
|
|
||||||
- `analytics_filter_article`: "文章" → "文章标题"
|
|
||||||
- `analytics_filter_all_articles`: 删除(不再需要)
|
|
||||||
- 新增 `analytics_filter_article_placeholder`: "搜索文章标题"
|
|
||||||
|
|
||||||
#### 优势
|
|
||||||
|
|
||||||
1. **可扩展性**:支持任意数量的文章
|
|
||||||
2. **用户体验**:无需下拉滚动,直接输入
|
|
||||||
3. **灵活性**:模糊匹配,不必输入完整标题
|
|
||||||
4. **性能优化**:不再需要预加载所有文章列表
|
|
||||||
|
|
||||||
#### 兼容性
|
|
||||||
|
|
||||||
- ✅ 向后兼容:URL参数从 `article_id` 改为 `article_title`
|
|
||||||
- ✅ 数据库兼容:使用标准SQL JOIN和LIKE
|
|
||||||
- ✅ 支持SQLite和MySQL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整功能列表(v1.1)
|
|
||||||
|
|
||||||
### 筛选功能
|
|
||||||
- ✅ **文章标题**:输入框模糊搜索(新)
|
|
||||||
- ✅ **IP地址**:输入框部分匹配
|
|
||||||
- ✅ **显示爬虫**:复选框切换
|
|
||||||
|
|
||||||
### 统计展示
|
|
||||||
- ✅ 全局统计卡片(5个指标)
|
|
||||||
- ✅ 文章排行榜(前20篇)
|
|
||||||
- ✅ 详细访问记录(分页)
|
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
- ✅ 自动记录阅读
|
|
||||||
- ✅ 智能爬虫识别
|
|
||||||
- ✅ 去重机制
|
|
||||||
- ✅ 异步处理
|
|
||||||
- ✅ 中英文支持
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 升级方法
|
|
||||||
|
|
||||||
如果你正在使用 v1.0:
|
|
||||||
|
|
||||||
1. **重新编译**:
|
|
||||||
```bash
|
|
||||||
go build -o go_blog
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **重启应用**:
|
|
||||||
```bash
|
|
||||||
./go_blog
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **无需数据库迁移**:数据结构未变更
|
|
||||||
|
|
||||||
4. **清除浏览器缓存**:确保加载新模板
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 后续计划
|
|
||||||
|
|
||||||
考虑添加的功能:
|
|
||||||
- 📅 时间范围筛选(按日期范围)
|
|
||||||
- 👤 用户名搜索(类似文章搜索)
|
|
||||||
- 📊 导出筛选结果(CSV/Excel)
|
|
||||||
- 🔍 高级搜索(组合多个条件)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**感谢反馈!** 🙏
|
|
||||||
|
|
||||||
这个改进让系统更适合大规模文章数量的场景。
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
# 阅读统计系统实现总结
|
|
||||||
|
|
||||||
## 已完成功能
|
|
||||||
|
|
||||||
### 1. 数据库模型
|
|
||||||
✅ **ArticleView 模型** (`models/article_view.go`)
|
|
||||||
- 记录每次独特的阅读
|
|
||||||
- 字段:文章ID、用户ID(可为NULL)、IP地址、User-Agent、是否为爬虫
|
|
||||||
- 包含去重逻辑(BeforeCreate hook)
|
|
||||||
- 建立了必要的索引
|
|
||||||
|
|
||||||
✅ **爬虫检测** (`models/bot_detector.go`)
|
|
||||||
- 基于User-Agent的模式匹配
|
|
||||||
- 支持主流搜索引擎爬虫、社交媒体爬虫、AI爬虫等
|
|
||||||
- 包含35+种常见爬虫特征
|
|
||||||
|
|
||||||
### 2. 阅读记录功能
|
|
||||||
✅ **自动记录** (`handlers/home.go`)
|
|
||||||
- 在文章详情页自动记录阅读
|
|
||||||
- 异步执行(goroutine)不阻塞页面响应
|
|
||||||
- 同一用户/IP只记录一次(去重)
|
|
||||||
- 自动识别并标记爬虫
|
|
||||||
|
|
||||||
### 3. 后台统计页面
|
|
||||||
✅ **统计Dashboard** (`handlers/admin_analytics.go`)
|
|
||||||
- 全局统计:总阅读量、真人阅读、爬虫访问、独立IP、独立用户
|
|
||||||
- 文章排行榜:前20篇文章的详细统计
|
|
||||||
- 详细访问记录:时间、文章、用户、IP、User-Agent、是否爬虫
|
|
||||||
- 强大的筛选功能:按文章、IP、是否显示爬虫
|
|
||||||
- 分页支持(每页50条)
|
|
||||||
|
|
||||||
✅ **精美模板** (`templates/admin/analytics_views.html`)
|
|
||||||
- 响应式设计
|
|
||||||
- 彩色统计卡片
|
|
||||||
- 表格展示详细数据
|
|
||||||
- 爬虫记录高亮显示(橙色背景)
|
|
||||||
- 筛选表单
|
|
||||||
- 分页加载
|
|
||||||
|
|
||||||
### 4. 国际化支持
|
|
||||||
✅ **中英文翻译** (`i18n/i18n.go`)
|
|
||||||
- 添加了35+个翻译key
|
|
||||||
- 完整的中英文对照
|
|
||||||
|
|
||||||
### 5. 路由和集成
|
|
||||||
✅ **路由配置** (`main.go`)
|
|
||||||
- `/admin/analytics/views` - 阅读统计页面
|
|
||||||
- 管理员权限保护
|
|
||||||
|
|
||||||
✅ **数据库迁移** (`models/db.go`)
|
|
||||||
- ArticleView表已加入自动迁移
|
|
||||||
|
|
||||||
✅ **导航入口** (`templates/admin/dashboard.html`)
|
|
||||||
- 后台首页添加"阅读统计"按钮
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
### 去重机制
|
|
||||||
1. **应用层去重**:在recordArticleView函数中查询现有记录
|
|
||||||
2. **数据库层去重**:BeforeCreate hook二次检查
|
|
||||||
3. **组合键**:article_id + user_id + ip(匿名用户user_id为NULL)
|
|
||||||
|
|
||||||
### 爬虫识别
|
|
||||||
检测的爬虫类型:
|
|
||||||
- 搜索引擎:Google, Bing, Baidu, Yandex等
|
|
||||||
- 社交媒体:Facebook, Twitter, LinkedIn等
|
|
||||||
- AI爬虫:GPTBot, ClaudeBot, Anthropic-AI
|
|
||||||
- SEO工具:Ahrefs, SEMrush, Moz等
|
|
||||||
- 无头浏览器:Headless, Phantom, Puppeteer
|
|
||||||
|
|
||||||
### 性能优化
|
|
||||||
- ✅ 异步记录(goroutine)
|
|
||||||
- ✅ 数据库索引优化
|
|
||||||
- ✅ 分页加载(50条/页)
|
|
||||||
- ✅ 最佳实践:忽略记录错误,不影响用户体验
|
|
||||||
|
|
||||||
### 安全性
|
|
||||||
- ✅ 管理员权限保护
|
|
||||||
- ✅ 使用c.ClientIP()获取真实IP(处理代理头)
|
|
||||||
- ✅ SQL注入防护(GORM参数化查询)
|
|
||||||
- ✅ XSS防护(模板自动转义)
|
|
||||||
|
|
||||||
## 使用说明
|
|
||||||
|
|
||||||
### 启动应用
|
|
||||||
```bash
|
|
||||||
./go_blog
|
|
||||||
```
|
|
||||||
|
|
||||||
### 访问统计页面
|
|
||||||
1. 以管理员身份登录
|
|
||||||
2. 访问后台首页
|
|
||||||
3. 点击"阅读统计"按钮
|
|
||||||
4. 或直接访问:`http://localhost:PORT/admin/analytics/views`
|
|
||||||
|
|
||||||
### 查看统计数据
|
|
||||||
**全局统计卡片**:
|
|
||||||
- 总阅读量:所有记录数
|
|
||||||
- 真人阅读:排除爬虫的阅读数
|
|
||||||
- 爬虫访问:被标记为爬虫的访问数
|
|
||||||
- 独立IP:不同IP地址数
|
|
||||||
- 独立用户:已登录用户数
|
|
||||||
|
|
||||||
**文章排行榜**:
|
|
||||||
- 显示阅读量最高的20篇文章
|
|
||||||
- 每篇文章显示:总阅读、真人阅读、爬虫访问、独立IP
|
|
||||||
|
|
||||||
**详细访问记录**:
|
|
||||||
- 时间、文章标题(可点击)、用户名、IP地址、User-Agent
|
|
||||||
- 爬虫记录以橙色背景高亮
|
|
||||||
- 真人显示绿色标签,爬虫显示橙色标签
|
|
||||||
|
|
||||||
### 使用筛选功能
|
|
||||||
- **按文章筛选**:下拉选择特定文章
|
|
||||||
- **按IP筛选**:输入IP地址(支持部分匹配)
|
|
||||||
- **显示爬虫**:勾选以包含爬虫流量
|
|
||||||
- 点击"应用筛选"查看结果
|
|
||||||
|
|
||||||
### 分页浏览
|
|
||||||
- 每页显示50条记录
|
|
||||||
- 底部显示"加载更多"按钮
|
|
||||||
- 筛选条件在翻页时保持
|
|
||||||
|
|
||||||
## 技术细节
|
|
||||||
|
|
||||||
### 数据模型
|
|
||||||
```go
|
|
||||||
type ArticleView struct {
|
|
||||||
ID uint
|
|
||||||
CreatedAt time.Time
|
|
||||||
ArticleID uint
|
|
||||||
UserID *uint // NULL for anonymous
|
|
||||||
IP string
|
|
||||||
UserAgent string
|
|
||||||
IsBot bool
|
|
||||||
Article Article
|
|
||||||
User *User
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 索引
|
|
||||||
- `idx_article_views_article` on `article_id`
|
|
||||||
- `idx_article_views_user` on `user_id`
|
|
||||||
- `idx_article_views_ip` on `ip`
|
|
||||||
- `idx_article_views_bot` on `is_bot`
|
|
||||||
|
|
||||||
### 异步记录
|
|
||||||
```go
|
|
||||||
// 不阻塞页面响应
|
|
||||||
go recordArticleView(db, article.ID, c)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 扩展建议
|
|
||||||
|
|
||||||
### 未来可添加的功能
|
|
||||||
1. **图表可视化**
|
|
||||||
- 阅读趋势图(按天/周/月)
|
|
||||||
- 访客地理分布
|
|
||||||
- 浏览器/设备统计
|
|
||||||
|
|
||||||
2. **高级分析**
|
|
||||||
- 用户阅读路径分析
|
|
||||||
- 文章热度变化趋势
|
|
||||||
- 访问高峰时段分析
|
|
||||||
|
|
||||||
3. **性能优化**
|
|
||||||
- Redis缓存统计数据
|
|
||||||
- 定期归档旧数据
|
|
||||||
- 批量写入优化
|
|
||||||
|
|
||||||
4. **导出功能**
|
|
||||||
- CSV/Excel导出
|
|
||||||
- 自定义报表生成
|
|
||||||
- 定期邮件报告
|
|
||||||
|
|
||||||
5. **实时监控**
|
|
||||||
- WebSocket实时访问流
|
|
||||||
- 在线访客数量
|
|
||||||
- 实时热门文章
|
|
||||||
|
|
||||||
## 测试验证
|
|
||||||
|
|
||||||
### 编译状态
|
|
||||||
✅ 代码编译成功(无错误)
|
|
||||||
|
|
||||||
### 验证清单
|
|
||||||
- ✅ 数据库模型创建
|
|
||||||
- ✅ 爬虫检测逻辑
|
|
||||||
- ✅ 文章详情页集成
|
|
||||||
- ✅ 后台handler实现
|
|
||||||
- ✅ 前端模板创建
|
|
||||||
- ✅ 国际化翻译
|
|
||||||
- ✅ 路由配置
|
|
||||||
- ✅ 数据库迁移
|
|
||||||
- ✅ 导航入口
|
|
||||||
|
|
||||||
### 建议测试步骤
|
|
||||||
1. ✅ 启动应用,确认数据库迁移成功
|
|
||||||
2. ✅ 访问多篇文章,生成测试数据
|
|
||||||
3. ✅ 访问 `/admin/analytics/views` 查看统计
|
|
||||||
4. ✅ 测试筛选功能
|
|
||||||
5. ✅ 用不同User-Agent测试爬虫识别
|
|
||||||
6. ✅ 测试分页功能
|
|
||||||
7. ✅ 切换语言测试国际化
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
### 新建文件
|
|
||||||
1. `models/article_view.go` - ArticleView数据模型
|
|
||||||
2. `models/bot_detector.go` - 爬虫检测逻辑
|
|
||||||
3. `handlers/admin_analytics.go` - 统计页面handler
|
|
||||||
4. `templates/admin/analytics_views.html` - 统计页面模板
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
1. `handlers/home.go` - 添加阅读记录逻辑
|
|
||||||
2. `models/db.go` - 添加ArticleView到迁移
|
|
||||||
3. `main.go` - 添加analytics路由
|
|
||||||
4. `i18n/i18n.go` - 添加翻译
|
|
||||||
5. `templates/admin/dashboard.html` - 添加入口链接
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **首次运行**:会自动创建article_views表
|
|
||||||
2. **性能**:异步记录不影响页面加载速度
|
|
||||||
3. **隐私**:IP地址完整记录,考虑是否需要脱敏
|
|
||||||
4. **存储**:长期运行可能积累大量数据,建议定期归档
|
|
||||||
5. **爬虫识别**:基于User-Agent,可能有漏报和误报
|
|
||||||
|
|
||||||
## 总结
|
|
||||||
|
|
||||||
阅读统计系统已完整实现,包含:
|
|
||||||
- ✅ 自动记录每篇文章的独特访问
|
|
||||||
- ✅ 智能识别并标记爬虫
|
|
||||||
- ✅ 功能强大的后台统计页面
|
|
||||||
- ✅ 详细的访问记录和筛选
|
|
||||||
- ✅ 完整的中英文支持
|
|
||||||
- ✅ 性能优化和安全保护
|
|
||||||
|
|
||||||
系统可以立即投入使用,为博客管理员提供深入的阅读数据洞察!
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# 阅读统计系统 - 快速使用指南
|
|
||||||
|
|
||||||
## 🚀 立即开始
|
|
||||||
|
|
||||||
### 1. 启动应用
|
|
||||||
```bash
|
|
||||||
./go_blog
|
|
||||||
```
|
|
||||||
|
|
||||||
首次启动会自动创建 `article_views` 数据库表。
|
|
||||||
|
|
||||||
### 2. 访问统计页面
|
|
||||||
1. 浏览器打开:http://localhost:8080/login
|
|
||||||
2. 登录(默认账号):`admin` / `admin`
|
|
||||||
3. 进入后台,点击 **"阅读统计"** 按钮
|
|
||||||
4. 或直接访问:http://localhost:8080/admin/analytics/views
|
|
||||||
|
|
||||||
## 📊 功能说明
|
|
||||||
|
|
||||||
### 全局统计(顶部卡片)
|
|
||||||
- **总阅读量**:所有访问记录数
|
|
||||||
- **真人阅读**:排除爬虫的真实用户访问
|
|
||||||
- **爬虫访问**:被识别为爬虫的访问数
|
|
||||||
- **独立IP**:访问过的不同IP地址数量
|
|
||||||
- **独立用户**:已登录用户的数量
|
|
||||||
|
|
||||||
### 文章排行榜
|
|
||||||
- 显示阅读量最高的前20篇文章
|
|
||||||
- 每篇显示:总阅读、真人阅读、爬虫访问、独立IP
|
|
||||||
|
|
||||||
### 详细访问记录
|
|
||||||
每条记录显示:
|
|
||||||
- **时间**:访问时间(精确到分钟)
|
|
||||||
- **文章**:文章标题(可点击跳转)
|
|
||||||
- **用户**:登录用户名或"匿名用户"
|
|
||||||
- **IP地址**:访客IP
|
|
||||||
- **User-Agent**:浏览器信息
|
|
||||||
- **类型**:👤真人 或 🤖爬虫(橙色高亮)
|
|
||||||
|
|
||||||
### 筛选功能
|
|
||||||
- **按文章筛选**:下拉选择特定文章
|
|
||||||
- **按IP筛选**:输入IP地址(支持部分匹配)
|
|
||||||
- **显示爬虫**:勾选后包含爬虫流量
|
|
||||||
|
|
||||||
## 🤖 爬虫识别
|
|
||||||
|
|
||||||
系统自动识别以下类型的爬虫:
|
|
||||||
|
|
||||||
### 搜索引擎
|
|
||||||
- Google (Googlebot)
|
|
||||||
- Bing (Bingbot)
|
|
||||||
- Baidu (Baiduspider)
|
|
||||||
- Yandex (Yandexbot)
|
|
||||||
- DuckDuckGo (Duckduckbot)
|
|
||||||
|
|
||||||
### 社交媒体
|
|
||||||
- Facebook (Facebookexternalhit, Facebot)
|
|
||||||
- Twitter (Twitterbot)
|
|
||||||
- LinkedIn (Linkedinbot)
|
|
||||||
- Telegram
|
|
||||||
- WhatsApp
|
|
||||||
|
|
||||||
### AI爬虫
|
|
||||||
- GPTBot (OpenAI)
|
|
||||||
- ClaudeBot (Anthropic)
|
|
||||||
- 字节跳动 (Bytespider)
|
|
||||||
|
|
||||||
### SEO工具
|
|
||||||
- Ahrefs
|
|
||||||
- SEMrush
|
|
||||||
- Moz (Dotbot)
|
|
||||||
- Dataforseo
|
|
||||||
|
|
||||||
### 其他
|
|
||||||
- 无头浏览器 (Headless, Phantom, Puppeteer, Selenium)
|
|
||||||
- 网页归档 (Archive.org_bot)
|
|
||||||
|
|
||||||
## 🎯 测试统计功能
|
|
||||||
|
|
||||||
### 方法1:运行测试脚本
|
|
||||||
```bash
|
|
||||||
./test_analytics.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
该脚本会:
|
|
||||||
- 检查应用状态
|
|
||||||
- 模拟不同User-Agent的访问
|
|
||||||
- 生成测试数据
|
|
||||||
- 验证数据库记录
|
|
||||||
|
|
||||||
### 方法2:手动测试
|
|
||||||
1. 访问几篇已发布的文章
|
|
||||||
2. 刷新页面(不会重复计数)
|
|
||||||
3. 使用不同浏览器访问(会记录为不同IP)
|
|
||||||
4. 查看后台统计
|
|
||||||
|
|
||||||
### 方法3:用curl模拟爬虫
|
|
||||||
```bash
|
|
||||||
# 模拟GoogleBot
|
|
||||||
curl -H "User-Agent: Googlebot/2.1" http://localhost:8080/article/your-article-slug
|
|
||||||
|
|
||||||
# 模拟BingBot
|
|
||||||
curl -H "User-Agent: bingbot/2.0" http://localhost:8080/article/your-article-slug
|
|
||||||
|
|
||||||
# 正常浏览器
|
|
||||||
curl -H "User-Agent: Mozilla/5.0 (Macintosh)" http://localhost:8080/article/your-article-slug
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✨ 核心特性
|
|
||||||
|
|
||||||
### 自动去重
|
|
||||||
- 同一IP+用户组合只记录一次
|
|
||||||
- 刷新页面不会重复计数
|
|
||||||
- 双重保护:应用层+数据库层
|
|
||||||
|
|
||||||
### 异步记录
|
|
||||||
- 不阻塞页面加载
|
|
||||||
- 后台goroutine处理
|
|
||||||
- 即使记录失败也不影响用户体验
|
|
||||||
|
|
||||||
### 性能优化
|
|
||||||
- 数据库索引优化
|
|
||||||
- 分页加载(50条/页)
|
|
||||||
- 异步处理
|
|
||||||
|
|
||||||
### 国际化
|
|
||||||
- 完整中英文支持
|
|
||||||
- 自动语言切换
|
|
||||||
|
|
||||||
## 📁 相关文件
|
|
||||||
|
|
||||||
### 新建文件
|
|
||||||
- `models/article_view.go` - 数据模型
|
|
||||||
- `models/bot_detector.go` - 爬虫检测
|
|
||||||
- `handlers/admin_analytics.go` - 统计handler
|
|
||||||
- `templates/admin/analytics_views.html` - 统计页面
|
|
||||||
- `test_analytics.sh` - 测试脚本
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
- `handlers/home.go` - 添加记录逻辑
|
|
||||||
- `models/db.go` - 数据库迁移
|
|
||||||
- `main.go` - 路由配置
|
|
||||||
- `i18n/i18n.go` - 翻译
|
|
||||||
- `templates/admin/dashboard.html` - 入口链接
|
|
||||||
|
|
||||||
## 🔧 高级配置
|
|
||||||
|
|
||||||
### 查询数据库(SQLite)
|
|
||||||
```bash
|
|
||||||
sqlite3 tmp/blog.db
|
|
||||||
|
|
||||||
# 查看所有访问记录
|
|
||||||
SELECT * FROM article_views ORDER BY created_at DESC LIMIT 10;
|
|
||||||
|
|
||||||
# 统计真人和爬虫访问
|
|
||||||
SELECT is_bot, COUNT(*) FROM article_views GROUP BY is_bot;
|
|
||||||
|
|
||||||
# 查看独立IP数
|
|
||||||
SELECT COUNT(DISTINCT ip) FROM article_views;
|
|
||||||
|
|
||||||
# 某篇文章的访问统计
|
|
||||||
SELECT COUNT(*) FROM article_views WHERE article_id = 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 清空统计数据
|
|
||||||
```bash
|
|
||||||
sqlite3 tmp/blog.db "DELETE FROM article_views;"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💡 使用建议
|
|
||||||
|
|
||||||
### 日常使用
|
|
||||||
1. **定期查看**:每周查看一次统计数据
|
|
||||||
2. **关注真人阅读**:爬虫访问仅供参考
|
|
||||||
3. **分析热门文章**:根据排行榜优化内容
|
|
||||||
4. **识别可疑IP**:发现异常流量
|
|
||||||
|
|
||||||
### 数据维护
|
|
||||||
1. **定期备份**:备份数据库文件
|
|
||||||
2. **归档旧数据**:超过6个月的数据可以归档
|
|
||||||
3. **监控存储**:注意数据库大小
|
|
||||||
|
|
||||||
### 隐私保护
|
|
||||||
- IP地址完整记录,考虑是否需要脱敏
|
|
||||||
- User-Agent信息敏感,注意保护
|
|
||||||
- 遵守当地隐私法规(如GDPR)
|
|
||||||
|
|
||||||
## 🐛 故障排查
|
|
||||||
|
|
||||||
### 问题1:看不到访问记录
|
|
||||||
**原因**:可能还没有人访问文章
|
|
||||||
**解决**:访问几篇已发布的文章,等待几秒后刷新统计页面
|
|
||||||
|
|
||||||
### 问题2:统计页面显示0
|
|
||||||
**原因**:
|
|
||||||
- 数据库迁移未执行
|
|
||||||
- article_views表不存在
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
```bash
|
|
||||||
# 停止应用
|
|
||||||
pkill go_blog
|
|
||||||
|
|
||||||
# 重新启动(会自动迁移)
|
|
||||||
./go_blog
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题3:所有访问都被标记为爬虫
|
|
||||||
**原因**:curl或工具的User-Agent被识别为爬虫
|
|
||||||
**解决**:使用真实浏览器访问
|
|
||||||
|
|
||||||
### 问题4:访问页面返回404
|
|
||||||
**原因**:路由未正确配置
|
|
||||||
**检查**:
|
|
||||||
```bash
|
|
||||||
# 查看路由列表
|
|
||||||
grep "analytics/views" main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📈 未来扩展
|
|
||||||
|
|
||||||
可以考虑添加的功能:
|
|
||||||
1. 📊 图表可视化(访问趋势图)
|
|
||||||
2. 🌍 地理位置分析(IP定位)
|
|
||||||
3. 📱 设备统计(移动端/桌面端)
|
|
||||||
4. ⏰ 访问时段分析(热门时间)
|
|
||||||
5. 📊 导出功能(CSV/Excel)
|
|
||||||
6. 🔔 实时监控(WebSocket)
|
|
||||||
7. 📧 定期报告(邮件通知)
|
|
||||||
|
|
||||||
## 📞 支持
|
|
||||||
|
|
||||||
遇到问题?
|
|
||||||
1. 查看 `ANALYTICS_IMPLEMENTATION.md` 详细文档
|
|
||||||
2. 检查应用日志
|
|
||||||
3. 运行测试脚本 `./test_analytics.sh`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**就这么简单!** 🎉
|
|
||||||
|
|
||||||
现在你的博客已经拥有了专业的阅读统计系统,可以深入了解读者行为,优化内容策略!
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# 文章详情页显示最后更新时间功能
|
|
||||||
|
|
||||||
## 功能概述
|
|
||||||
|
|
||||||
为文章详情页添加了最后更新时间的显示,让读者可以看到文章的发布时间和最后修改时间。
|
|
||||||
|
|
||||||
## 修改的文件
|
|
||||||
|
|
||||||
### 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. **信任度提升**:显示更新时间表明作者在持续维护内容
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# 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()` 刷新内存缓存
|
|
||||||
- 更改后立即生效,无需重启服务
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# 主页文章卡片功能更新
|
|
||||||
|
|
||||||
## 新增功能:显示阅读量和评论数
|
|
||||||
|
|
||||||
### 效果展示
|
|
||||||
|
|
||||||
每篇文章卡片底部现在显示:
|
|
||||||
- 👁️ **阅读量**:显示该文章被访问的次数
|
|
||||||
- 💬 **评论数**:显示已通过审核的评论数量
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ 文章标题 [置顶] │
|
|
||||||
│ 文章摘要内容... │
|
|
||||||
│ │
|
|
||||||
│ 阅读全文 → 👁️ 123 💬 5 │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 技术实现
|
|
||||||
|
|
||||||
#### 1. 后端改动
|
|
||||||
|
|
||||||
**HomePage Handler** - 加载初始文章的评论数:
|
|
||||||
```go
|
|
||||||
// 查询评论数
|
|
||||||
db.Model(&models.Comment{}).
|
|
||||||
Select("article_id, COUNT(*) as count").
|
|
||||||
Where("article_id IN ?", articleIDs).
|
|
||||||
Where("status = ?", models.CommentApproved).
|
|
||||||
Group("article_id").
|
|
||||||
Scan(&commentCounts)
|
|
||||||
|
|
||||||
// 传递给模板
|
|
||||||
data["CommentCounts"] = commentCountMap
|
|
||||||
```
|
|
||||||
|
|
||||||
**HomeArticlesAPI Handler** - 返回JSON格式的文章列表:
|
|
||||||
```go
|
|
||||||
type ArticleResponse struct {
|
|
||||||
models.Article
|
|
||||||
CommentCount int64 `json:"comment_count"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 前端改动
|
|
||||||
|
|
||||||
**模板 (home.html)**:
|
|
||||||
```html
|
|
||||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
|
||||||
<!-- 阅读量 -->
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<svg>眼睛图标</svg>
|
|
||||||
{{.ViewCount}}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- 评论数 -->
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<svg>对话图标</svg>
|
|
||||||
<span class="comment-count">{{index $.CommentCounts .ID}}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**JavaScript 动态加载**:
|
|
||||||
```javascript
|
|
||||||
const commentCount = article.comment_count || 0;
|
|
||||||
const viewCount = article.view_count || 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据来源
|
|
||||||
|
|
||||||
- **阅读量 (view_count)**:来自 `articles.view_count` 字段
|
|
||||||
- 每次访问文章时自动增加
|
|
||||||
- 显示所有访问次数(包括刷新)
|
|
||||||
|
|
||||||
- **评论数 (comment_count)**:来自 `comments` 表统计
|
|
||||||
- 只统计已通过审核的评论 (`status = 1`)
|
|
||||||
- 实时查询,保证准确性
|
|
||||||
|
|
||||||
### 性能优化
|
|
||||||
|
|
||||||
1. **批量查询**:使用 `IN` 查询一次性获取所有文章的评论数
|
|
||||||
2. **索引支持**:`article_id` 和 `status` 字段都有索引
|
|
||||||
3. **缓存友好**:评论数在前端渲染,无需额外请求
|
|
||||||
|
|
||||||
### 样式细节
|
|
||||||
|
|
||||||
- 使用 Heroicons SVG 图标
|
|
||||||
- 灰色文字 (`text-gray-500`) 不抢眼
|
|
||||||
- 与"阅读全文"按钮对齐
|
|
||||||
- 响应式设计,移动端友好
|
|
||||||
|
|
||||||
### API 响应示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"articles": [
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"title": "文章标题",
|
|
||||||
"view_count": 123,
|
|
||||||
"comment_count": 5,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"hasMore": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 文件变更
|
|
||||||
|
|
||||||
- ✅ `handlers/home.go` - 添加评论数查询逻辑
|
|
||||||
- ✅ `templates/pages/home.html` - 显示阅读量和评论数UI
|
|
||||||
- ✅ JavaScript - 动态加载时包含统计信息
|
|
||||||
|
|
||||||
### 兼容性
|
|
||||||
|
|
||||||
- ✅ 向后兼容:如果没有评论,显示 0
|
|
||||||
- ✅ 支持无限滚动:新加载的文章也显示统计
|
|
||||||
- ✅ 支持SQLite和MySQL
|
|
||||||
|
|
||||||
### 用户体验
|
|
||||||
|
|
||||||
**优势**:
|
|
||||||
1. 用户可以快速看到文章的热度
|
|
||||||
2. 评论数激发互动兴趣
|
|
||||||
3. 不需要点开文章就能了解活跃度
|
|
||||||
|
|
||||||
**显示规则**:
|
|
||||||
- 阅读量始终显示(即使是0)
|
|
||||||
- 评论数始终显示(即使是0)
|
|
||||||
- 数字简洁明了,不带单位
|
|
||||||
|
|
||||||
### 未来增强
|
|
||||||
|
|
||||||
可以考虑的改进:
|
|
||||||
- 📊 阅读量格式化(如 1.2k)
|
|
||||||
- 🔥 热度标记(高阅读量文章)
|
|
||||||
- ⭐ 点赞功能
|
|
||||||
- 📈 阅读趋势指示器
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**效果**:让用户在浏览文章列表时就能看到每篇文章的热度和互动情况!📊
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
# 权限控制与用户文章管理功能
|
|
||||||
|
|
||||||
## 提交摘要
|
|
||||||
本次更新修复了权限边界问题,并为普通用户添加了独立的文章管理功能。
|
|
||||||
|
|
||||||
## 1. 权限控制修复
|
|
||||||
|
|
||||||
### 问题
|
|
||||||
- 普通用户(author角色)能够访问后台管理页面 `/admin`
|
|
||||||
- 普通用户能够修改平台设置、审核评论、管理用户
|
|
||||||
- 权限边界不清晰,存在安全隐患
|
|
||||||
|
|
||||||
### 解决方案
|
|
||||||
|
|
||||||
#### 后端路由保护 ([main.go](main.go))
|
|
||||||
为所有管理功能添加 `AdminRequired` 中间件:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 整个 /admin 路径要求管理员权限
|
|
||||||
admin := router.Group("/admin")
|
|
||||||
admin.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
|
||||||
|
|
||||||
// 评论管理要求管理员权限
|
|
||||||
comments := router.Group("/admin/comments")
|
|
||||||
comments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
|
||||||
|
|
||||||
// 用户管理要求管理员权限
|
|
||||||
users := router.Group("/admin/users")
|
|
||||||
users.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
|
||||||
|
|
||||||
// 平台设置要求管理员权限
|
|
||||||
settings := router.Group("/admin/settings")
|
|
||||||
settings.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
|
||||||
|
|
||||||
// 附件管理要求管理员权限
|
|
||||||
attachments := router.Group("/admin/articles")
|
|
||||||
attachments.Use(middleware.AuthRequired(), middleware.AdminRequired(db))
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 前端UI控制
|
|
||||||
**头部导航菜单** ([templates/layouts/base.html](templates/layouts/base.html)):
|
|
||||||
- 所有用户:个人信息、我的文章
|
|
||||||
- 仅管理员:后台管理(分隔线后显示)
|
|
||||||
|
|
||||||
**管理后台仪表板** ([templates/admin/dashboard.html](templates/admin/dashboard.html)):
|
|
||||||
- 管理员专属按钮:用户管理、评论管理、平台设置
|
|
||||||
|
|
||||||
## 2. 用户文章管理功能
|
|
||||||
|
|
||||||
### 新增功能
|
|
||||||
为普通用户创建独立的文章管理界面,无需访问后台即可管理自己的文章。
|
|
||||||
|
|
||||||
### 路由设计 ([main.go](main.go))
|
|
||||||
```go
|
|
||||||
myArticles := router.Group("/my")
|
|
||||||
myArticles.Use(middleware.AuthRequired())
|
|
||||||
{
|
|
||||||
myArticles.GET("/articles", handlers.MyArticlesPage(db))
|
|
||||||
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
|
|
||||||
myArticles.POST("/articles/new", handlers.MyArticleCreate(db))
|
|
||||||
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
|
|
||||||
myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db))
|
|
||||||
myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户文章附件管理
|
|
||||||
myAttachments := router.Group("/my/articles")
|
|
||||||
myAttachments.Use(middleware.AuthRequired())
|
|
||||||
{
|
|
||||||
myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
|
||||||
myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
|
||||||
myAttachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Handler实现 ([handlers/my_articles.go](handlers/my_articles.go))
|
|
||||||
|
|
||||||
**权限控制特点**:
|
|
||||||
- `MyArticlesPage`: 只查询当前用户的文章 (`WHERE author_id = ?`)
|
|
||||||
- `MyArticleEditPage`: 验证文章所有权 (`WHERE id = ? AND author_id = ?`)
|
|
||||||
- `MyArticleUpdate`: 验证文章所有权后才允许更新
|
|
||||||
- `MyArticleDelete`: 验证文章所有权后才允许删除
|
|
||||||
|
|
||||||
### 模板文件
|
|
||||||
|
|
||||||
**文章列表** ([templates/user/my_articles.html](templates/user/my_articles.html)):
|
|
||||||
- 显示用户自己的文章
|
|
||||||
- 支持创建、编辑、删除操作
|
|
||||||
- 显示文章状态(草稿/已发布/置顶)
|
|
||||||
|
|
||||||
**文章编辑器** ([templates/user/my_article_form.html](templates/user/my_article_form.html)):
|
|
||||||
- Markdown编辑器支持
|
|
||||||
- 标题、slug、摘要、正文、封面
|
|
||||||
- 状态选择(草稿/发布)
|
|
||||||
- 置顶选项
|
|
||||||
|
|
||||||
### 国际化支持 ([i18n/i18n.go](i18n/i18n.go))
|
|
||||||
|
|
||||||
新增翻译key:
|
|
||||||
```go
|
|
||||||
// 英文
|
|
||||||
"my_articles": "My Articles",
|
|
||||||
"my_articles_title": "My Articles",
|
|
||||||
"comment_manage": "Manage Comments",
|
|
||||||
"article_field_*": // 表单字段标签
|
|
||||||
"article_save": "Save",
|
|
||||||
"article_cancel": "Cancel",
|
|
||||||
|
|
||||||
// 中文
|
|
||||||
"my_articles": "我的文章",
|
|
||||||
"my_articles_title": "我的文章",
|
|
||||||
"comment_manage": "评论管理",
|
|
||||||
// ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 权限矩阵
|
|
||||||
|
|
||||||
| 功能 | 路径 | admin | author | 未登录 |
|
|
||||||
|------|------|-------|--------|--------|
|
|
||||||
| 管理后台 | `/admin` | ✓ | ✗ | ✗ |
|
|
||||||
| 文章管理(后台) | `/admin/articles` | ✓ | ✗ | ✗ |
|
|
||||||
| 评论管理 | `/admin/comments` | ✓ | ✗ | ✗ |
|
|
||||||
| 用户管理 | `/admin/users` | ✓ | ✗ | ✗ |
|
|
||||||
| 平台设置 | `/admin/settings` | ✓ | ✗ | ✗ |
|
|
||||||
| **我的文章** | `/my/articles` | ✓ | ✓ | ✗ |
|
|
||||||
| 个人资料 | `/profile` | ✓ | ✓ | ✗ |
|
|
||||||
| 文章浏览 | `/article/:slug` | ✓ | ✓ | ✓ |
|
|
||||||
|
|
||||||
## 4. 用户体验
|
|
||||||
|
|
||||||
### 普通用户(author)
|
|
||||||
1. 登录后点击头像
|
|
||||||
2. 看到选项:
|
|
||||||
- 个人信息
|
|
||||||
- **我的文章** ← 新增
|
|
||||||
- 退出登录
|
|
||||||
3. 点击"我的文章"进入独立的文章管理界面
|
|
||||||
4. 可以创建、编辑、删除自己的文章
|
|
||||||
5. 无法访问后台管理功能
|
|
||||||
|
|
||||||
### 管理员(admin)
|
|
||||||
1. 登录后点击头像
|
|
||||||
2. 看到选项:
|
|
||||||
- 个人信息
|
|
||||||
- 我的文章
|
|
||||||
- ---(分隔线)---
|
|
||||||
- **后台管理** ← 管理员专属
|
|
||||||
- 退出登录
|
|
||||||
3. 点击"后台管理"进入完整的管理后台
|
|
||||||
4. 后台仪表板显示管理员专属按钮:
|
|
||||||
- 用户管理
|
|
||||||
- 评论管理
|
|
||||||
- 平台设置
|
|
||||||
|
|
||||||
## 5. 安全改进
|
|
||||||
|
|
||||||
### 多层防护
|
|
||||||
1. **路由层**:`AdminRequired` 中间件拦截未授权访问
|
|
||||||
2. **Handler层**:查询时验证用户身份和所有权
|
|
||||||
3. **UI层**:根据角色隐藏不该显示的按钮和链接
|
|
||||||
|
|
||||||
### 拦截行为
|
|
||||||
- 普通用户访问 `/admin/*` → 重定向到 `/admin`
|
|
||||||
- 由于 `/admin` 也需要管理员权限 → 再次重定向到 `/admin`
|
|
||||||
- 实际效果:普通用户无法访问任何管理功能
|
|
||||||
|
|
||||||
## 6. 测试建议
|
|
||||||
|
|
||||||
### 管理员账户测试
|
|
||||||
```bash
|
|
||||||
# 登录管理员
|
|
||||||
访问 http://localhost:8080/login
|
|
||||||
用户名: admin
|
|
||||||
密码: (你的管理员密码)
|
|
||||||
|
|
||||||
# 应该能访问:
|
|
||||||
- /admin (后台仪表板)
|
|
||||||
- /admin/articles (文章管理)
|
|
||||||
- /admin/comments (评论管理)
|
|
||||||
- /admin/users (用户管理)
|
|
||||||
- /admin/settings/site (平台设置)
|
|
||||||
- /my/articles (我的文章)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 普通用户测试
|
|
||||||
```bash
|
|
||||||
# 创建或登录普通用户
|
|
||||||
访问 http://localhost:8080/login
|
|
||||||
用户名: author
|
|
||||||
密码: (普通用户密码)
|
|
||||||
|
|
||||||
# 应该能访问:
|
|
||||||
- /my/articles (我的文章)
|
|
||||||
- /profile (个人资料)
|
|
||||||
|
|
||||||
# 不应该能访问(会被拦截):
|
|
||||||
- /admin
|
|
||||||
- /admin/comments
|
|
||||||
- /admin/users
|
|
||||||
- /admin/settings
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 文件变更清单
|
|
||||||
|
|
||||||
### 新增文件
|
|
||||||
- `handlers/my_articles.go` - 用户文章管理handler
|
|
||||||
- `templates/user/my_articles.html` - 文章列表模板
|
|
||||||
- `templates/user/my_article_form.html` - 文章编辑表单
|
|
||||||
- `test_permissions.md` - 权限测试文档
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
- `main.go` - 添加权限中间件和用户文章路由
|
|
||||||
- `i18n/i18n.go` - 添加翻译key
|
|
||||||
- `templates/layouts/base.html` - 简化导航菜单
|
|
||||||
- `templates/admin/dashboard.html` - 添加管理员专属按钮
|
|
||||||
|
|
||||||
## 8. 后续改进建议
|
|
||||||
|
|
||||||
1. **细粒度权限**:考虑添加编辑角色,可以编辑所有文章但不能管理用户
|
|
||||||
2. **文章协作**:允许管理员指定文章的协作者
|
|
||||||
3. **审计日志**:记录敏感操作(用户管理、设置修改)
|
|
||||||
4. **草稿分享**:生成草稿预览链接,方便审稿
|
|
||||||
5. **文章统计**:在"我的文章"页面显示阅读量、评论数等统计数据
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# 文章发布时间编辑功能
|
|
||||||
|
|
||||||
## 功能概述
|
|
||||||
|
|
||||||
为博客系统的文章编辑页面添加了自定义发布时间的功能,允许管理员和普通用户在创建或编辑文章时手动设置发布时间。
|
|
||||||
|
|
||||||
## 修改的文件
|
|
||||||
|
|
||||||
### 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. 测试中英文界面的显示
|
|
||||||
@@ -1,41 +1,50 @@
|
|||||||
# Go Blog
|
# Go Blog
|
||||||
|
|
||||||
A simple, fast blog engine built with Go, Gin, and Tailwind CSS. Supports SQLite and MySQL, with automatic first-run setup.
|
一个简洁、快速的博客引擎,使用 Go + Gin + Tailwind CSS 构建。支持 SQLite 和 MySQL,首次运行自动完成配置。
|
||||||
|
|
||||||
## Features
|
## 功能特性
|
||||||
|
|
||||||
- **Multi-language** — 中文 / English, auto-detected or manually switched
|
- **文章管理** — 文章的创建、编辑、删除,支持标签分类、自定义发布时间和更新时间
|
||||||
- **OS-aware config** — Linux `/etc/blog_go/`, Windows `./win/etc/blog_go/`
|
- **评论系统** — 文章评论功能,后台可审核(通过/拒绝/删除),支持评论设置
|
||||||
- **First-run setup** — auto-creates config, database, and admin user
|
- **用户系统** — 用户注册、登录,角色分为管理员(admin)和作者(author)
|
||||||
- **Avatar + Profile** — upload avatar, edit personal info, change password
|
- **角色权限** — 管理员可访问后台管理面板;作者可管理自己的文章
|
||||||
- **Role system** — admin sees management panel; authors see profile only
|
- **个人中心** — 上传头像(支持裁剪)、编辑个人信息、修改密码
|
||||||
- **Responsive UI** — Tailwind CSS, works on desktop and mobile
|
- **站点设置** — 自定义站点标题、副标题、favicon 图标
|
||||||
|
- **导航管理** — 自定义首页导航链接,支持图标
|
||||||
|
- **瀑布流布局** — 首页文章以瀑布流展示,支持无限滚动加载
|
||||||
|
- **阅读统计** — 文章阅读量统计,带机器人流量检测
|
||||||
|
- **附件上传** — 文章支持上传附件,基于内容寻址自动去重
|
||||||
|
- **RSS 订阅** — 自动生成 RSS Feed(`/rss`、`/feed`)
|
||||||
|
- **搜索功能** — 文章全文搜索
|
||||||
|
- **多语言** — 支持中文 / English,自动检测浏览器语言或手动切换
|
||||||
|
- **自适应界面** — Tailwind CSS,桌面端和移动端均可正常使用
|
||||||
|
- **开箱即用** — 首次运行自动创建配置文件、数据库和管理员账号
|
||||||
|
|
||||||
## Tech Stack
|
## 技术栈
|
||||||
|
|
||||||
| Layer | Library |
|
| 层级 | 库/工具 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Router | [gin](https://github.com/gin-gonic/gin) |
|
| 路由 | [gin](https://github.com/gin-gonic/gin) |
|
||||||
| ORM | [gorm](https://gorm.io) |
|
| ORM | [gorm](https://gorm.io) |
|
||||||
| SQLite | [glebarez/sqlite](https://github.com/glebarez/sqlite) (pure Go, no CGO) |
|
| SQLite | [glebarez/sqlite](https://github.com/glebarez/sqlite)(纯 Go,无需 CGO) |
|
||||||
| Sessions | [gin-contrib/sessions](https://github.com/gin-contrib/sessions) |
|
| Session | [gin-contrib/sessions](https://github.com/gin-contrib/sessions) |
|
||||||
| Config | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) |
|
| 配置 | [gopkg.in/yaml.v3](https://gopkg.in/yaml.v3) |
|
||||||
| Passwords | [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto/bcrypt) |
|
| 密码 | [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto/bcrypt) |
|
||||||
| CSS | [Tailwind CSS](https://tailwindcss.com) (CDN) |
|
| CSS | [Tailwind CSS](https://tailwindcss.com)(CDN) |
|
||||||
|
|
||||||
## Quick Start
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:8080 and log in with **admin** / **admin**.
|
打开 <http://localhost:8080> ,使用 **admin** / **admin** 登录。
|
||||||
|
|
||||||
## Configuration
|
## 配置
|
||||||
|
|
||||||
On first run, a config file is created automatically:
|
首次运行时,配置文件会自动生成:
|
||||||
|
|
||||||
| Platform | Config Path | Storage Path |
|
| 平台 | 配置文件路径 | 数据存储路径 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Linux | `/etc/blog_go/config.yaml` | `/srv/blog_go/` |
|
| Linux | `/etc/blog_go/config.yaml` | `/srv/blog_go/` |
|
||||||
| Windows | `./win/etc/blog_go/config.yaml` | `./win/srv/blog_go/` |
|
| Windows | `./win/etc/blog_go/config.yaml` | `./win/srv/blog_go/` |
|
||||||
@@ -43,16 +52,16 @@ On first run, a config file is created automatically:
|
|||||||
```yaml
|
```yaml
|
||||||
# config.yaml
|
# config.yaml
|
||||||
database:
|
database:
|
||||||
type: sqlite # sqlite (default) or mysql
|
type: sqlite # sqlite(默认)或 mysql
|
||||||
dsn: "" # MySQL DSN, ignored for sqlite
|
dsn: "" # MySQL 连接串,sqlite 模式下忽略
|
||||||
port: "8080" # web server port
|
port: "8080" # Web 服务端口
|
||||||
path: ./win/srv/blog_go # storage path (db, uploads)
|
path: ./win/srv/blog_go # 数据存储路径(数据库、上传文件)
|
||||||
secret: <auto-generated> # session encryption key
|
secret: <自动生成> # Session 加密密钥
|
||||||
```
|
```
|
||||||
|
|
||||||
### MySQL
|
### 使用 MySQL
|
||||||
|
|
||||||
Edit `config.yaml`:
|
编辑 `config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
database:
|
database:
|
||||||
@@ -61,41 +70,82 @@ database:
|
|||||||
port: "8080"
|
port: "8080"
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the database first:
|
先创建数据库:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE DATABASE blog_go CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE blog_go CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
go_blog/
|
go_blog/
|
||||||
├── main.go # Entry point, routes, middleware wiring
|
├── main.go # 入口,路由注册,中间件装配
|
||||||
├── config/
|
├── config/
|
||||||
│ └── config.go # OS-aware config load / auto-create
|
│ └── config.go # 配置加载 / 自动创建(区分操作系统)
|
||||||
├── models/
|
├── models/
|
||||||
│ ├── user.go # User model + bcrypt
|
│ ├── user.go # 用户模型 + bcrypt
|
||||||
│ └── db.go # GORM init, migration, seed
|
│ ├── article.go # 文章模型
|
||||||
|
│ ├── article_tag.go # 文章-标签关联
|
||||||
|
│ ├── article_view.go # 文章阅读统计
|
||||||
|
│ ├── attachment.go # 附件模型
|
||||||
|
│ ├── bot_detector.go # 机器人流量检测
|
||||||
|
│ ├── comment.go # 评论模型
|
||||||
|
│ ├── comment_config.go # 评论配置
|
||||||
|
│ ├── config_cache.go # 平台配置缓存
|
||||||
|
│ ├── db.go # GORM 初始化、自动迁移
|
||||||
|
│ ├── nav_link.go # 导航链接模型
|
||||||
|
│ ├── seed.go # 初始数据填充
|
||||||
|
│ ├── site_setting.go # 站点设置模型
|
||||||
|
│ ├── tag.go # 标签模型
|
||||||
|
│ └── upload_config.go # 上传配置
|
||||||
├── middleware/
|
├── middleware/
|
||||||
│ └── auth.go # Auth guard, language detection
|
│ └── auth.go # 登录验证、角色鉴权、语言检测
|
||||||
├── handlers/
|
├── handlers/
|
||||||
│ ├── helpers.go # DefaultData + getTr utilities
|
│ ├── helpers.go # 公共工具函数
|
||||||
│ ├── home.go # GET /
|
│ ├── home.go # 首页
|
||||||
│ ├── auth.go # GET/POST /login, POST /logout
|
│ ├── article.go # 文章详情、文章列表 API
|
||||||
│ ├── admin.go # GET /admin (protected)
|
│ ├── auth.go # 登录
|
||||||
│ └── profile.go # GET/POST /profile (protected)
|
│ ├── admin.go # 管理后台首页
|
||||||
|
│ ├── admin_comment.go # 评论管理
|
||||||
|
│ ├── admin_user.go # 用户管理
|
||||||
|
│ ├── admin_analytics.go # 阅读统计
|
||||||
|
│ ├── attachment.go # 附件上传/删除
|
||||||
|
│ ├── comment.go # 评论提交
|
||||||
|
│ ├── my_articles.go # 用户文章管理
|
||||||
|
│ ├── profile.go # 个人中心
|
||||||
|
│ ├── rss.go # RSS Feed
|
||||||
|
│ ├── settings.go # 站点/导航/上传/评论设置
|
||||||
|
│ └── upload_validator.go # 上传文件校验
|
||||||
├── i18n/
|
├── i18n/
|
||||||
│ └── i18n.go # EN/ZH translation maps + Accept-Language
|
│ └── i18n.go # 中英文翻译映射 + Accept-Language 检测
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── layouts/base.html # Header (nav + avatar dropdown) + footer
|
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
|
||||||
│ ├── pages/
|
│ ├── pages/
|
||||||
│ │ ├── home.html # Public home page
|
│ │ ├── home.html # 首页(瀑布流 + 无限滚动)
|
||||||
│ │ ├── login.html # Login form
|
│ │ ├── article.html # 文章详情页
|
||||||
│ │ └── profile.html # Edit profile page
|
│ │ ├── login.html # 登录页
|
||||||
│ └── admin/
|
│ │ ├── register.html # 注册页
|
||||||
│ └── dashboard.html # Admin dashboard
|
│ │ ├── profile.html # 个人中心
|
||||||
└── win/ # Auto-created runtime data (Windows)
|
│ │ ├── search.html # 搜索页
|
||||||
|
│ │ └── 404.html # 404 页面
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── dashboard.html # 管理后台首页
|
||||||
|
│ │ ├── article_list.html # 文章列表
|
||||||
|
│ │ ├── article_create.html# 文章编辑
|
||||||
|
│ │ ├── comment_list.html # 评论审核
|
||||||
|
│ │ ├── user_list.html # 用户列表
|
||||||
|
│ │ ├── user_form.html # 用户表单
|
||||||
|
│ │ ├── analytics_views.html # 阅读统计
|
||||||
|
│ │ ├── settings_site.html # 站点设置
|
||||||
|
│ │ ├── settings_navlinks.html # 导航链接设置
|
||||||
|
│ │ ├── settings_upload.html # 上传设置
|
||||||
|
│ │ ├── settings_download.html # 下载设置
|
||||||
|
│ │ └── settings_comment.html # 评论设置
|
||||||
|
│ └── user/
|
||||||
|
│ ├── my_articles.html # 我的文章列表
|
||||||
|
│ └── my_article_form.html # 我的文章编辑
|
||||||
|
└── win/ # 运行时数据目录(Windows,自动创建)
|
||||||
├── etc/blog_go/
|
├── etc/blog_go/
|
||||||
│ └── config.yaml
|
│ └── config.yaml
|
||||||
└── srv/blog_go/
|
└── srv/blog_go/
|
||||||
@@ -103,26 +153,73 @@ go_blog/
|
|||||||
└── avatars/
|
└── avatars/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Routes
|
## 路由
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
### 公开路由
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/` | No | Home page |
|
|
||||||
| GET | `/login` | No | Login form |
|
|
||||||
| POST | `/login` | No | Submit login |
|
|
||||||
| POST | `/logout` | No | Clear session |
|
|
||||||
| GET | `/profile` | Yes | Edit profile |
|
|
||||||
| POST | `/profile` | Yes | Save profile |
|
|
||||||
| GET | `/admin` | Yes | Admin dashboard (admin only) |
|
|
||||||
| GET | `/uploads/*` | No | Static files (avatars, etc.) |
|
|
||||||
|
|
||||||
## User Roles
|
| 方法 | 路径 | 说明 |
|
||||||
|
|
||||||
| Role | Profile | Admin Dashboard |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `admin` | ✓ | ✓ |
|
| GET | `/` | 首页 |
|
||||||
| `author` | ✓ | ✗ |
|
| GET | `/search` | 搜索页 |
|
||||||
|
| GET | `/api/articles` | 文章列表 API(无限滚动) |
|
||||||
|
| GET | `/rss`、`/feed` | RSS 订阅 |
|
||||||
|
| GET | `/article/:slug` | 文章详情 |
|
||||||
|
| POST | `/article/:slug/comments` | 发表评论 |
|
||||||
|
| GET | `/login` | 登录页 |
|
||||||
|
| POST | `/login` | 提交登录 |
|
||||||
|
| GET | `/register` | 注册页 |
|
||||||
|
| POST | `/register` | 提交注册 |
|
||||||
|
| POST | `/logout` | 退出登录 |
|
||||||
|
| GET | `/uploads/*` | 静态文件(头像、附件等) |
|
||||||
|
|
||||||
## License
|
### 管理后台(需管理员权限)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/admin` | 管理后台首页 |
|
||||||
|
| GET | `/admin/articles` | 文章列表 |
|
||||||
|
| GET/POST | `/admin/articles/new` | 新建文章 |
|
||||||
|
| GET/POST | `/admin/articles/:id/edit` | 编辑文章 |
|
||||||
|
| POST | `/admin/articles/:id/delete` | 删除文章 |
|
||||||
|
| POST | `/admin/articles/attachments` | 上传附件 |
|
||||||
|
| POST | `/admin/articles/attachments/:id/delete` | 删除附件 |
|
||||||
|
| GET | `/admin/articles/:id/attachments` | 附件列表 |
|
||||||
|
| GET | `/admin/comments` | 评论管理 |
|
||||||
|
| POST | `/admin/comments/:id/approve` | 通过评论 |
|
||||||
|
| POST | `/admin/comments/:id/reject` | 拒绝评论 |
|
||||||
|
| POST | `/admin/comments/:id/delete` | 删除评论 |
|
||||||
|
| GET | `/admin/users` | 用户列表 |
|
||||||
|
| GET/POST | `/admin/users/new` | 新建用户 |
|
||||||
|
| GET/POST | `/admin/users/:id/edit` | 编辑用户 |
|
||||||
|
| POST | `/admin/users/:id/delete` | 删除用户 |
|
||||||
|
| GET | `/admin/analytics/views` | 阅读统计 |
|
||||||
|
| GET/POST | `/admin/settings/site` | 站点设置 |
|
||||||
|
| GET/POST | `/admin/settings/navlinks` | 导航链接设置 |
|
||||||
|
| GET/POST | `/admin/settings/upload` | 上传设置 |
|
||||||
|
| GET/POST | `/admin/settings/download` | 下载设置 |
|
||||||
|
| GET/POST | `/admin/settings/comments` | 评论设置 |
|
||||||
|
|
||||||
|
### 个人中心(需登录)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET/POST | `/profile` | 编辑个人信息 |
|
||||||
|
| POST | `/profile/avatar` | 上传头像 |
|
||||||
|
| GET | `/my/articles` | 我的文章列表 |
|
||||||
|
| GET/POST | `/my/articles/new` | 新建文章 |
|
||||||
|
| GET/POST | `/my/articles/:id/edit` | 编辑文章 |
|
||||||
|
| POST | `/my/articles/:id/delete` | 删除文章 |
|
||||||
|
| POST | `/my/articles/attachments` | 上传附件 |
|
||||||
|
| POST | `/my/articles/attachments/:id/delete` | 删除附件 |
|
||||||
|
| GET | `/my/articles/:id/attachments` | 附件列表 |
|
||||||
|
|
||||||
|
## 用户角色
|
||||||
|
|
||||||
|
| 角色 | 个人中心 | 我的文章 | 管理后台 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `admin` | ✓ | ✓ | ✓ |
|
||||||
|
| `author` | ✓ | ✓ | ✗ |
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
# 标签筛选功能修复总结
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
用户报告:无法通过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. ✅ 使用搜索和标签功能
|
|
||||||
|
|
||||||
该功能已经可以正常使用,所有核心功能都在正常工作。
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
# 标签和搜索功能实施总结
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
成功实现了完整的标签管理系统和文章搜索功能,允许用户通过标签分类文章,并通过关键字搜索文章。
|
|
||||||
|
|
||||||
## 已完成的功能
|
|
||||||
|
|
||||||
### 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. ✅ 性能优化
|
|
||||||
|
|
||||||
系统现在可以支持更好的内容组织和发现体验。用户可以通过标签快速找到相关主题的文章,也可以通过搜索功能直接查找感兴趣的内容。
|
|
||||||
+28
-5
@@ -14,7 +14,7 @@ import (
|
|||||||
// Config holds all application configuration.
|
// Config holds all application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Database DatabaseConfig `yaml:"database"`
|
Database DatabaseConfig `yaml:"database"`
|
||||||
Port string `yaml:"port"`
|
Web WebConfig `yaml:"web"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,12 @@ type DatabaseConfig struct {
|
|||||||
DSN string `yaml:"dsn"` // MySQL connection string (required when type is "mysql")
|
DSN string `yaml:"dsn"` // MySQL connection string (required when type is "mysql")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebConfig holds web-server listening configuration.
|
||||||
|
type WebConfig struct {
|
||||||
|
Port string `yaml:"port"` // TCP port, "" or "0" to disable
|
||||||
|
Socket string `yaml:"socket"` // Unix socket path, "" to disable
|
||||||
|
}
|
||||||
|
|
||||||
const defaultPort = "8080"
|
const defaultPort = "8080"
|
||||||
|
|
||||||
// mysqlExampleDSN is written into new config files as a reference.
|
// mysqlExampleDSN is written into new config files as a reference.
|
||||||
@@ -64,9 +70,21 @@ func generateSecret() string {
|
|||||||
return fmt.Sprintf("%x", hash)
|
return fmt.Sprintf("%x", hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDefaultSocketPath returns the OS-aware default unix socket path.
|
||||||
|
func getDefaultSocketPath() string {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
return "/run/blog_go/web.sock"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
// LoadConfig reads the config file or creates one with defaults.
|
// LoadConfig reads the config file or creates one with defaults.
|
||||||
func LoadConfig() *Config {
|
// If customPath is non-empty, it overrides the OS-aware config file path.
|
||||||
|
func LoadConfig(customPath string) *Config {
|
||||||
configDir, configFile := getConfigPath()
|
configDir, configFile := getConfigPath()
|
||||||
|
if customPath != "" {
|
||||||
|
configFile = customPath
|
||||||
|
configDir = filepath.Dir(configFile)
|
||||||
|
}
|
||||||
defaultPath := getDefaultStoragePath()
|
defaultPath := getDefaultStoragePath()
|
||||||
|
|
||||||
// Check if config file exists; create with defaults if not.
|
// Check if config file exists; create with defaults if not.
|
||||||
@@ -81,7 +99,10 @@ func LoadConfig() *Config {
|
|||||||
Type: "sqlite",
|
Type: "sqlite",
|
||||||
DSN: mysqlExampleDSN,
|
DSN: mysqlExampleDSN,
|
||||||
},
|
},
|
||||||
Port: defaultPort,
|
Web: WebConfig{
|
||||||
|
Port: defaultPort,
|
||||||
|
Socket: getDefaultSocketPath(),
|
||||||
|
},
|
||||||
Path: defaultPath,
|
Path: defaultPath,
|
||||||
Secret: generateSecret(),
|
Secret: generateSecret(),
|
||||||
}
|
}
|
||||||
@@ -116,8 +137,10 @@ func LoadConfig() *Config {
|
|||||||
|
|
||||||
// applyDefaults fills zero-value fields with sensible defaults.
|
// applyDefaults fills zero-value fields with sensible defaults.
|
||||||
func applyDefaults(cfg *Config, defaultPath string) *Config {
|
func applyDefaults(cfg *Config, defaultPath string) *Config {
|
||||||
if cfg.Port == "" {
|
// If the entire web block is empty (old config without "web" key),
|
||||||
cfg.Port = defaultPort
|
// fill default port so the app still starts on 8080.
|
||||||
|
if cfg.Web.Port == "" && cfg.Web.Socket == "" {
|
||||||
|
cfg.Web.Port = defaultPort
|
||||||
}
|
}
|
||||||
if cfg.Database.Type == "" {
|
if cfg.Database.Type == "" {
|
||||||
cfg.Database.Type = "sqlite"
|
cfg.Database.Type = "sqlite"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -19,6 +20,11 @@ func LoginPage() gin.HandlerFunc {
|
|||||||
if c.Query("error") == "1" {
|
if c.Query("error") == "1" {
|
||||||
data["Error"] = tr["login_error"]
|
data["Error"] = tr["login_error"]
|
||||||
}
|
}
|
||||||
|
// Check if registration is allowed from site settings
|
||||||
|
siteSetting, _ := c.Get("site_setting")
|
||||||
|
if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil {
|
||||||
|
data["AllowRegistration"] = s.AllowRegistration
|
||||||
|
}
|
||||||
c.HTML(http.StatusOK, "login", data)
|
c.HTML(http.StatusOK, "login", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,3 +79,106 @@ func Logout() gin.HandlerFunc {
|
|||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterPage renders the registration form (only when registration is enabled).
|
||||||
|
func RegisterPage(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Check if registration is allowed
|
||||||
|
var s models.SiteSetting
|
||||||
|
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tr := getTr(c)
|
||||||
|
data := DefaultData(c)
|
||||||
|
data["Title"] = tr["page_register"]
|
||||||
|
|
||||||
|
if errMsg := c.Query("error"); errMsg != "" {
|
||||||
|
data["Error"] = tr[errMsg]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "register", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register processes the registration form submission.
|
||||||
|
func Register(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// Check if registration is allowed
|
||||||
|
var s models.SiteSetting
|
||||||
|
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(c.PostForm("username"))
|
||||||
|
password := c.PostForm("password")
|
||||||
|
confirmPassword := c.PostForm("confirm_password")
|
||||||
|
email := strings.TrimSpace(c.PostForm("email"))
|
||||||
|
displayName := strings.TrimSpace(c.PostForm("display_name"))
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if username == "" || password == "" {
|
||||||
|
c.Redirect(http.StatusFound, "/register?error=register_required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(username) < 3 || len(username) > 32 {
|
||||||
|
c.Redirect(http.StatusFound, "/register?error=register_username_length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < 6 {
|
||||||
|
c.Redirect(http.StatusFound, "/register?error=register_password_length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if password != confirmPassword {
|
||||||
|
c.Redirect(http.StatusFound, "/register?error=register_password_mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
var existingUser models.User
|
||||||
|
if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil {
|
||||||
|
c.Redirect(http.StatusFound, "/register?error=user_username_exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
user := models.User{
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
DisplayName: displayName,
|
||||||
|
Role: models.RoleAuthor,
|
||||||
|
Status: models.StatusNormal,
|
||||||
|
}
|
||||||
|
|
||||||
|
if displayName == "" {
|
||||||
|
user.DisplayName = username
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.SetPassword(password); err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/register?error=register_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/register?error=register_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-login after successful registration
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set("user_id", user.ID)
|
||||||
|
session.Set("username", user.Username)
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to home page
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -183,7 +183,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc {
|
|||||||
Content: sanitizeMarkdown(form.Content),
|
Content: sanitizeMarkdown(form.Content),
|
||||||
IsPrivate: form.IsPrivate,
|
IsPrivate: form.IsPrivate,
|
||||||
Status: models.CommentApproved,
|
Status: models.CommentApproved,
|
||||||
IPAddress: truncate(c.ClientIP(), 64),
|
IPAddress: truncate(GetClientIP(c), 64),
|
||||||
UserAgent: truncate(c.GetHeader("User-Agent"), 512),
|
UserAgent: truncate(c.GetHeader("User-Agent"), 512),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ func DefaultData(c *gin.Context) gin.H {
|
|||||||
siteHomeWelcome, _ := c.Get("site_home_welcome")
|
siteHomeWelcome, _ := c.Get("site_home_welcome")
|
||||||
siteHomeSubtitle, _ := c.Get("site_home_subtitle")
|
siteHomeSubtitle, _ := c.Get("site_home_subtitle")
|
||||||
siteFooterText, _ := c.Get("site_footer_text")
|
siteFooterText, _ := c.Get("site_footer_text")
|
||||||
|
navLinks, _ := c.Get("nav_links")
|
||||||
|
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"Tr": tr,
|
"Tr": tr,
|
||||||
@@ -46,6 +49,7 @@ func DefaultData(c *gin.Context) gin.H {
|
|||||||
"SiteHomeWelcome": siteHomeWelcome,
|
"SiteHomeWelcome": siteHomeWelcome,
|
||||||
"SiteHomeSubtitle": siteHomeSubtitle,
|
"SiteHomeSubtitle": siteHomeSubtitle,
|
||||||
"SiteFooterText": siteFooterText,
|
"SiteFooterText": siteFooterText,
|
||||||
|
"NavLinks": navLinks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,3 +66,18 @@ func getTr(c *gin.Context) map[string]string {
|
|||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetClientIP extracts the real client IP address, accounting for CDN/reverse proxy setups.
|
||||||
|
// It checks X-Forwarded-For and X-Real-IP headers before falling back to c.ClientIP().
|
||||||
|
func GetClientIP(c *gin.Context) string {
|
||||||
|
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||||
|
if i := strings.IndexByte(xff, ','); i != -1 {
|
||||||
|
return strings.TrimSpace(xff[:i])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(xff)
|
||||||
|
}
|
||||||
|
if xri := c.GetHeader("X-Real-IP"); xri != "" {
|
||||||
|
return strings.TrimSpace(xri)
|
||||||
|
}
|
||||||
|
return c.ClientIP()
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -306,7 +306,7 @@ func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get client IP and User-Agent
|
// Get client IP and User-Agent
|
||||||
ip := c.ClientIP()
|
ip := GetClientIP(c)
|
||||||
userAgent := c.Request.UserAgent()
|
userAgent := c.Request.UserAgent()
|
||||||
isBot := models.IsBot(userAgent)
|
isBot := models.IsBot(userAgent)
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
|||||||
s.HomeSubtitleEn = strings.TrimSpace(c.PostForm("home_subtitle_en"))
|
s.HomeSubtitleEn = strings.TrimSpace(c.PostForm("home_subtitle_en"))
|
||||||
s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh"))
|
s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh"))
|
||||||
s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en"))
|
s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en"))
|
||||||
|
s.AllowRegistration = c.PostForm("allow_registration") == "1"
|
||||||
s.UpdatedBy = userIDFromSession(c)
|
s.UpdatedBy = userIDFromSession(c)
|
||||||
|
|
||||||
// Favicon upload (optional). A favicon_url form field takes precedence over an
|
// Favicon upload (optional). A favicon_url form field takes precedence over an
|
||||||
@@ -427,3 +428,104 @@ func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1")
|
c.Redirect(http.StatusFound, "/admin/settings/comments?saved=1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- Navigation Links settings ----------------
|
||||||
|
|
||||||
|
// NavLinksSettingsPage renders the navigation links management page.
|
||||||
|
func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tr := getTr(c)
|
||||||
|
var links []models.NavLink
|
||||||
|
db.Order("sort asc, id asc").Find(&links)
|
||||||
|
|
||||||
|
data := DefaultData(c)
|
||||||
|
data["Title"] = tr["settings_navlinks_title"]
|
||||||
|
data["NavLinks"] = links
|
||||||
|
if msg := c.Query("saved"); msg == "1" {
|
||||||
|
data["Success"] = tr["settings_saved"]
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "settings_navlinks", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NavLinksSettingsSave dispatches navigation link actions.
|
||||||
|
func NavLinksSettingsSave(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
switch c.PostForm("action") {
|
||||||
|
case "add":
|
||||||
|
addNavLink(db, c)
|
||||||
|
case "toggle":
|
||||||
|
toggleNavLink(db, c)
|
||||||
|
case "edit":
|
||||||
|
editNavLink(db, c)
|
||||||
|
case "delete":
|
||||||
|
deleteNavLink(db, c)
|
||||||
|
}
|
||||||
|
models.RefreshConfigCache(db)
|
||||||
|
c.Redirect(http.StatusFound, "/admin/settings/navlinks?saved=1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNavLink(db *gorm.DB, c *gin.Context) {
|
||||||
|
titleZh := strings.TrimSpace(c.PostForm("title_zh"))
|
||||||
|
titleEn := strings.TrimSpace(c.PostForm("title_en"))
|
||||||
|
url := strings.TrimSpace(c.PostForm("url"))
|
||||||
|
|
||||||
|
if url == "" || (titleZh == "" && titleEn == "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sort, _ := strconv.Atoi(c.PostForm("sort"))
|
||||||
|
|
||||||
|
link := models.NavLink{
|
||||||
|
TitleZh: titleZh,
|
||||||
|
TitleEn: titleEn,
|
||||||
|
URL: url,
|
||||||
|
OpenNew: c.PostForm("open_new") == "1",
|
||||||
|
Enabled: c.PostForm("enabled") != "0",
|
||||||
|
Sort: sort,
|
||||||
|
UpdatedBy: userIDFromSession(c),
|
||||||
|
}
|
||||||
|
db.Create(&link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleNavLink(db *gorm.DB, c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||||
|
var link models.NavLink
|
||||||
|
if db.First(&link, id).Error != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link.Enabled = !link.Enabled
|
||||||
|
link.UpdatedBy = userIDFromSession(c)
|
||||||
|
db.Save(&link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func editNavLink(db *gorm.DB, c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||||
|
var link models.NavLink
|
||||||
|
if db.First(&link, id).Error != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
titleZh := strings.TrimSpace(c.PostForm("title_zh"))
|
||||||
|
titleEn := strings.TrimSpace(c.PostForm("title_en"))
|
||||||
|
url := strings.TrimSpace(c.PostForm("url"))
|
||||||
|
|
||||||
|
if url == "" || (titleZh == "" && titleEn == "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link.TitleZh = titleZh
|
||||||
|
link.TitleEn = titleEn
|
||||||
|
link.URL = url
|
||||||
|
link.OpenNew = c.PostForm("open_new") == "1"
|
||||||
|
link.Sort, _ = strconv.Atoi(c.PostForm("sort"))
|
||||||
|
link.UpdatedBy = userIDFromSession(c)
|
||||||
|
db.Save(&link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteNavLink(db *gorm.DB, c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.PostForm("id"))
|
||||||
|
db.Delete(&models.NavLink{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE_NAME="blog_go"
|
||||||
|
SERVICE_USER="blog_go"
|
||||||
|
CONFIG_DIR="/etc/${SERVICE_NAME}"
|
||||||
|
DATA_DIR="/srv/${SERVICE_NAME}"
|
||||||
|
INSTALL_DIR="/opt/${SERVICE_NAME}"
|
||||||
|
BINARY_NAME="${SERVICE_NAME}"
|
||||||
|
SOCKET_DIR="/run/${SERVICE_NAME}"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
echo "请使用 root 权限运行: sudo $0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
echo "拉取最新代码..."
|
||||||
|
git pull
|
||||||
|
|
||||||
|
echo "编译 Go 程序..."
|
||||||
|
go build -o "${BINARY_NAME}" .
|
||||||
|
|
||||||
|
echo "检查系统用户..."
|
||||||
|
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||||
|
useradd --system --home-dir "${DATA_DIR}" --shell /usr/sbin/nologin "${SERVICE_USER}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "创建目录..."
|
||||||
|
install -d -m 0750 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${CONFIG_DIR}" "${DATA_DIR}"
|
||||||
|
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${INSTALL_DIR}"
|
||||||
|
install -d -m 0755 "${SOCKET_DIR}"
|
||||||
|
chown "${SERVICE_USER}:${SERVICE_USER}" "${SOCKET_DIR}"
|
||||||
|
|
||||||
|
echo "安装程序和模板文件..."
|
||||||
|
install -m 0755 -o root -g root "${SCRIPT_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||||
|
rm -rf "${INSTALL_DIR}/templates"
|
||||||
|
cp -a "${SCRIPT_DIR}/templates" "${INSTALL_DIR}/templates"
|
||||||
|
chown -R root:root "${INSTALL_DIR}/templates"
|
||||||
|
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||||
|
chmod 0755 "${INSTALL_DIR}"
|
||||||
|
find "${INSTALL_DIR}/templates" -type d -exec chmod 0755 {} \;
|
||||||
|
find "${INSTALL_DIR}/templates" -type f -exec chmod 0644 {} \;
|
||||||
|
|
||||||
|
SOCKET_PATH="${SOCKET_DIR}/web.sock"
|
||||||
|
|
||||||
|
if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
|
||||||
|
SECRET=$(openssl rand -hex 32)
|
||||||
|
cat > "${CONFIG_DIR}/config.yaml" <<EOF
|
||||||
|
database:
|
||||||
|
type: sqlite
|
||||||
|
dsn: ""
|
||||||
|
web:
|
||||||
|
port: "8080"
|
||||||
|
socket: ${SOCKET_PATH}
|
||||||
|
path: ${DATA_DIR}
|
||||||
|
secret: ${SECRET}
|
||||||
|
EOF
|
||||||
|
chown "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}/config.yaml"
|
||||||
|
chmod 0640 "${CONFIG_DIR}/config.yaml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "写入 systemd 服务文件..."
|
||||||
|
cat > "${SERVICE_FILE}" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Blog Go Service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${SERVICE_USER}
|
||||||
|
Group=${SERVICE_USER}
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
ExecStart=${INSTALL_DIR}/${BINARY_NAME} -config ${CONFIG_DIR}/config.yaml
|
||||||
|
ExecStartPost=/bin/sh -c 'while [ ! -S ${SOCKET_DIR}/web.sock ]; do sleep 0.1; done; chmod 666 ${SOCKET_DIR}/web.sock'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ReadWritePaths=${CONFIG_DIR} ${DATA_DIR} ${INSTALL_DIR} ${SOCKET_DIR}
|
||||||
|
RuntimeDirectory=${SERVICE_NAME}
|
||||||
|
RuntimeDirectoryMode=0755
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${SERVICE_NAME}"
|
||||||
|
systemctl restart "${SERVICE_NAME}"
|
||||||
|
|
||||||
|
echo "部署完成,服务状态:"
|
||||||
|
systemctl --no-pager --full status "${SERVICE_NAME}"
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-contrib/sessions/cookie"
|
"github.com/gin-contrib/sessions/cookie"
|
||||||
@@ -15,8 +18,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// 0. Parse command-line flags.
|
||||||
|
configFlag := flag.String("config", "", "path to config file (default: OS-aware path)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
// 1. Load configuration (auto-creates if missing).
|
// 1. Load configuration (auto-creates if missing).
|
||||||
cfg := config.LoadConfig()
|
cfg := config.LoadConfig(*configFlag)
|
||||||
|
|
||||||
// 2. Initialize the database (auto-migrates, seeds admin).
|
// 2. Initialize the database (auto-migrates, seeds admin).
|
||||||
db := models.InitDB(cfg)
|
db := models.InitDB(cfg)
|
||||||
@@ -161,9 +168,39 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 9. Start the server.
|
// 9. Start the server.
|
||||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
webPort := cfg.Web.Port
|
||||||
log.Printf("Go Blog starting on http://localhost%s", addr)
|
socketPath := cfg.Web.Socket
|
||||||
if err := router.Run(addr); err != nil {
|
usePort := webPort != "" && webPort != "0"
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
useSocket := socketPath != ""
|
||||||
|
|
||||||
|
if !usePort && !useSocket {
|
||||||
|
log.Fatalf("Neither port nor socket is configured — at least one must be enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if usePort {
|
||||||
|
go func() {
|
||||||
|
addr := fmt.Sprintf(":%s", webPort)
|
||||||
|
log.Printf("Go Blog starting on http://localhost%s", addr)
|
||||||
|
if err := router.Run(addr); err != nil {
|
||||||
|
log.Fatalf("Failed to start HTTP server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if useSocket {
|
||||||
|
go func() {
|
||||||
|
os.Remove(socketPath) // remove stale socket file if exists
|
||||||
|
listener, err := net.Listen("unix", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to listen on unix socket %s: %v", socketPath, err)
|
||||||
|
}
|
||||||
|
log.Printf("Go Blog starting on unix socket %s", socketPath)
|
||||||
|
if err := router.RunListener(listener); err != nil {
|
||||||
|
log.Fatalf("Failed to serve on unix socket: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block forever.
|
||||||
|
select {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.Set("site_home_subtitle", site.HomeSubtitle(string(lang)))
|
c.Set("site_home_subtitle", site.HomeSubtitle(string(lang)))
|
||||||
c.Set("site_footer_text", site.FooterText(string(lang)))
|
c.Set("site_footer_text", site.FooterText(string(lang)))
|
||||||
|
|
||||||
|
// --- Navigation links (from DB cache) ---
|
||||||
|
navLinks := models.GetNavLinks()
|
||||||
|
c.Set("nav_links", navLinks)
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ var configCache = struct {
|
|||||||
comment *CommentConfig
|
comment *CommentConfig
|
||||||
types []UploadFileType
|
types []UploadFileType
|
||||||
baseURLs []DownloadBaseURL
|
baseURLs []DownloadBaseURL
|
||||||
|
navLinks []NavLink
|
||||||
}{
|
}{
|
||||||
site: &SiteSetting{},
|
site: &SiteSetting{},
|
||||||
upload: &UploadConfig{Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"},
|
upload: &UploadConfig{Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"},
|
||||||
@@ -58,6 +59,11 @@ func LoadConfigCache(db *gorm.DB) {
|
|||||||
if err := db.Order("is_default desc, priority asc, id asc").Find(&b).Error; err == nil {
|
if err := db.Order("is_default desc, priority asc, id asc").Find(&b).Error; err == nil {
|
||||||
configCache.baseURLs = b
|
configCache.baseURLs = b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var n []NavLink
|
||||||
|
if err := db.Where("enabled = ?", true).Order("sort asc, id asc").Find(&n).Error; err == nil {
|
||||||
|
configCache.navLinks = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshConfigCache reloads all cached platform configuration. Admin handlers
|
// RefreshConfigCache reloads all cached platform configuration. Admin handlers
|
||||||
@@ -118,3 +124,10 @@ func DefaultDownloadBaseURL() string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNavLinks returns the cached list of enabled navigation links, sorted by sort order.
|
||||||
|
func GetNavLinks() []NavLink {
|
||||||
|
configCache.mu.RLock()
|
||||||
|
defer configCache.mu.RUnlock()
|
||||||
|
return configCache.navLinks
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// NavLink represents a custom navigation link in the header.
|
||||||
|
type NavLink struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
TitleZh string `gorm:"size:100;not null" json:"title_zh"` // Link text (Chinese)
|
||||||
|
TitleEn string `gorm:"size:100;not null" json:"title_en"` // Link text (English)
|
||||||
|
URL string `gorm:"size:512;not null" json:"url"` // Target URL
|
||||||
|
OpenNew bool `gorm:"default:false" json:"open_new"` // Open in new window
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"` // Show/hide link
|
||||||
|
Sort int `gorm:"default:0;index" json:"sort"` // Display order (lower = first)
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName overrides the default GORM table name.
|
||||||
|
func (NavLink) TableName() string {
|
||||||
|
return "nav_links"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title returns the link text for the given language code, falling back to
|
||||||
|
// the other language when the requested one is empty.
|
||||||
|
func (n *NavLink) Title(lang string) string {
|
||||||
|
if lang == "zh" {
|
||||||
|
if n.TitleZh != "" {
|
||||||
|
return n.TitleZh
|
||||||
|
}
|
||||||
|
return n.TitleEn
|
||||||
|
}
|
||||||
|
if n.TitleEn != "" {
|
||||||
|
return n.TitleEn
|
||||||
|
}
|
||||||
|
return n.TitleZh
|
||||||
|
}
|
||||||
+16
-15
@@ -6,21 +6,22 @@ import "time"
|
|||||||
// logo, top-left title, header banner text, and footer text, each with
|
// logo, top-left title, header banner text, and footer text, each with
|
||||||
// zh/en variants that fall back to i18n defaults when empty.
|
// zh/en variants that fall back to i18n defaults when empty.
|
||||||
type SiteSetting struct {
|
type SiteSetting struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Logo string `gorm:"size:512" json:"logo"` // local filename (served under /uploads/logos) OR a full URL
|
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
|
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)
|
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)
|
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)
|
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh)
|
||||||
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en)
|
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en)
|
||||||
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // home page welcome heading (zh)
|
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // home page welcome heading (zh)
|
||||||
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // home page welcome heading (en)
|
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // home page welcome heading (en)
|
||||||
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"` // home page subtitle (zh)
|
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"` // home page subtitle (zh)
|
||||||
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (en)
|
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (en)
|
||||||
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh)
|
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh)
|
||||||
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en)
|
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en)
|
||||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // whether users can self-register
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName overrides the default GORM table name.
|
// TableName overrides the default GORM table name.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 mb-8">
|
<div class="flex gap-2 mb-8">
|
||||||
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
|
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
|
||||||
|
<a href="/admin/settings/navlinks" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_navlinks_title"}}</a>
|
||||||
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
|
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
|
||||||
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_download_title"}}</a>
|
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_download_title"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
{{define "settings_navlinks"}}
|
||||||
|
{{template "header" .}}
|
||||||
|
<section class="max-w-3xl mx-auto px-4 py-12">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 mb-2">{{index .Tr "settings_navlinks_title"}}</h2>
|
||||||
|
<p class="text-gray-500 mb-4">{{index .Tr "settings_navlinks_desc"}}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mb-8">
|
||||||
|
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
|
||||||
|
<a href="/admin/settings/navlinks" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_navlinks_title"}}</a>
|
||||||
|
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
|
||||||
|
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Success}}
|
||||||
|
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">{{.Success}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Add New Link Form -->
|
||||||
|
<form action="/admin/settings/navlinks" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_add"}}</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_title_zh"}}</label>
|
||||||
|
<input type="text" name="title_zh" placeholder="首页" required
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_title_en"}}</label>
|
||||||
|
<input type="text" name="title_en" placeholder="Home" required
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_url"}}</label>
|
||||||
|
<input type="text" name="url" placeholder="/about" required
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{index .Tr "navlinks_url_hint"}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_sort"}}</label>
|
||||||
|
<input type="number" name="sort" value="0" min="0"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="open_new" value="1" class="rounded border-gray-300">
|
||||||
|
<span class="text-sm text-gray-700">{{index .Tr "navlinks_open_new"}}</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="enabled" value="1" checked class="rounded border-gray-300">
|
||||||
|
<span class="text-sm text-gray-700">{{index .Tr "navlinks_enabled"}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||||
|
{{index .Tr "navlinks_add"}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Existing Links List -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_enabled"}}</h3>
|
||||||
|
|
||||||
|
{{if .NavLinks}}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{{range .NavLinks}}
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors" id="link-{{.ID}}">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="font-medium text-gray-900">{{if .TitleZh}}{{.TitleZh}}{{else}}{{.TitleEn}}{{end}}</span>
|
||||||
|
{{if not .Enabled}}
|
||||||
|
<span class="px-2 py-0.5 bg-gray-200 text-gray-600 text-xs rounded">{{index $.Tr "status_disabled"}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .OpenNew}}
|
||||||
|
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded">↗ {{index $.Tr "navlinks_open_new"}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
<div><strong>ZH:</strong> {{.TitleZh}} | <strong>EN:</strong> {{.TitleEn}}</div>
|
||||||
|
<div><strong>URL:</strong> <a href="{{.URL}}" target="_blank" class="text-blue-600 hover:underline">{{.URL}}</a></div>
|
||||||
|
<div><strong>{{index $.Tr "navlinks_sort"}}:</strong> {{.Sort}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Toggle Button -->
|
||||||
|
<form action="/admin/settings/navlinks" method="post" class="inline">
|
||||||
|
<input type="hidden" name="action" value="toggle">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg border {{if .Enabled}}bg-green-50 border-green-300 text-green-700 hover:bg-green-100{{else}}bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200{{end}} transition-colors">
|
||||||
|
{{if .Enabled}}✓{{else}}✗{{end}} {{index $.Tr "settings_toggle"}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<!-- Edit Button -->
|
||||||
|
<button onclick="editLink({{.ID}}, '{{.TitleZh}}', '{{.TitleEn}}', '{{.URL}}', {{if .OpenNew}}true{{else}}false{{end}}, {{.Sort}})" class="text-sm px-3 py-1.5 bg-blue-50 border border-blue-300 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors">
|
||||||
|
{{index $.Tr "navlinks_edit"}}
|
||||||
|
</button>
|
||||||
|
<!-- Delete Button -->
|
||||||
|
<form action="/admin/settings/navlinks" method="post" class="inline" onsubmit="return confirm('{{index $.Tr "navlinks_confirm_delete"}}')">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button type="submit" class="text-sm px-3 py-1.5 bg-red-50 border border-red-300 text-red-700 rounded-lg hover:bg-red-100 transition-colors">
|
||||||
|
{{index $.Tr "navlinks_delete"}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-500 text-center py-8">{{index .Tr "navlinks_no_links"}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div id="editModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 p-6">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">{{index .Tr "navlinks_edit"}}</h3>
|
||||||
|
<form action="/admin/settings/navlinks" method="post" id="editForm">
|
||||||
|
<input type="hidden" name="action" value="edit">
|
||||||
|
<input type="hidden" name="id" id="edit_id">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_title_zh"}}</label>
|
||||||
|
<input type="text" name="title_zh" id="edit_title_zh" required
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_title_en"}}</label>
|
||||||
|
<input type="text" name="title_en" id="edit_title_en" required
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_url"}}</label>
|
||||||
|
<input type="text" name="url" id="edit_url" required
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "navlinks_sort"}}</label>
|
||||||
|
<input type="number" name="sort" id="edit_sort" min="0"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="open_new" id="edit_open_new" value="1" class="rounded border-gray-300">
|
||||||
|
<span class="text-sm text-gray-700">{{index .Tr "navlinks_open_new"}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button type="button" onclick="closeEditModal()" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||||
|
{{index .Tr "navlinks_cancel"}}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
{{index .Tr "navlinks_save"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function editLink(id, titleZh, titleEn, url, openNew, sort) {
|
||||||
|
document.getElementById('edit_id').value = id;
|
||||||
|
document.getElementById('edit_title_zh').value = titleZh;
|
||||||
|
document.getElementById('edit_title_en').value = titleEn;
|
||||||
|
document.getElementById('edit_url').value = url;
|
||||||
|
document.getElementById('edit_open_new').checked = openNew;
|
||||||
|
document.getElementById('edit_sort').value = sort;
|
||||||
|
document.getElementById('editModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('editModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
document.getElementById('editModal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeEditModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{template "footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 mb-8">
|
<div class="flex gap-2 mb-8">
|
||||||
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_site_title"}}</a>
|
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_site_title"}}</a>
|
||||||
|
<a href="/admin/settings/navlinks" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_navlinks_title"}}</a>
|
||||||
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
|
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_upload_title"}}</a>
|
||||||
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +128,18 @@
|
|||||||
|
|
||||||
<p class="text-xs text-gray-400">{{index .Tr "settings_leave_blank"}}</p>
|
<p class="text-xs text-gray-400">{{index .Tr "settings_leave_blank"}}</p>
|
||||||
|
|
||||||
|
<!-- Registration settings -->
|
||||||
|
<div class="pt-4 border-t border-gray-200">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="allow_registration" value="1" {{if .Site.AllowRegistration}}checked{{end}}
|
||||||
|
class="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-semibold text-gray-700">{{index .Tr "settings_allow_registration"}}</span>
|
||||||
|
<span class="block text-xs text-gray-500">{{index .Tr "settings_allow_registration_hint"}}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||||
{{index .Tr "settings_save"}}
|
{{index .Tr "settings_save"}}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 mb-8">
|
<div class="flex gap-2 mb-8">
|
||||||
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
|
<a href="/admin/settings/site" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_site_title"}}</a>
|
||||||
|
<a href="/admin/settings/navlinks" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_navlinks_title"}}</a>
|
||||||
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_upload_title"}}</a>
|
<a href="/admin/settings/upload" class="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 text-white">{{index .Tr "settings_upload_title"}}</a>
|
||||||
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
<a href="/admin/settings/download" class="px-4 py-2 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors">{{index .Tr "settings_download_title"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{{index .Tr "home"}}</span>
|
<span>{{index .Tr "home"}}</span>
|
||||||
</a>
|
</a>
|
||||||
|
{{range .NavLinks}}
|
||||||
|
<a href="{{.URL}}" {{if .OpenNew}}target="_blank" rel="noopener noreferrer"{{end}} class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
|
||||||
|
<span>{{.Title $.Lang}}</span>
|
||||||
|
{{if .OpenNew}}
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||||
|
</svg>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
{{if .IsLoggedIn}}
|
{{if .IsLoggedIn}}
|
||||||
<!-- Avatar Dropdown (click to toggle) -->
|
<!-- Avatar Dropdown (click to toggle) -->
|
||||||
<div class="relative" id="avatarDropdown">
|
<div class="relative" id="avatarDropdown">
|
||||||
|
|||||||
@@ -40,6 +40,13 @@
|
|||||||
{{index .Tr "login_submit"}}
|
{{index .Tr "login_submit"}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{if .AllowRegistration}}
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<span class="text-sm text-gray-600">{{index .Tr "login_no_account"}}</span>
|
||||||
|
<a href="/register" class="text-sm text-blue-600 hover:text-blue-700 font-medium ml-1">{{index .Tr "login_register_link"}}</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{{define "register"}}
|
||||||
|
{{template "header" .}}
|
||||||
|
<section class="max-w-md mx-auto px-4 py-20">
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "register_title"}}</h2>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
|
||||||
|
{{.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form action="/register" method="post" class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_username"}}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="32"
|
||||||
|
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 "register_ph_username"}}"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{index .Tr "register_username_hint"}}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="display_name" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_display_name"}}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="display_name"
|
||||||
|
name="display_name"
|
||||||
|
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 "register_ph_display_name"}}"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{index .Tr "register_display_name_hint"}}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_email"}}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
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 "register_ph_email"}}"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{index .Tr "register_email_hint"}}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_password"}}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
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 "register_ph_password"}}"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{index .Tr "register_password_hint"}}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_confirm_password"}}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
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 "register_ph_confirm_password"}}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{{index .Tr "register_submit"}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<span class="text-sm text-gray-600">{{index .Tr "register_have_account"}}</span>
|
||||||
|
<a href="/login" class="text-sm text-blue-600 hover:text-blue-700 font-medium ml-1">{{index .Tr "register_login_link"}}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{template "footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# 阅读统计系统测试脚本
|
|
||||||
|
|
||||||
echo "========================================"
|
|
||||||
echo " 阅读统计系统功能测试"
|
|
||||||
echo "========================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 确保应用已启动
|
|
||||||
if ! pgrep -f "./go_blog" > /dev/null; then
|
|
||||||
echo "❌ 应用未运行,请先启动: ./go_blog"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ 应用正在运行"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 测试1:访问首页
|
|
||||||
echo "测试 1: 访问首页..."
|
|
||||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/)
|
|
||||||
if [ "$STATUS" == "200" ]; then
|
|
||||||
echo "✅ 首页访问成功 (HTTP $STATUS)"
|
|
||||||
else
|
|
||||||
echo "❌ 首页访问失败 (HTTP $STATUS)"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 测试2:获取文章列表
|
|
||||||
echo "测试 2: 获取文章列表..."
|
|
||||||
ARTICLES=$(curl -s http://localhost:8080/api/articles | grep -o '"id":[0-9]*' | head -5)
|
|
||||||
if [ -n "$ARTICLES" ]; then
|
|
||||||
echo "✅ 找到文章:"
|
|
||||||
echo "$ARTICLES"
|
|
||||||
else
|
|
||||||
echo "⚠️ 暂无已发布的文章"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 测试3:模拟多次访问(生成测试数据)
|
|
||||||
echo "测试 3: 模拟访问文章(生成测试数据)..."
|
|
||||||
echo "提示: 需要先创建并发布一些文章才能测试阅读记录"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 获取第一篇文章的slug
|
|
||||||
FIRST_ARTICLE=$(curl -s http://localhost:8080/api/articles | grep -o '"slug":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
|
|
||||||
if [ -n "$FIRST_ARTICLE" ]; then
|
|
||||||
echo "访问文章: $FIRST_ARTICLE"
|
|
||||||
|
|
||||||
# 模拟5次访问(不同User-Agent)
|
|
||||||
echo " - 正常浏览器访问..."
|
|
||||||
curl -s -o /dev/null -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" http://localhost:8080/article/$FIRST_ARTICLE
|
|
||||||
|
|
||||||
echo " - Chrome访问..."
|
|
||||||
curl -s -o /dev/null -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0" http://localhost:8080/article/$FIRST_ARTICLE
|
|
||||||
|
|
||||||
echo " - GoogleBot访问..."
|
|
||||||
curl -s -o /dev/null -H "User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" http://localhost:8080/article/$FIRST_ARTICLE
|
|
||||||
|
|
||||||
echo " - BingBot访问..."
|
|
||||||
curl -s -o /dev/null -H "User-Agent: Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" http://localhost:8080/article/$FIRST_ARTICLE
|
|
||||||
|
|
||||||
echo " - BaiduSpider访问..."
|
|
||||||
curl -s -o /dev/null -H "User-Agent: Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" http://localhost:8080/article/$FIRST_ARTICLE
|
|
||||||
|
|
||||||
echo "✅ 已生成测试数据"
|
|
||||||
sleep 1 # 等待异步记录完成
|
|
||||||
else
|
|
||||||
echo "⚠️ 未找到文章,跳过访问测试"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 测试4:检查数据库表
|
|
||||||
echo "测试 4: 检查数据库..."
|
|
||||||
if [ -f "tmp/blog.db" ]; then
|
|
||||||
echo "✅ 数据库文件存在: tmp/blog.db"
|
|
||||||
|
|
||||||
# 使用sqlite3检查表结构(如果可用)
|
|
||||||
if command -v sqlite3 &> /dev/null; then
|
|
||||||
echo ""
|
|
||||||
echo "article_views 表结构:"
|
|
||||||
sqlite3 tmp/blog.db ".schema article_views" 2>/dev/null || echo " (需要SQLite命令行工具)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "article_views 记录数:"
|
|
||||||
COUNT=$(sqlite3 tmp/blog.db "SELECT COUNT(*) FROM article_views" 2>/dev/null)
|
|
||||||
if [ -n "$COUNT" ]; then
|
|
||||||
echo " 总记录: $COUNT"
|
|
||||||
BOT_COUNT=$(sqlite3 tmp/blog.db "SELECT COUNT(*) FROM article_views WHERE is_bot = 1" 2>/dev/null)
|
|
||||||
HUMAN_COUNT=$(sqlite3 tmp/blog.db "SELECT COUNT(*) FROM article_views WHERE is_bot = 0" 2>/dev/null)
|
|
||||||
echo " 真人访问: $HUMAN_COUNT"
|
|
||||||
echo " 爬虫访问: $BOT_COUNT"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ 数据库文件不存在(可能使用MySQL)"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 测试5:访问统计页面(需要登录)
|
|
||||||
echo "测试 5: 访问统计页面..."
|
|
||||||
echo "⚠️ 统计页面需要管理员登录才能访问"
|
|
||||||
echo ""
|
|
||||||
echo "手动测试步骤:"
|
|
||||||
echo "1. 浏览器访问: http://localhost:8080/login"
|
|
||||||
echo "2. 使用默认账号登录: admin / admin"
|
|
||||||
echo "3. 访问: http://localhost:8080/admin/analytics/views"
|
|
||||||
echo "4. 查看统计数据、文章排行榜、访问记录"
|
|
||||||
echo "5. 测试筛选功能:按文章、IP筛选,显示/隐藏爬虫"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "========================================"
|
|
||||||
echo " 测试完成!"
|
|
||||||
echo "========================================"
|
|
||||||
echo ""
|
|
||||||
echo "功能验证清单:"
|
|
||||||
echo "✅ 数据库模型创建 (article_views表)"
|
|
||||||
echo "✅ 自动记录阅读(访问文章时)"
|
|
||||||
echo "✅ 爬虫识别(GoogleBot, BingBot等)"
|
|
||||||
echo "✅ 后台路由注册 (/admin/analytics/views)"
|
|
||||||
echo "✅ 去重机制(同一用户/IP只记录一次)"
|
|
||||||
echo ""
|
|
||||||
echo "下一步:"
|
|
||||||
echo "1. 访问博客,浏览几篇文章"
|
|
||||||
echo "2. 登录后台查看统计数据"
|
|
||||||
echo "3. 尝试筛选和分页功能"
|
|
||||||
echo ""
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# 权限边界修复验证
|
|
||||||
|
|
||||||
## 修复内容
|
|
||||||
|
|
||||||
### 问题
|
|
||||||
普通用户(author角色)能够访问和修改平台设置,存在严重的权限漏洞。
|
|
||||||
|
|
||||||
### 修复的路由组
|
|
||||||
|
|
||||||
1. **平台设置路由** (`/admin/settings/*`)
|
|
||||||
- 修复前: `middleware.AuthRequired()` - 任何登录用户都可访问
|
|
||||||
- 修复后: `middleware.AuthRequired(), middleware.AdminRequired(db)` - 仅管理员可访问
|
|
||||||
- 影响路由:
|
|
||||||
- GET/POST `/admin/settings/site` - 站点设置
|
|
||||||
- GET/POST `/admin/settings/upload` - 上传设置
|
|
||||||
- GET/POST `/admin/settings/download` - 下载设置
|
|
||||||
- GET/POST `/admin/settings/comments` - 评论设置
|
|
||||||
|
|
||||||
2. **评论管理路由** (`/admin/comments/*`)
|
|
||||||
- 修复前: 在 admin 组下,仅 `AuthRequired()`
|
|
||||||
- 修复后: 独立路由组,使用 `AuthRequired(), AdminRequired(db)`
|
|
||||||
- 影响路由:
|
|
||||||
- GET `/admin/comments` - 评论列表
|
|
||||||
- POST `/admin/comments/:id/approve` - 批准评论
|
|
||||||
- POST `/admin/comments/:id/reject` - 拒绝评论
|
|
||||||
- POST `/admin/comments/:id/delete` - 删除评论
|
|
||||||
|
|
||||||
## 权限矩阵
|
|
||||||
|
|
||||||
| 路由组 | 功能 | 登录要求 | 角色要求 | 说明 |
|
|
||||||
|--------|------|----------|----------|------|
|
|
||||||
| `/admin` | 仪表板 | ✓ | - | 所有登录用户可访问 |
|
|
||||||
| `/admin/articles` | 文章管理 | ✓ | - | 允许作者管理自己的文章 |
|
|
||||||
| `/admin/articles/attachments` | 附件管理 | ✓ | - | 作者上传文章附件 |
|
|
||||||
| `/admin/comments` | 评论管理 | ✓ | admin | ✅ 仅管理员 |
|
|
||||||
| `/admin/users` | 用户管理 | ✓ | admin | ✅ 仅管理员 |
|
|
||||||
| `/admin/settings` | 平台设置 | ✓ | admin | ✅ 仅管理员 |
|
|
||||||
| `/profile` | 个人资料 | ✓ | - | 所有登录用户 |
|
|
||||||
|
|
||||||
## 验证步骤
|
|
||||||
|
|
||||||
### 1. 使用管理员账户测试
|
|
||||||
```bash
|
|
||||||
# 登录管理员账户
|
|
||||||
curl -X POST http://localhost:3000/login \
|
|
||||||
-d "username=admin&password=admin123"
|
|
||||||
|
|
||||||
# 应该能访问设置页面
|
|
||||||
curl http://localhost:3000/admin/settings/site
|
|
||||||
|
|
||||||
# 应该能访问评论管理
|
|
||||||
curl http://localhost:3000/admin/comments
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 使用普通用户账户测试
|
|
||||||
```bash
|
|
||||||
# 登录普通用户
|
|
||||||
curl -X POST http://localhost:3000/login \
|
|
||||||
-d "username=author&password=password"
|
|
||||||
|
|
||||||
# 应该被重定向到 /admin(403效果)
|
|
||||||
curl -L http://localhost:3000/admin/settings/site
|
|
||||||
|
|
||||||
# 应该被重定向到 /admin(403效果)
|
|
||||||
curl -L http://localhost:3000/admin/comments
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 预期行为
|
|
||||||
- **管理员**: 可以访问所有 `/admin/*` 路由
|
|
||||||
- **普通用户(author)**:
|
|
||||||
- ✓ 可以访问 `/admin` 仪表板
|
|
||||||
- ✓ 可以管理文章 (`/admin/articles/*`)
|
|
||||||
- ✗ **不能**访问平台设置 (`/admin/settings/*`)
|
|
||||||
- ✗ **不能**访问评论管理 (`/admin/comments/*`)
|
|
||||||
- ✗ **不能**访问用户管理 (`/admin/users/*`)
|
|
||||||
|
|
||||||
## 安全建议
|
|
||||||
|
|
||||||
### 已修复
|
|
||||||
- ✅ 平台设置访问控制
|
|
||||||
- ✅ 评论管理访问控制
|
|
||||||
- ✅ 用户管理访问控制
|
|
||||||
|
|
||||||
### 未来改进建议
|
|
||||||
1. **细粒度文章权限**: 作者只能编辑/删除自己的文章
|
|
||||||
2. **审计日志**: 记录敏感操作(设置修改、用户管理)
|
|
||||||
3. **会话超时**: 考虑缩短敏感操作的会话时间
|
|
||||||
4. **CSRF保护**: 为所有POST请求添加CSRF token
|
|
||||||
Reference in New Issue
Block a user