fix templates v1

This commit is contained in:
tamakyi
2026-10-22 17:04:10 +08:00
parent 8fcb23f7b1
commit bd02eb7ed2
12 changed files with 293 additions and 12 deletions
+28
View File
@@ -217,3 +217,31 @@
- ⚠️ `?fresh=1` 每次加载都会清草稿 —— 测「刷新后配置还在」必须导航到不带 fresh 的地址。 - ⚠️ `?fresh=1` 每次加载都会清草稿 —— 测「刷新后配置还在」必须导航到不带 fresh 的地址。
- ⚠️ Playwright 的 `fill()` 对 `input[type=range]` 报 Malformed value,要 evaluate 设 value + dispatch input。 - ⚠️ Playwright 的 `fill()` 对 `input[type=range]` 报 Malformed value,要 evaluate 设 value + dispatch input。
- ⚠️ 滑杆步长别用 0.02:0.55 会被吸附成 0.54/0.56,用户以为坏了。改 0.01。 - ⚠️ 滑杆步长别用 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 断点两侧切换干净,无需再调。
+22
View File
@@ -44,6 +44,9 @@ type QuestionsStore interface {
SetPinned(ctx context.Context, id uint, pinned bool) error SetPinned(ctx context.Context, id uint, pinned bool) error
SetRead(ctx context.Context, id uint) error SetRead(ctx context.Context, id uint) error
SetUnread(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) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error)
GetHotQuestions(ctx context.Context, limit int) ([]*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 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 全文搜索提问箱主人收到的提问(含私密,仅限本人管理后台使用)。 // SearchByUserID 全文搜索提问箱主人收到的提问(含私密,仅限本人管理后台使用)。
// 使用 LIKE 模糊匹配问题内容与回答;keyword 中的通配符 % _ \ 会被转义,避免模式滥用。 // 使用 LIKE 模糊匹配问题内容与回答;keyword 中的通配符 % _ \ 会被转义,避免模式滥用。
func (db *questions) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error) { func (db *questions) SearchByUserID(ctx context.Context, userID uint, keyword string) ([]*Question, error) {
+53
View File
@@ -37,6 +37,7 @@ func TestQuestions(t *testing.T) {
{"Count", testQuestionsCount}, {"Count", testQuestionsCount},
{"SetPrivate", testQuestionsSetPrivate}, {"SetPrivate", testQuestionsSetPrivate},
{"SetPublic", testQuestionsSetPublic}, {"SetPublic", testQuestionsSetPublic},
{"NextUnread", testQuestionsNextUnread},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Cleanup(func() { t.Cleanup(func() {
@@ -493,3 +494,55 @@ func testQuestionsSetPublic(t *testing.T, ctx context.Context, db *questions) {
require.False(t, got.IsPrivate) 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)
})
}
+4
View File
@@ -144,6 +144,9 @@ var enDict = map[string]string{
"qitem.markRead": "Mark as read", "qitem.markRead": "Mark as read",
"qitem.markUnread": "Mark as unread", "qitem.markUnread": "Mark as unread",
"qitem.readLocked": "Answered, locked as read", "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 page
"admin.profile": "Profile", "admin.profile": "Profile",
@@ -764,6 +767,7 @@ var enDict = map[string]string{
"question.flash.operated": "Operation successful", "question.flash.operated": "Operation successful",
"question.flash.markedRead": "Marked as read", "question.flash.markedRead": "Marked as read",
"question.flash.markedUnread": "Marked as unread", "question.flash.markedUnread": "Marked as unread",
"question.flash.noUnread": "No unread questions left",
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容 // 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
"question.errRestrictedPublish": "Publishing is suspended while this box is under moderation", "question.errRestrictedPublish": "Publishing is suspended while this box is under moderation",
"question.errRestrictedPublic": "Content cannot be made public while this box is under moderation", "question.errRestrictedPublic": "Content cannot be made public while this box is under moderation",
+4
View File
@@ -144,6 +144,9 @@ var jaDict = map[string]string{
"qitem.markRead": "既読にする", "qitem.markRead": "既読にする",
"qitem.markUnread": "未読にする", "qitem.markUnread": "未読にする",
"qitem.readLocked": "回答済みのため既読に固定", "qitem.readLocked": "回答済みのため既読に固定",
"qitem.nextUnread": "次の未読",
"qitem.nextUnreadTitle": "この質問を既読にして次の未読を開く",
"qitem.submitAndNext": "回答して次の未読を開く",
// 個人設定ページ // 個人設定ページ
"admin.profile": "プロフィール", "admin.profile": "プロフィール",
@@ -764,6 +767,7 @@ var jaDict = map[string]string{
"question.flash.operated": "操作が完了しました", "question.flash.operated": "操作が完了しました",
"question.flash.markedRead": "既読にしました", "question.flash.markedRead": "既読にしました",
"question.flash.markedUnread": "未読にしました", "question.flash.markedUnread": "未読にしました",
"question.flash.noUnread": "未読の質問はありません",
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容 // 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
"question.errRestrictedPublish": "現在はプラットフォームによる措置期間中のため、回答の公開を停止しています", "question.errRestrictedPublish": "現在はプラットフォームによる措置期間中のため、回答の公開を停止しています",
"question.errRestrictedPublic": "措置期間中はコンテンツを公開に設定できません", "question.errRestrictedPublic": "措置期間中はコンテンツを公開に設定できません",
+4
View File
@@ -144,6 +144,9 @@ var zhCNDict = map[string]string{
"qitem.markRead": "标记为已读", "qitem.markRead": "标记为已读",
"qitem.markUnread": "标记为未读", "qitem.markUnread": "标记为未读",
"qitem.readLocked": "已回答,已锁定为已读", "qitem.readLocked": "已回答,已锁定为已读",
"qitem.nextUnread": "下一条未读",
"qitem.nextUnreadTitle": "将本条标记为已读,并打开下一条未读",
"qitem.submitAndNext": "回答并进入下一条未读",
// 个人设置页 // 个人设置页
"admin.profile": "个人信息", "admin.profile": "个人信息",
@@ -764,6 +767,7 @@ var zhCNDict = map[string]string{
"question.flash.operated": "操作成功", "question.flash.operated": "操作成功",
"question.flash.markedRead": "已标记为已读", "question.flash.markedRead": "已标记为已读",
"question.flash.markedUnread": "已标记为未读", "question.flash.markedUnread": "已标记为未读",
"question.flash.noUnread": "没有未读提问了",
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容 // 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
"question.errRestrictedPublish": "当前处于平台处置期,已暂停发布回答", "question.errRestrictedPublish": "当前处于平台处置期,已暂停发布回答",
"question.errRestrictedPublic": "当前处于平台处置期,不可将内容设为公开", "question.errRestrictedPublic": "当前处于平台处置期,不可将内容设为公开",
+4
View File
@@ -144,6 +144,9 @@ var zhTWDict = map[string]string{
"qitem.markRead": "標記為已讀", "qitem.markRead": "標記為已讀",
"qitem.markUnread": "標記為未讀", "qitem.markUnread": "標記為未讀",
"qitem.readLocked": "已回答,已鎖定為已讀", "qitem.readLocked": "已回答,已鎖定為已讀",
"qitem.nextUnread": "下一條未讀",
"qitem.nextUnreadTitle": "將本條標記為已讀,並開啟下一條未讀",
"qitem.submitAndNext": "回答並進入下一條未讀",
// 個人設定頁 // 個人設定頁
"admin.profile": "個人資訊", "admin.profile": "個人資訊",
@@ -764,6 +767,7 @@ var zhTWDict = map[string]string{
"question.flash.operated": "操作成功", "question.flash.operated": "操作成功",
"question.flash.markedRead": "已標記為已讀", "question.flash.markedRead": "已標記為已讀",
"question.flash.markedUnread": "已標記為未讀", "question.flash.markedUnread": "已標記為未讀",
"question.flash.noUnread": "沒有未讀提問了",
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容 // 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
"question.errRestrictedPublish": "當前處於平台處置期,已暫停發布回答", "question.errRestrictedPublish": "當前處於平台處置期,已暫停發布回答",
"question.errRestrictedPublic": "當前處於平台處置期,不可將內容設為公開", "question.errRestrictedPublic": "當前處於平台處置期,不可將內容設為公開",
+2
View File
@@ -315,6 +315,8 @@ func New() *flamego.Flame {
f.Post("/pin", question.TogglePin) f.Post("/pin", question.TogglePin)
f.Post("/read", question.SetRead) f.Post("/read", question.SetRead)
f.Post("/unread", question.SetUnread) f.Post("/unread", question.SetUnread)
// 「下一条未读」= 标记本条已读 + 跳到最早的一条未读(改变状态,故为 POST)
f.Post("/next-unread", question.NextUnread)
f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer) f.Post("/answer", reqUserSignIn, form.Bind(form.PublishAnswerQuestion{}), question.PublishAnswer)
// 举报入口:公开、不要求登录(主要目击者是匿名访客),限流防刷。 // 举报入口:公开、不要求登录(主要目击者是匿名访客),限流防刷。
// 举报只写入 content_reports,不改变任何可见性;处置由箱主或 CLI 完成。 // 举报只写入 content_reports,不改变任何可见性;处置由箱主或 CLI 完成。
+85 -9
View File
@@ -73,6 +73,16 @@ func Questioner(ctx context.Context, pageUser *db.User) {
ctx.Map(canDelete) ctx.Map(canDelete)
ctx.Data["CanDelete"] = 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) followUps, err := db.FollowUps.GetByQuestionID(ctx.Request().Context(), questionID)
if err != nil { if err != nil {
@@ -116,18 +126,14 @@ func Questioner(ctx context.Context, pageUser *db.User) {
ctx.Data["BackFrom"] = backFrom ctx.Data["BackFrom"] = backFrom
// 从后台返回时,把进入时携带的筛选参数(sort/status/type/read/q)原样回传, // 从后台返回时,把进入时携带的筛选参数(sort/status/type/read/q)原样回传,
// 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。 // 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。
if backFrom == "admin" { // 「下一条未读」同样带上这份参数,否则答完一条后"返回"就掉回箱主页了。
backQuery := url.Values{} if backQuery := adminBackQuery(ctx); backFrom == "admin" {
for _, key := range []string{"sort", "status", "type", "read", "q"} {
if v := ctx.Query(key); v != "" {
backQuery.Set(key, v)
}
}
backTo := "/user/questions" backTo := "/user/questions"
if qs := backQuery.Encode(); qs != "" { if backQuery != "" {
backTo += "?" + qs backTo += "?" + backQuery
} }
ctx.Data["BackTo"] = backTo ctx.Data["BackTo"] = backTo
ctx.Data["NextQuery"] = backQuery
} }
ctx.Map(question) ctx.Map(question)
@@ -258,6 +264,18 @@ func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question
} else { } else {
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answered")) 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)) 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.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedUnread"))
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID)) 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()
}
+1
View File
@@ -187,6 +187,7 @@
<details class="uk-card uk-card-default uk-card-body tb-changelog"> <details class="uk-card uk-card-default uk-card-body tb-changelog">
<summary>近期更新</summary> <summary>近期更新</summary>
<ul class="tb-list"> <ul class="tb-list">
<li><strong>2026.10</strong> 优化前端显示,增加主题编辑器功能,预置3套主题,并添加了问题页面“进入下一条未读提问”的功能,优化可用性。</li>
<li><strong>2026.9</strong> 新增审核严格度选项,增加提问、投票、私聊的总开关,可设置定时收集、定量收集或关闭功能,前后端显示也更易用了。</li> <li><strong>2026.9</strong> 新增审核严格度选项,增加提问、投票、私聊的总开关,可设置定时收集、定量收集或关闭功能,前后端显示也更易用了。</li>
<li><strong>2026.8</strong> 新增<a href="https://box.shiroko.one/vote/sANyLkiBNUGN">投票功能</a>、追加提问、多语言、<a href="https://box.shiroko.one/_/tamakyi/2894">Markdown 显示</a>、置顶显示等,详情见<a href="https://blog.shiroko.one/record/tamabox_changelog_20260813">这里</a>。</li> <li><strong>2026.8</strong> 新增<a href="https://box.shiroko.one/vote/sANyLkiBNUGN">投票功能</a>、追加提问、多语言、<a href="https://box.shiroko.one/_/tamakyi/2894">Markdown 显示</a>、置顶显示等,详情见<a href="https://blog.shiroko.one/record/tamabox_changelog_20260813">这里</a>。</li>
<li><strong>2026.8</strong> 放弃使用 recaptcha 等对国内用户不友好的验证方式,更新密码加密方式,修复图片上传等一系列问题。</li> <li><strong>2026.8</strong> 放弃使用 recaptcha 等对国内用户不友好的验证方式,更新密码加密方式,修复图片上传等一系列问题。</li>
+47 -3
View File
@@ -14,6 +14,37 @@
height: 40px; height: 40px;
line-height: 38px; line-height: 38px;
} }
/* 页脚次级操作之间的分隔:返回提问箱 / 下一条未读 靠在一起会连成一片 */
.tb-foot-sep {
margin: 0 4px;
color: #b0b6bd;
}
html[data-theme="dark"] .tb-foot-sep {
color: #5c6674;
}
/* 手机端三个按钮在 300px 内放不下一行(实测需 416px):
保留原来的「回答 / 标记为已读」一行不动,让「回答并下一条未读」独占下一行整行宽度。
display:contents 把两个提交按钮提升为该行的直接子项,才能用 order 把它排到状态按钮之后。 */
@media (max-width: 640px) {
.tb-ans-actions {
display: contents;
}
.tb-ans-actions .uk-button {
order: 1;
}
.tb-ans-state {
order: 2;
margin-left: auto;
}
.tb-ans-next {
order: 3;
flex: 1 1 100%;
}
/* 上面 .tb-ans-actions .uk-button 的 order:1 特异性更高,这里必须同级别盖回去 */
.tb-ans-actions .uk-button.tb-ans-next {
order: 3;
}
}
.tb-followup-card { .tb-followup-card {
border: 1px solid #e7e9ec; border: 1px solid #e7e9ec;
border-left: 3px solid #b0b6bd; border-left: 3px solid #b0b6bd;
@@ -245,7 +276,7 @@
<div class="uk-card-body uk-padding-remove-top"> <div class="uk-card-body uk-padding-remove-top">
<hr> <hr>
<h5 class="uk-text-center">{{t .LangCode "qitem.answerTitle"}}</h5> <h5 class="uk-text-center">{{t .LangCode "qitem.answerTitle"}}</h5>
<form id="tb-answer-form" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/answer" enctype="multipart/form-data"> <form id="tb-answer-form" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/answer{{ if .NextQuery }}?{{ .NextQuery }}{{ end }}" enctype="multipart/form-data">
{{ .CSRFTokenHTML }} {{ .CSRFTokenHTML }}
<div class="uk-margin uk-text-center"> <div class="uk-margin uk-text-center">
<textarea name="answer" class="uk-textarea" rows="5" maxlength="{{ .Limits.AnswerContentLen }}" <textarea name="answer" class="uk-textarea" rows="5" maxlength="{{ .Limits.AnswerContentLen }}"
@@ -268,8 +299,13 @@
</form> </form>
<!-- 回答(左)+ 阅读状态(右)同一行,压缩空间;tb-ans-row 保证左右按钮等高 --> <!-- 回答(左)+ 阅读状态(右)同一行,压缩空间;tb-ans-row 保证左右按钮等高 -->
<div class="uk-flex uk-flex-middle uk-flex-wrap uk-margin-small-top tb-ans-row" style="gap: 12px; justify-content: space-between;"> <div class="uk-flex uk-flex-middle uk-flex-wrap uk-margin-small-top tb-ans-row" style="gap: 12px; justify-content: space-between;">
<button type="submit" form="tb-answer-form" class="uk-button uk-button-primary">{{t .LangCode "qitem.submitAnswer"}}</button> <div class="uk-flex uk-flex-middle uk-flex-wrap tb-ans-actions" style="gap: 8px;">
<div class="uk-flex uk-flex-middle uk-flex-wrap" style="gap: 8px;"> <button type="submit" form="tb-answer-form" class="uk-button uk-button-primary">{{t .LangCode "qitem.submitAnswer"}}</button>
{{ if .NextUnreadID }}
<button type="submit" form="tb-answer-form" name="next" value="1" class="uk-button uk-button-default tb-ans-next">{{t .LangCode "qitem.submitAndNext"}}</button>
{{ end }}
</div>
<div class="uk-flex uk-flex-middle uk-flex-wrap tb-ans-state" style="gap: 8px;">
{{ if .Question.IsRead }} {{ if .Question.IsRead }}
<span class="uk-text-small uk-text-muted">✓ {{t .LangCode "qlist.read"}}</span> <span class="uk-text-small uk-text-muted">✓ {{t .LangCode "qlist.read"}}</span>
<form class="uk-display-inline" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/unread"> <form class="uk-display-inline" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/unread">
@@ -397,6 +433,14 @@
{{ $backTo := printf "/_/%s" .PageUser.Domain }} {{ $backTo := printf "/_/%s" .PageUser.Domain }}
{{ if eq .BackFrom "admin" }}{{ $backTo = .BackTo }}{{ end }} {{ if eq .BackFrom "admin" }}{{ $backTo = .BackTo }}{{ end }}
<a class="uk-button uk-button-link" href="{{ $backTo }}">{{t .LangCode "qitem.backToBox" .PageUser.Name}}</a> <a class="uk-button uk-button-link" href="{{ $backTo }}">{{t .LangCode "qitem.backToBox" .PageUser.Name}}</a>
{{ if and .CanDelete .NextUnreadID }}
<!-- 下一条未读:标记本条已读并跳转,会改状态,故用 POST 表单而非链接 -->
<span class="tb-foot-sep" aria-hidden="true">·</span>
<form class="uk-display-inline" method="post" action="/_/{{ .PageUser.Domain }}/{{ .Question.ID }}/next-unread{{ if .NextQuery }}?{{ .NextQuery }}{{ end }}">
{{ .CSRFTokenHTML }}
<button type="submit" class="uk-button uk-button-link" title="{{t .LangCode "qitem.nextUnreadTitle"}}">{{t .LangCode "qitem.nextUnread"}} ›</button>
</form>
{{ end }}
</div> </div>
<!-- Box owner can't create new question here. --> <!-- Box owner can't create new question here. -->
+39
View File
@@ -178,6 +178,7 @@ func TestGenPreview(t *testing.T) {
// 每个页面作用域(data-page-scope)各一份。新增页面时要同步 dev/theme-builder/index.html // 每个页面作用域(data-page-scope)各一份。新增页面时要同步 dev/theme-builder/index.html
// 的 SCOPES 列表,否则生成器里选不到。 // 的 SCOPES 列表,否则生成器里选不到。
writeQuestionItemPreviews(t, outDir) writeQuestionItemPreviews(t, outDir)
writeQuestionOwnerPreviews(t, outDir)
writeAdminListPreviews(t, outDir) writeAdminListPreviews(t, outDir)
writeVotePreviews(t, outDir) writeVotePreviews(t, outDir)
} }
@@ -213,6 +214,44 @@ func writeQuestionItemPreviews(t *testing.T, outDir string) {
} }
} }
// writeQuestionOwnerPreviews 单题页的箱主视角:回答行有「回答并下一条」、页脚有「下一条未读」,
// 未回答 / 已回答两种状态都要能肉眼核对排版。
func writeQuestionOwnerPreviews(t *testing.T, outDir string) {
t.Helper()
for _, state := range []struct {
name string
answer string
}{
{"unanswered", ""},
{"answered", "沿着河堤一直往西,到海边的旧仓库就掉头。"},
} {
for _, theme := range []string{"light", "dark"} {
data := publicBaseData()
data["VConsole"] = false
data["CanDelete"] = true
data["IsOwnPage"] = true
data["NextUnreadID"] = uint(2)
data["Question"] = map[string]interface{}{
"ID": 1, "Content": "你平时一个人骑车会去哪里?", "Answer": state.answer,
"CreatedAt": time.Now(), "UpdatedAt": time.Now(),
"IsPrivate": false, "IsPinned": false, "IsRead": state.answer != "",
"Token": "abc123", "ReceiveReplyEmail": "",
}
data["QuestionToken"] = "abc123"
data["AskUploadImages"] = []interface{}{}
data["AnswerUploadImages"] = []interface{}{}
data["FollowUps"] = []interface{}{}
data["CanFollowUp"] = false
data["IsQuestioner"] = false
page, err := renderToString(t, "question/item", data)
if err != nil {
t.Fatalf("render question/item owner: %v", err)
}
writePreview(t, outDir, "preview-question-owner-"+state.name+"-"+theme+".html", page, theme, "../static")
}
}
}
// writeAdminListPreviews 箱主后台的问题列表(/user/questions)。 // writeAdminListPreviews 箱主后台的问题列表(/user/questions)。
func writeAdminListPreviews(t *testing.T, outDir string) { func writeAdminListPreviews(t *testing.T, outDir string) {
t.Helper() t.Helper()