2026-10-06 08:23:15 +08:00
|
|
|
|
<!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}
|
2026-10-06 09:01:10 +08:00
|
|
|
|
.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}
|
2026-10-06 08:23:15 +08:00
|
|
|
|
|
|
|
|
|
|
/* ── 抽屉 ── */
|
|
|
|
|
|
.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>
|
2026-10-06 09:01:10 +08:00
|
|
|
|
<button id="btndel" type="button" class="danger" title="删除当前项目(可选粒度)">删除</button>
|
2026-10-06 08:23:15 +08:00
|
|
|
|
<div class="spacer"></div>
|
|
|
|
|
|
<span class="stamp" id="stamp">加载中…</span>
|
2026-10-06 09:01:10 +08:00
|
|
|
|
<button id="btnnew" type="button" class="primary">+ 新建目标</button>
|
2026-10-06 08:23:15 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-10-06 09:01:10 +08:00
|
|
|
|
<!-- 新建目标(新工作区)对话框 -->
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-10-06 08:23:15 +08:00
|
|
|
|
<script>
|
|
|
|
|
|
"use strict";
|
|
|
|
|
|
var STATE = { data:null, cur:0 };
|
|
|
|
|
|
|
|
|
|
|
|
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, function(c){
|
|
|
|
|
|
return ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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){
|
2026-10-06 09:01:10 +08:00
|
|
|
|
n.innerHTML = '<div class="errline">扫工作区失败:' + esc((d&&d.err)||"未知错误") + '</div>';
|
2026-10-06 08:23:15 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
STATE.data = d;
|
|
|
|
|
|
n.innerHTML = "";
|
2026-10-06 09:01:10 +08:00
|
|
|
|
document.getElementById("ws").textContent = (d.roots||[]).join(" · ") || "—";
|
2026-10-06 08:23:15 +08:00
|
|
|
|
document.getElementById("stamp").textContent = "数据时间 " + (d.ts || "—");
|
|
|
|
|
|
var sel = document.getElementById("psel");
|
2026-10-06 09:01:10 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-10-06 08:23:15 +08:00
|
|
|
|
render();
|
|
|
|
|
|
}).catch(function(e){
|
|
|
|
|
|
document.getElementById("notice").innerHTML =
|
|
|
|
|
|
'<div class="errline">请求失败:'+esc(e)+'</div>';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function render(){
|
|
|
|
|
|
var d = STATE.data; if(!d) return;
|
2026-10-06 09:01:10 +08:00
|
|
|
|
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;
|
2026-10-06 08:23:15 +08:00
|
|
|
|
|
|
|
|
|
|
// 概要
|
|
|
|
|
|
var q = p.queue, s = [];
|
|
|
|
|
|
s.push('<span class="chip">项目名 <b>'+esc(p.name)+'</b></span>');
|
2026-10-06 09:01:10 +08:00
|
|
|
|
s.push('<span class="chip">工作区 <b>'+esc(p.dir)+'</b></span>');
|
|
|
|
|
|
s.push('<span class="chip">建立 <b>'+esc(fmtWhen(p.declared_at))+'</b></span>');
|
2026-10-06 08:23:15 +08:00
|
|
|
|
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>');
|
2026-10-06 09:01:10 +08:00
|
|
|
|
if(p.acc && p.acc.n) s.push('<span class="chip">验收 <b>'+p.acc.pass+' / '+p.acc.n+'</b></span>');
|
2026-10-06 08:23:15 +08:00
|
|
|
|
s.push('<span class="chip">工作流 <b>'+p.flows.length+'</b></span>');
|
2026-10-06 09:01:10 +08:00
|
|
|
|
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("");
|
2026-10-06 08:23:15 +08:00
|
|
|
|
|
|
|
|
|
|
// 提示行
|
|
|
|
|
|
var nt = [];
|
2026-10-06 09:01:10 +08:00
|
|
|
|
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>');
|
2026-10-06 08:23:15 +08:00
|
|
|
|
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)+'">'+
|
2026-10-06 09:01:10 +08:00
|
|
|
|
'<div class="t">'+esc(t.title||t.id)+'</div>'+
|
2026-10-06 08:23:15 +08:00
|
|
|
|
(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>');
|
2026-10-06 09:01:10 +08:00
|
|
|
|
h.push('<dt>工作区</dt><dd>'+esc(p.ws)+'</dd>');
|
2026-10-06 08:23:15 +08:00
|
|
|
|
h.push('</dl>');
|
|
|
|
|
|
|
2026-10-06 09:01:10 +08:00
|
|
|
|
// 验收判据(本项目达成与否的唯一机读依据)
|
|
|
|
|
|
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>');
|
2026-10-06 08:23:15 +08:00
|
|
|
|
});
|
2026-10-06 09:01:10 +08:00
|
|
|
|
} else h.push('<div class="empty">(无验收判据)</div>');
|
2026-10-06 08:23:15 +08:00
|
|
|
|
h.push('</div>');
|
|
|
|
|
|
|
|
|
|
|
|
// 工作流
|
|
|
|
|
|
h.push('<div class="sect"><h4>工作流(任务类别)</h4>');
|
|
|
|
|
|
if((p.flows||[]).length){
|
|
|
|
|
|
p.flows.forEach(function(f){
|
2026-10-06 09:01:10 +08:00
|
|
|
|
h.push('<div class="flow"><span class="fn">'+esc(f.line)+'</span>'+
|
2026-10-06 08:23:15 +08:00
|
|
|
|
'<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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 09:01:10 +08:00
|
|
|
|
/* ── 新建目标(新工作区) ── */
|
|
|
|
|
|
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>';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 08:23:15 +08:00
|
|
|
|
document.getElementById("mask").addEventListener("click", closeDetail);
|
|
|
|
|
|
document.getElementById("dclose").addEventListener("click", closeDetail);
|
2026-10-06 09:01:10 +08:00
|
|
|
|
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);
|
2026-10-06 08:23:15 +08:00
|
|
|
|
document.getElementById("psel").addEventListener("change", function(e){
|
|
|
|
|
|
STATE.cur = parseInt(e.target.value, 10) || 0; render(); });
|
|
|
|
|
|
document.getElementById("refresh").addEventListener("click", load);
|
2026-10-06 09:01:10 +08:00
|
|
|
|
document.addEventListener("keydown", function(e){
|
|
|
|
|
|
if(e.key === "Escape"){ closeDetail(); closeNew(); } });
|
2026-10-06 08:23:15 +08:00
|
|
|
|
|
|
|
|
|
|
load();
|
|
|
|
|
|
setInterval(load, 5000);
|
|
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|