3917 lines
199 KiB
JavaScript
3917 lines
199 KiB
JavaScript
/* DMS管理平台 */
|
||
(function () {
|
||
"use strict";
|
||
|
||
const Ico = window.Icon;
|
||
|
||
const state = {
|
||
view: "board",
|
||
navFolded: false, // 侧栏收拢:只留菜单图标
|
||
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: "",
|
||
customRisk: "",
|
||
customOrigin: "",
|
||
customSort: null, // { key: amount | sign_date | update_date, dir: asc | desc }
|
||
customArchived: "false",
|
||
customRows: [],
|
||
// 列表分页(全站统一):每页条数 / 各列表当前页 / 各列表上一次的筛选指纹
|
||
pageSize: 10,
|
||
pager: {},
|
||
pagerSig: {},
|
||
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, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
const nl2br = (s) => esc(s).replace(/\n/g, "<br>");
|
||
const money = (n) =>
|
||
n === null || n === undefined
|
||
? "—"
|
||
: "¥" + Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||
const wan = (n) =>
|
||
n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(1) + " 万";
|
||
const fen = (n) => (n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(2));
|
||
/** 重点标记:统一用星标图标,on = 已标重点 */
|
||
const starMark = (on, readonly, title) =>
|
||
`<span class="star${on ? " on" : ""}${readonly ? " readonly" : ""}"${title ? ` title="${esc(title)}"` : ""}>${Ico.svg("star")}</span>`;
|
||
|
||
async function api(path, opts) {
|
||
const res = await fetch("/api" + path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts || {}));
|
||
if (!res.ok) {
|
||
let msg = res.statusText;
|
||
try { msg = (await res.json()).detail || msg; } catch (e) { /* ignore */ }
|
||
// 会话过期 / 被管理员下线:任何请求都直接把界面切回登录页
|
||
if (res.status === 401 && path !== "/auth/login") showAuthGate(msg);
|
||
const err = new Error(msg);
|
||
err.status = res.status;
|
||
throw err;
|
||
}
|
||
return res.json();
|
||
}
|
||
const post = (path, body) => api(path, { method: "POST", body: JSON.stringify(body) });
|
||
const put = (path, body) => api(path, { method: "PUT", body: JSON.stringify(body) });
|
||
const patch = (path, body) => api(path, { method: "PATCH", body: JSON.stringify(body) });
|
||
const del = (path) => api(path, { method: "DELETE" });
|
||
|
||
let toastTimer = null;
|
||
function toast(msg) {
|
||
const t = $("#toast");
|
||
t.textContent = msg;
|
||
t.classList.add("show");
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => t.classList.remove("show"), 2400);
|
||
}
|
||
|
||
/* ---------------------------------------------- 登录与菜单权限 */
|
||
/** 菜单权限:can = 能看(ro/rw),canW = 能改(只有 rw)。未登录一律 false。 */
|
||
function can(menu) {
|
||
return !!(state.me && state.me.menus && state.me.menus[menu]);
|
||
}
|
||
const canR = can;
|
||
function canW(menu) {
|
||
return !!(state.me && state.me.menus && state.me.menus[menu] === "rw");
|
||
}
|
||
|
||
function showAuthGate(msg) {
|
||
$("#authGate").classList.remove("hidden");
|
||
const box = $("#authErr");
|
||
box.textContent = msg || "";
|
||
box.style.display = msg ? "" : "none";
|
||
const f = $("#authPass").value ? $("#authOk") : ($("#authUser").value ? $("#authPass") : $("#authUser"));
|
||
setTimeout(() => f.focus(), 60);
|
||
}
|
||
|
||
function bindAuthForm() {
|
||
$("#authForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const btn = $("#authOk");
|
||
btn.disabled = true;
|
||
try {
|
||
await post("/auth/login", { username: $("#authUser").value.trim(), password: $("#authPass").value });
|
||
location.reload(); // 登录成功后整页重载,按新权限重新渲染
|
||
} catch (err) {
|
||
const box = $("#authErr");
|
||
box.textContent = err.message;
|
||
box.style.display = "";
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
/** 只保留有权限的菜单;顶栏「更新录入」按钮同样跟着 intake 权限走 */
|
||
function applyNavPermissions() {
|
||
$$("#nav .nav-item").forEach((b) => b.classList.toggle("hidden", !can(b.dataset.view)));
|
||
$$("#nav .nav-label").forEach((l) => {
|
||
let n = l.nextElementSibling, any = false;
|
||
while (n && !n.classList.contains("nav-label")) {
|
||
if (n.classList.contains("nav-item") && !n.classList.contains("hidden")) any = true;
|
||
n = n.nextElementSibling;
|
||
}
|
||
l.classList.toggle("hidden", !any);
|
||
});
|
||
$("#btnIntake").classList.toggle("hidden", !can("intake"));
|
||
}
|
||
|
||
/** 有权限的第一个菜单,用于回落 */
|
||
function firstAllowedView() {
|
||
const b = $$("#nav .nav-item").filter((x) => can(x.dataset.view))[0];
|
||
return b ? b.dataset.view : null;
|
||
}
|
||
|
||
/* ---------------------------------------- 侧栏底部的用户胶囊 */
|
||
function renderUserChip() {
|
||
const me = state.me;
|
||
$("#sideUser").innerHTML = `
|
||
<button class="user-chip" id="userChip">
|
||
<span class="avatar">${esc(String(me.name || me.username || "?").slice(0, 1))}</span>
|
||
<span class="u-text"><b>${esc(me.name || me.username)}</b><i>${esc(me.role_name || "未分配角色")}</i></span>
|
||
${Ico.svg("chevron-up", "ic")}
|
||
</button>
|
||
<div class="user-menu hidden" id="userMenu">
|
||
<button id="miProfile">${Ico.svg("sliders", "ic")}用户设置</button>
|
||
<button class="danger" id="miLogout">${Ico.svg("log-out", "ic")}退出登录</button>
|
||
</div>`;
|
||
const menu = $("#userMenu");
|
||
$("#userChip").addEventListener("click", (e) => { e.stopPropagation(); menu.classList.toggle("hidden"); });
|
||
document.addEventListener("click", () => menu.classList.add("hidden"));
|
||
$("#miProfile").addEventListener("click", openProfileDialog);
|
||
$("#miLogout").addEventListener("click", doLogout);
|
||
}
|
||
|
||
/** 用户设置:改昵称 + 改自己的密码;角色由管理员在权限管理里分配 */
|
||
function openProfileDialog() {
|
||
const me = state.me;
|
||
openModal(`
|
||
<div class="modal-head"><h3>用户设置</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="form-row"><label>姓名</label><input class="inp" id="pfName" value="${esc(me.name || "")}" data-autofocus style="width:100%" /></div>
|
||
<div class="form-row"><label>账号</label><div class="ro">${esc(me.username)}</div></div>
|
||
<div class="form-row"><label>角色</label><div class="ro">${esc(me.role_name || "未分配")} <span class="tag-mini">${(me.menus && Object.keys(me.menus).length) || 0} 个菜单</span></div></div>
|
||
<div class="sec-title" style="margin-top:16px">修改密码</div>
|
||
<div class="form-row"><label>原密码</label><input class="inp" id="pfOld" type="password" autocomplete="current-password" style="width:100%" /></div>
|
||
<div class="form-row"><label>新密码</label><input class="inp" id="pfNew" type="password" autocomplete="new-password" style="width:100%" /></div>
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn" data-mclose>关闭</button>
|
||
<button class="btn primary" id="pfOk">保存</button>
|
||
</div>`);
|
||
$("#pfOk").addEventListener("click", async () => {
|
||
const btn = $("#pfOk");
|
||
btn.disabled = true;
|
||
modalError("");
|
||
try {
|
||
const name = $("#pfName").value.trim();
|
||
if (name && name !== me.name) state.me = await put("/auth/me", { name: name });
|
||
const nw = $("#pfNew").value.trim();
|
||
if (nw) await post("/auth/password", { old_password: $("#pfOld").value, new_password: nw });
|
||
renderUserChip();
|
||
closeModal(true);
|
||
toast(nw ? "已保存,其他设备需重新登录" : "已保存");
|
||
} catch (e) {
|
||
modalError(e.message);
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function doLogout() {
|
||
try { await post("/auth/logout", {}); } catch (e) { /* 会话可能已经失效,忽略 */ }
|
||
location.reload();
|
||
}
|
||
|
||
/* ------------------------------------------------ 列表页统一骨架 */
|
||
/** 卡头 = 列表页的子 header:标题+计数(左)、筛选 chip(紧跟标题)、操作按钮(最右)。
|
||
工具栏不再单独占一行,所有页面统一走这里。 */
|
||
function cardHead(o) {
|
||
const tools = [(o.fields || ""), (o.tools || "")].filter(Boolean).join("");
|
||
const cnt = o.count ? `<span class="cnt">${o.count}</span>` : "";
|
||
const cap = o.title || o.count ? `<h4><span class="t">${esc(o.title)}</span>${cnt}</h4>` : "";
|
||
return `<div class="card-head">
|
||
${cap}
|
||
${o.chips ? `<div class="head-chips">${o.chips}</div>` : ""}
|
||
${tools ? `<div class="head-tools">${tools}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
function listCard(o) {
|
||
return `<div class="card">
|
||
${cardHead(o)}
|
||
${o.rows.length ? `<div class="tbl-wrap">${o.table}</div>` : `<div class="empty">${o.empty}</div>`}
|
||
${o.pager || ""}
|
||
</div>`;
|
||
}
|
||
|
||
/* -------------------------------------- 列表分页:全站统一,默认每页 10 条 */
|
||
const PAGE_SIZES = [10, 20, 50, 100];
|
||
|
||
/** 取当前页要渲染的行。
|
||
* key 每个列表一个固定标识,页码记在 state.pager[key];
|
||
* sig 该列表当前的筛选条件,条件一变回到第 1 页,翻页 / 排序保持当前页。 */
|
||
function pagedRows(rows, key, sig) {
|
||
sig = [].concat(sig || []).join("\u0001");
|
||
if (state.pagerSig[key] !== sig) { state.pagerSig[key] = sig; state.pager[key] = 1; }
|
||
const size = state.pageSize;
|
||
const pages = Math.max(1, Math.ceil(rows.length / size));
|
||
const page = Math.min(Math.max(state.pager[key] || 1, 1), pages);
|
||
state.pager[key] = page;
|
||
return rows.slice((page - 1) * size, page * size);
|
||
}
|
||
|
||
/** 页码条:一页装得下就不显示 */
|
||
function pagerBar(key, total) {
|
||
const size = state.pageSize;
|
||
const pages = Math.ceil(total / size);
|
||
if (pages < 2) return "";
|
||
const page = state.pager[key] || 1;
|
||
const btn = (label, p, active, disabled) =>
|
||
`<button class="pg-btn${active ? " active" : ""}" data-pg="${p}"${disabled ? " disabled" : ""}>${label}</button>`;
|
||
// 页码只列 首页 / 末页 / 当前页前后各 2 页,中间用省略号
|
||
let nums = "", last = 0;
|
||
for (let i = 1; i <= pages; i++) {
|
||
if (i !== 1 && i !== pages && Math.abs(i - page) > 2) continue;
|
||
if (i - last > 1) nums += `<span class="pg-gap">…</span>`;
|
||
nums += btn(i, i, i === page, false);
|
||
last = i;
|
||
}
|
||
const sizes = PAGE_SIZES.map((n) =>
|
||
`<option value="${n}" ${n === size ? "selected" : ""}>${n} 条/页</option>`).join("");
|
||
return `<div class="pager" data-pager="${key}">
|
||
<span class="pg-info">共 ${total} 条</span>
|
||
<div class="pg-btns">${btn("‹", page - 1, false, page <= 1)}${nums}${btn("›", page + 1, false, page >= pages)}</div>
|
||
<span class="pg-info">第 ${page} / ${pages} 页</span>
|
||
<select class="sel xs pg-size">${sizes}</select>
|
||
</div>`;
|
||
}
|
||
|
||
/** 页码条的交互:点页码 / 换每页条数后由调用方重绘列表 */
|
||
function bindPager(root, rerender) {
|
||
root.querySelectorAll("[data-pager]").forEach((bar) => {
|
||
const key = bar.dataset.pager;
|
||
bar.querySelectorAll("[data-pg]").forEach((b) =>
|
||
b.addEventListener("click", () => { state.pager[key] = Number(b.dataset.pg); rerender(); })
|
||
);
|
||
const sel = bar.querySelector(".pg-size");
|
||
if (sel) sel.addEventListener("change", () => {
|
||
state.pageSize = Number(sel.value) || PAGE_SIZES[0];
|
||
Object.keys(state.pager).forEach((k) => { state.pager[k] = 1; });
|
||
rerender();
|
||
});
|
||
});
|
||
}
|
||
|
||
/** 表格行内操作:全站统一「修改 / 归档 / 删除」三个图标按钮,名称点开详情。
|
||
事件属性名由调用方给出,沿用各表原有的绑定。 */
|
||
function rowOps(o) {
|
||
// 只读角色不给行内「修改 / 归档 / 删除」
|
||
if (!canW(state.view)) return "";
|
||
const b = (attr, icon, title, cls) =>
|
||
`<button class="icon-btn sm${cls ? " " + cls : ""}" ${attr} title="${esc(title)}">${Ico.svg(icon)}</button>`;
|
||
if (!o.edit && !o.archive && !o.remove) return "";
|
||
return `<span class="row-ops">` +
|
||
(o.edit ? b(o.edit, "pencil", "修改") : "") +
|
||
(o.archive ? b(o.archive + ` data-archived="${o.archived ? "1" : "0"}"`, "archive", o.archived ? "恢复" : "归档") : "") +
|
||
(o.remove ? b(o.remove, "trash", "删除", "danger") : "") +
|
||
`</span>`;
|
||
}
|
||
|
||
/* -------------------------------------- 周期记录(执行 / 交付)的新增·修改·删除 */
|
||
/** 记录条目右上角的操作按钮,鼠标移到条目上才显现 */
|
||
function updOps(kind, u) {
|
||
if (!canW(kind === "custom" ? "custom" : "projects")) return "";
|
||
return `<span class="u-ops">
|
||
<button class="icon-btn xs" data-uop="edit" data-ukind="${kind}" data-uid="${u.id}" title="修改这条记录">${Ico.svg("pencil")}</button>
|
||
<button class="icon-btn xs danger" data-uop="del" data-ukind="${kind}" data-uid="${u.id}" title="删除这条记录">${Ico.svg("trash")}</button>
|
||
</span>`;
|
||
}
|
||
|
||
/**
|
||
* 新增 / 修改一条周期记录,全站唯一入口(看板右栏、项目抽屉、定制抽屉共用)。
|
||
* kind = "project" 自研产品的执行记录,"custom" 定制项目的交付记录;
|
||
* 传 update 就是修改,否则在 periodId 下新建;写完调 onDone 刷新所在区域。
|
||
*/
|
||
function openUpdateDialog(o) {
|
||
const isP = o.kind !== "custom";
|
||
const u = o.update || null;
|
||
const weeks = state.periods.weeks || [];
|
||
const periodId = u ? u.period_id : o.periodId;
|
||
const stages = (state.customMeta && state.customMeta.stages) || [];
|
||
const nv = (v) => (v === null || v === undefined ? "" : String(v));
|
||
// 新建时沿用项目当前的状态 / 阶段:后端会按记录回写档案,别让默认值改坏台账
|
||
const defStatus = u ? u.status : o.status || "in_progress";
|
||
const defStage = u ? u.stage : o.stage || "";
|
||
// 计收 / 下单增量在库里存的是元,页面上统一按万元填写
|
||
const toWan = (v) => (v === null || v === undefined ? "" : (Number(v) / 10000).toFixed(2));
|
||
const fields = isP
|
||
? `<div class="form-row"><label>子任务</label><input class="inp" id="udTask" value="${esc(u ? u.task_name || "" : "")}" placeholder="留空记在整体进展下" style="width:100%" /></div>
|
||
<div class="form-row"><label>状态</label><select class="sel" id="udStatus" style="width:100%">
|
||
${(state.meta.statuses || []).map((s) => `<option value="${esc(s.code)}" ${s.code === defStatus ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label>进度(%)</label><input class="inp" id="udProgress" type="number" min="0" max="100" value="${nv(u && u.progress)}" style="width:100%" /></div>`
|
||
: `<div class="form-row"><label>交付阶段</label><select class="sel" id="udStage" style="width:100%">
|
||
${stages.map((s) => `<option value="${esc(s.code)}" ${s.code === defStage ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label>进度(%)</label><input class="inp" id="udProgress" type="number" min="0" max="100" value="${nv(u && u.progress)}" style="width:100%" /></div>
|
||
<div class="form-row"><label>本期新增计收(万元)</label><input class="inp" id="udRevenue" type="number" min="0" step="0.01" value="${toWan(u && u.revenue_delta)}" style="width:100%" /></div>
|
||
<div class="form-row"><label>本期新增下单(万元)</label><input class="inp" id="udAmount" type="number" min="0" step="0.01" value="${toWan(u && u.amount_delta)}" style="width:100%" /></div>`;
|
||
|
||
openModal(
|
||
`<div class="modal-head"><h3>${u ? "修改" : "添加"}${isP ? "执行记录" : "交付记录"}</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="form-grid">
|
||
<div class="form-row full"><label>周期 *</label><select class="sel" id="udPeriod" style="width:100%">
|
||
${weeks.map((w) => `<option value="${w.id}" ${w.id === periodId ? "selected" : ""}>${esc(w.full_label)}</option>`).join("")}
|
||
</select></div>
|
||
${fields}
|
||
<div class="form-row full"><label>内容 *</label><textarea class="inp" id="udContent" rows="5" data-autofocus style="width:100%">${esc(u ? u.content || u.summary || "" : "")}</textarea></div>
|
||
<div class="form-row full"><label>风险</label><textarea class="inp" id="udRisk" rows="2" style="width:100%">${esc(u ? u.risk || "" : "")}</textarea></div>
|
||
<div class="form-row full"><label>下一步</label><textarea class="inp" id="udNext" rows="2" style="width:100%">${esc(u ? u.next_step || "" : "")}</textarea></div>
|
||
</div>
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">${u ? "保存修改" : "添加"}</button></div>`
|
||
);
|
||
|
||
const numVal = (id) => { const el = $(id); return el.value === "" ? null : Number(el.value); };
|
||
const wanVal = (id) => { const el = $(id); return el.value === "" ? null : Math.round(Number(el.value) * 10000); };
|
||
$("#mOk").addEventListener("click", async () => {
|
||
const content = $("#udContent").value.trim();
|
||
if (!content) return modalError("请填写内容");
|
||
const btn = $("#mOk");
|
||
btn.disabled = true;
|
||
const body = {
|
||
period_id: Number($("#udPeriod").value),
|
||
content: content,
|
||
progress: numVal("#udProgress"),
|
||
risk: $("#udRisk").value.trim() || null,
|
||
next_step: $("#udNext").value.trim() || null,
|
||
};
|
||
try {
|
||
if (isP) {
|
||
body.status = $("#udStatus").value;
|
||
body.task_name = $("#udTask").value.trim();
|
||
if (u) await patch("/updates/" + u.id, body);
|
||
else await post("/updates", Object.assign({ project_id: o.projectId, source: "manual" }, body));
|
||
} else {
|
||
body.stage = $("#udStage").value;
|
||
body.revenue_delta = wanVal("#udRevenue");
|
||
body.amount_delta = wanVal("#udAmount");
|
||
if (u) await patch("/custom/updates/" + u.id, body);
|
||
else await post("/custom/updates", Object.assign({ custom_project_id: o.customProjectId, source: "manual" }, body));
|
||
}
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
return modalError(e.message);
|
||
}
|
||
closeModal();
|
||
toast(u ? "已修改" : "已添加");
|
||
await afterWrite();
|
||
if (o.onDone) await o.onDone();
|
||
});
|
||
}
|
||
|
||
/** 给一块区域里的周期记录绑上「修改 / 删除」。list 用来按 id 找回记录,reload 刷新这块区域。 */
|
||
function bindUpdateOps(root, list, reload) {
|
||
root.querySelectorAll("[data-uop]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const id = Number(b.dataset.uid);
|
||
const kind = b.dataset.ukind;
|
||
const u = (list || []).find((x) => x.id === id);
|
||
if (!u) return;
|
||
if (b.dataset.uop === "edit") {
|
||
openUpdateDialog({
|
||
kind: kind,
|
||
update: u,
|
||
projectId: u.project_id,
|
||
customProjectId: u.custom_project_id,
|
||
onDone: reload,
|
||
});
|
||
return;
|
||
}
|
||
const ok = await confirmDialog({
|
||
title: "删除这条记录?",
|
||
body: `<div class="notice err">删除后无法恢复。</div>
|
||
<div style="font-size:12.5px;color:var(--text-2);line-height:1.7">${esc([u.period_full, u.task_name || u.project_name].filter(Boolean).join(" · "))}</div>`,
|
||
okText: "永久删除",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await del(kind === "custom" ? "/custom/updates/" + id : "/updates/" + id);
|
||
} catch (e) { toast("删除失败:" + e.message); return; }
|
||
toast("已删除");
|
||
await afterWrite();
|
||
if (reload) await reload();
|
||
})
|
||
);
|
||
}
|
||
|
||
/** 页内搜索框:与右上角全局搜索共用 state.keyword */
|
||
function keywordInputHtml(id, placeholder, width) {
|
||
return `<input id="${id}" class="search" type="search" placeholder="${esc(placeholder)}" value="${esc(state.keyword)}" style="width:${width}px;height:30px" />`;
|
||
}
|
||
|
||
/** 绑定页内搜索。
|
||
默认:防抖实时搜索;opts.enter=true 时只在「回车 / 清空(×)」后才查询。
|
||
重绘后把光标放回搜索框,连续操作不中断。 */
|
||
function bindKeyword(id, opts) {
|
||
opts = opts || {};
|
||
const el = $("#" + id);
|
||
if (!el) return;
|
||
const apply = (v) => {
|
||
v = (v || "").trim();
|
||
if (v === state.keyword) return;
|
||
keywordFocus = true;
|
||
state.keyword = v;
|
||
$("#globalSearch").value = v;
|
||
refresh();
|
||
};
|
||
if (opts.enter) {
|
||
// 输入阶段不查询;回车提交后才检索
|
||
el.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); apply(el.value); }
|
||
});
|
||
// type=search 的清除「×」会触发 search 事件
|
||
el.addEventListener("search", () => apply(el.value));
|
||
} else {
|
||
let t = null;
|
||
el.addEventListener("input", (e) => {
|
||
clearTimeout(t);
|
||
const v = e.target.value.trim();
|
||
t = setTimeout(() => apply(v), 320);
|
||
});
|
||
}
|
||
if (keywordFocus) {
|
||
keywordFocus = false;
|
||
el.focus();
|
||
el.setSelectionRange(el.value.length, el.value.length);
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------- 弹窗 / 确认框 */
|
||
let modalResolve = null;
|
||
|
||
function openModal(html, onMount) {
|
||
$("#modalMask").classList.add("open");
|
||
$("#modal").innerHTML = html;
|
||
if (onMount) onMount();
|
||
const f = $("#modal").querySelector("[data-autofocus]");
|
||
if (f) f.focus();
|
||
}
|
||
|
||
/** 关闭弹窗;result 省略时按「取消」处理 */
|
||
function closeModal(result) {
|
||
if (!$("#modalMask").classList.contains("open")) return;
|
||
$("#modalMask").classList.remove("open");
|
||
$("#modal").innerHTML = "";
|
||
const cb = modalResolve;
|
||
modalResolve = null;
|
||
if (cb) cb(result === undefined ? false : result);
|
||
}
|
||
|
||
function modalError(msg) {
|
||
const e = $("#mErr");
|
||
if (!e) return;
|
||
e.textContent = msg || "";
|
||
e.style.display = msg ? "" : "none";
|
||
}
|
||
|
||
/** 需要用户明确点确认才继续;requireText 时还必须逐字输入该文本 */
|
||
function confirmDialog(o) {
|
||
return new Promise((resolve) => {
|
||
openModal(
|
||
`<div class="modal-head"><h3>${esc(o.title)}</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
${o.body || ""}
|
||
${
|
||
o.requireText
|
||
? `<div class="form-row" style="margin-top:14px"><label>请输入<br><b>${esc(o.requireText)}</b></label>
|
||
<input class="inp" id="mConfirmText" data-autofocus placeholder="${esc(o.requirePlaceholder || "逐字输入项目名称")}" style="width:100%" /></div>`
|
||
: ""
|
||
}
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn" data-mclose>${esc(o.cancelText || "取消")}</button>
|
||
<button class="btn ${o.danger ? "danger solid" : "primary"}" id="mOk" ${o.requireText ? "disabled" : ""}>${esc(o.okText || "确认")}</button>
|
||
</div>`
|
||
);
|
||
modalResolve = resolve;
|
||
$("#mOk").addEventListener("click", () => closeModal(true));
|
||
if (o.requireText) {
|
||
const t = $("#mConfirmText");
|
||
const ok = $("#mOk");
|
||
t.addEventListener("input", () => {
|
||
ok.disabled = t.value.trim() !== o.requireText;
|
||
});
|
||
t.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && !ok.disabled) closeModal(true);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function statusMeta(code) {
|
||
const list = (state.meta && state.meta.statuses) || [];
|
||
return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" };
|
||
}
|
||
function statusPill(code) {
|
||
const s = statusMeta(code);
|
||
return `<span class="pill" style="background:${s.color}1a;color:${s.color}">${Ico.status(code)}${esc(s.name)}</span>`;
|
||
}
|
||
function catPill(cat) {
|
||
if (!cat) return `<span class="tag-mini">未分类</span>`;
|
||
return `<span class="tag-mini" style="background:${cat.color}18;color:${cat.color}">${esc(cat.name)}</span>`;
|
||
}
|
||
|
||
/** 规范省级行政区下拉(按大区分组)。selected 不在标准列表时补一个选项,避免丢值。 */
|
||
function regionOptions(selected, withEmpty) {
|
||
const groups = (state.customMeta && state.customMeta.region_groups) || [];
|
||
const all = groups.reduce((acc, g) => acc.concat(g.regions), []);
|
||
let html = withEmpty === false ? "" : `<option value="" ${!selected ? "selected" : ""}>(未标注)</option>`;
|
||
for (const g of groups) {
|
||
html += `<optgroup label="${esc(g.group)}">` +
|
||
g.regions.map((r) => `<option value="${esc(r)}" ${r === selected ? "selected" : ""}>${esc(r)}</option>`).join("") +
|
||
`</optgroup>`;
|
||
}
|
||
if (selected && all.indexOf(selected) < 0) {
|
||
html += `<option value="${esc(selected)}" selected>${esc(selected)}(非标准)</option>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
/** 触发浏览器下载(导出 Excel) */
|
||
function download(url) {
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.style.display = "none";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(() => a.remove(), 1000);
|
||
toast("正在生成 Excel,请稍候…");
|
||
}
|
||
|
||
/**
|
||
* 负责人下拉。选项来自人力资源库:已归档人员不再出现在选项里,
|
||
* 只有他本来就是当前负责人时才带「(已归档)」标注出现一次,避免历史记录丢值;
|
||
* 库里没有的历史人名同样补一个选项,保证老数据不被改坏。
|
||
*/
|
||
function ownerSelectHtml(value, attrs) {
|
||
const cur = (value || "").trim();
|
||
let html = `<select class="sel" ${attrs || 'style="width:104px"'}>
|
||
<option value="">(未指定)</option>`;
|
||
state.staff.forEach((s) => {
|
||
if (s.archived && s.name !== cur) return;
|
||
html += `<option value="${esc(s.name)}" ${s.name === cur ? "selected" : ""}>${esc(s.name)}${s.archived ? "(已归档)" : ""}</option>`;
|
||
});
|
||
if (cur && !state.staff.some((s) => s.name === cur)) {
|
||
html += `<option value="${esc(cur)}" selected>${esc(cur)}(不在花名册)</option>`;
|
||
}
|
||
return html + `</select>`;
|
||
}
|
||
|
||
/**
|
||
* 多负责人 / 团队成员的可编辑名单:人名下拉(选项来自人力资源库)横向排开、自动换行,
|
||
* 不分主次,每个人都能删;「+ 添加」紧跟在名单末尾。
|
||
* 增删按钮的事件统一委托在 #drawerBody 上(见 bindDrawerPeople)。
|
||
*/
|
||
function peopleEditorHtml(id, label, values, opts) {
|
||
const list = values && values.length ? values : [""];
|
||
const rows = list
|
||
.map(
|
||
(n) => `
|
||
<div class="people-row">
|
||
${ownerSelectHtml(n, 'style="width:118px"')}
|
||
<button class="btn sm ghost people-del" type="button" data-pdel title="移除">${Ico.svg("x")}</button>
|
||
</div>`,
|
||
)
|
||
.join("");
|
||
return `
|
||
<div class="form-row top">
|
||
<label>${label}</label>
|
||
<div>
|
||
<div class="people-rows" id="${id}">${rows}
|
||
<button class="btn sm ghost" type="button" data-padd="${id}">${Ico.svg("plus")}添加${opts.add}</button>
|
||
</div>
|
||
${opts.hint ? `<div class="sec-hint" style="margin:6px 0 0">${opts.hint}</div>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** 读回名单里的人名(按行序,空行忽略) */
|
||
function peopleValues(id) {
|
||
return $$("#" + id + " select").map((x) => x.value.trim()).filter(Boolean);
|
||
}
|
||
|
||
/** 名单增删:抽屉里所有「多负责人 / 团队成员」共用一个委托 */
|
||
function bindDrawerPeople() {
|
||
$("#drawerBody").addEventListener("click", (e) => {
|
||
const add = e.target.closest("[data-padd]");
|
||
if (add) {
|
||
const box = document.getElementById(add.dataset.padd);
|
||
if (!box) return;
|
||
const row = document.createElement("div");
|
||
row.className = "people-row";
|
||
row.innerHTML =
|
||
ownerSelectHtml("", 'style="width:130px"') +
|
||
`<button class="btn sm ghost people-del" type="button" data-pdel title="移除">${Ico.svg("x")}</button>`;
|
||
box.insertBefore(row, add);
|
||
row.querySelector("select").focus();
|
||
return;
|
||
}
|
||
const del = e.target.closest("[data-pdel]");
|
||
if (del) del.closest(".people-row").remove();
|
||
});
|
||
}
|
||
|
||
/** 只读名单:小标签一排 */
|
||
function nameChips(list) {
|
||
return (list || []).length
|
||
? list.map((n) => `<span class="tag-mini">${esc(n)}</span>`).join(" ")
|
||
: "—";
|
||
}
|
||
|
||
/** 拉一次花名册,供各处「负责人」下拉使用 */
|
||
async function loadStaff() {
|
||
if (!canR("staff")) return null; // 没有花名册权限时不请求,负责人下拉留空
|
||
const d = await api("/staff?archived=all");
|
||
state.staff = d.items;
|
||
state.staffDict = d.dict;
|
||
return d;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- scope */
|
||
function scopeQuery(extra) {
|
||
const p = [];
|
||
if (state.gran === "week") {
|
||
if (state.weekId) p.push("period_id=" + state.weekId);
|
||
} else {
|
||
const m = currentMonth();
|
||
if (m) p.push("year=" + m.year, "month=" + m.month);
|
||
}
|
||
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
|
||
if (extra) p.push.apply(p, extra);
|
||
return p.length ? "?" + p.join("&") : "";
|
||
}
|
||
function currentMonth() {
|
||
if (!state.periods || state.monthIdx === null) return null;
|
||
return state.periods.months[state.monthIdx] || null;
|
||
}
|
||
/** 顶栏统计周期落在哪个季度(周 / 月都换算成所属年份的 1~4),没选到周期时退回今天 */
|
||
function scopeQuarter() {
|
||
const per = state.gran === "week"
|
||
? (state.periods && (state.periods.weeks || []).find((x) => x.id === state.weekId))
|
||
: currentMonth();
|
||
const m = Number(per && per.month);
|
||
if (m >= 1 && m <= 12) return Math.floor((m - 1) / 3) + 1;
|
||
const now = new Date();
|
||
return Math.floor(now.getMonth() / 3) + 1;
|
||
}
|
||
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 stackBar(items) {
|
||
const total = items.reduce((a, b) => a + b.count, 0) || 1;
|
||
let x = 0, segs = "";
|
||
items.filter((i) => i.count > 0).forEach((d) => {
|
||
const w = (d.count / total) * 100;
|
||
segs += `<rect x="${x.toFixed(2)}%" y="0" width="${w.toFixed(2)}%" height="14" fill="${d.color}"><title>${esc(d.name)}: ${d.count}</title></rect>`;
|
||
x += w;
|
||
});
|
||
return `<svg class="svg-chart" viewBox="0 0 100 14" preserveAspectRatio="none" style="height:14px;border-radius:99px;overflow:hidden">${segs}</svg>`;
|
||
}
|
||
|
||
function barList(rows) {
|
||
return rows.map((r) => `
|
||
<div class="bar-row">
|
||
<div class="b-name" title="${esc(r.name)}">${r.is_key ? starMark(true) + " " : ""}${esc(r.name)}</div>
|
||
<div class="b-track"><i style="width:${Math.min(100, r.progress)}%;background:${r.color}"></i></div>
|
||
<div class="b-val">${r.progress}${r.estimated ? "*" : ""}</div>
|
||
</div>`).join("");
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- periods */
|
||
async function createNewPeriod() {
|
||
const label = prompt("新建周期(格式如:9月第1周)\n年份会自动接在最新周期之后,也可写成 2027年1月第1周", "");
|
||
if (!label || !label.trim()) return null;
|
||
try {
|
||
const r = await post("/periods", { label: label.trim() });
|
||
await loadPeriods();
|
||
state.gran = "week";
|
||
state.weekId = r.period.id;
|
||
state.intakePeriodId = r.period.id;
|
||
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === "week"));
|
||
renderPeriodSelect();
|
||
toast(r.created ? `已新增周期 ${r.period.full_label}` : `周期 ${r.period.full_label} 已存在,已切换`);
|
||
return r.period.id;
|
||
} catch (err) {
|
||
toast("新建周期失败:" + err.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function loadPeriods() {
|
||
state.periods = await api("/periods");
|
||
if (!state.periods.weeks.length) return;
|
||
// 刷新后恢复的周期可能已经被删除/重建,不在列表里就回落到今天所在的周(没有就取最新一期)
|
||
if (!state.periods.weeks.some((w) => w.id === state.weekId)) {
|
||
state.weekId = state.periods.today_id || state.periods.weeks[state.periods.weeks.length - 1].id;
|
||
}
|
||
if (state.monthIdx === null || state.monthIdx >= state.periods.months.length) {
|
||
state.monthIdx = state.periods.months.length - 1;
|
||
}
|
||
renderPeriodSelect();
|
||
}
|
||
|
||
function renderPeriodSelect() {
|
||
saveUI();
|
||
const sel = $("#periodSelect");
|
||
if (!state.periods) return;
|
||
if (state.gran === "week") {
|
||
sel.innerHTML =
|
||
state.periods.weeks
|
||
.map((w) => `<option value="${w.id}" ${w.id === state.weekId ? "selected" : ""}>${esc(w.full_label)}</option>`)
|
||
.join("") + `<option value="__new">+ 新建周期…</option>`;
|
||
} else {
|
||
sel.innerHTML = state.periods.months
|
||
.map((m, i) => `<option value="${i}" ${i === state.monthIdx ? "selected" : ""}>${esc(m.label)}(${m.week_count} 周)</option>`)
|
||
.join("");
|
||
}
|
||
$("#periodHint").textContent = state.gran === "week" ? "" : "月视图 · 自动汇总该月所有周";
|
||
}
|
||
|
||
function shiftPeriod(delta) {
|
||
if (state.gran === "week") {
|
||
const arr = state.periods.weeks;
|
||
let i = arr.findIndex((w) => w.id === state.weekId);
|
||
i = Math.min(arr.length - 1, Math.max(0, i + delta));
|
||
state.weekId = arr[i].id;
|
||
} else {
|
||
let i = state.monthIdx + delta;
|
||
i = Math.min(state.periods.months.length - 1, Math.max(0, i));
|
||
state.monthIdx = i;
|
||
}
|
||
renderPeriodSelect();
|
||
refresh();
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 周看板 */
|
||
/** 左栏一行:重点项目 / 定制项目共用 */
|
||
function weeklyItem(r) {
|
||
const sel = state.weeklySel;
|
||
const active = sel && sel.kind === r.kind && sel.id === r.id;
|
||
const color = r.kind === "project"
|
||
? ((r.category && r.category.color) || "#2563eb")
|
||
: stageMeta(r.stage).color;
|
||
const badge = r.has_update
|
||
? `<span class="tag-mini" style="background:#e9f7ef;color:#16a34a">${r.update_count} 条</span>`
|
||
: `<span class="tag-mini" style="background:#fdecec;color:#dc2626">未更新</span>`;
|
||
return `
|
||
<div class="wk-item ${active ? "active" : ""}" style="--c:${color}" data-wk="${r.kind}:${r.id}">
|
||
<div class="wk-i-top">
|
||
<span class="wk-i-name">${r.is_key ? starMark(true) : ""}${esc(r.name)}</span>
|
||
${badge}
|
||
</div>
|
||
<div class="wk-i-meta">
|
||
${r.kind === "project" ? statusPill(r.status) : stagePill(r.stage)}
|
||
<span class="tag-mini">${esc(r.owner || "未指派")}</span>
|
||
${r.kind === "project"
|
||
? `<span class="tag-mini">${r.progress}%</span>`
|
||
: `<span class="tag-mini">${fen(r.amount)} 万</span>`}
|
||
${r.risks.length ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">风险 ${r.risks.length}</span>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** 交付环节步骤条:日期可直接补录,补了日期后端会自动推进商务阶段 */
|
||
function pipelineBar(p, readonly) {
|
||
const steps = (state.customMeta && state.customMeta.pipeline) || [];
|
||
return `<div class="pipe">
|
||
${steps.map((s, i) => `
|
||
${i ? `<div class="pipe-arrow">${Ico.svg("chevron-right")}</div>` : ""}
|
||
<div class="pipe-step ${p[s.date_field] ? "done" : ""} ${p.stage === s.stage ? "now" : ""}" style="--c:${s.color}">
|
||
<div class="pipe-name">${Ico.pipe(s.code)}${esc(s.name)}</div>
|
||
${readonly
|
||
? `<div class="pipe-date ${p[s.date_field] ? "" : "none"}">${p[s.date_field] ? esc(p[s.date_field]) : "未填"}</div>`
|
||
: `<input class="inp" type="date" data-cf="${s.date_field}" value="${p[s.date_field] || ""}" />`}
|
||
</div>`).join("")}
|
||
</div>`;
|
||
}
|
||
|
||
/** 一条执行 / 交付记录;传 kind 才给行内「修改 / 删除」 */
|
||
function updateItem(u, kind) {
|
||
return `
|
||
<div class="upd-item">
|
||
<div class="u-head">
|
||
<span class="u-task">${esc(u.task_name || u.period_full || "记录")}</span>
|
||
${u.stage ? stagePill(u.stage) : statusPill(u.status)}
|
||
${u.progress !== null && u.progress !== undefined ? `<span class="tag-mini">${u.progress}%</span>` : ""}
|
||
${u.task_name && u.period_full ? `<span class="tag-mini">${esc(u.period_label || "")}</span>` : ""}
|
||
${kind ? updOps(kind, u) : ""}
|
||
</div>
|
||
<div class="u-body">${nl2br(u.summary || u.content || "(无正文)")}</div>
|
||
${u.risk ? `<div class="u-risk">${Ico.svg("alert-circle")}<div>${nl2br(u.risk)}</div></div>` : ""}
|
||
${u.next_step ? `<div class="u-next">${Ico.svg("arrow-right")}<div>${nl2br(u.next_step)}</div></div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
function weeklyHead(row) {
|
||
const isP = row.kind === "project";
|
||
const color = isP ? ((row.category && row.category.color) || "#2563eb") : stageMeta(row.stage).color;
|
||
const menu = isP ? "projects" : "custom";
|
||
return `
|
||
<div class="wk-head" style="--c:${color}">
|
||
<div class="wk-head-main">
|
||
<div class="wk-head-title">${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}</div>
|
||
<div class="wk-i-meta">
|
||
${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)}
|
||
<span class="tag-mini">${esc(row.owner || "未指派")}</span>
|
||
<span class="tag-mini">最近更新 ${esc(row.last_update || "尚无记录")}</span>
|
||
${row.idle_weeks ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">已 ${row.idle_weeks} 周没更新</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="wk-head-ops">
|
||
${canW(menu) ? `<button class="btn sm primary" id="wkAddUpd">${Ico.svg("plus")}${isP ? "添加执行记录" : "添加交付记录"}</button>` : ""}
|
||
<button class="btn sm" id="wkDrawer">完整详情</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* 右栏:常规项目详情 */
|
||
async function weeklyProjectDetail(row) {
|
||
const d = await api("/projects/" + row.id);
|
||
const p = d.project;
|
||
const color = (p.category && p.category.color) || "#2563eb";
|
||
const trend = d.timeline.slice(-8).reverse();
|
||
return `
|
||
${weeklyHead(row)}
|
||
<div class="sec">
|
||
<div class="sec-title">本期执行记录(${row.updates.length})</div>
|
||
${row.updates.length
|
||
? row.updates.map((x) => updateItem(x, "project")).join("")
|
||
: `<div class="notice warn">本周期还没有执行记录。重点项目每周都要更新一次,点上方「添加执行记录」补上。</div>`}
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">进度与状态</div>
|
||
<div class="progress"><i style="width:${Math.min(100, row.progress)}%;background:${color}"></i></div>
|
||
<div class="progress-label"><span>整体进度</span><span>${row.progress}%</span></div>
|
||
${d.tasks.length ? `<div class="inline-fields" style="margin-top:10px">${d.tasks.map((t) => `<span class="tag-mini">${esc(t.name)}</span>`).join("")}</div>` : ""}
|
||
</div>
|
||
|
||
${trend.length ? `
|
||
<div class="sec">
|
||
<div class="sec-title">近 ${trend.length} 期趋势</div>
|
||
${trend.map((t) => `
|
||
<div class="timeline-row">
|
||
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
|
||
<div style="flex:1">${statusPill(t.status)}${t.progress !== null ? ` <span class="tag-mini">${t.progress}%</span>` : ""}
|
||
<span class="tag-mini">${t.updates.length} 条</span></div>
|
||
</div>`).join("")}
|
||
</div>` : ""}`;
|
||
}
|
||
|
||
/* 右栏:定制项目详情(交付环节 + 财务) */
|
||
async function weeklyCustomDetail(row) {
|
||
const d = await api("/custom/projects/" + row.id);
|
||
const p = d.project;
|
||
const hist = d.updates.slice(-5).reverse();
|
||
return `
|
||
${weeklyHead(row)}
|
||
<div class="sec">
|
||
<div class="sec-title">交付环节</div>
|
||
${pipelineBar(p, !canW("custom"))}
|
||
${p.remark ? `<div class="kcard-remark" style="margin-top:10px">${nl2br(p.remark)}</div>` : ""}
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">财务跟踪</div>
|
||
<div class="grid kpi" style="gap:10px;grid-template-columns:repeat(auto-fit,minmax(132px,1fr))">
|
||
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">合同额</div><div class="k-value" style="font-size:18px">${wan(p.amount)}</div></div>
|
||
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">可确认收入</div><div class="k-value" style="font-size:18px">${wan(p.confirmed_amount)}</div><div class="k-foot">${p.revenue_ratio}%</div></div>
|
||
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">已计收</div><div class="k-value" style="font-size:18px">${wan(p.revenue_amount)}</div></div>
|
||
<div class="kpi">${Ico.badge("hourglass")}<div class="k-label">待计收</div><div class="k-value" style="font-size:18px">${wan(p.pending_amount)}</div></div>
|
||
</div>
|
||
<div class="inline-fields" style="margin-top:12px">
|
||
<label class="field-mini">开票金额<input class="inp" type="number" data-cf="invoice_amount" value="${p.invoice_amount === null ? "" : p.invoice_amount}" style="width:110px;text-align:right" /></label>
|
||
<label class="field-mini">已回款<input class="inp" type="number" data-cf="received_amount" value="${p.received_amount === null ? "" : p.received_amount}" style="width:110px;text-align:right" /></label>
|
||
<span class="tag-mini" style="background:#fff4e0;color:#b45309">待开票 ${fen(p.pending_invoice)} 万 · 待回款 ${fen(p.pending_received)} 万</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">本期交付记录(${row.updates.length})</div>
|
||
${row.updates.length
|
||
? row.updates.map((x) => updateItem(x, "custom")).join("")
|
||
: `<div class="notice ${row.is_key ? "warn" : "info"}">本周期没有交付记录。</div>`}
|
||
</div>
|
||
|
||
${hist.length ? `
|
||
<div class="sec">
|
||
<div class="sec-title">更早的交付记录</div>
|
||
${hist.map((u) => `
|
||
<div class="timeline-row">
|
||
<div class="t-label">${esc((u.period_full || "").replace(/^\d+年/, ""))}</div>
|
||
<div style="flex:1">${stagePill(u.stage)}${u.revenue_delta ? ` <span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(u.revenue_delta)}万</span>` : ""}</div>
|
||
</div>`).join("")}
|
||
</div>` : ""}`;
|
||
}
|
||
|
||
function bindWeeklyDetail(box, row) {
|
||
// 写入后 afterWrite() 会重绘看板并保留当前选中项,这里不用再自己刷新
|
||
const add = $("#wkAddUpd");
|
||
if (add) add.addEventListener("click", () =>
|
||
openUpdateDialog({
|
||
kind: row.kind, periodId: state.weekId, projectId: row.id, customProjectId: row.id,
|
||
status: row.status, stage: row.stage,
|
||
})
|
||
);
|
||
bindUpdateOps(box, row.updates);
|
||
const dw = $("#wkDrawer");
|
||
if (dw) dw.addEventListener("click", () =>
|
||
row.kind === "project" ? openDrawer(row.id) : openCustomDrawer(row.id)
|
||
);
|
||
box.querySelectorAll("[data-cf]").forEach((inp) => {
|
||
if (!canW("custom")) { inp.disabled = true; return; }
|
||
inp.addEventListener("change", async () => {
|
||
const f = inp.dataset.cf;
|
||
let v = inp.value;
|
||
if (f === "invoice_amount" || f === "received_amount") v = v === "" ? null : Number(v);
|
||
try {
|
||
await patch("/custom/projects/" + row.id, { [f]: v });
|
||
toast("已保存");
|
||
await afterWrite();
|
||
} catch (e) { toast("保存失败:" + e.message); }
|
||
});
|
||
});
|
||
}
|
||
|
||
/** 定制项目本周动态里的一条:整行可点,点进去看该项目详情。
|
||
* o.province = 带上所属省份;o.owner === false = 不显示负责人 */
|
||
function weeklyCustomRow(r, tag, o) {
|
||
o = o || {};
|
||
return `
|
||
<div class="wk-item" style="--c:${stageMeta(r.stage).color}" data-cpick="${r.id}">
|
||
<div class="wk-i-top">
|
||
<span class="wk-i-name">${r.is_key ? starMark(true) : ""}${esc(r.name)}</span>
|
||
${tag || ""}
|
||
</div>
|
||
<div class="wk-i-meta">
|
||
${stagePill(r.stage)}
|
||
${o.province ? `<span class="tag-mini">${esc(r.region || "省份未填")}</span>` : ""}
|
||
${o.owner === false ? "" : `<span class="tag-mini">${esc(r.owner || "未指派")}</span>`}
|
||
<span class="tag-mini">${fen(r.amount)} 万</span>
|
||
${r.period_revenue_delta ? `<span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(r.period_revenue_delta)} 万</span>` : ""}
|
||
${(r.risks || []).length ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">风险 ${(r.risks || []).length}</span>` : ""}
|
||
</div>
|
||
${r.summary ? `<div class="wk-i-sub">${esc(String(r.summary).replace(/\s+/g, " ").slice(0, 90))}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
/** 右栏:定制项目本周动态。左栏的「定制项目」只给入口,清单都收在这里 */
|
||
function weeklyCustomOverview(data) {
|
||
// 「本周新增 / 本周验收」与定制项目的「本周进展」tab 共用同一套口径
|
||
const { added, accepted, listed } = customWeeklySections(data.custom, scopeRange());
|
||
// 高风险取全部在档项目:/weekly 的 custom 只有本期有动态的,取全量才不会漏
|
||
// 只列「统计周期所在季度」要推动验收的高风险项目(按推动验收计划 Q1~Q4)
|
||
const riskQ = "Q" + scopeQuarter();
|
||
const risky = (state.customAll || data.custom)
|
||
.filter((r) => isHighRisk(r) && String(r.accept_plan || "").trim().toUpperCase() === riskQ)
|
||
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0));
|
||
// 合计只算列出来的行(同一项目可能同时出现在新增与验收两段,这里按 id 去重),免得数字对不上清单
|
||
const sum = listed.reduce((a, r) => a + Number(r.amount || 0), 0);
|
||
const sec = (title, sub, list, tag, o) => `
|
||
<div class="sec">
|
||
<div class="sec-title">${title}</div>
|
||
${list.length ? list.map((r) => weeklyCustomRow(r, tag(r), o)).join("")
|
||
: `<div class="empty" style="padding:14px 4px;font-size:12px">${(o && o.empty) || "本周期没有"}</div>`}
|
||
</div>`;
|
||
return `
|
||
<div class="wk-head" style="--c:#0d9488">
|
||
<div class="wk-head-main">
|
||
<div class="wk-head-title">${Ico.badge("package")}定制项目 · 本周动态</div>
|
||
<div class="wk-i-meta">
|
||
<span class="tag-mini">本周新增 ${added.length} 个</span>
|
||
<span class="tag-mini">本周验收 ${accepted.length} 个</span>
|
||
<span class="tag-mini">高风险 ${riskQ} ${risky.length} 个</span>
|
||
<span class="tag-mini">合同额合计 ${fen(sum)} 万</span>
|
||
<span class="tag-mini">本期计收 ${fen(data.kpi.revenue_delta)} 万</span>
|
||
</div>
|
||
</div>
|
||
<div class="wk-head-ops">
|
||
<button class="btn sm" id="wkGoCustom">去定制项目</button>
|
||
</div>
|
||
</div>
|
||
${sec("本周新增", "下单时间落在本周期(已下单 / 提前交付)", added,
|
||
(r) => `<span class="tag-mini" style="background:#e8f0ff;color:#2563eb">${esc(String(r.sign_date || "").slice(0, 10) || "本期下单")}</span>`)}
|
||
${sec("本周验收", "更新时间落在本周期且状态为已验收", accepted,
|
||
(r) => `<span class="tag-mini" style="background:#fff4e0;color:#b45309">${esc(String(r.update_date || "").slice(0, 10) || "本期验收")}</span>`)}
|
||
${sec("高风险项目(推动验收计划 " + riskQ + ")", "风险值为高风险且推动验收计划落在统计周期所在季度", risky,
|
||
(r) => (r.project_status ? `<span class="tag-mini">${esc(r.project_status)}</span>` : ""),
|
||
{ province: true, owner: false, empty: riskQ + " 没有推动验收的高风险项目" })}`;
|
||
}
|
||
|
||
function bindWeeklyCustomOverview(box) {
|
||
const gc = box.querySelector("#wkGoCustom");
|
||
if (gc) gc.addEventListener("click", () => setView("custom"));
|
||
box.querySelectorAll("[data-cpick]").forEach((el) =>
|
||
el.addEventListener("click", () => {
|
||
state.weeklySel = { kind: "custom", id: Number(el.dataset.cpick) };
|
||
renderWeeklyDetail();
|
||
})
|
||
);
|
||
}
|
||
|
||
async function renderWeeklyDetail() {
|
||
const box = $("#wkRight");
|
||
if (!box) return;
|
||
const sel = state.weeklySel;
|
||
if (sel && sel.kind === "focus") {
|
||
const f = (state.focusRows || []).find((x) => x.id === sel.id);
|
||
box.innerHTML = f ? focusDetailHtml(f) : `<div class="empty">在左侧选择一条重点工作查看详情</div>`;
|
||
const eb = $("#wkFocusEdit");
|
||
if (eb) eb.addEventListener("click", () => openFocusDrawer(sel.id, "edit"));
|
||
return;
|
||
}
|
||
if (sel && sel.kind === "customs") {
|
||
box.innerHTML = weeklyCustomOverview(state.weekly);
|
||
bindWeeklyCustomOverview(box);
|
||
return;
|
||
}
|
||
const row = sel && (sel.kind === "project" ? state.weekly.projects : state.weekly.custom)
|
||
.find((r) => r.id === sel.id);
|
||
if (!row) { box.innerHTML = `<div class="empty">在左侧选择一个项目查看执行详情</div>`; return; }
|
||
box.innerHTML = `<div class="empty">加载中…</div>`;
|
||
try {
|
||
box.innerHTML = row.kind === "project"
|
||
? await weeklyProjectDetail(row)
|
||
: await weeklyCustomDetail(row);
|
||
bindWeeklyDetail(box, row);
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function renderBoard() {
|
||
saveUI();
|
||
const host = $("#view-board");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
if (!state.customMeta) state.customMeta = await api("/custom/meta");
|
||
const [data, focusRows, customAll] = await Promise.all([
|
||
api("/weekly" + scopeQuery()),
|
||
api("/focus" + focusQuery(["archived=false"])),
|
||
// 「本季度验收」与「定制项目 · 项目总览」同一口径:严格按「验收时间」(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 : ""; };
|
||
// 与「定制项目 · 项目总览」同一口径:严格按「验收时间」(Q1~Q4 季度文本),没填的不计入
|
||
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 = `
|
||
<div class="grid kpi" style="margin-bottom:8px">
|
||
<div class="kpi accent-blue">${Ico.badge("star")}<div class="k-label">重点项目</div><div class="k-value">${k.key_total}</div><div class="k-foot">本期已更新 ${k.key_updated}</div></div>
|
||
<div class="kpi accent-blue">${Ico.badge("list-checks")}<div class="k-label">本周重点工作</div><div class="k-value">${focus.week.length}</div><div class="k-foot">延期 ${focus.overdue.length} 条 · 已完成 ${focus.done.length} 条</div></div>
|
||
<div class="kpi accent-violet">${Ico.badge("activity")}<div class="k-label">执行记录</div><div class="k-value">${k.update_count}</div><div class="k-foot">在办项目 ${k.project_listed} 个</div></div>
|
||
<div class="kpi accent-green">${Ico.badge("wallet")}<div class="k-label">截止验收(${qYear}年Q${qNo})</div><div class="k-value" style="font-size:22px">${fen(accQuarterAmount)} 万</div><div class="k-foot">${accQuarter.length} 个项目 · 本年验收 ${accYearCount} 个</div></div>
|
||
<div class="kpi accent-amber">${Ico.badge("alert")}<div class="k-label">工作风险 / 阻塞</div><div class="k-value">${k.at_risk_count} / ${k.blocked_count}</div></div>
|
||
</div>`;
|
||
|
||
const group = (title, rows, tip, icon) => `
|
||
<div class="wk-group">
|
||
<div class="wk-group-head">${icon ? Ico.svg(icon) : ""}${title}<span class="cnt">${rows.length}</span>
|
||
<span class="wk-group-foot">${rows.filter((r) => r.has_update).length} ${tip}</span></div>
|
||
${rows.length ? rows.map(weeklyItem).join("") : `<div class="empty" style="padding:16px 6px;font-size:12px">本周期没有记录</div>`}
|
||
</div>`;
|
||
|
||
// 左栏顺序:每周工作 → 自研产品 → 定制项目(标题与图标一律跟左侧主导航一致)
|
||
const focusList = focus.overdue.concat(focus.week);
|
||
const openCustom = cur && (cur.kind === "customs" || cur.kind === "custom");
|
||
|
||
host.innerHTML = kpi + `
|
||
<div class="wk-split">
|
||
<div class="wk-left card">
|
||
<div class="wk-group">
|
||
<div class="wk-group-head">${Ico.svg("target")}每周工作<span class="cnt">${focusList.length}</span>
|
||
<span class="wk-group-foot">${focus.overdue.length ? focus.overdue.length + " 条延期" : "已完成 " + focus.done.length + " 条"}</span></div>
|
||
${focusList.length ? focusList.map(focusItem).join("") : `<div class="empty" style="padding:16px 6px;font-size:12px">本周没有安排重点工作</div>`}
|
||
</div>
|
||
${group("自研产品", data.projects, "个已更新", "list-checks")}
|
||
<div class="wk-group">
|
||
<div class="wk-group-head">${Ico.svg("package")}定制项目<span class="cnt">${data.custom.length}</span>
|
||
<span class="wk-group-foot">本周动态 ${cDyn} 个</span></div>
|
||
<div class="wk-item ${openCustom ? "active" : ""}" style="--c:#0d9488" data-wk="customs:0">
|
||
<div class="wk-i-top">
|
||
<span class="wk-i-ic">${Ico.svg("package")}</span>
|
||
<span class="wk-i-name">本周定制项目动态</span>
|
||
<span class="tag-mini">${Ico.svg("arrow-right")}</span>
|
||
</div>
|
||
<div class="wk-i-meta">
|
||
<span class="tag-mini">新增 ${cNew.length}</span>
|
||
<span class="tag-mini">验收 ${cAcc.length}</span>
|
||
<span class="tag-mini">高风险 ${cRisk}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="wk-right card" id="wkRight"><div class="empty">加载中…</div></div>
|
||
</div>`;
|
||
|
||
host.querySelectorAll("[data-wk]").forEach((el) =>
|
||
el.addEventListener("click", () => {
|
||
const parts = el.dataset.wk.split(":");
|
||
state.weeklySel = { kind: parts[0], id: Number(parts[1]) };
|
||
host.querySelectorAll("[data-wk]").forEach((x) => x.classList.toggle("active", x === el));
|
||
renderWeeklyDetail();
|
||
})
|
||
);
|
||
renderWeeklyDetail();
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 每周重点工作 */
|
||
/** 重点工作一律按「周」跟踪,基准周取顶栏当前选中的那一周 */
|
||
function focusWeek() {
|
||
return (state.periods.weeks || []).find((w) => w.id === state.weekId) || null;
|
||
}
|
||
|
||
function focusQuery(extra) {
|
||
const p = extra ? extra.slice() : [];
|
||
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
|
||
return p.length ? "?" + p.join("&") : "";
|
||
}
|
||
|
||
/** 分堆按「录入周」(f.sort_key 就是条目录入的那一周),和预计完成时间无关:
|
||
week = 本周录入的;overdue = 更早周次录入、标了延期又没做完的结转条目。
|
||
「已完成」是交叉视图,已完成条目仍留在自己那一周里,避免录完就看不见。
|
||
更早周次里没标延期的未完成条目不进当周视图,只能在「全部」里查得到。 */
|
||
function focusBuckets(rows) {
|
||
const w = focusWeek();
|
||
const cur = w ? w.sort_key : 0;
|
||
const out = { cur: cur, overdue: [], week: [], done: [] };
|
||
rows.forEach((f) => {
|
||
if (f.sort_key === cur) out.week.push(f);
|
||
else if (f.sort_key < cur && focusIsLate(f)) out.overdue.push(f);
|
||
if (f.status === "done") out.done.push(f);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/** 是否延期:录入时人工打开的开关,不再按日期自动判定 */
|
||
function focusIsLate(f) {
|
||
return f.status !== "done" && !!f.delayed;
|
||
}
|
||
|
||
/** 本地今天(YYYY-MM-DD),新增条目的预计完成时间默认值 */
|
||
function todayIso() {
|
||
const d = new Date();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
/** 预计完成时间:有日历日期就显示日期,早期没日期的条目退回显示录入周 */
|
||
function focusDueText(f) {
|
||
return f.due_date || f.period_full || "—";
|
||
}
|
||
|
||
/** 状态徽章:打开「是否延期」的未完成条目挂一个「延期」 */
|
||
function focusPill(f) {
|
||
return (focusIsLate(f) ? `<span class="pill" style="background:#fdecec;color:#dc2626">延期</span>` : "") +
|
||
statusPill(f.status);
|
||
}
|
||
|
||
/** 工作看板左栏的一条重点工作 */
|
||
function focusItem(f) {
|
||
const sel = state.weeklySel;
|
||
const late = focusIsLate(f);
|
||
return `
|
||
<div class="wk-item ${sel && sel.kind === "focus" && sel.id === f.id ? "active" : ""}"
|
||
style="--c:${late ? "#dc2626" : statusMeta(f.status).color}" data-wk="focus:${f.id}">
|
||
<div class="wk-i-top">
|
||
<span class="wk-i-ic">${Ico.status(f.status)}</span>
|
||
<span class="wk-i-name">${esc(f.content)}</span>
|
||
${late ? `<span class="tag-mini" style="background:#fdecec;color:#dc2626">延期</span>` : ""}
|
||
</div>
|
||
<div class="wk-i-meta">
|
||
${statusPill(f.status)}
|
||
<span class="tag-mini">预计 ${esc(focusDueText(f))}</span>
|
||
${late ? `<span class="tag-mini">录入 ${esc(f.period_label || "—")}</span>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** 工作看板右栏的一条重点工作(只读,改内容走抽屉) */
|
||
function focusDetailHtml(f) {
|
||
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
|
||
return `
|
||
<div class="wk-head" style="--c:${statusMeta(f.status).color}">
|
||
<div class="wk-head-main">
|
||
<div class="wk-head-title">${Ico.statusBadge(f.status)}${esc(f.content)}</div>
|
||
<div class="wk-i-meta">
|
||
${focusPill(f)}
|
||
<span class="tag-mini">预计完成 ${esc(focusDueText(f))}</span>
|
||
</div>
|
||
</div>
|
||
${canW("focus") ? `<div class="wk-head-ops"><button class="btn sm primary" id="wkFocusEdit">修改</button></div>` : ""}
|
||
</div>
|
||
<div class="sec">
|
||
<div class="sec-title">说明</div>
|
||
${f.detail ? nl2br(f.detail) : `<div class="empty">未填写说明</div>`}
|
||
</div>
|
||
<div class="sec">
|
||
<div class="sec-title">结果反馈</div>
|
||
${f.result ? nl2br(f.result) : `<div class="empty">未填写结果反馈</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
async function renderFocus() {
|
||
saveUI();
|
||
const host = $("#view-focus");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
const rows = await api("/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")]));
|
||
state.focusRows = rows;
|
||
const b = focusBuckets(rows);
|
||
const shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || [];
|
||
const pageRows = pagedRows(shown, "focus", [state.keyword, state.focusFilter, state.focusArchived]);
|
||
|
||
const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]];
|
||
const chips = filters.map((x) => `<button class="chip ${state.focusFilter === x[0] ? "active" : ""}" data-ffilter="${x[0]}">${x[1]} ${b[x[0]] !== undefined ? b[x[0]].length : rows.length}</button>`).join("");
|
||
|
||
const table = `<table class="tbl">
|
||
<thead><tr>
|
||
<th style="min-width:220px">工作内容</th><th style="min-width:220px">说明</th>
|
||
<th>预计完成时间</th><th>状态</th><th style="min-width:150px">结果反馈</th><th>更新</th><th class="col-ops">操作</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${pageRows.map((f) => `
|
||
<tr ${f.archived ? 'style="opacity:.62"' : ""}>
|
||
<td><div style="display:flex;align-items:center;gap:6px">
|
||
<a href="javascript:;" data-fopen="${f.id}" style="color:var(--text);text-decoration:none">${esc(f.content)}</a>
|
||
${f.archived ? `<span class="arch-badge">已归档</span>` : ""}
|
||
</div></td>
|
||
<td style="color:var(--muted)">${esc((f.detail || "—").replace(/\s+/g, " ").slice(0, 48))}</td>
|
||
<td class="nowrap">${esc(focusDueText(f))}${f.period_label ? `<div class="period-hint">录入 ${esc(f.period_label)}</div>` : ""}</td>
|
||
<td class="nowrap">${focusPill(f)}</td>
|
||
<td style="color:var(--muted)">${esc((f.result || "—").replace(/\s+/g, " ").slice(0, 40))}</td>
|
||
<td class="nowrap">${esc((f.updated_at || "").slice(0, 10) || "—")}</td>
|
||
<td class="nowrap col-ops">${rowOps({
|
||
edit: `data-fedit="${f.id}"`,
|
||
archive: `data-farch="${f.id}"`,
|
||
archived: f.archived,
|
||
remove: `data-fdel="${f.id}"`,
|
||
})}</td>
|
||
</tr>`).join("")}
|
||
</tbody></table>`;
|
||
|
||
const body = listCard({
|
||
title: "重点工作清单",
|
||
count: rows.length,
|
||
chips: chips,
|
||
fields: `<label class="switch"><input type="checkbox" id="fcArchived" ${state.focusArchived ? "checked" : ""} /> 含归档条目</label>`,
|
||
tools: `${keywordInputHtml("fKeyword", "搜索工作内容…", 180)}
|
||
${canR("focus") ? `<button class="btn sm" id="btnExportFocus">导出 Excel</button>` : ""}
|
||
${canW("focus") ? `<button class="btn sm primary" id="btnAddFocus">${Ico.svg("plus")}新增</button>` : ""}`,
|
||
rows: shown,
|
||
table: table,
|
||
pager: pagerBar("focus", shown.length),
|
||
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);
|
||
})
|
||
);
|
||
bindPager(host, renderFocus);
|
||
const bad = $("#btnAddFocus");
|
||
if (bad) bad.addEventListener("click", () => openFocusDrawer(null, "create"));
|
||
}
|
||
|
||
/** 删除重点工作条目:列表和抽屉共用。条目不是项目,危险确认即可,不要求逐字输入 */
|
||
async function deleteFocus(f) {
|
||
const ok = await confirmDialog({
|
||
title: "删除重点工作条目",
|
||
danger: true,
|
||
okText: "永久删除",
|
||
body: `<div class="notice err">「${esc(f.content)}」将被删除,<b>删除后无法恢复</b>。</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是这一周不再跟进?请改用「归档」。</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await del("/focus/" + f.id);
|
||
} catch (e) { toast("删除失败:" + e.message); return; }
|
||
toast("已删除「" + f.content + "」");
|
||
closeDrawer();
|
||
await afterWrite();
|
||
}
|
||
|
||
/** 重点工作抽屉:新增走单独面板,详情只读、修改才出表单;基础信息的动作一律在面板顶部 */
|
||
async function openFocusDrawer(fid, mode) {
|
||
mode = mode === "create" ? "create" : mode === "edit" ? "edit" : "view";
|
||
if (!canW("focus")) mode = "view";
|
||
$("#drawerMask").classList.add("open");
|
||
$("#drawer").classList.add("open");
|
||
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
|
||
drawerActions("");
|
||
let f = null;
|
||
if (mode === "create") {
|
||
f = { content: "", detail: "", period_id: state.weekId, status: state.meta.statuses[0].code, archived: false };
|
||
} else {
|
||
try {
|
||
const rows = await api("/focus?archived=all");
|
||
f = rows.find((x) => x.id === fid) || null;
|
||
} catch (e) {
|
||
$("#drawerBody").innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
|
||
return;
|
||
}
|
||
if (!f) {
|
||
$("#drawerBody").innerHTML = `<div class="empty">条目不存在,可能已被删除</div>`;
|
||
return;
|
||
}
|
||
}
|
||
// 录入周 = 顶栏当前选中的那一周(新增时定下来,之后不随预计完成时间变)
|
||
const w = focusWeek();
|
||
const entryWeek = mode === "create" ? (w ? w.full_label : "顶栏当前周") : (f.period_full || f.period_label || "—");
|
||
$("#drawerTitle").innerHTML = mode === "create"
|
||
? `<i data-icon="list-checks"></i>新增每周重点工作`
|
||
: `<i data-icon="list-checks"></i>${esc(f.content)}`;
|
||
$("#drawerSub").innerHTML = mode === "create"
|
||
? `一条就是一个活儿,不用先建项目 · 录入周 ${esc(entryWeek)}`
|
||
: `${focusPill(f)} · 录入 ${esc(f.period_full || f.period_label || "—")} · 预计完成 ${esc(focusDueText(f))}`;
|
||
|
||
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
|
||
// 基础信息的动作在标题栏右侧按钮条:危险动作在左,主按钮在最右
|
||
drawerActions(mode === "view" ? `
|
||
<button class="btn sm danger" id="fdDelete">删除</button>
|
||
<button class="btn sm" id="fdArchive">${f.archived ? "恢复" : "归档"}</button>
|
||
<button class="btn sm primary" id="fdEdit">修改</button>` : `
|
||
<button class="btn sm" id="fdCancel">取消</button>
|
||
<button class="btn sm primary" id="fdSave">${mode === "create" ? "添加" : "保存"}</button>`);
|
||
|
||
const form = mode === "view" ? `
|
||
<div class="sec">
|
||
<div class="sec-title">基本信息</div>
|
||
${ro("工作内容", esc(f.content) + (f.archived ? ` <span class="arch-badge">已归档</span>` : ""))}
|
||
${ro("说明", f.detail ? nl2br(f.detail) : "—")}
|
||
${ro("预计完成时间", esc(focusDueText(f)))}
|
||
${ro("录入周", esc(f.period_full || f.period_label || "—"))}
|
||
${ro("执行状态", statusPill(f.status))}
|
||
${ro("是否延期", f.delayed ? `<span class="pill" style="background:#fdecec;color:#dc2626">延期</span>` : "未延期")}
|
||
${ro("结果反馈", f.result ? nl2br(f.result) : "—")}
|
||
${ro("最近更新", esc((f.updated_at || "").replace("T", " ") || "—"))}
|
||
</div>` : `
|
||
<div class="sec">
|
||
<div class="sec-title">基本信息</div>
|
||
<div class="form-row"><label>工作内容 *</label><input class="inp" id="fdContent" value="${esc(f.content)}" placeholder="要干的事,一句话" style="width:100%" /></div>
|
||
<div class="form-row"><label>说明</label><textarea class="inp" id="fdDetail" rows="4" placeholder="补充说明(可选)" style="width:100%">${esc(f.detail || "")}</textarea></div>
|
||
<div class="form-row"><label>录入周</label><div class="ro">${esc(entryWeek)}</div></div>
|
||
<div class="form-row"><label>预计完成时间 *</label><input class="inp" id="fdDue" type="date" value="${esc(f.due_date || todayIso())}" style="width:200px" /></div>
|
||
<div class="form-row"><label>执行状态</label><select class="sel" id="fdStatus" style="width:150px">
|
||
${state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === f.status ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label>结果反馈</label><textarea class="inp" id="fdResult" rows="3" placeholder="做出来的结果 / 交付结论(可选)" style="width:100%">${esc(f.result || "")}</textarea></div>
|
||
<div class="form-row"><label>是否延期</label><label class="switch"><input type="checkbox" id="fdDelayed" ${f.delayed ? "checked" : ""} /> 标记为延期</label></div>
|
||
</div>`;
|
||
$("#drawerBody").innerHTML =
|
||
(mode === "view" ? "" : `<div class="notice err" id="fdErr" style="display:none"></div>`) + form;
|
||
|
||
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
|
||
const after = () => afterWrite();
|
||
|
||
on("#fdEdit", () => openFocusDrawer(fid, "edit"));
|
||
on("#fdCancel", () => (mode === "create" ? closeDrawer() : openFocusDrawer(fid, "view")));
|
||
on("#fdSave", async () => {
|
||
const btn = $("#fdSave");
|
||
const err = $("#fdErr");
|
||
const content = $("#fdContent").value.trim();
|
||
if (!content) { err.textContent = "请填写工作内容"; err.style.display = ""; return; }
|
||
btn.disabled = true;
|
||
err.style.display = "none";
|
||
const due = $("#fdDue").value;
|
||
if (!due) { err.textContent = "请选择预计完成时间"; err.style.display = ""; return; }
|
||
const body = {
|
||
content: content,
|
||
detail: $("#fdDetail").value,
|
||
status: $("#fdStatus").value,
|
||
due_date: due,
|
||
result: $("#fdResult").value,
|
||
delayed: $("#fdDelayed").checked,
|
||
};
|
||
if (mode === "create") body.period_id = state.weekId; // 录入周 = 顶栏当前周
|
||
try {
|
||
if (mode === "create") fid = (await post("/focus", body)).id;
|
||
else await patch("/focus/" + fid, body);
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
err.textContent = "保存失败:" + e.message;
|
||
err.style.display = "";
|
||
return;
|
||
}
|
||
toast(mode === "create" ? "已添加「" + content + "」" : "已保存");
|
||
after();
|
||
openFocusDrawer(fid, "view");
|
||
});
|
||
on("#fdArchive", async () => {
|
||
try {
|
||
await patch("/focus/" + fid, { archived: !f.archived });
|
||
} catch (e) { toast("操作失败:" + e.message); return; }
|
||
toast(f.archived ? "已恢复" : "已归档");
|
||
closeDrawer();
|
||
after();
|
||
});
|
||
on("#fdDelete", () => deleteFocus(f));
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 项目管理 */
|
||
/** 删除项目是高危动作:必须逐字输入项目名才能点「永久删除」。列表与抽屉共用一套。 */
|
||
async function deleteProject(p) {
|
||
const ok = await confirmDialog({
|
||
title: "删除项目",
|
||
danger: true,
|
||
okText: "永久删除",
|
||
requireText: p.name,
|
||
body:
|
||
`<div class="notice err">将连同 <b>${p.task_count}</b> 个子任务、<b>${p.update_count}</b> 条执行记录一并删除,<b>删除后无法恢复</b>。</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const r = await del("/projects/" + p.id);
|
||
toast(`已删除「${r.name}」:${r.deleted_tasks} 个子任务 / ${r.deleted_updates} 条记录`);
|
||
closeDrawer();
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("删除失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
async function renderProjects() {
|
||
saveUI();
|
||
const host = $("#view-projects");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
const extra = ["archived=" + (state.showArchived ? "all" : "false")];
|
||
if (state.categoryId) extra.push("category_id=" + state.categoryId);
|
||
if (state.projOwner) extra.push("owner=" + encodeURIComponent(state.projOwner));
|
||
const rows = await api("/projects" + scopeQuery(extra));
|
||
const shown = state.projIdleOnly ? rows.filter((r) => !r.updated_this_period) : rows;
|
||
const idle = rows.filter((r) => !r.updated_this_period).length;
|
||
const pageRows = pagedRows(shown, "projects",
|
||
[state.keyword, state.showArchived, state.categoryId, state.projOwner, state.projIdleOnly, state.gran]);
|
||
|
||
const ownerFilter = `<select id="pOwner" class="sel" style="width:120px">
|
||
<option value="">全部负责人</option>
|
||
${state.staff.map((s) => `<option value="${esc(s.name)}" ${s.name === state.projOwner ? "selected" : ""}>${esc(s.name)}${s.archived ? "(归档)" : ""}</option>`).join("")}
|
||
</select>`;
|
||
|
||
const chips = `<button class="chip ${state.projIdleOnly ? "active" : ""}" data-pidle="1">只看本${state.gran === "week" ? "周" : "月"}未更新</button>`;
|
||
// 原来挂在左侧菜单栏的分类筛选器,收进卡头,能力不变
|
||
const catFilter = `<select id="pCat" class="sel" style="width:120px" title="分类">
|
||
<option value="">全部分类</option>
|
||
${state.categories.map((c) => `<option value="${c.id}" ${c.id === state.categoryId ? "selected" : ""}>${esc(c.name)}(${c.project_count})</option>`).join("")}
|
||
</select>`;
|
||
|
||
const table = `<table class="tbl">
|
||
<thead><tr>
|
||
<th style="min-width:200px">项目名称</th><th>分类</th><th>负责人</th><th>状态</th>
|
||
<th class="num">进度%</th><th>本${state.gran === "week" ? "周" : "月"}更新</th>
|
||
<th class="num">记录</th><th class="num">子任务</th><th>最近记录</th><th class="col-ops">操作</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${pageRows.map((p) => `
|
||
<tr data-pid="${p.id}" ${p.archived ? 'style="opacity:.62"' : ""}>
|
||
<td><div style="display:flex;align-items:center;gap:6px">
|
||
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
|
||
<a href="javascript:;" data-popen="${p.id}" style="color:var(--text);text-decoration:none">${esc(p.name)}</a>
|
||
${p.archived ? `<span class="arch-badge">已归档</span>` : ""}
|
||
</div></td>
|
||
<td>${catPill(p.category)}</td>
|
||
<td><div class="people-cell">${nameChips(p.owners)}</div></td>
|
||
<td>${statusPill(p.status)}</td>
|
||
<td class="num">${Math.round(p.progress)}%</td>
|
||
<td class="nowrap">${p.updated_this_period
|
||
? `<span class="pill" style="background:#e9f7ef;color:#16a34a">已更新</span>`
|
||
: `<span class="pill" style="background:#fdecec;color:#dc2626">${p.idle_weeks === null ? "从未更新" : p.idle_weeks + " 周未更新"}</span>`}</td>
|
||
<td class="num">${p.update_count}</td>
|
||
<td class="num">${p.task_count}</td>
|
||
<td>${esc((p.last_update && p.last_update.period_full) || "—")}</td>
|
||
<td class="nowrap col-ops">${rowOps({
|
||
edit: `data-pedit="${p.id}"`,
|
||
archive: `data-parch="${p.id}"`,
|
||
archived: p.archived,
|
||
remove: `data-pdel="${p.id}"`,
|
||
})}</td>
|
||
</tr>`).join("")}
|
||
</tbody></table>`;
|
||
|
||
const body = listCard({
|
||
title: "重点项目明细",
|
||
count: rows.length,
|
||
chips: chips,
|
||
fields: `<label class="switch"><input type="checkbox" id="chkArchived" ${state.showArchived ? "checked" : ""} /> 含归档项目</label>`,
|
||
tools: `${keywordInputHtml("pKeyword", "搜索项目名称…", 180)}
|
||
${catFilter}
|
||
${ownerFilter}
|
||
${canR("projects") ? `<button class="btn sm" id="btnExportProjects">导出 Excel</button>` : ""}
|
||
${canW("projects") ? `<button class="btn sm primary" id="btnNewProject">${Ico.svg("plus")}新增</button>` : ""}`,
|
||
rows: shown,
|
||
table: table,
|
||
pager: pagerBar("projects", shown.length),
|
||
empty: state.keyword
|
||
? "没有匹配「" + esc(state.keyword) + "」的项目"
|
||
: "还没有项目",
|
||
});
|
||
|
||
host.innerHTML = body;
|
||
|
||
bindKeyword("pKeyword", { enter: true });
|
||
bindPager(host, renderProjects);
|
||
const pc = $("#pCat");
|
||
if (pc) pc.addEventListener("change", () => {
|
||
state.categoryId = pc.value ? Number(pc.value) : null;
|
||
renderProjects();
|
||
});
|
||
host.querySelectorAll("[data-pidle]").forEach((b) =>
|
||
b.addEventListener("click", () => { state.projIdleOnly = !state.projIdleOnly; renderProjects(); })
|
||
);
|
||
const po = $("#pOwner");
|
||
if (po) po.addEventListener("change", () => { state.projOwner = po.value; renderProjects(); });
|
||
host.querySelectorAll("[data-popen]").forEach((a) =>
|
||
a.addEventListener("click", () => openDrawer(Number(a.dataset.popen), "view"))
|
||
);
|
||
host.querySelectorAll("[data-pedit]").forEach((b) =>
|
||
b.addEventListener("click", () => openDrawer(Number(b.dataset.pedit), "edit"))
|
||
);
|
||
host.querySelectorAll("[data-parch]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const toArchive = b.dataset.archived !== "1";
|
||
await patch("/projects/" + b.dataset.parch, { archived: toArchive });
|
||
toast(toArchive ? "已归档(勾选「含归档项目」可查看)" : "已恢复");
|
||
await afterWrite();
|
||
})
|
||
);
|
||
host.querySelectorAll("[data-pdel]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const p = shown.find((x) => x.id === Number(b.dataset.pdel));
|
||
if (p) deleteProject(p);
|
||
})
|
||
);
|
||
$("#chkArchived").addEventListener("change", (e) => { state.showArchived = e.target.checked; renderProjects(); });
|
||
const bex = $("#btnExportProjects");
|
||
if (bex) bex.addEventListener("click", () => {
|
||
const q = ["archived=" + (state.showArchived ? "all" : "false")];
|
||
if (state.categoryId) q.push("category_id=" + state.categoryId);
|
||
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
|
||
download("/api/export/projects?" + q.join("&"));
|
||
});
|
||
const bn = $("#btnNewProject");
|
||
if (bn) bn.addEventListener("click", () => {
|
||
openModal(
|
||
`<div class="modal-head"><h3>新建项目</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="form-grid">
|
||
<div class="form-row full"><label>项目名称 *</label><input class="inp" id="npName" data-autofocus style="width:100%" /></div>
|
||
<div class="form-row"><label>分类</label><select class="sel" id="npCat" style="width:100%">
|
||
<option value="">(未分类)</option>
|
||
${state.categories.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label>负责人</label>${ownerSelectHtml("", 'id="npOwner" style="width:100%"')}</div>
|
||
<div class="form-row full"><label>重点项目</label><label class="switch"><input type="checkbox" id="npKey" /> 标记为重点(★,进入每周周看板)</label></div>
|
||
</div>
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">创建</button></div>`
|
||
);
|
||
$("#mOk").addEventListener("click", async () => {
|
||
const name = $("#npName").value.trim();
|
||
if (!name) return modalError("请填写项目名称");
|
||
const btn = $("#mOk");
|
||
btn.disabled = true;
|
||
try {
|
||
await post("/projects", {
|
||
name: name,
|
||
category_id: $("#npCat").value ? Number($("#npCat").value) : null,
|
||
owner: $("#npOwner").value || null,
|
||
is_key: $("#npKey").checked,
|
||
});
|
||
closeModal();
|
||
toast("已创建「" + name + "」");
|
||
await afterWrite();
|
||
} catch (e) { btn.disabled = false; modalError(e.message); }
|
||
});
|
||
$("#npName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); });
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 定制项目 */
|
||
function stageMeta(code) {
|
||
const list = (state.customMeta && state.customMeta.stages) || [];
|
||
return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" };
|
||
}
|
||
function stagePill(code) {
|
||
const s = stageMeta(code);
|
||
return `<span class="pill" style="background:${s.color}1a;color:${s.color}">${Ico.stage(code)}${esc(s.name)}</span>`;
|
||
}
|
||
|
||
/* ---- 市场台账(《定开项目.xlsx》)的小色块 ---- */
|
||
const CUSTOM_STATUS_COLOR = {
|
||
已验收: "#16a34a",
|
||
交付完成: "#0891b2",
|
||
进行中: "#2563eb",
|
||
待开发: "#b45309",
|
||
退单: "#7a8394",
|
||
};
|
||
|
||
/** 台账里的项目状态:已验收/交付完成/进行中/待开发/退单,和项目状态机不是一套 */
|
||
function customStatusPill(code) {
|
||
if (!code) return `<span style="color:var(--muted)">—</span>`;
|
||
const color = CUSTOM_STATUS_COLOR[code] || "#7a8394";
|
||
return `<span class="pill" style="background:${color}1a;color:${color}">${esc(code)}</span>`;
|
||
}
|
||
|
||
/** 风险值是否「高风险」(台账取值为 1.高风险 / 2.低风险) */
|
||
const isHighRisk = (p) => String(p.risk_level || "").includes("高");
|
||
|
||
/** 季度下拉:系统里凡按季度维护的字段统一用 Q1~Q4,不再手填文本。
|
||
* 历史数据里的其他写法(如「27年」「--」)保留成选项,避免一改就把原值清掉。 */
|
||
function quarterSelectHtml(id, value) {
|
||
const cur = String(value || "").trim();
|
||
const list = ["Q1", "Q2", "Q3", "Q4"];
|
||
if (cur && list.indexOf(cur) < 0) list.push(cur);
|
||
return `<select class="sel" id="${id}" style="width:100%">` +
|
||
`<option value="">未填</option>` +
|
||
list.map((q) => `<option value="${esc(q)}" ${q === cur ? "selected" : ""}>${esc(q)}</option>`).join("") +
|
||
`</select>`;
|
||
}
|
||
|
||
/** 风险值:1.高风险 红 / 2.低风险 绿 */
|
||
function riskPill(code) {
|
||
if (!code) return `<span style="color:var(--muted)">—</span>`;
|
||
const high = String(code).includes("高");
|
||
const color = high ? "#dc2626" : "#16a34a";
|
||
const text = String(code).replace(/^\d+\./, "");
|
||
return `<span class="pill" style="background:${color}1a;color:${color}">${esc(text)}</span>`;
|
||
}
|
||
|
||
let keywordFocus = false;
|
||
|
||
/** 定制「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */
|
||
const CUSTOM_ORIGINS = ["华智下单", "存量项目", "汇智直签", "提前交付"];
|
||
const CUSTOM_PLACED = ["华智下单", "存量项目", "汇智直签"];
|
||
const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0;
|
||
async function renderCustom() {
|
||
saveUI();
|
||
const host = $("#view-custom");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
if (!state.customMeta) state.customMeta = await api("/custom/meta");
|
||
|
||
// 一次性取「全部定制项目」(不带搜索/筛选条件):
|
||
// 列表在本地按关键字/区域/状态筛选,而统计条始终基于全部数据。
|
||
const allRows = await api("/custom/projects?archived=" + state.customArchived);
|
||
state.customRows = allRows;
|
||
|
||
// 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域
|
||
const usedSet = {};
|
||
(state.customMeta.regions || []).forEach((r) => { usedSet[r] = true; });
|
||
const stdAll = (state.customMeta.region_groups || []).reduce((a, g) => a.concat(g.regions), []);
|
||
let regionFilterHtml = `<option value="">全部区域</option>`;
|
||
(state.customMeta.region_groups || []).forEach((g) => {
|
||
const hit = g.regions.filter((r) => usedSet[r]);
|
||
if (!hit.length) return;
|
||
regionFilterHtml += `<optgroup label="${esc(g.group)}">` +
|
||
hit.map((r) => `<option value="${esc(r)}" ${r === state.customRegion ? "selected" : ""}>${esc(r)}</option>`).join("") +
|
||
`</optgroup>`;
|
||
});
|
||
const nonStd = (state.customMeta.regions || []).filter((r) => stdAll.indexOf(r) < 0);
|
||
if (nonStd.length) {
|
||
regionFilterHtml += `<optgroup label="其他">` +
|
||
nonStd.map((r) => `<option value="${esc(r)}" ${r === state.customRegion ? "selected" : ""}>${esc(r)}</option>`).join("") +
|
||
`</optgroup>`;
|
||
}
|
||
|
||
const STATUS_OPTIONS = ["已验收", "待开发", "交付完成", "进行中", "退单"];
|
||
const statusFilterHtml = `<option value="">全部状态</option>` +
|
||
STATUS_OPTIONS.map((s) => `<option value="${esc(s)}" ${s === state.customStatus ? "selected" : ""}>${esc(s)}</option>`).join("");
|
||
const originFilterHtml = `<option value="">全部归属</option>` +
|
||
CUSTOM_ORIGINS.map((o) => `<option value="${esc(o)}" ${o === state.customOrigin ? "selected" : ""}>${esc(o)}</option>`).join("");
|
||
const RISK_OPTIONS = ["1.高风险", "2.低风险"];
|
||
const riskFilterHtml = `<option value="">全部风险值</option>` +
|
||
RISK_OPTIONS.map((r) => `<option value="${esc(r)}" ${r === state.customRisk ? "selected" : ""}>${esc(r)}</option>`).join("");
|
||
|
||
const isList = state.customTab !== "weekly" && state.customTab !== "annual";
|
||
// 三个 tab 是卡片外的页签,选中项和下方卡片连成一体;header 面板只放筛选器和功能按钮
|
||
const tabs = `<div class="ctabs">` + [["list", "项目总览"], ["weekly", "本周进展"], ["annual", "年度任务"]]
|
||
.map(([k, label]) => `<button class="ctab ${state.customTab === k ? "active" : ""}" data-ctab="${k}">${label}</button>`)
|
||
.join("") + `</div>`;
|
||
const head = {
|
||
tools: isList ? `
|
||
${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)}
|
||
<select id="cOrigin" class="sel" style="width:130px">${originFilterHtml}</select>
|
||
<select id="cRegion" class="sel" style="width:132px">${regionFilterHtml}</select>
|
||
<select id="cStatus" class="sel" style="width:140px">${statusFilterHtml}</select>
|
||
<select id="cRisk" class="sel" style="width:126px">${riskFilterHtml}</select>
|
||
${canR("custom") ? `<button class="btn sm" id="btnExportCustom">导出 Excel</button>` : ""}
|
||
${canW("custom") ? `<button class="btn sm primary" id="btnNewCustom">${Ico.svg("plus")}新增</button>` : ""}` : "",
|
||
};
|
||
|
||
host.innerHTML = tabs + (
|
||
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(); });
|
||
const rk = $("#cRisk");
|
||
if (rk) rk.addEventListener("change", () => { state.customRisk = rk.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.customRisk) q.push("risk=" + encodeURIComponent(state.customRisk));
|
||
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
|
||
download("/api/export/custom?" + q.join("&"));
|
||
});
|
||
bindCustomList(host);
|
||
bindPager(host, renderCustom);
|
||
}
|
||
|
||
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);
|
||
if (state.customRisk) list = list.filter((p) => (p.risk_level || "") === state.customRisk);
|
||
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 pageRows = pagedRows(view, "custom",
|
||
[state.customTab, state.keyword, state.customOrigin, state.customRegion, state.customStatus, state.customRisk, state.customArchived]);
|
||
|
||
// ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ----
|
||
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, accent, icon) =>
|
||
`<div class="kpi ${accent}">${Ico.badge(icon)}<div class="k-label">${label}</div><div class="k-value" style="font-size:22px">${fen(amount)} 万</div><div class="k-foot">${count} 个项目</div></div>`;
|
||
|
||
const statBar = `<div class="grid kpi" style="padding:2px 16px 12px">
|
||
${stat("已下单项目 / 合同额", placed.length, sum(placed, "amount"), "accent-blue", "wallet")}
|
||
${stat(`本季度下单(${Y}年Q${Q})`, signedQuarter.length, sum(signedQuarter, "amount"), "accent-violet", "trending-up")}
|
||
${stat(`本年新增下单(${Y}年)`, signedYear.length, sum(signedYear, "amount"), "accent-violet", "trending-up")}
|
||
${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "accent-amber", "receipt")}
|
||
${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "accent-amber", "receipt")}
|
||
</div>
|
||
`;
|
||
|
||
return `<div class="card">
|
||
${cardHead({ tools: hdr.tools })}
|
||
${statBar}
|
||
${view.length ? customTableHtml(pageRows) : `<div class="empty">${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定制项目" : "当前筛选条件下没有定制项目"}</div>`}
|
||
${pagerBar("custom", view.length)}
|
||
</div>`;
|
||
}
|
||
|
||
/** 定制项目表格(「项目总览」与「本周进展」两个 tab 共用);下单时间 / 订单金额 / 更新时间 表头可排序 */
|
||
function customTableHtml(view, withOps) {
|
||
const ops = withOps !== false;
|
||
const s = state.customSort || { key: "update_date", dir: "desc" };
|
||
const sth = (label, k, cls) =>
|
||
`<th class="${cls ? cls + " " : ""}sortable" data-sort="${k}" style="cursor:pointer;user-select:none" title="点击排序">${label}${s.key === k ? (s.dir === "asc" ? " ▲" : " ▼") : ""}</th>`;
|
||
return `<div class="tbl-wrap">
|
||
<table class="tbl">
|
||
<thead><tr>
|
||
<th style="min-width:200px">项目名称</th>
|
||
<th>项目ID</th>
|
||
${sth("下单时间", "sign_date")}
|
||
<th>项目归属</th>
|
||
<th>办事处</th>
|
||
<th>行业</th>
|
||
<th class="num">市场下单人天</th>
|
||
${sth("订单金额", "amount", "num")}
|
||
<th>市场责任人</th>
|
||
<th>项目状态</th>
|
||
<th class="num">进度</th>
|
||
<th>验收时间</th>
|
||
<th>推动验收计划</th>
|
||
<th>风险值</th>
|
||
${sth("更新时间", "update_date")}
|
||
<th>备注</th>
|
||
${ops ? `<th class="col-ops">操作</th>` : ""}
|
||
</tr></thead>
|
||
<tbody>
|
||
${view.map((p) => `
|
||
<tr data-cid="${p.id}" ${p.archived ? 'style="opacity:.62"' : ""}>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
|
||
<a href="javascript:;" data-copen="${p.id}" style="color:var(--text);text-decoration:none">${esc(p.name)}</a>
|
||
${p.archived ? `<span class="arch-badge">已归档</span>` : ""}
|
||
</div>
|
||
</td>
|
||
<td class="nowrap">${esc(p.project_code || "—")}</td>
|
||
<td class="nowrap">${p.sign_date ? esc(p.sign_date) : "—"}</td>
|
||
<td class="nowrap">${esc(p.origin || "—")}</td>
|
||
<td class="nowrap">${esc(p.office || "—")}</td>
|
||
<td class="nowrap">${esc(p.industry || "—")}</td>
|
||
<td class="num">${p.man_days == null ? "—" : Math.round(p.man_days).toLocaleString()}</td>
|
||
<td class="num">${money(p.amount)}</td>
|
||
<td class="nowrap">${esc(p.market_owner || "—")}</td>
|
||
<td class="nowrap">${customStatusPill(p.project_status)}</td>
|
||
<td class="num">${(p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"}</td>
|
||
<td class="nowrap">${p.accept_date ? esc(p.accept_date) : "—"}</td>
|
||
<td>${esc(p.accept_plan || "—")}</td>
|
||
<td class="nowrap">${riskPill(p.risk_level)}</td>
|
||
<td class="nowrap">${esc(p.update_date || "—")}</td>
|
||
<td>${esc((p.remark || "").slice(0, 40)) || "—"}</td>
|
||
${ops ? `<td class="nowrap col-ops">${rowOps({
|
||
edit: `data-cedit="${p.id}"`,
|
||
archive: `data-carch="${p.id}"`,
|
||
archived: p.archived,
|
||
remove: `data-cdel="${p.id}"`,
|
||
})}</td>` : ""}
|
||
</tr>`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
/** 定制项目「本期」分堆 —— 工作看板右栏与「本周进展」tab 共用同一套口径:
|
||
· 本周新增:下单时间落在本期 ∧(已下单 ∨ 提前交付),和「更新时间」无关
|
||
· 本周验收:更新时间落在本期 ∧ 项目状态为「已验收」
|
||
rows 传 /custom/projects 全量或 /weekly 的行都行,两者都带 sign_date / update_date / origin / project_status。 */
|
||
function customWeeklySections(rows, sc) {
|
||
const inScope = (v) => {
|
||
const s = String(v || "").slice(0, 10);
|
||
return !!(sc && s && s >= sc.start && s <= sc.end);
|
||
};
|
||
const added = rows.filter((r) => inScope(r.sign_date) && (isPlaced(r) || r.origin === "提前交付"));
|
||
const accepted = rows.filter((r) => inScope(r.update_date) && r.project_status === "已验收");
|
||
const inAdded = {}, inAccepted = {};
|
||
added.forEach((r) => { inAdded[r.id] = true; });
|
||
accepted.forEach((r) => { inAccepted[r.id] = true; });
|
||
const seen = {}, listed = [];
|
||
[added, accepted].forEach((list) => list.forEach((r) => {
|
||
if (!seen[r.id]) { seen[r.id] = true; listed.push(r); }
|
||
}));
|
||
return { added, accepted, listed };
|
||
}
|
||
|
||
/** 本周进展:更新时间落在「顶栏选中周期」内的项目;并按 验收/下单/提前交付/进行中 分类统计 */
|
||
function renderCustomWeekly(allRows, hdr) {
|
||
hdr = hdr || {};
|
||
const sc = scopeRange();
|
||
if (!sc) return `<div class="empty">没有取到当前周期的起止日期(刚更新过后端的话,请重启服务后刷新)</div>`;
|
||
const start = sc.start, end = sc.end;
|
||
const dayOf = (v) => String(v || "").slice(0, 10);
|
||
const inScope = (v) => { const s = dayOf(v); return !!s && s >= start && s <= end; };
|
||
const rows = allRows.filter((p) => inScope(p.update_date)); // 更新时间在本期
|
||
|
||
const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0);
|
||
// 新增项目 / 验收情况:与工作看板右栏共用 customWeeklySections,两处口径必须一致
|
||
const { added: newDeal, accepted: acc } = customWeeklySections(allRows, sc);
|
||
// 进行中项目:本期更新的「进行中 + 交付完成」
|
||
const doing = rows.filter((p) => p.project_status === "进行中" || p.project_status === "交付完成");
|
||
|
||
const stat = (label, rows, accent, icon) =>
|
||
`<div class="kpi ${accent}">${Ico.badge(icon)}<div class="k-label">${label}</div><div class="k-value" style="font-size:22px">${fen(sum(rows, "amount"))} 万</div><div class="k-foot">${rows.length} 个项目</div></div>`;
|
||
|
||
const statBar = `<div class="grid kpi" style="padding:2px 16px 12px">
|
||
${stat("验收情况", acc, "accent-amber", "receipt")}
|
||
${stat("新增项目", newDeal, "accent-violet", "trending-up")}
|
||
${stat("进行中项目", doing, "accent-blue", "activity")}
|
||
</div>
|
||
`;
|
||
|
||
return `<div class="card">
|
||
${statBar}
|
||
${rows.length
|
||
? customTableHtml(pagedRows(
|
||
rows.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))),
|
||
"customWeekly", [state.customTab, sc.start, sc.end]), false)
|
||
: `<div class="empty">本期没有更新的定制项目</div>`}
|
||
${pagerBar("customWeekly", rows.length)}
|
||
</div>`;
|
||
}
|
||
|
||
/** 年度任务:季度计收目标(可在页面上改,存后端)+ 按「验收时间 / 下单时间」回填的完成数据 */
|
||
function renderCustomAnnual(allRows, hdr) {
|
||
hdr = hdr || {};
|
||
const meta = annualTargetMeta();
|
||
const Y = meta.year;
|
||
const saved = meta.targets;
|
||
const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; };
|
||
const signQ = (d) => {
|
||
const s = String(d || "");
|
||
if (!/^\d{4}-\d{2}/.test(s) || Number(s.slice(0, 4)) !== Y) return 0;
|
||
return Math.floor((Number(s.slice(5, 7)) - 1) / 3) + 1;
|
||
};
|
||
const sumW = (arr) => arr.reduce((a, p) => a + Number(p.amount || 0), 0) / 10000; // 元 → 万元
|
||
const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||
const rate = (target, acc) => (!target ? "—" : ((acc / target) * 100).toFixed(1) + "%");
|
||
const targetOf = (q) => Number(saved[String(q)] || 0);
|
||
|
||
const rows = [1, 2, 3, 4].map((q) => {
|
||
const acc = allRows.filter((p) => qOf(p.accept_date) === "Q" + q); // 计收:验收项目
|
||
const sign = allRows.filter((p) => isPlaced(p) && signQ(p.sign_date) === q); // 签单:下单项目(已下单)
|
||
const accAmt = sumW(acc);
|
||
return { q, target: targetOf(q), accN: acc.length, accAmt, signN: sign.length, signAmt: sumW(sign) };
|
||
});
|
||
const tot = rows.reduce((a, r) => ({
|
||
target: a.target + r.target, accN: a.accN + r.accN, accAmt: a.accAmt + r.accAmt,
|
||
signN: a.signN + r.signN, signAmt: a.signAmt + r.signAmt,
|
||
}), { target: 0, accN: 0, accAmt: 0, signN: 0, signAmt: 0 });
|
||
|
||
const editing = !!state.customAnnualEdit;
|
||
const tr = (r) => `
|
||
<tr>
|
||
<td class="nowrap">Q${r.q}</td>
|
||
<td class="num">${editing
|
||
? `<input class="inp num-inp" type="number" min="0" step="0.01" data-at="${r.q}" value="${r.target}" ${canW("custom") ? "" : "disabled"} />`
|
||
: fmt(r.target)}</td>
|
||
<td class="num">${r.accN}</td>
|
||
<td class="num">${fmt(r.accAmt)}</td>
|
||
<td class="num" data-ate="${r.q}" data-acc="${r.accAmt}">${rate(r.target, r.accAmt)}</td>
|
||
<td class="num">${r.signN}</td>
|
||
<td class="num">${fmt(r.signAmt)}</td>
|
||
</tr>`;
|
||
|
||
return `<div class="card">
|
||
${cardHead({ tools: canW("custom") ? `<button class="btn sm primary" id="atSave">${editing ? "保存" : "编辑"}</button>` : "" })}
|
||
<div class="tbl-wrap">
|
||
<table class="tbl">
|
||
<thead><tr>
|
||
<th>季度(${Y}年)</th>
|
||
<th class="num">计收目标(万元)</th>
|
||
<th class="num">计收项目数量</th>
|
||
<th class="num">计收项目金额(万元)</th>
|
||
<th class="num">计收完成率</th>
|
||
<th class="num">新增签单项目数量</th>
|
||
<th class="num">新增签单项目数量金额(万元)</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${rows.map(tr).join("")}
|
||
<tr style="font-weight:600">
|
||
<td class="nowrap">合计</td>
|
||
<td class="num" data-att="1">${fmt(tot.target)}</td>
|
||
<td class="num">${tot.accN}</td>
|
||
<td class="num">${fmt(tot.accAmt)}</td>
|
||
<td class="num" data-ate="0" data-acc="${tot.accAmt}">${rate(tot.target, tot.accAmt)}</td>
|
||
<td class="num">${tot.signN}</td>
|
||
<td class="num">${fmt(tot.signAmt)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
</div>`;
|
||
}
|
||
|
||
/** 年度计收目标(万元):后端存一年一套,没配过时返回《年度任务.xlsx》的默认值 */
|
||
function annualTargetMeta() {
|
||
const a = (state.customMeta && state.customMeta.annual_targets) || {};
|
||
return { year: a.year || new Date().getFullYear(), targets: a.targets || {} };
|
||
}
|
||
|
||
/** 年度目标:默认只读,点「编辑」进入编辑模式(按钮变「保存」),改数字实时重算完成率与合计 */
|
||
function bindAnnualTargets(host) {
|
||
const btn = host.querySelector("#atSave");
|
||
if (!btn) return;
|
||
if (!state.customAnnualEdit) {
|
||
btn.addEventListener("click", () => { state.customAnnualEdit = true; renderCustom(); });
|
||
return;
|
||
}
|
||
const meta = annualTargetMeta();
|
||
const save = btn;
|
||
const inputs = Array.prototype.slice.call(host.querySelectorAll("[data-at]"));
|
||
const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||
const valOf = (q) => {
|
||
const el = inputs.filter((i) => i.dataset.at === String(q))[0];
|
||
return el ? Number(el.value) || 0 : 0;
|
||
};
|
||
const rate = (target, acc) => (!target ? "—" : ((acc / target) * 100).toFixed(1) + "%");
|
||
const recalc = () => {
|
||
const total = [1, 2, 3, 4].reduce((a, q) => a + valOf(q), 0);
|
||
host.querySelectorAll("[data-ate]").forEach((cell) => {
|
||
const q = Number(cell.dataset.ate);
|
||
const target = q === 0 ? total : valOf(q);
|
||
cell.textContent = rate(target, Number(cell.dataset.acc) || 0);
|
||
});
|
||
host.querySelector("[data-att]").textContent = fmt(total);
|
||
};
|
||
inputs.forEach((i) => i.addEventListener("input", recalc));
|
||
save.addEventListener("click", async () => {
|
||
const targets = {};
|
||
inputs.forEach((i) => { targets[i.dataset.at] = Number(i.value) || 0; });
|
||
save.disabled = true;
|
||
try {
|
||
state.customMeta.annual_targets = await put("/custom/annual-targets", { year: meta.year, targets: targets });
|
||
} catch (e) {
|
||
save.disabled = false;
|
||
toast("保存失败:" + e.message);
|
||
return;
|
||
}
|
||
state.customAnnualEdit = false;
|
||
toast("已保存年度目标");
|
||
renderCustom();
|
||
});
|
||
}
|
||
|
||
/** 项目总览表:行内「修改 / 归档 / 删除」,名称可点开详情;表头(下单时间 / 订单金额 / 更新时间)可排序 */
|
||
function bindCustomList(host) {
|
||
host.querySelectorAll("[data-copen]").forEach((a) =>
|
||
a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view"))
|
||
);
|
||
host.querySelectorAll("[data-cedit]").forEach((b) =>
|
||
b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit"))
|
||
);
|
||
const findRow = (id) => (state.customRows || []).find((x) => x.id === id);
|
||
host.querySelectorAll("[data-carch]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const p = findRow(Number(b.dataset.carch));
|
||
if (p) archiveCustomProject(p, afterWrite);
|
||
})
|
||
);
|
||
host.querySelectorAll("[data-cdel]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const p = findRow(Number(b.dataset.cdel));
|
||
if (p) deleteCustomProject(p, afterWrite);
|
||
})
|
||
);
|
||
bindAnnualTargets(host);
|
||
host.querySelectorAll("[data-sort]").forEach((th) =>
|
||
th.addEventListener("click", () => {
|
||
const k = th.dataset.sort;
|
||
const cur = state.customSort || { key: "update_date", dir: "desc" };
|
||
state.customSort = { key: k, dir: cur.key === k && cur.dir === "desc" ? "asc" : "desc" };
|
||
renderCustom();
|
||
})
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 新建定制项目 —— 全站唯一的新建入口。
|
||
* 「定制项目」工具栏和「更新录入」页都调用它,避免两套录入逻辑。
|
||
*/
|
||
function openNewCustomDialog(onDone) {
|
||
const today = new Date();
|
||
const iso = today.getFullYear() + "-" +
|
||
String(today.getMonth() + 1).padStart(2, "0") + "-" +
|
||
String(today.getDate()).padStart(2, "0");
|
||
|
||
// 选项直接对齐《定开项目.xlsx》台账字段取值
|
||
const opts = (list, sel) =>
|
||
list.map((v) => `<option value="${esc(v)}" ${v === sel ? "selected" : ""}>${esc(v)}</option>`).join("");
|
||
const originOpts = opts(CUSTOM_ORIGINS, "华智下单");
|
||
const industryOpts = opts(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], "");
|
||
const statusOpts = opts(["待开发", "进行中", "交付完成", "已验收", "退单"], "待开发");
|
||
const riskOpts = opts(["1.高风险", "2.低风险"], "2.低风险");
|
||
const numVal = (sel) => ($(sel).value === "" ? null : Number($(sel).value));
|
||
const txtVal = (sel) => $(sel).value.trim() || null;
|
||
|
||
openModal(
|
||
`<div class="modal-head"><h3>新建定制项目</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="notice info">字段与《定开项目.xlsx》台账一致;<b>项目名称</b>必填,其余可建档后补。区域按「办事处」自动归集。</div>
|
||
<div class="form-grid">
|
||
<div class="form-row"><label>项目名称 *</label><input class="inp" id="ncName" data-autofocus placeholder="如:重庆高新三期" style="width:100%" /></div>
|
||
<div class="form-row"><label>项目归属</label><select class="sel" id="ncOrigin" style="width:100%">${originOpts}</select></div>
|
||
<div class="form-row"><label>项目ID</label><input class="inp" id="ncCode" placeholder="市场编号" style="width:100%" /></div>
|
||
<div class="form-row"><label>下单时间</label><input class="inp" id="ncSign" type="date" value="${iso}" style="width:100%" /></div>
|
||
<div class="form-row"><label>办事处</label><input class="inp" id="ncOffice" placeholder="如:黑龙江" style="width:100%" /></div>
|
||
<div class="form-row"><label>行业</label><select class="sel" id="ncIndustry" style="width:100%">${industryOpts}</select></div>
|
||
<div class="form-row"><label>市场下单人天</label><input class="inp" id="ncManDays" type="number" min="0" step="0.5" style="width:100%" /></div>
|
||
<div class="form-row"><label>订单金额(元)</label><input class="inp" id="ncAmount" type="number" min="0" style="width:100%" /></div>
|
||
<div class="form-row"><label>市场责任人</label><input class="inp" id="ncMarketOwner" placeholder="多人用 / 分隔" style="width:100%" /></div>
|
||
<div class="form-row"><label>项目状态</label><select class="sel" id="ncStatus" style="width:100%">${statusOpts}</select></div>
|
||
<div class="form-row"><label>进度(%)</label><input class="inp" id="ncProgress" type="number" min="0" max="100" step="5" placeholder="进行中项目填 0~100" style="width:100%" /></div>
|
||
<div class="form-row"><label>验收时间</label>${quarterSelectHtml("ncAcceptQuarter", "")}</div>
|
||
<div class="form-row"><label>推动验收计划</label>${quarterSelectHtml("ncAcceptPlan", "")}</div>
|
||
<div class="form-row"><label>风险值</label><select class="sel" id="ncRisk" style="width:100%">${riskOpts}</select></div>
|
||
<div class="form-row"><label>验收日期</label><input class="inp" id="ncAcceptReal" type="date" title="真实验收日期,用于本月/本年验收统计" style="width:100%" /></div>
|
||
<div class="form-row full"><label>备注</label><textarea class="inp" id="ncRemark" rows="2" style="width:100%"></textarea></div>
|
||
<div class="form-row full"><label>重点项目</label><label class="switch"><input type="checkbox" id="ncKey" /> 标记为重点(★,看板置顶)</label></div>
|
||
</div>
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn" data-mclose>取消</button>
|
||
<button class="btn primary" id="mOk">创建</button>
|
||
</div>`
|
||
);
|
||
|
||
const submit = async () => {
|
||
const name = txtVal("#ncName");
|
||
if (!name) return modalError("请填写项目名称");
|
||
const btn = $("#mOk");
|
||
btn.disabled = true;
|
||
try {
|
||
const p = await post("/custom/projects", {
|
||
name: name,
|
||
origin: txtVal("#ncOrigin"),
|
||
project_code: txtVal("#ncCode"),
|
||
sign_date: txtVal("#ncSign"),
|
||
office: txtVal("#ncOffice"),
|
||
industry: txtVal("#ncIndustry"),
|
||
man_days: numVal("#ncManDays"),
|
||
progress: numVal("#ncProgress") == null ? 0 : numVal("#ncProgress"),
|
||
amount: numVal("#ncAmount"),
|
||
market_owner: txtVal("#ncMarketOwner"),
|
||
project_status: txtVal("#ncStatus"),
|
||
accept_date: txtVal("#ncAcceptQuarter"),
|
||
accept_plan: txtVal("#ncAcceptPlan"),
|
||
risk_level: txtVal("#ncRisk"),
|
||
accept_date_real: txtVal("#ncAcceptReal"),
|
||
is_key: $("#ncKey").checked,
|
||
remark: txtVal("#ncRemark"),
|
||
});
|
||
closeModal();
|
||
toast("已创建「" + p.name + "」");
|
||
if (onDone) onDone(p); else refresh();
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
modalError(e.message);
|
||
}
|
||
};
|
||
$("#mOk").addEventListener("click", submit);
|
||
$("#ncName").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||
}
|
||
|
||
/** 归档 / 恢复:归档后不进看板与统计,随时可恢复 */
|
||
async function archiveCustomProject(p, onDone) {
|
||
const toArchive = !p.archived;
|
||
const ok = await confirmDialog({
|
||
title: toArchive ? "归档定制项目" : "恢复定制项目",
|
||
okText: toArchive ? "确认归档" : "确认恢复",
|
||
body:
|
||
`<p style="margin:0 0 10px"><b>${esc(p.name)}</b></p>` +
|
||
`<div class="notice ${toArchive ? "warn" : "info"}">${
|
||
toArchive
|
||
? "归档后该项目不再出现在项目明细与统计中,历史周期记录完整保留。"
|
||
: "恢复后重新计入项目明细与统计。"
|
||
}</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await patch("/custom/projects/" + p.id, { archived: toArchive });
|
||
toast(toArchive ? "已归档" : "已恢复");
|
||
if (onDone) onDone(); else refresh();
|
||
} catch (e) {
|
||
toast("操作失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
/** 删除:连带周期记录一起清除,需逐字输入项目名确认 */
|
||
async function deleteCustomProject(p, onDone) {
|
||
const ok = await confirmDialog({
|
||
title: "删除定制项目",
|
||
danger: true,
|
||
okText: "永久删除",
|
||
requireText: p.name,
|
||
body:
|
||
`<div class="notice err">将连同该项目 <b>${p.update_count || 0}</b> 条周期记录一并删除,<b>删除后无法恢复</b>。</div>
|
||
<ul class="bullet-list">
|
||
<li>阶段:${esc(stageMeta(p.stage).name)} 合同额:${money(p.amount)}</li>
|
||
<li>已计收:${money(p.revenue_amount)} 入库时间:${esc(p.entry_date || "—")}</li>
|
||
</ul>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const r = await del("/custom/projects/" + p.id);
|
||
toast("已删除「" + r.name + "」及其 " + r.deleted_updates + " 条周期记录");
|
||
if (onDone) onDone(); else refresh();
|
||
} catch (e) {
|
||
toast("删除失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
/** cid 定制项目抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
|
||
async function openCustomDrawer(cid, mode) {
|
||
mode = mode === "edit" ? "edit" : "view";
|
||
if (!canW("custom")) mode = "view";
|
||
$("#drawerMask").classList.add("open");
|
||
$("#drawer").classList.add("open");
|
||
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
|
||
drawerActions("");
|
||
const d = await api("/custom/projects/" + cid);
|
||
state.drawerCustom = d;
|
||
const p = d.project;
|
||
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}${p.archived ? ` <span class="arch-badge">已归档</span>` : ""}`;
|
||
const progPct = (p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : null;
|
||
$("#drawerSub").innerHTML = `${stagePill(p.stage)} ${customStatusPill(p.project_status)}` +
|
||
(progPct ? ` <span class="tag-mini">进度 ${progPct}</span>` : "");
|
||
|
||
const timeline = d.timeline.slice().reverse().map((t) => `
|
||
<div class="timeline-row">
|
||
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
|
||
<div style="flex:1">${stagePill(t.stage)}${t.progress !== null ? ` <span class="tag-mini">${t.progress}%</span>` : ""}
|
||
${t.revenue_delta ? ` <span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(t.revenue_delta)}万</span>` : ""}</div>
|
||
</div>
|
||
<div style="font-size:12.5px;color:var(--text-2);line-height:1.7;padding:0 0 8px 120px;white-space:pre-wrap">${nl2br(t.content || "")}</div>`).join("");
|
||
|
||
// 「添加动态」落到顶栏当前选中的那一周,与更新录入页的周期下拉一致
|
||
|
||
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
|
||
const optList = (list, sel) => list.map((v) => `<option value="${esc(v)}" ${v === sel ? "selected" : ""}>${esc(v)}</option>`).join("");
|
||
const originOpts = optList(CUSTOM_ORIGINS, p.origin);
|
||
const industryOpts = optList(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], p.industry);
|
||
const statusOpts = optList(["待开发", "进行中", "交付完成", "已验收", "退单"], p.project_status);
|
||
const riskOpts = optList(["1.高风险", "2.低风险"], p.risk_level);
|
||
|
||
// 市场台账(来源《定开项目.xlsx》):详情只读,修改模式给输入框
|
||
const numOrDash = (v) => (v === null || v === undefined ? "—" : String(Math.round(v)));
|
||
const ledgerSec = `
|
||
<div class="sec">
|
||
<div class="sec-title">项目信息</div>
|
||
${mode === "edit" ? `
|
||
<div class="form-row"><label>项目归属</label><select class="sel" id="cdOrigin" style="width:100%">${originOpts}</select></div>
|
||
<div class="form-row"><label>项目名称 *</label><input class="inp" id="cdName" value="${esc(p.name)}" style="width:100%" /></div>
|
||
<div class="form-row"><label>项目ID</label><input class="inp" id="cdCode" value="${esc(p.project_code || "")}" style="width:100%" /></div>
|
||
<div class="form-row"><label>下单时间</label><input class="inp" id="cdSign" value="${esc(p.sign_date || "")}" style="width:100%" placeholder="YYYY-MM-DD 或季度文本" /></div>
|
||
<div class="form-row"><label>办事处</label><input class="inp" id="cdOffice" value="${esc(p.office || "")}" style="width:100%" /></div>
|
||
<div class="form-row"><label>行业</label><select class="sel" id="cdIndustry" style="width:100%">${industryOpts}</select></div>
|
||
<div class="form-row"><label>市场下单人天</label><input class="inp" id="cdManDays" type="number" min="0" step="0.5" value="${p.man_days == null ? "" : p.man_days}" style="width:100%" /></div>
|
||
<div class="form-row"><label>订单金额(元)</label><input class="inp" id="cdAmount" type="number" min="0" value="${p.amount == null ? "" : p.amount}" style="width:100%" /></div>
|
||
<div class="form-row"><label>市场责任人</label><input class="inp" id="cdMarketOwner" value="${esc(p.market_owner || "")}" style="width:100%" placeholder="多人用 / 分隔" /></div>
|
||
<div class="form-row"><label>项目状态</label><select class="sel" id="cdStatus" style="width:100%">${statusOpts}</select></div>
|
||
<div class="form-row"><label>进度(%)</label><div style="display:flex;align-items:center;gap:6px"><input class="inp" id="cdProgress" type="number" min="0" max="100" step="5" value="${p.progress == null ? "" : Math.round(p.progress)}" style="width:100px" /> %</div></div>
|
||
<div class="form-row"><label>验收时间</label>${quarterSelectHtml("cdAcceptQuarter", p.accept_date)}</div>
|
||
<div class="form-row"><label>推动验收计划</label>${quarterSelectHtml("cdAcceptPlan", p.accept_plan)}</div>
|
||
<div class="form-row"><label>风险值</label><select class="sel" id="cdRisk" style="width:100%">${riskOpts}</select></div>
|
||
<div class="form-row full"><label>验收日期</label><input class="inp" id="cdAcceptReal" type="date" value="${esc(p.accept_date_real || "")}" style="width:100%" title="真实验收日期,用于本月/本年验收统计" /></div>
|
||
<div class="form-row full"><label>备注</label><textarea class="inp" id="cdRemark" rows="2" style="width:100%">${esc(p.remark || "")}</textarea></div>
|
||
<div class="form-row full"><label>更新时间</label><div class="ro">${esc(p.update_date || "—")}<span style="font-size:12px;color:var(--muted);margin-left:10px">跟随「下单时间」</span></div></div>
|
||
` : `
|
||
${ro("项目归属", esc(p.origin || "—"))}
|
||
${ro("项目ID", esc(p.project_code || "—"))}
|
||
${ro("办事处", esc(p.office || p.region || "—"))}
|
||
${ro("行业", esc(p.industry || "—"))}
|
||
${ro("下单时间", esc(p.sign_date || "—"))}
|
||
${ro("市场下单人天", numOrDash(p.man_days))}
|
||
${ro("市场责任人", esc(p.market_owner || "—"))}
|
||
${ro("交付负责人", esc(p.owner || "—"))}
|
||
${ro("团队成员", nameChips(p.members))}
|
||
${ro("客户", esc(p.customer || "—"))}
|
||
${ro("项目状态", customStatusPill(p.project_status))}
|
||
${ro("进度", progPct || "—")}
|
||
${ro("验收时间", esc(p.accept_date || "—"))}
|
||
${ro("推动验收计划", esc(p.accept_plan || "—"))}
|
||
${ro("风险值", riskPill(p.risk_level))}
|
||
${ro("交付环节", `<div style="max-width:100%">${pipelineBar(p, true)}</div>`)}
|
||
${ro("备注", p.remark ? nl2br(p.remark) : "—")}
|
||
${ro("更新时间", esc(p.update_date || "—"))}
|
||
`}
|
||
</div>`;
|
||
|
||
drawerActions(mode === "edit" ? `
|
||
<button class="btn sm" id="cdCancel">取消</button>
|
||
<button class="btn sm primary" id="cdSave">保存</button>` : `
|
||
<button class="btn sm danger" id="cdDelete">删除</button>
|
||
<button class="btn sm" id="cdArchive">${p.archived ? "恢复" : "归档"}</button>
|
||
<button class="btn sm" id="cdKey">${p.is_key ? "取消重点" : "标记重点"}</button>
|
||
<button class="btn sm primary" id="cdEdit">修改</button>`);
|
||
|
||
$("#drawerBody").innerHTML = `
|
||
${mode === "edit" ? `<div class="notice err" id="cdErr" style="display:none"></div>` : ""}
|
||
${ledgerSec}
|
||
${mode === "view" ? `
|
||
<div class="sec">
|
||
<div class="sec-title">收入确认</div>
|
||
<div class="grid kpi" style="gap:10px">
|
||
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">合同额</div><div class="k-value" style="font-size:19px">${wan(p.amount)}</div></div>
|
||
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">可确认收入</div><div class="k-value" style="font-size:19px">${wan(p.confirmed_amount)}</div><div class="k-foot">${p.manual_ratio == null ? "按阶段" : "手动"} ${p.revenue_ratio}%</div></div>
|
||
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">已计收</div><div class="k-value" style="font-size:19px">${wan(p.revenue_amount)}</div></div>
|
||
<div class="kpi">${Ico.badge("hourglass")}<div class="k-label">待计收</div><div class="k-value" style="font-size:19px">${wan(p.pending_amount)}</div></div>
|
||
</div>
|
||
<div class="grid kpi" style="gap:10px;margin-top:10px">
|
||
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">已开票</div><div class="k-value" style="font-size:19px">${wan(p.invoice_amount)}</div><div class="k-foot">待开票 ${wan(p.pending_invoice)}</div></div>
|
||
<div class="kpi">${Ico.badge("trending-up")}<div class="k-label">已回款</div><div class="k-value" style="font-size:19px">${wan(p.received_amount)}</div><div class="k-foot">待回款 ${wan(p.pending_received)}</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">交付记录(${d.updates.length})</div>
|
||
<div class="sec-actions upd">
|
||
<button class="btn sm primary" id="cdAddUpd">${Ico.svg("plus")}添加交付记录</button>
|
||
<button class="btn sm ghost" id="cdIntake">${Ico.svg("sparkles")}AI 录入</button>
|
||
</div>
|
||
${d.updates.length ? d.updates.map((u) => updateItem(u, "custom")).join("") : `<div class="empty">暂无交付记录</div>`}
|
||
</div>
|
||
|
||
${d.timeline.length ? `<div class="sec"><div class="sec-title">阶段流转</div>${timeline}</div>` : ""}
|
||
` : ""}`;
|
||
|
||
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
|
||
|
||
on("#cdEdit", () => openCustomDrawer(cid, "edit"));
|
||
on("#cdAddUpd", () =>
|
||
openUpdateDialog({ kind: "custom", periodId: state.weekId, customProjectId: cid, stage: p.stage, onDone: () => openCustomDrawer(cid, "view") }));
|
||
on("#cdIntake", () => {
|
||
state.intakeType = "custom";
|
||
state.intakePeriodId = state.weekId;
|
||
customIntakeId = cid;
|
||
closeDrawer();
|
||
setView("intake");
|
||
});
|
||
bindUpdateOps($("#drawerBody"), d.updates, () => openCustomDrawer(cid, "view"));
|
||
on("#cdCancel", () => openCustomDrawer(cid, "view"));
|
||
on("#cdSave", async () => {
|
||
const btn = $("#cdSave");
|
||
const err = $("#cdErr");
|
||
btn.disabled = true;
|
||
err.style.display = "none";
|
||
try {
|
||
await patch("/custom/projects/" + cid, {
|
||
name: $("#cdName").value.trim(),
|
||
origin: $("#cdOrigin").value.trim() || null,
|
||
project_code: $("#cdCode").value.trim() || null,
|
||
sign_date: $("#cdSign").value || null,
|
||
office: $("#cdOffice").value.trim() || null,
|
||
industry: $("#cdIndustry").value.trim() || null,
|
||
man_days: $("#cdManDays").value === "" ? null : Number($("#cdManDays").value),
|
||
amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value),
|
||
market_owner: $("#cdMarketOwner").value.trim() || null,
|
||
project_status: $("#cdStatus").value.trim() || null,
|
||
progress: $("#cdProgress").value === "" ? 0 : Number($("#cdProgress").value),
|
||
accept_date: $("#cdAcceptQuarter").value.trim() || null,
|
||
accept_plan: $("#cdAcceptPlan").value.trim() || null,
|
||
risk_level: $("#cdRisk").value.trim() || null,
|
||
accept_date_real: $("#cdAcceptReal").value || null,
|
||
remark: $("#cdRemark").value,
|
||
});
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
err.textContent = "保存失败:" + e.message;
|
||
err.style.display = "";
|
||
return;
|
||
}
|
||
toast("已保存");
|
||
await afterWrite();
|
||
openCustomDrawer(cid, "view");
|
||
});
|
||
on("#cdKey", async () => {
|
||
await patch("/custom/projects/" + cid, { is_key: !p.is_key });
|
||
await afterWrite();
|
||
openCustomDrawer(cid, mode);
|
||
});
|
||
on("#cdArchive", () => archiveCustomProject(p, () => { closeDrawer(); afterWrite(); }));
|
||
on("#cdDelete", () => deleteCustomProject(p, () => { closeDrawer(); afterWrite(); }));
|
||
}
|
||
|
||
/* ------------------------------------------------ 录入方式(两类项目共用) */
|
||
/** 写入方式:直接写入 / AI 整理 二选一(单选),下面只出选中的那一个按钮 */
|
||
function intakeActionHtml(o) {
|
||
const mode = intakeMode();
|
||
return `
|
||
<div class="step" style="margin:16px 0 10px"><span class="step-no">3</span><span class="step-title">选择写入方式</span></div>
|
||
<div class="radio-row" id="intakeModeSeg">
|
||
<label class="radio"><input type="radio" name="intakeMode" value="direct"${mode === "direct" ? " checked" : ""} /><b>直接写入</b><span class="radio-tip">原文一字不改,只存 1 条</span></label>
|
||
<label class="radio"><input type="radio" name="intakeMode" value="ai"${mode === "ai" ? " checked" : ""} /><b>AI 整理</b><span class="radio-tip">AI 补摘要 / 进度 / 状态 / 风险 / 下一步,正文仍是原文,也只存 1 条</span></label>
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
|
||
${mode === "ai"
|
||
? `<button class="btn primary" id="${o.parseBtn}">AI 整理</button>`
|
||
: `<button class="btn primary" id="${o.directBtn}">直接写入</button>`}
|
||
<button class="btn" id="${o.clearBtn}">清空</button>
|
||
<span id="${o.modeText}" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>`;
|
||
}
|
||
|
||
function intakeMode() {
|
||
return state.intakeMode === "ai" ? "ai" : "direct";
|
||
}
|
||
|
||
/** 切换录入方式(单选):保留已填的原文,重画当前录入页 */
|
||
function bindIntakeMode(rawSel, rerender) {
|
||
document.querySelectorAll('#intakeModeSeg input[name="intakeMode"]').forEach((b) =>
|
||
b.addEventListener("change", () => {
|
||
if (intakeMode() === b.value) return;
|
||
const keep = $(rawSel) ? $(rawSel).value : "";
|
||
state.intakeMode = b.value;
|
||
Promise.resolve(rerender()).then(() => { const el = $(rawSel); if (el) el.value = keep; });
|
||
})
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------ 定制项目录入 */
|
||
async function renderCustomIntake(host) {
|
||
const projects = await api("/custom/projects");
|
||
const weeks = state.periods.weeks.slice().reverse();
|
||
const curPeriodId =
|
||
state.gran === "week"
|
||
? state.weekId
|
||
: currentMonth()
|
||
? currentMonth().period_ids[currentMonth().period_ids.length - 1]
|
||
: null;
|
||
|
||
host.innerHTML = `
|
||
<div class="seg" id="intakeTypeSeg" style="margin-bottom:14px">
|
||
<button class="seg-btn ${state.intakeType === "general" ? "active" : ""}" data-itype="general">自研产品</button>
|
||
<button class="seg-btn ${state.intakeType === "custom" ? "active" : ""}" data-itype="custom">定制项目</button>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>定制项目 · 交付记录录入</h4>
|
||
<button class="btn sm" id="btnNewCustomIntake">${Ico.svg("plus")}新增</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="step" style="margin-bottom:12px"><span class="step-no">1</span><span class="step-title">选择定制项目与周期</span></div>
|
||
<div class="inline-fields" style="margin-bottom:20px">
|
||
<input id="cInSearch" class="inp" placeholder="搜索项目名称…" style="width:200px" />
|
||
<select id="cInProject" class="sel" style="width:320px">
|
||
${projects.map((p) => `<option value="${p.id}" ${p.id === customIntakeId ? "selected" : ""}>${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")}
|
||
</select>
|
||
<select id="cInPeriod" class="sel" style="width:200px">
|
||
${weeks.map((w) => `<option value="${w.id}" ${w.id === (state.intakePeriodId || curPeriodId) ? "selected" : ""}>${esc(w.full_label)}</option>`).join("")}
|
||
<option value="__new">+ 新建周期…</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="step" style="margin-bottom:10px"><span class="step-no">2</span><span class="step-title">粘贴该项目的原始内容</span></div>
|
||
<textarea id="cInRaw" class="inp big-textarea" placeholder="例如: 重庆高新三期:合同已签订,金额 100 万,开发交付阶段,本周完成集群部署,进度 60% 风险:客户机房断电,联调推迟 下一步:下周完成灰度上线,推动 30% 预付款计收"></textarea>
|
||
|
||
${intakeActionHtml({ parseBtn: "btnCParse", directBtn: "btnCDirect", clearBtn: "btnCClear", modeText: "cInMode" })}
|
||
<div id="cInPreview"></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
const sel = $("#cInProject");
|
||
host.querySelectorAll("[data-itype]").forEach((b) =>
|
||
b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); })
|
||
);
|
||
sel.addEventListener("change", () => { customIntakeId = Number(sel.value); });
|
||
$("#btnNewCustomIntake").addEventListener("click", () =>
|
||
openNewCustomDialog((p) => { customIntakeId = p.id; renderCustomIntake(host); })
|
||
);
|
||
$("#cInPeriod").addEventListener("change", async (e) => {
|
||
if (e.target.value === "__new") {
|
||
await createNewPeriod();
|
||
await renderIntake();
|
||
return;
|
||
}
|
||
state.intakePeriodId = Number(e.target.value);
|
||
});
|
||
$("#cInSearch").addEventListener("input", (e) => {
|
||
const kw = e.target.value.trim();
|
||
sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw))
|
||
.map((p) => `<option value="${p.id}">${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("");
|
||
});
|
||
bindIntakeMode("#cInRaw", () => renderCustomIntake(host));
|
||
const on = (sel2, fn) => { const el = $(sel2); if (el) el.addEventListener("click", fn); };
|
||
on("#btnCClear", () => {
|
||
$("#cInRaw").value = ""; $("#cInPreview").innerHTML = ""; $("#cInMode").textContent = "";
|
||
});
|
||
on("#btnCParse", () => doCustomParse(true));
|
||
on("#btnCDirect", commitCustomDirect);
|
||
}
|
||
|
||
/** 定制项目 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */
|
||
async function commitCustomDirect() {
|
||
const raw = $("#cInRaw").value.trim();
|
||
if (!raw) return toast("请先填写内容");
|
||
const id = Number($("#cInProject").value);
|
||
if (!id) return toast("请选择定制项目");
|
||
const pid = Number($("#cInPeriod").value);
|
||
if (!pid) return toast("请选择周期");
|
||
const btn = $("#btnCDirect");
|
||
btn.disabled = true;
|
||
try {
|
||
await post("/custom/updates", {
|
||
custom_project_id: id,
|
||
period_id: pid,
|
||
content: raw,
|
||
source: "manual",
|
||
});
|
||
$("#cInRaw").value = "";
|
||
$("#cInPreview").innerHTML = `<div class="notice info">写入成功:已按原文存入 <b>1</b> 条执行记录,未拆解、未改写。</div>`;
|
||
toast("已写入 1 条执行记录");
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("写入失败:" + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
let customIntakeId = null;
|
||
let customIntakeParsed = null;
|
||
let customIntakeRaw = "";
|
||
|
||
async function doCustomParse(useAI) {
|
||
const raw = $("#cInRaw").value.trim();
|
||
if (!raw) return toast("请先粘贴内容");
|
||
const sel = $("#cInProject");
|
||
const id = Number(sel.value);
|
||
if (!id) return toast("请选择定制项目");
|
||
|
||
const btn = $("#btnCParse");
|
||
const old = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<span class="spinner"></span> 整理中…`;
|
||
try {
|
||
const r = await post("/custom/ai/parse", {
|
||
custom_project_id: id,
|
||
project_name: null,
|
||
period_id: Number($("#cInPeriod").value),
|
||
raw_text: raw,
|
||
use_ai: useAI,
|
||
});
|
||
customIntakeId = r.project ? r.project.id : customIntakeId;
|
||
customIntakeParsed = r.parsed;
|
||
customIntakeRaw = raw;
|
||
renderCustomPreview(r);
|
||
} catch (e) {
|
||
$("#cInPreview").innerHTML = `<div class="notice err">整理失败:${esc(e.message)}</div>`;
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = old;
|
||
}
|
||
}
|
||
|
||
function renderCustomPreview(r) {
|
||
const p = r.parsed;
|
||
const stageOpts = state.customMeta.stages
|
||
.map((s) => `<option value="${s.code}" ${s.code === p.stage ? "selected" : ""}>${esc(s.name)}(${s.base_ratio}%)</option>`)
|
||
.join("");
|
||
$("#cInMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "未调用大模型,已按内置规则整理";
|
||
|
||
$("#cInPreview").innerHTML = `
|
||
${r.warning ? `<div class="notice warn">${esc(r.warning)}</div>` : ""}
|
||
${r.error ? `<div class="notice err">AI 调用异常:${esc(r.error)}</div>` : ""}
|
||
<div class="sec-title">整理结果 · 1 条记录(可直接修改后写入)</div>
|
||
<div class="preview-box">
|
||
<div class="kv"><label>项目</label><div><b>${esc(r.project ? r.project.name : "(新建)")}</b> · ${esc(r.period ? r.period.full_label : "")}</div></div>
|
||
<div class="kv"><label>执行内容(正文,原文照录)</label><textarea class="inp" id="cPvContent" rows="5" style="width:100%">${esc(customIntakeRaw)}</textarea></div>
|
||
<div class="kv"><label>商务阶段</label><select class="sel" id="cPvStage">${stageOpts}</select></div>
|
||
<div class="kv"><label>开发进度</label><input class="inp" id="cPvProgress" type="number" min="0" max="100" value="${p.progress === null || p.progress === undefined ? "" : p.progress}" style="width:100px" /></div>
|
||
<div class="kv"><label>合同额</label><input class="inp" id="cPvAmount" type="number" value="${p.amount === null || p.amount === undefined ? "" : p.amount}" style="width:160px" /></div>
|
||
<div class="kv"><label>累计已计收</label><input class="inp" id="cPvRevenue" type="number" value="${p.revenue_amount === null || p.revenue_amount === undefined ? "" : p.revenue_amount}" style="width:160px" /></div>
|
||
<div class="kv"><label>本期新增计收</label><input class="inp" id="cPvDelta" type="number" value="${p.revenue_delta === null || p.revenue_delta === undefined ? "" : p.revenue_delta}" style="width:160px" /></div>
|
||
<div class="kv"><label>摘要</label><input class="inp" id="cPvSummary" value="${esc(p.summary || "")}" style="width:100%" /></div>
|
||
<div class="kv"><label>风险</label><textarea class="inp" id="cPvRisks" rows="2" style="width:100%">${esc((p.risks || []).join("\n"))}</textarea></div>
|
||
<div class="kv"><label>下一步</label><textarea class="inp" id="cPvNext" rows="2" style="width:100%">${esc((p.next_steps || []).join("\n"))}</textarea></div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin-top:14px">
|
||
<button class="btn primary" id="btnCCommit">确认写入这 1 条</button>
|
||
<span style="font-size:12px;color:var(--muted);align-self:center">正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定制项目」</span>
|
||
</div>`;
|
||
|
||
$("#btnCCommit").addEventListener("click", commitCustomIntake);
|
||
}
|
||
|
||
function collectCustomPreview() {
|
||
const num = (id) => { const v = $(id).value.trim(); return v === "" ? null : Number(v); };
|
||
const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean);
|
||
return {
|
||
stage: $("#cPvStage").value,
|
||
progress: num("#cPvProgress"),
|
||
amount: num("#cPvAmount"),
|
||
revenue_amount: num("#cPvRevenue"),
|
||
revenue_delta: num("#cPvDelta"),
|
||
summary: $("#cPvSummary").value.trim(),
|
||
risks: lines("#cPvRisks"),
|
||
next_steps: lines("#cPvNext"),
|
||
updates: [], // AI 整理只落 1 条:正文走 raw_text
|
||
};
|
||
}
|
||
|
||
async function commitCustomIntake() {
|
||
const sel = $("#cInProject");
|
||
const btn = $("#btnCCommit");
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await post("/custom/ai/commit", {
|
||
custom_project_id: Number(sel.value),
|
||
project_name: null,
|
||
period_id: Number($("#cInPeriod").value),
|
||
raw_text: ($("#cPvContent").value || "").trim() || customIntakeRaw,
|
||
parsed: collectCustomPreview(),
|
||
mode: customIntakeParsed && customIntakeParsed.model ? "ai" : "rule",
|
||
model: (customIntakeParsed && customIntakeParsed.model) || null,
|
||
});
|
||
toast(`已写入 ${res.created.length} 条记录`);
|
||
$("#cInPreview").innerHTML = `<div class="notice info">写入成功:「${esc(res.project.name)}」阶段 <b>${esc(stageMeta(res.project.stage).name)}</b>,可确认收入 ${wan(res.project.confirmed_amount)}。</div>`;
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("写入失败:" + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 更新录入 */
|
||
let intakeProjectId = null;
|
||
let intakeParsed = null;
|
||
|
||
async function renderIntake() {
|
||
const host = $("#view-intake");
|
||
// 录入页整个页面都是写操作,只读角色没有可用的部分,直接给说明
|
||
if (!canW("intake")) {
|
||
host.innerHTML = `<div class="notice warn">「更新录入」是写操作,当前角色是只读权限,无法录入内容。</div>`;
|
||
return;
|
||
}
|
||
if (!state.customMeta) state.customMeta = await api("/custom/meta");
|
||
if (state.intakeType === "custom") return renderCustomIntake(host);
|
||
|
||
const projects = await api("/projects?archived=false");
|
||
const weeks = state.periods.weeks.slice().reverse();
|
||
const curPeriodId = state.gran === "week" ? state.weekId : (currentMonth() ? currentMonth().period_ids[currentMonth().period_ids.length - 1] : null);
|
||
|
||
host.innerHTML = `
|
||
<div class="seg" id="intakeTypeSeg" style="margin-bottom:14px">
|
||
<button class="seg-btn ${state.intakeType === "general" ? "active" : ""}" data-itype="general">自研产品</button>
|
||
<button class="seg-btn ${state.intakeType === "custom" ? "active" : ""}" data-itype="custom">定制项目</button>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>自研产品 · 执行记录录入</h4></div>
|
||
<div class="card-body">
|
||
<div class="step" style="margin-bottom:12px"><span class="step-no">1</span><span class="step-title">选择项目与周期</span></div>
|
||
<div class="inline-fields" style="margin-bottom:20px">
|
||
<input id="intakeProjectSearch" class="inp" placeholder="搜索项目…" style="width:200px" />
|
||
<select id="intakeProject" class="sel" style="width:300px">
|
||
${projects.map((p) => `<option value="${p.id}" ${p.id === intakeProjectId ? "selected" : ""}>${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")}
|
||
<option value="__new">+ 新建项目(按下方名称)</option>
|
||
</select>
|
||
<input id="intakeNewProject" class="inp" placeholder="新项目名称" style="width:220px;display:none" />
|
||
<select id="intakePeriod" class="sel" style="width:200px">
|
||
${weeks.map((w) => `<option value="${w.id}" ${w.id === (state.intakePeriodId || curPeriodId) ? "selected" : ""}>${esc(w.full_label)}</option>`).join("")}
|
||
<option value="__new">+ 新建周期…</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="step" style="margin-bottom:10px"><span class="step-no">2</span><span class="step-title">粘贴该项目的原始内容</span></div>
|
||
<textarea id="intakeRaw" class="inp big-textarea" placeholder="把周报 / 项目群消息 / 会议纪要原文粘进来,例如: 1、授权卸载管理已完成开发和联调,可以转测 2、集群部署,进度50% 3、国产化操作系统切换,进度50%;风险:license 未到位"></textarea>
|
||
|
||
${intakeActionHtml({ parseBtn: "btnAIParse", directBtn: "btnDirectWrite", clearBtn: "btnClearIntake", modeText: "intakeMode" })}
|
||
<div id="intakePreview"></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
host.querySelectorAll("[data-itype]").forEach((b) =>
|
||
b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); })
|
||
);
|
||
bindIntakeMode("#intakeRaw", renderIntake);
|
||
|
||
const sel = $("#intakeProject");
|
||
sel.addEventListener("change", () => {
|
||
if (sel.value !== "__new") intakeProjectId = Number(sel.value);
|
||
$("#intakeNewProject").style.display = sel.value === "__new" ? "" : "none";
|
||
});
|
||
$("#intakePeriod").addEventListener("change", async (e) => {
|
||
if (e.target.value === "__new") {
|
||
await createNewPeriod();
|
||
await renderIntake();
|
||
return;
|
||
}
|
||
state.intakePeriodId = Number(e.target.value);
|
||
});
|
||
$("#intakeProjectSearch").addEventListener("input", (e) => {
|
||
const kw = e.target.value.trim();
|
||
sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw))
|
||
.map((p) => `<option value="${p.id}">${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")
|
||
+ `<option value="__new">+ 新建项目(按下方名称)</option>`;
|
||
});
|
||
$("#btnClearIntake").addEventListener("click", () => {
|
||
$("#intakeRaw").value = ""; $("#intakePreview").innerHTML = ""; $("#intakeMode").textContent = "";
|
||
});
|
||
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
|
||
on("#btnAIParse", () => doParse(true));
|
||
on("#btnDirectWrite", commitDirectWrite);
|
||
}
|
||
|
||
/**
|
||
* 自研产品 · 直接写入:填入的内容一字不改存成 1 条执行记录。
|
||
* 不调大模型、不建子任务、不写 progress,项目的进度和状态一律不动。
|
||
*/
|
||
async function commitDirectWrite() {
|
||
const raw = $("#intakeRaw").value.trim();
|
||
if (!raw) return toast("请先填写内容");
|
||
const sel = $("#intakeProject");
|
||
const btn = $("#btnDirectWrite");
|
||
let projectId = sel.value === "__new" ? null : Number(sel.value);
|
||
btn.disabled = true;
|
||
try {
|
||
if (!projectId) {
|
||
const name = $("#intakeNewProject").value.trim();
|
||
if (!name) { toast("请填写项目名称"); return; }
|
||
const created = await post("/projects", { name });
|
||
projectId = created.id;
|
||
intakeProjectId = created.id;
|
||
sel.insertAdjacentHTML("beforeend", `<option value="${created.id}" selected>${esc(created.name)}</option>`);
|
||
$("#intakeNewProject").style.display = "none";
|
||
}
|
||
await post("/updates", {
|
||
project_id: projectId,
|
||
period_id: Number($("#intakePeriod").value),
|
||
content: raw,
|
||
source: "manual",
|
||
});
|
||
$("#intakeRaw").value = "";
|
||
$("#intakePreview").innerHTML = `<div class="notice info">写入成功:已按原文存入 <b>1</b> 条执行记录,未拆解、未改写。</div>`;
|
||
toast("已写入 1 条执行记录");
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("写入失败:" + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function doParse(useAI) {
|
||
const raw = $("#intakeRaw").value.trim();
|
||
if (!raw) return toast("请先粘贴内容");
|
||
const sel = $("#intakeProject");
|
||
const projectId = sel.value === "__new" ? null : Number(sel.value);
|
||
const projectName = sel.value === "__new" ? $("#intakeNewProject").value.trim() : null;
|
||
if (!projectId && !projectName) return toast("请选择或填写项目名称");
|
||
|
||
const btn = $("#btnAIParse");
|
||
const old = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<span class="spinner"></span> 整理中…`;
|
||
try {
|
||
const r = await post("/ai/parse", {
|
||
project_id: projectId, project_name: projectName,
|
||
period_id: Number($("#intakePeriod").value),
|
||
raw_text: raw, use_ai: useAI,
|
||
});
|
||
intakeProjectId = r.project ? r.project.id : intakeProjectId;
|
||
intakeParsed = r.parsed;
|
||
renderIntakePreview(r, raw);
|
||
} catch (e) {
|
||
$("#intakePreview").innerHTML = `<div class="notice err">整理失败:${esc(e.message)}</div>`;
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = old;
|
||
}
|
||
}
|
||
|
||
function renderIntakePreview(r, raw) {
|
||
const p = r.parsed;
|
||
const statusOpts = state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === p.status ? "selected" : ""}>${esc(s.name)}</option>`).join("");
|
||
$("#intakeMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "未调用大模型,已按内置规则整理";
|
||
|
||
$("#intakePreview").innerHTML = `
|
||
${r.warning ? `<div class="notice warn">${esc(r.warning)}</div>` : ""}
|
||
${r.error ? `<div class="notice err">AI 调用异常:${esc(r.error)}</div>` : ""}
|
||
<div class="sec-title">整理结果 · 1 条记录(可直接修改后写入)</div>
|
||
<div class="preview-box">
|
||
<div class="kv"><label>项目</label><div><b>${esc(r.project ? r.project.name : "")}</b> · ${esc(r.period ? r.period.full_label : "")}</div></div>
|
||
<div class="kv"><label>执行内容(正文,原文照录)</label><textarea class="inp" id="pvContent" rows="5" style="width:100%">${esc(raw)}</textarea></div>
|
||
<div class="kv"><label>一句话摘要</label><input class="inp" id="pvSummary" value="${esc(p.summary || "")}" style="width:100%" /></div>
|
||
<div class="kv"><label>总进度 %</label><input class="inp" id="pvProgress" type="number" min="0" max="100" value="${p.progress === null || p.progress === undefined ? "" : p.progress}" style="width:100px" /></div>
|
||
<div class="kv"><label>整体状态</label><select class="sel" id="pvStatus">${statusOpts}</select></div>
|
||
<div class="kv"><label>风险 / 阻塞</label><textarea class="inp" id="pvRisks" rows="2" style="width:100%">${esc((p.risks || []).join("\n"))}</textarea></div>
|
||
<div class="kv"><label>下一步计划</label><textarea class="inp" id="pvNext" rows="2" style="width:100%">${esc((p.next_steps || []).join("\n"))}</textarea></div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin-top:14px">
|
||
<button class="btn primary" id="btnCommit">确认写入这 1 条</button>
|
||
<span style="font-size:12px;color:var(--muted);align-self:center">正文用你的原文,AI 只补摘要 / 进度 / 状态 / 风险 / 下一步;写入后可在「工作看板」中立即查看</span>
|
||
</div>`;
|
||
|
||
$("#btnCommit").addEventListener("click", () => commitIntake(r, raw));
|
||
}
|
||
|
||
/** 收集预览表单。tasks 恒为空 → 后端按「1 条执行记录」写入,不建子任务 */
|
||
function collectPreview() {
|
||
const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean);
|
||
const pg = $("#pvProgress").value;
|
||
return {
|
||
summary: $("#pvSummary").value.trim(),
|
||
progress: pg === "" ? null : Number(pg),
|
||
status: $("#pvStatus").value,
|
||
risks: lines("#pvRisks"),
|
||
next_steps: lines("#pvNext"),
|
||
tasks: [],
|
||
};
|
||
}
|
||
|
||
async function commitIntake(r, raw) {
|
||
const sel = $("#intakeProject");
|
||
const btn = $("#btnCommit");
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await post("/ai/commit", {
|
||
project_id: sel.value === "__new" ? null : Number(sel.value),
|
||
project_name: sel.value === "__new" ? $("#intakeNewProject").value.trim() : null,
|
||
period_id: Number($("#intakePeriod").value),
|
||
raw_text: ($("#pvContent").value || "").trim() || raw,
|
||
parsed: collectPreview(),
|
||
mode: r.mode,
|
||
model: r.parsed.model || null,
|
||
});
|
||
toast(`已写入 ${res.created.length} 条执行记录`);
|
||
$("#intakePreview").innerHTML = `<div class="notice info">写入成功:项目「${esc(res.project.name)}」新增 ${res.created.length} 条记录。可切换到「工作看板」查看。</div>`;
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("写入失败:" + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- settings */
|
||
async function renderSettings() {
|
||
const host = $("#view-settings");
|
||
const [s, health, periods] = await Promise.all([
|
||
api("/settings"),
|
||
fetch("/health").then((r) => r.json()),
|
||
api("/periods"),
|
||
]);
|
||
const weeks = periods.weeks.slice().reverse();
|
||
const rw = canW("settings");
|
||
host.innerHTML = `
|
||
<div class="grid g2">
|
||
<div class="card">
|
||
<div class="card-head"><h4>AI 整理配置</h4></div>
|
||
<div class="card-body">
|
||
${state.meta.ai.enabled
|
||
? `<div class="notice info">已启用 AI 整理(模型:${esc(state.meta.ai.model || "—")})</div>`
|
||
: `<div class="notice warn">未配置 API Key,当前使用本地规则解析。填写下方配置后自动切换到大模型整理。</div>`}
|
||
<div class="form-row"><label>Base URL</label><input class="inp" id="cfgBase" value="${esc(s.ai_api_base || "")}" style="width:100%" placeholder="https://api.deepseek.com/v1" /></div>
|
||
<div class="form-row"><label>API Key</label><input class="inp" id="cfgKey" type="password" value="${esc(s.ai_api_key || "")}" style="width:100%" placeholder="sk-..." /></div>
|
||
<div class="form-row"><label>模型</label><input class="inp" id="cfgModel" value="${esc(s.ai_model || "")}" style="width:100%" placeholder="deepseek-chat" /></div>
|
||
<div class="form-row"><label>温度</label><input class="inp" id="cfgTemp" value="${esc(s.ai_temperature || "0.2")}" style="width:100px" /></div>
|
||
<div style="margin-top:14px">${rw ? `<button class="btn primary" id="btnSaveCfg">保存配置</button>` : `<span class="tag-mini">只读权限</span>`}</div>
|
||
<div style="margin-top:14px;font-size:12px;color:var(--muted);line-height:1.8">
|
||
兼容:DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。<br>
|
||
例:Ollama → Base URL <code>http://localhost:11434/v1</code>,模型 <code>qwen2.5:14b</code>,Key 任意填。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>数据库</h4></div>
|
||
<div class="card-body">
|
||
<div class="form-row"><label>当前引擎</label><b>${esc(health.db)}</b></div>
|
||
<div style="font-size:12.5px;color:var(--text-2);line-height:1.9">
|
||
默认使用 SQLite(<code>data/board.db</code>),无需安装任何服务。<br><br>
|
||
<b>迁移到 MySQL:</b><br>
|
||
1. 安装驱动:<code>pip install pymysql cryptography</code><br>
|
||
2. 在 <code>.env</code> 中设置:<br>
|
||
<code>DATABASE_URL=mysql+pymysql://user:pwd@host:3306/unis_board?charset=utf8mb4</code><br>
|
||
3. 重启服务,表结构自动创建;再用
|
||
<code>python scripts/import_excel.py --reset</code> 重新灌数即可。<br><br>
|
||
数据访问层全部走 SQLAlchemy,无原生 SQL,切换方言不需要改业务代码。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:16px">
|
||
<div class="card-head">
|
||
<h4>周期管理</h4>
|
||
${rw ? `<button class="btn sm primary" id="btnAddPeriod">${Ico.svg("plus")}新增</button>` : ""}
|
||
</div>
|
||
<div class="card-body" style="max-height:360px;overflow:auto;padding:0">
|
||
<table class="tbl">
|
||
<thead><tr><th>周期</th><th class="num">常规记录</th><th class="num">定制记录</th><th></th></tr></thead>
|
||
<tbody>
|
||
${pagedRows(weeks, "periods").map((w) => `
|
||
<tr data-pid="${w.id}">
|
||
<td><b>${esc(w.full_label)}</b> <span style="color:var(--muted);font-size:12px">(原标签:${esc(w.label)})</span></td>
|
||
<td class="num">${w.update_count || 0}</td>
|
||
<td class="num">${w.custom_update_count || 0}</td>
|
||
<td class="num">${rw ? `<button class="btn sm danger" data-delperiod="${w.id}" ${(w.update_count || w.custom_update_count) ? "disabled title='该周期已有记录,不能删除'" : ""}>删除</button>` : ""}</td>
|
||
</tr>`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
${pagerBar("periods", weeks.length)}
|
||
</div>`;
|
||
|
||
if (!rw) host.querySelectorAll("#cfgBase,#cfgKey,#cfgModel,#cfgTemp").forEach((i) => { i.disabled = true; });
|
||
bindPager(host, renderSettings);
|
||
const bap = $("#btnAddPeriod");
|
||
if (bap) bap.addEventListener("click", async () => {
|
||
const id = await createNewPeriod();
|
||
if (id) renderSettings();
|
||
});
|
||
host.querySelectorAll("[data-delperiod]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const id = Number(b.dataset.delperiod);
|
||
const row = b.closest("tr");
|
||
const name = row.querySelector("b").textContent;
|
||
if (!confirm(`确认删除周期「${name}」?`)) return;
|
||
try {
|
||
await del("/periods/" + id);
|
||
toast("已删除周期 " + name);
|
||
await loadPeriods();
|
||
renderSettings();
|
||
} catch (e) {
|
||
toast("删除失败:" + e.message);
|
||
}
|
||
})
|
||
);
|
||
const bsc = $("#btnSaveCfg");
|
||
if (bsc) bsc.addEventListener("click", async () => {
|
||
await put("/settings", {
|
||
values: {
|
||
ai_api_base: $("#cfgBase").value.trim(),
|
||
ai_api_key: $("#cfgKey").value.trim(),
|
||
ai_model: $("#cfgModel").value.trim(),
|
||
ai_temperature: $("#cfgTemp").value.trim() || "0.2",
|
||
},
|
||
});
|
||
state.meta = await api("/meta");
|
||
toast("配置已保存");
|
||
renderSettings();
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- drawer */
|
||
/** pid 详情抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
|
||
async function openDrawer(pid, mode) {
|
||
mode = mode === "edit" ? "edit" : "view";
|
||
if (!canW("projects")) mode = "view";
|
||
$("#drawerMask").classList.add("open");
|
||
$("#drawer").classList.add("open");
|
||
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
|
||
drawerActions("");
|
||
let d = await api("/projects/" + pid);
|
||
state.drawerProject = d;
|
||
// 弹窗里改完记录,只重画下面的周期反馈,抽屉不关闭、滚动位置不丢
|
||
const reloadFeed = async () => {
|
||
d = await api("/projects/" + pid);
|
||
state.drawerProject = d;
|
||
renderFeed();
|
||
};
|
||
const p = d.project;
|
||
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
|
||
$("#drawerSub").innerHTML = `${catPill(p.category)} ${statusPill(p.status)} <span class="tag-mini">进度 ${Math.round(p.progress)}%</span>`;
|
||
|
||
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
|
||
|
||
// 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起
|
||
const baseSec = mode === "edit" ? `
|
||
<div class="sec">
|
||
<div class="sec-title">项目信息</div>
|
||
<div class="form-row"><label>项目名称</label><input class="inp" id="dwName" value="${esc(p.name)}" style="width:100%" /></div>
|
||
<div class="form-row"><label>分类</label><select class="sel" id="dwCat" style="width:150px">
|
||
<option value="">(未分类)</option>
|
||
${state.categories.map((c) => `<option value="${c.id}" ${c.id === p.category_id ? "selected" : ""}>${esc(c.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label>状态</label><select class="sel" id="dwStatus" style="width:130px">
|
||
${state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === p.status ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
|
||
</select></div>
|
||
${peopleEditorHtml("dwOwners", "负责人", p.owners, { add: "负责人" })}
|
||
${peopleEditorHtml("dwMembers", "团队成员", p.members, { add: "成员" })}
|
||
<div class="form-row"><label>进度</label><div style="display:flex;align-items:center;gap:6px"><input class="inp" id="dwProgress" type="number" min="0" max="100" value="${Math.round(p.progress)}" style="width:90px" /> %</div></div>
|
||
<div class="form-row"><label>标签</label><input class="inp" id="dwTags" placeholder="标签(、分隔)" value="${esc(p.tags.map((t) => t.name).join("、"))}" style="width:220px" /></div>
|
||
</div>` : `
|
||
<div class="sec">
|
||
<div class="sec-title">项目信息</div>
|
||
${ro("项目名称", esc(p.name) + (p.archived ? ` <span class="arch-badge">已归档</span>` : ""))}
|
||
${ro("分类", catPill(p.category))}
|
||
${ro("状态", statusPill(p.status))}
|
||
${ro("负责人", nameChips(p.owners))}
|
||
${ro("团队成员", nameChips(p.members))}
|
||
${ro("进度", Math.round(p.progress) + "%")}
|
||
${ro("标签", p.tags.length ? p.tags.map((t) => `<span class="tag-mini">${esc(t.name)}</span>`).join(" ") : "—")}
|
||
${ro("记录", `${p.task_count} 个子任务 / ${p.update_count} 条执行记录`)}
|
||
</div>`;
|
||
|
||
// 基础信息的动作在标题栏右侧按钮条;附表的更新动作仍在它的列表上方,两类不混
|
||
drawerActions(mode === "edit" ? `
|
||
<button class="btn sm" id="dwCancel">取消</button>
|
||
<button class="btn sm primary" id="dwSave">保存</button>` : `
|
||
<button class="btn sm danger" id="dwDelete">删除</button>
|
||
<button class="btn sm" id="dwArchive">${p.archived ? "恢复" : "归档"}</button>
|
||
<button class="btn sm" id="dwKey">${p.is_key ? "取消重点" : "标记重点"}</button>
|
||
<button class="btn sm primary" id="dwEdit">修改</button>`);
|
||
|
||
const updItem = (u) => `
|
||
<div class="upd-item">
|
||
<div class="u-head"><span class="u-task">${esc(u.task_name || "整体进展")}</span>${statusPill(u.status)}${u.progress !== null ? `<span class="tag-mini">${u.progress}%</span>` : ""}${updOps("project", u)}</div>
|
||
<div class="u-body">${nl2br(u.content || u.summary || "")}</div>
|
||
${u.risk ? `<div class="u-risk">${Ico.svg("alert-circle")}<div>${nl2br(u.risk)}</div></div>` : ""}
|
||
${u.next_step ? `<div class="u-next">${Ico.svg("arrow-right")}<div>${nl2br(u.next_step)}</div></div>` : ""}
|
||
</div>`;
|
||
|
||
// 把项目的 timeline 按「周」或「月」归成显示单元(timeline 已由后端按时间升序返回)
|
||
function feedUnits() {
|
||
if (state.dw.group === "week") {
|
||
return d.timeline.map((t) => ({
|
||
label: t.period.full_label, count: t.updates.length, progress: t.progress,
|
||
periods: [t], ids: [t.period.id],
|
||
}));
|
||
}
|
||
const map = new Map();
|
||
d.timeline.forEach((t) => {
|
||
const k = t.period.year + "-" + t.period.month;
|
||
if (!map.has(k)) map.set(k, { label: t.period.year + "年" + t.period.month + "月", count: 0, progress: null, periods: [], ids: [] });
|
||
const u = map.get(k);
|
||
u.periods.push(t);
|
||
u.count += t.updates.length;
|
||
u.ids.push(t.period.id);
|
||
if (t.progress !== null) u.progress = u.progress === null ? t.progress : Math.max(u.progress, t.progress);
|
||
});
|
||
return [...map.values()];
|
||
}
|
||
|
||
const FEED_RANGES = {
|
||
week: [[2, "近 2 周"], [0, "全部"]],
|
||
month: [[1, "近 1 月"], [0, "全部"]],
|
||
};
|
||
|
||
// 「添加执行记录」直接去「更新录入」页,不再弹录入窗
|
||
const gotoIntake = () => {
|
||
state.intakeType = "general";
|
||
state.intakeMode = "direct";
|
||
state.intakePeriodId = state.weekId;
|
||
intakeProjectId = pid;
|
||
closeDrawer();
|
||
setView("intake");
|
||
};
|
||
|
||
// 范围以顶栏选中的周期为锚点,和看板其余部分保持一致
|
||
function renderFeed() {
|
||
const host = $("#dwFeed");
|
||
const units = feedUnits();
|
||
const unitName = state.dw.group === "week" ? "周" : "个月"; /* 「7 月」会被读成七月,用「个月」 */
|
||
const scope = scopePeriodIds();
|
||
let anchor = -1;
|
||
units.forEach((u, i) => { if (u.ids.some((id) => scope.indexOf(id) >= 0)) anchor = i; });
|
||
if (anchor < 0) anchor = units.length - 1;
|
||
|
||
const n = state.dw.range;
|
||
const step = n === 0 ? Math.max(1, anchor + 1) : n;
|
||
const last = Math.max(0, anchor - step * state.dw.back);
|
||
const first = n === 0 ? 0 : Math.max(0, last - n + 1);
|
||
const win = units.slice(first, last + 1);
|
||
const nUpd = win.reduce((a, u) => a + u.count, 0);
|
||
|
||
const bar = `
|
||
<div class="feed-bar">
|
||
|
||
<div class="seg" id="dwGroup">
|
||
<button class="seg-btn ${state.dw.group === "week" ? "active" : ""}" data-g="week">按周</button>
|
||
<button class="seg-btn ${state.dw.group === "month" ? "active" : ""}" data-g="month">按月</button>
|
||
</div>
|
||
<div class="feed-chips">${FEED_RANGES[state.dw.group].map(([v, l]) =>
|
||
`<button class="chip ${state.dw.range === v ? "active" : ""}" data-r="${v}">${l}</button>`).join("")}</div>
|
||
<div class="feed-pager">
|
||
<button class="nav-arrow sm" id="dwOlder" title="更早" ${first <= 0 ? "disabled" : ""}>${Ico.svg("chevron-left")}</button>
|
||
<button class="nav-arrow sm" id="dwNewer" title="更新" ${state.dw.back <= 0 ? "disabled" : ""}>${Ico.svg("chevron-right")}</button>
|
||
</div>
|
||
<div class="feed-ops">
|
||
<button class="btn sm primary" id="dwAddUpd">${Ico.svg("plus")}添加执行记录</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
// win 是按升序取的窗口,展示时倒过来:新的在前
|
||
const body = win.length ? win.slice().reverse().map((u) => `
|
||
<div class="feed-hd"><b>${esc(u.label)}</b>${u.progress !== null ? `<span>进度 ${u.progress}%</span>` : ""}<span class="feed-cnt">${u.count} 条</span></div>
|
||
${u.periods.slice().reverse().map((t) => `
|
||
${state.dw.group === "month" ? `<div class="feed-sub">${esc(t.period.full_label.replace(/^\d+年/, ""))}${t.progress !== null ? " · 进度 " + t.progress + "%" : ""} ${statusPill(t.status)}</div>` : ""}
|
||
${t.updates.map(updItem).join("")}`).join("")}`).join("")
|
||
: `<div class="empty">还没有执行记录</div>`;
|
||
|
||
host.innerHTML = `<div class="sec"><div class="sec-title">执行记录</div>${bar}<div>${body}</div></div>`;
|
||
host.querySelector("#dwAddUpd").addEventListener("click", () => gotoIntake());
|
||
bindUpdateOps(host, d.timeline.flatMap((t) => t.updates), reloadFeed);
|
||
|
||
host.querySelectorAll("#dwGroup [data-g]").forEach((b) => b.addEventListener("click", () => {
|
||
const g = b.dataset.g;
|
||
if (state.dw.group === g) return;
|
||
state.dw = { group: g, range: g === "week" ? 2 : 1, back: 0 };
|
||
renderFeed();
|
||
}));
|
||
host.querySelectorAll(".feed-chips [data-r]").forEach((b) => b.addEventListener("click", () => {
|
||
state.dw.range = Number(b.dataset.r);
|
||
state.dw.back = 0;
|
||
renderFeed();
|
||
}));
|
||
host.querySelector("#dwOlder").addEventListener("click", () => { state.dw.back += 1; renderFeed(); });
|
||
host.querySelector("#dwNewer").addEventListener("click", () => { state.dw.back = Math.max(0, state.dw.back - 1); renderFeed(); });
|
||
}
|
||
|
||
const timeline = d.timeline.slice().reverse().map((t) => `
|
||
<div class="timeline-row">
|
||
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
|
||
<div class="t-bar"><div class="progress" style="margin:0"><i style="width:${t.progress || 0}%;background:${statusMeta(t.status).color}"></i></div></div>
|
||
<div class="t-val">${t.progress === null ? "—" : t.progress + "%"}</div>
|
||
</div>`).join("");
|
||
|
||
// 周期反馈单独渲染,切换范围/周期不必重新请求
|
||
state.dw = { group: "week", range: 2, back: 0 };
|
||
$("#drawerBody").innerHTML =
|
||
(mode === "edit" ? `<div class="notice err" id="dwErr" style="display:none"></div>` : "") +
|
||
baseSec + `<div id="dwFeed"></div>` +
|
||
`<div class="sec"><div class="sec-title">历史进度走势</div>${timeline || '<div class="empty">暂无</div>'}</div>`;
|
||
renderFeed();
|
||
|
||
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
|
||
|
||
on("#dwEdit", () => openDrawer(pid, "edit"));
|
||
on("#dwCancel", () => openDrawer(pid, "view"));
|
||
on("#dwSave", async () => {
|
||
const btn = $("#dwSave");
|
||
const err = $("#dwErr");
|
||
btn.disabled = true;
|
||
err.style.display = "none";
|
||
try {
|
||
await patch("/projects/" + pid, {
|
||
name: $("#dwName").value.trim(),
|
||
category_id: $("#dwCat").value === "" ? null : Number($("#dwCat").value),
|
||
status: $("#dwStatus").value,
|
||
owners: peopleValues("dwOwners"),
|
||
members: peopleValues("dwMembers"),
|
||
progress: Number($("#dwProgress").value),
|
||
tags: $("#dwTags").value.split(/[、,,;;\s]+/).filter(Boolean),
|
||
});
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
err.textContent = "保存失败:" + e.message;
|
||
err.style.display = "";
|
||
return;
|
||
}
|
||
toast("已保存");
|
||
await afterWrite();
|
||
openDrawer(pid, "view");
|
||
});
|
||
on("#dwKey", async () => {
|
||
await patch("/projects/" + pid, { is_key: !p.is_key });
|
||
await afterWrite();
|
||
openDrawer(pid, mode);
|
||
});
|
||
on("#dwArchive", async () => {
|
||
await patch("/projects/" + pid, { archived: !p.archived });
|
||
toast(p.archived ? "已恢复" : "已归档");
|
||
closeDrawer();
|
||
await afterWrite();
|
||
});
|
||
on("#dwDelete", () => deleteProject(p));
|
||
}
|
||
|
||
// 标题栏右侧的标准按钮条:按钮由当前打开的抽屉自己填,主按钮在最右
|
||
function drawerActions(html) {
|
||
$("#drawerActions").innerHTML = html || "";
|
||
}
|
||
|
||
function closeDrawer() {
|
||
drawerActions("");
|
||
$("#drawer").classList.remove("open");
|
||
$("#drawerMask").classList.remove("open");
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 人力资源库 */
|
||
function openStaffDialog(person, onDone) {
|
||
const isNew = !person;
|
||
const p = person || {};
|
||
const groups = (state.staffDict.groups || []).map(
|
||
(g) => `<option value="${esc(g)}" ${g === p.group ? "selected" : ""}>${esc(g)}</option>`
|
||
).join("");
|
||
const affs = (state.staffDict.affiliations || []).map(
|
||
(a) => `<option value="${esc(a)}" ${a === p.affiliation ? "selected" : ""}>${esc(a)}</option>`
|
||
).join("");
|
||
openModal(
|
||
`<div class="modal-head"><h3>${isNew ? "新增人员" : "编辑人员"}</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="form-grid">
|
||
<div class="form-row"><label>姓名 *</label><input class="inp" id="sfName" data-autofocus value="${esc(p.name || "")}" style="width:100%" /></div>
|
||
<div class="form-row"><label>归属小组</label><select class="sel" id="sfGroup" style="width:100%">${groups}</select></div>
|
||
<div class="form-row"><label>人员归属</label><select class="sel" id="sfAff" style="width:100%">${affs}</select></div>
|
||
<div class="form-row full"><label>说明</label><input class="inp" id="sfNote" value="${esc(p.note || "")}" placeholder="如:外包 / 实习 / 兼岗" style="width:100%" /></div>
|
||
</div>
|
||
${isNew ? "" : `<div class="notice info">改姓名会把名下项目的负责人一并改过来,历史记录不会断。</div>`}
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">${isNew ? "新增" : "保存"}</button></div>`
|
||
);
|
||
$("#mOk").addEventListener("click", async () => {
|
||
const name = $("#sfName").value.trim();
|
||
if (!name) return modalError("请填写姓名");
|
||
const btn = $("#mOk");
|
||
btn.disabled = true;
|
||
const body = {
|
||
name: name,
|
||
group: $("#sfGroup").value || null,
|
||
affiliation: $("#sfAff").value || null,
|
||
note: $("#sfNote").value.trim() || null,
|
||
};
|
||
try {
|
||
let msg = "已新增「" + name + "」";
|
||
if (isNew) { await post("/staff", body); }
|
||
else {
|
||
const r = await patch("/staff/" + p.id, body);
|
||
const n = r.synced.projects + r.synced.custom_projects;
|
||
msg = n ? `已更新,并同步 ${n} 个项目的负责人` : "已更新";
|
||
}
|
||
closeModal();
|
||
toast(msg);
|
||
if (onDone) onDone(); else await afterWrite();
|
||
} catch (e) { btn.disabled = false; modalError(e.message); }
|
||
});
|
||
$("#sfName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); });
|
||
}
|
||
|
||
async function deleteStaff(m) {
|
||
const ok = await confirmDialog({
|
||
title: "删除人员",
|
||
danger: true,
|
||
okText: "确认删除",
|
||
body: `<p style="margin:0 0 10px">确认从花名册删除 <b>${esc(m.name)}</b>?</p>
|
||
<div class="notice warn">删除后这个人不再出现在「负责人」下拉里。
|
||
若他名下还有在管项目,接口会拒绝删除 —— 离职人员请用<b>归档</b>,历史数据完整保留。</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await del("/staff/" + m.id);
|
||
toast("已删除「" + m.name + "」");
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast(e.message);
|
||
}
|
||
}
|
||
|
||
async function renderStaff() {
|
||
saveUI();
|
||
const host = $("#view-staff");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
const extra = ["archived=" + state.staffArchived];
|
||
if (state.staffGroup) extra.push("group=" + encodeURIComponent(state.staffGroup));
|
||
if (state.keyword) extra.push("keyword=" + encodeURIComponent(state.keyword));
|
||
const d = await api("/staff?" + extra.join("&"));
|
||
await loadStaff(); // 负责人下拉始终用完整花名册
|
||
const sum = d.summary;
|
||
|
||
const archOpts = [["false", "在编"], ["true", "已归档"], ["all", "全部"]]
|
||
.map((x) => `<option value="${x[0]}" ${x[0] === state.staffArchived ? "selected" : ""}>${x[1]}</option>`).join("");
|
||
const groupOpts = `<option value="">全部小组</option>` +
|
||
(d.dict.groups || []).map((g) => `<option value="${esc(g)}" ${g === state.staffGroup ? "selected" : ""}>${esc(g)}</option>`).join("");
|
||
|
||
const kpi = `
|
||
<div class="grid kpi" style="margin-bottom:14px">
|
||
<div class="kpi accent-blue">${Ico.badge("users")}<div class="k-label">在册人员</div><div class="k-value">${sum.total}</div><div class="k-foot">其中 ${sum.with_project} 人带项目</div></div>
|
||
${sum.groups.slice(0, 4).map((g, i) => `<div class="kpi accent-${["violet", "green", "amber", "red"][i % 4]}">${Ico.badge("users")}<div class="k-label">${esc(g.name)}</div><div class="k-value">${g.count}</div></div>`).join("")}
|
||
</div>
|
||
<div class="grid g2e" style="margin-bottom:16px">
|
||
<div class="card">
|
||
<div class="card-head"><h4>人员归属</h4></div>
|
||
<div class="card-body">
|
||
${sum.affiliations.map((a) => `
|
||
<div class="bar-row">
|
||
<div class="b-name">${esc(a.name)}</div>
|
||
<div class="b-track"><i style="width:${Math.round((a.count / (sum.total || 1)) * 100)}%;background:#2563eb"></i></div>
|
||
<div class="b-val">${a.count}</div>
|
||
</div>`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>花名册外的负责人</h4></div>
|
||
<div class="card-body">
|
||
${sum.outside.length ? sum.outside.map((o) => `
|
||
<div class="upd-item" style="display:flex;align-items:center;gap:10px">
|
||
<b>${esc(o.name)}</b>
|
||
<span class="tag-mini">项目 ${o.project_count}</span>
|
||
<span class="tag-mini">定制 ${o.custom_count}</span>
|
||
<span style="flex:1"></span>
|
||
<button class="btn sm" data-outside="${esc(o.name)}">加入花名册</button>
|
||
</div>`).join("") : `<div class="empty">所有项目负责人都能对上花名册</div>`}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
const pageRows = pagedRows(d.items, "staff", [state.keyword, state.staffArchived, state.staffGroup]);
|
||
const table = `<table class="tbl">
|
||
<thead><tr>
|
||
<th style="min-width:120px">姓名</th><th>归属小组</th><th>人员归属</th><th>说明</th>
|
||
<th class="num">在管项目</th><th class="num">定制项目</th><th class="num">定制合同额</th>
|
||
<th class="num">名下待计收</th><th>最近更新</th><th class="col-ops">操作</th>
|
||
</tr></thead><tbody>
|
||
${pageRows.map((m) => `
|
||
<tr data-sid="${m.id}" ${m.archived ? 'style="opacity:.62"' : ""}>
|
||
<td><div style="display:flex;align-items:center;gap:6px">
|
||
${canW("staff")
|
||
? `<a href="javascript:;" data-sedit="${m.id}" style="color:var(--text);text-decoration:none;font-weight:600">${esc(m.name)}</a>`
|
||
: `<b>${esc(m.name)}</b>`}
|
||
${m.archived ? `<span class="arch-badge">已归档</span>` : ""}
|
||
</div></td>
|
||
<td><select class="sel" data-sf="group" style="width:112px">
|
||
${(d.dict.groups || []).map((g) => `<option value="${esc(g)}" ${g === m.group ? "selected" : ""}>${esc(g)}</option>`).join("")}
|
||
${m.group && (d.dict.groups || []).indexOf(m.group) < 0 ? `<option value="${esc(m.group)}" selected>${esc(m.group)}</option>` : ""}
|
||
</select></td>
|
||
<td><select class="sel" data-sf="affiliation" style="width:92px">
|
||
${(d.dict.affiliations || []).map((a) => `<option value="${esc(a)}" ${a === m.affiliation ? "selected" : ""}>${esc(a)}</option>`).join("")}
|
||
${m.affiliation && (d.dict.affiliations || []).indexOf(m.affiliation) < 0 ? `<option value="${esc(m.affiliation)}" selected>${esc(m.affiliation)}</option>` : ""}
|
||
</select></td>
|
||
<td><input class="inp" data-sf="note" value="${esc(m.note || "")}" style="width:150px" placeholder="—" /></td>
|
||
<td class="num">${m.project_count ? `${m.project_count}${m.key_project_count ? ` ${starMark(true, false, "重点项目")}<span class="tag-mini">${m.key_project_count}</span>` : ""}` : "—"}</td>
|
||
<td class="num">${m.custom_count}</td>
|
||
<td class="num">${m.custom_amount ? fen(m.custom_amount) + " 万" : "—"}</td>
|
||
<td class="num">${m.custom_pending ? `<span style="color:#b45309">${fen(m.custom_pending)} 万</span>` : "—"}</td>
|
||
<td>${esc(m.last_update || "—")}</td>
|
||
<td class="nowrap col-ops">${rowOps({
|
||
edit: `data-sedit="${m.id}"`,
|
||
archive: `data-sarch="${m.id}"`,
|
||
archived: m.archived,
|
||
remove: `data-sdel="${m.id}"`,
|
||
})}</td>
|
||
</tr>`).join("")}
|
||
</tbody></table>`;
|
||
|
||
const body = listCard({
|
||
title: "人员花名册",
|
||
count: d.items.length,
|
||
fields: `<select id="stArchived" class="sel" style="width:104px">${archOpts}</select>
|
||
<select id="stGroup" class="sel" style="width:120px">${groupOpts}</select>
|
||
${keywordInputHtml("stKeyword", "搜索姓名…", 160)}`,
|
||
tools: `${canR("staff") ? `<button class="btn sm" id="btnExportStaff">导出 Excel</button>` : ""}
|
||
${canW("staff") ? `<button class="btn sm primary" id="btnNewStaff">${Ico.svg("plus")}新增</button>` : ""}`,
|
||
rows: d.items,
|
||
table: table,
|
||
pager: pagerBar("staff", d.items.length),
|
||
empty: state.keyword
|
||
? "没有匹配「" + esc(state.keyword) + "」的人员"
|
||
: (state.staffGroup ? "「" + esc(state.staffGroup) + "」还没有人员" : "还没有人员"),
|
||
});
|
||
|
||
host.innerHTML = kpi + body;
|
||
|
||
bindKeyword("stKeyword");
|
||
bindPager(host, renderStaff);
|
||
$("#stArchived").addEventListener("change", (e) => { state.staffArchived = e.target.value; renderStaff(); });
|
||
$("#stGroup").addEventListener("change", (e) => { state.staffGroup = e.target.value; renderStaff(); });
|
||
const bn = $("#btnNewStaff");
|
||
if (bn) bn.addEventListener("click", () => openStaffDialog(null));
|
||
const bex = $("#btnExportStaff");
|
||
if (bex) bex.addEventListener("click", () => download("/api/export/staff?archived=" + state.staffArchived));
|
||
host.querySelectorAll("[data-sedit]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const m = d.items.find((x) => x.id === Number(b.dataset.sedit));
|
||
openStaffDialog(m);
|
||
})
|
||
);
|
||
host.querySelectorAll("[data-sarch]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const m = d.items.find((x) => x.id === Number(b.dataset.sarch));
|
||
await patch("/staff/" + m.id, { archived: !m.archived });
|
||
toast(m.archived ? "已恢复" : `已归档「${m.name}」,不再出现在负责人下拉里`);
|
||
await afterWrite();
|
||
})
|
||
);
|
||
host.querySelectorAll("[data-sdel]").forEach((b) =>
|
||
b.addEventListener("click", () =>
|
||
deleteStaff(d.items.find((x) => x.id === Number(b.dataset.sdel)))
|
||
)
|
||
);
|
||
host.querySelectorAll("[data-outside]").forEach((b) =>
|
||
b.addEventListener("click", () => openStaffDialog({ name: b.dataset.outside }))
|
||
);
|
||
const roStaff = !canW("staff");
|
||
host.querySelectorAll("tr[data-sid]").forEach((tr) => {
|
||
const id = Number(tr.dataset.sid);
|
||
tr.querySelectorAll("[data-sf]").forEach((inp) => {
|
||
if (roStaff) { inp.disabled = true; return; }
|
||
inp.addEventListener("change", async () => {
|
||
const f = inp.dataset.sf;
|
||
try {
|
||
await patch("/staff/" + id, { [f]: inp.value || null });
|
||
toast("已保存");
|
||
await afterWrite();
|
||
} catch (e) { toast("保存失败:" + e.message); }
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------ 权限管理(RBAC) */
|
||
/** 角色 = 一张「菜单 × 能改/只读」矩阵;用户挂一个角色。下拉改完即存,后端立刻生效。 */
|
||
async function renderRbac() {
|
||
const host = $("#view-rbac");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
const [meta, roles, users] = await Promise.all([
|
||
api("/rbac/meta"), api("/rbac/roles"), api("/rbac/users"),
|
||
]);
|
||
const rw = canW("rbac");
|
||
const menus = meta.menus;
|
||
const save = async (fn) => {
|
||
try { await fn(); } catch (e) { toast("保存失败:" + e.message); }
|
||
await renderRbac();
|
||
};
|
||
|
||
const levelCell = (role, m) => {
|
||
const cur = (role.menus || {})[m.key] || "";
|
||
return `<td><select class="sel xs" data-rmenu="${role.id}" data-menu="${m.key}"${rw ? "" : " disabled"}>` +
|
||
meta.levels.map((l) => `<option value="${l.value}" ${l.value === cur ? "selected" : ""}>${l.label}</option>`).join("") +
|
||
`</select></td>`;
|
||
};
|
||
const rolePage = pagedRows(roles, "rbacRole");
|
||
const roleTable = `<table class="tbl">
|
||
<thead><tr>
|
||
<th class="rbac-role">角色 / 说明</th>
|
||
${menus.map((m) => `<th class="rbac-menu-th">${esc(m.label)}</th>`).join("")}
|
||
<th class="num">用户</th>
|
||
${rw ? `<th class="col-ops">操作</th>` : ""}
|
||
</tr></thead><tbody>
|
||
${rolePage.map((r) => `<tr>
|
||
<td class="rbac-role"><b>${esc(r.name)}</b>${r.is_builtin ? ` <span class="tag-mini builtin">内置</span>` : ""}${r.description ? `<span>${esc(r.description)}</span>` : ""}</td>
|
||
${menus.map((m) => levelCell(r, m)).join("")}
|
||
<td class="num">${r.user_count}</td>
|
||
${rw ? `<td class="col-ops nowrap"><span class="row-ops">
|
||
<button class="icon-btn sm" data-redit="${r.id}" title="修改名称/说明">${Ico.svg("pencil")}</button>
|
||
${r.is_builtin ? "" : `<button class="icon-btn sm danger" data-rdel="${r.id}" title="删除">${Ico.svg("trash")}</button>`}
|
||
</span></td>` : ""}
|
||
</tr>`).join("")}
|
||
</tbody></table>`;
|
||
|
||
const roleOpts = (sel) => roles.map((r) =>
|
||
`<option value="${r.id}" ${r.id === sel ? "selected" : ""}>${esc(r.name)}</option>`).join("");
|
||
const userPage = pagedRows(users, "rbacUser");
|
||
const userTable = `<table class="tbl">
|
||
<thead><tr>
|
||
<th>姓名</th><th>登录账号</th><th>角色</th><th class="num">可用菜单</th><th>最近登录</th>
|
||
${rw ? `<th class="col-ops">操作</th>` : ""}
|
||
</tr></thead><tbody>
|
||
${userPage.map((u) => {
|
||
const self = u.id === state.me.id;
|
||
return `<tr${u.active ? "" : ' style="opacity:.6"'}>
|
||
<td><b>${esc(u.name)}</b>${self ? ` <span class="tag-mini">我自己</span>` : ""}${u.active ? "" : ` <span class="tag-mini">已停用</span>`}</td>
|
||
<td>${esc(u.username)}</td>
|
||
<td><select class="sel xs" data-urole="${u.id}"${rw && !self ? "" : " disabled"} title="${self ? "不能改自己的角色" : ""}">${roleOpts(u.role_id)}</select></td>
|
||
<td class="num">${u.menus ? Object.keys(u.menus).length : 0}</td>
|
||
<td>${esc(u.last_login_at ? u.last_login_at.replace("T", " ").slice(0, 16) : "从未登录")}</td>
|
||
${rw ? `<td class="col-ops nowrap"><span class="row-ops">
|
||
<button class="icon-btn sm" data-upw="${u.id}" title="重置密码">${Ico.svg("sliders")}</button>
|
||
${self ? "" : `
|
||
<button class="icon-btn sm" data-uactive="${u.id}" data-on="${u.active ? "1" : "0"}" title="${u.active ? "停用" : "启用"}">${Ico.svg(u.active ? "ban" : "check-circle")}</button>
|
||
<button class="icon-btn sm danger" data-udel="${u.id}" title="删除">${Ico.svg("trash")}</button>`}
|
||
</span></td>` : ""}
|
||
</tr>`;
|
||
}).join("")}
|
||
</tbody></table>`;
|
||
|
||
host.innerHTML =
|
||
listCard({
|
||
title: "角色权限", count: roles.length, rows: roles, table: roleTable, empty: "还没有角色",
|
||
pager: pagerBar("rbacRole", roles.length),
|
||
tools: rw ? `<button class="btn sm primary" id="btnNewRole">${Ico.svg("plus")}新增</button>` : "",
|
||
}) +
|
||
`<div style="height:16px"></div>` +
|
||
listCard({
|
||
title: "用户", count: users.length, rows: users, table: userTable, empty: "还没有用户",
|
||
pager: pagerBar("rbacUser", users.length),
|
||
tools: rw ? `<button class="btn sm primary" id="btnNewUser">${Ico.svg("plus")}新增</button>` : "",
|
||
});
|
||
|
||
bindPager(host, renderRbac);
|
||
// ---- 权限矩阵:一个角色的整行一起提交 ----
|
||
host.querySelectorAll("[data-rmenu]").forEach((sel) =>
|
||
sel.addEventListener("change", () => {
|
||
const rid = Number(sel.dataset.rmenu);
|
||
const mine = state.me.role_id === rid;
|
||
const next = {};
|
||
sel.closest("tr").querySelectorAll("[data-rmenu]").forEach((x) => { if (x.value) next[x.dataset.menu] = x.value; });
|
||
save(() => patch("/rbac/roles/" + rid, { menus: next })).then(() => {
|
||
if (mine) toast("已更新你所在角色的权限,刷新页面后生效");
|
||
});
|
||
})
|
||
);
|
||
const bnr = $("#btnNewRole");
|
||
if (bnr) bnr.addEventListener("click", () => openRoleDialog(null));
|
||
host.querySelectorAll("[data-redit]").forEach((b) =>
|
||
b.addEventListener("click", () => openRoleDialog(roles.find((x) => x.id === Number(b.dataset.redit))))
|
||
);
|
||
host.querySelectorAll("[data-rdel]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const r = roles.find((x) => x.id === Number(b.dataset.rdel));
|
||
const ok = await confirmDialog({ title: "删除角色", danger: true, okText: "确认删除",
|
||
body: `<p style="margin:0">确认删除角色 <b>${esc(r.name)}</b>?该角色下不能还有用户。</p>` });
|
||
if (ok) save(() => del("/rbac/roles/" + r.id));
|
||
})
|
||
);
|
||
|
||
// ---- 用户 ----
|
||
const bnu = $("#btnNewUser");
|
||
if (bnu) bnu.addEventListener("click", () => openUserDialog(null, roles));
|
||
host.querySelectorAll("[data-urole]").forEach((sel) =>
|
||
sel.addEventListener("change", () =>
|
||
save(() => patch("/rbac/users/" + Number(sel.dataset.urole), { role_id: Number(sel.value) }))
|
||
)
|
||
);
|
||
host.querySelectorAll("[data-upw]").forEach((b) =>
|
||
b.addEventListener("click", () =>
|
||
openPasswordReset(users.find((x) => x.id === Number(b.dataset.upw)))
|
||
)
|
||
);
|
||
host.querySelectorAll("[data-uactive]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const on = b.dataset.on === "1";
|
||
save(() => patch("/rbac/users/" + Number(b.dataset.uactive), { active: !on }));
|
||
})
|
||
);
|
||
host.querySelectorAll("[data-udel]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const u = users.find((x) => x.id === Number(b.dataset.udel));
|
||
const ok = await confirmDialog({ title: "删除用户", danger: true, okText: "确认删除",
|
||
body: `<p style="margin:0">确认删除用户 <b>${esc(u.name)}</b>(${esc(u.username)})?该账号会立即下线。</p>` });
|
||
if (ok) save(() => del("/rbac/users/" + u.id));
|
||
})
|
||
);
|
||
}
|
||
|
||
/** 角色只影响「能看哪些菜单」,名称说明在这里改,菜单授权在矩阵里勾 */
|
||
function openRoleDialog(role) {
|
||
const isNew = !role;
|
||
openModal(
|
||
`<div class="modal-head"><h3>${isNew ? "新建角色" : "修改角色"}</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="form-row"><label>角色名 *</label><input class="inp" id="rlName" data-autofocus value="${esc(role ? role.name : "")}" style="width:100%" placeholder="如:项目管理员" /></div>
|
||
<div class="form-row"><label>说明</label><input class="inp" id="rlDesc" value="${esc((role && role.description) || "")}" style="width:100%" placeholder="这个角色给谁用" /></div>
|
||
${isNew ? `<div class="notice info">新建后默认不带任何菜单权限,保存后在矩阵里逐项勾选。</div>` : ""}
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">${isNew ? "新建" : "保存"}</button></div>`
|
||
);
|
||
$("#mOk").addEventListener("click", async () => {
|
||
const name = $("#rlName").value.trim();
|
||
if (!name) return modalError("请填写角色名");
|
||
const body = { name: name, description: $("#rlDesc").value.trim() };
|
||
try {
|
||
if (isNew) await post("/rbac/roles", { name: body.name, description: body.description, menus: {} });
|
||
else await patch("/rbac/roles/" + role.id, body);
|
||
closeModal();
|
||
toast(isNew ? "已新建角色,在矩阵里勾选菜单即可授权" : "已保存");
|
||
await renderRbac();
|
||
} catch (e) { modalError(e.message); }
|
||
});
|
||
}
|
||
|
||
function openUserDialog(user, roles) {
|
||
const isNew = !user;
|
||
const u = user || {};
|
||
openModal(
|
||
`<div class="modal-head"><h3>${isNew ? "新建用户" : "编辑用户"}</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="form-grid">
|
||
<div class="form-row"><label>姓名 *</label><input class="inp" id="usName" data-autofocus value="${esc(u.name || "")}" style="width:100%" /></div>
|
||
<div class="form-row"><label>登录账号 *</label><input class="inp" id="usUser" value="${esc(u.username || "")}" ${isNew ? "" : "disabled"} style="width:100%" autocomplete="off" /></div>
|
||
<div class="form-row"><label>角色</label><select class="sel" id="usRole" style="width:100%">${
|
||
roles.map((r) => `<option value="${r.id}" ${r.id === u.role_id ? "selected" : ""}>${esc(r.name)}</option>`).join("")
|
||
}</select></div>
|
||
${isNew ? `<div class="form-row"><label>初始密码 *</label><input class="inp" id="usPass" type="text" value="" style="width:100%" placeholder="至少 6 位" autocomplete="new-password" /></div>` : ""}
|
||
</div>
|
||
<div class="notice info">换角色会强制该账号重新登录,权限立即按新角色生效。</div>
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">${isNew ? "新建" : "保存"}</button></div>`
|
||
);
|
||
$("#mOk").addEventListener("click", async () => {
|
||
const name = $("#usName").value.trim();
|
||
if (!name) return modalError("请填写姓名");
|
||
const btn = $("#mOk");
|
||
btn.disabled = true;
|
||
try {
|
||
if (isNew) {
|
||
const username = $("#usUser").value.trim();
|
||
const password = $("#usPass").value.trim();
|
||
if (!username) return void (btn.disabled = false, modalError("请填写登录账号"));
|
||
await post("/rbac/users", { name: name, username: username, password: password, role_id: Number($("#usRole").value), active: true });
|
||
} else {
|
||
await patch("/rbac/users/" + u.id, { name: name });
|
||
}
|
||
closeModal();
|
||
toast(isNew ? `已新建用户「${name}」` : "已保存");
|
||
await renderRbac();
|
||
} catch (e) { btn.disabled = false; modalError(e.message); }
|
||
});
|
||
}
|
||
|
||
function openPasswordReset(user) {
|
||
openModal(
|
||
`<div class="modal-head"><h3>重置密码</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<p style="margin:0 0 12px">给 <b>${esc(user.name)}</b>(${esc(user.username)})设置新密码。</p>
|
||
<div class="form-row"><label>新密码 *</label><input class="inp" id="rsPass" type="text" data-autofocus style="width:100%" placeholder="至少 6 位" autocomplete="new-password" /></div>
|
||
<div class="notice warn">重置后该用户的其他登录全部失效,需要用新密码重新登录。</div>
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">重置</button></div>`
|
||
);
|
||
$("#mOk").addEventListener("click", async () => {
|
||
try {
|
||
await patch("/rbac/users/" + user.id, { password: $("#rsPass").value.trim() });
|
||
closeModal();
|
||
toast(`已重置「${user.name}」的密码`);
|
||
} catch (e) { modalError(e.message); }
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- shell */
|
||
async function loadCategories() {
|
||
// 侧栏的分类筛选器已下线,这里只给「自研产品」子 header 上的分类下拉供数据
|
||
if (!canR("projects")) { state.categories = []; return; }
|
||
state.categories = await api("/categories");
|
||
}
|
||
|
||
function setView(view) {
|
||
if (!can(view)) view = firstAllowedView();
|
||
if (!view) { hostNoPermission(); return; }
|
||
state.view = view;
|
||
saveUI();
|
||
$$("#nav .nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === view));
|
||
$$(".view").forEach((v) => v.classList.add("hidden"));
|
||
$("#view-" + view).classList.remove("hidden");
|
||
refresh();
|
||
}
|
||
|
||
async function refresh() {
|
||
const v = state.view;
|
||
try {
|
||
if (v === "board") await renderBoard();
|
||
else if (v === "projects") await renderProjects();
|
||
else if (v === "custom") await renderCustom();
|
||
else if (v === "focus") await renderFocus();
|
||
else if (v === "staff") await renderStaff();
|
||
else if (v === "intake") await renderIntake();
|
||
else if (v === "settings") await renderSettings();
|
||
else if (v === "rbac") await renderRbac();
|
||
} catch (e) {
|
||
$("#view-" + v).innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
/** 写操作统一收尾:刷新侧栏计数 + 当前视图。
|
||
录入页不重绘,否则会清空用户正在编辑的表单。 */
|
||
async function afterWrite() {
|
||
await Promise.all([loadCategories(), loadStaff()]);
|
||
if (state.view === "intake") return;
|
||
await refresh();
|
||
}
|
||
|
||
/** 侧栏收拢/展开:收拢后菜单只剩图标(气泡提示取菜单名),hover logo 出现展开按钮。 */
|
||
function applyNavFold() {
|
||
document.querySelector(".app").classList.toggle("nav-folded", !!state.navFolded);
|
||
const b = $("#navFold");
|
||
if (b) b.setAttribute("aria-label", state.navFolded ? "展开菜单" : "收起菜单");
|
||
// 收拢后菜单只剩图标,气泡提示取菜单名
|
||
$$("#nav .nav-item").forEach((x) => { x.dataset.tip = x.textContent.trim(); });
|
||
}
|
||
|
||
function bindShell() {
|
||
$$("#nav .nav-item").forEach((b) => b.addEventListener("click", () => setView(b.dataset.view)));
|
||
$("#navFold").addEventListener("click", () => {
|
||
state.navFolded = !state.navFolded;
|
||
applyNavFold();
|
||
saveUI();
|
||
});
|
||
applyNavFold();
|
||
$$("#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", "navFolded", "gran", "weekId", "monthIdx", "categoryId", "keyword",
|
||
"projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived",
|
||
"customTab", "customArchived", "customRegion", "customOrigin", "customSort", "staffArchived", "staffGroup"];
|
||
|
||
function saveUI() {
|
||
const o = {};
|
||
UI_FIELDS.forEach((k) => { o[k] = state[k]; });
|
||
try { sessionStorage.setItem(UI_KEY, JSON.stringify(o)); } catch (e) { /* 存不了就算了 */ }
|
||
}
|
||
|
||
function restoreUI() {
|
||
let o = null;
|
||
try { o = JSON.parse(sessionStorage.getItem(UI_KEY) || "null"); } catch (e) { o = null; }
|
||
if (!o) return;
|
||
UI_FIELDS.forEach((k) => { if (k in o) state[k] = o[k]; });
|
||
if (["board", "projects", "custom", "focus", "staff", "intake", "settings", "rbac"].indexOf(state.view) < 0) state.view = "board";
|
||
if (state.gran !== "month") state.gran = "week";
|
||
if (state.focusFilter === "later") state.focusFilter = "week";
|
||
}
|
||
|
||
function hostNoPermission() {
|
||
document.querySelector(".views").innerHTML =
|
||
`<div class="notice warn">当前角色没有分配任何功能菜单,请联系管理员在「权限管理」里授权。</div>`;
|
||
}
|
||
|
||
async function main() {
|
||
restoreUI();
|
||
bindAuthForm();
|
||
try {
|
||
state.me = await api("/auth/me");
|
||
} catch (e) {
|
||
if (e.status === 401) return showAuthGate(); // 首次访问:切到登录页但不显示报错
|
||
throw e;
|
||
}
|
||
if (!state.me.menus || !Object.keys(state.me.menus).length) {
|
||
bindShell();
|
||
renderUserChip();
|
||
return hostNoPermission();
|
||
}
|
||
state.meta = await api("/meta");
|
||
await loadPeriods();
|
||
await loadStaff();
|
||
await loadCategories();
|
||
bindShell();
|
||
renderUserChip();
|
||
applyNavPermissions();
|
||
// 恢复出来的视图状态要回写到顶栏/侧栏控件上
|
||
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === state.gran));
|
||
$("#globalSearch").value = state.keyword || "";
|
||
setView(state.view);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
document.body.insertAdjacentHTML("beforeend", `<div class="notice err" style="margin:20px">初始化失败:${esc(e.message)}</div>`);
|
||
});
|
||
})();
|