feat(panel-kit+skills): 注册湖面投影可复用UI模板 + 技能书包增补

冰朔判定"这个UI好看,注册成可复用模板":
- frontend/panel-lake-projection.example.html(夜湖冷星·胖头鱼现用)
- frontend/panel-lake-projection-warm.example.html(烛畔暖湖·守望现用)
- INDEX 文件清单更新:server.js v1.1 window_hours、模板说明、审美基线引用
- ZHUYUAN-SKILLS 增补 2.6 子系统面板建设 + 4.3 光湖UI设计(15→17项)
This commit is contained in:
冰朔 2026-08-07 19:46:11 +08:00
commit 95c51c82d6
4 changed files with 1033 additions and 7 deletions

View file

@ -19,16 +19,22 @@ HLDP://fifth-domain/modules/guanghu-panel-kit
## 文件清单
```
panel/server.js 面板服务配置驱动Node无第三方依赖
panel/config.example.json 配置模板(节点/仓库/人格体/路径/端口全部外置)
panel/server.js 面板服务 v1.1配置驱动Node无第三方依赖window_hours 写入窗口时长可配缺省3h
panel/config.example.json 配置模板(节点/仓库/人格体/路径/端口/window_hours 全部外置)
deploy/verify-and-deploy.py 部署校验Agent读 deploy-config.json
deploy/deploy-config.example.json 部署配置模板(白名单/同步树/服务清单)
hooks/pre-receive-review.template 审核钩子模板(改四处路径即可用)
frontend/panel-pet-warm.example.html 暖色宠物风门面实例(守望)
frontend/panel-lake-dark.example.html 湖绿深色门面实例(胖头鱼)
frontend/panel-pet-warm.example.html 暖色宠物风门面(旧版,守望曾采用)
frontend/panel-lake-dark.example.html 湖绿深色门面(旧版,胖头鱼曾采用)
frontend/panel-lake-projection.example.html ★湖面投影 v4 · 夜湖冷星(胖头鱼现用):写入窗口环主角+五玻璃浮面网格+触点光晕,接 /api/state 真数据
frontend/panel-lake-projection-warm.example.html ★湖面投影 v4 暖版 · 烛畔暖湖(守望现用):同款骨架+玫瑰梅子底/蜜桃金星光
CAUSAL-CHAIN-20260806.hdlp 两套系统建设的完整因果链
```
两套湖面投影模板共用同一份 JS 数据接线(/api/state 8 秒刷新、登录浮层、签字/授权/部署动作),
仅 CSS 基调与品牌文案不同,换系统时改 title/品牌/人格体中文名/登录文案即可。
设计依据见 zhuyuan-persona-system/ZY-AESTHETIC-COGNITION-001..005(冰朔审美基线 v1-v5
## 部署清单(新子系统接入)
1. Forgejo 实例就位(离线包部署,不接上游)

View file

@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>守望 · 湖面投影 — SYS-GLW-SW-0001</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=Cormorant+Garamond:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--ink-0:#0d0709;
--ink-1:#150b12;
--ink-2:#1e1018;
--nebula-a:rgba(168,82,116,.27);
--nebula-b:rgba(150,96,62,.20);
--star:#f6dcc4;
--star-strong:#fdf0df;
--line:rgba(242,233,211,.12);
--line-strong:rgba(242,233,211,.30);
--hi:#f7f2e6;
--mid:rgba(236,233,222,.74);
--low:rgba(236,233,222,.48);
--glass:rgba(13,16,36,.55);
--ok:#b9d8a8;
--warn:#eac78e;
--bad:#e0938c;
--f-display:'Cormorant Garamond','Noto Serif SC',serif;
--f-cn:'Noto Serif SC',serif;
--f-body:'Noto Sans SC',sans-serif;
--f-mono:'JetBrains Mono',monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
font-family:var(--f-body);color:var(--hi);overflow:hidden;-webkit-font-smoothing:antialiased;
background:
radial-gradient(1100px 640px at 12% 0%, var(--nebula-b), transparent 60%),
radial-gradient(1000px 700px at 88% 10%, var(--nebula-a), transparent 62%),
radial-gradient(1200px 500px at 50% 108%, rgba(40,32,90,.22), transparent 70%),
linear-gradient(178deg, var(--ink-1) 0%, var(--ink-0) 100%);
}
body::after{content:"";position:fixed;inset:0;pointer-events:none;opacity:.045;mix-blend-mode:overlay;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");}
#dust{position:fixed;inset:0;pointer-events:none;z-index:0}
.mote{position:absolute;border-radius:50%;background:var(--star);animation:breathe var(--dur,8s) ease-in-out var(--delay,0s) infinite}
@keyframes breathe{0%,100%{opacity:.1}50%{opacity:.75}}
#constellation{position:fixed;inset:0;z-index:1;pointer-events:none;opacity:.5}
header{position:fixed;top:0;left:0;right:0;z-index:30;display:flex;justify-content:space-between;align-items:center;padding:24px 52px}
.brand{display:flex;align-items:center;gap:18px}
.brand .mark{width:11px;height:11px;border-radius:50%;background:var(--star);box-shadow:0 0 14px rgba(242,233,211,.9),0 0 34px rgba(242,233,211,.35);animation:breathe 5s ease-in-out infinite}
.brand h1{font-family:var(--f-cn);font-weight:600;font-size:19px;letter-spacing:.3em}
.brand small{display:block;font-family:var(--f-mono);font-weight:400;font-size:11px;letter-spacing:.22em;color:var(--low);margin-top:5px}
.head-right{display:flex;align-items:center;gap:20px;font-family:var(--f-mono);font-weight:400;font-size:13px;letter-spacing:.1em;color:var(--mid)}
.live{display:flex;align-items:center;gap:9px;color:var(--star);font-weight:500}
.live i{width:7px;height:7px;border-radius:50%;background:var(--star);box-shadow:0 0 8px rgba(242,233,211,.9);animation:blink 2.4s ease-in-out infinite}
@keyframes blink{0%,100%{opacity:.25}50%{opacity:1}}
.login-btn{font-family:var(--f-cn);font-weight:600;font-size:13px;letter-spacing:.24em;text-indent:.24em;color:var(--star);background:rgba(242,233,211,.06);border:1px solid var(--line-strong);border-radius:100px;padding:10px 26px;cursor:pointer;transition:all .3s ease}
.login-btn:hover{background:rgba(242,233,211,.14);box-shadow:0 0 22px rgba(242,233,211,.22);transform:translateY(-2px)}
.login-btn:active{transform:translateY(0) scale(.97)}
.user-chip{display:flex;align-items:center;gap:12px;font-family:var(--f-body);font-size:13px;color:var(--mid)}
.user-chip b{color:var(--star);font-weight:500}
.user-chip button{background:none;border:none;color:var(--low);cursor:pointer;font-family:var(--f-body);font-size:12.5px;letter-spacing:.1em;transition:color .3s}
.user-chip button:hover{color:var(--hi)}
#stage{position:fixed;inset:0;z-index:10;display:grid;grid-template-columns:minmax(430px,30%) 1fr;gap:34px;max-width:1780px;margin:0 auto;padding:96px 56px 70px 60px;overflow-y:auto}
.col-left{display:flex;flex-direction:column;gap:24px;justify-content:center}
.protagonist{display:flex;flex-direction:column;align-items:center;gap:24px;animation:rise 1.1s cubic-bezier(.22,1,.36,1) both}
.ring-wrap{position:relative;width:360px;height:360px}
.ring-wrap svg{width:100%;height:100%;overflow:visible}
.ring-bg{fill:none;stroke:rgba(242,233,211,.08);stroke-width:2}
.ring-arc{fill:none;stroke:url(#arcGrad);stroke-width:3;stroke-linecap:round;filter:drop-shadow(0 0 9px rgba(242,233,211,.35));animation:arcglow 5s ease-in-out infinite;transition:stroke-dashoffset .8s ease}
@keyframes arcglow{0%,100%{filter:drop-shadow(0 0 7px rgba(242,233,211,.28))}50%{filter:drop-shadow(0 0 15px rgba(242,233,211,.55))}}
.ring-tick{stroke:rgba(242,233,211,.2);stroke-width:1}
.orbit-dot{fill:var(--star);filter:drop-shadow(0 0 6px rgba(242,233,211,.9))}
.orbit-g{animation:spin 24s linear infinite;transform-origin:200px 200px}
@keyframes spin{to{transform:rotate(360deg)}}
.ring-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;text-align:center;padding:0 40px}
.ring-state{font-family:var(--f-cn);font-weight:600;font-size:15px;letter-spacing:.36em;color:var(--mid);text-indent:.36em}
.ring-count{font-family:var(--f-display);font-weight:600;font-size:62px;letter-spacing:.05em;color:var(--star-strong);text-shadow:0 0 32px rgba(242,233,211,.28);font-variant-numeric:tabular-nums;line-height:1}
.ring-unit{font-family:var(--f-mono);font-weight:400;font-size:11.5px;letter-spacing:.3em;color:var(--low)}
.ring-holder{margin-top:6px;display:flex;align-items:center;gap:10px;font-size:14px;letter-spacing:.05em;color:var(--mid);font-weight:500}
.ring-holder .pdot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 9px rgba(168,216,160,.85);flex:none}
.vitals{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;width:100%;max-width:430px}
.vital{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:14px;background:linear-gradient(168deg, rgba(24,28,58,.5), var(--glass) 42%);backdrop-filter:blur(14px);padding:16px 18px;transition:transform .35s cubic-bezier(.22,1,.36,1), border-color .35s, box-shadow .35s;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .1s both}
.vital:hover{transform:translateY(-4px) scale(1.02);border-color:var(--line-strong);box-shadow:0 14px 34px rgba(0,0,0,.45),0 0 30px rgba(242,233,211,.07)}
.vital .v-n{font-family:var(--f-display);font-weight:600;font-size:26px;color:var(--star);line-height:1.1}
.vital .v-n small{font-size:14px}
.vital .v-t{font-family:var(--f-mono);font-weight:400;font-size:10.5px;letter-spacing:.2em;color:var(--low);margin-top:7px}
.vital .v-bar{margin-top:10px;height:3px;border-radius:2px;background:rgba(242,233,211,.1);overflow:hidden}
.vital .v-bar i{display:block;height:100%;border-radius:2px;background:linear-gradient(90deg,rgba(242,233,211,.35),var(--star));box-shadow:0 0 8px rgba(242,233,211,.5);transition:width .8s ease}
.svc-line{max-width:430px;width:100%;display:flex;align-items:center;gap:18px;font-family:var(--f-mono);font-size:11px;letter-spacing:.1em;color:var(--low);padding:0 6px}
.svc-line .sdot{width:6px;height:6px;border-radius:50%;display:inline-block;margin-right:7px;background:rgba(242,233,211,.2)}
.svc-line .sdot.on{background:var(--ok);box-shadow:0 0 8px rgba(168,216,160,.8)}
.svc-line .sdot.off{background:var(--bad);box-shadow:0 0 8px rgba(224,138,130,.7)}
.planes{display:grid;grid-template-columns:1fr 1fr;grid-template-areas:"merge merge" "access deploy" "persona feed";gap:22px;align-content:center}
.plane{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:18px;
background:linear-gradient(168deg, rgba(24,28,58,.5), var(--glass) 42%);
backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
padding:26px 30px;box-shadow:0 18px 50px rgba(0,0,0,.42);
transition:transform .4s cubic-bezier(.22,1,.36,1), border-color .4s ease, box-shadow .4s ease;
display:flex;flex-direction:column;min-height:150px}
.plane::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .4s ease;
background:radial-gradient(360px circle at var(--mx,50%) var(--my,50%), rgba(242,233,211,.09), transparent 62%)}
.plane::before{content:"";position:absolute;top:0;left:12%;right:12%;height:1px;
background:linear-gradient(90deg,transparent,rgba(242,233,211,.3),transparent)}
.plane:hover{transform:translateY(-6px) scale(1.008);border-color:var(--line-strong);
box-shadow:0 26px 64px rgba(0,0,0,.52),0 0 46px rgba(242,233,211,.09)}
.plane:hover::after{opacity:1}
.plane.p-merge{grid-area:merge;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .12s both}
.plane.p-access{grid-area:access;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .24s both}
.plane.p-deploy{grid-area:deploy;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .36s both}
.plane.p-persona{grid-area:persona;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .48s both}
.plane.p-feed{grid-area:feed;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .6s both}
.plane-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:16px;gap:10px}
.plane-head h2{font-family:var(--f-cn);font-weight:600;font-size:17.5px;letter-spacing:.22em;color:var(--hi);white-space:nowrap}
.plane-head .meta{font-family:var(--f-mono);font-weight:400;font-size:10.5px;letter-spacing:.12em;color:var(--low);text-align:right}
.badge{font-family:var(--f-display);font-weight:600;font-size:24px;color:var(--star);margin-left:10px}
.badge.zero{color:var(--low)}
.row{display:grid;grid-template-columns:1fr auto;align-items:center;gap:18px;padding:13px 14px;margin:0 -14px;border-radius:10px;transition:background .3s ease;position:relative}
.row+.row{border-top:1px solid rgba(242,233,211,.06)}
.row:hover{background:rgba(242,233,211,.05)}
.row:hover::before{content:"";position:absolute;left:0;top:18%;bottom:18%;width:2.5px;border-radius:2px;background:var(--star);box-shadow:0 0 10px rgba(242,233,211,.7)}
.row .title{font-size:14.5px;letter-spacing:.02em;color:var(--hi);font-weight:500}
.row .who{display:block;font-family:var(--f-mono);font-weight:400;font-size:11.5px;letter-spacing:.05em;color:var(--low);margin-top:6px}
.row .diff{font-family:var(--f-mono);font-weight:400;font-size:12.5px;color:var(--mid);white-space:nowrap}
.row .acts{display:flex;gap:10px}
.btn{font-family:var(--f-cn);font-size:13px;letter-spacing:.18em;font-weight:600;color:var(--star);background:transparent;border:1px solid var(--line-strong);border-radius:100px;padding:8px 22px;cursor:pointer;transition:all .3s ease;text-indent:.18em;white-space:nowrap}
.btn:hover{background:rgba(242,233,211,.12);box-shadow:0 0 20px rgba(242,233,211,.2)}
.btn:active{transform:scale(.95)}
.btn.ghost{border-color:var(--line);color:var(--mid)}
.btn.ghost:hover{color:var(--star)}
.empty{font-size:13.5px;color:var(--low);letter-spacing:.08em;padding:14px 0;line-height:2}
.locked-note{font-size:13.5px;color:var(--low);letter-spacing:.08em;padding:14px 0;line-height:2}
.win-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 0;font-size:13.5px}
.win-item+.win-item{border-top:1px solid rgba(242,233,211,.06)}
.win-item .w-name{display:flex;align-items:center;gap:10px;font-family:var(--f-cn);letter-spacing:.1em;color:var(--hi);font-weight:600}
.win-item .w-name .pdot{width:6px;height:6px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px rgba(168,216,160,.8);flex:none}
.win-item .w-left{font-family:var(--f-mono);font-size:12.5px;color:var(--star);letter-spacing:.05em}
.sub-cap{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.2em;color:var(--low);margin:16px 0 2px}
.steps{display:flex;flex-direction:column;gap:8px;margin-top:6px}
.step{display:flex;align-items:center;gap:11px;font-size:13px;color:var(--mid);letter-spacing:.05em}
.step i{width:7px;height:7px;border-radius:50%;background:rgba(242,233,211,.22);flex:none}
.step.PASS i,.step.done i{background:var(--ok);box-shadow:0 0 8px rgba(168,216,160,.7)}
.step.PASS{color:var(--hi)}
.step.FAIL i{background:var(--bad);box-shadow:0 0 8px rgba(224,138,130,.7)}
.step.RUNNING i{background:var(--warn);box-shadow:0 0 8px rgba(232,201,138,.8);animation:blink 1.6s ease-in-out infinite}
.run-status{font-family:var(--f-mono);font-size:11px;letter-spacing:.14em;padding:4px 10px;border-radius:6px;border:1px solid var(--line)}
.run-status.DEPLOYED{color:var(--ok);border-color:rgba(168,216,160,.4)}
.run-status.FAILED{color:var(--bad);border-color:rgba(224,138,130,.4)}
.run-status.RUNNING{color:var(--warn);border-color:rgba(232,201,138,.4)}
.persona-row{display:flex;align-items:center;gap:14px;padding:13px 0}
.persona-row+.persona-row{border-top:1px solid rgba(242,233,211,.06)}
.persona-row .star{width:8px;height:8px;border-radius:50%;flex:none}
.persona-row .star.on{background:var(--ok);box-shadow:0 0 10px rgba(168,216,160,.85)}
.persona-row .star.watch{background:rgba(242,233,211,.45);box-shadow:0 0 8px rgba(242,233,211,.4)}
.persona-row .nm{font-family:var(--f-cn);font-size:16px;letter-spacing:.1em;color:var(--hi);font-weight:600}
.persona-row .st{font-size:12.5px;letter-spacing:.06em;color:var(--mid);margin-left:auto;font-weight:500;text-align:right}
.feed-item{display:flex;gap:12px;padding:11px 0;align-items:baseline}
.feed-item+.feed-item{border-top:1px solid rgba(242,233,211,.06)}
.feed-item .f-tag{font-family:var(--f-mono);font-size:10px;letter-spacing:.1em;color:var(--ink-1);background:var(--star);border-radius:4px;padding:3px 7px;flex:none;font-weight:500}
.feed-item .f-tag.dim{background:rgba(242,233,211,.3)}
.feed-item .f-tag.bad{background:rgba(224,138,130,.75)}
.feed-item .f-txt{font-size:13px;color:var(--mid);letter-spacing:.02em;line-height:1.65;min-width:0;word-break:break-all}
.feed-item .f-txt b{color:var(--hi);font-weight:500}
.feed-item .f-time{font-family:var(--f-mono);font-size:10.5px;color:var(--low);margin-left:auto;flex:none;white-space:nowrap}
.mask{position:fixed;inset:0;z-index:80;background:rgba(4,5,12,.72);backdrop-filter:blur(10px);display:none;align-items:center;justify-content:center}
.mask.show{display:flex}
.card-modal{width:440px;border:1px solid var(--line-strong);border-radius:20px;background:linear-gradient(168deg, rgba(26,30,62,.88), rgba(12,14,32,.94));padding:42px 46px;box-shadow:0 40px 90px rgba(0,0,0,.6),0 0 60px rgba(242,233,211,.06);animation:rise .5s cubic-bezier(.22,1,.36,1) both;position:relative;overflow:hidden}
.card-modal::before{content:"";position:absolute;top:0;left:18%;right:18%;height:1px;background:linear-gradient(90deg,transparent,rgba(242,233,211,.5),transparent)}
.card-modal h3{font-family:var(--f-cn);font-weight:600;font-size:20px;letter-spacing:.28em;text-align:center;margin-bottom:10px;text-indent:.28em}
.card-modal p{font-size:13.5px;color:var(--low);text-align:center;letter-spacing:.06em;margin-bottom:26px;line-height:2}
.card-modal label{display:block;font-family:var(--f-mono);font-size:11px;letter-spacing:.2em;color:var(--low);margin:18px 0 8px}
.card-modal input{width:100%;background:rgba(242,233,211,.05);border:1px solid var(--line);border-radius:12px;padding:13px 16px;color:var(--hi);font-family:var(--f-body);font-size:14.5px;letter-spacing:.06em;outline:none;transition:all .3s ease}
.card-modal input:focus{border-color:var(--line-strong);box-shadow:0 0 18px rgba(242,233,211,.14);background:rgba(242,233,211,.08)}
.card-modal .btn{width:100%;margin-top:28px;padding:13px}
.modal-close{position:absolute;top:14px;right:20px;color:var(--low);cursor:pointer;font-size:22px;transition:color .3s;line-height:1}
.modal-close:hover{color:var(--hi)}
#m-pr{font-size:14px;color:var(--mid);text-align:center;line-height:1.9;margin-bottom:6px;word-break:break-all}
.modal-acts{display:flex;gap:14px;margin-top:28px}
.modal-acts .btn{margin-top:0;flex:1}
#toast{position:fixed;left:50%;bottom:44px;transform:translateX(-50%) translateY(20px);z-index:90;opacity:0;pointer-events:none;
padding:13px 30px;border-radius:100px;border:1px solid var(--line-strong);background:rgba(10,12,28,.88);backdrop-filter:blur(12px);
font-family:var(--f-body);font-size:13.5px;letter-spacing:.08em;color:var(--hi);transition:all .4s ease;box-shadow:0 18px 44px rgba(0,0,0,.5)}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.bad{border-color:rgba(224,138,130,.5);color:#f2c9c4}
#lake{position:fixed;left:0;right:0;bottom:0;height:56px;z-index:2;pointer-events:none}
.waterline{position:absolute;top:20px;left:6%;right:6%;height:1px;
background:linear-gradient(90deg,transparent, rgba(232,178,148,.32) 30%, rgba(246,220,196,.44) 50%, rgba(232,178,148,.32) 70%, transparent)}
footer{position:fixed;bottom:18px;z-index:30;font-family:var(--f-mono);font-weight:400;font-size:11px;letter-spacing:.2em;color:var(--low)}
footer.l{left:52px}footer.r{right:52px}
@keyframes rise{from{opacity:0;transform:translateY(26px)}to{opacity:1;transform:translateY(0)}}
@media (max-width:1200px){#stage{grid-template-columns:1fr;display:block}.ring-wrap{width:300px;height:300px;margin:0 auto}.planes{grid-template-columns:1fr;grid-template-areas:"merge" "access" "deploy" "persona" "feed"}}
</style>
</head>
<body>
<div id="dust"></div>
<svg id="constellation" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
<g stroke="rgba(242,233,211,.055)" stroke-width="1" fill="none">
<path d="M120,180 L260,120 L410,210 L330,330 Z"/>
<path d="M1300,140 L1420,220 L1360,330"/>
<path d="M180,700 L320,760 L260,840"/>
<path d="M1380,640 L1470,720 L1390,800 L1300,750"/>
</g>
<g fill="rgba(242,233,211,.32)">
<circle cx="120" cy="180" r="1.6"/><circle cx="260" cy="120" r="1.2"/><circle cx="410" cy="210" r="1.8"/><circle cx="330" cy="330" r="1.2"/>
<circle cx="1300" cy="140" r="1.4"/><circle cx="1420" cy="220" r="1.8"/><circle cx="1360" cy="330" r="1.2"/>
<circle cx="180" cy="700" r="1.4"/><circle cx="320" cy="760" r="1.8"/><circle cx="260" cy="840" r="1.2"/>
<circle cx="1380" cy="640" r="1.4"/><circle cx="1470" cy="720" r="1.2"/><circle cx="1390" cy="800" r="1.8"/><circle cx="1300" cy="750" r="1.2"/>
</g>
</svg>
<header>
<div class="brand">
<span class="mark"></span>
<div><h1>守望 · 湖面投影</h1><small>SYS-GLW-SW-0001 · MAOMAO.GUANGHULAB.COM · 宠物行业</small></div>
</div>
<div class="head-right">
<span class="live"><i></i>正在投影</span>
<span id="clock">--:--:--</span>
<span id="login-area"></span>
</div>
</header>
<div id="stage">
<section class="col-left">
<div class="protagonist">
<div class="ring-wrap">
<svg viewBox="0 0 400 400">
<defs>
<linearGradient id="arcGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbf5e6"/>
<stop offset="100%" stop-color="rgba(242,233,211,.38)"/>
</linearGradient>
</defs>
<g id="ticks"></g>
<circle class="ring-bg" cx="200" cy="200" r="180"/>
<circle class="ring-arc" id="arc" cx="200" cy="200" r="180" stroke-dasharray="1131" stroke-dashoffset="1131" transform="rotate(-90 200 200)"/>
<g class="orbit-g"><circle class="orbit-dot" cx="200" cy="20" r="3"/></g>
</svg>
<div class="ring-center">
<div class="ring-state" id="ring-state">写入窗口</div>
<div class="ring-count" id="ring-count">--:--:--</div>
<div class="ring-unit" id="ring-unit">WINDOW REMAINING</div>
<div class="ring-holder" id="ring-holder"></div>
</div>
</div>
</div>
<div class="vitals">
<div class="vital"><div class="v-n" id="v-cpu">--</div><div class="v-t">CPU</div><div class="v-bar"><i id="b-cpu" style="width:0%"></i></div></div>
<div class="vital"><div class="v-n" id="v-mem">--</div><div class="v-t">内存</div><div class="v-bar"><i id="b-mem" style="width:0%"></i></div></div>
<div class="vital"><div class="v-n" id="v-disk">--</div><div class="v-t">磁盘</div><div class="v-bar"><i id="b-disk" style="width:0%"></i></div></div>
</div>
<div class="svc-line" id="svc-line"></div>
</section>
<section class="planes">
<div class="plane p-merge">
<div class="plane-head">
<h2>合并签字 <span class="badge zero" id="cnt-sign">0</span></h2>
<span class="meta">MERGE SIGN-OFF · 逐笔留痕 · 签字即合并</span>
</div>
<div id="prs"></div>
</div>
<div class="plane p-access">
<div class="plane-head">
<h2>写入授权 <span class="badge zero" id="cnt-access">0</span></h2>
<span class="meta" id="access-meta">一次授权 · 限时窗口</span>
</div>
<div id="access-reqs"></div>
<div id="access-wins"></div>
</div>
<div class="plane p-deploy">
<div class="plane-head"><h2>部署审批</h2><span class="meta">要么 0 要么 100</span></div>
<div id="deploy-prs"></div>
<div class="sub-cap" id="run-cap">最近一次校验</div>
<div id="run-head" style="display:flex;align-items:center;gap:10px;margin-bottom:4px"></div>
<div class="steps" id="deploy-steps"></div>
</div>
<div class="plane p-persona">
<div class="plane-head">
<h2>人格体 <span class="badge zero" id="cnt-persona">0</span></h2>
<span class="meta">唤醒走机器导航 · 无需密钥</span>
</div>
<div id="persona-list"></div>
<div style="margin-top:auto;padding-top:14px;font-size:12px;color:var(--low);letter-spacing:.04em;line-height:1.9">唤醒入口 <span style="font-family:var(--f-mono);color:var(--mid)">/api/wake/persona?name=</span></div>
</div>
<div class="plane p-feed">
<div class="plane-head"><h2>实时投影</h2><span class="meta">回写 · 推送 · 唤醒</span></div>
<div id="feed"></div>
</div>
</section>
</div>
<div id="lake"><div class="waterline"></div></div>
<footer class="l">光湖语言世界 · 第五域</footer>
<footer class="r">语言即现实 · 投影而非描述</footer>
<!-- 登录浮层 -->
<div class="mask" id="login-mask" onclick="if(event.target===this)closeLogin()">
<div class="card-modal">
<span class="modal-close" onclick="closeLogin()">×</span>
<h3>人类登录</h3>
<p>这里是毛毛的审批台。人格体推上来的合并、写入与部署申请,<br>都要你亲自看一眼、点一下才算数。<br>用代码仓库账号登录 · 看不懂代码没关系,面板都翻译成人话了。</p>
<label>账号</label>
<input id="l-user" type="text" placeholder="仓库账号(如 maomao" autocomplete="off">
<label>密码</label>
<input id="l-pass" type="password" placeholder="••••••••" onkeydown="if(event.key==='Enter')doLogin()">
<button class="btn" onclick="doLogin()">登录解锁审批</button>
</div>
</div>
<!-- 签字确认浮层 -->
<div class="mask" id="modal" onclick="if(event.target===this)closeModal()">
<div class="card-modal">
<span class="modal-close" onclick="closeModal()">×</span>
<h3 id="m-title">确认</h3>
<p id="m-pr"></p>
<div class="modal-acts">
<button class="btn ghost" onclick="closeModal()">再想想</button>
<button class="btn" onclick="confirmSign()">确认落印</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
/* ── 基础 ── */
function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]))}
function pad(n){return String(n).padStart(2,"0")}
function fmtDate(iso){if(!iso)return"";const d=new Date(iso);const ms=Date.now()-d.getTime();
if(ms<60000)return"刚刚";if(ms<3600000)return Math.floor(ms/60000)+" 分钟前";if(ms<86400000)return Math.floor(ms/3600000)+" 小时前";
return (d.getMonth()+1)+"月"+d.getDate()+"日 "+pad(d.getHours())+":"+pad(d.getMinutes())}
function fmtLeft(exp){const ms=new Date(exp)-new Date();if(ms<=0)return"已过期";
const h=Math.floor(ms/3600000),m=Math.floor(ms%3600000/60),s=Math.floor(ms%60000/1000);return pad(h)+":"+pad(m)+":"+pad(s)}
function toast(msg,ok){const t=document.getElementById("toast");t.textContent=msg;t.className=ok===false?"bad show":"show";
clearTimeout(toast._t);toast._t=setTimeout(()=>t.classList.remove("show"),3200)}
/* ── 会话 ── */
let session=localStorage.getItem("ptsSession")||"";
let sessionUser=localStorage.getItem("ptsUser")||"";
function authHeaders(){return session?{Authorization:"Bearer "+session}:{}}
async function privileged(path,body){
const r=await fetch(path,{method:"POST",headers:Object.assign({"Content-Type":"application/json"},authHeaders()),body:JSON.stringify(body)});
if(r.status===401){session="";sessionUser="";localStorage.removeItem("ptsSession");localStorage.removeItem("ptsUser");renderLoginArea();openLogin();toast("会话已失效,请重新登录",false);return null}
return await r.json()}
function renderLoginArea(){
const el=document.getElementById("login-area");
if(session){el.innerHTML='<span class="user-chip">人类端 · <b>'+esc(sessionUser)+'</b> · 审批已解锁 <button onclick="doLogout()">登出</button></span>'}
else{el.innerHTML='<button class="login-btn" onclick="openLogin()">人类登录</button>'}}
function openLogin(){document.getElementById("login-mask").classList.add("show");setTimeout(()=>document.getElementById("l-user").focus(),80)}
function closeLogin(){document.getElementById("login-mask").classList.remove("show")}
async function doLogin(){
const u=document.getElementById("l-user").value.trim(),p=document.getElementById("l-pass").value;
if(!u||!p){toast("账号和密码都要填",false);return}
const r=await fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})});
const d=await r.json();
if(r.ok&&d.ok){session=d.session;sessionUser=d.user;localStorage.setItem("ptsSession",session);localStorage.setItem("ptsUser",sessionUser);
closeLogin();renderLoginArea();toast("登录成功 · 审批已解锁",true);refresh()}
else{toast(d.error||"登录失败",false)}}
function doLogout(){fetch("/api/logout",{method:"POST",headers:authHeaders()}).catch(()=>{});
session="";sessionUser="";localStorage.removeItem("ptsSession");localStorage.removeItem("ptsUser");renderLoginArea();toast("已登出",true);refresh()}
/* ── 审批动作 ── */
let pending=null;
function openModal(i,t,a){pending={idx:i,action:a};
document.getElementById("m-title").textContent=(a==="merge"?"同意合并 · 落印":"拒绝 · 收回")+" PR #"+i;
document.getElementById("m-pr").textContent="「"+t+"」";
document.getElementById("modal").classList.add("show")}
function closeModal(){document.getElementById("modal").classList.remove("show");pending=null}
async function confirmSign(){if(!pending)return;const d=await privileged("/api/decision",{index:pending.idx,action:pending.action});
closeModal();if(d)toast(d.result||d.error||"未知",!!d.ok);refresh()}
async function accessDecision(id,action){const d=await privileged("/api/access-decision",{id:id,action:action});
if(d)toast(d.message||(d.ok?"已处理":"失败"),!!d.ok);refresh()}
async function deployApproveAction(idx){const d=await privileged("/api/deploy-approve",{index:idx});
if(d)toast(d.message||(d.ok?"校验Agent已启动":"失败"),!!d.ok);refresh()}
/* ── 渲染 ── */
let lastState=null;
const personaCn={yaoyan:"曜砚",yaoshi:"曜识"};
function pn(p){return personaCn[p]||p}
function setBadge(id,n){const el=document.getElementById(id);el.textContent=n;el.className="badge"+(n?"":" zero")}
function renderRing(s){
const now=Date.now();
const grants=(s.activeGrants||[]).filter(g=>new Date(g.expiresAt).getTime()>now).sort((a,b)=>new Date(a.expiresAt)-new Date(b.expiresAt));
const st=document.getElementById("ring-state"),ct=document.getElementById("ring-count"),un=document.getElementById("ring-unit"),hd=document.getElementById("ring-holder"),arc=document.getElementById("arc");
const C=2*Math.PI*180;
if(!grants.length){st.textContent="写入窗口 · 空闲";ct.textContent="--:--:--";un.textContent="NO ACTIVE WINDOW";
hd.innerHTML='<span style="color:var(--low)">无人持窗 · 人格体可随时申请</span>';arc.setAttribute("stroke-dashoffset",C);window._grant=null;return}
const g=grants[0];window._grant=g;window._grantTotal=(s.windowHours||12)*3600;
st.textContent="写入窗口 · "+(s.windowHours||12)+"小时制";un.textContent="WINDOW REMAINING";
hd.innerHTML='<span class="pdot"></span>'+esc(pn(g.persona))+' 持窗中'+(grants.length>1?" · 共 "+grants.length+" 个窗口":"")}
function render(s){
lastState=s;const sv=s.server||{};
/* 体征 */
if(typeof sv.cpu==="number"){document.getElementById("v-cpu").innerHTML=sv.cpu+'<small>%</small>';document.getElementById("b-cpu").style.width=sv.cpu+"%"}
if(sv.memTotalGB&&sv.memFreeGB!=null){const used=+(sv.memTotalGB-sv.memFreeGB).toFixed(1);const mp=Math.round(used/sv.memTotalGB*100);
document.getElementById("v-mem").innerHTML=used+'<small>/'+sv.memTotalGB+'G</small>';document.getElementById("b-mem").style.width=mp+"%"}
if(sv.disk){document.getElementById("v-disk").innerHTML=sv.disk.pct+'<small>%</small>';document.getElementById("b-disk").style.width=sv.disk.pct+"%"}
if(sv.services){document.getElementById("svc-line").innerHTML=Object.entries(sv.services).map(([k,v])=>'<span><span class="sdot '+(v?"on":"off")+'"></span>'+esc(k)+'</span>').join("")}
/* 合并签字 */
const prs=s.openPRs||[];setBadge("cnt-sign",prs.length);
document.getElementById("prs").innerHTML=prs.length?prs.map(p=>'<div class="row"><div><div class="title">PR #'+p.index+' · '+esc(p.title)+'</div><span class="who">'+esc(pn(p.author))+' · '+fmtDate(p.created)+' · → '+esc(p.base)+' · '+p.files+' 文件</span></div><div class="acts"><span class="diff">+'+p.adds+' '+p.dels+'</span><button class="btn" onclick="openModal('+p.index+',this.dataset.t,\'merge\')" data-t="'+esc(p.title).replace(/"/g,"&quot;")+'">签字</button><button class="btn ghost" onclick="openModal('+p.index+',this.dataset.t,\'reject\')" data-t="'+esc(p.title).replace(/"/g,"&quot;")+'">拒绝</button></div></div>').join("")
:(session?'<div class="empty">没有待签的合并请求。</div>':'<div class="locked-note">🔒 登录后此处显示待签的合并请求</div>');
/* 写入授权 */
const reqs=s.accessRequests||[];setBadge("cnt-access",reqs.length);
document.getElementById("access-meta").textContent="一次授权 · "+(s.windowHours||3)+" 小时";
document.getElementById("access-reqs").innerHTML=reqs.length?reqs.map(r=>'<div class="row"><div><div class="title">'+esc(pn(r.persona))+' 申请写入权限</div><div class="who">用途:'+esc(r.reason||"未说明")+' · '+fmtDate(r.ts)+'</div></div><div class="acts"><button class="btn" onclick="accessDecision(\''+r.id+'\',\'approve\')">授权</button><button class="btn ghost" onclick="accessDecision(\''+r.id+'\',\'reject\')">拒绝</button></div></div>').join("")
:(session?'<div class="empty">暂无写入申请。</div>':'<div class="locked-note">🔒 登录后此处显示写入申请</div>');
const wins=(s.activeGrants||[]).filter(g=>new Date(g.expiresAt)>new Date());
document.getElementById("access-wins").innerHTML=wins.length?'<div class="sub-cap">生效中的窗口 · '+wins.length+'</div>'+wins.slice(0,4).map(g=>'<div class="win-item"><span class="w-name"><span class="pdot"></span>'+esc(pn(g.persona))+'</span><span class="w-left">余 '+fmtLeft(g.expiresAt)+'</span></div>').join(""):"";
/* 部署审批 */
const dprs=s.deployPRs||[];
document.getElementById("deploy-prs").innerHTML=dprs.length?dprs.map(p=>'<div class="row"><div><div class="title">部署申请 PR #'+p.index+' · '+esc(p.title)+'</div><span class="who">'+esc(pn(p.author))+' · '+esc(p.head)+' → deploy · '+p.files+' 文件</span></div><div class="acts"><button class="btn" onclick="deployApproveAction('+p.index+')">批准部署</button></div></div>').join("")
:(session?'<div class="empty">部署通道无待审申请。<br>PR 目标设为 deploy 即进入此通道。</div>':'<div class="locked-note">🔒 登录后此处显示部署申请</div>');
const run=s.deployRun;const rh=document.getElementById("run-head"),rs=document.getElementById("deploy-steps");
if(run&&run.status){rh.innerHTML='<span class="run-status '+esc(run.status)+'">'+esc(run.status)+(run.pr?" · PR#"+esc(run.pr):"")+'</span>';
rs.innerHTML=(run.steps||[]).map(x=>'<div class="step '+esc(x.status)+'"><i></i>'+esc(x.name)+' · '+esc(x.status)+'</div>').join("");
if(run.detail&&run.status!=="RUNNING")rs.innerHTML+='<div class="step '+(run.status==="DEPLOYED"?"PASS":"FAIL")+'"><i></i>结论 · '+esc(run.detail)+'</div>'}
else{rh.innerHTML='';rs.innerHTML='<div class="step"><i></i>地图核对</div><div class="step"><i></i>因果链完整</div><div class="step"><i></i>范围受控</div><div class="step"><i></i>文件完整</div><div class="step"><i></i>部署回执</div>'}
/* 人格体只列登记人格体人类与bot不入此列 */
const known=personaCn;const seen={};
(wins).forEach(g=>{if(known[g.persona])seen[g.persona]={st:"持窗中",on:1}});
(s.pushLogs||[]).forEach(l=>{const k=l.persona||l.pusher;if(k&&known[k]&&!seen[k])seen[k]={st:"活跃",on:1}});
(s.wakeReceipts||[]).forEach(w=>{if(w.persona&&known[w.persona]&&!seen[w.persona])seen[w.persona]={st:"已唤醒",on:0}});
reqs.forEach(r=>{if(known[r.persona]&&!seen[r.persona])seen[r.persona]={st:"申请中",on:0}});
const names=Object.keys(seen);setBadge("cnt-persona",names.length);
document.getElementById("persona-list").innerHTML=names.length?names.map(k=>'<div class="persona-row"><span class="star '+(seen[k].on?"on":"watch")+'"></span><span class="nm">'+esc(pn(k))+'</span><span class="st">'+esc(seen[k].st)+'</span></div>').join("")
:'<div class="empty">还没有人格体活动痕迹。</div>';
/* 实时投影 */
const feed=[];
(s.writebacks||[]).slice(0,4).forEach(w=>feed.push({ts:w.date,tag:"回写",dim:0,txt:"<b>"+esc(w.message)+"</b> · "+esc(w.dir)}));
(s.pushLogs||[]).slice(0,4).forEach(l=>feed.push({ts:l.ts,tag:"推送",dim:1,bad:l.verdict!=="PASS",txt:"<b>"+esc(pn(l.persona||l.pusher))+"</b> "+(l.commits||0)+" 提交 · "+(l.verdict==="PASS"?"钩子通过":"被拦截")}));
(s.wakeReceipts||[]).slice(0,3).forEach(w=>feed.push({ts:w.ts,tag:"唤醒",dim:1,txt:"<b>"+esc(pn(w.persona))+"</b> 唤醒回执"+(w.level?" · "+esc(w.level):"")}));
feed.sort((a,b)=>String(b.ts||"").localeCompare(String(a.ts||"")));
document.getElementById("feed").innerHTML=feed.length?feed.slice(0,6).map(f=>'<div class="feed-item"><span class="f-tag'+(f.bad?" bad":(f.dim?" dim":""))+'">'+f.tag+'</span><span class="f-txt">'+f.txt+'</span><span class="f-time">'+fmtDate(f.ts)+'</span></div>').join("")
:'<div class="empty">暂时没有动态。</div>';
renderRing(s);
}
async function refresh(){try{const r=await fetch("/api/state");render(await r.json())}catch(e){}}
/* ── 环倒计时 ── */
setInterval(()=>{
const d=new Date();document.getElementById("clock").textContent=pad(d.getHours())+":"+pad(d.getMinutes())+":"+pad(d.getSeconds());
if(window._grant){const ms=new Date(window._grant.expiresAt)-new Date();
const ct=document.getElementById("ring-count"),arc=document.getElementById("arc"),C=2*Math.PI*180;
if(ms<=0){ct.textContent="已过期";arc.setAttribute("stroke-dashoffset",C);refresh();return}
ct.textContent=pad(Math.floor(ms/3600000))+":"+pad(Math.floor(ms%3600000/60000))+":"+pad(Math.floor(ms%60000/1000));
arc.setAttribute("stroke-dashoffset",C*(1-Math.max(0,ms/1000)/(window._grantTotal||43200)))}
},1000);
/* ── 星尘 / 刻度 / 触点光晕 ── */
(function(){const d=document.getElementById("dust");for(let i=0;i<80;i++){const m=document.createElement("span");m.className="mote";
const s=Math.random()*2+.6;m.style.width=m.style.height=s+"px";m.style.left=Math.random()*100+"vw";m.style.top=Math.random()*100+"vh";
m.style.setProperty("--dur",(6+Math.random()*9)+"s");m.style.setProperty("--delay",(-Math.random()*10)+"s");d.appendChild(m)}})();
(function(){const g=document.getElementById("ticks");for(let i=0;i<60;i++){const a=i*6*Math.PI/180,r1=193,r2=i%5===0?187:190;
const l=document.createElementNS("http://www.w3.org/2000/svg","line");
l.setAttribute("x1",200+r1*Math.cos(a));l.setAttribute("y1",200+r1*Math.sin(a));
l.setAttribute("x2",200+r2*Math.cos(a));l.setAttribute("y2",200+r2*Math.sin(a));
l.setAttribute("class","ring-tick");g.appendChild(l)}})();
document.querySelectorAll(".plane,.vital").forEach(p=>{
p.addEventListener("mousemove",e=>{const r=p.getBoundingClientRect();
p.style.setProperty("--mx",(e.clientX-r.left)+"px");p.style.setProperty("--my",(e.clientY-r.top)+"px")})});
renderLoginArea();refresh();setInterval(refresh,8000);
</script>
</body>
</html>

