/* 软件开发部 · 关键任务追踪看板 */
(function () {
"use strict";
const Ico = window.Icon;
const state = {
view: "board",
gran: "week",
weekId: null,
monthIdx: null,
categoryId: null,
keyOnly: false,
keyword: "",
idle: "key",
showArchived: false,
meta: null,
periods: null,
categories: [],
board: null,
stats: null,
projects: [],
drawerProject: null,
// 详情抽屉:周期反馈的显示粒度 / 范围 / 往前翻页偏移
dw: { group: "week", range: 4, back: 0 },
// 定制交付
customMeta: null,
customTab: "pipeline",
customRegion: "",
customStage: "",
customArchived: "false",
customRows: [],
customBoard: null,
customStats: null,
intakeType: "general",
// 更新录入的两种方式:direct = 直接写入(原样存 1 条);ai = AI 整理(补结构化字段,也只存 1 条)
intakeMode: "direct",
intakePeriodId: null,
drawerCustom: null,
// 周看板(左栏总览 + 右栏详情)
weekly: null,
weeklySel: null,
weeklyOwner: "",
weeklyAllCustom: false,
// 项目管理
projOwner: "",
projIdleOnly: false,
// 每周重点工作
focusRows: [],
focusFilter: "week",
focusArchived: false,
// 人力资源库
staff: [],
staffDict: { groups: [], affiliations: [] },
staffArchived: "false",
staffGroup: "",
};
/* ---------------------------------------------------------------- utils */
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel));
function esc(s) {
if (s === null || s === undefined) return "";
return String(s)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
const nl2br = (s) => esc(s).replace(/\n/g, "
");
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) =>
`${Ico.svg("star")}`;
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 `
${esc(o.title)}${o.sub ? `${esc(o.sub)}` : ""}
${o.summary ? `
${o.summary}` : ""}
${o.fields ? `
${o.fields}
` : ""}
${o.rows.length ? `
${o.table}
` : `
${o.empty}
`}
`;
}
/** 页内搜索框:与右上角全局搜索共用 state.keyword */
function keywordInputHtml(id, placeholder, width) {
return ``;
}
/** 绑定页内搜索:防抖取数,重绘后把光标放回搜索框,连续打字不中断 */
function bindKeyword(id) {
const el = $("#" + id);
if (!el) return;
let t = null;
el.addEventListener("input", (e) => {
clearTimeout(t);
const v = e.target.value.trim();
t = setTimeout(() => {
if (v === state.keyword) return;
keywordFocus = true;
state.keyword = v;
$("#globalSearch").value = v;
refresh();
}, 320);
});
if (keywordFocus) {
keywordFocus = false;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
}
/* ---------------------------------------------------- 弹窗 / 确认框 */
let modalResolve = null;
function openModal(html, onMount) {
$("#modalMask").classList.add("open");
$("#modal").innerHTML = html;
if (onMount) onMount();
const f = $("#modal").querySelector("[data-autofocus]");
if (f) f.focus();
}
/** 关闭弹窗;result 省略时按「取消」处理 */
function closeModal(result) {
if (!$("#modalMask").classList.contains("open")) return;
$("#modalMask").classList.remove("open");
$("#modal").innerHTML = "";
const cb = modalResolve;
modalResolve = null;
if (cb) cb(result === undefined ? false : result);
}
function modalError(msg) {
const e = $("#mErr");
if (!e) return;
e.textContent = msg || "";
e.style.display = msg ? "" : "none";
}
/** 需要用户明确点确认才继续;requireText 时还必须逐字输入该文本 */
function confirmDialog(o) {
return new Promise((resolve) => {
openModal(
`${esc(o.title)}
${o.body || ""}
${
o.requireText
? `
`
: ""
}
`
);
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 `${Ico.status(code)}${esc(s.name)}`;
}
function catPill(cat) {
if (!cat) return `未分类`;
return `${esc(cat.name)}`;
}
/** 规范省级行政区下拉(按大区分组)。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 ? "" : ``;
for (const g of groups) {
html += ``;
}
if (selected && all.indexOf(selected) < 0) {
html += ``;
}
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 = ``;
}
/**
* 多负责人 / 团队成员的可编辑名单:人名下拉(选项来自人力资源库)横向排开、自动换行,
* 不分主次,每个人都能删;「+ 添加」紧跟在名单末尾。
* 增删按钮的事件统一委托在 #drawerBody 上(见 bindDrawerPeople)。
*/
function peopleEditorHtml(id, label, values, opts) {
const list = values && values.length ? values : [""];
const rows = list
.map(
(n) => `
${ownerSelectHtml(n, 'style="width:118px"')}
`,
)
.join("");
return `
`;
}
/** 读回名单里的人名(按行序,空行忽略) */
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"') +
``;
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) => `${esc(n)}`).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 += `${esc(d.label)}: ${d.value}${esc(opts.unit || "")}`;
if (d.value > 0) {
bars += `${d.value}`;
}
bars += `${esc(d.label)}`;
});
let grid = "";
for (let g = 0; g <= 3; g++) {
const y = padT + (innerH / 3) * g;
grid += ``;
grid += `${Math.round(max - (max / 3) * g)}`;
}
return ``;
}
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 += `${esc(d.name)}: ${d.value}`;
acc += len;
});
return ``;
}
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 += `${esc(d.name)}: ${d.count}`;
x += w;
});
return ``;
}
function barList(rows) {
return rows.map((r) => `
${r.is_key ? starMark(true) + " " : ""}${esc(r.name)}
${r.progress}${r.estimated ? "*" : ""}
`).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) => ``)
.join("") + ``;
} else {
sel.innerHTML = state.periods.months
.map((m, i) => ``)
.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 `
${Ico.badge("layers")}
在管项目
${k.project_total}
${esc(scopeLabel())} · 执行记录
${k.update_count}
${Ico.badge("gauge")}
平均进度
${k.avg_progress}%
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
${Ico.badge("alert-circle")}
重点项目未更新
${s.idle_key.length}
周期更新活跃度近 ${trend.length} 个周期的执行记录条数
${barChart(trend, { unit: " 条" })}
项目分类分布
${donutChart(s.category_dist.map((c) => ({ name: c.name, color: c.color, value: c.count })))}
${s.category_dist.map((c) => `
${esc(c.name)} ${c.count}
`).join("")}
执行状态分布${esc(scopeLabel())}
${stackBar(s.status_dist)}
${s.status_dist.map((x) => `${esc(x.name)} ${x.count}`).join("")}
进度领先 Top 10
${barList(s.top_progress)}
风险与阻塞${s.risks.length} 条
${s.risks.length ? s.risks.map((r) => `
${esc(r.project_name)}${statusPill(r.status)}
${esc(r.period_label || "")}
${nl2br(r.risk || r.content || "")}
`).join("") : `
本周期暂无风险上报
`}
低进度预警进度 < 30% 且未完成
${s.low_progress.length ? barList(s.low_progress) : `
暂无
`}
最近更新
${s.recent_updates.length ? s.recent_updates.map((u) => `
${esc(u.project_name)}${statusPill(u.status)}${esc(u.task_name || "")}${esc(u.period_label || "")}
${nl2br((u.summary || u.content || "").slice(0, 160))}
`).join("") : `
暂无
`}
`;
}
/* ---------------------------------------------------------------- 周看板 */
/** 左栏一行:重点项目 / 定制项目共用 */
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
? `${r.update_count} 条`
: `未更新`;
return `
${r.is_key ? starMark(true) : ""}${esc(r.name)}
${badge}
${r.kind === "project" ? statusPill(r.status) : stagePill(r.stage)}
${esc(r.owner || "未指派")}
${r.kind === "project"
? `${r.progress}%`
: `${fen(r.amount)} 万`}
${r.risks.length ? `风险 ${r.risks.length}` : ""}
`;
}
/** 交付环节步骤条:日期可直接补录,补了日期后端会自动推进商务阶段 */
function pipelineBar(p, readonly) {
const steps = (state.customMeta && state.customMeta.pipeline) || [];
return `
${steps.map((s, i) => `
${i ? `
${Ico.svg("chevron-right")}
` : ""}
${Ico.pipe(s.code)}${esc(s.name)}
${readonly
? `
${p[s.date_field] ? esc(p[s.date_field]) : "未填"}
`
: `
`}
`).join("")}
`;
}
/** 一条执行 / 交付记录 */
function updateItem(u) {
return `
${esc(u.task_name || u.period_full || "记录")}
${u.stage ? stagePill(u.stage) : statusPill(u.status)}
${u.progress !== null && u.progress !== undefined ? `${u.progress}%` : ""}
${u.period_full && u.period_full !== u.task_name ? `${esc(u.period_label || "")}` : ""}
${nl2br(u.summary || u.content || "(无正文)")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""}
${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`;
}
function weeklyHead(row) {
const isP = row.kind === "project";
const color = isP ? ((row.category && row.category.color) || "#2563eb") : stageMeta(row.stage).color;
return `
${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}
${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)}
${esc(row.owner || "未指派")}
最近更新 ${esc(row.last_update || "尚无记录")}
${row.idle_weeks ? `已 ${row.idle_weeks} 周没更新` : ""}
`;
}
/* 右栏:常规项目详情 */
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)}
本期执行记录(${row.updates.length})
${row.updates.length
? row.updates.map(updateItem).join("")
: `
本周期还没有执行记录。重点项目每周都要更新一次,点上方「录一笔执行记录」补上。
`}
进度与状态${esc(row.progress_source)} · 共 ${p.update_count} 条历史记录 / ${d.tasks.length} 个子任务
整体进度${row.progress}%
${d.tasks.length ? `
${d.tasks.map((t) => `${esc(t.name)}`).join("")}
` : ""}
${trend.length ? `
近 ${trend.length} 期趋势
${trend.map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${statusPill(t.status)}${t.progress !== null ? ` ${t.progress}%` : ""}
${t.updates.length} 条
`).join("")}
` : ""}`;
}
/* 右栏:定制项目详情(交付环节 + 财务) */
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)}
交付环节补录日期会自动推进商务阶段
${pipelineBar(p)}
${p.remark ? `` : ""}
财务跟踪
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
${Ico.badge("wallet")}
已计收
${wan(p.revenue_amount)}
${Ico.badge("hourglass")}
待计收
${wan(p.pending_amount)}
待开票 ${fen(p.pending_invoice)} 万 · 待回款 ${fen(p.pending_received)} 万
本期交付记录(${row.updates.length})
${row.updates.length
? row.updates.map(updateItem).join("")
: `
本周期没有交付记录。
`}
${hist.length ? `
更早的交付记录
${hist.map((u) => `
${esc((u.period_full || "").replace(/^\d+年/, ""))}
${stagePill(u.stage)}${u.revenue_delta ? ` 计收 ${fen(u.revenue_delta)}万` : ""}
`).join("")}
` : ""}`;
}
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 `
${r.is_key ? starMark(true) : ""}${esc(r.name)}
${tag || ""}
${stagePill(r.stage)}
${esc(r.owner || "未指派")}
${fen(r.amount)} 万
${r.period_revenue_delta ? `计收 ${fen(r.period_revenue_delta)} 万` : ""}
${r.risks.length ? `风险 ${r.risks.length}` : ""}
${r.summary ? `
${esc(String(r.summary).replace(/\s+/g, " ").slice(0, 90))}
` : ""}
`;
}
/** 右栏:定制项目本周动态。左栏的「定制项目」只给入口,清单都收在这里 */
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) => `
${title}${sub} · ${list.length} 个
${list.length ? list.map((r) => weeklyCustomRow(r, tag(r))).join("")
: `
本周期没有
`}
`;
return `
${Ico.badge("package")}定制项目 · 本周动态
新入库 ${added.length} 个
有交付动态 ${moved.length} 个
合同额合计 ${fen(sum)} 万
本期计收 ${fen(data.kpi.revenue_delta)} 万
${sec("本周新增", "入库时间落在本周期", added,
(r) => `${esc(r.entry_date || "新入库")}`)}
${sec("本周有变化", "本周期有交付记录", moved,
(r) => `${r.update_count} 条记录`)}
${state.weeklyAllCustom ? sec("其他在途项目", "本周没有动态", idle,
(r) => (r.is_key ? `重点` : "")) : ""}
${!state.weeklyAllCustom && idle.length
? `另有 ${idle.length} 个在途定制项目本周没有动态,勾选「列入全部在途」一并列出。
` : ""}`;
}
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) : `在左侧选择一条重点工作查看详情
`;
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 = `在左侧选择一个项目查看执行详情
`; return; }
box.innerHTML = `加载中…
`;
try {
box.innerHTML = row.kind === "project"
? await weeklyProjectDetail(row)
: await weeklyCustomDetail(row);
bindWeeklyDetail(box, row);
} catch (e) {
box.innerHTML = `加载失败:${esc(e.message)}
`;
}
}
async function renderBoard() {
saveUI();
const host = $("#view-board");
host.innerHTML = `加载中…
`;
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 = `
${esc(scopeLabel())} · 项目 ${data.projects.length} 个 · 定制动态 ${data.kpi.custom_new + data.kpi.custom_updated} 个
`;
const kpi = `
${Ico.badge("star")}
重点项目
${k.key_total}
${Ico.badge("alert-circle")}
重点项目未更新
${k.key_idle}
${Ico.badge("list-checks")}
本周重点工作
${focus.week.length}
${Ico.badge("activity")}
执行记录
${k.update_count}
${Ico.badge("wallet")}
定制合同额
${wan(k.custom_amount)}
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
`;
const group = (title, rows, tip) => `
${title}${rows.length}
${rows.length ? rows.map(weeklyItem).join("") : `
本周期没有记录
`}
`;
// 左栏顺序:本周工作重点 → 重点项目 → 定制项目(只给入口,点开在右栏看清单)
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 + `
本周工作重点${focusList.length}
${focusList.length ? focusList.map(focusItem).join("") : `
本周没有安排重点工作
`}
${group("重点项目", data.projects, "个已更新")}
定制项目${data.custom.length}
${Ico.svg("package")}
本周定制项目动态
${Ico.svg("arrow-right")}
新增 ${cNew.length}
有变化 ${cUpd.length}
${state.weeklyAllCustom ? `含全部在途` : ""}
`;
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",
`统计概览更新活跃度 / 分类分布 / 风险与阻塞
${html}
`);
}).catch((e) => {
host.insertAdjacentHTML("beforeend", `统计概览加载失败:${esc(e.message)}
`);
});
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) ? `延期` : "") +
statusPill(f.status);
}
/** 工作看板左栏的一条重点工作 */
function focusItem(f) {
const sel = state.weeklySel;
const late = focusIsLate(f);
return `
${Ico.status(f.status)}
${esc(f.content)}
${late ? `延期` : ""}
${statusPill(f.status)}
预计 ${esc(focusDueText(f))}
${late ? `录入 ${esc(f.period_label || "—")}` : ""}
`;
}
/** 工作看板右栏的一条重点工作(只读,改内容走抽屉) */
function focusDetailHtml(f) {
const ro = (label, val) => ``;
return `
${Ico.statusBadge(f.status)}${esc(f.content)}
${focusPill(f)}
预计完成 ${esc(focusDueText(f))}
说明只读
${f.detail ? nl2br(f.detail) : `
未填写说明
`}
结果反馈只读
${f.result ? nl2br(f.result) : `
未填写结果反馈
`}
`;
}
async function renderFocus() {
saveUI();
const host = $("#view-focus");
host.innerHTML = `加载中…
`;
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 = `
${filters.map((x) => ``).join("")}
${keywordInputHtml("fKeyword", "搜索工作内容…", 200)}
本周/延期按录入周统计 · 基准周 ${esc(week ? week.full_label : "—")}
`;
const table = `
| 工作内容 | 说明 |
预计完成时间 | 状态 | 结果反馈 | 更新 | 操作 |
${shown.map((f) => `
|
${esc((f.detail || "—").replace(/\s+/g, " ").slice(0, 48))} |
${esc(focusDueText(f))}${f.period_label ? ` 录入 ${esc(f.period_label)} ` : ""} |
${focusPill(f)} |
${esc((f.result || "—").replace(/\s+/g, " ").slice(0, 40))} |
${esc((f.updated_at || "").slice(0, 10) || "—")} |
|
`).join("")}
`;
const body = listCard({
title: "重点工作清单",
sub: "按录入时间倒序 · 共 " + rows.length + " 条",
summary: `当前 ${shown.length} 条 · 已完成 ${b.done.length} · 延期 ${b.overdue.length}`,
fields: ``,
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: `「${esc(f.content)}」将被删除,删除后无法恢复。
只是这一周不再跟进?请改用「归档」。
`,
});
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 = `加载中…
`;
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 = `加载失败:${esc(e.message)}
`;
return;
}
if (!f) {
$("#drawerBody").innerHTML = `条目不存在,可能已被删除
`;
return;
}
}
// 录入周 = 顶栏当前选中的那一周(新增时定下来,之后不随预计完成时间变)
const w = focusWeek();
const entryWeek = mode === "create" ? (w ? w.full_label : "顶栏当前周") : (f.period_full || f.period_label || "—");
$("#drawerTitle").innerHTML = mode === "create"
? `新增每周重点工作`
: `${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) => ``;
// 基础信息的动作在标题栏右侧按钮条:危险动作在左,主按钮在最右
drawerActions(mode === "view" ? `
` : `
`);
const form = mode === "view" ? `
基本信息只读
${ro("工作内容", esc(f.content) + (f.archived ? `
已归档` : ""))}
${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 ? `
延期` : "未延期")}
${ro("结果反馈", f.result ? nl2br(f.result) : "—")}
${ro("最近更新", esc((f.updated_at || "").replace("T", " ") || "—"))}
` : `
`;
$("#drawerBody").innerHTML =
(mode === "view" ? "" : ``) + 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 = `加载中…
`;
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 = ``;
const keyCount = rows.filter((r) => r.is_key).length;
const toolbar = `
${keywordInputHtml("pKeyword", "搜索项目名称…", 200)}
${ownerFilter}
${esc(scopeLabel())} · ${rows.length} 个项目
`;
const table = `
| 项目名称 | 分类 | 负责人 | 状态 |
进度% | 本${state.gran === "week" ? "周" : "月"}更新 |
记录 | 子任务 | 最近记录 | 操作 |
${shown.map((p) => `
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
${esc(p.name)}
${p.archived ? ` 已归档` : ""}
|
${catPill(p.category)} |
${nameChips(p.owners)} |
${statusPill(p.status)} |
${Math.round(p.progress)}% |
${p.updated_this_period
? `已更新`
: `${p.idle_weeks === null ? "从未更新" : p.idle_weeks + " 周未更新"}`} |
${p.update_count} |
${p.task_count} |
${esc((p.last_update && p.last_update.period_full) || "—")} |
|
`).join("")}
`;
const body = listCard({
title: "重点项目明细",
sub: "重点项目排在前面 · 列表只读,改数据请点「修改」",
summary: `共 ${rows.length} 个 · 重点 ${keyCount} 个 · 本${state.gran === "week" ? "周" : "月"}未更新 ${idle} 个`,
fields: ``,
rows: shown,
table: table,
empty: state.keyword
? "没有匹配「" + esc(state.keyword) + "」的项目"
: "还没有项目",
});
host.innerHTML = toolbar + body;
bindKeyword("pKeyword");
host.querySelectorAll("[data-pidle]").forEach((b) =>
b.addEventListener("click", () => { state.projIdleOnly = !state.projIdleOnly; renderProjects(); })
);
const po = $("#pOwner");
if (po) po.addEventListener("change", () => { state.projOwner = po.value; renderProjects(); });
host.querySelectorAll("[data-popen]").forEach((a) =>
a.addEventListener("click", () => openDrawer(Number(a.dataset.popen), "view"))
);
host.querySelectorAll("[data-pedit]").forEach((b) =>
b.addEventListener("click", () => openDrawer(Number(b.dataset.pedit), "edit"))
);
host.querySelectorAll("[data-parch]").forEach((b) =>
b.addEventListener("click", async () => {
const toArchive = b.dataset.archived !== "1";
await patch("/projects/" + b.dataset.parch, { archived: toArchive });
toast(toArchive ? "已归档(勾选「含归档项目」可查看)" : "已恢复");
await afterWrite();
})
);
$("#chkArchived").addEventListener("change", (e) => { state.showArchived = e.target.checked; renderProjects(); });
$("#btnExportProjects").addEventListener("click", () => {
const q = ["archived=" + (state.showArchived ? "all" : "false")];
if (state.categoryId) q.push("category_id=" + state.categoryId);
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
download("/api/export/projects?" + q.join("&"));
});
$("#btnNewProject").addEventListener("click", () => {
openModal(
`新建项目
`
);
$("#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 `${Ico.stage(code)}${esc(s.name)}`;
}
/** 定制交付视图的查询条件:周期 + 项目名称检索 + 区域(+ 归档态) */
function customQuery(extra) {
const p = [];
if (state.gran === "week") {
if (state.weekId) p.push("period_id=" + state.weekId);
} else {
const m = currentMonth();
if (m) p.push("year=" + m.year, "month=" + m.month);
}
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
if (state.customRegion) p.push("region=" + encodeURIComponent(state.customRegion));
(extra || []).forEach((x) => p.push(x));
return "?" + p.join("&");
}
let keywordFocus = false;
async function renderCustom() {
saveUI();
const host = $("#view-custom");
host.innerHTML = `加载中…
`;
if (!state.customMeta) state.customMeta = await api("/custom/meta");
const [board, stats] = await Promise.all([
api("/custom/board" + customQuery()),
api("/custom/stats" + customQuery(["archived=" + state.customArchived])),
]);
state.customBoard = board;
state.customStats = stats;
const k = stats.kpi;
// 项目明细 / 交付流水线 / 财务跟踪 都基于 /custom/projects:
// 支持归档态筛选,并按入库时间倒序
const needRows = ["list", "pipeline", "finance"].indexOf(state.customTab) >= 0;
state.customRows = needRows
? await api("/custom/projects" + customQuery(["archived=" + state.customArchived]))
: [];
// 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域
const usedSet = {};
(state.customMeta.regions || []).forEach((r) => { usedSet[r] = true; });
const stdAll = (state.customMeta.region_groups || []).reduce((a, g) => a.concat(g.regions), []);
let regionFilterHtml = ``;
(state.customMeta.region_groups || []).forEach((g) => {
const hit = g.regions.filter((r) => usedSet[r]);
if (!hit.length) return;
regionFilterHtml += ``;
});
const nonStd = (state.customMeta.regions || []).filter((r) => stdAll.indexOf(r) < 0);
if (nonStd.length) {
regionFilterHtml += ``;
}
const tabs = `
${keywordInputHtml("cKeyword", "搜索项目名称…", 200)}
${esc(scopeLabel())} · ${k.project_count} 个项目
`;
let body = "";
if (state.customTab === "pipeline") body = renderCustomPipeline(state.customRows, k);
else if (state.customTab === "board") body = renderCustomBoard(board);
else if (state.customTab === "finance") body = renderCustomFinance(stats, state.customRows);
else body = renderCustomList(state.customRows);
host.innerHTML = tabs + body;
host.querySelectorAll("[data-ctab]").forEach((b) =>
b.addEventListener("click", () => { state.customTab = b.dataset.ctab; renderCustom(); })
);
const rs = $("#cRegion");
if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); });
bindKeyword("cKeyword");
const bn = $("#btnNewCustom");
if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite()));
const bex = $("#btnExportCustom");
if (bex) bex.addEventListener("click", () => {
const q = ["archived=" + state.customArchived];
if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion));
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
download("/api/export/custom" + (q.length ? "?" + q.join("&") : ""));
});
// 卡片只是入口:重点标记等基础信息一律进详情面板改,外层不再直接写数据
host.querySelectorAll("[data-cpid]").forEach((c) =>
c.addEventListener("click", () => openCustomDrawer(Number(c.dataset.cpid)))
);
bindCustomList(host);
}
function renderCustomBoard(board) {
const cols = board.columns.filter((c) => c.count > 0 || c.code !== "closed");
return `
${cols.map((c) => `
${Ico.stage(c.code)}${esc(c.name)}
${c.count} 个 · ${fen(c.amount)} 万${c.updated_count ? ` · 本期 ${c.updated_count}` : ""}
${c.projects.length ? c.projects.map((p) => `
${esc(p.name)}
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
${esc(p.region || "未标注")}
${p.amount ? fen(p.amount) + " 万" : "金额待定"}
${p.stage === "developing" ? `
开发进度${p.progress}%
` : ""}
${p.remark ? `` : ""}
`).join("") : `
—
`}
`).join("")}
`;
}
/* 商务阶段 -> 已走完的环节下标。导入的老数据只有阶段没有环节日期,用它兜底 */
const STAGE_STEP = { lead: 0, signed: 1, developing: 1, delivered: 2, accepted: 3, revenue: 4 };
/** 按已填的环节日期取最后一环(与后端 custom_current_step 口径一致) */
function customDateIndex(p) {
const steps = (state.customMeta && state.customMeta.pipeline) || [];
let step = -1;
steps.forEach((s, i) => { if ((p[s.date_field] || "").trim()) step = i; });
return step;
}
/** 项目当前走到哪一环:环节日期与商务阶段取更靠后的那个 */
function customStepIndex(p) {
const byStage = STAGE_STEP[p.stage];
return Math.max(byStage === undefined ? -1 : byStage, customDateIndex(p));
}
function daysSince(iso) {
if (!iso) return null;
const t = Date.parse(iso + "T00:00:00");
return isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000);
}
/** 交付流水线:入库 → 签单 → 交付 → 验收 → 计收 的漏斗与卡点 */
function renderCustomPipeline(rows, kpi) {
const steps = (state.customMeta && state.customMeta.pipeline) || [];
const agingDays = (state.customMeta && state.customMeta.aging_days) || 45;
const total = rows.length;
const funnel = steps.map((s, i) => {
const hit = rows.filter((p) => customStepIndex(p) >= i);
return {
key: s.code, name: s.name, color: s.color,
count: hit.length,
amount: hit.reduce((a, p) => a + (p.amount || 0), 0),
};
});
const maxCount = Math.max(1, ...funnel.map((f) => f.count));
// 卡住的项目:还没走到计收,且最后一个有日期的环节停留超过 agingDays 天
const stuck = [];
rows.forEach((p) => {
const i = customStepIndex(p);
if (i < 0 || i >= steps.length - 1) return;
const di = customDateIndex(p);
if (di < 0) return;
const days = daysSince(p[steps[di].date_field]);
if (days === null || days < agingDays) return;
stuck.push({ p: p, step: steps[i], from: p[steps[di].date_field], days: days });
});
stuck.sort((a, b) => b.days - a.days);
const noDate = rows.filter((p) => customDateIndex(p) < 0).length;
return `
交付漏斗在库 ${total} 个 · 重点 ${kpi.key_count} 个 · 本期更新 ${kpi.period_updated} 个
金额与项目数按环节累计 · 商务阶段与已录环节日期取先到哪一步
${funnel.map((f) => `
${Ico.pipe(f.key)}${esc(f.name)}
${f.count}
${fen(f.amount)} 万
`).join("")}
${noDate ? `
还有 ${noDate} 个项目一个环节日期都没有,去「项目明细」补上入库时间后漏斗才准。
` : ""}
卡点提醒最后一个有日期的环节停留超过 ${agingDays} 天
${stuck.length} 个项目需要推进
${stuck.length ? `
| 项目名称 | 卡在 | 环节日期 | 停留天数 | 合同额 | 负责人 | 操作 |
${stuck.slice(0, 20).map((x) => `
| ${esc(x.p.name)} |
${esc(x.step.name)} |
${esc(x.p[x.step.date_field] || x.from || "—")} |
${x.days} 天 |
${fen(x.p.amount)} 万 |
${esc(x.p.owner || "未指派")} |
|
`).join("")}
` : `
没有停滞超期的项目
`}
`;
}
/** 财务跟踪:按负责人 / 区域汇总,并列出待回款排行 */
function renderCustomFinance(stats, rows) {
const byOwner = {};
rows.forEach((p) => {
const key = p.owner || "(未指派)";
const o = byOwner[key] || (byOwner[key] = { name: key, count: 0, amount: 0, confirmed: 0, revenue: 0, invoice: 0, received: 0 });
o.count += 1;
o.amount += p.amount || 0;
o.confirmed += p.confirmed_amount || 0;
o.revenue += p.revenue_amount || 0;
o.invoice += p.invoice_amount || 0;
o.received += p.received_amount || 0;
});
const owners = Object.keys(byOwner).map((k) => byOwner[k]).sort((a, b) => b.amount - a.amount);
const unpaid = rows.filter((p) => (p.amount || 0) > 0)
.map((p) => ({ p: p, pend: (p.amount || 0) - (p.received_amount || 0) }))
.sort((a, b) => b.pend - a.pend);
const num = (n) => fen(n);
const maxAmt = Math.max(1, ...stats.by_stage.map((x) => x.amount || 0));
const k = stats.kpi;
return `
${Ico.badge("wallet")}
合同总额
${wan(k.amount_total)}
${Ico.badge("receipt")}
按进度可确认收入
${wan(k.confirmed_total)}
${Ico.badge("wallet")}
已计收
${wan(k.revenue_total)}
${Ico.badge("hourglass")}
待计收
${wan(k.pending_total)}
${Ico.badge("receipt")}
已开票
${wan(k.invoice_total)}
${Ico.badge("trending-up")}
已回款
${wan(k.received_total)}
${Ico.badge("receipt")}
待开票
${wan(k.pending_invoice)}
${Ico.badge("hourglass")}
待回款
${wan(k.pending_received)}
阶段金额分布合同额与按进度可确认收入
${stats.by_stage.map((x) => `
${esc(x.name)} ${x.count} 个
${num(x.amount)}
`).join("")}
周期记录活跃度各周期有记录的项目数
${barChart(stats.trend.map((t) => ({
label: `${t.month}/${t.week || ""}`,
value: t.update_count,
color: scopePeriodIds().indexOf(t.period_id) >= 0 ? "#7c3aed" : "#c4b5fd",
dim: scopePeriodIds().indexOf(t.period_id) < 0,
})), { unit: " 条", height: 170 })}
按负责人合同额 / 计收 / 开票 / 回款
| 负责人 | 项目 | 合同额 | 可确认 | 已计收 | 已开票 | 已回款 |
${owners.map((o) => `
| ${esc(o.name)} | ${o.count} |
${num(o.amount)} | ${num(o.confirmed)} |
${num(o.revenue)} | ${num(o.invoice)} |
${num(o.received)} |
`).join("")}
按区域合同额与可确认收入
| 区域 | 项目 | 合同额 | 可确认 | 已计收 |
${stats.by_region.slice(0, 14).map((r) => `
| ${esc(r.name)} | ${r.count} |
${num(r.amount)} | ${num(r.confirmed)} |
${num(r.revenue)} |
`).join("")}
待回款排行合同额 − 已回款 · 共 ${unpaid.length} 个有合同额的项目
${unpaid.length ? `
| 项目名称 | 区域 | 负责人 | 验收 |
合同额 | 开票 | 回款 | 待回款 | 操作 |
${unpaid.slice(0, 20).map((x) => `
| ${esc(x.p.name)} |
${esc(x.p.region || "—")} |
${esc(x.p.owner || "未指派")} |
${esc(x.p.accept_date || "未验收")} |
${num(x.p.amount)} |
${num(x.p.invoice_amount)} |
${num(x.p.received_amount)} |
${num(x.pend)} |
|
`).join("")}
` : `
暂无合同额数据
`}
`;
}
function renderCustomList(rows) {
const archOpts = [
["false", "在库"],
["true", "已归档"],
["all", "全部"],
]
.map((x) => ``)
.join("");
const sum = (f) => fen(rows.reduce((a, p) => a + Number(p[f] || 0), 0));
return `
定制项目明细按入库时间倒序 · 共 ${rows.length} 个 · 列表只读,改数据请点「修改」
合同额 ${sum("amount")} 万 · 可确认 ${sum("confirmed_amount")} 万 · 已计收 ${sum("revenue_amount")} 万 · 开票 ${sum("invoice_amount")} 万 · 回款 ${sum("received_amount")} 万
${rows.length ? `
| 项目名称 | 区域 | 负责人 |
入库 | 签单 | 交付 | 验收 |
阶段 | 合同额/万 | 进度% |
可确认/万 | 已计收/万 |
开票/万 | 回款/万 |
备注 | 操作 |
${rows.map((p) => `
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
${esc(p.name)}
${p.archived ? ` 已归档` : ""}
|
${esc(p.region || "—")} |
${esc(p.owner || "—")} |
${["entry_date", "sign_date", "deliver_date", "accept_date"].map((f) => `${p[f] ? esc(p[f]) : "—"} | `).join("")}
${stagePill(p.stage)} |
${fen(p.amount)} |
${Math.round(p.progress)}% |
${fen(p.confirmed_amount)} |
${fen(p.revenue_amount)} |
${fen(p.invoice_amount)} |
${fen(p.received_amount)} |
${esc((p.remark || "").slice(0, 40)) || "—"} |
|
`).join("")}
` : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定制项目" : "当前筛选条件下没有定制项目"}
`}
`;
}
/** 项目明细表:列表只读,只有详情 / 修改 / 归档三个动作 */
function bindCustomList(host) {
const ca = $("#cArchived");
if (ca) ca.addEventListener("change", () => { state.customArchived = ca.value; renderCustom(); });
host.querySelectorAll("[data-copen]").forEach((a) =>
a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view"))
);
host.querySelectorAll("[data-cedit]").forEach((b) =>
b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit"))
);
host.querySelectorAll("[data-carch]").forEach((b) =>
b.addEventListener("click", () => {
const p = (state.customRows || []).find((x) => x.id === Number(b.dataset.carch));
if (p) archiveCustomProject(p, () => afterWrite());
})
);
}
/**
* 新建定制项目 —— 全站唯一的新建入口。
* 「定制交付」工具栏和「更新录入」页都调用它,避免两套录入逻辑。
*/
function openNewCustomDialog(onDone) {
const today = new Date();
const iso = today.getFullYear() + "-" +
String(today.getMonth() + 1).padStart(2, "0") + "-" +
String(today.getDate()).padStart(2, "0");
const stageOpts = ((state.customMeta && state.customMeta.stages) || [])
.map((s) => ``)
.join("");
openModal(
`新建定制项目
只有项目名称必填,其余可先留空,建档后在列表里直接改。新项目会自动按入库时间排在最前面。
`
);
const submit = async () => {
const name = $("#ncName").value.trim();
if (!name) return modalError("请填写项目名称");
const btn = $("#mOk");
btn.disabled = true;
try {
const p = await post("/custom/projects", {
name: name,
region: $("#ncRegion").value || null,
customer: $("#ncCustomer").value.trim() || null,
amount: $("#ncAmount").value === "" ? null : Number($("#ncAmount").value),
stage: $("#ncStage").value,
owner: $("#ncOwner").value || null,
entry_date: $("#ncEntry").value || null,
is_key: $("#ncKey").checked,
remark: $("#ncRemark").value.trim() || null,
});
closeModal();
toast("已创建「" + p.name + "」");
if (onDone) onDone(p); else refresh();
} catch (e) {
btn.disabled = false;
modalError(e.message);
}
};
$("#mOk").addEventListener("click", submit);
$("#ncName").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
}
/** 归档 / 恢复:归档后不进看板与统计,随时可恢复 */
async function archiveCustomProject(p, onDone) {
const toArchive = !p.archived;
const ok = await confirmDialog({
title: toArchive ? "归档定制项目" : "恢复定制项目",
okText: toArchive ? "确认归档" : "确认恢复",
body:
`${esc(p.name)}
` +
`${
toArchive
? "归档后该项目不再出现在阶段看板、收入统计和默认列表中,历史周期记录完整保留。"
: "恢复后重新计入看板与统计。"
}
`,
});
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:
`将连同该项目 ${p.update_count || 0} 条周期记录一并删除,删除后无法恢复。
- 阶段:${esc(stageMeta(p.stage).name)} 合同额:${money(p.amount)}
- 已计收:${money(p.revenue_amount)} 入库时间:${esc(p.entry_date || "—")}
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`,
});
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 = `加载中…
`;
drawerActions("");
const d = await api("/custom/projects/" + cid);
state.drawerCustom = d;
const p = d.project;
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
$("#drawerSub").innerHTML = `${stagePill(p.stage)} · ${esc(p.region || "未标注区域")} · 合同额 ${money(p.amount)}`;
const stageOpts = state.customMeta.stages
.map((s) => ``)
.join("");
const timeline = d.timeline.slice().reverse().map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${stagePill(t.stage)}${t.progress !== null ? ` ${t.progress}%` : ""}
${t.revenue_delta ? ` 计收 ${fen(t.revenue_delta)}万` : ""}
${nl2br(t.content || "")}
`).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) => ``;
// 基础信息:详情只读、修改才给表单,操作条只出现在自己那一档里
const baseSec = mode === "edit" ? `
` : `
基础信息只读
${ro("项目名称", esc(p.name) + (p.archived ? `
已归档` : ""))}
${ro("阶段", stagePill(p.stage))}
${ro("合同额", money(p.amount))}
${ro("开发进度", Math.round(p.progress) + "%")}
${ro("确认比例", `${esc(ratioLabel)} · 可确认 ${money(p.confirmed_amount)}`)}
${ro("交付环节", `
${pipelineBar(p, true)}
`)}
${ro("区域", esc(p.region || "—"))}
${ro("交付负责人", esc(p.owner || "—"))}
${ro("团队成员", nameChips(p.members))}
${ro("客户", esc(p.customer || "—"))}
${ro("备注", p.remark ? nl2br(p.remark) : "—")}
`;
drawerActions(mode === "edit" ? `
` : `
`);
$("#drawerBody").innerHTML = `
${mode === "edit" ? `` : ""}
${baseSec}
收入确认${mode === "edit" ? "开票与回款可编辑" : "计算值 · 只读"}
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
${Ico.badge("wallet")}
已计收
${wan(p.revenue_amount)}
${Ico.badge("hourglass")}
待计收
${wan(p.pending_amount)}
${Ico.badge("receipt")}
已开票
${wan(p.invoice_amount)}
${Ico.badge("trending-up")}
已回款
${wan(p.received_amount)}
${mode === "edit" ? `
开票与回款 · 编辑
随标题栏「保存」一并提交
` : ""}
周期执行记录(${d.updates.length})附表 · 只读
附表 · 更新记录
到「更新录入 · 定制交付」写入
${d.updates.length ? d.updates.map((u) => `
${esc(u.period_full || "")}${stagePill(u.stage)}${u.progress !== null ? `${u.progress}%` : ""}
${nl2br(u.content || u.summary || "")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""}
${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`).join("") : `
暂无记录
`}
${d.timeline.length ? `` : ""}`;
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
on("#cdEdit", () => openCustomDrawer(cid, "edit"));
on("#cdAddUpd", () => {
state.intakeType = "custom";
state.intakePeriodId = state.weekId;
customIntakeId = cid;
closeDrawer();
setView("intake");
});
on("#cdCancel", () => openCustomDrawer(cid, "view"));
on("#cdSave", async () => {
const btn = $("#cdSave");
const err = $("#cdErr");
const ratioRaw = $("#cdRatio").value.trim();
btn.disabled = true;
err.style.display = "none";
try {
await patch("/custom/projects/" + cid, {
name: $("#cdName").value.trim(),
stage: $("#cdStage").value,
amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value),
progress: Number($("#cdProgress").value),
revenue_amount: $("#cdRevenue").value === "" ? null : Number($("#cdRevenue").value),
manual_ratio: ratioRaw === "" ? null : Number(ratioRaw) / 100,
entry_date: $('#drawerBody [data-cf="entry_date"]').value || null,
sign_date: $('#drawerBody [data-cf="sign_date"]').value || null,
deliver_date: $('#drawerBody [data-cf="deliver_date"]').value || null,
accept_date: $('#drawerBody [data-cf="accept_date"]').value || null,
revenue_date: $('#drawerBody [data-cf="revenue_date"]').value || null,
invoice_amount: $("#cdInvoice").value === "" ? null : Number($("#cdInvoice").value),
received_amount: $("#cdReceived").value === "" ? null : Number($("#cdReceived").value),
region: $("#cdRegion").value,
owner: $("#cdOwner").value || null,
members: peopleValues("cdMembers"),
customer: $("#cdCustomer").value,
remark: $("#cdRemark").value,
});
} catch (e) {
btn.disabled = false;
err.textContent = "保存失败:" + e.message;
err.style.display = "";
return;
}
toast("已保存");
await afterWrite();
openCustomDrawer(cid, "view");
});
on("#cdKey", async () => {
await patch("/custom/projects/" + cid, { is_key: !p.is_key });
await afterWrite();
openCustomDrawer(cid, mode);
});
on("#cdArchive", () => archiveCustomProject(p, () => { closeDrawer(); afterWrite(); }));
on("#cdDelete", () => deleteCustomProject(p, () => { closeDrawer(); afterWrite(); }));
}
/* ------------------------------------------------ 录入方式(两类项目共用) */
/** 录入方式分段控件:直接写入(第一位)/ AI 整理 */
function intakeModeSegHtml() {
const mode = intakeMode();
return `
录入方式
${mode === "ai"
? "AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持你的原文,最终只写 1 条"
: "填进去什么就存什么,不拆解、不改写、不动项目进度"}
`;
}
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 = `
${intakeModeSegHtml()}
定制项目更新录入${intakeMode() === "ai"
? "给已有定制项目记一期:粘贴原文 → AI 整理成 1 条(含阶段 / 金额 / 计收)→ 确认写入"
: "给已有定制项目记一期:内容原样存成 1 条,不拆解、不改写"}
1选择定制项目与周期
项目按入库时间倒序排列。列表里没有要找的项目?点标题右侧「+ 新建定制项目」先建档,建好会自动选中回到这里。
2粘贴该项目的原始内容
${intakeMode() === "ai" ? `
` : `
`}
`;
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) => ``).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 = `写入成功:已按原文存入 1 条执行记录,未拆解、未改写。
`;
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 = ` 整理中…`;
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 = `整理失败:${esc(e.message)}
`;
} finally {
btn.disabled = false; btn.innerHTML = old;
}
}
function renderCustomPreview(r) {
const p = r.parsed;
const stageOpts = state.customMeta.stages
.map((s) => ``)
.join("");
$("#cInMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理";
$("#cInPreview").innerHTML = `
${r.warning ? `${esc(r.warning)}
` : ""}
${r.error ? `AI 调用异常:${esc(r.error)}
` : ""}
整理结果 · 1 条记录(可直接修改后写入)
正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定制交付」
`;
$("#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 = `写入成功:「${esc(res.project.name)}」阶段 ${esc(stageMeta(res.project.stage).name)},可确认收入 ${wan(res.project.confirmed_amount)}。
`;
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" ? `
` : `
`;
host.innerHTML = `
${intakeModeSegHtml()}
更新录入${mode === "ai" ? "选择项目 → 粘贴原始内容 → AI 整理 → 确认写入" : "选择项目 → 填写内容 → 原样写入 1 条"}
`;
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) => ``).join("")
+ ``;
});
$("#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", ``);
$("#intakeNewProject").style.display = "none";
}
await post("/updates", {
project_id: projectId,
period_id: Number($("#intakePeriod").value),
content: raw,
source: "manual",
});
$("#intakeRaw").value = "";
$("#intakePreview").innerHTML = `写入成功:已按原文存入 1 条执行记录,未拆解、未改写。
`;
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 = ` 整理中…`;
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 = `整理失败:${esc(e.message)}
`;
} finally {
btn.disabled = false; btn.innerHTML = old;
}
}
function renderIntakePreview(r, raw) {
const p = r.parsed;
const statusOpts = state.meta.statuses.map((s) => ``).join("");
$("#intakeMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理";
$("#intakePreview").innerHTML = `
${r.warning ? `${esc(r.warning)}
` : ""}
${r.error ? `AI 调用异常:${esc(r.error)}
` : ""}
整理结果 · 1 条记录(可直接修改后写入)
${esc(r.project ? r.project.name : "")} · ${esc(r.period ? r.period.full_label : "")}
正文用你的原文,AI 只补摘要 / 进度 / 状态 / 风险 / 下一步;写入后可在「周期看板」中立即查看
`;
$("#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 = `写入成功:项目「${esc(res.project.name)}」新增 ${res.created.length} 条记录。可切换到「周期看板」查看。
`;
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 = `
数据库
${esc(health.db)}
默认使用 SQLite(data/board.db),无需安装任何服务。
迁移到 MySQL:
1. 安装驱动:pip install pymysql cryptography
2. 在 .env 中设置:
DATABASE_URL=mysql+pymysql://user:pwd@host:3306/unis_board?charset=utf8mb4
3. 重启服务,表结构自动创建;再用
python scripts/import_excel.py --reset 重新灌数即可。
数据访问层全部走 SQLAlchemy,无原生 SQL,切换方言不需要改业务代码。
周期管理共 ${periods.weeks.length} 个周期 · 录入新周期数据前可先在这里新增
| 周期 | 常规记录 | 定制记录 | |
${weeks.map((w) => `
| ${esc(w.full_label)} (原标签:${esc(w.label)}) |
${w.update_count || 0} |
${w.custom_update_count || 0} |
|
`).join("")}
`;
$("#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 = `加载中…
`;
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) => ``;
// 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起
const baseSec = mode === "edit" ? `
基础信息编辑
只改基础信息,不会动下面的周期记录
${peopleEditorHtml("dwOwners", "负责人", p.owners, {
add: "负责人",
// 界面上负责人不分主次,但存储里 owner = owners[0],统计口径照实说明
hint: "可多位。看板、筛选、导出与人力负载取名单里的第一位做归属统计。",
})}
${peopleEditorHtml("dwMembers", "团队成员", p.members, { add: "成员" })}
` : `
基础信息只读
${ro("项目名称", esc(p.name) + (p.archived ? `
已归档` : ""))}
${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) => `
${esc(t.name)}`).join(" ") : "—")}
${ro("记录", `${p.task_count} 个子任务 / ${p.update_count} 条执行记录`)}
`;
// 基础信息的动作在标题栏右侧按钮条;附表的更新动作仍在它的列表上方,两类不混
drawerActions(mode === "edit" ? `
` : `
`);
const updItem = (u) => `
${esc(u.task_name || "整体进展")}${statusPill(u.status)}${u.progress !== null ? `${u.progress}%` : ""}
${nl2br(u.content || u.summary || "")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""}
${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`;
// 把项目的 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 = `
查看范围(只读筛选)
${FEED_RANGES[state.dw.group].map(([v, l]) =>
``).join("")}
显示 ${win.length} / ${units.length} ${unitName} · ${nUpd} 条${first > 0 ? ` · 更早还有 ${first} ${unitName}` : ""}
`;
// win 是按升序取的窗口,展示时倒过来:新的在前
const body = win.length ? win.slice().reverse().map((u) => `
${esc(u.label)}${u.progress !== null ? `进度 ${u.progress}%` : ""}${u.count} 条
${u.periods.slice().reverse().map((t) => `
${state.dw.group === "month" ? `${esc(t.period.full_label.replace(/^\d+年/, ""))}${t.progress !== null ? " · 进度 " + t.progress + "%" : ""} ${statusPill(t.status)}
` : ""}
${t.updates.map(updItem).join("")}`).join("")}`).join("")
: `还没有执行记录
`;
host.innerHTML = `周期反馈附表 · 只读
附表 · 更新记录
到「更新录入」页粘贴原文写入
${bar}
${body}
`;
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) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${t.progress === null ? "—" : t.progress + "%"}
`).join("");
// 周期反馈单独渲染,切换范围/周期不必重新请求
state.dw = { group: "week", range: 4, back: 0 };
$("#drawerBody").innerHTML =
(mode === "edit" ? `` : "") +
baseSec + `` +
`历史进度走势附表 · 只读
${timeline || '
暂无
'}
`;
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:
`将连同 ${p.task_count} 个子任务、${p.update_count} 条执行记录一并删除,删除后无法恢复。
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`,
});
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) => ``
).join("");
const affs = (state.staffDict.affiliations || []).map(
(a) => ``
).join("");
openModal(
`${isNew ? "新增人员" : "编辑人员"}
${isNew ? "" : `
改姓名会把名下项目的负责人一并改过来,历史记录不会断。
`}
`
);
$("#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: `确认从花名册删除 ${esc(m.name)}?
删除后这个人不再出现在「负责人」下拉里。
若他名下还有在管项目,接口会拒绝删除 —— 离职人员请用归档,历史数据完整保留。
`,
});
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 = `加载中…
`;
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) => ``).join("");
const groupOpts = `` +
(d.dict.groups || []).map((g) => ``).join("");
const kpi = `
${Ico.badge("users")}
在册人员
${sum.total}
${sum.groups.slice(0, 4).map((g, i) => `
${Ico.badge("users")}
${esc(g.name)}
${g.count}
`).join("")}
人员归属汇智 / 云数 / 外服
${sum.affiliations.map((a) => `
${esc(a.name)}
${a.count}
`).join("")}
花名册外的负责人历史数据里对不上花名册的人名
${sum.outside.length ? sum.outside.map((o) => `
${esc(o.name)}
项目 ${o.project_count}
定制 ${o.custom_count}
`).join("") : `
所有项目负责人都能对上花名册
`}
`;
const toolbar = `
${keywordInputHtml("stKeyword", "搜索姓名…", 200)}
在册 ${sum.total} 人
`;
const table = ``;
const body = listCard({
title: "人员花名册",
sub: "小组 / 归属 / 说明可直接在表内改",
summary: `当前 ${d.items.length} 人 · 带项目 ${sum.with_project} 人 · 花名册外 ${sum.outside.length} 人`,
fields: `
`,
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 =
`` +
state.categories.map((c) => `
`).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 = `加载失败:${esc(e.message)}
`;
}
$("#sideFoot").innerHTML = `当前周期:${esc(scopeLabel())}
共 ${state.periods.weeks.length} 个周期 / ${state.periods.months.length} 个月`;
}
/** 写操作统一收尾:刷新侧栏计数 + 当前视图。
录入页不重绘,否则会清空用户正在编辑的表单。 */
async function afterWrite() {
await Promise.all([loadCategories(), loadStaff()]);
if (state.view === "intake") return;
await refresh();
}
function bindShell() {
$$("#nav .nav-item").forEach((b) => b.addEventListener("click", () => setView(b.dataset.view)));
$$("#granSeg .seg-btn").forEach((b) =>
b.addEventListener("click", () => {
state.gran = b.dataset.gran;
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x === b));
renderPeriodSelect();
refresh();
})
);
$("#prevPeriod").addEventListener("click", () => shiftPeriod(-1));
$("#nextPeriod").addEventListener("click", () => shiftPeriod(1));
$("#periodSelect").addEventListener("change", (e) => {
if (e.target.value === "__new") {
renderPeriodSelect();
createNewPeriod().then((id) => { if (id) refresh(); });
return;
}
if (state.gran === "week") state.weekId = Number(e.target.value);
else state.monthIdx = Number(e.target.value);
renderPeriodSelect();
refresh();
});
$("#sideKeyOnly").addEventListener("change", (e) => { state.keyOnly = e.target.checked; refresh(); });
$("#btnIntake").addEventListener("click", () => setView("intake"));
$("#drawerClose").addEventListener("click", closeDrawer);
bindDrawerPeople();
$("#drawerMask").addEventListener("click", closeDrawer);
$("#modalMask").addEventListener("click", (e) => {
if (e.target.id === "modalMask" || e.target.dataset.mclose !== undefined) closeModal(false);
});
let t = null;
$("#globalSearch").addEventListener("input", (e) => {
clearTimeout(t);
const v = e.target.value.trim();
t = setTimeout(() => { state.keyword = v; refresh(); }, 320);
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if ($("#modalMask").classList.contains("open")) closeModal(false);
else closeDrawer();
});
}
/* ------------------------------------------- 界面状态(刷新后保持) */
const UI_KEY = "unis.ui.v1";
const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyOnly", "keyword",
"projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived",
"customTab", "customArchived", "customRegion", "staffArchived", "staffGroup"];
function saveUI() {
const o = {};
UI_FIELDS.forEach((k) => { o[k] = state[k]; });
try { sessionStorage.setItem(UI_KEY, JSON.stringify(o)); } catch (e) { /* 存不了就算了 */ }
}
function restoreUI() {
let o = null;
try { o = JSON.parse(sessionStorage.getItem(UI_KEY) || "null"); } catch (e) { o = null; }
if (!o) return;
UI_FIELDS.forEach((k) => { if (k in o) state[k] = o[k]; });
if (["board", "projects", "custom", "focus", "staff", "intake", "settings"].indexOf(state.view) < 0) state.view = "board";
if (state.gran !== "month") state.gran = "week";
if (state.focusFilter === "later") state.focusFilter = "week";
}
async function main() {
restoreUI();
state.meta = await api("/meta");
await loadStaff();
await loadPeriods();
await loadCategories();
bindShell();
// 恢复出来的视图状态要回写到顶栏/侧栏控件上
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === state.gran));
$("#sideKeyOnly").checked = !!state.keyOnly;
$("#globalSearch").value = state.keyword || "";
setView(state.view);
}
main().catch((e) => {
document.body.insertAdjacentHTML("beforeend", `初始化失败:${esc(e.message)}
`);
});
})();