unis_manager/web/app.js

3359 lines
174 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* 软件开发部 · 关键任务追踪看板 */
(function () {
"use strict";
const Ico = window.Icon;
const state = {
view: "board",
gran: "week",
weekId: null,
monthIdx: null,
categoryId: null,
keyOnly: false,
keyword: "",
idle: "key",
showArchived: false,
meta: null,
periods: null,
categories: [],
board: null,
stats: null,
projects: [],
drawerProject: null,
// 详情抽屉:周期反馈的显示粒度 / 范围 / 往前翻页偏移
dw: { group: "week", range: 4, back: 0 },
// 定制交付
customMeta: null,
customTab: "pipeline",
customRegion: "",
customStage: "",
customArchived: "false",
customRows: [],
customBoard: null,
customStats: null,
intakeType: "general",
// 更新录入的两种方式:direct = 直接写入(原样存 1 条);ai = AI 整理(补结构化字段,也只存 1 条)
intakeMode: "direct",
intakePeriodId: null,
drawerCustom: null,
// 周看板(左栏总览 + 右栏详情)
weekly: null,
weeklySel: null,
weeklyOwner: "",
weeklyAllCustom: false,
// 项目管理
projOwner: "",
projIdleOnly: false,
// 每周重点工作
focusRows: [],
focusFilter: "week",
focusArchived: false,
// 人力资源库
staff: [],
staffDict: { groups: [], affiliations: [] },
staffArchived: "false",
staffGroup: "",
};
/* ---------------------------------------------------------------- utils */
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel));
function esc(s) {
if (s === null || s === undefined) return "";
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
const nl2br = (s) => esc(s).replace(/\n/g, "<br>");
const money = (n) =>
n === null || n === undefined
? "—"
: "¥" + Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const wan = (n) =>
n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(1) + " 万";
const fen = (n) => (n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(2));
/** 重点标记:统一用星标图标,on = 已标重点 */
const starMark = (on, readonly, title) =>
`<span class="star${on ? " on" : ""}${readonly ? " readonly" : ""}"${title ? ` title="${esc(title)}"` : ""}>${Ico.svg("star")}</span>`;
async function api(path, opts) {
const res = await fetch("/api" + path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts || {}));
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).detail || msg; } catch (e) { /* ignore */ }
throw new Error(msg);
}
return res.json();
}
const post = (path, body) => api(path, { method: "POST", body: JSON.stringify(body) });
const put = (path, body) => api(path, { method: "PUT", body: JSON.stringify(body) });
const patch = (path, body) => api(path, { method: "PATCH", body: JSON.stringify(body) });
const del = (path) => api(path, { method: "DELETE" });
let toastTimer = null;
function toast(msg) {
const t = $("#toast");
t.textContent = msg;
t.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove("show"), 2400);
}
/* ------------------------------------------------ 列表页统一骨架 */
/** 列表页统一样式(照「定制交付 · 项目明细」):工具栏在卡片上方,
卡片头部 = 标题 + 汇总说明 + 卡内筛选,下面是可滚动表格或空态。 */
function listCard(o) {
return `<div class="card">
<div class="card-head">
<h4><span class="t">${esc(o.title)}</span>${o.sub ? `<span class="sub">${esc(o.sub)}</span>` : ""}</h4>
${o.summary ? `<span class="period-hint">${o.summary}</span>` : ""}
${o.fields ? `<div class="inline-fields">${o.fields}</div>` : ""}
</div>
${o.rows.length ? `<div class="tbl-wrap">${o.table}</div>` : `<div class="empty">${o.empty}</div>`}
</div>`;
}
/** 页内搜索框:与右上角全局搜索共用 state.keyword */
function keywordInputHtml(id, placeholder, width) {
return `<input id="${id}" class="search" type="search" placeholder="${esc(placeholder)}" value="${esc(state.keyword)}" style="width:${width}px;height:30px" />`;
}
/** 绑定页内搜索:防抖取数,重绘后把光标放回搜索框,连续打字不中断 */
function bindKeyword(id) {
const el = $("#" + id);
if (!el) return;
let t = null;
el.addEventListener("input", (e) => {
clearTimeout(t);
const v = e.target.value.trim();
t = setTimeout(() => {
if (v === state.keyword) return;
keywordFocus = true;
state.keyword = v;
$("#globalSearch").value = v;
refresh();
}, 320);
});
if (keywordFocus) {
keywordFocus = false;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
}
/* ---------------------------------------------------- 弹窗 / 确认框 */
let modalResolve = null;
function openModal(html, onMount) {
$("#modalMask").classList.add("open");
$("#modal").innerHTML = html;
if (onMount) onMount();
const f = $("#modal").querySelector("[data-autofocus]");
if (f) f.focus();
}
/** 关闭弹窗;result 省略时按「取消」处理 */
function closeModal(result) {
if (!$("#modalMask").classList.contains("open")) return;
$("#modalMask").classList.remove("open");
$("#modal").innerHTML = "";
const cb = modalResolve;
modalResolve = null;
if (cb) cb(result === undefined ? false : result);
}
function modalError(msg) {
const e = $("#mErr");
if (!e) return;
e.textContent = msg || "";
e.style.display = msg ? "" : "none";
}
/** 需要用户明确点确认才继续;requireText 时还必须逐字输入该文本 */
function confirmDialog(o) {
return new Promise((resolve) => {
openModal(
`<div class="modal-head"><h3>${esc(o.title)}</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
<div class="modal-body">
${o.body || ""}
${
o.requireText
? `<div class="form-row" style="margin-top:14px"><label>请输入<br><b>${esc(o.requireText)}</b></label>
<input class="inp" id="mConfirmText" data-autofocus placeholder="逐字输入项目名称" style="width:100%" /></div>`
: ""
}
<div class="notice err" id="mErr" style="display:none"></div>
</div>
<div class="modal-foot">
<button class="btn" data-mclose>${esc(o.cancelText || "取消")}</button>
<button class="btn ${o.danger ? "danger solid" : "primary"}" id="mOk" ${o.requireText ? "disabled" : ""}>${esc(o.okText || "确认")}</button>
</div>`
);
modalResolve = resolve;
$("#mOk").addEventListener("click", () => closeModal(true));
if (o.requireText) {
const t = $("#mConfirmText");
const ok = $("#mOk");
t.addEventListener("input", () => {
ok.disabled = t.value.trim() !== o.requireText;
});
t.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !ok.disabled) closeModal(true);
});
}
});
}
function statusMeta(code) {
const list = (state.meta && state.meta.statuses) || [];
return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" };
}
function statusPill(code) {
const s = statusMeta(code);
return `<span class="pill" style="background:${s.color}1a;color:${s.color}">${Ico.status(code)}${esc(s.name)}</span>`;
}
function catPill(cat) {
if (!cat) return `<span class="tag-mini">未分类</span>`;
return `<span class="tag-mini" style="background:${cat.color}18;color:${cat.color}">${esc(cat.name)}</span>`;
}
/** 规范省级行政区下拉(按大区分组)。selected 不在标准列表时补一个选项,避免丢值。 */
function regionOptions(selected, withEmpty) {
const groups = (state.customMeta && state.customMeta.region_groups) || [];
const all = groups.reduce((acc, g) => acc.concat(g.regions), []);
let html = withEmpty === false ? "" : `<option value="" ${!selected ? "selected" : ""}>(未标注)</option>`;
for (const g of groups) {
html += `<optgroup label="${esc(g.group)}">` +
g.regions.map((r) => `<option value="${esc(r)}" ${r === selected ? "selected" : ""}>${esc(r)}</option>`).join("") +
`</optgroup>`;
}
if (selected && all.indexOf(selected) < 0) {
html += `<option value="${esc(selected)}" selected>${esc(selected)}(非标准)</option>`;
}
return html;
}
/** 触发浏览器下载(导出 Excel) */
function download(url) {
const a = document.createElement("a");
a.href = url;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), 1000);
toast("正在生成 Excel,请稍候…");
}
/**
* 负责人下拉。选项来自人力资源库:已归档人员不再出现在选项里,
* 只有他本来就是当前负责人时才带「(已归档)」标注出现一次,避免历史记录丢值;
* 库里没有的历史人名同样补一个选项,保证老数据不被改坏。
*/
function ownerSelectHtml(value, attrs) {
const cur = (value || "").trim();
let html = `<select class="sel" ${attrs || 'style="width:104px"'}>
<option value="">(未指定)</option>`;
state.staff.forEach((s) => {
if (s.archived && s.name !== cur) return;
html += `<option value="${esc(s.name)}" ${s.name === cur ? "selected" : ""}>${esc(s.name)}${s.archived ? "(已归档)" : ""}</option>`;
});
if (cur && !state.staff.some((s) => s.name === cur)) {
html += `<option value="${esc(cur)}" selected>${esc(cur)}(不在花名册)</option>`;
}
return html + `</select>`;
}
/**
* 多负责人 / 团队成员的可编辑名单:人名下拉(选项来自人力资源库)横向排开、自动换行,
* 不分主次,每个人都能删;「+ 添加」紧跟在名单末尾。
* 增删按钮的事件统一委托在 #drawerBody 上(见 bindDrawerPeople)。
*/
function peopleEditorHtml(id, label, values, opts) {
const list = values && values.length ? values : [""];
const rows = list
.map(
(n) => `
<div class="people-row">
${ownerSelectHtml(n, 'style="width:118px"')}
<button class="btn sm ghost people-del" type="button" data-pdel title="移除">${Ico.svg("x")}</button>
</div>`,
)
.join("");
return `
<div class="form-row top">
<label>${label}</label>
<div>
<div class="people-rows" id="${id}">${rows}
<button class="btn sm ghost" type="button" data-padd="${id}">${Ico.svg("plus")}添加${opts.add}</button>
</div>
${opts.hint ? `<div class="sec-hint" style="margin:6px 0 0">${opts.hint}</div>` : ""}
</div>
</div>`;
}
/** 读回名单里的人名(按行序,空行忽略) */
function peopleValues(id) {
return $$("#" + id + " select").map((x) => x.value.trim()).filter(Boolean);
}
/** 名单增删:抽屉里所有「多负责人 / 团队成员」共用一个委托 */
function bindDrawerPeople() {
$("#drawerBody").addEventListener("click", (e) => {
const add = e.target.closest("[data-padd]");
if (add) {
const box = document.getElementById(add.dataset.padd);
if (!box) return;
const row = document.createElement("div");
row.className = "people-row";
row.innerHTML =
ownerSelectHtml("", 'style="width:130px"') +
`<button class="btn sm ghost people-del" type="button" data-pdel title="移除">${Ico.svg("x")}</button>`;
box.insertBefore(row, add);
row.querySelector("select").focus();
return;
}
const del = e.target.closest("[data-pdel]");
if (del) del.closest(".people-row").remove();
});
}
/** 只读名单:小标签一排 */
function nameChips(list) {
return (list || []).length
? list.map((n) => `<span class="tag-mini">${esc(n)}</span>`).join(" ")
: "—";
}
/** 拉一次花名册,供各处「负责人」下拉使用 */
async function loadStaff() {
const d = await api("/staff?archived=all");
state.staff = d.items;
state.staffDict = d.dict;
return d;
}
/* ---------------------------------------------------------------- scope */
function scopeQuery(extra) {
const p = [];
if (state.gran === "week") {
if (state.weekId) p.push("period_id=" + state.weekId);
} else {
const m = currentMonth();
if (m) p.push("year=" + m.year, "month=" + m.month);
}
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
if (extra) p.push.apply(p, extra);
return p.length ? "?" + p.join("&") : "";
}
function currentMonth() {
if (!state.periods || state.monthIdx === null) return null;
return state.periods.months[state.monthIdx] || null;
}
function scopePeriodIds() {
if (state.gran === "week") return state.weekId ? [state.weekId] : [];
const m = currentMonth();
return m ? m.period_ids : [];
}
function scopeLabel() {
if (state.gran === "week") {
const w = (state.periods.weeks || []).find((x) => x.id === state.weekId);
return w ? w.full_label : "—";
}
const m = currentMonth();
return m ? m.label : "—";
}
/* ---------------------------------------------------------------- charts */
function barChart(items, opts) {
opts = opts || {};
const W = 660, H = opts.height || 190, padL = 34, padB = 30, padT = 14;
const innerW = W - padL - 12, innerH = H - padB - padT;
const max = Math.max(1, ...items.map((d) => d.value));
const n = items.length || 1;
const slot = innerW / n;
const bw = Math.min(38, slot * 0.6);
let bars = "";
items.forEach((d, i) => {
const h = Math.max(2, (d.value / max) * innerH);
const x = padL + slot * i + (slot - bw) / 2;
const y = padT + innerH - h;
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="3" fill="${d.color || "#2563eb"}" opacity="${d.dim ? .45 : .9}"><title>${esc(d.label)}: ${d.value}${esc(opts.unit || "")}</title></rect>`;
if (d.value > 0) {
bars += `<text x="${(x + bw / 2).toFixed(1)}" y="${(y - 4).toFixed(1)}" font-size="10" fill="#7a8394" text-anchor="middle">${d.value}</text>`;
}
bars += `<text x="${(padL + slot * i + slot / 2).toFixed(1)}" y="${(H - 10).toFixed(1)}" font-size="10" fill="#7a8394" text-anchor="middle">${esc(d.label)}</text>`;
});
let grid = "";
for (let g = 0; g <= 3; g++) {
const y = padT + (innerH / 3) * g;
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${W - 8}" y2="${y.toFixed(1)}" stroke="#eef0f4"/>`;
grid += `<text x="4" y="${(y + 3).toFixed(1)}" font-size="9" fill="#aeb5c2">${Math.round(max - (max / 3) * g)}</text>`;
}
return `<svg class="svg-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">${grid}${bars}</svg>`;
}
function donutChart(items) {
const total = items.reduce((a, b) => a + b.value, 0) || 1;
const r = 58, cx = 80, cy = 80, C = 2 * Math.PI * r;
let acc = 0, segs = "";
items.forEach((d) => {
const len = (d.value / total) * C;
segs += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${d.color}" stroke-width="22" stroke-dasharray="${len.toFixed(2)} ${(C - len).toFixed(2)}" stroke-dashoffset="${(-acc).toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"><title>${esc(d.name)}: ${d.value}</title></circle>`;
acc += len;
});
return `<svg class="svg-chart" viewBox="0 0 160 160" style="max-width:190px;margin:0 auto">
${segs}
<text x="80" y="76" text-anchor="middle" font-size="22" font-weight="700" fill="#1f2430">${total}</text>
<text x="80" y="95" text-anchor="middle" font-size="10" fill="#7a8394">项目总数</text>
</svg>`;
}
function stackBar(items) {
const total = items.reduce((a, b) => a + b.count, 0) || 1;
let x = 0, segs = "";
items.filter((i) => i.count > 0).forEach((d) => {
const w = (d.count / total) * 100;
segs += `<rect x="${x.toFixed(2)}%" y="0" width="${w.toFixed(2)}%" height="14" fill="${d.color}"><title>${esc(d.name)}: ${d.count}</title></rect>`;
x += w;
});
return `<svg class="svg-chart" viewBox="0 0 100 14" preserveAspectRatio="none" style="height:14px;border-radius:99px;overflow:hidden">${segs}</svg>`;
}
function barList(rows) {
return rows.map((r) => `
<div class="bar-row">
<div class="b-name" title="${esc(r.name)}">${r.is_key ? starMark(true) + " " : ""}${esc(r.name)}</div>
<div class="b-track"><i style="width:${Math.min(100, r.progress)}%;background:${r.color}"></i></div>
<div class="b-val">${r.progress}${r.estimated ? "*" : ""}</div>
</div>`).join("");
}
/* ---------------------------------------------------------------- periods */
async function createNewPeriod() {
const label = prompt("新建周期(格式如:9月第1周)\n年份会自动接在最新周期之后,也可写成 2027年1月第1周", "");
if (!label || !label.trim()) return null;
try {
const r = await post("/periods", { label: label.trim() });
await loadPeriods();
state.gran = "week";
state.weekId = r.period.id;
state.intakePeriodId = r.period.id;
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === "week"));
renderPeriodSelect();
toast(r.created ? `已新增周期 ${r.period.full_label}` : `周期 ${r.period.full_label} 已存在,已切换`);
return r.period.id;
} catch (err) {
toast("新建周期失败:" + err.message);
return null;
}
}
async function loadPeriods() {
state.periods = await api("/periods");
if (!state.periods.weeks.length) return;
// 刷新后恢复的周期可能已经被删除/重建,不在列表里就回落到今天所在的周(没有就取最新一期)
if (!state.periods.weeks.some((w) => w.id === state.weekId)) {
state.weekId = state.periods.today_id || state.periods.weeks[state.periods.weeks.length - 1].id;
}
if (state.monthIdx === null || state.monthIdx >= state.periods.months.length) {
state.monthIdx = state.periods.months.length - 1;
}
renderPeriodSelect();
}
function renderPeriodSelect() {
saveUI();
const sel = $("#periodSelect");
if (!state.periods) return;
if (state.gran === "week") {
sel.innerHTML =
state.periods.weeks
.map((w) => `<option value="${w.id}" ${w.id === state.weekId ? "selected" : ""}>${esc(w.full_label)}</option>`)
.join("") + `<option value="__new">+ 新建周期…</option>`;
} else {
sel.innerHTML = state.periods.months
.map((m, i) => `<option value="${i}" ${i === state.monthIdx ? "selected" : ""}>${esc(m.label)}(${m.week_count} 周)</option>`)
.join("");
}
$("#periodHint").textContent = state.gran === "week" ? "周视图" : "月视图(自动汇总该月所有周)";
}
function shiftPeriod(delta) {
if (state.gran === "week") {
const arr = state.periods.weeks;
let i = arr.findIndex((w) => w.id === state.weekId);
i = Math.min(arr.length - 1, Math.max(0, i + delta));
state.weekId = arr[i].id;
} else {
let i = state.monthIdx + delta;
i = Math.min(state.periods.months.length - 1, Math.max(0, i));
state.monthIdx = i;
}
renderPeriodSelect();
refresh();
}
/* ---------------------------------------------------------------- dashboard */
/** 统计概览(原「执行面板」):折进工作看板底部,不再占一个主导航位 */
async function dashboardHtml() {
const s = await api("/stats" + scopeQuery());
state.stats = s;
const k = s.kpi;
const trend = s.trend.map((t) => {
const cur = scopePeriodIds().indexOf(t.period_id) >= 0;
return { label: `${t.month}/${t.week || ""}`, value: t.update_count, color: cur ? "#2563eb" : "#93b4f5", dim: !cur };
});
return `
<div class="grid kpi" style="margin-bottom:16px">
<div class="kpi accent-blue">${Ico.badge("layers")}<div class="k-label">在管项目</div><div class="k-value">${k.project_total}</div><div class="k-foot">重点项目 ${k.key_total} 个</div></div>
<div class="kpi accent-violet"><div class="k-label">${esc(scopeLabel())} · 执行记录</div><div class="k-value">${k.update_count}</div><div class="k-foot">覆盖项目 ${k.covered_projects} / ${k.project_total}</div></div>
<div class="kpi accent-green">${Ico.badge("gauge")}<div class="k-label">平均进度</div><div class="k-value">${k.avg_progress}%</div><div class="k-foot">按项目最新进度估算</div></div>
<div class="kpi accent-amber">${Ico.badge("alert")}<div class="k-label">风险 / 阻塞</div><div class="k-value">${k.risk_count}</div><div class="k-foot">本周期需关注条目</div></div>
<div class="kpi accent-red">${Ico.badge("alert-circle")}<div class="k-label">重点项目未更新</div><div class="k-value">${s.idle_key.length}</div><div class="k-foot">本周期无执行记录</div></div>
</div>
<div class="grid g2" style="margin-bottom:16px">
<div class="card">
<div class="card-head"><h4>周期更新活跃度<span class="sub">近 ${trend.length} 个周期的执行记录条数</span></h4></div>
<div class="card-body">${barChart(trend, { unit: " 条" })}</div>
</div>
<div class="card">
<div class="card-head"><h4>项目分类分布</h4></div>
<div class="card-body">
<div style="display:flex;gap:18px;align-items:center;flex-wrap:wrap">
<div style="flex:0 0 190px">${donutChart(s.category_dist.map((c) => ({ name: c.name, color: c.color, value: c.count })))}</div>
<div class="legend" style="flex-direction:column;gap:6px;flex:1">
${s.category_dist.map((c) => `<div><i style="background:${c.color}"></i>${esc(c.name)} <b>${c.count}</b></div>`).join("")}
</div>
</div>
</div>
</div>
</div>
<div class="grid g2e" style="margin-bottom:16px">
<div class="card">
<div class="card-head"><h4>执行状态分布<span class="sub">${esc(scopeLabel())}</span></h4></div>
<div class="card-body">
${stackBar(s.status_dist)}
<div class="legend">${s.status_dist.map((x) => `<span><i style="background:${x.color}"></i>${esc(x.name)} ${x.count}</span>`).join("")}</div>
<div class="sec-title" style="margin-top:18px">进度领先 Top 10</div>
${barList(s.top_progress)}
</div>
</div>
<div class="card">
<div class="card-head"><h4>风险与阻塞<span class="sub">${s.risks.length} 条</span></h4></div>
<div class="card-body" style="max-height:330px;overflow:auto">
${s.risks.length ? s.risks.map((r) => `
<div class="upd-item">
<div class="u-head">
<span class="u-task">${esc(r.project_name)}</span>${statusPill(r.status)}
<span class="tag-mini">${esc(r.period_label || "")}</span>
</div>
<div class="u-body">${nl2br(r.risk || r.content || "")}</div>
</div>`).join("") : `<div class="empty">本周期暂无风险上报</div>`}
</div>
</div>
</div>
<div class="grid g2">
<div class="card">
<div class="card-head"><h4>低进度预警<span class="sub">进度 &lt; 30% 且未完成</span></h4></div>
<div class="card-body">${s.low_progress.length ? barList(s.low_progress) : `<div class="empty">暂无</div>`}</div>
</div>
<div class="card">
<div class="card-head"><h4>最近更新</h4></div>
<div class="card-body" style="max-height:300px;overflow:auto">
${s.recent_updates.length ? s.recent_updates.map((u) => `
<div class="upd-item">
<div class="u-head"><span class="u-task">${esc(u.project_name)}</span>${statusPill(u.status)}<span class="tag-mini">${esc(u.task_name || "")}</span><span class="tag-mini">${esc(u.period_label || "")}</span></div>
<div class="u-body">${nl2br((u.summary || u.content || "").slice(0, 160))}</div>
</div>`).join("") : `<div class="empty">暂无</div>`}
</div>
</div>
</div>`;
}
/* ---------------------------------------------------------------- 周看板 */
/** 左栏一行:重点项目 / 定制项目共用 */
function weeklyItem(r) {
const sel = state.weeklySel;
const active = sel && sel.kind === r.kind && sel.id === r.id;
const color = r.kind === "project"
? ((r.category && r.category.color) || "#2563eb")
: stageMeta(r.stage).color;
const badge = r.has_update
? `<span class="tag-mini" style="background:#e9f7ef;color:#16a34a">${r.update_count} 条</span>`
: `<span class="tag-mini" style="background:#fdecec;color:#dc2626">未更新</span>`;
return `
<div class="wk-item ${active ? "active" : ""}" style="--c:${color}" data-wk="${r.kind}:${r.id}">
<div class="wk-i-top">
<span class="wk-i-name">${r.is_key ? starMark(true) : ""}${esc(r.name)}</span>
${badge}
</div>
<div class="wk-i-meta">
${r.kind === "project" ? statusPill(r.status) : stagePill(r.stage)}
<span class="tag-mini">${esc(r.owner || "未指派")}</span>
${r.kind === "project"
? `<span class="tag-mini">${r.progress}%</span>`
: `<span class="tag-mini">${fen(r.amount)} 万</span>`}
${r.risks.length ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">风险 ${r.risks.length}</span>` : ""}
</div>
</div>`;
}
/** 交付环节步骤条:日期可直接补录,补了日期后端会自动推进商务阶段 */
function pipelineBar(p, readonly) {
const steps = (state.customMeta && state.customMeta.pipeline) || [];
return `<div class="pipe">
${steps.map((s, i) => `
${i ? `<div class="pipe-arrow">${Ico.svg("chevron-right")}</div>` : ""}
<div class="pipe-step ${p[s.date_field] ? "done" : ""} ${p.stage === s.stage ? "now" : ""}" style="--c:${s.color}">
<div class="pipe-name">${Ico.pipe(s.code)}${esc(s.name)}</div>
${readonly
? `<div class="pipe-date ${p[s.date_field] ? "" : "none"}">${p[s.date_field] ? esc(p[s.date_field]) : "未填"}</div>`
: `<input class="inp" type="date" data-cf="${s.date_field}" value="${p[s.date_field] || ""}" />`}
</div>`).join("")}
</div>`;
}
/** 一条执行 / 交付记录 */
function updateItem(u) {
return `
<div class="upd-item">
<div class="u-head">
<span class="u-task">${esc(u.task_name || u.period_full || "记录")}</span>
${u.stage ? stagePill(u.stage) : statusPill(u.status)}
${u.progress !== null && u.progress !== undefined ? `<span class="tag-mini">${u.progress}%</span>` : ""}
${u.period_full && u.period_full !== u.task_name ? `<span class="tag-mini">${esc(u.period_label || "")}</span>` : ""}
</div>
<div class="u-body">${nl2br(u.summary || u.content || "(无正文)")}</div>
${u.risk ? `<div class="u-risk">${Ico.svg("alert-circle")}<div>${nl2br(u.risk)}</div></div>` : ""}
${u.next_step ? `<div class="u-next">${Ico.svg("arrow-right")}<div>${nl2br(u.next_step)}</div></div>` : ""}
</div>`;
}
function weeklyHead(row) {
const isP = row.kind === "project";
const color = isP ? ((row.category && row.category.color) || "#2563eb") : stageMeta(row.stage).color;
return `
<div class="wk-head" style="--c:${color}">
<div class="wk-head-title">${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}</div>
<div class="wk-i-meta">
${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)}
<span class="tag-mini">${esc(row.owner || "未指派")}</span>
<span class="tag-mini">最近更新 ${esc(row.last_update || "尚无记录")}</span>
${row.idle_weeks ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">已 ${row.idle_weeks} 周没更新</span>` : ""}
</div>
<div class="inline-fields" style="margin-top:10px">
<button class="btn sm primary" id="wkIntake">${Ico.svg("plus")}录一笔${isP ? "执行" : "交付"}记录</button>
<button class="btn sm" id="wkDrawer">完整详情</button>
</div>
</div>`;
}
/* 右栏:常规项目详情 */
async function weeklyProjectDetail(row) {
const d = await api("/projects/" + row.id);
const p = d.project;
const color = (p.category && p.category.color) || "#2563eb";
const trend = d.timeline.slice(-8).reverse();
return `
${weeklyHead(row)}
<div class="sec">
<div class="sec-title">本期执行记录(${row.updates.length})</div>
${row.updates.length
? row.updates.map(updateItem).join("")
: `<div class="notice warn">本周期还没有执行记录。重点项目每周都要更新一次,点上方「录一笔执行记录」补上。</div>`}
</div>
<div class="sec">
<div class="sec-title">进度与状态<span class="sub">${esc(row.progress_source)} · 共 ${p.update_count} 条历史记录 / ${d.tasks.length} 个子任务</span></div>
<div class="progress"><i style="width:${Math.min(100, row.progress)}%;background:${color}"></i></div>
<div class="progress-label"><span>整体进度</span><span>${row.progress}%</span></div>
${d.tasks.length ? `<div class="inline-fields" style="margin-top:10px">${d.tasks.map((t) => `<span class="tag-mini">${esc(t.name)}</span>`).join("")}</div>` : ""}
</div>
${trend.length ? `
<div class="sec">
<div class="sec-title">近 ${trend.length} 期趋势</div>
${trend.map((t) => `
<div class="timeline-row">
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
<div style="flex:1">${statusPill(t.status)}${t.progress !== null ? ` <span class="tag-mini">${t.progress}%</span>` : ""}
<span class="tag-mini">${t.updates.length} 条</span></div>
</div>`).join("")}
</div>` : ""}`;
}
/* 右栏:定制项目详情(交付环节 + 财务) */
async function weeklyCustomDetail(row) {
const d = await api("/custom/projects/" + row.id);
const p = d.project;
const hist = d.updates.slice(-5).reverse();
return `
${weeklyHead(row)}
<div class="sec">
<div class="sec-title">交付环节<span class="sub">补录日期会自动推进商务阶段</span></div>
${pipelineBar(p)}
${p.remark ? `<div class="kcard-remark" style="margin-top:10px">${nl2br(p.remark)}</div>` : ""}
</div>
<div class="sec">
<div class="sec-title">财务跟踪</div>
<div class="grid kpi" style="gap:10px;grid-template-columns:repeat(auto-fit,minmax(132px,1fr))">
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">合同额</div><div class="k-value" style="font-size:18px">${wan(p.amount)}</div></div>
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">可确认收入</div><div class="k-value" style="font-size:18px">${wan(p.confirmed_amount)}</div><div class="k-foot">${p.revenue_ratio}%</div></div>
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">已计收</div><div class="k-value" style="font-size:18px">${wan(p.revenue_amount)}</div></div>
<div class="kpi">${Ico.badge("hourglass")}<div class="k-label">待计收</div><div class="k-value" style="font-size:18px">${wan(p.pending_amount)}</div></div>
</div>
<div class="inline-fields" style="margin-top:12px">
<label class="field-mini">开票金额<input class="inp" type="number" data-cf="invoice_amount" value="${p.invoice_amount === null ? "" : p.invoice_amount}" style="width:110px;text-align:right" /></label>
<label class="field-mini">已回款<input class="inp" type="number" data-cf="received_amount" value="${p.received_amount === null ? "" : p.received_amount}" style="width:110px;text-align:right" /></label>
<span class="tag-mini" style="background:#fff4e0;color:#b45309">待开票 ${fen(p.pending_invoice)} 万 · 待回款 ${fen(p.pending_received)} 万</span>
</div>
</div>
<div class="sec">
<div class="sec-title">本期交付记录(${row.updates.length})</div>
${row.updates.length
? row.updates.map(updateItem).join("")
: `<div class="notice ${row.is_key ? "warn" : "info"}">本周期没有交付记录。</div>`}
</div>
${hist.length ? `
<div class="sec">
<div class="sec-title">更早的交付记录</div>
${hist.map((u) => `
<div class="timeline-row">
<div class="t-label">${esc((u.period_full || "").replace(/^\d+年/, ""))}</div>
<div style="flex:1">${stagePill(u.stage)}${u.revenue_delta ? ` <span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(u.revenue_delta)}万</span>` : ""}</div>
</div>`).join("")}
</div>` : ""}`;
}
function bindWeeklyDetail(box, row) {
const inBtn = $("#wkIntake");
if (inBtn) inBtn.addEventListener("click", () => {
state.intakePeriodId = state.weekId;
if (row.kind === "project") { state.intakeType = "general"; intakeProjectId = row.id; }
else { state.intakeType = "custom"; customIntakeId = row.id; }
setView("intake");
});
const dw = $("#wkDrawer");
if (dw) dw.addEventListener("click", () =>
row.kind === "project" ? openDrawer(row.id) : openCustomDrawer(row.id)
);
box.querySelectorAll("[data-cf]").forEach((inp) =>
inp.addEventListener("change", async () => {
const f = inp.dataset.cf;
let v = inp.value;
if (f === "invoice_amount" || f === "received_amount") v = v === "" ? null : Number(v);
try {
await patch("/custom/projects/" + row.id, { [f]: v });
toast("已保存");
await afterWrite();
} catch (e) { toast("保存失败:" + e.message); }
})
);
}
/** 定制项目本周动态里的一条:整行可点,点进去看该项目详情 */
function weeklyCustomRow(r, tag) {
return `
<div class="wk-item" style="--c:${stageMeta(r.stage).color}" data-cpick="${r.id}">
<div class="wk-i-top">
<span class="wk-i-name">${r.is_key ? starMark(true) : ""}${esc(r.name)}</span>
${tag || ""}
</div>
<div class="wk-i-meta">
${stagePill(r.stage)}
<span class="tag-mini">${esc(r.owner || "未指派")}</span>
<span class="tag-mini">${fen(r.amount)} 万</span>
${r.period_revenue_delta ? `<span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(r.period_revenue_delta)} 万</span>` : ""}
${r.risks.length ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">风险 ${r.risks.length}</span>` : ""}
</div>
${r.summary ? `<div class="wk-i-sub">${esc(String(r.summary).replace(/\s+/g, " ").slice(0, 90))}</div>` : ""}
</div>`;
}
/** 右栏:定制项目本周动态。左栏的「定制项目」只给入口,清单都收在这里 */
function weeklyCustomOverview(data) {
const rows = data.custom;
const added = rows.filter((r) => r.is_new);
const moved = rows.filter((r) => r.has_update && !r.is_new);
const idle = rows.filter((r) => !r.is_new && !r.has_update);
// 合计只算列出来的行,免得数字对不上清单
const shown = added.concat(moved).concat(state.weeklyAllCustom ? idle : []);
const sum = shown.reduce((a, r) => a + Number(r.amount || 0), 0);
const sec = (title, sub, list, tag) => `
<div class="sec">
<div class="sec-title">${title}<span class="sub">${sub} · ${list.length} 个</span></div>
${list.length ? list.map((r) => weeklyCustomRow(r, tag(r))).join("")
: `<div class="empty" style="padding:14px 4px;font-size:12px">本周期没有</div>`}
</div>`;
return `
<div class="wk-head" style="--c:#0d9488">
<div class="wk-head-title">${Ico.badge("package")}定制项目 · 本周动态</div>
<div class="wk-i-meta">
<span class="tag-mini">新入库 ${added.length} 个</span>
<span class="tag-mini">有交付动态 ${moved.length} 个</span>
<span class="tag-mini">合同额合计 ${fen(sum)} 万</span>
<span class="tag-mini">本期计收 ${fen(data.kpi.revenue_delta)} 万</span>
</div>
<div class="inline-fields" style="margin-top:10px">
<label class="switch"><input type="checkbox" id="wkAllCustom" ${state.weeklyAllCustom ? "checked" : ""} /> 列入全部在途</label>
<button class="btn sm" id="wkGoCustom">去定制交付</button>
</div>
</div>
${sec("本周新增", "入库时间落在本周期", added,
(r) => `<span class="tag-mini" style="background:#e8f0ff;color:#2563eb">${esc(r.entry_date || "新入库")}</span>`)}
${sec("本周有变化", "本周期有交付记录", moved,
(r) => `<span class="tag-mini" style="background:#e9f7ef;color:#16a34a">${r.update_count} 条记录</span>`)}
${state.weeklyAllCustom ? sec("其他在途项目", "本周没有动态", idle,
(r) => (r.is_key ? `<span class="tag-mini">重点</span>` : "")) : ""}
${!state.weeklyAllCustom && idle.length
? `<div class="notice info" style="margin-top:14px">另有 <b>${idle.length}</b> 个在途定制项目本周没有动态,勾选「列入全部在途」一并列出。</div>` : ""}`;
}
function bindWeeklyCustomOverview(box) {
const ac = box.querySelector("#wkAllCustom");
if (ac) ac.addEventListener("change", () => { state.weeklyAllCustom = ac.checked; renderBoard(); });
const gc = box.querySelector("#wkGoCustom");
if (gc) gc.addEventListener("click", () => setView("custom"));
box.querySelectorAll("[data-cpick]").forEach((el) =>
el.addEventListener("click", () => {
state.weeklySel = { kind: "custom", id: Number(el.dataset.cpick) };
renderWeeklyDetail();
})
);
}
async function renderWeeklyDetail() {
const box = $("#wkRight");
if (!box) return;
const sel = state.weeklySel;
if (sel && sel.kind === "focus") {
const f = (state.focusRows || []).find((x) => x.id === sel.id);
box.innerHTML = f ? focusDetailHtml(f) : `<div class="empty">在左侧选择一条重点工作查看详情</div>`;
const eb = $("#wkFocusEdit");
if (eb) eb.addEventListener("click", () => openFocusDrawer(sel.id, "edit"));
return;
}
if (sel && sel.kind === "customs") {
box.innerHTML = weeklyCustomOverview(state.weekly);
bindWeeklyCustomOverview(box);
return;
}
const row = sel && (sel.kind === "project" ? state.weekly.projects : state.weekly.custom)
.find((r) => r.id === sel.id);
if (!row) { box.innerHTML = `<div class="empty">在左侧选择一个项目查看执行详情</div>`; return; }
box.innerHTML = `<div class="empty">加载中…</div>`;
try {
box.innerHTML = row.kind === "project"
? await weeklyProjectDetail(row)
: await weeklyCustomDetail(row);
bindWeeklyDetail(box, row);
} catch (e) {
box.innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
}
}
async function renderBoard() {
saveUI();
const host = $("#view-board");
host.innerHTML = `<div class="empty">加载中…</div>`;
if (!state.customMeta) state.customMeta = await api("/custom/meta");
const extra = ["all_custom=" + (state.weeklyAllCustom ? "true" : "false")];
if (state.weeklyOwner) extra.push("owner=" + encodeURIComponent(state.weeklyOwner));
const [data, focusRows] = await Promise.all([
api("/weekly" + scopeQuery(extra)),
api("/focus" + focusQuery(["archived=false"])),
]);
state.weekly = data;
state.focusRows = focusRows;
const k = data.kpi;
const focus = focusBuckets(focusRows);
// 默认选中本期有记录的第一条,选中项还在列表里就保持不变
const all = data.projects.concat(data.custom);
const cur = state.weeklySel;
const keep = cur && (cur.kind === "customs" ? true
: cur.kind === "focus"
? focusRows.some((f) => f.id === cur.id)
: all.some((r) => r.kind === cur.kind && r.id === cur.id)) ? cur : null;
const pick = keep || all.find((r) => r.has_update) || all[0] || null;
state.weeklySel = pick ? { kind: pick.kind, id: pick.id } : null;
const owners = {};
all.forEach((r) => { if (r.owner) owners[r.owner] = true; });
const toolbar = `
<div class="board-toolbar">
<select id="wkOwner" class="sel" style="width:120px">
<option value="">全部负责人</option>
${Object.keys(owners).sort().map((n) => `<option value="${esc(n)}" ${n === state.weeklyOwner ? "selected" : ""}>${esc(n)}</option>`).join("")}
</select>
<span style="flex:1"></span>
<button class="btn sm" id="wkExportUpdates">导出本周期记录</button>
<span class="period-hint">${esc(scopeLabel())} · 项目 ${data.projects.length} 个 · 定制动态 ${data.kpi.custom_new + data.kpi.custom_updated} 个</span>
</div>`;
const kpi = `
<div class="grid kpi" style="margin-bottom:14px">
<div class="kpi accent-blue">${Ico.badge("star")}<div class="k-label">重点项目</div><div class="k-value">${k.key_total}</div><div class="k-foot">本期已更新 ${k.key_updated}</div></div>
<div class="kpi accent-red">${Ico.badge("alert-circle")}<div class="k-label">重点项目未更新</div><div class="k-value">${k.key_idle}</div><div class="k-foot">每周都要更新一次</div></div>
<div class="kpi accent-blue">${Ico.badge("list-checks")}<div class="k-label">本周重点工作</div><div class="k-value">${focus.week.length}</div><div class="k-foot">延期 ${focus.overdue.length} 条 · 已完成 ${focus.done.length} 条</div></div>
<div class="kpi accent-violet">${Ico.badge("activity")}<div class="k-label">执行记录</div><div class="k-value">${k.update_count}</div><div class="k-foot">在办项目 ${k.project_listed} 个</div></div>
<div class="kpi accent-green">${Ico.badge("wallet")}<div class="k-label">定制合同额</div><div class="k-value" style="font-size:22px">${wan(k.custom_amount)}</div><div class="k-foot">${k.custom_total} 个项目 · 本期计收 ${fen(k.revenue_delta)} 万</div></div>
<div class="kpi accent-amber">${Ico.badge("alert")}<div class="k-label">风险 / 阻塞</div><div class="k-value">${k.risk_count}</div><div class="k-foot">本期上报条目</div></div>
</div>`;
const group = (title, rows, tip) => `
<div class="wk-group">
<div class="wk-group-head">${title}<span class="cnt">${rows.length}</span>
<span class="wk-group-foot">${rows.filter((r) => r.has_update).length} ${tip}</span></div>
${rows.length ? rows.map(weeklyItem).join("") : `<div class="empty" style="padding:16px 6px;font-size:12px">本周期没有记录</div>`}
</div>`;
// 左栏顺序:本周工作重点 → 重点项目 → 定制项目(只给入口,点开在右栏看清单)
const focusList = focus.overdue.concat(focus.week);
const cNew = data.custom.filter((r) => r.is_new);
const cUpd = data.custom.filter((r) => r.has_update && !r.is_new);
const openCustom = cur && (cur.kind === "customs" || cur.kind === "custom");
host.innerHTML = kpi + toolbar + `
<div class="wk-split">
<div class="wk-left card">
<div class="wk-group">
<div class="wk-group-head">本周工作重点<span class="cnt">${focusList.length}</span>
<span class="wk-group-foot">${focus.overdue.length ? focus.overdue.length + " 条延期" : "已完成 " + focus.done.length + " 条"}</span></div>
${focusList.length ? focusList.map(focusItem).join("") : `<div class="empty" style="padding:16px 6px;font-size:12px">本周没有安排重点工作</div>`}
</div>
${group("重点项目", data.projects, "个已更新")}
<div class="wk-group">
<div class="wk-group-head">定制项目<span class="cnt">${data.custom.length}</span>
<span class="wk-group-foot">本周动态 ${cNew.length + cUpd.length} 个</span></div>
<div class="wk-item ${openCustom ? "active" : ""}" style="--c:#0d9488" data-wk="customs:0">
<div class="wk-i-top">
<span class="wk-i-ic">${Ico.svg("package")}</span>
<span class="wk-i-name">本周定制项目动态</span>
<span class="tag-mini">${Ico.svg("arrow-right")}</span>
</div>
<div class="wk-i-meta">
<span class="tag-mini">新增 ${cNew.length}</span>
<span class="tag-mini">有变化 ${cUpd.length}</span>
${state.weeklyAllCustom ? `<span class="tag-mini">含全部在途</span>` : ""}
</div>
</div>
</div>
</div>
<div class="wk-right card" id="wkRight"><div class="empty">加载中…</div></div>
</div>`;
const wo = $("#wkOwner");
if (wo) wo.addEventListener("change", () => { state.weeklyOwner = wo.value; renderBoard(); });
const wex = $("#wkExportUpdates");
if (wex) wex.addEventListener("click", () => download("/api/export/updates" + scopeQuery([])));
// 统计概览(原「执行面板」)折在最底下,展开才看;失败也不影响看板主体
dashboardHtml().then((html) => {
host.insertAdjacentHTML("beforeend",
`<details class="card ov"><summary><span class="ov-chev"><i data-icon="chevron-right"></i></span>统计概览<span class="sub">更新活跃度 / 分类分布 / 风险与阻塞</span></summary><div class="ov-body">${html}</div></details>`);
}).catch((e) => {
host.insertAdjacentHTML("beforeend", `<div class="notice err">统计概览加载失败:${esc(e.message)}</div>`);
});
host.querySelectorAll("[data-wk]").forEach((el) =>
el.addEventListener("click", () => {
const parts = el.dataset.wk.split(":");
state.weeklySel = { kind: parts[0], id: Number(parts[1]) };
host.querySelectorAll("[data-wk]").forEach((x) => x.classList.toggle("active", x === el));
renderWeeklyDetail();
})
);
renderWeeklyDetail();
}
/* ------------------------------------------------------------ 每周重点工作 */
/** 重点工作一律按「周」跟踪,基准周取顶栏当前选中的那一周 */
function focusWeek() {
return (state.periods.weeks || []).find((w) => w.id === state.weekId) || null;
}
function focusQuery(extra) {
const p = extra ? extra.slice() : [];
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
return p.length ? "?" + p.join("&") : "";
}
/** 分堆按「录入周」(f.sort_key 就是条目录入的那一周),和预计完成时间无关:
week = 本周录入的;overdue = 更早周次录入、标了延期又没做完的结转条目。
「已完成」是交叉视图,已完成条目仍留在自己那一周里,避免录完就看不见。
更早周次里没标延期的未完成条目不进当周视图,只能在「全部」里查得到。 */
function focusBuckets(rows) {
const w = focusWeek();
const cur = w ? w.sort_key : 0;
const out = { cur: cur, overdue: [], week: [], done: [] };
rows.forEach((f) => {
if (f.sort_key === cur) out.week.push(f);
else if (f.sort_key < cur && focusIsLate(f)) out.overdue.push(f);
if (f.status === "done") out.done.push(f);
});
return out;
}
/** 是否延期:录入时人工打开的开关,不再按日期自动判定 */
function focusIsLate(f) {
return f.status !== "done" && !!f.delayed;
}
/** 本地今天(YYYY-MM-DD),新增条目的预计完成时间默认值 */
function todayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** 预计完成时间:有日历日期就显示日期,早期没日期的条目退回显示录入周 */
function focusDueText(f) {
return f.due_date || f.period_full || "—";
}
/** 状态徽章:打开「是否延期」的未完成条目挂一个「延期」 */
function focusPill(f) {
return (focusIsLate(f) ? `<span class="pill" style="background:#fdecec;color:#dc2626">延期</span>` : "") +
statusPill(f.status);
}
/** 工作看板左栏的一条重点工作 */
function focusItem(f) {
const sel = state.weeklySel;
const late = focusIsLate(f);
return `
<div class="wk-item ${sel && sel.kind === "focus" && sel.id === f.id ? "active" : ""}"
style="--c:${late ? "#dc2626" : statusMeta(f.status).color}" data-wk="focus:${f.id}">
<div class="wk-i-top">
<span class="wk-i-ic">${Ico.status(f.status)}</span>
<span class="wk-i-name">${esc(f.content)}</span>
${late ? `<span class="tag-mini" style="background:#fdecec;color:#dc2626">延期</span>` : ""}
</div>
<div class="wk-i-meta">
${statusPill(f.status)}
<span class="tag-mini">预计 ${esc(focusDueText(f))}</span>
${late ? `<span class="tag-mini">录入 ${esc(f.period_label || "—")}</span>` : ""}
</div>
</div>`;
}
/** 工作看板右栏的一条重点工作(只读,改内容走抽屉) */
function focusDetailHtml(f) {
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
return `
<div class="wk-head" style="--c:${statusMeta(f.status).color}">
<div class="wk-head-title">${Ico.statusBadge(f.status)}${esc(f.content)}</div>
<div class="wk-i-meta">
${focusPill(f)}
<span class="tag-mini">预计完成 ${esc(focusDueText(f))}</span>
</div>
<div class="inline-fields" style="margin-top:10px">
<button class="btn sm primary" id="wkFocusEdit">修改</button>
</div>
</div>
<div class="sec">
<div class="sec-title">说明<span class="sec-kind ro">只读</span></div>
${f.detail ? nl2br(f.detail) : `<div class="empty">未填写说明</div>`}
</div>
<div class="sec">
<div class="sec-title">结果反馈<span class="sec-kind ro">只读</span></div>
${f.result ? nl2br(f.result) : `<div class="empty">未填写结果反馈</div>`}
</div>`;
}
async function renderFocus() {
saveUI();
const host = $("#view-focus");
host.innerHTML = `<div class="empty">加载中…</div>`;
const rows = await api("/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")]));
state.focusRows = rows;
const b = focusBuckets(rows);
const week = focusWeek();
const shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || [];
const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]];
const toolbar = `
<div class="board-toolbar">
${filters.map((x) => `<button class="chip ${state.focusFilter === x[0] ? "active" : ""}" data-ffilter="${x[0]}">${x[1]} ${b[x[0]] !== undefined ? b[x[0]].length : rows.length}</button>`).join("")}
<span style="flex:1"></span>
${keywordInputHtml("fKeyword", "搜索工作内容…", 200)}
<button class="btn sm" id="btnExportFocus">导出 Excel</button>
<button class="btn sm primary" id="btnAddFocus">${Ico.svg("plus")}新增重点工作</button>
<span class="period-hint">本周/延期按录入周统计 · 基准周 ${esc(week ? week.full_label : "—")}</span>
</div>`;
const table = `<table class="tbl">
<thead><tr>
<th style="min-width:220px">工作内容</th><th style="min-width:220px">说明</th>
<th>预计完成时间</th><th>状态</th><th style="min-width:150px">结果反馈</th><th>更新</th><th class="col-ops">操作</th>
</tr></thead>
<tbody>
${shown.map((f) => `
<tr ${f.archived ? 'style="opacity:.62"' : ""}>
<td><div style="display:flex;align-items:center;gap:6px">
<a href="javascript:;" data-fopen="${f.id}" style="color:var(--text);text-decoration:none">${esc(f.content)}</a>
${f.archived ? `<span class="arch-badge">已归档</span>` : ""}
</div></td>
<td style="color:var(--muted)">${esc((f.detail || "—").replace(/\s+/g, " ").slice(0, 48))}</td>
<td class="nowrap">${esc(focusDueText(f))}${f.period_label ? `<div class="period-hint">录入 ${esc(f.period_label)}</div>` : ""}</td>
<td class="nowrap">${focusPill(f)}</td>
<td style="color:var(--muted)">${esc((f.result || "—").replace(/\s+/g, " ").slice(0, 40))}</td>
<td class="nowrap">${esc((f.updated_at || "").slice(0, 10) || "—")}</td>
<td class="nowrap col-ops">
<button class="btn sm" data-fopen="${f.id}">详情</button>
<button class="btn sm" data-fedit="${f.id}">修改</button>
<button class="btn sm" data-farch="${f.id}" data-archived="${f.archived ? "1" : "0"}">${f.archived ? "恢复" : "归档"}</button>
<button class="btn sm danger" data-fdel="${f.id}">删除</button>
</td>
</tr>`).join("")}
</tbody></table>`;
const body = listCard({
title: "重点工作清单",
sub: "按录入时间倒序 · 共 " + rows.length + " 条",
summary: `当前 ${shown.length} 条 · 已完成 ${b.done.length} · 延期 ${b.overdue.length}`,
fields: `<label class="switch"><input type="checkbox" id="fcArchived" ${state.focusArchived ? "checked" : ""} /> 含归档条目</label>`,
rows: shown,
table: table,
empty: state.keyword
? "没有匹配「" + esc(state.keyword) + "」的重点工作"
: state.focusFilter === "week"
? "本周(" + esc(week ? week.full_label : "—") + ")还没有录入重点工作,点右上角「新增重点工作」添加"
: "这一堆还没有条目,点右上角「新增重点工作」添加",
});
host.innerHTML = toolbar + body;
bindKeyword("fKeyword");
host.querySelectorAll("[data-ffilter]").forEach((c) =>
c.addEventListener("click", () => { state.focusFilter = c.dataset.ffilter; renderFocus(); })
);
$("#fcArchived").addEventListener("change", (e) => { state.focusArchived = e.target.checked; renderFocus(); });
$("#btnExportFocus").addEventListener("click", () =>
download("/api/export/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")]))
);
host.querySelectorAll("[data-fopen]").forEach((el) =>
el.addEventListener("click", () => openFocusDrawer(Number(el.dataset.fopen), "view"))
);
host.querySelectorAll("[data-fedit]").forEach((el) =>
el.addEventListener("click", () => openFocusDrawer(Number(el.dataset.fedit), "edit"))
);
host.querySelectorAll("[data-farch]").forEach((el) =>
el.addEventListener("click", async () => {
const toArchive = el.dataset.archived !== "1";
try {
await patch("/focus/" + el.dataset.farch, { archived: toArchive });
} catch (e) { toast("操作失败:" + e.message); return; }
toast(toArchive ? "已归档(勾选「含归档条目」可查看)" : "已恢复");
await afterWrite();
})
);
host.querySelectorAll("[data-fdel]").forEach((el) =>
el.addEventListener("click", () => {
const row = rows.find((x) => x.id === Number(el.dataset.fdel));
if (row) deleteFocus(row);
})
);
$("#btnAddFocus").addEventListener("click", () => openFocusDrawer(null, "create"));
}
/** 删除重点工作条目:列表和抽屉共用。条目不是项目,危险确认即可,不要求逐字输入 */
async function deleteFocus(f) {
const ok = await confirmDialog({
title: "删除重点工作条目",
danger: true,
okText: "永久删除",
body: `<div class="notice err">「${esc(f.content)}」将被删除,<b>删除后无法恢复</b>。</div>
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是这一周不再跟进?请改用「归档」。</div>`,
});
if (!ok) return;
try {
await del("/focus/" + f.id);
} catch (e) { toast("删除失败:" + e.message); return; }
toast("已删除「" + f.content + "」");
closeDrawer();
await afterWrite();
}
/** 重点工作抽屉:新增走单独面板,详情只读、修改才出表单;基础信息的动作一律在面板顶部 */
async function openFocusDrawer(fid, mode) {
mode = mode === "create" ? "create" : mode === "edit" ? "edit" : "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
drawerActions("");
let f = null;
if (mode === "create") {
f = { content: "", detail: "", period_id: state.weekId, status: state.meta.statuses[0].code, archived: false };
} else {
try {
const rows = await api("/focus?archived=all");
f = rows.find((x) => x.id === fid) || null;
} catch (e) {
$("#drawerBody").innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
return;
}
if (!f) {
$("#drawerBody").innerHTML = `<div class="empty">条目不存在,可能已被删除</div>`;
return;
}
}
// 录入周 = 顶栏当前选中的那一周(新增时定下来,之后不随预计完成时间变)
const w = focusWeek();
const entryWeek = mode === "create" ? (w ? w.full_label : "顶栏当前周") : (f.period_full || f.period_label || "—");
$("#drawerTitle").innerHTML = mode === "create"
? `<i data-icon="list-checks"></i>新增每周重点工作`
: `<i data-icon="list-checks"></i>${esc(f.content)}`;
$("#drawerSub").innerHTML = mode === "create"
? `一条就是一个活儿,不用先建项目 · 录入周 ${esc(entryWeek)}`
: `${focusPill(f)} · 录入 ${esc(f.period_full || f.period_label || "—")} · 预计完成 ${esc(focusDueText(f))}`;
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
// 基础信息的动作在标题栏右侧按钮条:危险动作在左,主按钮在最右
drawerActions(mode === "view" ? `
<button class="btn sm danger" id="fdDelete">删除</button>
<button class="btn sm" id="fdArchive">${f.archived ? "恢复" : "归档"}</button>
<button class="btn sm primary" id="fdEdit">修改</button>` : `
<button class="btn sm" id="fdCancel">取消</button>
<button class="btn sm primary" id="fdSave">${mode === "create" ? "添加" : "保存"}</button>`);
const form = mode === "view" ? `
<div class="sec">
<div class="sec-title">基本信息<span class="sec-kind ro">只读</span></div>
${ro("工作内容", esc(f.content) + (f.archived ? ` <span class="arch-badge">已归档</span>` : ""))}
${ro("说明", f.detail ? nl2br(f.detail) : "—")}
${ro("预计完成时间", esc(focusDueText(f)))}
${ro("录入周", esc(f.period_full || f.period_label || "—"))}
${ro("执行状态", statusPill(f.status))}
${ro("是否延期", f.delayed ? `<span class="pill" style="background:#fdecec;color:#dc2626">延期</span>` : "未延期")}
${ro("结果反馈", f.result ? nl2br(f.result) : "—")}
${ro("最近更新", esc((f.updated_at || "").replace("T", " ") || "—"))}
</div>` : `
<div class="sec">
<div class="sec-title">基本信息<span class="sec-kind">${mode === "create" ? "新增" : "编辑"}</span></div>
<div class="form-row"><label>工作内容 *</label><input class="inp" id="fdContent" value="${esc(f.content)}" placeholder="要干的事,一句话" style="width:100%" /></div>
<div class="form-row"><label>说明</label><textarea class="inp" id="fdDetail" rows="4" placeholder="补充说明(可选)" style="width:100%">${esc(f.detail || "")}</textarea></div>
<div class="form-row"><label>录入周</label><div class="ro">${esc(entryWeek)}</div></div>
<div class="form-row"><label>预计完成时间 *</label><input class="inp" id="fdDue" type="date" value="${esc(f.due_date || todayIso())}" style="width:200px" /></div>
<div class="form-row"><label>执行状态</label><select class="sel" id="fdStatus" style="width:150px">
${state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === f.status ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
</select></div>
<div class="form-row"><label>结果反馈</label><textarea class="inp" id="fdResult" rows="3" placeholder="做出来的结果 / 交付结论(可选)" style="width:100%">${esc(f.result || "")}</textarea></div>
<div class="form-row"><label>是否延期</label><label class="switch"><input type="checkbox" id="fdDelayed" ${f.delayed ? "checked" : ""} /> 标记为延期</label></div>
<div class="form-row"><label></label><span class="sec-hint">条目归在「录入周」这一周,不随预计完成时间变;延期人工开关,更早周次没做完的标上延期就结转进当周视图</span></div>
</div>`;
$("#drawerBody").innerHTML =
(mode === "view" ? "" : `<div class="notice err" id="fdErr" style="display:none"></div>`) + form;
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
const after = () => afterWrite();
on("#fdEdit", () => openFocusDrawer(fid, "edit"));
on("#fdCancel", () => (mode === "create" ? closeDrawer() : openFocusDrawer(fid, "view")));
on("#fdSave", async () => {
const btn = $("#fdSave");
const err = $("#fdErr");
const content = $("#fdContent").value.trim();
if (!content) { err.textContent = "请填写工作内容"; err.style.display = ""; return; }
btn.disabled = true;
err.style.display = "none";
const due = $("#fdDue").value;
if (!due) { err.textContent = "请选择预计完成时间"; err.style.display = ""; return; }
const body = {
content: content,
detail: $("#fdDetail").value,
status: $("#fdStatus").value,
due_date: due,
result: $("#fdResult").value,
delayed: $("#fdDelayed").checked,
};
if (mode === "create") body.period_id = state.weekId; // 录入周 = 顶栏当前周
try {
if (mode === "create") fid = (await post("/focus", body)).id;
else await patch("/focus/" + fid, body);
} catch (e) {
btn.disabled = false;
err.textContent = "保存失败:" + e.message;
err.style.display = "";
return;
}
toast(mode === "create" ? "已添加「" + content + "」" : "已保存");
after();
openFocusDrawer(fid, "view");
});
on("#fdArchive", async () => {
try {
await patch("/focus/" + fid, { archived: !f.archived });
} catch (e) { toast("操作失败:" + e.message); return; }
toast(f.archived ? "已恢复" : "已归档");
closeDrawer();
after();
});
on("#fdDelete", () => deleteFocus(f));
}
/* ---------------------------------------------------------------- 项目管理 */
async function renderProjects() {
saveUI();
const host = $("#view-projects");
host.innerHTML = `<div class="empty">加载中…</div>`;
const extra = ["archived=" + (state.showArchived ? "all" : "false")];
if (state.categoryId) extra.push("category_id=" + state.categoryId);
if (state.keyOnly) extra.push("is_key=true");
if (state.projOwner) extra.push("owner=" + encodeURIComponent(state.projOwner));
const rows = await api("/projects" + scopeQuery(extra));
const shown = state.projIdleOnly ? rows.filter((r) => !r.updated_this_period) : rows;
const idle = rows.filter((r) => !r.updated_this_period).length;
const ownerFilter = `<select id="pOwner" class="sel" style="width:120px">
<option value="">全部负责人</option>
${state.staff.map((s) => `<option value="${esc(s.name)}" ${s.name === state.projOwner ? "selected" : ""}>${esc(s.name)}${s.archived ? "(归档)" : ""}</option>`).join("")}
</select>`;
const keyCount = rows.filter((r) => r.is_key).length;
const toolbar = `
<div class="board-toolbar">
<button class="chip ${state.projIdleOnly ? "active" : ""}" data-pidle="1">只看本${state.gran === "week" ? "周" : "月"}未更新</button>
<span style="flex:1"></span>
${keywordInputHtml("pKeyword", "搜索项目名称…", 200)}
${ownerFilter}
<button class="btn sm" id="btnExportProjects">导出 Excel</button>
<button class="btn sm primary" id="btnNewProject">${Ico.svg("plus")}新建项目</button>
<span class="period-hint">${esc(scopeLabel())} · ${rows.length} 个项目</span>
</div>`;
const table = `<table class="tbl">
<thead><tr>
<th style="min-width:200px">项目名称</th><th>分类</th><th>负责人</th><th>状态</th>
<th class="num">进度%</th><th>本${state.gran === "week" ? "周" : "月"}更新</th>
<th class="num">记录</th><th class="num">子任务</th><th>最近记录</th><th class="col-ops">操作</th>
</tr></thead>
<tbody>
${shown.map((p) => `
<tr data-pid="${p.id}" ${p.archived ? 'style="opacity:.62"' : ""}>
<td><div style="display:flex;align-items:center;gap:6px">
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
<a href="javascript:;" data-popen="${p.id}" style="color:var(--text);text-decoration:none">${esc(p.name)}</a>
${p.archived ? `<span class="arch-badge">已归档</span>` : ""}
</div></td>
<td>${catPill(p.category)}</td>
<td><div class="people-cell">${nameChips(p.owners)}</div></td>
<td>${statusPill(p.status)}</td>
<td class="num">${Math.round(p.progress)}%</td>
<td class="nowrap">${p.updated_this_period
? `<span class="pill" style="background:#e9f7ef;color:#16a34a">已更新</span>`
: `<span class="pill" style="background:#fdecec;color:#dc2626">${p.idle_weeks === null ? "从未更新" : p.idle_weeks + " 周未更新"}</span>`}</td>
<td class="num">${p.update_count}</td>
<td class="num">${p.task_count}</td>
<td>${esc((p.last_update && p.last_update.period_full) || "—")}</td>
<td class="nowrap col-ops">
<button class="btn sm" data-popen="${p.id}">详情</button>
<button class="btn sm" data-pedit="${p.id}">修改</button>
<button class="btn sm" data-parch="${p.id}" data-archived="${p.archived ? "1" : "0"}">${p.archived ? "恢复" : "归档"}</button>
</td>
</tr>`).join("")}
</tbody></table>`;
const body = listCard({
title: "重点项目明细",
sub: "重点项目排在前面 · 列表只读,改数据请点「修改」",
summary: `共 ${rows.length} 个 · 重点 ${keyCount} 个 · 本${state.gran === "week" ? "周" : "月"}未更新 ${idle} 个`,
fields: `<label class="switch"><input type="checkbox" id="chkArchived" ${state.showArchived ? "checked" : ""} /> 含归档项目</label>`,
rows: shown,
table: table,
empty: state.keyword
? "没有匹配「" + esc(state.keyword) + "」的项目"
: "还没有项目",
});
host.innerHTML = toolbar + body;
bindKeyword("pKeyword");
host.querySelectorAll("[data-pidle]").forEach((b) =>
b.addEventListener("click", () => { state.projIdleOnly = !state.projIdleOnly; renderProjects(); })
);
const po = $("#pOwner");
if (po) po.addEventListener("change", () => { state.projOwner = po.value; renderProjects(); });
host.querySelectorAll("[data-popen]").forEach((a) =>
a.addEventListener("click", () => openDrawer(Number(a.dataset.popen), "view"))
);
host.querySelectorAll("[data-pedit]").forEach((b) =>
b.addEventListener("click", () => openDrawer(Number(b.dataset.pedit), "edit"))
);
host.querySelectorAll("[data-parch]").forEach((b) =>
b.addEventListener("click", async () => {
const toArchive = b.dataset.archived !== "1";
await patch("/projects/" + b.dataset.parch, { archived: toArchive });
toast(toArchive ? "已归档(勾选「含归档项目」可查看)" : "已恢复");
await afterWrite();
})
);
$("#chkArchived").addEventListener("change", (e) => { state.showArchived = e.target.checked; renderProjects(); });
$("#btnExportProjects").addEventListener("click", () => {
const q = ["archived=" + (state.showArchived ? "all" : "false")];
if (state.categoryId) q.push("category_id=" + state.categoryId);
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
download("/api/export/projects?" + q.join("&"));
});
$("#btnNewProject").addEventListener("click", () => {
openModal(
`<div class="modal-head"><h3>新建项目</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
<div class="modal-body">
<div class="form-grid">
<div class="form-row full"><label>项目名称 *</label><input class="inp" id="npName" data-autofocus style="width:100%" /></div>
<div class="form-row"><label>分类</label><select class="sel" id="npCat" style="width:100%">
<option value="">(未分类)</option>
${state.categories.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join("")}
</select></div>
<div class="form-row"><label>负责人</label>${ownerSelectHtml("", 'id="npOwner" style="width:100%"')}</div>
<div class="form-row full"><label>重点项目</label><label class="switch"><input type="checkbox" id="npKey" /> 标记为重点(★,进入每周周看板)</label></div>
</div>
<div class="notice err" id="mErr" style="display:none"></div>
</div>
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">创建</button></div>`
);
$("#mOk").addEventListener("click", async () => {
const name = $("#npName").value.trim();
if (!name) return modalError("请填写项目名称");
const btn = $("#mOk");
btn.disabled = true;
try {
await post("/projects", {
name: name,
category_id: $("#npCat").value ? Number($("#npCat").value) : null,
owner: $("#npOwner").value || null,
is_key: $("#npKey").checked,
});
closeModal();
toast("已创建「" + name + "」");
await afterWrite();
} catch (e) { btn.disabled = false; modalError(e.message); }
});
$("#npName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); });
});
}
/* ---------------------------------------------------------------- 定制交付 */
function stageMeta(code) {
const list = (state.customMeta && state.customMeta.stages) || [];
return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" };
}
function stagePill(code) {
const s = stageMeta(code);
return `<span class="pill" style="background:${s.color}1a;color:${s.color}">${Ico.stage(code)}${esc(s.name)}</span>`;
}
/** 定制交付视图的查询条件:周期 + 项目名称检索 + 区域(+ 归档态) */
function customQuery(extra) {
const p = [];
if (state.gran === "week") {
if (state.weekId) p.push("period_id=" + state.weekId);
} else {
const m = currentMonth();
if (m) p.push("year=" + m.year, "month=" + m.month);
}
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
if (state.customRegion) p.push("region=" + encodeURIComponent(state.customRegion));
(extra || []).forEach((x) => p.push(x));
return "?" + p.join("&");
}
let keywordFocus = false;
async function renderCustom() {
saveUI();
const host = $("#view-custom");
host.innerHTML = `<div class="empty">加载中…</div>`;
if (!state.customMeta) state.customMeta = await api("/custom/meta");
const [board, stats] = await Promise.all([
api("/custom/board" + customQuery()),
api("/custom/stats" + customQuery(["archived=" + state.customArchived])),
]);
state.customBoard = board;
state.customStats = stats;
const k = stats.kpi;
// 项目明细 / 交付流水线 / 财务跟踪 都基于 /custom/projects:
// 支持归档态筛选,并按入库时间倒序
const needRows = ["list", "pipeline", "finance"].indexOf(state.customTab) >= 0;
state.customRows = needRows
? await api("/custom/projects" + customQuery(["archived=" + state.customArchived]))
: [];
// 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域
const usedSet = {};
(state.customMeta.regions || []).forEach((r) => { usedSet[r] = true; });
const stdAll = (state.customMeta.region_groups || []).reduce((a, g) => a.concat(g.regions), []);
let regionFilterHtml = `<option value="">全部区域</option>`;
(state.customMeta.region_groups || []).forEach((g) => {
const hit = g.regions.filter((r) => usedSet[r]);
if (!hit.length) return;
regionFilterHtml += `<optgroup label="${esc(g.group)}">` +
hit.map((r) => `<option value="${esc(r)}" ${r === state.customRegion ? "selected" : ""}>${esc(r)}</option>`).join("") +
`</optgroup>`;
});
const nonStd = (state.customMeta.regions || []).filter((r) => stdAll.indexOf(r) < 0);
if (nonStd.length) {
regionFilterHtml += `<optgroup label="其他">` +
nonStd.map((r) => `<option value="${esc(r)}" ${r === state.customRegion ? "selected" : ""}>${esc(r)}</option>`).join("") +
`</optgroup>`;
}
const tabs = `
<div class="board-toolbar">
<button class="chip ${state.customTab === "pipeline" ? "active" : ""}" data-ctab="pipeline">交付流水线</button>
<button class="chip ${state.customTab === "board" ? "active" : ""}" data-ctab="board">阶段看板</button>
<button class="chip ${state.customTab === "finance" ? "active" : ""}" data-ctab="finance">财务跟踪</button>
<button class="chip ${state.customTab === "list" ? "active" : ""}" data-ctab="list">项目明细</button>
<span style="flex:1"></span>
${keywordInputHtml("cKeyword", "搜索项目名称…", 200)}
<select id="cRegion" class="sel" style="width:132px">${regionFilterHtml}</select>
<button class="btn sm" id="btnExportCustom">导出 Excel</button>
<button class="btn sm primary" id="btnNewCustom">${Ico.svg("plus")}新建定制项目</button>
<span class="period-hint">${esc(scopeLabel())} · ${k.project_count} 个项目</span>
</div>`;
let body = "";
if (state.customTab === "pipeline") body = renderCustomPipeline(state.customRows, k);
else if (state.customTab === "board") body = renderCustomBoard(board);
else if (state.customTab === "finance") body = renderCustomFinance(stats, state.customRows);
else body = renderCustomList(state.customRows);
host.innerHTML = tabs + body;
host.querySelectorAll("[data-ctab]").forEach((b) =>
b.addEventListener("click", () => { state.customTab = b.dataset.ctab; renderCustom(); })
);
const rs = $("#cRegion");
if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); });
bindKeyword("cKeyword");
const bn = $("#btnNewCustom");
if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite()));
const bex = $("#btnExportCustom");
if (bex) bex.addEventListener("click", () => {
const q = ["archived=" + state.customArchived];
if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion));
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
download("/api/export/custom" + (q.length ? "?" + q.join("&") : ""));
});
// 卡片只是入口:重点标记等基础信息一律进详情面板改,外层不再直接写数据
host.querySelectorAll("[data-cpid]").forEach((c) =>
c.addEventListener("click", () => openCustomDrawer(Number(c.dataset.cpid)))
);
bindCustomList(host);
}
function renderCustomBoard(board) {
const cols = board.columns.filter((c) => c.count > 0 || c.code !== "closed");
return `<div class="kanban">
${cols.map((c) => `
<div class="kcol">
<div class="kcol-head">
<div class="kcol-title" style="--c:${c.color}">${Ico.stage(c.code)}${esc(c.name)}</div>
<div class="kcol-meta">${c.count} 个 · ${fen(c.amount)} 万${c.updated_count ? ` · <span style="color:#2563eb">本期 ${c.updated_count}</span>` : ""}</div>
</div>
<div class="kcol-body">
${c.projects.length ? c.projects.map((p) => `
<div class="kcard" data-cpid="${p.id}">
<div class="kcard-top">
<div class="kcard-name">${esc(p.name)}</div>
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
</div>
<div class="kcard-meta">
<span class="tag-mini">${esc(p.region || "未标注")}</span>
<b style="font-size:12px">${p.amount ? fen(p.amount) + " 万" : "金额待定"}</b>
</div>
${p.stage === "developing" ? `<div class="progress" style="margin-top:8px"><i style="width:${p.progress}%;background:${c.color}"></i></div>
<div class="progress-label"><span>开发进度</span><span>${p.progress}%</span></div>` : ""}
<div class="kcard-foot">
<span>可确认 <b>${fen(p.confirmed_amount)} 万</b></span>
<span style="color:${p.revenue_amount ? "#16a34a" : "#7a8394"}">已计收 ${fen(p.revenue_amount || 0)} 万</span>
</div>
${p.remark ? `<div class="kcard-remark">${esc(p.remark.slice(0, 60))}</div>` : ""}
</div>`).join("") : `<div class="empty" style="padding:18px 6px;font-size:12px">—</div>`}
</div>
</div>`).join("")}
</div>`;
}
/* 商务阶段 -> 已走完的环节下标。导入的老数据只有阶段没有环节日期,用它兜底 */
const STAGE_STEP = { lead: 0, signed: 1, developing: 1, delivered: 2, accepted: 3, revenue: 4 };
/** 按已填的环节日期取最后一环(与后端 custom_current_step 口径一致) */
function customDateIndex(p) {
const steps = (state.customMeta && state.customMeta.pipeline) || [];
let step = -1;
steps.forEach((s, i) => { if ((p[s.date_field] || "").trim()) step = i; });
return step;
}
/** 项目当前走到哪一环:环节日期与商务阶段取更靠后的那个 */
function customStepIndex(p) {
const byStage = STAGE_STEP[p.stage];
return Math.max(byStage === undefined ? -1 : byStage, customDateIndex(p));
}
function daysSince(iso) {
if (!iso) return null;
const t = Date.parse(iso + "T00:00:00");
return isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000);
}
/** 交付流水线:入库 → 签单 → 交付 → 验收 → 计收 的漏斗与卡点 */
function renderCustomPipeline(rows, kpi) {
const steps = (state.customMeta && state.customMeta.pipeline) || [];
const agingDays = (state.customMeta && state.customMeta.aging_days) || 45;
const total = rows.length;
const funnel = steps.map((s, i) => {
const hit = rows.filter((p) => customStepIndex(p) >= i);
return {
key: s.code, name: s.name, color: s.color,
count: hit.length,
amount: hit.reduce((a, p) => a + (p.amount || 0), 0),
};
});
const maxCount = Math.max(1, ...funnel.map((f) => f.count));
// 卡住的项目:还没走到计收,且最后一个有日期的环节停留超过 agingDays 天
const stuck = [];
rows.forEach((p) => {
const i = customStepIndex(p);
if (i < 0 || i >= steps.length - 1) return;
const di = customDateIndex(p);
if (di < 0) return;
const days = daysSince(p[steps[di].date_field]);
if (days === null || days < agingDays) return;
stuck.push({ p: p, step: steps[i], from: p[steps[di].date_field], days: days });
});
stuck.sort((a, b) => b.days - a.days);
const noDate = rows.filter((p) => customDateIndex(p) < 0).length;
return `
<div class="card" style="margin-bottom:16px">
<div class="card-head"><h4>交付漏斗<span class="sub">在库 ${total} 个 · 重点 ${kpi.key_count} 个 · 本期更新 ${kpi.period_updated} 个</span></h4>
<span class="period-hint">金额与项目数按环节累计 · 商务阶段与已录环节日期取先到哪一步</span></div>
<div class="card-body">
<div class="funnel">
${funnel.map((f) => `
<div class="fun-step" style="--c:${f.color}">
<div class="fun-name">${Ico.pipe(f.key)}${esc(f.name)}</div>
<div class="fun-count">${f.count}</div>
<div class="fun-bar"><i style="width:${Math.round((f.count / maxCount) * 100)}%;background:${f.color}"></i></div>
<div class="fun-amt">${fen(f.amount)} 万</div>
</div>`).join("")}
</div>
${noDate ? `<div class="notice info" style="margin-top:14px">还有 <b>${noDate}</b> 个项目一个环节日期都没有,去「项目明细」补上入库时间后漏斗才准。</div>` : ""}
</div>
</div>
<div class="card">
<div class="card-head"><h4>卡点提醒<span class="sub">最后一个有日期的环节停留超过 ${agingDays} 天</span></h4>
<span class="period-hint">${stuck.length} 个项目需要推进</span></div>
${stuck.length ? `<div class="tbl-wrap"><table class="tbl">
<thead><tr><th style="min-width:200px">项目名称</th><th>卡在</th><th>环节日期</th><th class="num">停留天数</th><th class="num">合同额</th><th>负责人</th><th class="col-ops">操作</th></tr></thead>
<tbody>
${stuck.slice(0, 20).map((x) => `
<tr>
<td><a href="javascript:;" data-copen="${x.p.id}" style="color:var(--text);text-decoration:none">${esc(x.p.name)}</a></td>
<td><span class="pill" style="background:${x.step.color}1a;color:${x.step.color}">${esc(x.step.name)}</span></td>
<td>${esc(x.p[x.step.date_field] || x.from || "—")}</td>
<td class="num"><b style="color:#dc2626">${x.days}</b> 天</td>
<td class="num">${fen(x.p.amount)} 万</td>
<td>${esc(x.p.owner || "未指派")}</td>
<td class="col-ops"><button class="btn sm" data-cedit="${x.p.id}">处理</button></td>
</tr>`).join("")}
</tbody></table></div>` : `<div class="empty">没有停滞超期的项目</div>`}
</div>`;
}
/** 财务跟踪:按负责人 / 区域汇总,并列出待回款排行 */
function renderCustomFinance(stats, rows) {
const byOwner = {};
rows.forEach((p) => {
const key = p.owner || "(未指派)";
const o = byOwner[key] || (byOwner[key] = { name: key, count: 0, amount: 0, confirmed: 0, revenue: 0, invoice: 0, received: 0 });
o.count += 1;
o.amount += p.amount || 0;
o.confirmed += p.confirmed_amount || 0;
o.revenue += p.revenue_amount || 0;
o.invoice += p.invoice_amount || 0;
o.received += p.received_amount || 0;
});
const owners = Object.keys(byOwner).map((k) => byOwner[k]).sort((a, b) => b.amount - a.amount);
const unpaid = rows.filter((p) => (p.amount || 0) > 0)
.map((p) => ({ p: p, pend: (p.amount || 0) - (p.received_amount || 0) }))
.sort((a, b) => b.pend - a.pend);
const num = (n) => fen(n);
const maxAmt = Math.max(1, ...stats.by_stage.map((x) => x.amount || 0));
const k = stats.kpi;
return `
<div class="grid kpi" style="margin-bottom:16px">
<div class="kpi accent-blue">${Ico.badge("wallet")}<div class="k-label">合同总额</div><div class="k-value" style="font-size:22px">${wan(k.amount_total)}</div><div class="k-foot">${k.project_count} 个项目 · 重点 ${k.key_count} 个</div></div>
<div class="kpi accent-violet">${Ico.badge("receipt")}<div class="k-label">按进度可确认收入</div><div class="k-value" style="font-size:22px">${wan(k.confirmed_total)}</div><div class="k-foot">确认比例 ${k.progress_rate}%</div></div>
<div class="kpi accent-green">${Ico.badge("wallet")}<div class="k-label">已计收</div><div class="k-value" style="font-size:22px">${wan(k.revenue_total)}</div><div class="k-foot">计收率 ${k.revenue_rate}%</div></div>
<div class="kpi accent-red">${Ico.badge("hourglass")}<div class="k-label">待计收</div><div class="k-value" style="font-size:22px">${wan(k.pending_total)}</div><div class="k-foot">可确认 − 已计收</div></div>
<div class="kpi accent-blue">${Ico.badge("receipt")}<div class="k-label">已开票</div><div class="k-value" style="font-size:22px">${wan(k.invoice_total)}</div><div class="k-foot">开票率 ${k.amount_total ? ((k.invoice_total / k.amount_total) * 100).toFixed(1) : 0}%</div></div>
<div class="kpi accent-green">${Ico.badge("trending-up")}<div class="k-label">已回款</div><div class="k-value" style="font-size:22px">${wan(k.received_total)}</div><div class="k-foot">回款率 ${k.received_rate}%</div></div>
<div class="kpi accent-amber">${Ico.badge("receipt")}<div class="k-label">待开票</div><div class="k-value" style="font-size:22px">${wan(k.pending_invoice)}</div><div class="k-foot">合同额 − 已开票</div></div>
<div class="kpi accent-red">${Ico.badge("hourglass")}<div class="k-label">待回款</div><div class="k-value" style="font-size:22px">${wan(k.pending_received)}</div><div class="k-foot">合同额 − 已回款</div></div>
</div>
<div class="grid g2" style="margin-bottom:16px">
<div class="card">
<div class="card-head"><h4>阶段金额分布<span class="sub">合同额与按进度可确认收入</span></h4></div>
<div class="card-body">
${stats.by_stage.map((x) => `
<div class="bar-row">
<div class="b-name"><i class="dot" style="background:${x.color};display:inline-block;margin-right:6px"></i>${esc(x.name)} <span style="color:var(--muted)">${x.count} 个</span></div>
<div class="b-track"><i style="width:${Math.round(((x.amount || 0) / maxAmt) * 100)}%;background:${x.color}"></i></div>
<div class="b-val">${num(x.amount)}</div>
</div>`).join("")}
</div>
</div>
<div class="card">
<div class="card-head"><h4>周期记录活跃度<span class="sub">各周期有记录的项目数</span></h4></div>
<div class="card-body">
${barChart(stats.trend.map((t) => ({
label: `${t.month}/${t.week || ""}`,
value: t.update_count,
color: scopePeriodIds().indexOf(t.period_id) >= 0 ? "#7c3aed" : "#c4b5fd",
dim: scopePeriodIds().indexOf(t.period_id) < 0,
})), { unit: " 条", height: 170 })}
</div>
</div>
</div>
<div class="grid g2" style="margin-bottom:16px">
<div class="card">
<div class="card-head"><h4>按负责人<span class="sub">合同额 / 计收 / 开票 / 回款</span></h4></div>
<div class="card-body" style="max-height:340px;overflow:auto">
<table class="tbl">
<thead><tr><th>负责人</th><th class="num">项目</th><th class="num">合同额</th><th class="num">可确认</th><th class="num">已计收</th><th class="num">已开票</th><th class="num">已回款</th></tr></thead>
<tbody>
${owners.map((o) => `<tr>
<td>${esc(o.name)}</td><td class="num">${o.count}</td>
<td class="num">${num(o.amount)}</td><td class="num">${num(o.confirmed)}</td>
<td class="num">${num(o.revenue)}</td><td class="num">${num(o.invoice)}</td>
<td class="num"><b>${num(o.received)}</b></td></tr>`).join("")}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-head"><h4>按区域<span class="sub">合同额与可确认收入</span></h4></div>
<div class="card-body" style="max-height:340px;overflow:auto">
<table class="tbl">
<thead><tr><th>区域</th><th class="num">项目</th><th class="num">合同额</th><th class="num">可确认</th><th class="num">已计收</th></tr></thead>
<tbody>
${stats.by_region.slice(0, 14).map((r) => `<tr>
<td>${esc(r.name)}</td><td class="num">${r.count}</td>
<td class="num">${num(r.amount)}</td><td class="num">${num(r.confirmed)}</td>
<td class="num">${num(r.revenue)}</td></tr>`).join("")}
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-head"><h4>待回款排行<span class="sub">合同额 − 已回款 · 共 ${unpaid.length} 个有合同额的项目</span></h4></div>
${unpaid.length ? `<div class="tbl-wrap"><table class="tbl">
<thead><tr><th style="min-width:200px">项目名称</th><th>区域</th><th>负责人</th><th>验收</th>
<th class="num">合同额</th><th class="num">开票</th><th class="num">回款</th><th class="num">待回款</th><th class="col-ops">操作</th></tr></thead>
<tbody>
${unpaid.slice(0, 20).map((x) => `
<tr>
<td><a href="javascript:;" data-copen="${x.p.id}" style="color:var(--text);text-decoration:none">${esc(x.p.name)}</a></td>
<td>${esc(x.p.region || "—")}</td>
<td>${esc(x.p.owner || "未指派")}</td>
<td>${esc(x.p.accept_date || "未验收")}</td>
<td class="num">${num(x.p.amount)}</td>
<td class="num">${num(x.p.invoice_amount)}</td>
<td class="num" style="color:${x.p.received_amount ? "#16a34a" : "#7a8394"}">${num(x.p.received_amount)}</td>
<td class="num"><b style="color:#b45309">${num(x.pend)}</b></td>
<td class="col-ops"><button class="btn sm" data-cedit="${x.p.id}">填回款</button></td>
</tr>`).join("")}
</tbody></table></div>` : `<div class="empty">暂无合同额数据</div>`}
</div>`;
}
function renderCustomList(rows) {
const archOpts = [
["false", "在库"],
["true", "已归档"],
["all", "全部"],
]
.map((x) => `<option value="${x[0]}" ${x[0] === state.customArchived ? "selected" : ""}>${x[1]}</option>`)
.join("");
const sum = (f) => fen(rows.reduce((a, p) => a + Number(p[f] || 0), 0));
return `<div class="card">
<div class="card-head">
<h4>定制项目明细<span class="sub">按入库时间倒序 · 共 ${rows.length} 个 · 列表只读,改数据请点「修改」</span></h4>
<span class="period-hint">合同额 ${sum("amount")} 万 · 可确认 ${sum("confirmed_amount")} 万 · 已计收 ${sum("revenue_amount")} 万 · 开票 ${sum("invoice_amount")} 万 · 回款 ${sum("received_amount")} 万</span>
<div class="inline-fields">
<select id="cArchived" class="sel" style="width:104px" title="归档状态">${archOpts}</select>
</div>
</div>
${rows.length ? `<div class="tbl-wrap">
<table class="tbl">
<thead><tr>
<th style="min-width:200px">项目名称</th><th>区域</th><th>负责人</th>
<th>入库</th><th>签单</th><th>交付</th><th>验收</th>
<th>阶段</th><th class="num">合同额/万</th><th class="num">进度%</th>
<th class="num">可确认/万</th><th class="num">已计收/万</th>
<th class="num">开票/万</th><th class="num">回款/万</th>
<th>备注</th><th class="col-ops">操作</th>
</tr></thead>
<tbody>
${rows.map((p) => `
<tr data-cid="${p.id}" ${p.archived ? 'style="opacity:.62"' : ""}>
<td>
<div style="display:flex;align-items:center;gap:6px">
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
<a href="javascript:;" data-copen="${p.id}" style="color:var(--text);text-decoration:none">${esc(p.name)}</a>
${p.archived ? `<span class="arch-badge">已归档</span>` : ""}
</div>
</td>
<td class="nowrap">${esc(p.region || "—")}</td>
<td class="nowrap">${esc(p.owner || "—")}</td>
${["entry_date", "sign_date", "deliver_date", "accept_date"].map((f) => `<td class="nowrap">${p[f] ? esc(p[f]) : "—"}</td>`).join("")}
<td>${stagePill(p.stage)}</td>
<td class="num">${fen(p.amount)}</td>
<td class="num">${Math.round(p.progress)}%</td>
<td class="num"><b>${fen(p.confirmed_amount)}</b></td>
<td class="num">${fen(p.revenue_amount)}</td>
<td class="num">${fen(p.invoice_amount)}</td>
<td class="num">${fen(p.received_amount)}</td>
<td>${esc((p.remark || "").slice(0, 40)) || "—"}</td>
<td class="nowrap col-ops">
<button class="btn sm" data-copen="${p.id}">详情</button>
<button class="btn sm" data-cedit="${p.id}">修改</button>
<button class="btn sm" data-carch="${p.id}" data-archived="${p.archived ? "1" : "0"}">${p.archived ? "恢复" : "归档"}</button>
</td>
</tr>`).join("")}
</tbody>
</table>
</div>` : `<div class="empty">${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定制项目" : "当前筛选条件下没有定制项目"}</div>`}
</div>`;
}
/** 项目明细表:列表只读,只有详情 / 修改 / 归档三个动作 */
function bindCustomList(host) {
const ca = $("#cArchived");
if (ca) ca.addEventListener("change", () => { state.customArchived = ca.value; renderCustom(); });
host.querySelectorAll("[data-copen]").forEach((a) =>
a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view"))
);
host.querySelectorAll("[data-cedit]").forEach((b) =>
b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit"))
);
host.querySelectorAll("[data-carch]").forEach((b) =>
b.addEventListener("click", () => {
const p = (state.customRows || []).find((x) => x.id === Number(b.dataset.carch));
if (p) archiveCustomProject(p, () => afterWrite());
})
);
}
/**
* 新建定制项目 —— 全站唯一的新建入口。
* 「定制交付」工具栏和「更新录入」页都调用它,避免两套录入逻辑。
*/
function openNewCustomDialog(onDone) {
const today = new Date();
const iso = today.getFullYear() + "-" +
String(today.getMonth() + 1).padStart(2, "0") + "-" +
String(today.getDate()).padStart(2, "0");
const stageOpts = ((state.customMeta && state.customMeta.stages) || [])
.map((s) => `<option value="${s.code}">${esc(s.name)}</option>`)
.join("");
openModal(
`<div class="modal-head"><h3>新建定制项目</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
<div class="modal-body">
<div class="notice info">只有<b>项目名称</b>必填,其余可先留空,建档后在列表里直接改。新项目会自动按入库时间排在最前面。</div>
<div class="form-grid">
<div class="form-row full"><label>项目名称 *</label><input class="inp" id="ncName" data-autofocus placeholder="如:重庆高新三期" style="width:100%" /></div>
<div class="form-row"><label>区域</label><select class="sel" id="ncRegion" style="width:100%">${regionOptions("")}</select></div>
<div class="form-row"><label>客户 / 办事处</label><input class="inp" id="ncCustomer" style="width:100%" /></div>
<div class="form-row"><label>合同额(元)</label><input class="inp" id="ncAmount" type="number" min="0" style="width:100%" /></div>
<div class="form-row"><label>当前阶段</label><select class="sel" id="ncStage" style="width:100%">${stageOpts}</select></div>
<div class="form-row"><label>负责人</label>${ownerSelectHtml("", 'id="ncOwner" style="width:100%"')}</div>
<div class="form-row"><label>入库时间</label><input class="inp" id="ncEntry" type="date" value="${iso}" style="width:100%" /></div>
<div class="form-row full"><label>重点项目</label><label class="switch"><input type="checkbox" id="ncKey" /> 标记为重点(★,看板置顶)</label></div>
<div class="form-row full"><label>备注</label><textarea class="inp" id="ncRemark" rows="2" style="width:100%"></textarea></div>
</div>
<div class="notice err" id="mErr" style="display:none"></div>
</div>
<div class="modal-foot">
<button class="btn" data-mclose>取消</button>
<button class="btn primary" id="mOk">创建</button>
</div>`
);
const submit = async () => {
const name = $("#ncName").value.trim();
if (!name) return modalError("请填写项目名称");
const btn = $("#mOk");
btn.disabled = true;
try {
const p = await post("/custom/projects", {
name: name,
region: $("#ncRegion").value || null,
customer: $("#ncCustomer").value.trim() || null,
amount: $("#ncAmount").value === "" ? null : Number($("#ncAmount").value),
stage: $("#ncStage").value,
owner: $("#ncOwner").value || null,
entry_date: $("#ncEntry").value || null,
is_key: $("#ncKey").checked,
remark: $("#ncRemark").value.trim() || null,
});
closeModal();
toast("已创建「" + p.name + "」");
if (onDone) onDone(p); else refresh();
} catch (e) {
btn.disabled = false;
modalError(e.message);
}
};
$("#mOk").addEventListener("click", submit);
$("#ncName").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
}
/** 归档 / 恢复:归档后不进看板与统计,随时可恢复 */
async function archiveCustomProject(p, onDone) {
const toArchive = !p.archived;
const ok = await confirmDialog({
title: toArchive ? "归档定制项目" : "恢复定制项目",
okText: toArchive ? "确认归档" : "确认恢复",
body:
`<p style="margin:0 0 10px"><b>${esc(p.name)}</b></p>` +
`<div class="notice ${toArchive ? "warn" : "info"}">${
toArchive
? "归档后该项目不再出现在阶段看板、收入统计和默认列表中,历史周期记录完整保留。"
: "恢复后重新计入看板与统计。"
}</div>`,
});
if (!ok) return;
try {
await patch("/custom/projects/" + p.id, { archived: toArchive });
toast(toArchive ? "已归档" : "已恢复");
if (onDone) onDone(); else refresh();
} catch (e) {
toast("操作失败:" + e.message);
}
}
/** 删除:连带周期记录一起清除,需逐字输入项目名确认 */
async function deleteCustomProject(p, onDone) {
const ok = await confirmDialog({
title: "删除定制项目",
danger: true,
okText: "永久删除",
requireText: p.name,
body:
`<div class="notice err">将连同该项目 <b>${p.update_count || 0}</b> 条周期记录一并删除,<b>删除后无法恢复</b>。</div>
<ul class="bullet-list">
<li>阶段:${esc(stageMeta(p.stage).name)} 合同额:${money(p.amount)}</li>
<li>已计收:${money(p.revenue_amount)} 入库时间:${esc(p.entry_date || "—")}</li>
</ul>
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。</div>`,
});
if (!ok) return;
try {
const r = await del("/custom/projects/" + p.id);
toast("已删除「" + r.name + "」及其 " + r.deleted_updates + " 条周期记录");
if (onDone) onDone(); else refresh();
} catch (e) {
toast("删除失败:" + e.message);
}
}
/** cid 定制项目抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
async function openCustomDrawer(cid, mode) {
mode = mode === "edit" ? "edit" : "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
drawerActions("");
const d = await api("/custom/projects/" + cid);
state.drawerCustom = d;
const p = d.project;
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
$("#drawerSub").innerHTML = `${stagePill(p.stage)} · ${esc(p.region || "未标注区域")} · 合同额 ${money(p.amount)}`;
const stageOpts = state.customMeta.stages
.map((s) => `<option value="${s.code}" ${s.code === p.stage ? "selected" : ""}>${esc(s.name)}</option>`)
.join("");
const timeline = d.timeline.slice().reverse().map((t) => `
<div class="timeline-row">
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
<div style="flex:1">${stagePill(t.stage)}${t.progress !== null ? ` <span class="tag-mini">${t.progress}%</span>` : ""}
${t.revenue_delta ? ` <span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(t.revenue_delta)}万</span>` : ""}</div>
</div>
<div style="font-size:12.5px;color:var(--text-2);line-height:1.7;padding:0 0 8px 120px;white-space:pre-wrap">${nl2br(t.content || "")}</div>`).join("");
// 「录一笔交付记录」落到顶栏当前选中的那一周,与更新录入页的周期下拉一致
const recWeek = (state.periods.weeks || []).find((x) => x.id === state.weekId);
const recLabel = recWeek ? recWeek.full_label : "本周";
const ratioLabel = p.manual_ratio !== null && p.manual_ratio !== undefined
? `手动 ${(p.manual_ratio * 100).toFixed(0)}%`
: `按阶段 ${p.revenue_ratio}%`;
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
// 基础信息:详情只读、修改才给表单,操作条只出现在自己那一档里
const baseSec = mode === "edit" ? `
<div class="sec">
<div class="sec-title">基础信息<span class="sec-kind">编辑</span></div>
<div class="sec-hint" style="margin:0 0 10px">只改基础信息与财务数字,不会动下面的交付记录</div>
<div class="form-row"><label>项目名称</label><input class="inp" id="cdName" value="${esc(p.name)}" style="width:100%" /></div>
<div class="form-row"><label>阶段</label><select class="sel" id="cdStage" style="width:150px">${stageOpts}</select></div>
<div class="form-row"><label>合同额</label><input class="inp" id="cdAmount" type="number" value="${p.amount === null ? "" : p.amount}" style="width:150px" /></div>
<div class="form-row"><label>开发进度</label><div style="display:flex;align-items:center;gap:6px"><input class="inp" id="cdProgress" type="number" min="0" max="100" value="${Math.round(p.progress)}" style="width:90px" /> %</div></div>
<div class="form-row"><label>已计收</label><input class="inp" id="cdRevenue" type="number" value="${p.revenue_amount === null ? "" : p.revenue_amount}" style="width:150px" /></div>
<div class="form-row"><label>确认比例</label>
<div style="display:flex;align-items:center;gap:8px">
<input class="inp" id="cdRatio" type="number" min="0" max="100" step="5" placeholder="留空按阶段自动" value="${p.manual_ratio === null || p.manual_ratio === undefined ? "" : Math.round(p.manual_ratio * 100)}" style="width:130px" /> %
<span style="font-size:12px;color:var(--muted)">当前 ${ratioLabel}</span>
</div>
</div>
<div class="form-row"><label>交付环节</label>
<div style="flex:1">
${pipelineBar(p)}
<div style="font-size:12px;color:var(--muted);margin-top:6px">入库时间默认取该项目最早记录所在周期的第一天(周一),可手工修正;补上后续环节日期会自动推进阶段。</div>
</div>
</div>
<div class="form-row"><label>区域</label><select class="sel" id="cdRegion" style="width:130px">${regionOptions(p.region)}</select></div>
<div class="form-row"><label>交付负责人</label>${ownerSelectHtml(p.owner, 'id="cdOwner" style="width:150px"')}</div>
${peopleEditorHtml("cdMembers", "团队成员", p.members, { add: "成员" })}
<div class="form-row"><label>客户</label><input class="inp" id="cdCustomer" value="${esc(p.customer || "")}" style="width:180px" /></div>
<div class="form-row"><label>备注</label><textarea class="inp" id="cdRemark" rows="2" style="width:100%">${esc(p.remark || "")}</textarea></div>
</div>` : `
<div class="sec">
<div class="sec-title">基础信息<span class="sec-kind ro">只读</span></div>
${ro("项目名称", esc(p.name) + (p.archived ? ` <span class="arch-badge">已归档</span>` : ""))}
${ro("阶段", stagePill(p.stage))}
${ro("合同额", money(p.amount))}
${ro("开发进度", Math.round(p.progress) + "%")}
${ro("确认比例", `${esc(ratioLabel)} · 可确认 ${money(p.confirmed_amount)}`)}
${ro("交付环节", `<div style="max-width:100%">${pipelineBar(p, true)}</div>`)}
${ro("区域", esc(p.region || "—"))}
${ro("交付负责人", esc(p.owner || "—"))}
${ro("团队成员", nameChips(p.members))}
${ro("客户", esc(p.customer || "—"))}
${ro("备注", p.remark ? nl2br(p.remark) : "—")}
</div>`;
drawerActions(mode === "edit" ? `
<button class="btn sm" id="cdCancel">取消</button>
<button class="btn sm primary" id="cdSave">保存</button>` : `
<button class="btn sm danger" id="cdDelete">删除</button>
<button class="btn sm" id="cdArchive">${p.archived ? "恢复" : "归档"}</button>
<button class="btn sm" id="cdKey">${p.is_key ? "取消重点" : "标记重点"}</button>
<button class="btn sm primary" id="cdEdit">修改</button>`);
$("#drawerBody").innerHTML = `
${mode === "edit" ? `<div class="notice err" id="cdErr" style="display:none"></div>` : ""}
${baseSec}
<div class="sec">
<div class="sec-title">收入确认<span class="sec-kind ${mode === "edit" ? "" : "ro"}">${mode === "edit" ? "开票与回款可编辑" : "计算值 · 只读"}</span></div>
<div class="grid kpi" style="gap:10px">
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">合同额</div><div class="k-value" style="font-size:19px">${wan(p.amount)}</div></div>
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">可确认收入</div><div class="k-value" style="font-size:19px">${wan(p.confirmed_amount)}</div><div class="k-foot">比例 ${p.revenue_ratio}%</div></div>
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">已计收</div><div class="k-value" style="font-size:19px">${wan(p.revenue_amount)}</div></div>
<div class="kpi">${Ico.badge("hourglass")}<div class="k-label">待计收</div><div class="k-value" style="font-size:19px">${wan(p.pending_amount)}</div></div>
</div>
<div class="grid kpi" style="gap:10px;margin-top:10px">
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">已开票</div><div class="k-value" style="font-size:19px">${wan(p.invoice_amount)}</div><div class="k-foot">待开票 ${wan(p.pending_invoice)}</div></div>
<div class="kpi">${Ico.badge("trending-up")}<div class="k-label">已回款</div><div class="k-value" style="font-size:19px">${wan(p.received_amount)}</div><div class="k-foot">待回款 ${wan(p.pending_received)}</div></div>
</div>
${mode === "edit" ? `<div class="sec-actions">
<span class="sec-actions-label">开票与回款 · 编辑</span>
<label class="field-mini">开票金额(元)<input class="inp" id="cdInvoice" type="number" value="${p.invoice_amount === null ? "" : p.invoice_amount}" style="width:120px;text-align:right" /></label>
<label class="field-mini">已回款(元)<input class="inp" id="cdReceived" type="number" value="${p.received_amount === null ? "" : p.received_amount}" style="width:120px;text-align:right" /></label>
<span class="sec-actions-label">随标题栏「保存」一并提交</span>
</div>` : ""}
</div>
<div class="sec">
<div class="sec-title">周期执行记录(${d.updates.length})<span class="sec-kind ro">附表 · 只读</span></div>
<div class="sec-actions upd">
<span class="sec-actions-label">附表 · 更新记录</span>
<button class="btn sm primary" id="cdAddUpd">${Ico.svg("plus")}录一笔「${esc(recLabel)}」交付记录</button>
<span class="sec-hint">到「更新录入 · 定制交付」写入</span>
</div>
${d.updates.length ? d.updates.map((u) => `
<div class="upd-item">
<div class="u-head"><span class="u-task">${esc(u.period_full || "")}</span>${stagePill(u.stage)}${u.progress !== null ? `<span class="tag-mini">${u.progress}%</span>` : ""}</div>
<div class="u-body">${nl2br(u.content || u.summary || "")}</div>
${u.risk ? `<div class="u-risk">${Ico.svg("alert-circle")}<div>${nl2br(u.risk)}</div></div>` : ""}
${u.next_step ? `<div class="u-next">${Ico.svg("arrow-right")}<div>${nl2br(u.next_step)}</div></div>` : ""}
</div>`).join("") : `<div class="empty">暂无记录</div>`}
</div>
${d.timeline.length ? `<div class="sec"><div class="sec-title">阶段流转<span class="sec-kind ro">附表 · 只读</span></div>${timeline}</div>` : ""}`;
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
on("#cdEdit", () => openCustomDrawer(cid, "edit"));
on("#cdAddUpd", () => {
state.intakeType = "custom";
state.intakePeriodId = state.weekId;
customIntakeId = cid;
closeDrawer();
setView("intake");
});
on("#cdCancel", () => openCustomDrawer(cid, "view"));
on("#cdSave", async () => {
const btn = $("#cdSave");
const err = $("#cdErr");
const ratioRaw = $("#cdRatio").value.trim();
btn.disabled = true;
err.style.display = "none";
try {
await patch("/custom/projects/" + cid, {
name: $("#cdName").value.trim(),
stage: $("#cdStage").value,
amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value),
progress: Number($("#cdProgress").value),
revenue_amount: $("#cdRevenue").value === "" ? null : Number($("#cdRevenue").value),
manual_ratio: ratioRaw === "" ? null : Number(ratioRaw) / 100,
entry_date: $('#drawerBody [data-cf="entry_date"]').value || null,
sign_date: $('#drawerBody [data-cf="sign_date"]').value || null,
deliver_date: $('#drawerBody [data-cf="deliver_date"]').value || null,
accept_date: $('#drawerBody [data-cf="accept_date"]').value || null,
revenue_date: $('#drawerBody [data-cf="revenue_date"]').value || null,
invoice_amount: $("#cdInvoice").value === "" ? null : Number($("#cdInvoice").value),
received_amount: $("#cdReceived").value === "" ? null : Number($("#cdReceived").value),
region: $("#cdRegion").value,
owner: $("#cdOwner").value || null,
members: peopleValues("cdMembers"),
customer: $("#cdCustomer").value,
remark: $("#cdRemark").value,
});
} catch (e) {
btn.disabled = false;
err.textContent = "保存失败:" + e.message;
err.style.display = "";
return;
}
toast("已保存");
await afterWrite();
openCustomDrawer(cid, "view");
});
on("#cdKey", async () => {
await patch("/custom/projects/" + cid, { is_key: !p.is_key });
await afterWrite();
openCustomDrawer(cid, mode);
});
on("#cdArchive", () => archiveCustomProject(p, () => { closeDrawer(); afterWrite(); }));
on("#cdDelete", () => deleteCustomProject(p, () => { closeDrawer(); afterWrite(); }));
}
/* ------------------------------------------------ 录入方式(两类项目共用) */
/** 录入方式分段控件:直接写入(第一位)/ AI 整理 */
function intakeModeSegHtml() {
const mode = intakeMode();
return `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap">
<span class="sec-actions-label">录入方式</span>
<div class="seg" id="intakeModeSeg" style="width:max-content">
<button class="seg-btn ${mode === "direct" ? "active" : ""}" data-imode="direct">直接写入</button>
<button class="seg-btn ${mode === "ai" ? "active" : ""}" data-imode="ai">AI 整理</button>
</div>
<span class="sec-hint">${mode === "ai"
? "AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持你的原文,最终<b>只写 1 条</b>"
: "填进去什么就存什么,不拆解、不改写、不动项目进度"}</span>
</div>`;
}
function intakeMode() {
return state.intakeMode === "ai" ? "ai" : "direct";
}
/** 切换录入方式:保留已填的原文,重画当前录入页 */
function bindIntakeMode(rawSel, rerender) {
document.querySelectorAll("#intakeModeSeg [data-imode]").forEach((b) =>
b.addEventListener("click", () => {
if (intakeMode() === b.dataset.imode) return;
const keep = $(rawSel) ? $(rawSel).value : "";
state.intakeMode = b.dataset.imode;
Promise.resolve(rerender()).then(() => { const el = $(rawSel); if (el) el.value = keep; });
})
);
}
/* ------------------------------------------------ 定制项目录入 */
async function renderCustomIntake(host) {
const projects = await api("/custom/projects");
const weeks = state.periods.weeks.slice().reverse();
const curPeriodId =
state.gran === "week"
? state.weekId
: currentMonth()
? currentMonth().period_ids[currentMonth().period_ids.length - 1]
: null;
host.innerHTML = `
<div class="seg" id="intakeTypeSeg" style="margin-bottom:14px">
<button class="seg-btn ${state.intakeType === "general" ? "active" : ""}" data-itype="general">重点项目</button>
<button class="seg-btn ${state.intakeType === "custom" ? "active" : ""}" data-itype="custom">定制交付项目</button>
</div>
${intakeModeSegHtml()}
<div class="card">
<div class="card-head"><h4>定制项目更新录入<span class="sub">${intakeMode() === "ai"
? "给<b>已有</b>定制项目记一期:粘贴原文 → AI 整理成 1 条(含阶段 / 金额 / 计收)→ 确认写入"
: "给<b>已有</b>定制项目记一期:内容原样存成 1 条,不拆解、不改写"}</span></h4>
<button class="btn sm" id="btnNewCustomIntake">${Ico.svg("plus")}新建定制项目</button>
</div>
<div class="card-body">
<div class="step" style="margin-bottom:12px"><span class="step-no">1</span><span class="step-title">选择定制项目与周期</span></div>
<div class="inline-fields" style="margin-bottom:20px">
<input id="cInSearch" class="inp" placeholder="搜索项目名称…" style="width:200px" />
<select id="cInProject" class="sel" style="width:320px">
${projects.map((p) => `<option value="${p.id}" ${p.id === customIntakeId ? "selected" : ""}>${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")}
</select>
<select id="cInPeriod" class="sel" style="width:200px">
${weeks.map((w) => `<option value="${w.id}" ${w.id === (state.intakePeriodId || curPeriodId) ? "selected" : ""}>${esc(w.full_label)}</option>`).join("")}
<option value="__new">+ 新建周期…</option>
</select>
</div>
<div class="notice info">项目按入库时间倒序排列。列表里没有要找的项目?点标题右侧<b>「+ 新建定制项目」</b>先建档,建好会自动选中回到这里。</div>
<div class="step" style="margin-bottom:10px"><span class="step-no">2</span><span class="step-title">粘贴该项目的原始内容</span></div>
<textarea id="cInRaw" class="inp big-textarea" placeholder="例如:&#10;重庆高新三期:合同已签订,金额 100 万,开发交付阶段,本周完成集群部署,进度 60%&#10;风险:客户机房断电,联调推迟&#10;下一步:下周完成灰度上线,推动 30% 预付款计收"></textarea>
${intakeMode() === "ai" ? `
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
<button class="btn primary" id="btnCParse">AI 整理</button>
<button class="btn" id="btnCRule">本地规则整理(不调用模型)</button>
<button class="btn" id="btnCClear">清空</button>
<span id="cInMode" style="font-size:12px;color:var(--muted)"></span>
</div>` : `
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
<button class="btn primary" id="btnCDirect">直接写入这 1 条</button>
<button class="btn" id="btnCClear">清空</button>
<span id="cInMode" style="font-size:12px;color:var(--muted)"></span>
</div>`}
<div id="cInPreview"></div>
</div>
</div>`;
const sel = $("#cInProject");
host.querySelectorAll("[data-itype]").forEach((b) =>
b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); })
);
sel.addEventListener("change", () => { customIntakeId = Number(sel.value); });
$("#btnNewCustomIntake").addEventListener("click", () =>
openNewCustomDialog((p) => { customIntakeId = p.id; renderCustomIntake(host); })
);
$("#cInPeriod").addEventListener("change", async (e) => {
if (e.target.value === "__new") {
await createNewPeriod();
await renderIntake();
return;
}
state.intakePeriodId = Number(e.target.value);
});
$("#cInSearch").addEventListener("input", (e) => {
const kw = e.target.value.trim();
sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw))
.map((p) => `<option value="${p.id}">${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("");
});
bindIntakeMode("#cInRaw", () => renderCustomIntake(host));
const on = (sel2, fn) => { const el = $(sel2); if (el) el.addEventListener("click", fn); };
on("#btnCClear", () => {
$("#cInRaw").value = ""; $("#cInPreview").innerHTML = ""; $("#cInMode").textContent = "";
});
on("#btnCParse", () => doCustomParse(true));
on("#btnCRule", () => doCustomParse(false));
on("#btnCDirect", commitCustomDirect);
}
/** 定制交付 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */
async function commitCustomDirect() {
const raw = $("#cInRaw").value.trim();
if (!raw) return toast("请先填写内容");
const id = Number($("#cInProject").value);
if (!id) return toast("请选择定制项目");
const pid = Number($("#cInPeriod").value);
if (!pid) return toast("请选择周期");
const btn = $("#btnCDirect");
btn.disabled = true;
try {
await post("/custom/updates", {
custom_project_id: id,
period_id: pid,
content: raw,
source: "manual",
});
$("#cInRaw").value = "";
$("#cInPreview").innerHTML = `<div class="notice info">写入成功:已按原文存入 <b>1</b> 条执行记录,未拆解、未改写。</div>`;
toast("已写入 1 条执行记录");
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
} finally {
btn.disabled = false;
}
}
let customIntakeId = null;
let customIntakeParsed = null;
let customIntakeRaw = "";
async function doCustomParse(useAI) {
const raw = $("#cInRaw").value.trim();
if (!raw) return toast("请先粘贴内容");
const sel = $("#cInProject");
const id = Number(sel.value);
if (!id) return toast("请选择定制项目");
const btn = useAI ? $("#btnCParse") : $("#btnCRule");
const old = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner"></span> 整理中…`;
try {
const r = await post("/custom/ai/parse", {
custom_project_id: id,
project_name: name,
period_id: Number($("#cInPeriod").value),
raw_text: raw,
use_ai: useAI,
});
customIntakeId = r.project ? r.project.id : customIntakeId;
customIntakeParsed = r.parsed;
customIntakeRaw = raw;
renderCustomPreview(r);
} catch (e) {
$("#cInPreview").innerHTML = `<div class="notice err">整理失败:${esc(e.message)}</div>`;
} finally {
btn.disabled = false; btn.innerHTML = old;
}
}
function renderCustomPreview(r) {
const p = r.parsed;
const stageOpts = state.customMeta.stages
.map((s) => `<option value="${s.code}" ${s.code === p.stage ? "selected" : ""}>${esc(s.name)}(${s.base_ratio}%)</option>`)
.join("");
$("#cInMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理";
$("#cInPreview").innerHTML = `
${r.warning ? `<div class="notice warn">${esc(r.warning)}</div>` : ""}
${r.error ? `<div class="notice err">AI 调用异常:${esc(r.error)}</div>` : ""}
<div class="sec-title">整理结果 · 1 条记录(可直接修改后写入)</div>
<div class="preview-box">
<div class="kv"><label>项目</label><div><b>${esc(r.project ? r.project.name : "(新建)")}</b> · ${esc(r.period ? r.period.full_label : "")}</div></div>
<div class="kv"><label>执行内容(正文,原文照录)</label><textarea class="inp" id="cPvContent" rows="5" style="width:100%">${esc(customIntakeRaw)}</textarea></div>
<div class="kv"><label>商务阶段</label><select class="sel" id="cPvStage">${stageOpts}</select></div>
<div class="kv"><label>开发进度</label><input class="inp" id="cPvProgress" type="number" min="0" max="100" value="${p.progress === null || p.progress === undefined ? "" : p.progress}" style="width:100px" /></div>
<div class="kv"><label>合同额</label><input class="inp" id="cPvAmount" type="number" value="${p.amount === null || p.amount === undefined ? "" : p.amount}" style="width:160px" /></div>
<div class="kv"><label>累计已计收</label><input class="inp" id="cPvRevenue" type="number" value="${p.revenue_amount === null || p.revenue_amount === undefined ? "" : p.revenue_amount}" style="width:160px" /></div>
<div class="kv"><label>本期新增计收</label><input class="inp" id="cPvDelta" type="number" value="${p.revenue_delta === null || p.revenue_delta === undefined ? "" : p.revenue_delta}" style="width:160px" /></div>
<div class="kv"><label>摘要</label><input class="inp" id="cPvSummary" value="${esc(p.summary || "")}" style="width:100%" /></div>
<div class="kv"><label>风险</label><textarea class="inp" id="cPvRisks" rows="2" style="width:100%">${esc((p.risks || []).join("\n"))}</textarea></div>
<div class="kv"><label>下一步</label><textarea class="inp" id="cPvNext" rows="2" style="width:100%">${esc((p.next_steps || []).join("\n"))}</textarea></div>
</div>
<div style="display:flex;gap:10px;margin-top:14px">
<button class="btn primary" id="btnCCommit">确认写入这 1 条</button>
<span style="font-size:12px;color:var(--muted);align-self:center">正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定制交付」</span>
</div>`;
$("#btnCCommit").addEventListener("click", commitCustomIntake);
}
function collectCustomPreview() {
const num = (id) => { const v = $(id).value.trim(); return v === "" ? null : Number(v); };
const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean);
return {
stage: $("#cPvStage").value,
progress: num("#cPvProgress"),
amount: num("#cPvAmount"),
revenue_amount: num("#cPvRevenue"),
revenue_delta: num("#cPvDelta"),
summary: $("#cPvSummary").value.trim(),
risks: lines("#cPvRisks"),
next_steps: lines("#cPvNext"),
updates: [], // AI 整理只落 1 条:正文走 raw_text
};
}
async function commitCustomIntake() {
const sel = $("#cInProject");
const btn = $("#btnCCommit");
btn.disabled = true;
try {
const res = await post("/custom/ai/commit", {
custom_project_id: Number(sel.value),
project_name: null,
period_id: Number($("#cInPeriod").value),
raw_text: ($("#cPvContent").value || "").trim() || customIntakeRaw,
parsed: collectCustomPreview(),
mode: customIntakeParsed && customIntakeParsed.model ? "ai" : "rule",
model: (customIntakeParsed && customIntakeParsed.model) || null,
});
toast(`已写入 ${res.created.length} 条记录`);
$("#cInPreview").innerHTML = `<div class="notice info">写入成功:「${esc(res.project.name)}」阶段 <b>${esc(stageMeta(res.project.stage).name)}</b>,可确认收入 ${wan(res.project.confirmed_amount)}。</div>`;
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
} finally {
btn.disabled = false;
}
}
/* ---------------------------------------------------------------- 更新录入 */
let intakeProjectId = null;
let intakeParsed = null;
async function renderIntake() {
const host = $("#view-intake");
if (!state.customMeta) state.customMeta = await api("/custom/meta");
if (state.intakeType === "custom") return renderCustomIntake(host);
const projects = await api("/projects?archived=false");
const weeks = state.periods.weeks.slice().reverse();
const curPeriodId = state.gran === "week" ? state.weekId : (currentMonth() ? currentMonth().period_ids[currentMonth().period_ids.length - 1] : null);
const mode = intakeMode();
const actionRow = mode === "ai" ? `
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
<button class="btn primary" id="btnAIParse">AI 整理</button>
<button class="btn" id="btnRuleParse">本地规则整理(不调用模型)</button>
<button class="btn" id="btnClearIntake">清空</button>
<span id="intakeMode" style="font-size:12px;color:var(--muted)"></span>
</div>` : `
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
<button class="btn primary" id="btnDirectWrite">直接写入这 1 条</button>
<button class="btn" id="btnClearIntake">清空</button>
<span id="intakeMode" style="font-size:12px;color:var(--muted)"></span>
</div>`;
host.innerHTML = `
<div class="seg" id="intakeTypeSeg" style="margin-bottom:14px">
<button class="seg-btn ${state.intakeType === "general" ? "active" : ""}" data-itype="general">重点项目</button>
<button class="seg-btn ${state.intakeType === "custom" ? "active" : ""}" data-itype="custom">定制交付项目</button>
</div>
${intakeModeSegHtml()}
<div class="card">
<div class="card-head"><h4>更新录入<span class="sub">${mode === "ai" ? "选择项目 → 粘贴原始内容 → AI 整理 → 确认写入" : "选择项目 → 填写内容 → 原样写入 1 条"}</span></h4></div>
<div class="card-body">
<div class="step" style="margin-bottom:12px"><span class="step-no">1</span><span class="step-title">选择项目与周期</span></div>
<div class="inline-fields" style="margin-bottom:20px">
<input id="intakeProjectSearch" class="inp" placeholder="搜索项目…" style="width:200px" />
<select id="intakeProject" class="sel" style="width:300px">
${projects.map((p) => `<option value="${p.id}" ${p.id === intakeProjectId ? "selected" : ""}>${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")}
<option value="__new">+ 新建项目(按下方名称)</option>
</select>
<input id="intakeNewProject" class="inp" placeholder="新项目名称" style="width:220px;display:none" />
<select id="intakePeriod" class="sel" style="width:200px">
${weeks.map((w) => `<option value="${w.id}" ${w.id === (state.intakePeriodId || curPeriodId) ? "selected" : ""}>${esc(w.full_label)}</option>`).join("")}
<option value="__new">+ 新建周期…</option>
</select>
</div>
<div class="step" style="margin-bottom:10px"><span class="step-no">2</span><span class="step-title">粘贴该项目的原始内容</span></div>
<textarea id="intakeRaw" class="inp big-textarea" placeholder="把周报 / 项目群消息 / 会议纪要原文粘进来,例如:&#10;1、授权卸载管理已完成开发和联调,可以转测&#10;2、集群部署,进度50%&#10;3、国产化操作系统切换,进度50%;风险:license 未到位"></textarea>
${actionRow}
<div id="intakePreview"></div>
</div>
</div>`;
host.querySelectorAll("[data-itype]").forEach((b) =>
b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); })
);
bindIntakeMode("#intakeRaw", renderIntake);
const sel = $("#intakeProject");
sel.addEventListener("change", () => {
$("#intakeNewProject").style.display = sel.value === "__new" ? "" : "none";
});
$("#intakePeriod").addEventListener("change", async (e) => {
if (e.target.value === "__new") {
await createNewPeriod();
await renderIntake();
return;
}
state.intakePeriodId = Number(e.target.value);
});
$("#intakeProjectSearch").addEventListener("input", (e) => {
const kw = e.target.value.trim();
sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw))
.map((p) => `<option value="${p.id}">${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")
+ `<option value="__new">+ 新建项目(按下方名称)</option>`;
});
$("#btnClearIntake").addEventListener("click", () => {
$("#intakeRaw").value = ""; $("#intakePreview").innerHTML = ""; $("#intakeMode").textContent = "";
});
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
on("#btnAIParse", () => doParse(true));
on("#btnRuleParse", () => doParse(false));
on("#btnDirectWrite", commitDirectWrite);
}
/**
* 重点项目 · 直接写入:填入的内容一字不改存成 1 条执行记录。
* 不调大模型、不建子任务、不写 progress,项目的进度和状态一律不动。
*/
async function commitDirectWrite() {
const raw = $("#intakeRaw").value.trim();
if (!raw) return toast("请先填写内容");
const sel = $("#intakeProject");
const btn = $("#btnDirectWrite");
let projectId = sel.value === "__new" ? null : Number(sel.value);
btn.disabled = true;
try {
if (!projectId) {
const name = $("#intakeNewProject").value.trim();
if (!name) { toast("请填写项目名称"); return; }
const created = await post("/projects", { name });
projectId = created.id;
intakeProjectId = created.id;
sel.insertAdjacentHTML("beforeend", `<option value="${created.id}" selected>${esc(created.name)}</option>`);
$("#intakeNewProject").style.display = "none";
}
await post("/updates", {
project_id: projectId,
period_id: Number($("#intakePeriod").value),
content: raw,
source: "manual",
});
$("#intakeRaw").value = "";
$("#intakePreview").innerHTML = `<div class="notice info">写入成功:已按原文存入 <b>1</b> 条执行记录,未拆解、未改写。</div>`;
toast("已写入 1 条执行记录");
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
} finally {
btn.disabled = false;
}
}
async function doParse(useAI) {
const raw = $("#intakeRaw").value.trim();
if (!raw) return toast("请先粘贴内容");
const sel = $("#intakeProject");
const projectId = sel.value === "__new" ? null : Number(sel.value);
const projectName = sel.value === "__new" ? $("#intakeNewProject").value.trim() : null;
if (!projectId && !projectName) return toast("请选择或填写项目名称");
const btn = useAI ? $("#btnAIParse") : $("#btnRuleParse");
const old = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner"></span> 整理中…`;
try {
const r = await post("/ai/parse", {
project_id: projectId, project_name: projectName,
period_id: Number($("#intakePeriod").value),
raw_text: raw, use_ai: useAI,
});
intakeProjectId = r.project ? r.project.id : intakeProjectId;
intakeParsed = r.parsed;
renderIntakePreview(r, raw);
} catch (e) {
$("#intakePreview").innerHTML = `<div class="notice err">整理失败:${esc(e.message)}</div>`;
} finally {
btn.disabled = false; btn.innerHTML = old;
}
}
function renderIntakePreview(r, raw) {
const p = r.parsed;
const statusOpts = state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === p.status ? "selected" : ""}>${esc(s.name)}</option>`).join("");
$("#intakeMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理";
$("#intakePreview").innerHTML = `
${r.warning ? `<div class="notice warn">${esc(r.warning)}</div>` : ""}
${r.error ? `<div class="notice err">AI 调用异常:${esc(r.error)}</div>` : ""}
<div class="sec-title">整理结果 · 1 条记录(可直接修改后写入)</div>
<div class="preview-box">
<div class="kv"><label>项目</label><div><b>${esc(r.project ? r.project.name : "")}</b> · ${esc(r.period ? r.period.full_label : "")}</div></div>
<div class="kv"><label>执行内容(正文,原文照录)</label><textarea class="inp" id="pvContent" rows="5" style="width:100%">${esc(raw)}</textarea></div>
<div class="kv"><label>一句话摘要</label><input class="inp" id="pvSummary" value="${esc(p.summary || "")}" style="width:100%" /></div>
<div class="kv"><label>总进度 %</label><input class="inp" id="pvProgress" type="number" min="0" max="100" value="${p.progress === null || p.progress === undefined ? "" : p.progress}" style="width:100px" /></div>
<div class="kv"><label>整体状态</label><select class="sel" id="pvStatus">${statusOpts}</select></div>
<div class="kv"><label>风险 / 阻塞</label><textarea class="inp" id="pvRisks" rows="2" style="width:100%">${esc((p.risks || []).join("\n"))}</textarea></div>
<div class="kv"><label>下一步计划</label><textarea class="inp" id="pvNext" rows="2" style="width:100%">${esc((p.next_steps || []).join("\n"))}</textarea></div>
</div>
<div style="display:flex;gap:10px;margin-top:14px">
<button class="btn primary" id="btnCommit">确认写入这 1 条</button>
<span style="font-size:12px;color:var(--muted);align-self:center">正文用你的原文,AI 只补摘要 / 进度 / 状态 / 风险 / 下一步;写入后可在「周期看板」中立即查看</span>
</div>`;
$("#btnCommit").addEventListener("click", () => commitIntake(r, raw));
}
/** 收集预览表单。tasks 恒为空 → 后端按「1 条执行记录」写入,不建子任务 */
function collectPreview() {
const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean);
const pg = $("#pvProgress").value;
return {
summary: $("#pvSummary").value.trim(),
progress: pg === "" ? null : Number(pg),
status: $("#pvStatus").value,
risks: lines("#pvRisks"),
next_steps: lines("#pvNext"),
tasks: [],
};
}
async function commitIntake(r, raw) {
const sel = $("#intakeProject");
const btn = $("#btnCommit");
btn.disabled = true;
try {
const res = await post("/ai/commit", {
project_id: sel.value === "__new" ? null : Number(sel.value),
project_name: sel.value === "__new" ? $("#intakeNewProject").value.trim() : null,
period_id: Number($("#intakePeriod").value),
raw_text: ($("#pvContent").value || "").trim() || raw,
parsed: collectPreview(),
mode: r.mode,
model: r.parsed.model || null,
});
toast(`已写入 ${res.created.length} 条执行记录`);
$("#intakePreview").innerHTML = `<div class="notice info">写入成功:项目「${esc(res.project.name)}」新增 ${res.created.length} 条记录。可切换到「周期看板」查看。</div>`;
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
} finally {
btn.disabled = false;
}
}
/* ---------------------------------------------------------------- settings */
async function renderSettings() {
const host = $("#view-settings");
const [s, health, periods] = await Promise.all([
api("/settings"),
fetch("/health").then((r) => r.json()),
api("/periods"),
]);
const weeks = periods.weeks.slice().reverse();
host.innerHTML = `
<div class="grid g2">
<div class="card">
<div class="card-head"><h4>AI 整理配置<span class="sub">OpenAI 兼容协议</span></h4></div>
<div class="card-body">
${state.meta.ai.enabled
? `<div class="notice info">已启用 AI 整理(模型:${esc(state.meta.ai.model || "—")})</div>`
: `<div class="notice warn">未配置 API Key,当前使用本地规则解析。填写下方配置后自动切换到大模型整理。</div>`}
<div class="form-row"><label>Base URL</label><input class="inp" id="cfgBase" value="${esc(s.ai_api_base || "")}" style="width:100%" placeholder="https://api.deepseek.com/v1" /></div>
<div class="form-row"><label>API Key</label><input class="inp" id="cfgKey" type="password" value="${esc(s.ai_api_key || "")}" style="width:100%" placeholder="sk-..." /></div>
<div class="form-row"><label>模型</label><input class="inp" id="cfgModel" value="${esc(s.ai_model || "")}" style="width:100%" placeholder="deepseek-chat" /></div>
<div class="form-row"><label>温度</label><input class="inp" id="cfgTemp" value="${esc(s.ai_temperature || "0.2")}" style="width:100px" /></div>
<div style="margin-top:14px"><button class="btn primary" id="btnSaveCfg">保存配置</button></div>
<div style="margin-top:14px;font-size:12px;color:var(--muted);line-height:1.8">
兼容:DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。<br>
例:Ollama → Base URL <code>http://localhost:11434/v1</code>,模型 <code>qwen2.5:14b</code>,Key 任意填。
</div>
</div>
</div>
<div class="card">
<div class="card-head"><h4>数据库</h4></div>
<div class="card-body">
<div class="form-row"><label>当前引擎</label><b>${esc(health.db)}</b></div>
<div style="font-size:12.5px;color:var(--text-2);line-height:1.9">
默认使用 SQLite(<code>data/board.db</code>),无需安装任何服务。<br><br>
<b>迁移到 MySQL:</b><br>
1. 安装驱动:<code>pip install pymysql cryptography</code><br>
2. 在 <code>.env</code> 中设置:<br>
<code>DATABASE_URL=mysql+pymysql://user:pwd@host:3306/unis_board?charset=utf8mb4</code><br>
3. 重启服务,表结构自动创建;再用
<code>python scripts/import_excel.py --reset</code> 重新灌数即可。<br><br>
数据访问层全部走 SQLAlchemy,无原生 SQL,切换方言不需要改业务代码。
</div>
</div>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-head">
<h4>周期管理<span class="sub">共 ${periods.weeks.length} 个周期 · 录入新周期数据前可先在这里新增</span></h4>
<button class="btn sm primary" id="btnAddPeriod">+ 新建周期</button>
</div>
<div class="card-body" style="max-height:360px;overflow:auto;padding:0">
<table class="tbl">
<thead><tr><th>周期</th><th class="num">常规记录</th><th class="num">定制记录</th><th></th></tr></thead>
<tbody>
${weeks.map((w) => `
<tr data-pid="${w.id}">
<td><b>${esc(w.full_label)}</b> <span style="color:var(--muted);font-size:12px">(原标签:${esc(w.label)})</span></td>
<td class="num">${w.update_count || 0}</td>
<td class="num">${w.custom_update_count || 0}</td>
<td class="num"><button class="btn sm danger" data-delperiod="${w.id}" ${(w.update_count || w.custom_update_count) ? "disabled title='该周期已有记录,不能删除'" : ""}>删除</button></td>
</tr>`).join("")}
</tbody>
</table>
</div>
</div>`;
$("#btnAddPeriod").addEventListener("click", async () => {
const id = await createNewPeriod();
if (id) renderSettings();
});
host.querySelectorAll("[data-delperiod]").forEach((b) =>
b.addEventListener("click", async () => {
const id = Number(b.dataset.delperiod);
const row = b.closest("tr");
const name = row.querySelector("b").textContent;
if (!confirm(`确认删除周期「${name}」?`)) return;
try {
await del("/periods/" + id);
toast("已删除周期 " + name);
await loadPeriods();
renderSettings();
} catch (e) {
toast("删除失败:" + e.message);
}
})
);
$("#btnSaveCfg").addEventListener("click", async () => {
await put("/settings", {
values: {
ai_api_base: $("#cfgBase").value.trim(),
ai_api_key: $("#cfgKey").value.trim(),
ai_model: $("#cfgModel").value.trim(),
ai_temperature: $("#cfgTemp").value.trim() || "0.2",
},
});
state.meta = await api("/meta");
toast("配置已保存");
renderSettings();
});
}
/* ---------------------------------------------------------------- drawer */
/** pid 详情抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
async function openDrawer(pid, mode) {
mode = mode === "edit" ? "edit" : "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
drawerActions("");
const d = await api("/projects/" + pid);
state.drawerProject = d;
const p = d.project;
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
$("#drawerSub").innerHTML = `${catPill(p.category)} ${statusPill(p.status)} · 进度 ${Math.round(p.progress)}% · ${p.update_count} 条执行记录 / ${p.task_count} 个子任务`;
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
// 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起
const baseSec = mode === "edit" ? `
<div class="sec">
<div class="sec-title">基础信息<span class="sec-kind">编辑</span></div>
<div class="sec-hint" style="margin:0 0 10px">只改基础信息,不会动下面的周期记录</div>
<div class="form-row"><label>项目名称</label><input class="inp" id="dwName" value="${esc(p.name)}" style="width:100%" /></div>
<div class="form-row"><label>分类</label><select class="sel" id="dwCat" style="width:150px">
<option value="">(未分类)</option>
${state.categories.map((c) => `<option value="${c.id}" ${c.id === p.category_id ? "selected" : ""}>${esc(c.name)}</option>`).join("")}
</select></div>
<div class="form-row"><label>状态</label><select class="sel" id="dwStatus" style="width:130px">
${state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === p.status ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
</select></div>
${peopleEditorHtml("dwOwners", "负责人", p.owners, {
add: "负责人",
// 界面上负责人不分主次,但存储里 owner = owners[0],统计口径照实说明
hint: "可多位。看板、筛选、导出与人力负载取名单里的第一位做归属统计。",
})}
${peopleEditorHtml("dwMembers", "团队成员", p.members, { add: "成员" })}
<div class="form-row"><label>进度</label><div style="display:flex;align-items:center;gap:6px"><input class="inp" id="dwProgress" type="number" min="0" max="100" value="${Math.round(p.progress)}" style="width:90px" /> %</div></div>
<div class="form-row"><label>标签</label><input class="inp" id="dwTags" placeholder="标签(、分隔)" value="${esc(p.tags.map((t) => t.name).join("、"))}" style="width:220px" /></div>
</div>` : `
<div class="sec">
<div class="sec-title">基础信息<span class="sec-kind ro">只读</span></div>
${ro("项目名称", esc(p.name) + (p.archived ? ` <span class="arch-badge">已归档</span>` : ""))}
${ro("分类", catPill(p.category))}
${ro("状态", statusPill(p.status))}
${ro("负责人", nameChips(p.owners))}
${ro("团队成员", nameChips(p.members))}
${ro("进度", Math.round(p.progress) + "%")}
${ro("标签", p.tags.length ? p.tags.map((t) => `<span class="tag-mini">${esc(t.name)}</span>`).join(" ") : "—")}
${ro("记录", `${p.task_count} 个子任务 / ${p.update_count} 条执行记录`)}
</div>`;
// 基础信息的动作在标题栏右侧按钮条;附表的更新动作仍在它的列表上方,两类不混
drawerActions(mode === "edit" ? `
<button class="btn sm" id="dwCancel">取消</button>
<button class="btn sm primary" id="dwSave">保存</button>` : `
<button class="btn sm danger" id="dwDelete">删除</button>
<button class="btn sm" id="dwArchive">${p.archived ? "恢复" : "归档"}</button>
<button class="btn sm" id="dwKey">${p.is_key ? "取消重点" : "标记重点"}</button>
<button class="btn sm primary" id="dwEdit">修改</button>`);
const updItem = (u) => `
<div class="upd-item">
<div class="u-head"><span class="u-task">${esc(u.task_name || "整体进展")}</span>${statusPill(u.status)}${u.progress !== null ? `<span class="tag-mini">${u.progress}%</span>` : ""}</div>
<div class="u-body">${nl2br(u.content || u.summary || "")}</div>
${u.risk ? `<div class="u-risk">${Ico.svg("alert-circle")}<div>${nl2br(u.risk)}</div></div>` : ""}
${u.next_step ? `<div class="u-next">${Ico.svg("arrow-right")}<div>${nl2br(u.next_step)}</div></div>` : ""}
</div>`;
// 把项目的 timeline 按「周」或「月」归成显示单元(timeline 已由后端按时间升序返回)
function feedUnits() {
if (state.dw.group === "week") {
return d.timeline.map((t) => ({
label: t.period.full_label, count: t.updates.length, progress: t.progress,
periods: [t], ids: [t.period.id],
}));
}
const map = new Map();
d.timeline.forEach((t) => {
const k = t.period.year + "-" + t.period.month;
if (!map.has(k)) map.set(k, { label: t.period.year + "年" + t.period.month + "月", count: 0, progress: null, periods: [], ids: [] });
const u = map.get(k);
u.periods.push(t);
u.count += t.updates.length;
u.ids.push(t.period.id);
if (t.progress !== null) u.progress = u.progress === null ? t.progress : Math.max(u.progress, t.progress);
});
return [...map.values()];
}
const FEED_RANGES = {
week: [[1, "本周"], [4, "近 4 周"], [12, "近 12 周"], [0, "全部"]],
month: [[1, "本月"], [3, "近 3 月"], [6, "近 6 月"], [0, "全部"]],
};
// 附表的写入走「更新录入」,和基础信息的保存彻底分成两条路
const recWeek = (state.periods.weeks || []).find((x) => x.id === state.weekId);
const recLabel = recWeek ? recWeek.full_label : "本周";
const gotoIntake = () => {
state.intakeType = "general";
state.intakePeriodId = state.weekId;
intakeProjectId = pid;
closeDrawer();
setView("intake");
};
// 范围以顶栏选中的周期为锚点,和看板其余部分保持一致
function renderFeed() {
const host = $("#dwFeed");
const units = feedUnits();
const unitName = state.dw.group === "week" ? "周" : "个月"; /* 「7 月」会被读成七月,用「个月」 */
const scope = scopePeriodIds();
let anchor = -1;
units.forEach((u, i) => { if (u.ids.some((id) => scope.indexOf(id) >= 0)) anchor = i; });
if (anchor < 0) anchor = units.length - 1;
const n = state.dw.range;
const step = n === 0 ? Math.max(1, anchor + 1) : n;
const last = Math.max(0, anchor - step * state.dw.back);
const first = n === 0 ? 0 : Math.max(0, last - n + 1);
const win = units.slice(first, last + 1);
const nUpd = win.reduce((a, u) => a + u.count, 0);
const bar = `
<div class="feed-bar">
<span class="sec-actions-label">查看范围(只读筛选)</span>
<div class="seg" id="dwGroup">
<button class="seg-btn ${state.dw.group === "week" ? "active" : ""}" data-g="week">按周</button>
<button class="seg-btn ${state.dw.group === "month" ? "active" : ""}" data-g="month">按月</button>
</div>
<div class="feed-chips">${FEED_RANGES[state.dw.group].map(([v, l]) =>
`<button class="chip ${state.dw.range === v ? "active" : ""}" data-r="${v}">${l}</button>`).join("")}</div>
<div class="feed-pager">
<button class="nav-arrow sm" id="dwOlder" title="更早" ${first <= 0 ? "disabled" : ""}>${Ico.svg("chevron-left")}</button>
<button class="nav-arrow sm" id="dwNewer" title="更新" ${state.dw.back <= 0 ? "disabled" : ""}>${Ico.svg("chevron-right")}</button>
</div>
<span class="feed-meta">显示 ${win.length} / ${units.length} ${unitName} · ${nUpd} 条${first > 0 ? ` · 更早还有 ${first} ${unitName}` : ""}</span>
</div>`;
// win 是按升序取的窗口,展示时倒过来:新的在前
const body = win.length ? win.slice().reverse().map((u) => `
<div class="feed-hd"><b>${esc(u.label)}</b>${u.progress !== null ? `<span>进度 ${u.progress}%</span>` : ""}<span class="feed-cnt">${u.count} 条</span></div>
${u.periods.slice().reverse().map((t) => `
${state.dw.group === "month" ? `<div class="feed-sub">${esc(t.period.full_label.replace(/^\d+年/, ""))}${t.progress !== null ? " · 进度 " + t.progress + "%" : ""} ${statusPill(t.status)}</div>` : ""}
${t.updates.map(updItem).join("")}`).join("")}`).join("")
: `<div class="empty">还没有执行记录</div>`;
host.innerHTML = `<div class="sec"><div class="sec-title">周期反馈<span class="sec-kind ro">附表 · 只读</span></div>
<div class="sec-actions upd">
<span class="sec-actions-label">附表 · 更新记录</span>
<button class="btn sm primary" id="dwAddUpd">${Ico.svg("plus")}录一笔「${esc(recLabel)}」执行记录</button>
<span class="sec-hint">到「更新录入」页粘贴原文写入</span>
</div>${bar}<div>${body}</div></div>`;
host.querySelector("#dwAddUpd").addEventListener("click", gotoIntake);
host.querySelectorAll("#dwGroup [data-g]").forEach((b) => b.addEventListener("click", () => {
const g = b.dataset.g;
if (state.dw.group === g) return;
state.dw = { group: g, range: g === "week" ? 4 : 3, back: 0 };
renderFeed();
}));
host.querySelectorAll(".feed-chips [data-r]").forEach((b) => b.addEventListener("click", () => {
state.dw.range = Number(b.dataset.r);
state.dw.back = 0;
renderFeed();
}));
host.querySelector("#dwOlder").addEventListener("click", () => { state.dw.back += 1; renderFeed(); });
host.querySelector("#dwNewer").addEventListener("click", () => { state.dw.back = Math.max(0, state.dw.back - 1); renderFeed(); });
}
const timeline = d.timeline.slice().reverse().map((t) => `
<div class="timeline-row">
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
<div class="t-bar"><div class="progress" style="margin:0"><i style="width:${t.progress || 0}%;background:${statusMeta(t.status).color}"></i></div></div>
<div class="t-val">${t.progress === null ? "—" : t.progress + "%"}</div>
</div>`).join("");
// 周期反馈单独渲染,切换范围/周期不必重新请求
state.dw = { group: "week", range: 4, back: 0 };
$("#drawerBody").innerHTML =
(mode === "edit" ? `<div class="notice err" id="dwErr" style="display:none"></div>` : "") +
baseSec + `<div id="dwFeed"></div>` +
`<div class="sec"><div class="sec-title">历史进度走势<span class="sec-kind ro">附表 · 只读</span></div>${timeline || '<div class="empty">暂无</div>'}</div>`;
renderFeed();
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
on("#dwEdit", () => openDrawer(pid, "edit"));
on("#dwCancel", () => openDrawer(pid, "view"));
on("#dwSave", async () => {
const btn = $("#dwSave");
const err = $("#dwErr");
btn.disabled = true;
err.style.display = "none";
try {
await patch("/projects/" + pid, {
name: $("#dwName").value.trim(),
category_id: $("#dwCat").value === "" ? null : Number($("#dwCat").value),
status: $("#dwStatus").value,
owners: peopleValues("dwOwners"),
members: peopleValues("dwMembers"),
progress: Number($("#dwProgress").value),
tags: $("#dwTags").value.split(/[、,,;;\s]+/).filter(Boolean),
});
} catch (e) {
btn.disabled = false;
err.textContent = "保存失败:" + e.message;
err.style.display = "";
return;
}
toast("已保存");
await afterWrite();
openDrawer(pid, "view");
});
on("#dwKey", async () => {
await patch("/projects/" + pid, { is_key: !p.is_key });
await afterWrite();
openDrawer(pid, mode);
});
on("#dwArchive", async () => {
await patch("/projects/" + pid, { archived: !p.archived });
toast(p.archived ? "已恢复" : "已归档");
closeDrawer();
await afterWrite();
});
// 删除是高危动作:必须逐字输入项目名才能点「永久删除」
on("#dwDelete", async () => {
const ok = await confirmDialog({
title: "删除项目",
danger: true,
okText: "永久删除",
requireText: p.name,
body:
`<div class="notice err">将连同 <b>${p.task_count}</b> 个子任务、<b>${p.update_count}</b> 条执行记录一并删除,<b>删除后无法恢复</b>。</div>
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。</div>`,
});
if (!ok) return;
try {
const r = await del("/projects/" + pid);
toast(`已删除「${r.name}」:${r.deleted_tasks} 个子任务 / ${r.deleted_updates} 条记录`);
closeDrawer();
await afterWrite();
} catch (e) {
toast("删除失败:" + e.message);
}
});
}
// 标题栏右侧的标准按钮条:按钮由当前打开的抽屉自己填,主按钮在最右
function drawerActions(html) {
$("#drawerActions").innerHTML = html || "";
}
function closeDrawer() {
drawerActions("");
$("#drawer").classList.remove("open");
$("#drawerMask").classList.remove("open");
}
/* ---------------------------------------------------------------- 人力资源库 */
function openStaffDialog(person, onDone) {
const isNew = !person;
const p = person || {};
const groups = (state.staffDict.groups || []).map(
(g) => `<option value="${esc(g)}" ${g === p.group ? "selected" : ""}>${esc(g)}</option>`
).join("");
const affs = (state.staffDict.affiliations || []).map(
(a) => `<option value="${esc(a)}" ${a === p.affiliation ? "selected" : ""}>${esc(a)}</option>`
).join("");
openModal(
`<div class="modal-head"><h3>${isNew ? "新增人员" : "编辑人员"}</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
<div class="modal-body">
<div class="form-grid">
<div class="form-row"><label>姓名 *</label><input class="inp" id="sfName" data-autofocus value="${esc(p.name || "")}" style="width:100%" /></div>
<div class="form-row"><label>归属小组</label><select class="sel" id="sfGroup" style="width:100%">${groups}</select></div>
<div class="form-row"><label>人员归属</label><select class="sel" id="sfAff" style="width:100%">${affs}</select></div>
<div class="form-row full"><label>说明</label><input class="inp" id="sfNote" value="${esc(p.note || "")}" placeholder="如:外包 / 实习 / 兼岗" style="width:100%" /></div>
</div>
${isNew ? "" : `<div class="notice info">改姓名会把名下项目的负责人一并改过来,历史记录不会断。</div>`}
<div class="notice err" id="mErr" style="display:none"></div>
</div>
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">${isNew ? "新增" : "保存"}</button></div>`
);
$("#mOk").addEventListener("click", async () => {
const name = $("#sfName").value.trim();
if (!name) return modalError("请填写姓名");
const btn = $("#mOk");
btn.disabled = true;
const body = {
name: name,
group: $("#sfGroup").value || null,
affiliation: $("#sfAff").value || null,
note: $("#sfNote").value.trim() || null,
};
try {
let msg = "已新增「" + name + "」";
if (isNew) { await post("/staff", body); }
else {
const r = await patch("/staff/" + p.id, body);
const n = r.synced.projects + r.synced.custom_projects;
msg = n ? `已更新,并同步 ${n} 个项目的负责人` : "已更新";
}
closeModal();
toast(msg);
if (onDone) onDone(); else await afterWrite();
} catch (e) { btn.disabled = false; modalError(e.message); }
});
$("#sfName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); });
}
async function deleteStaff(m) {
const ok = await confirmDialog({
title: "删除人员",
danger: true,
okText: "确认删除",
body: `<p style="margin:0 0 10px">确认从花名册删除 <b>${esc(m.name)}</b>?</p>
<div class="notice warn">删除后这个人不再出现在「负责人」下拉里。
若他名下还有在管项目,接口会拒绝删除 —— 离职人员请用<b>归档</b>,历史数据完整保留。</div>`,
});
if (!ok) return;
try {
await del("/staff/" + m.id);
toast("已删除「" + m.name + "」");
await afterWrite();
} catch (e) {
toast(e.message);
}
}
async function renderStaff() {
saveUI();
const host = $("#view-staff");
host.innerHTML = `<div class="empty">加载中…</div>`;
const extra = ["archived=" + state.staffArchived];
if (state.staffGroup) extra.push("group=" + encodeURIComponent(state.staffGroup));
if (state.keyword) extra.push("keyword=" + encodeURIComponent(state.keyword));
const d = await api("/staff?" + extra.join("&"));
await loadStaff(); // 负责人下拉始终用完整花名册
const sum = d.summary;
const archOpts = [["false", "在编"], ["true", "已归档"], ["all", "全部"]]
.map((x) => `<option value="${x[0]}" ${x[0] === state.staffArchived ? "selected" : ""}>${x[1]}</option>`).join("");
const groupOpts = `<option value="">全部小组</option>` +
(d.dict.groups || []).map((g) => `<option value="${esc(g)}" ${g === state.staffGroup ? "selected" : ""}>${esc(g)}</option>`).join("");
const kpi = `
<div class="grid kpi" style="margin-bottom:14px">
<div class="kpi accent-blue">${Ico.badge("users")}<div class="k-label">在册人员</div><div class="k-value">${sum.total}</div><div class="k-foot">其中 ${sum.with_project} 人带项目</div></div>
${sum.groups.slice(0, 4).map((g, i) => `<div class="kpi accent-${["violet", "green", "amber", "red"][i % 4]}">${Ico.badge("users")}<div class="k-label">${esc(g.name)}</div><div class="k-value">${g.count}</div></div>`).join("")}
</div>
<div class="grid g2e" style="margin-bottom:16px">
<div class="card">
<div class="card-head"><h4>人员归属<span class="sub">汇智 / 云数 / 外服</span></h4></div>
<div class="card-body">
${sum.affiliations.map((a) => `
<div class="bar-row">
<div class="b-name">${esc(a.name)}</div>
<div class="b-track"><i style="width:${Math.round((a.count / (sum.total || 1)) * 100)}%;background:#2563eb"></i></div>
<div class="b-val">${a.count}</div>
</div>`).join("")}
</div>
</div>
<div class="card">
<div class="card-head"><h4>花名册外的负责人<span class="sub">历史数据里对不上花名册的人名</span></h4></div>
<div class="card-body">
${sum.outside.length ? sum.outside.map((o) => `
<div class="upd-item" style="display:flex;align-items:center;gap:10px">
<b>${esc(o.name)}</b>
<span class="tag-mini">项目 ${o.project_count}</span>
<span class="tag-mini">定制 ${o.custom_count}</span>
<span style="flex:1"></span>
<button class="btn sm" data-outside="${esc(o.name)}">加入花名册</button>
</div>`).join("") : `<div class="empty">所有项目负责人都能对上花名册</div>`}
</div>
</div>
</div>`;
const toolbar = `
<div class="board-toolbar">
<span style="flex:1"></span>
${keywordInputHtml("stKeyword", "搜索姓名…", 200)}
<button class="btn sm" id="btnExportStaff">导出 Excel</button>
<button class="btn sm primary" id="btnNewStaff">${Ico.svg("plus")}新增人员</button>
<span class="period-hint">在册 ${sum.total} 人</span>
</div>`;
const table = `<table class="tbl">
<thead><tr>
<th style="min-width:120px">姓名</th><th>归属小组</th><th>人员归属</th><th>说明</th>
<th class="num">在管项目</th><th class="num">定制项目</th><th class="num">定制合同额</th>
<th class="num">名下待计收</th><th>最近更新</th><th class="col-ops">操作</th>
</tr></thead><tbody>
${d.items.map((m) => `
<tr data-sid="${m.id}" ${m.archived ? 'style="opacity:.62"' : ""}>
<td><div style="display:flex;align-items:center;gap:6px">
<a href="javascript:;" data-sedit="${m.id}" style="color:var(--text);text-decoration:none;font-weight:600">${esc(m.name)}</a>
${m.archived ? `<span class="arch-badge">已归档</span>` : ""}
</div></td>
<td><select class="sel" data-sf="group" style="width:112px">
${(d.dict.groups || []).map((g) => `<option value="${esc(g)}" ${g === m.group ? "selected" : ""}>${esc(g)}</option>`).join("")}
${m.group && (d.dict.groups || []).indexOf(m.group) < 0 ? `<option value="${esc(m.group)}" selected>${esc(m.group)}</option>` : ""}
</select></td>
<td><select class="sel" data-sf="affiliation" style="width:92px">
${(d.dict.affiliations || []).map((a) => `<option value="${esc(a)}" ${a === m.affiliation ? "selected" : ""}>${esc(a)}</option>`).join("")}
${m.affiliation && (d.dict.affiliations || []).indexOf(m.affiliation) < 0 ? `<option value="${esc(m.affiliation)}" selected>${esc(m.affiliation)}</option>` : ""}
</select></td>
<td><input class="inp" data-sf="note" value="${esc(m.note || "")}" style="width:150px" placeholder="—" /></td>
<td class="num">${m.project_count ? `${m.project_count}${m.key_project_count ? ` ${starMark(true, false, "重点项目")}<span class="tag-mini">${m.key_project_count}</span>` : ""}` : "—"}</td>
<td class="num">${m.custom_count}</td>
<td class="num">${m.custom_amount ? fen(m.custom_amount) + " 万" : "—"}</td>
<td class="num">${m.custom_pending ? `<span style="color:#b45309">${fen(m.custom_pending)} 万</span>` : "—"}</td>
<td>${esc(m.last_update || "—")}</td>
<td class="nowrap col-ops">
<button class="btn sm" data-sedit="${m.id}">编辑</button>
<button class="btn sm" data-sarch="${m.id}" data-archived="${m.archived ? "1" : "0"}">${m.archived ? "恢复" : "归档"}</button>
<button class="btn sm danger" data-sdel="${m.id}">删除</button>
</td>
</tr>`).join("")}
</tbody></table>`;
const body = listCard({
title: "人员花名册",
sub: "小组 / 归属 / 说明可直接在表内改",
summary: `当前 ${d.items.length} 人 · 带项目 ${sum.with_project} 人 · 花名册外 ${sum.outside.length} 人`,
fields: `<select id="stArchived" class="sel" style="width:104px">${archOpts}</select>
<select id="stGroup" class="sel" style="width:120px">${groupOpts}</select>`,
rows: d.items,
table: table,
empty: state.keyword
? "没有匹配「" + esc(state.keyword) + "」的人员"
: (state.staffGroup ? "「" + esc(state.staffGroup) + "」还没有人员" : "还没有人员"),
});
host.innerHTML = toolbar + kpi + body;
bindKeyword("stKeyword");
$("#stArchived").addEventListener("change", (e) => { state.staffArchived = e.target.value; renderStaff(); });
$("#stGroup").addEventListener("change", (e) => { state.staffGroup = e.target.value; renderStaff(); });
$("#btnNewStaff").addEventListener("click", () => openStaffDialog(null));
$("#btnExportStaff").addEventListener("click", () => download("/api/export/staff?archived=" + state.staffArchived));
host.querySelectorAll("[data-sedit]").forEach((b) =>
b.addEventListener("click", () => {
const m = d.items.find((x) => x.id === Number(b.dataset.sedit));
openStaffDialog(m);
})
);
host.querySelectorAll("[data-sarch]").forEach((b) =>
b.addEventListener("click", async () => {
const m = d.items.find((x) => x.id === Number(b.dataset.sarch));
await patch("/staff/" + m.id, { archived: !m.archived });
toast(m.archived ? "已恢复" : `已归档「${m.name}」,不再出现在负责人下拉里`);
await afterWrite();
})
);
host.querySelectorAll("[data-sdel]").forEach((b) =>
b.addEventListener("click", () =>
deleteStaff(d.items.find((x) => x.id === Number(b.dataset.sdel)))
)
);
host.querySelectorAll("[data-outside]").forEach((b) =>
b.addEventListener("click", () => openStaffDialog({ name: b.dataset.outside }))
);
host.querySelectorAll("tr[data-sid]").forEach((tr) => {
const id = Number(tr.dataset.sid);
tr.querySelectorAll("[data-sf]").forEach((inp) =>
inp.addEventListener("change", async () => {
const f = inp.dataset.sf;
try {
await patch("/staff/" + id, { [f]: inp.value || null });
toast("已保存");
await afterWrite();
} catch (e) { toast("保存失败:" + e.message); }
})
);
});
}
/* ---------------------------------------------------------------- shell */
async function loadCategories() {
state.categories = await api("/categories");
const box = $("#sideCategories");
box.innerHTML =
`<button class="side-item ${state.categoryId === null ? "active" : ""}" data-cat="">
<span class="dot" style="background:#c3c9d4"></span>全部项目</button>` +
state.categories.map((c) => `
<button class="side-item ${state.categoryId === c.id ? "active" : ""}" data-cat="${c.id}">
<span class="dot" style="background:${c.color}"></span>${esc(c.name)}
<span class="cnt">${c.project_count}</span>
</button>`).join("");
box.querySelectorAll("[data-cat]").forEach((b) =>
b.addEventListener("click", () => {
state.categoryId = b.dataset.cat === "" ? null : Number(b.dataset.cat);
loadCategories();
if (state.view === "board") renderBoard(); else if (state.view === "projects") renderProjects();
})
);
}
function setView(view) {
state.view = view;
saveUI();
$$("#nav .nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === view));
$$(".view").forEach((v) => v.classList.add("hidden"));
$("#view-" + view).classList.remove("hidden");
refresh();
}
async function refresh() {
const v = state.view;
try {
if (v === "board") await renderBoard();
else if (v === "projects") await renderProjects();
else if (v === "custom") await renderCustom();
else if (v === "focus") await renderFocus();
else if (v === "staff") await renderStaff();
else if (v === "intake") await renderIntake();
else if (v === "settings") await renderSettings();
} catch (e) {
$("#view-" + v).innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
}
$("#sideFoot").innerHTML = `当前周期:${esc(scopeLabel())}<br>共 ${state.periods.weeks.length} 个周期 / ${state.periods.months.length} 个月`;
}
/** 写操作统一收尾:刷新侧栏计数 + 当前视图。
录入页不重绘,否则会清空用户正在编辑的表单。 */
async function afterWrite() {
await Promise.all([loadCategories(), loadStaff()]);
if (state.view === "intake") return;
await refresh();
}
function bindShell() {
$$("#nav .nav-item").forEach((b) => b.addEventListener("click", () => setView(b.dataset.view)));
$$("#granSeg .seg-btn").forEach((b) =>
b.addEventListener("click", () => {
state.gran = b.dataset.gran;
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x === b));
renderPeriodSelect();
refresh();
})
);
$("#prevPeriod").addEventListener("click", () => shiftPeriod(-1));
$("#nextPeriod").addEventListener("click", () => shiftPeriod(1));
$("#periodSelect").addEventListener("change", (e) => {
if (e.target.value === "__new") {
renderPeriodSelect();
createNewPeriod().then((id) => { if (id) refresh(); });
return;
}
if (state.gran === "week") state.weekId = Number(e.target.value);
else state.monthIdx = Number(e.target.value);
renderPeriodSelect();
refresh();
});
$("#sideKeyOnly").addEventListener("change", (e) => { state.keyOnly = e.target.checked; refresh(); });
$("#btnIntake").addEventListener("click", () => setView("intake"));
$("#drawerClose").addEventListener("click", closeDrawer);
bindDrawerPeople();
$("#drawerMask").addEventListener("click", closeDrawer);
$("#modalMask").addEventListener("click", (e) => {
if (e.target.id === "modalMask" || e.target.dataset.mclose !== undefined) closeModal(false);
});
let t = null;
$("#globalSearch").addEventListener("input", (e) => {
clearTimeout(t);
const v = e.target.value.trim();
t = setTimeout(() => { state.keyword = v; refresh(); }, 320);
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if ($("#modalMask").classList.contains("open")) closeModal(false);
else closeDrawer();
});
}
/* ------------------------------------------- 界面状态(刷新后保持) */
const UI_KEY = "unis.ui.v1";
const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyOnly", "keyword",
"projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived",
"customTab", "customArchived", "customRegion", "staffArchived", "staffGroup"];
function saveUI() {
const o = {};
UI_FIELDS.forEach((k) => { o[k] = state[k]; });
try { sessionStorage.setItem(UI_KEY, JSON.stringify(o)); } catch (e) { /* 存不了就算了 */ }
}
function restoreUI() {
let o = null;
try { o = JSON.parse(sessionStorage.getItem(UI_KEY) || "null"); } catch (e) { o = null; }
if (!o) return;
UI_FIELDS.forEach((k) => { if (k in o) state[k] = o[k]; });
if (["board", "projects", "custom", "focus", "staff", "intake", "settings"].indexOf(state.view) < 0) state.view = "board";
if (state.gran !== "month") state.gran = "week";
if (state.focusFilter === "later") state.focusFilter = "week";
}
async function main() {
restoreUI();
state.meta = await api("/meta");
await loadStaff();
await loadPeriods();
await loadCategories();
bindShell();
// 恢复出来的视图状态要回写到顶栏/侧栏控件上
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === state.gran));
$("#sideKeyOnly").checked = !!state.keyOnly;
$("#globalSearch").value = state.keyword || "";
setView(state.view);
}
main().catch((e) => {
document.body.insertAdjacentHTML("beforeend", `<div class="notice err" style="margin:20px">初始化失败:${esc(e.message)}</div>`);
});
})();