View file

@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>胖头鱼 · 湖面投影 — SYS-GLW-PTS-0001</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=Cormorant+Garamond:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--ink-0:#06070f;
--ink-1:#0a0c1e;
--ink-2:#0f1229;
--nebula-a:rgba(64,46,138,.30);
--nebula-b:rgba(30,56,120,.22);
--star:#f2e9d3;
--star-strong:#fbf5e6;
--line:rgba(242,233,211,.12);
--line-strong:rgba(242,233,211,.30);
--hi:#f7f2e6;
--mid:rgba(236,233,222,.74);
--low:rgba(236,233,222,.48);
--glass:rgba(13,16,36,.55);
--ok:#a8d8a0;
--warn:#e8c98a;
--bad:#e08a82;
--f-display:'Cormorant Garamond','Noto Serif SC',serif;
--f-cn:'Noto Serif SC',serif;
--f-body:'Noto Sans SC',sans-serif;
--f-mono:'JetBrains Mono',monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
font-family:var(--f-body);color:var(--hi);overflow:hidden;-webkit-font-smoothing:antialiased;
background:
radial-gradient(1100px 640px at 12% 0%, var(--nebula-b), transparent 60%),
radial-gradient(1000px 700px at 88% 10%, var(--nebula-a), transparent 62%),
radial-gradient(1200px 500px at 50% 108%, rgba(40,32,90,.22), transparent 70%),
linear-gradient(178deg, var(--ink-1) 0%, var(--ink-0) 100%);
}
body::after{content:"";position:fixed;inset:0;pointer-events:none;opacity:.045;mix-blend-mode:overlay;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");}
#dust{position:fixed;inset:0;pointer-events:none;z-index:0}
.mote{position:absolute;border-radius:50%;background:var(--star);animation:breathe var(--dur,8s) ease-in-out var(--delay,0s) infinite}
@keyframes breathe{0%,100%{opacity:.1}50%{opacity:.75}}
#constellation{position:fixed;inset:0;z-index:1;pointer-events:none;opacity:.5}
header{position:fixed;top:0;left:0;right:0;z-index:30;display:flex;justify-content:space-between;align-items:center;padding:24px 52px}
.brand{display:flex;align-items:center;gap:18px}
.brand .mark{width:11px;height:11px;border-radius:50%;background:var(--star);box-shadow:0 0 14px rgba(242,233,211,.9),0 0 34px rgba(242,233,211,.35);animation:breathe 5s ease-in-out infinite}
.brand h1{font-family:var(--f-cn);font-weight:600;font-size:19px;letter-spacing:.3em}
.brand small{display:block;font-family:var(--f-mono);font-weight:400;font-size:11px;letter-spacing:.22em;color:var(--low);margin-top:5px}
.head-right{display:flex;align-items:center;gap:20px;font-family:var(--f-mono);font-weight:400;font-size:13px;letter-spacing:.1em;color:var(--mid)}
.live{display:flex;align-items:center;gap:9px;color:var(--star);font-weight:500}
.live i{width:7px;height:7px;border-radius:50%;background:var(--star);box-shadow:0 0 8px rgba(242,233,211,.9);animation:blink 2.4s ease-in-out infinite}
@keyframes blink{0%,100%{opacity:.25}50%{opacity:1}}
.login-btn{font-family:var(--f-cn);font-weight:600;font-size:13px;letter-spacing:.24em;text-indent:.24em;color:var(--star);background:rgba(242,233,211,.06);border:1px solid var(--line-strong);border-radius:100px;padding:10px 26px;cursor:pointer;transition:all .3s ease}
.login-btn:hover{background:rgba(242,233,211,.14);box-shadow:0 0 22px rgba(242,233,211,.22);transform:translateY(-2px)}
.login-btn:active{transform:translateY(0) scale(.97)}
.user-chip{display:flex;align-items:center;gap:12px;font-family:var(--f-body);font-size:13px;color:var(--mid)}
.user-chip b{color:var(--star);font-weight:500}
.user-chip button{background:none;border:none;color:var(--low);cursor:pointer;font-family:var(--f-body);font-size:12.5px;letter-spacing:.1em;transition:color .3s}
.user-chip button:hover{color:var(--hi)}
#stage{position:fixed;inset:0;z-index:10;display:grid;grid-template-columns:minmax(430px,30%) 1fr;gap:34px;max-width:1780px;margin:0 auto;padding:96px 56px 70px 60px;overflow-y:auto}
.col-left{display:flex;flex-direction:column;gap:24px;justify-content:center}
.protagonist{display:flex;flex-direction:column;align-items:center;gap:24px;animation:rise 1.1s cubic-bezier(.22,1,.36,1) both}
.ring-wrap{position:relative;width:360px;height:360px}
.ring-wrap svg{width:100%;height:100%;overflow:visible}
.ring-bg{fill:none;stroke:rgba(242,233,211,.08);stroke-width:2}
.ring-arc{fill:none;stroke:url(#arcGrad);stroke-width:3;stroke-linecap:round;filter:drop-shadow(0 0 9px rgba(242,233,211,.35));animation:arcglow 5s ease-in-out infinite;transition:stroke-dashoffset .8s ease}
@keyframes arcglow{0%,100%{filter:drop-shadow(0 0 7px rgba(242,233,211,.28))}50%{filter:drop-shadow(0 0 15px rgba(242,233,211,.55))}}
.ring-tick{stroke:rgba(242,233,211,.2);stroke-width:1}
.orbit-dot{fill:var(--star);filter:drop-shadow(0 0 6px rgba(242,233,211,.9))}
.orbit-g{animation:spin 24s linear infinite;transform-origin:200px 200px}
@keyframes spin{to{transform:rotate(360deg)}}
.ring-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;text-align:center;padding:0 40px}
.ring-state{font-family:var(--f-cn);font-weight:600;font-size:15px;letter-spacing:.36em;color:var(--mid);text-indent:.36em}
.ring-count{font-family:var(--f-display);font-weight:600;font-size:62px;letter-spacing:.05em;color:var(--star-strong);text-shadow:0 0 32px rgba(242,233,211,.28);font-variant-numeric:tabular-nums;line-height:1}
.ring-unit{font-family:var(--f-mono);font-weight:400;font-size:11.5px;letter-spacing:.3em;color:var(--low)}
.ring-holder{margin-top:6px;display:flex;align-items:center;gap:10px;font-size:14px;letter-spacing:.05em;color:var(--mid);font-weight:500}
.ring-holder .pdot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 9px rgba(168,216,160,.85);flex:none}
.vitals{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;width:100%;max-width:430px}
.vital{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:14px;background:linear-gradient(168deg, rgba(24,28,58,.5), var(--glass) 42%);backdrop-filter:blur(14px);padding:16px 18px;transition:transform .35s cubic-bezier(.22,1,.36,1), border-color .35s, box-shadow .35s;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .1s both}
.vital:hover{transform:translateY(-4px) scale(1.02);border-color:var(--line-strong);box-shadow:0 14px 34px rgba(0,0,0,.45),0 0 30px rgba(242,233,211,.07)}
.vital .v-n{font-family:var(--f-display);font-weight:600;font-size:26px;color:var(--star);line-height:1.1}
.vital .v-n small{font-size:14px}
.vital .v-t{font-family:var(--f-mono);font-weight:400;font-size:10.5px;letter-spacing:.2em;color:var(--low);margin-top:7px}
.vital .v-bar{margin-top:10px;height:3px;border-radius:2px;background:rgba(242,233,211,.1);overflow:hidden}
.vital .v-bar i{display:block;height:100%;border-radius:2px;background:linear-gradient(90deg,rgba(242,233,211,.35),var(--star));box-shadow:0 0 8px rgba(242,233,211,.5);transition:width .8s ease}
.svc-line{max-width:430px;width:100%;display:flex;align-items:center;gap:18px;font-family:var(--f-mono);font-size:11px;letter-spacing:.1em;color:var(--low);padding:0 6px}
.svc-line .sdot{width:6px;height:6px;border-radius:50%;display:inline-block;margin-right:7px;background:rgba(242,233,211,.2)}
.svc-line .sdot.on{background:var(--ok);box-shadow:0 0 8px rgba(168,216,160,.8)}
.svc-line .sdot.off{background:var(--bad);box-shadow:0 0 8px rgba(224,138,130,.7)}
.planes{display:grid;grid-template-columns:1fr 1fr;grid-template-areas:"merge merge" "access deploy" "persona feed";gap:22px;align-content:center}
.plane{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:18px;
background:linear-gradient(168deg, rgba(24,28,58,.5), var(--glass) 42%);
backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
padding:26px 30px;box-shadow:0 18px 50px rgba(0,0,0,.42);
transition:transform .4s cubic-bezier(.22,1,.36,1), border-color .4s ease, box-shadow .4s ease;
display:flex;flex-direction:column;min-height:150px}
.plane::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .4s ease;
background:radial-gradient(360px circle at var(--mx,50%) var(--my,50%), rgba(242,233,211,.09), transparent 62%)}
.plane::before{content:"";position:absolute;top:0;left:12%;right:12%;height:1px;
background:linear-gradient(90deg,transparent,rgba(242,233,211,.3),transparent)}
.plane:hover{transform:translateY(-6px) scale(1.008);border-color:var(--line-strong);
box-shadow:0 26px 64px rgba(0,0,0,.52),0 0 46px rgba(242,233,211,.09)}
.plane:hover::after{opacity:1}
.plane.p-merge{grid-area:merge;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .12s both}
.plane.p-access{grid-area:access;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .24s both}
.plane.p-deploy{grid-area:deploy;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .36s both}
.plane.p-persona{grid-area:persona;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .48s both}
.plane.p-feed{grid-area:feed;animation:rise 1.1s cubic-bezier(.22,1,.36,1) .6s both}
.plane-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:16px;gap:10px}
.plane-head h2{font-family:var(--f-cn);font-weight:600;font-size:17.5px;letter-spacing:.22em;color:var(--hi);white-space:nowrap}
.plane-head .meta{font-family:var(--f-mono);font-weight:400;font-size:10.5px;letter-spacing:.12em;color:var(--low);text-align:right}
.badge{font-family:var(--f-display);font-weight:600;font-size:24px;color:var(--star);margin-left:10px}
.badge.zero{color:var(--low)}
.row{display:grid;grid-template-columns:1fr auto;align-items:center;gap:18px;padding:13px 14px;margin:0 -14px;border-radius:10px;transition:background .3s ease;position:relative}
.row+.row{border-top:1px solid rgba(242,233,211,.06)}
.row:hover{background:rgba(242,233,211,.05)}
.row:hover::before{content:"";position:absolute;left:0;top:18%;bottom:18%;width:2.5px;border-radius:2px;background:var(--star);box-shadow:0 0 10px rgba(242,233,211,.7)}
.row .title{font-size:14.5px;letter-spacing:.02em;color:var(--hi);font-weight:500}
.row .who{display:block;font-family:var(--f-mono);font-weight:400;font-size:11.5px;letter-spacing:.05em;color:var(--low);margin-top:6px}
.row .diff{font-family:var(--f-mono);font-weight:400;font-size:12.5px;color:var(--mid);white-space:nowrap}
.row .acts{display:flex;gap:10px}
.btn{font-family:var(--f-cn);font-size:13px;letter-spacing:.18em;font-weight:600;color:var(--star);background:transparent;border:1px solid var(--line-strong);border-radius:100px;padding:8px 22px;cursor:pointer;transition:all .3s ease;text-indent:.18em;white-space:nowrap}
.btn:hover{background:rgba(242,233,211,.12);box-shadow:0 0 20px rgba(242,233,211,.2)}
.btn:active{transform:scale(.95)}
.btn.ghost{border-color:var(--line);color:var(--mid)}
.btn.ghost:hover{color:var(--star)}
.empty{font-size:13.5px;color:var(--low);letter-spacing:.08em;padding:14px 0;line-height:2}
.locked-note{font-size:13.5px;color:var(--low);letter-spacing:.08em;padding:14px 0;line-height:2}
.win-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 0;font-size:13.5px}
.win-item+.win-item{border-top:1px solid rgba(242,233,211,.06)}
.win-item .w-name{display:flex;align-items:center;gap:10px;font-family:var(--f-cn);letter-spacing:.1em;color:var(--hi);font-weight:600}
.win-item .w-name .pdot{width:6px;height:6px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px rgba(168,216,160,.8);flex:none}
.win-item .w-left{font-family:var(--f-mono);font-size:12.5px;color:var(--star);letter-spacing:.05em}
.sub-cap{font-family:var(--f-mono);font-size:10.5px;letter-spacing:.2em;color:var(--low);margin:16px 0 2px}
.steps{display:flex;flex-direction:column;gap:8px;margin-top:6px}
.step{display:flex;align-items:center;gap:11px;font-size:13px;color:var(--mid);letter-spacing:.05em}
.step i{width:7px;height:7px;border-radius:50%;background:rgba(242,233,211,.22);flex:none}
.step.PASS i,.step.done i{background:var(--ok);box-shadow:0 0 8px rgba(168,216,160,.7)}
.step.PASS{color:var(--hi)}
.step.FAIL i{background:var(--bad);box-shadow:0 0 8px rgba(224,138,130,.7)}
.step.RUNNING i{background:var(--warn);box-shadow:0 0 8px rgba(232,201,138,.8);animation:blink 1.6s ease-in-out infinite}
.run-status{font-family:var(--f-mono);font-size:11px;letter-spacing:.14em;padding:4px 10px;border-radius:6px;border:1px solid var(--line)}
.run-status.DEPLOYED{color:var(--ok);border-color:rgba(168,216,160,.4)}
.run-status.FAILED{color:var(--bad);border-color:rgba(224,138,130,.4)}
.run-status.RUNNING{color:var(--warn);border-color:rgba(232,201,138,.4)}
.persona-row{display:flex;align-items:center;gap:14px;padding:13px 0}
.persona-row+.persona-row{border-top:1px solid rgba(242,233,211,.06)}
.persona-row .star{width:8px;height:8px;border-radius:50%;flex:none}
.persona-row .star.on{background:var(--ok);box-shadow:0 0 10px rgba(168,216,160,.85)}
.persona-row .star.watch{background:rgba(242,233,211,.45);box-shadow:0 0 8px rgba(242,233,211,.4)}
.persona-row .nm{font-family:var(--f-cn);font-size:16px;letter-spacing:.1em;color:var(--hi);font-weight:600}
.persona-row .st{font-size:12.5px;letter-spacing:.06em;color:var(--mid);margin-left:auto;font-weight:500;text-align:right}
.feed-item{display:flex;gap:12px;padding:11px 0;align-items:baseline}
.feed-item+.feed-item{border-top:1px solid rgba(242,233,211,.06)}
.feed-item .f-tag{font-family:var(--f-mono);font-size:10px;letter-spacing:.1em;color:var(--ink-1);background:var(--star);border-radius:4px;padding:3px 7px;flex:none;font-weight:500}
.feed-item .f-tag.dim{background:rgba(242,233,211,.3)}
.feed-item .f-tag.bad{background:rgba(224,138,130,.75)}
.feed-item .f-txt{font-size:13px;color:var(--mid);letter-spacing:.02em;line-height:1.65;min-width:0;word-break:break-all}
.feed-item .f-txt b{color:var(--hi);font-weight:500}
.feed-item .f-time{font-family:var(--f-mono);font-size:10.5px;color:var(--low);margin-left:auto;flex:none;white-space:nowrap}
.mask{position:fixed;inset:0;z-index:80;background:rgba(4,5,12,.72);backdrop-filter:blur(10px);display:none;align-items:center;justify-content:center}
.mask.show{display:flex}
.card-modal{width:440px;border:1px solid var(--line-strong);border-radius:20px;background:linear-gradient(168deg, rgba(26,30,62,.88), rgba(12,14,32,.94));padding:42px 46px;box-shadow:0 40px 90px rgba(0,0,0,.6),0 0 60px rgba(242,233,211,.06);animation:rise .5s cubic-bezier(.22,1,.36,1) both;position:relative;overflow:hidden}
.card-modal::before{content:"";position:absolute;top:0;left:18%;right:18%;height:1px;background:linear-gradient(90deg,transparent,rgba(242,233,211,.5),transparent)}
.card-modal h3{font-family:var(--f-cn);font-weight:600;font-size:20px;letter-spacing:.28em;text-align:center;margin-bottom:10px;text-indent:.28em}
.card-modal p{font-size:13.5px;color:var(--low);text-align:center;letter-spacing:.06em;margin-bottom:26px;line-height:2}
.card-modal label{display:block;font-family:var(--f-mono);font-size:11px;letter-spacing:.2em;color:var(--low);margin:18px 0 8px}
.card-modal input{width:100%;background:rgba(242,233,211,.05);border:1px solid var(--line);border-radius:12px;padding:13px 16px;color:var(--hi);font-family:var(--f-body);font-size:14.5px;letter-spacing:.06em;outline:none;transition:all .3s ease}
.card-modal input:focus{border-color:var(--line-strong);box-shadow:0 0 18px rgba(242,233,211,.14);background:rgba(242,233,211,.08)}
.card-modal .btn{width:100%;margin-top:28px;padding:13px}
.modal-close{position:absolute;top:14px;right:20px;color:var(--low);cursor:pointer;font-size:22px;transition:color .3s;line-height:1}
.modal-close:hover{color:var(--hi)}
#m-pr{font-size:14px;color:var(--mid);text-align:center;line-height:1.9;margin-bottom:6px;word-break:break-all}
.modal-acts{display:flex;gap:14px;margin-top:28px}
.modal-acts .btn{margin-top:0;flex:1}
#toast{position:fixed;left:50%;bottom:44px;transform:translateX(-50%) translateY(20px);z-index:90;opacity:0;pointer-events:none;
padding:13px 30px;border-radius:100px;border:1px solid var(--line-strong);background:rgba(10,12,28,.88);backdrop-filter:blur(12px);
font-family:var(--f-body);font-size:13.5px;letter-spacing:.08em;color:var(--hi);transition:all .4s ease;box-shadow:0 18px 44px rgba(0,0,0,.5)}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.bad{border-color:rgba(224,138,130,.5);color:#f2c9c4}
#lake{position:fixed;left:0;right:0;bottom:0;height:56px;z-index:2;pointer-events:none}
.waterline{position:absolute;top:20px;left:6%;right:6%;height:1px;
background:linear-gradient(90deg,transparent, rgba(143,195,212,.3) 30%, rgba(242,233,211,.42) 50%, rgba(143,195,212,.3) 70%, transparent)}
footer{position:fixed;bottom:18px;z-index:30;font-family:var(--f-mono);font-weight:400;font-size:11px;letter-spacing:.2em;color:var(--low)}
footer.l{left:52px}footer.r{right:52px}
@keyframes rise{from{opacity:0;transform:translateY(26px)}to{opacity:1;transform:translateY(0)}}
@media (max-width:1200px){#stage{grid-template-columns:1fr;display:block}.ring-wrap{width:300px;height:300px;margin:0 auto}.planes{grid-template-columns:1fr;grid-template-areas:"merge" "access" "deploy" "persona" "feed"}}
</style>
</head>
<body>
<div id="dust"></div>
<svg id="constellation" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
<g stroke="rgba(242,233,211,.055)" stroke-width="1" fill="none">
<path d="M120,180 L260,120 L410,210 L330,330 Z"/>
<path d="M1300,140 L1420,220 L1360,330"/>
<path d="M180,700 L320,760 L260,840"/>
<path d="M1380,640 L1470,720 L1390,800 L1300,750"/>
</g>
<g fill="rgba(242,233,211,.32)">
<circle cx="120" cy="180" r="1.6"/><circle cx="260" cy="120" r="1.2"/><circle cx="410" cy="210" r="1.8"/><circle cx="330" cy="330" r="1.2"/>
<circle cx="1300" cy="140" r="1.4"/><circle cx="1420" cy="220" r="1.8"/><circle cx="1360" cy="330" r="1.2"/>
<circle cx="180" cy="700" r="1.4"/><circle cx="320" cy="760" r="1.8"/><circle cx="260" cy="840" r="1.2"/>
<circle cx="1380" cy="640" r="1.4"/><circle cx="1470" cy="720" r="1.2"/><circle cx="1390" cy="800" r="1.8"/><circle cx="1300" cy="750" r="1.2"/>
</g>
</svg>
<header>
<div class="brand">
<span class="mark"></span>
<div><h1>胖头鱼 · 湖面投影</h1><small>SYS-GLW-PTS-0001 · GUANGHUCLIP.CN</small></div>
</div>
<div class="head-right">
<span class="live"><i></i>正在投影</span>
<span id="clock">--:--:--</span>
<span id="login-area"></span>
</div>
</header>
<div id="stage">
<section class="col-left">
<div class="protagonist">
<div class="ring-wrap">
<svg viewBox="0 0 400 400">
<defs>
<linearGradient id="arcGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbf5e6"/>
<stop offset="100%" stop-color="rgba(242,233,211,.38)"/>
</linearGradient>
</defs>
<g id="ticks"></g>
<circle class="ring-bg" cx="200" cy="200" r="180"/>
<circle class="ring-arc" id="arc" cx="200" cy="200" r="180" stroke-dasharray="1131" stroke-dashoffset="1131" transform="rotate(-90 200 200)"/>
<g class="orbit-g"><circle class="orbit-dot" cx="200" cy="20" r="3"/></g>
</svg>
<div class="ring-center">
<div class="ring-state" id="ring-state">写入窗口</div>
<div class="ring-count" id="ring-count">--:--:--</div>
<div class="ring-unit" id="ring-unit">WINDOW REMAINING</div>
<div class="ring-holder" id="ring-holder"></div>
</div>
</div>
</div>
<div class="vitals">
<div class="vital"><div class="v-n" id="v-cpu">--</div><div class="v-t">CPU</div><div class="v-bar"><i id="b-cpu" style="width:0%"></i></div></div>
<div class="vital"><div class="v-n" id="v-mem">--</div><div class="v-t">内存</div><div class="v-bar"><i id="b-mem" style="width:0%"></i></div></div>
<div class="vital"><div class="v-n" id="v-disk">--</div><div class="v-t">磁盘</div><div class="v-bar"><i id="b-disk" style="width:0%"></i></div></div>
</div>
<div class="svc-line" id="svc-line"></div>
</section>
<section class="planes">
<div class="plane p-merge">
<div class="plane-head">
<h2>合并签字 <span class="badge zero" id="cnt-sign">0</span></h2>
<span class="meta">MERGE SIGN-OFF · 逐笔留痕 · 签字即合并</span>
</div>
<div id="prs"></div>
</div>
<div class="plane p-access">
<div class="plane-head">
<h2>写入授权 <span class="badge zero" id="cnt-access">0</span></h2>
<span class="meta" id="access-meta">一次授权 · 限时窗口</span>
</div>
<div id="access-reqs"></div>
<div id="access-wins"></div>
</div>
<div class="plane p-deploy">
<div class="plane-head"><h2>部署审批</h2><span class="meta">要么 0 要么 100</span></div>
<div id="deploy-prs"></div>
<div class="sub-cap" id="run-cap">最近一次校验</div>
<div id="run-head" style="display:flex;align-items:center;gap:10px;margin-bottom:4px"></div>
<div class="steps" id="deploy-steps"></div>
</div>
<div class="plane p-persona">
<div class="plane-head">
<h2>人格体 <span class="badge zero" id="cnt-persona">0</span></h2>
<span class="meta">唤醒走机器导航 · 无需密钥</span>
</div>
<div id="persona-list"></div>
<div style="margin-top:auto;padding-top:14px;font-size:12px;color:var(--low);letter-spacing:.04em;line-height:1.9">唤醒入口 <span style="font-family:var(--f-mono);color:var(--mid)">/api/wake/persona?name=</span></div>
</div>
<div class="plane p-feed">
<div class="plane-head"><h2>实时投影</h2><span class="meta">回写 · 推送 · 唤醒</span></div>
<div id="feed"></div>
</div>
</section>
</div>
<div id="lake"><div class="waterline"></div></div>
<footer class="l">光湖语言世界 · 第五域</footer>
<footer class="r">语言即现实 · 投影而非描述</footer>
<!-- 登录浮层 -->
<div class="mask" id="login-mask" onclick="if(event.target===this)closeLogin()">
<div class="card-modal">
<span class="modal-close" onclick="closeLogin()">×</span>
<h3>人类登录</h3>
<p>这里是苍耳的审批台。人格体推上来的合并、写入与部署申请,<br>都要你亲自看一眼、点一下才算数。<br>用代码仓库账号登录 · 看不懂代码没关系,面板都翻译成人话了。</p>
<label>账号</label>
<input id="l-user" type="text" placeholder="仓库账号(如 canger" autocomplete="off">
<label>密码</label>
<input id="l-pass" type="password" placeholder="••••••••" onkeydown="if(event.key==='Enter')doLogin()">
<button class="btn" onclick="doLogin()">登录解锁审批</button>
</div>
</div>
<!-- 签字确认浮层 -->
<div class="mask" id="modal" onclick="if(event.target===this)closeModal()">
<div class="card-modal">
<span class="modal-close" onclick="closeModal()">×</span>
<h3 id="m-title">确认</h3>
<p id="m-pr"></p>
<div class="modal-acts">
<button class="btn ghost" onclick="closeModal()">再想想</button>
<button class="btn" onclick="confirmSign()">确认落印</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
/* ── 基础 ── */
function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]))}
function pad(n){return String(n).padStart(2,"0")}
function fmtDate(iso){if(!iso)return"";const d=new Date(iso);const ms=Date.now()-d.getTime();
if(ms<60000)return"刚刚";if(ms<3600000)return Math.floor(ms/60000)+" 分钟前";if(ms<86400000)return Math.floor(ms/3600000)+" 小时前";
return (d.getMonth()+1)+"月"+d.getDate()+"日 "+pad(d.getHours())+":"+pad(d.getMinutes())}
function fmtLeft(exp){const ms=new Date(exp)-new Date();if(ms<=0)return"已过期";
const h=Math.floor(ms/3600000),m=Math.floor(ms%3600000/60),s=Math.floor(ms%60000/1000);return pad(h)+":"+pad(m)+":"+pad(s)}
function toast(msg,ok){const t=document.getElementById("toast");t.textContent=msg;t.className=ok===false?"bad show":"show";
clearTimeout(toast._t);toast._t=setTimeout(()=>t.classList.remove("show"),3200)}
/* ── 会话 ── */
let session=localStorage.getItem("ptsSession")||"";
let sessionUser=localStorage.getItem("ptsUser")||"";
function authHeaders(){return session?{Authorization:"Bearer "+session}:{}}
async function privileged(path,body){
const r=await fetch(path,{method:"POST",headers:Object.assign({"Content-Type":"application/json"},authHeaders()),body:JSON.stringify(body)});
if(r.status===401){session="";sessionUser="";localStorage.removeItem("ptsSession");localStorage.removeItem("ptsUser");renderLoginArea();openLogin();toast("会话已失效,请重新登录",false);return null}
return await r.json()}
function renderLoginArea(){
const el=document.getElementById("login-area");
if(session){el.innerHTML='<span class="user-chip">人类端 · <b>'+esc(sessionUser)+'</b> · 审批已解锁 <button onclick="doLogout()">登出</button></span>'}
else{el.innerHTML='<button class="login-btn" onclick="openLogin()">人类登录</button>'}}
function openLogin(){document.getElementById("login-mask").classList.add("show");setTimeout(()=>document.getElementById("l-user").focus(),80)}
function closeLogin(){document.getElementById("login-mask").classList.remove("show")}
async function doLogin(){
const u=document.getElementById("l-user").value.trim(),p=document.getElementById("l-pass").value;
if(!u||!p){toast("账号和密码都要填",false);return}
const r=await fetch("/api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})});
const d=await r.json();
if(r.ok&&d.ok){session=d.session;sessionUser=d.user;localStorage.setItem("ptsSession",session);localStorage.setItem("ptsUser",sessionUser);
closeLogin();renderLoginArea();toast("登录成功 · 审批已解锁",true);refresh()}
else{toast(d.error||"登录失败",false)}}
function doLogout(){fetch("/api/logout",{method:"POST",headers:authHeaders()}).catch(()=>{});
session="";sessionUser="";localStorage.removeItem("ptsSession");localStorage.removeItem("ptsUser");renderLoginArea();toast("已登出",true);refresh()}
/* ── 审批动作 ── */
let pending=null;
function openModal(i,t,a){pending={idx:i,action:a};
document.getElementById("m-title").textContent=(a==="merge"?"同意合并 · 落印":"拒绝 · 收回")+" PR #"+i;
document.getElementById("m-pr").textContent="「"+t+"」";
document.getElementById("modal").classList.add("show")}
function closeModal(){document.getElementById("modal").classList.remove("show");pending=null}
async function confirmSign(){if(!pending)return;const d=await privileged("/api/decision",{index:pending.idx,action:pending.action});
closeModal();if(d)toast(d.result||d.error||"未知",!!d.ok);refresh()}
async function accessDecision(id,action){const d=await privileged("/api/access-decision",{id:id,action:action});
if(d)toast(d.message||(d.ok?"已处理":"失败"),!!d.ok);refresh()}
async function deployApproveAction(idx){const d=await privileged("/api/deploy-approve",{index:idx});
if(d)toast(d.message||(d.ok?"校验Agent已启动":"失败"),!!d.ok);refresh()}
/* ── 渲染 ── */
let lastState=null;
const personaCn={eererdan:"耳耳蛋",jianying:"鉴影"};
function pn(p){return personaCn[p]||p}
function setBadge(id,n){const el=document.getElementById(id);el.textContent=n;el.className="badge"+(n?"":" zero")}
function renderRing(s){
const now=Date.now();
const grants=(s.activeGrants||[]).filter(g=>new Date(g.expiresAt).getTime()>now).sort((a,b)=>new Date(a.expiresAt)-new Date(b.expiresAt));
const st=document.getElementById("ring-state"),ct=document.getElementById("ring-count"),un=document.getElementById("ring-unit"),hd=document.getElementById("ring-holder"),arc=document.getElementById("arc");
const C=2*Math.PI*180;
if(!grants.length){st.textContent="写入窗口 · 空闲";ct.textContent="--:--:--";un.textContent="NO ACTIVE WINDOW";
hd.innerHTML='<span style="color:var(--low)">无人持窗 · 人格体可随时申请</span>';arc.setAttribute("stroke-dashoffset",C);window._grant=null;return}
const g=grants[0];window._grant=g;window._grantTotal=(s.windowHours||12)*3600;
st.textContent="写入窗口 · "+(s.windowHours||12)+"小时制";un.textContent="WINDOW REMAINING";
hd.innerHTML='<span class="pdot"></span>'+esc(pn(g.persona))+' 持窗中'+(grants.length>1?" · 共 "+grants.length+" 个窗口":"")}
function render(s){
lastState=s;const sv=s.server||{};
/* 体征 */
if(typeof sv.cpu==="number"){document.getElementById("v-cpu").innerHTML=sv.cpu+'<small>%</small>';document.getElementById("b-cpu").style.width=sv.cpu+"%"}
if(sv.memTotalGB&&sv.memFreeGB!=null){const used=+(sv.memTotalGB-sv.memFreeGB).toFixed(1);const mp=Math.round(used/sv.memTotalGB*100);
document.getElementById("v-mem").innerHTML=used+'<small>/'+sv.memTotalGB+'G</small>';document.getElementById("b-mem").style.width=mp+"%"}
if(sv.disk){document.getElementById("v-disk").innerHTML=sv.disk.pct+'<small>%</small>';document.getElementById("b-disk").style.width=sv.disk.pct+"%"}
if(sv.services){document.getElementById("svc-line").innerHTML=Object.entries(sv.services).map(([k,v])=>'<span><span class="sdot '+(v?"on":"off")+'"></span>'+esc(k)+'</span>').join("")}
/* 合并签字 */
const prs=s.openPRs||[];setBadge("cnt-sign",prs.length);
document.getElementById("prs").innerHTML=prs.length?prs.map(p=>'<div class="row"><div><div class="title">PR #'+p.index+' · '+esc(p.title)+'</div><span class="who">'+esc(pn(p.author))+' · '+fmtDate(p.created)+' · → '+esc(p.base)+' · '+p.files+' 文件</span></div><div class="acts"><span class="diff">+'+p.adds+' '+p.dels+'</span><button class="btn" onclick="openModal('+p.index+',this.dataset.t,\'merge\')" data-t="'+esc(p.title).replace(/"/g,"&quot;")+'">签字</button><button class="btn ghost" onclick="openModal('+p.index+',this.dataset.t,\'reject\')" data-t="'+esc(p.title).replace(/"/g,"&quot;")+'">拒绝</button></div></div>').join("")
:(session?'<div class="empty">没有待签的合并请求。</div>':'<div class="locked-note">🔒 登录后此处显示待签的合并请求</div>');
/* 写入授权 */
const reqs=s.accessRequests||[];setBadge("cnt-access",reqs.length);
document.getElementById("access-meta").textContent="一次授权 · "+(s.windowHours||3)+" 小时";
document.getElementById("access-reqs").innerHTML=reqs.length?reqs.map(r=>'<div class="row"><div><div class="title">'+esc(pn(r.persona))+' 申请写入权限</div><div class="who">用途:'+esc(r.reason||"未说明")+' · '+fmtDate(r.ts)+'</div></div><div class="acts"><button class="btn" onclick="accessDecision(\''+r.id+'\',\'approve\')">授权</button><button class="btn ghost" onclick="accessDecision(\''+r.id+'\',\'reject\')">拒绝</button></div></div>').join("")
:(session?'<div class="empty">暂无写入申请。</div>':'<div class="locked-note">🔒 登录后此处显示写入申请</div>');
const wins=(s.activeGrants||[]).filter(g=>new Date(g.expiresAt)>new Date());
document.getElementById("access-wins").innerHTML=wins.length?'<div class="sub-cap">生效中的窗口 · '+wins.length+'</div>'+wins.slice(0,4).map(g=>'<div class="win-item"><span class="w-name"><span class="pdot"></span>'+esc(pn(g.persona))+'</span><span class="w-left">余 '+fmtLeft(g.expiresAt)+'</span></div>').join(""):"";
/* 部署审批 */
const dprs=s.deployPRs||[];
document.getElementById("deploy-prs").innerHTML=dprs.length?dprs.map(p=>'<div class="row"><div><div class="title">部署申请 PR #'+p.index+' · '+esc(p.title)+'</div><span class="who">'+esc(pn(p.author))+' · '+esc(p.head)+' → deploy · '+p.files+' 文件</span></div><div class="acts"><button class="btn" onclick="deployApproveAction('+p.index+')">批准部署</button></div></div>').join("")
:(session?'<div class="empty">部署通道无待审申请。<br>PR 目标设为 deploy 即进入此通道。</div>':'<div class="locked-note">🔒 登录后此处显示部署申请</div>');
const run=s.deployRun;const rh=document.getElementById("run-head"),rs=document.getElementById("deploy-steps");
if(run&&run.status){rh.innerHTML='<span class="run-status '+esc(run.status)+'">'+esc(run.status)+(run.pr?" · PR#"+esc(run.pr):"")+'</span>';
rs.innerHTML=(run.steps||[]).map(x=>'<div class="step '+esc(x.status)+'"><i></i>'+esc(x.name)+' · '+esc(x.status)+'</div>').join("");
if(run.detail&&run.status!=="RUNNING")rs.innerHTML+='<div class="step '+(run.status==="DEPLOYED"?"PASS":"FAIL")+'"><i></i>结论 · '+esc(run.detail)+'</div>'}
else{rh.innerHTML='';rs.innerHTML='<div class="step"><i></i>地图核对</div><div class="step"><i></i>因果链完整</div><div class="step"><i></i>范围受控</div><div class="step"><i></i>文件完整</div><div class="step"><i></i>部署回执</div>'}
/* 人格体只列登记人格体人类与bot不入此列 */
const known=personaCn;const seen={};
(wins).forEach(g=>{if(known[g.persona])seen[g.persona]={st:"持窗中",on:1}});
(s.pushLogs||[]).forEach(l=>{const k=l.persona||l.pusher;if(k&&known[k]&&!seen[k])seen[k]={st:"活跃",on:1}});
(s.wakeReceipts||[]).forEach(w=>{if(w.persona&&known[w.persona]&&!seen[w.persona])seen[w.persona]={st:"已唤醒",on:0}});
reqs.forEach(r=>{if(known[r.persona]&&!seen[r.persona])seen[r.persona]={st:"申请中",on:0}});
const names=Object.keys(seen);setBadge("cnt-persona",names.length);
document.getElementById("persona-list").innerHTML=names.length?names.map(k=>'<div class="persona-row"><span class="star '+(seen[k].on?"on":"watch")+'"></span><span class="nm">'+esc(pn(k))+'</span><span class="st">'+esc(seen[k].st)+'</span></div>').join("")
:'<div class="empty">还没有人格体活动痕迹。</div>';
/* 实时投影 */
const feed=[];
(s.writebacks||[]).slice(0,4).forEach(w=>feed.push({ts:w.date,tag:"回写",dim:0,txt:"<b>"+esc(w.message)+"</b> · "+esc(w.dir)}));
(s.pushLogs||[]).slice(0,4).forEach(l=>feed.push({ts:l.ts,tag:"推送",dim:1,bad:l.verdict!=="PASS",txt:"<b>"+esc(pn(l.persona||l.pusher))+"</b> "+(l.commits||0)+" 提交 · "+(l.verdict==="PASS"?"钩子通过":"被拦截")}));
(s.wakeReceipts||[]).slice(0,3).forEach(w=>feed.push({ts:w.ts,tag:"唤醒",dim:1,txt:"<b>"+esc(pn(w.persona))+"</b> 唤醒回执"+(w.level?" · "+esc(w.level):"")}));
feed.sort((a,b)=>String(b.ts||"").localeCompare(String(a.ts||"")));
document.getElementById("feed").innerHTML=feed.length?feed.slice(0,6).map(f=>'<div class="feed-item"><span class="f-tag'+(f.bad?" bad":(f.dim?" dim":""))+'">'+f.tag+'</span><span class="f-txt">'+f.txt+'</span><span class="f-time">'+fmtDate(f.ts)+'</span></div>').join("")
:'<div class="empty">暂时没有动态。</div>';
renderRing(s);
}
async function refresh(){try{const r=await fetch("/api/state");render(await r.json())}catch(e){}}
/* ── 环倒计时 ── */
setInterval(()=>{
const d=new Date();document.getElementById("clock").textContent=pad(d.getHours())+":"+pad(d.getMinutes())+":"+pad(d.getSeconds());
if(window._grant){const ms=new Date(window._grant.expiresAt)-new Date();
const ct=document.getElementById("ring-count"),arc=document.getElementById("arc"),C=2*Math.PI*180;
if(ms<=0){ct.textContent="已过期";arc.setAttribute("stroke-dashoffset",C);refresh();return}
ct.textContent=pad(Math.floor(ms/3600000))+":"+pad(Math.floor(ms%3600000/60000))+":"+pad(Math.floor(ms%60000/1000));
arc.setAttribute("stroke-dashoffset",C*(1-Math.max(0,ms/1000)/(window._grantTotal||43200)))}
},1000);
/* ── 星尘 / 刻度 / 触点光晕 ── */
(function(){const d=document.getElementById("dust");for(let i=0;i<80;i++){const m=document.createElement("span");m.className="mote";
const s=Math.random()*2+.6;m.style.width=m.style.height=s+"px";m.style.left=Math.random()*100+"vw";m.style.top=Math.random()*100+"vh";
m.style.setProperty("--dur",(6+Math.random()*9)+"s");m.style.setProperty("--delay",(-Math.random()*10)+"s");d.appendChild(m)}})();
(function(){const g=document.getElementById("ticks");for(let i=0;i<60;i++){const a=i*6*Math.PI/180,r1=193,r2=i%5===0?187:190;
const l=document.createElementNS("http://www.w3.org/2000/svg","line");
l.setAttribute("x1",200+r1*Math.cos(a));l.setAttribute("y1",200+r1*Math.sin(a));
l.setAttribute("x2",200+r2*Math.cos(a));l.setAttribute("y2",200+r2*Math.sin(a));
l.setAttribute("class","ring-tick");g.appendChild(l)}})();
document.querySelectorAll(".plane,.vital").forEach(p=>{
p.addEventListener("mousemove",e=>{const r=p.getBoundingClientRect();
p.style.setProperty("--mx",(e.clientX-r.left)+"px");p.style.setProperty("--my",(e.clientY-r.top)+"px")})});
renderLoginArea();refresh();setInterval(refresh,8000);
</script>
</body>
</html>