Compare commits
4
Commits
2981731d4f
...
bd02eb7ed2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd02eb7ed2 | ||
|
|
8fcb23f7b1 | ||
|
|
e4810040a7 | ||
|
|
8941744621 |
@@ -185,3 +185,63 @@
|
||||
以后在 `.uk-container` 里加末位区块都绕这条。
|
||||
- 环境坑:沙箱 go 是 1.25.5,go.mod 要 1.27.3,GOTOOLCHAIN=auto 需下载工具链模块,
|
||||
而 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 断点两侧切换干净,无需再调。
|
||||
|
||||
@@ -68,3 +68,7 @@
|
||||
- 自测:`python dev/theme-builder/check_sigv4.py`(签名对拍)、
|
||||
`python dev/theme-builder/selftest_upload.py`(假 S3,16 项)、
|
||||
`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`。
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
- ⚠️ **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(...)`)。
|
||||
- 令牌 `--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=`。
|
||||
- 整页背景 `--tb-page-image`(cover)/ `--tb-page-pattern`(平铺,`-size` 控尺寸)/ `--tb-page-position`
|
||||
(焦点,默认 `var(--tb-page-position, center center)`,旧主题不写也不坏)。底色用**纯色**别用渐变;
|
||||
|
||||
@@ -169,6 +169,23 @@ python dev/theme-builder/serve.py --host 127.0.0.1 # 只给本机用(公共
|
||||
顺带:这套字号阶梯(`--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。导出时只打包实际用到的图。
|
||||
每张图左上角有 `fx` 按钮,进处理面板(处理过的会高亮)。
|
||||
|
||||
@@ -362,6 +379,7 @@ INSTALL-<slug>.txt
|
||||
?dump=fit 尺寸自适应回归(五档视口占用、溢出检测、fitw、旧工程兼容、@media 覆盖各作用域)
|
||||
?dump=quality 画质回归(q 单调性、q=1 被夹到 0.95、长边上限真的压像素)
|
||||
?dump=palette 派生文字色的对比度回归(四种配色下 ≥AA、层级不倒挂、手填低对比不继续变淡)
|
||||
?dump=navbar 顶栏三档回归(实色无 blur、半透明带 alpha+blur、全透明被 validate 拦、alpha 边界)
|
||||
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
|
||||
?loadurl=/static/images/x.webp&fx=x
|
||||
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
|
||||
|
||||
@@ -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 .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 .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}
|
||||
@@ -278,6 +281,11 @@
|
||||
<h2>配色</h2>
|
||||
<div class="pal" id="palette"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>顶栏</h2>
|
||||
<div id="navbar-editor"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="stage">
|
||||
@@ -367,13 +375,17 @@ const D = {
|
||||
palette: {
|
||||
/* primary 从 #4a7fc1 压深到 #3d6fac:前者在 #eef3f9 上只有 3.69:1,
|
||||
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',
|
||||
},
|
||||
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理。
|
||||
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。
|
||||
scale 是「每层策略选缩放」时的系数;hideDeco 是「一次全藏」的快捷开关。 */
|
||||
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,是先砍像素
|
||||
—— CSS 里显示 400px 高的图,2 倍屏只需要 800px,上传 3000px 的立绘纯属白扛。 */
|
||||
export: { quality: 0.88, maxSide: 1600 },
|
||||
@@ -409,6 +421,7 @@ const S = {
|
||||
curLayer: null,
|
||||
scopes: {},
|
||||
palette: Object.assign({}, D.palette),
|
||||
navbar: Object.assign({}, D.navbar),
|
||||
narrow: Object.assign({}, D.narrow),
|
||||
export: Object.assign({}, D.export),
|
||||
assetBase: '',
|
||||
@@ -759,6 +772,15 @@ function onPrimary(primary){
|
||||
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){
|
||||
const back = textBackdrops(p);
|
||||
return [
|
||||
@@ -766,7 +788,7 @@ function paletteVars(p){
|
||||
` --tb-surface: ${p.surface};`,
|
||||
` --tb-surface-2: ${mixRGB(p.surface, p.text, 0.06)};`,
|
||||
` --tb-surface-hover: ${mixRGB(p.surface, p.primary, 0.10)};`,
|
||||
` --tb-navbar: ${p.navbar};`,
|
||||
` --tb-navbar: ${navbarValue(p)};`,
|
||||
` --tb-border: ${p.border};`,
|
||||
` --tb-text: ${p.text};`,
|
||||
` --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('}');
|
||||
|
||||
// 令牌只能给颜色,模糊得另写规则;选择器不带 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(`html[data-theme="dark"][data-theme-style="${slug}"] {`);
|
||||
L.push(paletteVars(S.dark));
|
||||
@@ -2237,6 +2269,85 @@ async function applyQuality(what){
|
||||
+ (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(){
|
||||
const wrap = $('palette');
|
||||
wrap.innerHTML = '';
|
||||
@@ -2323,6 +2434,7 @@ function renderAll(){
|
||||
renderHostEditor();
|
||||
renderNarrowEditor();
|
||||
renderPalette();
|
||||
renderNavbar();
|
||||
applyTheme();
|
||||
scheduleSave();
|
||||
}
|
||||
@@ -2342,6 +2454,7 @@ function toJSON(){
|
||||
viewport: S.viewport,
|
||||
mode: S.mode,
|
||||
palette: S.palette,
|
||||
navbar: S.navbar,
|
||||
dark: S.dark,
|
||||
narrow: S.narrow,
|
||||
export: S.export,
|
||||
@@ -2375,6 +2488,18 @@ function fromJSON(o){
|
||||
S.mode = o.mode === 'dark' ? 'dark' : 'light';
|
||||
S.palette = Object.assign({}, S.palette, o.palette || {});
|
||||
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,
|
||||
scale: MOB_SCALE_DEFAULT }, o.narrow || {});
|
||||
S.export = Object.assign({}, D.export, o.export || {});
|
||||
@@ -2499,6 +2624,12 @@ function validate(){
|
||||
|
||||
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 同一条规则)。
|
||||
// 这条是给工具自己兜底的 —— 真跑出问题说明生成逻辑有 bug,而不是用户配错。
|
||||
const css = buildCSS(false);
|
||||
@@ -3814,6 +3945,44 @@ function boot(){
|
||||
r.ok = false;
|
||||
}
|
||||
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 {
|
||||
const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }];
|
||||
const bytes = zipStore(files);
|
||||
|
||||
@@ -189,7 +189,9 @@ SCOPES = {
|
||||
"box": [
|
||||
# 枝的细端在右上、粗端朝左下:放在右上角时"从角上垂下来",
|
||||
# 尺寸收到 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-petal)", "34px", "left 14% bottom 32%"),
|
||||
("var(--sk-petal)", "24px", "right 22% bottom 8%"),
|
||||
@@ -262,7 +264,10 @@ def build(asset_base=""):
|
||||
--tb-surface: #ffffff;
|
||||
--tb-surface-2: #fdf0f4;
|
||||
--tb-surface-hover: #fff7fa;
|
||||
--tb-navbar: #fdf6f8;
|
||||
/* 顶栏做磨砂玻璃:樱花从角落长出来,实色顶栏会把它齐刷刷切一段;但也不能给
|
||||
transparent —— 顶栏会"消失",导航失去依托,文字直接压在花纹上。留 28% 的透光。
|
||||
模糊由下面「组件微调」里的 backdrop-filter 加(令牌只能给颜色)。 */
|
||||
--tb-navbar: rgba(253, 246, 248, 0.72);
|
||||
--tb-border: #f3dde5;
|
||||
|
||||
--tb-text: #3a2c31;
|
||||
@@ -303,7 +308,7 @@ html[data-theme="dark"][data-theme-style="new-sakura"] {{
|
||||
--tb-surface: #2c2227;
|
||||
--tb-surface-2: #372a30;
|
||||
--tb-surface-hover: rgba(255, 147, 180, 0.10);
|
||||
--tb-navbar: #221a1e;
|
||||
--tb-navbar: rgba(34, 26, 30, 0.72);
|
||||
--tb-border: #47333b;
|
||||
|
||||
--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 {
|
||||
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);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,39 @@ question / admin 页卡片从 y=110 起就切掉半个,结果是黏在卡片
|
||||
深色块必须写成 `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 起笔即落在顶栏下沿之上,
|
||||
宽屏仍是"从右上角长出来"的观感。
|
||||
|
||||
## 配色与对比度
|
||||
|
||||
| 令牌 | 亮色 | 深色 | 说明 |
|
||||
|
||||
@@ -10,7 +10,7 @@ var (
|
||||
BuildCommit = "dev"
|
||||
|
||||
// StaticVersion 静态资源缓存版本号:base.css 与主题 css 共用,改样式只改这一处。
|
||||
StaticVersion = "20261022b"
|
||||
StaticVersion = "20261022c"
|
||||
)
|
||||
|
||||
// 文本审核通道取值([security] text_censor_source)。定义在 conf 侧,
|
||||
|
||||
@@ -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 完成。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
--tb-surface-2 次级面(标签底、进度条轨道、代码块)
|
||||
--tb-surface-hover 悬停底(列表行、下拉项)
|
||||
--tb-navbar 顶栏底色(不写则同 --tb-surface;
|
||||
页面底色不是白时建议覆盖成 bg 或它的同色,否则顶栏会成为一条白带)
|
||||
页面底色不是白时建议覆盖成 bg 或它的同色,否则顶栏会成为一条白带。
|
||||
⚠️ 装饰层从页面角落长出来时,实色顶栏会把它齐刷刷切一段;
|
||||
这时用半透明(如 rgba(...,.72))+ backdrop-filter: blur(),
|
||||
但**不要写 transparent** —— 顶栏会失去依托,文字直接压在花纹上。
|
||||
TestNavbarNotFullyTransparent 会拦住全透明。)
|
||||
线条 --tb-border 边框、分隔线
|
||||
文字 --tb-text 正文
|
||||
--tb-text-secondary 次要文字(说明、meta、顶栏链接)
|
||||
@@ -79,7 +83,7 @@ html[data-theme-style="SLUG"] {
|
||||
--tb-surface: #ffffff;
|
||||
--tb-surface-2: #f4f6f8;
|
||||
--tb-surface-hover: #f3f7ff;
|
||||
--tb-navbar: var(--tb-surface); /* 顶栏底色,需要时改成 var(--tb-bg) */
|
||||
--tb-navbar: var(--tb-surface); /* 顶栏底色,需要时改成 var(--tb-bg) 或半透明的同色 */
|
||||
--tb-border: #e8eaee;
|
||||
|
||||
--tb-text: #1f2329;
|
||||
|
||||
@@ -24,7 +24,10 @@ html[data-theme-style="new-sakura"] {
|
||||
--tb-surface: #ffffff;
|
||||
--tb-surface-2: #fdf0f4;
|
||||
--tb-surface-hover: #fff7fa;
|
||||
--tb-navbar: #fdf6f8;
|
||||
/* 顶栏做磨砂玻璃:樱花从角落长出来,实色顶栏会把它齐刷刷切一段;但也不能给
|
||||
transparent —— 顶栏会"消失",导航失去依托,文字直接压在花纹上。留 28% 的透光。
|
||||
模糊由下面「组件微调」里的 backdrop-filter 加(令牌只能给颜色)。 */
|
||||
--tb-navbar: rgba(253, 246, 248, 0.72);
|
||||
--tb-border: #f3dde5;
|
||||
|
||||
--tb-text: #3a2c31;
|
||||
@@ -61,7 +64,7 @@ html[data-theme-style="new-sakura"] {
|
||||
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-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"] {
|
||||
@@ -93,7 +96,7 @@ html[data-theme="dark"][data-theme-style="new-sakura"] {
|
||||
--tb-surface: #2c2227;
|
||||
--tb-surface-2: #372a30;
|
||||
--tb-surface-hover: rgba(255, 147, 180, 0.10);
|
||||
--tb-navbar: #221a1e;
|
||||
--tb-navbar: rgba(34, 26, 30, 0.72);
|
||||
--tb-border: #47333b;
|
||||
|
||||
--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"] {
|
||||
--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-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"] {
|
||||
@@ -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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;">
|
||||
<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;">
|
||||
<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>
|
||||
{{ 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