Files
dsh_ai1net_server/交付物/项目管理界面/project-board.html
T
admin 89f820a0f8 项目管理界面:改为跨工作区 + 增删落地(回收站走 SHFileOperationW)
用户口径:「项目管理管的是本机所有工作区的项目 不是单个工作区的,可以
通过项目管理工具 创建新目标(新工作区),管理目标」。

数据面
- ⛔ 不再拉「单一上游看板」(并非每个区都起着 board.py ⇒ 会静默漏区)
- ✅ 直读各区文件:goal.json / tasks.json / collabd-state.json / collabd.config.json
- 「装了机制」判据=有 collabd.config.json 或 goal.json;跳过的也带出来
  (skipped 列表,同族 P0「作用域不许静默排除」)

增删
- 增:POST /api/create → init_workspace.py(骨架+目标登记)
- 删(轻):POST /api/delete{mode:"goal"} ⇒ goal.json 改名归档(可逆)
- 删(重):POST /api/delete{mode:"ws"} ⇒ 送系统回收站(可逆)

回收站:本机 gio/trash-put/trash 全没有,PowerShell Add-Type 被安全策略
拦 ⇒ 改走纯 ctypes 调 Windows Shell API SHFileOperationW。两个坑:
  ① pFrom 必须手搓双 NUL 结尾缓冲(LPCWSTR 会在第一个 NUL 截断)
  ② 判据⛔ 不是 rc==0 —— 实测成功时 rc 恒为 2(Shell 脏状态)⇒ 假报错。
     真判据=原路径不存在 且 fAnyOperationsAborted 为假

其它修复
- init_workspace.py 子进程输出按 GBK 解码 ⇒ 中文乱码 ⇒ 显式 encoding=utf-8
- 起服务只许触发计划任务(裸 & 活不过工具调用边界,被 SIGTERM)

实测:列表 3 个项目 + 12 个未装机制目录;增删两种粒度 HTTP 端到端全通;
      _pmtest-* 五个测试目录已进回收站且可恢复($I 元数据逐条核对)。
2026-10-06 09:01:10 +08:00

