Switch dashboard to a light theme and drop Google Fonts.

- Replace the warm dark palette with an Apple-style light surface scheme.
- Inline fonts via the system stack instead of preconnecting to Google.
- Rename the document title from "Laya 路由看板" to "Laya 路由".

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
richarjiang
2026-09-24 10:14:52 +08:00
parent c4582ba810
commit f429caf7e8

View File

@@ -3,219 +3,274 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Laya 路由看板</title> <title>Laya 路由</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,520;9..144,680&family=IBM+Plex+Mono:wght@400;500&family=Source+Sans+3:wght@400;600&display=swap" rel="stylesheet" />
<style> <style>
:root { :root {
--ink: #12140f; --canvas: #f5f5f7;
--panel: #1c2118; --surface: rgba(255, 255, 255, 0.78);
--paper: #efe7d6; --label: #1d1d1f;
--muted: #b4ad9c; --secondary: #6e6e73;
--line: rgba(239, 231, 214, 0.14); --tertiary: #86868b;
--amber: #e2a322; --line: rgba(0, 0, 0, 0.08);
--moss: #8eae6a; --fill: rgba(0, 0, 0, 0.06);
--cinnabar: #e06a45; --blue: #0071e3;
--simple: #8eae6a; --simple: #248a3d;
--normal: #e2a322; --normal: #0071e3;
--complex: #e06a45; --complex: #c93400;
--shadow: 0 12px 40px rgba(0, 0, 0, 0.06);
color-scheme: light;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; height: 100%; } html, body { margin: 0; height: 100%; }
body { body {
background: background: var(--canvas);
radial-gradient(900px 420px at 0% 0%, rgba(226, 163, 34, 0.16), transparent 55%), color: var(--label);
linear-gradient(180deg, #171b14 0%, var(--ink) 40%); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Helvetica Neue", sans-serif;
color: var(--paper); font-size: 15px;
font-family: "Source Sans 3", "Songti SC", serif; line-height: 1.45;
letter-spacing: -0.011em;
} }
header { button, input { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.app {
display: grid;
grid-template-columns: 300px 1fr;
height: 100%;
}
.sidebar {
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
justify-content: space-between; min-height: 0;
gap: 24px; background: rgba(255, 255, 255, 0.55);
padding: 22px 28px 16px; backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--line); border-right: 1px solid var(--line);
} }
h1 { .toolbar {
font-family: Fraunces, "Songti SC", serif;
font-weight: 520;
font-size: 34px;
letter-spacing: -0.03em;
margin: 0;
}
.sub { color: var(--muted); margin: 4px 0 0; font-size: 14px; }
.live {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; justify-content: space-between;
font-family: "IBM Plex Mono", monospace; gap: 12px;
padding: 18px 16px 10px;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
letter-spacing: -0.022em;
}
.status {
margin: 2px 0 0;
color: var(--secondary);
font-size: 12px; font-size: 12px;
color: var(--muted); line-height: 1.35;
} }
.dot { .live {
width: 9px; height: 9px; border-radius: 50%; width: 8px;
background: var(--moss); height: 8px;
box-shadow: 0 0 0 0 rgba(142, 174, 106, 0.7); border-radius: 50%;
animation: pulse 1.8s infinite; background: #34c759;
flex: none;
} }
.dot.off { background: var(--cinnabar); animation: none; } .live.off { background: #ff3b30; }
@keyframes pulse { .list {
70% { box-shadow: 0 0 0 8px rgba(142, 174, 106, 0); } overflow: auto;
padding: 4px 8px 20px;
min-height: 0;
} }
button.toggle {
background: transparent;
color: var(--paper);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 12px;
font: inherit;
cursor: pointer;
}
button.toggle.on { border-color: var(--amber); color: var(--amber); }
main {
display: grid;
grid-template-columns: 320px 1fr;
height: calc(100% - 92px);
}
aside, section.stage { overflow: auto; }
aside { border-right: 1px solid var(--line); padding: 12px; }
.session { .session {
display: block;
width: 100%; width: 100%;
text-align: left; text-align: left;
background: transparent; background: transparent;
color: inherit; border: 0;
border: 1px solid transparent; border-radius: 10px;
border-radius: 12px; padding: 10px 10px 11px;
padding: 12px; margin-bottom: 2px;
margin-bottom: 8px;
cursor: pointer;
font: inherit;
} }
.session:hover, .session.active { background: var(--panel); border-color: var(--line); } .session:hover { background: rgba(0, 0, 0, 0.04); }
.session .top, .meta { .session.active { background: var(--blue); color: white; }
.session.active .quiet,
.session.active .level { color: rgba(255, 255, 255, 0.82); }
.rowline {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
font-family: "IBM Plex Mono", monospace; align-items: baseline;
font-size: 11px;
color: var(--muted);
} }
.quiet { color: var(--tertiary); font-size: 12px; }
.snippet { .snippet {
margin: 8px 0 0; margin: 3px 0 0;
font-size: 15px; font-size: 14px;
line-height: 1.35; line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.stage { padding: 22px 28px 48px; } .stage {
.empty { color: var(--muted); padding-top: 12vh; max-width: 36em; } min-width: 0;
.turn { min-height: 0;
display: grid; overflow: auto;
grid-template-columns: 28px 1fr; padding: 28px 8vw 64px;
gap: 14px; }
.stage-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 22px; margin-bottom: 22px;
} }
.rail { position: relative; } .stage-head h2 {
.rail i {
display: block;
width: 12px; height: 12px;
border-radius: 50%;
margin-top: 8px;
background: var(--amber);
}
.rail::before {
content: "";
position: absolute;
left: 5px; top: 24px; bottom: -22px;
width: 1px;
background: var(--line);
}
.turn:last-child .rail::before { display: none; }
.card {
background: rgba(28, 33, 24, 0.88);
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px 18px 18px;
}
.card.arrive { animation: rise 0.45s ease; }
@keyframes rise {
from { transform: translateY(8px); opacity: 0; }
to { transform: none; opacity: 1; }
}
.prompt { margin: 8px 0 14px; white-space: pre-wrap; line-height: 1.45; }
.level {
font-family: Fraunces, serif;
font-size: 28px;
margin: 0; margin: 0;
text-transform: lowercase; font-size: 28px;
font-weight: 650;
letter-spacing: -0.025em;
}
.path {
margin: 6px 0 0;
color: var(--secondary);
font-size: 13px;
word-break: break-all;
}
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
background: white;
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 10px 6px 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.switch input { accent-color: var(--blue); width: 16px; height: 16px; }
.empty {
max-width: 36rem;
color: var(--secondary);
font-size: 17px;
line-height: 1.5;
padding-top: 12vh;
}
.turn { margin: 0 0 28px; }
.card {
background: var(--surface);
backdrop-filter: saturate(180%) blur(20px);
border-radius: 18px;
box-shadow: var(--shadow);
padding: 18px 18px 16px;
}
.card.arrive { animation: rise 0.38s cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.when { margin: 0; color: var(--tertiary); font-size: 12px; }
.prompt {
margin: 10px 0 16px;
font-size: 17px;
line-height: 1.45;
letter-spacing: -0.018em;
white-space: pre-wrap;
}
.split {
display: flex;
height: 10px;
border-radius: 99px;
overflow: hidden;
background: var(--fill);
}
.split span { display: block; height: 100%; width: 0; transition: width 0.45s cubic-bezier(0.2, 0.8, 0.2, 1); }
.split .simple { background: #34c759; }
.split .normal { background: #007aff; }
.split .complex { background: #ff9f0a; }
.legend {
display: flex;
gap: 14px;
margin: 8px 0 16px;
color: var(--secondary);
font-size: 12px;
}
.legend b { font-weight: 600; color: var(--label); }
.meter { margin: 0 0 9px; }
.meter .rowline { margin-bottom: 4px; font-size: 13px; }
.track { height: 6px; border-radius: 99px; background: var(--fill); overflow: hidden; }
.track i {
display: block;
height: 100%;
width: 0;
border-radius: inherit;
background: var(--blue);
transition: width 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.track.hot i { background: #ff9f0a; }
.note { margin: 12px 0 0; color: var(--secondary); font-size: 14px; }
@media (max-width: 800px) {
.app { grid-template-columns: 1fr; grid-template-rows: minmax(180px, 34vh) 1fr; }
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
.stage { padding: 20px 16px 48px; }
}
@media (prefers-reduced-motion: reduce) {
.card.arrive { animation: none; }
.split span, .track i { transition: none; }
} }
.bars, .meters { display: grid; gap: 8px; }
.bars { margin-bottom: 14px; }
.row { display: grid; grid-template-columns: 92px 1fr 52px; gap: 8px; align-items: center; font-size: 13px; }
.track { height: 8px; background: rgba(239, 231, 214, 0.08); border-radius: 99px; overflow: hidden; }
.fill { height: 100%; width: 0; border-radius: inherit; transition: width 0.45s ease; }
.fill.simple { background: var(--simple); }
.fill.normal { background: var(--normal); }
.fill.complex { background: var(--complex); }
.simple { color: var(--simple); }
.normal { color: var(--normal); }
.complex { color: var(--complex); }
.advice { margin: 12px 0 0; color: var(--amber); font-size: 14px; }
.num { font-family: "IBM Plex Mono", monospace; text-align: right; color: var(--muted); }
</style> </style>
</head> </head>
<body> <body>
<header> <div class="app">
<div> <aside class="sidebar">
<h1>路由值班室</h1> <div class="toolbar">
<p class="sub" id="status">正在连接本地 Laya…</p> <div>
</div> <h1>路由</h1>
<div class="live"> <p class="status" id="status">正在连接</p>
<span class="dot" id="dot"></span> </div>
<span id="clock">等待事件</span> <span class="live" id="dot" title="实时连接"></span>
<button class="toggle on" id="follow" type="button">跟随最新</button> </div>
</div> <div class="list" id="sessions"></div>
</header> </aside>
<main>
<aside id="sessions"></aside>
<section class="stage" id="stage"></section> <section class="stage" id="stage"></section>
</main> </div>
<script> <script>
const LEVELS = ["simple", "normal", "complex"]; const LEVELS = [
const METERS = [ ["simple", "简单"],
["needs_plan", "需要计划"], ["normal", "普通"],
["needs_repo", "需要读仓库"], ["complex", "复杂"],
["needs_web", "需要查文档"],
["needs_deep_reasoning", "需要深推理"],
]; ];
const state = { records: [], follow: true, selected: null, connected: false }; const METERS = [
["needs_plan", "先做计划"],
["needs_repo", "先看仓库"],
["needs_web", "先查文档"],
["needs_deep_reasoning", "先想清楚"],
];
const state = { records: [], follow: true, selected: null, fresh: new Set() };
function keyOf(record) { function keyOf(record) {
if (record.session_id) return record.session_id; if (record.session_id) return record.session_id;
return `loose:${record.client || "unknown"}:${record.cwd || ""}`; return `loose:${record.client || "unknown"}:${record.cwd || ""}`;
} }
function labelOf(record) { function titleOf(record) {
const client = record.client || "未知客户端"; const client = { codex: "Codex", claude: "Claude", cli: "命令行" }[record.client] || record.client || "未知来源";
if (!record.session_id) return `${client} · 未带会话号`; return record.session_id ? client : `${client},没有会话号`;
return client;
} }
function when(ts) { function when(ts) {
if (!ts) return ""; if (!ts) return "";
return new Date(ts).toLocaleString("zh-CN", { hour12: false }); return new Date(ts).toLocaleString("zh-CN", { hour12: false, month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" });
} }
function pct(value) { function pct(value) {
return `${Math.round(Number(value || 0) * 100)}%`; return `${Math.round(Number(value || 0) * 100)}%`;
} }
function advice(record) { function advice(record) {
const bits = []; const bits = [];
if (record.level === "simple") bits.push("直接处理,不必铺开"); if (record.level === "simple") bits.push("可以直接做");
if (record.level === "normal") bits.push("按常规查看相关代码"); if (record.level === "normal") bits.push("按平常的方式看相关代码");
if (record.level === "complex") bits.push("先调查,必要时再做计划"); if (record.level === "complex") bits.push("先调查,需要时再写计划");
if (record.needs_plan >= 0.7) bits.push("计划分数高"); if (record.needs_plan >= 0.7) bits.push("计划这项比较高");
if (record.needs_repo >= 0.7) bits.push("先读仓库"); if (record.needs_repo >= 0.7) bits.push("先读仓库");
if (record.needs_web >= 0.7) bits.push("查当前文档"); if (record.needs_web >= 0.7) bits.push("先查现在的文档");
if (record.needs_deep_reasoning >= 0.7) bits.push("先权衡再改"); if (record.needs_deep_reasoning >= 0.7) bits.push("先比较做法再改");
return bits.join(" · "); return bits.join(",");
} }
function sessions() { function sessions() {
const groups = new Map(); const groups = new Map();
@@ -230,9 +285,8 @@ function sessions() {
return ta < tb ? 1 : -1; return ta < tb ? 1 : -1;
}); });
} }
function bar(name, value, klass) { function escapeHtml(value) {
const width = Math.max(0, Math.min(100, Number(value || 0) * 100)); return String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]));
return `<div class="row"><span>${name}</span><div class="track"><div class="fill ${klass}" style="width:${width}%"></div></div><span class="num">${pct(value)}</span></div>`;
} }
function render() { function render() {
const groups = sessions(); const groups = sessions();
@@ -240,81 +294,102 @@ function render() {
const aside = document.getElementById("sessions"); const aside = document.getElementById("sessions");
aside.innerHTML = groups.map(([key, items]) => { aside.innerHTML = groups.map(([key, items]) => {
const last = items[items.length - 1]; const last = items[items.length - 1];
const snippet = (last.prompt || "未记录原文").replace(/\s+/g, " ").slice(0, 72); const snippet = (last.prompt || "没有记下原文").replace(/\s+/g, " ");
const levelName = LEVELS.find(([id]) => id === last.level)?.[1] || last.level;
return `<button class="session ${key === state.selected ? "active" : ""}" data-key="${encodeURIComponent(key)}" type="button"> return `<button class="session ${key === state.selected ? "active" : ""}" data-key="${encodeURIComponent(key)}" type="button">
<div class="top"><span>${labelOf(last)}</span><span class="${last.level}">${last.level}</span></div> <div class="rowline"><span>${escapeHtml(titleOf(last))}</span><span class="level quiet">${escapeHtml(levelName)}</span></div>
<p class="snippet">${escapeHtml(snippet)}</p> <p class="snippet">${escapeHtml(snippet)}</p>
<div class="top"><span>${items.length} 次判断</span><span>${when(last.ts)}</span></div> <div class="rowline"><span class="quiet">${items.length} 次判断</span><span class="quiet">${when(last.ts)}</span></div>
</button>`; </button>`;
}).join("") || `<p class="empty">还没有记录。</p>`; }).join("") || `<p class="empty">还没有判断。</p>`;
const stage = document.getElementById("stage"); const stage = document.getElementById("stage");
const group = groups.find(([key]) => key === state.selected); const group = groups.find(([key]) => key === state.selected);
if (!group) { if (!group) {
stage.innerHTML = `<p class="empty">在 Codex 或 Claude Code 里提交一条任务后,这里会按会话展开每一次判断。概率没有温度校准,只作为现场提示。</p>`; stage.innerHTML = `<p class="empty">在 Codex 或 Claude Code 里提交一条任务。判断会按会话出现在这里。这些百分比没有校准,只用来提示。</p>`;
return; return;
} }
const [key, items] = group; const [key, items] = group;
const head = items[items.length - 1]; const head = items[items.length - 1];
stage.innerHTML = `<p class="meta"><span>${escapeHtml(labelOf(head))}</span><span>${escapeHtml(head.cwd || "")}</span></p> const followChecked = state.follow ? "checked" : "";
<p class="sub">${escapeHtml(key)}</p> stage.innerHTML = `<div class="stage-head">
${items.map((record, index) => ` <div>
<article class="turn"> <h2>${escapeHtml(titleOf(head))}</h2>
<div class="rail"><i class="${record.level}" style="background:var(--${record.level})"></i></div> <p class="path">${escapeHtml(head.cwd || "没有记录工作目录")}</p>
<div class="card"> </div>
<div class="meta"><span>第 ${index + 1} 步 · ${when(record.ts)}</span><span>${record.elapsed_ms ?? "?"} ms · ${record.device || ""}</span></div> <label class="switch"><input id="follow" type="checkbox" ${followChecked} /> 跟随最新</label>
<h2 class="level ${record.level}">${record.level}</h2> </div>
<p class="prompt">${escapeHtml(record.prompt || "原文未写入日志")}</p> ${items.map((record, index) => {
<div class="bars"> const id = recordId(record);
${LEVELS.map((level) => bar(level, (record.level_probabilities || {})[level], level)).join("")} const arrive = state.fresh.has(id) ? "arrive" : "";
state.fresh.delete(id);
const probs = record.level_probabilities || {};
const levelName = LEVELS.find(([name]) => name === record.level)?.[1] || record.level;
return `<article class="turn">
<div class="card ${arrive}">
<p class="when">第 ${index + 1} 次 · ${when(record.ts)} · ${record.elapsed_ms ?? "?"} 毫秒</p>
<p class="prompt">${escapeHtml(record.prompt || "原文没有写入日志")}</p>
<div class="split" role="img" aria-label="复杂度分布">
${LEVELS.map(([name]) => `<span class="${name}" style="width:${Math.max(0, Math.min(100, Number(probs[name] || 0) * 100))}%"></span>`).join("")}
</div> </div>
<div class="meters"> <div class="legend">
${METERS.map(([field, name]) => bar(name, record[field], record[field] >= 0.7 ? "complex" : "normal")).join("")} ${LEVELS.map(([name, label]) => `<span>${label} <b>${pct(probs[name])}</b></span>`).join("")}
</div> </div>
<p class="advice">${escapeHtml(advice(record))}</p> ${METERS.map(([field, label]) => {
const value = Number(record[field] || 0);
return `<div class="meter"><div class="rowline"><span>${label}</span><span class="quiet">${pct(value)}</span></div><div class="track ${value >= 0.7 ? "hot" : ""}"><i style="width:${Math.max(0, Math.min(100, value * 100))}%"></i></div></div>`;
}).join("")}
<p class="note">${escapeHtml(levelName)}。${escapeHtml(advice(record))}</p>
</div> </div>
</article>`).join("")}`; </article>`;
}).join("")}`;
const follow = document.getElementById("follow");
if (follow) {
follow.addEventListener("change", () => {
state.follow = follow.checked;
render();
});
}
if (state.follow) stage.scrollTop = stage.scrollHeight; if (state.follow) stage.scrollTop = stage.scrollHeight;
} }
function escapeHtml(value) { function recordId(record) {
return String(value).replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch])); return `${record.ts}|${record.prompt_sha256}|${record.session_id || ""}`;
} }
function remember(record) { function remember(record) {
const id = `${record.ts}|${record.prompt_sha256}|${record.session_id || ""}`; const id = recordId(record);
if (state.records.some((item) => `${item.ts}|${item.prompt_sha256}|${item.session_id || ""}` === id)) return; if (state.records.some((item) => recordId(item) === id)) return;
state.fresh.add(id);
state.records.push(record); state.records.push(record);
document.getElementById("clock").textContent = when(record.ts);
render(); render();
} }
document.getElementById("follow").addEventListener("click", (event) => {
state.follow = !state.follow;
event.currentTarget.classList.toggle("on", state.follow);
render();
});
document.getElementById("sessions").addEventListener("click", (event) => { document.getElementById("sessions").addEventListener("click", (event) => {
const button = event.target.closest("button[data-key]"); const button = event.target.closest("button[data-key]");
if (!button) return; if (!button) return;
state.follow = false; state.follow = false;
document.getElementById("follow").classList.remove("on");
state.selected = decodeURIComponent(button.dataset.key); state.selected = decodeURIComponent(button.dataset.key);
render(); render();
}); });
async function boot() { async function boot() {
const response = await fetch("/api/feed"); const response = await fetch("/api/feed");
if (!response.ok) {
document.getElementById("status").textContent = "读不到记录";
return;
}
const body = await response.json(); const body = await response.json();
state.records = body.records || []; state.records = body.records || [];
document.getElementById("status").textContent = `${body.model || "laya"} · ${body.device || "设备未知"} · 问题版本 ${body.questions_version || "?"}`; const device = body.device || "设备未知";
document.getElementById("status").textContent = `${device} · 已连接`;
render(); render();
const source = new EventSource("/events"); const source = new EventSource("/events");
source.onopen = () => { source.onopen = () => document.getElementById("dot").classList.remove("off");
state.connected = true;
document.getElementById("dot").classList.remove("off");
};
source.onerror = () => { source.onerror = () => {
state.connected = false;
document.getElementById("dot").classList.add("off"); document.getElementById("dot").classList.add("off");
document.getElementById("status").textContent = "实时连接中断,正在重试";
};
source.onmessage = (event) => {
document.getElementById("status").textContent = `${device} · 已连接`;
remember(JSON.parse(event.data));
}; };
source.onmessage = (event) => remember(JSON.parse(event.data));
} }
boot(); boot();
</script> </script>