From bd02eb7ed2a1a3d29fe7223b2d8829c420773419 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 22 Oct 2026 17:04:10 +0800 Subject: [PATCH] fix templates v1 --- .workbuddy/memory/2026-10-22.md | 28 ++++++++++ internal/db/questions.go | 22 ++++++++ internal/db/questions_test.go | 53 +++++++++++++++++++ internal/i18n/dict_en.go | 4 ++ internal/i18n/dict_ja.go | 4 ++ internal/i18n/dict_zh_cn.go | 4 ++ internal/i18n/dict_zh_tw.go | 4 ++ internal/route/route.go | 2 + route/question/question.go | 94 +++++++++++++++++++++++++++++---- templates/home.html | 1 + templates/question/item.html | 50 ++++++++++++++++-- templates/zz_preview_test.go | 39 ++++++++++++++ 12 files changed, 293 insertions(+), 12 deletions(-) diff --git a/.workbuddy/memory/2026-10-22.md b/.workbuddy/memory/2026-10-22.md index 770209a..64e8273 100644 --- a/.workbuddy/memory/2026-10-22.md +++ b/.workbuddy/memory/2026-10-22.md @@ -217,3 +217,31 @@ - ⚠️ `?fresh=1` 每次加载都会清草稿 —— 测「刷新后配置还在」必须导航到不带 fresh 的地址。 - ⚠️ Playwright 的 `fill()` 对 `input[type=range]` 报 Malformed value,要 evaluate 设 value + dispatch input。 - ⚠️ 滑杆步长别用 0.02:0.55 会被吸附成 0.54/0.56,用户以为坏了。改 0.01。 + +## 单题页「下一条未读」(箱主收件箱式导航) + +- 新增 `db.Questions.NextUnread(ctx, userID, excludeID)`:未读 = `is_read=false AND answer=''`, + `created_at ASC` 取第一条,无则返回 `(nil, nil)`。正序=先来先处理,反复取即可清空积压。 +- `POST /_/{domain}/{qid}/next-unread`(`question.NextUnread`):**先 SetRead 再跳**。 + 只跳不标记会「打转」—— 当前这条仍是未读,下一次取最早未读又把它选回来(2 条未读时最明显)。 +- `PublishAnswer` 支持同表单第二个提交按钮 `name="next" value="1"`(「回答并下一条未读」)。 +- 按钮位置:回答行 `[回答][回答并下一条未读]`(箱主+有下一条时);页脚与「返回提问箱」同一行 + 放 `下一条未读 ›`(POST 表单 + `uk-button-link`,因为要改状态)。 +- `adminBackQuery(ctx)` 抽出(原 BackTo 逻辑),`NextQuery` 进模板 → 答案表单/next-unread 表单的 + action 都带上 `?from=admin&sort=...`,跳转后续「返回」仍回带筛选的后台列表。 + flamego `ctx.Query` 只读 URL query,所以 POST 必须把参数写在 action 里(不是隐藏域)。 +- 预览:`templates/zz_preview_test.go` 新增 `writeQuestionOwnerPreviews`(未回答/已回答 × 深浅)。 + ⚠️ 预览 HTML 的 `base.css` 是 `/static/...` 绝对路径,file:// 打开**不含主题变量**(暗色看着像亮色); + 要验暗色必须 `python -m http.server` 起本地服务走 http。移动端预览本身有横向溢出(waline 表情行所致),非本次改动。 +- 移动端排版实测(关键数据):390px 视口下 `.tb-ans-row` 只有 **300px**(360px 视口 270px), + 而「回答 90 + 回答并下一条未读 174 + 标记为已读 132 + 间距 20」= 416px,**收窄内边距也不够**(320px)。 + → 手机上一行放不下三个按钮,改成:第 1 行保留原「回答 … 标记为已读」,第 2 行「回答并下一条未读」整行宽。 + 实现:`@media(max-width:640px){ .tb-ans-actions{display:contents} .tb-ans-state{order:2;margin-left:auto} + .tb-ans-actions .uk-button{order:1} .tb-ans-actions .uk-button.tb-ans-next{order:3;flex:1 1 100%} }` + ⚠️ `.tb-ans-actions .uk-button`(0,2,0)特异性高于 `.tb-ans-next`(0,1,0),必须再写一条同级选择器盖 order。 +- 页脚两个次级操作之间加 `.tb-foot-sep`(`·`,浅色 #b0b6bd / 暗色 #5c6674),原来两个蓝色链接几乎连成一片。 +- Chrome headless 在 Windows 上 `--window-size` < 500 无效(innerWidth 恒 500)——要测真·移动端布局, + 用「父页面 + 宽 390 的 iframe」再取 `contentDocument` 量尺寸/截图,媒体查询才会按 iframe 宽度生效。 +- 文案定为「回答并进入下一条未读」(en: Answer & open next unread / ja: 回答して次の未読を開く)。 + 实测宽度:该按钮 202px;661px 视口(桌面态最窄)行宽 526 ≥ 90+202+132+20=444,仍单行; + 640px 断点两侧切换干净,无需再调。 diff --git a/internal/db/questions.go b/internal/db/questions.go index bb78cf5..ab1e344 100644 --- a/internal/db/questions.go +++ b/internal/db/questions.go @@ -44,6 +44,9 @@ type QuestionsStore interface { SetPinned(ctx context.Context, id uint, pinned bool) error SetRead(ctx context.Context, id uint) error SetUnread(ctx context.Context, id uint) error + // NextUnread 取该用户最早的一条未读提问(未读 = is_read=false 且未回答)。 + // excludeID 用于跳过正在查看的那条,避免在两条未读之间来回跳。 + NextUnread(ctx context.Context, userID, excludeID uint) (*Question, error) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error) GetHotQuestions(ctx context.Context, limit int) ([]*Question, error) @@ -502,6 +505,25 @@ func (db *questions) SetUnread(ctx context.Context, id uint) error { return db.WithContext(ctx).Model(&Question{}).Where("id = ? AND answer = ''", id).Update("is_read", false).Error } +// NextUnread 按创建时间正序取第一条未读提问(未读 = is_read=false 且未回答),无则返回 nil。 +// 正序即「先来先处理」:反复取下一条就能把积压按先后顺序清空,不会跳过更早的提问。 +func (db *questions) NextUnread(ctx context.Context, userID, excludeID uint) (*Question, error) { + var question Question + q := db.WithContext(ctx). + Model(&Question{}). + Where("user_id = ? AND is_read = ? AND answer = ''", userID, false) + if excludeID > 0 { + q = q.Where("id != ?", excludeID) + } + if err := q.Order("created_at ASC, id ASC").First(&question).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, errors.Wrap(err, "get next unread question") + } + return &question, nil +} + // SearchByUserID 全文搜索提问箱主人收到的提问(含私密,仅限本人管理后台使用)。 // 使用 LIKE 模糊匹配问题内容与回答;keyword 中的通配符 % _ \ 会被转义,避免模式滥用。 func (db *questions) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error) { diff --git a/internal/db/questions_test.go b/internal/db/questions_test.go index 8bfb9d6..0c60630 100644 --- a/internal/db/questions_test.go +++ b/internal/db/questions_test.go @@ -37,6 +37,7 @@ func TestQuestions(t *testing.T) { {"Count", testQuestionsCount}, {"SetPrivate", testQuestionsSetPrivate}, {"SetPublic", testQuestionsSetPublic}, + {"NextUnread", testQuestionsNextUnread}, } { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { @@ -493,3 +494,55 @@ func testQuestionsSetPublic(t *testing.T, ctx context.Context, db *questions) { require.False(t, got.IsPrivate) }) } + +// NextUnread 必须满足三条:只取本人的提问、只取未读(is_read=false 且未回答)、按最早优先。 +func testQuestionsNextUnread(t *testing.T, ctx context.Context, db *questions) { + create := func(userID uint, content string) *Question { + q, err := db.Create(ctx, CreateQuestionOptions{ + FromIP: "114.5.1.4", + UserID: userID, + Content: content, + }) + require.Nil(t, err) + return q + } + + q1 := create(1, "Content - 1") + q2 := create(1, "Content - 2") + q3 := create(1, "Content - 3") + other := create(2, "Content - other") + + t.Run("oldest first", func(t *testing.T) { + got, err := db.NextUnread(ctx, 1, 0) + require.Nil(t, err) + require.Equal(t, q1.ID, got.ID) + }) + + t.Run("exclude current", func(t *testing.T) { + got, err := db.NextUnread(ctx, 1, q1.ID) + require.Nil(t, err) + require.Equal(t, q2.ID, got.ID) + }) + + t.Run("only own questions", func(t *testing.T) { + got, err := db.NextUnread(ctx, 2, 0) + require.Nil(t, err) + require.Equal(t, other.ID, got.ID) + }) + + t.Run("skip read and answered", func(t *testing.T) { + require.Nil(t, db.SetRead(ctx, q1.ID)) + require.Nil(t, db.SetRead(ctx, q2.ID)) + require.Nil(t, db.AnswerByID(ctx, q3.ID, "Answer - 3")) + + got, err := db.NextUnread(ctx, 1, 0) + require.Nil(t, err) + require.Nil(t, got, "已读与已回答都不算未读") + }) + + t.Run("empty", func(t *testing.T) { + got, err := db.NextUnread(ctx, 404, 0) + require.Nil(t, err) + require.Nil(t, got) + }) +} diff --git a/internal/i18n/dict_en.go b/internal/i18n/dict_en.go index 752a66b..ffc0442 100644 --- a/internal/i18n/dict_en.go +++ b/internal/i18n/dict_en.go @@ -144,6 +144,9 @@ var enDict = map[string]string{ "qitem.markRead": "Mark as read", "qitem.markUnread": "Mark as unread", "qitem.readLocked": "Answered, locked as read", + "qitem.nextUnread": "Next unread", + "qitem.nextUnreadTitle": "Mark this one as read and open the next unread question", + "qitem.submitAndNext": "Answer & open next unread", // Admin / profile page "admin.profile": "Profile", @@ -764,6 +767,7 @@ var enDict = map[string]string{ "question.flash.operated": "Operation successful", "question.flash.markedRead": "Marked as read", "question.flash.markedUnread": "Marked as unread", + "question.flash.noUnread": "No unread questions left", // 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容 "question.errRestrictedPublish": "Publishing is suspended while this box is under moderation", "question.errRestrictedPublic": "Content cannot be made public while this box is under moderation", diff --git a/internal/i18n/dict_ja.go b/internal/i18n/dict_ja.go index c03b7a8..14b076d 100644 --- a/internal/i18n/dict_ja.go +++ b/internal/i18n/dict_ja.go @@ -144,6 +144,9 @@ var jaDict = map[string]string{ "qitem.markRead": "既読にする", "qitem.markUnread": "未読にする", "qitem.readLocked": "回答済みのため既読に固定", + "qitem.nextUnread": "次の未読", + "qitem.nextUnreadTitle": "この質問を既読にして次の未読を開く", + "qitem.submitAndNext": "回答して次の未読を開く", // 個人設定ページ "admin.profile": "プロフィール", @@ -764,6 +767,7 @@ var jaDict = map[string]string{ "question.flash.operated": "操作が完了しました", "question.flash.markedRead": "既読にしました", "question.flash.markedUnread": "未読にしました", + "question.flash.noUnread": "未読の質問はありません", // 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容 "question.errRestrictedPublish": "現在はプラットフォームによる措置期間中のため、回答の公開を停止しています", "question.errRestrictedPublic": "措置期間中はコンテンツを公開に設定できません", diff --git a/internal/i18n/dict_zh_cn.go b/internal/i18n/dict_zh_cn.go index 4d04363..ba1f8db 100644 --- a/internal/i18n/dict_zh_cn.go +++ b/internal/i18n/dict_zh_cn.go @@ -144,6 +144,9 @@ var zhCNDict = map[string]string{ "qitem.markRead": "标记为已读", "qitem.markUnread": "标记为未读", "qitem.readLocked": "已回答,已锁定为已读", + "qitem.nextUnread": "下一条未读", + "qitem.nextUnreadTitle": "将本条标记为已读,并打开下一条未读", + "qitem.submitAndNext": "回答并进入下一条未读", // 个人设置页 "admin.profile": "个人信息", @@ -764,6 +767,7 @@ var zhCNDict = map[string]string{ "question.flash.operated": "操作成功", "question.flash.markedRead": "已标记为已读", "question.flash.markedUnread": "已标记为未读", + "question.flash.noUnread": "没有未读提问了", // 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容 "question.errRestrictedPublish": "当前处于平台处置期,已暂停发布回答", "question.errRestrictedPublic": "当前处于平台处置期,不可将内容设为公开", diff --git a/internal/i18n/dict_zh_tw.go b/internal/i18n/dict_zh_tw.go index 1831b38..321b4bf 100644 --- a/internal/i18n/dict_zh_tw.go +++ b/internal/i18n/dict_zh_tw.go @@ -144,6 +144,9 @@ var zhTWDict = map[string]string{ "qitem.markRead": "標記為已讀", "qitem.markUnread": "標記為未讀", "qitem.readLocked": "已回答,已鎖定為已讀", + "qitem.nextUnread": "下一條未讀", + "qitem.nextUnreadTitle": "將本條標記為已讀,並開啟下一條未讀", + "qitem.submitAndNext": "回答並進入下一條未讀", // 個人設定頁 "admin.profile": "個人資訊", @@ -764,6 +767,7 @@ var zhTWDict = map[string]string{ "question.flash.operated": "操作成功", "question.flash.markedRead": "已標記為已讀", "question.flash.markedUnread": "已標記為未讀", + "question.flash.noUnread": "沒有未讀提問了", // 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容 "question.errRestrictedPublish": "當前處於平台處置期,已暫停發布回答", "question.errRestrictedPublic": "當前處於平台處置期,不可將內容設為公開", diff --git a/internal/route/route.go b/internal/route/route.go index ec2f8f4..3f649df 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -315,6 +315,8 @@ func New() *flamego.Flame { f.Post("/pin", question.TogglePin) f.Post("/read", question.SetRead) f.Post("/unread", question.SetUnread) + // 「下一条未读」= 标记本条已读 + 跳到最早的一条未读(改变状态,故为 POST) + f.Post("/next-unread", question.NextUnread) f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer) // 举报入口:公开、不要求登录(主要目击者是匿名访客),限流防刷。 // 举报只写入 content_reports,不改变任何可见性;处置由箱主或 CLI 完成。 diff --git a/route/question/question.go b/route/question/question.go index 0a86251..6bf11cc 100644 --- a/route/question/question.go +++ b/route/question/question.go @@ -73,6 +73,16 @@ func Questioner(ctx context.Context, pageUser *db.User) { ctx.Map(canDelete) ctx.Data["CanDelete"] = canDelete + // 箱主导航:下一条未读。只查一条(不做 count),用于决定详情页是否渲染该按钮。 + if canDelete { + nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question") + } else if nextUnread != nil { + ctx.Data["NextUnreadID"] = nextUnread.ID + } + } + // ---------- 追问 ---------- followUps, err := db.FollowUps.GetByQuestionID(ctx.Request().Context(), questionID) if err != nil { @@ -116,18 +126,14 @@ func Questioner(ctx context.Context, pageUser *db.User) { ctx.Data["BackFrom"] = backFrom // 从后台返回时,把进入时携带的筛选参数(sort/status/type/read/q)原样回传, // 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。 - if backFrom == "admin" { - backQuery := url.Values{} - for _, key := range []string{"sort", "status", "type", "read", "q"} { - if v := ctx.Query(key); v != "" { - backQuery.Set(key, v) - } - } + // 「下一条未读」同样带上这份参数,否则答完一条后"返回"就掉回箱主页了。 + if backQuery := adminBackQuery(ctx); backFrom == "admin" { backTo := "/user/questions" - if qs := backQuery.Encode(); qs != "" { - backTo += "?" + qs + if backQuery != "" { + backTo += "?" + backQuery } ctx.Data["BackTo"] = backTo + ctx.Data["NextQuery"] = backQuery } ctx.Map(question) @@ -258,6 +264,18 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question } else { ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answered")) } + + // 「回答并下一条」:同一表单的第二个提交按钮带 next=1。答题后本条已锁定为已读, + // 直接打开最早的一条未读;队列已空(渲染后被打空的竞态)则退回当前页。 + if ctx.Request().FormValue("next") == "1" { + nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question") + } else if nextUnread != nil { + ctx.Redirect(questionPath(ctx, pageUser.Domain, nextUnread.ID)) + return + } + } ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID)) } @@ -396,3 +414,61 @@ func SetUnread(ctx context.Context, pageUser *db.User, question *db.Question, ca ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedUnread")) ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID)) } + +// NextUnread 阅后即走:把当前提问标记为已读,然后打开最早的一条未读提问。 +// +// 必须同时改状态:只跳转而不标记的话,当前这条仍是未读,下一次取「最早未读」 +// 又会把它选回来,两条未读之间会来回打转。 +func NextUnread(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) { + if !canDelete { + ctx.Redirect("/_/" + pageUser.Domain) + return + } + + if err := db.Questions.SetRead(ctx.Request().Context(), question.ID); err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question read") + ctx.SetInternalError() + ctx.Success("question/item") + return + } + + nextUnread, err := db.Questions.NextUnread(ctx.Request().Context(), pageUser.ID, question.ID) + if err != nil { + logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get next unread question") + ctx.SetInternalError() + ctx.Success("question/item") + return + } + if nextUnread == nil { + ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.noUnread")) + ctx.Redirect(questionPath(ctx, pageUser.Domain, question.ID)) + return + } + + ctx.Redirect(questionPath(ctx, pageUser.Domain, nextUnread.ID)) +} + +// questionPath 拼单题页地址;从后台进入时带上筛选参数,跳转后「返回」仍回原列表。 +func questionPath(ctx context.Context, domain string, questionID uint) string { + path := fmt.Sprintf("/_/%s/%d", domain, questionID) + if backQuery := adminBackQuery(ctx); backQuery != "" { + path += "?" + backQuery + } + return path +} + +// adminBackQuery 提取后台列表的筛选参数(仅 from=admin 时有效),供单题页的 +// 「返回」「下一条未读」沿用。POST 请求从表单 action 的 query 里读取。 +func adminBackQuery(ctx context.Context) string { + if ctx.Query("from") != "admin" { + return "" + } + + backQuery := url.Values{} + for _, key := range []string{"sort", "status", "type", "read", "q"} { + if v := ctx.Query(key); v != "" { + backQuery.Set(key, v) + } + } + return backQuery.Encode() +} diff --git a/templates/home.html b/templates/home.html index 58952ad..c7d42dc 100644 --- a/templates/home.html +++ b/templates/home.html @@ -187,6 +187,7 @@
近期更新