Compare commits

..
4 Commits
Author SHA1 Message Date
tamakyi bd02eb7ed2 fix templates v1 2026-10-22 17:04:10 +08:00
tamakyi 8fcb23f7b1 记忆:生成器顶栏面板 2026-10-22 16:16:05 +08:00
tamakyi e4810040a7 生成器新增「顶栏」面板:实色 / 半透明磨砂 / 全透明(仅预览)
以前顶栏只能在配色里填一个颜色字符串:写 rgba 拿不到模糊,档位和颜色又分在两处,
改了一处就忘了另一处。现在三档一个面板说话。

- S.navbar = {mode, alpha, blur},基色仍取「配色 → 顶栏」,navbarValue() 统一计算:
  solid 先 flatten 压平(旧工程里可能自带 alpha)、glass 加 alpha、clear 出 transparent。
  半透明档额外写 .uk-navbar-container{backdrop-filter:blur(N)} —— 令牌只能给颜色,
  模糊必须落成规则;选择器不带 data-theme,深浅两套共用一条。
- 全透明**只给预览**:validate() 拦导出,与 internal/theme 的 TestNavbarNotFullyTransparent
  同一条规矩,面板上直接写明原因。
- 面板带「导航文字 x.x:1」读数,按压平后的顶栏算(半透明时是顶栏压在底色上的结果),
  低于 4.5 变橙。
- 旧工程迁移:配色里塞过 rgba(...,0.52) 的,按 alpha 反推档位(1→实色 / 0→全透明 / 其余→半透明),
  打开旧工程不会突然变实色。
- 自检 ?dump=navbar(10 项:三档产出、blur 有无、全透明被拦、alpha 0.2/0.95 边界)。
- 滑杆步长 0.02 → 0.01:0.55 会被吸附成 0.54/0.56,手感像坏了。
2026-10-22 16:15:49 +08:00
tamakyi 8941744621 new-sakura:顶栏改半透明磨砂,并立规矩禁止全透明
线上(box.shiroko.one/_/tamakyi)两个现象来自同一处:`--tb-navbar` 是实色,
顶栏那条带(y=20..80)把从右上角长出来的花枝齐刷刷切掉一段。

第一版顺手改成 transparent —— 花枝完整了,但顶栏"消失":导航失去依托,
文字直接压在花纹上。用户当场否掉,要求半透明。

- 顶栏:`rgba(253,246,248,0.72)` / 深色 `rgba(34,26,30,0.72)`,配
  `.uk-navbar-container{backdrop-filter:blur(12px)}`(令牌只能是颜色,模糊写在规则里)。
  0.72 有依据:导航文字 #6d555d 纯底 6.34:1,最坏情况(深花瓣在顶栏后面)压平后 5.08:1。
- 花枝 top -16px → 26px:顶栏半透明后,700~760px 宽度下枝头正好压在「登录」上(截图确认)。
- 手机底栏 #tb-mobile-utils 在 base.css 写死 rgba(255,255,255,.92),粉底上是一条白杠,
  主题里改成同色系 0.92。
- 新增 TestNavbarNotFullyTransparent:拦 `transparent` / `rgba(...,0)` / `#rrggbb00`,
  并说明"这不是审美偏好,是踩过之后立的规矩"。变异验证过(改回 transparent 立即 FAIL)。
  约定同时写进 static/css/themes/_scaffold.css 的令牌说明。
- StaticVersion 20261022b → 20261022c:css 改了不提版本号,浏览器拿着旧样式,
  现象就是"改了但线上没变"。
