/* 软件开发部 · 关键任务追踪看板 */
(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, ">")
.replace(/"/g, """);
}
const nl2br = (s) => esc(s).replace(/\n/g, "
");
const money = (n) =>
n === null || n === undefined
? "—"
: "¥" + Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
const wan = (n) =>
n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(1) + " 万";
const fen = (n) => (n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(2));
/** 重点标记:统一用星标图标,on = 已标重点 */
const starMark = (on, readonly, title) =>
`${Ico.svg("star")}`;
async function api(path, opts) {
const res = await fetch("/api" + path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts || {}));
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).detail || msg; } catch (e) { /* ignore */ }
// 会话过期 / 被管理员下线:任何请求都直接把界面切回登录页
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 = `
`;
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(`
用户设置
`);
$("#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 ? `${o.count}` : "";
// chipsRow:切 tab 的 chip 独占一行 header(标题与当前 tab 同名,省掉,计数跟在 chip 后),
// 筛选器和主功能按钮放同一行。
if (o.chipsRow) {
return `
${o.chips ? `
${o.chips}${cnt}
` : ""}
${tools ? `
${tools}
` : ""}
`;
}
return `
${esc(o.title)}${cnt}
${o.chips ? `
${o.chips}
` : ""}
${tools ? `
${tools}
` : ""}
`;
}
function listCard(o) {
return `
${cardHead(o)}
${o.rows.length ? `
${o.table}
` : `
${o.empty}
`}
`;
}
/** 表格行内操作:全站统一「修改 / 归档 / 删除」三个图标按钮,名称点开详情。
事件属性名由调用方给出,沿用各表原有的绑定。 */
function rowOps(o) {
// 只读角色不给行内「修改 / 归档 / 删除」
if (!canW(state.view)) return "";
const b = (attr, icon, title, cls) =>
``;
if (!o.edit && !o.archive && !o.remove) return "";
return `` +
(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") : "") +
``;
}
/* -------------------------------------- 周期记录(执行 / 交付)的新增·修改·删除 */
/** 记录条目右上角的操作按钮,鼠标移到条目上才显现 */
function updOps(kind, u) {
if (!canW(kind === "custom" ? "custom" : "projects")) return "";
return `
`;
}
/**
* 新增 / 修改一条周期记录,全站唯一入口(看板右栏、项目抽屉、定开抽屉共用)。
* 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
? `
`
: `
`;
openModal(
`${u ? "修改" : "添加"}${isP ? "执行记录" : "交付记录"}
`
);
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: `删除后无法恢复。
${esc([u.period_full, u.task_name || u.project_name].filter(Boolean).join(" · "))}
`,
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 ``;
}
/** 绑定页内搜索。
默认:防抖实时搜索;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(
`${esc(o.title)}
${o.body || ""}
${
o.requireText
? `
`
: ""
}
`
);
modalResolve = resolve;
$("#mOk").addEventListener("click", () => closeModal(true));
if (o.requireText) {
const t = $("#mConfirmText");
const ok = $("#mOk");
t.addEventListener("input", () => {
ok.disabled = t.value.trim() !== o.requireText;
});
t.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !ok.disabled) closeModal(true);
});
}
});
}
function statusMeta(code) {
const list = (state.meta && state.meta.statuses) || [];
return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" };
}
function statusPill(code) {
const s = statusMeta(code);
return `${Ico.status(code)}${esc(s.name)}`;
}
function catPill(cat) {
if (!cat) return `未分类`;
return `${esc(cat.name)}`;
}
/** 规范省级行政区下拉(按大区分组)。selected 不在标准列表时补一个选项,避免丢值。 */
function regionOptions(selected, withEmpty) {
const groups = (state.customMeta && state.customMeta.region_groups) || [];
const all = groups.reduce((acc, g) => acc.concat(g.regions), []);
let html = withEmpty === false ? "" : ``;
for (const g of groups) {
html += ``;
}
if (selected && all.indexOf(selected) < 0) {
html += ``;
}
return html;
}
/** 触发浏览器下载(导出 Excel) */
function download(url) {
const a = document.createElement("a");
a.href = url;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), 1000);
toast("正在生成 Excel,请稍候…");
}
/**
* 负责人下拉。选项来自人力资源库:已归档人员不再出现在选项里,
* 只有他本来就是当前负责人时才带「(已归档)」标注出现一次,避免历史记录丢值;
* 库里没有的历史人名同样补一个选项,保证老数据不被改坏。
*/
function ownerSelectHtml(value, attrs) {
const cur = (value || "").trim();
let html = ``;
}
/**
* 多负责人 / 团队成员的可编辑名单:人名下拉(选项来自人力资源库)横向排开、自动换行,
* 不分主次,每个人都能删;「+ 添加」紧跟在名单末尾。
* 增删按钮的事件统一委托在 #drawerBody 上(见 bindDrawerPeople)。
*/
function peopleEditorHtml(id, label, values, opts) {
const list = values && values.length ? values : [""];
const rows = list
.map(
(n) => `
${ownerSelectHtml(n, 'style="width:118px"')}
`,
)
.join("");
return `
`;
}
/** 读回名单里的人名(按行序,空行忽略) */
function peopleValues(id) {
return $$("#" + id + " select").map((x) => x.value.trim()).filter(Boolean);
}
/** 名单增删:抽屉里所有「多负责人 / 团队成员」共用一个委托 */
function bindDrawerPeople() {
$("#drawerBody").addEventListener("click", (e) => {
const add = e.target.closest("[data-padd]");
if (add) {
const box = document.getElementById(add.dataset.padd);
if (!box) return;
const row = document.createElement("div");
row.className = "people-row";
row.innerHTML =
ownerSelectHtml("", 'style="width:130px"') +
``;
box.insertBefore(row, add);
row.querySelector("select").focus();
return;
}
const del = e.target.closest("[data-pdel]");
if (del) del.closest(".people-row").remove();
});
}
/** 只读名单:小标签一排 */
function nameChips(list) {
return (list || []).length
? list.map((n) => `${esc(n)}`).join(" ")
: "—";
}
/** 拉一次花名册,供各处「负责人」下拉使用 */
async function loadStaff() {
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 += `${esc(d.label)}: ${d.value}${esc(opts.unit || "")}`;
if (d.value > 0) {
bars += `${d.value}`;
}
bars += `${esc(d.label)}`;
});
let grid = "";
for (let g = 0; g <= 3; g++) {
const y = padT + (innerH / 3) * g;
grid += ``;
grid += `${Math.round(max - (max / 3) * g)}`;
}
return ``;
}
function donutChart(items) {
const total = items.reduce((a, b) => a + b.value, 0) || 1;
const r = 58, cx = 80, cy = 80, C = 2 * Math.PI * r;
let acc = 0, segs = "";
items.forEach((d) => {
const len = (d.value / total) * C;
segs += `${esc(d.name)}: ${d.value}`;
acc += len;
});
return ``;
}
function stackBar(items) {
const total = items.reduce((a, b) => a + b.count, 0) || 1;
let x = 0, segs = "";
items.filter((i) => i.count > 0).forEach((d) => {
const w = (d.count / total) * 100;
segs += `${esc(d.name)}: ${d.count}`;
x += w;
});
return ``;
}
function barList(rows) {
return rows.map((r) => `
${r.is_key ? starMark(true) + " " : ""}${esc(r.name)}
${r.progress}${r.estimated ? "*" : ""}
`).join("");
}
/* ---------------------------------------------------------------- periods */
async function createNewPeriod() {
const label = prompt("新建周期(格式如:9月第1周)\n年份会自动接在最新周期之后,也可写成 2027年1月第1周", "");
if (!label || !label.trim()) return null;
try {
const r = await post("/periods", { label: label.trim() });
await loadPeriods();
state.gran = "week";
state.weekId = r.period.id;
state.intakePeriodId = r.period.id;
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === "week"));
renderPeriodSelect();
toast(r.created ? `已新增周期 ${r.period.full_label}` : `周期 ${r.period.full_label} 已存在,已切换`);
return r.period.id;
} catch (err) {
toast("新建周期失败:" + err.message);
return null;
}
}
async function loadPeriods() {
state.periods = await api("/periods");
if (!state.periods.weeks.length) return;
// 刷新后恢复的周期可能已经被删除/重建,不在列表里就回落到今天所在的周(没有就取最新一期)
if (!state.periods.weeks.some((w) => w.id === state.weekId)) {
state.weekId = state.periods.today_id || state.periods.weeks[state.periods.weeks.length - 1].id;
}
if (state.monthIdx === null || state.monthIdx >= state.periods.months.length) {
state.monthIdx = state.periods.months.length - 1;
}
renderPeriodSelect();
}
function renderPeriodSelect() {
saveUI();
const sel = $("#periodSelect");
if (!state.periods) return;
if (state.gran === "week") {
sel.innerHTML =
state.periods.weeks
.map((w) => ``)
.join("") + ``;
} else {
sel.innerHTML = state.periods.months
.map((m, i) => ``)
.join("");
}
$("#periodHint").textContent = state.gran === "week" ? "" : "月视图 · 自动汇总该月所有周";
}
function shiftPeriod(delta) {
if (state.gran === "week") {
const arr = state.periods.weeks;
let i = arr.findIndex((w) => w.id === state.weekId);
i = Math.min(arr.length - 1, Math.max(0, i + delta));
state.weekId = arr[i].id;
} else {
let i = state.monthIdx + delta;
i = Math.min(state.periods.months.length - 1, Math.max(0, i));
state.monthIdx = i;
}
renderPeriodSelect();
refresh();
}
/* ---------------------------------------------------------------- dashboard */
/** 统计概览(原「执行面板」):折进工作看板底部,不再占一个主导航位 */
async function dashboardHtml() {
const s = await api("/stats" + scopeQuery());
state.stats = s;
const k = s.kpi;
const trend = s.trend.map((t) => {
const cur = scopePeriodIds().indexOf(t.period_id) >= 0;
return { label: `${t.month}/${t.week || ""}`, value: t.update_count, color: cur ? "#2563eb" : "#93b4f5", dim: !cur };
});
return `
${Ico.badge("layers")}
在管项目
${k.project_total}
${Ico.badge("gauge")}
平均进度
${k.avg_progress}%
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
${Ico.badge("alert-circle")}
重点项目未更新
${s.idle_key.length}
周期更新活跃度
${barChart(trend, { unit: " 条" })}
项目分类分布
${donutChart(s.category_dist.map((c) => ({ name: c.name, color: c.color, value: c.count })))}
${s.category_dist.map((c) => `
${esc(c.name)} ${c.count}
`).join("")}
执行状态分布
${stackBar(s.status_dist)}
${s.status_dist.map((x) => `${esc(x.name)} ${x.count}`).join("")}
进度领先 Top 10
${barList(s.top_progress)}
风险与阻塞
${s.risks.length ? s.risks.map((r) => `
${esc(r.project_name)}${statusPill(r.status)}
${esc(r.period_label || "")}
${nl2br(r.risk || r.content || "")}
`).join("") : `
本周期暂无风险上报
`}
低进度预警
${s.low_progress.length ? barList(s.low_progress) : `
暂无
`}
最近更新
${s.recent_updates.length ? s.recent_updates.map((u) => `
${esc(u.project_name)}${statusPill(u.status)}${esc(u.task_name || "")}${esc(u.period_label || "")}
${nl2br((u.summary || u.content || "").slice(0, 160))}
`).join("") : `
暂无
`}
`;
}
/* ---------------------------------------------------------------- 周看板 */
/** 左栏一行:重点项目 / 定开项目共用 */
function weeklyItem(r) {
const sel = state.weeklySel;
const active = sel && sel.kind === r.kind && sel.id === r.id;
const color = r.kind === "project"
? ((r.category && r.category.color) || "#2563eb")
: stageMeta(r.stage).color;
const badge = r.has_update
? `${r.update_count} 条`
: `未更新`;
return `
${r.is_key ? starMark(true) : ""}${esc(r.name)}
${badge}
${r.kind === "project" ? statusPill(r.status) : stagePill(r.stage)}
${esc(r.owner || "未指派")}
${r.kind === "project"
? `${r.progress}%`
: `${fen(r.amount)} 万`}
${r.risks.length ? `风险 ${r.risks.length}` : ""}
`;
}
/** 交付环节步骤条:日期可直接补录,补了日期后端会自动推进商务阶段 */
function pipelineBar(p, readonly) {
const steps = (state.customMeta && state.customMeta.pipeline) || [];
return `
${steps.map((s, i) => `
${i ? `
${Ico.svg("chevron-right")}
` : ""}
${Ico.pipe(s.code)}${esc(s.name)}
${readonly
? `
${p[s.date_field] ? esc(p[s.date_field]) : "未填"}
`
: `
`}
`).join("")}
`;
}
/** 一条执行 / 交付记录;传 kind 才给行内「修改 / 删除」 */
function updateItem(u, kind) {
return `
${esc(u.task_name || u.period_full || "记录")}
${u.stage ? stagePill(u.stage) : statusPill(u.status)}
${u.progress !== null && u.progress !== undefined ? `${u.progress}%` : ""}
${u.task_name && u.period_full ? `${esc(u.period_label || "")}` : ""}
${kind ? updOps(kind, u) : ""}
${nl2br(u.summary || u.content || "(无正文)")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""}
${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`;
}
function weeklyHead(row) {
const isP = row.kind === "project";
const color = isP ? ((row.category && row.category.color) || "#2563eb") : stageMeta(row.stage).color;
const menu = isP ? "projects" : "custom";
return `
${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}
${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)}
${esc(row.owner || "未指派")}
最近更新 ${esc(row.last_update || "尚无记录")}
${row.idle_weeks ? `已 ${row.idle_weeks} 周没更新` : ""}
${canW(menu) ? `` : ""}
`;
}
/* 右栏:常规项目详情 */
async function weeklyProjectDetail(row) {
const d = await api("/projects/" + row.id);
const p = d.project;
const color = (p.category && p.category.color) || "#2563eb";
const trend = d.timeline.slice(-8).reverse();
return `
${weeklyHead(row)}
本期执行记录(${row.updates.length})
${row.updates.length
? row.updates.map((x) => updateItem(x, "project")).join("")
: `
本周期还没有执行记录。重点项目每周都要更新一次,点上方「添加执行记录」补上。
`}
进度与状态
整体进度${row.progress}%
${d.tasks.length ? `
${d.tasks.map((t) => `${esc(t.name)}`).join("")}
` : ""}
${trend.length ? `
近 ${trend.length} 期趋势
${trend.map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${statusPill(t.status)}${t.progress !== null ? ` ${t.progress}%` : ""}
${t.updates.length} 条
`).join("")}
` : ""}`;
}
/* 右栏:定开项目详情(交付环节 + 财务) */
async function weeklyCustomDetail(row) {
const d = await api("/custom/projects/" + row.id);
const p = d.project;
const hist = d.updates.slice(-5).reverse();
return `
${weeklyHead(row)}
交付环节
${pipelineBar(p, !canW("custom"))}
${p.remark ? `` : ""}
财务跟踪
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
${Ico.badge("wallet")}
已计收
${wan(p.revenue_amount)}
${Ico.badge("hourglass")}
待计收
${wan(p.pending_amount)}
待开票 ${fen(p.pending_invoice)} 万 · 待回款 ${fen(p.pending_received)} 万
本期交付记录(${row.updates.length})
${row.updates.length
? row.updates.map((x) => updateItem(x, "custom")).join("")
: `
本周期没有交付记录。
`}
${hist.length ? `
更早的交付记录
${hist.map((u) => `
${esc((u.period_full || "").replace(/^\d+年/, ""))}
${stagePill(u.stage)}${u.revenue_delta ? ` 计收 ${fen(u.revenue_delta)}万` : ""}
`).join("")}
` : ""}`;
}
function bindWeeklyDetail(box, row) {
// 写入后 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 `
${r.is_key ? starMark(true) : ""}${esc(r.name)}
${tag || ""}
${stagePill(r.stage)}
${esc(r.owner || "未指派")}
${fen(r.amount)} 万
${r.period_revenue_delta ? `计收 ${fen(r.period_revenue_delta)} 万` : ""}
${(r.risks || []).length ? `风险 ${(r.risks || []).length}` : ""}
${r.summary ? `
${esc(String(r.summary).replace(/\s+/g, " ").slice(0, 90))}
` : ""}
`;
}
/** 右栏:定开项目本周动态。左栏的「定开项目」只给入口,清单都收在这里 */
function weeklyCustomOverview(data) {
// 「本周新增 / 本周验收」与定开项目的「本周进展」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) => `
${title}
${list.length ? list.map((r) => weeklyCustomRow(r, tag(r))).join("")
: `
本周期没有
`}
`;
return `
${Ico.badge("package")}定开项目 · 本周动态
本周新增 ${added.length} 个
本周验收 ${accepted.length} 个
高风险 ${risky.length} 个
合同额合计 ${fen(sum)} 万
本期计收 ${fen(data.kpi.revenue_delta)} 万
${sec("本周新增", "下单时间落在本周期(已下单 / 提前交付)", added,
(r) => `${esc(String(r.sign_date || "").slice(0, 10) || "本期下单")}`)}
${sec("本周验收", "更新时间落在本周期且状态为已验收", accepted,
(r) => `${esc(String(r.update_date || "").slice(0, 10) || "本期验收")}`)}
${sec("高风险项目", "风险值为「高风险」的在档定开项目", risky.slice(0, RISK_SHOW),
(r) => (r.project_status ? `${esc(r.project_status)}` : ""))}
${risky.length > RISK_SHOW ? `共 ${risky.length} 个高风险项目,按合同额显示前 ${RISK_SHOW} 个
` : ""}`;
}
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) : `在左侧选择一条重点工作查看详情
`;
const eb = $("#wkFocusEdit");
if (eb) eb.addEventListener("click", () => openFocusDrawer(sel.id, "edit"));
return;
}
if (sel && sel.kind === "customs") {
box.innerHTML = weeklyCustomOverview(state.weekly);
bindWeeklyCustomOverview(box);
return;
}
const row = sel && (sel.kind === "project" ? state.weekly.projects : state.weekly.custom)
.find((r) => r.id === sel.id);
if (!row) { box.innerHTML = `在左侧选择一个项目查看执行详情
`; return; }
box.innerHTML = `加载中…
`;
try {
box.innerHTML = row.kind === "project"
? await weeklyProjectDetail(row)
: await weeklyCustomDetail(row);
bindWeeklyDetail(box, row);
} catch (e) {
box.innerHTML = `加载失败:${esc(e.message)}
`;
}
}
async function renderBoard() {
saveUI();
const host = $("#view-board");
host.innerHTML = `加载中…
`;
if (!state.customMeta) state.customMeta = await api("/custom/meta");
const [data, focusRows, customAll] = await Promise.all([
api("/weekly" + scopeQuery()),
api("/focus" + focusQuery(["archived=false"])),
// 「本季度验收」与「定制项目 · 项目总览」同一口径:按「验收时间」(Q1~Q4 季度文本)
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 accQuarter = customAll.filter((p) => qOf(p.accept_date) === "Q" + qNo);
const accQuarterAmount = accQuarter.reduce((a, p) => a + Number(p.amount || 0), 0);
const accYearCount = customAll.filter((p) => qOf(p.accept_date)).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 = `
${Ico.badge("star")}
重点项目
${k.key_total}
${Ico.badge("list-checks")}
本周重点工作
${focus.week.length}
${Ico.badge("activity")}
执行记录
${k.update_count}
${Ico.badge("wallet")}
本季度验收(${qYear}年Q${qNo})
${fen(accQuarterAmount)} 万
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
`;
const group = (title, rows, tip) => `
${title}${rows.length}
${rows.length ? rows.map(weeklyItem).join("") : `
本周期没有记录
`}
`;
// 左栏顺序:本周工作重点 → 重点项目 → 定开项目(只给入口,点开在右栏看清单)
const focusList = focus.overdue.concat(focus.week);
const openCustom = cur && (cur.kind === "customs" || cur.kind === "custom");
host.innerHTML = kpi + `
本周工作重点${focusList.length}
${focusList.length ? focusList.map(focusItem).join("") : `
本周没有安排重点工作
`}
${group("重点项目", data.projects, "个已更新")}
定开项目${data.custom.length}
${Ico.svg("package")}
本周定开项目动态
${Ico.svg("arrow-right")}
新增 ${cNew.length}
验收 ${cAcc.length}
高风险 ${cRisk}
`;
// 统计概览(原「执行面板」)折在最底下,展开才看;失败也不影响看板主体
dashboardHtml().then((html) => {
host.insertAdjacentHTML("beforeend",
`统计概览
${html}
`);
}).catch((e) => {
host.insertAdjacentHTML("beforeend", `统计概览加载失败:${esc(e.message)}
`);
});
host.querySelectorAll("[data-wk]").forEach((el) =>
el.addEventListener("click", () => {
const parts = el.dataset.wk.split(":");
state.weeklySel = { kind: parts[0], id: Number(parts[1]) };
host.querySelectorAll("[data-wk]").forEach((x) => x.classList.toggle("active", x === el));
renderWeeklyDetail();
})
);
renderWeeklyDetail();
}
/* ------------------------------------------------------------ 每周重点工作 */
/** 重点工作一律按「周」跟踪,基准周取顶栏当前选中的那一周 */
function focusWeek() {
return (state.periods.weeks || []).find((w) => w.id === state.weekId) || null;
}
function focusQuery(extra) {
const p = extra ? extra.slice() : [];
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
return p.length ? "?" + p.join("&") : "";
}
/** 分堆按「录入周」(f.sort_key 就是条目录入的那一周),和预计完成时间无关:
week = 本周录入的;overdue = 更早周次录入、标了延期又没做完的结转条目。
「已完成」是交叉视图,已完成条目仍留在自己那一周里,避免录完就看不见。
更早周次里没标延期的未完成条目不进当周视图,只能在「全部」里查得到。 */
function focusBuckets(rows) {
const w = focusWeek();
const cur = w ? w.sort_key : 0;
const out = { cur: cur, overdue: [], week: [], done: [] };
rows.forEach((f) => {
if (f.sort_key === cur) out.week.push(f);
else if (f.sort_key < cur && focusIsLate(f)) out.overdue.push(f);
if (f.status === "done") out.done.push(f);
});
return out;
}
/** 是否延期:录入时人工打开的开关,不再按日期自动判定 */
function focusIsLate(f) {
return f.status !== "done" && !!f.delayed;
}
/** 本地今天(YYYY-MM-DD),新增条目的预计完成时间默认值 */
function todayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** 预计完成时间:有日历日期就显示日期,早期没日期的条目退回显示录入周 */
function focusDueText(f) {
return f.due_date || f.period_full || "—";
}
/** 状态徽章:打开「是否延期」的未完成条目挂一个「延期」 */
function focusPill(f) {
return (focusIsLate(f) ? `延期` : "") +
statusPill(f.status);
}
/** 工作看板左栏的一条重点工作 */
function focusItem(f) {
const sel = state.weeklySel;
const late = focusIsLate(f);
return `
${Ico.status(f.status)}
${esc(f.content)}
${late ? `延期` : ""}
${statusPill(f.status)}
预计 ${esc(focusDueText(f))}
${late ? `录入 ${esc(f.period_label || "—")}` : ""}
`;
}
/** 工作看板右栏的一条重点工作(只读,改内容走抽屉) */
function focusDetailHtml(f) {
const ro = (label, val) => ``;
return `
${Ico.statusBadge(f.status)}${esc(f.content)}
${focusPill(f)}
预计完成 ${esc(focusDueText(f))}
${canW("focus") ? `
` : ""}
说明
${f.detail ? nl2br(f.detail) : `
未填写说明
`}
结果反馈
${f.result ? nl2br(f.result) : `
未填写结果反馈
`}
`;
}
async function renderFocus() {
saveUI();
const host = $("#view-focus");
host.innerHTML = `加载中…
`;
const rows = await api("/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")]));
state.focusRows = rows;
const b = focusBuckets(rows);
const shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || [];
const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]];
const chips = filters.map((x) => ``).join("");
const table = `
| 工作内容 | 说明 |
预计完成时间 | 状态 | 结果反馈 | 更新 | 操作 |
${shown.map((f) => `
|
${esc((f.detail || "—").replace(/\s+/g, " ").slice(0, 48))} |
${esc(focusDueText(f))}${f.period_label ? ` 录入 ${esc(f.period_label)} ` : ""} |
${focusPill(f)} |
${esc((f.result || "—").replace(/\s+/g, " ").slice(0, 40))} |
${esc((f.updated_at || "").slice(0, 10) || "—")} |
${rowOps({
edit: `data-fedit="${f.id}"`,
archive: `data-farch="${f.id}"`,
archived: f.archived,
remove: `data-fdel="${f.id}"`,
})} |
`).join("")}
`;
const body = listCard({
title: "重点工作清单",
count: rows.length,
chips: chips,
fields: ``,
tools: `${keywordInputHtml("fKeyword", "搜索工作内容…", 180)}
${canR("focus") ? `` : ""}
${canW("focus") ? `` : ""}`,
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: `「${esc(f.content)}」将被删除,删除后无法恢复。
只是这一周不再跟进?请改用「归档」。
`,
});
if (!ok) return;
try {
await del("/focus/" + f.id);
} catch (e) { toast("删除失败:" + e.message); return; }
toast("已删除「" + f.content + "」");
closeDrawer();
await afterWrite();
}
/** 重点工作抽屉:新增走单独面板,详情只读、修改才出表单;基础信息的动作一律在面板顶部 */
async function openFocusDrawer(fid, mode) {
mode = mode === "create" ? "create" : mode === "edit" ? "edit" : "view";
if (!canW("focus")) mode = "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `加载中…
`;
drawerActions("");
let f = null;
if (mode === "create") {
f = { content: "", detail: "", period_id: state.weekId, status: state.meta.statuses[0].code, archived: false };
} else {
try {
const rows = await api("/focus?archived=all");
f = rows.find((x) => x.id === fid) || null;
} catch (e) {
$("#drawerBody").innerHTML = `加载失败:${esc(e.message)}
`;
return;
}
if (!f) {
$("#drawerBody").innerHTML = `条目不存在,可能已被删除
`;
return;
}
}
// 录入周 = 顶栏当前选中的那一周(新增时定下来,之后不随预计完成时间变)
const w = focusWeek();
const entryWeek = mode === "create" ? (w ? w.full_label : "顶栏当前周") : (f.period_full || f.period_label || "—");
$("#drawerTitle").innerHTML = mode === "create"
? `新增每周重点工作`
: `${esc(f.content)}`;
$("#drawerSub").innerHTML = mode === "create"
? `一条就是一个活儿,不用先建项目 · 录入周 ${esc(entryWeek)}`
: `${focusPill(f)} · 录入 ${esc(f.period_full || f.period_label || "—")} · 预计完成 ${esc(focusDueText(f))}`;
const ro = (label, val) => ``;
// 基础信息的动作在标题栏右侧按钮条:危险动作在左,主按钮在最右
drawerActions(mode === "view" ? `
` : `
`);
const form = mode === "view" ? `
基本信息
${ro("工作内容", esc(f.content) + (f.archived ? `
已归档` : ""))}
${ro("说明", f.detail ? nl2br(f.detail) : "—")}
${ro("预计完成时间", esc(focusDueText(f)))}
${ro("录入周", esc(f.period_full || f.period_label || "—"))}
${ro("执行状态", statusPill(f.status))}
${ro("是否延期", f.delayed ? `
延期` : "未延期")}
${ro("结果反馈", f.result ? nl2br(f.result) : "—")}
${ro("最近更新", esc((f.updated_at || "").replace("T", " ") || "—"))}
` : `
`;
$("#drawerBody").innerHTML =
(mode === "view" ? "" : ``) + form;
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
const after = () => afterWrite();
on("#fdEdit", () => openFocusDrawer(fid, "edit"));
on("#fdCancel", () => (mode === "create" ? closeDrawer() : openFocusDrawer(fid, "view")));
on("#fdSave", async () => {
const btn = $("#fdSave");
const err = $("#fdErr");
const content = $("#fdContent").value.trim();
if (!content) { err.textContent = "请填写工作内容"; err.style.display = ""; return; }
btn.disabled = true;
err.style.display = "none";
const due = $("#fdDue").value;
if (!due) { err.textContent = "请选择预计完成时间"; err.style.display = ""; return; }
const body = {
content: content,
detail: $("#fdDetail").value,
status: $("#fdStatus").value,
due_date: due,
result: $("#fdResult").value,
delayed: $("#fdDelayed").checked,
};
if (mode === "create") body.period_id = state.weekId; // 录入周 = 顶栏当前周
try {
if (mode === "create") fid = (await post("/focus", body)).id;
else await patch("/focus/" + fid, body);
} catch (e) {
btn.disabled = false;
err.textContent = "保存失败:" + e.message;
err.style.display = "";
return;
}
toast(mode === "create" ? "已添加「" + content + "」" : "已保存");
after();
openFocusDrawer(fid, "view");
});
on("#fdArchive", async () => {
try {
await patch("/focus/" + fid, { archived: !f.archived });
} catch (e) { toast("操作失败:" + e.message); return; }
toast(f.archived ? "已恢复" : "已归档");
closeDrawer();
after();
});
on("#fdDelete", () => deleteFocus(f));
}
/* ---------------------------------------------------------------- 项目管理 */
/** 删除项目是高危动作:必须逐字输入项目名才能点「永久删除」。列表与抽屉共用一套。 */
async function deleteProject(p) {
const ok = await confirmDialog({
title: "删除项目",
danger: true,
okText: "永久删除",
requireText: p.name,
body:
`将连同 ${p.task_count} 个子任务、${p.update_count} 条执行记录一并删除,删除后无法恢复。
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`,
});
if (!ok) return;
try {
const r = await del("/projects/" + 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 = `加载中…
`;
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 = ``;
const chips = ``;
// 原来挂在左侧菜单栏的分类筛选器,收进卡头,能力不变
const catFilter = ``;
const table = `
| 项目名称 | 分类 | 负责人 | 状态 |
进度% | 本${state.gran === "week" ? "周" : "月"}更新 |
记录 | 子任务 | 最近记录 | 操作 |
${shown.map((p) => `
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
${esc(p.name)}
${p.archived ? ` 已归档` : ""}
|
${catPill(p.category)} |
${nameChips(p.owners)} |
${statusPill(p.status)} |
${Math.round(p.progress)}% |
${p.updated_this_period
? `已更新`
: `${p.idle_weeks === null ? "从未更新" : p.idle_weeks + " 周未更新"}`} |
${p.update_count} |
${p.task_count} |
${esc((p.last_update && p.last_update.period_full) || "—")} |
${rowOps({
edit: `data-pedit="${p.id}"`,
archive: `data-parch="${p.id}"`,
archived: p.archived,
remove: `data-pdel="${p.id}"`,
})} |
`).join("")}
`;
const body = listCard({
title: "重点项目明细",
count: rows.length,
chips: chips,
fields: ``,
tools: `${keywordInputHtml("pKeyword", "搜索项目名称…", 180)}
${catFilter}
${ownerFilter}
${canR("projects") ? `` : ""}
${canW("projects") ? `` : ""}`,
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(
`新建项目
`
);
$("#mOk").addEventListener("click", async () => {
const name = $("#npName").value.trim();
if (!name) return modalError("请填写项目名称");
const btn = $("#mOk");
btn.disabled = true;
try {
await post("/projects", {
name: name,
category_id: $("#npCat").value ? Number($("#npCat").value) : null,
owner: $("#npOwner").value || null,
is_key: $("#npKey").checked,
});
closeModal();
toast("已创建「" + name + "」");
await afterWrite();
} catch (e) { btn.disabled = false; modalError(e.message); }
});
$("#npName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); });
});
}
/* ---------------------------------------------------------------- 定开项目 */
function stageMeta(code) {
const list = (state.customMeta && state.customMeta.stages) || [];
return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" };
}
function stagePill(code) {
const s = stageMeta(code);
return `${Ico.stage(code)}${esc(s.name)}`;
}
/* ---- 市场台账(《定开项目.xlsx》)的小色块 ---- */
const CUSTOM_STATUS_COLOR = {
已验收: "#16a34a",
交付完成: "#0891b2",
进行中: "#2563eb",
待开发: "#b45309",
退单: "#7a8394",
};
/** 台账里的项目状态:已验收/交付完成/进行中/待开发/退单,和项目状态机不是一套 */
function customStatusPill(code) {
if (!code) return `—`;
const color = CUSTOM_STATUS_COLOR[code] || "#7a8394";
return `${esc(code)}`;
}
/** 风险值是否「高风险」(台账取值为 1.高风险 / 2.低风险) */
const isHighRisk = (p) => String(p.risk_level || "").includes("高");
/** 风险值:1.高风险 红 / 2.低风险 绿 */
function riskPill(code) {
if (!code) return `—`;
const high = String(code).includes("高");
const color = high ? "#dc2626" : "#16a34a";
const text = String(code).replace(/^\d+\./, "");
return `${esc(text)}`;
}
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 = `加载中…
`;
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 = ``;
(state.customMeta.region_groups || []).forEach((g) => {
const hit = g.regions.filter((r) => usedSet[r]);
if (!hit.length) return;
regionFilterHtml += ``;
});
const nonStd = (state.customMeta.regions || []).filter((r) => stdAll.indexOf(r) < 0);
if (nonStd.length) {
regionFilterHtml += ``;
}
const STATUS_OPTIONS = ["已验收", "待开发", "交付完成", "进行中", "退单"];
const statusFilterHtml = `` +
STATUS_OPTIONS.map((s) => ``).join("");
const originFilterHtml = `` +
CUSTOM_ORIGINS.map((o) => ``).join("");
const isList = state.customTab !== "weekly" && state.customTab !== "annual";
// 三个 tab 用顶部「周/月」那套分段控件,筛选器和主按钮(导出 / 新建)放下一行
const head = {
chips: `
`,
tools: isList ? `
${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)}
${canR("custom") ? `` : ""}
${canW("custom") ? `` : ""}` : "",
};
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) => qOf(p.accept_date)); // 有「验收时间」即视为已验收
const accQuarter = accRows.filter((p) => qOf(p.accept_date) === thisQ);
const accYear = accRows;
const stat = (label, count, amount, color) =>
`${count}个 · ${fen(amount)}万
${label}
`;
const statBar = `
${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)")}
`;
return `
${cardHead({ title: "定开项目总览", count: view.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })}
${statBar}
${view.length ? customTableHtml(view) : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}
`}
`;
}
/** 定开项目表格(「项目总览」与「本周进展」两个 tab 共用);下单时间 / 订单金额 / 更新时间 表头可排序 */
function customTableHtml(view, withOps) {
const ops = withOps !== false;
const s = state.customSort || { key: "update_date", dir: "desc" };
const sth = (label, k, cls) =>
`${label}${s.key === k ? (s.dir === "asc" ? " ▲" : " ▼") : ""} | `;
return `
| 项目名称 |
项目ID |
${sth("下单时间", "sign_date")}
项目归属 |
办事处 |
行业 |
市场下单人天 |
${sth("订单金额", "amount", "num")}
市场责任人 |
项目状态 |
进度 |
验收时间 |
推动验收计划 |
风险值 |
${sth("更新时间", "update_date")}
备注 |
${ops ? `操作 | ` : ""}
${view.map((p) => `
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
${esc(p.name)}
${p.archived ? ` 已归档` : ""}
|
${esc(p.project_code || "—")} |
${p.sign_date ? esc(p.sign_date) : "—"} |
${esc(p.origin || "—")} |
${esc(p.office || "—")} |
${esc(p.industry || "—")} |
${p.man_days == null ? "—" : Math.round(p.man_days).toLocaleString()} |
${money(p.amount)} |
${esc(p.market_owner || "—")} |
${customStatusPill(p.project_status)} |
${(p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"} |
${p.accept_date ? esc(p.accept_date) : "—"} |
${esc(p.accept_plan || "—")} |
${riskPill(p.risk_level)} |
${esc(p.update_date || "—")} |
${esc((p.remark || "").slice(0, 40)) || "—"} |
${ops ? `${rowOps({
edit: `data-cedit="${p.id}"`,
archive: `data-carch="${p.id}"`,
archived: p.archived,
remove: `data-cdel="${p.id}"`,
})} | ` : ""}
`).join("")}
`;
}
/** 定开项目「本期」分堆 —— 工作看板右栏与「本周进展」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 `没有取到当前周期的起止日期(刚更新过后端的话,请重启服务后刷新)
`;
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) =>
`${rows.length}个 · ${fen(sum(rows, "amount"))}万
${label}
`;
const statBar = `
${stat("验收情况", acc, "var(--amber)")}
${stat("新增项目", newDeal, "var(--violet)")}
${stat("进行中项目", doing, "var(--primary)")}
`;
return `
${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)
: `
本期没有更新的定开项目
`}
`;
}
/** 年度任务:季度计收目标(可在页面上改,存后端)+ 按「验收时间 / 下单时间」回填的完成数据 */
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) => `
| Q${r.q} |
${editing
? ``
: fmt(r.target)} |
${r.accN} |
${fmt(r.accAmt)} |
${rate(r.target, r.accAmt)} |
${r.signN} |
${fmt(r.signAmt)} |
`;
return `
${cardHead({
title: "年度任务(" + Y + "年)", count: Y + "年",
chips: hdr.chips, chipsRow: true,
tools: canW("custom") ? `
` : "",
})}
| 季度 |
计收目标(万元) |
计收项目数量 |
计收项目金额(万元) |
计收完成率 |
新增签单项目数量 |
新增签单项目数量金额(万元) |
${rows.map(tr).join("")}
| 合计 |
${fmt(tot.target)} |
${tot.accN} |
${fmt(tot.accAmt)} |
${rate(tot.target, tot.accAmt)} |
${tot.signN} |
${fmt(tot.signAmt)} |
`;
}
/** 年度计收目标(万元):后端存一年一套,没配过时返回《年度任务.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) => ``).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(
`新建定开项目
字段与《定开项目.xlsx》台账一致;项目名称必填,其余可建档后补。区域按「办事处」自动归集。
`
);
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:
`${esc(p.name)}
` +
`${
toArchive
? "归档后该项目不再出现在项目明细与统计中,历史周期记录完整保留。"
: "恢复后重新计入项目明细与统计。"
}
`,
});
if (!ok) return;
try {
await patch("/custom/projects/" + p.id, { archived: toArchive });
toast(toArchive ? "已归档" : "已恢复");
if (onDone) onDone(); else refresh();
} catch (e) {
toast("操作失败:" + e.message);
}
}
/** 删除:连带周期记录一起清除,需逐字输入项目名确认 */
async function deleteCustomProject(p, onDone) {
const ok = await confirmDialog({
title: "删除定开项目",
danger: true,
okText: "永久删除",
requireText: p.name,
body:
`将连同该项目 ${p.update_count || 0} 条周期记录一并删除,删除后无法恢复。
- 阶段:${esc(stageMeta(p.stage).name)} 合同额:${money(p.amount)}
- 已计收:${money(p.revenue_amount)} 入库时间:${esc(p.entry_date || "—")}
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`,
});
if (!ok) return;
try {
const r = await del("/custom/projects/" + p.id);
toast("已删除「" + r.name + "」及其 " + r.deleted_updates + " 条周期记录");
if (onDone) onDone(); else refresh();
} catch (e) {
toast("删除失败:" + e.message);
}
}
/** cid 定开项目抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
async function openCustomDrawer(cid, mode) {
mode = mode === "edit" ? "edit" : "view";
if (!canW("custom")) mode = "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `加载中…
`;
drawerActions("");
const d = await api("/custom/projects/" + cid);
state.drawerCustom = d;
const p = d.project;
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}${p.archived ? ` 已归档` : ""}`;
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 ? ` 进度 ${progPct}` : "");
const timeline = d.timeline.slice().reverse().map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${stagePill(t.stage)}${t.progress !== null ? ` ${t.progress}%` : ""}
${t.revenue_delta ? ` 计收 ${fen(t.revenue_delta)}万` : ""}
${nl2br(t.content || "")}
`).join("");
// 「添加动态」落到顶栏当前选中的那一周,与更新录入页的周期下拉一致
const ro = (label, val) => ``;
const optList = (list, sel) => list.map((v) => ``).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 = `
项目信息
${mode === "edit" ? `
` : `
${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("交付环节", `
${pipelineBar(p, true)}
`)}
${ro("备注", p.remark ? nl2br(p.remark) : "—")}
${ro("更新时间", esc(p.update_date || "—"))}
`}
`;
drawerActions(mode === "edit" ? `
` : `
`);
$("#drawerBody").innerHTML = `
${mode === "edit" ? `` : ""}
${ledgerSec}
${mode === "view" ? `
收入确认
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
${Ico.badge("wallet")}
已计收
${wan(p.revenue_amount)}
${Ico.badge("hourglass")}
待计收
${wan(p.pending_amount)}
${Ico.badge("receipt")}
已开票
${wan(p.invoice_amount)}
${Ico.badge("trending-up")}
已回款
${wan(p.received_amount)}
交付记录(${d.updates.length})
${d.updates.length ? d.updates.map((u) => updateItem(u, "custom")).join("") : `
暂无交付记录
`}
${d.timeline.length ? `` : ""}
` : ""}`;
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 `
${mode === "ai" ? `
AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持原文,最终只写 1 条` : ""}
`;
}
function intakeMode() {
return state.intakeMode === "ai" ? "ai" : "direct";
}
/** 切换录入方式:保留已填的原文,重画当前录入页 */
function bindIntakeMode(rawSel, rerender) {
document.querySelectorAll("#intakeModeSeg [data-imode]").forEach((b) =>
b.addEventListener("click", () => {
if (intakeMode() === b.dataset.imode) return;
const keep = $(rawSel) ? $(rawSel).value : "";
state.intakeMode = b.dataset.imode;
Promise.resolve(rerender()).then(() => { const el = $(rawSel); if (el) el.value = keep; });
})
);
}
/* ------------------------------------------------ 定制项目录入 */
async function renderCustomIntake(host) {
const projects = await api("/custom/projects");
const weeks = state.periods.weeks.slice().reverse();
const curPeriodId =
state.gran === "week"
? state.weekId
: currentMonth()
? currentMonth().period_ids[currentMonth().period_ids.length - 1]
: null;
host.innerHTML = `
${intakeModeSegHtml()}
定制项目 · 交付记录录入
1选择定制项目与周期
2粘贴该项目的原始内容
${intakeMode() === "ai" ? `
` : `
`}
`;
const sel = $("#cInProject");
host.querySelectorAll("[data-itype]").forEach((b) =>
b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); })
);
sel.addEventListener("change", () => { customIntakeId = Number(sel.value); });
$("#btnNewCustomIntake").addEventListener("click", () =>
openNewCustomDialog((p) => { customIntakeId = p.id; renderCustomIntake(host); })
);
$("#cInPeriod").addEventListener("change", async (e) => {
if (e.target.value === "__new") {
await createNewPeriod();
await renderIntake();
return;
}
state.intakePeriodId = Number(e.target.value);
});
$("#cInSearch").addEventListener("input", (e) => {
const kw = e.target.value.trim();
sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw))
.map((p) => ``).join("");
});
bindIntakeMode("#cInRaw", () => renderCustomIntake(host));
const on = (sel2, fn) => { const el = $(sel2); if (el) el.addEventListener("click", fn); };
on("#btnCClear", () => {
$("#cInRaw").value = ""; $("#cInPreview").innerHTML = ""; $("#cInMode").textContent = "";
});
on("#btnCParse", () => doCustomParse(true));
on("#btnCRule", () => doCustomParse(false));
on("#btnCDirect", commitCustomDirect);
}
/** 定制项目 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */
async function commitCustomDirect() {
const raw = $("#cInRaw").value.trim();
if (!raw) return toast("请先填写内容");
const id = Number($("#cInProject").value);
if (!id) return toast("请选择定制项目");
const pid = Number($("#cInPeriod").value);
if (!pid) return toast("请选择周期");
const btn = $("#btnCDirect");
btn.disabled = true;
try {
await post("/custom/updates", {
custom_project_id: id,
period_id: pid,
content: raw,
source: "manual",
});
$("#cInRaw").value = "";
$("#cInPreview").innerHTML = `写入成功:已按原文存入 1 条执行记录,未拆解、未改写。
`;
toast("已写入 1 条执行记录");
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
} finally {
btn.disabled = false;
}
}
let customIntakeId = null;
let customIntakeParsed = null;
let customIntakeRaw = "";
async function doCustomParse(useAI) {
const raw = $("#cInRaw").value.trim();
if (!raw) return toast("请先粘贴内容");
const sel = $("#cInProject");
const id = Number(sel.value);
if (!id) return toast("请选择定制项目");
const btn = useAI ? $("#btnCParse") : $("#btnCRule");
const old = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ` 整理中…`;
try {
const r = await post("/custom/ai/parse", {
custom_project_id: id,
project_name: 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 = `整理失败:${esc(e.message)}
`;
} finally {
btn.disabled = false; btn.innerHTML = old;
}
}
function renderCustomPreview(r) {
const p = r.parsed;
const stageOpts = state.customMeta.stages
.map((s) => ``)
.join("");
$("#cInMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理";
$("#cInPreview").innerHTML = `
${r.warning ? `${esc(r.warning)}
` : ""}
${r.error ? `AI 调用异常:${esc(r.error)}
` : ""}
整理结果 · 1 条记录(可直接修改后写入)
正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定制项目」
`;
$("#btnCCommit").addEventListener("click", commitCustomIntake);
}
function collectCustomPreview() {
const num = (id) => { const v = $(id).value.trim(); return v === "" ? null : Number(v); };
const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean);
return {
stage: $("#cPvStage").value,
progress: num("#cPvProgress"),
amount: num("#cPvAmount"),
revenue_amount: num("#cPvRevenue"),
revenue_delta: num("#cPvDelta"),
summary: $("#cPvSummary").value.trim(),
risks: lines("#cPvRisks"),
next_steps: lines("#cPvNext"),
updates: [], // AI 整理只落 1 条:正文走 raw_text
};
}
async function commitCustomIntake() {
const sel = $("#cInProject");
const btn = $("#btnCCommit");
btn.disabled = true;
try {
const res = await post("/custom/ai/commit", {
custom_project_id: Number(sel.value),
project_name: null,
period_id: Number($("#cInPeriod").value),
raw_text: ($("#cPvContent").value || "").trim() || customIntakeRaw,
parsed: collectCustomPreview(),
mode: customIntakeParsed && customIntakeParsed.model ? "ai" : "rule",
model: (customIntakeParsed && customIntakeParsed.model) || null,
});
toast(`已写入 ${res.created.length} 条记录`);
$("#cInPreview").innerHTML = `写入成功:「${esc(res.project.name)}」阶段 ${esc(stageMeta(res.project.stage).name)},可确认收入 ${wan(res.project.confirmed_amount)}。
`;
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
} finally {
btn.disabled = false;
}
}
/* ---------------------------------------------------------------- 更新录入 */
let intakeProjectId = null;
let intakeParsed = null;
async function renderIntake() {
const host = $("#view-intake");
// 录入页整个页面都是写操作,只读角色没有可用的部分,直接给说明
if (!canW("intake")) {
host.innerHTML = `「更新录入」是写操作,当前角色是只读权限,无法录入内容。
`;
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" ? `
` : `
`;
host.innerHTML = `
${intakeModeSegHtml()}
`;
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) => ``).join("")
+ ``;
});
$("#btnClearIntake").addEventListener("click", () => {
$("#intakeRaw").value = ""; $("#intakePreview").innerHTML = ""; $("#intakeMode").textContent = "";
});
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
on("#btnAIParse", () => doParse(true));
on("#btnRuleParse", () => doParse(false));
on("#btnDirectWrite", commitDirectWrite);
}
/**
* 自研产品 · 直接写入:填入的内容一字不改存成 1 条执行记录。
* 不调大模型、不建子任务、不写 progress,项目的进度和状态一律不动。
*/
async function commitDirectWrite() {
const raw = $("#intakeRaw").value.trim();
if (!raw) return toast("请先填写内容");
const sel = $("#intakeProject");
const btn = $("#btnDirectWrite");
let projectId = sel.value === "__new" ? null : Number(sel.value);
btn.disabled = true;
try {
if (!projectId) {
const name = $("#intakeNewProject").value.trim();
if (!name) { toast("请填写项目名称"); return; }
const created = await post("/projects", { name });
projectId = created.id;
intakeProjectId = created.id;
sel.insertAdjacentHTML("beforeend", ``);
$("#intakeNewProject").style.display = "none";
}
await post("/updates", {
project_id: projectId,
period_id: Number($("#intakePeriod").value),
content: raw,
source: "manual",
});
$("#intakeRaw").value = "";
$("#intakePreview").innerHTML = `写入成功:已按原文存入 1 条执行记录,未拆解、未改写。
`;
toast("已写入 1 条执行记录");
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
} finally {
btn.disabled = false;
}
}
async function doParse(useAI) {
const raw = $("#intakeRaw").value.trim();
if (!raw) return toast("请先粘贴内容");
const sel = $("#intakeProject");
const projectId = sel.value === "__new" ? null : Number(sel.value);
const projectName = sel.value === "__new" ? $("#intakeNewProject").value.trim() : null;
if (!projectId && !projectName) return toast("请选择或填写项目名称");
const btn = useAI ? $("#btnAIParse") : $("#btnRuleParse");
const old = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ` 整理中…`;
try {
const r = await post("/ai/parse", {
project_id: projectId, project_name: projectName,
period_id: Number($("#intakePeriod").value),
raw_text: raw, use_ai: useAI,
});
intakeProjectId = r.project ? r.project.id : intakeProjectId;
intakeParsed = r.parsed;
renderIntakePreview(r, raw);
} catch (e) {
$("#intakePreview").innerHTML = `整理失败:${esc(e.message)}
`;
} finally {
btn.disabled = false; btn.innerHTML = old;
}
}
function renderIntakePreview(r, raw) {
const p = r.parsed;
const statusOpts = state.meta.statuses.map((s) => ``).join("");
$("#intakeMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理";
$("#intakePreview").innerHTML = `
${r.warning ? `${esc(r.warning)}
` : ""}
${r.error ? `AI 调用异常:${esc(r.error)}
` : ""}
整理结果 · 1 条记录(可直接修改后写入)
${esc(r.project ? r.project.name : "")} · ${esc(r.period ? r.period.full_label : "")}
正文用你的原文,AI 只补摘要 / 进度 / 状态 / 风险 / 下一步;写入后可在「工作看板」中立即查看
`;
$("#btnCommit").addEventListener("click", () => commitIntake(r, raw));
}
/** 收集预览表单。tasks 恒为空 → 后端按「1 条执行记录」写入,不建子任务 */
function collectPreview() {
const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean);
const pg = $("#pvProgress").value;
return {
summary: $("#pvSummary").value.trim(),
progress: pg === "" ? null : Number(pg),
status: $("#pvStatus").value,
risks: lines("#pvRisks"),
next_steps: lines("#pvNext"),
tasks: [],
};
}
async function commitIntake(r, raw) {
const sel = $("#intakeProject");
const btn = $("#btnCommit");
btn.disabled = true;
try {
const res = await post("/ai/commit", {
project_id: sel.value === "__new" ? null : Number(sel.value),
project_name: sel.value === "__new" ? $("#intakeNewProject").value.trim() : null,
period_id: Number($("#intakePeriod").value),
raw_text: ($("#pvContent").value || "").trim() || raw,
parsed: collectPreview(),
mode: r.mode,
model: r.parsed.model || null,
});
toast(`已写入 ${res.created.length} 条执行记录`);
$("#intakePreview").innerHTML = `写入成功:项目「${esc(res.project.name)}」新增 ${res.created.length} 条记录。可切换到「工作看板」查看。
`;
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
} finally {
btn.disabled = false;
}
}
/* ---------------------------------------------------------------- settings */
async function renderSettings() {
const host = $("#view-settings");
const [s, health, periods] = await Promise.all([
api("/settings"),
fetch("/health").then((r) => r.json()),
api("/periods"),
]);
const weeks = periods.weeks.slice().reverse();
const rw = canW("settings");
host.innerHTML = `
数据库
${esc(health.db)}
默认使用 SQLite(data/board.db),无需安装任何服务。
迁移到 MySQL:
1. 安装驱动:pip install pymysql cryptography
2. 在 .env 中设置:
DATABASE_URL=mysql+pymysql://user:pwd@host:3306/unis_board?charset=utf8mb4
3. 重启服务,表结构自动创建;再用
python scripts/import_excel.py --reset 重新灌数即可。
数据访问层全部走 SQLAlchemy,无原生 SQL,切换方言不需要改业务代码。
周期管理
${rw ? `` : ""}
| 周期 | 常规记录 | 定开记录 | |
${weeks.map((w) => `
| ${esc(w.full_label)} (原标签:${esc(w.label)}) |
${w.update_count || 0} |
${w.custom_update_count || 0} |
${rw ? `` : ""} |
`).join("")}
`;
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 = `加载中…
`;
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)} 进度 ${Math.round(p.progress)}%`;
const ro = (label, val) => ``;
// 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起
const baseSec = mode === "edit" ? `
项目信息
${peopleEditorHtml("dwOwners", "负责人", p.owners, { add: "负责人" })}
${peopleEditorHtml("dwMembers", "团队成员", p.members, { add: "成员" })}
` : `
项目信息
${ro("项目名称", esc(p.name) + (p.archived ? `
已归档` : ""))}
${ro("分类", catPill(p.category))}
${ro("状态", statusPill(p.status))}
${ro("负责人", nameChips(p.owners))}
${ro("团队成员", nameChips(p.members))}
${ro("进度", Math.round(p.progress) + "%")}
${ro("标签", p.tags.length ? p.tags.map((t) => `
${esc(t.name)}`).join(" ") : "—")}
${ro("记录", `${p.task_count} 个子任务 / ${p.update_count} 条执行记录`)}
`;
// 基础信息的动作在标题栏右侧按钮条;附表的更新动作仍在它的列表上方,两类不混
drawerActions(mode === "edit" ? `
` : `
`);
const updItem = (u) => `
${esc(u.task_name || "整体进展")}${statusPill(u.status)}${u.progress !== null ? `${u.progress}%` : ""}${updOps("project", u)}
${nl2br(u.content || u.summary || "")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""}
${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`;
// 把项目的 timeline 按「周」或「月」归成显示单元(timeline 已由后端按时间升序返回)
function feedUnits() {
if (state.dw.group === "week") {
return d.timeline.map((t) => ({
label: t.period.full_label, count: t.updates.length, progress: t.progress,
periods: [t], ids: [t.period.id],
}));
}
const map = new Map();
d.timeline.forEach((t) => {
const k = t.period.year + "-" + t.period.month;
if (!map.has(k)) map.set(k, { label: t.period.year + "年" + t.period.month + "月", count: 0, progress: null, periods: [], ids: [] });
const u = map.get(k);
u.periods.push(t);
u.count += t.updates.length;
u.ids.push(t.period.id);
if (t.progress !== null) u.progress = u.progress === null ? t.progress : Math.max(u.progress, t.progress);
});
return [...map.values()];
}
const FEED_RANGES = {
week: [[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 = `
${FEED_RANGES[state.dw.group].map(([v, l]) =>
``).join("")}
`;
// win 是按升序取的窗口,展示时倒过来:新的在前
const body = win.length ? win.slice().reverse().map((u) => `
${esc(u.label)}${u.progress !== null ? `进度 ${u.progress}%` : ""}${u.count} 条
${u.periods.slice().reverse().map((t) => `
${state.dw.group === "month" ? `${esc(t.period.full_label.replace(/^\d+年/, ""))}${t.progress !== null ? " · 进度 " + t.progress + "%" : ""} ${statusPill(t.status)}
` : ""}
${t.updates.map(updItem).join("")}`).join("")}`).join("")
: `还没有执行记录
`;
host.innerHTML = ``;
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) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${t.progress === null ? "—" : t.progress + "%"}
`).join("");
// 周期反馈单独渲染,切换范围/周期不必重新请求
state.dw = { group: "week", range: 2, back: 0 };
$("#drawerBody").innerHTML =
(mode === "edit" ? `` : "") +
baseSec + `` +
`历史进度走势
${timeline || '
暂无
'}
`;
renderFeed();
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
on("#dwEdit", () => openDrawer(pid, "edit"));
on("#dwCancel", () => openDrawer(pid, "view"));
on("#dwSave", async () => {
const btn = $("#dwSave");
const err = $("#dwErr");
btn.disabled = true;
err.style.display = "none";
try {
await patch("/projects/" + pid, {
name: $("#dwName").value.trim(),
category_id: $("#dwCat").value === "" ? null : Number($("#dwCat").value),
status: $("#dwStatus").value,
owners: peopleValues("dwOwners"),
members: peopleValues("dwMembers"),
progress: Number($("#dwProgress").value),
tags: $("#dwTags").value.split(/[、,,;;\s]+/).filter(Boolean),
});
} catch (e) {
btn.disabled = false;
err.textContent = "保存失败:" + e.message;
err.style.display = "";
return;
}
toast("已保存");
await afterWrite();
openDrawer(pid, "view");
});
on("#dwKey", async () => {
await patch("/projects/" + pid, { is_key: !p.is_key });
await afterWrite();
openDrawer(pid, mode);
});
on("#dwArchive", async () => {
await patch("/projects/" + pid, { archived: !p.archived });
toast(p.archived ? "已恢复" : "已归档");
closeDrawer();
await afterWrite();
});
on("#dwDelete", () => 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) => ``
).join("");
const affs = (state.staffDict.affiliations || []).map(
(a) => ``
).join("");
openModal(
`${isNew ? "新增人员" : "编辑人员"}
${isNew ? "" : `
改姓名会把名下项目的负责人一并改过来,历史记录不会断。
`}
`
);
$("#mOk").addEventListener("click", async () => {
const name = $("#sfName").value.trim();
if (!name) return modalError("请填写姓名");
const btn = $("#mOk");
btn.disabled = true;
const body = {
name: name,
group: $("#sfGroup").value || null,
affiliation: $("#sfAff").value || null,
note: $("#sfNote").value.trim() || null,
};
try {
let msg = "已新增「" + name + "」";
if (isNew) { await post("/staff", body); }
else {
const r = await patch("/staff/" + p.id, body);
const n = r.synced.projects + r.synced.custom_projects;
msg = n ? `已更新,并同步 ${n} 个项目的负责人` : "已更新";
}
closeModal();
toast(msg);
if (onDone) onDone(); else await afterWrite();
} catch (e) { btn.disabled = false; modalError(e.message); }
});
$("#sfName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); });
}
async function deleteStaff(m) {
const ok = await confirmDialog({
title: "删除人员",
danger: true,
okText: "确认删除",
body: `确认从花名册删除 ${esc(m.name)}?
删除后这个人不再出现在「负责人」下拉里。
若他名下还有在管项目,接口会拒绝删除 —— 离职人员请用归档,历史数据完整保留。
`,
});
if (!ok) return;
try {
await del("/staff/" + m.id);
toast("已删除「" + m.name + "」");
await afterWrite();
} catch (e) {
toast(e.message);
}
}
async function renderStaff() {
saveUI();
const host = $("#view-staff");
host.innerHTML = `加载中…
`;
const extra = ["archived=" + state.staffArchived];
if (state.staffGroup) extra.push("group=" + encodeURIComponent(state.staffGroup));
if (state.keyword) extra.push("keyword=" + encodeURIComponent(state.keyword));
const d = await api("/staff?" + extra.join("&"));
await loadStaff(); // 负责人下拉始终用完整花名册
const sum = d.summary;
const archOpts = [["false", "在编"], ["true", "已归档"], ["all", "全部"]]
.map((x) => ``).join("");
const groupOpts = `` +
(d.dict.groups || []).map((g) => ``).join("");
const kpi = `
${Ico.badge("users")}
在册人员
${sum.total}
${sum.groups.slice(0, 4).map((g, i) => `
${Ico.badge("users")}
${esc(g.name)}
${g.count}
`).join("")}
人员归属
${sum.affiliations.map((a) => `
${esc(a.name)}
${a.count}
`).join("")}
花名册外的负责人
${sum.outside.length ? sum.outside.map((o) => `
${esc(o.name)}
项目 ${o.project_count}
定开 ${o.custom_count}
`).join("") : `
所有项目负责人都能对上花名册
`}
`;
const table = `
| 姓名 | 归属小组 | 人员归属 | 说明 |
在管项目 | 定开项目 | 定开合同额 |
名下待计收 | 最近更新 | 操作 |
${d.items.map((m) => `
${canW("staff")
? ` ${esc(m.name)}`
: ` ${esc(m.name)}`}
${m.archived ? ` 已归档` : ""}
|
|
|
|
${m.project_count ? `${m.project_count}${m.key_project_count ? ` ${starMark(true, false, "重点项目")}${m.key_project_count}` : ""}` : "—"} |
${m.custom_count} |
${m.custom_amount ? fen(m.custom_amount) + " 万" : "—"} |
${m.custom_pending ? `${fen(m.custom_pending)} 万` : "—"} |
${esc(m.last_update || "—")} |
${rowOps({
edit: `data-sedit="${m.id}"`,
archive: `data-sarch="${m.id}"`,
archived: m.archived,
remove: `data-sdel="${m.id}"`,
})} |
`).join("")}
`;
const body = listCard({
title: "人员花名册",
count: d.items.length,
fields: `
${keywordInputHtml("stKeyword", "搜索姓名…", 160)}`,
tools: `${canR("staff") ? `` : ""}
${canW("staff") ? `` : ""}`,
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 = `加载中…
`;
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 ` | `;
};
const roleTable = `
| 角色 / 说明 |
${menus.map((m) => ``).join("")}
用户 |
${rw ? `操作 | ` : ""}
${roles.map((r) => `
| ${esc(r.name)}${r.is_builtin ? ` 内置` : ""}${r.description ? `${esc(r.description)}` : ""} |
${menus.map((m) => levelCell(r, m)).join("")}
${r.user_count} |
${rw ? `
${r.is_builtin ? "" : ``}
| ` : ""}
`).join("")}
`;
const roleOpts = (sel) => roles.map((r) =>
``).join("");
const userTable = `
| 姓名 | 登录账号 | 角色 | 可用菜单 | 最近登录 |
${rw ? `操作 | ` : ""}
${users.map((u) => {
const self = u.id === state.me.id;
return `
| ${esc(u.name)}${self ? ` 我自己` : ""}${u.active ? "" : ` 已停用`} |
${esc(u.username)} |
|
${u.menus ? Object.keys(u.menus).length : 0} |
${esc(u.last_login_at ? u.last_login_at.replace("T", " ").slice(0, 16) : "从未登录")} |
${rw ? `
${self ? "" : `
`}
| ` : ""}
`;
}).join("")}
`;
host.innerHTML =
listCard({
title: "角色权限", count: roles.length, rows: roles, table: roleTable, empty: "还没有角色",
tools: rw ? `` : "",
}) +
`` +
listCard({
title: "用户", count: users.length, rows: users, table: userTable, empty: "还没有用户",
tools: rw ? `` : "",
});
// ---- 权限矩阵:一个角色的整行一起提交 ----
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: `确认删除角色 ${esc(r.name)}?该角色下不能还有用户。
` });
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: `确认删除用户 ${esc(u.name)}(${esc(u.username)})?该账号会立即下线。
` });
if (ok) save(() => del("/rbac/users/" + u.id));
})
);
}
/** 角色只影响「能看哪些菜单」,名称说明在这里改,菜单授权在矩阵里勾 */
function openRoleDialog(role) {
const isNew = !role;
openModal(
`${isNew ? "新建角色" : "修改角色"}
${isNew ? `
新建后默认不带任何菜单权限,保存后在矩阵里逐项勾选。
` : ""}
`
);
$("#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(
`${isNew ? "新建用户" : "编辑用户"}
换角色会强制该账号重新登录,权限立即按新角色生效。
`
);
$("#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(
`重置密码
给 ${esc(user.name)}(${esc(user.username)})设置新密码。
重置后该用户的其他登录全部失效,需要用新密码重新登录。
`
);
$("#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 = `加载失败:${esc(e.message)}
`;
}
}
/** 写操作统一收尾:刷新侧栏计数 + 当前视图。
录入页不重绘,否则会清空用户正在编辑的表单。 */
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 =
`当前角色没有分配任何功能菜单,请联系管理员在「权限管理」里授权。
`;
}
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", `初始化失败:${esc(e.message)}
`);
});
})();