From b0ca76e8dc1f5c01201274b07a65838c0692f033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Mon, 22 Jun 2026 20:20:19 +0800 Subject: [PATCH] feat: add custom publish time and last updated time display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ARTICLE_UPDATE_TIME_FEATURE.md | 90 +++++++++++++++++++++++ PUBLISHED_AT_FEATURE.md | 93 ++++++++++++++++++++++++ handlers/article.go | 108 +++++++++++++++++++--------- handlers/home.go | 6 ++ handlers/my_articles.go | 40 ++++++----- i18n/i18n.go | 6 ++ templates/admin/article_create.html | 8 +++ templates/pages/article.html | 9 ++- templates/user/my_article_form.html | 7 ++ 9 files changed, 315 insertions(+), 52 deletions(-) create mode 100644 ARTICLE_UPDATE_TIME_FEATURE.md create mode 100644 PUBLISHED_AT_FEATURE.md diff --git a/ARTICLE_UPDATE_TIME_FEATURE.md b/ARTICLE_UPDATE_TIME_FEATURE.md new file mode 100644 index 0000000..2daf983 --- /dev/null +++ b/ARTICLE_UPDATE_TIME_FEATURE.md @@ -0,0 +1,90 @@ +# 文章详情页显示最后更新时间功能 + +## 功能概述 + +为文章详情页添加了最后更新时间的显示,让读者可以看到文章的发布时间和最后修改时间。 + +## 修改的文件 + +### 1. 前端模板 + +#### `/templates/pages/article.html` +- 修改了文章元信息显示部分(第24-33行) +- 将原来的单一时间戳改为显示两个时间: + - **发布时间**:文章首次发布的时间(`PublishedAt`) + - **最后更新**:文章最后修改的时间(`UpdatedAt`) +- 使用条件判断确保只在有值时显示 + +显示格式: +``` +作者名 · 发布时间: 2024-01-15 10:30 · 最后更新: 2024-01-20 14:25 +``` + +### 2. 后端处理器 + +#### `/handlers/home.go` +- 修改 `renderArticleDetail()` 函数(第189-201行): + - 添加 `data["UpdatedAt"]` 传递更新时间到模板 +- 添加 `formatUpdateTime()` 函数(第213-216行): + - 格式化 `UpdatedAt` 字段为可读的时间字符串 + - 格式:`2006-01-02 15:04`(年-月-日 时:分) + +### 3. 国际化文本 + +#### `/i18n/i18n.go` +添加了翻译键: +- `article_last_updated`: "Last Updated" / "最后更新" + +## 功能特性 + +1. **双时间戳显示**: + - 发布时间(PublishedAt):文章首次发布的时间 + - 最后更新(UpdatedAt):文章最后一次修改的时间 + +2. **自动更新**: + - `UpdatedAt` 字段由 GORM 自动维护 + - 每次调用 `Updates()` 或 `Save()` 时自动更新 + +3. **智能显示**: + - 如果文章没有发布时间,不显示发布时间 + - 始终显示最后更新时间 + +4. **时间格式**: + - 统一使用 `YYYY-MM-DD HH:MM` 格式 + - 清晰易读 + +## 显示效果 + +文章详情页顶部会显示: + +### 英文界面 +``` +John Doe · Published: 2024-01-15 10:30 · Last Updated: 2024-01-20 14:25 +``` + +### 中文界面 +``` +张三 · 发布时间: 2024-01-15 10:30 · 最后更新: 2024-01-20 14:25 +``` + +## 技术实现 + +- **数据库字段**:`updated_at` 字段是 GORM 的标准字段,类型为 `time.Time` +- **自动维护**:GORM 在每次更新记录时自动设置 `updated_at` +- **格式化**:使用 Go 的标准时间格式 `2006-01-02 15:04` + +## 与发布时间编辑功能的配合 + +这个功能与之前实现的"自定义发布时间"功能完美配合: +- **发布时间**(PublishedAt):可以由用户手动设置或自动生成 +- **更新时间**(UpdatedAt):始终由系统自动维护,反映真实的修改时间 + +这样读者可以清楚地知道: +1. 文章最初是什么时候发布的 +2. 文章最后一次修改是什么时候 + +## 用户价值 + +1. **内容时效性**:读者可以判断文章内容是否及时更新 +2. **信息透明**:清楚显示文章的发布和修改历史 +3. **信任度提升**:显示更新时间表明作者在持续维护内容 diff --git a/PUBLISHED_AT_FEATURE.md b/PUBLISHED_AT_FEATURE.md new file mode 100644 index 0000000..77f3c60 --- /dev/null +++ b/PUBLISHED_AT_FEATURE.md @@ -0,0 +1,93 @@ +# 文章发布时间编辑功能 + +## 功能概述 + +为博客系统的文章编辑页面添加了自定义发布时间的功能,允许管理员和普通用户在创建或编辑文章时手动设置发布时间。 + +## 修改的文件 + +### 1. 前端模板 + +#### `/templates/admin/article_create.html` +- 在"置顶"选项前添加了发布时间输入框 +- 使用 `datetime-local` 类型的输入框,支持选择日期和时间 +- 添加提示文本:留空则在发布时自动设置 + +#### `/templates/user/my_article_form.html` +- 在状态选择框前添加了发布时间输入框 +- 使用相同的 `datetime-local` 输入框 +- 添加相应的提示文本 + +### 2. 后端处理器 + +#### `/handlers/article.go` +- 修改 `articleForm` 结构体,添加 `PublishedAt string` 字段 +- 修改 `parseArticleForm()` 函数,解析表单中的 `published_at` 字段 +- 修改 `applyFormToData()` 函数,将 `PublishedAt` 传递给模板 +- 添加 `parsePublishedAt()` 函数:解析 datetime-local 格式的时间字符串 +- 添加 `formatPublishedAt()` 函数:将时间格式化为 datetime-local 格式用于表单回显 +- 修改 `ArticleEditPage()` 函数:在编辑页面回显发布时间 +- 修改 `ArticleUpdate()` 函数: + - 如果用户提供了自定义发布时间,则使用该时间 + - 否则保持原有逻辑(首次发布时自动设置当前时间) +- 修改 `ArticleCreate()` 函数: + - 如果用户提供了自定义发布时间,则使用该时间 + - 否则在发布时自动设置当前时间 + +#### `/handlers/my_articles.go` +- 修改 `MyArticleEditPage()` 函数:在编辑页面回显发布时间 +- 修改 `MyArticleUpdate()` 函数: + - 如果用户提供了自定义发布时间,则使用该时间 + - 否则保持原有逻辑(首次发布时自动设置当前时间) + +### 3. 国际化文本 + +#### `/i18n/i18n.go` +添加了以下翻译键: +- `article_published_at`: "Published Time" / "发布时间" +- `article_published_at_hint`: "Leave blank to auto-set on publish" / "留空则在发布时自动设置" + +## 功能特性 + +1. **自定义发布时间**:用户可以手动设置文章的发布时间,适用于: + - 导入历史文章时保留原始发布时间 + - 预设未来的发布时间(虽然文章会立即可见) + - 修正错误的发布时间 + +2. **自动时间戳**:如果用户不填写发布时间: + - 保存为草稿:不设置发布时间 + - 首次发布:自动设置为当前时间 + - 已发布文章再次编辑:保持原发布时间不变 + +3. **向后兼容**: + - 现有的自动时间戳逻辑完全保留 + - 只有在用户明确输入时间时才会覆盖自动时间 + +4. **双语支持**:中英文界面均已适配 + +## 使用方法 + +### 管理员编辑页面 +1. 访问 `/admin/articles/:id/edit` 或 `/admin/articles/new` +2. 在"发布时间"字段中选择日期和时间 +3. 留空则使用自动时间戳 + +### 普通用户编辑页面 +1. 访问 `/my/articles/:id/edit` 或 `/my/articles/new` +2. 在"发布时间"字段中选择日期和时间 +3. 留空则使用自动时间戳 + +## 技术实现 + +- **时间格式**:使用 HTML5 `datetime-local` 输入类型,格式为 `2006-01-02T15:04` +- **时区处理**:使用服务器的本地时区 (`time.Local`) 进行解析和格式化 +- **数据库**:`published_at` 字段类型为 `*time.Time`(可为空) + +## 测试建议 + +1. 创建新文章时设置自定义发布时间 +2. 创建新文章时留空发布时间(应自动设置) +3. 编辑已发布文章并修改发布时间 +4. 编辑已发布文章但不修改发布时间(应保持原时间) +5. 将草稿改为发布状态(应自动设置发布时间或使用自定义时间) +6. 测试中英文界面的显示 diff --git a/handlers/article.go b/handlers/article.go index a34123e..5b27756 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -69,29 +69,31 @@ func fallbackSlug(id uint) string { // articleForm holds the parsed article form fields, shared by the create and // edit handlers and their validation-error repopulation paths. type articleForm struct { - Title string - Slug string - Summary string - Content string - Cover string - StatusStr string - IsTop bool - Action string // form action URL - TitleText string // page heading text (create vs edit) - ArticleID uint // existing article ID (edit page); 0 on create - SessionToken string // pending-attachment ownership token (create page) + Title string + Slug string + Summary string + Content string + Cover string + StatusStr string + IsTop bool + PublishedAt string // datetime-local format: "2006-01-02T15:04" + Action string // form action URL + TitleText string // page heading text (create vs edit) + ArticleID uint // existing article ID (edit page); 0 on create + SessionToken string // pending-attachment ownership token (create page) } // parseArticleForm reads and trims the article form fields from the request. func parseArticleForm(c *gin.Context) articleForm { return articleForm{ - Title: strings.TrimSpace(c.PostForm("title")), - Slug: strings.TrimSpace(c.PostForm("slug")), - Summary: strings.TrimSpace(c.PostForm("summary")), - Content: strings.TrimSpace(c.PostForm("content")), - Cover: strings.TrimSpace(c.PostForm("cover")), - StatusStr: c.PostForm("status"), - IsTop: c.PostForm("is_top") == "1", + Title: strings.TrimSpace(c.PostForm("title")), + Slug: strings.TrimSpace(c.PostForm("slug")), + Summary: strings.TrimSpace(c.PostForm("summary")), + Content: strings.TrimSpace(c.PostForm("content")), + Cover: strings.TrimSpace(c.PostForm("cover")), + StatusStr: c.PostForm("status"), + IsTop: c.PostForm("is_top") == "1", + PublishedAt: strings.TrimSpace(c.PostForm("published_at")), } } @@ -105,6 +107,7 @@ func applyFormToData(data gin.H, f articleForm) { data["FormCover"] = f.Cover data["FormStatus"] = f.StatusStr data["FormIsTop"] = f.IsTop + data["FormPublishedAt"] = f.PublishedAt data["FormAction"] = f.Action data["FormTitleText"] = f.TitleText data["FormArticleID"] = f.ArticleID @@ -154,6 +157,29 @@ func statusFromForm(statusStr string) int { return models.ArticleDraft } +// parsePublishedAt parses the datetime-local format ("2006-01-02T15:04") from +// the form into a time.Time pointer. Returns nil if the string is empty or invalid. +func parsePublishedAt(publishedAtStr string) *time.Time { + if publishedAtStr == "" { + return nil + } + // datetime-local format: "2006-01-02T15:04" + t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local) + if err != nil { + return nil + } + return &t +} + +// formatPublishedAt formats a time.Time pointer into datetime-local format for the form. +// Returns empty string if the pointer is nil. +func formatPublishedAt(t *time.Time) string { + if t == nil { + return "" + } + return t.Local().Format("2006-01-02T15:04") +} + // ArticleCreatePage renders the article creation form. func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { @@ -205,7 +231,11 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { } var publishedAt *time.Time - if status == models.ArticlePublished { + if f.PublishedAt != "" { + // User provided a custom published time + publishedAt = parsePublishedAt(f.PublishedAt) + } else if status == models.ArticlePublished { + // Auto-stamp with current time if publishing without custom time now := time.Now() publishedAt = &now } @@ -271,16 +301,17 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc { } renderArticleForm(c, db, articleForm{ - Title: article.Title, - Slug: article.Slug, - Summary: article.Summary, - Content: article.Content, - Cover: article.Cover, - StatusStr: strconv.Itoa(article.Status), - IsTop: article.IsTop, - Action: "/admin/articles/" + id + "/edit", - TitleText: tr["article_edit_title"], - ArticleID: article.ID, + Title: article.Title, + Slug: article.Slug, + Summary: article.Summary, + Content: article.Content, + Cover: article.Cover, + StatusStr: strconv.Itoa(article.Status), + IsTop: article.IsTop, + PublishedAt: formatPublishedAt(article.PublishedAt), + Action: "/admin/articles/" + id + "/edit", + TitleText: tr["article_edit_title"], + ArticleID: article.ID, }, "") } } @@ -319,12 +350,19 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc { newStatus := statusFromForm(f.StatusStr) - // Stamp the publish time the first time an article is published. - wasPublished := article.Status == models.ArticlePublished - var publishedAt *time.Time = article.PublishedAt - if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { - now := time.Now() - publishedAt = &now + // Handle published_at: use form value if provided, otherwise auto-stamp on first publish. + var publishedAt *time.Time + if f.PublishedAt != "" { + // User provided a custom published time + publishedAt = parsePublishedAt(f.PublishedAt) + } else { + // Auto-stamp the publish time the first time an article is published. + wasPublished := article.Status == models.ArticlePublished + publishedAt = article.PublishedAt + if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { + now := time.Now() + publishedAt = &now + } } updates := map[string]interface{}{ diff --git a/handlers/home.go b/handlers/home.go index 0ab3a2f..d64755a 100644 --- a/handlers/home.go +++ b/handlers/home.go @@ -191,6 +191,7 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f data["Article"] = article data["AuthorName"] = authorName data["PublishedAt"] = formatPublishTime(article.PublishedAt) + data["UpdatedAt"] = formatUpdateTime(article.UpdatedAt) data["Comments"] = tree data["CommentConfig"] = models.GetCommentConfig() data["CommentForm"] = form @@ -209,6 +210,11 @@ func formatPublishTime(publishedAt *time.Time) string { return "" } +// formatUpdateTime returns the last update time as a readable string. +func formatUpdateTime(updatedAt time.Time) string { + return updatedAt.Format("2006-01-02 15:04") +} + // commentFlashKey is the session key for the one-time comment notice. const commentFlashKey = "comment_flash" diff --git a/handlers/my_articles.go b/handlers/my_articles.go index d17e543..c017712 100644 --- a/handlers/my_articles.go +++ b/handlers/my_articles.go @@ -66,16 +66,17 @@ func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc { } renderMyArticleForm(c, db, articleForm{ - Title: article.Title, - Slug: article.Slug, - Summary: article.Summary, - Content: article.Content, - Cover: article.Cover, - StatusStr: strconv.Itoa(article.Status), - IsTop: article.IsTop, - Action: "/my/articles/" + id + "/edit", - TitleText: tr["article_edit_title"], - ArticleID: article.ID, + Title: article.Title, + Slug: article.Slug, + Summary: article.Summary, + Content: article.Content, + Cover: article.Cover, + StatusStr: strconv.Itoa(article.Status), + IsTop: article.IsTop, + PublishedAt: formatPublishedAt(article.PublishedAt), + Action: "/my/articles/" + id + "/edit", + TitleText: tr["article_edit_title"], + ArticleID: article.ID, }, "") } } @@ -117,12 +118,19 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { newStatus := statusFromForm(f.StatusStr) - // Stamp the publish time the first time an article is published. - wasPublished := article.Status == models.ArticlePublished - var publishedAt *time.Time = article.PublishedAt - if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { - now := time.Now() - publishedAt = &now + // Handle published_at: use form value if provided, otherwise auto-stamp on first publish. + var publishedAt *time.Time + if f.PublishedAt != "" { + // User provided a custom published time + publishedAt = parsePublishedAt(f.PublishedAt) + } else { + // Stamp the publish time the first time an article is published. + wasPublished := article.Status == models.ArticlePublished + publishedAt = article.PublishedAt + if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { + now := time.Now() + publishedAt = &now + } } updates := map[string]interface{}{ diff --git a/i18n/i18n.go b/i18n/i18n.go index b39d54e..e32d73d 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -122,6 +122,8 @@ var translations = map[Lang]map[string]string{ "article_published": "Published", "article_field_is_top": "Pin to top", "article_is_top": "Pin to top", + "article_published_at": "Published Time", + "article_published_at_hint": "Leave blank to auto-set on publish", "article_save_draft": "Save Draft", "article_save": "Save", "article_cancel": "Cancel", @@ -160,6 +162,7 @@ var translations = map[Lang]map[string]string{ "article_col_status": "Status", "article_col_published": "Published", "article_col_actions": "Actions", + "article_last_updated": "Last Updated", // Settings (platform configuration) "settings_nav": "Platform Settings", @@ -475,6 +478,8 @@ var translations = map[Lang]map[string]string{ "article_published": "已发布", "article_field_is_top": "置顶", "article_is_top": "置顶", + "article_published_at": "发布时间", + "article_published_at_hint": "留空则在发布时自动设置", "article_save_draft": "保存草稿", "article_save": "保存", "article_cancel": "取消", @@ -513,6 +518,7 @@ var translations = map[Lang]map[string]string{ "article_col_status": "状态", "article_col_published": "发布时间", "article_col_actions": "操作", + "article_last_updated": "最后更新", // 平台设置 "settings_nav": "平台设置", diff --git a/templates/admin/article_create.html b/templates/admin/article_create.html index dd68a35..6939547 100644 --- a/templates/admin/article_create.html +++ b/templates/admin/article_create.html @@ -77,6 +77,14 @@ + +
+ + +

{{index .Tr "article_published_at_hint"}}

+
+
{{end}} {{.AuthorName}} - {{if .PublishedAt}}·{{.PublishedAt}}{{end}} + {{if .PublishedAt}} + · + {{index .Tr "article_col_published"}}: {{.PublishedAt}} + {{end}} + {{if .UpdatedAt}} + · + {{index .Tr "article_last_updated"}}: {{.UpdatedAt}} + {{end}}
{{if .Article.Cover}} diff --git a/templates/user/my_article_form.html b/templates/user/my_article_form.html index 7a7f3aa..be1d53a 100644 --- a/templates/user/my_article_form.html +++ b/templates/user/my_article_form.html @@ -48,6 +48,13 @@

{{index .Tr "article_cover_help"}}

+
+ + +

{{index .Tr "article_published_at_hint"}}

+
+