unis_manager/web/app.js

3951 lines
202 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,
keyword: "",
idle: "key",
showArchived: false,
meta: null,
periods: null,
categories: [],
board: null,
stats: null,
projects: [],
drawerProject: null,
// 详情抽屉:周期反馈的显示粒度 / 范围 / 往前翻页偏移
dw: { group: "week", range: 2, back: 0 },
// 定开项目
customMeta: null,
// 定开:项目明细 / 本周进展 两个 tab(原交付流水线 / 阶段看板 / 财务跟踪已下线)
customTab: "list",
customAnnualEdit: false, // 年度任务:默认查看模式,点「编辑」才可改
customRegion: "",
customStage: "",
customStatus: "",
customOrigin: "",
customSort: null, // { key: amount | sign_date | update_date, dir: asc | desc }
customArchived: "false",
customRows: [],
intakeType: "general",
// 更新录入的两种方式:direct = 直接写入(原样存 1 条);ai = AI 整理(补结构化字段,也只存 1 条)
intakeMode: "direct",
intakePeriodId: null,
drawerCustom: null,
// 周看板(左栏总览 + 右栏详情)
weekly: null,
weeklySel: null,
// 项目管理
projOwner: "",
projIdleOnly: false,
// 每周重点工作
focusRows: [],
focusFilter: "week",
focusArchived: false,
// 人力资源库
staff: [],
staffDict: { groups: [], affiliations: [] },
staffArchived: "false",
staffGroup: "",
// 登录用户(含角色与菜单权限);未登录时为 null
me: null,
};
/* ---------------------------------------------------------------- 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 */ }
// 会话过期 / 被管理员下线:任何请求都直接把界面切回登录页
if (res.status === 401 && path !== "/auth/login") showAuthGate(msg);
const err = new Error(msg);
err.status = res.status;
throw err;
}
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);
}
/* ---------------------------------------------- 登录与菜单权限 */
/** 菜单权限:can = 能看(ro/rw),canW = 能改(只有 rw)。未登录一律 false。 */
function can(menu) {
return !!(state.me && state.me.menus && state.me.menus[menu]);
}
const canR = can;
function canW(menu) {
return !!(state.me && state.me.menus && state.me.menus[menu] === "rw");
}
function showAuthGate(msg) {
$("#authGate").classList.remove("hidden");
const box = $("#authErr");
box.textContent = msg || "";
box.style.display = msg ? "" : "none";
const f = $("#authPass").value ? $("#authOk") : ($("#authUser").value ? $("#authPass") : $("#authUser"));
setTimeout(() => f.focus(), 60);
}
function bindAuthForm() {
$("#authForm").addEventListener("submit", async (e) => {
e.preventDefault();
const btn = $("#authOk");
btn.disabled = true;
try {
await post("/auth/login", { username: $("#authUser").value.trim(), password: $("#authPass").value });
location.reload(); // 登录成功后整页重载,按新权限重新渲染
} catch (err) {
const box = $("#authErr");
box.textContent = err.message;
box.style.display = "";
btn.disabled = false;
}
});
}
/** 只保留有权限的菜单;顶栏「更新录入」按钮同样跟着 intake 权限走 */
function applyNavPermissions() {
$$("#nav .nav-item").forEach((b) => b.classList.toggle("hidden", !can(b.dataset.view)));
$$("#nav .nav-label").forEach((l) => {
let n = l.nextElementSibling, any = false;
while (n && !n.classList.contains("nav-label")) {
if (n.classList.contains("nav-item") && !n.classList.contains("hidden")) any = true;
n = n.nextElementSibling;
}
l.classList.toggle("hidden", !any);
});
$("#btnIntake").classList.toggle("hidden", !can("intake"));
}
/** 有权限的第一个菜单,用于回落 */
function firstAllowedView() {
const b = $$("#nav .nav-item").filter((x) => can(x.dataset.view))[0];
return b ? b.dataset.view : null;
}
/* ---------------------------------------- 侧栏底部的用户胶囊 */
function renderUserChip() {
const me = state.me;
$("#sideUser").innerHTML = `
<button class="user-chip" id="userChip">
<span class="avatar">${esc(String(me.name || me.username || "?").slice(0, 1))}</span>
<span class="u-text"><b>${esc(me.name || me.username)}</b><i>${esc(me.role_name || "未分配角色")}</i></span>
${Ico.svg("chevron-up", "ic")}
</button>
<div class="user-menu hidden" id="userMenu">
<button id="miProfile">${Ico.svg("sliders", "ic")}用户设置</button>
<button class="danger" id="miLogout">${Ico.svg("log-out", "ic")}退出登录</button>
</div>`;
const menu = $("#userMenu");
$("#userChip").addEventListener("click", (e) => { e.stopPropagation(); menu.classList.toggle("hidden"); });
document.addEventListener("click", () => menu.classList.add("hidden"));
$("#miProfile").addEventListener("click", openProfileDialog);
$("#miLogout").addEventListener("click", doLogout);
}
/** 用户设置:改昵称 + 改自己的密码;角色由管理员在权限管理里分配 */
function openProfileDialog() {
const me = state.me;
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-row"><label>姓名</label><input class="inp" id="pfName" value="${esc(me.name || "")}" data-autofocus style="width:100%" /></div>
<div class="form-row"><label>账号</label><div class="ro">${esc(me.username)}</div></div>
<div class="form-row"><label>角色</label><div class="ro">${esc(me.role_name || "未分配")} <span class="tag-mini">${(me.menus && Object.keys(me.menus).length) || 0} 个菜单</span></div></div>
<div class="sec-title" style="margin-top:16px">修改密码</div>
<div class="form-row"><label>原密码</label><input class="inp" id="pfOld" type="password" autocomplete="current-password" style="width:100%" /></div>
<div class="form-row"><label>新密码</label><input class="inp" id="pfNew" type="password" autocomplete="new-password" style="width:100%" /></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="pfOk">保存</button>
</div>`);
$("#pfOk").addEventListener("click", async () => {
const btn = $("#pfOk");
btn.disabled = true;
modalError("");
try {
const name = $("#pfName").value.trim();
if (name && name !== me.name) state.me = await put("/auth/me", { name: name });
const nw = $("#pfNew").value.trim();
if (nw) await post("/auth/password", { old_password: $("#pfOld").value, new_password: nw });
renderUserChip();
closeModal(true);
toast(nw ? "已保存,其他设备需重新登录" : "已保存");
} catch (e) {
modalError(e.message);
btn.disabled = false;
}
});
}
async function doLogout() {
try { await post("/auth/logout", {}); } catch (e) { /* 会话可能已经失效,忽略 */ }
location.reload();
}
/* ------------------------------------------------ 列表页统一骨架 */
/** 卡头 = 列表页的子 header:标题+计数(左)、筛选 chip(紧跟标题)、操作按钮(最右)。
工具栏不再单独占一行,所有页面统一走这里。 */
function cardHead(o) {
const tools = [(o.fields || ""), (o.tools || "")].filter(Boolean).join("");
const cnt = o.count ? `<span class="cnt">${o.count}</span>` : "";
// chipsRow:切 tab 的 chip 独占一行 header(标题与当前 tab 同名,省掉,计数跟在 chip 后),
// 筛选器和主功能按钮放同一行。
if (o.chipsRow) {
return `<div class="card-head chips-row">
${o.chips ? `<div class="head-chips">${o.chips}${cnt}</div>` : ""}
${tools ? `<div class="head-tools">${tools}</div>` : ""}
</div>`;
}
return `<div class="card-head">
<h4><span class="t">${esc(o.title)}</span>${cnt}</h4>
${o.chips ? `<div class="head-chips">${o.chips}</div>` : ""}
${tools ? `<div class="head-tools">${tools}</div>` : ""}
</div>`;
}
function listCard(o) {
return `<div class="card">
${cardHead(o)}
${o.rows.length ? `<div class="tbl-wrap">${o.table}</div>` : `<div class="empty">${o.empty}</div>`}
</div>`;
}
/** 表格行内操作:全站统一「修改 / 归档 / 删除」三个图标按钮,名称点开详情。
事件属性名由调用方给出,沿用各表原有的绑定。 */
function rowOps(o) {
// 只读角色不给行内「修改 / 归档 / 删除」
if (!canW(state.view)) return "";
const b = (attr, icon, title, cls) =>
`<button class="icon-btn sm${cls ? " " + cls : ""}" ${attr} title="${esc(title)}">${Ico.svg(icon)}</button>`;
if (!o.edit && !o.archive && !o.remove) return "";
return `<span class="row-ops">` +
(o.edit ? b(o.edit, "pencil", "修改") : "") +
(o.archive ? b(o.archive + ` data-archived="${o.archived ? "1" : "0"}"`, "archive", o.archived ? "恢复" : "归档") : "") +
(o.remove ? b(o.remove, "trash", "删除", "danger") : "") +
`</span>`;
}
/* -------------------------------------- 周期记录(执行 / 交付)的新增·修改·删除 */
/** 记录条目右上角的操作按钮,鼠标移到条目上才显现 */
function updOps(kind, u) {
if (!canW(kind === "custom" ? "custom" : "projects")) return "";
return `<span class="u-ops">
<button class="icon-btn xs" data-uop="edit" data-ukind="${kind}" data-uid="${u.id}" title="修改这条记录">${Ico.svg("pencil")}</button>
<button class="icon-btn xs danger" data-uop="del" data-ukind="${kind}" data-uid="${u.id}" title="删除这条记录">${Ico.svg("trash")}</button>
</span>`;
}
/**
* 新增 / 修改一条周期记录,全站唯一入口(看板右栏、项目抽屉、定开抽屉共用)。
* kind = "project" 自研产品的执行记录,"custom" 定开项目的交付记录;
* 传 update 就是修改,否则在 periodId 下新建;写完调 onDone 刷新所在区域。
*/
function openUpdateDialog(o) {
const isP = o.kind !== "custom";
const u = o.update || null;
const weeks = state.periods.weeks || [];
const periodId = u ? u.period_id : o.periodId;
const stages = (state.customMeta && state.customMeta.stages) || [];
const nv = (v) => (v === null || v === undefined ? "" : String(v));
// 新建时沿用项目当前的状态 / 阶段:后端会按记录回写档案,别让默认值改坏台账
const defStatus = u ? u.status : o.status || "in_progress";
const defStage = u ? u.stage : o.stage || "";
// 计收 / 下单增量在库里存的是元,页面上统一按万元填写
const toWan = (v) => (v === null || v === undefined ? "" : (Number(v) / 10000).toFixed(2));
const fields = isP
? `<div class="form-row"><label>子任务</label><input class="inp" id="udTask" value="${esc(u ? u.task_name || "" : "")}" placeholder="留空记在整体进展下" style="width:100%" /></div>
<div class="form-row"><label>状态</label><select class="sel" id="udStatus" style="width:100%">
${(state.meta.statuses || []).map((s) => `<option value="${esc(s.code)}" ${s.code === defStatus ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
</select></div>
<div class="form-row"><label>进度(%)</label><input class="inp" id="udProgress" type="number" min="0" max="100" value="${nv(u && u.progress)}" style="width:100%" /></div>`
: `<div class="form-row"><label>交付阶段</label><select class="sel" id="udStage" style="width:100%">
${stages.map((s) => `<option value="${esc(s.code)}" ${s.code === defStage ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
</select></div>
<div class="form-row"><label>进度(%)</label><input class="inp" id="udProgress" type="number" min="0" max="100" value="${nv(u && u.progress)}" style="width:100%" /></div>
<div class="form-row"><label>本期新增计收(万元)</label><input class="inp" id="udRevenue" type="number" min="0" step="0.01" value="${toWan(u && u.revenue_delta)}" style="width:100%" /></div>
<div class="form-row"><label>本期新增下单(万元)</label><input class="inp" id="udAmount" type="number" min="0" step="0.01" value="${toWan(u && u.amount_delta)}" style="width:100%" /></div>`;
openModal(
`<div class="modal-head"><h3>${u ? "修改" : "添加"}${isP ? "执行记录" : "交付记录"}</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><select class="sel" id="udPeriod" style="width:100%">
${weeks.map((w) => `<option value="${w.id}" ${w.id === periodId ? "selected" : ""}>${esc(w.full_label)}</option>`).join("")}
</select></div>
${fields}
<div class="form-row full"><label>内容 *</label><textarea class="inp" id="udContent" rows="5" data-autofocus style="width:100%">${esc(u ? u.content || u.summary || "" : "")}</textarea></div>
<div class="form-row full"><label>风险</label><textarea class="inp" id="udRisk" rows="2" style="width:100%">${esc(u ? u.risk || "" : "")}</textarea></div>
<div class="form-row full"><label>下一步</label><textarea class="inp" id="udNext" rows="2" style="width:100%">${esc(u ? u.next_step || "" : "")}</textarea></div>
</div>
<div class="notice err" id="mErr" style="display:none"></div>
</div>
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">${u ? "保存修改" : "添加"}</button></div>`
);
const numVal = (id) => { const el = $(id); return el.value === "" ? null : Number(el.value); };
const wanVal = (id) => { const el = $(id); return el.value === "" ? null : Math.round(Number(el.value) * 10000); };
$("#mOk").addEventListener("click", async () => {
const content = $("#udContent").value.trim();
if (!content) return modalError("请填写内容");
const btn = $("#mOk");
btn.disabled = true;
const body = {
period_id: Number($("#udPeriod").value),
content: content,
progress: numVal("#udProgress"),
risk: $("#udRisk").value.trim() || null,
next_step: $("#udNext").value.trim() || null,
};
try {
if (isP) {
body.status = $("#udStatus").value;
body.task_name = $("#udTask").value.trim();
if (u) await patch("/updates/" + u.id, body);
else await post("/updates", Object.assign({ project_id: o.projectId, source: "manual" }, body));
} else {
body.stage = $("#udStage").value;
body.revenue_delta = wanVal("#udRevenue");
body.amount_delta = wanVal("#udAmount");
if (u) await patch("/custom/updates/" + u.id, body);
else await post("/custom/updates", Object.assign({ custom_project_id: o.customProjectId, source: "manual" }, body));
}
} catch (e) {
btn.disabled = false;
return modalError(e.message);
}
closeModal();
toast(u ? "已修改" : "已添加");
await afterWrite();
if (o.onDone) await o.onDone();
});
}
/** 给一块区域里的周期记录绑上「修改 / 删除」。list 用来按 id 找回记录,reload 刷新这块区域。 */
function bindUpdateOps(root, list, reload) {
root.querySelectorAll("[data-uop]").forEach((b) =>
b.addEventListener("click", async () => {
const id = Number(b.dataset.uid);
const kind = b.dataset.ukind;
const u = (list || []).find((x) => x.id === id);
if (!u) return;
if (b.dataset.uop === "edit") {
openUpdateDialog({
kind: kind,
update: u,
projectId: u.project_id,
customProjectId: u.custom_project_id,
onDone: reload,
});
return;
}
const ok = await confirmDialog({
title: "删除这条记录?",
body: `<div class="notice err">删除后无法恢复。</div>
<div style="font-size:12.5px;color:var(--text-2);line-height:1.7">${esc([u.period_full, u.task_name || u.project_name].filter(Boolean).join(" · "))}</div>`,
okText: "永久删除",
danger: true,
});
if (!ok) return;
try {
await del(kind === "custom" ? "/custom/updates/" + id : "/updates/" + id);
} catch (e) { toast("删除失败:" + e.message); return; }
toast("已删除");
await afterWrite();
if (reload) await reload();
})
);
}
/** 页内搜索框:与右上角全局搜索共用 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() {
if (!canR("staff")) return null; // 没有花名册权限时不请求,负责人下拉留空
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 : [];
}
/** 顶栏当前选中周期(周 = 那一周,月 = 该月各周的并集)的实际起止日期,口径同后端 /periods */
function scopeRange() {
if (state.gran === "week") {
const w = (state.periods.weeks || []).find((x) => x.id === state.weekId);
return w && w.first_day ? { start: w.first_day, end: w.last_day, label: w.full_label } : null;
}
const m = currentMonth();
return m && m.first_day ? { start: m.first_day, end: m.last_day, label: m.label } : null;
}
/* ---------------------------------------------------------------- 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">执行记录</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>
<div class="kpi accent-amber">${Ico.badge("alert")}<div class="k-label">风险 / 阻塞</div><div class="k-value">${k.risk_count}</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>
</div>
<div class="grid g2" style="margin-bottom:16px">
<div class="card">
<div class="card-head"><h4>周期更新活跃度</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>执行状态分布</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>风险与阻塞</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>低进度预警</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>`;
}
/** 一条执行 / 交付记录;传 kind 才给行内「修改 / 删除」 */
function updateItem(u, kind) {
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.task_name && u.period_full ? `<span class="tag-mini">${esc(u.period_label || "")}</span>` : ""}
${kind ? updOps(kind, u) : ""}
</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;
const menu = isP ? "projects" : "custom";
return `
<div class="wk-head" style="--c:${color}">
<div class="wk-head-main">
<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>
<div class="wk-head-ops">
${canW(menu) ? `<button class="btn sm primary" id="wkAddUpd">${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((x) => updateItem(x, "project")).join("")
: `<div class="notice warn">本周期还没有执行记录。重点项目每周都要更新一次,点上方「添加执行记录」补上。</div>`}
</div>
<div class="sec">
<div class="sec-title">进度与状态</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">交付环节</div>
${pipelineBar(p, !canW("custom"))}
${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((x) => updateItem(x, "custom")).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) {
// 写入后 afterWrite() 会重绘看板并保留当前选中项,这里不用再自己刷新
const add = $("#wkAddUpd");
if (add) add.addEventListener("click", () =>
openUpdateDialog({
kind: row.kind, periodId: state.weekId, projectId: row.id, customProjectId: row.id,
status: row.status, stage: row.stage,
})
);
bindUpdateOps(box, row.updates);
const dw = $("#wkDrawer");
if (dw) dw.addEventListener("click", () =>
row.kind === "project" ? openDrawer(row.id) : openCustomDrawer(row.id)
);
box.querySelectorAll("[data-cf]").forEach((inp) => {
if (!canW("custom")) { inp.disabled = true; return; }
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) {
// 「本周新增 / 本周验收」与定开项目的「本周进展」tab 共用同一套口径
const { added, accepted, listed } = customWeeklySections(data.custom, scopeRange());
// 高风险取全部在档项目:/weekly 的 custom 只有本期有动态的,取全量才不会漏
const risky = (state.customAll || data.custom).filter((r) => isHighRisk(r))
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0));
const RISK_SHOW = 10; // 高风险项目可能几十条,卡片里按合同额只显示前若干个
// 合计只算列出来的行(同一项目可能同时出现在新增与验收两段,这里按 id 去重),免得数字对不上清单
const sum = listed.reduce((a, r) => a + Number(r.amount || 0), 0);
const sec = (title, sub, list, tag) => `
<div class="sec">
<div class="sec-title">${title}</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-main">
<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">本周验收 ${accepted.length} 个</span>
<span class="tag-mini">高风险 ${risky.length} 个</span>
<span class="tag-mini">合同额合计 ${fen(sum)} 万</span>
<span class="tag-mini">本期计收 ${fen(data.kpi.revenue_delta)} 万</span>
</div>
</div>
<div class="wk-head-ops">
<button class="btn sm" id="wkGoCustom">去定开项目</button>
</div>
</div>
${sec("本周新增", "下单时间落在本周期(已下单 / 提前交付)", added,
(r) => `<span class="tag-mini" style="background:#e8f0ff;color:#2563eb">${esc(String(r.sign_date || "").slice(0, 10) || "本期下单")}</span>`)}
${sec("本周验收", "更新时间落在本周期且状态为已验收", accepted,
(r) => `<span class="tag-mini" style="background:#fff4e0;color:#b45309">${esc(String(r.update_date || "").slice(0, 10) || "本期验收")}</span>`)}
${sec("高风险项目", "风险值为「高风险」的在档定开项目", risky.slice(0, RISK_SHOW),
(r) => (r.project_status ? `<span class="tag-mini">${esc(r.project_status)}</span>` : ""))}
${risky.length > RISK_SHOW ? `<div class="sec-hint">共 ${risky.length} 个高风险项目,按合同额显示前 ${RISK_SHOW} 个</div>` : ""}`;
}
function bindWeeklyCustomOverview(box) {
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 [data, focusRows, customAll] = await Promise.all([
api("/weekly" + scopeQuery()),
api("/focus" + focusQuery(["archived=false"])),
// 「本季度验收」与「定制项目 · 项目总览」同一口径:先看「项目状态=已验收」,季度归属再按「验收时间」
api("/custom/projects?archived=false"),
]);
const now = new Date();
const qYear = now.getFullYear(), qNo = Math.floor(now.getMonth() / 3) + 1;
const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; };
// 先按「项目状态=已验收」过滤(状态一改统计立即同步);季度归属再按「验收时间」,为空时算本季度
const accRows = customAll.filter((p) => String(p.project_status || "").trim() === "已验收");
const accQuarter = accRows.filter((p) => { const q = qOf(p.accept_date); return !q || q === "Q" + qNo; });
const accQuarterAmount = accQuarter.reduce((a, p) => a + Number(p.amount || 0), 0);
const accYearCount = accRows.length;
state.weekly = data;
state.customAll = customAll; // 右栏「高风险项目」用全量台账
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 cSec = customWeeklySections(data.custom, scopeRange());
const cNew = cSec.added, cAcc = cSec.accepted, cRisk = customAll.filter((p) => isHighRisk(p)).length;
const cDyn = cSec.listed.length;
const kpi = `
<div class="grid kpi" style="margin-bottom:8px">
<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-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">本季度验收(${qYear}年Q${qNo})</div><div class="k-value" style="font-size:22px">${fen(accQuarterAmount)} 万</div><div class="k-foot">${accQuarter.length} 个项目 · 本年验收 ${accYearCount} 个</div></div>
<div class="kpi accent-amber">${Ico.badge("alert")}<div class="k-label">风险 / 阻塞</div><div class="k-value">${k.risk_count}</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 openCustom = cur && (cur.kind === "customs" || cur.kind === "custom");
host.innerHTML = kpi + `
<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">本周动态 ${cDyn} 个</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">验收 ${cAcc.length}</span>
<span class="tag-mini">高风险 ${cRisk}</span>
</div>
</div>
</div>
</div>
<div class="wk-right card" id="wkRight"><div class="empty">加载中…</div></div>
</div>`;
// 统计概览(原「执行面板」)折在最底下,展开才看;失败也不影响看板主体
dashboardHtml().then((html) => {
host.insertAdjacentHTML("beforeend",
`<details class="card ov"><summary><span class="ov-chev"><i data-icon="chevron-right"></i></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-main">
<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>
${canW("focus") ? `<div class="wk-head-ops"><button class="btn sm primary" id="wkFocusEdit">修改</button></div>` : ""}
</div>
<div class="sec">
<div class="sec-title">说明</div>
${f.detail ? nl2br(f.detail) : `<div class="empty">未填写说明</div>`}
</div>
<div class="sec">
<div class="sec-title">结果反馈</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 shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || [];
const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]];
const chips = 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("");
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">${rowOps({
edit: `data-fedit="${f.id}"`,
archive: `data-farch="${f.id}"`,
archived: f.archived,
remove: `data-fdel="${f.id}"`,
})}</td>
</tr>`).join("")}
</tbody></table>`;
const body = listCard({
title: "重点工作清单",
count: rows.length,
chips: chips,
fields: `<label class="switch"><input type="checkbox" id="fcArchived" ${state.focusArchived ? "checked" : ""} /> 含归档条目</label>`,
tools: `${keywordInputHtml("fKeyword", "搜索工作内容…", 180)}
${canR("focus") ? `<button class="btn sm" id="btnExportFocus">导出 Excel</button>` : ""}
${canW("focus") ? `<button class="btn sm primary" id="btnAddFocus">${Ico.svg("plus")}新增重点工作</button>` : ""}`,
rows: shown,
table: table,
empty: state.keyword
? "没有匹配「" + esc(state.keyword) + "」的重点工作"
: state.focusFilter === "week"
? "本周还没有录入重点工作,点右上角「新增重点工作」添加"
: "这一堆还没有条目,点右上角「新增重点工作」添加",
});
host.innerHTML = 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(); });
const bex = $("#btnExportFocus");
if (bex) bex.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);
})
);
const bad = $("#btnAddFocus");
if (bad) bad.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";
if (!canW("focus")) mode = "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">基本信息</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">基本信息</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>`;
$("#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 deleteProject(p) {
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/" + p.id);
toast(`已删除「${r.name}」:${r.deleted_tasks} 个子任务 / ${r.deleted_updates} 条记录`);
closeDrawer();
await afterWrite();
} catch (e) {
toast("删除失败:" + e.message);
}
}
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.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 chips = `<button class="chip ${state.projIdleOnly ? "active" : ""}" data-pidle="1">只看本${state.gran === "week" ? "周" : "月"}未更新</button>`;
// 原来挂在左侧菜单栏的分类筛选器,收进卡头,能力不变
const catFilter = `<select id="pCat" class="sel" style="width:120px" title="分类">
<option value="">全部分类</option>
${state.categories.map((c) => `<option value="${c.id}" ${c.id === state.categoryId ? "selected" : ""}>${esc(c.name)}(${c.project_count})</option>`).join("")}
</select>`;
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">${rowOps({
edit: `data-pedit="${p.id}"`,
archive: `data-parch="${p.id}"`,
archived: p.archived,
remove: `data-pdel="${p.id}"`,
})}</td>
</tr>`).join("")}
</tbody></table>`;
const body = listCard({
title: "重点项目明细",
count: rows.length,
chips: chips,
fields: `<label class="switch"><input type="checkbox" id="chkArchived" ${state.showArchived ? "checked" : ""} /> 含归档项目</label>`,
tools: `${keywordInputHtml("pKeyword", "搜索项目名称…", 180)}
${catFilter}
${ownerFilter}
${canR("projects") ? `<button class="btn sm" id="btnExportProjects">导出 Excel</button>` : ""}
${canW("projects") ? `<button class="btn sm primary" id="btnNewProject">${Ico.svg("plus")}新建项目</button>` : ""}`,
rows: shown,
table: table,
empty: state.keyword
? "没有匹配「" + esc(state.keyword) + "」的项目"
: "还没有项目",
});
host.innerHTML = body;
bindKeyword("pKeyword", { enter: true });
const pc = $("#pCat");
if (pc) pc.addEventListener("change", () => {
state.categoryId = pc.value ? Number(pc.value) : null;
renderProjects();
});
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();
})
);
host.querySelectorAll("[data-pdel]").forEach((b) =>
b.addEventListener("click", () => {
const p = shown.find((x) => x.id === Number(b.dataset.pdel));
if (p) deleteProject(p);
})
);
$("#chkArchived").addEventListener("change", (e) => { state.showArchived = e.target.checked; renderProjects(); });
const bex = $("#btnExportProjects");
if (bex) bex.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("&"));
});
const bn = $("#btnNewProject");
if (bn) bn.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 customStatusPill(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.低风险) */
const isHighRisk = (p) => String(p.risk_level || "").includes("高");
/** 风险值: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;
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";
// 三个 tab 用顶部「周/月」那套分段控件,筛选器和主按钮(导出 / 新建)放下一行
const head = {
chips: `
<div class="seg">
<button class="seg-btn ${state.customTab === "list" ? "active" : ""}" data-ctab="list">项目总览</button>
<button class="seg-btn ${state.customTab === "weekly" ? "active" : ""}" data-ctab="weekly">本周进展</button>
<button class="seg-btn ${state.customTab === "annual" ? "active" : ""}" data-ctab="annual">年度任务</button>
</div>`,
tools: 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>
${canR("custom") ? `<button class="btn sm" id="btnExportCustom">导出 Excel</button>` : ""}
${canW("custom") ? `<button class="btn sm primary" id="btnNewCustom">${Ico.svg("plus")}新建定开项目</button>` : ""}` : "",
};
host.innerHTML = (
state.customTab === "weekly" ? renderCustomWeekly(allRows, head)
: state.customTab === "annual" ? renderCustomAnnual(allRows, head)
: renderCustomList(allRows, head));
host.querySelectorAll("[data-ctab]").forEach((b) =>
b.addEventListener("click", () => {
if (b.dataset.ctab !== state.customTab) state.customAnnualEdit = false; // 切 tab 退出年度任务的编辑态
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, hdr) {
hdr = hdr || {};
// 列表:按 关键字 + 归属 + 区域 + 状态 在本地筛选;再按表头排序
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 s = state.customSort || { key: "update_date", dir: "desc" };
const sv = (p, k) => (k === "amount" ? Number(p.amount || 0) : String(p[k] || ""));
const view = list.slice().sort((a, b) => {
const va = sv(a, s.key), vb = sv(b, s.key);
const c = va < vb ? -1 : va > vb ? 1 : 0;
return s.dir === "asc" ? c : -c;
});
// ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ----
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) => String(p.project_status || "").trim() === "已验收");
const accQuarter = accRows.filter((p) => { const q = qOf(p.accept_date); return !q || q === 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>
`;
return `<div class="card">
${cardHead({ title: "定开项目总览", count: view.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })}
${statBar}
${view.length ? customTableHtml(view) : `<div class="empty">${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}</div>`}
</div>`;
}
/** 定开项目表格(「项目总览」与「本周进展」两个 tab 共用);下单时间 / 订单金额 / 更新时间 表头可排序 */
function customTableHtml(view, withOps) {
const ops = withOps !== false;
const s = state.customSort || { key: "update_date", dir: "desc" };
const sth = (label, k, cls) =>
`<th class="${cls ? cls + " " : ""}sortable" data-sort="${k}" style="cursor:pointer;user-select:none" title="点击排序">${label}${s.key === k ? (s.dir === "asc" ? " ▲" : " ▼") : ""}</th>`;
return `<div class="tbl-wrap">
<table class="tbl">
<thead><tr>
<th style="min-width:200px">项目名称</th>
<th>项目ID</th>
${sth("下单时间", "sign_date")}
<th>项目归属</th>
<th>办事处</th>
<th>行业</th>
<th class="num">市场下单人天</th>
${sth("订单金额", "amount", "num")}
<th>市场责任人</th>
<th>项目状态</th>
<th class="num">进度</th>
<th>验收时间</th>
<th>推动验收计划</th>
<th>风险值</th>
${sth("更新时间", "update_date")}
<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">${customStatusPill(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 class="nowrap">${esc(p.update_date || "—")}</td>
<td>${esc((p.remark || "").slice(0, 40)) || "—"}</td>
${ops ? `<td class="nowrap col-ops">${rowOps({
edit: `data-cedit="${p.id}"`,
archive: `data-carch="${p.id}"`,
archived: p.archived,
remove: `data-cdel="${p.id}"`,
})}</td>` : ""}
</tr>`).join("")}
</tbody>
</table>
</div>`;
}
/** 定开项目「本期」分堆 —— 工作看板右栏与「本周进展」tab 共用同一套口径:
· 本周新增:下单时间落在本期 ∧(已下单 ∨ 提前交付),和「更新时间」无关
· 本周验收:更新时间落在本期 ∧ 项目状态为「已验收」
rows 传 /custom/projects 全量或 /weekly 的行都行,两者都带 sign_date / update_date / origin / project_status。 */
function customWeeklySections(rows, sc) {
const inScope = (v) => {
const s = String(v || "").slice(0, 10);
return !!(sc && s && s >= sc.start && s <= sc.end);
};
const added = rows.filter((r) => inScope(r.sign_date) && (isPlaced(r) || r.origin === "提前交付"));
const accepted = rows.filter((r) => inScope(r.update_date) && r.project_status === "已验收");
const inAdded = {}, inAccepted = {};
added.forEach((r) => { inAdded[r.id] = true; });
accepted.forEach((r) => { inAccepted[r.id] = true; });
const seen = {}, listed = [];
[added, accepted].forEach((list) => list.forEach((r) => {
if (!seen[r.id]) { seen[r.id] = true; listed.push(r); }
}));
return { added, accepted, listed };
}
/** 本周进展:更新时间落在「顶栏选中周期」内的项目;并按 验收/下单/提前交付/进行中 分类统计 */
function renderCustomWeekly(allRows, hdr) {
hdr = hdr || {};
const sc = scopeRange();
if (!sc) return `<div class="empty">没有取到当前周期的起止日期(刚更新过后端的话,请重启服务后刷新)</div>`;
const start = sc.start, end = sc.end;
const dayOf = (v) => String(v || "").slice(0, 10);
const inScope = (v) => { const s = dayOf(v); return !!s && s >= start && s <= end; };
const rows = allRows.filter((p) => inScope(p.update_date)); // 更新时间在本期
const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0);
// 新增项目 / 验收情况:与工作看板右栏共用 customWeeklySections,两处口径必须一致
const { added: newDeal, accepted: acc } = customWeeklySections(allRows, sc);
// 进行中项目:本期更新的「进行中 + 交付完成」
const doing = rows.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>
`;
return `<div class="card">
${cardHead({ title: "本周进展", count: rows.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })}
${statBar}
${rows.length
? customTableHtml(rows.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), false)
: `<div class="empty">本期没有更新的定开项目</div>`}
</div>`;
}
/** 年度任务:季度计收目标(可在页面上改,存后端)+ 按「验收时间 / 下单时间」回填的完成数据 */
function renderCustomAnnual(allRows, hdr) {
hdr = hdr || {};
const meta = annualTargetMeta();
const Y = meta.year;
const saved = meta.targets;
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 rate = (target, acc) => (!target ? "—" : ((acc / target) * 100).toFixed(1) + "%");
const targetOf = (q) => Number(saved[String(q)] || 0);
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 accAmt = sumW(acc);
return { q, target: targetOf(q), accN: acc.length, accAmt, 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 });
const editing = !!state.customAnnualEdit;
const tr = (r) => `
<tr>
<td class="nowrap">Q${r.q}</td>
<td class="num">${editing
? `<input class="inp num-inp" type="number" min="0" step="0.01" data-at="${r.q}" value="${r.target}" ${canW("custom") ? "" : "disabled"} />`
: fmt(r.target)}</td>
<td class="num">${r.accN}</td>
<td class="num">${fmt(r.accAmt)}</td>
<td class="num" data-ate="${r.q}" data-acc="${r.accAmt}">${rate(r.target, r.accAmt)}</td>
<td class="num">${r.signN}</td>
<td class="num">${fmt(r.signAmt)}</td>
</tr>`;
return `<div class="card">
${cardHead({
title: "年度任务(" + Y + "年)", count: Y + "年",
chips: hdr.chips, chipsRow: true,
tools: canW("custom") ? `<button class="btn sm primary" id="atSave">${editing ? "保存" : "编辑"}</button>` : "",
})}
<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(tr).join("")}
<tr style="font-weight:600">
<td class="nowrap">合计</td>
<td class="num" data-att="1">${fmt(tot.target)}</td>
<td class="num">${tot.accN}</td>
<td class="num">${fmt(tot.accAmt)}</td>
<td class="num" data-ate="0" data-acc="${tot.accAmt}">${rate(tot.target, tot.accAmt)}</td>
<td class="num">${tot.signN}</td>
<td class="num">${fmt(tot.signAmt)}</td>
</tr>
</tbody>
</table>
</div>
</div>`;
}
/** 年度计收目标(万元):后端存一年一套,没配过时返回《年度任务.xlsx》的默认值 */
function annualTargetMeta() {
const a = (state.customMeta && state.customMeta.annual_targets) || {};
return { year: a.year || new Date().getFullYear(), targets: a.targets || {} };
}
/** 年度目标:默认只读,点「编辑」进入编辑模式(按钮变「保存」),改数字实时重算完成率与合计 */
function bindAnnualTargets(host) {
const btn = host.querySelector("#atSave");
if (!btn) return;
if (!state.customAnnualEdit) {
btn.addEventListener("click", () => { state.customAnnualEdit = true; renderCustom(); });
return;
}
const meta = annualTargetMeta();
const save = btn;
const inputs = Array.prototype.slice.call(host.querySelectorAll("[data-at]"));
const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const valOf = (q) => {
const el = inputs.filter((i) => i.dataset.at === String(q))[0];
return el ? Number(el.value) || 0 : 0;
};
const rate = (target, acc) => (!target ? "—" : ((acc / target) * 100).toFixed(1) + "%");
const recalc = () => {
const total = [1, 2, 3, 4].reduce((a, q) => a + valOf(q), 0);
host.querySelectorAll("[data-ate]").forEach((cell) => {
const q = Number(cell.dataset.ate);
const target = q === 0 ? total : valOf(q);
cell.textContent = rate(target, Number(cell.dataset.acc) || 0);
});
host.querySelector("[data-att]").textContent = fmt(total);
};
inputs.forEach((i) => i.addEventListener("input", recalc));
save.addEventListener("click", async () => {
const targets = {};
inputs.forEach((i) => { targets[i.dataset.at] = Number(i.value) || 0; });
save.disabled = true;
try {
state.customMeta.annual_targets = await put("/custom/annual-targets", { year: meta.year, targets: targets });
} catch (e) {
save.disabled = false;
toast("保存失败:" + e.message);
return;
}
state.customAnnualEdit = false;
toast("已保存年度目标");
renderCustom();
});
}
/** 项目总览表:行内「修改 / 归档 / 删除」,名称可点开详情;表头(下单时间 / 订单金额 / 更新时间)可排序 */
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 findRow = (id) => (state.customRows || []).find((x) => x.id === id);
host.querySelectorAll("[data-carch]").forEach((b) =>
b.addEventListener("click", () => {
const p = findRow(Number(b.dataset.carch));
if (p) archiveCustomProject(p, afterWrite);
})
);
host.querySelectorAll("[data-cdel]").forEach((b) =>
b.addEventListener("click", () => {
const p = findRow(Number(b.dataset.cdel));
if (p) deleteCustomProject(p, afterWrite);
})
);
bindAnnualTargets(host);
host.querySelectorAll("[data-sort]").forEach((th) =>
th.addEventListener("click", () => {
const k = th.dataset.sort;
const cur = state.customSort || { key: "update_date", dir: "desc" };
state.customSort = { key: k, dir: cur.key === k && cur.dir === "desc" ? "asc" : "desc" };
renderCustom();
})
);
}
/**
* 新建定开项目 —— 全站唯一的新建入口。
* 「定开项目」工具栏和「更新录入」页都调用它,避免两套录入逻辑。
*/
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";
if (!canW("custom")) mode = "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)}${p.archived ? ` <span class="arch-badge">已归档</span>` : ""}`;
const progPct = (p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : null;
$("#drawerSub").innerHTML = `${stagePill(p.stage)} ${customStatusPill(p.project_status)}` +
(progPct ? ` <span class="tag-mini">进度 ${progPct}</span>` : "");
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 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">项目信息</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 || p.region || "—"))}
${ro("行业", esc(p.industry || "—"))}
${ro("下单时间", esc(p.sign_date || "—"))}
${ro("市场下单人天", numOrDash(p.man_days))}
${ro("市场责任人", esc(p.market_owner || "—"))}
${ro("交付负责人", esc(p.owner || "—"))}
${ro("团队成员", nameChips(p.members))}
${ro("客户", esc(p.customer || "—"))}
${ro("项目状态", customStatusPill(p.project_status))}
${ro("进度", progPct || "—")}
${ro("验收时间", esc(p.accept_date || "—"))}
${ro("推动验收计划", esc(p.accept_plan || "—"))}
${ro("风险值", riskPill(p.risk_level))}
${ro("交付环节", `<div style="max-width:100%">${pipelineBar(p, true)}</div>`)}
${ro("备注", p.remark ? nl2br(p.remark) : "—")}
${ro("更新时间", esc(p.update_date || "—"))}
`}
</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" ? `
<div class="sec">
<div class="sec-title">收入确认</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.manual_ratio == null ? "按阶段" : "手动"} ${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})</div>
<div class="sec-actions upd">
<button class="btn sm primary" id="cdAddUpd">${Ico.svg("plus")}添加交付记录</button>
<button class="btn sm ghost" id="cdIntake">${Ico.svg("sparkles")}AI 录入</button>
</div>
${d.updates.length ? d.updates.map((u) => updateItem(u, "custom")).join("") : `<div class="empty">暂无交付记录</div>`}
</div>
${d.timeline.length ? `<div class="sec"><div class="sec-title">阶段流转</div>${timeline}</div>` : ""}
` : ""}`;
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
on("#cdEdit", () => openCustomDrawer(cid, "edit"));
on("#cdAddUpd", () =>
openUpdateDialog({ kind: "custom", periodId: state.weekId, customProjectId: cid, stage: p.stage, onDone: () => openCustomDrawer(cid, "view") }));
on("#cdIntake", () => {
state.intakeType = "custom";
state.intakePeriodId = state.weekId;
customIntakeId = cid;
closeDrawer();
setView("intake");
});
bindUpdateOps($("#drawerBody"), d.updates, () => openCustomDrawer(cid, "view"));
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">
<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>
${mode === "ai" ? `<span class="sec-hint">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>定制项目 · 交付记录录入</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="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: null,
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 (!canW("intake")) {
host.innerHTML = `<div class="notice warn">「更新录入」是写操作,当前角色是只读权限,无法录入内容。</div>`;
return;
}
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>自研产品 · 执行记录录入</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", () => {
if (sel.value !== "__new") intakeProjectId = Number(sel.value);
$("#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();
const rw = canW("settings");
host.innerHTML = `
<div class="grid g2">
<div class="card">
<div class="card-head"><h4>AI 整理配置</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">${rw ? `<button class="btn primary" id="btnSaveCfg">保存配置</button>` : `<span class="tag-mini">只读权限</span>`}</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>周期管理</h4>
${rw ? `<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">${rw ? `<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>`;
if (!rw) host.querySelectorAll("#cfgBase,#cfgKey,#cfgModel,#cfgTemp").forEach((i) => { i.disabled = true; });
const bap = $("#btnAddPeriod");
if (bap) bap.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);
}
})
);
const bsc = $("#btnSaveCfg");
if (bsc) bsc.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";
if (!canW("projects")) mode = "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
drawerActions("");
let d = await api("/projects/" + pid);
state.drawerProject = d;
// 弹窗里改完记录,只重画下面的周期反馈,抽屉不关闭、滚动位置不丢
const reloadFeed = async () => {
d = await api("/projects/" + pid);
state.drawerProject = d;
renderFeed();
};
const p = d.project;
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
$("#drawerSub").innerHTML = `${catPill(p.category)} ${statusPill(p.status)} <span class="tag-mini">进度 ${Math.round(p.progress)}%</span>`;
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">项目信息</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: "负责人" })}
${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">项目信息</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>` : ""}${updOps("project", u)}</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: [[2, "近 2 周"], [0, "全部"]],
month: [[1, "近 1 月"], [0, "全部"]],
};
// 「添加执行记录」直接去「更新录入」页,不再弹录入窗
const gotoIntake = () => {
state.intakeType = "general";
state.intakeMode = "direct";
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">
<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>
<div class="feed-ops">
<button class="btn sm primary" id="dwAddUpd">${Ico.svg("plus")}添加执行记录</button>
</div>
</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">执行记录</div>${bar}<div>${body}</div></div>`;
host.querySelector("#dwAddUpd").addEventListener("click", () => gotoIntake());
bindUpdateOps(host, d.timeline.flatMap((t) => t.updates), reloadFeed);
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" ? 2 : 1, 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: 2, 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">历史进度走势</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", () => deleteProject(p));
}
// 标题栏右侧的标准按钮条:按钮由当前打开的抽屉自己填,主按钮在最右
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>人员归属</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>花名册外的负责人</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 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">
${canW("staff")
? `<a href="javascript:;" data-sedit="${m.id}" style="color:var(--text);text-decoration:none;font-weight:600">${esc(m.name)}</a>`
: `<b>${esc(m.name)}</b>`}
${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">${rowOps({
edit: `data-sedit="${m.id}"`,
archive: `data-sarch="${m.id}"`,
archived: m.archived,
remove: `data-sdel="${m.id}"`,
})}</td>
</tr>`).join("")}
</tbody></table>`;
const body = listCard({
title: "人员花名册",
count: d.items.length,
fields: `<select id="stArchived" class="sel" style="width:104px">${archOpts}</select>
<select id="stGroup" class="sel" style="width:120px">${groupOpts}</select>
${keywordInputHtml("stKeyword", "搜索姓名…", 160)}`,
tools: `${canR("staff") ? `<button class="btn sm" id="btnExportStaff">导出 Excel</button>` : ""}
${canW("staff") ? `<button class="btn sm primary" id="btnNewStaff">${Ico.svg("plus")}新增人员</button>` : ""}`,
rows: d.items,
table: table,
empty: state.keyword
? "没有匹配「" + esc(state.keyword) + "」的人员"
: (state.staffGroup ? "「" + esc(state.staffGroup) + "」还没有人员" : "还没有人员"),
});
host.innerHTML = kpi + body;
bindKeyword("stKeyword");
$("#stArchived").addEventListener("change", (e) => { state.staffArchived = e.target.value; renderStaff(); });
$("#stGroup").addEventListener("change", (e) => { state.staffGroup = e.target.value; renderStaff(); });
const bn = $("#btnNewStaff");
if (bn) bn.addEventListener("click", () => openStaffDialog(null));
const bex = $("#btnExportStaff");
if (bex) bex.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 }))
);
const roStaff = !canW("staff");
host.querySelectorAll("tr[data-sid]").forEach((tr) => {
const id = Number(tr.dataset.sid);
tr.querySelectorAll("[data-sf]").forEach((inp) => {
if (roStaff) { inp.disabled = true; return; }
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); }
});
});
});
}
/* ------------------------------------------------------ 权限管理(RBAC) */
/** 角色 = 一张「菜单 × 能改/只读」矩阵;用户挂一个角色。下拉改完即存,后端立刻生效。 */
async function renderRbac() {
const host = $("#view-rbac");
host.innerHTML = `<div class="empty">加载中…</div>`;
const [meta, roles, users] = await Promise.all([
api("/rbac/meta"), api("/rbac/roles"), api("/rbac/users"),
]);
const rw = canW("rbac");
const menus = meta.menus;
const save = async (fn) => {
try { await fn(); } catch (e) { toast("保存失败:" + e.message); }
await renderRbac();
};
const levelCell = (role, m) => {
const cur = (role.menus || {})[m.key] || "";
return `<td><select class="sel xs" data-rmenu="${role.id}" data-menu="${m.key}"${rw ? "" : " disabled"}>` +
meta.levels.map((l) => `<option value="${l.value}" ${l.value === cur ? "selected" : ""}>${l.label}</option>`).join("") +
`</select></td>`;
};
const roleTable = `<table class="tbl">
<thead><tr>
<th class="rbac-role">角色 / 说明</th>
${menus.map((m) => `<th class="rbac-menu-th">${esc(m.label)}</th>`).join("")}
<th class="num">用户</th>
${rw ? `<th class="col-ops">操作</th>` : ""}
</tr></thead><tbody>
${roles.map((r) => `<tr>
<td class="rbac-role"><b>${esc(r.name)}</b>${r.is_builtin ? ` <span class="tag-mini builtin">内置</span>` : ""}${r.description ? `<span>${esc(r.description)}</span>` : ""}</td>
${menus.map((m) => levelCell(r, m)).join("")}
<td class="num">${r.user_count}</td>
${rw ? `<td class="col-ops nowrap"><span class="row-ops">
<button class="icon-btn sm" data-redit="${r.id}" title="修改名称/说明">${Ico.svg("pencil")}</button>
${r.is_builtin ? "" : `<button class="icon-btn sm danger" data-rdel="${r.id}" title="删除">${Ico.svg("trash")}</button>`}
</span></td>` : ""}
</tr>`).join("")}
</tbody></table>`;
const roleOpts = (sel) => roles.map((r) =>
`<option value="${r.id}" ${r.id === sel ? "selected" : ""}>${esc(r.name)}</option>`).join("");
const userTable = `<table class="tbl">
<thead><tr>
<th>姓名</th><th>登录账号</th><th>角色</th><th class="num">可用菜单</th><th>最近登录</th>
${rw ? `<th class="col-ops">操作</th>` : ""}
</tr></thead><tbody>
${users.map((u) => {
const self = u.id === state.me.id;
return `<tr${u.active ? "" : ' style="opacity:.6"'}>
<td><b>${esc(u.name)}</b>${self ? ` <span class="tag-mini">我自己</span>` : ""}${u.active ? "" : ` <span class="tag-mini">已停用</span>`}</td>
<td>${esc(u.username)}</td>
<td><select class="sel xs" data-urole="${u.id}"${rw && !self ? "" : " disabled"} title="${self ? "不能改自己的角色" : ""}">${roleOpts(u.role_id)}</select></td>
<td class="num">${u.menus ? Object.keys(u.menus).length : 0}</td>
<td>${esc(u.last_login_at ? u.last_login_at.replace("T", " ").slice(0, 16) : "从未登录")}</td>
${rw ? `<td class="col-ops nowrap"><span class="row-ops">
<button class="icon-btn sm" data-upw="${u.id}" title="重置密码">${Ico.svg("sliders")}</button>
${self ? "" : `
<button class="icon-btn sm" data-uactive="${u.id}" data-on="${u.active ? "1" : "0"}" title="${u.active ? "停用" : "启用"}">${Ico.svg(u.active ? "ban" : "check-circle")}</button>
<button class="icon-btn sm danger" data-udel="${u.id}" title="删除">${Ico.svg("trash")}</button>`}
</span></td>` : ""}
</tr>`;
}).join("")}
</tbody></table>`;
host.innerHTML =
listCard({
title: "角色权限", count: roles.length, rows: roles, table: roleTable, empty: "还没有角色",
tools: rw ? `<button class="btn sm primary" id="btnNewRole">${Ico.svg("plus")}新建角色</button>` : "",
}) +
`<div style="height:16px"></div>` +
listCard({
title: "用户", count: users.length, rows: users, table: userTable, empty: "还没有用户",
tools: rw ? `<button class="btn sm primary" id="btnNewUser">${Ico.svg("plus")}新建用户</button>` : "",
});
// ---- 权限矩阵:一个角色的整行一起提交 ----
host.querySelectorAll("[data-rmenu]").forEach((sel) =>
sel.addEventListener("change", () => {
const rid = Number(sel.dataset.rmenu);
const mine = state.me.role_id === rid;
const next = {};
sel.closest("tr").querySelectorAll("[data-rmenu]").forEach((x) => { if (x.value) next[x.dataset.menu] = x.value; });
save(() => patch("/rbac/roles/" + rid, { menus: next })).then(() => {
if (mine) toast("已更新你所在角色的权限,刷新页面后生效");
});
})
);
const bnr = $("#btnNewRole");
if (bnr) bnr.addEventListener("click", () => openRoleDialog(null));
host.querySelectorAll("[data-redit]").forEach((b) =>
b.addEventListener("click", () => openRoleDialog(roles.find((x) => x.id === Number(b.dataset.redit))))
);
host.querySelectorAll("[data-rdel]").forEach((b) =>
b.addEventListener("click", async () => {
const r = roles.find((x) => x.id === Number(b.dataset.rdel));
const ok = await confirmDialog({ title: "删除角色", danger: true, okText: "确认删除",
body: `<p style="margin:0">确认删除角色 <b>${esc(r.name)}</b>?该角色下不能还有用户。</p>` });
if (ok) save(() => del("/rbac/roles/" + r.id));
})
);
// ---- 用户 ----
const bnu = $("#btnNewUser");
if (bnu) bnu.addEventListener("click", () => openUserDialog(null, roles));
host.querySelectorAll("[data-urole]").forEach((sel) =>
sel.addEventListener("change", () =>
save(() => patch("/rbac/users/" + Number(sel.dataset.urole), { role_id: Number(sel.value) }))
)
);
host.querySelectorAll("[data-upw]").forEach((b) =>
b.addEventListener("click", () =>
openPasswordReset(users.find((x) => x.id === Number(b.dataset.upw)))
)
);
host.querySelectorAll("[data-uactive]").forEach((b) =>
b.addEventListener("click", () => {
const on = b.dataset.on === "1";
save(() => patch("/rbac/users/" + Number(b.dataset.uactive), { active: !on }));
})
);
host.querySelectorAll("[data-udel]").forEach((b) =>
b.addEventListener("click", async () => {
const u = users.find((x) => x.id === Number(b.dataset.udel));
const ok = await confirmDialog({ title: "删除用户", danger: true, okText: "确认删除",
body: `<p style="margin:0">确认删除用户 <b>${esc(u.name)}</b>(${esc(u.username)})?该账号会立即下线。</p>` });
if (ok) save(() => del("/rbac/users/" + u.id));
})
);
}
/** 角色只影响「能看哪些菜单」,名称说明在这里改,菜单授权在矩阵里勾 */
function openRoleDialog(role) {
const isNew = !role;
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-row"><label>角色名 *</label><input class="inp" id="rlName" data-autofocus value="${esc(role ? role.name : "")}" style="width:100%" placeholder="如:项目管理员" /></div>
<div class="form-row"><label>说明</label><input class="inp" id="rlDesc" value="${esc((role && role.description) || "")}" style="width:100%" placeholder="这个角色给谁用" /></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 = $("#rlName").value.trim();
if (!name) return modalError("请填写角色名");
const body = { name: name, description: $("#rlDesc").value.trim() };
try {
if (isNew) await post("/rbac/roles", { name: body.name, description: body.description, menus: {} });
else await patch("/rbac/roles/" + role.id, body);
closeModal();
toast(isNew ? "已新建角色,在矩阵里勾选菜单即可授权" : "已保存");
await renderRbac();
} catch (e) { modalError(e.message); }
});
}
function openUserDialog(user, roles) {
const isNew = !user;
const u = user || {};
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="usName" data-autofocus value="${esc(u.name || "")}" style="width:100%" /></div>
<div class="form-row"><label>登录账号 *</label><input class="inp" id="usUser" value="${esc(u.username || "")}" ${isNew ? "" : "disabled"} style="width:100%" autocomplete="off" /></div>
<div class="form-row"><label>角色</label><select class="sel" id="usRole" style="width:100%">${
roles.map((r) => `<option value="${r.id}" ${r.id === u.role_id ? "selected" : ""}>${esc(r.name)}</option>`).join("")
}</select></div>
${isNew ? `<div class="form-row"><label>初始密码 *</label><input class="inp" id="usPass" type="text" value="" style="width:100%" placeholder="至少 6 位" autocomplete="new-password" /></div>` : ""}
</div>
<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 = $("#usName").value.trim();
if (!name) return modalError("请填写姓名");
const btn = $("#mOk");
btn.disabled = true;
try {
if (isNew) {
const username = $("#usUser").value.trim();
const password = $("#usPass").value.trim();
if (!username) return void (btn.disabled = false, modalError("请填写登录账号"));
await post("/rbac/users", { name: name, username: username, password: password, role_id: Number($("#usRole").value), active: true });
} else {
await patch("/rbac/users/" + u.id, { name: name });
}
closeModal();
toast(isNew ? `已新建用户「${name}」` : "已保存");
await renderRbac();
} catch (e) { btn.disabled = false; modalError(e.message); }
});
}
function openPasswordReset(user) {
openModal(
`<div class="modal-head"><h3>重置密码</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
<div class="modal-body">
<p style="margin:0 0 12px">给 <b>${esc(user.name)}</b>(${esc(user.username)})设置新密码。</p>
<div class="form-row"><label>新密码 *</label><input class="inp" id="rsPass" type="text" data-autofocus style="width:100%" placeholder="至少 6 位" autocomplete="new-password" /></div>
<div class="notice warn">重置后该用户的其他登录全部失效,需要用新密码重新登录。</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 () => {
try {
await patch("/rbac/users/" + user.id, { password: $("#rsPass").value.trim() });
closeModal();
toast(`已重置「${user.name}」的密码`);
} catch (e) { modalError(e.message); }
});
}
/* ---------------------------------------------------------------- shell */
async function loadCategories() {
// 侧栏的分类筛选器已下线,这里只给「自研产品」子 header 上的分类下拉供数据
if (!canR("projects")) { state.categories = []; return; }
state.categories = await api("/categories");
}
function setView(view) {
if (!can(view)) view = firstAllowedView();
if (!view) { hostNoPermission(); return; }
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();
else if (v === "rbac") await renderRbac();
} catch (e) {
$("#view-" + v).innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
}
}
/** 写操作统一收尾:刷新侧栏计数 + 当前视图。
录入页不重绘,否则会清空用户正在编辑的表单。 */
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();
});
$("#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", "keyword",
"projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived",
"customTab", "customArchived", "customRegion", "customOrigin", "customSort", "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", "rbac"].indexOf(state.view) < 0) state.view = "board";
if (state.gran !== "month") state.gran = "week";
if (state.focusFilter === "later") state.focusFilter = "week";
}
function hostNoPermission() {
document.querySelector(".views").innerHTML =
`<div class="notice warn">当前角色没有分配任何功能菜单,请联系管理员在「权限管理」里授权。</div>`;
}
async function main() {
restoreUI();
bindAuthForm();
try {
state.me = await api("/auth/me");
} catch (e) {
if (e.status === 401) return showAuthGate(); // 首次访问:切到登录页但不显示报错
throw e;
}
if (!state.me.menus || !Object.keys(state.me.menus).length) {
bindShell();
renderUserChip();
return hostNoPermission();
}
state.meta = await api("/meta");
await loadPeriods();
await loadStaff();
await loadCategories();
bindShell();
renderUserChip();
applyNavPermissions();
// 恢复出来的视图状态要回写到顶栏/侧栏控件上
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === state.gran));
$("#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>`);
});
})();