unis_manager/web/app.js

3381 lines
176 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,
// 定开:项目明细 / 本周进展 两个 tab(原交付流水线 / 阶段看板 / 财务跟踪已下线)
customTab: "list",
customRegion: "",
customStage: "",
customStatus: "",
customOrigin: "",
customArchived: "false",
customRows: [],
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" />`;
}
/** 绑定页内搜索。
默认:防抖实时搜索;opts.enter=true 时只在「回车 / 清空(×)」后才查询。
重绘后把光标放回搜索框,连续操作不中断。 */
function bindKeyword(id, opts) {
opts = opts || {};
const el = $("#" + id);
if (!el) return;
const apply = (v) => {
v = (v || "").trim();
if (v === state.keyword) return;
keywordFocus = true;
state.keyword = v;
$("#globalSearch").value = v;
refresh();
};
if (opts.enter) {
// 输入阶段不查询;回车提交后才检索
el.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); apply(el.value); }
});
// type=search 的清除「×」会触发 search 事件
el.addEventListener("search", () => apply(el.value));
} else {
let t = null;
el.addEventListener("input", (e) => {
clearTimeout(t);
const v = e.target.value.trim();
t = setTimeout(() => apply(v), 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="${esc(o.requirePlaceholder || "逐字输入项目名称")}" 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", { enter: true });
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>`;
}
/* ---- 市场台账(《定开项目.xlsx》)的小色块 ---- */
const CUSTOM_STATUS_COLOR = {
已验收: "#16a34a",
交付完成: "#0891b2",
交付中: "#2563eb",
待开发: "#b45309",
退单: "#7a8394",
};
/** 项目状态:已验收/交付完成/交付中/待开发/退单 */
function statusPill(code) {
if (!code) return `<span style="color:var(--muted)">—</span>`;
const color = CUSTOM_STATUS_COLOR[code] || "#7a8394";
return `<span class="pill" style="background:${color}1a;color:${color}">${esc(code)}</span>`;
}
/** 风险值:1.高风险 红 / 2.低风险 绿 */
function riskPill(code) {
if (!code) return `<span style="color:var(--muted)">—</span>`;
const high = String(code).includes("高");
const color = high ? "#dc2626" : "#16a34a";
const text = String(code).replace(/^\d+\./, "");
return `<span class="pill" style="background:${color}1a;color:${color}">${esc(text)}</span>`;
}
let keywordFocus = false;
/** 定开「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */
const CUSTOM_ORIGINS = ["新签订单", "存量项目", "汇智直签", "提前交付", "其他"];
const CUSTOM_PLACED = ["新签订单", "存量项目", "汇智直签"];
const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0;
/** 年度计收目标(万元),来源《年度任务.xlsx》(Q1 35 / Q2 225 / Q3 160 / Q4 260,合计 680) */
const CUSTOM_ANNUAL_TARGETS = { 1: 35, 2: 225, 3: 160, 4: 260 };
async function renderCustom() {
saveUI();
const host = $("#view-custom");
host.innerHTML = `<div class="empty">加载中…</div>`;
if (!state.customMeta) state.customMeta = await api("/custom/meta");
// 一次性取「全部定开项目」(不带搜索/筛选条件):
// 列表在本地按关键字/区域/状态筛选,而统计条始终基于全部数据。
const allRows = await api("/custom/projects?archived=" + state.customArchived);
state.customRows = allRows;
// 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域
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 STATUS_OPTIONS = ["已验收", "待开发", "交付完成", "交付中", "退单"];
const statusFilterHtml = `<option value="">全部状态</option>` +
STATUS_OPTIONS.map((s) => `<option value="${esc(s)}" ${s === state.customStatus ? "selected" : ""}>${esc(s)}</option>`).join("");
const originFilterHtml = `<option value="">全部归属</option>` +
CUSTOM_ORIGINS.map((o) => `<option value="${esc(o)}" ${o === state.customOrigin ? "selected" : ""}>${esc(o)}</option>`).join("");
const isList = state.customTab !== "weekly" && state.customTab !== "annual";
const toolbar = `
<div class="board-toolbar">
<button class="chip ${state.customTab === "list" ? "active" : ""}" data-ctab="list">项目总览</button>
<button class="chip ${state.customTab === "weekly" ? "active" : ""}" data-ctab="weekly">本周进展</button>
<button class="chip ${state.customTab === "annual" ? "active" : ""}" data-ctab="annual">年度任务</button>
<span style="flex:1"></span>
${isList ? `
${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)}
<select id="cOrigin" class="sel" style="width:130px">${originFilterHtml}</select>
<select id="cRegion" class="sel" style="width:132px">${regionFilterHtml}</select>
<select id="cStatus" class="sel" style="width:140px">${statusFilterHtml}</select>
<button class="btn sm" id="btnExportCustom">导出 Excel</button>
<button class="btn sm primary" id="btnNewCustom">${Ico.svg("plus")}新建定开项目</button>
<span class="period-hint">全部 ${allRows.length} 个项目</span>`
: state.customTab === "annual"
? `<span class="period-hint">计收按「验收时间」、签单按「下单时间」归属季度</span>`
: `<span class="period-hint">${esc(scopeLabel())} · 共 ${allRows.length} 个项目</span>`}
</div>`;
host.innerHTML = toolbar + (
state.customTab === "weekly" ? renderCustomWeekly(allRows)
: state.customTab === "annual" ? renderCustomAnnual(allRows)
: renderCustomList(allRows));
host.querySelectorAll("[data-ctab]").forEach((b) =>
b.addEventListener("click", () => { state.customTab = b.dataset.ctab; renderCustom(); })
);
const os = $("#cOrigin");
if (os) os.addEventListener("change", () => { state.customOrigin = os.value; renderCustom(); });
const rs = $("#cRegion");
if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); });
const st = $("#cStatus");
if (st) st.addEventListener("change", () => { state.customStatus = st.value; renderCustom(); });
if (isList) bindKeyword("cKeyword", { enter: true });
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.customOrigin) q.push("origin=" + encodeURIComponent(state.customOrigin));
if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion));
if (state.customStatus) q.push("status=" + encodeURIComponent(state.customStatus));
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
download("/api/export/custom?" + q.join("&"));
});
bindCustomList(host);
}
function renderCustomList(allRows) {
// 列表:按 关键字 + 归属 + 区域 + 状态 在本地筛选;并按「更新时间」倒序
const kw = (state.keyword || "").trim();
let list = allRows;
if (kw) list = list.filter((p) => String(p.name || "").includes(kw));
if (state.customOrigin) list = list.filter((p) => (p.origin || "") === state.customOrigin);
if (state.customRegion) list = list.filter((p) => (p.region || "") === state.customRegion);
if (state.customStatus) list = list.filter((p) => (p.project_status || "") === state.customStatus);
const view = list.slice().sort((a, b) =>
String(b.update_date || "").localeCompare(String(a.update_date || ""))
);
// ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ----
const placed = allRows.filter(isPlaced); // 已下单 = 新签订单 / 存量项目 / 汇智直签
const today = new Date();
const Y = today.getFullYear();
const M = today.getMonth() + 1;
const Q = Math.floor((M - 1) / 3) + 1; // 当前季度 1~4
const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0);
// 取日期里的「年-月」;像「2024年3月前」这种文本不参与季度/年度判定
const ymOf = (d) => {
const s = String(d || "");
return /^\d{4}-\d{2}/.test(s) ? { y: Number(s.slice(0, 4)), m: Number(s.slice(5, 7)) } : null;
};
const inYear = (d) => { const t = ymOf(d); return !!t && t.y === Y; };
const inQuarter = (d) => { const t = ymOf(d); return !!t && t.y === Y && Math.floor((t.m - 1) / 3) + 1 === Q; };
const signedQuarter = placed.filter((p) => inQuarter(p.sign_date));
const signedYear = placed.filter((p) => inYear(p.sign_date));
// 验收:以「验收时间」(accept_date,季度文本 Q1~Q4) 为准;本季度 = 当前季度
const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; };
const thisQ = "Q" + Q;
const accRows = allRows.filter((p) => qOf(p.accept_date)); // 有「验收时间」即视为已验收
const accQuarter = accRows.filter((p) => qOf(p.accept_date) === thisQ);
const accYear = accRows;
const stat = (label, count, amount, color) =>
`<div class="stat" style="--c:${color}"><div class="stat-num">${count}<span class="stat-unit">个</span> · <span class="stat-money">${fen(amount)}</span><span class="stat-unit">万</span></div><div class="stat-label">${label}</div></div>`;
const statBar = `<div class="stat-row">
${stat("已下单项目 / 合同额", placed.length, sum(placed, "amount"), "var(--primary)")}
${stat(`本季度下单(${Y}年Q${Q})`, signedQuarter.length, sum(signedQuarter, "amount"), "var(--violet)")}
${stat(`本年新增下单(${Y}年)`, signedYear.length, sum(signedYear, "amount"), "var(--violet)")}
${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "var(--amber)")}
${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "var(--amber)")}
</div>
<div class="stat-note">「已下单项目 / 合同额」与「本季度·本年下单」只统计 <b>新签订单 / 存量项目 / 汇智直签</b>(提前交付不计入下单);统计基于全部项目,不受搜索 / 筛选影响。</div>`;
const manDays = view.reduce((a, p) => a + Number(p.man_days || 0), 0);
const highRisk = view.filter((p) => String(p.risk_level || "").includes("高")).length;
const accepted = view.filter((p) => p.project_status === "已验收").length;
return `<div class="card">
<div class="card-head">
<h4>定开项目总览<span class="sub">按「更新时间」倒序 · 共 ${view.length} 个${state.customStatus ? "(" + esc(state.customStatus) + ")" : ""} · 列表只读,改数据请点「修改」</span></h4>
<span class="period-hint">人天 ${manDays.toLocaleString()} · 高风险 ${highRisk} 个 · 已验收 ${accepted} 个</span>
<div class="inline-fields">
<button class="btn sm danger" id="btnPurgeWeek" title="删除「更新时间」在本周(周一~周日)内的定开项目">删除本周更新</button>
</div>
</div>
${statBar}
${view.length ? customTableHtml(view) : `<div class="empty">${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}</div>`}
</div>`;
}
/** 定开项目表格(「项目明细」与「本周进展」两个 tab 共用) */
function customTableHtml(view, withOps) {
const ops = withOps !== false;
return `<div class="tbl-wrap">
<table class="tbl">
<thead><tr>
<th style="min-width:200px">项目名称</th>
<th>项目ID</th>
<th>下单时间</th>
<th>项目归属</th>
<th>办事处</th>
<th>行业</th>
<th class="num">市场下单人天</th>
<th class="num">订单金额</th>
<th>市场责任人</th>
<th>项目状态</th>
<th class="num">进度</th>
<th>验收时间</th>
<th>推动验收计划</th>
<th>风险值</th>
<th>备注</th>
<th>更新时间</th>
${ops ? `<th class="col-ops">操作</th>` : ""}
</tr></thead>
<tbody>
${view.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.project_code || "—")}</td>
<td class="nowrap">${p.sign_date ? esc(p.sign_date) : "—"}</td>
<td class="nowrap">${esc(p.origin || "—")}</td>
<td class="nowrap">${esc(p.office || "—")}</td>
<td class="nowrap">${esc(p.industry || "—")}</td>
<td class="num">${p.man_days == null ? "—" : Math.round(p.man_days).toLocaleString()}</td>
<td class="num">${money(p.amount)}</td>
<td class="nowrap">${esc(p.market_owner || "—")}</td>
<td class="nowrap">${statusPill(p.project_status)}</td>
<td class="num">${(p.project_status === "交付中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"}</td>
<td class="nowrap">${p.accept_date ? esc(p.accept_date) : "—"}</td>
<td>${esc(p.accept_plan || "—")}</td>
<td class="nowrap">${riskPill(p.risk_level)}</td>
<td>${esc((p.remark || "").slice(0, 40)) || "—"}</td>
<td class="nowrap">${esc(p.update_date || "—")}</td>
${ops ? `<td class="nowrap col-ops"><button class="btn sm" data-cedit="${p.id}">修改</button></td>` : ""}
</tr>`).join("")}
</tbody>
</table>
</div>`;
}
/** 本周进展:更新时间落在本周(周一~周日)的项目;并按 验收/下单/提前交付/进行中 分类统计 */
function renderCustomWeekly(allRows) {
const d = new Date();
const dow = (d.getDay() + 6) % 7; // 周一 = 0
const mon = new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow);
const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6);
const iso = (x) => x.getFullYear() + "-" + String(x.getMonth() + 1).padStart(2, "0") + "-" + String(x.getDate()).padStart(2, "0");
const start = iso(mon), end = iso(sun);
const dayOf = (v) => String(v || "").slice(0, 10);
const inWeek = (v) => { const s = dayOf(v); return !!s && s >= start && s <= end; };
const week = allRows.filter((p) => inWeek(p.update_date)); // 更新时间在本周
const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0);
const acc = week.filter((p) => p.project_status === "已验收");
// 本周新增项目:按「下单时间」在本周的新下单 / 提前交付项目(不是「本周更新」)
const newDeal = allRows.filter((p) => inWeek(p.sign_date) && (isPlaced(p) || p.origin === "提前交付"));
// 交付项目:本周更新的「交付中 + 交付完成」
const doing = week.filter((p) => p.project_status === "交付中" || p.project_status === "交付完成");
const stat = (label, rows, color) =>
`<div class="stat" style="--c:${color}"><div class="stat-num">${rows.length}<span class="stat-unit">个</span> · <span class="stat-money">${fen(sum(rows, "amount"))}</span><span class="stat-unit">万</span></div><div class="stat-label">${label}</div></div>`;
const statBar = `<div class="stat-row">
${stat("验收情况", acc, "var(--amber)")}
${stat("本周新增项目", newDeal, "var(--violet)")}
${stat("交付项目", doing, "var(--primary)")}
</div>
<div class="stat-note">本周 = <b>${start} ~ ${end}</b>;<b>本周新增项目</b> = 按「下单时间」在本周的新下单 / 提前交付项目(已下单 + 提前交付);验收情况 / 交付项目 为「更新时间」在本周的项目(交付项目 = 交付中 + 交付完成)。</div>`;
return `<div class="card">
<div class="card-head">
<h4>本周进展<span class="sub">更新时间在本周(${start} ~ ${end})· 共 ${week.length} 个项目 · 列表只读,改数据请点项目名称</span></h4>
<span class="period-hint">验收 ${acc.length} · 新增 ${newDeal.length} · 交付 ${doing.length}</span>
</div>
${statBar}
${week.length
? customTableHtml(week.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), false)
: `<div class="empty">本周(${start} ~ ${end})没有更新的定开项目</div>`}
</div>`;
}
/** 年度任务:季度计收目标(《年度任务.xlsx》)+ 按「验收时间 / 下单时间」回填的完成数据 */
function renderCustomAnnual(allRows) {
const Y = new Date().getFullYear();
const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; };
const signQ = (d) => {
const s = String(d || "");
if (!/^\d{4}-\d{2}/.test(s) || Number(s.slice(0, 4)) !== Y) return 0;
return Math.floor((Number(s.slice(5, 7)) - 1) / 3) + 1;
};
const sumW = (arr) => arr.reduce((a, p) => a + Number(p.amount || 0), 0) / 10000; // 元 → 万元
const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const rows = [1, 2, 3, 4].map((q) => {
const acc = allRows.filter((p) => qOf(p.accept_date) === "Q" + q); // 计收:验收项目
const sign = allRows.filter((p) => isPlaced(p) && signQ(p.sign_date) === q); // 签单:下单项目(已下单)
const target = CUSTOM_ANNUAL_TARGETS[q] || 0;
const accAmt = sumW(acc);
return { q, target, accN: acc.length, accAmt, rate: target ? (accAmt / target) * 100 : null, signN: sign.length, signAmt: sumW(sign) };
});
const tot = rows.reduce((a, r) => ({
target: a.target + r.target, accN: a.accN + r.accN, accAmt: a.accAmt + r.accAmt,
signN: a.signN + r.signN, signAmt: a.signAmt + r.signAmt,
}), { target: 0, accN: 0, accAmt: 0, signN: 0, signAmt: 0 });
tot.rate = tot.target ? (tot.accAmt / tot.target) * 100 : null;
const tr = (label, r, strong) => `
<tr${strong ? ' style="font-weight:600"' : ""}>
<td class="nowrap">${label}</td>
<td class="num">${fmt(r.target)}</td>
<td class="num">${r.accN}</td>
<td class="num">${fmt(r.accAmt)}</td>
<td class="num">${r.rate == null ? "—" : r.rate.toFixed(1) + "%"}</td>
<td class="num">${r.signN}</td>
<td class="num">${fmt(r.signAmt)}</td>
</tr>`;
return `<div class="card">
<div class="card-head">
<h4>年度任务(${Y}年)<span class="sub">计收目标取自《年度任务.xlsx》;完成数据按时间自动回填</span></h4>
<span class="period-hint">已验收 ${tot.accN} 个 · 新增签单 ${tot.signN} 个</span>
</div>
<div class="tbl-wrap">
<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>
${rows.map((r) => tr("Q" + r.q, r, false)).join("")}
${tr("合计", tot, true)}
</tbody>
</table>
</div>
<div class="stat-note">计收项目 = 按「验收时间」归属季度的项目;新增签单 = 按「下单时间」归属本年度季度、且项目归属为已下单(新签订单 / 存量项目 / 汇智直签)的项目;金额由「元」换算为「万元」;计收完成率 = 计收项目金额 ÷ 计收目标。</div>
</div>`;
}
/** 项目明细表:列表只读,行内只有「修改」;名称可点开详情 */
function bindCustomList(host) {
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"))
);
const bp = $("#btnPurgeWeek");
if (bp) bp.addEventListener("click", purgeWeek);
}
/** 删除本周(周一~周日)更新的定开项目:先预览数量,二次确认后执行。 */
async function purgeWeek() {
let pv;
try {
pv = await api("/custom/weekly-preview");
} catch (e) {
return toast("读取本周更新失败:" + e.message);
}
if (!pv.count) {
return toast("本周(" + pv.start + " ~ " + pv.end + ")没有更新的定开项目");
}
const ok = await confirmDialog({
title: "删除本周更新的定开项目",
okText: "确认删除 " + pv.count + " 个",
danger: true,
requireText: "删除",
requirePlaceholder: "输入「删除」以确认",
body:
`<p style="margin:0 0 10px">将删除<b>更新时间在本周(${esc(pv.start)} ~ ${esc(pv.end)})</b>内的定开项目:` +
`<b>${pv.count} 个</b>,合同额合计 <b>${fen(pv.amount)} 万</b>。</p>` +
`<div class="notice warn">此操作会连同项目的更新记录一起删除,且<b>不可撤销</b>。` +
`若只是想标记,请在详情里改状态。</div>` +
(pv.names && pv.names.length
? `<div class="notice info" style="max-height:160px;overflow:auto">${pv.names.map((n) => esc(n)).join("、")}${pv.count > pv.names.length ? " …" : ""}</div>`
: ""),
});
if (!ok) return;
try {
const r = await post("/custom/purge-week", {});
toast("已删除本周更新的 " + r.deleted + " 个定开项目");
afterWrite();
} catch (e) {
toast("删除失败:" + e.message);
}
}
/**
* 新建定开项目 —— 全站唯一的新建入口。
* 「定开项目」工具栏和「更新录入」页都调用它,避免两套录入逻辑。
*/
function openNewCustomDialog(onDone) {
const today = new Date();
const iso = today.getFullYear() + "-" +
String(today.getMonth() + 1).padStart(2, "0") + "-" +
String(today.getDate()).padStart(2, "0");
// 选项直接对齐《定开项目.xlsx》台账字段取值
const opts = (list, sel) =>
list.map((v) => `<option value="${esc(v)}" ${v === sel ? "selected" : ""}>${esc(v)}</option>`).join("");
const originOpts = opts(CUSTOM_ORIGINS, "新签订单");
const industryOpts = opts(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], "");
const statusOpts = opts(["待开发", "交付中", "交付完成", "已验收", "退单"], "待开发");
const riskOpts = opts(["1.高风险", "2.低风险"], "2.低风险");
const numVal = (sel) => ($(sel).value === "" ? null : Number($(sel).value));
const txtVal = (sel) => $(sel).value.trim() || null;
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">字段与《定开项目.xlsx》台账一致;<b>项目名称</b>必填,其余可建档后补。区域按「办事处」自动归集。</div>
<div class="form-grid">
<div class="form-row"><label>项目名称 *</label><input class="inp" id="ncName" data-autofocus placeholder="如:重庆高新三期" style="width:100%" /></div>
<div class="form-row"><label>项目归属</label><select class="sel" id="ncOrigin" style="width:100%">${originOpts}</select></div>
<div class="form-row"><label>项目ID</label><input class="inp" id="ncCode" placeholder="市场编号" style="width:100%" /></div>
<div class="form-row"><label>下单时间</label><input class="inp" id="ncSign" type="date" value="${iso}" style="width:100%" /></div>
<div class="form-row"><label>办事处</label><input class="inp" id="ncOffice" placeholder="如:黑龙江" style="width:100%" /></div>
<div class="form-row"><label>行业</label><select class="sel" id="ncIndustry" style="width:100%">${industryOpts}</select></div>
<div class="form-row"><label>市场下单人天</label><input class="inp" id="ncManDays" type="number" min="0" step="0.5" 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><input class="inp" id="ncMarketOwner" placeholder="多人用 / 分隔" style="width:100%" /></div>
<div class="form-row"><label>项目状态</label><select class="sel" id="ncStatus" style="width:100%">${statusOpts}</select></div>
<div class="form-row"><label>进度(%)</label><input class="inp" id="ncProgress" type="number" min="0" max="100" step="5" placeholder="交付中项目填 0~100" style="width:100%" /></div>
<div class="form-row"><label>验收时间</label><input class="inp" id="ncAcceptQuarter" placeholder="季度文本,如 Q3" style="width:100%" /></div>
<div class="form-row"><label>推动验收计划</label><input class="inp" id="ncAcceptPlan" placeholder="如 Q4 / 27年" style="width:100%" /></div>
<div class="form-row"><label>风险值</label><select class="sel" id="ncRisk" style="width:100%">${riskOpts}</select></div>
<div class="form-row"><label>验收日期</label><input class="inp" id="ncAcceptReal" type="date" title="真实验收日期,用于本月/本年验收统计" style="width:100%" /></div>
<div class="form-row full"><label>备注</label><textarea class="inp" id="ncRemark" rows="2" style="width:100%"></textarea></div>
<div class="form-row full"><label>重点项目</label><label class="switch"><input type="checkbox" id="ncKey" /> 标记为重点(★,看板置顶)</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>`
);
const submit = async () => {
const name = txtVal("#ncName");
if (!name) return modalError("请填写项目名称");
const btn = $("#mOk");
btn.disabled = true;
try {
const p = await post("/custom/projects", {
name: name,
origin: txtVal("#ncOrigin"),
project_code: txtVal("#ncCode"),
sign_date: txtVal("#ncSign"),
office: txtVal("#ncOffice"),
industry: txtVal("#ncIndustry"),
man_days: numVal("#ncManDays"),
progress: numVal("#ncProgress") == null ? 0 : numVal("#ncProgress"),
amount: numVal("#ncAmount"),
market_owner: txtVal("#ncMarketOwner"),
project_status: txtVal("#ncStatus"),
accept_date: txtVal("#ncAcceptQuarter"),
accept_plan: txtVal("#ncAcceptPlan"),
risk_level: txtVal("#ncRisk"),
accept_date_real: txtVal("#ncAcceptReal"),
is_key: $("#ncKey").checked,
remark: txtVal("#ncRemark"),
});
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 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 optList = (list, sel) => list.map((v) => `<option value="${esc(v)}" ${v === sel ? "selected" : ""}>${esc(v)}</option>`).join("");
const originOpts = optList(CUSTOM_ORIGINS, p.origin);
const industryOpts = optList(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], p.industry);
const statusOpts = optList(["待开发", "交付中", "交付完成", "已验收", "退单"], p.project_status);
const riskOpts = optList(["1.高风险", "2.低风险"], p.risk_level);
// 市场台账(来源《定开项目.xlsx》):详情只读,修改模式给输入框
const numOrDash = (v) => (v === null || v === undefined ? "—" : String(Math.round(v)));
const ledgerSec = `
<div class="sec">
<div class="sec-title">定开项目信息<span class="sec-kind ${mode === "edit" ? "" : "ro"}">${mode === "edit" ? "编辑" : "只读"}</span></div>
${mode === "edit" ? `
<div class="form-row"><label>项目归属</label><select class="sel" id="cdOrigin" style="width:100%">${originOpts}</select></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>项目ID</label><input class="inp" id="cdCode" value="${esc(p.project_code || "")}" style="width:100%" /></div>
<div class="form-row"><label>下单时间</label><input class="inp" id="cdSign" value="${esc(p.sign_date || "")}" style="width:100%" placeholder="YYYY-MM-DD 或季度文本" /></div>
<div class="form-row"><label>办事处</label><input class="inp" id="cdOffice" value="${esc(p.office || "")}" style="width:100%" /></div>
<div class="form-row"><label>行业</label><select class="sel" id="cdIndustry" style="width:100%">${industryOpts}</select></div>
<div class="form-row"><label>市场下单人天</label><input class="inp" id="cdManDays" type="number" min="0" step="0.5" value="${p.man_days == null ? "" : p.man_days}" style="width:100%" /></div>
<div class="form-row"><label>订单金额(元)</label><input class="inp" id="cdAmount" type="number" min="0" value="${p.amount == null ? "" : p.amount}" style="width:100%" /></div>
<div class="form-row"><label>市场责任人</label><input class="inp" id="cdMarketOwner" value="${esc(p.market_owner || "")}" style="width:100%" placeholder="多人用 / 分隔" /></div>
<div class="form-row"><label>项目状态</label><select class="sel" id="cdStatus" style="width:100%">${statusOpts}</select></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" step="5" value="${p.progress == null ? "" : Math.round(p.progress)}" style="width:100px" /> %</div></div>
<div class="form-row"><label>验收时间</label><input class="inp" id="cdAcceptQuarter" value="${esc(p.accept_date || "")}" style="width:100%" placeholder="季度文本,如 Q3" /></div>
<div class="form-row"><label>推动验收计划</label><input class="inp" id="cdAcceptPlan" value="${esc(p.accept_plan || "")}" style="width:100%" placeholder="如 Q4 / 27年" /></div>
<div class="form-row"><label>风险值</label><select class="sel" id="cdRisk" style="width:100%">${riskOpts}</select></div>
<div class="form-row full"><label>验收日期</label><input class="inp" id="cdAcceptReal" type="date" value="${esc(p.accept_date_real || "")}" style="width:100%" title="真实验收日期,用于本月/本年验收统计" /></div>
<div class="form-row full"><label>备注</label><textarea class="inp" id="cdRemark" rows="2" style="width:100%">${esc(p.remark || "")}</textarea></div>
<div class="form-row full"><label>更新时间</label><div class="ro">${esc(p.update_date || "—")}<span style="font-size:12px;color:var(--muted);margin-left:10px">跟随「下单时间」</span></div></div>
` : `
${ro("项目归属", esc(p.origin || "—"))}
${ro("项目ID", esc(p.project_code || "—"))}
${ro("办事处", esc(p.office || "—"))}
${ro("行业", esc(p.industry || "—"))}
${ro("下单时间", esc(p.sign_date || "—"))}
${ro("市场下单人天", numOrDash(p.man_days))}
${ro("市场责任人", esc(p.market_owner || "—"))}
${ro("项目状态", statusPill(p.project_status))}
${ro("进度", (p.project_status === "交付中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—")}
${ro("验收时间", esc(p.accept_date || "—"))}
${ro("推动验收计划", esc(p.accept_plan || "—"))}
${ro("风险值", riskPill(p.risk_level))}
${ro("更新时间", esc(p.update_date || "—"))}
`}
</div>`;
// 基础信息:只读展示(编辑请用上方「定开项目信息」表单)
const baseSec = `
<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>` : ""}
${ledgerSec}
${mode === "view" ? `
${baseSec}
<div class="sec">
<div class="sec-title">收入确认<span class="sec-kind ro">计算值 · 只读</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>
</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");
btn.disabled = true;
err.style.display = "none";
try {
await patch("/custom/projects/" + cid, {
name: $("#cdName").value.trim(),
origin: $("#cdOrigin").value.trim() || null,
project_code: $("#cdCode").value.trim() || null,
sign_date: $("#cdSign").value || null,
office: $("#cdOffice").value.trim() || null,
industry: $("#cdIndustry").value.trim() || null,
man_days: $("#cdManDays").value === "" ? null : Number($("#cdManDays").value),
amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value),
market_owner: $("#cdMarketOwner").value.trim() || null,
project_status: $("#cdStatus").value.trim() || null,
progress: $("#cdProgress").value === "" ? 0 : Number($("#cdProgress").value),
accept_date: $("#cdAcceptQuarter").value.trim() || null,
accept_plan: $("#cdAcceptPlan").value.trim() || null,
risk_level: $("#cdRisk").value.trim() || null,
accept_date_real: $("#cdAcceptReal").value || null,
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", "customOrigin", "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>`);
});
})();