fix(cms): 图库分组自由输入 + 前端筛选动态化;速通坞折叠防误触

用户实测三处:①照片分组写死 street/landscape 两选不能加——Sveltia select
无 creatable(文档核实),改 string 自由输入;前端筛选同步动态化(分组
从数据反向派生,[a-z0-9-] 命名的分组零配置自动出现筛选片),顺带排查全
后台其余 select:分轨/颜色/字体/来源均为语义契约(写死是安全设计)唯分
组该开放;②速通坞与编辑区抢点击(手机端尤甚)——改默认收起成一枚
26px 圆钮,点开才展开五入口,状态记忆+点外部收起;③翻译无按钮答疑:
英文版走构建链自动机翻(保存中文即出,无按钮是设计),仪表盘有覆盖数。
(同轮收编站长经 CMS 新增的图库照片 夜色金茂湾)

验证: pnpm astro build exit 0;config-check 通过;E2E 65/65(筛选断言适配动态 id);产物断言 f-g-landscape 数据派生

Spec: 2026-11-05_blog-5n-admin-deep-optimization

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-05 22:34:38 +08:00
1 parent 5d187e1240
commit ef2bd97267
4 files changed
+63 -25

No files matched your search

+1 -1
View File
@@ -132,7 +132,7 @@ collections:
# title 必填:Astro schema z.string() 无 .optional(),omit 纪律下必需且有值才会落键(空 title 会让 main 不可构建) # title 必填:Astro schema z.string() 无 .optional(),omit 纪律下必需且有值才会落键(空 title 会让 main 不可构建)
- { label: 标题, name: title, widget: string, required: true } - { label: 标题, name: title, widget: string, required: true }
- { label: 图片, name: image, widget: image } - { label: 图片, name: image, widget: image }
- { label: 分组, name: group, widget: select, options: [street, landscape] } - { label: 分组, name: group, widget: string, hint: '小写英文短词(如 landscape / street / portrait);新分组直接输入新词,图库筛选会自动出现' }
- { label: 替代文本, name: alt, widget: string, required: false } - { label: 替代文本, name: alt, widget: string, required: false }
- { label: 人为描述, name: caption, widget: text, required: false, hint: 展示在灯箱里的人为描述(一句话策展文案) } - { label: 人为描述, name: caption, widget: text, required: false, hint: 展示在灯箱里的人为描述(一句话策展文案) }
- label: EXIF 覆盖(一般留空,构建期自动从图提取) - label: EXIF 覆盖(一般留空,构建期自动从图提取)
+43 -13
View File
@@ -9,23 +9,53 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="./manifest.webmanifest" /> <link rel="manifest" href="./manifest.webmanifest" />
<style> <style>
/* 速通坞(5n D1):日常要跳的五个入口常驻右上,不挡 Sveltia 主 UI(对话层 z 更高) */ /* 速通坞(5n D1 + 用户实测防误触改版):默认收起成一枚小圆钮,
#quick-dock{position:fixed;top:10px;right:12px;z-index:9000;display:flex;gap:4px; 点开才展开五入口;开关状态存 localStorage(手机端默认必收起)。
font:12px/1 system-ui,sans-serif;opacity:.78;transition:opacity .2s} 收起态只有一个 26px 圆钮,不再与编辑区抢点击面。 */
#quick-dock:hover{opacity:1} #qd-toggle{position:fixed;top:10px;right:12px;z-index:9000;width:26px;height:26px;
#quick-dock a{color:#5b6472;text-decoration:none;padding:4px 8px;border-radius:6px;background:rgba(127,131,140,.12)} border-radius:50%;border:1px solid rgba(127,131,140,.4);background:rgba(255,255,255,.85);
#quick-dock a:hover{color:#3454d1;background:rgba(52,84,209,.1)} color:#5b6472;font:14px/1 system-ui,sans-serif;cursor:pointer;opacity:.6;transition:opacity .2s}
@media (max-width:720px){#quick-dock a:not(.qd-key){display:none}} #qd-toggle:hover{opacity:1}
</style> body[data-qd=open] #qd-toggle{background:rgba(52,84,209,.12);color:#3454d1;border-color:rgba(52,84,209,.4)}
#qd-links{position:fixed;top:44px;right:12px;z-index:9000;display:none;flex-direction:column;gap:4px;
font:12px/1 system-ui,sans-serif;padding:6px;border-radius:10px;background:rgba(255,255,255,.95);
box-shadow:0 2px 10px rgba(0,0,0,.12)}
body[data-qd=open] #qd-links{display:flex}
#qd-links a{color:#5b6472;text-decoration:none;padding:5px 10px;border-radius:6px;white-space:nowrap}
#qd-links a:hover{color:#3454d1;background:rgba(52,84,209,.08)}
</style>
</head> </head>
<body> <body>
<nav id="quick-dock" aria-label="快捷入口"> <nav id="quick-dock" aria-label="快捷入口">
<a class="qd-key" href="https://wait4x.online/artalk/" target="_blank" rel="noopener">评论</a> <button id="qd-toggle" type="button" aria-expanded="false" title="快捷入口">≡</button>
<a class="qd-key" href="./stats.html" target="_blank" rel="noopener">仪表盘</a> <div id="qd-links">
<a href="https://git.wait4x.online/wait4x/blog" target="_blank" rel="noopener">仓库</a> <a href="https://wait4x.online/artalk/" target="_blank" rel="noopener">评论管理</a>
<a href="https://git.wait4x.online/wait4x/blog/src/branch/main/docs/%E5%90%8E%E5%8F%B0%E5%9B%9E%E6%BB%9A%E6%8C%87%E5%BC%95.md" target="_blank" rel="noopener">回滚</a> <a href="./stats.html" target="_blank" rel="noopener">内容仪表盘</a>
<a href="https://git.wait4x.online/wait4x/blog/src/branch/main/docs/%E7%AE%A1%E7%90%86%E5%8F%B0.md" target="_blank" rel="noopener">手册</a> <a href="https://git.wait4x.online/wait4x/blog" target="_blank" rel="noopener">仓库</a>
<a href="https://git.wait4x.online/wait4x/blog/src/branch/main/docs/%E5%90%8E%E5%8F%B0%E5%9B%9E%E6%BB%9A%E6%8C%87%E5%BC%95.md" target="_blank" rel="noopener">回滚指引</a>
<a href="https://git.wait4x.online/wait4x/blog/src/branch/main/docs/%E7%AE%A1%E7%90%86%E5%8F%B0.md" target="_blank" rel="noopener">管理台手册</a>
</div>
</nav> </nav>
<script>
// 速通坞开合:默认收起(误触根除);记住用户选择;点外部自动收起
(function () {
var t = document.getElementById('qd-toggle');
var set = function (open) {
document.body.dataset.qd = open ? 'open' : 'closed';
t.setAttribute('aria-expanded', String(open));
try { localStorage.setItem('qd-open', open ? '1' : '0'); } catch (e) {}
};
var saved;
try { saved = localStorage.getItem('qd-open'); } catch (e) {}
set(saved === '1'); // 缺省收起
t.addEventListener('click', function () {
set(document.body.dataset.qd !== 'open');
});
document.addEventListener('click', function (e) {
if (document.body.dataset.qd === 'open' && !e.target.closest('#quick-dock')) set(false);
});
})();
</script>
<!-- pinned 本地 bundle(0.259.0,5n T1 升级):官方模板仅此一个 script,无 CSS link、无 type=module --> <!-- pinned 本地 bundle(0.259.0,5n T1 升级):官方模板仅此一个 script,无 CSS link、无 type=module -->
<script src="./sveltia-cms.js"></script> <script src="./sveltia-cms.js"></script>
<!-- 艺术字体切片(unicode-range 按需取片):选择面板的示例字形用真实字体渲染 --> <!-- 艺术字体切片(unicode-range 按需取片):选择面板的示例字形用真实字体渲染 -->
+16 -6
View File
@@ -73,6 +73,14 @@ const cards = await Promise.all(
}), }),
); );
const first = cards[0]; const first = cards[0];
// 分组筛选动态化(5n 后续):后台分组已开放自由输入(select 无 creatable),
// 这里从数据反向派生筛选片——新分组零配置自动出现。
// 仅 [a-z0-9-] 命名的分组生成 CSS 规则(选择器安全);其余分组仍正常展示在「全部」里
const groups = [...new Set(cards.map((c) => c.group).filter(Boolean))]
.filter((g) => /^[a-z0-9-]+$/.test(g));
const filterRules = groups
.map((g) => `.g-main:has(#f-g-${g}:checked) .shot:not([data-group='${g}']) { display: none; }`)
.join('\n');
--- ---
<BaseLayout title={`${t('gallery_title', locale)} · wait4x`}> <BaseLayout title={`${t('gallery_title', locale)} · wait4x`}>
@@ -82,10 +90,12 @@ const first = cards[0];
<legend class="sr-only">{t('gallery_filter_legend', locale)}</legend> <legend class="sr-only">{t('gallery_filter_legend', locale)}</legend>
<input class="f-input" type="radio" name="gallery-group" id="f-all" checked /> <input class="f-input" type="radio" name="gallery-group" id="f-all" checked />
<label class="tap chip" for="f-all">{t('gallery_filter_all', locale)}</label> <label class="tap chip" for="f-all">{t('gallery_filter_all', locale)}</label>
<input class="f-input" type="radio" name="gallery-group" id="f-street" /> {groups.map((g) => (
<label class="tap chip" for="f-street">{t('gallery_filter_street', locale)}</label> <Fragment>
<input class="f-input" type="radio" name="gallery-group" id="f-landscape" /> <input class="f-input" type="radio" name="gallery-group" id={`f-g-${g}`} />
<label class="tap chip" for="f-landscape">{t('gallery_filter_landscape', locale)}</label> <label class="tap chip" for={`f-g-${g}`}>{g.charAt(0).toUpperCase() + g.slice(1)}</label>
</Fragment>
))}
</fieldset> </fieldset>
<div class="stage"> <div class="stage">
@@ -618,8 +628,7 @@ const first = cards[0];
.chip:hover { color: var(--ink-1); border-color: var(--ink-4); } .chip:hover { color: var(--ink-1); border-color: var(--ink-4); }
.f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; } .f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
.f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; } .f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; }
.g-main:has(#f-street:checked) .shot:not([data-group='street']) { display: none; } /* 动态分组筛选规则见下方 is:global 块(分组来自数据,构建期生成) */
.g-main:has(#f-landscape:checked) .shot:not([data-group='landscape']) { display: none; }
/* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */ /* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */
.stage { container-type: inline-size; } .stage { container-type: inline-size; }
@@ -1035,4 +1044,5 @@ const first = cards[0];
border-radius: 6px; border-radius: 6px;
} }
.shot img, .thumb img { border-radius: var(--radius-img); } .shot img, .thumb img { border-radius: var(--radius-img); }
<style is:global set:html={filterRules} />
</style> </style>
+3 -5
View File
@@ -295,14 +295,12 @@ await page.keyboard.press('Escape');
ok('灯箱·Esc 关闭', await page.locator('#lb[open]').count() === 0); ok('灯箱·Esc 关闭', await page.locator('#lb[open]').count() === 0);
// 7) 分组筛选(当前库存单组:21 张全 landscape;验证筛选逻辑用街拍=0 反证) // 7) 分组筛选(当前库存单组:21 张全 landscape;验证筛选逻辑用街拍=0 反证)
await page.click('label[for=f-landscape]'); await page.click('label[for=f-g-landscape]'); // 5n:分组筛选动态化(数据派生 id)
await page.waitForTimeout(200); await page.waitForTimeout(200);
const landscapeShown = await page.locator('.shot:visible').count(); const landscapeShown = await page.locator('.shot:visible').count();
ok('图库·风景筛选', landscapeShown === 21, `${landscapeShown} 张`); ok('图库·风景筛选', landscapeShown === 21, `${landscapeShown} 张`);
await page.click('label[for=f-street]'); // 5n:分组筛选动态化——筛选片由数据派生(当前库存仅 landscape 一组,不再有 street 档)
await page.waitForTimeout(200); ok('图库·筛选片数据派生', (await page.locator('.filters .chip').count()) === 2);
const streetShown = await page.locator('.shot:visible').count();
ok('图库·街拍筛选(应为0)', streetShown === 0, `${streetShown} 张`);
await page.click('label[for=f-all]'); await page.click('label[for=f-all]');
// 8) 书评字体(思源宋)与技术页 TOC // 8) 书评字体(思源宋)与技术页 TOC