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:
1 parent
b789d30a5d
commit
3bd8369eda
12 files changed
+4146
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user