Files
edict/dashboard/dashboard.html
Matt Van Horn f4a019a88d feat: add smooth animations to kanban dashboard (#293)
添加卡片加载渐入动画、主题切换平滑过渡、Tab 数字计数器动画,纯 CSS + vanilla JS,尊重 prefers-reduced-motion
2026-04-20 00:23:01 +08:00

3350 lines
182 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>军机处 · 三省六部总控台</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@700;900&display=swap');
:root{
--bg:#07090f;--panel:#0f1219;--panel2:#141824;--line:#1c2236;
--text:#dde4f8;--muted:#5a6b92;--ok:#2ecc8a;--warn:#f5c842;--danger:#ff5270;
--acc:#6a9eff;--acc2:#a07aff;
}
html.light{
--bg:#f4f5f7;--panel:#ffffff;--panel2:#ebedf0;--line:#d8dce6;
--text:#1a1d26;--muted:#6b7280;--ok:#16a34a;--warn:#d97706;--danger:#dc2626;
--acc:#3b82f6;--acc2:#7c3aed;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:"PingFang SC",Inter,-apple-system,"Segoe UI",sans-serif;min-height:100vh;transition:background-color .3s ease,color .2s ease}
::-webkit-scrollbar{width:4px;height:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#1e2538;border-radius:4px}
html.light ::-webkit-scrollbar-thumb{background:#c4c8d4}
.theme-toggle{background:var(--panel);border:1px solid var(--line);border-radius:8px;cursor:pointer;font-size:16px;padding:4px 10px;color:var(--text);transition:all .15s}
.theme-toggle:hover{background:var(--panel2)}
.wrap{max-width:1400px;margin:0 auto;padding:16px}
.wrap,.chip{transition:background-color .3s ease,color .2s ease,border-color .2s ease}
/* HEADER */
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid var(--line)}
.logo{font-size:20px;font-weight:800;background:linear-gradient(135deg,#6a9eff,#a07aff);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.sub{font-size:11px;color:var(--muted)}
.hdr-r{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.chip{font-size:11px;padding:3px 9px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:var(--muted)}
.chip.ok{border-color:#2ecc8a44;color:var(--ok)}.chip.warn{border-color:#f5c84244;color:var(--warn)}.chip.err{border-color:#ff527044;color:var(--danger)}
.btn-refresh{font-size:11px;padding:4px 10px;border:1px solid var(--acc);border-radius:6px;background:transparent;color:var(--acc);cursor:pointer}
.btn-refresh:hover{background:#0a1228}
#cd{font-size:11px;color:var(--muted)}
/* TABS */
.tabs{display:flex;gap:2px;margin-bottom:18px;border-bottom:1px solid var(--line);overflow-x:auto}
.tab{font-size:13px;padding:8px 16px;border-radius:8px 8px 0 0;cursor:pointer;color:var(--muted);border:1px solid transparent;border-bottom:none;white-space:nowrap;position:relative;bottom:-1px;transition:background-color .3s ease,color .2s ease,border-color .2s ease;user-select:none}
.tab:hover{color:var(--text);background:var(--panel)}
.tab.active{color:var(--text);background:var(--panel);border-color:var(--line);font-weight:600}
.tbadge{font-size:10px;padding:1px 5px;border-radius:999px;background:#1a2040;color:var(--acc);margin-left:4px}
.panel{display:none}.panel.active{display:block}
/* ══ 旨意看板 ══ */
.edict-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px}
.edict-card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px;cursor:pointer;transition:background-color .3s ease,color .2s ease,border-color .15s,transform .1s,box-shadow .15s}
@keyframes fadeSlideIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.edict-card{animation:fadeSlideIn .3s ease both}
.edict-grid .edict-card:nth-child(1){animation-delay:0s}
.edict-grid .edict-card:nth-child(2){animation-delay:.05s}
.edict-grid .edict-card:nth-child(3){animation-delay:.06s}
.edict-grid .edict-card:nth-child(4){animation-delay:.08s}
.edict-grid .edict-card:nth-child(5){animation-delay:.1s}
.edict-grid .edict-card:nth-child(n+6){animation-delay:.12s}
.edict-card:hover{border-color:var(--acc);transform:translateY(-2px);box-shadow:0 4px 20px rgba(106,158,255,.1)}
/* pipeline strip on card */
.ec-pipe{display:flex;align-items:center;gap:0;margin-bottom:14px;overflow-x:auto;padding-bottom:2px}
.ep-node{display:flex;flex-direction:column;align-items:center;gap:1px;padding:5px 8px;border-radius:6px;flex-shrink:0;min-width:52px}
.ep-node.done{background:#0a2018}
.ep-node.active{background:#0f1a38;border:1px solid var(--acc)}
.ep-node.pending{opacity:.3}
.ep-icon{font-size:14px}
.ep-name{font-size:9px;color:var(--muted);white-space:nowrap}
.ep-node.done .ep-name{color:var(--ok)}
.ep-node.active .ep-name{color:var(--acc);font-weight:700}
.ep-arrow{font-size:10px;color:#1c2236;padding:0 1px;flex-shrink:0}
.ec-id{font-size:10px;color:var(--acc);font-weight:700;letter-spacing:.04em;margin-bottom:5px}
.ec-title{font-size:15px;font-weight:700;line-height:1.4;margin-bottom:10px;color:var(--text)}
.ec-meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-bottom:8px}
.tag{font-size:10px;padding:2px 7px;border-radius:4px;border:1px solid;display:inline-block;white-space:nowrap}
/* state colors */
.st-Inbox{border-color:#3a4a7a44;color:#7a9aff;background:#0a1028}
.st-Zhongshu{border-color:#a07aff44;color:#a07aff;background:#110a28}
.st-Taizi{border-color:#e8a04044;color:#e8a040;background:#281a08}
.st-Menxia{border-color:#ff9a6a44;color:#ff9a6a;background:#280f0a}
.st-Assigned,.st-Doing{border-color:#6a9eff44;color:#6a9eff;background:#0a1428}
.st-Review{border-color:#f5c84244;color:#f5c842;background:#201a08}
.st-Done{border-color:#2ecc8a44;color:var(--ok);background:#0a2018}
.st-Blocked{border-color:#ff527044;color:var(--danger);background:#200a10}
.st-Cancelled{border-color:#88888844;color:#888;background:#1a1a1a}
.st-Next{border-color:#4a9adf44;color:#4a9adf;background:#0a1424}
/* dept colors */
.dt-中书省{border-color:#a07aff44;color:#a07aff;background:#1a0f38}
.dt-门下省{border-color:#6a9eff44;color:#6a9eff;background:#0f1a38}
.dt-尚书省{border-color:#6aef9a44;color:#6aef9a;background:#0a2018}
.dt-礼部{border-color:#f5c84244;color:#f5c842;background:#201a08}
.dt-户部{border-color:#ff9a6a44;color:#ff9a6a;background:#28100a}
.dt-兵部{border-color:#ff527044;color:#ff5270;background:#280a10}
.dt-刑部{border-color:#cc444444;color:#cc4444;background:#280808}
.dt-工部{border-color:#44aaff44;color:#44aaff;background:#081828}
.ec-footer{display:flex;align-items:center;justify-content:space-between;margin-top:10px;flex-wrap:wrap;gap:6px}
.hb{font-size:10px;padding:2px 7px;border-radius:999px;border:1px solid var(--line)}
.hb.active{border-color:#2ecc8a44;color:var(--ok)}.hb.warn{border-color:#f5c84244;color:var(--warn)}.hb.stalled{border-color:#ff527044;color:var(--danger);animation:pulse 1.5s infinite}.hb.unknown{color:var(--muted)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
/* ══ TASK DETAIL MODAL ══ */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:100;display:none;backdrop-filter:blur(3px);overflow-y:auto}
.modal-bg.open{display:flex;align-items:flex-start;justify-content:center;padding:40px 16px}
.modal{background:var(--panel);border:1px solid var(--line);border-radius:18px;width:100%;max-width:760px;padding:28px;position:relative;box-shadow:0 20px 60px rgba(0,0,0,.6)}
.modal-close{position:absolute;top:16px;right:16px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;border-radius:8px;cursor:pointer;font-size:18px;color:var(--muted)}
.modal-close:hover{background:var(--panel2);color:var(--text)}
.modal-id{font-size:11px;color:var(--acc);font-weight:700;letter-spacing:.04em;margin-bottom:6px}
.modal-title{font-size:22px;font-weight:800;line-height:1.3;margin-bottom:18px}
/* full pipeline in modal */
.m-pipe{display:flex;align-items:stretch;gap:0;overflow-x:auto;padding:16px;background:var(--panel2);border-radius:12px;margin-bottom:20px}
.mp-stage{display:flex;align-items:center;flex-shrink:0}
.mp-node{display:flex;flex-direction:column;align-items:center;gap:4px;padding:10px 14px;border-radius:10px;min-width:80px;position:relative}
.mp-node.done{background:#0a2018;border:1px solid #2ecc8a44}
.mp-node.active{background:#0f1838;border:2px solid var(--acc);box-shadow:0 0 14px rgba(106,158,255,.2)}
.mp-node.pending{opacity:.25;border:1px dashed var(--line)}
.mp-icon{font-size:22px}
.mp-dept{font-size:12px;font-weight:700;margin-top:2px}
.mp-node.done .mp-dept{color:var(--ok)}
.mp-node.active .mp-dept{color:var(--acc)}
.mp-node.pending .mp-dept{color:var(--muted)}
.mp-action{font-size:10px;color:var(--muted);margin-top:1px}
.mp-node.active .mp-action{color:#6a9eff88}
.mp-done-tick{position:absolute;top:-6px;right:-6px;width:16px;height:16px;background:var(--ok);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:#000;font-weight:700}
.mp-arrow{color:#1c2236;font-size:18px;padding:0 6px;margin-top:-10px}
/* current stage banner */
.cur-stage{display:flex;align-items:center;gap:10px;padding:12px 16px;background:#0a1228;border:1px solid var(--acc);border-radius:10px;margin-bottom:18px}
.cs-icon{font-size:24px}
.cs-info .cs-dept{font-size:16px;font-weight:700;color:var(--acc)}
.cs-info .cs-action{font-size:12px;color:var(--muted);margin-top:2px}
.cs-hb{margin-left:auto}
/* flow log */
.m-section{margin-bottom:18px}
.m-sec-label{font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.06em;text-transform:uppercase;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--line)}
.fl-timeline{display:flex;flex-direction:column;gap:0;position:relative}
.fl-timeline::before{content:'';position:absolute;left:60px;top:0;bottom:0;width:1px;background:var(--line)}
.fl-item{display:flex;gap:0;position:relative;padding:8px 0}
.fl-time{min-width:60px;font-size:10px;color:var(--muted);text-align:right;padding-right:14px;flex-shrink:0;padding-top:3px}
.fl-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;margin-top:3px;position:relative;z-index:1}
.fl-content{padding-left:12px;flex:1}
.fl-who{font-size:12px;margin-bottom:2px}
.fl-who .from{font-weight:700}
.fl-who .to{font-weight:700}
.fl-rem{font-size:11px;color:var(--muted);line-height:1.5}
.fl-ok{color:var(--ok)}.fl-warn{color:var(--warn)}.fl-blue{color:var(--acc)}.fl-purple{color:var(--acc2)}
/* meta rows */
.m-rows{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.m-row{background:var(--panel2);border-radius:8px;padding:10px 12px}
.mr-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:3px}
.mr-val{font-size:13px;font-weight:600;word-break:break-all}
/* ══ 省部调度 tab ══ */
.duty-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:12px}
.duty-card{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden;transition:background-color .3s ease,color .2s ease,border-color .15s}
.duty-card:hover{border-color:#2e3d6a}
.duty-card.active-card{border-color:var(--acc)}
.duty-card.blocked-card{border-color:#ff527055}
.dc-hdr{display:flex;align-items:center;gap:10px;padding:12px 16px;background:var(--panel2);border-bottom:1px solid var(--line)}
.dc-emoji{font-size:22px}
.dc-info{flex:1}
.dc-name{font-size:14px;font-weight:800}
.dc-role{font-size:10px;color:var(--muted)}
.dc-status{display:flex;align-items:center;gap:5px;font-size:11px}
.dc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dc-dot.active{background:var(--ok)}.dc-dot.busy{background:var(--warn);animation:pulse 1.5s infinite}
.dc-dot.blocked{background:var(--danger);animation:pulse 1s infinite}.dc-dot.idle{background:#2a3a5a}
.dc-body{padding:14px 16px}
/* 候命状态 */
.dc-idle{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px;padding:6px 0}
/* 执行中任务 */
.dc-task{display:flex;flex-direction:column;gap:6px}
.dc-task-id{font-size:10px;color:var(--acc);font-weight:700;letter-spacing:.04em}
.dc-task-title{font-size:14px;font-weight:700;color:var(--text);line-height:1.3}
.dc-task-now{font-size:12px;color:var(--muted);line-height:1.5;margin-top:2px}
.dc-task-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:6px}
.dc-footer{padding:8px 16px;border-top:1px solid var(--line);display:flex;align-items:center;gap:8px;background:var(--panel2)}
.dc-model{font-size:10px;color:var(--muted)}.dc-la{font-size:10px;color:var(--muted);margin-left:auto}
/* ══ Model / Skills tabs ══ */
.model-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;margin-bottom:18px}
.mc-card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px}
.mc-top{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.mc-emoji{font-size:22px}
.mc-name{font-size:15px;font-weight:700}.mc-role{font-size:11px;color:var(--muted)}
.mc-cur{font-size:11px;color:var(--muted);margin-bottom:8px}.mc-cur b{color:var(--text)}
.msel{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;cursor:pointer}.msel:focus{border-color:var(--acc)}
.mc-btns{display:flex;gap:6px;margin-top:8px}
.btn{font-size:12px;padding:6px 14px;border-radius:7px;border:none;cursor:pointer;font-weight:600}
.btn-p{background:var(--acc);color:#000}.btn-p:hover{filter:brightness(1.15)}.btn-p:disabled{background:#2a3a6a;color:var(--muted);cursor:not-allowed}
.btn-g{background:transparent;border:1px solid var(--line);color:var(--muted)}.btn-g:hover{border-color:#2e3d6a;color:var(--text)}
.mc-st{font-size:11px;margin-top:6px;padding:4px 8px;border-radius:5px;display:none}
.mc-st.ok{display:block;background:#0a2018;color:var(--ok);border:1px solid #2ecc8a44}
.mc-st.err{display:block;background:#200a10;color:var(--danger);border:1px solid #ff527044}
.mc-st.pending{display:block;background:#0a1228;color:var(--acc);border:1px solid #6a9eff44}
.cl-wrap{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px}
.cl-title{font-size:12px;font-weight:700;color:var(--muted);letter-spacing:.05em;text-transform:uppercase;margin-bottom:10px}
.cl-row{display:flex;gap:10px;font-size:11px;padding:5px 0;border-bottom:1px solid var(--line)}.cl-row:last-child{border-bottom:none}
.cl-t{color:var(--muted);min-width:115px}.cl-a{color:var(--acc);min-width:80px}.cl-c{color:var(--muted)}.cl-c b{color:var(--text)}
.skills-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.sk-card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.sk-hdr{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--panel2);border-bottom:1px solid var(--line)}
.sk-emoji{font-size:18px}.sk-name{font-size:14px;font-weight:700}.sk-cnt{font-size:11px;color:var(--muted);margin-left:auto}
.sk-list{padding:10px}
.sk-item{display:flex;gap:8px;padding:8px 10px;border-radius:7px;font-size:12px;margin-bottom:3px;cursor:pointer;border:1px solid transparent;transition:all .12s}
.sk-item:hover{background:var(--panel2);border-color:var(--line)}
.si-name{font-weight:600;min-width:100px}.si-desc{color:var(--muted);flex:1;line-height:1.4}
.si-arrow{color:var(--muted);font-size:14px;opacity:.3;transition:opacity .12s}.sk-item:hover .si-arrow{opacity:1}
.sk-empty{font-size:12px;color:var(--muted);padding:12px;text-align:center;opacity:.6}
.sk-add{display:flex;align-items:center;justify-content:center;gap:6px;padding:8px;font-size:12px;color:var(--acc);cursor:pointer;border-top:1px solid var(--line);transition:background .12s}
.sk-add:hover{background:var(--panel2)}
/* skill detail modal */
.sk-modal-body{max-height:70vh;overflow-y:auto}
.sk-md{font-size:13px;line-height:1.7;color:var(--text)}
.sk-md h1,.sk-md h2,.sk-md h3{margin:16px 0 8px;color:var(--text)}
.sk-md h1{font-size:18px}.sk-md h2{font-size:15px;border-bottom:1px solid var(--line);padding-bottom:6px}.sk-md h3{font-size:13px}
.sk-md p{margin:6px 0}
.sk-md ul,.sk-md ol{padding-left:20px;margin:6px 0}
.sk-md li{margin:3px 0}
.sk-md code{font-size:11px;background:var(--panel2);padding:2px 6px;border-radius:4px;font-family:monospace}
.sk-md pre{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
.sk-md pre code{background:none;padding:0}
.sk-md table{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0}
.sk-md th,.sk-md td{padding:6px 10px;border:1px solid var(--line);text-align:left}
.sk-md th{background:var(--panel2)}
.sk-md hr{border:none;border-top:1px solid var(--line);margin:14px 0}
.sk-path{font-size:10px;color:var(--muted);padding:8px 0;word-break:break-all;border-top:1px solid var(--line);margin-top:12px}
/* ══ SESSIONS TAB ══ */
.sess-filters{display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;align-items:center}
.sess-filter{font-size:11px;padding:4px 10px;border-radius:999px;border:1px solid var(--line);background:var(--panel);color:var(--muted);cursor:pointer;transition:all .12s}
.sess-filter:hover{border-color:var(--acc);color:var(--text)}
.sess-filter.active{border-color:var(--acc);color:var(--acc);background:#0a1228}
.sess-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:10px}
.sess-card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;transition:background-color .3s ease,color .2s ease,border-color .12s}
.sess-card:hover{border-color:#2e3d6a}
.sc-top{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.sc-emoji{font-size:20px}
.sc-agent{font-size:13px;font-weight:700}
.sc-org{font-size:11px;color:var(--muted)}
.sc-title{font-size:13px;font-weight:600;margin-bottom:6px;line-height:1.4}
.sc-now{font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:6px}
.sc-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.sc-id{font-size:10px;color:var(--acc);font-weight:600}
.sc-time{font-size:10px;color:var(--muted);margin-left:auto}
/* ══ TASK ACTIONS ══ */
.task-actions{display:flex;gap:8px;margin-bottom:18px;flex-wrap:wrap}
.btn-action{font-size:12px;padding:7px 16px;border-radius:8px;border:none;cursor:pointer;font-weight:700;transition:all .15s}
.btn-stop{background:#ff527022;color:#ff5270;border:1px solid #ff527044}.btn-stop:hover{background:#ff527044}
.btn-cancel{background:#88888822;color:#888;border:1px solid #88888844}.btn-cancel:hover{background:#88888844}
.btn-resume{background:#2ecc8a22;color:#2ecc8a;border:1px solid #2ecc8a44}.btn-resume:hover{background:#2ecc8a44}
.btn-action:disabled{opacity:.4;cursor:not-allowed}
/* ══ SCHEDULER PANEL ══ */
.sched-section{margin-bottom:18px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:12px}
.sched-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}
.sched-title{font-size:11px;font-weight:700;letter-spacing:.06em;color:var(--acc)}
.sched-status{font-size:10px;color:var(--muted)}
.sched-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:10px}
.sched-kpi{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 10px}
.sched-kpi .k{font-size:10px;color:var(--muted);margin-bottom:2px}
.sched-kpi .v{font-size:13px;font-weight:700}
.sched-line{font-size:11px;color:var(--muted);display:flex;gap:12px;flex-wrap:wrap;margin-bottom:10px}
.sched-actions{display:flex;gap:6px;flex-wrap:wrap}
.sched-btn{font-size:11px;padding:5px 10px;border-radius:6px;border:1px solid var(--line);background:transparent;color:var(--muted);cursor:pointer;transition:all .15s}
.sched-btn:hover{border-color:var(--acc);color:var(--text)}
.sched-btn.warn:hover{border-color:#f5c842;color:#f5c842}
.sched-btn.danger:hover{border-color:#ff5270;color:#ff5270}
/* card-level mini actions */
.ec-actions{display:flex;gap:4px;margin-top:8px}
.ec-actions .mini-act{font-size:10px;padding:3px 8px;border-radius:5px;border:1px solid var(--line);background:transparent;cursor:pointer;color:var(--muted);transition:all .12s}
.ec-actions .mini-act:hover{border-color:var(--acc);color:var(--text)}
.ec-actions .mini-act.danger:hover{border-color:#ff5270;color:#ff5270}
/* ══ ARCHIVE FILTER BAR ══ */
.archive-bar{display:flex;align-items:center;gap:8px;margin-bottom:14px;flex-wrap:wrap}
.archive-bar .ab-label{font-size:12px;color:var(--muted);margin-right:4px}
.archive-bar .ab-btn{font-size:11px;padding:4px 12px;border-radius:6px;border:1px solid var(--line);background:transparent;cursor:pointer;color:var(--muted);transition:all .15s;font-weight:600}
.archive-bar .ab-btn:hover{border-color:var(--acc);color:var(--text)}
.archive-bar .ab-btn.active{border-color:var(--acc);color:var(--acc);background:#0f1a38}
.archive-bar .ab-count{font-size:10px;color:var(--muted);margin-left:auto}
.archive-bar .ab-archive-all{font-size:11px;padding:4px 12px;border-radius:6px;border:1px solid #2ecc8a44;background:transparent;cursor:pointer;color:var(--ok);font-weight:600;transition:all .15s}
.archive-bar .ab-archive-all:hover{background:#0a2018;border-color:var(--ok)}
.archive-bar .ab-scan{font-size:11px;padding:4px 12px;border-radius:6px;border:1px solid #6a9eff44;background:transparent;cursor:pointer;color:var(--acc);font-weight:600;transition:all .15s}
.archive-bar .ab-scan:hover{background:#0a1228;border-color:var(--acc)}
.archive-bar .ab-scan-status{font-size:10px;color:var(--muted)}
.archive-bar .ab-scan-detail{font-size:11px;padding:4px 10px;border-radius:6px;border:1px solid var(--line);background:transparent;cursor:pointer;color:var(--muted);font-weight:600;transition:all .15s}
.archive-bar .ab-scan-detail:hover{border-color:var(--acc);color:var(--text)}
.archive-bar .ab-scan-detail.active{border-color:var(--acc);color:var(--acc);background:#0f1a38}
.archive-bar .ab-scan-copy{font-size:11px;padding:4px 10px;border-radius:6px;border:1px solid #2ecc8a44;background:transparent;cursor:pointer;color:var(--ok);font-weight:600;transition:all .15s}
.archive-bar .ab-scan-copy:hover{background:#0a2018;border-color:var(--ok)}
.global-scan-detail{display:none;margin-top:-4px;margin-bottom:12px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px 12px}
.global-scan-detail.open{display:block}
.global-scan-detail .gs-empty{font-size:11px;color:var(--muted)}
.global-scan-detail .gs-list{display:flex;flex-direction:column;gap:6px}
.global-scan-detail .gs-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;background:var(--panel);border:1px solid var(--line);font-size:11px}
.global-scan-detail .gs-tag{font-size:10px;border-radius:10px;padding:2px 8px;font-weight:700;border:1px solid var(--line);color:var(--muted)}
.global-scan-detail .gs-tag.retry{color:var(--acc);border-color:#6a9eff55}
.global-scan-detail .gs-tag.escalate{color:#f5c842;border-color:#f5c84255}
.global-scan-detail .gs-tag.rollback{color:#ff5270;border-color:#ff527055}
.global-scan-detail .gs-task{font-weight:700;color:var(--text)}
.global-scan-detail .gs-meta{color:var(--muted)}
.global-scan-detail .gs-hint{margin-top:8px;font-size:10px;color:var(--muted)}
/* archived card styling */
.edict-card.archived{opacity:.55;border-style:dashed}
.edict-card.archived:hover{opacity:.85}
/* ══ TODO LIST ══ */
.todo-section{margin-bottom:18px}
.todo-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.todo-progress{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
.todo-bar{width:120px;height:6px;background:#0e1320;border-radius:3px;overflow:hidden}
.todo-bar-fill{height:100%;border-radius:3px;background:var(--ok);transition:width .3s}
.todo-list{display:flex;flex-direction:column;gap:4px}
.todo-item{display:flex;flex-direction:column;background:var(--panel2);border-radius:8px;font-size:12px;transition:opacity .15s}
.todo-item.done{opacity:.55}
.todo-item .t-row{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:default}
.todo-item.has-detail .t-row{cursor:pointer}
.todo-item .t-icon{font-size:14px;flex-shrink:0}
.todo-item .t-id{color:var(--muted);font-size:10px;min-width:20px}
.todo-item .t-title{flex:1;color:var(--text)}
.todo-item.done .t-title{text-decoration:line-through;color:var(--muted)}
.todo-item .t-status{font-size:10px;padding:2px 6px;border-radius:4px}
.todo-item .t-status.s-done{color:var(--ok);background:#0a2018;border:1px solid #2ecc8a44}
.todo-item .t-status.s-progress{color:var(--acc);background:#0a1228;border:1px solid #6a9eff44}
.todo-item .t-status.s-notstarted{color:var(--muted);background:var(--panel);border:1px solid var(--line)}
.todo-item .t-expand{color:var(--muted);font-size:10px;transition:transform .2s;flex-shrink:0}
.todo-item.expanded .t-expand{transform:rotate(90deg)}
.todo-detail{display:none;padding:4px 10px 10px 36px;font-size:11px;line-height:1.6;color:var(--text);white-space:pre-wrap;word-break:break-word;border-top:1px solid var(--line);margin:0 6px;opacity:.85}
.todo-item.expanded .todo-detail{display:block}
/* card todo mini-bar */
.ec-todo-bar{display:flex;align-items:center;gap:6px;font-size:10px;color:var(--muted);margin-top:6px}
.ec-todo-track{flex:1;max-width:80px;height:4px;background:#0e1320;border-radius:2px;overflow:hidden}
.ec-todo-fill{height:100%;background:var(--ok);border-radius:2px}
/* ══ LIVE ACTIVITY PANEL ══ */
.la-section{margin-bottom:18px}
.la-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.la-header .la-title{font-size:11px;font-weight:700;color:var(--acc);letter-spacing:.06em}
.la-header .la-agent{font-size:11px;color:var(--muted)}
.la-header .la-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok);margin-right:4px;animation:pulse 1.5s infinite}
.la-header .la-dot.idle{background:var(--muted);animation:none}
.la-log{max-height:320px;overflow-y:auto;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;font-size:12px;scroll-behavior:smooth}
.la-entry{display:flex;gap:8px;align-items:flex-start;padding:5px 8px;border-radius:6px;line-height:1.5;word-break:break-all}
.la-entry:hover{background:rgba(106,158,255,.04)}
.la-entry .la-icon{flex-shrink:0;font-size:13px;margin-top:1px}
.la-entry .la-body{flex:1;min-width:0}
.la-entry .la-time{font-size:10px;color:var(--muted);flex-shrink:0;min-width:44px;text-align:right}
.la-entry.la-assistant{color:var(--text)}
.la-entry.la-thinking{color:#a07aff;font-style:italic;opacity:.75}
.la-entry.la-tool{color:#44aaff}
.la-entry.la-tool-result{color:var(--muted);font-size:11px}
.la-entry.la-tool-result.ok{color:var(--ok)}
.la-entry.la-tool-result.err{color:var(--danger)}
.la-entry.la-user{color:var(--warn)}
.la-empty{text-align:center;color:var(--muted);padding:20px;font-size:12px}
.la-tool-name{font-weight:700;margin-right:4px}
.la-trunc{color:var(--muted);font-size:10px;opacity:.6}
.la-flow-wrap{display:flex;flex-direction:column;gap:6px}
.la-groups{display:flex;flex-direction:column;gap:8px;margin-top:4px}
.la-group{border:1px solid var(--line);border-radius:8px;background:var(--panel)}
.la-group-hd{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-bottom:1px solid var(--line);font-size:11px;color:var(--muted)}
.la-group-hd .name{font-weight:700;color:var(--text)}
.la-group-bd{display:flex;flex-direction:column;gap:4px;padding:6px}
/* ══ SUBSCRIPTION CONFIG ══ */
.sub-config{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px;margin-bottom:18px}
.sub-section{margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid var(--line)}
.sub-section:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.sub-sec-title{font-size:13px;font-weight:700;margin-bottom:10px}
.sub-cats{display:flex;flex-wrap:wrap;gap:8px}
.sub-cat{display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:8px;border:1px solid var(--line);background:var(--panel2);cursor:pointer;transition:all .15s;user-select:none}
.sub-cat:hover{border-color:var(--acc)}
.sub-cat.active{border-color:var(--ok);background:#0a2018}
.sub-cat .sc-check{width:16px;height:16px;border-radius:4px;border:1px solid var(--line);display:flex;align-items:center;justify-content:center;font-size:10px;transition:all .15s}
.sub-cat.active .sc-check{background:var(--ok);border-color:var(--ok);color:#000}
.sub-cat .sc-label{font-size:12px;font-weight:600}
.sub-cat .sc-count{font-size:10px;color:var(--muted)}
.sub-input{background:var(--panel2);border:1px solid var(--line);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;min-width:0}
.sub-input:focus{border-color:var(--acc)}
.sub-kw-list{display:flex;flex-wrap:wrap;gap:6px}
.sub-kw{display:flex;align-items:center;gap:4px;padding:4px 8px 4px 10px;border-radius:999px;background:#0f1a38;border:1px solid #1e2e50;font-size:11px;color:var(--acc)}
.sub-kw .kw-del{cursor:pointer;opacity:.5;font-size:13px;padding:0 2px}.sub-kw .kw-del:hover{opacity:1;color:var(--danger)}
.sub-feed-list{display:flex;flex-direction:column;gap:4px}
.sub-feed{display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--panel2);border-radius:7px;font-size:12px}
.sub-feed .sf-name{font-weight:600;min-width:80px;color:var(--acc)}
.sub-feed .sf-url{flex:1;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sub-feed .sf-cat{font-size:10px;padding:2px 6px;border-radius:4px;border:1px solid var(--line)}
.sub-feed .sf-del{cursor:pointer;color:var(--muted);font-size:14px}.sub-feed .sf-del:hover{color:var(--danger)}
/* ══ CONFIRM DIALOG ══ */
.confirm-bg{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:200;display:none;backdrop-filter:blur(3px)}
.confirm-bg.open{display:flex;align-items:center;justify-content:center}
.confirm-box{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:24px;max-width:420px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,.6)}
.confirm-title{font-size:16px;font-weight:700;margin-bottom:8px}
.confirm-msg{font-size:13px;color:var(--muted);margin-bottom:14px;line-height:1.5}
.confirm-input{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:7px;color:var(--text);padding:8px 10px;font-size:12px;outline:none;margin-bottom:14px}
.confirm-input:focus{border-color:var(--acc)}
.confirm-btns{display:flex;gap:8px;justify-content:flex-end}
/* TOAST */
#toaster{position:fixed;bottom:20px;right:20px;display:flex;flex-direction:column;gap:8px;z-index:300}
.toast{font-size:13px;padding:10px 16px;border-radius:10px;border:1px solid var(--line);background:var(--panel);color:var(--text);box-shadow:0 4px 20px rgba(0,0,0,.4);animation:tin .2s;max-width:320px}
.toast.ok{border-color:#2ecc8a55;background:#0a1a10}.toast.err{border-color:#ff527055;background:#200a10}
@keyframes tin{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:1}}
/* ══ AGENT STATUS PANEL ══ */
.as-panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 18px;margin-bottom:16px}
.as-header{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.as-title{font-size:13px;font-weight:700;color:var(--fg)}
.as-gw{font-size:11px;padding:3px 10px;border-radius:999px;margin-left:auto}
.as-gw.ok{background:#0a2018;border:1px solid #2ecc8a44;color:var(--ok)}
.as-gw.err{background:#200a10;border:1px solid #ff527044;color:var(--danger)}
.as-gw.warn{background:#201a08;border:1px solid #f5c84244;color:var(--warn)}
.as-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px}
.as-card{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;position:relative}
.as-card:hover{border-color:var(--acc);background:#0a1228}
.as-card .as-emoji{font-size:22px;margin-bottom:3px}
.as-card .as-label{font-size:12px;font-weight:700;color:var(--fg)}
.as-card .as-role{font-size:10px;color:var(--muted)}
.as-card .as-status{font-size:10px;margin-top:4px}
.as-card .as-time{font-size:9px;color:var(--muted);margin-top:2px}
.as-card .as-dot{position:absolute;top:6px;right:6px;width:8px;height:8px;border-radius:50%}
.as-dot.running{background:#2ecc8a;box-shadow:0 0 6px #2ecc8a88;animation:pulse 1.5s infinite}
.as-dot.idle{background:#4a5568}
.as-dot.offline{background:#ff5270;animation:pulse 1.2s infinite}
.as-dot.unconfigured{background:#6b7280}
.as-wake-btn{font-size:10px;padding:2px 8px;border-radius:6px;border:1px solid var(--acc);color:var(--acc);background:transparent;cursor:pointer;margin-top:6px;transition:background .15s}
.as-wake-btn:hover{background:var(--acc);color:#fff}
.as-wake-btn:disabled{opacity:.4;cursor:not-allowed}
.as-refresh{font-size:11px;padding:4px 12px;border-radius:8px;border:1px solid var(--line);color:var(--muted);background:transparent;cursor:pointer;transition:background .15s}
.as-refresh:hover{background:var(--panel2);color:var(--fg)}
.as-wake-all{font-size:11px;padding:4px 12px;border-radius:8px;border:1px solid var(--warn);color:var(--warn);background:transparent;cursor:pointer;transition:background .15s;margin-left:6px}
.as-wake-all:hover{background:var(--warn);color:#fff}
.as-summary{font-size:11px;color:var(--muted);display:flex;gap:12px;margin-top:10px;padding-top:8px;border-top:1px solid var(--line)}
.as-summary span{display:flex;align-items:center;gap:4px}
/* ══ OFFICIALS ══ */
/* activity bar */
.off-activity{display:flex;align-items:center;gap:8px;padding:8px 14px;background:#0a1228;border:1px solid #1a2a4a;border-radius:10px;margin-bottom:14px;font-size:12px;flex-wrap:wrap}
.act-label{color:var(--muted);flex-shrink:0}
.act-dot{display:inline-flex;align-items:center;gap:5px;padding:3px 8px;border-radius:999px;background:#0f1a38;border:1px solid #1e2e50;margin:2px}
.act-dot.alive{border-color:#2ecc8a44;background:#0a2018;color:var(--ok)}
.act-dot.warn{border-color:#f5c84244;background:#201a08;color:var(--warn)}
.act-dot.idle{color:var(--muted)}
/* summary row */
.off-kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
.kpi-v{font-size:24px;font-weight:800;margin-bottom:3px}
.kpi-l{font-size:11px;color:var(--muted)}
.kpi-v.gold{color:#f5c842}.kpi-v.green{color:var(--ok)}.kpi-v.blue{color:var(--acc)}.kpi-v.warn{color:var(--warn)}
/* main layout */
.off-layout{display:grid;grid-template-columns:260px 1fr;gap:14px}
@media(max-width:700px){.off-layout{grid-template-columns:1fr}.off-kpi{grid-template-columns:repeat(2,1fr)}}
/* left: rank list */
.off-ranklist{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden}
.orl-hdr{padding:10px 14px;background:var(--panel2);border-bottom:1px solid var(--line);font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
.orl-item{display:flex;align-items:center;gap:10px;padding:10px 14px;cursor:pointer;border-bottom:1px solid var(--line);transition:background .1s}
.orl-item:last-child{border-bottom:none}
.orl-item:hover{background:var(--panel2)}
.orl-item.selected{background:#0a1228;border-left:3px solid var(--acc)}
.orl-medal{font-size:16px;min-width:20px;text-align:center}
.orl-emoji{font-size:18px}
.orl-name{flex:1}.orl-role{font-size:12px;font-weight:700}.orl-org{font-size:10px;color:var(--muted)}
.orl-score{font-size:11px;font-weight:700;color:var(--acc)}
.orl-hbdot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.orl-hbdot.active{background:var(--ok)}.orl-hbdot.warn{background:var(--warn)}.orl-hbdot.stalled{background:var(--danger);animation:pulse 1.2s infinite}.orl-hbdot.idle{background:#2a3a5a}
/* right: detail */
.off-detail{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:22px;min-height:400px}
.od-empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:13px;min-height:200px}
.od-hero{display:flex;align-items:center;gap:16px;margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--line)}
.od-emoji{font-size:40px}
.od-name{font-size:22px;font-weight:800}
.od-role{font-size:13px;color:var(--muted);margin-top:2px}
.od-rank-badge{font-size:11px;padding:3px 9px;border-radius:999px;border:1px solid #f5c84244;color:#f5c842;background:#201a08;margin-top:4px;display:inline-block}
.od-hb{margin-left:auto;text-align:right}
.od-section{margin-bottom:18px}
.od-sec-title{font-size:10px;font-weight:700;color:var(--muted);letter-spacing:.07em;text-transform:uppercase;margin-bottom:10px}
/* stats 3-grid */
.od-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.ods{background:var(--panel2);border-radius:8px;padding:10px;text-align:center}
.ods-v{font-size:20px;font-weight:800}.ods-l{font-size:10px;color:var(--muted);margin-top:2px}
/* token bars */
.tbar{margin-bottom:7px}
.tbar-hdr{display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px}
.tbar-label{color:var(--muted)}.tbar-val{font-weight:600}
.tbar-track{height:6px;background:#0e1320;border-radius:3px;overflow:hidden}
.tbar-fill{height:100%;border-radius:3px}
/* cost row */
.od-cost-row{display:flex;gap:10px;flex-wrap:wrap}
.cost-chip{font-size:12px;padding:5px 12px;border-radius:8px;border:1px solid var(--line);background:var(--panel2)}
.cost-chip b{font-size:15px}
.cost-chip.hi{border-color:#ff527044}.cost-chip.md{border-color:#f5c84244}.cost-chip.lo{border-color:#2ecc8a44}
/* edict list */
.od-edict-list{display:flex;flex-direction:column;gap:5px}
.oe-item{display:flex;align-items:center;gap:8px;padding:7px 10px;background:var(--panel2);border-radius:7px;font-size:12px;cursor:pointer}
.oe-item:hover{background:#141c30}
.oe-id{font-size:10px;color:var(--acc);font-weight:700;min-width:110px}
.oe-title{flex:1;color:var(--text)}
.oe-state{font-size:10px}
.off-grid{display:none} /* legacy, hidden */
/* ══ 早朝简报 ══ */
.mn-hdr{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-bottom:18px;padding:14px 18px;background:linear-gradient(135deg,#0a1228,#140e28);border:1px solid #1a2a4a;border-radius:14px}
.mn-date{font-size:20px;font-weight:800;background:linear-gradient(135deg,#f5c842,#ff9a3c);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.mn-sub{font-size:12px;color:var(--muted);margin-top:2px}
.mn-hdr-r{margin-left:auto;display:flex;gap:8px;align-items:center}
.btn-morning-refresh{font-size:12px;padding:6px 14px;border-radius:8px;background:linear-gradient(135deg,#f5c842,#ff9a3c);color:#000;border:none;cursor:pointer;font-weight:700}
.btn-morning-refresh:hover{filter:brightness(1.1)}
.mn-cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
.mn-cat{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden}
.mn-cat-hdr{display:flex;align-items:center;gap:8px;padding:10px 14px;background:var(--panel2);border-bottom:1px solid var(--line)}
.mn-cat-icon{font-size:20px}.mn-cat-name{font-size:15px;font-weight:800}.mn-cat-cnt{font-size:11px;color:var(--muted);margin-left:auto}
.mn-items{padding:10px}
.mn-item{display:flex;gap:10px;padding:8px;border-radius:8px;margin-bottom:6px;cursor:pointer;border:1px solid transparent;transition:border-color .1s}
.mn-item:hover{background:var(--panel2);border-color:var(--line)}
.mn-item:last-child{margin-bottom:0}
.mn-img{width:70px;height:52px;border-radius:6px;object-fit:cover;flex-shrink:0;background:var(--panel2);display:flex;align-items:center;justify-content:center;font-size:24px;overflow:hidden}
.mn-img img{width:100%;height:100%;object-fit:cover}
.mn-content{flex:1;min-width:0}
.mn-title{font-size:12px;font-weight:700;line-height:1.4;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.mn-summary{font-size:11px;color:var(--muted);line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.mn-meta{display:flex;align-items:center;gap:6px;margin-top:4px;font-size:10px;color:var(--muted)}
.mn-source{color:var(--acc)}.mn-time{margin-left:auto}
.mn-empty{text-align:center;padding:30px 20px;color:var(--muted);font-size:13px}
.mn-loading{display:flex;align-items:center;justify-content:center;gap:10px;padding:40px;color:var(--muted);font-size:13px}
/* ══ MORNING BRIEF ══ */
.mb-hdr{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap;gap:10px}
.mb-title{font-size:20px;font-weight:800;background:linear-gradient(135deg,#f5c842,#ff9a4a);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.mb-sub{font-size:12px;color:var(--muted);margin-top:3px}
.mb-cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}
.mb-cat{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden}
.mb-cat-hdr{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line)}
.mb-cat-icon{font-size:20px}
.mb-cat-name{font-size:14px;font-weight:800}
.mb-cat-cnt{font-size:11px;color:var(--muted);margin-left:auto}
.mb-news-list{padding:10px}
.mb-card{display:flex;gap:12px;padding:10px 8px;border-radius:10px;margin-bottom:6px;cursor:pointer;transition:background .12s;border-bottom:1px solid var(--line)}
.mb-card:last-child{border-bottom:none;margin-bottom:0}
.mb-card:hover{background:var(--panel2)}
.mb-img{width:72px;height:52px;border-radius:7px;object-fit:cover;flex-shrink:0;background:var(--panel2);display:flex;align-items:center;justify-content:center;font-size:22px;overflow:hidden}
.mb-img img{width:100%;height:100%;object-fit:cover;border-radius:7px}
.mb-info{flex:1;min-width:0}
.mb-headline{font-size:13px;font-weight:700;line-height:1.4;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.mb-summary{font-size:11px;color:var(--muted);line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.mb-meta{display:flex;align-items:center;gap:8px;margin-top:5px}
.mb-source{font-size:10px;color:var(--acc)}
.mb-time{font-size:10px;color:var(--muted)}
.mb-empty{text-align:center;padding:30px;color:var(--muted);font-size:13px}
.mb-loading{display:flex;align-items:center;justify-content:center;padding:60px;color:var(--muted);font-size:14px;gap:10px}
/* MISC */
.empty{text-align:center;padding:40px 20px;color:var(--muted);font-size:13px}
.sec-title{font-size:12px;font-weight:700;color:var(--muted);letter-spacing:.07em;text-transform:uppercase;margin-bottom:12px}
code{font-size:11px;background:var(--panel2);padding:2px 6px;border-radius:4px;font-family:monospace}
/* ══ COURT CEREMONY ══ */
.ceremony-bg{position:fixed;inset:0;z-index:9999;background:#07090f;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:0;animation:crmFadeIn .6s ease forwards;cursor:pointer}
.ceremony-bg.out{animation:crmFadeOut .5s ease forwards}
.crm-glow{position:absolute;width:400px;height:400px;border-radius:50%;background:radial-gradient(circle,rgba(106,158,255,.08),transparent 70%);animation:crmPulse 3s ease-in-out infinite}
.crm-line1{font-family:'Noto Serif SC',serif;font-size:52px;font-weight:900;color:#dde4f8;letter-spacing:.15em;opacity:0;transform:translateY(20px)}
.crm-line2{font-family:'Noto Serif SC',serif;font-size:22px;font-weight:700;color:var(--acc);letter-spacing:.2em;margin-top:12px;opacity:0;transform:translateY(15px)}
.crm-line3{font-size:14px;color:var(--muted);margin-top:24px;opacity:0;letter-spacing:.05em}
.crm-date{font-size:12px;color:#2a3555;margin-top:40px;opacity:0;letter-spacing:.08em}
.crm-skip{font-size:11px;color:#2a3555;margin-top:18px;opacity:0;animation:crmChar .4s 2.5s forwards}
.crm-line1.in{animation:crmSlideUp .6s .3s ease forwards}
.crm-line2.in{animation:crmSlideUp .5s 1.1s ease forwards}
.crm-line3.in{animation:crmSlideUp .5s 1.6s ease forwards}
.crm-date.in{animation:crmChar .4s 2s ease forwards}
@keyframes crmFadeIn{to{opacity:1}}
@keyframes crmFadeOut{to{opacity:0;pointer-events:none}}
@keyframes crmSlideUp{to{opacity:1;transform:translateY(0)}}
@keyframes crmChar{to{opacity:1}}
@keyframes crmPulse{0%,100%{transform:scale(1);opacity:.5}50%{transform:scale(1.1);opacity:.8}}
/* ══ MEMORIAL TAB ══ */
.mem-list{display:flex;flex-direction:column;gap:8px}
.mem-card{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;cursor:pointer;transition:border-color .12s}
.mem-card:hover{border-color:var(--acc)}
.mem-icon{font-size:28px;flex-shrink:0;margin-top:2px}
.mem-info{flex:1;min-width:0}
.mem-title{font-size:14px;font-weight:700;margin-bottom:4px}
.mem-sub{font-size:11px;color:var(--muted);line-height:1.5}
.mem-tags{display:flex;gap:4px;flex-wrap:wrap;margin-top:6px}
.mem-tag{font-size:10px;padding:2px 8px;border-radius:4px;background:var(--panel2);color:var(--muted);border:1px solid var(--line)}
.mem-right{display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0}
.mem-date{font-size:10px;color:var(--muted)}
.mem-cost{font-size:10px;color:var(--acc)}
.mem-empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
/* memorial detail */
.md-timeline{position:relative;padding-left:24px;margin:16px 0}
.md-timeline::before{content:'';position:absolute;left:7px;top:0;bottom:0;width:2px;background:var(--line)}
.md-tl-item{position:relative;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--line)}
.md-tl-item:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.md-tl-dot{position:absolute;left:-20px;top:3px;width:10px;height:10px;border-radius:50%;background:var(--acc);border:2px solid var(--bg)}
.md-tl-dot.green{background:var(--ok)}
.md-tl-dot.yellow{background:var(--warn)}
.md-tl-dot.red{background:var(--danger)}
.md-tl-from{font-size:11px;font-weight:700;color:var(--acc)}
.md-tl-to{font-size:11px;color:var(--muted)}
.md-tl-remark{font-size:12px;margin-top:3px;line-height:1.5}
.md-tl-time{font-size:10px;color:var(--muted);margin-top:2px}
/* ══ TEMPLATE TAB ══ */
.tpl-cats{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}
.tpl-cat{font-size:12px;padding:6px 14px;border-radius:999px;border:1px solid var(--line);background:var(--panel);color:var(--muted);cursor:pointer;transition:all .12s}
.tpl-cat:hover{border-color:var(--acc);color:var(--text)}
.tpl-cat.active{border-color:var(--acc);color:var(--acc);background:#0a1228}
.tpl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px}
.tpl-card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;transition:border-color .12s;cursor:pointer;display:flex;flex-direction:column}
.tpl-card:hover{border-color:var(--acc)}
.tpl-top{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.tpl-icon{font-size:24px}
.tpl-name{font-size:14px;font-weight:700}
.tpl-pop{font-size:10px;color:var(--muted);margin-left:auto}
.tpl-desc{font-size:12px;color:var(--muted);line-height:1.5;margin-bottom:10px;flex:1}
.tpl-footer{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.tpl-dept{font-size:10px;padding:2px 6px;border-radius:4px;background:var(--panel2);color:var(--acc)}
.tpl-est{font-size:10px;color:var(--muted);margin-left:auto}
.tpl-go{font-size:11px;padding:5px 14px;border-radius:6px;background:var(--acc);color:#fff;border:none;cursor:pointer;font-weight:600;margin-left:8px;transition:opacity .12s}
.tpl-go:hover{opacity:.85}
.tpl-form{margin-top:18px}
.tpl-field{margin-bottom:14px}
.tpl-label{font-size:12px;font-weight:600;display:block;margin-bottom:6px}
.tpl-input{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--text);font-size:13px;outline:none}
@media(prefers-reduced-motion:reduce){
.edict-card{animation:none!important}
*{transition-duration:0s!important}
}
</style>
</head>
<body>
<!-- COURT CEREMONY -->
<div class="ceremony-bg" id="ceremony" style="display:none" onclick="skipCeremony()">
<div class="crm-glow"></div>
<div class="crm-line1" id="crm-l1">早朝开始</div>
<div class="crm-line2" id="crm-l2">各部听旨</div>
<div class="crm-line3" id="crm-l3"></div>
<div class="crm-date" id="crm-date"></div>
<div class="crm-skip">点击任意处跳过</div>
</div>
<div class="wrap">
<!-- HEADER -->
<div class="hdr">
<div>
<div class="logo">⚔️ 军机处 · 三省六部总控台</div>
<div class="sub">皇上视角 · 实时旨意追踪</div>
</div>
<div class="hdr-r">
<button class="theme-toggle" onclick="toggleTheme()" id="theme-btn" title="切换主题">🌙</button>
<button class="theme-toggle" onclick="openFeedback()" title="祈告上苍(反馈问题或建议)">🙏 祈告上苍</button>
<span class="chip" id="chip-sync">⟳ 同步中</span>
<span class="chip" id="chip-tasks">— 旨意</span>
<button class="btn-refresh" onclick="loadAll()">⟳ 立即刷新</button>
<span id="cd"></span>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<div class="tab active" data-tab="edicts">📜 旨意看板 <span class="tbadge" id="tb-e">0</span></div>
<div class="tab" data-tab="monitor">🔭 省部调度 <span class="tbadge" id="tb-m">0</span></div>
<div class="tab" data-tab="officials">👥 官员总览</div>
<div class="tab" data-tab="models">⚙️ 模型配置</div>
<div class="tab" data-tab="skills">🛠️ 技能配置</div>
<div class="tab" data-tab="sessions">💬 小任务 <span class="tbadge" id="tb-s">0</span></div>
<div class="tab" data-tab="memorials">📜 奏折阁 <span class="tbadge" id="tb-mem">0</span></div>
<div class="tab" data-tab="templates">📜 旨库</div>
<div class="tab" data-tab="morning">📰 天下要闻</div>
</div>
<!-- ══ 旨意看板 ══ -->
<div class="panel active" id="panel-edicts">
<div class="archive-bar" id="archive-bar">
<span class="ab-label">📋 筛选:</span>
<button class="ab-btn active" data-filter="active" onclick="setEdictFilter('active')">🔥 进行中</button>
<button class="ab-btn" data-filter="archived" onclick="setEdictFilter('archived')">📦 已归档</button>
<button class="ab-btn" data-filter="all" onclick="setEdictFilter('all')">全部</button>
<span class="ab-count" id="archive-count"></span>
<button class="ab-scan" id="btn-global-scan" onclick="runGlobalSchedulerScan()">🧭 太子巡检</button>
<span class="ab-scan-status" id="global-scan-status">未巡检</span>
<button class="ab-scan-detail" id="btn-global-scan-detail" onclick="toggleGlobalScanDetails()" style="display:none">查看巡检详情</button>
<button class="ab-scan-copy" id="btn-global-scan-copy" onclick="copyGlobalScanReport()" style="display:none">📋 复制巡检报告</button>
<button class="ab-archive-all" id="btn-archive-done" onclick="archiveAllDone()" style="display:none">📦 一键归档已完成</button>
</div>
<div class="global-scan-detail" id="global-scan-detail"></div>
<div id="edict-grid" class="edict-grid"></div>
</div>
<!-- ══ 省部调度 ══ -->
<div class="panel" id="panel-monitor">
<div id="agent-status-panel"></div>
<div style="font-size:12px;color:var(--muted);margin-bottom:14px">各省部当前承接旨意与执行状态 · 每5秒自动刷新</div>
<div class="duty-grid" id="duty-grid"></div>
</div>
<!-- ══ 官员总览 ══ -->
<div class="panel" id="panel-officials">
<div id="off-activity" class="off-activity" style="display:none"></div>
<div id="off-kpi" class="off-kpi"></div>
<div class="off-layout">
<div id="off-ranklist" class="off-ranklist"></div>
<div id="off-detail" class="off-detail"><div class="od-empty">← 点击左侧官员查看详情</div></div>
</div>
</div>
<!-- ══ 模型配置 ══ -->
<div class="panel" id="panel-models">
<div class="sec-title" style="margin-bottom:14px">各省部 · 模型配置 <span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:11px;color:var(--muted)">— 更改后自动重启 Gateway约5秒</span></div>
<div class="model-grid" id="model-grid"></div>
<div class="cl-wrap"><div class="cl-title">变更记录</div><div id="cl-list"></div></div>
</div>
<!-- ══ 技能配置 ══ -->
<div class="panel" id="panel-skills">
<div class="sec-title" style="margin-bottom:14px">各省部 · Skills 配置 <span style="font-weight:400;text-transform:none;letter-spacing:0;font-size:11px;color:var(--muted)">— 点击技能查看详情,底部可添加新技能</span></div>
<div class="skills-grid" id="skills-grid"></div>
</div>
<!-- ══ 小任务/会话监控 ══ -->
<div class="panel" id="panel-sessions">
<div style="font-size:12px;color:var(--muted);margin-bottom:14px">飞书/Telegram 中的日常对话与小任务 · 非JJC圣旨类任务</div>
<div class="sess-filters">
<span style="font-size:11px;color:var(--muted)">筛选:</span>
<span class="sess-filter active" data-sf="all" onclick="filterSessions('all',this)">全部</span>
<span class="sess-filter" data-sf="active" onclick="filterSessions('active',this)">进行中</span>
<span class="sess-filter" data-sf="zhongshu" onclick="filterSessions('zhongshu',this)">中书省</span>
<span class="sess-filter" data-sf="shangshu" onclick="filterSessions('shangshu',this)">尚书省</span>
<span class="sess-filter" data-sf="libu" onclick="filterSessions('libu',this)">礼部</span>
<span class="sess-filter" data-sf="hubu" onclick="filterSessions('hubu',this)">户部</span>
<span class="sess-filter" data-sf="bingbu" onclick="filterSessions('bingbu',this)">兵部</span>
<span class="sess-filter" data-sf="xingbu" onclick="filterSessions('xingbu',this)">刑部</span>
<span class="sess-filter" data-sf="gongbu" onclick="filterSessions('gongbu',this)">工部</span>
<span class="sess-filter" data-sf="libu_hr" onclick="filterSessions('libu_hr',this)">吏部</span>
<span class="sess-filter" data-sf="menxia" onclick="filterSessions('menxia',this)">门下省</span>
<span class="sess-filter" data-sf="zaochao" onclick="filterSessions('zaochao',this)">钦天监</span>
</div>
<div id="sess-grid" class="sess-grid"></div>
</div>
<!-- ══ 奏折阁 ══ -->
<div class="panel" id="panel-memorials">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<div style="flex:1">
<div class="sec-title" style="margin-bottom:2px">📜 奏折阁 · 旨意存档</div>
<div style="font-size:11px;color:var(--muted)">任务完成后自动生成奏折,记录从下旨到完成的完整过程</div>
</div>
<select id="mem-filter" onchange="renderMemorials()" style="font-size:11px;padding:4px 10px;background:var(--panel2);border:1px solid var(--line);border-radius:6px;color:var(--text);outline:none">
<option value="all">全部</option>
<option value="Done">已完成</option>
<option value="Cancelled">已取消</option>
</select>
</div>
<div id="mem-list" class="mem-list"></div>
</div>
<!-- ══ 圣旨模板库 ══ -->
<div class="panel" id="panel-templates">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<div style="flex:1">
<div class="sec-title" style="margin-bottom:2px">📜 旨库 · 圣旨模板</div>
<div style="font-size:11px;color:var(--muted)">选择模板、填写参数、一键下旨 — 降低使用门槛,快速启动任务</div>
</div>
</div>
<div class="tpl-cats" id="tpl-cats"></div>
<div class="tpl-grid" id="tpl-grid"></div>
<!-- 自由下旨 -->
<div style="margin-top:24px;padding-top:18px;border-top:1px solid var(--line)">
<div style="font-size:13px;font-weight:700;margin-bottom:8px">✍️ 自由下旨</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:10px">无需模板,直接用自然语言描述你的需求,系统会自动派发给太子处理</div>
<textarea id="free-edict-input" style="width:100%;min-height:80px;resize:vertical;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:10px;font-size:13px;color:var(--text);font-family:inherit" placeholder="例如:帮我分析一下最近三个月的销售数据趋势,生成图表和报告…"></textarea>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:10px">
<select id="free-edict-priority" style="background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:6px 10px;font-size:12px;color:var(--text)">
<option value="normal">普通优先级</option>
<option value="high">高优先级</option>
</select>
<button class="tpl-go" style="padding:8px 20px;font-size:13px" onclick="submitFreeEdict()">📜 下旨</button>
</div>
</div>
</div>
<!-- ══ 天下要闻 ══ -->
<div class="panel" id="panel-morning">
<div class="mb-hdr">
<div>
<div class="mb-title">📰 天下要闻 · 御览</div>
<div id="mb-subtitle" class="mb-sub">加载中…</div>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn btn-g" onclick="toggleSubConfig()" style="font-size:12px;padding:6px 14px">⚙ 订阅管理</button>
<button class="btn btn-g" id="mb-refresh-btn" onclick="refreshNews()" style="font-size:12px;padding:6px 14px">⟳ 立即采集</button>
</div>
</div>
<!-- 订阅管理面板 -->
<div id="sub-config" class="sub-config" style="display:none">
<div class="sub-section">
<div class="sub-sec-title">📂 新闻分类 <span style="font-weight:400;font-size:11px;color:var(--muted)">— 勾选启用,取消禁用</span></div>
<div id="sub-cats" class="sub-cats"></div>
</div>
<div class="sub-section">
<div class="sub-sec-title">🔑 自定义关注词 <span style="font-weight:400;font-size:11px;color:var(--muted)">— 额外关键词过滤</span></div>
<div id="sub-keywords" class="sub-kw-list"></div>
<div style="display:flex;gap:6px;margin-top:8px">
<input id="new-kw" class="sub-input" placeholder="输入关键词芯片、SpaceX…" onkeydown="if(event.key==='Enter')addKeyword()">
<button class="btn btn-p" onclick="addKeyword()" style="font-size:12px;padding:6px 14px">添加</button>
</div>
</div>
<div class="sub-section">
<div class="sub-sec-title">📡 自定义 RSS 源</div>
<div id="sub-feeds" class="sub-feed-list"></div>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
<input id="new-feed-name" class="sub-input" placeholder="源名称" style="max-width:120px">
<input id="new-feed-url" class="sub-input" placeholder="RSS URL" style="flex:1">
<select id="new-feed-cat" class="sub-input" style="max-width:130px"></select>
<button class="btn btn-p" onclick="addFeed()" style="font-size:12px;padding:6px 14px">添加</button>
</div>
</div>
<div class="sub-section">
<div class="sub-sec-title">🔔 消息推送</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
<select id="notification-channel" class="sub-input" style="max-width:160px">
<option value="feishu">💬 飞书 Feishu</option>
<option value="wecom">📱 企业微信</option>
<option value="telegram">✈️ Telegram</option>
<option value="discord">🎮 Discord</option>
<option value="slack">💬 Slack</option>
<option value="webhook">🔗 通用 Webhook</option>
</select>
<label style="font-size:12px;display:flex;align-items:center;gap:4px">
<input type="checkbox" id="notification-enabled" checked> 启用推送
</label>
</div>
<input id="notification-webhook" class="sub-input" placeholder="Webhook URL留空则不推送" style="width:100%;margin-bottom:6px">
<button class="btn btn-p" onclick="saveSubConfig()" style="font-size:12px;padding:6px 14px">保存全部配置</button>
<div style="font-size:11px;color:var(--muted);margin-top:6px">采集完成后自动推送简报链接到对应渠道。</div>
</div>
<div id="sub-status" style="font-size:12px;margin-top:8px;display:none"></div>
</div>
<div id="mb-body"></div>
</div>
</div>
<!-- TASK DETAIL MODAL -->
<div class="modal-bg" id="modal-bg" onclick="closeModal(event)">
<div class="modal" id="modal">
<div class="modal-close" onclick="closeModal()"></div>
<div id="modal-body"></div>
</div>
</div>
<!-- CONFIRM DIALOG -->
<div class="confirm-bg" id="confirm-bg">
<div class="confirm-box">
<div class="confirm-title" id="confirm-title"></div>
<div class="confirm-msg" id="confirm-msg"></div>
<input class="confirm-input" id="confirm-reason" placeholder="原因(可选)"/>
<div class="confirm-btns">
<button class="btn btn-g" onclick="closeConfirm()">取消</button>
<button class="btn btn-p" id="confirm-ok" onclick="doConfirm()">确认</button>
</div>
</div>
</div>
<div id="toaster"></div>
<script>
/* ══ CONFIG ══ */
const API = (location.origin === 'file://' ? 'http://127.0.0.1:7891' : location.origin) + '/api';
let liveStatus=null, agentConfig=null, activeTab='edicts', countdown=5;
let globalScanActions = [], globalScanDetailOpen = false;
let globalScanCheckedAt = '', globalScanCount = 0;
const GLOBAL_SCAN_STORAGE_KEY = 'openclaw_global_scan_state_v1';
function persistGlobalScanState(meta = {}){
try{
const payload = {
actions: globalScanActions,
detailOpen: globalScanDetailOpen,
checkedAt: meta.checkedAt || globalScanCheckedAt || '',
count: Number(meta.count ?? globalScanCount ?? 0),
};
localStorage.setItem(GLOBAL_SCAN_STORAGE_KEY, JSON.stringify(payload));
}catch(_){ }
}
function restoreGlobalScanState(){
const st = document.getElementById('global-scan-status');
const detailBtn = document.getElementById('btn-global-scan-detail');
const copyBtn = document.getElementById('btn-global-scan-copy');
let raw = null;
try{ raw = JSON.parse(localStorage.getItem(GLOBAL_SCAN_STORAGE_KEY) || 'null'); }catch(_){ raw = null; }
if(!raw || !Array.isArray(raw.actions)){
if(detailBtn) detailBtn.style.display = 'none';
if(copyBtn) copyBtn.style.display = 'none';
return;
}
globalScanActions = raw.actions;
globalScanDetailOpen = !!raw.detailOpen && globalScanActions.length>0;
globalScanCheckedAt = String(raw.checkedAt || '');
globalScanCount = Number(raw.count || globalScanActions.length || 0);
if(st){
const at = globalScanCheckedAt.replace('T',' ').substring(11,19);
const count = globalScanCount;
st.textContent = count || at ? `最近巡检: ${count} 个动作${at ? ' · ' + at : ''}` : '未巡检';
}
if(detailBtn){
detailBtn.style.display = globalScanActions.length ? 'inline-block' : 'none';
detailBtn.classList.toggle('active', globalScanDetailOpen);
detailBtn.textContent = globalScanDetailOpen ? '收起巡检详情' : '查看巡检详情';
}
if(copyBtn){
copyBtn.style.display = globalScanActions.length ? 'inline-block' : 'none';
}
renderGlobalScanDetails();
}
/* ══ PIPELINE DEFINITION ══ */
const PIPE = [
{key:'Inbox', dept:'皇上', icon:'👑', action:'下旨'},
{key:'Taizi', dept:'太子', icon:'🤴', action:'分拣'},
{key:'Zhongshu', dept:'中书省', icon:'📜', action:'起草'},
{key:'Menxia', dept:'门下省', icon:'🔍', action:'审议'},
{key:'Assigned', dept:'尚书省', icon:'📮', action:'派发'},
{key:'Doing', dept:'六部', icon:'⚙️', action:'执行'},
{key:'Review', dept:'尚书省', icon:'🔎', action:'汇总'},
{key:'Done', dept:'回奏', icon:'✅', action:'完成'},
];
const PIPE_STATE_IDX = {Inbox:0,Pending:0,Taizi:1,Zhongshu:2,Menxia:3,Assigned:4,Doing:5,Review:6,Done:7,Blocked:5,Cancelled:5,Next:4};
/* ══ DEPT COLORS ══ */
const DEPT_COLOR = {'太子':'#e8a040','中书省':'#a07aff','门下省':'#6a9eff','尚书省':'#6aef9a','礼部':'#f5c842','户部':'#ff9a6a','兵部':'#ff5270','刑部':'#cc4444','工部':'#44aaff','吏部':'#9b59b6','皇上':'#ffd700','回奏':'#2ecc8a'};
function deptColor(d){return DEPT_COLOR[d]||'#6a9eff'}
/* ══ UTILS ══ */
const esc=s=>s?String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'):'';
async function fetchJ(u){const r=await fetch(u,{cache:'no-store'});if(!r.ok)throw Error(r.status);return r.json()}
async function postJ(u,d){const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});return r.json()}
function toast(msg,type='ok',ms=3000){const el=document.createElement('div');el.className=`toast ${type}`;el.textContent=msg;document.getElementById('toaster').appendChild(el);setTimeout(()=>el.remove(),ms)}
const STATE_LABEL={Inbox:'收件',Pending:'待处理',Taizi:'太子分拣',Zhongshu:'中书起草',Menxia:'门下审议',Assigned:'已派发',Doing:'执行中',Review:'待审查',Done:'已完成',Blocked:'阻塞',Cancelled:'已取消',Next:'待执行'};
// 根据轮次动态生成状态标签
function stateLabel(t){
const r = t.review_round||0;
if(t.state==='Menxia' && r>1) return `门下审议(第${r}轮)`;
if(t.state==='Zhongshu' && r>0) return `中书修订(第${r}轮)`;
return STATE_LABEL[t.state]||t.state;
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function animateCounter(el, target, format=v=>String(v)) {
if(!el) return;
const next = Number(target) || 0;
if(prefersReducedMotion.matches){
el.textContent = format(next);
el.dataset.counterValue = String(next);
return;
}
const start = Number(el.dataset.counterValue || 0);
const duration = 600;
const startTime = performance.now();
function step(now) {
const progress = Math.min((now - startTime) / duration, 1);
const value = Math.floor(start + (next - start) * progress);
el.textContent = format(value);
if(progress < 1) requestAnimationFrame(step);
else el.dataset.counterValue = String(next);
}
requestAnimationFrame(step);
}
/* ══ TASK CLASSIFICATION ══ */
// 皇上的旨意JJC-* 开头,有真实标题
function isEdict(t){ return /^JJC-/i.test(t.id||'') }
// 系统会话OC-* / MC-* 开头
function isSession(t){ return /^(OC-|MC-)/i.test(t.id||'') }
// 归档判定:仅检查显式 archived 标记Done/Cancelled 留在活跃视图,等用户手动归档)
function isArchived(t){ return !!t.archived }
let edictFilter = 'active'; // 'active' | 'archived' | 'all'
function setEdictFilter(f){
edictFilter = f;
document.querySelectorAll('.archive-bar .ab-btn').forEach(b=>b.classList.toggle('active',b.dataset.filter===f));
if(liveStatus) renderEdicts(liveStatus.tasks||[]);
}
/* ══ PIPE STATUS FOR A TASK ══ */
function getPipeStatus(t){
const stateIdx = PIPE_STATE_IDX[t.state] ?? 4;
return PIPE.map((stage, i) => ({
...stage,
status: i < stateIdx ? 'done' : i === stateIdx ? 'active' : 'pending'
}));
}
/* ══ CARD MINI PIPELINE ══ */
function miniPipe(t){
const stages = getPipeStatus(t);
return `<div class="ec-pipe">
${stages.map((s,i) => `
<div class="ep-node ${s.status}">
<div class="ep-icon">${s.icon}</div>
<div class="ep-name">${s.dept}</div>
</div>
${i < stages.length-1 ? `<div class="ep-arrow"></div>` : ''}
`).join('')}
</div>`;
}
/* ══ EDICT CARDS ══ */
function renderEdicts(tasks){
const allEdicts = tasks.filter(isEdict);
const activeEdicts = allEdicts.filter(t=>!isArchived(t));
const archivedEdicts = allEdicts.filter(t=>isArchived(t));
// Apply filter
let edicts;
if(edictFilter==='active') edicts = activeEdicts;
else if(edictFilter==='archived') edicts = archivedEdicts;
else edicts = allEdicts;
edicts.sort((a,b) => {
const order = {Doing:0,Review:1,Assigned:2,Menxia:3,Zhongshu:4,Taizi:5,Inbox:6,Blocked:7,Next:8,Done:9,Cancelled:10};
return (order[a.state]??9) - (order[b.state]??9);
});
// Tab badge = active count only
animateCounter(document.getElementById('tb-e'), activeEdicts.length);
document.getElementById('chip-tasks').textContent = activeEdicts.length + ' 道旨意';
// Archive stats
document.getElementById('archive-count').textContent = `活跃 ${activeEdicts.length} · 归档 ${archivedEdicts.length} · 共 ${allEdicts.length}`;
// Show "一键归档" button if there are un-archived Done/Cancelled tasks
const unArchivedDone = allEdicts.filter(t=>!t.archived && ['Done','Cancelled'].includes(t.state));
document.getElementById('btn-archive-done').style.display = unArchivedDone.length ? '' : 'none';
const el = document.getElementById('edict-grid');
if(!edicts.length){
el.innerHTML='<div class="empty" style="grid-column:1/-1">暂无旨意<br><small style="font-size:11px;margin-top:6px;display:block;color:var(--muted)">通过飞书向太子发送任务,太子分拣后转中书省处理</small></div>';
return;
}
el.innerHTML = edicts.map(t => {
const hb = t.heartbeat||{status:'unknown',label:'⚪'};
const deptCls = 'dt-'+(t.org||'').replace(/\s/g,'');
const stCls = 'st-'+(t.state||'');
const isBlocked = t.block && t.block !== '无' && t.block !== '-';
const curStage = PIPE.find((_,i) => getPipeStatus(t)[i].status === 'active');
const todos = t.todos||[];
const todoDone = todos.filter(x=>x.status==='completed').length;
const todoTotal = todos.length;
const canStop = !['Done','Blocked','Cancelled'].includes(t.state);
const canResume = ['Blocked','Cancelled'].includes(t.state);
const archCls = isArchived(t) ? ' archived' : '';
return `
<div class="edict-card${archCls}" onclick='openTask(${JSON.stringify(t.id)})'>
${miniPipe(t)}
<div class="ec-id">${esc(t.id)}</div>
<div class="ec-title">${esc(t.title||'(无标题)')}</div>
<div class="ec-meta">
<span class="tag ${stCls}">${stateLabel(t)}</span>
${t.org ? `<span class="tag ${deptCls}">${esc(t.org)}</span>` : ''}
${curStage ? `<span style="font-size:11px;color:var(--muted)">当前: <b style="color:${deptColor(curStage.dept)}">${curStage.dept} · ${curStage.action}</b></span>` : ''}
</div>
${t.now && t.now !== '-' ? `<div style="font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:6px">${esc(t.now.substring(0,80))}</div>` : ''}
${(t.review_round||0)>0 ? `<div style="font-size:11px;margin-bottom:6px">
${Array.from({length:t.review_round||0},(_,i)=>`<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:${i<(t.review_round||0)-1?'#1a3a6a':'var(--acc)'}22;border:1px solid ${i<(t.review_round||0)-1?'#2a4a8a':'var(--acc)'};font-size:9px;text-align:center;line-height:13px;margin-right:2px;color:${i<(t.review_round||0)-1?'#4a6aaa':'var(--acc)'}">${i+1}</span>`).join('')}
<span style="color:var(--muted);font-size:10px"> ${t.review_round} 轮磋商</span>
</div>` : ''}
${todoTotal ? `<div class="ec-todo-bar">
<span>📋 ${todoDone}/${todoTotal}</span>
<div class="ec-todo-track"><div class="ec-todo-fill" style="width:${Math.round(todoDone/todoTotal*100)}%"></div></div>
<span>${todoDone===todoTotal?'✅ 全部完成':'🔄 进行中'}</span>
</div>` : ''}
<div class="ec-footer">
<span class="hb ${hb.status}">${esc(hb.label)}</span>
${isBlocked ? `<span class="tag" style="border-color:#ff527044;color:var(--danger);background:#200a10">🚫 ${esc(t.block)}</span>` : ''}
${t.eta && t.eta !== '-' ? `<span style="font-size:11px;color:var(--muted)">📅 ${esc(t.eta)}</span>` : ''}
</div>
<div class="ec-actions" onclick="event.stopPropagation()">
${canStop ? `<button class="mini-act" onclick="confirmAction('${esc(t.id)}','stop')">⏸ 叫停</button>
<button class="mini-act danger" onclick="confirmAction('${esc(t.id)}','cancel')">🚫 取消</button>` : ''}
${canResume ? `<button class="mini-act" onclick="taskAction('${esc(t.id)}','resume','恢复执行')">▶ 恢复</button>` : ''}
${isArchived(t) && !t.archived ? `<button class="mini-act" onclick="archiveTask('${esc(t.id)}')" title="移入归档">📦 归档</button>` : ''}
${t.archived ? `<button class="mini-act" onclick="unarchiveTask('${esc(t.id)}')" title="取消归档">📤 取消归档</button>` : ''}
</div>
</div>`;
}).join('');
}
/* ══ AGENT STATUS PANEL ══ */
let _agentsStatusData = null;
let _agentsStatusLoading = false;
async function loadAgentsStatus(){
if(_agentsStatusLoading) return;
_agentsStatusLoading = true;
try{
_agentsStatusData = await fetchJ(API+'/agents-status');
renderAgentsStatus(_agentsStatusData);
}catch(e){
document.getElementById('agent-status-panel').innerHTML=
'<div class="as-panel"><div style="color:var(--danger);font-size:12px">⚠️ 无法获取 Agent 状态</div></div>';
}finally{
_agentsStatusLoading = false;
}
}
function renderAgentsStatus(data){
if(!data||!data.ok) return;
const gw = data.gateway||{};
const agents = data.agents||[];
// 跳过 main和 taizi 重复)
const filtered = agents.filter(a=>a.id!=='main');
const running = filtered.filter(a=>a.status==='running').length;
const idle = filtered.filter(a=>a.status==='idle').length;
const offline = filtered.filter(a=>a.status==='offline').length;
const unconf = filtered.filter(a=>a.status==='unconfigured').length;
const gwCls = gw.probe?'ok':gw.alive?'warn':'err';
document.getElementById('agent-status-panel').innerHTML=`
<div class="as-panel">
<div class="as-header">
<span class="as-title">🔌 Agent 在线状态</span>
<span class="as-gw ${gwCls}">Gateway: ${esc(gw.status||'未知')}</span>
<button class="as-refresh" onclick="loadAgentsStatus()" title="刷新状态">🔄 刷新</button>
${offline+unconf>0?`<button class="as-wake-all" onclick="wakeAllAgents()" title="唤醒所有离线Agent">⚡ 全部唤醒</button>`:''}
</div>
<div class="as-grid">
${filtered.map(a=>{
const dotCls = a.status;
const canWake = a.status!=='running' && a.status!=='unconfigured' && gw.alive;
return `<div class="as-card" title="${esc(a.role)} · ${esc(a.statusLabel)}">
<div class="as-dot ${dotCls}"></div>
<div class="as-emoji">${a.emoji}</div>
<div class="as-label">${esc(a.label)}</div>
<div class="as-role">${esc(a.role)}</div>
<div class="as-status">${esc(a.statusLabel)}</div>
${a.lastActive?`<div class="as-time">⏰ ${esc(a.lastActive)}</div>`:'<div class="as-time">无活动记录</div>'}
${canWake?`<button class="as-wake-btn" onclick="event.stopPropagation();wakeAgent('${a.id}',this)">⚡ 唤醒</button>`:''}
</div>`;
}).join('')}
</div>
<div class="as-summary">
<span><span class="as-dot running" style="position:static;width:8px;height:8px"></span> ${running} 运行中</span>
<span><span class="as-dot idle" style="position:static;width:8px;height:8px"></span> ${idle} 待命</span>
${offline?`<span><span class="as-dot offline" style="position:static;width:8px;height:8px"></span> ${offline} 离线</span>`:''}
${unconf?`<span><span class="as-dot unconfigured" style="position:static;width:8px;height:8px"></span> ${unconf} 未配置</span>`:''}
<span style="margin-left:auto;font-size:10px;color:var(--muted)">检测于 ${(data.checkedAt||'').substring(11,19)}</span>
</div>
</div>`;
}
async function wakeAgent(agentId, btn){
if(btn){ btn.disabled=true; btn.textContent='⏳ 唤醒中...'; }
try{
const r = await postJ(API+'/agent-wake', {agentId});
toast(r.message||'唤醒指令已发出','ok');
// 30秒后自动刷新状态
setTimeout(()=>loadAgentsStatus(), 30000);
}catch(e){
toast('唤醒失败: '+e.message,'err');
if(btn){ btn.disabled=false; btn.textContent='⚡ 唤醒'; }
}
}
async function wakeAllAgents(){
if(!_agentsStatusData) return;
const toWake = (_agentsStatusData.agents||[]).filter(a=>
a.id!=='main' && a.status!=='running' && a.status!=='unconfigured'
);
if(!toWake.length){ toast('所有 Agent 均已在线','ok'); return; }
toast(`正在唤醒 ${toWake.length} 个 Agent...`,'ok',5000);
for(const a of toWake){
try{ await postJ(API+'/agent-wake', {agentId: a.id}); }catch(e){}
}
toast(`${toWake.length} 个唤醒指令已发出30秒后刷新状态`,'ok',5000);
setTimeout(()=>loadAgentsStatus(), 30000);
}
/* ══ DEPT MONITOR ══ */
function renderMonitor(tasks){
const DEPTS = [
{id:'taizi', label:'太子', emoji:'🤴',role:'太子', rank:'储君'},
{id:'zhongshu',label:'中书省',emoji:'📜',role:'中书令', rank:'正一品'},
{id:'menxia', label:'门下省',emoji:'🔍',role:'侍中', rank:'正一品'},
{id:'shangshu',label:'尚书省',emoji:'📮',role:'尚书令', rank:'正一品'},
{id:'libu', label:'礼部', emoji:'📝',role:'礼部尚书',rank:'正二品'},
{id:'hubu', label:'户部', emoji:'💰',role:'户部尚书',rank:'正二品'},
{id:'bingbu', label:'兵部', emoji:'⚔️',role:'兵部尚书',rank:'正二品'},
{id:'xingbu', label:'刑部', emoji:'⚖️',role:'刑部尚书',rank:'正二品'},
{id:'gongbu', label:'工部', emoji:'🔧',role:'工部尚书',rank:'正二品'},
{id:'libu_hr', label:'吏部', emoji:'👔',role:'吏部尚书',rank:'正二品'},
{id:'zaochao', label:'钦天监',emoji:'📰',role:'朝报官', rank:'正三品'},
];
// 每个部门当前承接的旨意:从 JJC 任务里找 org 匹配且非 Done 的
const activeTasks = tasks.filter(t => isEdict(t) && t.state !== 'Done' && t.state !== 'Next');
// 官员统计数据(如已加载)
const offMap = {};
if(officialsData && officialsData.officials){
officialsData.officials.forEach(o=>offMap[o.id]=o);
}
let activeCount = 0;
const cards = DEPTS.map(d => {
// 该部门正在处理的旨意
const myTasks = activeTasks.filter(t => t.org === d.label);
// 也从 flow_log 中找最近参与的旨意
const recentEdict = tasks.filter(t=>isEdict(t))
.find(t=>(t.flow_log||[]).some(f=>f.from===d.label||f.to===d.label));
const isActive = myTasks.some(t=>t.state==='Doing');
const isBlocked = myTasks.some(t=>t.state==='Blocked');
const off = offMap[d.id]||{};
const hb = off.heartbeat||{status:'idle',label:'⚪'};
if(isActive) activeCount++;
const dotCls = isBlocked?'blocked':isActive?'busy':hb.status==='active'?'active':'idle';
const statusText = isBlocked?'⚠️ 阻塞':isActive?'⚙️ 执行中':hb.status==='active'?'🟢 活跃':'⚪ 候命';
const cardCls = isBlocked?'blocked-card':isActive?'active-card':'';
return `<div class="duty-card ${cardCls}">
<div class="dc-hdr">
<span class="dc-emoji">${d.emoji}</span>
<div class="dc-info">
<div class="dc-name">${d.label}</div>
<div class="dc-role">${d.role} · ${d.rank}</div>
</div>
<div class="dc-status">
<span class="dc-dot ${dotCls}"></span>
<span>${statusText}</span>
</div>
</div>
<div class="dc-body">
${myTasks.length ? myTasks.map(t=>`
<div class="dc-task" onclick='openTask(${JSON.stringify(t.id)})' style="cursor:pointer;padding:6px;border-radius:8px;border:1px solid var(--line);margin-bottom:6px">
<div class="dc-task-id">${esc(t.id)}</div>
<div class="dc-task-title">${esc(t.title||'(无标题)')}</div>
${t.now&&t.now!=='-'?`<div class="dc-task-now">${esc(t.now.substring(0,70))}</div>`:''}
<div class="dc-task-meta">
<span class="tag st-${t.state}">${stateLabel(t)}</span>
${t.block&&t.block!=='无'?`<span class="tag" style="border-color:#ff527044;color:var(--danger)">🚫${esc(t.block)}</span>`:''}
</div>
</div>`).join('') :
`<div class="dc-idle">
<span style="font-size:20px">🪭</span>
<span>候命中 ${recentEdict?`· 最近参与 <b style="color:var(--muted)">${esc(recentEdict.id)}</b>`:'· 尚无旨意记录'}</span>
</div>`}
</div>
<div class="dc-footer">
<span class="dc-model">🤖 ${esc(off.model_short||'待配置')}</span>
${off.last_active?`<span class="dc-la">⏰ ${esc(off.last_active)}</span>`:''}
</div>
</div>`;
});
animateCounter(document.getElementById('tb-m'), activeCount, v=>v + '活跃');
document.getElementById('duty-grid').innerHTML = cards.join('');
}
/* ══ TASK DETAIL MODAL ══ */
function openTask(id){
if(!liveStatus||!liveStatus.tasks)return;
const t = liveStatus.tasks.find(x=>x.id===id);
if(!t)return;
const stages = getPipeStatus(t);
const activeStage = stages.find(s=>s.status==='active');
const hb = t.heartbeat||{status:'unknown',label:'⚪ 无数据'};
const flowLog = t.flow_log||[];
const todos = t.todos||[];
const todoDone = todos.filter(x=>x.status==='completed').length;
const todoTotal = todos.length;
const canStop = !['Done','Blocked','Cancelled'].includes(t.state);
const canResume = ['Blocked','Cancelled'].includes(t.state);
document.getElementById('modal-body').innerHTML = `
<div class="modal-id">${esc(t.id)}</div>
<div class="modal-title">${esc(t.title||'(无标题)')}</div>
<!-- 当前阶段横幅 -->
${activeStage ? `
<div class="cur-stage">
<div class="cs-icon">${activeStage.icon}</div>
<div class="cs-info">
<div class="cs-dept" style="color:${deptColor(activeStage.dept)}">${activeStage.dept}</div>
<div class="cs-action">当前阶段:${activeStage.action}</div>
</div>
<span class="hb ${hb.status} cs-hb">${esc(hb.label)}</span>
</div>` : ''}
<!-- 流程管线 -->
<div class="m-pipe">
${stages.map((s,i) => `
<div class="mp-stage">
<div class="mp-node ${s.status}">
${s.status==='done' ? '<div class="mp-done-tick">✓</div>' : ''}
<div class="mp-icon">${s.icon}</div>
<div class="mp-dept" style="${s.status==='active'?'color:var(--acc)':s.status==='done'?'color:var(--ok)':''}">${s.dept}</div>
<div class="mp-action">${s.action}</div>
</div>
${i < stages.length-1 ? `<div class="mp-arrow" style="${s.status==='done'?'color:var(--ok);opacity:.6':''}">→</div>` : ''}
</div>
`).join('')}
</div>
<!-- 操作按钮 -->
<div class="task-actions">
${canStop ? `<button class="btn-action btn-stop" onclick="confirmAction('${esc(t.id)}','stop')">⏸ 叫停任务</button>
<button class="btn-action btn-cancel" onclick="confirmAction('${esc(t.id)}','cancel')">🚫 取消任务</button>` : ''}
${canResume ? `<button class="btn-action btn-resume" onclick="taskAction('${esc(t.id)}','resume','恢复执行')">▶️ 恢复执行</button>` : ''}
${['Review','Menxia'].includes(t.state) ? `<button class="btn-action" style="background:#2ecc8a22;color:#2ecc8a;border:1px solid #2ecc8a44" onclick="reviewAction('${esc(t.id)}','approve')">✅ 准奏</button>
<button class="btn-action" style="background:#ff527022;color:#ff5270;border:1px solid #ff527044" onclick="reviewAction('${esc(t.id)}','reject')">🚫 封驳</button>` : ''}
${['Pending','Taizi','Zhongshu','Menxia','Assigned','Doing','Review','Next'].includes(t.state) ? `<button class="btn-action" style="background:#7c5cfc18;color:#7c5cfc;border:1px solid #7c5cfc44" onclick="advanceState('${esc(t.id)}','${esc(t.state)}')">⏩ 推进到下一步</button>` : ''}
</div>
<!-- 太子调度 -->
<div class="sched-section">
<div class="sched-head">
<span class="sched-title">🧭 太子调度</span>
<span class="sched-status" id="sched-status">加载中...</span>
</div>
<div class="sched-grid" id="sched-grid">
<div class="sched-kpi"><div class="k">停滞时长</div><div class="v">-</div></div>
<div class="sched-kpi"><div class="k">重试次数</div><div class="v">-</div></div>
<div class="sched-kpi"><div class="k">升级级别</div><div class="v">-</div></div>
<div class="sched-kpi"><div class="k">派发状态</div><div class="v">-</div></div>
</div>
<div class="sched-line" id="sched-line"></div>
<div class="sched-actions">
<button class="sched-btn" onclick="schedulerAction('${esc(t.id)}','retry')">🔁 重试派发</button>
<button class="sched-btn warn" onclick="schedulerAction('${esc(t.id)}','escalate')">📣 升级协调</button>
<button class="sched-btn danger" onclick="schedulerAction('${esc(t.id)}','rollback')">↩️ 回滚稳定点</button>
<button class="sched-btn" onclick="schedulerAction('${esc(t.id)}','scan')">🔍 立即扫描</button>
</div>
</div>
<!-- Todo 子任务 -->
${todoTotal ? `
<div class="todo-section">
<div class="todo-header">
<div class="m-sec-label" style="margin-bottom:0;border:none;padding:0">子任务清单(${todoDone}/${todoTotal}</div>
<div class="todo-progress">
<div class="todo-bar"><div class="todo-bar-fill" style="width:${Math.round(todoDone/todoTotal*100)}%"></div></div>
<span>${Math.round(todoDone/todoTotal*100)}%</span>
</div>
</div>
<div class="todo-list">
${todos.map(td => {
const ico = td.status==='completed'?'✅':td.status==='in-progress'?'🔄':'⬜';
const stCls = td.status==='completed'?'s-done':td.status==='in-progress'?'s-progress':'s-notstarted';
const stLabel = td.status==='completed'?'已完成':td.status==='in-progress'?'进行中':'待开始';
const itemCls = td.status==='completed'?'done':'';
const hasDetail = !!(td.detail);
const detailCls = hasDetail ? 'has-detail' : '';
return `<div class="todo-item ${itemCls} ${detailCls}" ${hasDetail ? `onclick="this.classList.toggle('expanded')"` : ''}>
<div class="t-row">
${hasDetail ? '<span class="t-expand">▶</span>' : ''}
<span class="t-icon">${ico}</span>
<span class="t-id">#${td.id||''}</span>
<span class="t-title">${esc(td.title||'')}</span>
<span class="t-status ${stCls}">${stLabel}</span>
</div>
${hasDetail ? `<div class="todo-detail">${esc(td.detail)}</div>` : ''}
</div>`;
}).join('')}
</div>
</div>` : ''}
<!-- 基本信息 -->
<div class="m-section">
<div class="m-rows">
<div class="m-row"><div class="mr-label">状态</div><div class="mr-val"><span class="tag st-${t.state}">${stateLabel(t)}</span>${(t.review_round||0)>0?`<span style="font-size:11px;color:var(--muted);margin-left:8px">共磋商 ${t.review_round} 轮</span>`:''}</div></div>
<div class="m-row"><div class="mr-label">执行部门</div><div class="mr-val"><span class="tag dt-${(t.org||'').replace(/\s/g,'')}">${esc(t.org||'—')}</span></div></div>
${t.eta&&t.eta!=='-'?`<div class="m-row"><div class="mr-label">预计完成</div><div class="mr-val">${esc(t.eta)}</div></div>`:''}
${t.block&&t.block!=='无'&&t.block!=='-'?`<div class="m-row"><div class="mr-label" style="color:var(--danger)">阻塞项</div><div class="mr-val" style="color:var(--danger)">${esc(t.block)}</div></div>`:''}
${t.now&&t.now!=='-'?`<div class="m-row" style="grid-column:1/-1"><div class="mr-label">当前进展</div><div class="mr-val" style="font-weight:400;font-size:12px">${esc(t.now)}</div></div>`:''}
${t.ac?`<div class="m-row" style="grid-column:1/-1"><div class="mr-label">验收标准</div><div class="mr-val" style="font-weight:400;font-size:12px">${esc(t.ac)}</div></div>`:''}
</div>
</div>
<!-- 流转日志 -->
${flowLog.length ? `
<div class="m-section">
<div class="m-sec-label">流转日志(${flowLog.length} 条)</div>
<div class="fl-timeline">
${flowLog.map(fl => {
const col = deptColor(fl.from||'');
const isOk = (fl.remark||'').includes('✅')||fl.to==='皇上';
const dotCls = isOk ? 'fl-ok' : (fl.from==='皇上'||fl.to==='皇上') ? 'fl-purple' : 'fl-blue';
return `<div class="fl-item">
<div class="fl-time">${fl.at?fmtLocalHM(fl.at):''}</div>
<div class="fl-dot" style="background:${col}"></div>
<div class="fl-content">
<div class="fl-who">
<span class="from" style="color:${col}">${esc(fl.from||'')}</span>
<span style="color:var(--muted)"> → </span>
<span class="to" style="color:${deptColor(fl.to||'')}">${esc(fl.to||'')}</span>
</div>
<div class="fl-rem ${dotCls}">${esc(fl.remark||'')}</div>
</div>
</div>`
}).join('')}
</div>
</div>` : '<div style="font-size:12px;color:var(--muted);padding:8px 0">暂无流转记录(任务启动后自动填充)</div>'}
<!-- 产出物 -->
${t.output&&t.output!=='-'&&t.output!==''?`
<div class="m-section">
<div class="m-sec-label">产出物</div>
<code>${esc(t.output)}</code>
</div>`:''}
<!-- 实时动态 / 执行回顾 -->
<div class="la-section" id="live-activity-section">
<div class="la-header">
<span class="la-title"><span class="la-dot" id="la-dot"></span>${['Done','Cancelled'].includes(t.state)?'执行回顾':'实时动态'}</span>
<span class="la-agent" id="la-agent-label">加载中...</span>
</div>
<div id="la-phase-bar"></div>
<div id="la-todos-bar"></div>
<div id="la-resource-bar"></div>
<div class="la-log" id="la-log">
<div class="la-empty">正在获取 Agent 活动数据...</div>
</div>
</div>
`;
document.getElementById('modal-bg').classList.add('open');
// 启动实时动态Done 任务只拉一次,不轮询)
if(['Done','Cancelled'].includes(t.state)){
startLiveActivity(t.id, true);
} else {
startLiveActivity(t.id, false);
}
fetchSchedulerState(t.id);
}
function closeModal(e){
if(e&&e.target!==document.getElementById('modal-bg')&&!e.target.classList.contains('modal-close'))return;
document.getElementById('modal-bg').classList.remove('open');
stopLiveActivity();
}
/* ══ LIVE ACTIVITY ══ */
let _laTimer = null;
let _laTaskId = null;
let _laLastCount = 0;
function stopLiveActivity(){
if(_laTimer){clearInterval(_laTimer);_laTimer=null;}
_laTaskId=null;_laLastCount=0;
}
function startLiveActivity(taskId, once){
stopLiveActivity();
_laTaskId=taskId;
fetchLiveActivity();
if(!once){
_laTimer=setInterval(fetchLiveActivity, 4000);
}
}
async function fetchLiveActivity(){
if(!_laTaskId)return;
try{
const r=await fetch(`/api/task-activity/${encodeURIComponent(_laTaskId)}`);
const d=await r.json();
renderLiveActivity(d);
fetchSchedulerState(_laTaskId);
}catch(e){
const log=document.getElementById('la-log');
if(log) log.innerHTML='<div class="la-empty">获取活动数据失败</div>';
}
}
function fmtStalled(sec){
const v=Math.max(0, parseInt(sec||0,10));
if(v<60) return `${v}`;
if(v<3600) return `${Math.floor(v/60)}${v%60}`;
const h=Math.floor(v/3600), m=Math.floor((v%3600)/60);
return `${h}小时${m}`;
}
async function fetchSchedulerState(taskId){
if(!taskId) return;
const statusEl=document.getElementById('sched-status');
if(!statusEl) return;
try{
const d = await fetchJ(API+`/scheduler-state/${encodeURIComponent(taskId)}`);
renderSchedulerState(d);
}catch(e){
statusEl.textContent='加载失败';
}
}
function renderSchedulerState(data){
const statusEl=document.getElementById('sched-status');
const grid=document.getElementById('sched-grid');
const line=document.getElementById('sched-line');
if(!statusEl||!grid||!line) return;
if(!data||!data.ok){
statusEl.textContent=(data&&data.error)?data.error:'无调度数据';
return;
}
const s=data.scheduler||{};
const stalledSec = data.stalledSec||0;
const retryCount = s.retryCount||0;
const escalation = s.escalationLevel||0;
const dispatchStatus = s.lastDispatchStatus||'idle';
const statusText = s.enabled===false?'已禁用':'运行中';
const lvlText = escalation===0?'无': escalation===1?'门下省': '尚书省';
statusEl.textContent = `${statusText} · 阈值 ${s.stallThresholdSec||180}s`;
grid.innerHTML = `
<div class="sched-kpi"><div class="k">停滞时长</div><div class="v">${esc(fmtStalled(stalledSec))}</div></div>
<div class="sched-kpi"><div class="k">重试次数</div><div class="v">${retryCount}</div></div>
<div class="sched-kpi"><div class="k">升级级别</div><div class="v">${esc(lvlText)}</div></div>
<div class="sched-kpi"><div class="k">派发状态</div><div class="v">${esc(dispatchStatus)}</div></div>
`;
const pieces=[];
if(s.lastProgressAt) pieces.push(`最近进展 ${esc((s.lastProgressAt||'').replace('T',' ').substring(0,19))}`);
if(s.lastDispatchAt) pieces.push(`最近派发 ${esc((s.lastDispatchAt||'').replace('T',' ').substring(0,19))}`);
pieces.push(`自动回滚 ${s.autoRollback===false?'关闭':'开启'}`);
if(s.lastDispatchAgent) pieces.push(`目标 ${esc(s.lastDispatchAgent)}`);
line.innerHTML = pieces.map(x=>`<span>${x}</span>`).join('');
}
async function schedulerAction(taskId, action){
if(!taskId) return;
try{
if(action==='scan'){
const r = await postJ(API+'/scheduler-scan', {thresholdSec: 180});
if(r.ok){ toast(`🔍 扫描完成:${r.count||0} 个动作`,'ok'); }
else{ toast(r.error||'扫描失败','err'); }
fetchSchedulerState(taskId);
return;
}
const reason = prompt(`请输入${action==='retry'?'重试':action==='escalate'?'升级':'回滚'}原因(可留空):`) || '';
const routeMap = {
retry: '/scheduler-retry',
escalate: '/scheduler-escalate',
rollback: '/scheduler-rollback',
};
const r = await postJ(API + routeMap[action], {taskId, reason});
if(r.ok){ toast(r.message||'操作成功','ok'); }
else{ toast(r.error||'操作失败','err'); }
fetchSchedulerState(taskId);
loadAll();
}catch(e){
toast('服务器连接失败','err');
}
}
function renderLiveActivity(data){
const log=document.getElementById('la-log');
const dot=document.getElementById('la-dot');
const agLbl=document.getElementById('la-agent-label');
if(!log)return;
if(!data.ok||!data.activity||!data.activity.length){
const msg=data.message||data.error||'Agent 尚未上报进展(等待 Agent 调用 progress 命令)';
log.innerHTML=`<div class="la-empty">${esc(msg)}</div>`;
if(dot)dot.classList.add('idle');
if(agLbl){
const agents=(data.relatedAgents||[]).join(', ');
agLbl.textContent=agents?`搜索范围: ${agents}`:'无对应 Agent';
}
return;
}
if(agLbl){
const parts=[];
if(data.agentLabel)parts.push(data.agentLabel);
if(data.relatedAgents&&data.relatedAgents.length>1) parts.push(`${data.relatedAgents.length}个 Agent`);
if(data.lastActive)parts.push(`最后活跃: ${data.lastActive}`);
agLbl.textContent=parts.join(' · ');
}
const srcBanner = '';
// ── 阶段耗时时间线 ──
const phaseBar = document.getElementById('la-phase-bar');
if(phaseBar && data.phaseDurations && data.phaseDurations.length){
const maxDur = Math.max(...data.phaseDurations.map(p=>p.durationSec||1), 1);
const phaseColors = {'皇上':'#eab308','太子':'#f97316','中书省':'#3b82f6','门下省':'#8b5cf6','尚书省':'#10b981','六部':'#06b6d4','礼部':'#ec4899','户部':'#f59e0b','兵部':'#ef4444','刑部':'#6366f1','工部':'#14b8a6','吏部':'#d946ef'};
let totalDurText = data.totalDuration ? `<span style="margin-left:auto;font-size:10px;color:var(--muted)">总耗时 ${data.totalDuration}</span>` : '';
phaseBar.innerHTML = `<div style="padding:4px 0 8px;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px"><span style="font-size:11px;font-weight:600;color:var(--fg)">⏱ 阶段耗时</span>${totalDurText}</div>
${data.phaseDurations.map(p=>{
const pct=Math.max(5,Math.round((p.durationSec||1)/maxDur*100));
const color=phaseColors[p.phase]||'#6b7280';
const ongoing=p.ongoing?'<span style="font-size:9px;color:#60a5fa"> ●进行中</span>':'';
return `<div style="display:flex;align-items:center;gap:6px;margin:2px 0;font-size:11px">
<span style="min-width:48px;color:var(--muted);text-align:right">${esc(p.phase)}</span>
<div style="flex:1;height:14px;background:var(--panel);border-radius:3px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px;opacity:${p.ongoing?0.6:0.85}"></div>
</div>
<span style="min-width:60px;font-size:10px;color:var(--muted)">${p.durationText}${ongoing}</span>
</div>`;
}).join('')}
</div>`;
}
// ── Todos 进度条 ──
const todosBar=document.getElementById('la-todos-bar');
if(todosBar && data.todosSummary){
const s=data.todosSummary;
todosBar.innerHTML=`<div style="padding:4px 0 8px;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="font-size:11px;font-weight:600;color:var(--fg)">📊 执行进度</span>
<span style="font-size:20px;font-weight:700;color:${s.percent>=100?'#22c55e':s.percent>=50?'#60a5fa':'var(--fg)'}">${s.percent}%</span>
<span style="font-size:10px;color:var(--muted)">✅${s.completed} 🔄${s.inProgress}${s.notStarted} / 共${s.total}项</span>
</div>
<div style="height:8px;background:var(--panel);border-radius:4px;overflow:hidden;display:flex">
<div style="width:${s.total?s.completed/s.total*100:0}%;background:#22c55e;transition:width .3s"></div>
<div style="width:${s.total?s.inProgress/s.total*100:0}%;background:#3b82f6;transition:width .3s"></div>
</div>
</div>`;
}
// ── 资源消耗汇总 ──
const resBar=document.getElementById('la-resource-bar');
if(resBar && data.resourceSummary){
const r=data.resourceSummary;
const parts=[];
if(r.totalTokens) parts.push(`🔢 ${r.totalTokens.toLocaleString()} tokens`);
if(r.totalCost) parts.push(`💰 $${r.totalCost.toFixed(4)}`);
if(r.totalElapsedSec){
const m=Math.floor(r.totalElapsedSec/60), s=r.totalElapsedSec%60;
parts.push(`${m>0?m+'分':''}${s}`);
}
if(parts.length){
resBar.innerHTML=`<div style="padding:4px 0 8px;border-bottom:1px solid var(--border);display:flex;gap:12px;align-items:center">
<span style="font-size:11px;font-weight:600;color:var(--fg)">📈 资源消耗</span>
${parts.map(p=>`<span style="font-size:11px;color:var(--muted)">${p}</span>`).join('')}
</div>`;
}
}
// 判断是否活跃最后一条活动5分钟内
const lastEntry=data.activity[data.activity.length-1];
let isActive=false;
if(lastEntry&&lastEntry.at){
const diff=Date.now()-lastEntry.at;
isActive=diff<300000; // 5min
}
if(dot){dot.classList.toggle('idle',!isActive);}
// Agent label map
const _agentLabels={main:'太子',zhongshu:'中书省',menxia:'门下省',shangshu:'尚书省',libu:'礼部',hubu:'户部',bingbu:'兵部',xingbu:'刑部',gongbu:'工部',libu_hr:'吏部',zaochao:'钦天监'};
const renderEntry=(a)=>{
const time=a.at?fmtActivityTime(a.at):'';
if(a.kind==='flow'){
return `<div class="la-entry la-tool"><span class="la-icon">📋</span><span class="la-body"><b>${esc(a.from)}</b> → <b>${esc(a.to)}</b> ${esc(a.remark||'')}</span><span class="la-time">${time}</span></div>`;
}
if(a.kind==='progress'){
const agBadge=a.agent?`<span style="font-size:9px;color:var(--muted);background:var(--panel);padding:1px 4px;border-radius:3px;margin-right:4px">${_agentLabels[a.agent]||a.agent}</span>`:'';
return `<div class="la-entry la-assistant"><span class="la-icon">🔄</span><span class="la-body">${agBadge}<b>当前进展:</b>${esc(a.text)}</span><span class="la-time">${time}</span></div>`;
}
if(a.kind==='todos'){
const agBadge=a.agent?`<span style="font-size:9px;color:var(--muted);background:var(--panel);padding:1px 4px;border-radius:3px;margin-right:4px">${_agentLabels[a.agent]||a.agent}</span>`:'';
// diff 高亮
const diffMap=new Map();
if(a.diff){
(a.diff.changed||[]).forEach(c=>diffMap.set(c.id,{type:'changed',from:c.from,to:c.to}));
(a.diff.added||[]).forEach(c=>diffMap.set(c.id,{type:'added'}));
}
const items=(a.items||[]).map(td=>{
const icon=td.status==='completed'?'✅':td.status==='in-progress'?'🔄':'⬜';
const cls=td.status==='completed'?'style="opacity:0.5;text-decoration:line-through"':td.status==='in-progress'?'style="color:#60a5fa;font-weight:bold"':'';
const d=diffMap.get(String(td.id));
const diffTag=d?(d.type==='changed'&&d.to==='completed'?'<span style="color:#22c55e;font-size:9px;margin-left:4px">✨刚完成</span>':d.type==='changed'?`<span style="color:#f59e0b;font-size:9px;margin-left:4px">↻${d.from}${d.to}</span>`:d.type==='added'?'<span style="color:#3b82f6;font-size:9px;margin-left:4px">🆕新增</span>':''):'';
return `<div ${cls}>${icon} ${esc(td.title)}${diffTag}</div>`;
}).join('');
const removedHtml=(a.diff&&a.diff.removed&&a.diff.removed.length)?a.diff.removed.map(r=>`<div style="opacity:0.4;text-decoration:line-through">🗑 ${esc(r.title)}</div>`).join(''):'';
return `<div class="la-entry" style="flex-direction:column;align-items:flex-start;gap:2px"><div style="font-size:11px;color:var(--muted);margin-bottom:2px">${agBadge}📝 执行计划</div>${items}${removedHtml}</div>`;
}
// legacy kinds (assistant/tool_result/user) - backward compatibility
const agBadge=a.agent?`<span style="font-size:9px;color:var(--muted);background:var(--panel);padding:1px 4px;border-radius:3px;margin-right:4px">${_agentLabels[a.agent]||a.agent}</span>`:'';
if(a.kind==='assistant'){
let html='';
if(a.thinking){
html+=`<div class="la-entry la-thinking"><span class="la-icon">💭</span><span class="la-body">${agBadge}${esc(a.thinking)}</span><span class="la-time">${time}</span></div>`;
}
if(a.tools&&a.tools.length){
a.tools.forEach(tc=>{
html+=`<div class="la-entry la-tool"><span class="la-icon">🔧</span><span class="la-body">${agBadge}<span class="la-tool-name">${esc(tc.name)}</span><span class="la-trunc">${esc(tc.input_preview||'')}</span></span><span class="la-time">${time}</span></div>`;
});
}
if(a.text){
html+=`<div class="la-entry la-assistant"><span class="la-icon">🤖</span><span class="la-body">${agBadge}${esc(a.text)}</span><span class="la-time">${time}</span></div>`;
}
return html;
}
if(a.kind==='tool_result'){
const ok=a.exitCode===0||a.exitCode===null||a.exitCode===undefined;
return `<div class="la-entry la-tool-result ${ok?'ok':'err'}"><span class="la-icon">${ok?'✅':'❌'}</span><span class="la-body">${agBadge}<span class="la-tool-name">${esc(a.tool||'')}</span>${a.output?esc(a.output.substring(0,150)):''}</span><span class="la-time">${time}</span></div>`;
}
if(a.kind==='user'){
return `<div class="la-entry la-user"><span class="la-icon">📥</span><span class="la-body">${agBadge}${esc(a.text||'')}</span><span class="la-time">${time}</span></div>`;
}
return '';
};
const flowItems=(data.activity||[]).filter(a=>a.kind==='flow');
const nonFlowItems=(data.activity||[]).filter(a=>a.kind!=='flow');
const flowHtml=flowItems.length
? `<div class="la-flow-wrap">${flowItems.map(renderEntry).join('')}</div>`
: '';
let groupedHtml='';
if(nonFlowItems.length){
const grouped=new Map();
nonFlowItems.forEach(a=>{
const key=a.agent||'unknown';
if(!grouped.has(key)) grouped.set(key,[]);
grouped.get(key).push(a);
});
groupedHtml=`<div class="la-groups">${Array.from(grouped.entries()).map(([agent, items])=>{
const label=_agentLabels[agent]||agent||'未标识';
const last=items[items.length-1];
const lastTime=last&&last.at?fmtActivityTime(last.at):'--:--:--';
return `<div class="la-group"><div class="la-group-hd"><span class="name">${esc(label)}</span><span>最近更新 ${lastTime}</span></div><div class="la-group-bd">${items.map(renderEntry).join('')}</div></div>`;
}).join('')}</div>`;
}
const entries=flowHtml+groupedHtml;
const shouldScroll=_laLastCount!==data.activity.length;
log.innerHTML=srcBanner+(entries||'<div class="la-empty">暂无此任务的对话记录</div>');
_laLastCount=data.activity.length;
if(shouldScroll){
log.scrollTop=log.scrollHeight;
}
}
function parseDateFlexible(ts){
if(ts===null||ts===undefined||ts==='') return null;
if(ts instanceof Date) return isNaN(ts.getTime())?null:ts;
if(typeof ts==='number'){
const ms = ts > 1e12 ? ts : ts * 1000;
const d = new Date(ms);
return isNaN(d.getTime()) ? null : d;
}
if(typeof ts!=='string') return null;
const s = ts.trim();
if(!s) return null;
if(/^\d+$/.test(s)){
const n = Number(s);
if(Number.isFinite(n)){
const ms = n > 1e12 ? n : n * 1000;
const d = new Date(ms);
return isNaN(d.getTime()) ? null : d;
}
}
const localMatch = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/);
if(localMatch){
const [, y, m, d, h, mi, se] = localMatch;
const dt = new Date(Number(y), Number(m)-1, Number(d), Number(h), Number(mi), Number(se));
return isNaN(dt.getTime()) ? null : dt;
}
const iso = s.includes(' ') ? s.replace(' ', 'T') : s;
const parsed = new Date(iso);
return isNaN(parsed.getTime()) ? null : parsed;
}
function fmtActivityTime(ts){
const d = parseDateFlexible(ts);
if(!d) return '';
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
}
function fmtLocalHM(ts){
const d = parseDateFlexible(ts);
if(!d) return '';
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}
function fmtLocalFull(ts){
const d = parseDateFlexible(ts);
if(!d) return '';
const Y=d.getFullYear(), M=String(d.getMonth()+1).padStart(2,'0'), D=String(d.getDate()).padStart(2,'0');
const h=String(d.getHours()).padStart(2,'0'), m=String(d.getMinutes()).padStart(2,'0'), s=String(d.getSeconds()).padStart(2,'0');
return `${Y}-${M}-${D} ${h}:${m}:${s}`;
}
/* ══ MODEL CONFIG ══ */
// Fallback 硬编码列表,优先从后端 knownModels 动态获取
const FALLBACK_MODELS=[
{id:'anthropic/claude-sonnet-4-6',l:'Claude Sonnet 4.6',p:'Anthropic'},
{id:'anthropic/claude-opus-4-5',l:'Claude Opus 4.5',p:'Anthropic'},
{id:'anthropic/claude-haiku-3-5',l:'Claude Haiku 3.5',p:'Anthropic'},
{id:'openai/gpt-4o',l:'GPT-4o',p:'OpenAI'},
{id:'openai/gpt-4o-mini',l:'GPT-4o Mini',p:'OpenAI'},
{id:'openai-codex/gpt-5.3-codex',l:'GPT-5.3 Codex',p:'OpenAI Codex'},
{id:'google/gemini-2.0-flash',l:'Gemini 2.0 Flash',p:'Google'},
{id:'google/gemini-2.5-pro',l:'Gemini 2.5 Pro',p:'Google'},
{id:'copilot/claude-sonnet-4',l:'Claude Sonnet 4',p:'Copilot'},
{id:'copilot/claude-opus-4.5',l:'Claude Opus 4.5',p:'Copilot'},
{id:'github-copilot/claude-opus-4.6',l:'Claude Opus 4.6',p:'GitHub Copilot'},
{id:'copilot/gpt-4o',l:'GPT-4o',p:'Copilot'},
{id:'copilot/gemini-2.5-pro',l:'Gemini 2.5 Pro',p:'Copilot'},
{id:'copilot/o3-mini',l:'o3-mini',p:'Copilot'},
];
function getModels(cfg){
// 优先从后端 agent_config.json 的 knownModels 字段动态获取
if(cfg && cfg.knownModels && cfg.knownModels.length){
return cfg.knownModels.map(m=>({id:m.id, l:m.label, p:m.provider}));
}
return FALLBACK_MODELS;
}
function renderModels(cfg){
if(!cfg||!cfg.agents){document.getElementById('model-grid').innerHTML='<div class="empty" style="grid-column:1/-1">请确保本地服务器已启动</div>';return}
const models = getModels(cfg);
const opts=models.map(m=>`<option value="${m.id}">${m.l} (${m.p})</option>`).join('');
document.getElementById('model-grid').innerHTML=cfg.agents.map(ag=>`
<div class="mc-card">
<div class="mc-top"><span class="mc-emoji">${ag.emoji||'🏛️'}</span>
<div><div class="mc-name">${esc(ag.label)} <span style="font-size:11px;color:var(--muted)">${esc(ag.id)}</span></div><div class="mc-role">${esc(ag.role)}</div></div>
</div>
<div class="mc-cur">当前: <b>${esc(ag.model)}</b></div>
<select class="msel" id="sel-${ag.id}" onchange="onMC('${ag.id}')">${opts}</select>
<div class="mc-btns"><button class="btn btn-p" id="btn-${ag.id}" onclick="applyModel('${ag.id}')" disabled>应用</button><button class="btn btn-g" onclick="resetMC('${ag.id}')">重置</button></div>
<div class="mc-st" id="mcs-${ag.id}"></div>
</div>`).join('');
cfg.agents.forEach(ag=>{const s=document.getElementById('sel-'+ag.id);if(s)s.value=ag.model});
}
function onMC(id){const s=document.getElementById('sel-'+id),b=document.getElementById('btn-'+id),ag=agentConfig&&agentConfig.agents?agentConfig.agents.find(a=>a.id===id):null;if(!ag||!s)return;b.disabled=s.value===ag.model}
function resetMC(id){const s=document.getElementById('sel-'+id),b=document.getElementById('btn-'+id),ag=agentConfig&&agentConfig.agents?agentConfig.agents.find(a=>a.id===id):null;if(!ag||!s)return;s.value=ag.model;b.disabled=true}
async function applyModel(id){
const s=document.getElementById('sel-'+id),b=document.getElementById('btn-'+id),st=document.getElementById('mcs-'+id);
if(!s||!b)return;b.disabled=true;st.className='mc-st pending';st.textContent='⟳ 提交中…';
try{const r=await postJ(API+'/set-model',{agentId:id,model:s.value});
if(r.ok){st.className='mc-st ok';st.textContent='✅ 已提交Gateway 重启中约5秒';toast(id+'模型已更改','ok');setTimeout(()=>loadAgentConfig(),5500)}
else{st.className='mc-st err';st.textContent='❌ '+( r.error||'错误');b.disabled=false}
}catch(e){st.className='mc-st err';st.textContent='❌ 无法连接服务器';b.disabled=false}
}
function renderCL(log){
const el=document.getElementById('cl-list');
if(!log||!log.length){el.innerHTML='<div style="font-size:12px;color:var(--muted);padding:8px 0">暂无变更</div>';return}
el.innerHTML=[...log].reverse().slice(0,15).map(e=>{
const rb = e.rolledBack ? ' <span style="color:var(--danger);font-size:10px;border:1px solid #ff527044;padding:1px 5px;border-radius:3px">⚠ 已回滚</span>' : '';
return `<div class="cl-row"><span class="cl-t">${esc((e.at||'').substring(0,16).replace('T',' '))}</span><span class="cl-a">${esc(e.agentId||'')}</span><span class="cl-c"><b>${esc(e.oldModel||'')}</b> → <b>${esc(e.newModel||'')}</b>${rb}</span></div>`;
}).join('')
}
/* ══ SKILLS ══ */
function renderSkills(cfg){
if(!cfg||!cfg.agents){document.getElementById('skills-grid').innerHTML='<div class="empty">无法加载</div>';return}
document.getElementById('skills-grid').innerHTML=cfg.agents.map(ag=>`
<div class="sk-card"><div class="sk-hdr"><span class="sk-emoji">${ag.emoji||'🏛️'}</span><span class="sk-name">${esc(ag.label)}</span><span class="sk-cnt">${(ag.skills||[]).length} 技能</span></div>
<div class="sk-list">${!(ag.skills||[]).length?'<div class="sk-empty">暂无 Skills</div>':(ag.skills||[]).map(sk=>`<div class="sk-item" onclick="openSkill('${esc(ag.id)}','${esc(sk.name)}')"><span class="si-name">📦 ${esc(sk.name)}</span><span class="si-desc">${esc(sk.description||'无描述')}</span><span class="si-arrow"></span></div>`).join('')}</div>
<div class="sk-add" onclick="openAddSkillForm('${esc(ag.id)}','${esc(ag.label)}')"> 添加技能</div></div>`).join('')
}
/* skill detail modal — 写入 modal-body (复用已有 modal) */
async function openSkill(agentId, skillName){
document.getElementById('modal-body').innerHTML=`
<div style="font-size:11px;color:var(--acc);font-weight:700;letter-spacing:.04em;margin-bottom:4px">${esc(agentId.toUpperCase())}</div>
<div style="font-size:20px;font-weight:800;margin-bottom:16px">📦 ${esc(skillName)}</div>
<div id="sk-modal-content" class="sk-modal-body"><div style="color:var(--muted);font-size:12px;padding:24px;text-align:center">⟳ 加载中…</div></div>`;
document.getElementById('modal-bg').classList.add('open');
try{
const r = await fetchJ(API+'/skill-content/'+encodeURIComponent(agentId)+'/'+encodeURIComponent(skillName));
const el = document.getElementById('sk-modal-content');
if(!el) return;
if(r.ok){
const html = simpleMarkdown(r.content||'');
el.innerHTML = `<div class="sk-md">${html}</div><div class="sk-path">📂 ${esc(r.path||'')}</div>`;
} else {
el.innerHTML = `<div style="color:#ff5270;font-size:13px;padding:16px">❌ ${esc(r.error||'无法读取')}</div>`;
}
}catch(e){
const el = document.getElementById('sk-modal-content');
if(el) el.innerHTML = `<div style="color:#ff5270;font-size:13px;padding:16px">❌ 服务器连接失败</div>`;
}
}
/* simple markdown → HTML */
function simpleMarkdown(md){
let h = esc(md);
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
h = h.replace(/^### (.+)$/gm, '<h3>$1</h3>');
h = h.replace(/^## (.+)$/gm, '<h2>$1</h2>');
h = h.replace(/^# (.+)$/gm, '<h1>$1</h1>');
h = h.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
h = h.replace(/\*(.+?)\*/g, '<i>$1</i>');
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
h = h.replace(/^---$/gm, '<hr>');
h = h.replace(/^\|(.+)\|$/gm, (m,row)=>{
const cells=row.split('|').map(c=>c.trim());
return '<tr>'+cells.map(c=>`<td>${c}</td>`).join('')+'</tr>';
});
h = h.replace(/(<tr>.*<\/tr>\n?)+/g, '<table>$&</table>');
h = h.replace(/^- (.+)$/gm, '<li>$1</li>');
h = h.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>');
h = h.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
h = h.replace(/\n\n/g, '</p><p>');
h = '<p>' + h + '</p>';
h = h.replace(/<p><\/p>/g, '');
h = h.replace(/<p>(<h[123]>)/g, '$1');
h = h.replace(/(<\/h[123]>)<\/p>/g, '$1');
h = h.replace(/<p>(<pre>)/g, '$1');
h = h.replace(/(<\/pre>)<\/p>/g, '$1');
h = h.replace(/<p>(<ul>)/g, '$1');
h = h.replace(/(<\/ul>)<\/p>/g, '$1');
h = h.replace(/<p>(<table>)/g, '$1');
h = h.replace(/(<\/table>)<\/p>/g, '$1');
h = h.replace(/<p>(<hr>)<\/p>/g, '$1');
return h;
}
/* ══ ADD SKILL FORM (规范化表单) ══ */
function openAddSkillForm(agentId, agentLabel){
document.getElementById('modal-body').innerHTML=`
<div style="font-size:11px;color:var(--acc);font-weight:700;letter-spacing:.04em;margin-bottom:4px">为 ${esc(agentLabel)} 添加技能</div>
<div style="font-size:20px;font-weight:800;margin-bottom:18px"> 新增 Skill</div>
<div style="background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:14px;margin-bottom:18px;font-size:12px;line-height:1.7;color:var(--muted)">
<b style="color:var(--text)">📋 Skill 规范说明</b><br>
• 技能名称使用<b style="color:var(--text)">小写英文 + 连字符</b>,如 <code style="background:var(--bg);padding:2px 6px;border-radius:4px">data-analysis</code>、<code style="background:var(--bg);padding:2px 6px;border-radius:4px">code-review</code><br>
• 创建后会在 <code style="background:var(--bg);padding:2px 6px;border-radius:4px">~/.openclaw/workspace-${esc(agentId)}/skills/{name}/SKILL.md</code> 生成模板文件<br>
• SKILL.md 使用 <b style="color:var(--text)">YAML frontmatter</b> 头部name + description正文为 Markdown 格式<br>
• 技能会在 agent 收到相关任务时<b style="color:var(--text)">自动激活</b>,无需手动触发
</div>
<form onsubmit="submitAddSkill(event,'${esc(agentId)}','${esc(agentLabel)}')" style="display:flex;flex-direction:column;gap:14px">
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">技能名称 <span style="color:#ff5270">*</span></label>
<input id="ask-name" type="text" required placeholder="如 data-analysis, code-review, doc-writer"
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]" title="小写英文+连字符至少2个字符"
style="width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--text);font-size:13px;outline:none"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9\\-]/g,'')">
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">技能描述 <span style="color:var(--muted);font-weight:400">(一句话说明用途)</span></label>
<input id="ask-desc" type="text" placeholder="如负责代码审查与质量检测发现潜在Bug和安全风险"
style="width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--text);font-size:13px;outline:none">
</div>
<div>
<label style="font-size:12px;font-weight:600;display:block;margin-bottom:6px">触发条件 <span style="color:var(--muted);font-weight:400">(可选,何时激活此技能)</span></label>
<input id="ask-trigger" type="text" placeholder="如:当任务涉及代码质量、安全审查时激活"
style="width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--text);font-size:13px;outline:none">
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:4px">
<button type="button" class="btn btn-g" onclick="closeModal()" style="padding:8px 20px;font-size:13px">取消</button>
<button type="submit" id="ask-submit" class="btn" style="padding:8px 20px;font-size:13px;background:var(--acc);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">📦 创建技能</button>
</div>
</form>`;
document.getElementById('modal-bg').classList.add('open');
document.getElementById('ask-name').focus();
}
async function submitAddSkill(e, agentId, agentLabel){
e.preventDefault();
const name = document.getElementById('ask-name').value.trim();
const desc = document.getElementById('ask-desc').value.trim();
const trigger = document.getElementById('ask-trigger').value.trim();
if(!name) return;
const btn = document.getElementById('ask-submit');
btn.disabled=true; btn.textContent='⟳ 创建中…';
try{
const r = await postJ(API+'/add-skill', {agentId, skillName:name, description:desc, trigger:trigger});
if(r.ok){
toast(`✅ 技能 ${name} 已添加到 ${agentLabel}`, 'ok');
closeModal();
loadAgentConfig();
} else { toast(r.error||'添加失败','err'); btn.disabled=false; btn.textContent='📦 创建技能'; }
}catch(e){ toast('服务器连接失败','err'); btn.disabled=false; btn.textContent='📦 创建技能'; }
}
/* ══ DATA LOADING ══ */
async function loadLive(){
try{
liveStatus=await fetchJ(API+'/live-status');
// 提前加载官员数据,供省部调度 Tab 使用
if(!officialsData){
try{ officialsData = await fetchJ(API+'/officials-stats'); }catch(e){}
}
const tasks=liveStatus.tasks||[];
const sync=liveStatus.syncStatus||{};
const cs=document.getElementById('chip-sync');
cs.textContent=sync.ok?'✅ 同步正常':'⚠️ 同步异常';
cs.className='chip '+(sync.ok?'ok':'warn');
renderEdicts(tasks);
renderMonitor(tasks);
renderSessions(tasks);
}catch(e){
document.getElementById('chip-sync').textContent='❌ 服务器未启动';
document.getElementById('chip-sync').className='chip err';
}
}
async function loadAgentConfig(){
try{
agentConfig=await fetchJ(API+'/agent-config');
renderModels(agentConfig);
renderSkills(agentConfig);
const log=await fetchJ(API+'/model-change-log').catch(()=>[]);
renderCL(log);
}catch(e){
document.getElementById('model-grid').innerHTML='<div class="empty" style="grid-column:1/-1">⚠️ 请先启动本地服务器:<code>python3 dashboard/server.py</code></div>';
}
}
async function loadAll(){
await loadLive();
if(['models','skills'].includes(activeTab))await loadAgentConfig();
if(activeTab==='sessions')renderSessions(liveStatus?liveStatus.tasks:[]);
if(activeTab==='memorials')renderMemorials();
if(activeTab==='templates')renderTemplates();
if(activeTab==='morning' && !_newsPolling) loadMorning();
}
/* ══ SESSIONS / 小任务 ══ */
const AGENT_EMOJI_MAP = {};
const AGENT_LABEL_MAP = {};
let sessFilter = 'all';
// isEdict already defined above
/* 从 title 和 sourceMeta 中提取人类可读名称 */
function humanTitle(t){
let title = t.title||'';
// "褚凤天 会话" → ok, keep
// "agent:shangshu:main 会话" → "尚书省 主会话"
// "agent:gongbu:subagent:xxx 会话" → "工部 子任务"
// "agent:shangshu:cron:xxx 会话" → "尚书省 定时任务"
// "heartbeat 会话" → "心跳检测"
if(title==='heartbeat 会话') return '💓 心跳检测';
const m = title.match(/^agent:(\w+):(\w+)/);
if(m){
const agLabel = AGENT_LABEL_MAP[m[1]] || m[1];
if(m[2]==='main') return agLabel+' · 主会话';
if(m[2]==='subagent') return agLabel+' · 子任务执行';
if(m[2]==='cron') return agLabel+' · 定时任务';
return agLabel+' · '+m[2];
}
// "兵部Mission Control 映射验收" — already nice
return title.replace(/ 会话$/, '') || t.id;
}
/* 提取来源渠道 */
function channelLabel(t){
const now = t.now||'';
if(now.includes('feishu/direct')) return {icon:'💬', text:'飞书对话'};
if(now.includes('feishu')) return {icon:'💬', text:'飞书'};
if(now.includes('wecom')) return {icon:'📱', text:'企业微信'};
if(now.includes('telegram')) return {icon:'✈️', text:'Telegram'};
if(now.includes('discord')) return {icon:'🎮', text:'Discord'};
if(now.includes('slack')) return {icon:'💬', text:'Slack'};
if(now.includes('webchat')) return {icon:'🌐', text:'WebChat'};
if(now.includes('cron')) return {icon:'⏰', text:'定时'};
if(now.includes('direct')) return {icon:'📨', text:'直连'};
return {icon:'🔗', text:'会话'};
}
/* 提取最后一条有意义的 assistant 消息 */
function lastMessage(t){
const acts = t.activity||[];
for(let i=acts.length-1;i>=0;i--){
const a=acts[i];
if(a.kind==='assistant'){
let txt = a.text||'';
if(txt.startsWith('NO_REPLY')) continue;
if(txt.startsWith('Reasoning:')) continue;
// 清理 markdown 和 [[reply_to_current]]
txt = txt.replace(/\[\[.*?\]\]/g,'').replace(/\*\*/g,'').replace(/^#+\s/gm,'').trim();
return txt.substring(0,120) + (txt.length>120?'…':'');
}
}
return '';
}
function renderSessions(tasks){
if(!tasks) tasks=[];
// build agent maps from agentConfig if available
if(agentConfig && agentConfig.agents){
agentConfig.agents.forEach(a=>{
AGENT_EMOJI_MAP[a.id] = a.emoji||'🏛️';
AGENT_LABEL_MAP[a.id] = a.label||a.id;
});
}
const sessions = tasks.filter(t=>!isEdict(t));
animateCounter(document.getElementById('tb-s'), sessions.length);
// apply filter
let filtered = sessions;
if(sessFilter === 'active') filtered = sessions.filter(t=>!['Done','Cancelled'].includes(t.state));
else if(sessFilter !== 'all') filtered = sessions.filter(t=>extractAgent(t) === sessFilter);
const grid = document.getElementById('sess-grid');
if(!filtered.length){
grid.innerHTML = '<div style="font-size:13px;color:var(--muted);padding:24px;text-align:center;grid-column:1/-1">暂无小任务/会话数据</div>';
return;
}
grid.innerHTML = filtered.map(t=>{
const agent = extractAgent(t);
const emoji = AGENT_EMOJI_MAP[agent] || '🏛️';
const agLabel = AGENT_LABEL_MAP[agent] || t.org || agent;
const hb = t.heartbeat||{};
const sm = t.sourceMeta||{};
const ch = channelLabel(t);
const title = humanTitle(t);
const msg = lastMessage(t);
const totalTk = sm.totalTokens||0;
const updatedAt = t.eta||''; // eta is actually the last update time in our data
const hbDot = hb.status==='active'?'🟢':hb.status==='warn'?'🟡':hb.status==='stalled'?'🔴':'⚪';
const hbText = hb.label ? hb.label.replace(/^[🟢🟡🔴⚪]\s*/,'') : '未知';
const model = (t.now||'').match(/模型\s*(\S+)/);
const modelStr = model ? model[1] : '';
const st = t.state||'Unknown';
return `<div class="sess-card" onclick="openSessionDetail(${JSON.stringify(JSON.stringify(t))})">
<div class="sc-top">
<span class="sc-emoji">${emoji}</span>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px">
<span class="sc-agent">${esc(agLabel)}</span>
<span style="font-size:10px;color:var(--muted);background:var(--panel2);padding:2px 6px;border-radius:4px">${ch.icon} ${ch.text}</span>
</div>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span title="${esc(hbText)}">${hbDot}</span>
<span class="tag st-${st}" style="font-size:10px">${STATE_LABEL[st]||st}</span>
</div>
</div>
<div class="sc-title">${esc(title)}</div>
${msg?`<div style="font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:8px;border-left:2px solid var(--line);padding-left:8px;max-height:40px;overflow:hidden">${esc(msg)}</div>`:''}
<div class="sc-meta">
${modelStr?`<span style="font-size:10px;color:var(--acc);font-weight:600">${esc(modelStr)}</span>`:''}
${totalTk?`<span style="font-size:10px;color:var(--muted)">🪙 ${totalTk.toLocaleString()} tokens</span>`:''}
${updatedAt?`<span class="sc-time">${timeAgo(updatedAt)}</span>`:''}
</div>
</div>`;
}).join('');
}
/* 会话详情弹窗 */
function openSessionDetail(jsonStr){
const t = JSON.parse(jsonStr);
const agent = extractAgent(t);
const agLabel = AGENT_LABEL_MAP[agent] || t.org || agent;
const emoji = AGENT_EMOJI_MAP[agent] || '🏛️';
const title = humanTitle(t);
const ch = channelLabel(t);
const hb = t.heartbeat||{};
const sm = t.sourceMeta||{};
const acts = t.activity||[];
const model = (t.now||'').match(/模型\s*(\S+)/);
const st = t.state||'Unknown';
document.getElementById('modal-body').innerHTML=`
<div style="font-size:11px;color:var(--acc);font-weight:700;letter-spacing:.04em;margin-bottom:4px">${esc(t.id)}</div>
<div style="font-size:20px;font-weight:800;margin-bottom:6px">${emoji} ${esc(title)}</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:18px;flex-wrap:wrap">
<span class="tag st-${st}">${STATE_LABEL[st]||st}</span>
<span style="font-size:11px;color:var(--muted)">${ch.icon} ${ch.text}</span>
<span style="font-size:11px;color:var(--muted)">${esc(agLabel)}</span>
${model?`<span style="font-size:11px;color:var(--acc)">${esc(model[1])}</span>`:''}
${hb.label?`<span style="font-size:11px">${esc(hb.label)}</span>`:''}
</div>
<!-- 统计 -->
<div style="display:flex;gap:14px;margin-bottom:18px;flex-wrap:wrap">
${sm.totalTokens?`<div style="background:var(--panel2);padding:10px 16px;border-radius:8px;font-size:12px"><div style="font-size:16px;font-weight:700;color:var(--acc)">${sm.totalTokens.toLocaleString()}</div><div style="color:var(--muted);font-size:10px">总 Tokens</div></div>`:''}
${sm.inputTokens!=null?`<div style="background:var(--panel2);padding:10px 16px;border-radius:8px;font-size:12px"><div style="font-size:16px;font-weight:700">${(sm.inputTokens||0).toLocaleString()}</div><div style="color:var(--muted);font-size:10px">输入</div></div>`:''}
${sm.outputTokens!=null?`<div style="background:var(--panel2);padding:10px 16px;border-radius:8px;font-size:12px"><div style="font-size:16px;font-weight:700">${(sm.outputTokens||0).toLocaleString()}</div><div style="color:var(--muted);font-size:10px">输出</div></div>`:''}
</div>
<!-- 最近活动 -->
<div style="font-size:12px;font-weight:700;margin-bottom:8px;color:var(--text)">📋 最近活动 <span style="font-weight:400;color:var(--muted)">(${acts.length} 条)</span></div>
<div style="max-height:350px;overflow-y:auto;border:1px solid var(--line);border-radius:10px;background:var(--panel2)">
${!acts.length?'<div style="padding:16px;color:var(--muted);font-size:12px;text-align:center">暂无活动记录</div>'
:acts.slice(-15).reverse().map(a=>{
const kind = a.kind||'';
const kIcon = kind==='assistant'?'🤖':kind==='tool'?'🔧':kind==='user'?'👤':'📝';
const kLabel = kind==='assistant'?'回复':kind==='tool'?'工具':kind==='user'?'用户':'事件';
let txt = (a.text||'').replace(/\[\[.*?\]\]/g,'').replace(/\*\*/g,'').trim();
if(txt.length>200) txt=txt.substring(0,200)+'…';
const time = fmtActivityTime(a.at);
return `<div style="padding:8px 12px;border-bottom:1px solid var(--line);font-size:12px;line-height:1.5">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">
<span>${kIcon}</span>
<span style="font-weight:600;font-size:11px">${kLabel}</span>
<span style="color:var(--muted);font-size:10px;margin-left:auto">${time}</span>
</div>
<div style="color:var(--muted)">${esc(txt)}</div>
</div>`;
}).join('')}
</div>
${t.output&&t.output!=='-'?`<div style="font-size:10px;color:var(--muted);margin-top:12px;word-break:break-all;border-top:1px solid var(--line);padding-top:8px">📂 ${esc(t.output)}</div>`:''}
`;
document.getElementById('modal-bg').classList.add('open');
}
function extractAgent(t){
const m = (t.id||'').match(/^OC-(\w+)-/);
if(m) return m[1];
// MC-* or other prefixes: check sourceMeta
const sm = t.sourceMeta||{};
if(sm.agentId){
// "mc-gateway-xxx" → try to match known agents
const known = ['taizi','zhongshu','menxia','shangshu','hubu','libu','bingbu','xingbu','gongbu','zaochao'];
for(const k of known){ if(sm.agentId.includes(k)) return k; }
}
return (t.org||'').replace(/省|部/g,'').toLowerCase();
}
function timeAgo(iso){
if(!iso) return '';
try{
const d = parseDateFlexible(iso);
if(!d) return '';
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff/60000);
if(mins<0) return '刚刚';
if(mins<1) return '刚刚';
if(mins<60) return mins+'分钟前';
const hrs = Math.floor(mins/60);
if(hrs<24) return hrs+'小时前';
return Math.floor(hrs/24)+'天前';
}catch(e){ return ''; }
}
function filterSessions(filter, el){
sessFilter = filter;
document.querySelectorAll('.sess-filter').forEach(x=>x.classList.remove('active'));
if(el) el.classList.add('active');
renderSessions(liveStatus?liveStatus.tasks:[]);
}
/* ══ MORNING BRIEF (天下要闻) ══ */
const CAT_META = {
'政治': {icon:'🏛️', color:'#6a9eff', desc:'全球政治动态'},
'军事': {icon:'⚔️', color:'#ff5270', desc:'军事与冲突'},
'经济': {icon:'💹', color:'#2ecc8a', desc:'经济与市场'},
'AI大模型': {icon:'🤖', color:'#a07aff', desc:'AI与大模型进展'},
};
const DEFAULT_CATS = ['政治','军事','经济','AI大模型'];
// --- Subscription config ---
let subConfig = null;
async function loadSubConfig(){
try{ subConfig = await fetchJ(API+'/morning-config'); }
catch(e){ subConfig = { categories: DEFAULT_CATS.map(c=>({name:c,enabled:true})), keywords:[], custom_feeds:[], notification:{enabled:true,channel:'feishu',webhook:''} }; }
}
function renderSubConfig(){
if(!subConfig) return;
// Categories
const catsEl = document.getElementById('sub-cats');
const allCats = [...DEFAULT_CATS];
(subConfig.categories||[]).forEach(c=>{ if(!allCats.includes(c.name)) allCats.push(c.name); });
const enabledSet = new Set((subConfig.categories||[]).filter(c=>c.enabled).map(c=>c.name));
catsEl.innerHTML = allCats.map(cat=>{
const meta=CAT_META[cat]||{icon:'📰',color:'var(--acc)',desc:cat};
const on=enabledSet.has(cat);
return `<div class="sub-cat ${on?'active':''}" onclick="toggleCat('${esc(cat)}')">
<div class="sc-check">${on?'✓':''}</div>
<span style="font-size:16px">${meta.icon}</span>
<div><div class="sc-label">${esc(cat)}</div><div class="sc-count">${meta.desc}</div></div>
</div>`;
}).join('');
// Keywords
const kwEl = document.getElementById('sub-keywords');
kwEl.innerHTML = (subConfig.keywords||[]).map((kw,i)=>`<span class="sub-kw">${esc(kw)}<span class="kw-del" onclick="removeKeyword(${i})">✕</span></span>`).join('');
// Custom feeds
const feedEl = document.getElementById('sub-feeds');
feedEl.innerHTML = (subConfig.custom_feeds||[]).length
? (subConfig.custom_feeds||[]).map((f,i)=>`<div class="sub-feed">
<span class="sf-name">${esc(f.name)}</span>
<span class="sf-url" title="${esc(f.url)}">${esc(f.url)}</span>
<span class="sf-cat">${esc(f.category)}</span>
<span class="sf-del" onclick="removeFeed(${i})">✕</span>
</div>`).join('')
: '<div style="font-size:12px;color:var(--muted);padding:4px 0">暂无自定义源</div>';
// Feed category dropdown
const sel = document.getElementById('new-feed-cat');
sel.innerHTML = allCats.map(c=>`<option value="${c}">${c}</option>`).join('') + '<option value="__new__">+ 新分类…</option>';
// Notification
const noti = subConfig.notification || {enabled:true, channel:'feishu', webhook: subConfig.feishu_webhook||''};
document.getElementById('notification-channel').value = noti.channel || 'feishu';
document.getElementById('notification-enabled').checked = noti.enabled !== false;
document.getElementById('notification-webhook').value = noti.webhook || '';
}
function toggleSubConfig(){
const el = document.getElementById('sub-config');
if(el.style.display==='none'){
el.style.display='block';
loadSubConfig().then(()=>renderSubConfig());
} else {
el.style.display='none';
}
}
function toggleCat(name){
if(!subConfig) return;
const cats = subConfig.categories||[];
const existing = cats.find(c=>c.name===name);
if(existing) existing.enabled = !existing.enabled;
else cats.push({name, enabled:true});
subConfig.categories = cats;
renderSubConfig();
}
function addKeyword(){
const inp = document.getElementById('new-kw');
const kw = inp.value.trim();
if(!kw) return;
if(!subConfig.keywords) subConfig.keywords=[];
if(!subConfig.keywords.includes(kw)) subConfig.keywords.push(kw);
inp.value = '';
renderSubConfig();
}
function removeKeyword(i){
if(!subConfig||!subConfig.keywords) return;
subConfig.keywords.splice(i,1);
renderSubConfig();
}
function addFeed(){
const name=document.getElementById('new-feed-name').value.trim();
const url=document.getElementById('new-feed-url').value.trim();
let cat=document.getElementById('new-feed-cat').value;
if(!name||!url){toast('请填写源名称和URL','err');return}
if(cat==='__new__'){
const nc=prompt('输入新分类名称:');
if(!nc) return;
cat=nc;
// ensure category exists
if(!subConfig.categories) subConfig.categories=[];
if(!subConfig.categories.find(c=>c.name===cat)) subConfig.categories.push({name:cat,enabled:true});
}
if(!subConfig.custom_feeds) subConfig.custom_feeds=[];
subConfig.custom_feeds.push({name,url,category:cat});
document.getElementById('new-feed-name').value='';
document.getElementById('new-feed-url').value='';
renderSubConfig();
}
function removeFeed(i){
if(!subConfig||!subConfig.custom_feeds) return;
subConfig.custom_feeds.splice(i,1);
renderSubConfig();
}
async function saveSubConfig(){
if(!subConfig) subConfig = {};
subConfig.notification = {
enabled: document.getElementById('notification-enabled').checked,
channel: document.getElementById('notification-channel').value,
webhook: document.getElementById('notification-webhook').value.trim()
};
const st = document.getElementById('sub-status');
st.style.display='block'; st.style.color='var(--acc)'; st.textContent='⟳ 保存中…';
try{
const r = await postJ(API+'/morning-config', subConfig);
if(r.ok){ st.style.color='var(--ok)'; st.textContent='✅ 配置已保存'; toast('订阅配置已保存','ok'); }
else{ st.style.color='var(--danger)'; st.textContent='❌ '+(r.error||'保存失败'); }
}catch(e){ st.style.color='var(--danger)'; st.textContent='❌ 服务器连接失败'; }
setTimeout(()=>st.style.display='none', 3000);
}
// --- News rendering ---
async function loadMorning(){
const body = document.getElementById('mb-body');
body.innerHTML = '<div class="mb-loading">⟳ 加载要闻中…</div>';
try{
const d = await fetchJ(API+'/morning-brief');
await loadSubConfig();
renderMorning(d);
}catch(e){
body.innerHTML = '<div class="mb-empty">⚠️ 暂无数据,请点击「立即采集」</div>';
}
}
function renderMorning(d){
const sub = document.getElementById('mb-subtitle');
if(d.generated_at){
const dateStr = d.date ? d.date.replace(/(\d{4})(\d{2})(\d{2})/,'$1年$2月$3日') : '';
sub.textContent = `${dateStr} | 采集于 ${d.generated_at} | 共 ${Object.values(d.categories||{}).flat().length} 条要闻`;
}
const body = document.getElementById('mb-body');
const cats = d.categories||{};
if(!Object.keys(cats).length){
body.innerHTML='<div class="mb-empty">暂无数据,点击右上角「立即采集」获取今日简报</div>';
return;
}
// Filter by enabled categories
const enabledSet = subConfig ? new Set((subConfig.categories||[]).filter(c=>c.enabled).map(c=>c.name)) : null;
const userKws = (subConfig && subConfig.keywords||[]).map(k=>k.toLowerCase());
body.innerHTML = `<div class="mb-cats">
${Object.entries(cats).map(([cat, items])=>{
if(enabledSet && !enabledSet.has(cat)) return '';
const meta = CAT_META[cat]||{icon:'📰',color:'var(--acc)'};
// Boost items matching user keywords
const scored = items.map(item=>{
const text = ((item.title||'')+(item.summary||'')).toLowerCase();
const kwHits = userKws.filter(k=>text.includes(k)).length;
return {...item, _kwHits: kwHits};
}).sort((a,b)=>b._kwHits-a._kwHits);
return `<div class="mb-cat">
<div class="mb-cat-hdr">
<span class="mb-cat-icon">${meta.icon}</span>
<span class="mb-cat-name" style="color:${meta.color}">${esc(cat)}</span>
<span class="mb-cat-cnt">${scored.length} </span>
</div>
<div class="mb-news-list">
${!scored.length ? '<div class="mb-empty" style="padding:16px">暂无新闻</div>' :
scored.map(item => {
const hasImg = item.image && item.image.startsWith('http');
const kwBadge = item._kwHits ? `<span style="font-size:9px;padding:1px 5px;border-radius:999px;background:#a07aff22;color:#a07aff;border:1px solid #a07aff44;margin-left:4px">⭐ 关注</span>` : '';
return `<div class="mb-card" onclick="window.open('${esc(item.link)}','_blank')">
<div class="mb-img">
${hasImg
? `<img src="${esc(item.image)}" onerror="this.parentElement.textContent='${meta.icon}'" loading="lazy">`
: `<span>${meta.icon}</span>`}
</div>
<div class="mb-info">
<div class="mb-headline">${esc(item.title)}${kwBadge}</div>
<div class="mb-summary">${esc(item.summary||item.desc||'')}</div>
<div class="mb-meta">
<span class="mb-source">📡 ${esc(item.source||'')}</span>
${item.pub_date ? `<span class="mb-time">${esc(item.pub_date.substring(0,16))}</span>` : ''}
</div>
</div>
</div>`;
}).join('')}
</div>
</div>`;
}).join('')}
</div>`;
}
let _newsPolling = null;
let _lastBriefDate = null;
async function refreshNews(){
const btn = document.getElementById('mb-refresh-btn');
btn.disabled = true; btn.textContent = '⟳ 采集中…';
try{
// 记录采集前的数据时间戳
try{
const old = await fetchJ(API+'/morning-brief');
_lastBriefDate = old.generated_at || null;
}catch(e){ _lastBriefDate = null; }
const r = await postJ(API+'/morning-brief/refresh', {});
toast(r.message||'采集已触发,自动检测更新中…', 'ok', 5000);
// 轮询检测更新每5秒检查一次最多2分钟
if(_newsPolling) clearInterval(_newsPolling);
let pollCount = 0;
_newsPolling = setInterval(async ()=>{
pollCount++;
if(pollCount > 24){ // 2分钟超时
clearInterval(_newsPolling);
_newsPolling = null;
btn.disabled = false; btn.textContent = '⟳ 立即采集';
toast('采集超时,请重试', 'err');
return;
}
try{
const fresh = await fetchJ(API+'/morning-brief');
if(fresh.generated_at && fresh.generated_at !== _lastBriefDate){
// 数据已更新
clearInterval(_newsPolling);
_newsPolling = null;
btn.disabled = false; btn.textContent = '⟳ 立即采集';
await loadMorning();
toast('✅ 天下要闻已更新', 'ok');
} else {
btn.textContent = `⟳ 采集中… (${pollCount * 5}s)`;
}
}catch(e){}
}, 5000);
}catch(e){
toast('触发失败', 'err');
btn.disabled = false; btn.textContent = '⟳ 立即采集';
}
}
/* ══ OFFICIALS ══ */
let officialsData = null;
let selectedOfficial = null;
async function loadOfficials(){
try{
officialsData = await fetchJ(API+'/officials-stats');
renderOfficials(officialsData);
}catch(e){
document.getElementById('off-detail').innerHTML='<div class="od-empty">⚠️ 无法加载,请确认服务器已启动</div>';
}
}
const MEDALS = ['🥇','🥈','🥉'];
function renderOfficials(data){
if(!data||!data.officials) return;
const offs = data.officials;
const totals = data.totals||{};
const maxTk = Math.max(...offs.map(o=>o.tokens_in+o.tokens_out+o.cache_read+o.cache_write),1);
// 活跃提示条
const alive = offs.filter(o=>(o.heartbeat||{}).status==='active');
const actEl = document.getElementById('off-activity');
if(alive.length){
actEl.style.display='flex';
actEl.innerHTML=`<span class="act-label">🟢 当前活跃:</span>`
+alive.map(o=>`<span class="act-dot alive">${o.emoji} ${o.role}</span>`).join('')
+`<span style="color:var(--muted);font-size:11px;margin-left:auto">其余官员待命</span>`;
}
// KPI 行
document.getElementById('off-kpi').innerHTML=`
<div class="kpi"><div class="kpi-v blue">${offs.length}</div><div class="kpi-l">在职官员</div></div>
<div class="kpi"><div class="kpi-v gold">${totals.tasks_done||0}</div><div class="kpi-l">累计完成旨意</div></div>
<div class="kpi"><div class="kpi-v ${(totals.cost_cny||0)>20?'warn':'green'}">¥${totals.cost_cny||0}</div><div class="kpi-l">累计费用(含缓存)</div></div>
<div class="kpi"><div class="kpi-v" style="font-size:16px;padding-top:4px">${esc(data.top_official||'—')}</div><div class="kpi-l">功绩最高</div></div>`;
// 左侧排行榜
document.getElementById('off-ranklist').innerHTML=`
<div class="orl-hdr">功绩排行</div>`
+offs.map(o=>{
const hb=o.heartbeat||{status:'idle'};
return `<div class="orl-item${selectedOfficial===o.id?' selected':''}" onclick="selectOfficial('${o.id}',this)">
<span class="orl-medal">${o.merit_rank<=3?MEDALS[o.merit_rank-1]:'#'+o.merit_rank}</span>
<span class="orl-emoji">${o.emoji}</span>
<span class="orl-name"><div class="orl-role">${esc(o.role)}</div><div class="orl-org">${esc(o.label)}</div></span>
<span class="orl-score">${o.merit_score}分</span>
<span class="orl-hbdot ${hb.status}"></span>
</div>`;
}).join('');
// 默认选中第一个
if(!selectedOfficial && offs.length) selectOfficial(offs[0].id);
}
function selectOfficial(id, el){
selectedOfficial = id;
// 高亮行
document.querySelectorAll('.orl-item').forEach(x=>x.classList.remove('selected'));
if(el) el.classList.add('selected');
// 渲染详情
if(!officialsData) return;
const o = officialsData.officials.find(x=>x.id===id);
if(!o) return;
const hb = o.heartbeat||{status:'idle',label:'⚪ 待命'};
const totTk = o.tokens_in+o.tokens_out+o.cache_read+o.cache_write;
const maxTk = Math.max(...officialsData.officials.map(x=>x.tokens_in+x.tokens_out+x.cache_read+x.cache_write),1);
const costCls = o.cost_cny>10?'hi':o.cost_cny>3?'md':'lo';
const edicts = o.participated_edicts||[];
const tkBars = [
{l:'输入', v:o.tokens_in, color:'#6a9eff', max:maxTk},
{l:'输出', v:o.tokens_out, color:'#a07aff', max:maxTk},
{l:'缓存读', v:o.cache_read, color:'#2ecc8a', max:maxTk},
{l:'缓存写', v:o.cache_write,color:'#f5c842', max:maxTk},
];
document.getElementById('off-detail').innerHTML=`
<!-- hero -->
<div class="od-hero">
<div class="od-emoji">${o.emoji}</div>
<div>
<div class="od-name">${esc(o.role)}</div>
<div class="od-role">${esc(o.label)} · <span style="color:var(--acc)">${esc(o.model_short||o.model)}</span></div>
<div class="od-rank-badge">🏅 ${esc(o.rank)} · 功绩分 ${o.merit_score}</div>
</div>
<div class="od-hb">
<div class="hb ${hb.status}" style="margin-bottom:4px">${esc(hb.label)}</div>
${o.last_active?`<div style="font-size:10px;color:var(--muted)">活跃 ${esc(o.last_active)}</div>`:''}
<div style="font-size:10px;color:var(--muted);margin-top:2px">${o.sessions} 个会话 · ${o.messages} 条消息</div>
</div>
</div>
<!-- 核心功绩 -->
<div class="od-section">
<div class="od-sec-title">功绩统计</div>
<div class="od-stats">
<div class="ods"><div class="ods-v" style="color:var(--ok)">${o.tasks_done}</div><div class="ods-l">完成旨意</div></div>
<div class="ods"><div class="ods-v" style="color:var(--warn)">${o.tasks_active}</div><div class="ods-l">执行中</div></div>
<div class="ods"><div class="ods-v" style="color:var(--acc)">${o.flow_participations}</div><div class="ods-l">流转参与</div></div>
</div>
</div>
<!-- Token 消耗 -->
<div class="od-section">
<div class="od-sec-title">Token 消耗</div>
${tkBars.map(b=>`
<div class="tbar">
<div class="tbar-hdr"><span class="tbar-label">${b.l}</span><span class="tbar-val">${b.v.toLocaleString()}</span></div>
<div class="tbar-track"><div class="tbar-fill" style="width:${b.max>0?Math.round(b.v/b.max*100):0}%;background:${b.color}"></div></div>
</div>`).join('')}
</div>
<!-- 费用 -->
<div class="od-section">
<div class="od-sec-title">累计费用</div>
<div class="od-cost-row">
<div class="cost-chip ${costCls}"><b>¥${o.cost_cny}</b> 人民币</div>
<div class="cost-chip ${costCls}"><b>$${o.cost_usd}</b> 美元</div>
<div class="cost-chip" style="font-size:11px;color:var(--muted)">总计 ${totTk.toLocaleString()} tokens含缓存</div>
</div>
</div>
<!-- 参与旨意 -->
<div class="od-section">
<div class="od-sec-title">参与旨意(${edicts.length} 道)</div>
${!edicts.length
? '<div style="font-size:12px;color:var(--muted);padding:8px 0">暂无旨意记录</div>'
: `<div class="od-edict-list">${edicts.map(e=>`
<div class="oe-item" onclick='openTask(${JSON.stringify(e.id)})'>
<span class="oe-id">${esc(e.id)}</span>
<span class="oe-title">${esc(e.title.substring(0,35))}</span>
<span class="tag st-${e.state} oe-state">${STATE_LABEL[e.state]||e.state}</span>
</div>`).join('')}</div>`}
</div>`;
}
/* ══ TASK ACTIONS ══ */
let _confirmCb = null;
function confirmAction(taskId, action){
const titles = {stop:'⏸ 叫停任务', cancel:'🚫 取消任务'};
const msgs = {stop:`确定要叫停 <b>${esc(taskId)}</b> 吗?任务将暂停,可稍后恢复。`, cancel:`确定要取消 <b>${esc(taskId)}</b> 吗?任务将标记为已取消。`};
document.getElementById('confirm-title').innerHTML = titles[action]||action;
document.getElementById('confirm-msg').innerHTML = msgs[action]||'';
document.getElementById('confirm-reason').value = '';
document.getElementById('confirm-ok').className = action==='cancel'?'btn btn-action btn-cancel':'btn btn-action btn-stop';
document.getElementById('confirm-ok').textContent = action==='cancel'?'确认取消':'确认叫停';
document.getElementById('confirm-bg').classList.add('open');
_confirmCb = () => {
const reason = document.getElementById('confirm-reason').value.trim();
taskAction(taskId, action, reason);
};
}
function closeConfirm(){ document.getElementById('confirm-bg').classList.remove('open'); _confirmCb=null; }
function doConfirm(){ if(_confirmCb) _confirmCb(); closeConfirm(); }
async function taskAction(taskId, action, reason){
try{
const r = await postJ(API+'/task-action', {taskId, action, reason: reason||''});
if(r.ok){ toast(r.message,'ok'); loadAll(); /* close modal if open */ document.getElementById('modal-bg').classList.remove('open'); }
else toast(r.error||'操作失败','err');
}catch(e){ toast('服务器连接失败','err'); }
}
/* ══ REVIEW ACTION (御批 准奏/封驳) ══ */
async function reviewAction(taskId, action){
const labels = {approve:'准奏', reject:'封驳'};
const comment = prompt(`${labels[action]} ${taskId}\n\n请输入批注(可留空):`);
if(comment === null) return; // cancelled
try{
const r = await postJ(API+'/review-action', {taskId, action, comment: comment||''});
if(r.ok){ toast(`${taskId}${labels[action]}`,'ok'); loadAll(); document.getElementById('modal-bg').classList.remove('open'); }
else toast(r.error||'操作失败','err');
}catch(e){ toast('服务器连接失败','err'); }
}
/* ══ ADVANCE STATE (手动推进到下一步) ══ */
const _NEXT_LABELS = {Taizi:'中书省起草', Zhongshu:'门下省审议', Menxia:'尚书省派发', Assigned:'开始执行', Doing:'进入审查', Review:'完成'};
async function advanceState(taskId, curState){
const next = _NEXT_LABELS[curState] || '下一步';
const comment = prompt(`⏩ 手动推进 ${taskId}\n当前: ${curState} → 下一步: ${next}\n\n请输入说明(可留空):`);
if(comment === null) return;
try{
const r = await postJ(API+'/advance-state', {taskId, comment: comment||''});
if(r.ok){ toast(`${r.message}`,'ok'); loadAll(); document.getElementById('modal-bg').classList.remove('open'); }
else toast(r.error||'推进失败','err');
}catch(e){ toast('服务器连接失败','err'); }
}
/* ══ ARCHIVE ACTIONS ══ */
async function archiveTask(taskId){
try{
const r = await postJ(API+'/archive-task', {taskId, archived: true});
if(r.ok){ toast(`📦 ${taskId} 已归档`,'ok'); loadAll(); }
else toast(r.error||'归档失败','err');
}catch(e){ toast('服务器连接失败','err'); }
}
async function unarchiveTask(taskId){
try{
const r = await postJ(API+'/archive-task', {taskId, archived: false});
if(r.ok){ toast(`📤 ${taskId} 已取消归档`,'ok'); loadAll(); }
else toast(r.error||'取消归档失败','err');
}catch(e){ toast('服务器连接失败','err'); }
}
async function archiveAllDone(){
if(!confirm('将所有已完成/已取消的旨意移入归档?')) return;
try{
const r = await postJ(API+'/archive-task', {archiveAllDone: true});
if(r.ok){ toast(`📦 ${r.count||0} 道旨意已归档`,'ok'); loadAll(); }
else toast(r.error||'批量归档失败','err');
}catch(e){ toast('服务器连接失败','err'); }
}
/* ══ GLOBAL TAIZI SCAN ══ */
async function runGlobalSchedulerScan(){
const btn = document.getElementById('btn-global-scan');
const st = document.getElementById('global-scan-status');
const detailBtn = document.getElementById('btn-global-scan-detail');
const copyBtn = document.getElementById('btn-global-scan-copy');
if(!btn) return;
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = '⟳ 巡检中...';
if(st) st.textContent = '巡检中...';
try{
const r = await postJ(API + '/scheduler-scan', {thresholdSec: 180});
if(r.ok){
const count = r.count || 0;
const at = (r.checkedAt || '').replace('T',' ').substring(11,19);
globalScanActions = Array.isArray(r.actions) ? r.actions : [];
globalScanCheckedAt = String(r.checkedAt || '');
globalScanCount = Number(count || 0);
if(st) st.textContent = `最近巡检: ${count} 个动作${at ? ' · ' + at : ''}`;
if(detailBtn){
detailBtn.style.display = globalScanActions.length ? 'inline-block' : 'none';
if(!globalScanActions.length){
globalScanDetailOpen = false;
detailBtn.classList.remove('active');
detailBtn.textContent = '查看巡检详情';
}
}
if(copyBtn){
copyBtn.style.display = globalScanActions.length ? 'inline-block' : 'none';
}
persistGlobalScanState({checkedAt: r.checkedAt || '', count});
renderGlobalScanDetails();
toast(`🧭 太子巡检完成:${count} 个动作`,'ok');
loadAll();
}else{
if(st) st.textContent = '巡检失败';
toast(r.error || '巡检失败','err');
}
}catch(e){
if(st) st.textContent = '巡检失败';
toast('服务器连接失败','err');
}finally{
btn.disabled = false;
btn.textContent = oldText;
}
}
function toggleGlobalScanDetails(){
if(!globalScanActions.length) return;
globalScanDetailOpen = !globalScanDetailOpen;
const btn = document.getElementById('btn-global-scan-detail');
if(btn){
btn.classList.toggle('active', globalScanDetailOpen);
btn.textContent = globalScanDetailOpen ? '收起巡检详情' : '查看巡检详情';
}
persistGlobalScanState();
renderGlobalScanDetails();
}
function renderGlobalScanDetails(){
const wrap = document.getElementById('global-scan-detail');
if(!wrap) return;
wrap.classList.toggle('open', globalScanDetailOpen && globalScanActions.length>0);
if(!globalScanDetailOpen || !globalScanActions.length){
wrap.innerHTML = '';
return;
}
const list = globalScanActions.slice(0, 12).map(a=>{
const action = String(a.action||'').toLowerCase();
const actionLabel = action==='retry' ? '重试' : action==='escalate' ? '升级' : action==='rollback' ? '回滚' : '动作';
const target = a.to || a.toState || '';
const stalled = Number.isFinite(a.stalledSec) ? `停滞 ${a.stalledSec}s` : '';
const meta = [target ? `目标: ${target}` : '', stalled].filter(Boolean).join(' · ');
return `<div class="gs-item">
<span class="gs-tag ${esc(action)}">${esc(actionLabel)}</span>
<span class="gs-task">${esc(a.taskId||'-')}</span>
<span class="gs-meta">${esc(meta || '无附加信息')}</span>
</div>`;
}).join('');
const hiddenCount = Math.max(0, globalScanActions.length - 12);
wrap.innerHTML = `<div class="gs-list">${list}</div>${hiddenCount ? `<div class="gs-hint">另有 ${hiddenCount} 条动作未展示</div>` : ''}`;
}
function buildGlobalScanReport(){
const checked = globalScanCheckedAt ? fmtLocalFull(globalScanCheckedAt) || globalScanCheckedAt.replace('T',' ').replace('Z','') : '未知时间';
const count = Number(globalScanCount || globalScanActions.length || 0);
const lines = [
'【太子巡检报告】',
`时间: ${checked}`,
`动作数: ${count}`,
'',
];
if(!globalScanActions.length){
lines.push('本次巡检无动作。');
return lines.join('\n');
}
globalScanActions.forEach((a, idx)=>{
const action = String(a.action||'').toLowerCase();
const label = action==='retry' ? '重试' : action==='escalate' ? '升级' : action==='rollback' ? '回滚' : '动作';
const to = a.to || a.toState || '-';
const stalled = Number.isFinite(a.stalledSec) ? `${a.stalledSec}s` : '-';
lines.push(`${idx+1}. ${a.taskId||'-'} | ${label} | 目标:${to} | 停滞:${stalled}`);
});
return lines.join('\n');
}
async function copyGlobalScanReport(){
const report = buildGlobalScanReport();
try{
await navigator.clipboard.writeText(report);
toast('📋 巡检报告已复制','ok');
}catch(_){
const ta = document.createElement('textarea');
ta.value = report;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try{ document.execCommand('copy'); toast('📋 巡检报告已复制','ok'); }
catch(__){ toast('复制失败,请手动复制','err'); }
ta.remove();
}
}
/* ══ TABS ══ */
document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',()=>{
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));
t.classList.add('active');activeTab=t.dataset.tab;
document.getElementById('panel-'+activeTab).classList.add('active');
if(['models','skills'].includes(activeTab))loadAgentConfig();
if(activeTab==='officials')loadOfficials();
if(activeTab==='monitor')loadAgentsStatus();
if(activeTab==='morning')loadMorning();
if(activeTab==='sessions')renderSessions(liveStatus?liveStatus.tasks:[]);
if(activeTab==='memorials')renderMemorials();
if(activeTab==='templates')renderTemplates();
}));
/* ══ COUNTDOWN (5s) ══ */
function startCd(){
countdown=5;
const el=document.getElementById('cd');
const iv=setInterval(()=>{
countdown--;el.textContent='⟳ '+countdown+'s';
if(countdown<=0){clearInterval(iv);loadAll().then(()=>{el.textContent='';startCd()})}
},1000);
}
/* ══ KEY ══ */
document.addEventListener('keydown',e=>{if(e.key==='Escape')document.getElementById('modal-bg').classList.remove('open')});
/* ══ COURT CEREMONY (上朝仪式感) ══ */
function showCeremony(){
const lastOpen = localStorage.getItem('openclaw_court_date');
const today = new Date().toISOString().substring(0,10);
const pref = JSON.parse(localStorage.getItem('openclaw_court_pref')||'{"enabled":true}');
if(!pref.enabled || lastOpen === today) return;
localStorage.setItem('openclaw_court_date', today);
const el = document.getElementById('ceremony');
el.style.display='flex';
// Fill data from live status
const tasks = liveStatus ? (liveStatus.tasks||[]) : [];
const jjc = tasks.filter(t=>(t.id||'').startsWith('JJC-'));
const pending = jjc.filter(t=>!['Done','Cancelled'].includes(t.state)).length;
const done = jjc.filter(t=>t.state==='Done').length;
const overdue = jjc.filter(t=>t.state!=='Done'&&t.state!=='Cancelled'&&t.eta&&new Date(t.eta.replace(' ','T'))<new Date()).length;
document.getElementById('crm-l3').textContent = `待办 ${pending} 件 · 已完成 ${done}` + (overdue?` · ⚠ 超期 ${overdue}`:'');
// Date line
const d = new Date();
const dateStr = d.getFullYear()+'年'+(d.getMonth()+1)+'月'+d.getDate()+'日 · '+['日','一','二','三','四','五','六'][d.getDay()]+'曜日';
document.getElementById('crm-date').textContent = dateStr;
// Trigger animations
document.getElementById('crm-l1').classList.add('in');
document.getElementById('crm-l2').classList.add('in');
document.getElementById('crm-l3').classList.add('in');
document.getElementById('crm-date').classList.add('in');
// Auto dismiss after 3.5s
window._crmTimer = setTimeout(()=>skipCeremony(), 3500);
}
function skipCeremony(){
clearTimeout(window._crmTimer);
const el = document.getElementById('ceremony');
el.classList.add('out');
setTimeout(()=>{el.style.display='none'}, 500);
}
/* ══ MEMORIALS (奏折系统) ══ */
function renderMemorials(){
const tasks = liveStatus ? (liveStatus.tasks||[]) : [];
const filter = (document.getElementById('mem-filter')||{}).value || 'all';
// Only JJC edicts that are Done or Cancelled
let mems = tasks.filter(t=>(t.id||'').startsWith('JJC-') && ['Done','Cancelled'].includes(t.state));
if(filter!=='all') mems = mems.filter(t=>t.state===filter);
animateCounter(document.getElementById('tb-mem'), mems.length);
const el = document.getElementById('mem-list');
if(!mems.length){ el.innerHTML='<div class="mem-empty">暂无奏折 — 任务完成后自动生成</div>'; return; }
el.innerHTML = mems.map(t=>{
const fl = t.flow_log||[];
const depts = [...new Set(fl.map(f=>f.from).concat(fl.map(f=>f.to)).filter(x=>x&&x!=='皇上'))];
const firstAt = fl.length ? (fl[0].at||'').substring(0,16).replace('T',' ') : '';
const lastAt = fl.length ? (fl[fl.length-1].at||'').substring(0,16).replace('T',' ') : '';
const stIcon = t.state==='Done'?'✅':'🚫';
return `<div class="mem-card" onclick='openMemorial(${JSON.stringify(JSON.stringify(t))})'>
<div class="mem-icon">📜</div>
<div class="mem-info">
<div class="mem-title">${stIcon} ${esc(t.title||t.id)}</div>
<div class="mem-sub">${esc(t.id)} · ${esc(t.org||'')} · 流转 ${fl.length} 步</div>
<div class="mem-tags">${depts.slice(0,5).map(d=>`<span class="mem-tag">${esc(d)}</span>`).join('')}</div>
</div>
<div class="mem-right">
<span class="mem-date">${esc(firstAt)}</span>
${lastAt!==firstAt?`<span class="mem-date">${esc(lastAt)}</span>`:''}
</div>
</div>`;
}).join('');
}
function openMemorial(jsonStr){
const t = JSON.parse(jsonStr);
const fl = t.flow_log||[];
const st = t.state||'Unknown';
const stIcon = st==='Done'?'✅':st==='Cancelled'?'🚫':'🔄';
const depts = [...new Set(fl.map(f=>f.from).concat(fl.map(f=>f.to)).filter(x=>x&&x!=='皇上'))];
// Reconstruct phases from flow_log
let originLog=[], planLog=[], reviewLog=[], execLog=[], resultLog=[];
for(const f of fl){
if(f.from==='皇上') originLog.push(f);
else if(f.to==='中书省'||f.from==='中书省') planLog.push(f);
else if(f.to==='门下省'||f.from==='门下省') reviewLog.push(f);
else if(f.remark&&(f.remark.includes('完成')||f.remark.includes('回奏'))) resultLog.push(f);
else execLog.push(f);
}
const renderPhase = (title, icon, items)=>{
if(!items.length) return '';
return `<div style="margin-bottom:18px">
<div style="font-size:13px;font-weight:700;margin-bottom:10px">${icon} ${title}</div>
<div class="md-timeline">${items.map(f=>{
const dotCls = f.remark&&f.remark.includes('✅')?'green':f.remark&&f.remark.includes('驳')?'red':'';
return `<div class="md-tl-item">
<div class="md-tl-dot ${dotCls}"></div>
<div style="display:flex;gap:6px;align-items:baseline">
<span class="md-tl-from">${esc(f.from||'')}</span>
<span class="md-tl-to">→ ${esc(f.to||'')}</span>
</div>
<div class="md-tl-remark">${esc(f.remark||'')}</div>
<div class="md-tl-time">${esc(fmtLocalFull(f.at))}</div>
</div>`;
}).join('')}</div>
</div>`;
};
document.getElementById('modal-body').innerHTML=`
<div style="font-size:11px;color:var(--acc);font-weight:700;letter-spacing:.04em;margin-bottom:4px">${esc(t.id)}</div>
<div style="font-size:20px;font-weight:800;margin-bottom:6px">${stIcon} ${esc(t.title||t.id)}</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:18px;flex-wrap:wrap">
<span class="tag st-${st}">${STATE_LABEL[st]||st}</span>
<span style="font-size:11px;color:var(--muted)">${esc(t.org||'')}</span>
<span style="font-size:11px;color:var(--muted)">流转 ${fl.length} 步</span>
${depts.map(d=>`<span class="mem-tag">${esc(d)}</span>`).join('')}
</div>
${t.now?`<div style="background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin-bottom:18px;font-size:12px;color:var(--muted)">${esc(t.now)}</div>`:''}
${renderPhase('圣旨原文', '👑', originLog)}
${renderPhase('中书规划', '📋', planLog)}
${renderPhase('门下审议', '🔍', reviewLog)}
${renderPhase('六部执行', '⚔️', execLog)}
${renderPhase('汇总回奏', '📨', resultLog)}
${t.output&&t.output!=='-'?`<div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--line)">
<div style="font-size:11px;font-weight:600;margin-bottom:4px">📦 产出物</div>
<code style="font-size:11px;word-break:break-all">${esc(t.output)}</code>
<button class="btn btn-g" onclick="loadTaskOutput('${esc(t.id)}')" style="font-size:11px;padding:4px 12px;margin-left:8px">📖 查看奏章</button>
<div id="task-output-content" style="display:none;margin-top:10px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:14px;max-height:400px;overflow-y:auto;font-size:12px;line-height:1.7;white-space:pre-wrap;word-break:break-word"></div>
</div>`:''}
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
<button class="btn btn-g" onclick="exportMemorial(${JSON.stringify(JSON.stringify(t.id))})" style="font-size:12px;padding:6px 16px">📋 复制奏折</button>
</div>
`;
document.getElementById('modal-bg').classList.add('open');
}
function exportMemorial(taskIdJson){
const taskId = JSON.parse(taskIdJson);
const tasks = liveStatus ? (liveStatus.tasks||[]) : [];
const t = tasks.find(x=>x.id===taskId);
if(!t){ toast('找不到任务','err'); return; }
const fl = t.flow_log||[];
let md = `# 📜 奏折 · ${t.title}\n\n`;
md += `- **任务编号**: ${t.id}\n`;
md += `- **状态**: ${t.state}\n`;
md += `- **负责部门**: ${t.org}\n`;
// 时间信息
if(fl.length){
const startAt = fl[0].at ? fl[0].at.substring(0,19).replace('T',' ') : '未知';
const endAt = fl[fl.length-1].at ? fl[fl.length-1].at.substring(0,19).replace('T',' ') : '未知';
md += `- **开始时间**: ${startAt}\n`;
md += `- **完成时间**: ${endAt}\n`;
// 计算耗时
try{
const s = new Date(fl[0].at), e = new Date(fl[fl.length-1].at);
const diffMin = Math.round((e-s)/60000);
if(diffMin>0) md += `- **总耗时**: ${diffMin >= 60 ? Math.floor(diffMin/60)+'小时'+diffMin%60+'分钟' : diffMin+'分钟'}\n`;
}catch(e){}
}
if(t.updatedAt) md += `- **最后更新**: ${t.updatedAt.substring(0,19).replace('T',' ')}\n`;
if(t.review_round) md += `- **磋商轮次**: 第 ${t.review_round}\n`;
md += `\n`;
md += `## 流转记录\n\n`;
for(const f of fl){
md += `- **${f.from}** → **${f.to}** \n ${f.remark} \n _${(f.at||'').substring(0,19)}_\n\n`;
}
if(t.output && t.output!=='-') md += `## 产出物\n\n\`${t.output}\`\n`;
navigator.clipboard.writeText(md).then(()=>toast('✅ 奏折已复制为 Markdown','ok')).catch(()=>toast('复制失败','err'));
}
/* ══ TEMPLATES (圣旨模板库) ══ */
const TEMPLATES = [
{id:'tpl-weekly-report', cat:'日常办公', icon:'📝', name:'周报生成',
desc:'基于本周看板数据和各部产出,自动生成结构化周报',
depts:['户部','礼部'], est:'~10分钟', cost:'¥0.5',
params:[
{key:'date_range', label:'报告周期', type:'text', default:'本周', required:true},
{key:'focus', label:'重点关注(逗号分隔)', type:'text', default:'项目进展,下周计划'},
{key:'format', label:'输出格式', type:'select', options:['Markdown','飞书文档'], default:'Markdown'}
],
command:'生成{date_range}的周报,重点覆盖{focus},输出为{format}格式'},
{id:'tpl-code-review', cat:'工程开发', icon:'🔍', name:'代码审查',
desc:'对指定代码仓库/文件进行质量审查,输出问题清单和改进建议',
depts:['兵部','刑部'], est:'~20分钟', cost:'¥2',
params:[
{key:'repo', label:'仓库/文件路径', type:'text', required:true},
{key:'scope', label:'审查范围', type:'select', options:['全量','增量(最近commit)','指定文件'], default:'增量(最近commit)'},
{key:'focus', label:'重点关注(可选)', type:'text', default:'安全漏洞,错误处理,性能'}
],
command:'对 {repo} 进行代码审查,范围:{scope},重点关注:{focus}'},
{id:'tpl-api-design', cat:'工程开发', icon:'⚡', name:'API 设计与实现',
desc:'从需求描述到 RESTful API 设计、实现、测试一条龙',
depts:['中书省','兵部'], est:'~45分钟', cost:'¥3',
params:[
{key:'requirement', label:'需求描述', type:'textarea', required:true},
{key:'tech', label:'技术栈', type:'select', options:['Python/FastAPI','Node/Express','Go/Gin'], default:'Python/FastAPI'},
{key:'auth', label:'鉴权方式', type:'select', options:['JWT','API Key','无'], default:'JWT'}
],
command:'设计并实现一个 {tech} 的 RESTful API{requirement}。鉴权方式:{auth}'},
{id:'tpl-competitor', cat:'数据分析', icon:'📊', name:'竞品分析',
desc:'爬取竞品网站数据,分析对比,生成结构化报告',
depts:['兵部','户部','礼部'], est:'~60分钟', cost:'¥5',
params:[
{key:'targets', label:'竞品名称/URL每行一个', type:'textarea', required:true},
{key:'dimensions', label:'分析维度', type:'text', default:'产品功能,定价策略,用户评价'},
{key:'format', label:'输出格式', type:'select', options:['Markdown报告','表格对比'], default:'Markdown报告'}
],
command:'对以下竞品进行分析:\n{targets}\n\n分析维度{dimensions},输出格式:{format}'},
{id:'tpl-data-report', cat:'数据分析', icon:'📈', name:'数据报告',
desc:'对给定数据集进行清洗、分析、可视化,输出分析报告',
depts:['户部','礼部'], est:'~30分钟', cost:'¥2',
params:[
{key:'data_source', label:'数据源描述/路径', type:'text', required:true},
{key:'questions', label:'分析问题(每行一个)', type:'textarea'},
{key:'viz', label:'是否需要可视化图表', type:'select', options:['是','否'], default:'是'}
],
command:'对数据 {data_source} 进行分析。{questions}\n需要可视化{viz}'},
{id:'tpl-blog', cat:'内容创作', icon:'✍️', name:'博客文章',
desc:'给定主题和要求,生成高质量博客文章',
depts:['礼部'], est:'~15分钟', cost:'¥1',
params:[
{key:'topic', label:'文章主题', type:'text', required:true},
{key:'audience', label:'目标读者', type:'text', default:'技术人员'},
{key:'length', label:'期望字数', type:'select', options:['~1000字','~2000字','~3000字'], default:'~2000字'},
{key:'style', label:'风格', type:'select', options:['技术教程','观点评论','案例分析'], default:'技术教程'}
],
command:'写一篇关于「{topic}」的博客文章,面向{audience}{length},风格:{style}'},
{id:'tpl-deploy', cat:'工程开发', icon:'🚀', name:'部署方案',
desc:'生成完整的部署检查单、Docker配置、CI/CD流程',
depts:['兵部','工部'], est:'~25分钟', cost:'¥2',
params:[
{key:'project', label:'项目名称/描述', type:'text', required:true},
{key:'env', label:'部署环境', type:'select', options:['Docker','K8s','VPS','Serverless'], default:'Docker'},
{key:'ci', label:'CI/CD 工具', type:'select', options:['GitHub Actions','GitLab CI','无'], default:'GitHub Actions'}
],
command:'为项目「{project}」生成{env}部署方案CI/CD使用{ci}'},
{id:'tpl-email', cat:'内容创作', icon:'📧', name:'邮件/通知文案',
desc:'根据场景和目的,生成专业邮件或通知文案',
depts:['礼部'], est:'~5分钟', cost:'¥0.3',
params:[
{key:'scenario', label:'使用场景', type:'select', options:['商务邮件','产品发布','客户通知','内部公告'], default:'商务邮件'},
{key:'purpose', label:'目的/内容', type:'textarea', required:true},
{key:'tone', label:'语调', type:'select', options:['正式','友好','简洁'], default:'正式'}
],
command:'撰写一封{scenario}{tone}语调。内容:{purpose}'},
{id:'tpl-standup', cat:'日常办公', icon:'🗓️', name:'每日站会摘要',
desc:'汇总各部今日进展和明日计划,生成站会摘要',
depts:['尚书省'], est:'~5分钟', cost:'¥0.3',
params:[
{key:'range', label:'汇总范围', type:'select', options:['今天','最近24小时','昨天+今天'], default:'今天'}
],
command:'汇总{range}各部工作进展和待办,生成站会摘要'}
];
const TPL_CATS = [
{name:'全部', icon:'📋'},
{name:'日常办公', icon:'💼'},
{name:'数据分析', icon:'📊'},
{name:'工程开发', icon:'⚙️'},
{name:'内容创作', icon:'✍️'}
];
let tplCatFilter = '全部';
function renderTemplates(){
// categories
document.getElementById('tpl-cats').innerHTML = TPL_CATS.map(c=>
`<span class="tpl-cat${tplCatFilter===c.name?' active':''}" onclick="tplCatFilter='${c.name}';renderTemplates()">${c.icon} ${c.name}</span>`
).join('');
let tpls = TEMPLATES;
if(tplCatFilter!=='全部') tpls = tpls.filter(t=>t.cat===tplCatFilter);
document.getElementById('tpl-grid').innerHTML = tpls.map(t=>`
<div class="tpl-card">
<div class="tpl-top">
<span class="tpl-icon">${t.icon}</span>
<span class="tpl-name">${esc(t.name)}</span>
</div>
<div class="tpl-desc">${esc(t.desc)}</div>
<div class="tpl-footer">
${t.depts.map(d=>`<span class="tpl-dept">${esc(d)}</span>`).join('')}
<span class="tpl-est">${esc(t.est)} · ${esc(t.cost)}</span>
<button class="tpl-go" onclick="openTemplateForm('${t.id}')">下旨</button>
</div>
</div>
`).join('');
}
function openTemplateForm(tplId){
const t = TEMPLATES.find(x=>x.id===tplId);
if(!t) return;
document.getElementById('modal-body').innerHTML=`
<div style="font-size:11px;color:var(--acc);font-weight:700;letter-spacing:.04em;margin-bottom:4px">圣旨模板</div>
<div style="font-size:20px;font-weight:800;margin-bottom:6px">${t.icon} ${esc(t.name)}</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:18px">${esc(t.desc)}</div>
<div style="display:flex;gap:6px;margin-bottom:18px;flex-wrap:wrap">${t.depts.map(d=>`<span class="tpl-dept">${esc(d)}</span>`).join('')}<span style="font-size:11px;color:var(--muted);margin-left:auto">${esc(t.est)} · ${esc(t.cost)}</span></div>
<form class="tpl-form" onsubmit="executeTemplate(event,'${t.id}')">
${t.params.map(p=>`<div class="tpl-field">
<label class="tpl-label">${esc(p.label)}${p.required?` <span style="color:#ff5270">*</span>`:''}</label>
${p.type==='textarea'
?`<textarea id="tpl-${p.key}" class="tpl-input" style="min-height:80px;resize:vertical" ${p.required?'required':''} placeholder="${esc(p.default||'')}">${esc(p.default||'')}</textarea>`
:p.type==='select'
?`<select id="tpl-${p.key}" class="tpl-input">${(p.options||[]).map(o=>`<option${o===p.default?' selected':''}>${esc(o)}</option>`).join('')}</select>`
:`<input id="tpl-${p.key}" class="tpl-input" type="text" value="${esc(p.default||'')}" ${p.required?'required':''}/>`
}
</div>`).join('')}
<div id="tpl-preview" style="background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:12px;margin-bottom:14px;font-size:12px;color:var(--muted);display:none"></div>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button type="button" class="btn btn-g" onclick="previewTemplate('${t.id}')" style="padding:8px 16px;font-size:12px">👁 预览旨意</button>
<button type="submit" class="tpl-go" style="padding:8px 20px;font-size:13px">📜 下旨</button>
</div>
</form>
`;
document.getElementById('modal-bg').classList.add('open');
}
function buildCommand(tplId){
const t = TEMPLATES.find(x=>x.id===tplId);
if(!t) return '';
let cmd = t.command;
for(const p of t.params){
const el = document.getElementById('tpl-'+p.key);
const val = el ? el.value : (p.default||'');
cmd = cmd.replace(new RegExp('\\{'+p.key+'\\}','g'), val);
}
return cmd;
}
function previewTemplate(tplId){
const cmd = buildCommand(tplId);
const el = document.getElementById('tpl-preview');
el.style.display='block';
el.innerHTML = `<div style="font-size:11px;font-weight:600;color:var(--text);margin-bottom:6px">📜 将发送给中书省的旨意:</div><div style="white-space:pre-wrap;line-height:1.6">${esc(cmd)}</div>`;
}
async function executeTemplate(e, tplId){
e.preventDefault();
const cmd = buildCommand(tplId);
if(!cmd.trim()){ toast('请填写必填参数','err'); return; }
const t = TEMPLATES.find(x=>x.id===tplId);
// 前置校验:检测 Gateway 和关键 Agent 是否在线
try{
const st = await fetchJ(API+'/agents-status');
if(st.ok && st.gateway && !st.gateway.alive){
toast('⚠️ Gateway 未启动,任务将无法派发!请先运行 openclaw gateway start','err',6000);
if(!confirm('Gateway 未启动,任务创建后将无法自动派发。是否仍然继续?')) return;
}
}catch(ex){/* 检测失败不阻塞创建 */}
// Show the generated command in a confirmation
if(!confirm(`确认下旨?\n\n${cmd.substring(0,200)}${cmd.length>200?'…':''}`)) return;
// 通过 API 创建真实任务
try{
const params = {};
for(const p of t.params){
const el = document.getElementById('tpl-'+p.key);
params[p.key] = el ? el.value : (p.default||'');
}
const r = await postJ(API+'/create-task', {
title: cmd.substring(0, 120),
org: '中书省',
targetDept: t.depts[0] || '',
priority: 'normal',
templateId: tplId,
params: params,
});
if(r.ok){
toast(`📜 ${r.taskId} 旨意已下达`, 'ok', 5000);
closeModal();
loadAll();
} else {
toast(r.error||'下旨失败','err');
}
}catch(err){
// 降级:复制到剪贴板
toast('⚠️ 服务器连接失败,已复制到剪贴板', 'err', 4000);
navigator.clipboard.writeText(cmd).catch(()=>{});
closeModal();
}
}
/* ══ THEME ══ */
function toggleTheme(){
const isLight = document.documentElement.classList.toggle('light');
localStorage.setItem('theme', isLight ? 'light' : 'dark');
document.getElementById('theme-btn').textContent = isLight ? '☀️' : '🌙';
}
(function initTheme(){
const saved = localStorage.getItem('theme');
if(saved === 'light'){
document.documentElement.classList.add('light');
document.getElementById('theme-btn').textContent = '☀️';
}
})();
/* ══ TASK OUTPUT / 奏章查看 ══ */
async function loadTaskOutput(taskId){
const el = document.getElementById('task-output-content');
if(!el) return;
if(el.style.display !== 'none'){ el.style.display='none'; return; }
el.style.display='block';
el.textContent='加载中…';
try{
const r = await fetchJ(API+'/task-output/'+encodeURIComponent(taskId));
if(r.ok && r.exists && r.content){
el.textContent = r.content;
} else if(r.ok && !r.exists){
el.textContent = '(产出物文件不存在或尚未生成)';
} else {
el.textContent = r.error || '加载失败';
}
}catch(err){
el.textContent = '服务器连接失败';
}
}
/* ══ FREE EDICT ══ */
async function submitFreeEdict(){
const input = document.getElementById('free-edict-input');
const text = (input.value||'').trim();
if(!text || text.length < 6){ toast('请输入至少6个字的旨意内容','err'); return; }
if(!confirm('确认下旨?\n\n'+text.substring(0,200)+(text.length>200?'…':''))) return;
const priority = document.getElementById('free-edict-priority').value;
try{
const r = await postJ(API+'/create-task', {
title: text.substring(0,120),
org: '中书省',
priority: priority,
templateId: '',
params: { content: text },
});
if(r.ok){
toast('📜 '+r.taskId+' 旨意已下达','ok',5000);
input.value = '';
loadAll();
} else {
toast(r.error||'下旨失败','err');
}
}catch(err){
toast('服务器连接失败','err');
}
}
/* ══ FEEDBACK ══ */
function openFeedback(){
const kind = prompt('请选择反馈类型:\n1 = 禀报异常 (Bug)\n2 = 奏请功能 (Feature)\n\n输入 1 或 2');
if(!kind) return;
const isBug = kind.trim() === '1';
const title = encodeURIComponent(isBug ? '[Bug] ' : '[Feature] ');
const tmpl = isBug
? encodeURIComponent('## 问题描述\n\n## 复现步骤\n1. \n2. \n3. \n\n## 期望行为\n\n## 实际行为\n\n## 环境信息\n- OS: \n- Browser: \n')
: encodeURIComponent('## 功能描述\n\n## 使用场景\n\n## 期望效果\n\n## 其他信息\n');
const label = isBug ? 'bug' : 'enhancement';
window.open(`https://github.com/cft0808/edict/issues/new?title=${title}&body=${tmpl}&labels=${label}`, '_blank');
}
/* ══ INIT ══ */
restoreGlobalScanState();
loadAll().then(()=>showCeremony());
startCd();
</script>
</body>
</html>