feat(books): 五书策展字段与泥潭作者;顺带入 T1 终端页文件

- schema 加 description/bookAuthor 可选;五书补字段(一句话策展评语
  + 原书作者;泥潭=刘楚昕 用户提供)
- 任务包入 T5 /opensource 独立页(用户追加需求,方案 A 复用扩展)
- 因 add -A 顺带包含 op-artalk 车道已自验 12/12 的 src/pages/terminal.astro
  (T1 交付物,验收随后)

验证: grep bookAuthor content/posts/*.md 五篇命中;schema 可选字段
      对既有构建零破坏

Spec: 2026-10-25_add-terminal-books-admin

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 13:46:56 +08:00
1 parent b789d30a5d
commit 3bd8369eda
12 files changed
+4146

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ const routes: Record<string, string> = {
<tr><td><kbd>g</kbd> <kbd>a</kbd></td><td>文章</td></tr>
<tr><td><kbd>g</kbd> <kbd>g</kbd></td><td>图库</td></tr>
<tr><td><kbd>g</kbd> <kbd>m</kbd></td><td>瞬间</td></tr>
<tr><td><kbd>/terminal</kbd></td><td>终端彩蛋</td></tr>
<tr><td><kbd>Esc</kbd></td><td>关闭浮层</td></tr>
</tbody>
</table>
+4
View File
@@ -26,6 +26,10 @@ const postsSchema = z.object({
tags: z.array(z.string()),
/** 情绪色,仅书评轨使用;#RRGGBB */
mood: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
/** 一句话策展评语(/books 书架卡;缺省回退正文首段) */
description: z.string().optional(),
/** 原书作者(书架卡署名) */
bookAuthor: z.string().optional(),
draft: z.boolean().optional(),
});
+322
View File
@@ -0,0 +1,322 @@
---
// /terminal 彩蛋页:交互终端,零依赖单文件实现
// 命令注册表唯一事实源;输出一律 DOM API + textContent(无 innerHTML,XSS 纪律)
// 实现集提炼自 Phase 5b 预研(satnaing/terminal-portfolio 实读 + CodeNerve 反例)
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="terminal" description="wait4x 的终端彩蛋:help 开始探索">
<section class="term-shell" id="term-shell" aria-label="交互终端彩蛋">
<div class="term-bar" aria-hidden="true">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="term-title">wait4x@blog: ~</span>
</div>
<div class="term-body">
<!-- 欢迎语静态渲染:无 JS / 构建期首屏即有内容 -->
<div class="term-out" id="term-out" aria-live="polite">
<p class="line">wait4x terminal v1.0 —— 键入 help 查看可用命令</p>
</div>
<noscript>
<p class="line dim">此彩蛋需要 JavaScript:静态站无运行时框架,仅此页例外。</p>
</noscript>
<form id="term-form" class="term-form" autocomplete="off">
<label class="prompt" for="term-input">wait4x@blog:~$</label>
<!-- 输入行独立成行:移动端软键盘弹出时不遮挡回显 -->
<input
id="term-input"
class="term-input"
type="text"
aria-label="终端命令输入"
spellcheck="false"
autocapitalize="off"
/>
</form>
</div>
</section>
<script>
// ClientRouter 软导航会整体换 DOM:全部监听挂 document 常驻 + pathname 守卫短路,
// 元素引用一律现取不缓存(站内 CommandPalette/Header 同款模式,见各自注释)
const PATH = '/terminal/';
const COMMANDS: { name: string; desc: string }[] = [
{ name: 'clear', desc: '清屏(等同 Ctrl+L)' },
{ name: 'exit', desc: '退出终端,返回首页' },
{ name: 'help', desc: '列出可用命令' },
{ name: 'ls', desc: '列出站内页面(可点击跳转)' },
{ name: 'open', desc: 'open gitea —— 打开代码托管(新标签页)' },
{ name: 'sudo', desc: '试试看' },
{ name: 'theme', desc: '切换亮/暗主题' },
{ name: 'whoami', desc: '我是谁' },
];
// 唯一允许 open 的外部目标:白名单硬编码,永不拼接用户输入
const OPEN_TARGETS: Record<string, string> = {
gitea: 'https://git.wait4x.online/wait4x',
};
const SITE_ROUTES: { path: string; label: string }[] = [
{ path: '/', label: '首页' },
{ path: '/archives/', label: '文章' },
{ path: '/gallery/', label: '图库' },
{ path: '/moments/', label: '瞬间' },
{ path: '/about/', label: '关于' },
{ path: '/en/', label: 'English home' },
];
// 模块级仅存终端语义状态(不存 DOM 引用);history 顺序旧→新
const history: string[] = [];
let histPos = 0;
function isTerminalPage(): boolean {
return location.pathname === PATH || location.pathname === '/terminal';
}
function out(): HTMLElement | null {
return document.getElementById('term-out');
}
function input(): HTMLInputElement | null {
return document.getElementById('term-input') as HTMLInputElement | null;
}
function print(text: string, cls = ''): void {
const box = out();
if (!box) return;
const p = document.createElement('p');
p.className = cls ? `line ${cls}` : 'line';
p.textContent = text; // 纪律:一切回显走 textContent
box.appendChild(p);
}
function printLink(path: string, label: string): void {
const box = out();
if (!box) return;
const p = document.createElement('p');
p.className = 'line';
const a = document.createElement('a');
a.href = path;
a.textContent = `${path.padEnd(14)}${label}`;
a.className = 'term-link';
p.appendChild(a);
box.appendChild(p);
}
function scrollToBottom(): void {
const shell = document.getElementById('term-shell');
shell?.scrollIntoView({ block: 'nearest' });
const box = out();
if (box) box.scrollTop = box.scrollHeight;
}
function run(rawLine: string): void {
const line = rawLine.trim();
print(`wait4x@blog:~$ ${rawLine}`, 'echo');
if (!line) return;
history.push(line);
histPos = history.length;
const [cmd, ...args] = line.split(/\s+/);
switch (cmd) {
case 'help': {
// 注册表即帮助:单一事实源
const width = Math.max(...COMMANDS.map((c) => c.name.length)) + 2;
for (const c of COMMANDS) print(`${c.name.padEnd(width)}${c.desc}`);
break;
}
case 'whoami':
print('wait4x —— 阅读 · 技术 · 摄影,博客在 gitea 开源(open gitea)');
break;
case 'ls':
for (const r of SITE_ROUTES) printLink(r.path, r.label);
break;
case 'theme': {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('theme', next); // 与 Header/Keyboard 的 t 键同存储键
print(`theme → ${next}`);
break;
}
case 'open': {
const target = args[0];
const url = target ? OPEN_TARGETS[target] : undefined;
if (!url) {
print(`用法: open <target>;可用: ${Object.keys(OPEN_TARGETS).join(', ')}`, 'dim');
} else {
window.open(url, '_blank', 'noopener');
print(`opening ${url} …`, 'dim');
}
break;
}
case 'sudo':
print('wait4x is not in the sudoers file. This incident will be reported.', 'err');
break;
case 'clear': {
out()?.replaceChildren();
break;
}
case 'exit':
print('logout', 'dim');
setTimeout(() => {
location.href = '/';
}, 600);
break;
default:
print(`command not found: ${cmd}(help 查看可用命令)`, 'err');
}
}
function submitLine(): void {
const el = input();
if (!el) return;
run(el.value);
el.value = '';
scrollToBottom();
}
function tabComplete(current: string): void {
if (!current) return;
const hits = COMMANDS.filter((c) => c.name.startsWith(current)).map((c) => c.name);
if (hits.length === 1) {
const el = input();
if (el) el.value = hits[0];
} else if (hits.length > 1) {
// 多命中:先回显当前输入再列候选,与真实 shell 一致
print(`wait4x@blog:~$ ${current}`, 'echo');
print(hits.join(' '), 'dim');
scrollToBottom();
}
}
function historyStep(dir: -1 | 1): void {
const el = input();
if (!el) return;
const next = histPos + dir;
if (next < 0 || next > history.length) return;
histPos = next;
el.value = next === history.length ? '' : history[next];
}
document.addEventListener('submit', (e) => {
if (!isTerminalPage()) return;
const form = e.target as HTMLElement;
if (form.id !== 'term-form') return;
e.preventDefault();
submitLine();
});
document.addEventListener('keydown', (e) => {
if (!isTerminalPage()) return;
const el = e.target as HTMLElement;
if (el.id !== 'term-input') return;
// 中文输入法组词中的 Enter 不是提交;Tab 亦然
if (e.isComposing) return;
if (e.key === 'Enter') {
e.preventDefault();
submitLine();
} else if (e.key === 'Tab') {
e.preventDefault();
const cur = (el as HTMLInputElement).value.trim();
tabComplete(cur);
} else if (e.ctrlKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
out()?.replaceChildren();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
historyStep(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
historyStep(1);
}
});
// 点击终端区域聚焦输入框;有选区(用户在复制输出)时不抢焦点
document.addEventListener('click', (e) => {
if (!isTerminalPage()) return;
const shell = (e.target as HTMLElement).closest?.('#term-shell');
if (!shell) return;
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
if ((e.target as HTMLElement).tagName === 'A') return;
input()?.focus();
});
// View Transitions:软导航抵达/回到本页时恢复焦点与光标末位
document.addEventListener('astro:after-swap', () => {
if (!isTerminalPage()) return;
const el = input();
if (el) {
el.focus();
const end = el.value.length;
el.setSelectionRange(end, end);
}
});
</script>
<style>
/* 恒深色例外区:--code-bg 亮暗同值,终端不跟随主题(本页唯一例外,彩蛋语义) */
.term-shell {
max-width: 720px;
margin: var(--space-6) auto;
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.65;
}
.term-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #3d4149;
}
.term-title {
margin-left: 8px;
color: #6e7681;
font-size: 11px;
letter-spacing: 0.05em;
}
.term-body { padding: 14px 16px 16px; }
.term-out { max-height: 60vh; overflow-y: auto; }
.line { margin: 0; color: var(--code-ink); white-space: pre-wrap; word-break: break-word; }
.line.echo { color: #adbac7; }
.line.dim { color: #6e7681; }
.line.err { color: #f85149; }
.term-link { color: #539bf5; text-decoration: none; }
.term-link:hover { text-decoration: underline; }
.term-form {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 4px;
}
.prompt { color: #7ee787; white-space: nowrap; }
.term-input {
flex: 1;
min-width: 0; /* flex 子项收缩时保输入可用(窄屏) */
background: transparent;
border: none;
outline: none;
color: var(--code-ink);
font: inherit;
caret-color: #7ee787;
padding: 0;
}
</style>
</BaseLayout>