mirror of
https://mirror.skon.top/github.com/cft0808/edict
synced 2026-04-20 21:00:16 +08:00
3350 lines
182 KiB
HTML
3350 lines
182 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'):'';
|
||
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>
|