Compare commits

...
13 Commits
Author SHA1 Message Date
kevin 7d9ccdde75 up 2026-06-24 11:46:08 +08:00
kevin 2875918289 修改运行路径 2026-06-24 11:32:45 +08:00
kevin 1fc933e014 修复CDN环境下获取真实客户端IP的问题 2026-06-24 11:18:56 +08:00
kevin fe69a2e38b up 2026-06-23 15:16:50 +08:00
kevin d8779ed5bf 修复安装问题 2026-06-23 14:57:36 +08:00
kevin a316932d3f up 2026-06-23 11:23:33 +08:00
kevin 5f8c7be5ac up 2026-06-23 10:45:37 +08:00
kevin fe72cbaf9d 删除过期文件 2026-06-23 10:37:08 +08:00
kevinandClaude Fable 5 d9bb91af00 feat: add navigation links management and user registration
Features:
- Custom navigation links management in admin settings
- Multi-language support for navigation links (Chinese/English)
- Control link behavior (open in new window)
- Sort order and enable/disable toggle for nav links
- User registration system with validation
- Admin can enable/disable user registration
- Registration form with username, display name, email, password
- Link to registration from login page

Navigation Links:
- Admin interface to add/edit/delete custom nav links
- Support for both internal and external URLs
- Display links in header navigation bar
- Respects language preference

User Registration:
- Username validation (3-32 characters, alphanumeric + underscore/dash)
- Password validation (minimum 6 characters)
- Password confirmation matching
- Optional display name and email fields
- Can be toggled on/off by admin in site settings

Templates:
- Added navigation links settings page
- Added user registration page
- Updated login page with registration link
- Updated base layout to render custom nav links

Documentation:
- NAV_LINKS_FEATURE.md - Navigation links feature documentation
- REGISTRATION_FEATURE.md - User registration documentation
- IMPLEMENTATION_SUMMARY.md - Overall implementation summary

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 21:01:38 +08:00
kevinandClaude Fable 5 2bdc368399 feat: implement tag management and search functionality
Features:
- Tag system with multi-language support (Chinese/English)
- Auto-create tags when associating with articles
- Tag filtering on homepage with visual indicators
- Full-text search across title, summary, and content
- Tag cloud sidebar showing article counts
- Search page with results display

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:20:19 +08:00
kevinandClaude Fable 5 b600eac21c feat: add favicon settings to site configuration
- Add Favicon field to SiteSetting model with FaviconIsURL() helper
- Implement favicon upload and management in site settings page
- Support both external URL and local file upload for favicon
- Add favicon display in page header with automatic URL detection
- Add i18n translations for favicon settings (EN/ZH)
- Update middleware and helpers to pass favicon data to templates
- Include migration script for existing databases