2026-10-22 16:05:37 +08:00
22 changed files with 663 additions and 24 deletions
+60
View File
@@ -185,3 +185,63 @@
以后在 `.uk-container` 里加末位区块都绕这条。 以后在 `.uk-container` 里加末位区块都绕这条。
- 环境坑:沙箱 go 是 1.25.5,go.mod 要 1.27.3,GOTOOLCHAIN=auto 需下载工具链模块, - 环境坑:沙箱 go 是 1.25.5,go.mod 要 1.27.3,GOTOOLCHAIN=auto 需下载工具链模块,
而 GOSUMDB=off 会卡住校验 → 下载工具链那次要放开 GOSUMDB(goproxy.cn 会代理 sumdb)。 而 GOSUMDB=off 会卡住校验 → 下载工具链那次要放开 GOSUMDB(goproxy.cn 会代理 sumdb)。
**7) 顶栏:实色 → 半透明磨砂(用户:不能全透明,以后别再来一次)**
- 线上 `https://box.shiroko.one/_/tamakyi` 实测:`--tb-navbar=#fdf6f8` 实色,顶栏带(y=20..80)
把右上花枝齐刷刷切一段;`--tb-navbar` 默认是 `--tb-surface`,彩色主题不覆盖就成一条近白横带。
- 第一版改成 `transparent` 被用户否掉:花枝完整了但顶栏"消失",导航没依托、文字压在花纹上。
- 最终:`rgba(253,246,248,0.72)` / `rgba(34,26,30,0.72)` + `backdrop-filter: blur(12px)`
(模糊只能写在规则里,令牌只是颜色)。0.72 有依据:文字 `#6d555d` 纯底 6.34:1,
最坏(深花瓣压顶栏后面)压平后 5.08:1。
- 花枝 `top` 从 -16px 下移到 26px:顶栏透明后 700~760px 宽度下枝头会压到「登录」(截图确认)。
- 守门:`internal/theme/navbar_test.go::TestNavbarNotFullyTransparent` 拦
`transparent` / `rgba(...,0)` / `#rrggbb00`,变异验证过。约定同步写进
`static/css/themes/_scaffold.css` 的令牌注释与 `topics/theme.md`。
- 手机底栏 `#tb-mobile-utils` 在 base.css 写死 `rgba(255,255,255,.92)`,彩色主题要自己调(本例改同色系 0.92)。
- ⚠️ `StaticVersion` → `20261022c`:主题 CSS 改了必须提版本号,否则浏览器拿着旧 css,
现象就是「改了但线上没变」。
- ⚠️ 跑 go 命令时**别加 `GOSUMDB=off`**:本机 go 1.25.5,go.mod 要 1.27.3,
`GOTOOLCHAIN=auto` 要校验 toolchain 模块,关掉 sumdb 会直接报 "verifying module"。
只设 `GOPROXY=https://goproxy.cn,direct GOFLAGS=-mod=mod` 即可(toolchain 已缓存)。
**8) 生成器新增「顶栏」面板:实色 / 半透明(磨砂)/ 全透明**
- 状态 `S.navbar = {mode, alpha, blur}`(默认 glass 0.72 / 12px),基色仍取「配色 → 顶栏」,
`navbarValue(p)` 统一算:solid → `rgb()`(先 flatten 压平旧工程里的 rgba)、glass → `rgba()`、
clear → `transparent`。模糊单独写 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则,
一条同时管深浅(选择器不带 data-theme)。
- **全透明只给预览**:`validate()` 直接拦(导出报错),与 `TestNavbarNotFullyTransparent` 同一条规矩。
面板上写明原因 —— 不然下一轮又会有人问「为什么不能导出」。
- 对比度读数按「压平后的顶栏」算(半透明时是顶栏压在底色上的结果),低于 4.5 变橙。
- 旧工程迁移:配色里塞过 `rgba(...,0.52)` 的,按 alpha 反推档位(1→solid,0→clear,其余→glass)。
- 自检 `?dump=navbar` 10 项;Playwright 端到端 17 项(含滑杆跟手、刷新后保持、迁移三例)。
- ⚠️ `?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 断点两侧切换干净,无需再调。
+4
View File
@@ -68,3 +68,7 @@
- 自测:`python dev/theme-builder/check_sigv4.py`(签名对拍)、 - 自测:`python dev/theme-builder/check_sigv4.py`(签名对拍)、
`python dev/theme-builder/selftest_upload.py`(假 S3,16 项)、 `python dev/theme-builder/selftest_upload.py`(假 S3,16 项)、
`python dev/theme-builder/mock_s3.py --port 9900`(手动点面板)。 `python dev/theme-builder/mock_s3.py --port 9900`(手动点面板)。
- 顶栏面板 `S.navbar={mode,alpha,blur}`:solid/glass/clear 三档,基色仍取「配色 → 顶栏」,
`navbarValue()` 统一算;glass 额外写 `.uk-navbar-container:not(.uk-navbar-transparent){backdrop-filter}`。
**clear 只给预览**,`validate()` 与 `TestNavbarNotFullyTransparent` 都拦。
自检 `?dump=navbar`。
+7
View File
@@ -27,6 +27,13 @@
- ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、 - ⚠️ **UIkit 3.3.3 主色是编译期常量 `#1e87f0`**,令牌注入不进去。base.css 末尾按组件映射(a/uk-link、
uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。 uk-button-primary、checkbox/radio、input:focus、uk-tab active、`.uk-progress`、`.uk-label:not(...)`)。
- 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。 - 令牌 `--tb-navbar`(顶栏底色,默认 `var(--tb-surface)`):页面底色偏彩时主题必须覆盖。
⚠️ **顶栏只能半透明,不能全透明**(2026-10-22 用户明确要求):装饰层从角落长出来时实色顶栏会把
花枝切一段;但改成 `transparent` 顶栏就"消失"了,导航失去依托、文字压在花纹上。正解是
`rgba(bg, 0.72)` + `.uk-navbar-container{backdrop-filter: blur(12px)}`(模糊只能写在规则里,
令牌只是颜色)。`TestNavbarNotFullyTransparent` 拦 `transparent` / `rgba(...,0)` / `#rrggbb00`。
同理 `#tb-mobile-utils` 在 base.css 里写死 `rgba(255,255,255,.92)`,彩色主题要自己调成同色系。
- ⚠️ 装饰层会穿过顶栏区域时,`top` 别停在 0~nav 高度内:改半透明后枝头会压到「登录」上
(700~760px 宽度实测),按顶栏高度(60 + margin 20)把 `top` 下移。
- 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。 - 静态资源版本号统一 `conf.StaticVersion`;**图片不走这个缓存键**(`max-age=86400`),换图要给 URL 带 `?v=`。
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position` - 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变; (焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
+18
View File
@@ -169,6 +169,23 @@ python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机用(公共
顺带:这套字号阶梯(`--tb-fs-xs…hero`)来自 `base.css`,生成器**不碰字号** —— 顺带:这套字号阶梯(`--tb-fs-xs…hero`)来自 `base.css`,生成器**不碰字号** ——
主题的职责是配色与背景,改字号会连带挤坏所有页面排版。 主题的职责是配色与背景,改字号会连带挤坏所有页面排版。
**顶栏** —— 三档,只管「配色 → 顶栏」那个基色怎么用:
| 档位 | 输出 | 适用 |
| --- | --- | --- |
| 实色 | `--tb-navbar: rgb(...)` | 页面没有装饰层穿过顶栏;最简单 |
| 半透明 | `--tb-navbar: rgba(...,0.72)` + `backdrop-filter: blur(12px)` | 装饰层从角落长出来、会经过顶栏时(推荐) |
| 全透明 | `--tb-navbar: transparent` | **只能预览**,导出会被拦 |
- 半透明档的两个滑杆:不透明度 0.2~0.95、背景模糊 0~24px。令牌只能是颜色值,
模糊另外写成 `.uk-navbar-container:not(.uk-navbar-transparent)` 规则(深浅两套共用一条)。
- 面板底部有 `导航文字 6.0:1` 的读数,按**压平后的顶栏**算(半透明时是「顶栏压在底色上」的结果,
不是顶栏色本身)。低于 4.5 变橙 —— 装饰层从顶栏后面穿过时还会更低,取最不利情况定透明度。
- ⚠️ **全透明不给导出**:顶栏一透明导航就失去依托、文字直接压在花纹上,比"被裁一段"更糟。
这一档留着的唯一用途是**预览对比**;`validate()` 会拦,仓库的 `TestNavbarNotFullyTransparent`
也会拦(两处同一条规矩,生成器里绕过也没用)。
- 旧工程(配色里直接塞 `rgba(...,0.52)`)打开时按 alpha 反推档位:1 → 实色、0 → 全透明、其余 → 半透明。
**图片池** —— 上传任意张,浏览器本地转 WebP。导出时只打包实际用到的图。 **图片池** —— 上传任意张,浏览器本地转 WebP。导出时只打包实际用到的图。
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。 每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
@@ -362,6 +379,7 @@ INSTALL-<slug>.txt
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域) ?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素) ?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡) ?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡)
?dump=navbar 顶栏三档回归(实色无 blur、半透明带 alpha+blur、全透明被 validate 拦、alpha 边界)
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例 ?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
?loadurl=/static/images/x.webp&fx=x ?loadurl=/static/images/x.webp&fx=x
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板 直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
+171 -2
View File
@@ -191,6 +191,9 @@
.mbox footer{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--ui-line)} .mbox footer{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--ui-line)}
.mbox footer .info{flex:1;font-size:11px;color:var(--ui-text2)} .mbox footer .info{flex:1;font-size:11px;color:var(--ui-text2)}
.seg{display:flex;gap:6px;margin:8px 0 9px}
.seg button{flex:1;font-size:11px;padding:4px 6px}
.pal{display:flex;flex-direction:column;gap:5px} .pal{display:flex;flex-direction:column;gap:5px}
.pal .p{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--ui-text2)} .pal .p{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--ui-text2)}
.pal .p input[type=text]{width:100%;font-size:11px;padding:2px 6px} .pal .p input[type=text]{width:100%;font-size:11px;padding:2px 6px}
@@ -278,6 +281,11 @@
<h2>配色</h2> <h2>配色</h2>
<div class="pal" id="palette"></div> <div class="pal" id="palette"></div>
</section> </section>
<section>
<h2>顶栏</h2>
<div id="navbar-editor"></div>
</section>
</aside> </aside>
<section class="stage"> <section class="stage">
@@ -367,13 +375,17 @@ const D = {
palette: { palette: {
/* primary 从 #4a7fc1 压深到 #3d6fac:前者在 #eef3f9 上只有 3.69:1, /* primary 从 #4a7fc1 压深到 #3d6fac:前者在 #eef3f9 上只有 3.69:1,
14px 的链接/按钮文字不达 AA。默认值应当是可用的,不能一上手就踩红线。 */ 14px 的链接/按钮文字不达 AA。默认值应当是可用的,不能一上手就踩红线。 */
bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)', bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'#ffffff',
text:'#222d3b', primary:'#3d6fac', border:'#d6e0ec', text:'#222d3b', primary:'#3d6fac', border:'#d6e0ec',
}, },
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理。 /* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理。
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。 这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。
scale 是「每层策略选缩放」时的系数;hideDeco 是「一次全藏」的快捷开关。 */ scale 是「每层策略选缩放」时的系数;hideDeco 是「一次全藏」的快捷开关。 */
narrow: { enabled: true, bg: null, hideDeco: false, maxWidth: 900, scale: MOB_SCALE_DEFAULT }, narrow: { enabled: true, bg: null, hideDeco: false, maxWidth: 900, scale: MOB_SCALE_DEFAULT },
/* 顶栏三档:solid 实色 / glass 半透明磨砂 / clear 全透明。
clear 只给预览 —— 顶栏一透明导航就没了依托(仓库的 TestNavbarNotFullyTransparent 也会拦导出)。
alpha 与 blur 只对 glass 生效;基色取「配色 → 顶栏」。 */
navbar: { mode: 'glass', alpha: 0.72, blur: 12 },
/* 导出画质:webp 体积 ≈ 像素数 × 质量。压体积最有效的不是 q,是先砍像素 /* 导出画质:webp 体积 ≈ 像素数 × 质量。压体积最有效的不是 q,是先砍像素
—— CSS 里显示 400px 高的图,2 倍屏只需要 800px,上传 3000px 的立绘纯属白扛。 */ —— CSS 里显示 400px 高的图,2 倍屏只需要 800px,上传 3000px 的立绘纯属白扛。 */
export: { quality: 0.88, maxSide: 1600 }, export: { quality: 0.88, maxSide: 1600 },
@@ -409,6 +421,7 @@ const S = {
curLayer: null, curLayer: null,
scopes: {}, scopes: {},
palette: Object.assign({}, D.palette), palette: Object.assign({}, D.palette),
navbar: Object.assign({}, D.navbar),
narrow: Object.assign({}, D.narrow), narrow: Object.assign({}, D.narrow),
export: Object.assign({}, D.export), export: Object.assign({}, D.export),
assetBase: '', assetBase: '',
@@ -759,6 +772,15 @@ function onPrimary(primary){
return contrastRatio('#ffffff', primary) >= contrastRatio('#0b1016', primary) ? '#ffffff' : '#0b1016'; return contrastRatio('#ffffff', primary) >= contrastRatio('#0b1016', primary) ? '#ffffff' : '#0b1016';
} }
/** 顶栏实际颜色:三档统一从一个基色算出来,避免「配色里的顶栏」与「档位」互相打架。
实色/半透明都先压平基色 —— 旧工程里基色本身可能是带 alpha 的 rgba。 */
function navbarValue(p){
const base = flatten(p.navbar, p.bg);
if (S.navbar.mode === 'clear') return 'transparent';
if (S.navbar.mode === 'solid') return base;
return withAlpha(base, S.navbar.alpha);
}
function paletteVars(p){ function paletteVars(p){
const back = textBackdrops(p); const back = textBackdrops(p);
return [ return [
@@ -766,7 +788,7 @@ function paletteVars(p){
` --tb-surface: ${p.surface};`, ` --tb-surface: ${p.surface};`,
` --tb-surface-2: ${mixRGB(p.surface, p.text, 0.06)};`, ` --tb-surface-2: ${mixRGB(p.surface, p.text, 0.06)};`,
` --tb-surface-hover: ${mixRGB(p.surface, p.primary, 0.10)};`, ` --tb-surface-hover: ${mixRGB(p.surface, p.primary, 0.10)};`,
` --tb-navbar: ${p.navbar};`, ` --tb-navbar: ${navbarValue(p)};`,
` --tb-border: ${p.border};`, ` --tb-border: ${p.border};`,
` --tb-text: ${p.text};`, ` --tb-text: ${p.text};`,
` --tb-text-secondary: ${mixToContrast(p.text, back, 5.5)};`, ` --tb-text-secondary: ${mixToContrast(p.text, back, 5.5)};`,
@@ -794,6 +816,16 @@ function buildCSS(preview){
L.push(decoVars(S.scopes.global, preview)); L.push(decoVars(S.scopes.global, preview));
L.push('}'); L.push('}');
// 令牌只能给颜色,模糊得另写规则;选择器不带 data-theme,深浅两套共用一条。
if (S.navbar.mode === 'glass' && S.navbar.blur > 0){
L.push('');
L.push('/* 顶栏磨砂:半透明底由 --tb-navbar 给,模糊只能写在规则里。 */');
L.push(`html[data-theme-style="${slug}"] .uk-navbar-container:not(.uk-navbar-transparent) {`);
L.push(` -webkit-backdrop-filter: blur(${S.navbar.blur}px);`);
L.push(` backdrop-filter: blur(${S.navbar.blur}px);`);
L.push('}');
}
L.push(''); L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"] {`); L.push(`html[data-theme="dark"][data-theme-style="${slug}"] {`);
L.push(paletteVars(S.dark)); L.push(paletteVars(S.dark));
@@ -2237,6 +2269,85 @@ async function applyQuality(what){
+ (before > 0 && after < before ? `(省 ${Math.round((1 - after / before) * 100)}%)` : ''), 'ok'); + (before > 0 && after < before ? `(省 ${Math.round((1 - after / before) * 100)}%)` : ''), 'ok');
} }
/** 顶栏面板:档位 + 两个只对半透明生效的滑杆 + 对比度读数。
没有这套控件时,「顶栏」只能靠配色里填一个 rgba 字符串 —— 模糊根本无从设置,
而档位与颜色分在两个地方,用户改了一处就忘了另一处。 */
function renderNavbar(){
const el = $('navbar-editor');
el.innerHTML = '';
const n = S.navbar;
const seg = document.createElement('div');
seg.className = 'seg';
[['solid', '实色'], ['glass', '半透明'], ['clear', '全透明']].forEach(([k, t]) => {
const b = document.createElement('button');
b.textContent = t;
b.className = n.mode === k ? 'on' : '';
b.title = k === 'glass' ? '半透明 + 背景模糊(推荐)'
: k === 'solid' ? '不透明:会盖住从角落穿过顶栏的装饰层'
: '顶栏完全消失,只能预览 —— 导出会被拦';
b.onclick = () => { n.mode = k; renderAll(); };
seg.appendChild(b);
});
el.appendChild(seg);
const slider = (label, value, min, max, step, set, fmt) => {
const row = document.createElement('div');
row.className = 'fxrow';
const lab = document.createElement('div');
lab.className = 'lab';
const name = document.createElement('span');
name.textContent = label;
const val = document.createElement('b');
val.textContent = fmt(value);
lab.append(name, val);
const r = document.createElement('input');
r.type = 'range'; r.min = min; r.max = max; r.step = step; r.value = value;
r.oninput = () => {
set(Number(r.value));
val.textContent = fmt(Number(r.value));
applyTheme();
};
// 松手才重绘整面板:拖动过程中重建 DOM 会把滑杆从鼠标下抽走
r.onchange = () => renderNavbar();
row.append(lab, r);
return row;
};
if (n.mode === 'glass'){
// 步长 0.01:0.02 会让「想调 0.55」被吸附成 0.54/0.56,手感像坏了
el.appendChild(slider('不透明度', n.alpha, 0.2, 0.95, 0.01,
v => { n.alpha = v; }, v => v.toFixed(2)));
el.appendChild(slider('背景模糊', n.blur, 0, 24, 1,
v => { n.blur = v; }, v => v + 'px'));
}
// 对比度:顶栏是半透明时,文字的对手是「顶栏压在底色上」的结果,不是顶栏色本身
const back = textBackdrops(S.palette);
const navText = mixToContrast(S.palette.text, back, 5.5);
const eff = n.mode === 'clear' ? S.palette.bg : flatten(navbarValue(S.palette), S.palette.bg);
const cr = contrastRatio(navText, eff);
const line = document.createElement('p');
line.className = 'hint';
line.innerHTML = `<span style="color:${cr < 4.5 ? '#e0a33e' : 'inherit'}">导航文字 ${cr.toFixed(2)}:1</span>`
+ `(${esc(eff)})· 装饰层从顶栏后面穿过时会更低,取最不利情况定透明度。`;
el.appendChild(line);
if (n.mode === 'clear'){
const warn = document.createElement('p');
warn.className = 'hint';
warn.style.color = '#e0a33e';
warn.textContent = '全透明只用于预览:顶栏会消失,导航失去依托,花纹直接从字底下穿过。'
+ '导出会被拦下(仓库的 TestNavbarNotFullyTransparent 也会拦)。';
el.appendChild(warn);
}
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '基色在「配色 → 顶栏」里改;这里决定它怎么用。深色用深色配色里的顶栏色,规则相同。';
el.appendChild(note);
}
function renderPalette(){ function renderPalette(){
const wrap = $('palette'); const wrap = $('palette');
wrap.innerHTML = ''; wrap.innerHTML = '';
@@ -2323,6 +2434,7 @@ function renderAll(){
renderHostEditor(); renderHostEditor();
renderNarrowEditor(); renderNarrowEditor();
renderPalette(); renderPalette();
renderNavbar();
applyTheme(); applyTheme();
scheduleSave(); scheduleSave();
} }
@@ -2342,6 +2454,7 @@ function toJSON(){
viewport: S.viewport, viewport: S.viewport,
mode: S.mode, mode: S.mode,
palette: S.palette, palette: S.palette,
navbar: S.navbar,
dark: S.dark, dark: S.dark,
narrow: S.narrow, narrow: S.narrow,
export: S.export, export: S.export,
@@ -2375,6 +2488,18 @@ function fromJSON(o){
S.mode = o.mode === 'dark' ? 'dark' : 'light'; S.mode = o.mode === 'dark' ? 'dark' : 'light';
S.palette = Object.assign({}, S.palette, o.palette || {}); S.palette = Object.assign({}, S.palette, o.palette || {});
S.dark = Object.assign({}, S.dark, o.dark || {}); S.dark = Object.assign({}, S.dark, o.dark || {});
// 旧工程没有 navbar 段:从配色里的顶栏色反推档位,别让打开旧工程时顶栏突然变实色
S.navbar = Object.assign({}, D.navbar, o.navbar || {});
if (!o.navbar){
const legacy = S.palette.navbar;
if (legacy === 'transparent') S.navbar.mode = 'clear';
else {
const a = parseColor(legacy)[3];
S.navbar.mode = a >= 0.999 ? 'solid' : 'glass';
if (a > 0 && a < 1) S.navbar.alpha = Math.round(a * 100) / 100;
}
}
if (!['solid', 'glass', 'clear'].includes(S.navbar.mode)) S.navbar.mode = 'glass';
S.narrow = Object.assign({ enabled: true, bg: null, hideDeco: false, maxWidth: 900, S.narrow = Object.assign({ enabled: true, bg: null, hideDeco: false, maxWidth: 900,
scale: MOB_SCALE_DEFAULT }, o.narrow || {}); scale: MOB_SCALE_DEFAULT }, o.narrow || {});
S.export = Object.assign({}, D.export, o.export || {}); S.export = Object.assign({}, D.export, o.export || {});
@@ -2499,6 +2624,12 @@ function validate(){
if (S.narrow.bg && !assetById(S.narrow.bg)) errs.push('窄屏竖版图引用了已删除的图片'); if (S.narrow.bg && !assetById(S.narrow.bg)) errs.push('窄屏竖版图引用了已删除的图片');
// 与 internal/theme/navbar_test.go 同一条规矩:顶栏可以调透明度,但不能全透明。
if (S.navbar.mode === 'clear'){
errs.push('顶栏不能全透明:导航会失去依托、文字直接压在花纹上。改成「半透明」或「实色」再导出'
+ '(仓库的 TestNavbarNotFullyTransparent 也会拦)。');
}
// 结构自检:三组装饰值必须都是 10 个(与 Go 的 TestDecoSlotCount 同一条规则)。 // 结构自检:三组装饰值必须都是 10 个(与 Go 的 TestDecoSlotCount 同一条规则)。
// 这条是给工具自己兜底的 —— 真跑出问题说明生成逻辑有 bug,而不是用户配错。 // 这条是给工具自己兜底的 —— 真跑出问题说明生成逻辑有 bug,而不是用户配错。
const css = buildCSS(false); const css = buildCSS(false);
@@ -3814,6 +3945,44 @@ function boot(){
r.ok = false; r.ok = false;
} }
pre.textContent = JSON.stringify(r, null, 1); pre.textContent = JSON.stringify(r, null, 1);
} else if (dump === 'navbar'){
// 三档的产出各是什么、磨砂规则有没有写、全透明有没有被拦 —— 无头里抓 #dump 判读。
const keep = Object.assign({}, S.navbar);
const rows = [];
let ok = true;
const chk = (name, cond, got) => {
if (!cond) ok = false;
rows.push((cond ? 'OK ' : 'FAIL ') + name + (cond ? '' : ' ← ' + got));
};
S.navbar.mode = 'solid'; S.navbar.blur = 12;
let v = navbarValue(S.palette);
let css = buildCSS(false);
chk('实色 → rgb(),无 alpha', /^rgb\(/.test(v), v);
chk('实色不写 blur 规则', !css.includes('backdrop-filter'), '');
S.navbar.mode = 'glass'; S.navbar.alpha = 0.72; S.navbar.blur = 12;
v = navbarValue(S.palette);
css = buildCSS(false);
chk('半透明 → rgba(...,0.72)', /^rgba\(.*0\.72\)$/.test(v), v);
chk('半透明写 blur(12px)', css.includes('blur(12px)'), '');
chk('深色也走同一档', /--tb-navbar: rgba\(/.test(css.split('data-theme="dark"')[1] || ''), '');
S.navbar.mode = 'clear';
chk('全透明 → transparent(仅预览)', navbarValue(S.palette) === 'transparent', navbarValue(S.palette));
const errs = validate();
chk('全透明被 validate 拦下', errs.some(e => e.includes('顶栏不能全透明')), JSON.stringify(errs));
chk('全透明不写 blur', !buildCSS(false).includes('backdrop-filter'), '');
// 透明度边界:0.2 仍要带 alpha,0.95 不能被四舍五入成 1
S.navbar.mode = 'glass'; S.navbar.alpha = 0.2;
chk('alpha=0.2 不塌成实色', /rgba\(.*0\.2\)/.test(navbarValue(S.palette)), navbarValue(S.palette));
S.navbar.alpha = 0.95;
chk('alpha=0.95 仍是 rgba', /rgba\(.*0\.95\)/.test(navbarValue(S.palette)), navbarValue(S.palette));
Object.assign(S.navbar, keep);
rows.push(ok ? 'ALL OK' : 'HAS FAIL');
pre.textContent = rows.join('\n');
} else { } else {
const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }]; const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }];
const bytes = zipStore(files); const bytes = zipStore(files);
+21 -3
View File
@@ -189,7 +189,9 @@ SCOPES = {
"box": [ "box": [
# 枝的细端在右上、粗端朝左下:放在右上角时"从角上垂下来", # 枝的细端在右上、粗端朝左下:放在右上角时"从角上垂下来",
# 尺寸收到 300px 才会有枝形 —— 390px 时可见部分只剩一团花(第一次就是这个问题) # 尺寸收到 300px 才会有枝形 —— 390px 时可见部分只剩一团花(第一次就是这个问题)
("var(--sk-branch-r)", "auto 300px", "right -24px top -16px"), # top 从 -16px 收到 26px:顶栏改透明后,700~760px 宽度下枝头会正好压在"登录"上。
# 顶栏高 60px、外边距 20px,26px 起笔即落在顶栏下沿之上,宽屏仍保留"从右上角长出"。
("var(--sk-branch-r)", "auto 300px", "right -24px top 26px"),
("var(--sk-blossom)", "auto 80px", "left 5% bottom 16%"), ("var(--sk-blossom)", "auto 80px", "left 5% bottom 16%"),
("var(--sk-petal)", "34px", "left 14% bottom 32%"), ("var(--sk-petal)", "34px", "left 14% bottom 32%"),
("var(--sk-petal)", "24px", "right 22% bottom 8%"), ("var(--sk-petal)", "24px", "right 22% bottom 8%"),
@@ -262,7 +264,10 @@ def build(asset_base=""):
--tb-surface: #ffffff; --tb-surface: #ffffff;
--tb-surface-2: #fdf0f4; --tb-surface-2: #fdf0f4;
--tb-surface-hover: #fff7fa; --tb-surface-hover: #fff7fa;
--tb-navbar: #fdf6f8; /* 顶栏做磨砂玻璃:樱花从角落长出来,实色顶栏会把它齐刷刷切一段;但也不能给
transparent —— 顶栏会"消失",导航失去依托,文字直接压在花纹上。留 28% 的透光。
模糊由下面「组件微调」里的 backdrop-filter 加(令牌只能给颜色)。 */
--tb-navbar: rgba(253, 246, 248, 0.72);
--tb-border: #f3dde5; --tb-border: #f3dde5;
--tb-text: #3a2c31; --tb-text: #3a2c31;
@@ -303,7 +308,7 @@ html[data-theme="dark"][data-theme-style="new-sakura"] {{
--tb-surface: #2c2227; --tb-surface: #2c2227;
--tb-surface-2: #372a30; --tb-surface-2: #372a30;
--tb-surface-hover: rgba(255, 147, 180, 0.10); --tb-surface-hover: rgba(255, 147, 180, 0.10);
--tb-navbar: #221a1e; --tb-navbar: rgba(34, 26, 30, 0.72);
--tb-border: #47333b; --tb-border: #47333b;
--tb-text: #f3e6ea; --tb-text: #f3e6ea;
@@ -407,6 +412,19 @@ html[data-theme-style="new-sakura"] .uk-alert-primary {
html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary { html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary {
color: #ffb3c8; color: #ffb3c8;
} }
/* 顶栏磨砂:半透明底由 --tb-navbar 给,模糊必须在这里加(令牌只能是颜色值)。
和手机底栏一样是「半透明 + 模糊」,不是全透明 —— 顶栏得看得见,只是后面透得出花。 */
html[data-theme-style="new-sakura"] .uk-navbar-container:not(.uk-navbar-transparent) {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
/* 手机底栏:base.css 写死 rgba(255,255,255,.92),压在粉底上是一条白杠,跟背景脱开 */
html[data-theme-style="new-sakura"] #tb-mobile-utils {
background: rgba(253, 246, 248, 0.92);
}
html[data-theme="dark"][data-theme-style="new-sakura"] #tb-mobile-utils {
background: rgba(34, 26, 30, 0.92);
}
""" """
+33
View File
@@ -70,6 +70,39 @@ question / admin 页卡片从 y=110 起就切掉半个,结果是黏在卡片
深色块必须写成 `html[data-theme="dark"][data-theme-style="new-sakura"]`(三重选择器), 深色块必须写成 `html[data-theme="dark"][data-theme-style="new-sakura"]`(三重选择器),
只写浅色块会被同特异性的作用域块盖掉。 只写浅色块会被同特异性的作用域块盖掉。
## 顶栏与手机底栏
顶栏走**磨砂玻璃**,不是实色也不是全透明:
```css
--tb-navbar: rgba(253, 246, 248, 0.72); /* 亮色;深色 rgba(34, 26, 30, 0.72) */
html[data-theme-style="new-sakura"] .uk-navbar-container:not(.uk-navbar-transparent) {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
```
三种写法的实测结论:
| 写法 | 现象 |
| --- | --- |
| 实色 `#fdf6f8` | 花枝经过顶栏时被齐刷刷切掉一段,像被裁过 |
| `transparent` | 花枝完整了,但顶栏"消失":导航没有依托,文字直接压在花纹上 |
| `rgba(...,0.72)` + `blur(12px)` | 花枝透过来但被模糊成柔光,顶栏仍是一块可辨认的面 |
0.72 是怎么定的:导航文字 `#6d555d` 压在顶栏上,纯底色 6.34:1;最坏情况(深花瓣正好在顶栏后面)
压平后 5.08:1,仍过 AA。再往下调透明度就会掉到 4.5 以下。
`TestNavbarNotFullyTransparent` 会拦住 `--tb-navbar: transparent`(以及 `rgba(...,0)`、
`#rrggbb00`)—— 这条不是审美偏好,是把上面第二种写法踩过之后立的规矩。
手机底栏(`#tb-mobile-utils`)在 `base.css` 里写死 `rgba(255,255,255,.92)`,压在粉底上是
一条白杠;主题里按同一思路改成 `rgba(253,246,248,.92)` / `rgba(34,26,30,.92)`。
另外 box 作用域的花枝从 `top -16px` 下移到 `top 26px`:顶栏半透明后,700~760px 宽度下
枝头正好压在「登录」上(实测截图确认)。顶栏高 60 + 外边距 20,26px 起笔即落在顶栏下沿之上,
宽屏仍是"从右上角长出来"的观感。
## 配色与对比度 ## 配色与对比度
| 令牌 | 亮色 | 深色 | 说明 | | 令牌 | 亮色 | 深色 | 说明 |
+1 -1
View File
@@ -10,7 +10,7 @@ var (
BuildCommit = "dev" BuildCommit = "dev"
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。 // StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
StaticVersion = "20261022b" StaticVersion = "20261022c"
) )
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧, // 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
+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 完成。
+57
View File
@@ -0,0 +1,57 @@
package theme
import (
"io/fs"
"regexp"
"strings"
"testing"
"github.com/tamakyi/TamaBox/static"
)
// TestNavbarNotFullyTransparent 拦住 `--tb-navbar: transparent`。
//
// 装饰层从页面角落长出来时,实色顶栏会把它齐刷刷切一段,看着像被裁过 —— 于是很容易顺手
// 改成完全透明。但顶栏一透明就没有依托:导航文字直接压在花纹上,滚动/密集页面里失去层级,
// 观感比"被裁"更差。正确做法是半透明(rgba 保留 20%~30% 透光)配 backdrop-filter: blur()。
//
// 这条不是审美偏好,是踩过的坑:new-sakura 第一版写成 transparent,用户当场要求改回去。
func TestNavbarNotFullyTransparent(t *testing.T) {
files := []string{"css/base.css"}
for _, slug := range discover() {
files = append(files, themeCSSDir+slug+".css")
}
checked := 0
for _, name := range files {
b, err := fs.ReadFile(static.FS, name)
if err != nil {
t.Fatalf("读取 %s: %v", name, err)
}
css := commentRe.ReplaceAllString(string(b), "")
for i, decl := range declarations(css, "--tb-navbar:") {
checked++
if isFullyTransparent(decl) {
t.Errorf("%s 第 %d 处 --tb-navbar = %q:顶栏不能完全透明,"+
"改成半透明(如 rgba(...,0.72))并配 backdrop-filter: blur()", name, i+1, strings.TrimSpace(decl))
}
}
}
if checked == 0 {
t.Fatal("一处 --tb-navbar 都没解析到,key 拼错了?")
}
t.Logf("共校验 %d 处顶栏底色", checked)
}
var (
rgbaZeroRe = regexp.MustCompile(`(?i)rgba?\([^)]*,\s*0(?:\.0+)?\s*\)`)
hexAlphaRe = regexp.MustCompile(`(?i)^#[0-9a-f]{6}00$`)
)
func isFullyTransparent(v string) bool {
v = strings.TrimSpace(strings.ToLower(v))
if v == "transparent" || v == "none" {
return true
}
return rgbaZeroRe.MatchString(v) || hexAlphaRe.MatchString(v)
}
+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()
}
+6 -2
View File
@@ -22,7 +22,11 @@
--tb-surface-2 次级面(标签底、进度条轨道、代码块) --tb-surface-2 次级面(标签底、进度条轨道、代码块)
--tb-surface-hover 悬停底(列表行、下拉项) --tb-surface-hover 悬停底(列表行、下拉项)
--tb-navbar 顶栏底色(不写则同 --tb-surface; --tb-navbar 顶栏底色(不写则同 --tb-surface;
页面底色不是白时建议覆盖成 bg 或它的同色,否则顶栏会成为一条白带) 页面底色不是白时建议覆盖成 bg 或它的同色,否则顶栏会成为一条白带。
⚠️ 装饰层从页面角落长出来时,实色顶栏会把它齐刷刷切一段;
这时用半透明(如 rgba(...,.72))+ backdrop-filter: blur(),
但**不要写 transparent** —— 顶栏会失去依托,文字直接压在花纹上。
TestNavbarNotFullyTransparent 会拦住全透明。)
线条 --tb-border 边框、分隔线 线条 --tb-border 边框、分隔线
文字 --tb-text 正文 文字 --tb-text 正文
--tb-text-secondary 次要文字(说明、meta、顶栏链接) --tb-text-secondary 次要文字(说明、meta、顶栏链接)
@@ -79,7 +83,7 @@ html[data-theme-style="SLUG"] {
--tb-surface: #ffffff; --tb-surface: #ffffff;
--tb-surface-2: #f4f6f8; --tb-surface-2: #f4f6f8;
--tb-surface-hover: #f3f7ff; --tb-surface-hover: #f3f7ff;
--tb-navbar: var(--tb-surface); /* 顶栏底色,需要时改成 var(--tb-bg) */ --tb-navbar: var(--tb-surface); /* 顶栏底色,需要时改成 var(--tb-bg) 或半透明的同色 */
--tb-border: #e8eaee; --tb-border: #e8eaee;
--tb-text: #1f2329; --tb-text: #1f2329;
+20 -4
View File
@@ -24,7 +24,10 @@ html[data-theme-style="new-sakura"] {
--tb-surface: #ffffff; --tb-surface: #ffffff;
--tb-surface-2: #fdf0f4; --tb-surface-2: #fdf0f4;
--tb-surface-hover: #fff7fa; --tb-surface-hover: #fff7fa;
--tb-navbar: #fdf6f8; /* 顶栏做磨砂玻璃:樱花从角落长出来,实色顶栏会把它齐刷刷切一段;但也不能给
transparent —— 顶栏会"消失",导航失去依托,文字直接压在花纹上。留 28% 的透光。
模糊由下面「组件微调」里的 backdrop-filter 加(令牌只能给颜色)。 */
--tb-navbar: rgba(253, 246, 248, 0.72);
--tb-border: #f3dde5; --tb-border: #f3dde5;
--tb-text: #3a2c31; --tb-text: #3a2c31;
@@ -61,7 +64,7 @@ html[data-theme-style="new-sakura"] {
html[data-theme-style="new-sakura"][data-page-scope="box"] { html[data-theme-style="new-sakura"][data-page-scope="box"] {
--tb-deco: var(--sk-branch-r), var(--sk-blossom), var(--sk-petal), var(--sk-petal), var(--sk-blossom), none, none, none, none, none; --tb-deco: var(--sk-branch-r), var(--sk-blossom), var(--sk-petal), var(--sk-petal), var(--sk-blossom), none, none, none, none, none;
--tb-deco-size: auto 300px, auto 80px, 34px, 24px, auto 52px, auto, auto, auto, auto, auto; --tb-deco-size: auto 300px, auto 80px, 34px, 24px, auto 52px, auto, auto, auto, auto, auto;
--tb-deco-position: right -24px top -16px, left 5% bottom 16%, left 14% bottom 32%, right 22% bottom 8%, right 6% bottom 6%, left top, left top, left top, left top, left top; --tb-deco-position: right -24px top 26px, left 5% bottom 16%, left 14% bottom 32%, right 22% bottom 8%, right 6% bottom 6%, left top, left top, left top, left top, left top;
} }
html[data-theme-style="new-sakura"][data-page-scope="question"] { html[data-theme-style="new-sakura"][data-page-scope="question"] {
@@ -93,7 +96,7 @@ html[data-theme="dark"][data-theme-style="new-sakura"] {
--tb-surface: #2c2227; --tb-surface: #2c2227;
--tb-surface-2: #372a30; --tb-surface-2: #372a30;
--tb-surface-hover: rgba(255, 147, 180, 0.10); --tb-surface-hover: rgba(255, 147, 180, 0.10);
--tb-navbar: #221a1e; --tb-navbar: rgba(34, 26, 30, 0.72);
--tb-border: #47333b; --tb-border: #47333b;
--tb-text: #f3e6ea; --tb-text: #f3e6ea;
@@ -124,7 +127,7 @@ html[data-theme="dark"][data-theme-style="new-sakura"] {
html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="box"] { html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="box"] {
--tb-deco: var(--sk-branch-r), var(--sk-blossom), var(--sk-petal), var(--sk-petal), var(--sk-blossom), none, none, none, none, none; --tb-deco: var(--sk-branch-r), var(--sk-blossom), var(--sk-petal), var(--sk-petal), var(--sk-blossom), none, none, none, none, none;
--tb-deco-size: auto 300px, auto 80px, 34px, 24px, auto 52px, auto, auto, auto, auto, auto; --tb-deco-size: auto 300px, auto 80px, 34px, 24px, auto 52px, auto, auto, auto, auto, auto;
--tb-deco-position: right -24px top -16px, left 5% bottom 16%, left 14% bottom 32%, right 22% bottom 8%, right 6% bottom 6%, left top, left top, left top, left top, left top; --tb-deco-position: right -24px top 26px, left 5% bottom 16%, left 14% bottom 32%, right 22% bottom 8%, right 6% bottom 6%, left top, left top, left top, left top, left top;
} }
html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="question"] { html[data-theme="dark"][data-theme-style="new-sakura"][data-page-scope="question"] {
@@ -193,3 +196,16 @@ html[data-theme-style="new-sakura"] .uk-alert-primary {
html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary { html[data-theme="dark"][data-theme-style="new-sakura"] .uk-alert-primary {
color: #ffb3c8; color: #ffb3c8;
} }
/* 顶栏磨砂:半透明底由 --tb-navbar 给,模糊必须在这里加(令牌只能是颜色值)。
和手机底栏一样是「半透明 + 模糊」,不是全透明 —— 顶栏得看得见,只是后面透得出花。 */
html[data-theme-style="new-sakura"] .uk-navbar-container:not(.uk-navbar-transparent) {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
/* 手机底栏:base.css 写死 rgba(255,255,255,.92),压在粉底上是一条白杠,跟背景脱开 */
html[data-theme-style="new-sakura"] #tb-mobile-utils {
background: rgba(253, 246, 248, 0.92);
}
html[data-theme="dark"][data-theme-style="new-sakura"] #tb-mobile-utils {
background: rgba(34, 26, 30, 0.92);
}
+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>
+46 -2
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;">
<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> <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 }} {{ 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()