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>
This commit is contained in:
2026-06-22 20:20:19 +08:00
co-authored by Claude Fable 5
parent b600eac21c
commit b0ca76e8dc
9 changed files with 315 additions and 52 deletions
+73 -35
View File
@@ -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{}{