replace the monochrome theme with the CYBER neon-cyan skin
The project shipped a black-and-white theme that was only half converted: the login page looked right, the message page was halfway, and the settings page had not been started. This finishes the move to the CYBER reference in prototype_reference/. Colors now come from a single token source (styles/variables.scss), where every semantic color is declared twice: once as OKLCH for the CSS, and once as an RGB triple so translucent variants can be built as rgba(var(--*-rgb), alpha). Cyan is the system itself, amber means the user has to intervene, green means the machine is working, and red only ever means something broke. The RGB triples are derived from the OKLCH values so the two declarations cannot drift apart. Pages adopt the .cy-* shell classes from the reference (view, panel, kpis, tabs, grid, table, field) instead of ad-hoc markup, which is what makes 19 of 26 views consistent. The remaining seven are deliberately bespoke: chat streams, the kanban board, the workflow canvas, the terminal, and the missing-agent screen. The chart palette and the kanban column colors move into single-source modules so they cannot drift either. A new guard test rejects raw hex colors, named colors, and literal RGB triples anywhere in the client source, with an explicit exemption list for the places that genuinely cannot read a CSS variable. The existing chat semantics spec keeps the chat chain honest. No behavior changes: no logic, no i18n keys, no data structures, and the /chat-run event contract is untouched.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,657 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hermes UI · 对话台原型 · CYBER 三色版</title>
|
||||
<!--
|
||||
============================================================================
|
||||
Hermes UI · 对话台(ChatView)独立原型 —— 青 / 橙 / 荧光绿 三色语义版
|
||||
----------------------------------------------------------------------------
|
||||
纯静态,不接入 packages/client。字段名与渲染结构对齐真实实现:
|
||||
- 侧栏走 PageSidebarNav(不吃全局 AppSidebar),与 App.vue 的 usesPageSidebar 一致
|
||||
- 气泡 role 全集:user / assistant / tool / command / system / moa
|
||||
- system.systemType: command | error | fork-divider
|
||||
- 输入区 toolbar:附件 · effort · 齿轮 · model · 语音 · 发送
|
||||
- 右侧 Outline 面板(真实:OutlinePanel.vue)+ Files / Terminal 合并 tab 面板
|
||||
(真实:ChatPanel.vue 的 showToolPanel + activeToolPanel,顶栏入口受 isSuperAdmin 门控)
|
||||
|
||||
★ 本版配色变化(相对 hermes-ui-cyber-api.html)
|
||||
原来只有青色一个强调色,语义全靠"青 / 绿 / 红 / 黄"混着顶。
|
||||
现在改成三色分工,各自只干一件事:
|
||||
青 (--acc) = 主色。品牌、主按钮、当前选中、运行中。只强调"这是系统本体"。
|
||||
橙 (--amber) = 需要你介入。审批卡、等待确认、上下文即将超限。
|
||||
荧光绿 (--lime) = 机器在动。工具调用成功、流式输出、连接健康、token 余量充足。
|
||||
规则:同屏内每种颜色只承担一种含义,绝不混用。危险红单独保留给"出错"。
|
||||
|
||||
令牌接入映射(→ packages/client/src/styles/variables.scss):
|
||||
--bg / --surface / --surface-2 / --surface-3 / --input-bg
|
||||
--fg / --fg-soft / --muted / --faint / --border / --hairline
|
||||
--acc / --acc-hv / --acc-ink / --acc-soft
|
||||
--amber / --amber-soft → 新增 --warning / --warning-soft
|
||||
--lime / --lime-soft → 新增 --success / --success-soft
|
||||
--err / --info → 沿用 --error / --info
|
||||
============================================================================
|
||||
-->
|
||||
<style>
|
||||
/* ── 1. TOKENS ───────────────────────────────────────────────────────────── */
|
||||
:root{
|
||||
/* 背景四层:向下沉 / 向上浮 */
|
||||
--bg: oklch(0.155 0.012 250);
|
||||
--surface: oklch(0.130 0.012 250);
|
||||
--surface-2: oklch(0.185 0.014 248);
|
||||
--surface-3: oklch(0.225 0.016 246);
|
||||
--input-bg: oklch(0.105 0.012 252);
|
||||
--code-bg: oklch(0.115 0.014 250);
|
||||
|
||||
/* 文字四层 */
|
||||
--fg: oklch(0.945 0.006 200);
|
||||
--fg-soft: oklch(0.860 0.008 210);
|
||||
--muted: oklch(0.705 0.014 230);
|
||||
--faint: oklch(0.520 0.014 236);
|
||||
|
||||
--border: oklch(0.305 0.018 242);
|
||||
--hairline: oklch(0.255 0.016 244);
|
||||
|
||||
/* ── 三色语义系统 ──────────────────────────────────────────────────────
|
||||
青 = 主色 / 系统本体 橙 = 需你介入 荧光绿 = 机器在动 */
|
||||
--acc: oklch(0.805 0.148 188); /* 霓虹青 */
|
||||
--acc-hv: oklch(0.870 0.150 188);
|
||||
--acc-ink: oklch(0.160 0.030 200);
|
||||
--acc-soft: oklch(0.805 0.148 188 / 0.13);
|
||||
--acc-line: oklch(0.805 0.148 188 / 0.38);
|
||||
|
||||
--amber: oklch(0.780 0.155 68); /* 琥珀橙 —— 待你介入 */
|
||||
--amber-hv: oklch(0.850 0.150 72);
|
||||
--amber-soft: oklch(0.780 0.155 68 / 0.12);
|
||||
--amber-line: oklch(0.780 0.155 68 / 0.42);
|
||||
|
||||
--lime: oklch(0.860 0.190 128); /* 荧光绿 —— 机器在动 */
|
||||
--lime-hv: oklch(0.910 0.185 130);
|
||||
--lime-soft: oklch(0.860 0.190 128 / 0.12);
|
||||
--lime-line: oklch(0.860 0.190 128 / 0.40);
|
||||
|
||||
/* 危险红单独保留:只表示"出错",不参与上面三色分工 */
|
||||
--err: oklch(0.665 0.185 22);
|
||||
--err-soft: oklch(0.665 0.185 22 / 0.11);
|
||||
--err-line: oklch(0.665 0.185 22 / 0.42);
|
||||
|
||||
--font-display: ui-monospace,'JetBrains Mono','SF Mono','Menlo','Cascadia Code',monospace;
|
||||
--font-body: -apple-system,BlinkMacSystemFont,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Segoe UI',Roboto,sans-serif;
|
||||
|
||||
--rail-w: 288px; --top-h: 60px;
|
||||
--radius: 6px; --radius-lg: 8px;
|
||||
--shadow: 0 18px 48px oklch(0 0 0 / 0.55);
|
||||
}
|
||||
|
||||
/* ── 2. RESET ────────────────────────────────────────────────────────────── */
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--fg);
|
||||
font:14px/1.55 var(--font-body);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.mono{font-family:var(--font-display)}
|
||||
.grow{flex:1;min-width:0}
|
||||
.right{margin-left:auto;display:flex;align-items:center;gap:6px}
|
||||
.mt4{margin-top:4px}.mt8{margin-top:8px}.mt16{margin-top:16px}
|
||||
.xs{font-size:11px}
|
||||
.fnt{font-family:var(--font-display)}
|
||||
.acc{color:var(--acc)}
|
||||
|
||||
/* ── 3. BUTTONS ──────────────────────────────────────────────────────────── */
|
||||
/* 空心管壁按钮:底透明,只有边框发光。这就是「霓虹灯广告牌」的质感 */
|
||||
.btn{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:var(--radius);
|
||||
border:1px solid var(--border);background:var(--surface-2);color:var(--fg);font-size:12.5px;transition:.14s;white-space:nowrap;cursor:pointer}
|
||||
.btn:hover{border-color:var(--faint);background:var(--surface-3)}
|
||||
.btn.sm{height:25px;padding:0 9px;font-size:11.5px}
|
||||
/* 主按钮:空心 + 青管壁 + 内发光,跟随系统主色 */
|
||||
.btn.pri{background:transparent;border-color:var(--acc-line);color:var(--acc);
|
||||
box-shadow:inset 0 0 12px oklch(0.805 0.148 188/.12),0 0 10px oklch(0.805 0.148 188/.34),0 0 22px oklch(0.805 0.148 188/.17);
|
||||
text-shadow:0 0 8px oklch(0.805 0.148 188/.45)}
|
||||
.btn.pri:hover{border-color:var(--acc);color:var(--acc-hv);
|
||||
box-shadow:inset 0 0 16px oklch(0.805 0.148 188/.2),0 0 14px oklch(0.805 0.148 188/.5),0 0 30px oklch(0.805 0.148 188/.26)}
|
||||
/* 橙按钮:需要你介入的动作 */
|
||||
.btn.amber{background:transparent;border-color:var(--amber-line);color:var(--amber);
|
||||
box-shadow:inset 0 0 12px oklch(0.780 0.155 68/.12),0 0 10px oklch(0.780 0.155 68/.3),0 0 22px oklch(0.780 0.155 68/.15);
|
||||
text-shadow:0 0 8px oklch(0.780 0.155 68/.4)}
|
||||
.btn.amber:hover{border-color:var(--amber);color:var(--amber-hv);
|
||||
box-shadow:inset 0 0 16px oklch(0.780 0.155 68/.2),0 0 14px oklch(0.780 0.155 68/.46),0 0 30px oklch(0.780 0.155 68/.24)}
|
||||
.btn.danger{background:transparent;border-color:var(--err-line);color:var(--err)}
|
||||
.btn.danger:hover{background:var(--err-soft);box-shadow:0 0 12px oklch(0.665 0.185 22/.3)}
|
||||
.btn.ghost{background:transparent}
|
||||
.btn.on{color:var(--acc);border-color:var(--acc-line);background:var(--acc-soft)}
|
||||
|
||||
.icon-btn{width:26px;height:26px;display:grid;place-items:center;border-radius:var(--radius);
|
||||
border:1px solid transparent;background:transparent;color:var(--muted);cursor:pointer;font-size:13px;transition:.14s}
|
||||
.icon-btn:hover{color:var(--acc);border-color:var(--acc-line);background:var(--acc-soft)}
|
||||
.icon-btn.on{color:var(--acc);border-color:var(--acc-line);background:var(--acc-soft);
|
||||
box-shadow:inset 0 0 10px oklch(0.805 0.148 188/.14)}
|
||||
|
||||
/* 发送键:青管壁 + 从左到右扫过的白色高光 */
|
||||
.send-btn{width:28px;height:26px;display:grid;place-items:center;border-radius:var(--radius);
|
||||
border:1px solid var(--acc-line);background:transparent;color:var(--acc);cursor:pointer;position:relative;overflow:hidden;
|
||||
box-shadow:inset 0 0 10px oklch(0.805 0.148 188/.18),0 0 10px oklch(0.805 0.148 188/.36)}
|
||||
.send-btn::after{content:'';position:absolute;top:0;bottom:0;width:42%;
|
||||
background:linear-gradient(90deg,transparent,oklch(0.985 0.003 200/.85),transparent);
|
||||
animation:sheen 3.4s ease-in-out infinite;pointer-events:none}
|
||||
@keyframes sheen{0%,62%{transform:translateX(-160%)}86%,100%{transform:translateX(340%)}}
|
||||
.send-btn:hover{box-shadow:inset 0 0 14px oklch(0.805 0.148 188/.28),0 0 14px oklch(0.805 0.148 188/.55)}
|
||||
|
||||
/* ── 4. TAGS / LEDS ──────────────────────────────────────────────────────── */
|
||||
.tag{display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:4px;
|
||||
font:11px/1 var(--font-display);border:1px solid var(--border);background:var(--surface-2);color:var(--muted);white-space:nowrap}
|
||||
/* 语义标签:每种颜色只表示一件事 */
|
||||
.tag.acc{color:var(--acc);border-color:var(--acc-line);background:var(--acc-soft)}
|
||||
.tag.amber{color:var(--amber);border-color:var(--amber-line);background:var(--amber-soft)}
|
||||
.tag.lime{color:var(--lime);border-color:var(--lime-line);background:var(--lime-soft)}
|
||||
.tag.err{color:var(--err);border-color:var(--err-line);background:var(--err-soft)}
|
||||
|
||||
.led{width:7px;height:7px;border-radius:50%;flex:0 0 auto;background:var(--faint);box-shadow:0 0 6px currentColor}
|
||||
.led.acc{background:var(--acc);color:var(--acc)}
|
||||
.led.lime{background:var(--lime);color:var(--lime)}
|
||||
.led.amber{background:var(--amber);color:var(--amber);animation:pulse 1.4s infinite}
|
||||
.led.err{background:var(--err);color:var(--err)}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.32}}
|
||||
|
||||
/* ── 5. SESSION LIST ─────────────────────────────────────────────────────── */
|
||||
.psl{display:grid;grid-template-columns:var(--rail-w) 1fr;height:100%;min-height:0;position:relative}
|
||||
.psl.with-rail{grid-template-columns:var(--rail-w) 1fr 300px}
|
||||
/* 主区是三栏里唯一可压缩的,标题区不能被挤成竖排:
|
||||
顶栏各段在窄宽度下按优先级隐藏,标题给足 min-width。 */
|
||||
.chat-header .grow{min-width:0}
|
||||
.chat-header .ttl{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chat-header .meta-row{display:flex;align-items:center;gap:8px;min-width:0}
|
||||
@media (max-width:1180px){
|
||||
.psl.with-rail{grid-template-columns:var(--rail-w) 1fr}
|
||||
/* Outline 改为浮层覆盖,不再挤压主区 */
|
||||
.psl.with-rail .outline{position:absolute;right:0;top:0;bottom:0;width:280px;z-index:4;
|
||||
box-shadow:-18px 0 48px oklch(0 0 0 / .55)}
|
||||
.psl.with-rail .outline::after{display:none}
|
||||
}
|
||||
@media (max-width:900px){
|
||||
.psl,.psl.with-rail{grid-template-columns:1fr}
|
||||
.psl>div:first-child{display:none}
|
||||
.chat-header .ws-tag{display:none}
|
||||
}
|
||||
|
||||
.slist-h{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid var(--border)}
|
||||
.slist-body{flex:1;overflow:auto;min-height:0;padding:8px}
|
||||
/* 分组标题:等宽 + 大字距 + 拖尾发丝线 */
|
||||
.slist-group-t{font:10px/1 var(--font-display);letter-spacing:.16em;text-transform:uppercase;
|
||||
color:var(--faint);padding:10px 8px 6px;display:flex;align-items:center;gap:7px}
|
||||
.slist-group-t::after{content:'';flex:1;height:1px;background:var(--hairline)}
|
||||
|
||||
.s-item{display:flex;align-items:flex-start;gap:8px;padding:8px 9px;border-radius:var(--radius);
|
||||
border:1px solid transparent;transition:.14s;cursor:pointer;position:relative;color:inherit;text-decoration:none}
|
||||
.s-item:hover{background:var(--surface-2)}
|
||||
/* 当前会话:青色软底 + 左侧发光条(与全局侧栏同构) */
|
||||
.s-item.on{background:var(--acc-soft);border-color:oklch(0.805 0.148 188/.3)}
|
||||
.s-item.on::before{content:'';position:absolute;left:-8px;top:50%;transform:translateY(-50%);
|
||||
width:2px;height:16px;background:var(--acc);border-radius:2px;box-shadow:0 0 8px var(--acc)}
|
||||
.s-item .t{font-size:12.5px;line-height:1.4}
|
||||
.s-item .m{font-size:11px;color:var(--faint);margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
.s-item.pin .t::before{content:'▮';font-size:9px;margin-right:4px;color:var(--amber)}
|
||||
|
||||
/* ── 6. CHAT MAIN ────────────────────────────────────────────────────────── */
|
||||
.chat-main{display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--bg);position:relative}
|
||||
/* 顶栏压一层青色微光,避免整片死黑 */
|
||||
.chat-main::after{content:'';position:absolute;inset:0;pointer-events:none;
|
||||
background:radial-gradient(120% 55% at 50% 0%,oklch(0.805 0.148 188/.05),transparent 62%)}
|
||||
|
||||
.chat-header{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:10px 14px;
|
||||
border-bottom:1px solid var(--border);min-height:var(--top-h);position:relative;z-index:1}
|
||||
.chat-header .ttl{font:600 14px/1.3 var(--font-display)}
|
||||
|
||||
.chat-body{flex:1;overflow:auto;min-height:0;padding:18px 20px;position:relative;z-index:1}
|
||||
.chat-body-inner{max-width:920px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
|
||||
|
||||
/* ── 7. BUBBLES ──────────────────────────────────────────────────────────── */
|
||||
.bubble-row{display:flex;gap:10px}
|
||||
.bubble-row.user{flex-direction:row-reverse}
|
||||
.bubble-av{width:26px;height:26px;border-radius:var(--radius);flex:0 0 auto;display:grid;place-items:center;
|
||||
font:10px/1 var(--font-display);border:1px solid var(--border);background:var(--surface-2);color:var(--muted)}
|
||||
/* 用户 = 青(你在驱动的会话) */
|
||||
.bubble-row.user .bubble-av{border-color:var(--acc-line);color:var(--acc);background:var(--acc-soft);
|
||||
box-shadow:inset 0 0 10px oklch(0.805 0.148 188/.14)}
|
||||
/* 助手 = 荧光绿(机器在动) */
|
||||
.bubble-row.assistant .bubble-av{border-color:var(--lime-line);color:var(--lime);background:var(--lime-soft);
|
||||
box-shadow:inset 0 0 10px oklch(0.860 0.190 128/.14)}
|
||||
|
||||
.bubble{max-width:min(78%,720px);padding:10px 13px;border-radius:var(--radius-lg);
|
||||
border:1px solid var(--border);background:var(--surface);font-size:13.5px;line-height:1.68;
|
||||
white-space:pre-wrap;word-break:break-word}
|
||||
.bubble-row.user .bubble{background:var(--surface-2)}
|
||||
/* 助手气泡左侧压一道荧光绿,暗示"这段是机器产出的" */
|
||||
.bubble-row.assistant .bubble{border-left:2px solid var(--lime-line)}
|
||||
.bubble-row.tool .bubble,.bubble-row.command .bubble{font-family:var(--font-display);font-size:11.5px;color:var(--muted)}
|
||||
.bubble-row.system .bubble{border-style:dashed;color:var(--muted);font-size:12px}
|
||||
.bubble-row.system.err .bubble{border-color:var(--err-line);color:var(--err);background:var(--err-soft)}
|
||||
.bubble-row.moa .bubble{border-color:oklch(0.700 0.110 245/.4)}
|
||||
.bubble .meta{display:flex;align-items:center;gap:8px;margin-bottom:6px;font:10.5px/1 var(--font-display);color:var(--faint)}
|
||||
|
||||
/* 正文内的 markdown:行内 code / 粗体 / 代码块 */
|
||||
.md-code-in{font-family:var(--font-display);font-size:12.5px;padding:1px 5px;border-radius:4px;
|
||||
background:var(--input-bg);border:1px solid var(--hairline);color:var(--lime)}
|
||||
.md-strong{color:var(--fg);font-weight:600}
|
||||
.md-code{font-family:var(--font-display);font-size:12px;line-height:1.6;color:var(--fg-soft);
|
||||
background:var(--input-bg);border:1px solid var(--hairline);border-radius:var(--radius);
|
||||
padding:9px 11px;margin:8px 0;overflow:auto;white-space:pre}
|
||||
|
||||
/* 推理过程:凹陷区块,等宽 */
|
||||
.reasoning{margin-top:8px;border:1px dashed var(--border);border-radius:var(--radius);
|
||||
padding:8px 10px;background:var(--input-bg);font:11.5px/1.6 var(--font-display);color:var(--faint)}
|
||||
.reasoning .rt{color:var(--muted);cursor:pointer;user-select:none}
|
||||
|
||||
/* 工具调用卡:荧光绿表示「机器已执行」 */
|
||||
.tool-call{margin-top:8px;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
|
||||
.tool-call .th{display:flex;align-items:center;gap:7px;padding:6px 9px;
|
||||
background:oklch(0.860 0.190 128/.07);border-bottom:1px solid var(--hairline);font:11px/1.4 var(--font-display)}
|
||||
.tool-call .th .rt{margin-left:auto;color:var(--faint)}
|
||||
.term{font-family:var(--font-display);font-size:11.5px;color:var(--muted);background:var(--input-bg);
|
||||
padding:8px 9px;overflow:auto;white-space:pre-wrap}
|
||||
|
||||
.fork-divider{display:flex;align-items:center;gap:10px;color:var(--faint);font:11px/1 var(--font-display)}
|
||||
.fork-divider::before,.fork-divider::after{content:'';flex:1;height:1px;background:var(--hairline)}
|
||||
|
||||
/* 流式输出光标 */
|
||||
.caret{display:inline-block;width:6px;height:13px;background:var(--lime);vertical-align:-2px;
|
||||
box-shadow:0 0 8px var(--lime);animation:pulse 1s infinite}
|
||||
|
||||
/* ── 8. APPROVAL CARD ────────────────────────────────────────────────────── */
|
||||
/* 橙色专属:需要你介入,不允许被别处复用 */
|
||||
.appr{border:1px solid var(--amber-line);background:var(--amber-soft);border-radius:var(--radius-lg);padding:12px 14px;
|
||||
box-shadow:inset 0 0 22px oklch(0.780 0.155 68/.06),0 0 20px oklch(0.780 0.155 68/.1)}
|
||||
.appr .q{font:600 13px/1.4 var(--font-display);color:var(--amber);
|
||||
display:flex;align-items:center;gap:7px;text-shadow:0 0 10px oklch(0.780 0.155 68/.35)}
|
||||
.appr .acts{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
|
||||
/* ── 9. COMPOSER ─────────────────────────────────────────────────────────── */
|
||||
.ctx-row{display:flex;align-items:center;gap:8px;padding:0 2px 6px;font:11px/1 var(--font-display);color:var(--faint)}
|
||||
/* 上下文余量条:绿 → 橙 → 红 三段,和气泡色语义完全一致 */
|
||||
.meter{flex:1;height:4px;border-radius:2px;background:rgba(120,132,148,.2);overflow:hidden;position:relative}
|
||||
.meter i{display:block;height:100%;border-radius:2px;
|
||||
background:linear-gradient(90deg,var(--lime),var(--lime-hv));
|
||||
box-shadow:0 0 8px oklch(0.860 0.190 128/.6)}
|
||||
.meter.warn i{background:linear-gradient(90deg,var(--amber),var(--amber-hv));box-shadow:0 0 8px oklch(0.780 0.155 68/.6)}
|
||||
.meter.crit i{background:linear-gradient(90deg,var(--err),var(--err));box-shadow:0 0 8px oklch(0.665 0.185 22/.6)}
|
||||
|
||||
.chat-input{flex:0 0 auto;border-top:1px solid var(--border);padding:10px 14px 12px;background:var(--surface);position:relative;z-index:1}
|
||||
.chat-input .box{border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--input-bg);padding:8px 10px;
|
||||
transition:border-color .16s,box-shadow .16s}
|
||||
.chat-input .box:focus-within{border-color:oklch(0.805 0.148 188/.5);
|
||||
box-shadow:0 0 0 3px oklch(0.805 0.148 188/.08),inset 0 0 14px oklch(0.805 0.148 188/.06)}
|
||||
.chat-input .ta{width:100%;background:none;border:none;resize:none;min-height:44px;
|
||||
font-size:13.5px;line-height:1.6;color:var(--fg);font-family:var(--font-body)}
|
||||
.chat-input .ta:focus{outline:none}
|
||||
.chat-input .ta::placeholder{color:var(--faint)}
|
||||
.chat-input .bar{display:flex;align-items:center;gap:6px;margin-top:6px}
|
||||
|
||||
/* ── 10. OUTLINE / TOOL PANEL ─────────────────────────────────────────────── */
|
||||
.outline{border-left:1px solid var(--border);background:var(--surface);overflow:auto;padding:12px;position:relative;z-index:1}
|
||||
.outline::after{content:'';position:absolute;inset:0;pointer-events:none;
|
||||
background:radial-gradient(120% 45% at 50% 0%,oklch(0.805 0.148 188/.045),transparent 60%)}
|
||||
.sec-t{font:10px/1 var(--font-display);letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin:14px 0 7px}
|
||||
.sec-t:first-child{margin-top:0}
|
||||
.oi{padding:6px 8px;border-radius:4px;font-size:12px;color:var(--muted);cursor:pointer;
|
||||
border-left:2px solid transparent;position:relative}
|
||||
.oi:hover{background:var(--surface-2);color:var(--fg)}
|
||||
/* 大纲当前条目:青色下划,和全局侧栏选中态同构 */
|
||||
.oi.on{color:var(--acc);border-left-color:var(--acc);background:var(--acc-soft)}
|
||||
|
||||
/* 工具面板:Files / Terminal 合并成一个 tab 面板。
|
||||
对应 ChatPanel.vue 的 showToolPanel + activeToolPanel('files' | 'terminal'),
|
||||
不是一个时刻只能开一个抽屉;顶栏入口在真实实现里受 isSuperAdmin 门控。 */
|
||||
.tool-p{position:absolute;top:0;right:0;bottom:0;width:300px;border-left:1px solid var(--border);
|
||||
background:var(--surface);z-index:3;display:flex;flex-direction:column}
|
||||
.tool-tabs{flex:0 0 auto;display:flex;border-bottom:1px solid var(--border)}
|
||||
.tool-tab{flex:1;height:34px;border:none;border-bottom:2px solid transparent;background:transparent;
|
||||
color:var(--muted);font:11px/1 var(--font-display);letter-spacing:.08em;text-transform:uppercase;
|
||||
cursor:pointer;transition:.14s}
|
||||
.tool-tab:hover{color:var(--fg);background:var(--surface-2)}
|
||||
.tool-tab.on{color:var(--acc);border-bottom-color:var(--acc)}
|
||||
.row-i{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);
|
||||
border-radius:var(--radius);background:var(--surface-2);transition:.14s;cursor:pointer}
|
||||
.row-i:hover{border-color:oklch(0.805 0.148 188/.35)}
|
||||
.row-i .ttl{font-size:12px}
|
||||
.ell{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* ── 11. LAYOUT ──────────────────────────────────────────────────────────── */
|
||||
.app{display:flex;height:100vh;overflow:hidden}
|
||||
.stage{flex:1;display:flex;flex-direction:column;min-width:0;position:relative}
|
||||
.topbar{flex:0 0 auto;height:var(--top-h);display:flex;align-items:center;gap:10px;padding:0 14px;
|
||||
border-bottom:1px solid var(--border);background:var(--surface);position:relative}
|
||||
.topbar::after{content:'';position:absolute;inset:0;pointer-events:none;
|
||||
background:radial-gradient(120% 60% at 50% 0%,oklch(0.805 0.148 188/.05),transparent 62%)}
|
||||
.brand-mark{width:26px;height:26px;border-radius:var(--radius);display:grid;place-items:center;
|
||||
font:700 12px/1 var(--font-display);color:var(--acc);
|
||||
border:1px solid var(--acc);
|
||||
box-shadow:0 0 0 1px oklch(0.805 0.148 188/.18),0 0 14px oklch(0.805 0.148 188/.22) inset}
|
||||
.brand-t{font:600 13px/1 var(--font-display);letter-spacing:.06em}
|
||||
|
||||
/* 场景切换器:只在原型里存在,接入时删掉 */
|
||||
.switcher{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--hairline);
|
||||
background:var(--surface);flex-wrap:wrap}
|
||||
.switcher .lb{font:10px/1 var(--font-display);letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-right:2px}
|
||||
|
||||
/* ── 12. PALETTE STRIP ───────────────────────────────────────────────────── */
|
||||
.strip{display:flex;align-items:center;gap:0;flex-wrap:wrap}
|
||||
.sw{display:flex;align-items:center;gap:7px;padding:5px 10px;border:1px solid var(--border);
|
||||
border-radius:var(--radius);background:var(--surface-2);margin:0 6px 6px 0}
|
||||
.sw .dot{width:10px;height:10px;border-radius:2px;flex:0 0 auto}
|
||||
.sw .nm{font:11px/1 var(--font-display)}
|
||||
.sw .hx{font:10px/1 var(--font-display);color:var(--faint)}
|
||||
.sw.acc .dot{background:var(--acc);box-shadow:0 0 8px var(--acc)}
|
||||
.sw.amber .dot{background:var(--amber);box-shadow:0 0 8px var(--amber)}
|
||||
.sw.lime .dot{background:var(--lime);box-shadow:0 0 8px var(--lime)}
|
||||
.sw.err .dot{background:var(--err);box-shadow:0 0 8px var(--err)}
|
||||
.sw.plain .dot{background:var(--border)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<div class="stage">
|
||||
|
||||
<!-- ═══ 顶栏 ═══ -->
|
||||
<div class="topbar">
|
||||
<div class="brand-mark">H</div>
|
||||
<div class="brand-t">HERMES</div>
|
||||
<span class="tag acc">对话台</span>
|
||||
<div class="right">
|
||||
<span class="tag lime"><span class="led lime"></span>WebSocket 已连接</span>
|
||||
<span class="tag">v0.6.29</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 场景切换(仅原型) ═══ -->
|
||||
<div class="switcher">
|
||||
<span class="lb">场景</span>
|
||||
<button class="btn sm on" data-sc="idle">常态</button>
|
||||
<button class="btn sm" data-sc="streaming">流式输出</button>
|
||||
<button class="btn sm" data-sc="approval">审批卡</button>
|
||||
<button class="btn sm" data-sc="warn">上下文将满</button>
|
||||
<button class="btn sm" data-sc="tool">工具调用</button>
|
||||
<button class="btn sm" data-sc="error">系统错误</button>
|
||||
<div class="grow"></div>
|
||||
<span class="lb">右侧</span>
|
||||
<button class="btn sm" data-toggle="outline">Outline</button>
|
||||
<button class="btn sm" data-toggle="tool">Files / Terminal</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 配色说明 ═══ -->
|
||||
<div style="padding:12px 14px 0">
|
||||
<div class="sec-t" style="margin-top:0">三色语义 —— 每种颜色只承担一种含义</div>
|
||||
<div class="strip">
|
||||
<div class="sw acc"><span class="dot"></span><span class="nm">霓虹青</span><span class="hx">主色 · 系统本体 · 当前选中</span></div>
|
||||
<div class="sw amber"><span class="dot"></span><span class="nm">琥珀橙</span><span class="hx">需要你介入 · 审批 / 即将超限</span></div>
|
||||
<div class="sw lime"><span class="dot"></span><span class="nm">荧光绿</span><span class="hx">机器在动 · 工具成功 / 流式 / 健康</span></div>
|
||||
<div class="sw err"><span class="dot"></span><span class="nm">危险红</span><span class="hx">出错(不参与三色分工)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 对话台主体 ═══ -->
|
||||
<div style="flex:1;min-height:0;padding:12px 14px 14px" id="host"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── helpers ─────────────────────────────────────────────────────────────── */
|
||||
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const tag = (cls, t) => `<span class="tag ${cls}">${t}</span>`;
|
||||
|
||||
/* 极简 markdown:行内 code / 粗体 / 代码块。真实实现走 MarkdownRenderer.vue,
|
||||
这里只为了让原型里的排版和接入后一致,不引入依赖。 */
|
||||
function md(src){
|
||||
const blocks = [];
|
||||
let s = String(src ?? '');
|
||||
// 先摘出代码块,避免块内被行内规则改坏
|
||||
s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
blocks.push(`<pre class="md-code">${esc(code.replace(/\n$/, ''))}</pre>`);
|
||||
return `\u0000B${blocks.length - 1}\u0000`;
|
||||
});
|
||||
s = esc(s)
|
||||
.replace(/`([^`]+)`/g, '<code class="md-code-in">$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong class="md-strong">$1</strong>');
|
||||
s = s.replace(/\u0000B(\d+)\u0000/g, (_, i) => blocks[+i]);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* ── 场景数据:role 全集 user/assistant/tool/command/system/moa ─────────── */
|
||||
const SCENES = {
|
||||
idle: [
|
||||
{ role:'user', ts:1710409200, content:'帮我看一下 kanban 那个列宽问题。' },
|
||||
{ role:'assistant', ts:1710409215,
|
||||
content:'看过了。`.col` 的宽度写死在 `grid-template-columns` 里,窄视口下没有降级规则。\n\n我准备把它改成 `minmax(140px, 1fr)`,这样 3 列以上会自动压缩。',
|
||||
reasoning:'用户问的是列宽。先确认断点行为,再看是不是 overflow 的问题。已读 KanbanColumn.vue,确认没有媒体查询。' },
|
||||
{ role:'tool', ts:1710409230, toolName:'read_file', result:'ok · 214 lines',
|
||||
content:'packages/client/src/components/hermes/kanban/KanbanColumn.vue' },
|
||||
{ role:'assistant', ts:1710409245,
|
||||
content:'确认了。改动很小,只动一行。要我直接改吗?' },
|
||||
],
|
||||
streaming: [
|
||||
{ role:'user', ts:1710409200, content:'梳理一下 context-compressor 的压缩策略。' },
|
||||
{ role:'assistant', ts:1710409210, streaming:true,
|
||||
content:'压缩策略分三层:\n\n1. **触发** — `triggerRatio` 默认 0.85,token 用量超过窗口的 85% 触发\n2. **保留** — `keepRecent` 最近 N 轮原样保留,不参与压缩\n3. **摘要** — 中间历史交给 `targetTokens` 指定的目标模型生成摘要',
|
||||
reasoning:'先列三层结构,再逐层展开。注意 keepRecent 和 targetTokens 的边界条件。' },
|
||||
],
|
||||
approval: [
|
||||
{ role:'user', ts:1710409200, content:'把列宽改成 minmax(140px, 1fr),然后跑一遍测试。' },
|
||||
{ role:'tool', ts:1710409215, toolName:'edit_file', result:'pending',
|
||||
content:'KanbanColumn.vue → 覆盖 1 行' },
|
||||
{ role:'assistant', ts:1710409218,
|
||||
content:'我准备改这一行,但需要你确认:\n\n```diff\n- grid-template-columns: repeat(3, 180px);\n+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));\n```' },
|
||||
{ approval:true },
|
||||
],
|
||||
warn: [
|
||||
{ role:'user', ts:1710409200, content:'继续把剩下的页面都过一遍。' },
|
||||
{ role:'assistant', ts:1710409210,
|
||||
content:'已经过了 9 个页面。还有 16 个要处理。' },
|
||||
],
|
||||
tool: [
|
||||
{ role:'user', ts:1710409200, content:'跑一下 harness:check。' },
|
||||
{ role:'tool', ts:1710409212, toolName:'run_command', result:'exit 0 · 1.4s',
|
||||
content:'$ npm run harness:check\n▸ checking package boundaries…\n ✓ packages/client does not import @/server\n ✓ packages/server does not import @/client\n▸ checking red lines…\n ✓ no state.db open from node\n▸ done in 1.4s' },
|
||||
{ role:'assistant', ts:1710409218, content:'全绿,没有违规。' },
|
||||
],
|
||||
error: [
|
||||
{ role:'user', ts:1710409200, content:'连一下远端 profile。' },
|
||||
{ role:'system', ts:1710409205, systemType:'error', content:'WebSocket 连接被拒绝:token 校验失败(401)。请重新登录。' },
|
||||
{ role:'system', ts:1710409210, systemType:'command', content:'/profile switch default' },
|
||||
],
|
||||
};
|
||||
|
||||
/* ── 渲染:会话列表 ───────────────────────────────────────────────────────── */
|
||||
const SESSIONS = {
|
||||
pinned: [
|
||||
{ t:'kanban 列宽根因分析', src:'api_server', n:24, at:'14:32' },
|
||||
{ t:'context-compressor 策略梳理', src:'cron', n:61, at:'13:08' },
|
||||
],
|
||||
today: [
|
||||
{ t:'CYBER 主题 · 设置页骨架', src:'api_server', n:18, at:'15:20' },
|
||||
{ t:'Monaco 加载亮色主题的 bug', src:'terminal', n:9, at:'11:47' },
|
||||
{ t:'group-chat WS 事件流排查', src:'group_chat', n:33, at:'10:15' },
|
||||
],
|
||||
earlier: [
|
||||
{ t:'Vite proxy 配置回归', src:'api_server', n:12, at:'昨天' },
|
||||
{ t:'记忆页三卡片布局', src:'api_server', n:7, at:'昨天' },
|
||||
{ t:'登录页 autofill 样式接管', src:'api_server', n:15, at:'周三' },
|
||||
],
|
||||
};
|
||||
const SRC_TAG = { api_server:'api_server', cron:'cron', channel:'channel', group_chat:'group_chat',
|
||||
terminal:'terminal', file:'file', workflow:'workflow', kanban:'kanban' };
|
||||
/* source 标签配色:跟着语义走,不乱用 */
|
||||
const SRC_CLS = { api_server:'', cron:'acc', channel:'amber', group_chat:'', terminal:'lime', file:'', workflow:'', kanban:'' };
|
||||
|
||||
function sessionList(){
|
||||
const grp = (title, items, pinned) =>
|
||||
`<div class="slist-group-t">${title}</div>` + items.map((s,i)=>
|
||||
`<a class="s-item ${i===0&&!pinned?'':''} ${pinned?'pin':''}">
|
||||
<span class="grow"><span class="t">${esc(s.t)}</span>
|
||||
<span class="m">${tag(SRC_CLS[s.src], s.src)}<span>${s.n} 条</span><span>${s.at}</span></span>
|
||||
</span></a>`).join('');
|
||||
return `<div style="display:flex;flex-direction:column;min-height:0;border-right:1px solid var(--border);background:var(--surface)">
|
||||
<div class="slist-h">
|
||||
<span class="fnt" style="font-size:11.5px;color:var(--muted)">会话</span>
|
||||
<div class="right">
|
||||
<button class="icon-btn" title="搜索">⌕</button>
|
||||
<button class="icon-btn on" title="新建会话">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slist-body">
|
||||
${grp('置顶', SESSIONS.pinned, true)}
|
||||
${grp('今天', SESSIONS.today, false)}
|
||||
${grp('更早', SESSIONS.earlier, false)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── 渲染:气泡 ───────────────────────────────────────────────────────────── */
|
||||
const AV = { user:'我', assistant:'AI', tool:'⚙', system:'⚑', command:'›', moa:'◇' };
|
||||
|
||||
function bubble(m, i){
|
||||
if (m.role === 'system' && m.systemType === 'fork-divider')
|
||||
return `<div class="fork-divider">${esc(m.content)}</div>`;
|
||||
|
||||
const isTool = !!m.toolName;
|
||||
const inner =
|
||||
(m.reasoning ? `<div class="reasoning"><div class="rt">▸ 推理过程(可折叠)</div><div class="mt4">${esc(m.reasoning)}</div></div>` : '') +
|
||||
// 工具输出走等宽 .term,不做 markdown;对话正文走 markdown。
|
||||
(isTool ? `<div class="tool-call"><div class="th"><span class="led lime"></span>
|
||||
<span>${esc(m.toolName)}</span><span class="rt">${esc(m.result || 'ok')}</span></div>
|
||||
<div class="term">${esc(m.content)}</div></div>` : md(m.content)) +
|
||||
(m.streaming ? `<span class="caret"></span>` : '');
|
||||
|
||||
const meta = m.role === 'tool' ? '' :
|
||||
`<div class="meta"><span>${esc(m.role)}</span>
|
||||
${m.systemType ? tag(m.systemType === 'error' ? 'err' : 'acc', esc(m.systemType)) : ''}
|
||||
<span>${String(m.ts).slice(-6, -4)}:${String(m.ts).slice(-2)}</span></div>`;
|
||||
|
||||
return `<div class="bubble-row ${esc(m.role)} ${m.systemType === 'error' ? 'err' : ''}">
|
||||
<div class="bubble-av">${AV[m.role] ?? '·'}</div>
|
||||
<div class="bubble">${meta}${inner}</div></div>`;
|
||||
}
|
||||
|
||||
/* 审批卡:橙色专属区 */
|
||||
const approval = () => `<div class="appr">
|
||||
<div class="q"><span class="led amber"></span>需要审批 · 该工具将写入工作区文件</div>
|
||||
<div class="term mt8" style="max-height:110px;border:1px solid var(--amber-line)">edit_file("packages/client/src/components/hermes/kanban/KanbanColumn.vue")
|
||||
→ 覆盖 1 行:grid-template-columns</div>
|
||||
<div class="acts mt8">
|
||||
<button class="btn amber sm">仅本次</button>
|
||||
<button class="btn ghost sm">本会话允许</button>
|
||||
<button class="btn ghost sm">始终允许</button>
|
||||
<button class="btn danger sm">拒绝</button>
|
||||
</div>
|
||||
<div class="xs fnt mt8" style="color:var(--amber)">PendingApproval.choices = once | session | always | deny</div>
|
||||
</div>`;
|
||||
|
||||
/* ── 渲染:输入区 ─────────────────────────────────────────────────────────── */
|
||||
function composer(ctxPct, cls){
|
||||
const meterCls = ctxPct >= 90 ? 'crit' : ctxPct >= 70 ? 'warn' : '';
|
||||
return `<div class="ctx-row">
|
||||
<span>tokens ${ctxPct === 20.6 ? '41,208' : '180,540'} / 200,000</span>
|
||||
<span class="meter ${meterCls}"><i style="width:${Math.min(ctxPct,100)}%"></i></span>
|
||||
<span style="color:var(--${ctxPct>=90?'err':ctxPct>=70?'amber':'lime'})">${ctxPct}%</span>
|
||||
</div>
|
||||
<div class="chat-input"><div class="box">
|
||||
<textarea class="ta" rows="2" placeholder="输入消息,Enter 发送 · Shift+Enter 换行 · @ 提及文件"></textarea>
|
||||
<div class="bar">
|
||||
<button class="icon-btn" title="附件">📎</button>
|
||||
<button class="btn sm ghost">effort: medium ▾</button>
|
||||
<button class="icon-btn" title="输入设置">⚙</button>
|
||||
<button class="btn sm ghost">claude-sonnet-4-5 ▾</button>
|
||||
<span class="right">
|
||||
<button class="icon-btn" title="语音">🎙</button>
|
||||
<button class="send-btn" title="发送">↑</button>
|
||||
</span>
|
||||
</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
/* ── 渲染:右侧面板 ───────────────────────────────────────────────────────── */
|
||||
const outlinePanel = () => `<aside class="outline">
|
||||
<div class="sec-t">大纲 Outline</div>
|
||||
${['定位问题','列宽根因','最小改动','截断处理','验证'].map((t,i)=>
|
||||
`<div class="oi ${i===0?'on':''}">${i+1}. ${t}</div>`).join('')}
|
||||
<div class="sec-t">附件</div>
|
||||
<div class="row-i" style="margin-bottom:6px"><span class="mono xs">▤</span><span class="ttl ell">KanbanColumn.vue</span></div>
|
||||
<div class="row-i"><span class="mono xs">▤</span><span class="ttl ell">sketch-01.png</span></div>
|
||||
</aside>`;
|
||||
|
||||
/* 工具面板:Files / Terminal 是同一个面板里的两个 tab,不是两个互斥抽屉。
|
||||
对应 ChatPanel.vue 的 showToolPanel + activeToolPanel + FilesPanel/TerminalPanel。 */
|
||||
const TOOL_FILES = ['src/components/hermes/kanban/KanbanColumn.vue','src/components/hermes/kanban/KanbanView.vue','src/views/hermes/ChatView.vue'];
|
||||
const TOOL_TERM = `HERMES terminal · WebSocket 已连接 · token 走 query
|
||||
<span class="acc">$</span> npm run harness:check
|
||||
|
||||
<span style="color:var(--lime)"> ✓</span> checking package boundaries…
|
||||
<span style="color:var(--lime)"> ✓</span> no state.db open from node
|
||||
<span style="color:var(--amber)"> !</span> scripts/harness-check.mjs: 3 whitelisted addresses
|
||||
<span style="color:var(--lime)">▸</span> done in 1.4s
|
||||
|
||||
<span class="acc">$</span> <span class="caret"></span>`;
|
||||
|
||||
const toolPanel = tab => `<div class="tool-p">
|
||||
<div class="tool-tabs">
|
||||
<button class="tool-tab ${tab==='files'?'on':''}" data-tab="files">Files</button>
|
||||
<button class="tool-tab ${tab==='terminal'?'on':''}" data-tab="terminal">Terminal</button>
|
||||
</div>
|
||||
<div class="slist-body">
|
||||
${tab === 'files'
|
||||
? TOOL_FILES.map(f=>`<div class="row-i" style="margin-bottom:6px"><span class="mono xs">▤</span><span class="ttl ell">${f.split('/').pop()}</span></div>`).join('')
|
||||
: `<div class="term" style="border:0;background:var(--code-bg)">${TOOL_TERM}</div>`}
|
||||
</div></div>`;
|
||||
|
||||
/* ── 主渲染 ───────────────────────────────────────────────────────────────── */
|
||||
const CTX = { idle:20.6, streaming:34, approval:20.6, warn:86, tool:20.6, error:20.6 };
|
||||
let scene = 'idle', panel = 'outline', toolTab = 'files';
|
||||
|
||||
function render(){
|
||||
const msgs = SCENES[scene];
|
||||
const main = `<div class="chat-main">
|
||||
<div class="chat-header">
|
||||
<button class="icon-btn">⇤</button>
|
||||
<div class="grow">
|
||||
<div class="ttl">kanban 列宽根因分析</div>
|
||||
<div class="xs fnt" style="color:var(--faint);margin-top:2px">01JQ8F2K · default profile</div>
|
||||
</div>
|
||||
${tag('lime', '<span class="led lime"></span>streaming')}
|
||||
${tag('', '~/work/hermes-ui')}
|
||||
<div class="right">
|
||||
<button class="icon-btn ${panel==='tool'?'on':''}" data-toggle="tool" title="Files / Terminal">▤</button>
|
||||
<button class="icon-btn ${panel==='outline'?'on':''}" data-toggle="outline" title="Outline">☰</button>
|
||||
<button class="icon-btn" title="复制 sessionId">⧉</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-body"><div class="chat-body-inner">
|
||||
${msgs.map(bubble).join('')}
|
||||
${msgs.some(m => m.approval) ? approval() : ''}
|
||||
</div></div>
|
||||
${composer(CTX[scene])}
|
||||
</div>`;
|
||||
|
||||
document.getElementById('host').innerHTML =
|
||||
`<div class="psl ${panel === 'outline' ? 'with-rail' : ''}">${sessionList()}${main}${panel === 'outline' ? outlinePanel() : ''}</div>` +
|
||||
(panel === 'tool' ? toolPanel(toolTab) : '');
|
||||
}
|
||||
|
||||
/* ── 事件 ────────────────────────────────────────────────────────────────── */
|
||||
document.addEventListener('click', e => {
|
||||
const sc = e.target.closest('[data-sc]');
|
||||
if (sc) {
|
||||
scene = sc.dataset.sc;
|
||||
document.querySelectorAll('[data-sc]').forEach(b => b.classList.toggle('on', b === sc));
|
||||
return render();
|
||||
}
|
||||
const tb = e.target.closest('[data-tab]');
|
||||
if (tb) { toolTab = tb.dataset.tab; return render(); }
|
||||
const tg = e.target.closest('[data-toggle]');
|
||||
if (tg) { panel = panel === tg.dataset.toggle ? null : tg.dataset.toggle; return render(); }
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,480 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hermes Studio · 登录 · CYBER 原型</title>
|
||||
<!--
|
||||
============================================================================
|
||||
Hermes Studio 登录页 · CYBER 霓虹暗色原型(单文件,不接入)
|
||||
----------------------------------------------------------------------------
|
||||
纯静态。仅放在 prototype_reference/,不修改 packages/client 与 packages/server。
|
||||
|
||||
字段与行为对齐真实实现:
|
||||
- 视图 packages/client/src/views/LoginView.vue
|
||||
- 接口 packages/client/src/api/auth.ts
|
||||
- 文案 packages/client/src/i18n/locales/{zh,en}.ts login.*
|
||||
- 装饰位面 packages/client/src/vendor/arkscore/IsometricWave(ArkScope)
|
||||
- 标题扰动 packages/client/src/vendor/arkscore/ScrambleTitle
|
||||
|
||||
真实登录流程(不要改):
|
||||
onMounted → fetchAuthStatus() GET /api/auth/status
|
||||
提交 → loginWithPassword() POST /api/auth/login { username, password }
|
||||
→ 200 返回 { token } → setApiKey(token) → router.replace('/hermes/chat')
|
||||
已有 token → 组件 setup 阶段直接 router.replace('/hermes/chat')
|
||||
|
||||
设计语言与 hermes-ui-cyber-api.html 一致(同一套 OKLCH 令牌)。
|
||||
令牌映射(接入时按此替换为 variables.scss):
|
||||
--bg → --bg-primary --surface → --bg-secondary --input-bg → --bg-input
|
||||
--fg → --text-primary --muted → --text-secondary --border → --border-color
|
||||
--radius 6 / --radius-lg 8 → 同名(与 ui-baseline.md 一致)
|
||||
============================================================================
|
||||
-->
|
||||
<style>
|
||||
/* ── 1. TOKENS ───────────────────────────────────────────────────────────── */
|
||||
:root{
|
||||
--bg: oklch(0.155 0.012 250);
|
||||
--surface: oklch(0.130 0.012 250);
|
||||
--surface-2: oklch(0.185 0.014 248);
|
||||
--surface-3: oklch(0.225 0.016 246);
|
||||
--input-bg: oklch(0.105 0.012 252);
|
||||
--code-bg: oklch(0.115 0.014 250);
|
||||
--fg: oklch(0.945 0.006 200);
|
||||
--fg-soft: oklch(0.860 0.008 210);
|
||||
--muted: oklch(0.705 0.014 230);
|
||||
--faint: oklch(0.520 0.014 236);
|
||||
--border: oklch(0.305 0.018 242);
|
||||
--hairline: oklch(0.255 0.016 244);
|
||||
--accent: oklch(0.805 0.148 188);
|
||||
--accent-hv: oklch(0.870 0.150 188);
|
||||
--accent-ink:oklch(0.160 0.030 200);
|
||||
--accent-soft:oklch(0.805 0.148 188 / 0.13);
|
||||
--ok: oklch(0.760 0.140 165);
|
||||
--warn: oklch(0.810 0.145 85);
|
||||
--err: oklch(0.665 0.185 22);
|
||||
--info: oklch(0.700 0.110 245);
|
||||
|
||||
--font-display: ui-monospace,'JetBrains Mono','SF Mono','Menlo','Cascadia Code',monospace;
|
||||
--font-body: -apple-system,BlinkMacSystemFont,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Segoe UI',Roboto,sans-serif;
|
||||
|
||||
--radius: 6px; --radius-lg: 8px;
|
||||
--shadow: 0 24px 64px oklch(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
/* ── 2. RESET / BASE ─────────────────────────────────────────────────────── */
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--fg);
|
||||
font:14px/1.55 var(--font-body);
|
||||
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
||||
}
|
||||
::selection{background:var(--accent-soft); color:var(--fg)}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.42}}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
@keyframes scan{0%{transform:translateY(-100%)}100%{transform:translateY(1000%)}}
|
||||
@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}
|
||||
|
||||
/* ── 3. 装饰位面(ArkScope 等价物,纯装饰,pointer-events:none) ─────────── */
|
||||
.deco{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
|
||||
.deco::before{content:'';position:absolute;inset:-40%;
|
||||
background:repeating-linear-gradient(0deg,oklch(0.805 0.148 188/.055) 0 1px,transparent 1px 32px),
|
||||
repeating-linear-gradient(90deg,oklch(0.805 0.148 188/.055) 0 1px,transparent 1px 32px);
|
||||
mask-image:radial-gradient(62% 52% at 50% 34%,#000,transparent 78%)}
|
||||
.deco::after{content:'';position:absolute;inset:0;
|
||||
background-image:radial-gradient(oklch(0.805 0.148 188/.13) 1px,transparent 1px);
|
||||
background-size:22px 22px;opacity:.5}
|
||||
/* 扫描线 */
|
||||
.scanline{position:absolute;left:0;right:0;height:64px;top:0;
|
||||
background:linear-gradient(180deg,transparent,oklch(0.805 0.148 188/.07) 45%,oklch(0.805 0.148 188/.14) 50%,oklch(0.805 0.148 188/.07) 55%,transparent);
|
||||
animation:scan 7.5s linear infinite}
|
||||
/* 等距波形(对齐 IsometricWave) */
|
||||
.wave{position:absolute;left:50%;bottom:-56px;transform:translateX(-50%);
|
||||
width:min(100%,780px);opacity:.42;pointer-events:none;z-index:0}
|
||||
.wave svg{width:100%;height:auto;display:block}
|
||||
|
||||
/* ── 4. 布局 ─────────────────────────────────────────────────────────────── */
|
||||
.btn{display:inline-flex;align-items:center;height:30px;padding:0 12px;
|
||||
border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-2);
|
||||
color:var(--fg-soft);font-size:12.5px;cursor:pointer;transition:background .13s,border-color .13s,color .13s}
|
||||
.btn:hover:not(:disabled){background:var(--surface-3);border-color:oklch(0.805 0.148 188/.34);color:var(--fg)}
|
||||
.btn:disabled{cursor:default;opacity:.7}
|
||||
.btn.pri{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}
|
||||
.btn.pri:hover:not(:disabled){background:var(--accent-hv);border-color:var(--accent-hv);color:var(--accent-ink)}
|
||||
.btn.ghost{background:transparent}
|
||||
.view{position:relative;min-height:100vh;display:grid;place-items:center;padding:32px 20px;overflow:hidden}
|
||||
.card{position:relative;z-index:2;width:100%;max-width:424px;
|
||||
border:1px solid var(--border);border-radius:var(--radius-lg);
|
||||
background:var(--surface);box-shadow:var(--shadow);
|
||||
animation:rise .5s cubic-bezier(.22,.9,.3,1) both}
|
||||
/* 顶部霓虹发丝线 */
|
||||
.card::before{content:'';position:absolute;left:16px;right:16px;top:0;height:1px;
|
||||
background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:.6}
|
||||
.card-hd{padding:30px 30px 0;text-align:center}
|
||||
.badge{width:62px;height:62px;margin:0 auto 16px;border:1px solid var(--accent);border-radius:var(--radius-lg);
|
||||
display:grid;place-items:center;color:var(--accent);font:700 24px/1 var(--font-display);
|
||||
box-shadow:0 0 0 1px oklch(0.805 0.148 188/.2),0 0 30px oklch(0.805 0.148 188/.18) inset}
|
||||
h1{margin:0;font:600 19px/1.25 var(--font-display);letter-spacing:.08em}
|
||||
.sub{margin:9px 0 0;font-size:12.5px;color:var(--muted);line-height:1.6}
|
||||
.hint{margin:14px 0 0;display:inline-flex;align-items:center;gap:8px;
|
||||
padding:5px 10px;border:1px dashed var(--hairline);border-radius:var(--radius);
|
||||
background:var(--surface-2);font:11.5px/1.5 var(--font-display);color:var(--muted)}
|
||||
.hint b{color:var(--accent);font-weight:600}
|
||||
.card-bd{padding:22px 30px 26px}
|
||||
.form{display:flex;flex-direction:column;gap:14px}
|
||||
.field{display:flex;flex-direction:column;gap:6px}
|
||||
.field>label{font-size:11.5px;color:var(--muted);letter-spacing:.04em}
|
||||
/* 输入框:左侧 icon + 右侧状态灯 */
|
||||
.inp-wrap{position:relative;display:flex;align-items:center}
|
||||
.inp-ic{position:absolute;left:11px;font:13px/1 var(--font-display);color:var(--faint);pointer-events:none}
|
||||
.inp{
|
||||
width:100%;height:40px;padding:0 34px 0 32px;border-radius:var(--radius);
|
||||
border:1px solid var(--border);background:var(--input-bg);color:var(--fg);
|
||||
font:14px var(--font-display);outline:none;
|
||||
transition:border-color .15s ease,box-shadow .15s ease;
|
||||
}
|
||||
.inp::placeholder{color:var(--faint);font-size:13px}
|
||||
.inp:focus{border-color:oklch(0.805 0.148 188/.6);box-shadow:0 0 0 3px oklch(0.805 0.148 188/.11)}
|
||||
.inp.err{border-color:var(--err)}
|
||||
.inp.err:focus{box-shadow:0 0 0 3px oklch(0.665 0.185 22/.14)}
|
||||
.pw-toggle{position:absolute;right:9px;width:22px;height:22px;border:0;background:transparent;
|
||||
color:var(--faint);cursor:pointer;font:12px/1 var(--font-display);border-radius:4px;padding:0}
|
||||
.pw-toggle:hover{color:var(--accent);background:var(--surface-2)}
|
||||
.led{width:7px;height:7px;border-radius:50%;position:absolute;right:12px;
|
||||
background:var(--faint);box-shadow:0 0 6px currentColor}
|
||||
.led.ok{background:var(--ok);color:var(--ok)}
|
||||
.led.err{background:var(--err);color:var(--err)}
|
||||
/* 密码框的 eye 按钮要和 led 分开位置 */
|
||||
.inp-wrap.has-led .pw-toggle{right:26px}
|
||||
|
||||
.submit{height:40px;justify-content:center;gap:8px;font-size:13.5px;letter-spacing:.06em;
|
||||
position:relative;overflow:hidden}
|
||||
.submit[data-busy="1"]{pointer-events:none;color:var(--accent-ink)}
|
||||
.spin{width:12px;height:12px;border:2px solid currentColor;border-right-color:transparent;
|
||||
border-radius:50%;animation:spin .7s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
/* 按钮扫光 */
|
||||
.submit::after{content:'';position:absolute;top:0;bottom:0;width:38%;
|
||||
background:linear-gradient(90deg,transparent,oklch(1 0 0/.26),transparent);
|
||||
transform:translateX(-140%);animation:sheen 3.4s ease-in-out infinite}
|
||||
@keyframes sheen{0%,62%{transform:translateX(-140%)}86%,100%{transform:translateX(320%)}}
|
||||
|
||||
/* 消息条 */
|
||||
.msg{display:none;align-items:flex-start;gap:9px;padding:10px 12px;border-radius:var(--radius);
|
||||
font-size:12px;line-height:1.55;text-align:left}
|
||||
.msg.on{display:flex;animation:rise .22s ease both}
|
||||
.msg-ic{flex:0 0 auto;font:13px/1.35 var(--font-display)}
|
||||
.msg.err{border:1px solid oklch(0.665 0.185 22/.42);background:oklch(0.665 0.185 22/.09);color:var(--err)}
|
||||
.msg.warn{border:1px solid oklch(0.810 0.145 85/.4);background:oklch(0.810 0.145 85/.08);color:var(--warn)}
|
||||
.msg code{display:block;margin-top:5px;font:11.5px/1.5 var(--font-display);
|
||||
color:var(--fg);background:var(--code-bg);border:1px solid var(--hairline);
|
||||
border-radius:4px;padding:3px 6px;word-break:break-all;user-select:all}
|
||||
|
||||
/* 页脚 */
|
||||
.card-ft{padding:0 30px 24px;text-align:center}
|
||||
.sep{display:flex;align-items:center;gap:11px;margin:0 0 14px;color:var(--faint);font-size:11px}
|
||||
.sep::before,.sep::after{content:'';flex:1;height:1px;background:var(--hairline)}
|
||||
.chips{display:flex;flex-wrap:wrap;justify-content:center;gap:7px;margin-bottom:14px}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:20px;
|
||||
border:1px solid var(--hairline);background:var(--surface-2);
|
||||
font:11px/1.4 var(--font-display);color:var(--muted)}
|
||||
.chip .dot{width:5px;height:5px;border-radius:50%;background:var(--ok);box-shadow:0 0 5px var(--ok)}
|
||||
.chip .dot.warn{background:var(--warn);box-shadow:0 0 5px var(--warn)}
|
||||
.copy{height:34px;justify-content:center;gap:7px;width:100%;font-size:12.5px}
|
||||
.legal{font-size:10.5px;color:var(--faint);text-align:center;line-height:1.7}
|
||||
.legal b{color:var(--muted);font-weight:400}
|
||||
|
||||
/* ── 5. API 契约面板 ─────────────────────────────────────────────────────── */
|
||||
.api{margin-top:16px;border:1px solid var(--hairline);border-radius:var(--radius);
|
||||
background:var(--surface-2);overflow:hidden}
|
||||
.api>summary{padding:9px 12px;cursor:pointer;list-style:none;font-size:11.5px;
|
||||
color:var(--muted);display:flex;align-items:center;gap:7px;user-select:none}
|
||||
.api>summary::-webkit-details-marker{display:none}
|
||||
.api>summary::before{content:'▸';color:var(--accent);font-size:10px;transition:transform .15s}
|
||||
.api[open]>summary::before{transform:rotate(90deg)}
|
||||
.api-b{padding:0 12px 12px;display:flex;flex-direction:column;gap:11px}
|
||||
.ep{border:1px solid var(--hairline);border-radius:var(--radius);overflow:hidden;background:var(--code-bg)}
|
||||
.ep-h{display:flex;align-items:center;gap:8px;padding:7px 10px;flex-wrap:wrap}
|
||||
.verb{font:700 9.5px/1 var(--font-display);letter-spacing:.06em;padding:3px 5px;border-radius:3px}
|
||||
.verb.get{background:oklch(0.700 0.110 245/.17);color:var(--info,#8ab4f8);border:1px solid oklch(0.700 0.110 245/.36)}
|
||||
.verb.post{background:var(--accent-soft);color:var(--accent);border:1px solid oklch(0.805 0.148 188/.4)}
|
||||
.ep-p{font:12px/1.4 var(--font-display);color:var(--fg)}
|
||||
.ep-env{font:10.5px/1.4 var(--font-display);color:var(--muted);margin-left:auto}
|
||||
.flds{border-top:1px solid var(--hairline);padding:8px 10px;display:flex;flex-direction:column;gap:5px}
|
||||
.fld{font:11.5px/1.55 var(--font-display);color:var(--muted);display:flex;gap:6px;flex-wrap:wrap}
|
||||
.fk{color:var(--accent)}
|
||||
.fv{color:var(--fg-soft)}
|
||||
.ft{color:var(--faint);font-size:10.5px}
|
||||
.note{font:11.5px/1.65 var(--font-body);color:var(--muted);
|
||||
padding:9px 11px;border-left:2px solid var(--accent);background:var(--accent-soft);border-radius:0 4px 4px 0}
|
||||
.note b{color:var(--fg-soft)}
|
||||
.code{font-family:var(--font-display);font-size:11px;background:var(--code-bg);
|
||||
border:1px solid var(--hairline);border-radius:4px;padding:1px 5px;color:var(--accent)}
|
||||
|
||||
/* 状态切换器(仅原型用,方便看不同分支) */
|
||||
.devbar{position:fixed;top:12px;right:12px;z-index:60;display:flex;gap:5px;
|
||||
padding:4px;border:1px solid var(--hairline);border-radius:var(--radius);
|
||||
background:oklch(0.13 0.012 250/.9);backdrop-filter:blur(6px)}
|
||||
.devbar button{height:24px;padding:0 9px;border:1px solid var(--hairline);border-radius:4px;
|
||||
background:transparent;color:var(--muted);font:10.5px/1 var(--font-display);cursor:pointer}
|
||||
.devbar button.on{background:var(--accent-soft);color:var(--accent);border-color:oklch(0.805 0.148 188/.42)}
|
||||
|
||||
@media (max-width:520px){
|
||||
.card-hd{padding:26px 22px 0}.card-bd{padding:20px 22px 22px}.card-ft{padding:0 22px 20px}
|
||||
.devbar{position:static;margin-bottom:14px;justify-content:center;flex-wrap:wrap}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ══ 原型专用:状态切换器(接入时删除) ══ -->
|
||||
<div class="devbar" id="devbar">
|
||||
<button data-scene="idle" class="on">默认</button>
|
||||
<button data-scene="error">401</button>
|
||||
<button data-scene="empty">未填</button>
|
||||
<button data-scene="locked">429 锁定</button>
|
||||
<button data-scene="busy">提交中</button>
|
||||
<button data-scene="ok">成功</button>
|
||||
</div>
|
||||
|
||||
<div class="view">
|
||||
<!-- ══ 装饰位面:只承载 ArkScope 动画,一律 pointer-events:none(基线硬规则) ══ -->
|
||||
<div class="deco" aria-hidden="true"><div class="scanline"></div></div>
|
||||
<div class="wave" aria-hidden="true">
|
||||
<svg viewBox="0 0 780 260" fill="none" stroke="oklch(0.805 0.148 188)" stroke-width="1">
|
||||
<g opacity=".55">
|
||||
<path d="M0 200 L65 168 L130 200 L195 168 L260 200 L325 168 L390 200 L455 168 L520 200 L585 168 L650 200 L715 168 L780 200"/>
|
||||
<path d="M0 232 L65 200 L130 232 L195 200 L260 232 L325 200 L390 232 L455 200 L520 232 L585 200 L650 232 L715 200 L780 232" opacity=".55"/>
|
||||
<path d="M32.5 184 L97.5 152 L162.5 184 L227.5 152 L292.5 184 L357.5 152 L422.5 184 L487.5 152 L552.5 184 L617.5 152 L682.5 184 L747.5 152" opacity=".32"/>
|
||||
</g>
|
||||
<g stroke="oklch(0.870 0.150 188)" stroke-width="1.4" opacity=".9">
|
||||
<path d="M195 168 L195 96 M227.5 152 L227.5 80 M455 168 L455 96 M487.5 152 L487.5 80"/>
|
||||
</g>
|
||||
<g fill="oklch(0.870 0.150 188)" opacity=".8">
|
||||
<rect x="190" y="90" width="10" height="10" rx="1.5"/>
|
||||
<rect x="222.5" y="74" width="10" height="10" rx="1.5"/>
|
||||
<rect x="450" y="90" width="10" height="10" rx="1.5"/>
|
||||
<rect x="482.5" y="74" width="10" height="10" rx="1.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ══ 卡片 ══ -->
|
||||
<main class="card">
|
||||
<header class="card-hd">
|
||||
<div class="badge" id="badge">H</div>
|
||||
<h1 id="title">HERMES STUDIO</h1>
|
||||
<p class="sub" id="desc">输入用户名和密码以继续。</p>
|
||||
<p class="hint">
|
||||
<span class="led" id="statusLed"></span>
|
||||
<span id="statusTxt">正在探测服务端…</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card-bd">
|
||||
<form class="form" id="form" autocomplete="off" novalidate>
|
||||
<div class="field">
|
||||
<label for="u">用户名</label>
|
||||
<div class="inp-wrap">
|
||||
<span class="inp-ic">◉</span>
|
||||
<input class="inp" id="u" type="text" placeholder="用户名" autocomplete="username" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="p">密码</label>
|
||||
<div class="inp-wrap">
|
||||
<span class="inp-ic">⌸</span>
|
||||
<input class="inp" id="p" type="password" placeholder="密码" autocomplete="current-password" />
|
||||
<button class="pw-toggle" type="button" id="pw" title="显示 / 隐藏">◉</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg" id="msg"><span class="msg-ic">▲</span><span id="msgBody"></span></div>
|
||||
|
||||
<button class="btn submit" id="submit" type="submit">
|
||||
<span class="spin" id="spin" hidden></span><span id="submitTxt">登录</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer class="card-ft">
|
||||
<div class="sep">默认凭据</div>
|
||||
<div class="chips">
|
||||
<span class="chip"><span class="dot"></span>用户名 <b>admin</b></span>
|
||||
<span class="chip"><span class="dot"></span>密码 <b>123456</b></span>
|
||||
</div>
|
||||
<button class="btn ghost copy" id="copy" type="button">⧉ 复制 admin / 123456</button>
|
||||
<p class="legal" style="margin:14px 0 0">
|
||||
本地部署 · 无遥测 · 数据不出机器<br>
|
||||
登录后 <b>POST /api/auth/login</b> 返回的 token 存入 <b>localStorage</b>
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ══ API 契约面板 ══ -->
|
||||
<div style="position:fixed;left:50%;transform:translateX(-50%);bottom:12px;width:min(94vw,424px);z-index:50">
|
||||
<details class="api" open>
|
||||
<summary>本页数据来源 · API 契约</summary>
|
||||
<div class="api-b">
|
||||
<div class="ep">
|
||||
<div class="ep-h">
|
||||
<span class="verb get">GET</span>
|
||||
<span class="ep-p">/api/auth/status</span>
|
||||
<span class="ep-env">裸对象</span>
|
||||
</div>
|
||||
<div class="flds">
|
||||
<div class="fld"><span class="fk">hasPasswordLogin</span><span class="fv">boolean</span><span class="ft">是否启用密码登录</span></div>
|
||||
<div class="fld"><span class="fk">hasUsers</span><span class="fv">boolean?</span><span class="ft">是否存在托管用户</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ep">
|
||||
<div class="ep-h">
|
||||
<span class="verb post">POST</span>
|
||||
<span class="ep-p">/api/auth/login</span>
|
||||
<span class="ep-env">{ token }</span>
|
||||
</div>
|
||||
<div class="flds">
|
||||
<div class="fld"><span class="fk">username</span><span class="fv">string</span><span class="ft">请求体</span></div>
|
||||
<div class="fld"><span class="fk">password</span><span class="fv">string</span><span class="ft">请求体</span></div>
|
||||
<div class="fld"><span class="fk">token</span><span class="fv">string</span><span class="ft">成功响应唯一字段 → setApiKey()</span></div>
|
||||
<div class="fld"><span class="fk">error</span><span class="fv">string</span><span class="ft">失败响应;429 / 503 触发锁定提示</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">
|
||||
守卫顺序:<b>checkHermesCliAvailable()</b> → <b>meta.public</b> → <b>hasApiKey()</b> → <b>requiresSuperAdmin</b>。
|
||||
已持有 token 时组件 setup 阶段直接 <code>router.replace('/hermes/chat')</code>。
|
||||
失败码 <b>429 / 503</b> → 显示 <code>hermes-ui clear-login-locks --restart</code> 与
|
||||
<code>hermes-ui reset-default-login</code>;其余显示 <code>err.message</code>。
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const el = {
|
||||
u:$('#u'), p:$('#p'), form:$('#form'), msg:$('#msg'), msgBody:$('#msgBody'),
|
||||
submit:$('#submit'), submitTxt:$('#submitTxt'), spin:$('#spin'),
|
||||
led:$('#statusLed'), statusTxt:$('#statusTxt'), pw:$('#pw'), copy:$('#copy'),
|
||||
};
|
||||
|
||||
/* ── 服务端探测:onMounted → fetchAuthStatus() ───────────────────────────── */
|
||||
function probe(state){
|
||||
const map = {
|
||||
ok: ['ok', '认证服务在线 · 密码登录可用'],
|
||||
err: ['err', '无法连接到服务器 · 登录仍可尝试'],
|
||||
none: ['', '未启用密码登录'],
|
||||
};
|
||||
const [cls, txt] = map[state] || map.ok;
|
||||
el.led.className = 'led ' + cls;
|
||||
el.statusTxt.textContent = txt;
|
||||
}
|
||||
|
||||
/* ── 消息条 ─────────────────────────────────────────────────────────────── */
|
||||
function showMsg(kind, html){
|
||||
el.msg.className = 'msg on ' + (kind || 'err');
|
||||
el.msg.querySelector('.msg-ic').textContent = kind === 'warn' ? '⚠' : kind === 'ok' ? '✓' : '▲';
|
||||
el.msgBody.innerHTML = html;
|
||||
}
|
||||
function hideMsg(){ el.msg.className = 'msg'; }
|
||||
|
||||
/* ── 提交 ───────────────────────────────────────────────────────────────── */
|
||||
let busy = false;
|
||||
function setBusy(on, label){
|
||||
busy = on;
|
||||
el.spin.hidden = !on;
|
||||
el.submit.dataset.busy = on ? '1' : '0';
|
||||
el.submitTxt.textContent = label || (on ? '...' : '登录');
|
||||
el.submit.disabled = on;
|
||||
}
|
||||
|
||||
function submit(e){
|
||||
if(e) e.preventDefault();
|
||||
if(busy) return;
|
||||
const u = el.u.value.trim(), p = el.p.value;
|
||||
el.u.classList.remove('err'); el.p.classList.remove('err');
|
||||
|
||||
// 空值:t('login.credentialsRequired')
|
||||
if(!u || !p){
|
||||
(u ? el.p : el.u).classList.add('err');
|
||||
showMsg('err', '请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
hideMsg();
|
||||
setBusy(true);
|
||||
|
||||
// 原型:按当前场景模拟服务端响应,不发真实请求
|
||||
const scene = currentScene();
|
||||
setTimeout(()=>{
|
||||
setBusy(false);
|
||||
if(scene === 'ok'){
|
||||
showMsg('ok', '登录成功,正在跳转到 <code>/hermes/chat</code>');
|
||||
setTimeout(()=>location.hash = '#ok', 500);
|
||||
return;
|
||||
}
|
||||
if(scene === 'error'){
|
||||
el.p.classList.add('err');
|
||||
showMsg('err', '用户名或密码错误');
|
||||
return;
|
||||
}
|
||||
if(scene === 'locked'){
|
||||
// err.status === 429 || 503 → showLockResetHint
|
||||
showMsg('warn',
|
||||
'登录失败次数过多,请稍后重试' +
|
||||
'<code>hermes-ui clear-login-locks --restart</code>' +
|
||||
'如需重置默认 admin 密码,可以执行:' +
|
||||
'<code>hermes-ui reset-default-login</code>');
|
||||
return;
|
||||
}
|
||||
showMsg('err', '用户名或密码错误');
|
||||
}, 620);
|
||||
}
|
||||
|
||||
/* ── 交互绑定 ───────────────────────────────────────────────────────────── */
|
||||
el.form.addEventListener('submit', submit);
|
||||
el.p.addEventListener('keyup', e=>{ if(e.key === 'Enter') submit(); });
|
||||
el.u.addEventListener('input', ()=>{ el.u.classList.remove('err'); hideMsg(); });
|
||||
el.p.addEventListener('input', ()=>{ el.p.classList.remove('err'); hideMsg(); });
|
||||
|
||||
el.pw.addEventListener('click', ()=>{
|
||||
const show = el.p.type === 'password';
|
||||
el.p.type = show ? 'text' : 'password';
|
||||
el.pw.textContent = show ? '⊘' : '◉';
|
||||
});
|
||||
|
||||
el.copy.addEventListener('click', async ()=>{
|
||||
const text = 'admin / 123456';
|
||||
try{ await navigator.clipboard.writeText(text); }
|
||||
catch{ /* file:// 下 clipboard 不可用,退回选中提示 */ }
|
||||
el.u.value = 'admin'; el.p.value = '123456';
|
||||
showMsg('ok', '已填入默认凭据 <code>admin / 123456</code>');
|
||||
el.u.focus();
|
||||
});
|
||||
|
||||
/* ── 原型专用:场景切换(接入时整段删除) ───────────────────────────────── */
|
||||
let scene = 'idle';
|
||||
function currentScene(){ return scene === 'idle' || scene === 'empty' || scene === 'busy' ? 'error' : scene; }
|
||||
document.getElementById('devbar').addEventListener('click', e=>{
|
||||
const b = e.target.closest('button[data-scene]');
|
||||
if(!b) return;
|
||||
document.querySelectorAll('#devbar button').forEach(x=>x.classList.toggle('on', x===b));
|
||||
scene = b.dataset.scene;
|
||||
hideMsg();
|
||||
el.u.classList.remove('err'); el.p.classList.remove('err');
|
||||
setBusy(false);
|
||||
probe('ok');
|
||||
|
||||
if(scene === 'empty'){ el.u.value=''; el.p.value=''; el.u.focus(); }
|
||||
if(scene === 'error'|| scene === 'locked'){ el.u.value='admin'; el.p.value='wrong'; }
|
||||
if(scene === 'busy'){ el.u.value='admin'; el.p.value='123456'; setBusy(true,'...'); }
|
||||
if(scene === 'ok'){ el.u.value='admin'; el.p.value='123456'; }
|
||||
});
|
||||
|
||||
/* ── 启动 ───────────────────────────────────────────────────────────────── */
|
||||
el.u.focus();
|
||||
setTimeout(()=>probe('ok'), 340);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user