Users can now customize their site's favicon from /admin/settings/site
by either providing an external URL or uploading a local image file
(recommended formats: .ico, .png, .svg, 16x16 or 32x32 pixels).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 20:09:05 +08:00
42 changed files with 2001 additions and 1551 deletions
-163
View File
@@ -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在中英文环境下都能正常工作
-23
View File
@@ -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"
]
}
}
-140
View File
@@ -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
- 🔍 高级搜索(组合多个条件)
---
**感谢反馈!** 🙏
这个改进让系统更适合大规模文章数量的场景。
-239
View File
@@ -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,可能有漏报和误报
## 总结
阅读统计系统已完整实现,包含:
- ✅ 自动记录每篇文章的独特访问
- ✅ 智能识别并标记爬虫
- ✅ 功能强大的后台统计页面
- ✅ 详细的访问记录和筛选
- ✅ 完整的中英文支持
- ✅ 性能优化和安全保护
系统可以立即投入使用,为博客管理员提供深入的阅读数据洞察!
-242
View File
@@ -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`
---
**就这么简单!** 🎉
现在你的博客已经拥有了专业的阅读统计系统,可以深入了解读者行为,优化内容策略!
-145
View File
@@ -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
- 🔥 热度标记(高阅读量文章)
- ⭐ 点赞功能
- 📈 阅读趋势指示器
---
**效果**:让用户在浏览文章列表时就能看到每篇文章的热度和互动情况!📊
-223
View File
@@ -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. **文章统计**:在"我的文章"页面显示阅读量、评论数等统计数据
+162 -65
View File
@@ -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
+28 -5
View File
@@ -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"
+160 -35
View File
@@ -69,29 +69,33 @@ func fallbackSlug(id uint) string {
// articleForm holds the parsed article form fields, shared by the create and // articleForm holds the parsed article form fields, shared by the create and
// edit handlers and their validation-error repopulation paths. // edit handlers and their validation-error repopulation paths.
type articleForm struct { type articleForm struct {
Title string Title string
Slug string Slug string
Summary string Summary string
Content string Content string
Cover string Cover string
StatusStr string StatusStr string
IsTop bool IsTop bool
Action string // form action URL PublishedAt string // datetime-local format: "2006-01-02T15:04"
TitleText string // page heading text (create vs edit) Tags string // comma-separated tag names
ArticleID uint // existing article ID (edit page); 0 on create Action string // form action URL
SessionToken string // pending-attachment ownership token (create page) TitleText string // page heading text (create vs edit)
ArticleID uint // existing article ID (edit page); 0 on create
SessionToken string // pending-attachment ownership token (create page)
} }
// parseArticleForm reads and trims the article form fields from the request. // parseArticleForm reads and trims the article form fields from the request.
func parseArticleForm(c *gin.Context) articleForm { func parseArticleForm(c *gin.Context) articleForm {
return articleForm{ return articleForm{
Title: strings.TrimSpace(c.PostForm("title")), Title: strings.TrimSpace(c.PostForm("title")),
Slug: strings.TrimSpace(c.PostForm("slug")), Slug: strings.TrimSpace(c.PostForm("slug")),
Summary: strings.TrimSpace(c.PostForm("summary")), Summary: strings.TrimSpace(c.PostForm("summary")),
Content: strings.TrimSpace(c.PostForm("content")), Content: strings.TrimSpace(c.PostForm("content")),
Cover: strings.TrimSpace(c.PostForm("cover")), Cover: strings.TrimSpace(c.PostForm("cover")),
StatusStr: c.PostForm("status"), StatusStr: c.PostForm("status"),
IsTop: c.PostForm("is_top") == "1", IsTop: c.PostForm("is_top") == "1",
PublishedAt: strings.TrimSpace(c.PostForm("published_at")),
Tags: strings.TrimSpace(c.PostForm("tags")),
} }
} }
@@ -105,6 +109,8 @@ func applyFormToData(data gin.H, f articleForm) {
data["FormCover"] = f.Cover data["FormCover"] = f.Cover
data["FormStatus"] = f.StatusStr data["FormStatus"] = f.StatusStr
data["FormIsTop"] = f.IsTop data["FormIsTop"] = f.IsTop
data["FormPublishedAt"] = f.PublishedAt
data["FormTags"] = f.Tags
data["FormAction"] = f.Action data["FormAction"] = f.Action
data["FormTitleText"] = f.TitleText data["FormTitleText"] = f.TitleText
data["FormArticleID"] = f.ArticleID data["FormArticleID"] = f.ArticleID
@@ -154,6 +160,96 @@ func statusFromForm(statusStr string) int {
return models.ArticleDraft return models.ArticleDraft
} }
// parsePublishedAt parses the datetime-local format ("2006-01-02T15:04") from
// the form into a time.Time pointer. Returns nil if the string is empty or invalid.
func parsePublishedAt(publishedAtStr string) *time.Time {
if publishedAtStr == "" {
return nil
}
// datetime-local format: "2006-01-02T15:04"
t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local)
if err != nil {
return nil
}
return &t
}
// formatPublishedAt formats a time.Time pointer into datetime-local format for the form.
// Returns empty string if the pointer is nil.
func formatPublishedAt(t *time.Time) string {
if t == nil {
return ""
}
return t.Local().Format("2006-01-02T15:04")
}
// parseTags splits comma-separated tag string and returns tag names.
func parseTags(tagStr string) []string {
if tagStr == "" {
return []string{}
}
parts := strings.Split(tagStr, ",")
var tags []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
tags = append(tags, trimmed)
}
}
return tags
}
// syncArticleTags associates tags with an article (find or create tags).
func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error {
// Clear existing tags
if err := db.Model(article).Association("Tags").Clear(); err != nil {
return err
}
// If no tags, we're done
if len(tagNames) == 0 {
return nil
}
// Find or create each tag and associate with article
var tags []models.Tag
for _, name := range tagNames {
tag, err := models.FindOrCreateTag(db, name, name)
if err != nil {
return err
}
if tag != nil {
tags = append(tags, *tag)
}
}
// Associate tags with article
if len(tags) > 0 {
if err := db.Model(article).Association("Tags").Append(tags); err != nil {
return err
}
}
// Update tag counts
for _, tag := range tags {
models.UpdateTagCount(db, tag.ID)
}
return nil
}
// formatArticleTags converts article tags to comma-separated string for form.
func formatArticleTags(tags []models.Tag) string {
if len(tags) == 0 {
return ""
}
var names []string
for _, tag := range tags {
names = append(names, tag.NameZh)
}
return strings.Join(names, ", ")
}
// ArticleCreatePage renders the article creation form. // ArticleCreatePage renders the article creation form.
func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
@@ -205,7 +301,11 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
} }
var publishedAt *time.Time var publishedAt *time.Time
if status == models.ArticlePublished { if f.PublishedAt != "" {
// User provided a custom published time
publishedAt = parsePublishedAt(f.PublishedAt)
} else if status == models.ArticlePublished {
// Auto-stamp with current time if publishing without custom time
now := time.Now() now := time.Now()
publishedAt = &now publishedAt = &now
} }
@@ -234,6 +334,13 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc {
} }
} }
// Sync article tags
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// Log error but don't fail the article creation
// The article is already created, tags are optional
}
// Bind any attachments uploaded during creation (plan A: pending rows // Bind any attachments uploaded during creation (plan A: pending rows
// owned by session_token, article_id=0). // owned by session_token, article_id=0).
if f.SessionToken != "" { if f.SessionToken != "" {
@@ -270,17 +377,22 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc {
return return
} }
// Preload tags for the article
db.Model(&article).Association("Tags").Find(&article.Tags)
renderArticleForm(c, db, articleForm{ renderArticleForm(c, db, articleForm{
Title: article.Title, Title: article.Title,
Slug: article.Slug, Slug: article.Slug,
Summary: article.Summary, Summary: article.Summary,
Content: article.Content, Content: article.Content,
Cover: article.Cover, Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status), StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop, IsTop: article.IsTop,
Action: "/admin/articles/" + id + "/edit", PublishedAt: formatPublishedAt(article.PublishedAt),
TitleText: tr["article_edit_title"], Tags: formatArticleTags(article.Tags),
ArticleID: article.ID, Action: "/admin/articles/" + id + "/edit",
TitleText: tr["article_edit_title"],
ArticleID: article.ID,
}, "") }, "")
} }
} }
@@ -319,12 +431,19 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
newStatus := statusFromForm(f.StatusStr) newStatus := statusFromForm(f.StatusStr)
// Stamp the publish time the first time an article is published. // Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
wasPublished := article.Status == models.ArticlePublished var publishedAt *time.Time
var publishedAt *time.Time = article.PublishedAt if f.PublishedAt != "" {
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { // User provided a custom published time
now := time.Now() publishedAt = parsePublishedAt(f.PublishedAt)
publishedAt = &now } else {
// Auto-stamp the publish time the first time an article is published.
wasPublished := article.Status == models.ArticlePublished
publishedAt = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
}
} }
updates := map[string]interface{}{ updates := map[string]interface{}{
@@ -343,6 +462,12 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc {
return return
} }
// Sync article tags
tagNames := parseTags(f.Tags)
if err := syncArticleTags(db, &article, tagNames); err != nil {
// Log error but don't fail the article update
}
c.Redirect(http.StatusFound, "/admin/articles") c.Redirect(http.StatusFound, "/admin/articles")
} }
} }
+109
View File
@@ -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
View File
@@ -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),
} }
+23
View File
@@ -1,6 +1,8 @@
package handlers package handlers
import ( import (
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -19,11 +21,14 @@ func DefaultData(c *gin.Context) gin.H {
siteSetting, _ := c.Get("site_setting") siteSetting, _ := c.Get("site_setting")
siteLogo, _ := c.Get("site_logo") siteLogo, _ := c.Get("site_logo")
siteLogoIsURL, _ := c.Get("site_logo_is_url") siteLogoIsURL, _ := c.Get("site_logo_is_url")
siteFavicon, _ := c.Get("site_favicon")
siteFaviconIsURL, _ := c.Get("site_favicon_is_url")
siteLogoText, _ := c.Get("site_logo_text") siteLogoText, _ := c.Get("site_logo_text")
siteHeaderText, _ := c.Get("site_header_text") siteHeaderText, _ := c.Get("site_header_text")
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,
@@ -37,11 +42,14 @@ func DefaultData(c *gin.Context) gin.H {
"SiteSetting": siteSetting, "SiteSetting": siteSetting,
"SiteLogo": siteLogo, "SiteLogo": siteLogo,
"SiteLogoIsURL": siteLogoIsURL, "SiteLogoIsURL": siteLogoIsURL,
"SiteFavicon": siteFavicon,
"SiteFaviconIsURL": siteFaviconIsURL,
"SiteLogoText": siteLogoText, "SiteLogoText": siteLogoText,
"SiteHeaderText": siteHeaderText, "SiteHeaderText": siteHeaderText,
"SiteHomeWelcome": siteHomeWelcome, "SiteHomeWelcome": siteHomeWelcome,
"SiteHomeSubtitle": siteHomeSubtitle, "SiteHomeSubtitle": siteHomeSubtitle,
"SiteFooterText": siteFooterText, "SiteFooterText": siteFooterText,
"NavLinks": navLinks,
} }
} }
@@ -58,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()
}
+120 -8
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
@@ -14,7 +15,7 @@ import (
// publishedArticleOrder is the ordering used to list published articles: // publishedArticleOrder is the ordering used to list published articles:
// pinned first, then by most recent publish/creation time. // pinned first, then by most recent publish/creation time.
const publishedArticleOrder = "is_top DESC, published_at DESC, created_at DESC" const publishedArticleOrder = "articles.is_top DESC, articles.published_at DESC, articles.created_at DESC"
// HomePage renders the public home page with the latest published articles. // HomePage renders the public home page with the latest published articles.
func HomePage(db *gorm.DB) gin.HandlerFunc { func HomePage(db *gorm.DB) gin.HandlerFunc {
@@ -23,12 +24,35 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
data := DefaultData(c) data := DefaultData(c)
data["Title"] = tr["page_home"] data["Title"] = tr["page_home"]
// Get tag filter if present
tagSlug := c.Query("tag")
// Build query
query := db.Where("status = ?", models.ArticlePublished)
if tagSlug != "" {
// Join with article_tags to filter by tag
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
Joins("JOIN tags ON tags.id = article_tags.tag_id").
Where("tags.slug = ?", tagSlug)
// Get tag info for display
var tag models.Tag
if err := db.Where("slug = ?", tagSlug).First(&tag).Error; err == nil {
data["FilterTag"] = tag
}
}
var articles []models.Article var articles []models.Article
db.Where("status = ?", models.ArticlePublished). query.Preload("Tags").
Order(publishedArticleOrder). Order(publishedArticleOrder).
Limit(10). Limit(10).
Find(&articles) Find(&articles)
// Load all tags for sidebar
var tags []models.Tag
db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags)
// Get comment counts for all articles // Get comment counts for all articles
articleIDs := make([]uint, len(articles)) articleIDs := make([]uint, len(articles))
for i, article := range articles { for i, article := range articles {
@@ -55,8 +79,9 @@ func HomePage(db *gorm.DB) gin.HandlerFunc {
commentCountMap[cc.ArticleID] = cc.Count commentCountMap[cc.ArticleID] = cc.Count
} }
// Add comment counts to template data // Add data to template
data["Articles"] = articles data["Articles"] = articles
data["Tags"] = tags
data["CommentCounts"] = commentCountMap data["CommentCounts"] = commentCountMap
c.HTML(http.StatusOK, "home", data) c.HTML(http.StatusOK, "home", data)
@@ -77,17 +102,33 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc {
pageSize := 10 pageSize := 10
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
// Get tag filter if present
tagSlug := c.Query("tag")
// Build query
query := db.Where("status = ?", models.ArticlePublished)
countQuery := db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished)
if tagSlug != "" {
// Join with article_tags to filter by tag
query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
Joins("JOIN tags ON tags.id = article_tags.tag_id").
Where("tags.slug = ?", tagSlug)
countQuery = countQuery.Joins("JOIN article_tags ON article_tags.article_id = articles.id").
Joins("JOIN tags ON tags.id = article_tags.tag_id").
Where("tags.slug = ?", tagSlug)
}
var articles []models.Article var articles []models.Article
db.Where("status = ?", models.ArticlePublished). query.Preload("Tags").
Order(publishedArticleOrder). Order(publishedArticleOrder).
Limit(pageSize). Limit(pageSize).
Offset(offset). Offset(offset).
Find(&articles) Find(&articles)
var total int64 var total int64
db.Model(&models.Article{}). countQuery.Count(&total)
Where("status = ?", models.ArticlePublished).
Count(&total)
// Get comment counts for these articles // Get comment counts for these articles
articleIDs := make([]uint, len(articles)) articleIDs := make([]uint, len(articles))
@@ -191,6 +232,7 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f
data["Article"] = article data["Article"] = article
data["AuthorName"] = authorName data["AuthorName"] = authorName
data["PublishedAt"] = formatPublishTime(article.PublishedAt) data["PublishedAt"] = formatPublishTime(article.PublishedAt)
data["UpdatedAt"] = formatUpdateTime(article.UpdatedAt)
data["Comments"] = tree data["Comments"] = tree
data["CommentConfig"] = models.GetCommentConfig() data["CommentConfig"] = models.GetCommentConfig()
data["CommentForm"] = form data["CommentForm"] = form
@@ -209,6 +251,11 @@ func formatPublishTime(publishedAt *time.Time) string {
return "" return ""
} }
// formatUpdateTime returns the last update time as a readable string.
func formatUpdateTime(updatedAt time.Time) string {
return updatedAt.Format("2006-01-02 15:04")
}
// commentFlashKey is the session key for the one-time comment notice. // commentFlashKey is the session key for the one-time comment notice.
const commentFlashKey = "comment_flash" const commentFlashKey = "comment_flash"
@@ -259,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)
@@ -297,3 +344,68 @@ func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
db.Create(&view) db.Create(&view)
// Ignore errors - this is a best-effort tracking system // Ignore errors - this is a best-effort tracking system
} }
// SearchPage handles article search by keyword.
func SearchPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
data := DefaultData(c)
keyword := strings.TrimSpace(c.Query("q"))
data["SearchKeyword"] = keyword
data["Title"] = tr["search_title"]
if keyword == "" {
data["Articles"] = []models.Article{}
data["Tags"] = []models.Tag{}
data["CommentCounts"] = make(map[uint]int64)
c.HTML(http.StatusOK, "search", data)
return
}
// Search in title, summary, and content
searchPattern := "%" + keyword + "%"
var articles []models.Article
db.Where("status = ?", models.ArticlePublished).
Where("title LIKE ? OR summary LIKE ? OR content LIKE ?", searchPattern, searchPattern, searchPattern).
Preload("Tags").
Order(publishedArticleOrder).
Limit(50). // Limit search results
Find(&articles)
// Load all tags for sidebar
var tags []models.Tag
db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags)
// Get comment counts
articleIDs := make([]uint, len(articles))
for i, article := range articles {
articleIDs[i] = article.ID
}
type CommentCount struct {
ArticleID uint
Count int64
}
var commentCounts []CommentCount
if len(articleIDs) > 0 {
db.Model(&models.Comment{}).
Select("article_id, COUNT(*) as count").
Where("article_id IN ?", articleIDs).
Where("status = ?", models.CommentApproved).
Group("article_id").
Scan(&commentCounts)
}
commentCountMap := make(map[uint]int64)
for _, cc := range commentCounts {
commentCountMap[cc.ArticleID] = cc.Count
}
data["Articles"] = articles
data["Tags"] = tags
data["CommentCounts"] = commentCountMap
c.HTML(http.StatusOK, "search", data)
}
}
+24 -16
View File
@@ -66,16 +66,17 @@ func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc {
} }
renderMyArticleForm(c, db, articleForm{ renderMyArticleForm(c, db, articleForm{
Title: article.Title, Title: article.Title,
Slug: article.Slug, Slug: article.Slug,
Summary: article.Summary, Summary: article.Summary,
Content: article.Content, Content: article.Content,
Cover: article.Cover, Cover: article.Cover,
StatusStr: strconv.Itoa(article.Status), StatusStr: strconv.Itoa(article.Status),
IsTop: article.IsTop, IsTop: article.IsTop,
Action: "/my/articles/" + id + "/edit", PublishedAt: formatPublishedAt(article.PublishedAt),
TitleText: tr["article_edit_title"], Action: "/my/articles/" + id + "/edit",
ArticleID: article.ID, TitleText: tr["article_edit_title"],
ArticleID: article.ID,
}, "") }, "")
} }
} }
@@ -117,12 +118,19 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc {
newStatus := statusFromForm(f.StatusStr) newStatus := statusFromForm(f.StatusStr)
// Stamp the publish time the first time an article is published. // Handle published_at: use form value if provided, otherwise auto-stamp on first publish.
wasPublished := article.Status == models.ArticlePublished var publishedAt *time.Time
var publishedAt *time.Time = article.PublishedAt if f.PublishedAt != "" {
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { // User provided a custom published time
now := time.Now() publishedAt = parsePublishedAt(f.PublishedAt)
publishedAt = &now } else {
// Stamp the publish time the first time an article is published.
wasPublished := article.Status == models.ArticlePublished
publishedAt = article.PublishedAt
if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil {
now := time.Now()
publishedAt = &now
}
} }
updates := map[string]interface{}{ updates := map[string]interface{}{
+143
View File
@@ -74,6 +74,7 @@ func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc {
// Pre-compute derived values so the template never invokes methods on // Pre-compute derived values so the template never invokes methods on
// an interface{}-wrapped struct (which Go templates cannot resolve). // an interface{}-wrapped struct (which Go templates cannot resolve).
data["SiteLogoIsURL"] = s.LogoIsURL() data["SiteLogoIsURL"] = s.LogoIsURL()
data["SiteFaviconIsURL"] = s.FaviconIsURL()
if msg := c.Query("saved"); msg == "1" { if msg := c.Query("saved"); msg == "1" {
data["Success"] = tr["settings_saved"] data["Success"] = tr["settings_saved"]
} }
@@ -99,8 +100,49 @@ 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
// uploaded file, so admins can set either a local file or an external link.
if faviconURL := strings.TrimSpace(c.PostForm("favicon_url")); faviconURL != "" {
s.Favicon = faviconURL
} else if file, header, err := c.Request.FormFile("favicon"); err == nil {
defer file.Close()
check := ValidateUpload(header)
if !check.OK || check.Type.Category != models.CategoryImage {
c.Redirect(http.StatusFound, "/admin/settings/site")
return
}
logoDir := filepath.Join(storagePath, "logos")
os.MkdirAll(logoDir, 0755)
// Remove the previous local favicon (skip external URLs).
if s.Favicon != "" && !s.FaviconIsURL() {
os.Remove(filepath.Join(logoDir, s.Favicon))
}
ext := strings.ToLower(filepath.Ext(header.Filename))
savedName := fmt.Sprintf("favicon%s", ext)
dst, err := os.Create(filepath.Join(logoDir, savedName))
if err != nil {
c.Redirect(http.StatusFound, "/admin/settings/site")
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
c.Redirect(http.StatusFound, "/admin/settings/site")
return
}
s.Favicon = savedName
}
// Remove favicon entirely if requested.
if c.PostForm("favicon_clear") == "1" {
if s.Favicon != "" && !s.FaviconIsURL() {
os.Remove(filepath.Join(storagePath, "logos", s.Favicon))
}
s.Favicon = ""
}
// Logo upload (optional). A logo_url form field takes precedence over an // Logo upload (optional). A logo_url form field takes precedence over an
// uploaded file, so admins can set either a local file or an external link. // uploaded file, so admins can set either a local file or an external link.
if logoURL := strings.TrimSpace(c.PostForm("logo_url")); logoURL != "" { if logoURL := strings.TrimSpace(c.PostForm("logo_url")); logoURL != "" {
@@ -386,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)
}
+140
View File
@@ -46,6 +46,38 @@ var translations = map[Lang]map[string]string{
"login_submit": "Sign In", "login_submit": "Sign In",
"login_error": "Invalid username or password.", "login_error": "Invalid username or password.",
"login_required": "Please fill in all fields.", "login_required": "Please fill in all fields.",
"login_no_account": "Don't have an account?",
"login_register_link": "Register",
// Register page
"page_register": "Register",
"register_title": "Create Account",
"register_username": "Username",
"register_ph_username": "Choose a username",
"register_username_hint": "3-32 characters, letters, numbers, underscore and dash only",
"register_display_name": "Display Name",
"register_ph_display_name": "Your display name",
"register_display_name_hint": "Optional, defaults to username if empty",
"register_email": "Email",
"register_ph_email": "Your email address",
"register_email_hint": "Optional, used for Gravatar avatar",
"register_password": "Password",
"register_ph_password": "Choose a password",
"register_password_hint": "At least 6 characters",
"register_confirm_password": "Confirm Password",
"register_ph_confirm_password": "Re-enter your password",
"register_submit": "Register",
"register_have_account": "Already have an account?",
"register_login_link": "Sign In",
"register_required": "Username and password are required.",
"register_username_length": "Username must be 3-32 characters.",
"register_password_length": "Password must be at least 6 characters.",
"register_password_mismatch": "Passwords do not match.",
"register_error": "Registration failed. Please try again.",
// Settings
"settings_allow_registration": "Allow user registration",
"settings_allow_registration_hint": "When enabled, visitors can create their own accounts from the login page",
// Dashboard // Dashboard
"dash_title": "Dashboard", "dash_title": "Dashboard",
@@ -122,6 +154,8 @@ var translations = map[Lang]map[string]string{
"article_published": "Published", "article_published": "Published",
"article_field_is_top": "Pin to top", "article_field_is_top": "Pin to top",
"article_is_top": "Pin to top", "article_is_top": "Pin to top",
"article_published_at": "Published Time",
"article_published_at_hint": "Leave blank to auto-set on publish",
"article_save_draft": "Save Draft", "article_save_draft": "Save Draft",
"article_save": "Save", "article_save": "Save",
"article_cancel": "Cancel", "article_cancel": "Cancel",
@@ -160,16 +194,52 @@ var translations = map[Lang]map[string]string{
"article_col_status": "Status", "article_col_status": "Status",
"article_col_published": "Published", "article_col_published": "Published",
"article_col_actions": "Actions", "article_col_actions": "Actions",
"article_last_updated": "Last Updated",
// Tags
"tags_title": "Tags",
"article_tags": "Tags",
"article_tags_hint": "Comma-separated tag names, e.g., golang, web, tutorial",
"tag_filter": "Filter by tag",
"tag_all": "All",
// Search
"search_placeholder": "Search articles...",
"search_title": "Search Results",
"search_results_for": "Search results for",
"search_no_results": "No articles found matching your search.",
"search_keyword": "Keyword",
// Settings (platform configuration) // Settings (platform configuration)
"settings_nav": "Platform Settings", "settings_nav": "Platform Settings",
"settings_saved": "Settings saved.", "settings_saved": "Settings saved.",
"settings_site_title": "Site Settings", "settings_site_title": "Site Settings",
"settings_site_desc": "Logo, top-left title, header banner, and footer text.", "settings_site_desc": "Logo, top-left title, header banner, and footer text.",
"settings_navlinks_title": "Navigation Links",
"settings_navlinks_desc": "Manage custom links in the header navigation.",
"navlinks_add": "Add Link",
"navlinks_title_zh": "Link Text (Chinese)",
"navlinks_title_en": "Link Text (English)",
"navlinks_url": "URL",
"navlinks_url_hint": "Can be relative (/about) or absolute (https://example.com)",
"navlinks_open_new": "Open in new window",
"navlinks_enabled": "Enabled",
"navlinks_sort": "Sort Order",
"navlinks_edit": "Edit",
"navlinks_delete": "Delete",
"navlinks_confirm_delete": "Are you sure you want to delete this link?",
"navlinks_no_links": "No navigation links configured yet.",
"navlinks_save": "Save",
"navlinks_cancel": "Cancel",
"settings_logo": "Logo", "settings_logo": "Logo",
"settings_logo_url": "Logo URL (external link)", "settings_logo_url": "Logo URL (external link)",
"settings_logo_upload": "Or upload a logo image", "settings_logo_upload": "Or upload a logo image",
"settings_logo_clear": "Remove current logo", "settings_logo_clear": "Remove current logo",
"settings_favicon": "Favicon",
"settings_favicon_url": "Favicon URL (external link)",
"settings_favicon_current": "Current favicon",
"settings_favicon_hint": "Recommended: .ico, .png or .svg format, 32x32 or 16x16 pixels",
"settings_favicon_clear": "Remove current favicon",
"settings_logo_text_zh": "Site Title (Chinese)", "settings_logo_text_zh": "Site Title (Chinese)",
"settings_logo_text_en": "Site Title (English)", "settings_logo_text_en": "Site Title (English)",
"settings_header_text_zh": "Header Banner Text (Chinese)", "settings_header_text_zh": "Header Banner Text (Chinese)",
@@ -396,6 +466,38 @@ var translations = map[Lang]map[string]string{
"login_submit": "登录", "login_submit": "登录",
"login_error": "用户名或密码错误。", "login_error": "用户名或密码错误。",
"login_required": "请填写所有字段。", "login_required": "请填写所有字段。",
"login_no_account": "还没有账号?",
"login_register_link": "注册",
// 注册页
"page_register": "注册",
"register_title": "创建账号",
"register_username": "用户名",
"register_ph_username": "请输入用户名",
"register_username_hint": "3-32个字符,仅限字母、数字、下划线和连字符",
"register_display_name": "显示名称",
"register_ph_display_name": "请输入显示名称",
"register_display_name_hint": "可选,留空则使用用户名",
"register_email": "邮箱",
"register_ph_email": "请输入邮箱地址",
"register_email_hint": "可选,用于显示 Gravatar 头像",
"register_password": "密码",
"register_ph_password": "请输入密码",
"register_password_hint": "至少6个字符",
"register_confirm_password": "确认密码",
"register_ph_confirm_password": "请再次输入密码",
"register_submit": "注册",
"register_have_account": "已有账号?",
"register_login_link": "登录",
"register_required": "用户名和密码不能为空。",
"register_username_length": "用户名必须是3-32个字符。",
"register_password_length": "密码至少需要6个字符。",
"register_password_mismatch": "两次输入的密码不一致。",
"register_error": "注册失败,请重试。",
// 平台设置
"settings_allow_registration": "允许用户注册",
"settings_allow_registration_hint": "启用后,访客可以从登录页面创建自己的账号",
// 后台 // 后台
"dash_title": "后台管理", "dash_title": "后台管理",
@@ -470,6 +572,8 @@ var translations = map[Lang]map[string]string{
"article_published": "已发布", "article_published": "已发布",
"article_field_is_top": "置顶", "article_field_is_top": "置顶",
"article_is_top": "置顶", "article_is_top": "置顶",
"article_published_at": "发布时间",
"article_published_at_hint": "留空则在发布时自动设置",
"article_save_draft": "保存草稿", "article_save_draft": "保存草稿",
"article_save": "保存", "article_save": "保存",
"article_cancel": "取消", "article_cancel": "取消",
@@ -508,16 +612,52 @@ var translations = map[Lang]map[string]string{
"article_col_status": "状态", "article_col_status": "状态",
"article_col_published": "发布时间", "article_col_published": "发布时间",
"article_col_actions": "操作", "article_col_actions": "操作",
"article_last_updated": "最后更新",
// 标签
"tags_title": "标签",
"article_tags": "标签",
"article_tags_hint": "逗号分隔的标签名称,例如:golang, web, 教程",
"tag_filter": "按标签筛选",
"tag_all": "全部",
// 搜索
"search_placeholder": "搜索文章...",
"search_title": "搜索结果",
"search_results_for": "搜索结果",
"search_no_results": "未找到匹配的文章。",
"search_keyword": "关键词",
// 平台设置 // 平台设置
"settings_nav": "平台设置", "settings_nav": "平台设置",
"settings_saved": "设置已保存。", "settings_saved": "设置已保存。",
"settings_site_title": "站点设置", "settings_site_title": "站点设置",
"settings_site_desc": "Logo、左上角标题、顶部横幅文本和页脚文本。", "settings_site_desc": "Logo、左上角标题、顶部横幅文本和页脚文本。",
"settings_navlinks_title": "导航链接",
"settings_navlinks_desc": "管理顶部导航栏的自定义链接。",
"navlinks_add": "添加链接",
"navlinks_title_zh": "链接文字(中文)",
"navlinks_title_en": "链接文字(英文)",
"navlinks_url": "链接地址",
"navlinks_url_hint": "可以是相对路径(/about)或完整链接(https://example.com",
"navlinks_open_new": "在新窗口打开",
"navlinks_enabled": "启用",
"navlinks_sort": "排序",
"navlinks_edit": "编辑",
"navlinks_delete": "删除",
"navlinks_confirm_delete": "确定要删除这个链接吗?",
"navlinks_no_links": "还没有配置导航链接。",
"navlinks_save": "保存",
"navlinks_cancel": "取消",
"settings_logo": "Logo", "settings_logo": "Logo",
"settings_logo_url": "Logo 链接(外链地址)", "settings_logo_url": "Logo 链接(外链地址)",
"settings_logo_upload": "或上传 Logo 图片", "settings_logo_upload": "或上传 Logo 图片",
"settings_logo_clear": "移除当前 Logo", "settings_logo_clear": "移除当前 Logo",
"settings_favicon": "网站图标(Favicon",
"settings_favicon_url": "Favicon 链接(外链地址)",
"settings_favicon_current": "当前 Favicon",
"settings_favicon_hint": "推荐:.ico、.png 或 .svg 格式,32x32 或 16x16 像素",
"settings_favicon_clear": "移除当前 Favicon",
"settings_logo_text_zh": "站点标题(中文)", "settings_logo_text_zh": "站点标题(中文)",
"settings_logo_text_en": "站点标题(英文)", "settings_logo_text_en": "站点标题(英文)",
"settings_header_text_zh": "顶部横幅文本(中文)", "settings_header_text_zh": "顶部横幅文本(中文)",
+98
View File
@@ -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}"
+47 -5
View File
@@ -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)
@@ -50,11 +57,14 @@ func main() {
// 8. Register routes. // 8. Register routes.
router.GET("/", handlers.HomePage(db)) router.GET("/", handlers.HomePage(db))
router.GET("/search", handlers.SearchPage(db))
router.GET("/api/articles", handlers.HomeArticlesAPI(db)) router.GET("/api/articles", handlers.HomeArticlesAPI(db))
router.GET("/rss", handlers.RSSFeed(db)) router.GET("/rss", handlers.RSSFeed(db))
router.GET("/feed", handlers.RSSFeed(db)) router.GET("/feed", handlers.RSSFeed(db))
router.GET("/login", handlers.LoginPage()) router.GET("/login", handlers.LoginPage())
router.POST("/login", handlers.Login(db)) router.POST("/login", handlers.Login(db))
router.GET("/register", handlers.RegisterPage(db))
router.POST("/register", handlers.Register(db))
router.POST("/logout", handlers.Logout()) router.POST("/logout", handlers.Logout())
router.GET("/article/:slug", handlers.ArticleDetail(db)) router.GET("/article/:slug", handlers.ArticleDetail(db))
router.POST("/article/:slug/comments", handlers.PostComment(db)) router.POST("/article/:slug/comments", handlers.PostComment(db))
@@ -110,6 +120,8 @@ func main() {
{ {
settings.GET("/site", handlers.SiteSettingsPage(db)) settings.GET("/site", handlers.SiteSettingsPage(db))
settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path)) settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
settings.POST("/navlinks", handlers.NavLinksSettingsSave(db))
settings.GET("/upload", handlers.UploadSettingsPage(db)) settings.GET("/upload", handlers.UploadSettingsPage(db))
settings.POST("/upload", handlers.UploadSettingsSave(db)) settings.POST("/upload", handlers.UploadSettingsSave(db))
settings.GET("/download", handlers.DownloadSettingsPage(db)) settings.GET("/download", handlers.DownloadSettingsPage(db))
@@ -156,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 {}
} }
+6
View File
@@ -123,12 +123,18 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc {
c.Set("site_setting", site) c.Set("site_setting", site)
c.Set("site_logo", site.Logo) c.Set("site_logo", site.Logo)
c.Set("site_logo_is_url", site.LogoIsURL()) c.Set("site_logo_is_url", site.LogoIsURL())
c.Set("site_favicon", site.Favicon)
c.Set("site_favicon_is_url", site.FaviconIsURL())
c.Set("site_logo_text", site.LogoText(string(lang))) c.Set("site_logo_text", site.LogoText(string(lang)))
c.Set("site_header_text", site.HeaderText(string(lang))) c.Set("site_header_text", site.HeaderText(string(lang)))
c.Set("site_home_welcome", site.HomeWelcome(string(lang))) c.Set("site_home_welcome", site.HomeWelcome(string(lang)))
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()
} }
} }
+1
View File
@@ -30,6 +30,7 @@ type Article struct {
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"` Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
PublishedAt *time.Time `gorm:"index" json:"published_at"` PublishedAt *time.Time `gorm:"index" json:"published_at"`
Author User `gorm:"foreignKey:AuthorID" json:"-"` Author User `gorm:"foreignKey:AuthorID" json:"-"`
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
} }
// TableName overrides the default GORM table name. // TableName overrides the default GORM table name.
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
)
// ArticleTag represents the many-to-many relationship between articles and tags.
type ArticleTag struct {
ArticleID uint `gorm:"primaryKey;index" json:"article_id"`
TagID uint `gorm:"primaryKey;index" json:"tag_id"`
CreatedAt time.Time `json:"created_at"`
}
// TableName overrides the default GORM table name.
func (ArticleTag) TableName() string {
return "article_tags"
}
+13
View File
@@ -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
}
+1 -1
View File
@@ -45,7 +45,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
} }
// Auto-migrate tables (idempotent). // Auto-migrate tables (idempotent).
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}); err != nil { if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
log.Fatalf("Failed to auto-migrate database: %v", err) log.Fatalf("Failed to auto-migrate database: %v", err)
} }
+37
View File
@@ -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
}
+25 -14
View File
@@ -6,20 +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
LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // top-left title (zh) Favicon string `gorm:"size:512" json:"favicon"` // local filename (served under /uploads/logos) OR a full URL
LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // top-left title (en) LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // top-left title (zh)
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh) LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // top-left title (en)
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en) HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh)
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // home page welcome heading (zh) HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en)
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // home page welcome heading (en) HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // home page welcome heading (zh)
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"` // home page subtitle (zh) HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // home page welcome heading (en)
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (en) HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"` // home page subtitle (zh)
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh) HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (en)
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en) FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh)
UpdatedBy uint `gorm:"index" json:"updated_by"` FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en)
UpdatedAt time.Time `json:"updated_at"` AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // whether users can self-register
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.
@@ -36,6 +38,15 @@ func (s *SiteSetting) LogoIsURL() bool {
return len(s.Logo) >= 4 && (s.Logo[:4] == "http") return len(s.Logo) >= 4 && (s.Logo[:4] == "http")
} }
// FaviconIsURL reports whether the favicon value is an external URL rather than a
// local filename.
func (s *SiteSetting) FaviconIsURL() bool {
if s == nil || s.Favicon == "" {
return false
}
return len(s.Favicon) >= 4 && (s.Favicon[:4] == "http")
}
// LogoText returns the title for the given language code, falling back to the // LogoText returns the title for the given language code, falling back to the
// other language when the requested one is empty. // other language when the requested one is empty.
func (s *SiteSetting) LogoText(lang string) string { func (s *SiteSetting) LogoText(lang string) string {
+126
View File
@@ -0,0 +1,126 @@
package models
import (
"strings"
"time"
"gorm.io/gorm"
)
// Tag represents a blog post tag with multi-language support.
type Tag struct {
ID uint `gorm:"primarykey" json:"id"`
NameZh string `gorm:"size:50;not null" json:"name_zh"`
NameEn string `gorm:"size:50;not null" json:"name_en"`
Slug string `gorm:"size:100;uniqueIndex;not null" json:"slug"`
Count int `gorm:"default:0" json:"count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName overrides the default GORM table name.
func (Tag) TableName() string {
return "tags"
}
// Name returns the tag name for the specified language.
func (t *Tag) Name(lang string) string {
if lang == "zh" {
return t.NameZh
}
return t.NameEn
}
// generateTagSlug creates a URL-friendly slug from tag name.
func generateTagSlug(name string) string {
slug := strings.ToLower(strings.TrimSpace(name))
slug = strings.ReplaceAll(slug, " ", "-")
slug = strings.ReplaceAll(slug, "_", "-")
return slug
}
// FindOrCreateTag finds a tag by name or creates it if it doesn't exist.
// If both nameZh and nameEn are provided, it uses them; otherwise uses the same name for both languages.
func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
nameZh = strings.TrimSpace(nameZh)
nameEn = strings.TrimSpace(nameEn)
// If only one name is provided, use it for both languages
if nameZh == "" && nameEn != "" {
nameZh = nameEn
} else if nameEn == "" && nameZh != "" {
nameEn = nameZh
}
if nameZh == "" {
return nil, nil
}
slug := generateTagSlug(nameZh)
var tag Tag
err := db.Where("slug = ?", slug).First(&tag).Error
if err == nil {
return &tag, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
// Create new tag
tag = Tag{
NameZh: nameZh,
NameEn: nameEn,
Slug: slug,
Count: 0,
}
if err := db.Create(&tag).Error; err != nil {
return nil, err
}
return &tag, nil
}
// GetAllTags returns all tags ordered by count descending.
func GetAllTags(db *gorm.DB) ([]Tag, error) {
var tags []Tag
err := db.Order("count DESC, name_zh ASC").Find(&tags).Error
return tags, err
}
// GetTagBySlug returns a tag by its slug.
func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
var tag Tag
err := db.Where("slug = ?", slug).First(&tag).Error
if err != nil {
return nil, err
}
return &tag, nil
}
// UpdateTagCount recalculates the article count for a tag.
func UpdateTagCount(db *gorm.DB, tagID uint) error {
var count int64
db.Table("article_tags").Where("tag_id = ?", tagID).Count(&count)
return db.Model(&Tag{}).Where("id = ?", tagID).Update("count", count).Error
}
// UpdateAllTagCounts recalculates article counts for all tags.
func UpdateAllTagCounts(db *gorm.DB) error {
// Get all tags
var tags []Tag
if err := db.Find(&tags).Error; err != nil {
return err
}
// Update count for each tag
for _, tag := range tags {
if err := UpdateTagCount(db, tag.ID); err != nil {
return err
}
}
return nil
}
+9
View File
@@ -0,0 +1,9 @@
-- Migration: Add favicon field to site_settings table
-- Date: 2026-06-22
-- Description: Add support for custom favicon in site settings
-- Add favicon column if it doesn't exist
ALTER TABLE site_settings ADD COLUMN IF NOT EXISTS favicon VARCHAR(512) DEFAULT '';
-- Add comment for documentation
COMMENT ON COLUMN site_settings.favicon IS 'Local filename (served under /uploads/logos) OR a full URL for the site favicon';
+17
View File
@@ -53,6 +53,15 @@
placeholder="https://..."> placeholder="https://...">
</div> </div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
<input type="text" name="tags" value="{{.FormTags}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
placeholder="{{index .Tr "article_tags_hint"}}">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
</div>
<!-- Attachments --> <!-- Attachments -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
@@ -77,6 +86,14 @@
</table> </table>
</div> </div>
<!-- Published At -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
</div>
<!-- IsTop --> <!-- IsTop -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}} <input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
+1
View File
@@ -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>
+200
View File
@@ -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}}
+36
View File
@@ -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>
@@ -34,6 +35,29 @@
</label> </label>
</div> </div>
<!-- Favicon -->
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_favicon"}}</label>
{{if .Site.Favicon}}
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-600">{{index .Tr "settings_favicon_current"}}:</span>
{{if .SiteFaviconIsURL}}
<img src="{{.Site.Favicon}}" alt="favicon" class="h-8 w-8">
{{else}}
<img src="/uploads/logos/{{.Site.Favicon}}" alt="favicon" class="h-8 w-8">
{{end}}
</div>
{{end}}
<input type="text" name="favicon_url" placeholder="{{index .Tr "settings_favicon_url"}}"
class="w-full border border-gray-300 rounded-lg px-3 py-2 mb-2" value="{{if .SiteFaviconIsURL}}{{.Site.Favicon}}{{end}}">
<input type="file" name="favicon" accept="image/x-icon,image/png,image/svg+xml"
class="block w-full text-sm text-gray-500 mb-2">
<p class="text-xs text-gray-500 mb-2">{{index .Tr "settings_favicon_hint"}}</p>
<label class="inline-flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" name="favicon_clear" value="1"> {{index .Tr "settings_favicon_clear"}}
</label>
</div>
<!-- Title texts --> <!-- Title texts -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
@@ -104,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"}}
+1
View File
@@ -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>
+29 -2
View File
@@ -5,6 +5,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - {{index .Tr "site_title"}}</title> <title>{{.Title}} - {{index .Tr "site_title"}}</title>
{{if .SiteFavicon}}
{{if .SiteFaviconIsURL}}
<link rel="icon" href="{{.SiteFavicon}}">
{{else}}
<link rel="icon" href="/uploads/logos/{{.SiteFavicon}}">
{{end}}
{{end}}
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss"> <link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
@@ -27,7 +34,22 @@
{{if .SiteLogoText}}{{.SiteLogoText}}{{else}}{{index .Tr "site_title"}}{{end}} {{if .SiteLogoText}}{{.SiteLogoText}}{{else}}{{index .Tr "site_title"}}{{end}}
</a> </a>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors text-sm">{{index .Tr "home"}}</a> <a href="/" class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
<span>{{index .Tr "home"}}</span>
</a>
{{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">
@@ -65,7 +87,12 @@
</div> </div>
</div> </div>
{{else}} {{else}}
<a href="/login" class="text-gray-600 hover:text-blue-600 transition-colors text-sm">{{index .Tr "login"}}</a> <a href="/login" class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
</svg>
<span>{{index .Tr "login"}}</span>
</a>
{{end}} {{end}}
</div> </div>
</div> </div>
+8 -1
View File
@@ -28,7 +28,14 @@
<img src="/uploads/avatars/{{.Article.Author.Avatar}}" alt="avatar" class="w-7 h-7 rounded-full object-cover"> <img src="/uploads/avatars/{{.Article.Author.Avatar}}" alt="avatar" class="w-7 h-7 rounded-full object-cover">
{{end}} {{end}}
<span class="font-medium text-gray-700">{{.AuthorName}}</span> <span class="font-medium text-gray-700">{{.AuthorName}}</span>
{{if .PublishedAt}}<span>·</span><span>{{.PublishedAt}}</span>{{end}} {{if .PublishedAt}}
<span>·</span>
<span>{{index .Tr "article_col_published"}}: {{.PublishedAt}}</span>
{{end}}
{{if .UpdatedAt}}
<span>·</span>
<span>{{index .Tr "article_last_updated"}}: {{.UpdatedAt}}</span>
{{end}}
</div> </div>
{{if .Article.Cover}} {{if .Article.Cover}}
+74 -8
View File
@@ -1,19 +1,54 @@
{{define "home"}} {{define "home"}}
{{template "header" .}} {{template "header" .}}
<!-- Search Box -->
<section class="max-w-5xl mx-auto px-4 py-6">
<div class="flex justify-center">
<form action="/search" method="get" class="w-full max-w-2xl">
<div class="relative">
<input type="text" name="q" placeholder="{{index .Tr "search_placeholder"}}"
class="w-full px-4 py-3 pl-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</form>
</div>
</section>
<section class="max-w-5xl mx-auto px-4 py-20 text-center"> <section class="max-w-5xl mx-auto px-4 py-20 text-center">
<h1 class="text-5xl font-extrabold text-gray-900 mb-4"> <h1 class="text-5xl font-extrabold text-gray-900 mb-4 flex items-center justify-center gap-3">
{{if .SiteHomeWelcome}}{{.SiteHomeWelcome}}{{else}}{{index .Tr "home_welcome"}}{{end}} <svg class="w-12 h-12 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/>
</svg>
<span>{{if .SiteHomeWelcome}}{{.SiteHomeWelcome}}{{else}}{{index .Tr "home_welcome"}}{{end}}</span>
</h1> </h1>
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto"> <p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto flex items-center justify-center gap-2">
{{if .SiteHomeSubtitle}}{{.SiteHomeSubtitle}}{{else}}{{index .Tr "home_subtitle"}}{{end}} <svg class="w-6 h-6 text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span>{{if .SiteHomeSubtitle}}{{.SiteHomeSubtitle}}{{else}}{{index .Tr "home_subtitle"}}{{end}}</span>
</p> </p>
</section> </section>
<section class="max-w-4xl mx-auto px-4 py-8"> <section class="max-w-7xl mx-auto px-4 py-8">
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2> <div class="flex gap-8">
<!-- Main content (articles) -->
<div class="flex-1">
{{if .FilterTag}}
<div class="mb-6 flex items-center gap-2 text-sm">
<span class="text-gray-600">{{index .Tr "tag_filter"}}:</span>
<span class="px-3 py-1 bg-blue-100 text-blue-700 rounded-full">
{{if eq .Lang "zh"}}{{.FilterTag.NameZh}}{{else}}{{.FilterTag.NameEn}}{{end}}
</span>
<a href="/" class="text-blue-600 hover:text-blue-800">{{index .Tr "tag_all"}}</a>
</div>
{{end}}
<!-- Articles Container --> <h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">{{index .Tr "home_latest"}}</h2>
<div id="articlesContainer" class="space-y-6">
<!-- Articles Container -->
<div id="articlesContainer" class="space-y-6">
{{range .Articles}} {{range .Articles}}
<article class="article-card bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow" data-cover="{{.Cover}}"> <article class="article-card bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow" data-cover="{{.Cover}}">
<a href="/article/{{.Slug}}" class="block article-link"> <a href="/article/{{.Slug}}" class="block article-link">
@@ -33,6 +68,15 @@
{{if .Summary}} {{if .Summary}}
<p class="text-gray-500 text-sm line-clamp-3 mb-3">{{.Summary}}</p> <p class="text-gray-500 text-sm line-clamp-3 mb-3">{{.Summary}}</p>
{{end}} {{end}}
{{if .Tags}}
<div class="flex items-center gap-2 flex-wrap mb-3">
{{range .Tags}}
<a href="/?tag={{.Slug}}" class="text-xs px-2 py-1 bg-gray-100 hover:bg-blue-100 text-gray-600 hover:text-blue-600 rounded transition-colors">
{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}
</a>
{{end}}
</div>
{{end}}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span> <span class="text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span>
<div class="flex items-center gap-4 text-sm text-gray-500"> <div class="flex items-center gap-4 text-sm text-gray-500">
@@ -70,6 +114,28 @@
<div id="noMoreArticles" class="hidden text-center text-gray-500 py-8"> <div id="noMoreArticles" class="hidden text-center text-gray-500 py-8">
{{if eq .Lang "zh"}}没有更多文章了{{else}}No more articles{{end}} {{if eq .Lang "zh"}}没有更多文章了{{else}}No more articles{{end}}
</div> </div>
</div>
<!-- Sidebar (tags) -->
<aside class="w-72 flex-shrink-0 hidden lg:block">
<div class="sticky top-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">{{index .Tr "tags_title"}}</h3>
{{if .Tags}}
<div class="flex flex-wrap gap-2">
{{range .Tags}}
<a href="/?tag={{.Slug}}"
class="px-3 py-1 text-sm bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full transition-colors inline-flex items-center gap-1">
<span>{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}</span>
<span class="text-xs text-gray-500">({{.Count}})</span>
</a>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-500">{{if eq .Lang "zh"}}暂无标签{{else}}No tags yet{{end}}</p>
{{end}}
</div>
</aside>
</div>
</section> </section>
<style> <style>
+7
View File
@@ -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" .}}
+90
View File
@@ -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}}
+241
View File
@@ -0,0 +1,241 @@
{{define "search"}}
{{template "header" .}}
<!-- Search Box -->
<section class="max-w-5xl mx-auto px-4 py-6">
<div class="flex justify-center">
<form action="/search" method="get" class="w-full max-w-2xl">
<div class="relative">
<input type="text" name="q" value="{{.SearchKeyword}}" placeholder="{{index .Tr "search_placeholder"}}"
class="w-full px-4 py-3 pl-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
<svg class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</form>
</div>
</section>
<section class="max-w-7xl mx-auto px-4 py-8">
<div class="flex gap-8">
<!-- Main content (search results) -->
<div class="flex-1">
{{if .SearchKeyword}}
<h2 class="text-2xl font-bold text-gray-900 mb-2">{{index .Tr "search_results_for"}}</h2>
<p class="text-gray-600 mb-8">"{{.SearchKeyword}}" - {{if eq .Lang "zh"}}找到{{else}}Found{{end}} {{len .Articles}} {{if eq .Lang "zh"}}篇文章{{else}}articles{{end}}</p>
{{else}}
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{index .Tr "search_title"}}</h2>
{{end}}
<!-- Articles Container -->
<div class="space-y-6">
{{range .Articles}}
<article class="article-card bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden hover:shadow-md transition-shadow" data-cover="{{.Cover}}">
<a href="/article/{{.Slug}}" class="block article-link">
<div class="article-content">
<!-- Cover will be positioned by JS -->
{{if .Cover}}
<div class="article-cover">
<img src="{{.Cover}}" alt="{{.Title}}" class="article-image" loading="lazy">
</div>
{{end}}
<div class="article-text p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">
{{.Title}}
{{if .IsTop}}<span class="align-middle text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded ml-1">{{index $.Tr "article_is_top"}}</span>{{end}}
</h3>
{{if .Summary}}
<p class="text-gray-500 text-sm line-clamp-3 mb-3">{{.Summary}}</p>
{{end}}
{{if .Tags}}
<div class="flex items-center gap-2 flex-wrap mb-3">
{{range .Tags}}
<a href="/?tag={{.Slug}}" class="text-xs px-2 py-1 bg-gray-100 hover:bg-blue-100 text-gray-600 hover:text-blue-600 rounded transition-colors">
{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}
</a>
{{end}}
</div>
{{end}}
<div class="flex items-center justify-between">
<span class="text-sm text-blue-600 font-medium">{{index $.Tr "home_read_more"}} →</span>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{.ViewCount}}
</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<span class="comment-count">{{index $.CommentCounts .ID}}</span>
</span>
</div>
</div>
</div>
</div>
</a>
</article>
{{else}}
<div class="text-center text-gray-500 py-12">
{{if .SearchKeyword}}
{{index .Tr "search_no_results"}}
{{else}}
{{index .Tr "home_no_posts"}}
{{end}}
</div>
{{end}}
</div>
</div>
<!-- Sidebar (tags) -->
<aside class="w-72 flex-shrink-0 hidden lg:block">
<div class="sticky top-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">{{index .Tr "tags_title"}}</h3>
{{if .Tags}}
<div class="flex flex-wrap gap-2">
{{range .Tags}}
<a href="/?tag={{.Slug}}"
class="px-3 py-1 text-sm bg-gray-100 hover:bg-blue-100 text-gray-700 hover:text-blue-700 rounded-full transition-colors inline-flex items-center gap-1">
<span>{{if eq $.Lang "zh"}}{{.NameZh}}{{else}}{{.NameEn}}{{end}}</span>
<span class="text-xs text-gray-500">({{.Count}})</span>
</a>
{{end}}
</div>
{{else}}
<p class="text-sm text-gray-500">{{if eq .Lang "zh"}}暂无标签{{else}}No tags yet{{end}}</p>
{{end}}
</div>
</aside>
</div>
</section>
<style>
/* Article card layouts */
.article-card {
position: relative;
}
.article-link {
text-decoration: none;
color: inherit;
}
.article-content {
display: flex;
}
/* Default: no cover or loading */
.article-content {
flex-direction: column;
}
/* Horizontal layout (landscape image on top) */
.article-card.layout-horizontal .article-content {
flex-direction: column;
}
.article-card.layout-horizontal .article-cover {
width: 100%;
height: 400px;
overflow: hidden;
background: #f3f4f6;
}
.article-card.layout-horizontal .article-image {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Vertical layout (portrait/square image on left) */
.article-card.layout-vertical .article-content {
flex-direction: row;
}
.article-card.layout-vertical .article-cover {
width: 280px;
min-width: 280px;
height: 200px;
overflow: hidden;
background: #f3f4f6;
}
.article-card.layout-vertical .article-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.article-card.layout-vertical .article-text {
flex: 1;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.article-card.layout-vertical .article-content {
flex-direction: column;
}
.article-card.layout-vertical .article-cover {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
}
}
/* Line clamp utility */
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<script>
(function() {
// Determine layout based on image aspect ratio
function determineLayout(card) {
const cover = card.getAttribute('data-cover');
if (!cover) {
return; // No cover, default column layout
}
const img = card.querySelector('.article-image');
if (!img) return;
// Wait for image to load
if (!img.complete) {
img.addEventListener('load', function() {
applyLayout(card, img);
});
} else {
applyLayout(card, img);
}
}
function applyLayout(card, img) {
const width = img.naturalWidth;
const height = img.naturalHeight;
const aspectRatio = width / height;
// If aspect ratio > 1.2, use horizontal (landscape)
// Otherwise use vertical (portrait/square on left)
if (aspectRatio > 1.2) {
card.classList.add('layout-horizontal');
} else {
card.classList.add('layout-vertical');
}
}
// Initialize existing articles
document.querySelectorAll('.article-card').forEach(determineLayout);
})();
</script>
{{template "footer" .}}
{{end}}
+7
View File
@@ -48,6 +48,13 @@
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_cover_help"}}</p> <p class="mt-1 text-sm text-gray-500">{{index .Tr "article_cover_help"}}</p>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_published_at"}}</label>
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_published_at_hint"}}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_status"}}</label> <label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_status"}}</label>
-127
View File
@@ -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 ""
-88
View File
@@ -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