fix templates v1
This commit is contained in:
@@ -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 断点两侧切换干净,无需再调。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "措置期間中はコンテンツを公開に設定できません",
|
||||
|
||||
@@ -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": "当前处于平台处置期,不可将内容设为公开",
|
||||
|
||||
@@ -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": "當前處於平台處置期,不可將內容設為公開",
|
||||
|
||||
@@ -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 完成。
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
<details class="uk-card uk-card-default uk-card-body tb-changelog">
|
||||
<summary>近期更新</summary>
|
||||
<ul class="tb-list">
|
||||
<li><strong>2026.10</strong> 优化前端显示,增加主题编辑器功能,预置3套主题,并添加了问题页面“进入下一条未读提问”的功能,优化可用性。</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> 放弃使用 recaptcha 等对国内用户不友好的验证方式,更新密码加密方式,修复图片上传等一系列问题。</li>
|
||||
|
||||
@@ -14,6 +14,37 @@
|
||||
height: 40px;
|
||||
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 {
|
||||
border: 1px solid #e7e9ec;
|
||||
border-left: 3px solid #b0b6bd;
|
||||
@@ -245,7 +276,7 @@
|
||||
<div class="uk-card-body uk-padding-remove-top">
|
||||
<hr>
|
||||
<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 }}
|
||||
<div class="uk-margin uk-text-center">
|
||||
<textarea name="answer" class="uk-textarea" rows="5" maxlength="{{ .Limits.AnswerContentLen }}"
|
||||
@@ -268,8 +299,13 @@
|
||||
</form>
|
||||
<!-- 回答(左)+ 阅读状态(右)同一行,压缩空间;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 tb-ans-actions" style="gap: 8px;">
|
||||
<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" style="gap: 8px;">
|
||||
{{ 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 }}
|
||||
<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">
|
||||
@@ -397,6 +433,14 @@
|
||||
{{ $backTo := printf "/_/%s" .PageUser.Domain }}
|
||||
{{ if eq .BackFrom "admin" }}{{ $backTo = .BackTo }}{{ end }}
|
||||
<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>
|
||||
|
||||
<!-- Box owner can't create new question here. -->
|
||||
|
||||
@@ -178,6 +178,7 @@ func TestGenPreview(t *testing.T) {
|
||||
// 每个页面作用域(data-page-scope)各一份。新增页面时要同步 dev/theme-builder/index.html
|
||||
// 的 SCOPES 列表,否则生成器里选不到。
|
||||
writeQuestionItemPreviews(t, outDir)
|
||||
writeQuestionOwnerPreviews(t, outDir)
|
||||
writeAdminListPreviews(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)。
|
||||
func writeAdminListPreviews(t *testing.T, outDir string) {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user