455 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>项目管理 · 执行任务</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --line:#2a2f3a;
--fg:#e6e9ef; --dim:#98a2b3; --dim2:#6b7686;
--accent:#4c6ef5; --warn:#f0a020; --bad:#e5534b; --ok:#3fb950;
--r:10px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.55 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}
a{color:inherit;text-decoration:none}
/* ── 顶栏:项目选择 ── */
header{position:sticky;top:0;z-index:20;background:rgba(15,17,21,.92);
backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.hrow{display:flex;align-items:center;gap:14px;padding:12px 18px;flex-wrap:wrap}
.brand{font-weight:600;letter-spacing:.2px}
.brand small{color:var(--dim2);font-weight:400;margin-left:8px}
select,button{background:var(--panel2);color:var(--fg);border:1px solid var(--line);
border-radius:8px;padding:7px 11px;font:inherit;cursor:pointer}
select:focus,button:focus{outline:2px solid var(--accent);outline-offset:1px}
.spacer{flex:1}
.stamp{color:var(--dim2);font-size:12px}
/* ── 项目概要 ── */
.summary{display:flex;gap:10px;padding:0 18px 12px;flex-wrap:wrap}
.chip{background:var(--panel);border:1px solid var(--line);border-radius:999px;
padding:4px 11px;font-size:12px;color:var(--dim)}
.chip b{color:var(--fg);font-weight:600}
/* ── 列 ── */
main{padding:16px 18px 60px}
.cols{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
@media(max-width:1100px){.cols{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:640px){.cols{grid-template-columns:1fr}}
.col{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
display:flex;flex-direction:column;min-height:160px}
.colhead{display:flex;align-items:center;gap:8px;padding:11px 13px;
border-bottom:1px solid var(--line)}
.dot{width:8px;height:8px;border-radius:50%;flex:none}
.coltitle{font-weight:600;font-size:13px}
.count{margin-left:auto;background:var(--panel2);border:1px solid var(--line);
border-radius:999px;padding:1px 9px;font-size:12px;color:var(--dim)}
.cards{padding:10px;display:flex;flex-direction:column;gap:9px;flex:1}
/* ── 卡片 ── */
.card{background:var(--panel2);border:1px solid var(--line);border-left-width:3px;
border-radius:8px;padding:10px 11px;cursor:pointer;
transition:transform .08s ease,border-color .12s ease}
.card:hover{transform:translateY(-1px);border-color:var(--accent)}
.card .t{font-weight:500;word-break:break-word}
.card .meta{margin-top:6px;color:var(--dim2);font-size:12px;
display:flex;gap:9px;flex-wrap:wrap}
.badge{display:inline-block;border-radius:5px;padding:1px 6px;font-size:11px;
border:1px solid var(--line);color:var(--dim)}
.empty{color:var(--dim2);font-size:12px;text-align:center;padding:18px 6px}
.warnline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(240,160,32,.09);border:1px solid rgba(240,160,32,.35);
color:#f4c67a;font-size:12.5px}
.errline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(229,83,75,.1);border:1px solid rgba(229,83,75,.4);color:#f2a09a}
.okline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.4);color:#8fe39a;
font-size:12.5px;white-space:pre-wrap;word-break:break-word}
pre.out{margin:8px 0 0;padding:10px;border-radius:8px;background:#12141a;
border:1px solid var(--line);color:var(--dim);font-size:11.5px;
max-height:260px;overflow:auto;white-space:pre-wrap}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button.danger{background:transparent;border-color:rgba(229,83,75,.5);color:#f2a09a}
button.danger:hover{background:rgba(229,83,75,.12)}
button[disabled]{opacity:.5;cursor:not-allowed}
/* ── 新建对话框 ── */
.modal{position:fixed;z-index:40;left:50%;top:50%;transform:translate(-50%,-50%) scale(.98);
width:min(560px,92vw);background:var(--panel);border:1px solid var(--line);
border-radius:12px;box-shadow:0 18px 50px rgba(0,0,0,.5);
opacity:0;pointer-events:none;transition:opacity .14s ease,transform .14s ease}
.modal.on{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)}
.hint{color:var(--dim);font-size:12.5px;margin:0 0 14px}
.hint b{color:var(--fg)}
.fld{display:flex;flex-direction:column;gap:5px;margin-bottom:11px}
.fld>span{color:var(--dim);font-size:12px}
.fld input,.fld select{background:var(--panel2);color:var(--fg);border:1px solid var(--line);
border-radius:8px;padding:8px 10px;font:inherit;width:100%}
.fld input:focus,.fld select:focus{outline:2px solid var(--accent);outline-offset:1px}
.fnote{margin:2px 0 12px;font-size:12px;color:var(--dim2);min-height:1.2em}
.fnote.bad{color:#f2a09a}
.facts{display:flex;align-items:center;gap:10px}
/* ── 抽屉 ── */
.mask{position:fixed;inset:0;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;
transition:opacity .15s ease;z-index:40}
.mask.on{opacity:1;pointer-events:auto}
aside{position:fixed;top:0;right:0;height:100%;width:min(560px,94vw);background:var(--panel);
border-left:1px solid var(--line);z-index:50;transform:translateX(100%);
transition:transform .18s ease;display:flex;flex-direction:column}
aside.on{transform:none}
.ahead{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line)}
.ahead .ttl{font-weight:600;flex:1;word-break:break-word}
.abody{overflow:auto;padding:14px 16px 30px}
.kv{display:grid;grid-template-columns:104px 1fr;gap:7px 12px;font-size:13px}
.kv dt{color:var(--dim2)}.kv dd{margin:0;word-break:break-word}
.sect{margin-top:18px}
.sect h4{margin:0 0 8px;font-size:12px;color:var(--dim2);font-weight:600;
text-transform:uppercase;letter-spacing:.6px}
.sess{display:flex;align-items:center;gap:9px;padding:8px 10px;border:1px solid var(--line);
border-radius:8px;margin-bottom:7px;background:var(--panel2);cursor:pointer}
.sess:hover{border-color:var(--accent)}
.sess .sn{flex:1;word-break:break-word;font-size:13px}
.sess .go{color:var(--accent);font-size:12px;flex:none}
.flow{display:flex;align-items:center;gap:9px;padding:6px 0;border-bottom:1px dashed var(--line);font-size:13px}
.flow:last-child{border-bottom:0}
.flow .fn{flex:1}
.flow .fc{color:var(--dim2);font-size:12px}
</style>
</head>
<body>
<header>
<div class="hrow">
<div class="brand">项目管理<small id="ws">—</small></div>
<label for="psel" class="stamp">项目</label>
<select id="psel" aria-label="选择项目"></select>
<button id="btndel" type="button" class="danger" title="删除当前项目(可选粒度)">删除</button>
<div class="spacer"></div>
<span class="stamp" id="stamp">加载中…</span>
<button id="btnnew" type="button" class="primary">+ 新建目标</button>
<button id="refresh" type="button">刷新</button>
</div>
<div class="summary" id="summary"></div>
</header>
<main>
<div id="notice"></div>
<div class="cols" id="cols"></div>
</main>
<div class="mask" id="mask"></div>
<aside id="drawer" aria-hidden="true">
<div class="ahead">
<div class="ttl" id="dtitle">—</div>
<button id="dclose" type="button">关闭</button>
</div>
<div class="abody" id="dbody"></div>
</aside>
<!-- 新建目标(新工作区)对话框 -->
<div class="mask" id="cmask"></div>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="mtitle">
<div class="ahead">
<div class="ttl" id="mtitle">新建目标(新工作区)</div>
<button id="mclose" type="button">关闭</button>
</div>
<div class="abody">
<p class="hint">在选定的根目录下建一个新工作区,并写入目标登记。走技能自带的建区入口(幂等)。
<b>目录名</b>同时作为工作区名,<b>目标标题</b>是「目标=项目」里的项目名。</p>
<label class="fld"><span>根目录</span><select id="froot"></select></label>
<label class="fld"><span>工作区名(目录名)</span>
<input id="fname" type="text" placeholder="例:my-new-project" autocomplete="off"></label>
<label class="fld"><span>目标标题</span>
<input id="ftitle" type="text" placeholder="例:把 XX 做成 YY" autocomplete="off"></label>
<label class="fld"><span>项目简称(可空 ⇒ 用工作区名)</span>
<input id="fshort" type="text" placeholder="例:XX 项目" autocomplete="off"></label>
<div class="fnote" id="fnote"></div>
<div class="facts">
<button id="mok" type="button" class="primary">创建</button>
<button id="mcancel" type="button">取消</button>
<span class="stamp" id="mstamp"></span>
</div>
</div>
</div>
<script>
"use strict";
var STATE = { data:null, cur:0 };
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, function(c){
return ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]; }); }
function fmtWhen(s){
if(!s) return "—";
var m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if(!m) return String(s);
return m[1]+"-"+m[2]+"-"+m[3]+" "+m[4]+":"+m[5];
}
function load(){
document.getElementById("stamp").textContent = "加载中…";
fetch("/api/projects", {cache:"no-store"}).then(function(r){ return r.json(); })
.then(function(d){
var n = document.getElementById("notice");
if(!d || d.ok === false){
n.innerHTML = '<div class="errline">扫工作区失败:' + esc((d&&d.err)||"未知错误") + '</div>';
return;
}
STATE.data = d;
n.innerHTML = "";
document.getElementById("ws").textContent = (d.roots||[]).join(" · ") || "—";
document.getElementById("stamp").textContent = "数据时间 " + (d.ts || "—");
var sel = document.getElementById("psel");
if(!d.projects.length){
sel.innerHTML = '<option value="">(本机暂无装了机制的工作区)</option>';
STATE.cur = 0;
} else {
sel.innerHTML = d.projects.map(function(p,i){
return '<option value="'+i+'">'+esc(p.name)+'</option>'; }).join("");
if(STATE.cur >= d.projects.length) STATE.cur = 0;
sel.value = String(STATE.cur);
}
render();
}).catch(function(e){
document.getElementById("notice").innerHTML =
'<div class="errline">请求失败:'+esc(e)+'</div>';
});
}
function render(){
var d = STATE.data; if(!d) return;
var p = d.projects[STATE.cur];
var sums = document.getElementById("summary");
var cols = document.getElementById("cols");
if(!p){
sums.innerHTML = "";
cols.innerHTML = '<div class="empty">本机没有装了机制的工作区。点右上角「+ 新建目标」建一个。</div>';
document.getElementById("btndel").disabled = true;
return;
}
document.getElementById("btndel").disabled = false;
// 概要
var q = p.queue, s = [];
s.push('<span class="chip">项目名 <b>'+esc(p.name)+'</b></span>');
s.push('<span class="chip">工作区 <b>'+esc(p.dir)+'</b></span>');
s.push('<span class="chip">建立 <b>'+esc(fmtWhen(p.declared_at))+'</b></span>');
s.push('<span class="chip">台账 <b>'+q.total+'</b> 件</span>');
s.push('<span class="chip">已完成 <b>'+q.done+'</b></span>');
s.push('<span class="chip">未完 <b>'+q.open+'</b></span>');
if(q.blocked) s.push('<span class="chip">受阻 <b>'+q.blocked+'</b></span>');
if(p.acc && p.acc.n) s.push('<span class="chip">验收 <b>'+p.acc.pass+' / '+p.acc.n+'</b></span>');
s.push('<span class="chip">工作流 <b>'+p.flows.length+'</b></span>');
if(p.life) s.push('<span class="chip">目标状态 <b>'+esc(p.life)+'</b></span>');
if(p.topics && p.topics.length)
s.push('<span class="chip">任务类别 <b>'+esc(p.topics.join("、"))+'</b></span>');
sums.innerHTML = s.join("");
// 提示行
var nt = [];
if(!p.has_goal)
nt.push('<div class="warnline">这个工作区<b>还没有登记目标</b>(只有机制骨架)⇒ 项目名是兜底的,卡片按台账显示。</div>');
if(p.life && p.life !== "进行中")
nt.push('<div class="warnline">该目标状态为「'+esc(p.life)+'」('+esc(fmtWhen(p.life_at))+')。</div>');
if(d.skipped && d.skipped.length)
nt.push('<div class="warnline">扫到但<b>没装机制</b>、未列出 '+d.skipped.length+' 个目录:'+
esc(d.skipped.join("、"))+'</div>');
document.getElementById("notice").innerHTML = nt.join("");
// 列
var byState = {};
p.cards.forEach(function(c){ (byState[c.state] = byState[c.state] || []).push(c); });
var html = d.cols.map(function(c){
var list = byState[c.key] || [];
var dot = c.key==="done" ? "var(--ok)" : c.key==="blocked" ? "var(--bad)"
: c.key==="running" ? "var(--accent)" : "var(--dim2)";
var inner = list.length ? list.map(function(t){
var bits = [];
if(t.line) bits.push('<span class="badge">'+esc(t.line)+'</span>');
if(t.by) bits.push('<span class="badge">'+esc(t.by)+'</span>');
if(t.t_end) bits.push(esc(fmtWhen(t.t_end)));
else if(t.t_start) bits.push(esc(fmtWhen(t.t_start)));
return '<div class="card" style="border-left-color:'+dot+'" data-tid="'+esc(t.id)+'">'+
'<div class="t">'+esc(t.title||t.id)+'</div>'+
(t.block_reason ? '<div class="meta" style="color:#f2a09a">受阻:'+esc(t.block_reason)+'</div>' : '')+
(bits.length ? '<div class="meta">'+bits.join("")+'</div>' : '')+
'</div>';
}).join("") : '<div class="empty">(此列无卡片)</div>';
return '<section class="col"><div class="colhead"><span class="dot" style="background:'+dot+
'"></span><span class="coltitle">'+esc(c.label)+'</span><span class="count">'+list.length+
'</span></div><div class="cards">'+inner+'</div></section>';
}).join("");
document.getElementById("cols").innerHTML = html;
// 卡片点开 → 详情(含跳会话)
Array.prototype.forEach.call(document.querySelectorAll(".card"), function(el){
el.addEventListener("click", function(){ openDetail(el.getAttribute("data-tid")); });
});
}
function openDetail(tid){
var p = STATE.data.projects[STATE.cur];
var t = (p.cards || []).filter(function(x){ return x.id === tid; })[0] || {};
var label = {}; STATE.data.cols.forEach(function(c){ label[c.key] = c.label; });
document.getElementById("dtitle").textContent = tid;
var h = [];
h.push('<dl class="kv">');
h.push('<dt>所属项目</dt><dd>'+esc(p.name)+'</dd>');
h.push('<dt>状态</dt><dd>'+esc(label[t.state]||t.state||"—")+'</dd>');
if(t.line) h.push('<dt>工作流</dt><dd>'+esc(t.line)+'</dd>');
if(t.by) h.push('<dt>负责</dt><dd>'+esc(t.by)+'</dd>');
if(t.t_start) h.push('<dt>开始</dt><dd>'+esc(fmtWhen(t.t_start))+'</dd>');
if(t.t_end) h.push('<dt>结束</dt><dd>'+esc(fmtWhen(t.t_end))+'</dd>');
if(t.block_reason) h.push('<dt>受阻原因</dt><dd style="color:#f2a09a">'+esc(t.block_reason)+'</dd>');
if(t.artifact) h.push('<dt>产出</dt><dd>'+esc(t.artifact)+'</dd>');
h.push('<dt>工作区</dt><dd>'+esc(p.ws)+'</dd>');
h.push('</dl>');
// 验收判据(本项目达成与否的唯一机读依据)
h.push('<div class="sect"><h4>验收判据('+((p.acc&&p.acc.n)||0)+' 条 · 通过 '+((p.acc&&p.acc.pass)||0)+')</h4>');
if(p.acc && p.acc.items && p.acc.items.length){
p.acc.items.forEach(function(a){
h.push('<div class="flow"><span class="fn">'+esc(a.k)+'</span>'+
'<span class="fc" style="color:'+(a.pass?"#8fe39a":"#f2a09a")+'">'+
(a.pass?"过":"未过")+' · '+esc(a.v.slice(0,120))+'</span></div>');
});
} else h.push('<div class="empty">(无验收判据)</div>');
h.push('</div>');
// 工作流
h.push('<div class="sect"><h4>工作流(任务类别)</h4>');
if((p.flows||[]).length){
p.flows.forEach(function(f){
h.push('<div class="flow"><span class="fn">'+esc(f.line)+'</span>'+
'<span class="fc">共 '+f.total+' · 完成 '+f.done+' · 未完 '+f.open+'</span></div>');
});
} else h.push('<div class="empty">(无)</div>');
h.push('</div>');
document.getElementById("dbody").innerHTML = h.join("");
document.getElementById("mask").classList.add("on");
document.getElementById("drawer").classList.add("on");
document.getElementById("drawer").setAttribute("aria-hidden","false");
}
function closeDetail(){
document.getElementById("mask").classList.remove("on");
document.getElementById("drawer").classList.remove("on");
document.getElementById("drawer").setAttribute("aria-hidden","true");
}
/* ── 新建目标(新工作区) ── */
function openNew(){
var d = STATE.data || {};
var r = document.getElementById("froot");
r.innerHTML = (d.roots||[]).map(function(x){
return '<option value="'+esc(x)+'">'+esc(x)+'</option>'; }).join("");
document.getElementById("fname").value = "";
document.getElementById("ftitle").value = "";
document.getElementById("fshort").value = "";
document.getElementById("fnote").textContent = "";
document.getElementById("fnote").className = "fnote";
document.getElementById("mstamp").textContent = "";
document.getElementById("mok").disabled = false;
document.getElementById("cmask").classList.add("on");
document.getElementById("modal").classList.add("on");
document.getElementById("fname").focus();
}
function closeNew(){
document.getElementById("cmask").classList.remove("on");
document.getElementById("modal").classList.remove("on");
}
function doCreate(){
var root = document.getElementById("froot").value;
var name = document.getElementById("fname").value.trim();
var title = document.getElementById("ftitle").value.trim();
var short = document.getElementById("fshort").value.trim();
var note = document.getElementById("fnote");
if(!name || /[\/\\<>:"|?*]/.test(name)){
note.className = "fnote bad";
note.textContent = "工作区名不合法(不许空、不许带路径分隔符或 <>:\"|?*)";
return;
}
if(!title){ title = name; }
document.getElementById("mok").disabled = true;
document.getElementById("mstamp").textContent = "创建中…(要跑建区脚本,稍等)";
fetch("/api/create", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({root:root, name:name, title:title, short:short})})
.then(function(r){ return r.json(); })
.then(function(d){
document.getElementById("mok").disabled = false;
if(d.ok){
note.className = "fnote";
note.innerHTML = '✅ 已创建:<b>'+esc(d.ws)+'</b>';
document.getElementById("mstamp").textContent = "";
var box = document.getElementById("notice");
if(box) box.innerHTML = '<div class="okline">创建成功:'+esc(d.ws)+
(d.out ? '<pre class="out">'+esc(d.out)+'</pre>' : '')+'</div>';
setTimeout(function(){ closeNew(); load(); }, 900);
} else {
note.className = "fnote bad";
note.textContent = "创建失败:" + (d.err || "未知错误");
document.getElementById("mstamp").textContent = "rc=" + (d.rc==null?"-":d.rc);
}
}).catch(function(e){
document.getElementById("mok").disabled = false;
note.className = "fnote bad";
note.textContent = "请求失败:" + e;
});
}
/* ── 删除当前项目 ── */
function doDelete(){
var p = (STATE.data||{}).projects && STATE.data.projects[STATE.cur];
if(!p) return;
var m = prompt("删除「"+p.name+"」——选择粒度:\n\n"+
"输入 1 = 只删【目标登记】(工作区与台账原样保留,可逆;项目从列表消失)\n"+
"输入 2 = 把【整个工作区】送回收站(不永久删除,可从回收站还原)\n\n"+
"(不输入或取消 = 什么都不做)", "1");
if(m !== "1" && m !== "2") return;
var mode = (m === "2") ? "ws" : "goal";
if(mode === "ws" &&
!confirm("确认把整个工作区送回收站?\n\n"+p.ws+"\n\n会连同台账、目标、脚本一起移走。")) return;
fetch("/api/delete", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ws:p.ws, mode:mode})})
.then(function(r){ return r.json(); })
.then(function(d){
var box = document.getElementById("notice");
if(d.ok){
box.innerHTML = '<div class="okline">已删除('+(mode==="ws"?"整个工作区送回收站":"目标登记归档")+
'):'+esc(d.moved_to||d.trashed||"")+'</div>';
load();
} else {
box.innerHTML = '<div class="errline">删除失败:'+esc(d.err||"未知错误")+'</div>';
}
}).catch(function(e){
document.getElementById("notice").innerHTML =
'<div class="errline">请求失败:'+esc(e)+'</div>';
});
}
document.getElementById("mask").addEventListener("click", closeDetail);
document.getElementById("dclose").addEventListener("click", closeDetail);
document.getElementById("cmask").addEventListener("click", closeNew);
document.getElementById("mclose").addEventListener("click", closeNew);
document.getElementById("mcancel").addEventListener("click", closeNew);
document.getElementById("mok").addEventListener("click", doCreate);
document.getElementById("btnnew").addEventListener("click", openNew);
document.getElementById("btndel").addEventListener("click", doDelete);
document.getElementById("psel").addEventListener("change", function(e){
STATE.cur = parseInt(e.target.value, 10) || 0; render(); });
document.getElementById("refresh").addEventListener("click", load);
document.addEventListener("keydown", function(e){
if(e.key === "Escape"){ closeDetail(); closeNew(); } });
load();
setInterval(load, 5000);
</script>
</body>
</html>